@gmickel/gno 2.10.0 → 2.11.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 (78) hide show
  1. package/README.md +11 -11
  2. package/assets/skill/SKILL.md +25 -14
  3. package/assets/skill/cli-reference.md +6 -5
  4. package/assets/skill/examples.md +16 -12
  5. package/assets/skill/mcp-reference.md +2 -1
  6. package/browser-extension/artifacts/{gno-browser-clipper-v2.10.0.zip → gno-browser-clipper-v2.11.0.zip} +0 -0
  7. package/browser-extension/artifacts/gno-browser-clipper-v2.11.0.zip.sha256 +1 -0
  8. package/browser-extension/dist/manifest.json +1 -1
  9. package/bunfig.toml +5 -1
  10. package/package.json +4 -1
  11. package/spec/cli.md +59 -11
  12. package/spec/mcp.md +28 -4
  13. package/spec/output-schemas/ask.schema.json +8 -0
  14. package/spec/output-schemas/claim-verification.schema.json +8 -2
  15. package/spec/output-schemas/context-capsule-v1.schema.json +6 -1
  16. package/spec/output-schemas/context-capsule-verification.schema.json +4 -2
  17. package/spec/output-schemas/get.schema.json +4 -0
  18. package/spec/output-schemas/mcp-remove-result.schema.json +2 -2
  19. package/spec/output-schemas/metadata-summary.schema.json +60 -0
  20. package/spec/output-schemas/multi-get.schema.json +4 -0
  21. package/spec/output-schemas/search-result.schema.json +4 -0
  22. package/spec/output-schemas/search-results.schema.json +4 -0
  23. package/src/app/constants.ts +16 -0
  24. package/src/app/context-format.ts +1 -0
  25. package/src/app/verified-ask.ts +2 -0
  26. package/src/cli/commands/agents/block.ts +9 -8
  27. package/src/cli/commands/ask-format.ts +18 -1
  28. package/src/cli/commands/ask.ts +4 -5
  29. package/src/cli/commands/cleanup.ts +9 -1
  30. package/src/cli/commands/collection/remove.ts +50 -31
  31. package/src/cli/commands/collection/rename.ts +36 -8
  32. package/src/cli/commands/daemon.ts +5 -1
  33. package/src/cli/commands/get.ts +11 -1
  34. package/src/cli/commands/metadata.ts +79 -0
  35. package/src/cli/commands/multi-get.ts +13 -0
  36. package/src/cli/commands/shared.ts +9 -7
  37. package/src/cli/format/search-results.ts +11 -0
  38. package/src/cli/options.ts +2 -0
  39. package/src/cli/program.ts +101 -16
  40. package/src/core/context-capsule-schema.ts +2 -0
  41. package/src/core/context-capsule-verification.ts +2 -0
  42. package/src/core/context-compiler.ts +2 -5
  43. package/src/core/context-evidence.ts +1 -0
  44. package/src/core/context-verifier.ts +14 -1
  45. package/src/core/metadata-discovery.ts +169 -0
  46. package/src/ingestion/sync.ts +49 -1
  47. package/src/ingestion/walker.ts +11 -3
  48. package/src/mcp/http-egress.ts +1 -0
  49. package/src/mcp/tool-descriptions-core.ts +2 -2
  50. package/src/mcp/tool-profile.ts +16 -1
  51. package/src/mcp/tools/get.ts +10 -0
  52. package/src/mcp/tools/index.ts +39 -4
  53. package/src/mcp/tools/multi-get.ts +15 -0
  54. package/src/mcp/tools/query.ts +5 -3
  55. package/src/mcp/tools/remove-collection.ts +2 -1
  56. package/src/mcp/tools/search.ts +5 -3
  57. package/src/mcp/tools/vsearch.ts +5 -3
  58. package/src/mcp/tools/workspace-write.ts +42 -34
  59. package/src/pipeline/answer-prompt.ts +15 -2
  60. package/src/pipeline/answer.ts +3 -0
  61. package/src/pipeline/claim-verification-schema.ts +6 -1
  62. package/src/pipeline/claim-verification.ts +54 -1
  63. package/src/pipeline/explain.ts +1 -1
  64. package/src/pipeline/fusion.ts +24 -4
  65. package/src/pipeline/hybrid.ts +5 -1
  66. package/src/pipeline/result-context.ts +29 -0
  67. package/src/pipeline/search.ts +5 -1
  68. package/src/pipeline/types.ts +4 -0
  69. package/src/pipeline/vsearch.ts +5 -1
  70. package/src/sdk/client.ts +32 -4
  71. package/src/sdk/documents.ts +5 -0
  72. package/src/sdk/types.ts +5 -0
  73. package/src/serve/routes/api.ts +32 -5
  74. package/src/serve/server.ts +12 -0
  75. package/src/store/sqlite/adapter.ts +99 -6
  76. package/src/store/types.ts +16 -0
  77. package/src/store/vector/variant-search.ts +112 -74
  78. package/browser-extension/artifacts/gno-browser-clipper-v2.10.0.zip.sha256 +0 -1
