diff --git a/rust/Cargo.lock b/rust/Cargo.lock index a69e7b78..68289797 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -950,6 +950,16 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "lithe-ai" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "toml", + "url", +] + [[package]] name = "lithe-core" version = "0.1.0" @@ -960,6 +970,7 @@ dependencies = [ "comrak", "encoding_rs", "ignore", + "lithe-ai", "lithe-core-rules", "lithe-git-host", "lithe-ide-host", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 092d56cb..a5b0825b 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["lithe-core", "lithe-core-rules", "lithe-git-host", "lithe-ide-host", "lithe-remote"] +members = ["lithe-ai", "lithe-core", "lithe-core-rules", "lithe-git-host", "lithe-ide-host", "lithe-remote"] resolver = "2" [profile.release] diff --git a/rust/contracts/ai-commit.md b/rust/contracts/ai-commit.md index addf0c4d..8dfd1f51 100644 --- a/rust/contracts/ai-commit.md +++ b/rust/contracts/ai-commit.md @@ -2,6 +2,9 @@ Version: 1. Behavioral fixture: `rust/fixtures/ai/commit-generation-v1.json`. +Every `ai.*` command is implemented in the `lithe-ai` crate (`rust/lithe-ai`), which +`lithe-core` routes to and re-exports as `lithe_core::ai`. + Windows consumes the typed Rust API in `lithe_core::ai`; macOS uses the same planner and decoder through the Core JSON commands `ai.planCommit` and `ai.decodeCommitMessage`. The API has no filesystem, HTTP, operating-system diff --git a/rust/contracts/ai-generation-requests.md b/rust/contracts/ai-generation-requests.md index de982558..de1bf7df 100644 --- a/rust/contracts/ai-generation-requests.md +++ b/rust/contracts/ai-generation-requests.md @@ -3,6 +3,8 @@ Version: 1. Behavioral fixtures: `rust/fixtures/ai/pull-request-description-v1.json` and `rust/fixtures/ai/inline-edit-v1.json`. +Both are implemented in the `lithe-ai` crate (`rust/lithe-ai`), like every `ai.*` command. + Both features reuse the credential-free provider request model of [AI commit generation](ai-commit.md): Core validates the endpoint, appends the protocol path exactly once, encodes a non-streaming body, and extracts text from diff --git a/rust/contracts/application-boundary.md b/rust/contracts/application-boundary.md index 5d41ea90..4758c4c9 100644 --- a/rust/contracts/application-boundary.md +++ b/rust/contracts/application-boundary.md @@ -27,7 +27,7 @@ no runtime JSON loading, package download or write to installed resources occurs The locale registry preserves each platform's persisted identifiers and default language. Explicit fallback chains resolve shared entries during generation; all existing platform catalogs and presentation-owned bilingual copy use this source. Translation coverage is not a -capability claim. [Format fixtures](../fixtures/i18n/format-v1.json) bind generation +capability claim. [Format fixtures](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/i18n/fixtures/format-v1.json) bind generation to native positional formatting, literal percent signs and unmodified argument data. ## Feature Contracts diff --git a/rust/contracts/rust-core-api.md b/rust/contracts/rust-core-api.md index 37636689..b0e40751 100644 --- a/rust/contracts/rust-core-api.md +++ b/rust/contracts/rust-core-api.md @@ -273,7 +273,7 @@ The ACP Agent ABI and the `agent.*` commands were removed together with the Agent conversation feature. `lithe_agent_open_json`, `lithe_agent_send_json` and `lithe_agent_close` no longer exist, and Core starts no adapter, proxies no model call and keeps no conversation history. Rust Core keeps only deterministic -AI configuration parsing (`ai.configuration.*`) plus MCP configuration planning. +AI configuration parsing (`ai.configuration.*`). The removed ABI text, fixtures and the `lithe-agent-host` crate remain available in this repository's history. @@ -341,6 +341,388 @@ subsystem: `mcp.parseConfiguration`, `mcp.parseRegistry`, `mcp.featuredRegistry` server (`lithe-mcp` plus `rust/fixtures/ide-api/mcp-protocol.json`) is unaffected and stays in `lithe-ide-host`. +Every command name `CoreCommand::parse` accepts has exactly one row below. The +`protocol::command` tests compare this table with the parser, so adding, renaming +or removing a command fails until the table changes in the same commit. + +| Command | Purpose | +| --- | --- | +| `core.ping` | Verify the ABI and protocol version | +| `ideHost.control` | Control the native IDE capability broker | +| `ai.commitInputBudget` | Budget diff prefixes before crossing the native boundary | +| `ai.planCommit` | Plan a credential-free commit message request | +| `ai.decodeCommitMessage` | Decode a provider commit message response | +| `ai.configuration.sources` | Name the native files of one AI CLI configuration source | +| `ai.configuration.parse` | Parse AI CLI configuration into credential-free metadata | +| `ai.pullRequest.plan` | Plan a credential-free pull request description request | +| `ai.pullRequest.decode` | Decode a strict pull request title and description | +| `ai.inlineEdit.plan` | Plan a credential-free inline edit request | +| `ai.inlineEdit.decode` | Decode the replacement text of an inline edit response | +| `plugins.declarative.validate` | Validate and normalize a declarative plugin manifest | +| `plugins.nativeUI.validateData` | Validate native plugin UI data with declaration identity and node budgets | +| `plugins.web.open` | Register a plugin page for a web tool window | +| `plugins.web.close` | Forget a plugin page and lists its calls to cancel | +| `plugins.web.resource` | Decide how a plugin page request is served | +| `plugins.web.document` | Prepend the policy and bridge to a page document | +| `plugins.web.request` | Check one bridge message of a plugin page | +| `plugins.web.reply` | Frame the host's answer to a bridge action | +| `plugins.web.event` | Frame an event for a plugin page | +| `plugins.declarative.runPlan` | Expand a declarative plugin's run template for one workspace file | +| `plugins.declarative.discoverTests` | List a declarative plugin's workspace and file test items | +| `plugins.declarative.testPlan` | Expand a declarative plugin's test template for a workspace, file, or test case | +| `plugins.declarative.languageServerLaunchPlan` | Expand a declarative plugin's language-server, build-provider, VCS-provider, backend, or debug-adapter launch with host directories and architecture | +| `plugins.languageServers.decodeLaunch` | Bound the answer of a plugin backend that resolved a language server's launch and return the plan the host starts | +| `plugins.languageServers.stateRetention` | Choose which inactive per-workspace state directories of a plugin language server the host deletes | +| `plugins.contentProviders.decode` | Bound the answer of a plugin content provider function and return the document text | +| `debug.adapters.startArguments` | Check one debug start against a plugin's `debugAdapters[]` entry and return its DAP launch arguments | +| `debug.validateAdapterEndpoint` | Validate an IDE-to-DAP TCP endpoint and classify whether remote consent is required; performs no DNS or network I/O | +| `debug.adapters.executablePlan` | Say where to look for the program a launch debugs: the user's setting and the adapter's candidate paths | +| `debug.transport.policy` | Return the default byte-stream deadlines, queue budgets and the session `initialize` deadline; creates no state | +| `debug.transport.handle` | Apply one platform event to an IDE-to-adapter byte-stream lifecycle and return the effects the platform runs next | +| `debug.transport.release` | Forget the lifecycle of a stopped byte stream once the platform dropped its handles | +| `plugins.declarative.resolveSettings` | Merge a plugin's stored global and project setting layers into effective values with their source | +| `plugins.settings.catalog` | Project enabled plugin settings pages from installed manifests | +| `plugins.settings.navigation` | Project searched settings navigation without platform policy | +| `plugins.settings.delivery` | Route committed preference effects using native ownership facts | +| `plugins.declarative.settingsState` | Read complete plugin preference documents into Core-normalized rows, sources, and bounded native read plans | +| `plugins.declarative.settingsActionPlan` | Resolve an installed settings action and its native confirmation requirement | +| `plugins.installed.select` | Select numeric installed versions from native facts without reading paths | +| `plugins.declarative.editSettings` | Validate one atomic setting edit batch and return replacement text while preserving unrelated plugins and dormant values | +| `plugins.declarative.workspaceConfiguration` | Expand a language server's `workspaceConfiguration` with the merged setting values | +| `plugins.declarative.decodeGrammar` | Validate independent static editor resources | +| `plugins.editorActions.decode` | Validate independent action registration, collection or click results | +| `plugins.hostApi.describe` | Describe backend host methods and their required grants from Core's method table | +| `plugins.hostApi.authorize` | Check a method, service and current session grants using Core's capability rules; the host still checks session and resource ownership | +| `plugins.editorProviders.decode` | Validate editor provider registrations and provider function results | +| `keymap.decodeImport` | Validate a normalized shortcut import against the host's action catalog | +| `keymap.planImportSources` | Plan the selected provider's declared shortcut source inputs | +| `keymap.planImportBindings` | Plan conflicts and default-key displacement for a shortcut import | +| `debug.startOrContinue` | Decide whether a combined debug shortcut starts, continues or does nothing | +| `toolchains.registry` | Revalidate complete enabled plugin manifests, `{plugins:[{manifest}]}`, into a deterministic tool-definition/provider registry; the host supplies no origin evidence. A short tool ID defined by several enabled plugins belongs to none of them: none of those definitions is registered, the rest register as usual, and `conflicts: [{tool, pluginIDs}]` (sorted by tool, `pluginIDs` sorted) reports each contested tool. A repeated plugin ID rejects the batch. No native host capability claim | +| `toolchains.decodeReply` | Validate an inert toolchain query/reply pair against bound provider identity, generation, declared tools and host-native filesystem evidence; no transport or native I/O | +| `toolchains.failureReply` | Build the JSON-RPC error reply for a query that missed its deadline or found no backend, so the host passes it to `toolchains.decodeReply` like a provider's own error; cancellation has none | +| `toolchains.resolvePins` | Validate a captured local document and find the nearest ancestor directory pin, retaining dormant and legacy entries without probing or writing | +| `toolchains.editPins` | Apply one bounded batch of directory pin edits to a captured local document without writing native files | +| `toolchains.pinStoragePlan` | Declare bounded workspace-native pin and ignore sources | +| `toolchains.decodePinDocument` | Validate a complete native source or supply the absent-source default | +| `toolchains.editPinSources` | Plan deterministic pin and Git ignore source edits without native I/O | +| `toolchains.resolveConsumerPreferences` | Validate an inert consumer preference document and resolve one tool's explicit Run, consumer, directory or workspace choice while keeping workspace/consumer inheritance symbolic; no native files or launch authority | +| `toolchains.editConsumerPreferences` | Edit or reset one exact consumer/directory tool preference; return the document and effective input without native writes | +| `toolchains.resolve` | Resolve the fixed/project/global/automatic layers from one generation-bound snapshot, retaining unmet requirements, unavailable providers with their cause, conflicts and terminal fixed-value failures | +| `toolchains.checkVersion` | Match captured output with an enabled plugin definition's version expression and compare generic numeric components with an optional minimum; never starts a probe | +| `toolchains.discoveryPlan` | Return platform-filtered definition-owned scan declarations, layouts, probes, PATH entries, login-shell home variables and budgets; never scans native directories | +| `toolchains.candidates` | List one tool's login-shell homes and host-scanned installations with checked executables, versions or skip reasons, and the automatic-layer choice; never starts a probe | +| `toolchains.instantiateLaunch` | Expand an enabled tool definition's `launch` template for a selected executable, entry, runtime arguments and arguments; a part the template cannot carry is `plugin.toolchain.launchMismatch`; performs no I/O | +| `run.decodeStart` | Validate a plugin backend's `lithe/host/run/start` params against its manifest and the workspace, and return the normalized start with temporary-file paths substituted | +| `debug.decodeBackendConfiguration` | Validate a plugin backend's `lithe/host/debug/start` params against its manifest and return the qualified adapter, request, label and configuration | +| `outputLinks.match` | Find the links plugin `outputLinkMatchers` declare in Run or Test console lines, as UTF-16 ranges with candidate paths or a backend function to resolve them | +| `outputLinks.decodeResolution` | Check a matcher's `resolve` function reply and return the 1-based file location to open, or `null` | +| `build.detect` | Find every build-system instance the enabled plugins declare in a workspace, with models that need no process | +| `build.projectOptions` | Validate a native team's persisted Build options | +| `build.resolveStoredOptions` | Merge checked team and machine-local Build preferences without I/O | +| `build.editStoredOption` | Edit one declared option in a machine-local Build document without I/O | +| `build.invocationPlan` | List a build system's executable candidates, runtime dependencies, and version probe for one platform | +| `build.checkToolVersion` | Decide whether a build tool's or runtime's version output meets the declared minimum | +| `build.resolveOptions` | Check stored build options against the declaration and return effective values, choices, and the fingerprint | +| `build.modelPlan` | Expand the command that prints a command-sourced build model, or the request a build provider answers | +| `build.taskPlan` | Expand one declared or model task for a workspace, project, or target | +| `build.testPlan` | Plan a test run for a scope, suite, or case and say where its reports will be | +| `build.prepareDebugPlan` | Plan same-plugin native debug preparation | +| `build.decodeDebugPreparation` | Validate an existing instance-contained native debug program and bounded process facts | +| `build.resolveModelInputs` | Validate enabled producer facts and project a bounded public model snapshot for a declared consumer | +| `build.configurationPlan` | Plan a read of the provider's non-sensitive native configuration | +| `build.configurationEditPlan` | Plans format-independent provider editing into private host staging | +| `build.decodeConfigurationEdit` | Validates the receipt without native document bytes | +| `build.decodeConfiguration` | Validate native source identities, raw/effective values and XML bindings | +| `build.prepareRunPlan` | Plan Provider-owned application/service/task preparation from a model target | +| `build.decodeRunPreparation` | Validate portable launch data before the native Run host starts a process | +| `build.testDiscoveryPlan` | Plan opt-in Provider test discovery for an instance | +| `build.decodeTestDiscovery` | Validate, deduplicate and order Provider test selectors | +| `build.dependencyPlan` | Expand the command that prints a project's dependency tree, or the request a build provider answers | +| `build.decodeModel` | Decode model output or a provider result into projects, targets, tasks, content roots, option values, and run entries | +| `build.decodeTestResults` | Decode a test run's reports and output into per-case results and a summary | +| `build.decodeDiagnostics` | Decode build output into workspace file diagnostics | +| `build.decodeDependencies` | Decode dependency output or a provider result into one project's bounded tree | +| `build.syncFingerprint` | Digest the files a build model depends on, to tell whether it is stale | +| `build.resolveTestRunOutcome` | Reduce a test run's termination, selected scope and decoded summary to one tool-neutral verdict | +| `build.mergeTestOutcomes` | Merge a run's test result ids into the stored history inside the host-stated id/prefix scope | +| `vcs.detectRoots` | Find the VCS roots of a workspace from static markers, with registered providers, ambiguous directories, and applied mappings | +| `vcs.ownerOf` | Assign workspace paths to the detected root that owns each | +| `vcs.decodeReply` | Validate a VCS provider's reply and return the normalized result | +| `project.generatorCatalog` | List the project generators of the given plugins, with the plugins that provide each requirement | +| `project.generatorForm` | Evaluate a generator's form: derived defaults, visibility, problems, and active requirements | +| `project.generationPlan` | Expand a filled-in form into the files, commands, and archive downloads that create a project | +| `project.checkName` | Check that a name can be a new project's directory name | +| `project.extractArchive` | Extract a downloaded project archive into the staging directory, refusing unsafe entries | +| `plugins.marketplace.validateIndex` | Validate a marketplace index and report each plugin's availability for one platform | +| `plugins.marketplace.resolveSource` | Resolve a user-entered marketplace source to the index URL to fetch | +| `plugins.marketplace.installReview` | List a package's permissions and programs, and those not yet approved | +| `community.discourse.auth.begin` | Create an ephemeral RSA-OAEP authorization session and return the Discourse browser URL | +| `community.discourse.auth.complete` | Decrypt, validate, and consume one Discourse user API key callback | +| `community.discourse.topics` | List normalized latest or top topic summaries | +| `community.discourse.topic` | Read one topic with ordered, sanitized post HTML | +| `community.discourse.categories` | List normalized visible categories | +| `community.discourse.search` | Search normalized topics and sanitized posts | +| `community.discourse.auth.revoke` | Revoke the current Discourse user API key | +| `workspace.snapshot` | Enumerate visible workspace nodes and relative file paths; `nestedWorktrees` lists linked worktrees checked out inside their own repository, which are left out of the tree, files, and search index; the built-in hidden names are skipped without being read, and include `node_modules`, Python virtual environments (`.venv`, `venv`), `__pycache__` and `*.pyc` files; `includeTree: false` returns only `files` and a childless `root`; `files` (and the tree, when included) keep the lowercase traversal order that bounded consumers such as Windows Quick Open rely on, not the natural display order of `workspace.listDirectories` | +| `workspace.releaseIndexing` | Retire a policy and clear index/cache data at project close; accepts `root`, optional `ownerId`, and that owner's last observed `revision`, returns `released`; an old close must not retire a new generation | +| `workspace.configureIndexing` | Install a process-local, canonical-root indexing policy before workspace consumers run; returns a monotonic revision, effective directory reasons and Git failure state; see `rust/fixtures/workspace/indexing-policy-v1.json` | +| `workspace.refreshIndexing` | Refresh external Git decisions for a live `root`, `ownerId`, `revision` lease without changing preferences or release tokens; returns lazy `IndexingStatus` | +| `workspace.filterChanges` | Filter an array of workspace-relative paths, or `{ paths, hiddenFilePatterns }` whose `!` entries turn default exclusions off, against Core's default build/cache exclusions; preserve order, spelling, and the empty root path. Native event loss bypasses filtering. Hosts relativize to the most specific explicit watch root and retain explicitly watched files | +| `workspace.listDirectories` | Return one visible level of children for each requested workspace-relative directory (`""` is the root); missing, hidden, symlinked, or nested-worktree paths are omitted, and each listing reports the nested worktrees it left out. Child directories have `children: []` only when a shallow probe proves them visibly empty; otherwise children remain omitted (unloaded), with no recursive tree allocation. Children list directories first, then names in IDEA's natural file-name order (digit runs compare by value, case-insensitive, `-` next to `_`; fixture `rust/fixtures/workspace/file-name-order-v1.json`) | +| `workspace.orderEntries` | Order natively enumerated directory siblings | +| `workspace.rankNames` | Pure ranking of candidate names that the platform enumerates itself (`{ query, candidates: [{ name, path? }], context: { openPaths, recentPaths }, limitPerTier? }`). Use it only for lists Core cannot index: Windows remote/WSL roots, symbol lists, navigator lists. Roots Core indexes go through `workspace.searchEverywhere`, so an inventory is never sent back to Core. Returns `matches: [{ index, degree, tier, nameMatchRanges }]` ordered by degree, open/recent tier, recency, then file-name order (fixture `rust/fixtures/search/rank-names-v1.json`) | +| `workspace.planDuplicate` | Name the copy of one project item next to its source (`foo.txt` → `foo copy.txt`, then `foo copy 2.txt`) from the sibling names the host has seen to exist | +| `workspace.planPaste` | Name pasted project items in one destination directory: a free name is kept, a taken one gets the copy suffix, and earlier items of the same paste count as taken | +| `workspace.reduceDirectoryAction` | Apply "set role", "exclude" or "cancel exclusion" to a root's persisted directory rules and return the complete state to persist | +| `workspace.relocateDirectoryRules` | Move (or drop, after a delete) the directory rule keys of a renamed, moved or deleted subtree, optionally into another workspace root's rules | +| `workspace.repositories` | Discover deterministic Git repository roots for an opened workspace | +| `workspace.recentProjects.reduce` | Apply one record, remove, pin, import, or normalize action to the recent project list and return the retained order | +| `workspace.searchIndex.warm` | Build or reuses the workspace search index | +| `workspace.searchIndex.update` | Apply changed paths to the search index | +| `workspace.searchIndex.invalidate` | Drop a workspace's cached search index | +| `search.actions` | Match host-localized action text without workspace I/O | +| `workspace.search` | Search visible file names and UTF-8 text files | +| `workspace.searchEverywhere` | Search file paths, contents, and symbols | +| `workspace.replacePreview` | Return deterministic replacement lines and complete replacement text | +| `workspace.replaceText` | Plan replacement against a host-captured text snapshot without writing | +| `workspace.searchTextBatch` | Search provider-captured text snapshots using workspace matching rules | +| `file.read` | Read a text file using a workspace-relative path; optional `encoding`, see [File encodings](#file-encodings) | +| `file.write` | Write a text file using a workspace-relative path; optional `encoding` and `bom` | +| `text.decode` | Decode `{ bytes }` (standard base64 of bytes the host read) like `file.read`, optional `encoding`; returns `{ text, encoding, hasBom, lossy, identity }` with the same 32 MB, plain-text and detection rules | +| `text.encode` | Encode `{ text }` like `file.write`, optional `encoding` and `bom`; returns `{ bytes, encoding, hasBom, identity }` with `bytes` in standard base64. Nothing is written, and an unmappable character fails with `encoding_unmappable` as `file.write` does | +| `document.lifecycle` | Reduce a shared document save, external-change, or conflict event without reading text or disk | +| `document.classifyText` | Classify decoded Unicode text independently of its filename | +| `document.planSaveBatch` | Select the open documents a Save All or save-before-launch batch writes | +| `document.reduceSaveBatch` | Reduce save batch member outcomes and live documents into one batch status | +| `history.record` | Store a versioned text snapshot and metadata | +| `history.entries` | List valid history entries for one file or a workspace | +| `history.content` | Read a stored history snapshot by relative storage path | +| `history.relocate` | Move retained history for a file or directory subtree after a rename or move | +| `history.planEvent` | Plan which snapshots an editing event (save, auto-save, restore, manual, discarded unsaved text, external change, before rename, before batch replace) records and under which reason | +| `history.rename` | Set or clear a user-visible label on a history entry | +| `history.delete` | Delete one history entry and its snapshot | +| `history.ignorePreview` | Validate local history ignore rules and report which rule skips a sample path | +| `history.usage` | Report one project's allocated history size and the storage cap | +| `history.clear` | Delete every snapshot of one project's history | +| `diff.compareTexts` | Compare two host-supplied texts line by line into paired side-by-side rows and unified lines | +| `execution.resolveEnvironment` | Merge native `inherited` string values and `overrides` into `{ environment }`; string overrides win. Core names no tool: a tool's runtime-home variable reaches a launch through the selected toolchain installation (`toolchains.*`), which the host applies before this merge, or as a literal override from the plugin that owns the toolchain. A structured `{ toolchain, property }` value the host could not resolve to text has no defined value and contributes nothing. `caseInsensitive: true` canonicalizes Windows keys to uppercase | +| `execution.resolveSearchPath` | Preserve existing path precedence, sort platform candidates by priority and identity, and deduplicate using the requested case policy | +| `processLifecycle.resolve` | Decide how each process or resource of one Lithe instance ends | +| `processLifecycle.inventory` | Group a process snapshot of one Lithe instance into terminals, Agent tasks and background services | +| `processLifecycle.settle` | Turn confirmation answers into what ends, what is kept and the updated settings | +| `markdown.render` | Render and sanitizes shared Markdown | +| `keymap.parseContext` | Compile a bounded boolean shortcut context | +| `keymap.evaluateContext` | Evaluate a precompiled shortcut context | +| `keymap.analyzeConflicts` | Find conflicts among the final layered shortcut bindings | +| `keymap.modifierGestureStep` | Advance the double-Shift gesture recognizer by one event. The rule itself lives in `lithe-core-rules` so the Windows frontend can run it synchronously | +| `editor.workbenchState.reduce` | Apply one editor-group state transition: open, close, move, pin, preview, merge or normalize. The rule lives in `lithe-core-rules` so the Windows frontend can run it synchronously inside store updates | +| `workspace.classifyVisibility` | Classify a batch of workspace-relative paths as hidden or visible. The rule lives in `lithe-core-rules` so the Windows frontend can run it synchronously | +| `workspace.visibilityDefaults` | Return the built-in hidden-path lists (from `lithe-core-rules`) | +| `workbench.chromeLayout.reduce` | Apply one transition to the rearrangeable workbench chrome: stripe, title bar and status bar button placement and open tool panels (from `lithe-core-rules`) | +| `terminal.parseLinkTarget` | Resolve terminal link candidates into file or external targets | +| `debug.createSession` | Create a transport-neutral DAP session and return its initialize frame | +| `debug.launch` | Queue a launch or attach request, including during initialization | +| `debug.steppingFilters` | Return adapter defaults or normalize portable stepping filters | +| `debug.relocateBreakpoints` | Move source breakpoints across one exact UTF-16 editor replacement | +| `debug.planVariablePage` | Plan the first bounded `variables` page of one scope or structured variable from its reported counts | +| `debug.reduceVariablePage` | Merge one returned variable page into its cursor and decide whether the container is exhausted | +| `debug.planDisassemblyWindow` | Plan the first `disassemble` request of the window around the instruction pointer or one listed edge | +| `debug.reduceDisassemblyPage` | Reduce one `disassemble` reply to the next bounded recovery request, a deduplicated page, or the adapter's failure | +| `debug.setBreakpoints` | Replace and deterministically order one source's DAP breakpoints | +| `debug.setExceptionBreakpoints` | Replace and deterministically order one session's exception filters | +| `debug.setFunctionBreakpoints` | Replace and deterministically order one session's named function breakpoints | +| `debug.setInstructionBreakpoints` | Replace and deterministically order one session's machine-instruction breakpoints | +| `debug.dataBreakpointInfo` | Resolve an adapter-owned data breakpoint identity for a paused variable or field | +| `debug.setDataBreakpoints` | Replace and deterministically order one session's resolved data breakpoints | +| `debug.setBreakpointsMuted` | Mute or unmutes every breakpoint of one debug session | +| `debug.planMemoryWindow` | Place the memory view's readMemory window at an address | +| `debug.locateMemoryWindow` | Find the byte read at in one readMemory reply | +| `debug.setVariable` | Replace one visible variable value in its adapter-owned parent container | +| `debug.cancelOperation` | Cancel or time out one pending operation and ignore its late response | +| `debug.execute` | Submit continue, pause, next, step-in, or step-out control | +| `debug.inspect` | Request normalized threads, frames, scopes, variables, or evaluation | +| `debug.receive` | Reduce base64-encoded bytes received from a platform-owned DAP transport | +| `debug.runInTerminalResponse` | Complete one adapter-requested native terminal launch | +| `debug.startDebuggingResponse` | Complete one adapter-requested child session start | +| `debug.disconnect` | Begin the DAP disconnect handshake without closing the native transport | +| `debug.destroySession` | Remove a session and its whole child tree after the platform closes its native transport; the response lists `destroyedChildSessionIds` (leaves first) whose transports the platform must also close | +| `lsp.applyTextEdits` | Apply LSP UTF-16 text edits with range validation | +| `lsp.planWorkspaceEdit` | Validate language-server workspace edit targets against the workspace and open documents, and order the commit | +| `lsp.documentIdentity` | Resolve a diagnostic URI or path strictly inside its workspace | +| `lsp.classifyNavigationTarget` | Classify a language-server jump target as workspace, external read-only or rejected | +| `lsp.declarationOrUsages` | Decide whether a go-to-declaration gesture opens the declaration or shows its usages | +| `editor.lineEdit` | Apply a deterministic line-level text transform and return the replacement plus selection to restore | +| `editor.find` | Find text or plans bounded replacements in a borrowed document | +| `editor.preferences.resolve` | Resolve persisted editor presentation preferences | +| `editor.recent.reduce` | Reduce and queries bounded recent-file history | +| `editor.recovery.decide` | Decide how a native checkpoint should recover | +| `keymap.schemes.reduce` | Reduce named shortcut schemes | +| `editor.planTextEdits` | Validate and order a plugin document edit batch into UTF-16 offsets with the document's line ending | +| `editor.parseNavigationInput` | Parse a bounded line or line:column input into zero-based editor coordinates | +| `editor.navigationHistory.reduce` | Reduce one bounded back/forward navigation history event and return the next state plus the location to open | +| `editor.lineCommentToken` | Resolve the line comment token for a file extension or language id | +| `network.proxyPolicy` | Normalize the stored proxy settings into the policy a network stack must apply (mode, server, credentials, exclusions) | +| `lsp.plainSnippet` | Convert LSP snippet insert text into plain editor text | +| `lsp.builtinCompletions` | Return lightweight current-file identifier completions | +| `lsp.builtinHover` | Return lightweight current-symbol hover text | +| `lsp.builtinNavigation` | Return lightweight current-file definition/reference locations | +| `lsp.mergeCompletions` | Choose which candidates survive when several prioritized providers complete at one position | +| `lsp.activationPolicy` | Decide whether a language server may start automatically after failures, using a platform monotonic clock | +| `lsp.problems.query` | Filter, order, and group Problems tool-window diagnostics and count them by severity | +| `lsp.startServer` | Start one Rust-owned process/session and begin initialization | +| `lsp.stopServer` | Gracefully shut down a session, with a bounded force-stop fallback | +| `lsp.updateWorkspaceConfiguration` | Replace the plugin configuration a running plugin-owned session answers `workspace/configuration` with, and announce it | +| `lsp.documentUri` | Convert a native absolute path into a canonical file URI | +| `lsp.syncDocument` | Open a document or apply a full-text or incremental `didChange` with monotonic versions | +| `lsp.saveDocument` | Report a confirmed disk save with its exact saved text, honoring server save options | +| `lsp.workspaceFilesChanged` | Publish normalized created, changed, or deleted workspace files to one session | +| `lsp.closeDocument` | Close a document and clear its diagnostics | +| `lsp.request` | Submit a typed semantic request and return an opaque operation ID | +| `lsp.buildRequest` | Forward one planned `lithe/build/*` request to a session that negotiated the build-provider protocol | +| `lsp.vcsRequest` | Send one VCS operation, planned by Core, to a session that negotiated the VCS-provider protocol | +| `lsp.toolchainRequest` | Send one `toolchains/query` to a plugin backend that negotiated the provider | +| `lsp.keymapImportRequest` | Send one shortcut import request to a negotiated plugin provider | +| `lsp.functionRequest` | Invoke one registered plugin function on a session its plugin started | +| `lsp.functionProvider` | Select a registered function provider strictly inside one workspace | +| `lsp.configureFunctionRelay` | Replace the enabled plugins and live workspace contexts Core's plugin-to-plugin function relay checks calls against | +| `lsp.hostResponse` | Answer one host-service request a plugin backend sent | +| `lsp.hostNotify` | Send one host notification to a plugin backend that negotiated it | +| `lsp.pluginServerRequest` | Send one request of a plugin backend to the plugin's own language server, under the plugin's declared session policy | +| `lsp.pluginServerNotify` | Send one notification of a plugin backend to the plugin's own language server | +| `lsp.cancelOperation` | Cancel one pending semantic operation | +| `lsp.pollEvents` | Drain ordered typed lifecycle/feature/diagnostic/result/log events | +| `lsp.setDetailedDiagnostics` | Toggle upstream verbose trace for one session | +| `lsp.waitEvents` | Block until queued events exist or a timeout elapses, then drain them | +| `lsp.destroyServer` | Remove a terminal session handle from the registry | +| `runConfig.inspect` | Inspect `.lithe` run documents, versions, and staleness without writing files | +| `runConfig.projectToolchain` | Read saved project tool paths for one Provider root, with the nearest path override | +| `runConfig.generate` | Regenerate detected run configurations | +| `runConfig.argumentText` | Format a batch of exact argument vectors as reversible editor text | +| `runConfig.resolve` | Merge generated, project, and local layers and return diagnostics | +| `runConfig.selectContextConfiguration` | Choose a matching effective configuration for a provider-supplied source and entry identity | +| `runConfig.decideTitleAction` | Decide what a press on the title bar's Run or Debug button does | +| `runConfig.updateOptions` | Apply typed option edits and return an updated project or local document | +| `runConfig.updateToolPreferences` | Replace only generic preferences in a captured local Run document, preserving legacy defaults and unrelated metadata | +| `runConfig.saveEditorChanges` | Prepare the local and optional project documents for one editor save | +| `runConfig.createUserConfiguration` | Validate a typed user configuration and return an updated document | +| `runConfig.createLaunchPlan` | Project one effective configuration into a platform-neutral Run or Debug plan | +| `runConfig.providerLaunchInputs` | Resolve user choices for a provider-owned launch | +| `git.status` | Resolve the repository, current branch, and working-tree changes | +| `git.untrackedDirectory` | List one level of a summarized untracked directory: its files and subdirectory summaries | +| `git.commitState` | Read exact HEAD, symbolic branch and index preconditions for a workspace commit | +| `git.workspaceCommitPrepare` | Build a reviewed multi-repository plan | +| `git.workspaceCommitStep` | Execute one guarded workspace commit/push | +| `git.classifyMetadataChanges` | Classify a bounded page of coalesced watcher paths without filesystem access | +| `git.watchContext` | Resolve the repository and absolute Git metadata roots needed by native file watchers | +| `git.worktrees` | Return deterministic registered-worktree metadata without scanning each checkout | +| `git.pullRequestContext` | Resolve worktree-aware PR branch defaults, publication state, and uncommitted-change state | +| `git.command` | Execute one argument-based Git operation and return its arguments, streams, exit code, and ordered subprocess invocations | +| `git.repositoryRoot` | Resolve the repository root containing a workspace path without acquiring repository write coordination | +| `git.resolveFileContext` | Resolve a file to its owning Git checkout and relative path | +| `git.workspaceStatusMerge` | Plan publication of fresh, retained, and unavailable repository snapshots | +| `git.write` | Validate and execute shared Git mutations such as stage, commit, branch, checkout, remote sync, clone, and stash | +| `git.rollbackPlan` | Plan whole-file rollback from explicit Git status identities without reading or changing a repository | +| `git.rollbackPrepare` | Recheck selected identities against the repository and return rollback steps | +| `git.prepareCommitSelection` | Prepare and verify the real indexes for explicitly selected changes | +| `git.reconcileDeletedSelection` | Clear missing selected staged additions after filesystem deletion | +| `git.changelists` | Reduce local ChangeList metadata and project membership and commit scope | +| `git.shelf` | Capture or applies one guarded Shelf layer | +| `git.commitMessageContext` | Capture bounded AI commit evidence and recheck its Git state | +| `git.consolePresentation` | Pure IDEA-style configuration/progress folds, empty-output notices and search ranges over retained diagnostic snapshots | +| `git.remoteUrl` | Read a configured remote URL silently; a missing remote returns a null URL | +| `git.fetchPlan` | Validate Fetch choices; optionally inspect a repository to expand enabled per-remote commands | +| `git.executable` | Resolve the executable Git will run without requiring a repository | +| `git.executionInspect` | Inspect executable capabilities, configuration provenance and effective Fetch preferences | +| `git.executionConfigure` | Explicitly save or clear one allowlisted value in a selected config scope | +| `git.authRespond` | Answer or cancel a live authentication challenge once | +| `remote.probe` | Check that an SSH connection reaches a supported host | +| `remote.authRespond` | Answer or cancel a prompt relayed by `remote.probe` or `remote.connect` once | +| `remote.connect` | Open a connection, installing the shipped server when needed | +| `remote.readDir` | List a directory over an open remote connection | +| `remote.disconnect` | Close an open connection and its OpenSSH process tree | +| `remote.closeAll` | Close every open connection; the host calls it at application exit | +| `remote.readFile` | Read a file over an open remote connection | +| `remote.writeFile` | Save a file over an open remote connection | +| `remote.hostRequest` | Perform a non-streaming operation on an existing workspace host | +| `remote.runProcess` | Own a remote process and relays output until it exits or is cancelled | +| `remote.captureProcess` | Capture a short remote process with explicit time and output budgets | +| `remote.runTerminal` | Own a remote PTY and relays output until it exits or is cancelled | +| `remote.runWatch` | Own a host watch and relays changes until cancellation or root removal | +| `remote.uploadFile` | Stream a native source into integrity-checked host file staging | +| `remote.hostInfo` | Return cached handshake capabilities of one connection | +| `git.historyRewritePreview` | Review undo, message edit, squash, or drop with complete messages, eligibility, and an immutable checkout expectation | +| `git.remotes` | Return configured fetch remote names and URLs | +| `git.tags` | Return tag names, tag object IDs, target IDs, dates, and annotations | +| `git.parseCheckoutMetadata` | Interpret bounded HEAD text without filesystem access | +| `git.parseGitdirPointer` | Interpret a bounded gitdir pointer without filesystem access | +| `git.historyActionCapabilities` | Read history menu hints from actual HEAD and the bounded first-parent chain | +| `git.rebasePreview` | Resolve the complete local linear range strictly after a selected unchanged base | +| `git.repositorySetup` | Inspect repository/unborn state and scoped/effective commit identity; see `git-repository-setup.md` | +| `git.initialize` | Initialize a directory outside existing repositories without staging or committing | +| `git.configureIdentity` | Save or clear one local/global `user.name` or `user.email` override | +| `git.rebaseStart` | Start a reviewed native interactive rebase with persisted messages and recovery identity | +| `git.rebaseSession` | Read the latest owned rebase session and distinguish edit/conflict pauses from completion | +| `git.rebaseControl` | Continue, skip, or abort an identified session, optionally amending an edit pause | +| `git.patchExport` | Export a lossless UTF-8 exchange patch | +| `git.patchPreview` | Review a patch with a forward applicability check | +| `git.patchApply` | Apply a patch tied to its reviewed snapshot | +| `git.diff` | Produce a structured working-tree, index, reference, commit, or stash patch | +| `git.parsePatch` | Parse a complete saved patch into Core-owned per-file documents | +| `git.apply` | Apply or check a patch in `stage`, `unstage`, `discard`, or Shelf restore mode | +| `git.planDiffBlocks` | Plan commit-review blocks for one file; Core owns every block patch | +| `git.applyDiffBlock` | Include, exclude or roll back one planned block under the write lease | +| `git.stageGutterChange` | Stage one editor gutter change with its original bytes | +| `git.history` | Return the legacy combined reference snapshot and first bounded commit page | +| `git.historyQuery` | Compare bounded commit date identities with explicit absolute time bounds | +| `git.references` | Return deterministic refs, recent local branches, ahead/behind state, and effective Git identity without scanning commit history | +| `git.historyPage` | Return one bounded commit page, author date, UTC offset, optional Unix timestamp, and an opaque continuation cursor | +| `git.historyCursorClose` | Release an unfinished incremental history cursor and its Git process | +| `git.historyLocate` | Append bounded history pages until one commit is found and report the budget, window cursor, and exhaustion | +| `git.branchNamePreview` | Clean and validate a new branch name against already loaded references without running Git | +| `git.repositoryColorAssignments` | Assign color slots to a bounded page of ordered repository identities | +| `git.updatePlan` | Route a branch Update and gate a Pull from facts the host collected: blockers, strategy default, whether to ask, and the expected checkout | +| `git.historyWindow` | Plan a history refresh window, page merge, and selection retention over commit identities | +| `git.graphProjection` | Project a loaded history window into commit-graph lanes, color IDs, routes, print elements, and labels, optionally filtered | +| `git.normalizeChangeTargets` | Drop targets already covered by a selected untracked summary | +| `git.rebasePlanPreview` | Validate an interactive rebase draft and derives its messages | +| `git.referenceTree` | Group one kind of loaded references into an ordered tree | +| `git.pushPreview` | Resolve a local branch push destination and the bounded commits not present on that remote base | +| `git.commit` | Return one structured commit by revision with its full message body | +| `git.commitFiles` | Return files changed by one commit | +| `git.comparison` | Return files changed between a reference and the working tree | +| `git.stashes` | Return structured stash references and messages | +| `git.checkoutPreflight` | Return local paths that would block switching to a reference | +| `git.pullPreflight` | Report the configured upstream, ahead/behind counts, divergence, tracked local changes, and the strategy Git is configured for, without fetching | +| `git.integrationPreflight` | Return local paths that block a merge, rebase, cherry-pick, or revert | +| `git.conflictMarkers` | Return staged text files that still contain conflict markers | +| `git.operationState` | Report an interrupted merge, rebase, cherry-pick, or revert and its conflicted paths | +| `git.blame` | Return structured line blame metadata | +| `git.lineHistory` | Trace which commits touched a one-based snapshot line range of one file | +| `github.parseRemote` | Parse a canonical GitHub HTTPS or SSH remote into owner/name | +| `github.requestPlan` | Validate one GitHub operation and produce a trusted platform HTTP request plan | +| `github.normalizeResponse` | Normalize raw GitHub JSON and HTTP status into deterministic data or a stable error | +| `github.deviceAuthorizationTransition` | Plan the next device-authorization action, polling interval, and deadline-bounded wait | +| `github.pullRequestCheckoutPlan` | Plan a PR's safe local branch and report an existing-ref conflict | +| `git.pullRequestCheckoutPreview` | Inspect and review a non-destructive PR checkout plan | +| `git.pullRequestCheckout` | Recheck and execute a reviewed PR checkout without resetting an existing branch | +| `diagnostics.redactText` | Redact credentials, tokens, and home-directory paths from diagnostic-bundle text | +| `diagnostics.prepareLogEvent` | Normalize a bounded application event and recursively redact structured fields | +| `diagnostics.queryLogs` | Merge and filter bounded host-collected module log tails | +| `diagnostics.recordPluginOperation` | Record host-observed declarative contribution outcomes | +| `diagnostics.drainCoreEvents` | Atomically take process-owned Core log facts and their dropped count | +| `diagnostics.configureCoreEvents` | Set the DEBUG admission gate for Core log facts from host detailed-diagnostics state | +| `diagnostics.encodeLogBatch` | Encode independent structured v2 log events with per-item errors | +| `diagnostics.parseLogFilter` | Parse bounded filter expressions and UTF-16 diagnostic ranges | +| `diagnostics.composeLogFilter` | Compose a filter predicate preserving unrelated terms | +| `diagnostics.formatLogRecords` | Validate and format canonical redacted records for copying | +| `diagnostics.queryLogsV2` | Query structured v2 and legacy v1 native log tails | +| `diagnostics.planLogCollection` | Plan bounded native log-tail reads from raw file metadata | +| `diagnostics.planLogRetention` | Plan log cleanup from host-owned file inventories | +| `diagnostics.resolveLogChannel` | Validate channels and resolve safe session-relative destinations | +| `diagnostics.resolveEngineLogSubject` | Resolve the same routing segment and channel used by engine startup, without I/O | +| `diagnostics.buildManifest` | Shape a deterministic diagnostic bundle manifest from host-gathered environment and file facts | + Workspace paths in responses are relative and use `/` separators. Line numbers are one-based. `git.status.repositoryRoot` may be an absolute path when the opened workspace is a subdirectory of the repository; all Git change paths are @@ -3327,6 +3709,30 @@ natively; this command only shapes and sorts them. The additive top-level `redaction:"core-v2"` identifies the shared redaction boundary; schemaVersion remains 1. Windows consumes the original fields and may ignore this marker. +### AI configuration + +These commands, like every `ai.*` command, are implemented in the `lithe-ai` crate +(`rust/lithe-ai`); `lithe-core` only routes them. + +`ai.configuration.sources` takes `{source, home, environment}` (`source` is +`codex` or `claude`, `home` an absolute path, `environment` only the directory +overrides `CODEX_HOME` / `CLAUDE_CONFIG_DIR`) and returns `{files}`: the native +files `{file, path}` the platform should read. `ai.configuration.parse` takes +`{source, files, environment, allowIncomplete}` with the file texts keyed +`config`, `auth`, `settings`, `credentials` and `root`, each at most 1 MiB, and +returns `null` when nothing is configured, otherwise `{model, provider, issue, +reasoningEffort, hasCredential, credential, unsupportedAuthentication}`. The +response never contains a secret: `credential` is a locator +(`{kind: "environment", name}` or `{kind: "file", file, path}`) that the host +resolves natively against the same text or environment. Malformed input returns +`invalid_request` with a fixed code (`AI_COMMIT_INVALID_CONFIG`, +`AI_CONFIGURATION_INVALID_AUTHENTICATION`, `AI_CONFIGURATION_SMART_QUOTES`, +`AI_CONFIGURATION_TOO_LARGE`) and never echoes input. `allowIncomplete: true` is +for editor drafts: it ignores the environment, accepts blank or partial text and +keeps missing endpoint/model fields empty. The shared cases are in +`rust/fixtures/ai/cli-configuration-v1.json`. It replaces the former +`agent.parseProviderConfiguration`. + ### Declarative plugins The `plugins.declarative.*` commands implement @@ -3772,6 +4178,8 @@ and the directories are absolute host paths used only as substitution text. | --- | --- | --- | | `build.detect` | `{ workspaceRoot, plugins: [{ manifest }], options?: { : { : value } }, instanceOptions?: { : { : { : value } } }, instanceDefaults?: { : { : value } }, emptySelections?: { : { : [optionID] } } }` | `detection`: `{ instances, rejected: [{ index, error }], shadowed: [{ pluginID, buildSystemID, by }] }`; each instance is `{ id, pluginID, buildSystemID, displayName, root, markers, modelSource, optionFingerprint, options, droppedOptions }` plus `model` or `modelError` for `static` and `hostIntegration` sources; a `provider` source has neither. `options` contains accepted stored values after Core scope/root merging, including explicit empty selections; `droppedOptions` names rejected stored values without rewriting the source document. | | `build.projectOptions` | `{ document }`, host-read persisted v1 Build options, without a manifest | `document`: validated storage with version 1 and systems; options, instance defaults, root values, explicit empty selections and saved custom choices are retained. Missing system fields normalize to empty maps; malformed, unsupported or oversized documents reject with `plugin.build.invalidOptionsDocument`. This pure operation never reads or writes a file and does not replace declared option resolution. | +| `build.resolveStoredOptions` | `{ document, local: { version: 1, document, emptySelections?: { : { : [optionID] } } } }`, without a manifest | `storage: { document, local, emptySelections }`: checked team/local merge; local values override matching keys, unrelated systems/roots/choices stay intact. No filesystem I/O. Local documents are machine/workspace preferences, not team files or plugin-native configuration. Their storage syntax is [build-local-options-v1.schema.json](build-local-options-v1.schema.json), with additional cross-field membership and byte limits checked by Core. | +| `build.editStoredOption` | `{ document, local, root, optionID, value, modelValues? }` in the standard manifest/build-system envelope | `edit: { local, options }`: changes one declared local preference. `null` removes the local override and restores team/declaration inheritance. Invalid or partially accepted edits reject before persistence. Workspace multi-selections preserve sibling-only values; explicit per-root empty markers apply only to workspace multi-select declarations. Platforms compare both native source revisions, atomically write only the local document and read back. | | `build.invocationPlan` | `{ root, platform: "macos" \| "linux" \| "windows" }` | `invocation`: `{ buildSystemID, root, executables, environmentInputs, runtimeDependencies, version, minimumVersion }` with project-file paths joined to `root`; every runtime dependency with a known probe, host-managed ones included, carries `version: { arguments }` | | `build.checkToolVersion` | `{ runtime?, output }` | `version`: `{ satisfied, version? }`; `runtime` checks that runtime dependency instead of the tool | | `build.resolveModelInputs` | `{revision, sources: [{manifest, instances: [{buildSystemID, root, state, model?, options?}]}]}` from installed enabled current-workspace sources | `modelInputs`: version 1 public snapshot with exact optional producer slots, state, normalized projects/targets/content roots, configuration fingerprint and explicitly shared options; no tasks/run/settings/private functions; see the [plugin contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/plugins/contract/v1/README.md#optional-read-only-model-inputs) | diff --git a/rust/fixtures/i18n/format-v1.json b/rust/fixtures/i18n/format-v1.json deleted file mode 100644 index 26a36794..00000000 --- a/rust/fixtures/i18n/format-v1.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "schemaVersion": 1, - "parameters": { "name": "string", "count": "integer" }, - "arguments": ["name", "count"], - "cases": [ - { - "name": "Reordered parameters and literal percent", - "template": "100% {count} 个:{name}", - "nativeTemplate": "100%% %2$lld 个:%1$@", - "stringValue": "中文/%@/{count}", - "integerValue": 9000000000, - "expected": "100% 9000000000 个:中文/%@/{count}" - }, - { - "name": "Repeated parameter and zero count", - "template": "{name}: {count} files ({name})", - "nativeTemplate": "%1$@: %2$lld files (%1$@)", - "stringValue": "feature/100%", - "integerValue": 0, - "expected": "feature/100%: 0 files (feature/100%)" - } - ] -} diff --git a/rust/fixtures/i18n/native-abi-v1.json b/rust/fixtures/i18n/native-abi-v1.json deleted file mode 100644 index 75991068..00000000 --- a/rust/fixtures/i18n/native-abi-v1.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "schemaVersion": 1, - "parameters": { "rows": "unsignedInteger", "elapsed": "integer" }, - "macos": { "formats": { "rows": "llu", "elapsed": "d" } }, - "arguments": ["rows", "elapsed"], - "template": "{rows} rows affected in {elapsed} ms", - "nativeTemplate": "%1$llu rows affected in %2$d ms", - "unsignedValue": "18446744073709551615", - "integerValue": 2147483647, - "expected": "18446744073709551615 rows affected in 2147483647 ms" -} diff --git a/rust/fixtures/phpunit-project/.gitignore b/rust/fixtures/phpunit-project/.gitignore deleted file mode 100644 index c84ab0ca..00000000 --- a/rust/fixtures/phpunit-project/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -/vendor/ -/composer.lock -/.phpunit.result.cache diff --git a/rust/fixtures/phpunit-project/composer.json b/rust/fixtures/phpunit-project/composer.json deleted file mode 100644 index d7667b3f..00000000 --- a/rust/fixtures/phpunit-project/composer.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "lithe/phpunit-fixture", - "require-dev": { "phpunit/phpunit": "9.6.22" }, - "scripts": { "test": ["@php vendor/bin/phpunit"] } -} diff --git a/rust/fixtures/phpunit-project/phpunit.xml b/rust/fixtures/phpunit-project/phpunit.xml deleted file mode 100644 index ddf7f01b..00000000 --- a/rust/fixtures/phpunit-project/phpunit.xml +++ /dev/null @@ -1 +0,0 @@ -tests diff --git a/rust/fixtures/phpunit-project/src/Store.php b/rust/fixtures/phpunit-project/src/Store.php deleted file mode 100644 index 1f61a213..00000000 --- a/rust/fixtures/phpunit-project/src/Store.php +++ /dev/null @@ -1,6 +0,0 @@ -values[] = $value; } - public function values(): array { return $this->values; } -} diff --git a/rust/fixtures/phpunit-project/tests/StoreTest.php b/rust/fixtures/phpunit-project/tests/StoreTest.php deleted file mode 100644 index 63a5e973..00000000 --- a/rust/fixtures/phpunit-project/tests/StoreTest.php +++ /dev/null @@ -1,12 +0,0 @@ -add('a'); $store->add('b'); - self::assertSame(['a', 'b'], $store->values()); - } - public function testKeepsInsertionOrderWithDuplicates(): void { - $store = new Store(); $store->add('a'); $store->add('a'); - self::assertSame(['a', 'a'], $store->values()); - } -} diff --git a/rust/lithe-ai/Cargo.toml b/rust/lithe-ai/Cargo.toml new file mode 100644 index 00000000..b8058915 --- /dev/null +++ b/rust/lithe-ai/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "lithe-ai" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +publish = false +description = "Credential-free AI request planning and response decoding for Lithe Core" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +# preserve_order matches lithe-core: planned request bodies keep their field order. +serde_json = { version = "1.0", features = ["preserve_order"] } +toml = { version = "1.1.4", default-features = false, features = ["parse", "serde"] } +url = "2.5" diff --git a/rust/lithe-core/src/ai/agent_configuration.rs b/rust/lithe-ai/src/agent_configuration.rs similarity index 96% rename from rust/lithe-core/src/ai/agent_configuration.rs rename to rust/lithe-ai/src/agent_configuration.rs index d8642986..8df0dfab 100644 --- a/rust/lithe-core/src/ai/agent_configuration.rs +++ b/rust/lithe-ai/src/agent_configuration.rs @@ -9,7 +9,7 @@ use super::configuration::{ self, codex_model, configuration_sources, ConfigurationFiles, DetectedConfiguration, INVALID_AUTHENTICATION, INVALID_CONFIG, }; -use crate::protocol::{CoreError, ErrorCode}; +use crate::{AiError, AiErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::BTreeMap; @@ -24,11 +24,11 @@ const MAXIMUM_ENVIRONMENT_VALUE_BYTES: usize = 32 * 1024; const TOO_LARGE: &str = "AI_CONFIGURATION_TOO_LARGE"; /// Fixed messages only: parser diagnostics may quote a pasted credential. -fn invalid(code: &'static str) -> CoreError { - CoreError::new(ErrorCode::InvalidRequest, code) +fn invalid(code: &'static str) -> AiError { + AiError::new(AiErrorCode::InvalidRequest, code) } -fn bounded_environment(environment: &BTreeMap) -> Result<(), CoreError> { +fn bounded_environment(environment: &BTreeMap) -> Result<(), AiError> { if environment.len() > MAXIMUM_ENVIRONMENT_ENTRIES || environment .iter() @@ -40,7 +40,7 @@ fn bounded_environment(environment: &BTreeMap) -> Result<(), Cor } /// Implements `ai.configuration.sources`: the native files of one source. -pub fn configuration_sources_command(payload: Value) -> Result { +pub fn configuration_sources_command(payload: Value) -> Result { #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct Request { @@ -68,7 +68,7 @@ pub fn configuration_sources_command(payload: Value) -> Result /// with `issue` when the configuration parses but is not a usable provider. /// Draft mode (`allowIncomplete`) parses editor text only: it ignores the /// environment and keeps missing endpoint and model fields empty. -pub fn parse_configuration_command(payload: Value) -> Result { +pub fn parse_configuration_command(payload: Value) -> Result { #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct Request { diff --git a/rust/lithe-core/src/ai/commands.rs b/rust/lithe-ai/src/commands.rs similarity index 84% rename from rust/lithe-core/src/ai/commands.rs rename to rust/lithe-ai/src/commands.rs index 9784c8ce..5b59a31f 100644 --- a/rust/lithe-core/src/ai/commands.rs +++ b/rust/lithe-ai/src/commands.rs @@ -5,7 +5,7 @@ use super::{ decode_message, decode_pull_request, plan_commit_bounded, plan_pull_request, CommitFile, CommitOptions, Provider, PullRequestComparison, PullRequestOptions, }; -use crate::protocol::{CoreError, ErrorCode}; +use crate::{AiError, AiErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; @@ -32,11 +32,11 @@ struct DecodeRequest { } // Avoid echoing untrusted payloads, including diffs or provider responses, in diagnostics. -fn invalid_payload() -> CoreError { - CoreError::new(ErrorCode::InvalidRequest, "AI_COMMIT_INVALID_OPTIONS") +fn invalid_payload() -> AiError { + AiError::new(AiErrorCode::InvalidRequest, "AI_COMMIT_INVALID_OPTIONS") } -pub(crate) fn plan_commit_command(payload: Value) -> Result { +pub(crate) fn plan_commit_command(payload: Value) -> Result { let request: PlanRequest = serde_json::from_value(payload).map_err(|_| invalid_payload())?; let lengths: Vec = request .files @@ -46,21 +46,21 @@ pub(crate) fn plan_commit_command(payload: Value) -> Result { .ok_or_else(invalid_payload)?; let files = request.files; let plan = plan_commit_bounded(&request.provider, &request.options, &files, &lengths) - .map_err(|code| CoreError::new(ErrorCode::InvalidRequest, code))?; + .map_err(|code| AiError::new(AiErrorCode::InvalidRequest, code))?; Ok(json!({"url": plan.url, "body": plan.body})) } -pub(crate) fn decode_commit_command(payload: Value) -> Result { +pub(crate) fn decode_commit_command(payload: Value) -> Result { let request: DecodeRequest = serde_json::from_value(payload).map_err(|_| invalid_payload())?; let message = decode_message(&request.protocol, &request.response, request.include_body) - .map_err(|code| CoreError::new(ErrorCode::InvalidRequest, code))?; + .map_err(|code| AiError::new(AiErrorCode::InvalidRequest, code))?; Ok(json!({"message": message})) } -pub(crate) fn commit_input_budget_command(payload: Value) -> Result { +pub(crate) fn commit_input_budget_command(payload: Value) -> Result { let request: BudgetRequest = serde_json::from_value(payload).map_err(|_| invalid_payload())?; let files = commit_input_budget(request.maximum_diff_characters, &request.file_lengths) - .map_err(|code| CoreError::new(ErrorCode::InvalidRequest, code))?; + .map_err(|code| AiError::new(AiErrorCode::InvalidRequest, code))?; Ok(json!({"files": files})) } @@ -78,19 +78,19 @@ struct PullRequestDecodeRequest { } /// `ai.pullRequest.plan`: credential-free request for a pull request title and description. -pub(crate) fn plan_pull_request_command(payload: Value) -> Result { +pub(crate) fn plan_pull_request_command(payload: Value) -> Result { let request: PullRequestPlanRequest = serde_json::from_value(payload).map_err(|_| invalid_payload())?; let plan = plan_pull_request(&request.provider, &request.options, &request.comparison) - .map_err(|code| CoreError::new(ErrorCode::InvalidRequest, code))?; + .map_err(|code| AiError::new(AiErrorCode::InvalidRequest, code))?; Ok(json!({"url": plan.url, "body": plan.body})) } /// `ai.pullRequest.decode`: strict title and description from a provider response. -pub(crate) fn decode_pull_request_command(payload: Value) -> Result { +pub(crate) fn decode_pull_request_command(payload: Value) -> Result { let request: PullRequestDecodeRequest = serde_json::from_value(payload).map_err(|_| invalid_payload())?; let description = decode_pull_request(&request.protocol, &request.response) - .map_err(|code| CoreError::new(ErrorCode::InvalidRequest, code))?; + .map_err(|code| AiError::new(AiErrorCode::InvalidRequest, code))?; serde_json::to_value(description).map_err(|_| invalid_payload()) } diff --git a/rust/lithe-core/src/ai/configuration.rs b/rust/lithe-ai/src/configuration.rs similarity index 100% rename from rust/lithe-core/src/ai/configuration.rs rename to rust/lithe-ai/src/configuration.rs diff --git a/rust/lithe-ai/src/dispatch.rs b/rust/lithe-ai/src/dispatch.rs new file mode 100644 index 00000000..2589512f --- /dev/null +++ b/rust/lithe-ai/src/dispatch.rs @@ -0,0 +1,89 @@ +//! Command routing and the JSON envelope shared with `lithe-core`. + +use crate::{agent_configuration, commands, inline_edit, AiError, AiErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; + +/// Every command name this crate owns. `lithe-core` routes exactly these names here. +pub const COMMANDS: &[&str] = &[ + "ai.commitInputBudget", + "ai.planCommit", + "ai.decodeCommitMessage", + "ai.configuration.sources", + "ai.configuration.parse", + "ai.pullRequest.plan", + "ai.pullRequest.decode", + "ai.inlineEdit.plan", + "ai.inlineEdit.decode", +]; + +/// Reports whether `command` is owned by this crate. +pub fn is_ai_command(command: &str) -> bool { + COMMANDS.contains(&command) +} + +/// Runs one AI command on an already decoded payload. +pub fn execute(command: &str, payload: Value) -> Result { + match command { + "ai.commitInputBudget" => commands::commit_input_budget_command(payload), + "ai.planCommit" => commands::plan_commit_command(payload), + "ai.decodeCommitMessage" => commands::decode_commit_command(payload), + "ai.configuration.sources" => agent_configuration::configuration_sources_command(payload), + "ai.configuration.parse" => agent_configuration::parse_configuration_command(payload), + "ai.pullRequest.plan" => commands::plan_pull_request_command(payload), + "ai.pullRequest.decode" => commands::decode_pull_request_command(payload), + "ai.inlineEdit.plan" => inline_edit::plan_command(payload), + "ai.inlineEdit.decode" => inline_edit::decode_command(payload), + _ => Err(AiError::new( + AiErrorCode::NotSupported, + "Unknown AI command", + )), + } +} + +#[derive(Deserialize)] +struct Request { + id: Option, + command: String, + #[serde(default)] + payload: Value, +} + +/// Executes one Core-style JSON request and returns the JSON response envelope. +/// +/// Unknown envelope fields (`operationId`, `origin`, ...) are ignored so callers can send the +/// same request object they send to `lithe-core`. +pub fn execute_json(request: &str) -> String { + let parsed: Request = match serde_json::from_str(request) { + Ok(parsed) => parsed, + Err(_) => { + let error = AiError::new(AiErrorCode::InvalidRequest, "Invalid JSON request"); + return failure(None, &error); + } + }; + match execute(&parsed.command, parsed.payload) { + Ok(data) => json!({"id": parsed.id, "ok": true, "data": data}).to_string(), + Err(error) => failure(parsed.id, &error), + } +} + +fn failure(id: Option, error: &AiError) -> String { + json!({"id": id, "ok": false, "error": error}).to_string() +} + +#[cfg(test)] +mod tests { + use super::{execute, COMMANDS}; + use crate::AiErrorCode; + use serde_json::json; + + #[test] + fn every_listed_command_is_routed() { + for command in COMMANDS { + let error = execute(command, json!(null)).expect_err(command); + assert_eq!(error.code, AiErrorCode::InvalidRequest, "{command}"); + } + let unknown = execute("ai.unknown", json!({})).unwrap_err(); + assert_eq!(unknown.code, AiErrorCode::NotSupported); + } +} diff --git a/rust/lithe-ai/src/error.rs b/rust/lithe-ai/src/error.rs new file mode 100644 index 00000000..7d41264d --- /dev/null +++ b/rust/lithe-ai/src/error.rs @@ -0,0 +1,37 @@ +//! Failure type whose JSON shape matches the Core error envelope. + +use serde::Serialize; + +/// Stable failure categories. Names serialize exactly like the matching `lithe-core` codes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AiErrorCode { + /// The payload does not match the command contract, or the planner refused it. The + /// message is a stable `AI_*` code that platforms map to their own wording. + InvalidRequest, + /// The command name is not owned by this crate. + NotSupported, +} + +/// An AI planning failure with a stable category and a message that is safe to display. +/// +/// Messages are fixed codes: diffs, pasted configuration and provider responses can carry +/// credentials or private source, so no failure echoes its input. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AiError { + /// Machine-readable category. + pub code: AiErrorCode, + /// Stable failure code such as `AI_COMMIT_INVALID_OPTIONS`. + pub message: String, +} + +impl AiError { + /// Creates an error from a category and a stable failure code. + pub fn new(code: AiErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } +} diff --git a/rust/lithe-core/src/ai/generation.rs b/rust/lithe-ai/src/generation.rs similarity index 93% rename from rust/lithe-core/src/ai/generation.rs rename to rust/lithe-ai/src/generation.rs index 9ee6bbed..b50885a4 100644 --- a/rust/lithe-core/src/ai/generation.rs +++ b/rust/lithe-ai/src/generation.rs @@ -133,10 +133,13 @@ pub(crate) fn plan_commit_bounded( plan_commit_with_truncation(provider, options, files, &truncated) } +/// The diff prefix one file may send, in characters, and whether that cuts the diff. #[derive(Serialize)] -pub(crate) struct InputBudget { - pub(crate) characters: usize, - pub(crate) truncated: bool, +pub struct InputBudget { + /// Characters of the file's diff that fit in its share of the total budget. + pub characters: usize, + /// Whether the prefix is shorter than the whole diff. + pub truncated: bool, } // The JSON boundary and the typed planner use the same allocation state. @@ -163,8 +166,12 @@ impl DiffBudget { } } -// Keep large editor-owned diffs on the host and pass only Core-selected prefixes. -pub(crate) fn commit_input_budget( +/// Splits `maximum` diff characters across files of the given `lengths`. +/// +/// Keeps large editor-owned diffs on the host: only the Core-selected prefixes cross the +/// boundary. Fails with `AI_COMMIT_INVALID_OPTIONS` outside 8,000–120,000 characters or above +/// 1,000 files. +pub fn commit_input_budget( maximum: usize, lengths: &[usize], ) -> Result, &'static str> { @@ -310,7 +317,9 @@ pub(crate) fn chat_token_field(field: &ChatTokenLimitField) -> ChatTokenField { } } -pub(crate) fn sensitive_path(path: &str) -> bool { +/// Reports whether `path` names a file that commonly holds credentials (`.env`, private keys, +/// `auth.json` and similar). Commit generation refuses to send such files to a provider. +pub fn sensitive_path(path: &str) -> bool { let path = path.replace('\\', "/").to_lowercase(); let name = path.rsplit('/').next().unwrap_or(""); name == ".env" diff --git a/rust/lithe-core/src/ai/inline_edit.rs b/rust/lithe-ai/src/inline_edit.rs similarity index 97% rename from rust/lithe-core/src/ai/inline_edit.rs rename to rust/lithe-ai/src/inline_edit.rs index c4d86fde..98bd0d56 100644 --- a/rust/lithe-core/src/ai/inline_edit.rs +++ b/rust/lithe-ai/src/inline_edit.rs @@ -9,7 +9,7 @@ use super::provider_request::{ encode_body, endpoint_url, response_text, strip_fence, ChatTokenField, EndpointError, PromptRequest, ResponseError, WireProtocol, }; -use crate::protocol::{CoreError, ErrorCode}; +use crate::{AiError, AiErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; @@ -106,13 +106,13 @@ struct DecodeRequest { response: Value, } -fn failure(code: &'static str) -> CoreError { +fn failure(code: &'static str) -> AiError { // Codes only: edit text and provider responses never enter diagnostics. - CoreError::new(ErrorCode::InvalidRequest, code) + AiError::new(AiErrorCode::InvalidRequest, code) } /// `ai.inlineEdit.plan`: returns the URL, body, and credential placement for one edit request. -pub(crate) fn plan_command(payload: Value) -> Result { +pub(crate) fn plan_command(payload: Value) -> Result { let request: PlanRequest = serde_json::from_value(payload).map_err(|_| failure("AI_INLINE_EDIT_INVALID_REQUEST"))?; plan(request).map_err(failure) @@ -257,7 +257,7 @@ fn tail(text: &str, characters: usize) -> &str { /// /// An empty replacement is a valid result the caller reports itself; a /// generation cut off by the output limit is rejected so partial code is never applied. -pub(crate) fn decode_command(payload: Value) -> Result { +pub(crate) fn decode_command(payload: Value) -> Result { let request: DecodeRequest = serde_json::from_value(payload).map_err(|_| failure("AI_INLINE_EDIT_INVALID_REQUEST"))?; let protocol = WireProtocol::parse(&request.wire_protocol) diff --git a/rust/lithe-ai/src/lib.rs b/rust/lithe-ai/src/lib.rs new file mode 100644 index 00000000..51382bc3 --- /dev/null +++ b/rust/lithe-ai/src/lib.rs @@ -0,0 +1,48 @@ +//! Portable AI CLI configuration parsing and HTTP request planning; hosts own I/O and secrets. +//! +//! This crate holds every `ai.*` command of Lithe Core: commit message generation, pull +//! request descriptions, inline edits and AI CLI configuration import. It plans HTTP requests +//! and decodes provider responses; it never sends a request, reads a file or holds a +//! credential. Hosts resolve credentials, perform the request and pass the response back. +//! +//! `lithe-core` links it as a normal dependency and routes exactly [`COMMANDS`] here through +//! its JSON dispatch, so hosts reach these commands over the usual Core channel. Requests and +//! responses reuse the Core envelope: `{id, command, payload}` in and `{id, ok, data | error}` +//! out, with the same stable error codes. +//! +//! Contracts: `rust/contracts/ai-commit.md`, `rust/contracts/ai-generation-requests.md` and the +//! "AI configuration" section of `rust/contracts/rust-core-api.md`. Fixtures live in +//! `rust/fixtures/ai/`. + +mod agent_configuration; +mod commands; +mod configuration; +mod dispatch; +mod error; +mod generation; +mod inline_edit; +mod provider_request; +mod pull_request; + +pub use dispatch::{execute, execute_json, is_ai_command, COMMANDS}; +pub use error::{AiError, AiErrorCode}; +use generation::plan_commit_bounded; +pub use generation::{commit_input_budget, sensitive_path as is_sensitive_commit_path}; + +pub use configuration::{ + configuration_sources, detect, is_configured, parse_claude, parse_codex, ConfigurationFile, + ConfigurationFiles, ConfigurationSourceFile, CredentialLocator, DetectedConfiguration, +}; +pub use generation::{ + decode_message, plan_commit, ChatTokenLimitField, CommitFile, CommitOptions, CommitRequestPlan, + Provider, +}; +pub use pull_request::{ + decode_pull_request, plan_pull_request, PullRequestComparison, PullRequestDescription, + PullRequestOptions, +}; + +#[cfg(test)] +mod request_tests; +#[cfg(test)] +mod tests; diff --git a/rust/lithe-core/src/ai/provider_request.rs b/rust/lithe-ai/src/provider_request.rs similarity index 100% rename from rust/lithe-core/src/ai/provider_request.rs rename to rust/lithe-ai/src/provider_request.rs diff --git a/rust/lithe-core/src/ai/pull_request.rs b/rust/lithe-ai/src/pull_request.rs similarity index 100% rename from rust/lithe-core/src/ai/pull_request.rs rename to rust/lithe-ai/src/pull_request.rs diff --git a/rust/lithe-core/src/ai/request_tests.rs b/rust/lithe-ai/src/request_tests.rs similarity index 98% rename from rust/lithe-core/src/ai/request_tests.rs rename to rust/lithe-ai/src/request_tests.rs index 13becb5d..8cfc05d6 100644 --- a/rust/lithe-core/src/ai/request_tests.rs +++ b/rust/lithe-ai/src/request_tests.rs @@ -8,13 +8,13 @@ use serde_json::{json, Value}; fn pull_request_fixture() -> Value { serde_json::from_str(include_str!( - "../../../fixtures/ai/pull-request-description-v1.json" + "../../fixtures/ai/pull-request-description-v1.json" )) .unwrap() } fn inline_edit_fixture() -> Value { - serde_json::from_str(include_str!("../../../fixtures/ai/inline-edit-v1.json")).unwrap() + serde_json::from_str(include_str!("../../fixtures/ai/inline-edit-v1.json")).unwrap() } fn typed(fixture: &Value) -> (Provider, PullRequestOptions, PullRequestComparison) { diff --git a/rust/lithe-core/src/ai/tests.rs b/rust/lithe-ai/src/tests.rs similarity index 98% rename from rust/lithe-core/src/ai/tests.rs rename to rust/lithe-ai/src/tests.rs index 4dd6061b..1a6587e9 100644 --- a/rust/lithe-core/src/ai/tests.rs +++ b/rust/lithe-ai/src/tests.rs @@ -5,10 +5,7 @@ use serde_json::{json, Value}; use std::collections::BTreeMap; fn configuration_fixture() -> Value { - serde_json::from_str(include_str!( - "../../../fixtures/ai/cli-configuration-v1.json" - )) - .unwrap() + serde_json::from_str(include_str!("../../fixtures/ai/cli-configuration-v1.json")).unwrap() } fn execute(command: &str, payload: &Value) -> Value { @@ -195,10 +192,8 @@ fn configuration_parse_errors_are_stable_codes_without_input() { } fn fixture() -> (Provider, CommitOptions, Vec, Value) { - let value: Value = serde_json::from_str(include_str!( - "../../../fixtures/ai/commit-generation-v1.json" - )) - .unwrap(); + let value: Value = + serde_json::from_str(include_str!("../../fixtures/ai/commit-generation-v1.json")).unwrap(); ( serde_json::from_value(value["provider"].clone()).unwrap(), serde_json::from_value(value["options"].clone()).unwrap(), @@ -617,7 +612,8 @@ fn bounded_commit_evidence_preserves_truncation_and_rejects_missing_prefixes() { diff: "界".repeat(8_000), original_characters: None, }; - let plan = plan_commit_bounded(&provider, &options, &[file.clone()], &[12_000]).unwrap(); + let plan = + plan_commit_bounded(&provider, &options, std::slice::from_ref(&file), &[12_000]).unwrap(); assert!(plan.body.to_string().contains("Diff truncated")); let mut typed_file = file.clone(); typed_file.original_characters = Some(12_000); diff --git a/rust/lithe-core/Cargo.toml b/rust/lithe-core/Cargo.toml index 17aeb1fa..e84cef93 100644 --- a/rust/lithe-core/Cargo.toml +++ b/rust/lithe-core/Cargo.toml @@ -14,6 +14,7 @@ crate-type = ["rlib", "staticlib", "cdylib"] plugin-sdk-e2e = [] [dependencies] +lithe-ai = { path = "../lithe-ai" } lithe-core-rules = { path = "../lithe-core-rules" } lithe-git-host = { path = "../lithe-git-host" } lithe-ide-host = { path = "../lithe-ide-host" } diff --git a/rust/lithe-core/src/ai/mod.rs b/rust/lithe-core/src/ai/mod.rs deleted file mode 100644 index 5582a631..00000000 --- a/rust/lithe-core/src/ai/mod.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Portable AI CLI configuration parsing and HTTP request planning; hosts own I/O and secrets. - -mod agent_configuration; -mod commands; -mod configuration; -mod generation; -mod inline_edit; -mod provider_request; -mod pull_request; -pub(crate) use generation::{ - commit_input_budget, plan_commit_bounded, sensitive_path as is_sensitive_commit_path, -}; - -pub use agent_configuration::{configuration_sources_command, parse_configuration_command}; -pub use configuration::{ - configuration_sources, detect, is_configured, parse_claude, parse_codex, ConfigurationFile, - ConfigurationFiles, ConfigurationSourceFile, CredentialLocator, DetectedConfiguration, -}; -pub use generation::{ - decode_message, plan_commit, ChatTokenLimitField, CommitFile, CommitOptions, CommitRequestPlan, - Provider, -}; -pub use pull_request::{ - decode_pull_request, plan_pull_request, PullRequestComparison, PullRequestDescription, - PullRequestOptions, -}; - -#[cfg(test)] -mod request_tests; -#[cfg(test)] -mod tests; - -pub(crate) use commands::{ - commit_input_budget_command, decode_commit_command, decode_pull_request_command, - plan_commit_command, plan_pull_request_command, -}; -pub(crate) use inline_edit::{ - decode_command as decode_inline_edit_command, plan_command as plan_inline_edit_command, -}; diff --git a/rust/lithe-core/src/git/commit_message_context.rs b/rust/lithe-core/src/git/commit_message_context.rs index c6a6a3f8..ec2ba12a 100644 --- a/rust/lithe-core/src/git/commit_message_context.rs +++ b/rust/lithe-core/src/git/commit_message_context.rs @@ -99,11 +99,11 @@ fn selected_repositories(mut repositories: Vec) -> Result Result<(Vec, St } } for (file, patch) in sections { - if crate::ai::is_sensitive_commit_path(&file.path) + if lithe_ai::is_sensitive_commit_path(&file.path) || file .original_path .as_deref() - .is_some_and(crate::ai::is_sensitive_commit_path) + .is_some_and(lithe_ai::is_sensitive_commit_path) { return Err(invalid("AI_COMMIT_SENSITIVE_FILE")); } @@ -357,7 +357,7 @@ pub(crate) fn commit_message_context_command(value: Value) -> Result>(); - let budgets = crate::ai::commit_input_budget( + let budgets = lithe_ai::commit_input_budget( request .maximum_diff_characters .ok_or_else(|| invalid("AI_COMMIT_INVALID_OPTIONS"))?, diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index 7a4180a8..3a87ef85 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -1,7 +1,6 @@ //! Deterministic application services shared by the macOS and Windows hosts. mod action_search; -pub mod ai; mod build; mod community; mod debug; @@ -30,6 +29,10 @@ pub use protocol::{ CoreCommand, CoreError, CoreEvent, CoreRequest, CoreResponse, ErrorCode, ResponseData, }; +/// The `ai.*` commands, kept in their own crate (`rust/lithe-ai`); re-exported so the former +/// `lithe_core::ai` paths keep resolving. +pub use lithe_ai as ai; + /// The cancellation scope of one request, for a host crate that answers some /// commands itself and still has to honour `cancel_operation` and timeouts the /// way Core's own dispatcher does. diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index d261453f..02619e61 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -1223,6 +1223,78 @@ impl CoreCommand { #[cfg(test)] mod tests { use super::CoreCommand; + use std::collections::BTreeSet; + use std::path::Path; + + /// Returns the command names that `CoreCommand::parse` matches, read from + /// this file so the test cannot drift from the parser it checks. + fn parsed_command_names(source: &str) -> Vec { + let body = source + .split_once("pub fn parse(value: &str) -> Option {") + .and_then(|(_, rest)| rest.split_once("_ => None,")) + .map(|(body, _)| body) + .expect("CoreCommand::parse body"); + body.lines() + .filter_map(|line| line.trim().strip_prefix('"')) + .filter_map(|line| line.split_once("\" =>")) + .map(|(name, _)| name.to_owned()) + .collect() + } + + /// Returns the first-column names of the `| Command | Purpose |` table. + fn documented_command_names(document: &str) -> Vec { + document + .lines() + .skip_while(|line| !line.starts_with("| Command | Purpose |")) + .skip(2) + .take_while(|line| line.starts_with("| `")) + .map(|line| { + line["| `".len()..] + .split_once('`') + .expect("backticked command name") + .0 + .to_owned() + }) + .collect() + } + + #[test] + fn api_command_table_lists_exactly_the_parsed_commands() { + let crate_root = Path::new(env!("CARGO_MANIFEST_DIR")); + let source = std::fs::read_to_string(crate_root.join("src/protocol/command.rs")) + .expect("read command.rs"); + let document = std::fs::read_to_string(crate_root.join("../contracts/rust-core-api.md")) + .expect("read rust-core-api.md"); + let parsed = parsed_command_names(&source); + let documented = documented_command_names(&document); + + assert!( + parsed.len() > 300, + "parser scan found {} names", + parsed.len() + ); + for name in &parsed { + assert!( + CoreCommand::parse(name).is_some(), + "scanned {name} does not parse" + ); + } + let unique: BTreeSet<_> = documented.iter().collect(); + assert_eq!( + unique.len(), + documented.len(), + "duplicate rows in the command table" + ); + + let parsed: BTreeSet<_> = parsed.iter().collect(); + let undocumented: Vec<_> = parsed.difference(&unique).collect(); + let unknown: Vec<_> = unique.difference(&parsed).collect(); + assert!( + undocumented.is_empty() && unknown.is_empty(), + "rust-core-api.md command table is out of date: missing {undocumented:?}, \ + not parsed {unknown:?}" + ); + } #[test] fn parses_semantic_lsp_runtime_commands() { diff --git a/rust/lithe-core/src/protocol/error.rs b/rust/lithe-core/src/protocol/error.rs index 0b1953cf..40e29e6a 100644 --- a/rust/lithe-core/src/protocol/error.rs +++ b/rust/lithe-core/src/protocol/error.rs @@ -120,6 +120,17 @@ pub fn invalid_relative_path(value: &str) -> bool { || normalized.split('/').any(|component| component == "..") } +impl From for CoreError { + /// AI errors already use Core's category names, so the mapping is one to one. + fn from(error: lithe_ai::AiError) -> Self { + let code = match error.code { + lithe_ai::AiErrorCode::InvalidRequest => ErrorCode::InvalidRequest, + lithe_ai::AiErrorCode::NotSupported => ErrorCode::NotSupported, + }; + Self::new(code, error.message) + } +} + impl From for CoreError { /// Rule errors already use Core's category names, so the mapping is one to one. fn from(error: lithe_core_rules::RuleError) -> Self { diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 25b0e142..d96c4323 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -67,6 +67,14 @@ fn plugin_response( } } +/// Runs one `ai.*` command in `lithe-ai`. +fn ai_response(id: Option, command: &str, payload: serde_json::Value) -> CoreResponse { + match lithe_ai::execute(command, payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error.into()), + } +} + fn agent_response( id: Option, payload: serde_json::Value, @@ -197,33 +205,23 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon "coreVersion": env!("CARGO_PKG_VERSION") }), ), - CoreCommand::AiCommitInputBudget => { - plugin_response(id, parsed.payload, crate::ai::commit_input_budget_command) - } - CoreCommand::AiPlanCommit => { - plugin_response(id, parsed.payload, crate::ai::plan_commit_command) - } + CoreCommand::AiCommitInputBudget => ai_response(id, "ai.commitInputBudget", parsed.payload), + CoreCommand::AiPlanCommit => ai_response(id, "ai.planCommit", parsed.payload), CoreCommand::AiDecodeCommitMessage => { - plugin_response(id, parsed.payload, crate::ai::decode_commit_command) + ai_response(id, "ai.decodeCommitMessage", parsed.payload) } CoreCommand::AiConfigurationSources => { - plugin_response(id, parsed.payload, crate::ai::configuration_sources_command) + ai_response(id, "ai.configuration.sources", parsed.payload) } CoreCommand::AiConfigurationParse => { - plugin_response(id, parsed.payload, crate::ai::parse_configuration_command) - } - CoreCommand::AiPullRequestPlan => { - plugin_response(id, parsed.payload, crate::ai::plan_pull_request_command) + ai_response(id, "ai.configuration.parse", parsed.payload) } + CoreCommand::AiPullRequestPlan => ai_response(id, "ai.pullRequest.plan", parsed.payload), CoreCommand::AiPullRequestDecode => { - plugin_response(id, parsed.payload, crate::ai::decode_pull_request_command) - } - CoreCommand::AiInlineEditPlan => { - plugin_response(id, parsed.payload, crate::ai::plan_inline_edit_command) - } - CoreCommand::AiInlineEditDecode => { - plugin_response(id, parsed.payload, crate::ai::decode_inline_edit_command) + ai_response(id, "ai.pullRequest.decode", parsed.payload) } + CoreCommand::AiInlineEditPlan => ai_response(id, "ai.inlineEdit.plan", parsed.payload), + CoreCommand::AiInlineEditDecode => ai_response(id, "ai.inlineEdit.decode", parsed.payload), CoreCommand::PluginsNativeUIValidateData => plugin_response( id, parsed.payload, diff --git a/rust/lithe-core/src/tests/protocol.rs b/rust/lithe-core/src/tests/protocol.rs index 9f979ef2..16bf67f3 100644 --- a/rust/lithe-core/src/tests/protocol.rs +++ b/rust/lithe-core/src/tests/protocol.rs @@ -485,6 +485,37 @@ fn every_rules_crate_command_is_routed_by_core() { } } +#[test] +fn every_ai_crate_command_is_routed_by_core() { + for command in lithe_ai::COMMANDS { + assert!( + crate::protocol::CoreCommand::parse(command).is_some(), + "{command} is owned by lithe-ai but not registered in CoreCommand" + ); + // A null payload reaches the command's own validation, so the failure comes from + // lithe-ai with its category intact instead of Core's unsupported-command error. + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({"id": "ai", "command": command, "payload": null}).to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], false, "{command}: {response}"); + assert_eq!( + response["error"]["code"], "invalid_request", + "{command}: {response}" + ); + } + let budgets: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "ai", "command": "ai.commitInputBudget", + "payload": {"maximumDiffCharacters": 8000, "fileLengths": [20]} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(budgets["ok"], true, "{budgets}"); + assert_eq!(budgets["data"]["files"][0]["characters"], 20); +} + #[test] fn terminal_link_fixtures_cross_the_json_boundary() { let fixture: Value = serde_json::from_str(include_str!( diff --git a/scripts/rust-core/README.md b/scripts/rust-core/README.md index 9b421443..66f68c3f 100644 --- a/scripts/rust-core/README.md +++ b/scripts/rust-core/README.md @@ -2,7 +2,7 @@ Run `scripts/rust-core/verify-rust-core-comments.sh` from any directory. It checks module documentation, English comments, unsafe API safety sections, exported -Rustdoc, and intra-doc links in `lithe-core` and `lithe-core-rules`. +Rustdoc, and intra-doc links in `lithe-core`, `lithe-core-rules` and `lithe-ai`. The small `comment-lexer` helper uses pinned `rustc_lexer` tokens to distinguish comments from ordinary, raw, and byte string literals. It does not parse source diff --git a/scripts/rust-core/verify-rust-core-comments.sh b/scripts/rust-core/verify-rust-core-comments.sh index f54cce7a..9ab5f96d 100755 --- a/scripts/rust-core/verify-rust-core-comments.sh +++ b/scripts/rust-core/verify-rust-core-comments.sh @@ -6,8 +6,9 @@ ROOT_DIR="$(cd -- "$SCRIPT_DIR/../.." && pwd)" # Keep this helper, Rustdoc and subsequent checks in one native build-state # directory. A desktop worktree supplies its own existing Cargo target. export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT_DIR/.artifacts/rust-core-checks}" -# lithe-core-rules is part of Core and holds reusable editor/UI rules. -SOURCE_DIRS=("$ROOT_DIR/rust/lithe-core/src" "$ROOT_DIR/rust/lithe-core-rules/src") +# lithe-core-rules is part of Core and holds reusable editor/UI rules; lithe-ai holds the +# ai.* commands. +SOURCE_DIRS=("$ROOT_DIR/rust/lithe-core/src" "$ROOT_DIR/rust/lithe-core-rules/src" "$ROOT_DIR/rust/lithe-ai/src") cd "$ROOT_DIR" rust_files=() @@ -105,7 +106,7 @@ fi # Let Rust understand visibility instead of treating pub items in private # modules as exported APIs. This covers public functions, types, variants, and # fields without requiring documentation for every internal helper. -for manifest in rust/lithe-core/Cargo.toml rust/lithe-core-rules/Cargo.toml; do +for manifest in rust/lithe-core/Cargo.toml rust/lithe-core-rules/Cargo.toml rust/lithe-ai/Cargo.toml; do cargo rustdoc --quiet \ --manifest-path "$manifest" \ --lib -- \