@gmickel/gno 2.10.1 → 2.12.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 (112) hide show
  1. package/README.md +11 -11
  2. package/assets/skill/SKILL.md +27 -15
  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/assets/spa-production.json.gz +0 -0
  7. package/browser-extension/artifacts/{gno-browser-clipper-v2.10.1.zip → gno-browser-clipper-v2.12.0.zip} +0 -0
  8. package/browser-extension/artifacts/gno-browser-clipper-v2.12.0.zip.sha256 +1 -0
  9. package/browser-extension/dist/manifest.json +1 -1
  10. package/package.json +10 -5
  11. package/spec/cli.md +127 -35
  12. package/spec/evals-agentic.md +3 -1
  13. package/spec/mcp.md +36 -6
  14. package/spec/output-schemas/ask.schema.json +8 -0
  15. package/spec/output-schemas/claim-verification.schema.json +8 -2
  16. package/spec/output-schemas/context-capsule-v1.schema.json +6 -1
  17. package/spec/output-schemas/context-capsule-verification.schema.json +4 -2
  18. package/spec/output-schemas/get.schema.json +4 -0
  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/spec/output-schemas/status.schema.json +28 -0
  24. package/src/app/constants.ts +16 -0
  25. package/src/app/context-agent-projection.ts +10 -1
  26. package/src/app/context-format.ts +1 -0
  27. package/src/app/verified-ask.ts +2 -0
  28. package/src/cli/commands/agents/block.ts +10 -9
  29. package/src/cli/commands/agents/commands.ts +6 -5
  30. package/src/cli/commands/agents/engine.ts +15 -2
  31. package/src/cli/commands/agents/harnesses.ts +21 -14
  32. package/src/cli/commands/ask-format.ts +18 -1
  33. package/src/cli/commands/ask.ts +4 -5
  34. package/src/cli/commands/cleanup.ts +46 -6
  35. package/src/cli/commands/collection/remove.ts +1 -0
  36. package/src/cli/commands/collection/rename.ts +1 -0
  37. package/src/cli/commands/daemon.ts +5 -1
  38. package/src/cli/commands/doctor.ts +37 -0
  39. package/src/cli/commands/get.ts +11 -1
  40. package/src/cli/commands/mcp/paths.ts +2 -1
  41. package/src/cli/commands/metadata.ts +79 -0
  42. package/src/cli/commands/multi-get.ts +13 -0
  43. package/src/cli/commands/setup-semantic.ts +6 -1
  44. package/src/cli/commands/shared.ts +35 -8
  45. package/src/cli/commands/skill/paths.ts +20 -2
  46. package/src/cli/commands/status.ts +89 -15
  47. package/src/cli/format/search-results.ts +11 -0
  48. package/src/cli/options.ts +2 -0
  49. package/src/cli/program.ts +64 -3
  50. package/src/core/activation-probe-plan.ts +0 -1
  51. package/src/core/config-mutation.ts +72 -55
  52. package/src/core/context-capsule-schema.ts +2 -0
  53. package/src/core/context-capsule-verification.ts +2 -0
  54. package/src/core/context-compiler.ts +2 -5
  55. package/src/core/context-evidence.ts +1 -0
  56. package/src/core/context-verifier.ts +14 -1
  57. package/src/core/memory-remember.ts +0 -1
  58. package/src/core/metadata-discovery.ts +169 -0
  59. package/src/core/module-path.ts +33 -0
  60. package/src/core/runtime-entrypoint.ts +3 -1
  61. package/src/ingestion/file-processor.ts +6 -3
  62. package/src/ingestion/prepare-file.ts +14 -4
  63. package/src/ingestion/source-availability/root.ts +138 -0
  64. package/src/ingestion/sync.ts +35 -1
  65. package/src/ingestion/walker.ts +11 -3
  66. package/src/llm/native-worker/client.ts +8 -4
  67. package/src/llm/native-worker/errors.ts +26 -3
  68. package/src/mcp/http-egress.ts +1 -0
  69. package/src/mcp/http-transport.ts +7 -2
  70. package/src/mcp/server.ts +3 -0
  71. package/src/mcp/tool-descriptions-core.ts +3 -3
  72. package/src/mcp/tool-profile.ts +16 -1
  73. package/src/mcp/tools/get.ts +10 -0
  74. package/src/mcp/tools/index.ts +68 -4
  75. package/src/mcp/tools/multi-get.ts +15 -0
  76. package/src/mcp/tools/query.ts +5 -3
  77. package/src/mcp/tools/search.ts +5 -3
  78. package/src/mcp/tools/status.ts +17 -2
  79. package/src/mcp/tools/vsearch.ts +5 -3
  80. package/src/mcp/tools/workspace-write.ts +42 -34
  81. package/src/pipeline/answer-prompt.ts +15 -2
  82. package/src/pipeline/answer.ts +3 -0
  83. package/src/pipeline/claim-verification-schema.ts +6 -1
  84. package/src/pipeline/claim-verification.ts +54 -1
  85. package/src/pipeline/explain.ts +1 -1
  86. package/src/pipeline/fusion.ts +24 -4
  87. package/src/pipeline/hybrid.ts +5 -1
  88. package/src/pipeline/query-snippet.ts +266 -0
  89. package/src/pipeline/result-context.ts +29 -0
  90. package/src/pipeline/search.ts +154 -20
  91. package/src/pipeline/types.ts +4 -0
  92. package/src/pipeline/vsearch.ts +5 -1
  93. package/src/sdk/client.ts +46 -6
  94. package/src/sdk/documents.ts +5 -0
  95. package/src/sdk/types.ts +11 -1
  96. package/src/serve/public/components/ThoroughnessSelector.tsx +1 -1
  97. package/src/serve/public/globals.built.css +1 -1
  98. package/src/serve/public/pages/Collections.tsx +8 -0
  99. package/src/serve/public/pages/Search.tsx +9 -1
  100. package/src/serve/resident-request.ts +8 -0
  101. package/src/serve/resident-runtime.ts +25 -3
  102. package/src/serve/routes/api.ts +29 -1
  103. package/src/serve/server.ts +12 -0
  104. package/src/serve/spa-production-build.ts +7 -3
  105. package/src/serve/status-model.ts +2 -0
  106. package/src/serve/status.ts +29 -0
  107. package/src/sessions/config-refresh.ts +6 -0
  108. package/src/sessions/import-child.ts +4 -1
  109. package/src/store/sqlite/adapter.ts +241 -20
  110. package/src/store/types.ts +61 -7
  111. package/src/store/vector/variant-search.ts +112 -74
  112. package/browser-extension/artifacts/gno-browser-clipper-v2.10.1.zip.sha256 +0 -1