@@ -38,7 +38,10 @@ import { typedMetadataWarnings, typedMetadataFilterReason } from "./filters";
38
38
  import { selectBestChunkForSteering } from "./intent";
39
39
  import { hasProjectAffinity } from "./project-affinity";
40
40
  import { detectQueryLanguage } from "./query-language";
41
- import { attachSearchResultContexts } from "./result-context";
41
+ import {
42
+ attachSearchResultContexts,
43
+ attachSupersession,
44
+ } from "./result-context";
42
45
  import { cleanDisplaySnippet } from "./snippet";
43
46
  import {
44
47
  resolveRecencyTimestamp,
@@ -520,6 +523,7 @@ async function searchVectorWithEmbeddingOwned(
520
523
  if (metadata) metadata.retrievalRank = index + 1;
521
524
  }
522
525
  await attachSearchResultContexts(store, finalResults);
526
+ await attachSupersession(store, finalResults);
523
527
  const lineageResult = await attachSearchResultEgressLineage(
524
528
  store,
525
529
  finalResults,
package/src/sdk/client.ts CHANGED
@@ -78,6 +78,7 @@ import {
78
78
  refreshContextFile,
79
79
  } from "../app/compiled-context-files";
80
80
  import {
81
+ decorateResultUris,
81
82
  decorateUriForIndex,
82
83
  DEFAULT_INDEX_NAME,
83
84
  getIndexDbPath,
@@ -140,6 +141,7 @@ import {
140
141
  type MemoryErrorCode,
141
142
  MemoryService,
142
143
  } from "../core/memory";
144
+ import { readMetadataSummary } from "../core/metadata-discovery";
143
145
  import { resolveNoteCreatePlan } from "../core/note-creation";
144
146
  import { resolveNotePreset } from "../core/note-presets";
145
147
  import {
@@ -701,10 +703,9 @@ class GnoClientImpl implements GnoClient {
701
703
  private decorateSearchResults(results: SearchResults): SearchResults {
702
704
  return {
703
705
  ...results,
704
- results: results.results.map((result) => ({
705
- ...result,
706
- uri: decorateUriForIndex(result.uri, this.indexName),
707
- })),
706
+ results: results.results.map((result) =>
707
+ decorateResultUris(result, this.indexName)
708
+ ),
708
709
  };
709
710
  }
710
711
 
@@ -1415,6 +1416,14 @@ class GnoClientImpl implements GnoClient {
1415
1416
  const decorated = {
1416
1417
  ...result,
1417
1418
  uri: decorateUriForIndex(result.uri, scoped.indexName),
1419
+ ...(result.supersededBy
1420
+ ? {
1421
+ supersededBy: decorateUriForIndex(
1422
+ result.supersededBy,
1423
+ scoped.indexName
1424
+ ),
1425
+ }
1426
+ : {}),
1418
1427
  };
1419
1428
  const traceMetadata = getRetrievalTraceMetadata(result);
1420
1429
  if (traceMetadata) {
@@ -1458,6 +1467,14 @@ class GnoClientImpl implements GnoClient {
1458
1467
  documents: result.documents.map((doc) => ({
1459
1468
  ...doc,
1460
1469
  uri: decorateUriForIndex(doc.uri, scoped.indexName),
1470
+ ...(doc.supersededBy
1471
+ ? {
1472
+ supersededBy: decorateUriForIndex(
1473
+ doc.supersededBy,
1474
+ scoped.indexName
1475
+ ),
1476
+ }
1477
+ : {}),
1461
1478
  })),
1462
1479
  };
1463
1480
  } finally {
@@ -1477,6 +1494,17 @@ class GnoClientImpl implements GnoClient {
1477
1494
  };
1478
1495
  }
1479
1496
 
1497
+ async listMetadata(options: { collection?: string; values?: number } = {}) {
1498
+ this.assertOpen();
1499
+ try {
1500
+ return await readMetadataSummary(this.store, options);
1501
+ } catch (cause) {
1502
+ if (cause instanceof RangeError)
1503
+ throw sdkError("VALIDATION", cause.message);
1504
+ throw cause;
1505
+ }
1506
+ }
1507
+
1480
1508
  async changes(
1481
1509
  options: ListKnowledgeChangesInput = {}
1482
1510
  ): Promise<KnowledgeChangesResult> {
@@ -30,6 +30,7 @@ import {
30
30
  evidenceFromExactDocument,
31
31
  RetrievalTraceSession,
32
32
  } from "../core/retrieval-trace-session";
33
+ import { supersedingUri } from "../pipeline/result-context";
33
34
  import { sdkError } from "./errors";
34
35
 
35
36
  const URI_PREFIX_PATTERN = /^gno:\/\/[^/]+\//;
@@ -177,6 +178,8 @@ export async function getDocumentByRef(
177
178
  options: GnoGetOptions = {}
178
179
  ): Promise<GetResponse> {
179
180
  const response = await getDocumentByRefUntraced(store, config, ref, options);
181
+ const successor = await supersedingUri(store, response.uri);
182
+ if (successor) response.supersededBy = successor;
180
183
  if (!options.traceId) return response;
181
184
  const resumed = await RetrievalTraceSession.resume({
182
185
  store,
@@ -325,7 +328,9 @@ export async function multiGetDocuments(
325
328
  );
326
329
  const coll = config.collections.find((c) => c.name === doc.collection);
327
330
  const sourceRelPath = doc.recordSourcePath ?? doc.relPath;
331
+ const successor = await supersedingUri(store, doc.uri);
328
332
  documents.push({
333
+ ...(successor ? { supersededBy: successor } : {}),
329
334
  docid: doc.docid,
330
335
  uri: doc.uri,
331
336
  title: doc.title ?? undefined,
package/src/sdk/types.ts CHANGED
@@ -394,6 +394,11 @@ export interface GnoClient {
394
394
  list(
395
395
  options?: GnoListOptions
396
396
  ): Promise<import("../cli/commands/ls").LsResponse>;
397
+ /** Typed metadata keys with types, document counts and common values. */
398
+ listMetadata(options?: {
399
+ collection?: string;
400
+ values?: number;
401
+ }): Promise<import("../core/metadata-discovery").MetadataSummary>;
397
402
  changes(options?: ListKnowledgeChangesInput): Promise<KnowledgeChangesResult>;
398
403
  diff(ref: string, changeId?: string): Promise<KnowledgeDiffResult>;
399
404
  impact(
@@ -5,7 +5,6 @@
5
5
  * @module src/serve/routes/api
6
6
  */
7
7
 
8
- // node:fs/promises structure ops + realpath have no Bun equivalent
9
8
  import { readdir, realpath } from "node:fs/promises";
10
9
  // node:path has no Bun equivalent
11
10
  import { posix as pathPosix } from "node:path";
@@ -90,6 +89,7 @@ import {
90
89
  type RecallInput,
91
90
  type RememberInput,
92
91
  } from "../../core/memory";
92
+ import { readMetadataSummary } from "../../core/metadata-discovery";
93
93
  import {
94
94
  hasContentMutation,
95
95
  recordContentMutation,
@@ -167,6 +167,8 @@ import {
167
167
  import { diagnoseQueryTarget } from "../../pipeline/diagnose";
168
168
  import { searchHybrid } from "../../pipeline/hybrid";
169
169
  import { validateQueryModes } from "../../pipeline/query-modes";
170
+ // node:fs/promises structure ops + realpath have no Bun equivalent
171
+ import { supersedingUri } from "../../pipeline/result-context";
170
172
  import { searchBm25 } from "../../pipeline/search";
171
173
  import {
172
174
  derivePublishArtifactFilename,
@@ -1574,9 +1576,8 @@ export async function handleImportPreview(
1574
1576
 
1575
1577
  /**
1576
1578
  * DELETE /api/collections/:name
1577
- * Remove a collection from config.
1578
- * Note: Does NOT remove indexed documents - they remain in DB until re-sync
1579
- * or manual cleanup. This preserves data for potential recovery.
1579
+ * Remove a collection from config. The config sync deletes the collection
1580
+ * row and its documents; content, chunks and vectors stay until cleanup.
1580
1581
  */
1581
1582
  export async function handleDeleteCollection(
1582
1583
  ctxHolder: ContextHolder,
@@ -1612,7 +1613,7 @@ export async function handleDeleteCollection(
1612
1613
  return jsonResponse({
1613
1614
  success: true,
1614
1615
  collection: name,
1615
- note: "Collection removed from config. Indexed documents remain in DB.",
1616
+ note: "Collection removed from config and its documents deleted from the index. Content, chunks and vectors stay until gno cleanup.",
1616
1617
  });
1617
1618
  }
1618
1619
 
@@ -2147,7 +2148,9 @@ export async function handleDoc(
2147
2148
  const record = projectRecordEvidenceMetadata(doc);
2148
2149
  const relPath = doc.recordSourcePath ?? doc.relPath;
2149
2150
 
2151
+ const successor = await supersedingUri(store, doc.uri);
2150
2152
  const responseData = {
2153
+ ...(successor ? { supersededBy: successor } : {}),
2151
2154
  docid: doc.docid,
2152
2155
  uri: doc.uri,
2153
2156
  title: doc.title,
@@ -2464,6 +2467,30 @@ export async function handleDocAsset(
2464
2467
  * Query params: collection, prefix
2465
2468
  * Returns tag list with document counts.
2466
2469
  */
2470
+ export async function handleMetadata(
2471
+ store: Pick<StorePort, "getTypedMetadataDocuments">,
2472
+ url: URL
2473
+ ): Promise<Response> {
2474
+ const collection = url.searchParams.get("collection") ?? undefined;
2475
+ const valuesParam = url.searchParams.get("values");
2476
+ try {
2477
+ return jsonResponse(
2478
+ await readMetadataSummary(store, {
2479
+ collection,
2480
+ values: valuesParam === null ? undefined : Number(valuesParam),
2481
+ })
2482
+ );
2483
+ } catch (cause) {
2484
+ return cause instanceof RangeError
2485
+ ? errorResponse("VALIDATION", cause.message)
2486
+ : errorResponse(
2487
+ "RUNTIME",
2488
+ cause instanceof Error ? cause.message : String(cause),
2489
+ 500
2490
+ );
2491
+ }
2492
+ }
2493
+
2467
2494
  export async function handleTags(
2468
2495
  store: SqliteAdapter,
2469
2496
  url: URL
@@ -83,6 +83,7 @@ import {
83
83
  handleSetPreset,
84
84
  handleStatus,
85
85
  handleSync,
86
+ handleMetadata,
86
87
  handleTags,
87
88
  handleTrashDoc,
88
89
  handleUpdateCollection,
@@ -1331,6 +1332,17 @@ export async function startServer(
1331
1332
  }
1332
1333
  },
1333
1334
  },
1335
+ "/api/metadata": {
1336
+ GET: async (req: Request) => {
1337
+ const url = new URL(req.url);
1338
+ return withSecurityHeaders(
1339
+ await handleResidentRead(runtime as ResidentRuntime, req, () =>
1340
+ handleMetadata(store, url)
1341
+ ),
1342
+ isDev
1343
+ );
1344
+ },
1345
+ },
1334
1346
  "/api/tags": {
1335
1347
  GET: async (req: Request) => {
1336
1348
  const url = new URL(req.url);
@@ -3693,9 +3693,89 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3693
3693
  }
3694
3694
 
3695
3695
  /**
3696
- * Get tag counts across all active documents.
3697
- * Optionally filter by collection or tag prefix.
3696
+ * Map each superseded memory fact's URI to the end of its supersession
3697
+ * chain. Input URIs may carry `?index=`; keys are the URIs as given and
3698
+ * successors are canonical (undecorated).
3698
3699
  */
3700
+ async getSupersedingUris(
3701
+ uris: string[]
3702
+ ): Promise<StoreResult<Map<string, string>>> {
3703
+ try {
3704
+ const db = this.ensureOpen();
3705
+ const successorOf = db.query<
3706
+ { uri: string; successor: string },
3707
+ [string]
3708
+ >(`
3709
+ SELECT p.uri AS uri, s.uri AS successor
3710
+ FROM documents p
3711
+ JOIN doc_edges e ON e.dst_doc_id = p.id AND e.edge_type = 'supersedes'
3712
+ JOIN documents s ON s.id = e.src_doc_id AND s.active = 1
3713
+ WHERE p.uri IN (SELECT value FROM json_each(?))
3714
+ ORDER BY s.id
3715
+ `);
3716
+ const canonical = new Map(uris.map((uri) => [uri, stripUriIndex(uri)]));
3717
+ const next = new Map<string, string>();
3718
+ const seen = new Set(canonical.values());
3719
+ let frontier = [...seen];
3720
+ while (frontier.length > 0) {
3721
+ const discovered: string[] = [];
3722
+ for (const row of successorOf.all(JSON.stringify(frontier))) {
3723
+ next.set(row.uri, row.successor);
3724
+ if (!seen.has(row.successor)) {
3725
+ seen.add(row.successor);
3726
+ discovered.push(row.successor);
3727
+ }
3728
+ }
3729
+ frontier = discovered;
3730
+ }
3731
+ const result = new Map<string, string>();
3732
+ for (const [input, uri] of canonical) {
3733
+ const visited = new Set([uri]);
3734
+ let current = next.get(uri);
3735
+ while (current && next.has(current) && !visited.has(current)) {
3736
+ visited.add(current);
3737
+ current = next.get(current);
3738
+ }
3739
+ if (current && current !== uri) result.set(input, current);
3740
+ }
3741
+ return ok(result);
3742
+ } catch (cause) {
3743
+ return err(
3744
+ "QUERY_FAILED",
3745
+ cause instanceof Error ? cause.message : "Failed to read supersession",
3746
+ cause
3747
+ );
3748
+ }
3749
+ }
3750
+
3751
+ async getTypedMetadataDocuments(options?: {
3752
+ collection?: string;
3753
+ }): Promise<StoreResult<{ documents: number; metadata: string[] }>> {
3754
+ try {
3755
+ const db = this.ensureOpen();
3756
+ const scope = options?.collection ? " AND collection = ?" : "";
3757
+ const params = options?.collection ? [options.collection] : [];
3758
+ const documents = db
3759
+ .query<{ count: number }, string[]>(
3760
+ `SELECT count(*) AS count FROM documents WHERE active = 1${scope}`
3761
+ )
3762
+ .get(...params)!.count;
3763
+ const metadata = db
3764
+ .query<{ typed_metadata: string }, string[]>(
3765
+ `SELECT typed_metadata FROM documents WHERE active = 1 AND typed_metadata IS NOT NULL${scope}`
3766
+ )
3767
+ .all(...params)
3768
+ .map((row) => row.typed_metadata);
3769
+ return ok({ documents, metadata });
3770
+ } catch (cause) {
3771
+ return err(
3772
+ "QUERY_FAILED",
3773
+ cause instanceof Error ? cause.message : "Failed to read metadata",
3774
+ cause
3775
+ );
3776
+ }
3777
+ }
3778
+
3699
3779
  async getTagCounts(options?: {
3700
3780
  collection?: string;
3701
3781
  prefix?: string;
@@ -6124,18 +6204,25 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
6124
6204
  )
6125
6205
  .get(configuredJson, configuredJson)?.count ?? 0;
6126
6206
 
6127
- // Embedding backlog: chunks from active docs without vectors
6128
- // Uses EXISTS to avoid duplicates when multiple docs share mirror_hash
6207
+ // Embedding backlog: chunks from active docs of configured collections
6208
+ // without vectors. DISTINCT avoids duplicates when docs share mirror_hash.
6129
6209
  const backlogRow = db
6130
6210
  .query<
6131
6211
  { count: number },
6132
- [string | null, string | null, string | null]
6212
+ [
6213
+ string | null,
6214
+ string | null,
6215
+ string | null,
6216
+ string | null,
6217
+ string | null,
6218
+ ]
6133
6219
  >(
6134
6220
  `
6135
6221
  WITH active_mirrors AS (
6136
6222
  SELECT DISTINCT mirror_hash
6137
6223
  FROM documents
6138
6224
  WHERE active = 1 AND mirror_hash IS NOT NULL
6225
+ AND (? IS NULL OR collection IN (SELECT value FROM json_each(?)))
6139
6226
  ),
6140
6227
  matching_vectors AS (
6141
6228
  SELECT mirror_hash, seq, MAX(embedded_at) AS embedded_at
@@ -6155,7 +6242,13 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
6155
6242
  WHERE mv.mirror_hash IS NULL
6156
6243
  `
6157
6244
  )
6158
- .get(embedModel, embedModel, embedFingerprint);
6245
+ .get(
6246
+ configuredJson,
6247
+ configuredJson,
6248
+ embedModel,
6249
+ embedModel,
6250
+ embedFingerprint
6251
+ );
6159
6252
 
6160
6253
  // Recent errors (last 24h)
6161
6254
  const recentErrorsRow = db
@@ -2245,6 +2245,22 @@ export interface StorePort {
2245
2245
  documentIds: number[]
2246
2246
  ): Promise<StoreResult<Map<number, TagRow[]>>>;
2247
2247
 
2248
+ /**
2249
+ * For each URI whose document an active document supersedes, the URI of
2250
+ * its current successor (the end of the supersession chain).
2251
+ */
2252
+ getSupersedingUris?(
2253
+ uris: string[]
2254
+ ): Promise<StoreResult<Map<string, string>>>;
2255
+
2256
+ /**
2257
+ * Indexed typed metadata JSON of active documents, optionally in one
2258
+ * collection, plus the active document count in that scope.
2259
+ */
2260
+ getTypedMetadataDocuments(options?: {
2261
+ collection?: string;
2262
+ }): Promise<StoreResult<{ documents: number; metadata: string[] }>>;
2263
+
2248
2264
  /**
2249
2265
  * Get tag counts across all active documents.
2250
2266
  * Optionally filter by collection or tag prefix.
@@ -82,6 +82,32 @@ export function vectorSearchUnavailableMessage(
82
82
  }`;
83
83
  }
84
84
 
85
+ const MAX_CACHED_PROOFS = 100_000;
86
+ const proofCaches = new WeakMap<
87
+ Database,
88
+ { marker: string; proofs: Map<string, boolean> }
89
+ >();
90
+
91
+ /**
92
+ * Input-hash proofs cached per connection. Any committed write, from this
93
+ * connection or another, changes the marker and drops the cache, so a cached
94
+ * proof never outlives the titles and chunk text it was computed from.
95
+ */
96
+ function provenOwners(db: Database): Map<string, boolean> {
97
+ const { version, changes } = db
98
+ .query<{ version: number; changes: number }, []>(
99
+ "SELECT (SELECT data_version FROM pragma_data_version) AS version, total_changes() AS changes"
100
+ )
101
+ .get()!;
102
+ const marker = `${version}:${changes}`;
103
+ let cache = proofCaches.get(db);
104
+ if (cache?.marker !== marker || cache.proofs.size > MAX_CACHED_PROOFS) {
105
+ cache = { marker, proofs: new Map() };
106
+ proofCaches.set(db, cache);
107
+ }
108
+ return cache.proofs;
109
+ }
110
+
85
111
  /** Null means legacy authority. Once promoted, unavailable provenance fails closed. */
86
112
  function searchVectorVariantsInSnapshot(
87
113
  db: Database,
@@ -148,14 +174,9 @@ function searchVectorVariantsInSnapshot(
148
174
  const tableName = `vec_v1_${partitionId}`;
149
175
  if (!db.query("SELECT 1 FROM sqlite_master WHERE name = ?").get(tableName))
150
176
  throw new Error("Activated variant index unavailable; run gno embed");
151
- if (
152
- db
153
- .query(
154
- `SELECT 1 FROM vector_variants v LEFT JOIN ${tableName} x ON x.variant_id = v.variant_id WHERE v.partition_id = ? AND (x.variant_id IS NULL OR x.embedding != v.embedding) LIMIT 1`
155
- )
156
- .get(partitionId)
157
- )
158
- throw new Error("Activated variant index inconsistent; run gno embed");
177
+ // The vec0 mirror is written in the same transactions as vector_variants and
178
+ // checked at activation and by `gno vec sync`; ranking below reads only
179
+ // vector_variants, so the mirror is never trusted here.
159
180
  const eligible = buildEligibleDocumentQuery(
160
181
  { ...options.eligibility, semanticMetadata: true },
161
182
  db
@@ -170,75 +191,92 @@ function searchVectorVariantsInSnapshot(
170
191
  conditions.push("o.mirror_hash IN (SELECT value FROM json_each(?))");
171
192
  params.push(JSON.stringify(options.allowedMirrorHashes));
172
193
  }
173
- const bindings = db
174
- .query<
175
- {
176
- document_id: number;
177
- seq: number;
178
- text: string;
179
- title: string | null;
180
- input_hash: string;
181
- },
182
- (string | number)[]
183
- >(`
184
- SELECT o.document_id, o.seq, c.text, d.title, v.input_hash
185
- FROM vector_owners o
186
- JOIN (${eligible.sql}) e ON e.id = o.document_id AND e.mirror_hash = o.mirror_hash
187
- JOIN documents d ON d.id = o.document_id
188
- JOIN content_chunks c ON c.mirror_hash = o.mirror_hash AND c.seq = o.seq
189
- JOIN vector_variants v ON v.variant_id = o.variant_id AND v.partition_id = o.partition_id
190
- WHERE ${conditions.join(" AND ")}
191
- `)
192
- .all(...eligible.params, ...params);
193
- const proven = bindings
194
- .filter(
195
- (row) =>
196
- row.input_hash ===
197
- embeddingInputHash(
198
- formatDocForEmbedding(row.text, row.title ?? undefined, model)
199
- )
200
- )
201
- .map((row) => `${row.document_id}:${row.seq}`);
202
- // Both owner eligibility and formatted-input validation precede ranking/LIMIT.
203
- const rows = db
204
- .query<
205
- {
206
- mirrorHash: string;
207
- seq: number;
208
- distance: number;
209
- documentIds: string;
210
- },
211
- (Uint8Array | string | number)[]
212
- >(`
213
- SELECT o.mirror_hash AS mirrorHash, o.seq, vec_distance_cosine(v.embedding, ?) AS distance,
214
- json_group_array(o.document_id) AS documentIds
215
- FROM vector_owners o
216
- JOIN (${eligible.sql}) e ON e.id = o.document_id AND e.mirror_hash = o.mirror_hash
217
- JOIN documents d ON d.id = o.document_id
218
- JOIN content_chunks c ON c.mirror_hash = o.mirror_hash AND c.seq = o.seq
219
- JOIN vector_variants v ON v.variant_id = o.variant_id AND v.partition_id = o.partition_id
220
- JOIN ${tableName} x ON x.variant_id = v.variant_id AND x.embedding = v.embedding
221
- WHERE ${conditions.join(" AND ")}
222
- AND (o.document_id || ':' || o.seq) IN (SELECT value FROM json_each(?))
223
- GROUP BY v.variant_id, o.mirror_hash, o.seq
224
- ORDER BY distance, o.mirror_hash, o.seq, v.variant_id LIMIT ?
225
- `)
226
- .all(
227
- encodeEmbedding(embedding),
194
+ // Owner eligibility precedes ranking/LIMIT. Grouping before the distance
195
+ // keeps embedding blobs out of the group sorter.
196
+ const ranked = db.query<
197
+ {
198
+ mirrorHash: string;
199
+ seq: number;
200
+ inputHash: string;
201
+ distance: number;
202
+ documentIds: string;
203
+ },
204
+ (Uint8Array | string | number)[]
205
+ >(`
206
+ SELECT g.mirrorHash, g.seq, v.input_hash AS inputHash,
207
+ vec_distance_cosine(v.embedding, ?) AS distance, g.documentIds
208
+ FROM (
209
+ SELECT o.variant_id AS variantId, o.mirror_hash AS mirrorHash, o.seq,
210
+ json_group_array(o.document_id) AS documentIds
211
+ FROM vector_owners o
212
+ JOIN (${eligible.sql}) e ON e.id = o.document_id AND e.mirror_hash = o.mirror_hash
213
+ JOIN content_chunks c ON c.mirror_hash = o.mirror_hash AND c.seq = o.seq
214
+ WHERE ${conditions.join(" AND ")}
215
+ GROUP BY o.variant_id, o.mirror_hash, o.seq
216
+ ) g
217
+ JOIN vector_variants v ON v.variant_id = g.variantId AND v.partition_id = ?
218
+ ORDER BY distance, g.mirrorHash, g.seq, g.variantId LIMIT ?
219
+ `);
220
+ const owners = db.query<
221
+ { id: number; title: string | null; text: string },
222
+ [string, number, string]
223
+ >(`
224
+ SELECT d.id, d.title, c.text FROM documents d
225
+ JOIN content_chunks c ON c.mirror_hash = ? AND c.seq = ?
226
+ WHERE d.id IN (SELECT value FROM json_each(?))
227
+ `);
228
+ // A vector counts only while it matches its owner's current formatted
229
+ // input. Proving candidates in rank order and skipping unproven owners
230
+ // returns exactly what filtering every owner before ranking would.
231
+ const proofs = provenOwners(db);
232
+ const queryVector = encodeEmbedding(embedding);
233
+ let limit = k;
234
+ let results: VectorSearchResult[] = [];
235
+ for (;;) {
236
+ const rows = ranked.all(
237
+ queryVector,
228
238
  ...eligible.params,
229
239
  ...params,
230
- JSON.stringify(proven),
231
- k
240
+ partitionId,
241
+ limit
232
242
  );
233
- return rows
234
- .filter(
235
- (row) =>
236
- options.minScore === undefined || 1 - row.distance >= options.minScore
237
- )
238
- .map((row) => ({
239
- ...row,
240
- documentIds: JSON.parse(row.documentIds) as number[],
241
- }));
243
+ results = [];
244
+ for (const row of rows) {
245
+ const documentIds = JSON.parse(row.documentIds) as number[];
246
+ const key = (id: number) =>
247
+ `${model}\0${id}\0${row.mirrorHash}\0${row.seq}\0${row.inputHash}`;
248
+ const unknown = documentIds.filter((id) => !proofs.has(key(id)));
249
+ if (unknown.length > 0) {
250
+ for (const owner of owners.all(
251
+ row.mirrorHash,
252
+ row.seq,
253
+ JSON.stringify(unknown)
254
+ )) {
255
+ proofs.set(
256
+ key(owner.id),
257
+ embeddingInputHash(
258
+ formatDocForEmbedding(owner.text, owner.title ?? undefined, model)
259
+ ) === row.inputHash
260
+ );
261
+ }
262
+ }
263
+ const proven = documentIds.filter((id) => proofs.get(key(id)));
264
+ if (proven.length === 0) continue;
265
+ results.push({
266
+ mirrorHash: row.mirrorHash,
267
+ seq: row.seq,
268
+ distance: row.distance,
269
+ documentIds: proven,
270
+ });
271
+ if (results.length === k) break;
272
+ }
273
+ if (results.length === k || rows.length < limit) break;
274
+ limit *= 4;
275
+ }
276
+ return results.filter(
277
+ (row) =>
278
+ options.minScore === undefined || 1 - row.distance >= options.minScore
279
+ );
242
280
  }
243
281
 
244
282
  /** Keep eligibility, provenance validation and ranking on one SQLite snapshot. */
@@ -1 +0,0 @@
1
- 56174f38783259cef05d81ae53adceebc8467799b735c2f3026b928f7070ae76 gno-browser-clipper-v2.10.0.zip