@gmickel/gno 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +1 -1
  2. package/assets/skill/SKILL.md +5 -3
  3. package/assets/skill/cli-reference.md +9 -2
  4. package/assets/skill/mcp-reference.md +2 -1
  5. package/assets/spa-production.json.gz +0 -0
  6. package/browser-extension/artifacts/{gno-browser-clipper-v2.6.0.zip → gno-browser-clipper-v2.7.0.zip} +0 -0
  7. package/browser-extension/artifacts/gno-browser-clipper-v2.7.0.zip.sha256 +1 -0
  8. package/browser-extension/dist/manifest.json +1 -1
  9. package/package.json +1 -1
  10. package/spec/cli.md +109 -18
  11. package/spec/mcp.md +36 -2
  12. package/spec/output-schemas/ask.schema.json +1 -1
  13. package/spec/output-schemas/capture-receipt.schema.json +1 -1
  14. package/spec/output-schemas/doctor.schema.json +88 -0
  15. package/spec/output-schemas/error.schema.json +11 -2
  16. package/spec/output-schemas/get.schema.json +1 -1
  17. package/spec/output-schemas/mcp-capture-result.schema.json +1 -2
  18. package/spec/output-schemas/memory-remember.schema.json +2 -2
  19. package/spec/output-schemas/multi-get.schema.json +4 -1
  20. package/spec/output-schemas/peek.schema.json +2 -9
  21. package/spec/output-schemas/resident-status.schema.json +22 -0
  22. package/spec/output-schemas/search-result.schema.json +1 -1
  23. package/spec/output-schemas/search-results.schema.json +1 -1
  24. package/spec/output-schemas/status.schema.json +98 -0
  25. package/src/cli/commands/doctor.ts +54 -20
  26. package/src/cli/commands/embed.ts +41 -3
  27. package/src/cli/commands/query.ts +5 -0
  28. package/src/cli/commands/status.ts +63 -5
  29. package/src/cli/commands/vec.ts +54 -0
  30. package/src/cli/detach.ts +29 -1
  31. package/src/cli/errors.ts +13 -9
  32. package/src/cli/program.ts +53 -1
  33. package/src/core/capture-sync.ts +9 -2
  34. package/src/core/host-paths.ts +31 -0
  35. package/src/core/memory-remember.ts +4 -3
  36. package/src/core/shutdown-budget.ts +6 -0
  37. package/src/core/vector-partition-status.ts +52 -0
  38. package/src/embed/backlog.ts +124 -18
  39. package/src/embed/fingerprint.ts +6 -3
  40. package/src/embed/retry.ts +66 -27
  41. package/src/embed/variant-backlog.ts +15 -10
  42. package/src/embed/variant-retry.ts +31 -22
  43. package/src/index.ts +21 -2
  44. package/src/llm/native-worker/dispatcher.ts +2 -0
  45. package/src/llm/native-worker/embedding-identity.ts +42 -0
  46. package/src/llm/native-worker/protocol.ts +1 -0
  47. package/src/llm/types.ts +3 -0
  48. package/src/mcp/context.ts +9 -0
  49. package/src/mcp/resources/index.ts +6 -5
  50. package/src/mcp/tool-descriptions-core.ts +1 -1
  51. package/src/mcp/tools/capture.ts +1 -3
  52. package/src/mcp/tools/index.ts +11 -4
  53. package/src/mcp/tools/memory-remember.ts +1 -1
  54. package/src/mcp/tools/status.ts +4 -0
  55. package/src/pipeline/hybrid.ts +37 -7
  56. package/src/pipeline/vsearch.ts +14 -2
  57. package/src/serve/embed-scheduler.ts +133 -19
  58. package/src/serve/host-path-redaction.ts +79 -0
  59. package/src/serve/public/components/sessions/SessionSearch.tsx +2 -2
  60. package/src/serve/public/globals.built.css +1 -1
  61. package/src/serve/public/hooks/use-api.ts +17 -2
  62. package/src/serve/public/lib/request-intent.ts +8 -0
  63. package/src/serve/public/{components/sessions → lib}/snippet.tsx +2 -3
  64. package/src/serve/public/pages/Dashboard.tsx +12 -9
  65. package/src/serve/public/pages/DocView.tsx +10 -5
  66. package/src/serve/public/pages/DocumentEditor.tsx +91 -14
  67. package/src/serve/public/pages/Search.tsx +1 -41
  68. package/src/serve/resident-runtime.ts +26 -1
  69. package/src/serve/resident-status.ts +13 -1
  70. package/src/serve/server.ts +10 -9
  71. package/src/serve/status-model.ts +16 -0
  72. package/src/serve/status.ts +2 -0
  73. package/src/serve/watch-reconciliation-shared.ts +3 -0
  74. package/src/serve/watch-service-events.ts +3 -2
  75. package/src/serve/watch-service-run-flush.ts +35 -2
  76. package/src/serve/watch-service.ts +5 -0
  77. package/src/store/migrations/031-runtime-independent-vectors.ts +29 -0
  78. package/src/store/migrations/032-vector-runtime-callers.ts +17 -0
  79. package/src/store/migrations/index.ts +4 -0
  80. package/src/store/sqlite/adapter.ts +22 -1
  81. package/src/store/types.ts +11 -1
  82. package/src/store/vector/lazy.ts +46 -43
  83. package/src/store/vector/runtime-compat.ts +651 -0
  84. package/src/store/vector/sqlite-vec.ts +20 -2
  85. package/src/store/vector/status.ts +276 -35
  86. package/src/store/vector/types.ts +2 -0
  87. package/src/store/vector/variant-search.ts +71 -23
  88. package/src/store/vector/variants.ts +49 -14
  89. package/browser-extension/artifacts/gno-browser-clipper-v2.6.0.zip.sha256 +0 -1