@@ -103,8 +103,10 @@ import type {
103
103
  SavedCapsuleReverificationState,
104
104
  SavedCapsuleVerificationExpectation,
105
105
  SavedCapsuleVerificationRecord,
106
+ StaleCollection,
106
107
  StorePort,
107
108
  StoreResult,
109
+ SyncCollectionsOptions,
108
110
  TagCount,
109
111
  TagRow,
110
112
  TagSource,
@@ -585,6 +587,27 @@ function resolveBusyTimeoutMs(busyTimeoutMs?: number): number {
585
587
  return busyTimeoutMs;
586
588
  }
587
589
 
590
+ /** The index holds collections the config omits; the message names them. */
591
+ export class StaleCollectionsError extends Error {}
592
+
593
+ /** Names the collections a config omits and how to resolve it. */
594
+ export function staleCollectionsMessage(
595
+ stale: readonly StaleCollection[]
596
+ ): string {
597
+ const names = stale
598
+ .map(
599
+ (c) =>
600
+ `${c.name} (${c.documents} ${c.documents === 1 ? "document" : "documents"})`
601
+ )
602
+ .join(", ");
603
+ return (
604
+ `This index holds collections the config does not list: ${names}. ` +
605
+ "Nothing was changed. Add them back to the config, open a separate index " +
606
+ '(--index <name>, or dbPath in the SDK), or run "gno cleanup" with this ' +
607
+ "config to delete their index data."
608
+ );
609
+ }
610
+
588
611
  export class SqliteAdapter implements StorePort, SqliteDbProvider {
589
612
  private db: Database | null = null;
590
613
  private dbPath = "";
@@ -898,24 +921,36 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
898
921
  // Config Sync
899
922
  // ─────────────────────────────────────────────────────────────────────────
900
923
 
901
- async syncCollections(collections: Collection[]): Promise<StoreResult<void>> {
924
+ async syncCollections(
925
+ collections: Collection[],
926
+ options: SyncCollectionsOptions = {}
927
+ ): Promise<StoreResult<void>> {
928
+ let stale: StaleCollection[] = [];
902
929
  try {
903
930
  const db = this.ensureOpen();
904
931
 
905
932
  const transaction = db.transaction(() => {
906
- // Get existing collection names
907
- const existing = new Set(
908
- db
909
- .query<{ name: string }, []>("SELECT name FROM collections")
910
- .all()
911
- .map((r) => r.name)
912
- );
913
-
914
- const incoming = new Set(collections.map((c) => c.name));
915
-
916
- // Delete removed collections
917
- for (const name of existing) {
918
- if (!incoming.has(name)) {
933
+ // Opening an index with a config never deletes indexed collections
934
+ // that config omits (their documents follow by cascade): a different
935
+ // config on the same index would wipe it. Only explicit removals do.
936
+ const { remove } = options;
937
+ const removed = new Set(remove === "stale" ? [] : remove);
938
+ const unlisted = this.queryStaleCollections(
939
+ db,
940
+ collections.map((c) => c.name)
941
+ ).filter((c) => remove !== "stale" && !removed.has(c.name));
942
+ // Unlisted collections without documents stay too; they only block
943
+ // the sync when they hold indexed documents.
944
+ stale = unlisted.filter((c) => c.documents > 0);
945
+ if (stale.length > 0 && !options.keepStale) {
946
+ throw new StaleCollectionsError();
947
+ }
948
+ const keep = new Set(unlisted.map((c) => c.name));
949
+ for (const { name } of this.queryStaleCollections(
950
+ db,
951
+ collections.map((c) => c.name)
952
+ )) {
953
+ if (!keep.has(name)) {
919
954
  db.run("DELETE FROM collections WHERE name = ?", [name]);
920
955
  }
921
956
  }
@@ -985,6 +1020,9 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
985
1020
  transaction.immediate();
986
1021
  return ok(undefined);
987
1022
  } catch (cause) {
1023
+ if (cause instanceof StaleCollectionsError) {
1024
+ return err("STALE_COLLECTIONS", staleCollectionsMessage(stale));
1025
+ }
988
1026
  return err(
989
1027
  "QUERY_FAILED",
990
1028
  cause instanceof Error ? cause.message : "Failed to sync collections",
@@ -993,6 +1031,55 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
993
1031
  }
994
1032
  }
995
1033
 
1034
+ async listStaleCollections(
1035
+ configured: readonly string[]
1036
+ ): Promise<StoreResult<StaleCollection[]>> {
1037
+ try {
1038
+ return ok(this.queryStaleCollections(this.ensureOpen(), configured));
1039
+ } catch (cause) {
1040
+ return err(
1041
+ "QUERY_FAILED",
1042
+ cause instanceof Error ? cause.message : "Failed to list collections",
1043
+ cause
1044
+ );
1045
+ }
1046
+ }
1047
+
1048
+ /**
1049
+ * Indexed collections `configured` omits, checked cheaply on each read of a
1050
+ * long-lived client: another client may add a collection after it opened.
1051
+ */
1052
+ staleIndexedCollections(configured: readonly string[]): StaleCollection[] {
1053
+ const db = this.ensureOpen();
1054
+ const listed = new Set(configured);
1055
+ const indexed = db
1056
+ .query<{ name: string }, []>(
1057
+ `SELECT c.name AS name FROM collections c
1058
+ WHERE EXISTS (SELECT 1 FROM documents d WHERE d.collection = c.name)`
1059
+ )
1060
+ .all();
1061
+ return indexed.some(({ name }) => !listed.has(name))
1062
+ ? this.queryStaleCollections(db, configured).filter(
1063
+ (c) => c.documents > 0
1064
+ )
1065
+ : [];
1066
+ }
1067
+
1068
+ private queryStaleCollections(
1069
+ db: Database,
1070
+ configured: readonly string[]
1071
+ ): StaleCollection[] {
1072
+ const listed = new Set(configured);
1073
+ return db
1074
+ .query<StaleCollection, []>(
1075
+ `SELECT c.name AS name,
1076
+ (SELECT COUNT(*) FROM documents d WHERE d.collection = c.name) AS documents
1077
+ FROM collections c ORDER BY c.name`
1078
+ )
1079
+ .all()
1080
+ .filter((c) => !listed.has(c.name));
1081
+ }
1082
+
996
1083
  async upsertCollections(
997
1084
  collections: Collection[]
998
1085
  ): Promise<StoreResult<void>> {
@@ -3119,6 +3206,64 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3119
3206
  }
3120
3207
  }
3121
3208
 
3209
+ async getChunkTermPresence(
3210
+ mirrorHashes: string[],
3211
+ terms: string[]
3212
+ ): Promise<
3213
+ StoreResult<{ mirrorHash: string; seq: number; present: boolean[] }[]>
3214
+ > {
3215
+ try {
3216
+ const uniqueHashes = uniqueNonEmptyValues(mirrorHashes);
3217
+ if (uniqueHashes.length === 0 || terms.length === 0) return ok([]);
3218
+ const db = this.ensureOpen();
3219
+ // SQLite lower() folds ASCII only, so a non-ASCII term is also looked
3220
+ // up in its upper-case and capitalized spellings (ÜBERBLICK, Überblick).
3221
+ const spellings = terms.flatMap((term) => [
3222
+ term,
3223
+ term.toUpperCase(),
3224
+ `${term.charAt(0).toUpperCase()}${term.slice(1)}`,
3225
+ ]);
3226
+ const columns = terms
3227
+ .map(
3228
+ () =>
3229
+ "(instr(lower(text), ?) > 0 OR instr(text, ?) > 0 OR instr(text, ?) > 0)"
3230
+ )
3231
+ .join(", ");
3232
+ const batchSize = Math.max(
3233
+ 1,
3234
+ SQLITE_SAFE_PARAMETER_BATCH_SIZE - spellings.length
3235
+ );
3236
+ const rows: { mirrorHash: string; seq: number; present: boolean[] }[] =
3237
+ [];
3238
+ for (let i = 0; i < uniqueHashes.length; i += batchSize) {
3239
+ const batch = uniqueHashes.slice(i, i + batchSize);
3240
+ const values = db
3241
+ .query<unknown, string[]>(
3242
+ `SELECT mirror_hash, seq, ${columns} FROM content_chunks
3243
+ WHERE mirror_hash IN (${batch.map(() => "?").join(",")})
3244
+ ORDER BY mirror_hash, seq`
3245
+ )
3246
+ .values(...spellings, ...batch) as unknown[][];
3247
+ for (const [mirrorHash, seq, ...present] of values) {
3248
+ rows.push({
3249
+ mirrorHash: String(mirrorHash),
3250
+ seq: Number(seq),
3251
+ present: present.map((flag) => flag === 1),
3252
+ });
3253
+ }
3254
+ }
3255
+ return ok(rows);
3256
+ } catch (cause) {
3257
+ return err(
3258
+ "QUERY_FAILED",
3259
+ cause instanceof Error
3260
+ ? cause.message
3261
+ : "Failed to read chunk term presence",
3262
+ cause
3263
+ );
3264
+ }
3265
+ }
3266
+
3122
3267
  // ─────────────────────────────────────────────────────────────────────────
3123
3268
  // FTS Search
3124
3269
  // ─────────────────────────────────────────────────────────────────────────
@@ -3143,7 +3288,6 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3143
3288
  WITH fts_matches AS (
3144
3289
  SELECT
3145
3290
  rowid,
3146
- ${options.snippet ? "snippet(documents_fts, 2, '<mark>', '</mark>', '...', 32) as snippet," : ""}
3147
3291
  bm25(
3148
3292
  documents_fts,
3149
3293
  ${FTS5_FIELD_WEIGHTS.filepath},
@@ -3163,7 +3307,6 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3163
3307
  d.mirror_hash,
3164
3308
  ${options.chunkLanguage ? "(SELECT min(lc.seq) FROM content_chunks lc WHERE lc.mirror_hash = d.mirror_hash AND lc.language = ?) as seq," : "0 as seq,"}
3165
3309
  fm.score as score,
3166
- ${options.snippet ? "fm.snippet as snippet," : ""}
3167
3310
  d.docid,
3168
3311
  d.uri,
3169
3312
  d.title,
@@ -3197,7 +3340,6 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3197
3340
  mirror_hash: string;
3198
3341
  seq: number;
3199
3342
  score: number;
3200
- snippet?: string;
3201
3343
  docid: string;
3202
3344
  uri: string;
3203
3345
  title: string | null;
@@ -3247,7 +3389,6 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3247
3389
  mirrorHash: r.mirror_hash,
3248
3390
  seq: r.seq,
3249
3391
  score: r.score, // Raw bm25() - smaller (more negative) is better
3250
- snippet: r.snippet,
3251
3392
  docid: r.docid,
3252
3393
  uri: r.uri,
3253
3394
  title: r.title ?? undefined,
@@ -3693,9 +3834,89 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3693
3834
  }
3694
3835
 
3695
3836
  /**
3696
- * Get tag counts across all active documents.
3697
- * Optionally filter by collection or tag prefix.
3837
+ * Map each superseded memory fact's URI to the end of its supersession
3838
+ * chain. Input URIs may carry `?index=`; keys are the URIs as given and
3839
+ * successors are canonical (undecorated).
3698
3840
  */
3841
+ async getSupersedingUris(
3842
+ uris: string[]
3843
+ ): Promise<StoreResult<Map<string, string>>> {
3844
+ try {
3845
+ const db = this.ensureOpen();
3846
+ const successorOf = db.query<
3847
+ { uri: string; successor: string },
3848
+ [string]
3849
+ >(`
3850
+ SELECT p.uri AS uri, s.uri AS successor
3851
+ FROM documents p
3852
+ JOIN doc_edges e ON e.dst_doc_id = p.id AND e.edge_type = 'supersedes'
3853
+ JOIN documents s ON s.id = e.src_doc_id AND s.active = 1
3854
+ WHERE p.uri IN (SELECT value FROM json_each(?))
3855
+ ORDER BY s.id
3856
+ `);
3857
+ const canonical = new Map(uris.map((uri) => [uri, stripUriIndex(uri)]));
3858
+ const next = new Map<string, string>();
3859
+ const seen = new Set(canonical.values());
3860
+ let frontier = [...seen];
3861
+ while (frontier.length > 0) {
3862
+ const discovered: string[] = [];
3863
+ for (const row of successorOf.all(JSON.stringify(frontier))) {
3864
+ next.set(row.uri, row.successor);
3865
+ if (!seen.has(row.successor)) {
3866
+ seen.add(row.successor);
3867
+ discovered.push(row.successor);
3868
+ }
3869
+ }
3870
+ frontier = discovered;
3871
+ }
3872
+ const result = new Map<string, string>();
3873
+ for (const [input, uri] of canonical) {
3874
+ const visited = new Set([uri]);
3875
+ let current = next.get(uri);
3876
+ while (current && next.has(current) && !visited.has(current)) {
3877
+ visited.add(current);
3878
+ current = next.get(current);
3879
+ }
3880
+ if (current && current !== uri) result.set(input, current);
3881
+ }
3882
+ return ok(result);
3883
+ } catch (cause) {
3884
+ return err(
3885
+ "QUERY_FAILED",
3886
+ cause instanceof Error ? cause.message : "Failed to read supersession",
3887
+ cause
3888
+ );
3889
+ }
3890
+ }
3891
+
3892
+ async getTypedMetadataDocuments(options?: {
3893
+ collection?: string;
3894
+ }): Promise<StoreResult<{ documents: number; metadata: string[] }>> {
3895
+ try {
3896
+ const db = this.ensureOpen();
3897
+ const scope = options?.collection ? " AND collection = ?" : "";
3898
+ const params = options?.collection ? [options.collection] : [];
3899
+ const documents = db
3900
+ .query<{ count: number }, string[]>(
3901
+ `SELECT count(*) AS count FROM documents WHERE active = 1${scope}`
3902
+ )
3903
+ .get(...params)!.count;
3904
+ const metadata = db
3905
+ .query<{ typed_metadata: string }, string[]>(
3906
+ `SELECT typed_metadata FROM documents WHERE active = 1 AND typed_metadata IS NOT NULL${scope}`
3907
+ )
3908
+ .all(...params)
3909
+ .map((row) => row.typed_metadata);
3910
+ return ok({ documents, metadata });
3911
+ } catch (cause) {
3912
+ return err(
3913
+ "QUERY_FAILED",
3914
+ cause instanceof Error ? cause.message : "Failed to read metadata",
3915
+ cause
3916
+ );
3917
+ }
3918
+ }
3919
+
3699
3920
  async getTagCounts(options?: {
3700
3921
  collection?: string;
3701
3922
  prefix?: string;
@@ -68,7 +68,23 @@ export type StoreErrorCode =
68
68
  | "VEC_REBUILD_FAILED"
69
69
  | "VEC_SYNC_FAILED"
70
70
  /** Embedding would build a separate vector partition without confirmation. */
71
- | "VECTOR_PARTITION_FORK";
71
+ | "VECTOR_PARTITION_FORK"
72
+ /** The index holds indexed collections the opening config does not list. */
73
+ | "STALE_COLLECTIONS";
74
+
75
+ /** How a config sync treats collections the config no longer lists. */
76
+ export interface SyncCollectionsOptions {
77
+ /** Explicitly removed names, or every unlisted collection ("stale"). */
78
+ remove?: readonly string[] | "stale";
79
+ /** Leave unlisted collections untouched instead of failing. */
80
+ keepStale?: boolean;
81
+ }
82
+
83
+ /** A collection in the index that the opening config does not list. */
84
+ export interface StaleCollection {
85
+ name: string;
86
+ documents: number;
87
+ }
72
88
 
73
89
  /** Store error with structured details */
74
90
  export interface StoreError {
@@ -738,8 +754,6 @@ export interface FtsSearchOptions extends DocumentEligibilityOptions {
738
754
  * so runtime language filtering is not currently implemented.
739
755
  */
740
756
  language?: string;
741
- /** Include snippet with highlights */
742
- snippet?: boolean;
743
757
  /** Match documents containing ANY positive term instead of ALL of them. */
744
758
  anyTerm?: boolean;
745
759
  /**
@@ -770,7 +784,6 @@ export interface FtsResult {
770
784
  mirrorHash: string;
771
785
  seq: number;
772
786
  score: number;
773
- snippet?: string;
774
787
  // Joined from documents table
775
788
  docid?: string;
776
789
  uri?: string;
@@ -1614,10 +1627,21 @@ export interface StorePort {
1614
1627
  // ─────────────────────────────────────────────────────────────────────────
1615
1628
 
1616
1629
  /**
1617
- * Sync collections from config to DB.
1618
- * Adds new, updates existing, removes deleted.
1630
+ * Sync collections from config to DB: adds new and updates existing ones.
1631
+ * A collection in the index but not in `collections` is deleted only when
1632
+ * `options.remove` names it (or is "stale"). One that holds documents
1633
+ * otherwise fails the sync with STALE_COLLECTIONS and changes nothing,
1634
+ * unless `keepStale` leaves it untouched.
1619
1635
  */
1620
- syncCollections(collections: Collection[]): Promise<StoreResult<void>>;
1636
+ syncCollections(
1637
+ collections: Collection[],
1638
+ options?: SyncCollectionsOptions
1639
+ ): Promise<StoreResult<void>>;
1640
+
1641
+ /** Collections in the index that `configured` does not list, with counts. */
1642
+ listStaleCollections(
1643
+ configured: readonly string[]
1644
+ ): Promise<StoreResult<StaleCollection[]>>;
1621
1645
 
1622
1646
  /**
1623
1647
  * Sync contexts from config to DB.
@@ -2159,6 +2183,20 @@ export interface StorePort {
2159
2183
  keys: { mirrorHash: string; seq: number }[]
2160
2184
  ): Promise<StoreResult<Map<string, ChunkRow[]>>>;
2161
2185
 
2186
+ /**
2187
+ * Optional: for every chunk of the given documents, which of the given
2188
+ * lowercase terms occur in its text (substring match on ASCII-lowercased
2189
+ * text), computed in the database so chunk text is never loaded. Rows are
2190
+ * ordered by mirrorHash, then seq; `present[i]` answers `terms[i]`. Callers
2191
+ * fall back to getChunksBatch when this capability is absent.
2192
+ */
2193
+ getChunkTermPresence?(
2194
+ mirrorHashes: string[],
2195
+ terms: string[]
2196
+ ): Promise<
2197
+ StoreResult<{ mirrorHash: string; seq: number; present: boolean[] }[]>
2198
+ >;
2199
+
2162
2200
  // ─────────────────────────────────────────────────────────────────────────
2163
2201
  // FTS Search
2164
2202
  // ─────────────────────────────────────────────────────────────────────────
@@ -2245,6 +2283,22 @@ export interface StorePort {
2245
2283
  documentIds: number[]
2246
2284
  ): Promise<StoreResult<Map<number, TagRow[]>>>;
2247
2285
 
2286
+ /**
2287
+ * For each URI whose document an active document supersedes, the URI of
2288
+ * its current successor (the end of the supersession chain).
2289
+ */
2290
+ getSupersedingUris?(
2291
+ uris: string[]
2292
+ ): Promise<StoreResult<Map<string, string>>>;
2293
+
2294
+ /**
2295
+ * Indexed typed metadata JSON of active documents, optionally in one
2296
+ * collection, plus the active document count in that scope.
2297
+ */
2298
+ getTypedMetadataDocuments(options?: {
2299
+ collection?: string;
2300
+ }): Promise<StoreResult<{ documents: number; metadata: string[] }>>;
2301
+
2248
2302
  /**
2249
2303
  * Get tag counts across all active documents.
2250
2304
  * 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
- e42897b28d0fc0fca7d1d7aca6ab016255cc3b5bf20d38bcac51535499eb7f25 gno-browser-clipper-v2.10.1.zip