diff --git a/.claude/skills/nexus-model-updates/SKILL.md b/.claude/skills/nexus-model-updates/SKILL.md index ad0dcdc78..9e9d220a0 100644 --- a/.claude/skills/nexus-model-updates/SKILL.md +++ b/.claude/skills/nexus-model-updates/SKILL.md @@ -36,20 +36,24 @@ python3 .claude/skills/nexus-model-updates/scripts/check_model_registry.py \ models live in the image adapters' own catalogs, not in `Models.ts`, the structural gate does not see them, and the committed tool catalogs embed their enum. -5. **A Live / realtime voice model** (Google `*-live*`, OpenAI `gpt-realtime*`) +5. **A video generation model** — follow `protocols/add-video-model.md`. They + live in one static array in `src/services/llm/types/VideoTypes.ts`. The + structural gate does not see them, and an id missing from that array is + rejected even when the provider lists it. +6. **A Live / realtime voice model** (Google `*-live*`, OpenAI `gpt-realtime*`) — follow `protocols/add-realtime-voice-model.md`. These ids answer only over a WebSocket session, so they do not belong in `Models.ts`, the structural gate does not see them, and the provider smoke lane cannot prove them; the realtime smoke lane drives the shipped session instead. -6. **Before calling any of the above done** — run `protocols/verify-model.md`. You +7. **Before calling any of the above done** — run `protocols/verify-model.md`. You MUST get a zero exit from `scripts/check_model_registry.py --repo-root . `, and you MUST NOT report a model as working on the strength of a registry entry: the entry is a claim about an id, and only a live call tests it. -7. **End of a session that used this skill** — run `protocols/self-refine.md`. +8. **End of a session that used this skill** — run `protocols/self-refine.md`. ## Map -- `protocols/` the procedures: add-model, add-image-model, +- `protocols/` the procedures: add-model, add-image-model, add-video-model, add-realtime-voice-model, change-default, add-provider-registry, verify-model, self-refine. - `references/` mechanism, read on demand: `registry-anatomy.md` (what a diff --git a/.claude/skills/nexus-model-updates/protocols/add-image-model.md b/.claude/skills/nexus-model-updates/protocols/add-image-model.md index 2f78a018a..b36083d17 100644 --- a/.claude/skills/nexus-model-updates/protocols/add-image-model.md +++ b/.claude/skills/nexus-model-updates/protocols/add-image-model.md @@ -75,6 +75,11 @@ endpoint actually accepts, proven by one live generation through the adapter. the resulting 400 looks like a broken model. ## Guidelines +- Pattern: Google's image docs show only Interactions API samples, but + `GeminiImageAdapter` calls `generateContent`. Before you add a Gemini image + id, check that `supportedGenerationMethods` in the keyed Google listing + (`references/provider-listings.md`) includes `generateContent`. Without a + key, record the entry as unverified. - Pattern: take the output format from the response's `media_type`, not from what was requested — Gemini returns JPEG regardless. - Pattern: read the wire enum, not the UI enum. Google's `imageConfig.imageSize` diff --git a/.claude/skills/nexus-model-updates/protocols/add-video-model.md b/.claude/skills/nexus-model-updates/protocols/add-video-model.md new file mode 100644 index 000000000..3f9bc6cfd --- /dev/null +++ b/.claude/skills/nexus-model-updates/protocols/add-video-model.md @@ -0,0 +1,64 @@ +# Protocol: add-video-model + +Context: video generation models are in neither `Models.ts` nor +an image adapter. One static array, `VIDEO_MODELS` in +`src/services/llm/types/VideoTypes.ts`, holds them. It decides what the video +settings offer and what `generateVideo` accepts. A model missing from that +array is rejected, even when the provider's live listing has it. The +`generateVideo` tool schema carries no model enum, so tool catalogs do not +change, and the structural gate does not see these entries either. + +## Mission +A catalog entry that sends only aspect ratios, resolutions and durations the +provider accepts, on a transport the adapter already uses. + +## Steps + +1. **Check the transport first.** `GoogleVideoAdapter` uses + `models/:predictLongRunning`. Google's newer video models are served + only by the Interactions API (`POST /v1beta/interactions`), and that is + new adapter work, not a catalog entry. `OpenRouterVideoAdapter` speaks + OpenRouter's unified `POST /api/v1/videos`, so any model in its video + catalog uses the same request shape. + +2. **Source the facts.** + + ```bash + curl -s https://openrouter.ai/api/v1/videos/models | python3 -c ' + import json,sys + for m in json.load(sys.stdin)["data"]: + print(m["id"], m.get("supported_resolutions"), m.get("supported_aspect_ratios"), + m.get("supported_durations"), "audio", m.get("generate_audio"), + "frames", m.get("supported_frame_images"))' + ``` + + For Google direct, the parameter table on ai.google.dev/gemini-api/docs/video + is the source, and `/docs/deprecations` gives shutdown dates. Read it per + model: Veo on the Gemini API accepts `16:9`/`9:16` only, even though other + routes add `1:1`. + +3. **Fit the entry to the unions.** `VideoAspectRatio` and `VideoResolution` + are narrow, closed unions. Declare the intersection of the listing and the + union. A model with no value in the intersection (for example a `2K`-only + model) needs a type change and is not a catalog-only addition. OpenRouter's + resolution enum is case-sensitive (`4K`), and the adapter maps the internal + `4k` to it. Keep that mapping when you touch `submitJob`. + +4. **`supportsReferenceImage` means `input_references`** (reference-to-video) + on OpenRouter, not a first-frame image. A model whose listing names only + `supported_frame_images` and whose vendor does not document reference + images gets `false`. + +5. **Defaults.** `resolveDefaultVideoSelection` falls back to the first entry of + the first configured provider in `VIDEO_PROVIDER_PRIORITY`, and the shipped + `defaultVideoModel` in `src/types/llm/ProviderTypes.ts` is an independent + value. Keep each provider's first entry stable unless you mean to move the + fallback. When the shipped default moves, the `shipped video default` test in + `tests/unit/VideoGenerationService.test.ts` must still pass. + +6. **Live call.** `tests/debug/video-generation-live-smoke.test.ts` drives the + real adapters. Video generation is billed per second, so run it only with + the owner's go-ahead. Without that run, report the entry as unverified. + +## Next +`verify-model.md` steps 1–3, then `self-refine.md`. diff --git a/.claude/skills/nexus-model-updates/refinement-log.md b/.claude/skills/nexus-model-updates/refinement-log.md index 847c52b61..987755fa5 100644 --- a/.claude/skills/nexus-model-updates/refinement-log.md +++ b/.claude/skills/nexus-model-updates/refinement-log.md @@ -131,3 +131,5 @@ Append-only record of changes made by `protocols/self-refine.md`. Newest on top. - 2026-10-09 | Cost audit found a model cache-read rate still using the usual discount ratio despite a published exception. | Corrected the existing model price from the provider table and updated the regression expectation; no workflow change. | Files: AnthropicModels.ts, LLMCostCalculator.cacheRates.test.ts. - 2026-10-13: Authenticated model metadata can expose a lower output ceiling than streaming documentation. Used the keyed listing and kept transport-specific caps separate; no protocol change needed. + +- 2026-10-14 | Updating the image and video catalogs, the skill had no route for video models: they live in a single static `VIDEO_MODELS` array in `src/services/llm/types/VideoTypes.ts` that the gate does not see. Several problems in that file were only visible in the provider's listing: Veo routes declared `1:1`, which no Veo route accepts; the OpenRouter adapter sent `4k` to a case-sensitive `4K` enum; and Google's Veo 3.1 previews shut down on 2026-10-22 with an Interactions-API-only successor. Gemini image docs now show only Interactions samples while the adapter calls `generateContent`, and there was no Google key available to check `supportedGenerationMethods`. | Added `protocols/add-video-model.md` and a SKILL.md workflow row, plus a guideline in `add-image-model.md` to confirm `generateContent` before adding a Gemini image id. | Files: `SKILL.md`, `protocols/add-video-model.md`, `protocols/add-image-model.md`. diff --git a/.claude/skills/nexus-storage/references/failure-modes.md b/.claude/skills/nexus-storage/references/failure-modes.md index 8f942a476..9b0e03301 100644 --- a/.claude/skills/nexus-storage/references/failure-modes.md +++ b/.claude/skills/nexus-storage/references/failure-modes.md @@ -26,6 +26,23 @@ default, so an unknown type is dropped in silence during replay. Fix: write through the repository (event first, then cache) and add the `case` to the applier. Re-verify by rebuilding the cache again and confirming survival. +## "After Rebuild cache the old value is still shown until reload" + +Two separate causes; check which before fixing. + +- A read answered from the repository `QueryCache` (shared by every repository, + 60 s TTL) rather than SQLite. Anything that wipes or replays the database + (`StorageMaintenanceService.rebuildCache`, startup replay, `sync()`) must call + `getQueryCache().clear()` after the replay settles. Compare + `SELECT ... FROM workspaces` with `workspaceService.getWorkspace` in the + running plugin: a mismatch is this cause. +- A replayed event resolved a UNIQUE constraint with `INSERT OR REPLACE`, which + deletes the *other* row (same name, different id) together with its edits. + Appliers must decide name collisions explicitly; `WorkspaceEventApplier` + keeps both rows and disambiguates the later-created name. Replay shards are + listed in filename order, so any "first wins" rule has to compare timestamps + or ids, never arrival order. + ## "A list tool returns an entity, but loading it by ID or name says not found" Check `pluginStorage.migration.state` before blaming the point lookup. Affected diff --git a/.claude/skills/nexus-storage/refinement-log.md b/.claude/skills/nexus-storage/refinement-log.md index 6f5bff1dc..6d3a72d72 100644 --- a/.claude/skills/nexus-storage/refinement-log.md +++ b/.claude/skills/nexus-storage/refinement-log.md @@ -153,3 +153,9 @@ to 0). Edit `.skills/` and run `npm run sync:skills`; never edit a mirror. | ## 2026-10-13 — thinking disclosure implementation Targeted chat message writes continue through the existing adapter/repository event path; no schema or event-type additions. Missing targets fail explicitly. Existing storage guidance applied without a protocol correction; no change. + +## 2026-10-14 — Rebuild left stale reads; replay deleted same-name workspaces + +- Observation: "Rebuild cache" left `getWorkspace` answering from the QueryCache, and `WorkspaceEventApplier` replayed a same-name, different-id `workspace_created` with INSERT OR REPLACE, deleting the other workspace. Neither symptom was in failure-modes.md, and the "first wins" rule was order-dependent because shards replay in listing order. +- Change: added the "old value is still shown until reload" entry to references/failure-modes.md. +- Files: references/failure-modes.md, refinement-log.md diff --git a/.cline/skills/nexus-model-updates/SKILL.md b/.cline/skills/nexus-model-updates/SKILL.md index ad0dcdc78..9e9d220a0 100644 --- a/.cline/skills/nexus-model-updates/SKILL.md +++ b/.cline/skills/nexus-model-updates/SKILL.md @@ -36,20 +36,24 @@ python3 .claude/skills/nexus-model-updates/scripts/check_model_registry.py \ models live in the image adapters' own catalogs, not in `Models.ts`, the structural gate does not see them, and the committed tool catalogs embed their enum. -5. **A Live / realtime voice model** (Google `*-live*`, OpenAI `gpt-realtime*`) +5. **A video generation model** — follow `protocols/add-video-model.md`. They + live in one static array in `src/services/llm/types/VideoTypes.ts`. The + structural gate does not see them, and an id missing from that array is + rejected even when the provider lists it. +6. **A Live / realtime voice model** (Google `*-live*`, OpenAI `gpt-realtime*`) — follow `protocols/add-realtime-voice-model.md`. These ids answer only over a WebSocket session, so they do not belong in `Models.ts`, the structural gate does not see them, and the provider smoke lane cannot prove them; the realtime smoke lane drives the shipped session instead. -6. **Before calling any of the above done** — run `protocols/verify-model.md`. You +7. **Before calling any of the above done** — run `protocols/verify-model.md`. You MUST get a zero exit from `scripts/check_model_registry.py --repo-root . `, and you MUST NOT report a model as working on the strength of a registry entry: the entry is a claim about an id, and only a live call tests it. -7. **End of a session that used this skill** — run `protocols/self-refine.md`. +8. **End of a session that used this skill** — run `protocols/self-refine.md`. ## Map -- `protocols/` the procedures: add-model, add-image-model, +- `protocols/` the procedures: add-model, add-image-model, add-video-model, add-realtime-voice-model, change-default, add-provider-registry, verify-model, self-refine. - `references/` mechanism, read on demand: `registry-anatomy.md` (what a diff --git a/.cline/skills/nexus-model-updates/protocols/add-image-model.md b/.cline/skills/nexus-model-updates/protocols/add-image-model.md index 2f78a018a..b36083d17 100644 --- a/.cline/skills/nexus-model-updates/protocols/add-image-model.md +++ b/.cline/skills/nexus-model-updates/protocols/add-image-model.md @@ -75,6 +75,11 @@ endpoint actually accepts, proven by one live generation through the adapter. the resulting 400 looks like a broken model. ## Guidelines +- Pattern: Google's image docs show only Interactions API samples, but + `GeminiImageAdapter` calls `generateContent`. Before you add a Gemini image + id, check that `supportedGenerationMethods` in the keyed Google listing + (`references/provider-listings.md`) includes `generateContent`. Without a + key, record the entry as unverified. - Pattern: take the output format from the response's `media_type`, not from what was requested — Gemini returns JPEG regardless. - Pattern: read the wire enum, not the UI enum. Google's `imageConfig.imageSize` diff --git a/.cline/skills/nexus-model-updates/protocols/add-video-model.md b/.cline/skills/nexus-model-updates/protocols/add-video-model.md new file mode 100644 index 000000000..3f9bc6cfd --- /dev/null +++ b/.cline/skills/nexus-model-updates/protocols/add-video-model.md @@ -0,0 +1,64 @@ +# Protocol: add-video-model + +Context: video generation models are in neither `Models.ts` nor +an image adapter. One static array, `VIDEO_MODELS` in +`src/services/llm/types/VideoTypes.ts`, holds them. It decides what the video +settings offer and what `generateVideo` accepts. A model missing from that +array is rejected, even when the provider's live listing has it. The +`generateVideo` tool schema carries no model enum, so tool catalogs do not +change, and the structural gate does not see these entries either. + +## Mission +A catalog entry that sends only aspect ratios, resolutions and durations the +provider accepts, on a transport the adapter already uses. + +## Steps + +1. **Check the transport first.** `GoogleVideoAdapter` uses + `models/:predictLongRunning`. Google's newer video models are served + only by the Interactions API (`POST /v1beta/interactions`), and that is + new adapter work, not a catalog entry. `OpenRouterVideoAdapter` speaks + OpenRouter's unified `POST /api/v1/videos`, so any model in its video + catalog uses the same request shape. + +2. **Source the facts.** + + ```bash + curl -s https://openrouter.ai/api/v1/videos/models | python3 -c ' + import json,sys + for m in json.load(sys.stdin)["data"]: + print(m["id"], m.get("supported_resolutions"), m.get("supported_aspect_ratios"), + m.get("supported_durations"), "audio", m.get("generate_audio"), + "frames", m.get("supported_frame_images"))' + ``` + + For Google direct, the parameter table on ai.google.dev/gemini-api/docs/video + is the source, and `/docs/deprecations` gives shutdown dates. Read it per + model: Veo on the Gemini API accepts `16:9`/`9:16` only, even though other + routes add `1:1`. + +3. **Fit the entry to the unions.** `VideoAspectRatio` and `VideoResolution` + are narrow, closed unions. Declare the intersection of the listing and the + union. A model with no value in the intersection (for example a `2K`-only + model) needs a type change and is not a catalog-only addition. OpenRouter's + resolution enum is case-sensitive (`4K`), and the adapter maps the internal + `4k` to it. Keep that mapping when you touch `submitJob`. + +4. **`supportsReferenceImage` means `input_references`** (reference-to-video) + on OpenRouter, not a first-frame image. A model whose listing names only + `supported_frame_images` and whose vendor does not document reference + images gets `false`. + +5. **Defaults.** `resolveDefaultVideoSelection` falls back to the first entry of + the first configured provider in `VIDEO_PROVIDER_PRIORITY`, and the shipped + `defaultVideoModel` in `src/types/llm/ProviderTypes.ts` is an independent + value. Keep each provider's first entry stable unless you mean to move the + fallback. When the shipped default moves, the `shipped video default` test in + `tests/unit/VideoGenerationService.test.ts` must still pass. + +6. **Live call.** `tests/debug/video-generation-live-smoke.test.ts` drives the + real adapters. Video generation is billed per second, so run it only with + the owner's go-ahead. Without that run, report the entry as unverified. + +## Next +`verify-model.md` steps 1–3, then `self-refine.md`. diff --git a/.cline/skills/nexus-model-updates/refinement-log.md b/.cline/skills/nexus-model-updates/refinement-log.md index 847c52b61..987755fa5 100644 --- a/.cline/skills/nexus-model-updates/refinement-log.md +++ b/.cline/skills/nexus-model-updates/refinement-log.md @@ -131,3 +131,5 @@ Append-only record of changes made by `protocols/self-refine.md`. Newest on top. - 2026-10-09 | Cost audit found a model cache-read rate still using the usual discount ratio despite a published exception. | Corrected the existing model price from the provider table and updated the regression expectation; no workflow change. | Files: AnthropicModels.ts, LLMCostCalculator.cacheRates.test.ts. - 2026-10-13: Authenticated model metadata can expose a lower output ceiling than streaming documentation. Used the keyed listing and kept transport-specific caps separate; no protocol change needed. + +- 2026-10-14 | Updating the image and video catalogs, the skill had no route for video models: they live in a single static `VIDEO_MODELS` array in `src/services/llm/types/VideoTypes.ts` that the gate does not see. Several problems in that file were only visible in the provider's listing: Veo routes declared `1:1`, which no Veo route accepts; the OpenRouter adapter sent `4k` to a case-sensitive `4K` enum; and Google's Veo 3.1 previews shut down on 2026-10-22 with an Interactions-API-only successor. Gemini image docs now show only Interactions samples while the adapter calls `generateContent`, and there was no Google key available to check `supportedGenerationMethods`. | Added `protocols/add-video-model.md` and a SKILL.md workflow row, plus a guideline in `add-image-model.md` to confirm `generateContent` before adding a Gemini image id. | Files: `SKILL.md`, `protocols/add-video-model.md`, `protocols/add-image-model.md`. diff --git a/.cline/skills/nexus-storage/references/failure-modes.md b/.cline/skills/nexus-storage/references/failure-modes.md index 8f942a476..9b0e03301 100644 --- a/.cline/skills/nexus-storage/references/failure-modes.md +++ b/.cline/skills/nexus-storage/references/failure-modes.md @@ -26,6 +26,23 @@ default, so an unknown type is dropped in silence during replay. Fix: write through the repository (event first, then cache) and add the `case` to the applier. Re-verify by rebuilding the cache again and confirming survival. +## "After Rebuild cache the old value is still shown until reload" + +Two separate causes; check which before fixing. + +- A read answered from the repository `QueryCache` (shared by every repository, + 60 s TTL) rather than SQLite. Anything that wipes or replays the database + (`StorageMaintenanceService.rebuildCache`, startup replay, `sync()`) must call + `getQueryCache().clear()` after the replay settles. Compare + `SELECT ... FROM workspaces` with `workspaceService.getWorkspace` in the + running plugin: a mismatch is this cause. +- A replayed event resolved a UNIQUE constraint with `INSERT OR REPLACE`, which + deletes the *other* row (same name, different id) together with its edits. + Appliers must decide name collisions explicitly; `WorkspaceEventApplier` + keeps both rows and disambiguates the later-created name. Replay shards are + listed in filename order, so any "first wins" rule has to compare timestamps + or ids, never arrival order. + ## "A list tool returns an entity, but loading it by ID or name says not found" Check `pluginStorage.migration.state` before blaming the point lookup. Affected diff --git a/.cline/skills/nexus-storage/refinement-log.md b/.cline/skills/nexus-storage/refinement-log.md index 6f5bff1dc..6d3a72d72 100644 --- a/.cline/skills/nexus-storage/refinement-log.md +++ b/.cline/skills/nexus-storage/refinement-log.md @@ -153,3 +153,9 @@ to 0). Edit `.skills/` and run `npm run sync:skills`; never edit a mirror. | ## 2026-10-13 — thinking disclosure implementation Targeted chat message writes continue through the existing adapter/repository event path; no schema or event-type additions. Missing targets fail explicitly. Existing storage guidance applied without a protocol correction; no change. + +## 2026-10-14 — Rebuild left stale reads; replay deleted same-name workspaces + +- Observation: "Rebuild cache" left `getWorkspace` answering from the QueryCache, and `WorkspaceEventApplier` replayed a same-name, different-id `workspace_created` with INSERT OR REPLACE, deleting the other workspace. Neither symptom was in failure-modes.md, and the "first wins" rule was order-dependent because shards replay in listing order. +- Change: added the "old value is still shown until reload" entry to references/failure-modes.md. +- Files: references/failure-modes.md, refinement-log.md diff --git a/.codex/skills/nexus-model-updates/SKILL.md b/.codex/skills/nexus-model-updates/SKILL.md index ad0dcdc78..9e9d220a0 100644 --- a/.codex/skills/nexus-model-updates/SKILL.md +++ b/.codex/skills/nexus-model-updates/SKILL.md @@ -36,20 +36,24 @@ python3 .claude/skills/nexus-model-updates/scripts/check_model_registry.py \ models live in the image adapters' own catalogs, not in `Models.ts`, the structural gate does not see them, and the committed tool catalogs embed their enum. -5. **A Live / realtime voice model** (Google `*-live*`, OpenAI `gpt-realtime*`) +5. **A video generation model** — follow `protocols/add-video-model.md`. They + live in one static array in `src/services/llm/types/VideoTypes.ts`. The + structural gate does not see them, and an id missing from that array is + rejected even when the provider lists it. +6. **A Live / realtime voice model** (Google `*-live*`, OpenAI `gpt-realtime*`) — follow `protocols/add-realtime-voice-model.md`. These ids answer only over a WebSocket session, so they do not belong in `Models.ts`, the structural gate does not see them, and the provider smoke lane cannot prove them; the realtime smoke lane drives the shipped session instead. -6. **Before calling any of the above done** — run `protocols/verify-model.md`. You +7. **Before calling any of the above done** — run `protocols/verify-model.md`. You MUST get a zero exit from `scripts/check_model_registry.py --repo-root . `, and you MUST NOT report a model as working on the strength of a registry entry: the entry is a claim about an id, and only a live call tests it. -7. **End of a session that used this skill** — run `protocols/self-refine.md`. +8. **End of a session that used this skill** — run `protocols/self-refine.md`. ## Map -- `protocols/` the procedures: add-model, add-image-model, +- `protocols/` the procedures: add-model, add-image-model, add-video-model, add-realtime-voice-model, change-default, add-provider-registry, verify-model, self-refine. - `references/` mechanism, read on demand: `registry-anatomy.md` (what a diff --git a/.codex/skills/nexus-model-updates/protocols/add-image-model.md b/.codex/skills/nexus-model-updates/protocols/add-image-model.md index 2f78a018a..b36083d17 100644 --- a/.codex/skills/nexus-model-updates/protocols/add-image-model.md +++ b/.codex/skills/nexus-model-updates/protocols/add-image-model.md @@ -75,6 +75,11 @@ endpoint actually accepts, proven by one live generation through the adapter. the resulting 400 looks like a broken model. ## Guidelines +- Pattern: Google's image docs show only Interactions API samples, but + `GeminiImageAdapter` calls `generateContent`. Before you add a Gemini image + id, check that `supportedGenerationMethods` in the keyed Google listing + (`references/provider-listings.md`) includes `generateContent`. Without a + key, record the entry as unverified. - Pattern: take the output format from the response's `media_type`, not from what was requested — Gemini returns JPEG regardless. - Pattern: read the wire enum, not the UI enum. Google's `imageConfig.imageSize` diff --git a/.codex/skills/nexus-model-updates/protocols/add-video-model.md b/.codex/skills/nexus-model-updates/protocols/add-video-model.md new file mode 100644 index 000000000..3f9bc6cfd --- /dev/null +++ b/.codex/skills/nexus-model-updates/protocols/add-video-model.md @@ -0,0 +1,64 @@ +# Protocol: add-video-model + +Context: video generation models are in neither `Models.ts` nor +an image adapter. One static array, `VIDEO_MODELS` in +`src/services/llm/types/VideoTypes.ts`, holds them. It decides what the video +settings offer and what `generateVideo` accepts. A model missing from that +array is rejected, even when the provider's live listing has it. The +`generateVideo` tool schema carries no model enum, so tool catalogs do not +change, and the structural gate does not see these entries either. + +## Mission +A catalog entry that sends only aspect ratios, resolutions and durations the +provider accepts, on a transport the adapter already uses. + +## Steps + +1. **Check the transport first.** `GoogleVideoAdapter` uses + `models/:predictLongRunning`. Google's newer video models are served + only by the Interactions API (`POST /v1beta/interactions`), and that is + new adapter work, not a catalog entry. `OpenRouterVideoAdapter` speaks + OpenRouter's unified `POST /api/v1/videos`, so any model in its video + catalog uses the same request shape. + +2. **Source the facts.** + + ```bash + curl -s https://openrouter.ai/api/v1/videos/models | python3 -c ' + import json,sys + for m in json.load(sys.stdin)["data"]: + print(m["id"], m.get("supported_resolutions"), m.get("supported_aspect_ratios"), + m.get("supported_durations"), "audio", m.get("generate_audio"), + "frames", m.get("supported_frame_images"))' + ``` + + For Google direct, the parameter table on ai.google.dev/gemini-api/docs/video + is the source, and `/docs/deprecations` gives shutdown dates. Read it per + model: Veo on the Gemini API accepts `16:9`/`9:16` only, even though other + routes add `1:1`. + +3. **Fit the entry to the unions.** `VideoAspectRatio` and `VideoResolution` + are narrow, closed unions. Declare the intersection of the listing and the + union. A model with no value in the intersection (for example a `2K`-only + model) needs a type change and is not a catalog-only addition. OpenRouter's + resolution enum is case-sensitive (`4K`), and the adapter maps the internal + `4k` to it. Keep that mapping when you touch `submitJob`. + +4. **`supportsReferenceImage` means `input_references`** (reference-to-video) + on OpenRouter, not a first-frame image. A model whose listing names only + `supported_frame_images` and whose vendor does not document reference + images gets `false`. + +5. **Defaults.** `resolveDefaultVideoSelection` falls back to the first entry of + the first configured provider in `VIDEO_PROVIDER_PRIORITY`, and the shipped + `defaultVideoModel` in `src/types/llm/ProviderTypes.ts` is an independent + value. Keep each provider's first entry stable unless you mean to move the + fallback. When the shipped default moves, the `shipped video default` test in + `tests/unit/VideoGenerationService.test.ts` must still pass. + +6. **Live call.** `tests/debug/video-generation-live-smoke.test.ts` drives the + real adapters. Video generation is billed per second, so run it only with + the owner's go-ahead. Without that run, report the entry as unverified. + +## Next +`verify-model.md` steps 1–3, then `self-refine.md`. diff --git a/.codex/skills/nexus-model-updates/refinement-log.md b/.codex/skills/nexus-model-updates/refinement-log.md index 847c52b61..987755fa5 100644 --- a/.codex/skills/nexus-model-updates/refinement-log.md +++ b/.codex/skills/nexus-model-updates/refinement-log.md @@ -131,3 +131,5 @@ Append-only record of changes made by `protocols/self-refine.md`. Newest on top. - 2026-10-09 | Cost audit found a model cache-read rate still using the usual discount ratio despite a published exception. | Corrected the existing model price from the provider table and updated the regression expectation; no workflow change. | Files: AnthropicModels.ts, LLMCostCalculator.cacheRates.test.ts. - 2026-10-13: Authenticated model metadata can expose a lower output ceiling than streaming documentation. Used the keyed listing and kept transport-specific caps separate; no protocol change needed. + +- 2026-10-14 | Updating the image and video catalogs, the skill had no route for video models: they live in a single static `VIDEO_MODELS` array in `src/services/llm/types/VideoTypes.ts` that the gate does not see. Several problems in that file were only visible in the provider's listing: Veo routes declared `1:1`, which no Veo route accepts; the OpenRouter adapter sent `4k` to a case-sensitive `4K` enum; and Google's Veo 3.1 previews shut down on 2026-10-22 with an Interactions-API-only successor. Gemini image docs now show only Interactions samples while the adapter calls `generateContent`, and there was no Google key available to check `supportedGenerationMethods`. | Added `protocols/add-video-model.md` and a SKILL.md workflow row, plus a guideline in `add-image-model.md` to confirm `generateContent` before adding a Gemini image id. | Files: `SKILL.md`, `protocols/add-video-model.md`, `protocols/add-image-model.md`. diff --git a/.codex/skills/nexus-storage/references/failure-modes.md b/.codex/skills/nexus-storage/references/failure-modes.md index 8f942a476..9b0e03301 100644 --- a/.codex/skills/nexus-storage/references/failure-modes.md +++ b/.codex/skills/nexus-storage/references/failure-modes.md @@ -26,6 +26,23 @@ default, so an unknown type is dropped in silence during replay. Fix: write through the repository (event first, then cache) and add the `case` to the applier. Re-verify by rebuilding the cache again and confirming survival. +## "After Rebuild cache the old value is still shown until reload" + +Two separate causes; check which before fixing. + +- A read answered from the repository `QueryCache` (shared by every repository, + 60 s TTL) rather than SQLite. Anything that wipes or replays the database + (`StorageMaintenanceService.rebuildCache`, startup replay, `sync()`) must call + `getQueryCache().clear()` after the replay settles. Compare + `SELECT ... FROM workspaces` with `workspaceService.getWorkspace` in the + running plugin: a mismatch is this cause. +- A replayed event resolved a UNIQUE constraint with `INSERT OR REPLACE`, which + deletes the *other* row (same name, different id) together with its edits. + Appliers must decide name collisions explicitly; `WorkspaceEventApplier` + keeps both rows and disambiguates the later-created name. Replay shards are + listed in filename order, so any "first wins" rule has to compare timestamps + or ids, never arrival order. + ## "A list tool returns an entity, but loading it by ID or name says not found" Check `pluginStorage.migration.state` before blaming the point lookup. Affected diff --git a/.codex/skills/nexus-storage/refinement-log.md b/.codex/skills/nexus-storage/refinement-log.md index 6f5bff1dc..6d3a72d72 100644 --- a/.codex/skills/nexus-storage/refinement-log.md +++ b/.codex/skills/nexus-storage/refinement-log.md @@ -153,3 +153,9 @@ to 0). Edit `.skills/` and run `npm run sync:skills`; never edit a mirror. | ## 2026-10-13 — thinking disclosure implementation Targeted chat message writes continue through the existing adapter/repository event path; no schema or event-type additions. Missing targets fail explicitly. Existing storage guidance applied without a protocol correction; no change. + +## 2026-10-14 — Rebuild left stale reads; replay deleted same-name workspaces + +- Observation: "Rebuild cache" left `getWorkspace` answering from the QueryCache, and `WorkspaceEventApplier` replayed a same-name, different-id `workspace_created` with INSERT OR REPLACE, deleting the other workspace. Neither symptom was in failure-modes.md, and the "first wins" rule was order-dependent because shards replay in listing order. +- Change: added the "old value is still shown until reload" entry to references/failure-modes.md. +- Files: references/failure-modes.md, refinement-log.md diff --git a/.skills/nexus-model-updates/SKILL.md b/.skills/nexus-model-updates/SKILL.md index ad0dcdc78..9e9d220a0 100644 --- a/.skills/nexus-model-updates/SKILL.md +++ b/.skills/nexus-model-updates/SKILL.md @@ -36,20 +36,24 @@ python3 .claude/skills/nexus-model-updates/scripts/check_model_registry.py \ models live in the image adapters' own catalogs, not in `Models.ts`, the structural gate does not see them, and the committed tool catalogs embed their enum. -5. **A Live / realtime voice model** (Google `*-live*`, OpenAI `gpt-realtime*`) +5. **A video generation model** — follow `protocols/add-video-model.md`. They + live in one static array in `src/services/llm/types/VideoTypes.ts`. The + structural gate does not see them, and an id missing from that array is + rejected even when the provider lists it. +6. **A Live / realtime voice model** (Google `*-live*`, OpenAI `gpt-realtime*`) — follow `protocols/add-realtime-voice-model.md`. These ids answer only over a WebSocket session, so they do not belong in `Models.ts`, the structural gate does not see them, and the provider smoke lane cannot prove them; the realtime smoke lane drives the shipped session instead. -6. **Before calling any of the above done** — run `protocols/verify-model.md`. You +7. **Before calling any of the above done** — run `protocols/verify-model.md`. You MUST get a zero exit from `scripts/check_model_registry.py --repo-root . `, and you MUST NOT report a model as working on the strength of a registry entry: the entry is a claim about an id, and only a live call tests it. -7. **End of a session that used this skill** — run `protocols/self-refine.md`. +8. **End of a session that used this skill** — run `protocols/self-refine.md`. ## Map -- `protocols/` the procedures: add-model, add-image-model, +- `protocols/` the procedures: add-model, add-image-model, add-video-model, add-realtime-voice-model, change-default, add-provider-registry, verify-model, self-refine. - `references/` mechanism, read on demand: `registry-anatomy.md` (what a diff --git a/.skills/nexus-model-updates/protocols/add-image-model.md b/.skills/nexus-model-updates/protocols/add-image-model.md index 2f78a018a..b36083d17 100644 --- a/.skills/nexus-model-updates/protocols/add-image-model.md +++ b/.skills/nexus-model-updates/protocols/add-image-model.md @@ -75,6 +75,11 @@ endpoint actually accepts, proven by one live generation through the adapter. the resulting 400 looks like a broken model. ## Guidelines +- Pattern: Google's image docs show only Interactions API samples, but + `GeminiImageAdapter` calls `generateContent`. Before you add a Gemini image + id, check that `supportedGenerationMethods` in the keyed Google listing + (`references/provider-listings.md`) includes `generateContent`. Without a + key, record the entry as unverified. - Pattern: take the output format from the response's `media_type`, not from what was requested — Gemini returns JPEG regardless. - Pattern: read the wire enum, not the UI enum. Google's `imageConfig.imageSize` diff --git a/.skills/nexus-model-updates/protocols/add-video-model.md b/.skills/nexus-model-updates/protocols/add-video-model.md new file mode 100644 index 000000000..3f9bc6cfd --- /dev/null +++ b/.skills/nexus-model-updates/protocols/add-video-model.md @@ -0,0 +1,64 @@ +# Protocol: add-video-model + +Context: video generation models are in neither `Models.ts` nor +an image adapter. One static array, `VIDEO_MODELS` in +`src/services/llm/types/VideoTypes.ts`, holds them. It decides what the video +settings offer and what `generateVideo` accepts. A model missing from that +array is rejected, even when the provider's live listing has it. The +`generateVideo` tool schema carries no model enum, so tool catalogs do not +change, and the structural gate does not see these entries either. + +## Mission +A catalog entry that sends only aspect ratios, resolutions and durations the +provider accepts, on a transport the adapter already uses. + +## Steps + +1. **Check the transport first.** `GoogleVideoAdapter` uses + `models/:predictLongRunning`. Google's newer video models are served + only by the Interactions API (`POST /v1beta/interactions`), and that is + new adapter work, not a catalog entry. `OpenRouterVideoAdapter` speaks + OpenRouter's unified `POST /api/v1/videos`, so any model in its video + catalog uses the same request shape. + +2. **Source the facts.** + + ```bash + curl -s https://openrouter.ai/api/v1/videos/models | python3 -c ' + import json,sys + for m in json.load(sys.stdin)["data"]: + print(m["id"], m.get("supported_resolutions"), m.get("supported_aspect_ratios"), + m.get("supported_durations"), "audio", m.get("generate_audio"), + "frames", m.get("supported_frame_images"))' + ``` + + For Google direct, the parameter table on ai.google.dev/gemini-api/docs/video + is the source, and `/docs/deprecations` gives shutdown dates. Read it per + model: Veo on the Gemini API accepts `16:9`/`9:16` only, even though other + routes add `1:1`. + +3. **Fit the entry to the unions.** `VideoAspectRatio` and `VideoResolution` + are narrow, closed unions. Declare the intersection of the listing and the + union. A model with no value in the intersection (for example a `2K`-only + model) needs a type change and is not a catalog-only addition. OpenRouter's + resolution enum is case-sensitive (`4K`), and the adapter maps the internal + `4k` to it. Keep that mapping when you touch `submitJob`. + +4. **`supportsReferenceImage` means `input_references`** (reference-to-video) + on OpenRouter, not a first-frame image. A model whose listing names only + `supported_frame_images` and whose vendor does not document reference + images gets `false`. + +5. **Defaults.** `resolveDefaultVideoSelection` falls back to the first entry of + the first configured provider in `VIDEO_PROVIDER_PRIORITY`, and the shipped + `defaultVideoModel` in `src/types/llm/ProviderTypes.ts` is an independent + value. Keep each provider's first entry stable unless you mean to move the + fallback. When the shipped default moves, the `shipped video default` test in + `tests/unit/VideoGenerationService.test.ts` must still pass. + +6. **Live call.** `tests/debug/video-generation-live-smoke.test.ts` drives the + real adapters. Video generation is billed per second, so run it only with + the owner's go-ahead. Without that run, report the entry as unverified. + +## Next +`verify-model.md` steps 1–3, then `self-refine.md`. diff --git a/.skills/nexus-model-updates/refinement-log.md b/.skills/nexus-model-updates/refinement-log.md index 847c52b61..987755fa5 100644 --- a/.skills/nexus-model-updates/refinement-log.md +++ b/.skills/nexus-model-updates/refinement-log.md @@ -131,3 +131,5 @@ Append-only record of changes made by `protocols/self-refine.md`. Newest on top. - 2026-10-09 | Cost audit found a model cache-read rate still using the usual discount ratio despite a published exception. | Corrected the existing model price from the provider table and updated the regression expectation; no workflow change. | Files: AnthropicModels.ts, LLMCostCalculator.cacheRates.test.ts. - 2026-10-13: Authenticated model metadata can expose a lower output ceiling than streaming documentation. Used the keyed listing and kept transport-specific caps separate; no protocol change needed. + +- 2026-10-14 | Updating the image and video catalogs, the skill had no route for video models: they live in a single static `VIDEO_MODELS` array in `src/services/llm/types/VideoTypes.ts` that the gate does not see. Several problems in that file were only visible in the provider's listing: Veo routes declared `1:1`, which no Veo route accepts; the OpenRouter adapter sent `4k` to a case-sensitive `4K` enum; and Google's Veo 3.1 previews shut down on 2026-10-22 with an Interactions-API-only successor. Gemini image docs now show only Interactions samples while the adapter calls `generateContent`, and there was no Google key available to check `supportedGenerationMethods`. | Added `protocols/add-video-model.md` and a SKILL.md workflow row, plus a guideline in `add-image-model.md` to confirm `generateContent` before adding a Gemini image id. | Files: `SKILL.md`, `protocols/add-video-model.md`, `protocols/add-image-model.md`. diff --git a/.skills/nexus-storage/references/failure-modes.md b/.skills/nexus-storage/references/failure-modes.md index 8f942a476..9b0e03301 100644 --- a/.skills/nexus-storage/references/failure-modes.md +++ b/.skills/nexus-storage/references/failure-modes.md @@ -26,6 +26,23 @@ default, so an unknown type is dropped in silence during replay. Fix: write through the repository (event first, then cache) and add the `case` to the applier. Re-verify by rebuilding the cache again and confirming survival. +## "After Rebuild cache the old value is still shown until reload" + +Two separate causes; check which before fixing. + +- A read answered from the repository `QueryCache` (shared by every repository, + 60 s TTL) rather than SQLite. Anything that wipes or replays the database + (`StorageMaintenanceService.rebuildCache`, startup replay, `sync()`) must call + `getQueryCache().clear()` after the replay settles. Compare + `SELECT ... FROM workspaces` with `workspaceService.getWorkspace` in the + running plugin: a mismatch is this cause. +- A replayed event resolved a UNIQUE constraint with `INSERT OR REPLACE`, which + deletes the *other* row (same name, different id) together with its edits. + Appliers must decide name collisions explicitly; `WorkspaceEventApplier` + keeps both rows and disambiguates the later-created name. Replay shards are + listed in filename order, so any "first wins" rule has to compare timestamps + or ids, never arrival order. + ## "A list tool returns an entity, but loading it by ID or name says not found" Check `pluginStorage.migration.state` before blaming the point lookup. Affected diff --git a/.skills/nexus-storage/refinement-log.md b/.skills/nexus-storage/refinement-log.md index 6f5bff1dc..6d3a72d72 100644 --- a/.skills/nexus-storage/refinement-log.md +++ b/.skills/nexus-storage/refinement-log.md @@ -153,3 +153,9 @@ to 0). Edit `.skills/` and run `npm run sync:skills`; never edit a mirror. | ## 2026-10-13 — thinking disclosure implementation Targeted chat message writes continue through the existing adapter/repository event path; no schema or event-type additions. Missing targets fail explicitly. Existing storage guidance applied without a protocol correction; no change. + +## 2026-10-14 — Rebuild left stale reads; replay deleted same-name workspaces + +- Observation: "Rebuild cache" left `getWorkspace` answering from the QueryCache, and `WorkspaceEventApplier` replayed a same-name, different-id `workspace_created` with INSERT OR REPLACE, deleting the other workspace. Neither symptom was in failure-modes.md, and the "first wins" rule was order-dependent because shards replay in listing order. +- Change: added the "old value is still shown until reload" entry to references/failure-modes.md. +- Files: references/failure-modes.md, refinement-log.md diff --git a/cli-first-tool-schemas.json b/cli-first-tool-schemas.json index 22351f2e2..b9ee3e1df 100644 --- a/cli-first-tool-schemas.json +++ b/cli-first-tool-schemas.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-10-13T18:31:18.516Z", + "generatedAt": "2026-10-14T17:23:45.944Z", "selector": "--help", "toolCount": 80, "agentCount": 14, @@ -1787,7 +1787,7 @@ "type": "string", "required": false, "positional": false, - "description": "[OPTIONAL] Image generation model (default: gemini-3.1-flash-image). Available: gemini-3.1-flash-lite-image, gemini-3.1-flash-image, gemini-3-pro-image, gemini-3.1-flash-image-preview, gemini-3-pro-image-preview, gemini-2.5-flash-image, gpt-5-image, gpt-5.4-image-2, flux-2-pro, flux-2-flex, flux-2-klein-4b, gpt-5-image-mini, gpt-image-2, seedream-4.5, seedream-5-lite, gpt-image-2.5-sunburst, gpt-image-2.5-flare, gpt-image-1.5, gpt-image-1, gpt-image-1-mini (One of: [\"gemini-3.1-flash-lite-image\", \"gemini-3.1-flash-image\", \"gemini-3-pro-image\", \"gemini-3.1-flash-image-preview\", \"gemini-3-pro-image-preview\", \"gemini-2.5-flash-image\", \"gpt-5-image\", \"gpt-5.4-image-2\", \"flux-2-pro\", \"flux-2-flex\", \"flux-2-klein-4b\", \"gpt-5-image-mini\", \"gpt-image-2\", \"seedream-4.5\", \"seedream-5-lite\", \"gpt-image-2.5-sunburst\", \"gpt-image-2.5-flare\", \"gpt-image-1.5\", \"gpt-image-1\", \"gpt-image-1-mini\"])" + "description": "[OPTIONAL] Image generation model (default: gemini-3.1-flash-image). Available: gemini-3.1-flash-lite-image, gemini-3.1-flash-image, gemini-nano-banana-2.1, gemini-3-pro-image, gemini-3.1-flash-image-preview, gemini-3-pro-image-preview, gemini-2.5-flash-image, gpt-5-image, gpt-5.4-image-2, flux-2-pro, flux-2-flex, flux-2-klein-4b, gpt-5-image-mini, gpt-image-2, seedream-4.5, seedream-5-lite, gpt-image-2.5-sunburst, gpt-image-2.5-flare, flux-3-image, seedream-5-pro, seedream-5-flash, gpt-image-1.5, gpt-image-1, gpt-image-1-mini (One of: [\"gemini-3.1-flash-lite-image\", \"gemini-3.1-flash-image\", \"gemini-nano-banana-2.1\", \"gemini-3-pro-image\", \"gemini-3.1-flash-image-preview\", \"gemini-3-pro-image-preview\", \"gemini-2.5-flash-image\", \"gpt-5-image\", \"gpt-5.4-image-2\", \"flux-2-pro\", \"flux-2-flex\", \"flux-2-klein-4b\", \"gpt-5-image-mini\", \"gpt-image-2\", \"seedream-4.5\", \"seedream-5-lite\", \"gpt-image-2.5-sunburst\", \"gpt-image-2.5-flare\", \"flux-3-image\", \"seedream-5-pro\", \"seedream-5-flash\", \"gpt-image-1.5\", \"gpt-image-1\", \"gpt-image-1-mini\"])" }, { "name": "aspectRatio", diff --git a/schemas/5.20.4/cli-tools.json b/schemas/5.20.4/cli-tools.json index 22351f2e2..b9ee3e1df 100644 --- a/schemas/5.20.4/cli-tools.json +++ b/schemas/5.20.4/cli-tools.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-10-13T18:31:18.516Z", + "generatedAt": "2026-10-14T17:23:45.944Z", "selector": "--help", "toolCount": 80, "agentCount": 14, @@ -1787,7 +1787,7 @@ "type": "string", "required": false, "positional": false, - "description": "[OPTIONAL] Image generation model (default: gemini-3.1-flash-image). Available: gemini-3.1-flash-lite-image, gemini-3.1-flash-image, gemini-3-pro-image, gemini-3.1-flash-image-preview, gemini-3-pro-image-preview, gemini-2.5-flash-image, gpt-5-image, gpt-5.4-image-2, flux-2-pro, flux-2-flex, flux-2-klein-4b, gpt-5-image-mini, gpt-image-2, seedream-4.5, seedream-5-lite, gpt-image-2.5-sunburst, gpt-image-2.5-flare, gpt-image-1.5, gpt-image-1, gpt-image-1-mini (One of: [\"gemini-3.1-flash-lite-image\", \"gemini-3.1-flash-image\", \"gemini-3-pro-image\", \"gemini-3.1-flash-image-preview\", \"gemini-3-pro-image-preview\", \"gemini-2.5-flash-image\", \"gpt-5-image\", \"gpt-5.4-image-2\", \"flux-2-pro\", \"flux-2-flex\", \"flux-2-klein-4b\", \"gpt-5-image-mini\", \"gpt-image-2\", \"seedream-4.5\", \"seedream-5-lite\", \"gpt-image-2.5-sunburst\", \"gpt-image-2.5-flare\", \"gpt-image-1.5\", \"gpt-image-1\", \"gpt-image-1-mini\"])" + "description": "[OPTIONAL] Image generation model (default: gemini-3.1-flash-image). Available: gemini-3.1-flash-lite-image, gemini-3.1-flash-image, gemini-nano-banana-2.1, gemini-3-pro-image, gemini-3.1-flash-image-preview, gemini-3-pro-image-preview, gemini-2.5-flash-image, gpt-5-image, gpt-5.4-image-2, flux-2-pro, flux-2-flex, flux-2-klein-4b, gpt-5-image-mini, gpt-image-2, seedream-4.5, seedream-5-lite, gpt-image-2.5-sunburst, gpt-image-2.5-flare, flux-3-image, seedream-5-pro, seedream-5-flash, gpt-image-1.5, gpt-image-1, gpt-image-1-mini (One of: [\"gemini-3.1-flash-lite-image\", \"gemini-3.1-flash-image\", \"gemini-nano-banana-2.1\", \"gemini-3-pro-image\", \"gemini-3.1-flash-image-preview\", \"gemini-3-pro-image-preview\", \"gemini-2.5-flash-image\", \"gpt-5-image\", \"gpt-5.4-image-2\", \"flux-2-pro\", \"flux-2-flex\", \"flux-2-klein-4b\", \"gpt-5-image-mini\", \"gpt-image-2\", \"seedream-4.5\", \"seedream-5-lite\", \"gpt-image-2.5-sunburst\", \"gpt-image-2.5-flare\", \"flux-3-image\", \"seedream-5-pro\", \"seedream-5-flash\", \"gpt-image-1.5\", \"gpt-image-1\", \"gpt-image-1-mini\"])" }, { "name": "aspectRatio", diff --git a/schemas/5.20.4/mcp-tools.json b/schemas/5.20.4/mcp-tools.json index 70d806987..b117cbd8d 100644 --- a/schemas/5.20.4/mcp-tools.json +++ b/schemas/5.20.4/mcp-tools.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "nexusVersion": "5.20.4", - "generatedAt": "2026-10-13T18:31:18.516Z", + "generatedAt": "2026-10-14T17:23:45.944Z", "toolCount": 2, "tools": [ { diff --git a/src/agents/searchManager/tools/searchContent.ts b/src/agents/searchManager/tools/searchContent.ts index 6390af1e4..58b07e08e 100644 --- a/src/agents/searchManager/tools/searchContent.ts +++ b/src/agents/searchManager/tools/searchContent.ts @@ -7,6 +7,7 @@ import type { EmbeddingService } from '../../../services/embeddings/EmbeddingSer import { resolveEmbeddingService, resolveSemanticSearchState, + resolveSemanticSearchFailure, type SemanticSearchState } from '../../../services/embeddings/embeddingAccess'; import { CommonParameters } from '../../../types'; @@ -68,20 +69,23 @@ const DESCRIPTION_AVAILABLE = 'Search vault files. Set semantic=true for AI-powe /** Tool descriptions while semantic search cannot be used: no pitch for it. */ const DESCRIPTION_KEYWORD_ONLY: Record, string> = { off: 'Search vault files by keyword, with fuzzy filename matching (best for exact words and names). Semantic search is turned off in Nexus settings, so semantic=true also runs keyword search.', - unsupported: 'Search vault files by keyword, with fuzzy filename matching (best for exact words and names). Semantic search is not available on this device, so semantic=true also runs keyword search.' + unsupported: 'Search vault files by keyword, with fuzzy filename matching (best for exact words and names). Semantic search is not available on this device, so semantic=true also runs keyword search.', + error: 'Search vault files by keyword, with fuzzy filename matching (best for exact words and names). Semantic search failed to start on this device, so semantic=true also runs keyword search.' }; /** The `semantic` property description for each state. The property itself always stays, so `--semantic` never becomes an unknown flag. */ const SEMANTIC_PARAM_DESCRIPTION: Record = { available: 'true = AI-powered conceptual search (desktop only, best for concepts/related ideas). false = keyword/fuzzy search (default, best for exact matches).', off: 'Unavailable: semantic search is turned off in Nexus settings. true runs keyword search instead.', - unsupported: 'Unavailable on this device. true runs keyword search instead.' + unsupported: 'Unavailable on this device. true runs keyword search instead.', + error: 'Unavailable: semantic search failed to start. true runs keyword search instead.' }; /** The `note` returned when semantic=true fell back to keyword search. */ export const SEMANTIC_FALLBACK_NOTES: Record, string> = { off: 'Semantic search is turned off in Nexus settings, so this used keyword search.', - unsupported: 'Semantic search is not available on this device, so this used keyword search.' + unsupported: 'Semantic search is not available on this device, so this used keyword search.', + error: 'Semantic search failed to start, so this used keyword search. Use "Try again" under Semantic search in Nexus settings to restart it.' }; /** Score for a verbatim occurrence of the whole query. */ @@ -255,7 +259,11 @@ export class SearchContentTool extends BaseTool SemanticSearchState) { + constructor( + plugin: Plugin, + getSemanticState?: () => SemanticSearchState, + private readonly getSemanticFailure: () => string | null = () => resolveSemanticSearchFailure(plugin) + ) { super('content', 'Content Search', DESCRIPTION_AVAILABLE, '2.0.0'); this.plugin = plugin; this.getSemanticState = getSemanticState ?? (() => resolveSemanticSearchState(plugin)); @@ -322,11 +330,13 @@ export class SearchContentTool extends BaseTool): string { + const note = SEMANTIC_FALLBACK_NOTES[state]; + if (state !== 'error') { + return note; + } + let reason: string | null = null; + try { + reason = this.getSemanticFailure(); + } catch { + reason = null; + } + return reason ? `${note} Cause: ${reason.replace(/[.\s]+$/, '')}.` : note; + } + /** Attach a note next to the results of a successful search. */ private withNote(result: ContentSearchResult, note: string): ContentSearchResult { const data = (result as unknown as { data?: Record }).data ?? {}; @@ -358,13 +383,16 @@ export class SearchContentTool extends BaseTool = new Map(); private agentModelOptionMap: Map = new Map(); private modelNames: { chat?: string; agent?: string; image?: string } = {}; + /** The image provider whose model list last came back empty, if any. */ + private imageProviderWithoutModels: string | null = null; // Registry capabilities plus context windows discovered at runtime (Ollama, LM Studio). private capabilities: ModelCapabilityResolver; @@ -198,6 +200,7 @@ export class ChatSettingsRenderer { this.config.llmProviderSettings = newSettings; this.providerManager.updateSettings(newSettings); this.imageService.updateSettings(newSettings); + this.imageProviderWithoutModels = null; this.render(); }); } @@ -251,7 +254,7 @@ export class ChatSettingsRenderer { this.createFold(FOLD_IDS.subagents, FOLD_TITLES.subagents, FOLD_ICONS.subagents, el => this.renderSubagents(el), () => this.subagentsSummary()), this.createFold(FOLD_IDS.images, FOLD_TITLES.images, FOLD_ICONS.images, - el => this.renderImages(el), () => imagesSummary(this.imageModelName())), + el => this.renderImages(el), () => this.imagesFoldSummary()), this.createFold(FOLD_IDS.voice, FOLD_TITLES.voice, FOLD_ICONS.voice, el => this.voice.render(el), () => this.voice.getSummary()) ]; @@ -864,6 +867,21 @@ export class ChatSettingsRenderer { // ========== IMAGES ========== + /** + * The Images summary. A provider with no key (or turned off) has no adapter + * and lists no models, so naming the saved model there — the catalog knows + * "Nano Banana 2" without a key — described a setting that cannot run. + */ + private imagesFoldSummary(): string { + const ready = this.imageService.getInitializedProviders(); + const provider = this.settings.imageProvider; + return imagesSummary({ + modelName: this.imageModelName(), + anyProviderUsable: ready.length > 0, + providerUsable: ready.includes(provider) && this.imageProviderWithoutModels !== provider + }); + } + /** The image model's name: as listed once loaded, else from the catalog, else its id. */ private imageModelName(): string | undefined { const { imageProvider, imageModel } = this.settings; @@ -916,8 +934,11 @@ export class ChatSettingsRenderer { const models = await this.imageService.getModelsForProvider(provider); if (provider !== this.settings.imageProvider) return; + this.imageProviderWithoutModels = models.length === 0 ? provider : null; if (models.length === 0) { dropdown.addOption('', 'No models available'); + this.modelNames.image = undefined; + this.updateSummaries(); } else { sortModelsNewestFirst(models, m => ({ provider, id: m.id })).forEach(m => { dropdown.addOption(m.id, m.name); diff --git a/src/components/shared/chatSettings/copy.ts b/src/components/shared/chatSettings/copy.ts index 6a2f6d9a4..c1a422c6f 100644 --- a/src/components/shared/chatSettings/copy.ts +++ b/src/components/shared/chatSettings/copy.ts @@ -79,6 +79,9 @@ export const COPY = { noModel: 'No model', /** Subagents summary: no subagent model and no cloud chat model to describe. */ notSet: 'Not set', + /** Images/Video summaries: no provider of that kind has a key and is turned on. */ + noImageProvider: 'No image provider', + noVideoProvider: 'No video provider', noProviders: 'No providers enabled', noCloudProviders: 'No cloud providers enabled', none: 'None' diff --git a/src/components/shared/chatSettings/sectionSummaries.ts b/src/components/shared/chatSettings/sectionSummaries.ts index 9dcb3bdb0..e80ef6b7b 100644 --- a/src/components/shared/chatSettings/sectionSummaries.ts +++ b/src/components/shared/chatSettings/sectionSummaries.ts @@ -88,12 +88,38 @@ export function subagentsSummary(input: { ]); } -export function imagesSummary(modelName?: string | null): string { - return modelName || 'Not set'; +/** + * Whether the media provider behind a summary can run at all. Both default to + * usable, so a caller that does not know yet does not blank the summary. + */ +export interface MediaProviderUsability { + /** False when the selected provider has no API key or is turned off. */ + providerUsable?: boolean; + /** False when no provider of this kind has a key and is turned on. */ + anyProviderUsable?: boolean; +} + +/** + * Naming a model whose provider cannot run claimed a setting that does not + * work: the Images summary said "Nano Banana 2" while its Model dropdown said + * "No models available". The model id stays saved; the summary just stops + * presenting it as the active choice. + */ +function unusableMediaSummary(input: MediaProviderUsability, noneLabel: string): string | null { + if (input.anyProviderUsable === false) return noneLabel; + if (input.providerUsable === false) return COPY.notSet; + return null; +} + +export function imagesSummary(input: { modelName?: string | null } & MediaProviderUsability): string { + return unusableMediaSummary(input, COPY.noImageProvider) ?? (input.modelName || COPY.notSet); } -export function videoSummary(input: { modelName?: string | null; aspectRatio?: string | null; resolution?: string | null }): string { - return join([input.modelName || 'Not set', input.aspectRatio, input.resolution]); +export function videoSummary( + input: { modelName?: string | null; aspectRatio?: string | null; resolution?: string | null } & MediaProviderUsability +): string { + return unusableMediaSummary(input, COPY.noVideoProvider) + ?? join([input.modelName || COPY.notSet, input.aspectRatio, input.resolution]); } export function voiceSummary(input: { transcriptionModel?: string | null; readAloudModel?: string | null; voice?: string | null }): string { diff --git a/src/database/adapters/HybridStorageAdapter.ts b/src/database/adapters/HybridStorageAdapter.ts index fc9bcb370..32d5bfd9d 100644 --- a/src/database/adapters/HybridStorageAdapter.ts +++ b/src/database/adapters/HybridStorageAdapter.ts @@ -316,6 +316,9 @@ export class HybridStorageAdapter implements IStorageAdapter { await this.runReconcile('conversation', () => this.reconcileMissingConversations()); await this.runReconcile('task', () => this.reconcileMissingTasks()); } + // The adapter is usable before replay finishes, so reads made during it + // cached a partial picture. Drop them now that the cache is complete. + this.queryCache.clear(); // Fill in the state columns denormalized in schema v16 (issue #219) for // rows that came over from a pre-v16 cache. Costs one indexed SELECT once diff --git a/src/database/adapters/lifecycle/StorageMaintenanceService.ts b/src/database/adapters/lifecycle/StorageMaintenanceService.ts index 58683e2b0..f140a1a62 100644 --- a/src/database/adapters/lifecycle/StorageMaintenanceService.ts +++ b/src/database/adapters/lifecycle/StorageMaintenanceService.ts @@ -160,6 +160,11 @@ export class StorageMaintenanceService { // succeeds; a failed rebuild throws above and never reaches here. this.externalEvents.trigger('cache-rebuilt'); } finally { + // The repositories' query cache still holds rows read from the + // database this rebuild threw away (`getWorkspace` answered with the + // pre-rebuild workspace until its TTL ran out or the plugin reloaded). + // Cleared on failure too: the database was wiped either way. + this.deps.getQueryCache().clear(); // Clear on both success and failure so a follow-up rebuild can // re-run; the original error (if any) still rejects this promise. try { await this.afterCacheRebuild?.(); } diff --git a/src/database/repositories/WorkspaceRepository.ts b/src/database/repositories/WorkspaceRepository.ts index 7c8c7c23a..a5de2ddd2 100644 --- a/src/database/repositories/WorkspaceRepository.ts +++ b/src/database/repositories/WorkspaceRepository.ts @@ -178,6 +178,19 @@ export class WorkspaceRepository async update(id: string, data: UpdateWorkspaceData): Promise { try { await this.transaction(async () => { + // 0. A rename onto another workspace's name would fail UNIQUE(name) + // after the event below was already appended — the same orphan + // event `create` guards against. Refuse it first. + if (data.name !== undefined) { + const holder = await this.sqliteCache.queryOne<{ id: string }>( + 'SELECT id FROM workspaces WHERE name = ? AND id != ? LIMIT 1', + [data.name, id] + ); + if (holder) { + throw new WorkspaceConflictError('name', holder.id, data.name); + } + } + // 1. Write event to JSONL const eventData: Partial<{ name: string; description: string; rootFolder: string; lastAccessed: number; isActive: boolean; isArchived: boolean; dedicatedAgentId: string; contextJson: string }> = { lastAccessed: data.lastAccessed ?? Date.now() @@ -248,7 +261,9 @@ export class WorkspaceRepository this.invalidateCache(id); this.log('update', { id }); } catch (error) { - this.logError('update', error); + if (!(error instanceof WorkspaceConflictError)) { + this.logError('update', error); + } throw error; } } diff --git a/src/database/sync/SyncCoordinator.ts b/src/database/sync/SyncCoordinator.ts index c504964bf..0e8f8075f 100644 --- a/src/database/sync/SyncCoordinator.ts +++ b/src/database/sync/SyncCoordinator.ts @@ -110,8 +110,13 @@ export class SyncCoordinator { /** Guards against overlapping sync() calls. */ private syncing = false; - /** Set when a sync() call arrives while another is in-flight. */ - private syncQueued = false; + /** + * The follow-up run owed to callers that arrived while a sync was in flight. + * They all wait on it, so a caller's `await sync()` means its changes are in + * the cache — the "Refresh synced data" command clears the query cache right + * after that await, and an early return used to let it clear too soon. + */ + private queuedSync: { promise: Promise; resolve: (result: SyncResult) => void } | null = null; constructor(jsonlWriter: IJSONLWriter, sqliteCache: ISQLiteCacheManager) { this.jsonlWriter = jsonlWriter; @@ -166,8 +171,12 @@ export class SyncCoordinator { */ async sync(options: SyncOptions = {}): Promise { if (this.syncing) { - this.syncQueued = true; - return this.createResult(true, 0, 0, [], Date.now(), []); + if (!this.queuedSync) { + let resolve!: (result: SyncResult) => void; + const promise = new Promise(r => { resolve = r; }); + this.queuedSync = { promise, resolve }; + } + return this.queuedSync.promise; } this.syncing = true; @@ -175,12 +184,18 @@ export class SyncCoordinator { return await this.syncInner(options); } finally { this.syncing = false; - if (this.syncQueued) { - this.syncQueued = false; - // Re-run to pick up changes that landed during the previous sync. - // Don't await — callers of the queued sync already got their - // early-return result above. - void this.sync(options); + const queued = this.queuedSync; + if (queued) { + this.queuedSync = null; + // Re-run to pick up changes that landed during the previous sync, and + // settle every caller that queued behind it with that run's result. + // Not awaited here: this run's own caller should not wait for it. + void this.sync(options).then( + queued.resolve, + (error: unknown) => queued.resolve( + this.createResult(false, 0, 0, [`Sync failed: ${String(error)}`], Date.now(), []) + ) + ); } } } diff --git a/src/database/sync/WorkspaceEventApplier.ts b/src/database/sync/WorkspaceEventApplier.ts index 76c776439..1c919eaf9 100644 --- a/src/database/sync/WorkspaceEventApplier.ts +++ b/src/database/sync/WorkspaceEventApplier.ts @@ -106,29 +106,102 @@ export class WorkspaceEventApplier { // events. Replaying a duplicate as INSERT OR REPLACE reverted every edit // made before it. First create wins; later ones are no-ops. A create after // `workspace_deleted` still lands, because the delete purged the row. - // OR REPLACE is kept for the name constraint only, as before. + const existing = await this.sqliteCache.queryOne<{ id: string }>( + 'SELECT id FROM workspaces WHERE id = ?', + [event.data.id] + ); + if (existing) { + return; + } + + const created = event.data.created ?? event.timestamp ?? Date.now(); + const name = await this.claimNameForCreate(event.data.id, event.data.name, created); + await this.sqliteCache.run( - `INSERT OR REPLACE INTO workspaces + `INSERT INTO workspaces (id, name, description, rootFolder, created, lastAccessed, isActive, isArchived, contextJson, dedicatedAgentId) - SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ? - WHERE NOT EXISTS (SELECT 1 FROM workspaces WHERE id = ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ event.data.id, - event.data.name, + name, event.data.description ?? null, event.data.rootFolder ?? '', - event.data.created ?? Date.now(), - event.data.created ?? Date.now(), + created, + created, // Default to 1 (active) if not specified event.data.isActive !== undefined ? (event.data.isActive ? 1 : 0) : 1, event.data.isArchived !== undefined ? (event.data.isArchived ? 1 : 0) : 0, event.data.contextJson ?? null, - event.data.dedicatedAgentId ?? null, - event.data.id + event.data.dedicatedAgentId ?? null ] ); } + /** + * Decide which name a replayed `workspace_created` gets when another + * workspace (a different id) already holds that name. + * + * `UNIQUE(name)` used to be resolved by INSERT OR REPLACE, which DELETES the + * row holding the name. Two workspaces with one name in the event store is + * real history — 5.17-5.20.x appended a `workspace_created` even when the + * live INSERT then failed on the name, and two devices can each create + * "Project X" before they sync — so a rebuild silently dropped whichever + * workspace was replayed first, its edits, and the parent row of its + * sessions, states, traces, projects, tasks and conversations. + * + * Rule: both workspaces keep their rows and ids, so every reference to either + * id still resolves. The earliest-created one (ties: smaller id) keeps the + * plain name; the other is shown as " ()". Deciding by `created` + * rather than by arrival makes the result independent of the order the + * shard files are listed in. Merging the two under one id (aliasing) was + * rejected: every applier and every live lookup by id would need to learn + * the alias, and two genuinely different workspaces would be fused. + * + * Returns the name the incoming row should be inserted with. When the + * incoming workspace is the earlier one, the current holder is renamed out + * of the way first. + */ + private async claimNameForCreate(id: string, name: string, created: number): Promise { + const holder = await this.sqliteCache.queryOne<{ id: string; created: number | null }>( + 'SELECT id, created FROM workspaces WHERE name = ? AND id != ?', + [name, id] + ); + if (!holder) { + return name; + } + + const holderCreated = typeof holder.created === 'number' ? holder.created : Number.POSITIVE_INFINITY; + const incomingWins = created < holderCreated || (created === holderCreated && id < holder.id); + if (!incomingWins) { + return this.disambiguatedName(name, id); + } + + await this.sqliteCache.run( + 'UPDATE workspaces SET name = ? WHERE id = ?', + [await this.disambiguatedName(name, holder.id), holder.id] + ); + return name; + } + + /** + * " ()" — derived from the id so the same history always yields + * the same name, with a counter only if a user already chose that exact text. + */ + private async disambiguatedName(name: string, id: string): Promise { + const base = `${name} (${id})`; + let candidate = base; + for (let n = 2; ; n++) { + const taken = await this.sqliteCache.queryOne<{ id: string }>( + 'SELECT id FROM workspaces WHERE name = ? AND id != ?', + [candidate, id] + ); + if (!taken) { + return candidate; + } + candidate = `${base} ${n}`; + } + } + private async applyWorkspaceUpdated(event: WorkspaceUpdatedEvent): Promise { if (!this.isValidWorkspaceId(event.workspaceId)) { return; @@ -137,7 +210,19 @@ export class WorkspaceEventApplier { const updates: string[] = []; const values: unknown[] = []; - if (event.data.name !== undefined) { updates.push('name = ?'); values.push(event.data.name); } + if (event.data.name !== undefined) { + // A rename onto a name another workspace holds would fail UNIQUE(name) + // and take the rest of this event (description, context, archive flag) + // down with it. Such events exist: the live update appended its event + // before SQLite rejected the name. Keep the rest, and give the name the + // same " ()" form a colliding create gets. + const holder = await this.sqliteCache.queryOne<{ id: string }>( + 'SELECT id FROM workspaces WHERE name = ? AND id != ?', + [event.data.name, event.workspaceId] + ); + const name = holder ? await this.disambiguatedName(event.data.name, event.workspaceId) : event.data.name; + updates.push('name = ?'); values.push(name); + } if (event.data.description !== undefined) { updates.push('description = ?'); values.push(event.data.description); } if (event.data.rootFolder !== undefined) { updates.push('rootFolder = ?'); values.push(event.data.rootFolder); } if (event.data.lastAccessed !== undefined) { updates.push('lastAccessed = ?'); values.push(event.data.lastAccessed); } diff --git a/src/services/embeddings/EmbeddingManager.ts b/src/services/embeddings/EmbeddingManager.ts index 8877010bc..9d6740bc1 100644 --- a/src/services/embeddings/EmbeddingManager.ts +++ b/src/services/embeddings/EmbeddingManager.ts @@ -59,6 +59,12 @@ export interface EmbeddingRuntimeStatus { indexing: boolean; paused: boolean; progress: IndexingProgress | null; + /** + * Why the last start failed (initialize() threw), or null. Cleared by the + * next start. A model that fails to load is reported by `progress` instead, + * with phase 'error'. + */ + startError?: string | null; } /** Stored index sizes plus runtime state. Counts are read even while turned off. */ @@ -128,6 +134,7 @@ export class EmbeddingManager { private readonly events = new Events(); private queueProgressRef: EventRef | null = null; private lastProgress: IndexingProgress | null = null; + private startError: string | null = null; constructor( app: App, @@ -154,6 +161,7 @@ export class EmbeddingManager { return; } + this.startError = null; try { // Create components this.engine = new EmbeddingEngine(); @@ -201,6 +209,7 @@ export class EmbeddingManager { } catch (error) { console.error('[EmbeddingManager] Initialization failed:', error); + this.startError = error instanceof Error ? error.message : String(error); // Don't throw - embeddings are optional functionality } @@ -486,7 +495,8 @@ export class EmbeddingManager { initialized: this.isInitialized, indexing: this.queue?.isIndexing() ?? false, paused: this.queue?.isIndexingPaused() ?? false, - progress: this.lastProgress + progress: this.lastProgress, + startError: this.startError }; } diff --git a/src/services/embeddings/embeddingAccess.ts b/src/services/embeddings/embeddingAccess.ts index 8353888de..233cac8a2 100644 --- a/src/services/embeddings/embeddingAccess.ts +++ b/src/services/embeddings/embeddingAccess.ts @@ -21,19 +21,28 @@ import { Platform } from 'obsidian'; import type { EmbeddingService } from './EmbeddingService'; /** - * Whether agents can be offered semantic search, as configured. + * Whether agents can be offered semantic search right now. * * - `available`: turned on, on a platform that supports it. The engine may * still be starting; callers check resolveEmbeddingService for that. - * - `off`: turned off in Nexus settings. + * - `off`: turned off — in Nexus settings, or on the running manager (a + * runtime toggle applies to the manager before the setting is saved). * - `unsupported`: this device cannot run it (mobile). + * - `error`: turned on, but the engine failed to start or its model failed to + * load. It stays that way until retried; see resolveSemanticSearchFailure. */ -export type SemanticSearchState = 'available' | 'off' | 'unsupported'; +export type SemanticSearchState = 'available' | 'off' | 'unsupported' | 'error'; /** The parts of EmbeddingManager this accessor relies on. */ interface EmbeddingManagerView { isEmbeddingEnabled(): boolean; getService(): EmbeddingService | null; + /** Optional so stand-ins without a runtime view still resolve from settings. */ + isTurnedOn?(): boolean; + getStatus?(): { + startError?: string | null; + progress?: { phase?: string; error?: string } | null; + }; } function isEmbeddingManagerView(value: unknown): value is EmbeddingManagerView { @@ -58,8 +67,8 @@ export function resolveEmbeddingService(plugin: unknown): EmbeddingService | nul } try { - const manager = (plugin as { embeddingManager?: unknown }).embeddingManager; - if (!isEmbeddingManagerView(manager) || !manager.isEmbeddingEnabled()) { + const manager = getManager(plugin); + if (!manager || !manager.isEmbeddingEnabled()) { return null; } return manager.getService() ?? null; @@ -68,13 +77,60 @@ export function resolveEmbeddingService(plugin: unknown): EmbeddingService | nul } } +function getManager(plugin: unknown): EmbeddingManagerView | null { + if (plugin === null || typeof plugin !== 'object') { + return null; + } + const manager = (plugin as { embeddingManager?: unknown }).embeddingManager; + return isEmbeddingManagerView(manager) ? manager : null; +} + /** - * Derive the semantic search state from the plugin's settings and platform. + * Why semantic search failed to start, or null when it has not failed. + * + * Two failures leave a turned-on manager unable to search: initialize() threw + * (`startError`), or the indexing pass ended in phase 'error' because the + * embedding model did not load — the service then turns itself off. A pass + * that errored for another reason while the service still runs is not a + * failure here: searching the vectors already stored still works. + */ +export function resolveSemanticSearchFailure(plugin: unknown): string | null { + try { + const manager = getManager(plugin); + if (!manager || typeof manager.getStatus !== 'function') { + return null; + } + if (typeof manager.isTurnedOn === 'function' && !manager.isTurnedOn()) { + return null; + } + const status = manager.getStatus(); + if (status.startError) { + return status.startError; + } + if (status.progress?.phase === 'error') { + const service = manager.isEmbeddingEnabled() ? manager.getService() : null; + if (!service || !service.isServiceEnabled()) { + return status.progress.error || 'The embedding model could not be loaded'; + } + } + return null; + } catch { + return null; + } +} + +/** + * Derive the semantic search state from the plugin's platform, settings and + * the embedding manager's runtime state. * * Reads `plugin.settings.settings.enableEmbeddings`, which defaults to on. - * Anything that is not a Nexus plugin (the headless schema exporter, test - * stubs) therefore reads as `available`, which keeps generated tool - * descriptions stable. + * The setting alone is not enough: the manager can be turned off at runtime + * while the saved setting still says on, and reading only the setting then + * reported "still initializing" forever for something that was off. A + * manager that is turned off reads as `off`; one that failed to start reads + * as `error`. Anything that is not a Nexus plugin (the headless schema + * exporter, test stubs) therefore reads as `available`, which keeps generated + * tool descriptions stable. */ export function resolveSemanticSearchState(plugin: unknown): SemanticSearchState { if (Platform.isMobile) { @@ -84,7 +140,14 @@ export function resolveSemanticSearchState(plugin: unknown): SemanticSearchState try { const settings = (plugin as { settings?: { settings?: { enableEmbeddings?: unknown } } } | null) ?.settings?.settings; - return settings?.enableEmbeddings === false ? 'off' : 'available'; + if (settings?.enableEmbeddings === false) { + return 'off'; + } + const manager = getManager(plugin); + if (manager && typeof manager.isTurnedOn === 'function' && !manager.isTurnedOn()) { + return 'off'; + } + return resolveSemanticSearchFailure(plugin) ? 'error' : 'available'; } catch { return 'available'; } diff --git a/src/services/llm/adapters/google/GeminiImageAdapter.ts b/src/services/llm/adapters/google/GeminiImageAdapter.ts index 36b0f26d0..ad9d78825 100644 --- a/src/services/llm/adapters/google/GeminiImageAdapter.ts +++ b/src/services/llm/adapters/google/GeminiImageAdapter.ts @@ -148,6 +148,21 @@ export const GEMINI_IMAGE_MODEL_SPECS: Readonly/endpoints. + 'gemini-nano-banana-2.1': { + openRouterId: 'google/gemini-nano-banana-2.1', + displayName: 'Nano Banana 2.1 (via OpenRouter)', + costPerImage: 0.0336, + pricingNote: '1120 output tokens at $30/M for 1K; 2K and 4K use more tokens; no 512', + maxReferenceImages: 14, + resolutions: ['1K', '2K', '4K'], + contextWindow: 65536, + lastUpdated: '2026-10-14' + }, + 'gpt-image-2.5-sunburst': { + openRouterId: 'openai/gpt-image-2.5-sunburst', + displayName: 'GPT Image 2.5 Sunburst (via OpenRouter)', + costPerImage: 0.0132, + pricingNote: '$30/M output image tokens, $8/M image and $5/M text input; quality is left at auto, so a 1024x1024 image is ~439 tokens at medium and more at higher qualities', + maxReferenceImages: 16, + resolutions: null, + contextWindow: 32000, + lastUpdated: '2026-10-14' + }, + 'gpt-image-2.5-flare': { + openRouterId: 'openai/gpt-image-2.5-flare', + displayName: 'GPT Image 2.5 Flare (via OpenRouter)', + costPerImage: 0.0132, + pricingNote: '$30/M output image tokens, $8/M image and $5/M text input; quality is left at auto, so a 1024x1024 image is ~439 tokens at medium and more at higher qualities', + maxReferenceImages: 16, + resolutions: null, + contextWindow: 32000, + lastUpdated: '2026-10-14' + }, + 'flux-3-image': { + openRouterId: 'black-forest-labs/flux-3-image', + displayName: 'FLUX.3 Image (via OpenRouter)', + costPerImage: 0.048, + // The endpoint also lists 768 and 1.5K tiers, which imageSize cannot express. + pricingNote: 'Per image by resolution tier: 1K $0.048, 2K $0.10, 4K $0.607', + maxReferenceImages: 10, + resolutions: ['1K', '2K', '4K'], + contextWindow: 4096, + lastUpdated: '2026-10-14' + }, + // BytePlus documents a 921,600-pixel minimum for Seedream 5.0 Pro and Flash, + // so 1K is real here, unlike Seedream 4.5 and 5.0 Lite. + 'seedream-5-pro': { + openRouterId: 'bytedance-seed/seedream-5-0-pro', + displayName: 'Seedream 5.0 Pro (via OpenRouter)', + costPerImage: 0.045, + pricingNote: '$0.045 per image, $0.09 at high resolution; $0.003 per reference image', + maxReferenceImages: 14, + resolutions: ['1K', '2K'], + contextWindow: 32000, + lastUpdated: '2026-10-14' + }, + 'seedream-5-flash': { + openRouterId: 'bytedance-seed/seedream-5-0-flash', + displayName: 'Seedream 5.0 Flash (via OpenRouter)', + costPerImage: 0.018, + pricingNote: '$0.018 per image; reference images free', + maxReferenceImages: 14, + resolutions: ['1K', '2K'], + contextWindow: 32000, + lastUpdated: '2026-10-14' } }; @@ -305,7 +370,7 @@ export class OpenRouterImageAdapter extends BaseImageAdapter { private httpReferer: string; private xTitle: string; - // gemini-2.5-flash-image is scheduled for shutdown on 2026-10-02. + // gemini-2.5-flash-image is deprecated. private readonly defaultModel = 'gemini-3.1-flash-image'; // Aspect ratios accepted across the catalog. Individual endpoints may diff --git a/src/services/llm/types/ImageTypes.ts b/src/services/llm/types/ImageTypes.ts index 4af03e5f5..1a9b2b920 100644 --- a/src/services/llm/types/ImageTypes.ts +++ b/src/services/llm/types/ImageTypes.ts @@ -241,10 +241,10 @@ export type ImageProvider = 'openai' | 'google' | 'openrouter'; export type ImageModel = | 'gpt-image-2.5-sunburst' // OpenAI GPT Image 2.5 Sunburst (OpenAI direct) | 'gpt-image-2.5-flare' // OpenAI GPT Image 2.5 Flare (OpenAI direct) - | 'gpt-image-1' // OpenAI GPT Image 1 (OpenAI direct) - | 'gpt-image-1.5' // OpenAI GPT Image 1.5 (OpenAI direct) - | 'gpt-image-1-mini' // OpenAI GPT Image 1 Mini (OpenAI direct) - | 'gemini-2.5-flash-image' // Google Nano Banana legacy, shuts down 2026-10-02 + | 'gpt-image-1' // OpenAI GPT Image 1 (OpenAI direct), shuts down 2026-10-23 + | 'gpt-image-1.5' // OpenAI GPT Image 1.5 (OpenAI direct), shuts down 2026-12-01 + | 'gpt-image-1-mini' // OpenAI GPT Image 1 Mini (OpenAI direct), shuts down 2026-12-01 + | 'gemini-2.5-flash-image' // Google Nano Banana legacy, deprecated (shutdown 2027-03-15) | 'gemini-3-pro-image-preview' // Google Nano Banana Pro preview id | 'gemini-3.1-flash-image-preview' // Google Nano Banana 2 preview id | 'gpt-5-image' // OpenAI GPT-5 Image (OpenRouter only) @@ -258,7 +258,11 @@ export type ImageModel = | 'gpt-5-image-mini' // OpenAI GPT-5 Image Mini (OpenRouter only) | 'gpt-image-2' // OpenAI GPT Image 2 (OpenRouter, and the OpenAI adapter) | 'seedream-4.5' // ByteDance Seedream 4.5 (OpenRouter only) - | 'seedream-5-lite'; // ByteDance Seedream 5.0 Lite (OpenRouter only) + | 'seedream-5-lite' // ByteDance Seedream 5.0 Lite (OpenRouter only) + | 'gemini-nano-banana-2.1' // Google Nano Banana 2.1 (Google direct and OpenRouter) + | 'flux-3-image' // Black Forest Labs FLUX.3 Image (OpenRouter only) + | 'seedream-5-pro' // ByteDance Seedream 5.0 Pro (OpenRouter only) + | 'seedream-5-flash'; // ByteDance Seedream 5.0 Flash (OpenRouter only) // Aspect ratio constants for Nano Banana models export enum AspectRatio { diff --git a/src/services/llm/types/VideoTypes.ts b/src/services/llm/types/VideoTypes.ts index a19074ae1..667ab5edd 100644 --- a/src/services/llm/types/VideoTypes.ts +++ b/src/services/llm/types/VideoTypes.ts @@ -34,15 +34,25 @@ export interface ResolvedVideoSelection { reason?: string; } +/** + * Video model catalog. Google direct facts come from + * https://ai.google.dev/gemini-api/docs/video and /docs/deprecations; + * OpenRouter facts from https://openrouter.ai/api/v1/videos/models, which the + * OpenRouter adapter also re-reads at request time to validate a request. + */ const VIDEO_MODELS: VideoModelDeclaration[] = [ { + // Google shuts down all Veo 3.1 preview ids on 2026-10-22 and names + // gemini-omni-1.1-flash as the replacement. That model is served only by + // the Interactions API, which GoogleVideoAdapter does not speak yet, so + // there is no Google-direct successor in this catalog. provider: 'google', id: 'veo-3.1-generate-preview', - name: 'Veo 3.1', + name: 'Veo 3.1 preview (shuts down 2026-10-22)', execution: 'long-running-operation', supportsReferenceImage: true, supportsAudioPrompting: true, - aspectRatios: ['16:9', '9:16', '1:1'], + aspectRatios: ['16:9', '9:16'], resolutions: ['720p', '1080p', '4k'], durations: [4, 6, 8], defaultAspectRatio: '16:9', @@ -55,7 +65,7 @@ const VIDEO_MODELS: VideoModelDeclaration[] = [ execution: 'long-running-operation', supportsReferenceImage: true, supportsAudioPrompting: true, - aspectRatios: ['16:9', '9:16', '1:1'], + aspectRatios: ['16:9', '9:16'], resolutions: ['720p', '1080p', '4k'], durations: [4, 6, 8], defaultAspectRatio: '16:9', @@ -82,11 +92,67 @@ const VIDEO_MODELS: VideoModelDeclaration[] = [ supportsReferenceImage: true, supportsAudioPrompting: true, aspectRatios: ['16:9', '9:16'], - resolutions: ['720p', '1080p'], + resolutions: ['720p', '1080p', '4k'], durations: [4, 6, 8], defaultAspectRatio: '16:9', defaultResolution: '720p', }, + { + // Added 2026-10-14 from /api/v1/videos/models. Listing also offers 480p and 4:3/3:4/21:9, outside the internal unions. + provider: 'openrouter', + id: 'bytedance/seedance-2.5', + name: 'ByteDance: Seedance 2.5 via OpenRouter', + execution: 'long-running-operation', + supportsReferenceImage: true, + supportsAudioPrompting: true, + aspectRatios: ['16:9', '9:16', '1:1'], + resolutions: ['720p'], + durations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30], + defaultAspectRatio: '16:9', + defaultResolution: '720p', + }, + { + // Added 2026-10-14 from /api/v1/videos/models. First/last-frame control only; no input_references. + provider: 'openrouter', + id: 'kwaivgi/kling-v3.0-pro', + name: 'Kling: Video v3.0 Pro via OpenRouter', + execution: 'long-running-operation', + supportsReferenceImage: false, + supportsAudioPrompting: true, + aspectRatios: ['16:9', '9:16', '1:1'], + resolutions: ['720p'], + durations: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + defaultAspectRatio: '16:9', + defaultResolution: '720p', + }, + { + // Added 2026-10-14 from /api/v1/videos/models. Starting-frame image only; the listing does not declare generate_audio. + provider: 'openrouter', + id: 'x-ai/grok-imagine-video-1.5', + name: 'xAI: Grok Imagine Video 1.5 via OpenRouter', + execution: 'long-running-operation', + supportsReferenceImage: false, + supportsAudioPrompting: false, + aspectRatios: ['16:9', '9:16', '1:1'], + resolutions: ['720p', '1080p'], + durations: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + defaultAspectRatio: '16:9', + defaultResolution: '720p', + }, + { + // Added 2026-10-14 from /api/v1/videos/models. First-frame image only; no audio. + provider: 'openrouter', + id: 'runway/gen-4.5', + name: 'Runway: Gen-4.5 via OpenRouter', + execution: 'long-running-operation', + supportsReferenceImage: false, + supportsAudioPrompting: false, + aspectRatios: ['16:9', '9:16'], + resolutions: ['720p'], + durations: [2, 3, 4, 5, 6, 7, 8, 9, 10], + defaultAspectRatio: '16:9', + defaultResolution: '720p', + }, ]; export const VIDEO_PROVIDER_PRIORITY: VideoProvider[] = ['google', 'openrouter']; diff --git a/src/services/video/adapters/OpenRouterVideoAdapter.ts b/src/services/video/adapters/OpenRouterVideoAdapter.ts index a852f46da..730d5f916 100644 --- a/src/services/video/adapters/OpenRouterVideoAdapter.ts +++ b/src/services/video/adapters/OpenRouterVideoAdapter.ts @@ -201,7 +201,9 @@ export class OpenRouterVideoAdapter implements VideoGenerationAdapter { const body: Record = { model: request.model, prompt: request.prompt, - resolution: request.resolution, + // The internal value is '4k' (Google's spelling); OpenRouter's resolution + // enum is case-sensitive and spells it '4K'. + resolution: request.resolution === '4k' ? '4K' : request.resolution, aspect_ratio: request.aspectRatio, }; diff --git a/src/settings/tabs/defaults/VideoSettings.ts b/src/settings/tabs/defaults/VideoSettings.ts index a8a361d94..137f8c4d4 100644 --- a/src/settings/tabs/defaults/VideoSettings.ts +++ b/src/settings/tabs/defaults/VideoSettings.ts @@ -54,12 +54,22 @@ export class VideoSettings implements FoldDefinition { }; }; + const isUsable = (item: ReturnType[number]): boolean => + item.enabled && item.configured && item.models.length > 0; + const updateSummary = (): void => { const current = selection(); + const all = availability(); + // A saved provider whose key was removed still resolves a model from + // the catalog; the summary must not present that as the active choice + // while the Provider dropdown marks it "(unavailable)". + const selected = all.find(item => item.provider === current.provider); host.setSummary(videoSummary({ modelName: current.modelDeclaration?.name ?? current.model, aspectRatio: current.aspectRatio, - resolution: current.resolution + resolution: current.resolution, + anyProviderUsable: all.some(isUsable), + providerUsable: selected ? isUsable(selected) : false })); }; @@ -73,7 +83,7 @@ export class VideoSettings implements FoldDefinition { }; const current = selection(); - const usable = availability().filter(item => item.enabled && item.configured && item.models.length > 0); + const usable = availability().filter(isUsable); new Setting(itemsEl) .setName(COPY.provider) diff --git a/src/types/llm/ProviderTypes.ts b/src/types/llm/ProviderTypes.ts index c25ff96dd..c0e636130 100644 --- a/src/types/llm/ProviderTypes.ts +++ b/src/types/llm/ProviderTypes.ts @@ -262,9 +262,13 @@ export const DEFAULT_LLM_PROVIDER_SETTINGS: LLMProviderSettings = { provider: 'google', model: 'gemini-3.1-flash-image' }, + // Google shuts down every Veo 3.1 preview id on 2026-10-22, and its + // successor (gemini-omni-1.1-flash) needs the Interactions API, which the + // Google video adapter does not speak. Veo 3.1 Fast via OpenRouter is the + // same model family on a route that is not being retired. defaultVideoModel: { - provider: 'google', - model: 'veo-3.1-generate-preview', + provider: 'openrouter', + model: 'google/veo-3.1-fast', aspectRatio: '16:9', resolution: '720p' }, diff --git a/tests/helpers/settingsRendererFixtures.ts b/tests/helpers/settingsRendererFixtures.ts index 3b5d87747..0431512fe 100644 --- a/tests/helpers/settingsRendererFixtures.ts +++ b/tests/helpers/settingsRendererFixtures.ts @@ -62,6 +62,9 @@ export function imageServiceModule() { getModelsForProvider: jest.fn(async (provider: string) => provider === 'google' ? [{ id: 'gemini-3.1-flash-image', name: 'Gemini 3.1 Flash Image' }] : [{ id: `${provider}-image`, name: `${provider} image` }]), + // Every provider has a key here; DefaultsWithoutProvider covers the + // real service, which has no adapter (and no models) without one. + getInitializedProviders: jest.fn(() => ['google', 'openai', 'openrouter']), updateSettings: jest.fn() })) }; diff --git a/tests/integration/cache-backend-rebuild-cache.test.ts b/tests/integration/cache-backend-rebuild-cache.test.ts index a199657e0..b9a2d2aa2 100644 --- a/tests/integration/cache-backend-rebuild-cache.test.ts +++ b/tests/integration/cache-backend-rebuild-cache.test.ts @@ -86,7 +86,8 @@ async function buildRebuildHarness(): Promise { initLifecycle, sqliteCache: cacheLifecycle as unknown, syncCoordinator: syncCoordinator as unknown, - cacheBlobStore: blobStore + cacheBlobStore: blobStore, + queryCache: { clear: jest.fn() } }); return { adapter, blobStore, cacheLifecycle, syncCoordinator, callOrder }; diff --git a/tests/integration/instruction-skill-cache-rebuild.test.ts b/tests/integration/instruction-skill-cache-rebuild.test.ts index b63eaa906..60592dc11 100644 --- a/tests/integration/instruction-skill-cache-rebuild.test.ts +++ b/tests/integration/instruction-skill-cache-rebuild.test.ts @@ -62,7 +62,8 @@ catch { /* Node >=22 lane; the plugin itself supports Node >=18 and mobile. */ } const maintenance = new StorageMaintenanceService({ getSqliteCache: () => sqlite, getCacheBlobStore: () => ({ remove: () => Promise.resolve() }), getInitLifecycle: () => ({ isInitialized: () => true }), - getSyncCoordinator: () => ({ fullRebuild: () => Promise.resolve({ success: true, errors: [] }) }) + getSyncCoordinator: () => ({ fullRebuild: () => Promise.resolve({ success: true, errors: [] }) }), + getQueryCache: () => ({ clear: () => undefined }) } as unknown as StorageMaintenanceDeps); maintenance.setBeforeCacheRebuild(async () => { const result = await skills.pauseForRebuild(); if (!result.ok) throw new Error(result.error.message); }); maintenance.setAfterCacheRebuild(async () => { const result = await skills.resumeAfterRebuild(); if (!result.ok) throw new Error(result.error.message); }); diff --git a/tests/unit/DefaultsWithoutProvider.test.ts b/tests/unit/DefaultsWithoutProvider.test.ts index b427e81eb..ab9cc307a 100644 --- a/tests/unit/DefaultsWithoutProvider.test.ts +++ b/tests/unit/DefaultsWithoutProvider.test.ts @@ -108,12 +108,42 @@ describe('Defaults with no provider set up', () => { } }); - it('names the default image model rather than showing its id', async () => { + it('does not name the default image model when no image provider is set up', async () => { const h = await renderDefaults(freshInstall()); try { const { provider, model } = DEFAULT_LLM_PROVIDER_SETTINGS.defaultImageModel!; const name = getImageModelDisplayName(provider, model); + // The premise: the catalog can name it without a key, which is how the + // summary used to show it. expect(name).toBeTruthy(); + // Pre-fix: the summary was that name while Model said "No models available". + expect(foldSummary(h.root, FOLD_TITLES.images)).toBe(COPY.noImageProvider); + expect(optionLabels(control(findRow(foldItems(h.root, FOLD_TITLES.images), COPY.model), 'select'))) + .toEqual(['No models available']); + } finally { + h.tab.destroy(); + } + }); + + it('says "Not set" when another image provider is set up but the selected one is not', async () => { + const settings = freshInstall(); + settings.providers.openai = { ...settings.providers.openai, enabled: true, apiKey: 'sk-test' }; + const h = await renderDefaults(settings); + try { + expect(DEFAULT_LLM_PROVIDER_SETTINGS.defaultImageModel!.provider).not.toBe('openai'); + expect(foldSummary(h.root, FOLD_TITLES.images)).toBe(COPY.notSet); + } finally { + h.tab.destroy(); + } + }); + + it('names the default image model, not its id, once its provider is set up', async () => { + const { provider, model } = DEFAULT_LLM_PROVIDER_SETTINGS.defaultImageModel!; + const settings = freshInstall(); + settings.providers[provider] = { ...settings.providers[provider], enabled: true, apiKey: 'test-key' }; + const h = await renderDefaults(settings); + try { + const name = getImageModelDisplayName(provider, model); expect(name).not.toBe(model); expect(foldSummary(h.root, FOLD_TITLES.images)).toBe(name); } finally { @@ -121,6 +151,29 @@ describe('Defaults with no provider set up', () => { } }); + it('does not name a video model when no video provider is set up', async () => { + const h = await renderDefaults(freshInstall()); + try { + expect(foldSummary(h.root, FOLD_TITLES.video)).toBe(COPY.noVideoProvider); + } finally { + h.tab.destroy(); + } + }); + + it('says "Not set" for a saved video provider whose key was removed', async () => { + const settings = freshInstall(); + const saved = DEFAULT_LLM_PROVIDER_SETTINGS.defaultVideoModel!; + const other = saved.provider === 'google' ? 'openrouter' : 'google'; + settings.providers[other] = { ...settings.providers[other], enabled: true, apiKey: 'test-key' }; + const h = await renderDefaults(settings); + try { + // Pre-fix: " · 16:9 · 720p" while Provider read "(unavailable)". + expect(foldSummary(h.root, FOLD_TITLES.video)).toBe(COPY.notSet); + } finally { + h.tab.destroy(); + } + }); + it('names a saved transcription model before its provider list has loaded', async () => { const h = await renderDefaults(freshInstall({ defaultTranscriptionModel: { provider: 'deepgram', model: 'nova-3' } })); try { diff --git a/tests/unit/EmbeddingManagerHotToggle.test.ts b/tests/unit/EmbeddingManagerHotToggle.test.ts index c280dfef1..4cfeaf6a1 100644 --- a/tests/unit/EmbeddingManagerHotToggle.test.ts +++ b/tests/unit/EmbeddingManagerHotToggle.test.ts @@ -239,10 +239,13 @@ describe('EmbeddingManager hot toggle', () => { expect(created.engine[0].dispose).toHaveBeenCalled(); expect(h.manager.getService()).toBeNull(); expect(h.states.at(-1)).toMatchObject({ enabled: true, initialized: false }); + // Why it failed stays readable, so search can say so instead of + // "still initializing" (resolveSemanticSearchState reads this). + expect(h.manager.getStatus().startError).toBe('status bar failed'); await h.manager.setEnabled(true); expect(created.engine).toHaveLength(2); - expect(h.states.at(-1)).toMatchObject({ enabled: true, initialized: true }); + expect(h.states.at(-1)).toMatchObject({ enabled: true, initialized: true, startError: null }); } finally { errorSpy.mockRestore(); } diff --git a/tests/unit/HybridStorageAdapter.phase0.test.ts b/tests/unit/HybridStorageAdapter.phase0.test.ts index 352fa74f8..040d28c22 100644 --- a/tests/unit/HybridStorageAdapter.phase0.test.ts +++ b/tests/unit/HybridStorageAdapter.phase0.test.ts @@ -98,6 +98,7 @@ describe('HybridStorageAdapter (Phase 0 characterization)', () => { adapter.syncCoordinator = { fullRebuild: jest.fn().mockResolvedValue(successfulSyncResult) }; + (adapter as unknown as { queryCache: unknown }).queryCache = { clear: jest.fn() }; return adapter; } diff --git a/tests/unit/NotesIndexCacheRebuild.test.ts b/tests/unit/NotesIndexCacheRebuild.test.ts index f47a95668..9ed8cc7da 100644 --- a/tests/unit/NotesIndexCacheRebuild.test.ts +++ b/tests/unit/NotesIndexCacheRebuild.test.ts @@ -58,6 +58,7 @@ function makeMaintenance(rebuildSucceeds: boolean) { getSqliteCache: () => sqliteCache, getCacheBlobStore: () => cacheBlobStore, getSyncCoordinator: () => syncCoordinator, + getQueryCache: () => ({ clear: () => undefined }), getInitLifecycle: () => ({ isInitialized: () => true }) } as unknown as StorageMaintenanceDeps; diff --git a/tests/unit/RebuildCacheQueryCache.test.ts b/tests/unit/RebuildCacheQueryCache.test.ts new file mode 100644 index 000000000..ff5bec805 --- /dev/null +++ b/tests/unit/RebuildCacheQueryCache.test.ts @@ -0,0 +1,157 @@ +/** + * "Nexus: Rebuild cache" must not leave the repositories answering from the + * database it threw away. + * + * Observed live: after a rebuild, `workspaceService.getWorkspace` kept returning + * the pre-rebuild workspace until the plugin was reloaded. Every repository read + * goes through one shared `QueryCache` (60 s TTL), and `rebuildCache()` closed, + * wiped and replayed SQLite without clearing it. + * + * Real pieces: node:sqlite with the production `workspaces` DDL, the production + * `WorkspaceRepository`, `QueryCache`, `SyncCoordinator` and + * `WorkspaceEventApplier`. Only the event store (an in-memory event list) and + * the cache-file lifecycle (close / blob remove / reopen) are stand-ins. + */ + +import { WorkspaceRepository } from '../../src/database/repositories/WorkspaceRepository'; +import { QueryCache } from '../../src/database/optimizations/QueryCache'; +import { SyncCoordinator } from '../../src/database/sync/SyncCoordinator'; +import type { IJSONLWriter, ISQLiteCacheManager, SyncResult } from '../../src/database/sync/SyncCoordinator'; +import type { ReconcilePipeline } from '../../src/database/sync/ReconcilePipeline'; +import { StorageMaintenanceService, type StorageMaintenanceDeps } from '../../src/database/adapters/lifecycle/StorageMaintenanceService'; +import { SCHEMA_SQL } from '../../src/database/schema/schema'; +import type { RepositoryDependencies } from '../../src/database/repositories/base/BaseRepository'; +import type { StorageEvent, WorkspaceEvent } from '../../src/database/interfaces/StorageEvents'; + +interface NodeSqliteDb { + exec(sql: string): void; + prepare(sql: string): { + run(...params: unknown[]): unknown; + all(...params: unknown[]): Record[]; + get(...params: unknown[]): Record | undefined; + }; +} + +function loadNodeSqlite(): (new (path: string) => NodeSqliteDb) | null { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return (require('node:sqlite') as { DatabaseSync: new (path: string) => NodeSqliteDb }).DatabaseSync; + } catch { + return null; + } +} + +const DatabaseSync = loadNodeSqlite(); +const describeWithSqlite = DatabaseSync ? describe : describe.skip; + +function openCache() { + const db = new DatabaseSync!(':memory:'); + db.exec(SCHEMA_SQL.replace( + /CREATE VIRTUAL TABLE IF NOT EXISTS (\w+) USING vec0\([\s\S]*?\);/g, + 'CREATE TABLE IF NOT EXISTS $1 (rowid INTEGER PRIMARY KEY);' + )); + const params = (p: unknown[] = []) => p.map(v => (v === undefined ? null : v)); + const cache = { + run: async (sql: string, p?: unknown[]) => db.prepare(sql).run(...params(p)), + query: async (sql: string, p?: unknown[]) => db.prepare(sql).all(...params(p)) as T[], + queryOne: async (sql: string, p?: unknown[]) => (db.prepare(sql).get(...params(p)) ?? null) as T | null, + transaction: async (fn: () => Promise) => fn(), + getSyncState: async () => null, + updateSyncState: async () => undefined, + isEventApplied: async () => false, + markEventApplied: async () => undefined, + clearAllData: async () => { db.exec('DELETE FROM sessions; DELETE FROM workspaces;'); }, + rebuildFTSIndexes: async () => undefined, + save: async () => undefined, + stopAutoSave: () => undefined, + close: async () => undefined, + initialize: async () => undefined + }; + return { db, cache }; +} + +const STREAM = 'workspaces/ws_w1.jsonl'; +const events: WorkspaceEvent[] = [ + { + id: 'e1', type: 'workspace_created', deviceId: 'dev', timestamp: 1, + data: { id: 'w1', name: 'Research', description: 'from creation', rootFolder: '/', created: 1 } + } as WorkspaceEvent, + { + id: 'e2', type: 'workspace_updated', deviceId: 'dev', timestamp: 2, + workspaceId: 'w1', data: { description: 'from the event store' } + } as WorkspaceEvent +]; + +const eventStore: IJSONLWriter = { + getDeviceId: () => 'dev', + listFiles: async (category) => (category === 'workspaces' ? [STREAM] : []), + getFileModTime: async () => null, + readEvents: async () => events as unknown as T[], + getEventsNotFromDevice: async () => [] as T[] +}; + +describeWithSqlite('rebuildCache clears the repository query cache', () => { + it('getById answers with the replayed row, not the one read before the rebuild', async () => { + const { db, cache } = openCache(); + const queryCache = new QueryCache(); + const repo = new WorkspaceRepository({ sqliteCache: cache, jsonlWriter: {}, queryCache } as unknown as RepositoryDependencies); + const coordinator = new SyncCoordinator(eventStore, cache as unknown as ISQLiteCacheManager); + + // A cache that drifted from the event store — the reason to rebuild. + db.prepare("INSERT INTO workspaces (id, name, description, rootFolder, created, lastAccessed) VALUES ('w1', 'Research', 'drifted', '/', 1, 1)").run(); + expect((await repo.getById('w1'))?.description).toBe('drifted'); + + const maintenance = new StorageMaintenanceService({ + getInitLifecycle: () => ({ isInitialized: () => true }), + getSqliteCache: () => cache, + getCacheBlobStore: () => ({ remove: async () => undefined }), + getSyncCoordinator: () => coordinator, + getQueryCache: () => queryCache + } as unknown as StorageMaintenanceDeps); + + await maintenance.rebuildCache(); + + expect(db.prepare("SELECT description FROM workspaces WHERE id = 'w1'").get()?.description).toBe('from the event store'); + // Pre-fix: 'drifted' — served from the QueryCache entry made before the rebuild. + expect((await repo.getById('w1'))?.description).toBe('from the event store'); + }); +}); + +describe('SyncCoordinator.sync: a call that arrives mid-sync waits for the follow-up run', () => { + it('does not resolve until the queued run has applied its changes', async () => { + const { cache } = openCache(); + const releases: Array<() => void> = []; + const finished: number[] = []; + let runs = 0; + const pipeline = { + reconcileAll: jest.fn(async () => { + const run = ++runs; + await new Promise(resolve => releases.push(resolve)); + finished.push(run); + return { success: true, eventsApplied: run, eventsSkipped: 0, errors: [] }; + }) + }; + const coordinator = new SyncCoordinator(eventStore, cache as unknown as ISQLiteCacheManager); + coordinator.setReconcilePipeline(pipeline as unknown as ReconcilePipeline); + + const first = coordinator.sync(); + let refreshResult: SyncResult | null = null; + // What "Refresh synced data" does while startup's sync is still running. + const refresh = coordinator.sync().then(r => { refreshResult = r; }); + + await Promise.resolve(); + // Pre-fix: already resolved with a fabricated 0-event success. + expect(refreshResult).toBeNull(); + + releases[0](); + await first; + await new Promise(resolve => setTimeout(resolve, 0)); + expect(refreshResult).toBeNull(); + expect(pipeline.reconcileAll).toHaveBeenCalledTimes(2); + + releases[1](); + await refresh; + expect(finished).toEqual([1, 2]); + expect(refreshResult).toMatchObject({ success: true, eventsApplied: 2 }); + }); +}); diff --git a/tests/unit/SearchContentSemanticOff.test.ts b/tests/unit/SearchContentSemanticOff.test.ts index 7a1a658ff..b026a2e35 100644 --- a/tests/unit/SearchContentSemanticOff.test.ts +++ b/tests/unit/SearchContentSemanticOff.test.ts @@ -36,7 +36,13 @@ interface SearchData { note?: string; } -function createHarness(options: { enableEmbeddings?: boolean; managerReady?: boolean } = {}) { +interface RuntimeView { + turnedOn: boolean; + startError?: string | null; + progress?: { phase: string; error?: string } | null; +} + +function createHarness(options: { enableEmbeddings?: boolean; managerReady?: boolean; runtime?: RuntimeView } = {}) { const files = [ new TFile('auth.md', 'Ideas/auth.md'), new TFile('cooking.md', 'Life/cooking.md') @@ -50,10 +56,24 @@ function createHarness(options: { enableEmbeddings?: boolean; managerReady?: boo getStats: jest.fn(async () => ({ noteCount: 2 })), semanticSearch: jest.fn(async () => [{ notePath: 'Ideas/auth.md', distance: 0.1, score: 0.1 }]) }; - const manager = { + const manager: Record = { isEmbeddingEnabled: () => options.managerReady ?? false, getService: () => (options.managerReady ? service : null) }; + // The real EmbeddingManager's runtime view. Left off by default so the + // settings-only path (and stand-ins without it) stays covered. + const runtime = options.runtime; + if (runtime) { + manager.isTurnedOn = () => runtime.turnedOn; + manager.getStatus = () => ({ + enabled: runtime.turnedOn, + initialized: options.managerReady ?? false, + indexing: false, + paused: false, + progress: runtime.progress ?? null, + startError: runtime.startError ?? null + }); + } const settings: Record = { memory: {} }; if (options.enableEmbeddings !== undefined) settings.enableEmbeddings = options.enableEmbeddings; @@ -87,7 +107,7 @@ function createHarness(options: { enableEmbeddings?: boolean; managerReady?: boo context: CONTEXT }) as Promise<{ success: boolean; error?: string; data?: SearchData }>; - return { agent, normalizer, tool, run, service, settings }; + return { agent, normalizer, tool, run, service, settings, runtime }; } describe('search content with semantic search turned off', () => { @@ -178,3 +198,92 @@ describe('search content with semantic search turned on', () => { expect(h.tool().description).toContain(SEMANTIC_PITCH); }); }); + +/** + * The saved setting is not the whole truth. EmbeddingManager.setEnabled(false) + * turns the runtime off before (or without) the setting being saved, and a + * model that fails to load leaves the manager "on" with nothing that can + * search. Reading only the setting answered both with "still initializing", + * which an agent retries indefinitely. + */ +describe('search content follows the embedding runtime, not just the setting', () => { + it('treats a manager turned off at runtime as off, though the setting is still on', async () => { + const h = createHarness({ enableEmbeddings: true, managerReady: false, runtime: { turnedOn: false } }); + + expect(h.tool().description).not.toContain(SEMANTIC_PITCH); + expect(h.tool().description).toMatch(/turned off/); + const result = await h.run(true); + + // Pre-fix: success false, "Semantic search is still initializing". + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.data?.note).toBe(SEMANTIC_FALLBACK_NOTES.off); + expect(result.data?.results.map(r => r.filePath)).toContain('Ideas/auth.md'); + }); + + it('still says "initializing" while the manager is on and starting', async () => { + const h = createHarness({ enableEmbeddings: true, managerReady: false, runtime: { turnedOn: true } }); + + expect(h.tool().description).toContain(SEMANTIC_PITCH); + const result = await h.run(true); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/still initializing/); + }); + + it('reports a model that failed to load, with its cause, instead of "initializing"', async () => { + const h = createHarness({ + enableEmbeddings: true, + managerReady: true, + runtime: { turnedOn: true, progress: { phase: 'error', error: 'The embedding model could not be loaded' } } + }); + h.service.isServiceEnabled.mockReturnValue(false); + + expect(h.tool().description).not.toContain(SEMANTIC_PITCH); + expect(h.tool().description).toMatch(/failed to start/); + const result = await h.run(true); + + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.data?.note).toBe(`${SEMANTIC_FALLBACK_NOTES.error} Cause: The embedding model could not be loaded.`); + expect(result.data?.note).not.toMatch(/initializing/); + expect(h.service.semanticSearch).not.toHaveBeenCalled(); + }); + + it('reports an engine that failed to start (no service at all) instead of "initializing"', async () => { + const h = createHarness({ + enableEmbeddings: true, + managerReady: false, + runtime: { turnedOn: true, startError: 'wasm unavailable' } + }); + + const result = await h.run(true); + + // Pre-fix: "still initializing" — the engine was never going to start. + expect(result.success).toBe(true); + expect(result.data?.note).toBe(`${SEMANTIC_FALLBACK_NOTES.error} Cause: wasm unavailable.`); + }); + + it('keeps searching stored vectors when a pass errored but the service still runs', async () => { + const h = createHarness({ + enableEmbeddings: true, + managerReady: true, + runtime: { turnedOn: true, progress: { phase: 'error', error: 'disk full' } } + }); + + const result = await h.run(true); + + expect(result.success).toBe(true); + expect(result.data?.note).toBeUndefined(); + expect(h.service.semanticSearch).toHaveBeenCalledTimes(1); + }); + + it('follows a runtime toggle live, without a new agent', () => { + const h = createHarness({ enableEmbeddings: true, managerReady: false, runtime: { turnedOn: true } }); + expect(h.tool().description).toContain(SEMANTIC_PITCH); + + h.runtime!.turnedOn = false; + + expect(h.tool().description).not.toContain(SEMANTIC_PITCH); + }); +}); diff --git a/tests/unit/SettingsSectionSummaries.test.ts b/tests/unit/SettingsSectionSummaries.test.ts index 851888ea0..726c1d635 100644 --- a/tests/unit/SettingsSectionSummaries.test.ts +++ b/tests/unit/SettingsSectionSummaries.test.ts @@ -63,8 +63,13 @@ it('builds the subagent tuning parts the way Chat options does', () => { }); it('summarises images, video and voice', () => { - expect(imagesSummary('Gemini 3.1 Flash Image')).toBe('Gemini 3.1 Flash Image'); + expect(imagesSummary({ modelName: 'Gemini 3.1 Flash Image' })).toBe('Gemini 3.1 Flash Image'); + expect(imagesSummary({ modelName: 'Nano Banana 2', providerUsable: false, anyProviderUsable: false })).toBe('No image provider'); + expect(imagesSummary({ modelName: 'Nano Banana 2', providerUsable: false, anyProviderUsable: true })).toBe('Not set'); + expect(imagesSummary({})).toBe('Not set'); expect(videoSummary({ modelName: 'Veo 3.1', aspectRatio: '16:9', resolution: '720p' })).toBe('Veo 3.1 · 16:9 · 720p'); + expect(videoSummary({ modelName: 'Veo 3.1', aspectRatio: '16:9', resolution: '720p', anyProviderUsable: false })).toBe('No video provider'); + expect(videoSummary({ modelName: 'Veo 3.1', aspectRatio: '16:9', resolution: '720p', providerUsable: false })).toBe('Not set'); expect(voiceSummary({ transcriptionModel: 'Deepgram Nova-3', readAloudModel: 'HeyGen Voice', voice: 'Alloy' })) .toBe('Deepgram Nova-3 · HeyGen Voice (Alloy)'); expect(voiceSummary({ transcriptionModel: 'Deepgram Nova-3', readAloudModel: null })).toBe('Deepgram Nova-3'); diff --git a/tests/unit/VideoGenerationService.test.ts b/tests/unit/VideoGenerationService.test.ts index 6d71703ef..30a45ebdc 100644 --- a/tests/unit/VideoGenerationService.test.ts +++ b/tests/unit/VideoGenerationService.test.ts @@ -8,6 +8,7 @@ import { OpenRouterVideoAdapter } from '../../src/services/video/adapters/OpenRo import { GenerateVideoTool } from '../../src/agents/promptManager/tools/generateVideo'; import { CheckGeneratedArtifactTool } from '../../src/agents/promptManager/tools/checkGeneratedArtifact'; import { DEFAULT_LLM_PROVIDER_SETTINGS, type LLMProviderSettings } from '../../src/types/llm/ProviderTypes'; +import { getVideoModel } from '../../src/services/llm/types/VideoTypes'; type MockVault = Vault & { getAbstractFileByPath: jest.Mock; @@ -359,6 +360,53 @@ describe('OpenRouterVideoAdapter', () => { expect(result.providerJobId).toBe('job-1'); }); + it('sends 4K in the case OpenRouter accepts when the internal resolution is 4k', async () => { + let submitted: Record | undefined; + __setRequestUrlMock(async (request) => { + if (request.url.endsWith('/videos/models')) { + return { + status: 200, + headers: {}, + text: '{}', + json: { + data: [{ + id: 'google/veo-3.1', + supported_resolutions: ['720p', '1080p', '4K'], + supported_aspect_ratios: ['16:9', '9:16'], + supported_durations: [4, 6, 8], + }] + }, + arrayBuffer: new ArrayBuffer(0), + }; + } + if (request.url.endsWith('/videos')) { + submitted = JSON.parse(String(request.body)) as Record; + return { + status: 202, + headers: {}, + text: '{}', + json: { id: 'job-4k', status: 'completed', unsigned_urls: ['https://cdn.example.com/4k.mp4'] }, + arrayBuffer: new ArrayBuffer(0), + }; + } + return { status: 200, headers: {}, text: '', json: null, arrayBuffer: new Uint8Array([1]).buffer }; + }); + + const adapter = new OpenRouterVideoAdapter({ apiKey: 'test-key', vault: makeVault() }); + await adapter.generate({ + prompt: 'A lighthouse at dusk.', + provider: 'openrouter', + model: 'google/veo-3.1', + aspectRatio: '16:9', + resolution: '4k', + seconds: 8, + pollIntervalMs: 1, + timeoutMs: 1000, + }); + + expect(submitted?.resolution).toBe('4K'); + }); + it('does not attach the Bearer key when polling an off-allowlist polling_url', async () => { const authByUrl: Record = {}; __setRequestUrlMock(async (request) => { @@ -721,3 +769,14 @@ describe('GoogleVideoAdapter credential allowlist', () => { expect(keyByUrl[trickUrl]).toBeUndefined(); }); }); + +describe('shipped video default', () => { + it('names a catalog model that offers the shipped aspect ratio and resolution', () => { + const shipped = DEFAULT_LLM_PROVIDER_SETTINGS.defaultVideoModel; + expect(shipped).toMatchObject({ provider: 'openrouter', model: 'google/veo-3.1-fast' }); + const declaration = getVideoModel(shipped?.provider, shipped?.model); + expect(declaration).toBeDefined(); + expect(declaration?.aspectRatios).toContain(shipped?.aspectRatio); + expect(declaration?.resolutions).toContain(shipped?.resolution); + }); +}); diff --git a/tests/unit/WorkspaceCreateEventIntegrity.test.ts b/tests/unit/WorkspaceCreateEventIntegrity.test.ts index f18e21674..142de5aee 100644 --- a/tests/unit/WorkspaceCreateEventIntegrity.test.ts +++ b/tests/unit/WorkspaceCreateEventIntegrity.test.ts @@ -209,3 +209,112 @@ describeWithSqlite('WorkspaceEventApplier: replaying a duplicate workspace_creat expect(rows).toEqual([expect.objectContaining({ id: 'default', description: 'second life', created: 3 })]); }); }); + +/** + * Two `workspace_created` events with one name and different ids. + * + * 5.17-5.20.x appended a `workspace_created` even when the live INSERT then + * failed UNIQUE(name), and two devices can each create the same name before + * syncing. Each id has its own shard (ws_.jsonl) and a rebuild replays the + * shards one after another in listing order. Replay used INSERT OR REPLACE, so + * the second shard's create DELETED the first workspace's row — its edits gone, + * its sessions/states/projects/tasks pointing at a workspace that no longer + * existed. The rule now: both rows survive under their own ids, the + * earliest-created keeps the name, the other becomes " ()", and the + * result does not depend on shard order. + */ +describeWithSqlite('WorkspaceEventApplier: two workspaces created with one name', () => { + const create = (id: string, created: number, description: string): WorkspaceEvent => ({ + id: `c-${id}-${created}`, type: 'workspace_created', deviceId: 'dev', timestamp: created, + data: { id, name: 'Default Workspace', description, rootFolder: '/', created, isActive: true } + } as WorkspaceEvent); + const update = (workspaceId: string, timestamp: number, data: Record): WorkspaceEvent => ({ + id: `u-${workspaceId}-${timestamp}`, type: 'workspace_updated', deviceId: 'dev', timestamp, + workspaceId, data: { lastAccessed: timestamp, ...data } + } as WorkspaceEvent); + const session = (workspaceId: string, timestamp: number): WorkspaceEvent => ({ + id: `s-${workspaceId}`, type: 'session_created', deviceId: 'dev', timestamp, + workspaceId, data: { id: `session-${workspaceId}`, name: 'S', startTime: timestamp } + } as WorkspaceEvent); + + // Each array is one shard; shards replay whole, one after another. + const realShard = [create('default', 10, 'real'), update('default', 20, { description: 'EDITED' }), session('default', 21)]; + const phantomShard = [create('ws_phantom', 30, 'phantom'), session('ws_phantom', 31)]; + + async function replayShards(shards: WorkspaceEvent[][]) { + const { cache, db } = openCache(); + const applier = new WorkspaceEventApplier(cache as unknown as ISQLiteCacheManager); + for (const shard of shards) { + for (const event of shard) { + await applier.apply(event); + } + } + const workspaces = db.prepare('SELECT id, name, description, created FROM workspaces ORDER BY id').all(); + const orphanSessions = db.prepare( + 'SELECT s.id FROM sessions s LEFT JOIN workspaces w ON w.id = s.workspaceId WHERE w.id IS NULL' + ).all(); + return { workspaces, orphanSessions }; + } + + const expected = [ + { id: 'default', name: 'Default Workspace', description: 'EDITED', created: 10 }, + { id: 'ws_phantom', name: 'Default Workspace (ws_phantom)', description: 'phantom', created: 30 } + ]; + + it('keeps the earlier workspace, its edits and its sessions when the later shard replays second', async () => { + const result = await replayShards([realShard, phantomShard]); + // Pre-fix: only ws_phantom survived; session-default was orphaned. + expect(result.workspaces).toEqual(expected); + expect(result.orphanSessions).toEqual([]); + }); + + it('gives the same result when the later shard replays first', async () => { + const result = await replayShards([phantomShard, realShard]); + expect(result.workspaces).toEqual(expected); + expect(result.orphanSessions).toEqual([]); + }); + + it('breaks a created-time tie by id, independent of shard order', async () => { + const a = [create('ws_a', 5, 'a')]; + const b = [create('ws_b', 5, 'b')]; + const forward = await replayShards([a, b]); + const reversed = await replayShards([b, a]); + expect(forward.workspaces).toEqual(reversed.workspaces); + expect(forward.workspaces).toEqual([ + expect.objectContaining({ id: 'ws_a', name: 'Default Workspace' }), + expect.objectContaining({ id: 'ws_b', name: 'Default Workspace (ws_b)' }) + ]); + }); + + it('applies the rest of an update whose rename collides, instead of failing the event', async () => { + const result = await replayShards([ + realShard, + [ + { ...create('ws_other', 40, 'other'), data: { id: 'ws_other', name: 'Other', created: 40, rootFolder: '/' } } as WorkspaceEvent, + update('ws_other', 50, { name: 'Default Workspace', description: 'kept' }) + ] + ]); + // Pre-fix: UNIQUE constraint failed: workspaces.name, and 'kept' never landed. + expect(result.workspaces).toEqual([ + expect.objectContaining({ id: 'default', name: 'Default Workspace', description: 'EDITED' }), + expect.objectContaining({ id: 'ws_other', name: 'Default Workspace (ws_other)', description: 'kept' }) + ]); + }); +}); + +describeWithSqlite('WorkspaceRepository.update: a rejected rename writes no event', () => { + it('refuses a name another workspace holds before appending', async () => { + const { cache, db } = openCache(); + const log = createEventLog(); + const repo = createRepository(cache, log.writer); + await repo.create(DEFAULT); + await repo.create({ ...DEFAULT, id: 'ws_other', name: 'Other' }); + + await expect(repo.update('ws_other', { name: 'Default Workspace', description: 'x' })) + .rejects.toThrow('UNIQUE constraint failed: workspaces.name'); + + // Pre-fix: a workspace_updated was appended, then the UPDATE threw. + expect(log.events.filter(e => e.type === 'workspace_updated')).toHaveLength(0); + expect(db.prepare("SELECT name FROM workspaces WHERE id = 'ws_other'").get()?.name).toBe('Other'); + }); +}); diff --git a/tool-schemas.json b/tool-schemas.json index 70d806987..b117cbd8d 100644 --- a/tool-schemas.json +++ b/tool-schemas.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "nexusVersion": "5.20.4", - "generatedAt": "2026-10-13T18:31:18.516Z", + "generatedAt": "2026-10-14T17:23:45.944Z", "toolCount": 2, "tools": [ {