@@ -5,7 +5,9 @@ import type { VectorVariantStore } from "../store/vector/variants";
5
5
  import { err, ok } from "../store/types";
6
6
  import { embedTextsWithRecovery } from "./batch";
7
7
  import {
8
+ type AcquireWriteTurn,
8
9
  chunkRetryKey,
10
+ inWriteTurn,
9
11
  isUpsertLockContention,
10
12
  upsertVectorsWithContentionRetry,
11
13
  } from "./retry";
@@ -30,11 +32,14 @@ export async function embedVariantBatch(params: {
30
32
  identityStillCurrent: () => boolean;
31
33
  delays?: number[];
32
34
  force?: boolean;
35
+ /** Gate held only around the checkpoint write, never around inference. */
36
+ acquireWriteTurn?: AcquireWriteTurn;
33
37
  }): Promise<{
34
38
  embedded: number;
35
39
  errors: number;
36
40
  contentionErrors: number;
37
41
  retryOwners: VectorOwnerInput[];
42
+ deferred?: boolean;
38
43
  }> {
39
44
  const { store, embedPort, identityStillCurrent } = params;
40
45
  const empty = {
@@ -76,30 +81,34 @@ export async function embedVariantBatch(params: {
76
81
  .map((owner) => ({ owner, embedding: vectors.get(owner.inputHash) }));
77
82
  if (!rows.length) return { ...empty, retryOwners };
78
83
  let written = 0;
79
- const persisted = await upsertVectorsWithContentionRetry(
80
- {
81
- upsertVectors: () => {
82
- try {
83
- // A contention wait may allow document or runtime mutations. Revalidate each attempt.
84
- if (!identityStillCurrent()) return Promise.resolve(ok(undefined));
85
- const valid = rows.filter(({ owner }) => isCurrent(store, owner));
86
- store.write(valid);
87
- written = valid.length;
88
- return Promise.resolve(ok(undefined));
89
- } catch (cause) {
90
- return Promise.resolve(
91
- err(
92
- "QUERY_FAILED",
93
- cause instanceof Error ? cause.message : String(cause),
94
- cause
95
- )
96
- );
97
- }
84
+ const turn = await inWriteTurn(params.acquireWriteTurn, () =>
85
+ upsertVectorsWithContentionRetry(
86
+ {
87
+ upsertVectors: () => {
88
+ try {
89
+ // A contention wait may allow document or runtime mutations. Revalidate each attempt.
90
+ if (!identityStillCurrent()) return Promise.resolve(ok(undefined));
91
+ const valid = rows.filter(({ owner }) => isCurrent(store, owner));
92
+ store.write(valid);
93
+ written = valid.length;
94
+ return Promise.resolve(ok(undefined));
95
+ } catch (cause) {
96
+ return Promise.resolve(
97
+ err(
98
+ "QUERY_FAILED",
99
+ cause instanceof Error ? cause.message : String(cause),
100
+ cause
101
+ )
102
+ );
103
+ }
104
+ },
98
105
  },
99
- },
100
- [],
101
- params.delays
106
+ [],
107
+ params.delays
108
+ )
102
109
  );
110
+ if (turn.deferred) return { ...empty, deferred: true };
111
+ const persisted = turn.value;
103
112
  if (!persisted.ok)
104
113
  return {
105
114
  ...empty,
package/src/index.ts CHANGED
@@ -11,13 +11,32 @@ import { resetModelManager } from "./llm/nodeLlamaCpp/lifecycle";
11
11
  import { IMPORT_CHILD_ENV } from "./sessions/import-child-env";
12
12
 
13
13
  /**
14
- * Cleanup models and exit.
15
- * Without this, llama.cpp native threads can keep the process alive.
14
+ * End `stream` and resolve once its queued writes have reached the OS.
15
+ * process.exit() drops pending asynchronous pipe writes, so a pipe consumer
16
+ * would otherwise see output cut at the pipe buffer size. Bun reports no
17
+ * writableLength and fires an empty write's callback immediately, so end()
18
+ * is the flush that actually waits. A closed consumer (EPIPE) settles
19
+ * through the callback or the 'error' event.
20
+ */
21
+ function flushStream(stream: NodeJS.WriteStream): Promise<void> {
22
+ if (stream.destroyed || stream.writableEnded) {
23
+ return Promise.resolve();
24
+ }
25
+ return new Promise((resolve) => {
26
+ stream.once("error", () => resolve());
27
+ stream.end(() => resolve());
28
+ });
29
+ }
30
+
31
+ /**
32
+ * Cleanup models, flush output, and exit.
33
+ * Without the explicit exit, llama.cpp native threads can keep the process alive.
16
34
  */
17
35
  async function cleanupAndExit(code: number): Promise<never> {
18
36
  await resetModelManager().catch(() => {
19
37
  // Ignore cleanup errors on exit
20
38
  });
39
+ await Promise.all([flushStream(process.stdout), flushStream(process.stderr)]);
21
40
  process.exit(code);
22
41
  }
23
42
 
@@ -15,6 +15,7 @@ import {
15
15
  fileIdentity,
16
16
  fingerprintModel,
17
17
  fingerprintRuntime,
18
+ runtimeLabel,
18
19
  } from "./embedding-identity";
19
20
  import { NativeWorkerError } from "./errors";
20
21
  import { checkEvaluation } from "./evaluation";
@@ -174,6 +175,7 @@ export class NativeDispatcher {
174
175
  gpu: llama.gpu,
175
176
  cpuMathCores: llama.cpuMathCores,
176
177
  }),
178
+ runtimeLabel: runtimeLabel(llama.gpu),
177
179
  },
178
180
  },
179
181
  };
@@ -31,3 +31,45 @@ export function fingerprintRuntime(settings: Record<string, unknown>): string {
31
31
  )
32
32
  .digest("hex");
33
33
  }
34
+
35
+ /** Environment that shapes the native runtime identity of an embedding. */
36
+ const RUNTIME_ENV = [
37
+ "GNO_LLAMA_GPU",
38
+ "NODE_LLAMA_CPP_GPU",
39
+ "GNO_EMBED_CONTEXT_SIZE",
40
+ "GNO_EMBED_CONTEXTS",
41
+ "GNO_EMBED_THREADS",
42
+ ] as const;
43
+
44
+ /**
45
+ * What a process can know about its embedding runtime without loading the
46
+ * model: status looks up the identity a caller with this key last resolved.
47
+ */
48
+ export function runtimeCallerKey(
49
+ model: string,
50
+ env: NodeJS.ProcessEnv = process.env
51
+ ): string {
52
+ return new Bun.CryptoHasher("sha256")
53
+ .update(
54
+ JSON.stringify([
55
+ model,
56
+ Bun.version,
57
+ nativePackage.version,
58
+ process.platform,
59
+ process.arch,
60
+ RUNTIME_ENV.map((name) => env[name] ?? null),
61
+ ])
62
+ )
63
+ .digest("hex");
64
+ }
65
+
66
+ const BACKEND_LABELS: Record<string, string> = {
67
+ cuda: "CUDA",
68
+ metal: "Metal",
69
+ vulkan: "Vulkan",
70
+ };
71
+
72
+ /** Readable provenance for status output; identity stays the fingerprint. */
73
+ export function runtimeLabel(gpu: string | false): string {
74
+ return `${gpu ? (BACKEND_LABELS[gpu] ?? gpu) : "CPU"}, Bun ${Bun.version}`;
75
+ }
@@ -112,6 +112,7 @@ export const EmbeddingIdentitySchema = z.strictObject({
112
112
  truncationPolicy: z.string().min(1),
113
113
  modelFingerprint: z.string().regex(/^[a-f0-9]{64}$/),
114
114
  runtimeFingerprint: z.string().regex(/^[a-f0-9]{64}$/),
115
+ runtimeLabel: z.string().min(1),
115
116
  });
116
117
  const metadata = z.strictObject({
117
118
  dimensions: id.optional(),
package/src/llm/types.ts CHANGED
@@ -95,7 +95,10 @@ export interface EmbeddingIdentity {
95
95
  /** Versioned policy including the effective token limit. */
96
96
  truncationPolicy: string;
97
97
  modelFingerprint: string;
98
+ /** Runtime provenance (Bun, binding, backend, threads); not vector identity. */
98
99
  runtimeFingerprint: string;
100
+ /** Readable provenance such as "CUDA, Bun 1.4.2". */
101
+ runtimeLabel?: string;
99
102
  }
100
103
 
101
104
  export interface EmbeddingPort {
@@ -120,6 +120,15 @@ export interface ToolContext {
120
120
  runWithSnapshot?<T>(operation: () => Promise<T>): Promise<T>;
121
121
  }
122
122
 
123
+ /**
124
+ * Host absolute paths reach only stdio callers, which run on the owner's
125
+ * machine. Every Streamable HTTP request runs inside an egress context,
126
+ * whatever its peer, so HTTP callers get URIs and relative paths only.
127
+ */
128
+ export const exposesHostPaths = (
129
+ ctx: Pick<ToolContext, "getEgressContext">
130
+ ): boolean => ctx.getEgressContext?.() === undefined;
131
+
123
132
  export interface CreateToolContextOptions {
124
133
  store: SqliteAdapter;
125
134
  getConfig: () => Config;
@@ -21,6 +21,7 @@ import { resolveEffectiveIndex } from "../../core/indexed-reference";
21
21
  import { normalizeTag, validateTag } from "../../core/tags";
22
22
  import { normalizeCollectionName } from "../../core/validation";
23
23
  import { openScopedIndexStore } from "../../store/sqlite/scoped-index";
24
+ import { exposesHostPaths } from "../context";
24
25
 
25
26
  // Tags resource URI prefix
26
27
  const TAGS_URI = `${URI_PREFIX}tags`;
@@ -53,15 +54,15 @@ function formatResourceContent(
53
54
  ctx: ToolContext,
54
55
  indexName = ctx.indexName
55
56
  ): string {
56
- // Find collection for absPath
57
+ // Host path for stdio callers; HTTP callers see the relative path.
57
58
  const uriParsed = parseUri(doc.uri);
58
- let absPath = doc.relPath;
59
- if (uriParsed) {
59
+ let source = doc.relPath;
60
+ if (uriParsed && exposesHostPaths(ctx)) {
60
61
  const collection = ctx.collections.find(
61
62
  (c) => c.name === uriParsed.collection
62
63
  );
63
64
  if (collection) {
64
- absPath = pathJoin(collection.path, doc.relPath);
65
+ source = pathJoin(collection.path, doc.relPath);
65
66
  }
66
67
  }
67
68
 
@@ -72,7 +73,7 @@ function formatResourceContent(
72
73
  const displayUri = decorateUriForIndex(doc.uri, indexName);
73
74
  const header = `<!-- ${displayUri}
74
75
  docid: ${doc.docid}
75
- source: ${absPath}
76
+ source: ${source}
76
77
  mime: ${doc.sourceMime}${langLine}
77
78
  -->
78
79
 
@@ -32,7 +32,7 @@ export const MCP_CORE_TOOL_DESCRIPTIONS: Readonly<Record<string, string>> = {
32
32
  gno_recall:
33
33
  "Call before answering about the user's preferences, decisions, people, or prior work, and before gno_remember to find the predecessor of a changed fact. Retrieves current facts from a memory-managed collection for the explicit scopes you pass; superseded facts are excluded. Returns at most 8 facts within 512 tokens by default, each with text, scopes, provenance, gno:// cite, and content hash, plus a content-free receipt. Pass that receipt to gno_remember when a stored fact derives from this recall. An empty result names the command that stores the first fact.",
34
34
  gno_capture:
35
- "Call to create a new note from text the user wants kept: pass collection and content (or a presetId scaffold), optionally title, path or folderPath, tags, and source provenance. Writes the file to disk with source: frontmatter, syncs it for keyword search, and returns a receipt with uri, docid, relPath, absPath, contentHash, collisionPolicyResult, and sync and embed status. Embedding is a separate step (embed.status stays short of completed until gno_index or gno_embed runs), so vector search sees the note later. An existing target follows collisionPolicy: error, open_existing, or create_with_suffix.",
35
+ "Call to create a new note from text the user wants kept: pass collection and content (or a presetId scaffold), optionally title, path or folderPath, tags, and source provenance. Writes the file to disk with source: frontmatter, syncs it for keyword search, and returns a receipt with uri, docid, relPath, absPath (stdio only), contentHash, collisionPolicyResult, and sync and embed status. Embedding is a separate step (embed.status stays short of completed until gno_index or gno_embed runs), so vector search sees the note later. An existing target follows collisionPolicy: error, open_existing, or create_with_suffix.",
36
36
  gno_remember:
37
37
  "Call when the user states a durable preference, decision, or fact worth recalling later; documents go through gno_capture and existing notes through file edits. Stores one fact in a memory-managed collection under the explicit scopes you pass. Without decision it returns likely matches and writes nothing; decision=add writes a new fact; decision=supersede replaces predecessorUri after a hash check, one successor per fact. Returns outcome (candidates, existing, added, or superseded) with the stored record; an exact duplicate returns the existing record. Text that replays a recall receipt span or declares a gno:// origin is rejected. The fact is lexically searchable when the call returns.",
38
38
  };
@@ -47,7 +47,6 @@ interface CaptureInput extends Omit<
47
47
 
48
48
  type McpCaptureResult = CaptureReceipt & {
49
49
  docid: string;
50
- absPath: string;
51
50
  overwritten: boolean;
52
51
  serverInstanceId: string;
53
52
  request?: RequestReceiptInfo;
@@ -80,7 +79,7 @@ function formatCaptureResult(result: McpCaptureResult): string {
80
79
  const lines: string[] = [];
81
80
  lines.push(`Doc: ${result.docid}`);
82
81
  lines.push(`URI: ${result.uri}`);
83
- lines.push(`Path: ${result.absPath}`);
82
+ if (result.absPath) lines.push(`Path: ${result.absPath}`);
84
83
  lines.push(`Created: ${result.created ? "yes" : "no"}`);
85
84
  lines.push(`Opened existing: ${result.openedExisting ? "yes" : "no"}`);
86
85
  lines.push(`Overwritten: ${result.overwritten ? "yes" : "no"}`);
@@ -216,7 +215,6 @@ export function handleCapture(
216
215
  return {
217
216
  ...published.receipt,
218
217
  docid: published.receipt.docid ?? "",
219
- absPath: published.receipt.absPath ?? "",
220
218
  overwritten: published.receipt.overwritten ?? false,
221
219
  serverInstanceId: ctx.serverInstanceId,
222
220
  ...(published.request ? { request: published.request } : {}),
@@ -20,6 +20,7 @@ import {
20
20
  compiledContextPreviewSchema,
21
21
  compiledContextCheckSchema,
22
22
  } from "../../core/compiled-context";
23
+ import { withoutHostPaths } from "../../core/host-paths";
23
24
  import { NOTE_PRESETS, type NotePresetId } from "../../core/note-presets";
24
25
  import { RETRIEVAL_TRACE_METADATA } from "../../core/retrieval-trace-session";
25
26
  import { normalizeTag } from "../../core/tags";
@@ -31,6 +32,7 @@ import {
31
32
  assertInferenceActive,
32
33
  acquireInferencePermit,
33
34
  } from "../../llm/inference-scope";
35
+ import { exposesHostPaths } from "../context";
34
36
  import { profileToolDescription } from "../tool-descriptions-core";
35
37
  import {
36
38
  createProfileToolRegistrar,
@@ -972,6 +974,10 @@ export interface ToolResult {
972
974
  // DRY Helper: Exception Firewall + Mutex + Response Shaping
973
975
  // ─────────────────────────────────────────────────────────────────────────────
974
976
 
977
+ /** Tool data as the current caller may see it (no host paths over HTTP). */
978
+ const forCaller = <T>(ctx: ToolContext, data: T): T =>
979
+ exposesHostPaths(ctx) ? data : withoutHostPaths(data);
980
+
975
981
  export async function runTool<T>(
976
982
  ctx: ToolContext,
977
983
  name: string,
@@ -990,12 +996,13 @@ export async function runTool<T>(
990
996
  const release = await acquireInferencePermit(() => ctx.toolMutex.acquire());
991
997
  try {
992
998
  assertInferenceActive();
993
- const data = await (ctx.runWithSnapshot?.(fn) ?? fn());
999
+ const raw = await (ctx.runWithSnapshot?.(fn) ?? fn());
994
1000
  assertInferenceActive();
995
1001
  const traceMetadata =
996
- data !== null && typeof data === "object"
997
- ? (data as Record<PropertyKey, unknown>)[RETRIEVAL_TRACE_METADATA]
1002
+ raw !== null && typeof raw === "object"
1003
+ ? (raw as Record<PropertyKey, unknown>)[RETRIEVAL_TRACE_METADATA]
998
1004
  : undefined;
1005
+ const data = forCaller(ctx, raw);
999
1006
  return {
1000
1007
  content: [{ type: "text", text: formatText(data) }],
1001
1008
  structuredContent: data as { [x: string]: unknown },
@@ -1039,7 +1046,7 @@ export async function runToolNoMutex<T>(
1039
1046
 
1040
1047
  try {
1041
1048
  assertInferenceActive();
1042
- const data = await (ctx.runWithSnapshot?.(fn) ?? fn());
1049
+ const data = forCaller(ctx, await (ctx.runWithSnapshot?.(fn) ?? fn()));
1043
1050
  assertInferenceActive();
1044
1051
  return {
1045
1052
  content: [{ type: "text", text: formatText(data) }],
@@ -127,7 +127,7 @@ export function formatRememberResult(result: RememberResult): string {
127
127
  lines.push(`Outcome: ${result.outcome}`);
128
128
  lines.push(`URI: ${result.record.uri}`);
129
129
  lines.push(`Hash: ${result.record.contentHash}`);
130
- lines.push(`Path: ${result.absPath}`);
130
+ if (result.absPath) lines.push(`Path: ${result.absPath}`);
131
131
  lines.push(`Sync: ${result.sync.status}`);
132
132
  if (result.record.supersedes.length > 0) {
133
133
  lines.push(`Supersedes: ${result.record.supersedes.join(", ")}`);
@@ -9,6 +9,7 @@ import type { ToolContext } from "../server";
9
9
 
10
10
  import { buildContentTypeBoostStatus } from "../../config/content-types";
11
11
  import { formatChunkingStatus } from "../../core/chunking-status";
12
+ import { formatVectorPartitionLines } from "../../core/vector-partition-status";
12
13
  import { resolveModelUri } from "../../llm/registry";
13
14
  import { createStandaloneResidentStatus } from "../../serve/resident-status";
14
15
  import { runTool, type ToolResult } from "./index";
@@ -59,6 +60,9 @@ function formatStatus(status: IndexStatus): string {
59
60
  if (status.embeddingBacklog > 0) {
60
61
  lines.push(`Embedding backlog: ${status.embeddingBacklog} chunks`);
61
62
  }
63
+ lines.push(
64
+ ...formatVectorPartitionLines(status.vectorPartitions, status.vectorRuntime)
65
+ );
62
66
 
63
67
  const chunking = formatChunkingStatus(status.chunking);
64
68
  if (chunking) lines.push(chunking);
@@ -33,7 +33,11 @@ import {
33
33
  withInferenceScope,
34
34
  } from "../llm/inference-scope";
35
35
  import { err, ok } from "../store/types";
36
- import { resolveVectorSearchIdentity } from "../store/vector/variant-search";
36
+ import {
37
+ lexicalFallbackNotice,
38
+ resolveVectorSearchIdentity,
39
+ VECTOR_RUNTIME_INCOMPATIBLE,
40
+ } from "../store/vector/variant-search";
37
41
  import { createChunkLookup } from "./chunk-lookup";
38
42
  import {
39
43
  attachAuxiliaryScoreMetadata,
@@ -284,7 +288,10 @@ async function searchVectorChunks(
284
288
  allowedMirrorHashes?: string[];
285
289
  eligibility?: VectorSearchOptions["eligibility"];
286
290
  }
287
- ): Promise<{ ok: true; chunks: ChunkId[] } | { ok: false; reason: string }> {
291
+ ): Promise<
292
+ | { ok: true; chunks: ChunkId[] }
293
+ | { ok: false; reason: string; notice?: string }
294
+ > {
288
295
  if (!vectorIndex.searchAvailable) {
289
296
  return { ok: false, reason: "vector_unavailable" };
290
297
  }
@@ -298,12 +305,19 @@ async function searchVectorChunks(
298
305
  return { ok: false, reason: "vector_embed_error" };
299
306
  }
300
307
 
308
+ const partition = await resolveVectorSearchIdentity(embedPort, vectorIndex);
309
+ if (partition.unavailable)
310
+ return {
311
+ ok: false,
312
+ reason: VECTOR_RUNTIME_INCOMPATIBLE,
313
+ notice: lexicalFallbackNotice(partition.unavailable),
314
+ };
301
315
  const queryEmbedding = new Float32Array(embedResult.value);
302
316
  const searchResult = await vectorIndex.searchNearest(
303
317
  queryEmbedding,
304
318
  options.limit,
305
319
  {
306
- embeddingIdentity: resolveVectorSearchIdentity(embedPort),
320
+ embeddingIdentity: partition.identity,
307
321
  minScore: options.minScore,
308
322
  allowedMirrorHashes: options.allowedMirrorHashes,
309
323
  eligibility: options.eligibility,
@@ -630,6 +644,7 @@ async function searchHybridWithHydration(
630
644
  // Vector search
631
645
  let vecCount = 0;
632
646
  let vectorsUsed = false;
647
+ let vectorNotice: string | undefined;
633
648
  const vectorAvailable =
634
649
  (vectorIndex?.searchAvailable && embedPort !== null) ?? false;
635
650
  if (!vectorAvailable) {
@@ -662,8 +677,10 @@ async function searchHybridWithHydration(
662
677
  }
663
678
  );
664
679
 
665
- if (!vectorResult.ok) counters.fallbackEvents.push(vectorResult.reason);
666
- else vectorsUsed = true;
680
+ if (!vectorResult.ok) {
681
+ counters.fallbackEvents.push(vectorResult.reason);
682
+ vectorNotice = vectorResult.notice;
683
+ } else vectorsUsed = true;
667
684
  const vecChunks = vectorResult.ok ? vectorResult.chunks : [];
668
685
  vecCount = vecChunks.length;
669
686
  vectorTraceChunks.push(...vecChunks);
@@ -691,8 +708,14 @@ async function searchHybridWithHydration(
691
708
  );
692
709
 
693
710
  assertInferenceResult(embedResult);
711
+ const partition = embedResult.ok
712
+ ? await resolveVectorSearchIdentity(embedPort, vectorIndex)
713
+ : undefined;
694
714
  if (!embedResult.ok) {
695
715
  counters.fallbackEvents.push("vector_embed_error");
716
+ } else if (partition?.unavailable) {
717
+ counters.fallbackEvents.push(VECTOR_RUNTIME_INCOMPATIBLE);
718
+ vectorNotice = lexicalFallbackNotice(partition.unavailable);
696
719
  } else {
697
720
  if (embedResult.value.batchFailed) {
698
721
  counters.fallbackEvents.push("vector_embed_batch_fallback");
@@ -709,7 +732,7 @@ async function searchHybridWithHydration(
709
732
  new Float32Array(embedding),
710
733
  variant.limit,
711
734
  {
712
- embeddingIdentity: resolveVectorSearchIdentity(embedPort),
735
+ embeddingIdentity: partition?.identity,
713
736
  allowedMirrorHashes: options.retrievalScope?.allowedMirrorHashes,
714
737
  eligibility: vectorEligibility,
715
738
  }
@@ -1390,6 +1413,11 @@ async function searchHybridWithHydration(
1390
1413
  trace: diagnoseTrace,
1391
1414
  },
1392
1415
  };
1416
+ if (vectorNotice)
1417
+ output.meta.warnings = [
1418
+ ...(output.meta.warnings ?? []),
1419
+ { code: VECTOR_RUNTIME_INCOMPATIBLE, message: vectorNotice },
1420
+ ];
1393
1421
  const fallbackCodes = [...new Set(counters.fallbackEvents)].sort();
1394
1422
  const capabilityOutcomes = [
1395
1423
  { capability: "lexical_search", status: "used" as const },
@@ -1400,7 +1428,9 @@ async function searchHybridWithHydration(
1400
1428
  status: "failed" as const,
1401
1429
  reasonCode: fallbackCodes.includes("vector_embed_error")
1402
1430
  ? "vector_embed_error"
1403
- : "vector_search_error",
1431
+ : vectorNotice
1432
+ ? VECTOR_RUNTIME_INCOMPATIBLE
1433
+ : "vector_search_error",
1404
1434
  }
1405
1435
  : { capability: "semantic_search", status: "used" as const }
1406
1436
  : {
@@ -21,7 +21,10 @@ import {
21
21
  } from "../llm/inference-scope";
22
22
  import { getContentBatch } from "../store/content-batch";
23
23
  import { err, ok } from "../store/types";
24
- import { resolveVectorSearchIdentity } from "../store/vector/variant-search";
24
+ import {
25
+ resolveVectorSearchIdentity,
26
+ vectorSearchUnavailableMessage,
27
+ } from "../store/vector/variant-search";
25
28
  import { createChunkLookup } from "./chunk-lookup";
26
29
  import {
27
30
  applyContentTypeBoost,
@@ -144,7 +147,16 @@ async function searchVectorWithEmbeddingOwned(
144
147
 
145
148
  let embeddingIdentity;
146
149
  try {
147
- embeddingIdentity = resolveVectorSearchIdentity(deps.embedPort);
150
+ const partition = await resolveVectorSearchIdentity(
151
+ deps.embedPort,
152
+ vectorIndex
153
+ );
154
+ if (partition.unavailable)
155
+ return err(
156
+ "VEC_SEARCH_UNAVAILABLE",
157
+ vectorSearchUnavailableMessage(partition.unavailable)
158
+ );
159
+ embeddingIdentity = partition.identity;
148
160
  } catch (cause) {
149
161
  return err(
150
162
  "QUERY_FAILED",