diff --git a/AGENTS.md b/AGENTS.md index 8c027466..e4514c3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ OMMS (npm `om-memory-system`) is a memory plugin for AI coding agents. One share | `src/services/` | Storage (Turso/libSQL), embeddings, privacy, deduplication, project tags, profiles, web backend | | `src/services/ai/live-model-choice.ts` | The live-model rule for both hosts. Pure functions; callers pass `CONFIG`. | | `src/importer/` | History import: shared option parser (`import-args.ts`), runner (`run-import.ts`), ledger, readers | -| `src/adapters/opencode/`, `src/index.ts` | OpenCode V1 plugin hooks and the OpenCode import command | +| `src/adapters/opencode/`, `src/index.ts` | OpenCode V1 plugin hooks, OpenCode model code, profile learning, and the OpenCode import command | | `src/v2/` | OpenCode V2 plugin adapter over the V1 plugin | | `src/adapters/pi/` | Pi extension, Pi model bridge, Pi import command | | `src/cli/` | `om-memory-system` terminal command for both hosts | @@ -28,7 +28,7 @@ OMMS (npm `om-memory-system`) is a memory plugin for AI coding agents. One share Keep these boundaries: -- `src/core/` and `src/services/` must not import `@opencode-ai/*`, `@earendil-works/*`, or `src/adapters/*`. +- `src/core/` and `src/services/` must not import `@opencode-ai/*`, `@earendil-works/*`, or `src/adapters/*`. Hosts register their models through `registerHostProfileModel` and `registerOpencodeHostModels`. - `src/importer/` must not import `src/adapters/*`. Each host's history reader lives in `src/importer/`, and adapters import from it. - `tests/host-neutral-capture-boundary.test.ts` and `tests/pi-adapter-boundary.test.ts` enforce that rule. - An adapter must not import the other host's adapter modules. diff --git a/docs/adr/006-one-live-model-rule-for-both-hosts.md b/docs/adr/006-one-live-model-rule-for-both-hosts.md index 540749ce..e3864c66 100644 --- a/docs/adr/006-one-live-model-rule-for-both-hosts.md +++ b/docs/adr/006-one-live-model-rule-for-both-hosts.md @@ -56,7 +56,7 @@ OpenCode call sites use `resolveOpencodeHostModel(CONFIG)`: capture, profile lea - `src/services/ai/live-model-choice.ts`, `src/config.ts` - `src/adapters/pi/live-model.ts`, `src/adapters/pi/extension.ts`, `src/adapters/pi/profile.ts` -- `src/adapters/opencode/auto-capture-summary.ts`, `src/services/user-memory-learning.ts` +- `src/adapters/opencode/auto-capture-summary.ts`, `src/adapters/opencode/profile-learning.ts` - `src/services/user-profile/user-profile-manager.ts`, `src/services/user-profile/ai-cleanup.ts` - `tests/live-model-choice.test.ts` - README "Auto-Capture AI Provider", `docs/pi-adapter.md` diff --git a/docs/adr/011-shared-code-never-imports-adapters.md b/docs/adr/011-shared-code-never-imports-adapters.md index ba88c79f..dfefad1b 100644 --- a/docs/adr/011-shared-code-never-imports-adapters.md +++ b/docs/adr/011-shared-code-never-imports-adapters.md @@ -39,7 +39,9 @@ Adding a host means adding `src/adapters//` and, when it has importable hi ### Neutral -- `src/services/` still holds OpenCode's own model code (`opencode-provider.ts`, `opencode-sdk-client.ts`, `profile-llm-client.ts`, `user-memory-learning.ts`), which uses OpenCode SDK types. Moving it into the OpenCode adapter is planned as the OpenSpec change `move-opencode-model-code-to-adapter`. +- The OpenSpec change `move-opencode-model-code-to-adapter` moved OpenCode's own model code from `src/services/` into `src/adapters/opencode/`. Shared code now reaches OpenCode models through registrations: `registerHostProfileModel` for profile calls and `registerOpencodeHostModels` for web imports, Health, and Settings. The standalone web app registers neither, so it reports OpenCode models as unavailable, as before. +- Follow-up: OpenCode and Pi still run separate profile learning loops (`src/adapters/opencode/profile-learning.ts` and the Pi adapter). One shared loop is future work. +- Follow-up: the OpenCode V2 plugin (`src/v2/`) still wraps the V1 plugin (`src/index.ts`) through a fake V1 client (`legacy-client.ts`). Rebuild it on the native V2 API and drop V1 support in a separate breaking change (4.0.0). ## Alternatives considered diff --git a/docs/opencode-adapter.md b/docs/opencode-adapter.md index 3e857de1..5ce950fa 100644 --- a/docs/opencode-adapter.md +++ b/docs/opencode-adapter.md @@ -89,17 +89,17 @@ History backfill (automatic history import) uses `opencodeBackfillModel`. See ## Lifecycle mapping -| OpenCode hook | omms behaviour | -| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Plugin start | Load shared config for the project directory, remove old capture traces, update the web login item when `webServerAutoStart` is set, warm storage and embeddings, read connected providers, register the OpenCode backfill model resolver, start automatic backfill when `autoBackfill` is on, start the web UI | -| `config` | Register the `omms-structured` agent and the `/memory-import-opencode-history` command | -| `chat.message` (V1) | Record the user prompt for capture and profile learning; inject recent project memories (`chatMessage.injectOn`: first or every prompt) | -| `prompt` + `context` (V2) | Record the prompt once OpenCode admits it; semantic retrieval injected as a delimited `` system section | -| `chat.params` | Record the prompt's model when capture follows the session model | -| `session.idle` | After 10 seconds of quiet, capture every uncaptured prompt in the session; the web-UI owner also runs profile learning and cleanup | -| `session.compacted` (V1) / `compaction` (V2) | Restore the session's own memories after compaction | -| `command.execute.before` (V1) / command (V2) | Run `/memory-import-opencode-history`; see [opencode-history-import.md](opencode-history-import.md) | -| `memory` tool | Shared add/search/profile/list/forget/help plus migrate/list-shards/export/import | +| OpenCode hook | omms behaviour | +| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Plugin start | Load shared config for the project directory, remove old capture traces, update the web login item when `webServerAutoStart` is set, warm storage and embeddings, read connected providers, register the OpenCode backfill model resolver, the profile model, and the import models, start automatic backfill when `autoBackfill` is on, start the web UI | +| `config` | Register the `omms-structured` agent and the `/memory-import-opencode-history` command | +| `chat.message` (V1) | Record the user prompt for capture and profile learning; inject recent project memories (`chatMessage.injectOn`: first or every prompt) | +| `prompt` + `context` (V2) | Record the prompt once OpenCode admits it; semantic retrieval injected as a delimited `` system section | +| `chat.params` | Record the prompt's model when capture follows the session model | +| `session.idle` | After 10 seconds of quiet, capture every uncaptured prompt in the session; the web-UI owner also runs profile learning and cleanup | +| `session.compacted` (V1) / `compaction` (V2) | Restore the session's own memories after compaction | +| `command.execute.before` (V1) / command (V2) | Run `/memory-import-opencode-history`; see [opencode-history-import.md](opencode-history-import.md) | +| `memory` tool | Shared add/search/profile/list/forget/help plus migrate/list-shards/export/import | ### Toasts diff --git a/docs/shared-core.md b/docs/shared-core.md index 4bb0d5d4..03f378ee 100644 --- a/docs/shared-core.md +++ b/docs/shared-core.md @@ -28,9 +28,9 @@ Rules: - `src/core/*`, `src/services/*`, and `src/types/*` must not import `src/importer/*`. The one exception is `src/services/web-server.ts`. It reaches the importer only through dynamic imports of - `src/importer/web-import-api.ts`, `src/importer/settings-health.ts`, and - `src/importer/web-import-jobs.ts`. `tests/pi-adapter-boundary.test.ts` - enforces this. + `src/importer/web-import-api.ts`, `src/importer/settings-health.ts`, + `src/importer/web-import-jobs.ts`, and `src/importer/settings-models.ts`. + `tests/pi-adapter-boundary.test.ts` enforces this. - The OpenCode entry points (`src/index.ts`, `src/v2/adapter.ts`, `src/v2/plugin.ts`) must not import `importer/` or `@earendil-works` directly. They load importer code through `src/adapters/opencode/*`. @@ -50,6 +50,13 @@ Rules: depend on shared code; shared code never depends on an adapter. - `session-loader.ts` loads the Pi SDK. The importer loads it with dynamic `import()`, only when it reads Pi history. +- Only three importer files name a host SDK: `session-loader.ts`, + `import-readiness.ts`, and `settings-models.ts`. Only `session-loader.ts` + imports it statically. The boundary test checks every file. +- OpenCode's model access for web imports, Health, and Settings reaches the + importer through `registerOpencodeHostModels` in `backfill-controls.ts`. + The OpenCode adapter registers it at plugin start. With nothing registered, + as in the standalone web app, OpenCode models report as unavailable. - `src/core/internal-prompt.ts` recognises omms's own summary and profile prompts, which are the same text on both hosts. @@ -58,7 +65,7 @@ Rules: The shared capture pipeline depends on two interfaces only: - `CaptureSummaryProvider.summarize(request)` does structured extraction. - - The OpenCode provider path (`src/services/ai/*`) and the Pi model bridge (`src/adapters/pi/provider.ts`) both implement it. + - The OpenCode provider path (`src/adapters/opencode/opencode-provider.ts`) and the Pi model bridge (`src/adapters/pi/provider.ts`) both implement it. - Throw to defer or skip the work unit. Do not return partial data. Manual memory operations stay available. - `AutoCaptureHost` extends `CaptureSummaryProvider`. It adds session conversation access (`getConversation`), readiness (`isCaptureReady`), and @@ -66,6 +73,14 @@ The shared capture pipeline depends on two interfaces only: - The OpenCode adapter implements it. - The Pi adapter calls `captureConversation` directly from `agent_settled`. +`ModelPort` in `src/core/profile-analysis.ts` is the profile model port. +It has `complete` for plain text and an optional `completeStructured` for +host-enforced JSON. Profile dedup, conflict, description, and cleanup calls in +`src/services/user-profile/` use the model a host registers with +`registerHostProfileModel` (`profile-model.ts`). With none, they use the +external API. OpenCode registers `adaptOpencodeProfileModel`. Pi registers +nothing, so its behaviour does not change. + `CaptureWorkUnit` in `src/core/capture.ts` is the only shape the shared pipeline accepts. It holds visible conversation content and provenance. Hidden reasoning and host SDK objects never go into it. @@ -77,7 +92,7 @@ Hidden reasoning and host SDK objects never go into it. | `src/core` | Ports, capture pipeline, context budgeting, shared extraction schema and parsing, retrieval, `memory` tool operations | | `src/services` | Storage (Turso/libSQL), embeddings, vector search, privacy, deduplication, project identity, profiles, portability, cleanup, web backend, live-model rule (`ai/live-model-choice.ts`) | | `src/importer` | History import and automatic backfill, import ledger, import runs and progress, backfill controls, path maps, the web import API | -| `src/adapters/opencode` + `src/index.ts` + `src/v2` | OpenCode lifecycle, session reading, provider bridge, OpenCode backfill model resolver, V2 compatibility | +| `src/adapters/opencode` + `src/index.ts` + `src/v2` | OpenCode lifecycle, session reading, provider bridge, OpenCode model code and profile learning, OpenCode backfill model resolver, V2 compatibility | | `src/adapters/pi` | Pi lifecycle (`session_start`, `before_agent_start`, `agent_settled`, `session_shutdown`), retrieval injection, model bridge, Pi backfill model resolver, `memory` tool registration | ### Shared modules added for import and settings diff --git a/docs/tdr/004-exclude-omms-internal-sessions-from-import.md b/docs/tdr/004-exclude-omms-internal-sessions-from-import.md index 826d87ec..d0888941 100644 --- a/docs/tdr/004-exclude-omms-internal-sessions-from-import.md +++ b/docs/tdr/004-exclude-omms-internal-sessions-from-import.md @@ -22,7 +22,7 @@ Their prompts are OMMS's own summarisation requests, so importing them would sto ## Decision -`INTERNAL_CAPTURE_SESSION_TITLES` in `src/services/ai/internal-capture-sessions.ts` lists every title OMMS has used. When the `session` table has a `title` column, the reader adds `AND (s.title IS NULL OR s.title NOT IN (...))` to the session query and to both session counts. Any new internal title must be added to that list. +`INTERNAL_CAPTURE_SESSION_TITLES` in `src/importer/opencode-internal-sessions.ts` lists every title OMMS has used. When the `session` table has a `title` column, the reader adds `AND (s.title IS NULL OR s.title NOT IN (...))` to the session query and to both session counts. Any new internal title must be added to that list. ## Consequences @@ -49,7 +49,7 @@ Their prompts are OMMS's own summarisation requests, so importing them would sto ## How to Recognise / Handle This Again 1. Symptom: imported memories or profile prompts describe summarising conversations or `save_memory` calls. -2. Check with the query above, or look for a new title in `internal-capture-sessions.ts`. +2. Check with the query above, or look for a new title in `opencode-internal-sessions.ts`. 3. Add the title to `INTERNAL_CAPTURE_SESSION_TITLES` and rerun; the "never imports omms's own internal capture sessions" test covers the filter. ## Revisit Triggers @@ -59,6 +59,6 @@ OMMS changes its internal session title, or OpenCode stores a reliable internal ## References - `src/importer/opencode-reader.ts` -- `src/services/ai/internal-capture-sessions.ts` +- `src/importer/opencode-internal-sessions.ts` - `tests/history-import-commands.test.ts` - ADR-005 diff --git a/openspec/changes/move-opencode-model-code-to-adapter/.openspec.yaml b/openspec/changes/archive/2026-09-28-move-opencode-model-code-to-adapter/.openspec.yaml similarity index 100% rename from openspec/changes/move-opencode-model-code-to-adapter/.openspec.yaml rename to openspec/changes/archive/2026-09-28-move-opencode-model-code-to-adapter/.openspec.yaml diff --git a/openspec/changes/move-opencode-model-code-to-adapter/design.md b/openspec/changes/archive/2026-09-28-move-opencode-model-code-to-adapter/design.md similarity index 75% rename from openspec/changes/move-opencode-model-code-to-adapter/design.md rename to openspec/changes/archive/2026-09-28-move-opencode-model-code-to-adapter/design.md index a3512141..614c7ebd 100644 --- a/openspec/changes/move-opencode-model-code-to-adapter/design.md +++ b/openspec/changes/archive/2026-09-28-move-opencode-model-code-to-adapter/design.md @@ -46,25 +46,29 @@ What calls the OpenCode-specific modules in `src/services/ai/` today: `INTERNAL_CAPTURE_SESSION_TITLES` and `isInternalCaptureSessionTitle` describe OpenCode's history format: the titles omms gives its own capture sessions. The OpenCode history reader needs them without OpenCode running. They move to `src/importer/opencode-internal-sessions.ts`. The in-process tracking of live internal sessions (`isTrackedInternalCaptureSession`) moves with the adapter. -### D3. Shared profile code takes a ModelPort +### D3. Shared profile code uses a registered ModelPort -`user-profile-manager.ts` and `ai-cleanup.ts` gain an optional `ModelPort` argument on the functions that call a model today. When the host passes one, they call `model.complete(system, prompt)` with the same prompt and schema as now. When it does not, they use the external API as today. The OpenCode adapter builds the `ModelPort` from its host model with a new `adaptOpencodeProfileModel`, in the same way as `adaptPiProfileModel`. The web server's profile cleanup endpoint passes the port registered by the host serving it (D4), or none in the standalone web app. +A host registers a `ModelPort` factory with `registerHostProfileModel` in `src/services/user-profile/profile-model.ts`. `user-profile-manager.ts` and `ai-cleanup.ts` call `resolveHostProfileModel()`. When it returns a port, they call it with the same prompt and schema as now. When it returns null, they use the external API as today. The OpenCode adapter registers `adaptOpencodeProfileModel` at plugin start. Pi registers nothing, so Pi behaviour does not change. The standalone web app registers nothing, so its cleanup uses the external API, as today. + +`ModelPort` gains an optional `completeStructured(system, user, schema)`. OpenCode maps it to its structured output, as before. A port without it falls back to `complete` and parses the reply with the schema. + +Why a registration and not an argument: an argument would have to pass through `mergeProfileData`, `mergeItems`, and `evolveAndUpdate`, and through callers in `src/core/memory-operations.ts` and the web server. The registration keeps the same routing in every process with less change. It lives in services, because `src/services/` cannot import `src/importer/`. Alternative: move both files into the adapter. Rejected, because Pi and the web server use them too. ### D4. The importer receives OpenCode's models by registration -`src/importer/backfill-controls.ts` generalises its registry. A host registers one object with its backfill model resolver, its import model factory, and its profile `ModelPort` factory. `web-import-jobs.ts` and `settings-health.ts` use the registered import model factory instead of importing `opencode-import-models.ts`. With nothing registered, as in the standalone web app, they report that OpenCode models are unavailable, exactly as they do now when no OpenCode client exists. +`src/importer/backfill-controls.ts` keeps `registerHostBackfillModels` and adds `registerOpencodeHostModels`. The OpenCode adapter registers one object with `isProviderConnected`, `createImportModels`, and `listSettingsModels`. `web-import-jobs.ts`, `settings-health.ts`, and `settings-models.ts` use it instead of importing adapter code. With nothing registered, as in the standalone web app, they report that OpenCode models are unavailable, with the same messages as now. Pi registers no import models, because web imports use only OpenCode or external models. The profile port is not in this registry (see D3). Alternative: let the importer import the adapter dynamically. Rejected by ADR-011. ### D5. Model listing for the Settings page moves to the importer -`settings-models.ts` lists each host's signed-in models for the page. It reads host data without the host running, which is the importer's role (ADR-011). It moves to `src/importer/settings-models.ts`. It loads the Pi SDK and the OpenCode client dynamically, as today. +`settings-models.ts` lists each host's signed-in models for the page. It reads host data without the host running, which is the importer's role (ADR-011). It moves to `src/importer/settings-models.ts`. It loads the Pi SDK dynamically, as today. It gets the OpenCode model list from the D4 registration. ### D6. One boundary test covers every shared file -`tests/pi-adapter-boundary.test.ts` gains a check that walks every `.ts` file under `src/core`, `src/services`, and `src/types`. It fails on any `@opencode-ai/` or `@earendil-works/` specifier in an `import`, `import type`, `export … from`, or `import()` call. For `src/importer`, it allows host SDKs only as dynamic `import()` in a named list of reader modules. The failure message names each file. +`tests/pi-adapter-boundary.test.ts` gains a check that walks every `.ts` file under `src/core`, `src/services`, and `src/types`. It fails on any `@opencode-ai/` or `@earendil-works/` specifier in an `import`, `import type`, `export … from`, or `import()` call. For `src/importer`, it allows host SDKs only in a named list of reader modules (`session-loader.ts`, `import-readiness.ts`, `settings-models.ts`). Only `session-loader.ts` may import its SDK statically, because `run-import.ts` loads it through `import()`. The test also fails if any file imports `session-loader.ts` statically. The failure message names each file. ## Risks / Trade-offs diff --git a/openspec/changes/move-opencode-model-code-to-adapter/proposal.md b/openspec/changes/archive/2026-09-28-move-opencode-model-code-to-adapter/proposal.md similarity index 96% rename from openspec/changes/move-opencode-model-code-to-adapter/proposal.md rename to openspec/changes/archive/2026-09-28-move-opencode-model-code-to-adapter/proposal.md index 13ca92a9..f4e531fe 100644 --- a/openspec/changes/move-opencode-model-code-to-adapter/proposal.md +++ b/openspec/changes/archive/2026-09-28-move-opencode-model-code-to-adapter/proposal.md @@ -10,7 +10,7 @@ OMMS's shared layers still hold OpenCode's own model code. `src/services/ai/` co - `opencode-provider.ts`, `opencode-sdk-client.ts`, `opencode-provider-loader.ts`, `opencode-host-config.ts`, `opencode-import-models.ts`, `opencode-diagnostics.ts`, and `profile-llm-client.ts` - `internal-capture-sessions.ts`, except the list of internal session titles. The OpenCode history reader needs that list, so it moves to `src/importer/`. - Move OpenCode's profile learning loop, `src/services/user-memory-learning.ts`, which takes OpenCode's `PluginInput`, into `src/adapters/opencode/`. -- Change `user-profile-manager.ts` and `ai-cleanup.ts` in `src/services/user-profile/` to call a model through the existing `ModelPort` (`src/core/profile-analysis.ts`), which the host passes in. They no longer resolve or call OpenCode models themselves. +- Change `user-profile-manager.ts` and `ai-cleanup.ts` in `src/services/user-profile/` to call a model through the existing `ModelPort` (`src/core/profile-analysis.ts`), which the host registers. They no longer resolve or call OpenCode models themselves. - Let the OpenCode adapter register its import models with the importer, the same way it registers its backfill models today (`registerHostBackfillModels`). `web-import-jobs.ts` and `settings-health.ts` then use the registered models instead of importing OpenCode code. - Move host model listing for the Settings page (`src/services/settings-models.ts`) into `src/importer/`. The importer already holds the code that reads host data without the host running. - Extend the boundary tests: diff --git a/openspec/changes/move-opencode-model-code-to-adapter/specs/host-neutral-memory-core/spec.md b/openspec/changes/archive/2026-09-28-move-opencode-model-code-to-adapter/specs/host-neutral-memory-core/spec.md similarity index 96% rename from openspec/changes/move-opencode-model-code-to-adapter/specs/host-neutral-memory-core/spec.md rename to openspec/changes/archive/2026-09-28-move-opencode-model-code-to-adapter/specs/host-neutral-memory-core/spec.md index a4130d18..67761102 100644 --- a/openspec/changes/move-opencode-model-code-to-adapter/specs/host-neutral-memory-core/spec.md +++ b/openspec/changes/archive/2026-09-28-move-opencode-model-code-to-adapter/specs/host-neutral-memory-core/spec.md @@ -18,5 +18,5 @@ The shared capture/profile pipeline SHALL depend on a provider-neutral structure #### Scenario: Profile learning on OpenCode - **WHEN** OpenCode runs profile learning or AI profile cleanup with an OpenCode host model -- **THEN** the shared profile code SHALL call the model through a `ModelPort` that the OpenCode adapter passes in +- **THEN** the shared profile code SHALL call the model through a `ModelPort` that the OpenCode adapter registers - **AND** the result SHALL be the same as before the move diff --git a/openspec/changes/archive/2026-09-28-move-opencode-model-code-to-adapter/tasks.md b/openspec/changes/archive/2026-09-28-move-opencode-model-code-to-adapter/tasks.md new file mode 100644 index 00000000..59b3fd63 --- /dev/null +++ b/openspec/changes/archive/2026-09-28-move-opencode-model-code-to-adapter/tasks.md @@ -0,0 +1,25 @@ +## 1. Boundary test first + +- [x] 1.1 Add the check from D6 to `tests/pi-adapter-boundary.test.ts`: every file in `src/core`, `src/services`, and `src/types` has no `@opencode-ai/` or `@earendil-works/` specifier in any import form, and `src/importer` loads them only by dynamic `import()` in named reader modules; confirm it fails today and lists the current offending files + +## 2. Move the OpenCode modules + +- [x] 2.1 `git mv` the seven `src/services/ai/opencode-*.ts` and `profile-llm-client.ts` modules into `src/adapters/opencode/`, and update every import and `mock.module` path; verify with `bun run typecheck` and the OpenCode capture, V2 plugin, and bundle-boundary tests +- [x] 2.2 Split `internal-capture-sessions.ts`: titles to `src/importer/opencode-internal-sessions.ts` (D2), live tracking to the adapter; verify with the OpenCode reader tests and the internal-session capture tests +- [x] 2.3 `git mv` `src/services/user-memory-learning.ts` to `src/adapters/opencode/profile-learning.ts` and update `src/index.ts` and its tests; verify with the existing profile learning tests + +## 3. Ports for shared code + +- [x] 3.1 Add `adaptOpencodeProfileModel` in `src/adapters/opencode/`, matching `adaptPiProfileModel`; verify with a unit test that it calls OpenCode's structured output with the same prompt +- [x] 3.2 Make `user-profile-manager.ts` and `ai-cleanup.ts` use the registered `ModelPort` (D3) and remove their OpenCode imports; verify with tests that the port is called when passed and the external API is used when not, with the same prompt and output as before +- [x] 3.3 Add `registerOpencodeHostModels` to `backfill-controls.ts` (D4) with OpenCode's connection check, import model factory, and model list; register it from the OpenCode adapter; verify with registry tests +- [x] 3.4 Switch `web-import-jobs.ts` and `settings-health.ts` to the registered import models; verify the web import and Health tests, including the standalone web app reporting OpenCode models as unavailable +- [x] 3.5 Move `src/services/settings-models.ts` to `src/importer/settings-models.ts` (D5); verify with the Settings models tests + +## 4. Close out + +- [x] 4.1 Confirm the boundary test from 1.1 now passes with no allowances beyond the named importer readers +- [x] 4.2 Update `docs/shared-core.md`, `docs/opencode-adapter.md`, and CLAUDE.md's architecture table; record the remaining follow-up (one profile learning loop for both hosts) in the change's design and the ADR-011 consequences +- [x] 4.3 Run `bun run ci:local` and `bun run check:package` +- [x] 4.4 Manual check in OpenCode V2 (V1 not checked; V1 removal is a follow-up in ADR-011): one live capture, one profile learning run, and one AI profile cleanup succeed, and the capture attempt log shows the host model + - Result (2026-09-28): live capture ran on the host model `zai-coding-plan/glm-5.3` (one saved, two skipped) and the attempt log showed the host model. The live profile learning and AI cleanup checks were skipped: learning runs only in the process that owns the web server, and a standalone web UI held port 4747. `tests/profile-model-port.test.ts` and `tests/opencode-profile-model.test.ts` cover that routing. diff --git a/openspec/changes/move-opencode-model-code-to-adapter/tasks.md b/openspec/changes/move-opencode-model-code-to-adapter/tasks.md deleted file mode 100644 index acb1dc29..00000000 --- a/openspec/changes/move-opencode-model-code-to-adapter/tasks.md +++ /dev/null @@ -1,24 +0,0 @@ -## 1. Boundary test first - -- [ ] 1.1 Add the check from D6 to `tests/pi-adapter-boundary.test.ts`: every file in `src/core`, `src/services`, and `src/types` has no `@opencode-ai/` or `@earendil-works/` specifier in any import form, and `src/importer` loads them only by dynamic `import()` in named reader modules; confirm it fails today and lists the current offending files - -## 2. Move the OpenCode modules - -- [ ] 2.1 `git mv` the seven `src/services/ai/opencode-*.ts` and `profile-llm-client.ts` modules into `src/adapters/opencode/`, and update every import and `mock.module` path; verify with `bun run typecheck` and the OpenCode capture, V2 plugin, and bundle-boundary tests -- [ ] 2.2 Split `internal-capture-sessions.ts`: titles to `src/importer/opencode-internal-sessions.ts` (D2), live tracking to the adapter; verify with the OpenCode reader tests and the internal-session capture tests -- [ ] 2.3 `git mv` `src/services/user-memory-learning.ts` to `src/adapters/opencode/profile-learning.ts` and update `src/index.ts` and its tests; verify with the existing profile learning tests - -## 3. Ports for shared code - -- [ ] 3.1 Add `adaptOpencodeProfileModel` in `src/adapters/opencode/`, matching `adaptPiProfileModel`; verify with a unit test that it calls OpenCode's structured output with the same prompt -- [ ] 3.2 Give `user-profile-manager.ts` and `ai-cleanup.ts` an optional `ModelPort` (D3) and remove their OpenCode imports; verify with tests that the port is called when passed and the external API is used when not, with the same prompt and output as before -- [ ] 3.3 Generalise the host registry in `backfill-controls.ts` (D4) to carry the backfill resolver, the import model factory, and the profile `ModelPort` factory; register them from the OpenCode adapter and the Pi extension; verify with registry tests on both hosts -- [ ] 3.4 Switch `web-import-jobs.ts` and `settings-health.ts` to the registered import models; verify the web import and Health tests, including the standalone web app reporting OpenCode models as unavailable -- [ ] 3.5 Move `src/services/settings-models.ts` to `src/importer/settings-models.ts` (D5); verify with the Settings models tests - -## 4. Close out - -- [ ] 4.1 Confirm the boundary test from 1.1 now passes with no allowances beyond the named importer readers -- [ ] 4.2 Update `docs/shared-core.md`, `docs/opencode-adapter.md`, and CLAUDE.md's architecture table; record the remaining follow-up (one profile learning loop for both hosts) in the change's design and the ADR-011 consequences -- [ ] 4.3 Run `bun run ci:local` and `bun run check:package` -- [ ] 4.4 Manual check in OpenCode V1 and V2: one live capture, one profile learning run, and one AI profile cleanup succeed, and the capture attempt log shows the host model diff --git a/openspec/specs/host-neutral-memory-core/spec.md b/openspec/specs/host-neutral-memory-core/spec.md index e93eadd9..cdf0c42a 100644 --- a/openspec/specs/host-neutral-memory-core/spec.md +++ b/openspec/specs/host-neutral-memory-core/spec.md @@ -30,7 +30,7 @@ Automatic capture SHALL accept a normalized work unit that contains visible user ### Requirement: Provider-specific extraction is behind a narrow port -The shared capture/profile pipeline SHALL depend on a provider-neutral structured-extraction interface rather than directly on OpenCode or Pi model APIs. +The shared capture/profile pipeline SHALL depend on a provider-neutral structured-extraction interface rather than directly on OpenCode or Pi model APIs. Shared code in `src/core/`, `src/services/`, `src/types/`, and `src/importer/` SHALL NOT import a host adapter module. Shared code in `src/core/`, `src/services/`, and `src/types/` SHALL NOT import a host SDK (`@opencode-ai/*` or `@earendil-works/*`), statically or dynamically. The importer MAY load a host SDK with dynamic `import()` only in its named modules that read host data without the host running. A host SHALL give shared code its model calls through the `ModelPort` and capture provider ports, or by registering them with the importer. A boundary test SHALL check every shared file for these rules. #### Scenario: Pi extraction fails @@ -38,6 +38,17 @@ The shared capture/profile pipeline SHALL depend on a provider-neutral structure - **THEN** automatic capture MAY fail or defer for that work unit - **AND** local manual memory search, list, add, and delete operations SHALL remain available +#### Scenario: Shared code imports a host SDK + +- **WHEN** a file in `src/services/` imports `@opencode-ai/sdk` +- **THEN** the boundary test SHALL fail and name the file + +#### Scenario: Profile learning on OpenCode + +- **WHEN** OpenCode runs profile learning or AI profile cleanup with an OpenCode host model +- **THEN** the shared profile code SHALL call the model through a `ModelPort` that the OpenCode adapter registers +- **AND** the result SHALL be the same as before the move + ### Requirement: Existing OpenCode storage remains compatible The shared core SHALL preserve compatibility with the current OpenCode memory data and project identity. diff --git a/src/adapters/opencode/auto-capture-summary.ts b/src/adapters/opencode/auto-capture-summary.ts index 74146504..d8e0a9d0 100644 --- a/src/adapters/opencode/auto-capture-summary.ts +++ b/src/adapters/opencode/auto-capture-summary.ts @@ -15,7 +15,7 @@ import type { CaptureSummaryRequest, } from "../../core/host.js"; import { log } from "../../services/logger.js"; -import { loadOpencodeProvider } from "../../services/ai/opencode-provider-loader.js"; +import { loadOpencodeProvider } from "./opencode-provider-loader.js"; type Notify = (notification: AutoCaptureNotification) => Promise | void; diff --git a/src/adapters/opencode/backfill-models.ts b/src/adapters/opencode/backfill-models.ts index 82e40f93..2509afb8 100644 --- a/src/adapters/opencode/backfill-models.ts +++ b/src/adapters/opencode/backfill-models.ts @@ -4,10 +4,7 @@ import { resolveExternalBackfillModels } from "../../importer/external-backfill- import { selectImportModel } from "../../importer/model-selection.js"; import type { HistoryImportModels } from "../../importer/run-import.js"; import { getAutoCaptureProviderStatus } from "../../services/ai/live-model-choice.js"; -import { - createOpencodeImportModels, - type OpencodeModelRef, -} from "../../services/ai/opencode-import-models.js"; +import { createOpencodeImportModels, type OpencodeModelRef } from "./opencode-import-models.js"; interface BackfillConfig { opencodeBackfillModel?: string; diff --git a/src/adapters/opencode/backfill-startup.ts b/src/adapters/opencode/backfill-startup.ts index b01d68a5..37c6fe6b 100644 --- a/src/adapters/opencode/backfill-startup.ts +++ b/src/adapters/opencode/backfill-startup.ts @@ -1,7 +1,13 @@ import { CONFIG } from "../../config.js"; import { scheduleAutoBackfill } from "../../importer/auto-backfill.js"; -import { registerHostBackfillModels } from "../../importer/backfill-controls.js"; +import { + registerHostBackfillModels, + registerOpencodeHostModels, +} from "../../importer/backfill-controls.js"; +import { listOpencodeClientModels } from "../../importer/settings-models.js"; import { resolveOpencodeBackfillModels } from "./backfill-models.js"; +import { createOpencodeImportModels } from "./opencode-import-models.js"; +import { getV2Client, isProviderConnected } from "./opencode-provider.js"; /** Start after connected providers load, without delaying plugin initialisation. */ export function startOpencodeBackfill(input: { @@ -26,3 +32,15 @@ export function registerOpencodeBackfillModels(input: { }): void { registerHostBackfillModels("opencode", () => resolveOpencodeBackfillModels(CONFIG, input)); } + +/** Let web imports, Health, and Settings in this process use OpenCode's signed-in models. */ +export function registerOpencodeImportModels(): void { + registerOpencodeHostModels({ + isProviderConnected: (providerID) => isProviderConnected(providerID), + createImportModels: (ref, directory) => createOpencodeImportModels(ref, directory), + async listSettingsModels() { + const client = getV2Client(); + return client ? listOpencodeClientModels(client) : null; + }, + }); +} diff --git a/src/adapters/opencode/import-command.ts b/src/adapters/opencode/import-command.ts index 1b545ca3..79fecd40 100644 --- a/src/adapters/opencode/import-command.ts +++ b/src/adapters/opencode/import-command.ts @@ -10,13 +10,10 @@ import { summarizeHistoryImportReport, runHistoryImport, } from "../../importer/run-import.js"; -import { - createOpencodeImportModels, - type OpencodeModelRef, -} from "../../services/ai/opencode-import-models.js"; -export { createOpencodeImportModels } from "../../services/ai/opencode-import-models.js"; -export type { OpencodeModelRef } from "../../services/ai/opencode-import-models.js"; -import { loadOpencodeProvider } from "../../services/ai/opencode-provider-loader.js"; +import { createOpencodeImportModels, type OpencodeModelRef } from "./opencode-import-models.js"; +export { createOpencodeImportModels } from "./opencode-import-models.js"; +export type { OpencodeModelRef } from "./opencode-import-models.js"; +import { loadOpencodeProvider } from "./opencode-provider-loader.js"; import { beginManualImport } from "../../importer/manual-import-guard.js"; import { memoryClient } from "../../services/client.js"; import { log } from "../../services/logger.js"; diff --git a/src/services/ai/internal-capture-sessions.ts b/src/adapters/opencode/internal-capture-sessions.ts similarity index 55% rename from src/services/ai/internal-capture-sessions.ts rename to src/adapters/opencode/internal-capture-sessions.ts index ca4d9f4b..2fa88438 100644 --- a/src/services/ai/internal-capture-sessions.ts +++ b/src/adapters/opencode/internal-capture-sessions.ts @@ -1,7 +1,8 @@ -/** Title used for transient structured-output sessions (capture / profile learning). */ -export const INTERNAL_CAPTURE_SESSION_TITLE = "omms capture"; -/** Title used by opencode-mem builds; sessions left by older versions stay internal. */ -const LEGACY_INTERNAL_CAPTURE_SESSION_TITLE = "opencode-mem capture"; +export { + INTERNAL_CAPTURE_SESSION_TITLE, + INTERNAL_CAPTURE_SESSION_TITLES, + isInternalCaptureSessionTitle, +} from "../../importer/opencode-internal-sessions.js"; /** Grace period so session.idle can still match after best-effort delete. */ const UNTRACK_GRACE_MS = 60_000; @@ -9,20 +10,6 @@ const UNTRACK_GRACE_MS = 60_000; const trackedSessionIDs = new Set(); const untrackTimers = new Map>(); -/** Every title omms has used for its own sessions (capture, profile learning, profile cleanup). */ -export const INTERNAL_CAPTURE_SESSION_TITLES = [ - INTERNAL_CAPTURE_SESSION_TITLE, - LEGACY_INTERNAL_CAPTURE_SESSION_TITLE, - "omms profile cleanup", - "opencode-mem profile cleanup", -] as const; - -export function isInternalCaptureSessionTitle(title: string | undefined | null): boolean { - return ( - title === INTERNAL_CAPTURE_SESSION_TITLE || title === LEGACY_INTERNAL_CAPTURE_SESSION_TITLE - ); -} - export function trackInternalCaptureSession(sessionID: string): void { const pending = untrackTimers.get(sessionID); if (pending) { diff --git a/src/services/ai/opencode-diagnostics.ts b/src/adapters/opencode/opencode-diagnostics.ts similarity index 100% rename from src/services/ai/opencode-diagnostics.ts rename to src/adapters/opencode/opencode-diagnostics.ts diff --git a/src/services/ai/opencode-host-config.ts b/src/adapters/opencode/opencode-host-config.ts similarity index 100% rename from src/services/ai/opencode-host-config.ts rename to src/adapters/opencode/opencode-host-config.ts diff --git a/src/services/ai/opencode-import-models.ts b/src/adapters/opencode/opencode-import-models.ts similarity index 96% rename from src/services/ai/opencode-import-models.ts rename to src/adapters/opencode/opencode-import-models.ts index 6d133dd2..de7f599e 100644 --- a/src/services/ai/opencode-import-models.ts +++ b/src/adapters/opencode/opencode-import-models.ts @@ -20,7 +20,7 @@ export async function createOpencodeImportModels( const { z } = await import("zod"); const { buildCaptureSystemPrompt, createUserProfileAnalysisSchema, parseCaptureSummary } = await import("../../core/extraction.js"); - const { detectLanguage, getLanguageName } = await import("../language-detector.js"); + const { detectLanguage, getLanguageName } = await import("../../services/language-detector.js"); const captureSchema = z.object({ summary: z.string().optional(), diff --git a/src/services/ai/opencode-provider-loader.ts b/src/adapters/opencode/opencode-provider-loader.ts similarity index 100% rename from src/services/ai/opencode-provider-loader.ts rename to src/adapters/opencode/opencode-provider-loader.ts diff --git a/src/services/ai/opencode-provider.ts b/src/adapters/opencode/opencode-provider.ts similarity index 100% rename from src/services/ai/opencode-provider.ts rename to src/adapters/opencode/opencode-provider.ts diff --git a/src/services/ai/opencode-sdk-client.ts b/src/adapters/opencode/opencode-sdk-client.ts similarity index 100% rename from src/services/ai/opencode-sdk-client.ts rename to src/adapters/opencode/opencode-sdk-client.ts diff --git a/src/services/user-memory-learning.ts b/src/adapters/opencode/profile-learning.ts similarity index 96% rename from src/services/user-memory-learning.ts rename to src/adapters/opencode/profile-learning.ts index c5913b94..d8f29abc 100644 --- a/src/services/user-memory-learning.ts +++ b/src/adapters/opencode/profile-learning.ts @@ -1,14 +1,14 @@ import type { PluginInput } from "@opencode-ai/plugin"; -import { getTags } from "./tags.js"; -import { log } from "./logger.js"; -import { CONFIG, refreshConfigIfChanged } from "../config.js"; -import { resolveOpencodeHostModel } from "./ai/live-model-choice.js"; -import { userPromptManager } from "./user-prompt/user-prompt-manager.js"; -import type { UserPrompt } from "./user-prompt/user-prompt-manager.js"; -import { userProfileManager } from "./user-profile/user-profile-manager.js"; -import { sortProfileItems } from "../utils/profile.js"; -import type { UserProfile, UserProfileData } from "./user-profile/types.js"; -import { loadOpencodeProvider } from "./ai/opencode-provider-loader.js"; +import { getTags } from "../../services/tags.js"; +import { log } from "../../services/logger.js"; +import { CONFIG, refreshConfigIfChanged } from "../../config.js"; +import { resolveOpencodeHostModel } from "../../services/ai/live-model-choice.js"; +import { userPromptManager } from "../../services/user-prompt/user-prompt-manager.js"; +import type { UserPrompt } from "../../services/user-prompt/user-prompt-manager.js"; +import { userProfileManager } from "../../services/user-profile/user-profile-manager.js"; +import { sortProfileItems } from "../../utils/profile.js"; +import type { UserProfile, UserProfileData } from "../../services/user-profile/types.js"; +import { loadOpencodeProvider } from "./opencode-provider-loader.js"; let isLearningRunning = false; @@ -35,7 +35,7 @@ async function runAutomaticProfileCleanup(userId: string): Promise { profileData.preferences.length + profileData.patterns.length + profileData.workflows.length; if (itemCount < 2) return; - const { aiCleanupProfile } = await import("./user-profile/ai-cleanup.js"); + const { aiCleanupProfile } = await import("../../services/user-profile/ai-cleanup.js"); const result = await aiCleanupProfile(profileData); if (result.diff.merged.length === 0 && result.diff.removed.length === 0) return; @@ -436,7 +436,7 @@ CRITICAL: Only output observations grounded in the RECENT PROMPTS above. Write d import { createUserProfileAnalysisSchema, USER_PROFILE_LLM_CONFIDENCE_MAX, -} from "../core/extraction.js"; +} from "../../core/extraction.js"; export { createUserProfileAnalysisSchema, USER_PROFILE_LLM_CONFIDENCE_MAX }; @@ -615,7 +615,7 @@ async function analyzeUserProfile( log("user-profile-learning: trying opencode provider"); try { const { generateStructuredOutput } = await loadOpencodeProvider(); - const { getOpenCodeClient } = await import("./ai/profile-llm-client.js"); + const { getOpenCodeClient } = await import("./profile-llm-client.js"); log("user-profile-learning: opencode provider diag", { provider: resolveOpencodeHostModel(CONFIG)?.providerID, @@ -691,8 +691,8 @@ Use the update_user_profile tool to save the ${existingProfile ? "updated" : "ne throw new Error("External API not configured for user memory learning"); } - const { AIProviderFactory } = await import("./ai/ai-provider-factory.js"); - const { buildMemoryProviderConfig } = await import("./ai/provider-config.js"); + const { AIProviderFactory } = await import("../../services/ai/ai-provider-factory.js"); + const { buildMemoryProviderConfig } = await import("../../services/ai/provider-config.js"); const providerConfig = buildMemoryProviderConfig(CONFIG); @@ -757,7 +757,7 @@ If no clear chains, return { "paths": [] }.`; try { const { z } = await import("zod"); const { generateStructuredOutput } = await loadOpencodeProvider(); - const { getOpenCodeClient } = await import("./ai/profile-llm-client.js"); + const { getOpenCodeClient } = await import("./profile-llm-client.js"); let v2Client; try { diff --git a/src/services/ai/profile-llm-client.ts b/src/adapters/opencode/profile-llm-client.ts similarity index 94% rename from src/services/ai/profile-llm-client.ts rename to src/adapters/opencode/profile-llm-client.ts index b7993593..56745785 100644 --- a/src/services/ai/profile-llm-client.ts +++ b/src/adapters/opencode/profile-llm-client.ts @@ -1,6 +1,6 @@ import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"; import { CONFIG } from "../../config.js"; -import { resolveOpencodeHostModel } from "./live-model-choice.js"; +import { resolveOpencodeHostModel } from "../../services/ai/live-model-choice.js"; import { loadOpencodeProvider } from "./opencode-provider-loader.js"; let _cachedClient: OpencodeClient | null = null; diff --git a/src/adapters/opencode/profile-model.ts b/src/adapters/opencode/profile-model.ts new file mode 100644 index 00000000..616a53a1 --- /dev/null +++ b/src/adapters/opencode/profile-model.ts @@ -0,0 +1,156 @@ +import { CONFIG } from "../../config.js"; +import type { ModelPort } from "../../core/profile-analysis.js"; +import { resolveOpencodeHostModel } from "../../services/ai/live-model-choice.js"; +import { log } from "../../services/logger.js"; +import { OPENCODE_PROFILE_CLEANUP_TIMEOUT_MS } from "../../services/request-timeouts.js"; +import { registerHostProfileModel } from "../../services/user-profile/profile-model.js"; +import type { OpencodeModelRef } from "./opencode-import-models.js"; +import { loadOpencodeProvider } from "./opencode-provider-loader.js"; +import { getOpenCodeClient } from "./profile-llm-client.js"; + +type PromptPart = { type?: string; text?: string }; +type PromptInfo = { + error?: { name: string; data?: { message?: string } }; +}; +type PromptResultShape = { + data?: { info?: PromptInfo; parts?: PromptPart[] }; + info?: PromptInfo; + parts?: PromptPart[]; +}; + +/** + * Extract assistant text from an OpenCode session.prompt result. + * AssistantMessage has no `text` field; content lives in `parts` (#177). + */ +export function extractTextFromPromptResult(promptResult: unknown): { + info: PromptInfo | undefined; + rawText: string; +} { + const result = promptResult as PromptResultShape; + const info = result?.data?.info ?? result?.info; + const parts = result?.data?.parts ?? result?.parts ?? []; + const rawText = parts + .filter((p) => p.type === "text" && p.text) + .map((p) => p.text) + .join("\n") + .trim(); + return { info, rawText }; +} + +function raceWithTimeout(promise: Promise, ms: number, message: string): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timer !== undefined) clearTimeout(timer); + }); +} + +/** + * Plain-text reply through a transient OpenCode session. Only AI profile cleanup + * calls it, so the session carries the cleanup title the history reader skips. + */ +async function completeViaSession( + v2Client: any, + hostModel: OpencodeModelRef, + systemPrompt: string, + userPrompt: string +): Promise { + const t0 = Date.now(); + // Only "inherit" (or no model configured) needs resolving to the session's recent model. + const model = + hostModel.modelID === "inherit" + ? (await loadOpencodeProvider()).resolveOpencodeModelRef(hostModel) + : hostModel; + + const created = (await raceWithTimeout( + v2Client.session.create({ + title: "omms profile cleanup", + directory: process.cwd(), + }), + 30000, + "session.create timeout" + )) as any; + log("AI cleanup: session.create result", { + rawType: typeof created, + keys: Object.keys(created || {}), + hasData: !!created?.data, + dataId: created?.data?.id, + }); + + const sessionID = created?.data?.id || created?.id || created?.sessionID; + if (!sessionID) throw new Error("session.create returned no session id"); + + log("AI cleanup: session created", { sessionID, createMs: Date.now() - t0 }); + + try { + const TIMEOUT_MS = OPENCODE_PROFILE_CLEANUP_TIMEOUT_MS; + const promptResult = await raceWithTimeout( + v2Client.session.prompt({ + sessionID, + model, + system: systemPrompt, + parts: [{ type: "text", text: userPrompt }], + // `noReply` suppresses assistant generation; cleanup needs the JSON reply (#177). + noReply: false, + }), + TIMEOUT_MS, + `opencodeClient prompt timeout after ${TIMEOUT_MS}ms` + ); + + log("AI cleanup: session.prompt done", { promptMs: Date.now() - t0 }); + + const { info, rawText } = extractTextFromPromptResult(promptResult); + + if (!info) throw new Error("prompt response missing info"); + if (info.error) + throw new Error(`opencode reported ${info.error.name}: ${info.error.data?.message ?? ""}`); + return rawText; + } finally { + try { + await v2Client.session.delete({ sessionID }); + } catch { + // ignore cleanup failures for ephemeral sessions + } + } +} + +/** Adapt OpenCode's host model to the shared profile code, as `adaptPiProfileModel` does for Pi. */ +export function adaptOpencodeProfileModel( + v2Client: unknown, + hostModel: OpencodeModelRef +): ModelPort { + return { + provider: hostModel.providerID, + modelId: hostModel.modelID, + complete: (systemPrompt, userPrompt) => + completeViaSession(v2Client, hostModel, systemPrompt, userPrompt), + async completeStructured(systemPrompt, userPrompt, schema) { + const client = await getOpenCodeClient(); + const { generateStructuredOutput } = await loadOpencodeProvider(); + return generateStructuredOutput({ + client, + providerID: hostModel.providerID, + modelID: hostModel.modelID, + systemPrompt, + userPrompt, + schema, + }); + }, + }; +} + +/** Route shared profile calls in this process to OpenCode's host model while one is configured. */ +export function registerOpencodeProfileModel(): void { + registerHostProfileModel(async () => { + const hostModel = resolveOpencodeHostModel(CONFIG); + if (!hostModel) return null; + const v2Client = (await loadOpencodeProvider()).getV2Client(); + if (!v2Client) { + log("profile model: opencode client unavailable, falling back to external API"); + return null; + } + return adaptOpencodeProfileModel(v2Client, hostModel); + }); +} diff --git a/src/adapters/opencode/user-prompt.ts b/src/adapters/opencode/user-prompt.ts index 2ba304a8..4c11ba53 100644 --- a/src/adapters/opencode/user-prompt.ts +++ b/src/adapters/opencode/user-prompt.ts @@ -1,5 +1,5 @@ import { isStructuredSummaryPromptMessage } from "../../core/internal-prompt.js"; -import { isInternalStructuredSession } from "../../services/ai/opencode-provider.js"; +import { isInternalStructuredSession } from "./opencode-provider.js"; import { userPromptManager } from "../../services/user-prompt/user-prompt-manager.js"; export { isStructuredSummaryPromptMessage }; diff --git a/src/core/profile-analysis.ts b/src/core/profile-analysis.ts index d52beaa7..e10b811f 100644 --- a/src/core/profile-analysis.ts +++ b/src/core/profile-analysis.ts @@ -7,6 +7,12 @@ export interface ModelPort { provider: string; modelId: string; complete(systemPrompt: string, userPrompt: string): Promise; + /** Host-enforced JSON output; callers fall back to `complete` and parse when absent. */ + completeStructured?( + systemPrompt: string, + userPrompt: string, + schema: z.ZodType + ): Promise; } export interface ExistingProfileInput { diff --git a/src/importer/backfill-controls.ts b/src/importer/backfill-controls.ts index 7de55f6f..8b80a1f7 100644 --- a/src/importer/backfill-controls.ts +++ b/src/importer/backfill-controls.ts @@ -17,6 +17,32 @@ export function registerHostBackfillModels(host: BackfillHost, resolve: ResolveM hostResolvers.set(host, resolve); } +export interface SettingsModel { + provider: string; + model: string; + name: string; +} + +/** OpenCode's model access for web imports, Health, and Settings, when OpenCode runs this process. */ +export interface OpencodeHostModels { + isProviderConnected(providerID: string): boolean; + createImportModels( + ref: { providerID: string; modelID: string }, + directory: string + ): Promise>; + listSettingsModels(): Promise; +} + +let opencodeHostModels: OpencodeHostModels | null = null; + +export function registerOpencodeHostModels(models: OpencodeHostModels | null): void { + opencodeHostModels = models; +} + +export function getOpencodeHostModels(): OpencodeHostModels | null { + return opencodeHostModels; +} + /** A control request the page cannot carry out; `status` is the HTTP status. */ export class BackfillControlError extends Error { constructor( diff --git a/src/importer/import-readiness.ts b/src/importer/import-readiness.ts index 0a09b539..7c475f2d 100644 --- a/src/importer/import-readiness.ts +++ b/src/importer/import-readiness.ts @@ -58,7 +58,7 @@ export async function importReadiness(deps: ReadinessDeps = {}): Promise (await import("../services/settings-models.js")).listOpencodeSettingsModels()) + (async () => (await import("./settings-models.js")).listOpencodeSettingsModels()) )(), (deps.loadPiSdk ?? (() => import("@earendil-works/pi-coding-agent")))().then( () => ({ available: true }), diff --git a/src/importer/opencode-internal-sessions.ts b/src/importer/opencode-internal-sessions.ts new file mode 100644 index 00000000..27052d68 --- /dev/null +++ b/src/importer/opencode-internal-sessions.ts @@ -0,0 +1,18 @@ +/** Title used for transient structured-output sessions (capture / profile learning). */ +export const INTERNAL_CAPTURE_SESSION_TITLE = "omms capture"; +/** Title used by opencode-mem builds; sessions left by older versions stay internal. */ +const LEGACY_INTERNAL_CAPTURE_SESSION_TITLE = "opencode-mem capture"; + +/** Every title omms has used for its own sessions (capture, profile learning, profile cleanup). */ +export const INTERNAL_CAPTURE_SESSION_TITLES = [ + INTERNAL_CAPTURE_SESSION_TITLE, + LEGACY_INTERNAL_CAPTURE_SESSION_TITLE, + "omms profile cleanup", + "opencode-mem profile cleanup", +] as const; + +export function isInternalCaptureSessionTitle(title: string | undefined | null): boolean { + return ( + title === INTERNAL_CAPTURE_SESSION_TITLE || title === LEGACY_INTERNAL_CAPTURE_SESSION_TITLE + ); +} diff --git a/src/importer/opencode-reader.ts b/src/importer/opencode-reader.ts index 5f101904..dbb77a59 100644 --- a/src/importer/opencode-reader.ts +++ b/src/importer/opencode-reader.ts @@ -3,7 +3,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { DatabaseSync } from "node:sqlite"; -import { INTERNAL_CAPTURE_SESSION_TITLES } from "../services/ai/internal-capture-sessions.js"; +import { INTERNAL_CAPTURE_SESSION_TITLES } from "./opencode-internal-sessions.js"; import type { ImportWindow } from "./importer.js"; import { acquirePrivateSnapshot, diff --git a/src/importer/settings-health.ts b/src/importer/settings-health.ts index 400ce369..62de3779 100644 --- a/src/importer/settings-health.ts +++ b/src/importer/settings-health.ts @@ -10,6 +10,16 @@ export { safeHealthError } from "../services/safe-health-error.js"; export type HealthRow = { check: string; status: "pass" | "warn" | "fail"; reason: string }; const PROBE = "Reply with a short acknowledgement."; +async function createOpencodeImportModels( + ref: { providerID: string; modelID: string }, + directory: string +) { + const { getOpencodeHostModels } = await import("./backfill-controls.js"); + const opencode = getOpencodeHostModels(); + if (!opencode) throw new Error("the OpenCode client is not ready; retry in a moment"); + return opencode.createImportModels(ref, directory); +} + export function captureFailureHealth( byModel: Array<{ total: number; failed: number }>, byReason: Array<{ reason: string; count: number }> @@ -115,11 +125,7 @@ export async function runSettingsHealth(input: HealthInput): Promise<{ checks: H if (ref?.modelID === "inherit") throw new Error("A session model needs an active session to test"); const capture = ref - ? ( - await ( - await import("../services/ai/opencode-import-models.js") - ).createOpencodeImportModels(ref, input.directory) - ).capture + ? (await createOpencodeImportModels(ref, input.directory)).capture : (await import("./model-selection.js")).selectImportModel({}).capture; await capture.summarize({ userPrompt: PROBE, diff --git a/src/services/settings-models.ts b/src/importer/settings-models.ts similarity index 54% rename from src/services/settings-models.ts rename to src/importer/settings-models.ts index 3bc68895..cc6391c7 100644 --- a/src/services/settings-models.ts +++ b/src/importer/settings-models.ts @@ -1,22 +1,35 @@ import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"; +import type { SettingsModel } from "./backfill-controls.js"; + +/** Connected models from an OpenCode client; null when the client returns no list. */ +export async function listOpencodeClientModels( + client: Pick +): Promise { + const result = await client.provider.list(); + if (!result.data) return null; + const connected = new Set(result.data.connected); + return result.data.all + .filter((provider) => connected.has(provider.id)) + .flatMap((provider) => + Object.entries(provider.models).map(([model, info]) => ({ + provider: provider.id, + model, + name: info.name, + })) + ); +} export async function listOpencodeSettingsModels(client?: Pick) { try { - const active = client ?? (await import("./ai/opencode-provider.js")).getV2Client(); - if (!active) return { available: false as const, reason: "OpenCode model list unavailable" }; - const result = await active.provider.list(); - if (!result.data) - return { available: false as const, reason: "OpenCode model list unavailable" }; - const connected = new Set(result.data.connected); - const models = result.data.all - .filter((provider) => connected.has(provider.id)) - .flatMap((provider) => - Object.entries(provider.models).map(([model, info]) => ({ - provider: provider.id, - model, - name: info.name, - })) - ); + let models: SettingsModel[] | null; + if (client) { + models = await listOpencodeClientModels(client); + } else { + const { getOpencodeHostModels } = await import("./backfill-controls.js"); + const opencode = getOpencodeHostModels(); + models = opencode ? await opencode.listSettingsModels() : null; + } + if (!models) return { available: false as const, reason: "OpenCode model list unavailable" }; return { available: true as const, models }; } catch { return { available: false as const, reason: "OpenCode model list unavailable" }; diff --git a/src/importer/web-import-jobs.ts b/src/importer/web-import-jobs.ts index d5d28fdb..56ee9b7a 100644 --- a/src/importer/web-import-jobs.ts +++ b/src/importer/web-import-jobs.ts @@ -220,14 +220,11 @@ export class SettingsImportJobs { models = { capture: selected.capture, profile: selected.profile }; } else { const [providerID = "", ...id] = input.modelChoice!.split("/"); - const { loadOpencodeProvider } = - await import("../services/ai/opencode-provider-loader.js"); - const { isProviderConnected } = await loadOpencodeProvider(); - if (!isProviderConnected(providerID)) + const { getOpencodeHostModels } = await import("./backfill-controls.js"); + const opencode = getOpencodeHostModels(); + if (!opencode?.isProviderConnected(providerID)) throw new Error("OpenCode provider is not connected"); - const { createOpencodeImportModels } = - await import("../services/ai/opencode-import-models.js"); - models = await createOpencodeImportModels( + models = await opencode.createImportModels( { providerID, modelID: id.join("/") }, directory ); diff --git a/src/index.ts b/src/index.ts index 2bf6b82e..6e2b81b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,7 @@ import { runOpencodeImportCommand, } from "./adapters/opencode/import-command.js"; import { formatMemoriesForCompaction } from "./core/retrieval.js"; -import { performUserProfileLearning } from "./services/user-memory-learning.js"; +import { performUserProfileLearning } from "./adapters/opencode/profile-learning.js"; import { userPromptManager } from "./services/user-prompt/user-prompt-manager.js"; import { startWebServer, WebServer } from "./services/web-server.js"; import { pruneTraces } from "./services/capture-diagnostics.js"; @@ -32,18 +32,19 @@ import { isConfigured, CONFIG, initConfigWithLegacyMigration } from "./config.js import { resolveOpencodeHostModel } from "./services/ai/live-model-choice.js"; import { log } from "./services/logger.js"; import { getLanguageName } from "./services/language-detector.js"; -import { getHostClientConfig } from "./services/ai/opencode-host-config.js"; -import { loadOpencodeProvider } from "./services/ai/opencode-provider-loader.js"; +import { getHostClientConfig } from "./adapters/opencode/opencode-host-config.js"; +import { loadOpencodeProvider } from "./adapters/opencode/opencode-provider-loader.js"; import { STRUCTURED_OUTPUT_AGENT, STRUCTURED_OUTPUT_TOOLS, -} from "./services/ai/opencode-provider.js"; +} from "./adapters/opencode/opencode-provider.js"; import { INTERNAL_CAPTURE_SESSION_TITLE, isInternalCaptureSessionTitle, isTrackedInternalCaptureSession, -} from "./services/ai/internal-capture-sessions.js"; +} from "./adapters/opencode/internal-capture-sessions.js"; +import { registerOpencodeProfileModel } from "./adapters/opencode/profile-model.js"; export { INTERNAL_CAPTURE_SESSION_TITLE, isInternalCaptureSessionTitle }; export { isStructuredSummaryPromptMessage }; @@ -284,6 +285,13 @@ export const OmmsPlugin: Plugin = async (ctx: PluginInput) => { } await configureOpencodeHostTransport(ctx); + // Shared profile dedup, conflict, description, and cleanup calls use OpenCode's host model. + registerOpencodeProfileModel(); + // Web imports, Health, and Settings served by this process can list and use OpenCode's models. + // Awaited so the web server never serves those routes before the registry is filled. + await import("./adapters/opencode/backfill-startup.js") + .then(({ registerOpencodeImportModels }) => registerOpencodeImportModels()) + .catch(() => {}); (async () => { let connected: string[] = []; diff --git a/src/services/user-profile/ai-cleanup.ts b/src/services/user-profile/ai-cleanup.ts index eed203f6..eeadfad6 100644 --- a/src/services/user-profile/ai-cleanup.ts +++ b/src/services/user-profile/ai-cleanup.ts @@ -1,12 +1,9 @@ import type { UserProfileData } from "./types.js"; import { CONFIG } from "../../config.js"; -import { resolveOpencodeHostModel } from "../ai/live-model-choice.js"; import { log } from "../logger.js"; -import { loadOpencodeProvider } from "../ai/opencode-provider-loader.js"; -import { - EXTERNAL_PROFILE_CLEANUP_TIMEOUT_MS, - OPENCODE_PROFILE_CLEANUP_TIMEOUT_MS, -} from "../request-timeouts.js"; +import type { ModelPort } from "../../core/profile-analysis.js"; +import { resolveHostProfileModel } from "./profile-model.js"; +import { EXTERNAL_PROFILE_CLEANUP_TIMEOUT_MS } from "../request-timeouts.js"; import { applySafeExtraParams } from "../ai/providers/base-provider.js"; export interface AICleanupResult { @@ -218,16 +215,12 @@ function formatForAI(item: IndexedProfileItem): Record { async function callAICleanup( prompt: string ): Promise<{ profile: IndexedProfile; mapping: AIMapping }> { - // Use opencode internal session when opencodeProvider is configured (same pattern as auto-capture). - // When the client is available, surface OpenCode errors instead of masking them as + // Use the host model when the host registered one (same pattern as auto-capture). + // When it is available, surface its errors instead of masking them as // "No AI provider configured" via a silent fallback (#177). - if (resolveOpencodeHostModel(CONFIG)) { - const { getV2Client } = await loadOpencodeProvider(); - const v2Client = getV2Client(); - if (v2Client) { - return callViaOpencodeWithClient(v2Client, prompt); - } - log("AI cleanup: opencode client unavailable, falling back to external API"); + const hostModel = await resolveHostProfileModel(); + if (hostModel) { + return callViaHostModel(hostModel, prompt); } if (CONFIG.memoryModel && CONFIG.memoryApiUrl) { @@ -291,117 +284,22 @@ async function callViaExternalAPI( }; } -type PromptPart = { type?: string; text?: string }; -type PromptInfo = { - error?: { name: string; data?: { message?: string } }; -}; -type PromptResultShape = { - data?: { info?: PromptInfo; parts?: PromptPart[] }; - info?: PromptInfo; - parts?: PromptPart[]; -}; - -/** - * Extract assistant text from an OpenCode session.prompt result. - * AssistantMessage has no `text` field; content lives in `parts` (#177). - */ -export function extractTextFromPromptResult(promptResult: unknown): { - info: PromptInfo | undefined; - rawText: string; -} { - const result = promptResult as PromptResultShape; - const info = result?.data?.info ?? result?.info; - const parts = result?.data?.parts ?? result?.parts ?? []; - const rawText = parts - .filter((p) => p.type === "text" && p.text) - .map((p) => p.text) - .join("\n") - .trim(); - return { info, rawText }; -} - -function raceWithTimeout(promise: Promise, ms: number, message: string): Promise { - let timer: ReturnType | undefined; - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(message)), ms); - }); - return Promise.race([promise, timeout]).finally(() => { - if (timer !== undefined) clearTimeout(timer); - }); -} - -async function callViaOpencodeWithClient( - v2Client: any, +async function callViaHostModel( + model: ModelPort, prompt: string ): Promise<{ profile: IndexedProfile; mapping: AIMapping }> { - const t0 = Date.now(); - const hostModel = resolveOpencodeHostModel(CONFIG) ?? { providerID: "", modelID: "inherit" }; - // Only "inherit" (or no model configured) needs resolving to the session's recent model. - const model = - hostModel.modelID === "inherit" - ? (await loadOpencodeProvider()).resolveOpencodeModelRef(hostModel) - : hostModel; const systemPrompt = "You are a user profile cleanup assistant. Merge duplicate entries and return only JSON without markdown wrapping."; + const rawText = await model.complete(systemPrompt, prompt); - const created = (await raceWithTimeout( - v2Client.session.create({ - title: "omms profile cleanup", - directory: process.cwd(), - }), - 30000, - "session.create timeout" - )) as any; - log("AI cleanup: session.create result", { - rawType: typeof created, - keys: Object.keys(created || {}), - hasData: !!created?.data, - dataId: created?.data?.id, - }); + const jsonMatch = rawText.match(/\{[\s\S]*\}/); + if (!jsonMatch) throw new Error("AI response did not contain valid JSON"); - const sessionID = created?.data?.id || created?.id || created?.sessionID; - if (!sessionID) throw new Error("session.create returned no session id"); - - log("AI cleanup: session created", { sessionID, createMs: Date.now() - t0 }); - - try { - const TIMEOUT_MS = OPENCODE_PROFILE_CLEANUP_TIMEOUT_MS; - const promptResult = await raceWithTimeout( - v2Client.session.prompt({ - sessionID, - model, - system: systemPrompt, - parts: [{ type: "text", text: prompt }], - // `noReply` suppresses assistant generation; cleanup needs the JSON reply (#177). - noReply: false, - }), - TIMEOUT_MS, - `opencodeClient prompt timeout after ${TIMEOUT_MS}ms` - ); - - log("AI cleanup: session.prompt done", { promptMs: Date.now() - t0 }); - - const { info, rawText } = extractTextFromPromptResult(promptResult); - - if (!info) throw new Error("prompt response missing info"); - if (info.error) - throw new Error(`opencode reported ${info.error.name}: ${info.error.data?.message ?? ""}`); - - const jsonMatch = rawText.match(/\{[\s\S]*\}/); - if (!jsonMatch) throw new Error("AI response did not contain valid JSON"); - - const parsed = JSON.parse(jsonMatch[0]); - return { - profile: parsed as IndexedProfile, - mapping: normalizeAIMapping(parsed.mapping), - }; - } finally { - try { - await v2Client.session.delete({ sessionID }); - } catch { - // ignore cleanup failures for ephemeral sessions - } - } + const parsed = JSON.parse(jsonMatch[0]); + return { + profile: parsed as IndexedProfile, + mapping: normalizeAIMapping(parsed.mapping), + }; } function buildItemIndex(profile: IndexedProfile): Map { diff --git a/src/services/user-profile/profile-model.ts b/src/services/user-profile/profile-model.ts new file mode 100644 index 00000000..566356e8 --- /dev/null +++ b/src/services/user-profile/profile-model.ts @@ -0,0 +1,42 @@ +import type { z } from "zod"; +import type { ModelPort } from "../../core/profile-analysis.js"; +import { extractJsonObject } from "../../core/extraction.js"; +import { log } from "../logger.js"; + +type ResolveProfileModel = () => Promise; + +/** + * The host model for profile dedup, conflict, description, and cleanup calls in + * this process. A host registers it at start-up; with none, or when it resolves + * to null, those calls use the external API. + */ +let hostProfileModel: ResolveProfileModel | null = null; + +export function registerHostProfileModel(resolve: ResolveProfileModel | null): void { + hostProfileModel = resolve; +} + +/** A host model that fails to resolve counts as unavailable, so callers keep their external API fallback. */ +export async function resolveHostProfileModel(): Promise { + if (!hostProfileModel) return null; + try { + return await hostProfileModel(); + } catch (error) { + log("profile model: host model unavailable, falling back to external API", { + error: String(error), + }); + return null; + } +} + +export async function completeStructured( + model: ModelPort, + systemPrompt: string, + userPrompt: string, + schema: z.ZodType +): Promise { + if (model.completeStructured) { + return model.completeStructured(systemPrompt, userPrompt, schema); + } + return schema.parse(extractJsonObject(await model.complete(systemPrompt, userPrompt))); +} diff --git a/src/services/user-profile/user-profile-manager.ts b/src/services/user-profile/user-profile-manager.ts index 839620b4..127fcc05 100644 --- a/src/services/user-profile/user-profile-manager.ts +++ b/src/services/user-profile/user-profile-manager.ts @@ -3,13 +3,13 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { tursoConnectionManager } from "../turso/connection-manager.js"; import type { TursoDb } from "../turso/turso-db.js"; import { CONFIG } from "../../config.js"; -import { resolveOpencodeHostModel } from "../ai/live-model-choice.js"; +import type { ModelPort } from "../../core/profile-analysis.js"; +import { completeStructured, resolveHostProfileModel } from "./profile-model.js"; import type { UserProfile, UserProfileChangelog, UserProfileData } from "./types.js"; import { safeArray } from "./profile-utils.js"; import { EmbeddingService } from "../embedding.js"; import { log } from "../logger.js"; import { cosineSimilarityNumbers, l2Normalize } from "../../utils/math.js"; -import { loadOpencodeProvider } from "../ai/opencode-provider-loader.js"; const CENTROID_EMA_WEIGHT = 0.85; const CENTROID_EMA_WEIGHT_COMPLEMENT = 0.15; @@ -1414,35 +1414,22 @@ B: "${descB}" Answer JSON only: { "duplicate": true|false, "reason": "one sentence explanation" }`; - if (resolveOpencodeHostModel(CONFIG)) { + const hostModel = await resolveHostProfileModel(); + if (hostModel) { try { const { z } = await import("zod"); - const { generateStructuredOutput } = await loadOpencodeProvider(); - const { getOpenCodeClient } = await import("../ai/profile-llm-client.js"); - - let v2Client; - try { - v2Client = await getOpenCodeClient(); - } catch (e) { - log("profile dedup check: native provider not connected", { error: String(e) }); - } - - if (v2Client) { - const result: any = await Promise.race([ - generateStructuredOutput({ - client: v2Client, - providerID: resolveOpencodeHostModel(CONFIG)!.providerID, - modelID: resolveOpencodeHostModel(CONFIG)!.modelID, - systemPrompt: "You are a semantic duplicate detector. Output valid JSON.", - userPrompt: prompt, - schema: z.object({ duplicate: z.boolean(), reason: z.string() }), - }), - new Promise((_, reject) => - setTimeout(() => reject(new Error("dedup check timeout")), 30000) - ), - ]); - return result.duplicate || false; - } + const result: any = await Promise.race([ + completeStructured( + hostModel, + "You are a semantic duplicate detector. Output valid JSON.", + prompt, + z.object({ duplicate: z.boolean(), reason: z.string() }) + ), + new Promise((_, reject) => + setTimeout(() => reject(new Error("dedup check timeout")), 30000) + ), + ]); + return result.duplicate || false; } catch (e) { log("profile dedup check: native provider failed", { error: String(e) }); } @@ -1614,35 +1601,22 @@ B: "${descB}" Answer JSON only: { "conflict": true|false, "reason": "one sentence explanation" }`; - if (resolveOpencodeHostModel(CONFIG)) { + const hostModel = await resolveHostProfileModel(); + if (hostModel) { try { const { z } = await import("zod"); - const { generateStructuredOutput } = await loadOpencodeProvider(); - const { getOpenCodeClient } = await import("../ai/profile-llm-client.js"); - - let v2Client; - try { - v2Client = await getOpenCodeClient(); - } catch (e) { - log("profile conflict check: native provider not connected", { error: String(e) }); - } - - if (v2Client) { - const result: any = await Promise.race([ - generateStructuredOutput({ - client: v2Client, - providerID: resolveOpencodeHostModel(CONFIG)!.providerID, - modelID: resolveOpencodeHostModel(CONFIG)!.modelID, - systemPrompt: "You are a preference contradiction detector. Output valid JSON.", - userPrompt: prompt, - schema: z.object({ conflict: z.boolean(), reason: z.string() }), - }), - new Promise((_, reject) => - setTimeout(() => reject(new Error("conflict check timeout")), 30000) - ), - ]); - return result.conflict || false; - } + const result: any = await Promise.race([ + completeStructured( + hostModel, + "You are a preference contradiction detector. Output valid JSON.", + prompt, + z.object({ conflict: z.boolean(), reason: z.string() }) + ), + new Promise((_, reject) => + setTimeout(() => reject(new Error("conflict check timeout")), 30000) + ), + ]); + return result.conflict || false; } catch (e) { log("profile conflict check: native provider failed", { error: String(e) }); } @@ -1745,9 +1719,10 @@ Generate a concise, abstract description of the user's general behavioral tenden let newDescription: string | null = null; - if (resolveOpencodeHostModel(CONFIG)) { + const hostModel = await resolveHostProfileModel(); + if (hostModel) { try { - newDescription = await this.callOpencodeProvider(systemPrompt, userPrompt); + newDescription = await this.callHostModel(hostModel, systemPrompt, userPrompt); } catch (e) { log("profile description evolution: native provider failed, trying external API", { error: String(e), @@ -1871,36 +1846,16 @@ Generate a concise, abstract description of the user's general behavioral tenden } } - private async callOpencodeProvider( + private async callHostModel( + model: ModelPort, systemPrompt: string, userPrompt: string ): Promise { - const { generateStructuredOutput } = await loadOpencodeProvider(); - const { getOpenCodeClient } = await import("../ai/profile-llm-client.js"); - - let v2Client; - try { - v2Client = await getOpenCodeClient(); - } catch (e) { - log("profile description evolution: native provider not connected", { - provider: resolveOpencodeHostModel(CONFIG)?.providerID, - error: String(e), - }); - return null; - } - const { z } = await import("zod"); const schema = z.object({ description: z.string() }); const result: any = await Promise.race([ - generateStructuredOutput({ - client: v2Client, - providerID: resolveOpencodeHostModel(CONFIG)!.providerID, - modelID: resolveOpencodeHostModel(CONFIG)!.modelID, - systemPrompt, - userPrompt, - schema, - }), + completeStructured(model, systemPrompt, userPrompt, schema), new Promise((_, reject) => setTimeout(() => reject(new Error("evolve description timeout")), 120000) ), diff --git a/src/services/web-server.ts b/src/services/web-server.ts index 5daa3990..f9eea36b 100644 --- a/src/services/web-server.ts +++ b/src/services/web-server.ts @@ -698,7 +698,7 @@ export class WebServer { if (path === "/api/settings/models" && method === "GET") { const { listOpencodeSettingsModels, listPiSettingsModels } = - await import("./settings-models.js"); + await import("../importer/settings-models.js"); const host = url.searchParams.get("host"); if (host === "pi") return this.jsonResponse(await listPiSettingsModels()); if (host === "opencode") return this.jsonResponse(await listOpencodeSettingsModels()); diff --git a/src/v2/plugin.ts b/src/v2/plugin.ts index 77acca3b..6c9c49f9 100644 --- a/src/v2/plugin.ts +++ b/src/v2/plugin.ts @@ -1,6 +1,6 @@ import type { Plugin } from "@opencode/plugin/promise/plugin"; import { OmmsPlugin } from "../index.js"; -import { loadOpencodeProvider } from "../services/ai/opencode-provider-loader.js"; +import { loadOpencodeProvider } from "../adapters/opencode/opencode-provider-loader.js"; import { registerV2Adapter } from "./adapter.js"; import { createV2MemoryBridge } from "./memory-bridge.js"; import { createLegacyClient } from "./legacy-client.js"; diff --git a/tests/ai-cleanup.test.ts b/tests/ai-cleanup.test.ts index fbaa5a62..b0e8c2d9 100644 --- a/tests/ai-cleanup.test.ts +++ b/tests/ai-cleanup.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { extractTextFromPromptResult } from "../src/services/user-profile/ai-cleanup.js"; +import { extractTextFromPromptResult } from "../src/adapters/opencode/profile-model.js"; const tempDirs: string[] = []; @@ -65,8 +65,9 @@ describe("extractTextFromPromptResult", () => { const aiCleanupUrl = new URL("../src/services/user-profile/ai-cleanup.js", import.meta.url).href; const configUrl = new URL("../src/config.js", import.meta.url).href; const loggerUrl = new URL("../src/services/logger.js", import.meta.url).href; +const profileModelUrl = new URL("../src/adapters/opencode/profile-model.js", import.meta.url).href; const opencodeProviderLoaderUrl = new URL( - "../src/services/ai/opencode-provider-loader.js", + "../src/adapters/opencode/opencode-provider-loader.js", import.meta.url ).href; @@ -164,6 +165,9 @@ if (${withExternalApi}) { }; } +// The OpenCode plugin registers its profile model at start-up. +const { registerOpencodeProfileModel } = await import(${JSON.stringify(profileModelUrl)}); +registerOpencodeProfileModel(); const { aiCleanupProfile } = await import(${JSON.stringify(aiCleanupUrl)}); const profile = { diff --git a/tests/auto-capture.test.ts b/tests/auto-capture.test.ts index 6ec646b5..b80a747b 100644 --- a/tests/auto-capture.test.ts +++ b/tests/auto-capture.test.ts @@ -22,7 +22,7 @@ const promptManagerUrl = new URL( const loggerUrl = new URL("../src/services/logger.js", import.meta.url).href; const languageUrl = new URL("../src/services/language-detector.js", import.meta.url).href; const opencodeProviderLoaderUrl = new URL( - "../src/services/ai/opencode-provider-loader.js", + "../src/adapters/opencode/opencode-provider-loader.js", import.meta.url ).href; diff --git a/tests/capture-diagnostics-parity.test.ts b/tests/capture-diagnostics-parity.test.ts index 94367654..9ff18278 100644 --- a/tests/capture-diagnostics-parity.test.ts +++ b/tests/capture-diagnostics-parity.test.ts @@ -6,7 +6,7 @@ const invalid = { type: "feature", summary: "" }; mock.module("../src/config.js", () => ({ CONFIG: config })); mock.module("../src/services/logger.js", () => ({ log: () => {} })); -mock.module("../src/services/ai/opencode-provider-loader.js", () => ({ +mock.module("../src/adapters/opencode/opencode-provider-loader.js", () => ({ loadOpencodeProvider: async () => ({ isProviderConnected: () => true, getV2Client: () => ({}), diff --git a/tests/compaction-agent-preservation.test.ts b/tests/compaction-agent-preservation.test.ts index 0f106835..3e8dcd58 100644 --- a/tests/compaction-agent-preservation.test.ts +++ b/tests/compaction-agent-preservation.test.ts @@ -83,7 +83,7 @@ const tagsUrl = new URL("../src/services/tags.js", import.meta.url).href; const contextUrl = new URL("../src/services/context.js", import.meta.url).href; const privacyUrl = new URL("../src/services/privacy.js", import.meta.url).href; const autoCaptureUrl = new URL("../src/services/auto-capture.js", import.meta.url).href; -const learningUrl = new URL("../src/services/user-memory-learning.js", import.meta.url).href; +const learningUrl = new URL("../src/adapters/opencode/profile-learning.js", import.meta.url).href; const promptManagerUrl = new URL( "../src/services/user-prompt/user-prompt-manager.js", import.meta.url diff --git a/tests/history-import-commands.test.ts b/tests/history-import-commands.test.ts index 6547e96d..b8b73c43 100644 --- a/tests/history-import-commands.test.ts +++ b/tests/history-import-commands.test.ts @@ -39,7 +39,7 @@ mock.module("../src/services/logger.js", () => ({ const structuredCalls: Array<{ providerID: string; modelID: string }> = []; const connected = new Set(["zai", "other"]); -mock.module("../src/services/ai/opencode-provider-loader.js", () => ({ +mock.module("../src/adapters/opencode/opencode-provider-loader.js", () => ({ loadOpencodeProvider: async () => ({ isProviderConnected: (provider: string) => connected.has(provider), getV2Client: () => ({}), diff --git a/tests/internal-capture-sessions.test.ts b/tests/internal-capture-sessions.test.ts index 04a0a56b..7a3476ff 100644 --- a/tests/internal-capture-sessions.test.ts +++ b/tests/internal-capture-sessions.test.ts @@ -5,7 +5,7 @@ import { isTrackedInternalCaptureSession, trackInternalCaptureSession, untrackInternalCaptureSession, -} from "../src/services/ai/internal-capture-sessions.js"; +} from "../src/adapters/opencode/internal-capture-sessions.js"; describe("internal capture session tracking", () => { it("tracks and untracks session ids with grace", async () => { diff --git a/tests/legacy-name-guard.test.ts b/tests/legacy-name-guard.test.ts index 4318a25e..432cbdf4 100644 --- a/tests/legacy-name-guard.test.ts +++ b/tests/legacy-name-guard.test.ts @@ -16,7 +16,7 @@ const LEGACY_FALLBACK_FILES = new Set([ "src/services/log-path.ts", // OPENCODE_MEM_LOG_FILE override "src/services/tags.ts", // .opencode-mem-project marker "src/services/auth-token.ts", // legacy token file and x-opencode-mem-token header - "src/services/ai/internal-capture-sessions.ts", // legacy capture session title + "src/importer/opencode-internal-sessions.ts", // legacy capture session title "src/services/onnxruntime-resolve.ts", // pre-migration plugin cache path hint "web/src/lib/preferences.ts", // legacy localStorage adoption "web/src/lib/theme.ts", // opencode-mem-theme key diff --git a/tests/memory-portability-tool.test.ts b/tests/memory-portability-tool.test.ts index fab7251a..c72f4a4f 100644 --- a/tests/memory-portability-tool.test.ts +++ b/tests/memory-portability-tool.test.ts @@ -18,7 +18,7 @@ const tagsUrl = new URL("../src/services/tags.js", import.meta.url).href; const contextUrl = new URL("../src/services/context.js", import.meta.url).href; const privacyUrl = new URL("../src/services/privacy.js", import.meta.url).href; const autoCaptureUrl = new URL("../src/services/auto-capture.js", import.meta.url).href; -const learningUrl = new URL("../src/services/user-memory-learning.js", import.meta.url).href; +const learningUrl = new URL("../src/adapters/opencode/profile-learning.js", import.meta.url).href; const promptManagerUrl = new URL( "../src/services/user-prompt/user-prompt-manager.js", import.meta.url diff --git a/tests/opencode-backfill-startup.test.ts b/tests/opencode-backfill-startup.test.ts index 4ae02a13..239e99ca 100644 --- a/tests/opencode-backfill-startup.test.ts +++ b/tests/opencode-backfill-startup.test.ts @@ -23,7 +23,7 @@ mock.module(${JSON.stringify(url("../src/services/client.js"))}, () => ({ memory warmup: async () => {}, close: async () => {} } })); mock.module(${JSON.stringify(url("../src/services/tags.js"))}, () => ({ getTags: () => ({ project: { tag: "test" }, user: { userEmail: "test@example.invalid" } }) })); -mock.module(${JSON.stringify(url("../src/services/ai/opencode-provider-loader.js"))}, () => ({ +mock.module(${JSON.stringify(url("../src/adapters/opencode/opencode-provider-loader.js"))}, () => ({ loadOpencodeProvider: async () => ({ resetHostFetch: () => {}, setHostFetch: () => {}, setV2Client: () => {}, createV2Client: () => ({}), setConnectedProviders: () => {} }) })); mock.module(${JSON.stringify(url("../src/importer/auto-backfill.js"))}, () => ({ diff --git a/tests/opencode-capture-diagnostics.test.ts b/tests/opencode-capture-diagnostics.test.ts index ab61956d..07e45f00 100644 --- a/tests/opencode-capture-diagnostics.test.ts +++ b/tests/opencode-capture-diagnostics.test.ts @@ -7,7 +7,7 @@ let toolCallResult: Record = { success: true, data: {} }; mock.module("../src/config.js", () => ({ CONFIG: config })); mock.module("../src/services/logger.js", () => ({ log: () => {} })); -mock.module("../src/services/ai/opencode-provider-loader.js", () => ({ +mock.module("../src/adapters/opencode/opencode-provider-loader.js", () => ({ loadOpencodeProvider: async () => ({ isProviderConnected: () => true, getV2Client: () => ({}), diff --git a/tests/opencode-host-models.test.ts b/tests/opencode-host-models.test.ts new file mode 100644 index 00000000..72a250ef --- /dev/null +++ b/tests/opencode-host-models.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { + getOpencodeHostModels, + registerOpencodeHostModels, + type OpencodeHostModels, +} from "../src/importer/backfill-controls.js"; +import { listOpencodeSettingsModels } from "../src/importer/settings-models.js"; +import { SettingsImportJobs } from "../src/importer/web-import-jobs.js"; + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); +const models = [{ provider: "zai", model: "glm/4.6", name: "GLM" }]; + +function fakeHost(overrides: Partial = {}) { + const created: Array<{ ref: unknown; directory: string }> = []; + const host: OpencodeHostModels = { + isProviderConnected: (providerID) => providerID === "zai", + createImportModels: async (ref, directory) => { + created.push({ ref, directory }); + throw new Error("registered factory reached"); + }, + listSettingsModels: async () => models, + ...overrides, + }; + return { host, created }; +} + +function jobs() { + return new SettingsImportJobs({ + readiness: async () => ({ + external: { state: "missing-key", provider: "openai-chat", model: null }, + opencode: { available: true, models }, + piReader: { available: true }, + }), + resolveSelection: async () => ({ + identity: { host: "pi", kind: "pi-folder", realPath: "/tmp/s", dev: 1, ino: 2 }, + keys: ["a.jsonl"], + cutoff: 1000, + }), + runner: async () => { + throw new Error("runner must not start"); + }, + }); +} + +const request = { + host: "pi", + source: "token", + selection: { mode: "all", excludedKeys: [], revision: "r1", listedAt: 1000 }, + options: {}, + modelChoice: "zai/glm/4.6", +}; + +afterEach(() => registerOpencodeHostModels(null)); + +describe("OpenCode host models registry", () => { + it("is empty until the OpenCode adapter registers", () => { + expect(getOpencodeHostModels()).toBeNull(); + const { host } = fakeHost(); + registerOpencodeHostModels(host); + expect(getOpencodeHostModels()).toBe(host); + }); + + it("reports the OpenCode model list as unavailable with nothing registered", async () => { + expect(await listOpencodeSettingsModels()).toEqual({ + available: false, + reason: "OpenCode model list unavailable", + }); + }); + + it("lists the registered OpenCode models, and reports unavailable when they cannot load", async () => { + registerOpencodeHostModels(fakeHost().host); + expect(await listOpencodeSettingsModels()).toEqual({ available: true, models }); + registerOpencodeHostModels(fakeHost({ listSettingsModels: async () => null }).host); + expect((await listOpencodeSettingsModels()).available).toBe(false); + registerOpencodeHostModels( + fakeHost({ + listSettingsModels: async () => { + throw new Error("down"); + }, + }).host + ); + expect((await listOpencodeSettingsModels()).available).toBe(false); + }); + + it("fails a web import with 'not connected' when nothing is registered", async () => { + const job = jobs(); + await job.start(request, "/tmp/project"); + await settle(); + expect(job.current()).toMatchObject({ state: "failed" }); + expect(JSON.stringify(job.current())).toContain("OpenCode provider is not connected"); + }); + + it("builds web import models through the registered factory", async () => { + const { host, created } = fakeHost(); + registerOpencodeHostModels(host); + const job = jobs(); + await job.start(request, "/tmp/project"); + await settle(); + expect(created).toEqual([ + { ref: { providerID: "zai", modelID: "glm/4.6" }, directory: "/tmp/project" }, + ]); + expect(JSON.stringify(job.current())).toContain("registered factory reached"); + }); +}); diff --git a/tests/opencode-profile-model.test.ts b/tests/opencode-profile-model.test.ts new file mode 100644 index 00000000..ce06c557 --- /dev/null +++ b/tests/opencode-profile-model.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, mock } from "bun:test"; +import { z } from "zod"; + +const structuredCalls: any[] = []; +const fakeClient = { tag: "opencode-client" }; + +mock.module("../src/adapters/opencode/opencode-provider-loader.js", () => ({ + loadOpencodeProvider: async () => ({ + generateStructuredOutput: async (args: any) => { + structuredCalls.push(args); + return args.schema.parse({ duplicate: true, reason: "same" }); + }, + resolveOpencodeModelRef: (ref: any) => ref, + getV2Client: () => null, + }), +})); +mock.module("../src/adapters/opencode/profile-llm-client.js", () => ({ + getOpenCodeClient: async () => fakeClient, +})); + +const { adaptOpencodeProfileModel } = await import("../src/adapters/opencode/profile-model.js"); + +const hostModel = { providerID: "openai", modelID: "gpt-test" }; + +describe("adaptOpencodeProfileModel", () => { + it("sends structured calls to OpenCode structured output with the same prompts", async () => { + const port = adaptOpencodeProfileModel({}, hostModel); + const schema = z.object({ duplicate: z.boolean(), reason: z.string() }); + + const result = await port.completeStructured!("system prompt", "user prompt", schema); + + expect(result).toEqual({ duplicate: true, reason: "same" }); + expect(port.provider).toBe("openai"); + expect(port.modelId).toBe("gpt-test"); + expect(structuredCalls).toHaveLength(1); + expect(structuredCalls[0]).toMatchObject({ + client: fakeClient, + providerID: "openai", + modelID: "gpt-test", + systemPrompt: "system prompt", + userPrompt: "user prompt", + }); + expect(structuredCalls[0].schema).toBe(schema); + }); + + it("sends plain calls through a cleanup session and deletes it", async () => { + const calls: string[] = []; + let prompt: any; + const v2Client = { + session: { + create: async (args: any) => { + calls.push(`create:${args.title}`); + return { data: { id: "s1" } }; + }, + prompt: async (args: any) => { + prompt = args; + return { data: { info: {}, parts: [{ type: "text", text: '{"ok":true}' }] } }; + }, + delete: async (args: any) => { + calls.push(`delete:${args.sessionID}`); + }, + }, + }; + const port = adaptOpencodeProfileModel(v2Client, hostModel); + + expect(await port.complete("cleanup system", "cleanup user")).toBe('{"ok":true}'); + expect(calls).toEqual(["create:omms profile cleanup", "delete:s1"]); + expect(prompt).toMatchObject({ + sessionID: "s1", + model: hostModel, + system: "cleanup system", + parts: [{ type: "text", text: "cleanup user" }], + noReply: false, + }); + }); +}); diff --git a/tests/opencode-provider.test.ts b/tests/opencode-provider.test.ts index 00be44f6..7908b59a 100644 --- a/tests/opencode-provider.test.ts +++ b/tests/opencode-provider.test.ts @@ -17,7 +17,7 @@ import { STRUCTURED_OUTPUT_METADATA, STRUCTURED_OUTPUT_PERMISSIONS, STRUCTURED_OUTPUT_TOOLS, -} from "../src/services/ai/opencode-provider.js"; +} from "../src/adapters/opencode/opencode-provider.js"; const schema = z.object({ topic: z.string(), @@ -856,7 +856,9 @@ describe("generateStructuredOutput regression tests (issue #110)", () => { // We cannot easily reset the module, so we use a unique schema sentinel // and rely on the test ordering: this test only runs if the previous // tests did not leak base URL. The cleanest way: re-import the module. - const mod = await import(`../src/services/ai/opencode-provider.js?cachebust=${Math.random()}`); + const mod = await import( + `../src/adapters/opencode/opencode-provider.js?cachebust=${Math.random()}` + ); // createV2Client has the side effect of setting the base URL, so any // call from other tests would have populated it. We just verify that // the function exists and behaves consistently when called repeatedly @@ -892,7 +894,8 @@ describe("resolveOpencodeModelRef / inherit", () => { }); it("passes concrete model ids through unchanged", async () => { - const { resolveOpencodeModelRef } = await import("../src/services/ai/opencode-provider.js"); + const { resolveOpencodeModelRef } = + await import("../src/adapters/opencode/opencode-provider.js"); expect(resolveOpencodeModelRef({ providerID: "local", modelID: "h100/qwen:latest" })).toEqual({ providerID: "local", modelID: "h100/qwen:latest", @@ -900,7 +903,8 @@ describe("resolveOpencodeModelRef / inherit", () => { }); it("prefers the recorded prompt model when modelID is inherit", async () => { - const { resolveOpencodeModelRef } = await import("../src/services/ai/opencode-provider.js"); + const { resolveOpencodeModelRef } = + await import("../src/adapters/opencode/opencode-provider.js"); expect( resolveOpencodeModelRef({ providerID: "local", @@ -928,7 +932,8 @@ describe("resolveOpencodeModelRef / inherit", () => { }) ); - const { resolveOpencodeModelRef } = await import("../src/services/ai/opencode-provider.js"); + const { resolveOpencodeModelRef } = + await import("../src/adapters/opencode/opencode-provider.js"); expect(resolveOpencodeModelRef({ providerID: "local", modelID: "inherit" })).toEqual({ providerID: "local", modelID: "h100/qwen:latest", @@ -936,7 +941,8 @@ describe("resolveOpencodeModelRef / inherit", () => { }); it("throws a clear error when inherit has no prompt model and no recent list", async () => { - const { resolveOpencodeModelRef } = await import("../src/services/ai/opencode-provider.js"); + const { resolveOpencodeModelRef } = + await import("../src/adapters/opencode/opencode-provider.js"); expect(() => resolveOpencodeModelRef({ providerID: "local", modelID: "inherit" })).toThrow( /no session model was recorded and no recent OpenCode model is available/ ); diff --git a/tests/opencode-trace-startup.test.ts b/tests/opencode-trace-startup.test.ts index fd50af85..5354d21a 100644 --- a/tests/opencode-trace-startup.test.ts +++ b/tests/opencode-trace-startup.test.ts @@ -43,7 +43,7 @@ mock.module(${JSON.stringify(url("../src/services/tags.js"))}, () => ({ mock.module(${JSON.stringify(url("../src/services/auto-capture.js"))}, () => ({ performAutoCapture: async () => {}, })); -mock.module(${JSON.stringify(url("../src/services/user-memory-learning.js"))}, () => ({ +mock.module(${JSON.stringify(url("../src/adapters/opencode/profile-learning.js"))}, () => ({ performUserProfileLearning: async () => {}, })); mock.module(${JSON.stringify(url("../src/services/web-server.js"))}, () => ({ diff --git a/tests/pi-adapter-boundary.test.ts b/tests/pi-adapter-boundary.test.ts index 5d9277ef..3c93b176 100644 --- a/tests/pi-adapter-boundary.test.ts +++ b/tests/pi-adapter-boundary.test.ts @@ -10,6 +10,24 @@ function piAdapterFiles(): string[] { .map((file) => join(piAdapterDir, file)); } +// Any quoted host SDK specifier counts, so string-built specifiers are caught too. +const hostSdkSpecifier = /["'`]@(opencode-ai|earendil-works)\//; +const staticHostSdkImport = + /^\s*(import|export)\s+(?!type\b)[^;]*from\s+["']@(opencode-ai|earendil-works)\//m; +// Importer modules that read host data without the host running (ADR-011). +const importerSdkReaders = new Set([ + "import-readiness.ts", + "session-loader.ts", + "settings-models.ts", +]); +// Readers allowed a static SDK import because they are themselves loaded only by import(). +const staticSdkReaders = new Set(["session-loader.ts"]); + +function tsFiles(dir: string): string[] { + const entries = readdirSync(join(import.meta.dir, "..", dir), { recursive: true }) as string[]; + return entries.filter((name) => name.endsWith(".ts")); +} + describe("Pi adapter boundary", () => { it("imports Pi package types only (no runtime Pi dependency)", () => { for (const file of piAdapterFiles()) { @@ -35,6 +53,45 @@ describe("Pi adapter boundary", () => { } }); + it("keeps host SDKs out of every shared core, services, and types file", () => { + const offenders: string[] = []; + for (const dir of ["src/core", "src/services", "src/types"]) { + for (const entry of tsFiles(dir)) { + const source = readFileSync(join(import.meta.dir, "..", dir, entry), "utf8"); + if (hostSdkSpecifier.test(source)) offenders.push(`${dir}/${entry}`); + } + } + expect(offenders).toEqual([]); + }); + + it("lets the importer load host SDKs only lazily, in named reader modules", () => { + const offenders: string[] = []; + for (const entry of tsFiles("src/importer")) { + const source = readFileSync(join(import.meta.dir, "../src/importer", entry), "utf8"); + if (!hostSdkSpecifier.test(source)) continue; + const staticImport = staticHostSdkImport.test(source); + if (!importerSdkReaders.has(entry) || (staticImport && !staticSdkReaders.has(entry))) { + offenders.push(`src/importer/${entry}`); + } + } + expect(offenders).toEqual([]); + + // A reader with a static SDK import must itself be loaded only by import(). + for (const dir of ["src/core", "src/services", "src/types", "src/importer"]) { + for (const entry of tsFiles(dir)) { + const source = readFileSync(join(import.meta.dir, "..", dir, entry), "utf8"); + for (const reader of staticSdkReaders) { + const name = reader.replace(/\.ts$/, ".js"); + const eager = new RegExp( + `^\\s*(import|export)\\s+(?!type\\b)[^;]*from\\s+["'][^"']*${name}["']`, + "m" + ); + expect(eager.test(source)).toBe(false); + } + } + } + }); + it("keeps the importer and Pi runtime out of the OpenCode entry points", () => { const openCodeEntryPaths = ["src/index.ts", "src/v2/adapter.ts", "src/v2/plugin.ts"]; for (const relative of openCodeEntryPaths) { @@ -54,6 +111,7 @@ describe("Pi adapter boundary", () => { 'import("../importer/web-import-jobs.js")', 'import("../importer/settings-health.js")', 'import("../importer/web-import-api.js")', + 'import("../importer/settings-models.js")', ]; for (const webImport of webImports) { expect(source).toContain(webImport); diff --git a/tests/plugin-bundle-boundary.test.ts b/tests/plugin-bundle-boundary.test.ts index 2e7191fd..8791a6a7 100644 --- a/tests/plugin-bundle-boundary.test.ts +++ b/tests/plugin-bundle-boundary.test.ts @@ -61,7 +61,7 @@ describe("OpenCode plugin loader bundle boundary", () => { }, 30_000); it("resolves the provider module from a single-file bundled lazy loader", async () => { - const { path, text } = buildDistEntry("./dist/services/ai/opencode-provider-loader.js"); + const { path, text } = buildDistEntry("./dist/adapters/opencode/opencode-provider-loader.js"); expect(text).not.toContain("@opencode-ai/sdk/v2/client"); expect(text).not.toContain("@vercel/oidc"); diff --git a/tests/plugin-host-config.test.ts b/tests/plugin-host-config.test.ts index 03d38f14..c61c03fa 100644 --- a/tests/plugin-host-config.test.ts +++ b/tests/plugin-host-config.test.ts @@ -9,7 +9,7 @@ import { isInternalCaptureSessionTitle, isStructuredSummaryPromptMessage, } from "../src/index.js"; -import { getHostClientConfig } from "../src/services/ai/opencode-host-config.js"; +import { getHostClientConfig } from "../src/adapters/opencode/opencode-host-config.js"; import { createV2Client, generateStructuredOutput, @@ -17,7 +17,7 @@ import { setHostFetch, STRUCTURED_OUTPUT_AGENT, STRUCTURED_OUTPUT_TOOLS, -} from "../src/services/ai/opencode-provider.js"; +} from "../src/adapters/opencode/opencode-provider.js"; import { z } from "zod"; function sdkService(config: Record): Record { diff --git a/tests/profile-model-port.test.ts b/tests/profile-model-port.test.ts new file mode 100644 index 00000000..20740ac2 --- /dev/null +++ b/tests/profile-model-port.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeAll, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; +import { CONFIG } from "../src/config.js"; +import type { ModelPort } from "../src/core/profile-analysis.js"; +import { + completeStructured, + registerHostProfileModel, +} from "../src/services/user-profile/profile-model.js"; + +const DEDUP_SYSTEM = "You are a semantic duplicate detector. Output valid JSON."; +const CONFLICT_SYSTEM = "You are a preference contradiction detector. Output valid JSON."; + +const storage = mkdtempSync(join(tmpdir(), "omms-profile-model-port-")); +const original = { + storagePath: CONFIG.storagePath, + memoryModel: CONFIG.memoryModel, + memoryApiUrl: CONFIG.memoryApiUrl, + fetch: globalThis.fetch, +}; +let manager: any; + +beforeAll(async () => { + CONFIG.storagePath = storage; + const { UserProfileManager } = + await import("../src/services/user-profile/user-profile-manager.js"); + manager = new UserProfileManager(); +}); + +afterEach(() => { + registerHostProfileModel(null); + CONFIG.memoryModel = original.memoryModel; + CONFIG.memoryApiUrl = original.memoryApiUrl; + globalThis.fetch = original.fetch; +}); + +process.on("exit", () => { + CONFIG.storagePath = original.storagePath; + rmSync(storage, { recursive: true, force: true }); +}); + +function recordingPort(reply: unknown) { + const calls: Array<{ system: string; user: string }> = []; + const port: ModelPort = { + provider: "openai", + modelId: "gpt-test", + complete: async () => { + throw new Error("complete must not be called when structured output exists"); + }, + async completeStructured(system, user, schema) { + calls.push({ system, user }); + return schema.parse(reply); + }, + }; + return { port, calls }; +} + +function externalApi(content: unknown) { + const bodies: any[] = []; + CONFIG.memoryModel = "gpt-ext"; + CONFIG.memoryApiUrl = "http://example.test/v1"; + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body))); + return new Response( + JSON.stringify({ choices: [{ message: { content: JSON.stringify(content) } }] }) + ); + }) as typeof fetch; + return bodies; +} + +describe("profile manager model routing", () => { + it("sends the duplicate check to the registered host model", async () => { + const { port, calls } = recordingPort({ duplicate: true, reason: "same" }); + registerHostProfileModel(async () => port); + const bodies = externalApi({ duplicate: false }); + + expect(await manager.checkSemanticDuplicate("likes tabs", "prefers tabs")).toBe(true); + expect(calls).toHaveLength(1); + expect(calls[0]!.system).toBe(DEDUP_SYSTEM); + expect(calls[0]!.user).toContain('A: "likes tabs"'); + expect(calls[0]!.user).toContain('B: "prefers tabs"'); + expect(bodies).toHaveLength(0); + }); + + it("sends the conflict check to the registered host model", async () => { + const { port, calls } = recordingPort({ conflict: true, reason: "opposite" }); + registerHostProfileModel(async () => port); + externalApi({ conflict: false }); + + expect(await manager.checkConflict("likes tabs", "hates tabs")).toBe(true); + expect(calls[0]!.system).toBe(CONFLICT_SYSTEM); + }); + + it("uses the external API with the same prompt when no host model is registered", async () => { + const { port, calls } = recordingPort({ duplicate: false, reason: "different" }); + const bodies = externalApi({ duplicate: true }); + + expect(await manager.checkSemanticDuplicate("likes tabs", "prefers tabs")).toBe(true); + registerHostProfileModel(async () => port); + await manager.checkSemanticDuplicate("likes tabs", "prefers tabs"); + + expect(bodies).toHaveLength(1); + expect(bodies[0].messages[0].content).toBe(DEDUP_SYSTEM); + expect(bodies[0].messages[1].content).toBe(calls[0]!.user); + }); + + it("uses the external API when the registered host resolves no model", async () => { + registerHostProfileModel(async () => null); + const bodies = externalApi({ conflict: true }); + + expect(await manager.checkConflict("likes tabs", "hates tabs")).toBe(true); + expect(bodies).toHaveLength(1); + }); + + it("uses the external API when the host model cannot be resolved", async () => { + registerHostProfileModel(async () => { + throw new Error("provider module failed to load"); + }); + const bodies = externalApi({ conflict: true }); + + expect(await manager.checkConflict("likes tabs", "hates tabs")).toBe(true); + expect(bodies).toHaveLength(1); + }); + + it("falls back to the external API when the host model fails", async () => { + registerHostProfileModel(async () => ({ + provider: "openai", + modelId: "gpt-test", + complete: async () => "", + completeStructured: async () => { + throw new Error("provider not connected"); + }, + })); + const bodies = externalApi({ duplicate: true }); + + expect(await manager.checkSemanticDuplicate("a b c", "a b d")).toBe(true); + expect(bodies).toHaveLength(1); + }); +}); + +describe("completeStructured", () => { + it("parses and validates a plain-text reply when the port has no structured output", async () => { + const port: ModelPort = { + provider: "pi", + modelId: "m", + complete: async () => 'Sure: {"description": "Prefers tabs"}', + }; + const schema = z.object({ description: z.string() }); + expect(await completeStructured(port, "s", "u", schema)).toEqual({ + description: "Prefers tabs", + }); + await expect( + completeStructured({ ...port, complete: async () => '{"other": 1}' }, "s", "u", schema) + ).rejects.toThrow(); + }); +}); diff --git a/tests/settings-models.test.ts b/tests/settings-models.test.ts index 81dd4463..86cb2216 100644 --- a/tests/settings-models.test.ts +++ b/tests/settings-models.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test"; import { listPiSettingsModels, listOpencodeSettingsModels, -} from "../src/services/settings-models.js"; +} from "../src/importer/settings-models.js"; describe("settings model lists", () => { it("lists connected OpenCode provider models", async () => { diff --git a/tests/tool-scope.test.ts b/tests/tool-scope.test.ts index d2d5ffee..c6937e02 100644 --- a/tests/tool-scope.test.ts +++ b/tests/tool-scope.test.ts @@ -18,7 +18,7 @@ const tagsUrl = new URL("../src/services/tags.js", import.meta.url).href; const contextUrl = new URL("../src/services/context.js", import.meta.url).href; const privacyUrl = new URL("../src/services/privacy.js", import.meta.url).href; const autoCaptureUrl = new URL("../src/services/auto-capture.js", import.meta.url).href; -const learningUrl = new URL("../src/services/user-memory-learning.js", import.meta.url).href; +const learningUrl = new URL("../src/adapters/opencode/profile-learning.js", import.meta.url).href; const promptManagerUrl = new URL( "../src/services/user-prompt/user-prompt-manager.js", import.meta.url diff --git a/tests/user-profile-learning-confidence.test.ts b/tests/user-profile-learning-confidence.test.ts index 027358fa..627da2dc 100644 --- a/tests/user-profile-learning-confidence.test.ts +++ b/tests/user-profile-learning-confidence.test.ts @@ -5,7 +5,7 @@ import { createUserProfileToolSchema, shouldRunAutomaticProfileCleanup, USER_PROFILE_LLM_CONFIDENCE_MAX, -} from "../src/services/user-memory-learning.js"; +} from "../src/adapters/opencode/profile-learning.js"; import { UserProfileValidator } from "../src/services/ai/validators/user-profile-validator.js"; describe("user-profile-learning confidence schema (#231)", () => { diff --git a/tests/user-profile-learning-error.test.ts b/tests/user-profile-learning-error.test.ts index 7b4f6d1e..2b6c075b 100644 --- a/tests/user-profile-learning-error.test.ts +++ b/tests/user-profile-learning-error.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; const tempDirs: string[] = []; -const learningUrl = new URL("../src/services/user-memory-learning.js", import.meta.url).href; +const learningUrl = new URL("../src/adapters/opencode/profile-learning.js", import.meta.url).href; const configUrl = new URL("../src/config.js", import.meta.url).href; const tagsUrl = new URL("../src/services/tags.js", import.meta.url).href; const promptManagerUrl = new URL( @@ -17,11 +17,13 @@ const profileManagerUrl = new URL( import.meta.url ).href; const opencodeProviderLoaderUrl = new URL( - "../src/services/ai/opencode-provider-loader.js", + "../src/adapters/opencode/opencode-provider-loader.js", + import.meta.url +).href; +const profileLlmClientUrl = new URL( + "../src/adapters/opencode/profile-llm-client.js", import.meta.url ).href; -const profileLlmClientUrl = new URL("../src/services/ai/profile-llm-client.js", import.meta.url) - .href; const loggerUrl = new URL("../src/services/logger.js", import.meta.url).href; function runProviderFailureScenario() { diff --git a/tests/v2-legacy-client.test.ts b/tests/v2-legacy-client.test.ts index e8efc2b9..00c7f3c0 100644 --- a/tests/v2-legacy-client.test.ts +++ b/tests/v2-legacy-client.test.ts @@ -44,7 +44,7 @@ describe("OpenCode v2 legacy client bridge", () => { it("lists each connected provider's models for the Settings page", async () => { const client = createLegacyClient(createContext()); - const { listOpencodeSettingsModels } = await import("../src/services/settings-models.js"); + const { listOpencodeSettingsModels } = await import("../src/importer/settings-models.js"); const listed = await listOpencodeSettingsModels(client as never); expect(listed).toEqual({ available: true,