@gmickel/gno 2.11.0 → 2.12.1

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 (61) hide show
  1. package/README.md +1 -1
  2. package/assets/skill/SKILL.md +3 -2
  3. package/assets/spa-production.json.gz +0 -0
  4. package/browser-extension/artifacts/{gno-browser-clipper-v2.11.0.zip → gno-browser-clipper-v2.12.1.zip} +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v2.12.1.zip.sha256 +1 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/package.json +8 -5
  8. package/spec/cli.md +100 -34
  9. package/spec/evals-agentic.md +3 -1
  10. package/spec/mcp.md +9 -3
  11. package/spec/output-schemas/status.schema.json +28 -0
  12. package/src/app/context-agent-projection.ts +10 -1
  13. package/src/cli/commands/agents/block.ts +2 -2
  14. package/src/cli/commands/agents/commands.ts +6 -5
  15. package/src/cli/commands/agents/engine.ts +15 -2
  16. package/src/cli/commands/agents/harnesses.ts +21 -14
  17. package/src/cli/commands/cleanup.ts +46 -6
  18. package/src/cli/commands/collection/remove.ts +1 -0
  19. package/src/cli/commands/collection/rename.ts +1 -0
  20. package/src/cli/commands/doctor.ts +37 -0
  21. package/src/cli/commands/mcp/paths.ts +2 -1
  22. package/src/cli/commands/setup-semantic.ts +6 -1
  23. package/src/cli/commands/shared.ts +26 -1
  24. package/src/cli/commands/skill/paths.ts +20 -2
  25. package/src/cli/commands/status.ts +89 -15
  26. package/src/cli/program.ts +25 -5
  27. package/src/core/activation-probe-plan.ts +0 -1
  28. package/src/core/config-mutation.ts +72 -55
  29. package/src/core/memory-remember.ts +0 -1
  30. package/src/core/module-path.ts +33 -0
  31. package/src/core/runtime-entrypoint.ts +3 -1
  32. package/src/ingestion/file-processor.ts +6 -3
  33. package/src/ingestion/prepare-file.ts +14 -4
  34. package/src/ingestion/source-availability/root.ts +138 -0
  35. package/src/ingestion/sync.ts +5 -19
  36. package/src/llm/native-worker/client.ts +8 -4
  37. package/src/llm/native-worker/errors.ts +26 -3
  38. package/src/mcp/http-transport.ts +7 -2
  39. package/src/mcp/server.ts +3 -0
  40. package/src/mcp/tool-descriptions-core.ts +2 -2
  41. package/src/mcp/tools/index.ts +30 -1
  42. package/src/mcp/tools/status.ts +17 -2
  43. package/src/pipeline/query-snippet.ts +266 -0
  44. package/src/pipeline/search.ts +149 -19
  45. package/src/sdk/client.ts +14 -2
  46. package/src/sdk/types.ts +6 -1
  47. package/src/serve/public/components/ThoroughnessSelector.tsx +1 -1
  48. package/src/serve/public/globals.built.css +1 -1
  49. package/src/serve/public/index.html +5 -0
  50. package/src/serve/public/pages/Collections.tsx +8 -0
  51. package/src/serve/public/pages/Search.tsx +9 -1
  52. package/src/serve/resident-request.ts +8 -0
  53. package/src/serve/resident-runtime.ts +25 -3
  54. package/src/serve/spa-production-build.ts +7 -3
  55. package/src/serve/status-model.ts +2 -0
  56. package/src/serve/status.ts +29 -0
  57. package/src/sessions/config-refresh.ts +6 -0
  58. package/src/sessions/import-child.ts +4 -1
  59. package/src/store/sqlite/adapter.ts +349 -200
  60. package/src/store/types.ts +45 -7
  61. package/browser-extension/artifacts/gno-browser-clipper-v2.11.0.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,
@@ -4935,20 +5076,19 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
4935
5076
  const edgeTypeFilterFor = (alias: string): string =>
4936
5077
  edgeType ? `AND ${alias}.edge_type = ?` : "";
4937
5078
  const candidateEdgeLimit = Math.min(frontierLimit * 4, 4_000);
4938
- const frontierCtes: string[] = [];
4939
- const frontierParams: (number | string)[] = [];
4940
- const frontierNames = ["f0"];
4941
- const allFrontiers = (): string =>
4942
- frontierNames
4943
- .map((name) => `SELECT doc_id FROM ${name}`)
4944
- .join(" UNION ");
4945
- const frontierJoinClause = (frontierName: string): string =>
5079
+ // Each level runs as its own statement over the previous frontier and
5080
+ // the visited set, passed as JSON. One statement chaining every level
5081
+ // as CTEs re-expands each earlier level at every reference, which
5082
+ // grows exponentially with depth: SQLite 3.54 refuses it from depth 3
5083
+ // ("nested too deep") and older versions fail at depth 6.
5084
+ const visitedIds = "SELECT doc_id FROM visited";
5085
+ const frontierJoinClause =
4946
5086
  direction === "out"
4947
- ? `e.src_doc_id = ${frontierName}.doc_id`
5087
+ ? "e.src_doc_id = prev.doc_id"
4948
5088
  : direction === "in"
4949
- ? `e.dst_doc_id = ${frontierName}.doc_id`
4950
- : `(e.src_doc_id = ${frontierName}.doc_id OR e.dst_doc_id = ${frontierName}.doc_id)`;
4951
- const boundedEdgePredicate = (frontierName: string): string => {
5089
+ ? "e.dst_doc_id = prev.doc_id"
5090
+ : "(e.src_doc_id = prev.doc_id OR e.dst_doc_id = prev.doc_id)";
5091
+ const boundedEdgePredicate = (): string => {
4952
5092
  if (direction === "out") {
4953
5093
  return `
4954
5094
  e.id IN (
@@ -4969,10 +5109,10 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
4969
5109
  ) AS next_rank
4970
5110
  FROM doc_edges e2
4971
5111
  JOIN documents next_doc ON next_doc.id = e2.dst_doc_id AND next_doc.active = 1${nextDocScope}
4972
- WHERE e2.src_doc_id = ${frontierName}.doc_id
5112
+ WHERE e2.src_doc_id = prev.doc_id
4973
5113
  ${edgeTypeFilterFor("e2")}
4974
- AND instr(${frontierName}.path, printf(',%d,', e2.dst_doc_id)) = 0
4975
- AND e2.dst_doc_id NOT IN (${allFrontiers()})
5114
+ AND instr(prev.path, printf(',%d,', e2.dst_doc_id)) = 0
5115
+ AND e2.dst_doc_id NOT IN (${visitedIds})
4976
5116
  )
4977
5117
  WHERE next_rank = 1
4978
5118
  ORDER BY edge_type ASC, next_doc_id ASC, edge_id ASC
@@ -5000,10 +5140,10 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5000
5140
  ) AS next_rank
5001
5141
  FROM doc_edges e2
5002
5142
  JOIN documents next_doc ON next_doc.id = e2.src_doc_id AND next_doc.active = 1${nextDocScope}
5003
- WHERE e2.dst_doc_id = ${frontierName}.doc_id
5143
+ WHERE e2.dst_doc_id = prev.doc_id
5004
5144
  ${edgeTypeFilterFor("e2")}
5005
- AND instr(${frontierName}.path, printf(',%d,', e2.src_doc_id)) = 0
5006
- AND e2.src_doc_id NOT IN (${allFrontiers()})
5145
+ AND instr(prev.path, printf(',%d,', e2.src_doc_id)) = 0
5146
+ AND e2.src_doc_id NOT IN (${visitedIds})
5007
5147
  )
5008
5148
  WHERE next_rank = 1
5009
5149
  ORDER BY edge_type ASC, next_doc_id ASC, edge_id ASC
@@ -5031,10 +5171,10 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5031
5171
  ) AS next_rank
5032
5172
  FROM doc_edges e2
5033
5173
  JOIN documents next_doc ON next_doc.id = e2.dst_doc_id AND next_doc.active = 1${nextDocScope}
5034
- WHERE e2.src_doc_id = ${frontierName}.doc_id
5174
+ WHERE e2.src_doc_id = prev.doc_id
5035
5175
  ${edgeTypeFilterFor("e2")}
5036
- AND instr(${frontierName}.path, printf(',%d,', e2.dst_doc_id)) = 0
5037
- AND e2.dst_doc_id NOT IN (${allFrontiers()})
5176
+ AND instr(prev.path, printf(',%d,', e2.dst_doc_id)) = 0
5177
+ AND e2.dst_doc_id NOT IN (${visitedIds})
5038
5178
  )
5039
5179
  WHERE next_rank = 1
5040
5180
  ORDER BY edge_type ASC, next_doc_id ASC, edge_id ASC
@@ -5059,10 +5199,10 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5059
5199
  ) AS next_rank
5060
5200
  FROM doc_edges e3
5061
5201
  JOIN documents next_doc ON next_doc.id = e3.src_doc_id AND next_doc.active = 1${nextDocScope}
5062
- WHERE e3.dst_doc_id = ${frontierName}.doc_id
5202
+ WHERE e3.dst_doc_id = prev.doc_id
5063
5203
  ${edgeTypeFilterFor("e3")}
5064
- AND instr(${frontierName}.path, printf(',%d,', e3.src_doc_id)) = 0
5065
- AND e3.src_doc_id NOT IN (${allFrontiers()})
5204
+ AND instr(prev.path, printf(',%d,', e3.src_doc_id)) = 0
5205
+ AND e3.src_doc_id NOT IN (${visitedIds})
5066
5206
  )
5067
5207
  WHERE next_rank = 1
5068
5208
  ORDER BY edge_type ASC, next_doc_id ASC, edge_id ASC
@@ -5071,40 +5211,62 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5071
5211
  )
5072
5212
  )`;
5073
5213
  };
5074
- const nextExprFor = (frontierName: string): string =>
5075
- `CASE WHEN e.src_doc_id = ${frontierName}.doc_id THEN e.dst_doc_id ELSE e.src_doc_id END`;
5076
-
5077
- const boundaryCandidateDepth = maxDepth + 1;
5078
- for (let depth = 1; depth <= boundaryCandidateDepth; depth += 1) {
5079
- const previous = `f${depth - 1}`;
5080
- const raw = `c${depth}_raw`;
5081
- const deduped = `c${depth}_deduped`;
5082
- const nodeDeduped = `c${depth}_node_deduped`;
5083
- const ranked = `c${depth}_ranked`;
5084
- const candidates = `c${depth}`;
5085
- const frontier = `f${depth}`;
5086
- const nextExpr = nextExprFor(previous);
5087
- frontierCtes.push(`
5088
- ${raw} AS (
5214
+ const nextExpr =
5215
+ "CASE WHEN e.src_doc_id = prev.doc_id THEN e.dst_doc_id ELSE e.src_doc_id END";
5216
+ const levelParams: (number | string)[] = [];
5217
+ if (direction === "both") {
5218
+ if (edgeType) {
5219
+ levelParams.push(edgeType);
5220
+ }
5221
+ levelParams.push(candidateEdgeLimit);
5222
+ }
5223
+ if (edgeType) {
5224
+ levelParams.push(edgeType);
5225
+ }
5226
+ levelParams.push(candidateEdgeLimit);
5227
+ if (edgeType) {
5228
+ levelParams.push(edgeType);
5229
+ }
5230
+ levelParams.push(
5231
+ frontierLimit,
5232
+ frontierLimit,
5233
+ visitedLimit,
5234
+ frontierLimit
5235
+ );
5236
+
5237
+ const levelQuery = (depth: number) =>
5238
+ db.query<
5239
+ {
5240
+ candidate_count: number;
5241
+ has_candidates: number;
5242
+ expansion_overflow: number;
5243
+ doc_id: number | null;
5244
+ depth: number | null;
5245
+ path: string | null;
5246
+ sort_key: string | null;
5247
+ },
5248
+ (number | string)[]
5249
+ >(`
5250
+ WITH
5251
+ prev(doc_id, path) AS (
5252
+ SELECT json_extract(value, '$[0]'), json_extract(value, '$[1]')
5253
+ FROM json_each(?)
5254
+ ),
5255
+ visited(doc_id) AS (
5256
+ SELECT value FROM json_each(?)
5257
+ ),
5258
+ raw AS (
5089
5259
  SELECT
5090
- ${previous}.doc_id AS from_doc_id,
5260
+ prev.doc_id AS from_doc_id,
5091
5261
  ${nextExpr} AS doc_id,
5092
5262
  ${depth} AS depth,
5093
- ${previous}.path || ${nextExpr} || ',' AS path,
5263
+ prev.path || ${nextExpr} || ',' AS path,
5094
5264
  printf('%06d|%s|%012d', ${depth}, e.edge_type, ${nextExpr}) AS sort_key,
5095
5265
  e.src_doc_id,
5096
- src.docid AS source_docid,
5097
- src.uri AS source_uri,
5098
- src.title AS source_title,
5099
5266
  e.dst_doc_id,
5100
- dst.docid AS target_docid,
5101
- dst.uri AS target_uri,
5102
- dst.title AS target_title,
5103
5267
  e.edge_type,
5104
- e.confidence,
5105
- e.source,
5106
5268
  row_number() OVER (
5107
- PARTITION BY ${previous}.doc_id, e.src_doc_id, e.dst_doc_id, e.edge_type
5269
+ PARTITION BY prev.doc_id, e.src_doc_id, e.dst_doc_id, e.edge_type
5108
5270
  ORDER BY
5109
5271
  CASE e.confidence
5110
5272
  WHEN 'manual' THEN 1
@@ -5117,47 +5279,47 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5117
5279
  src.docid ASC,
5118
5280
  dst.docid ASC
5119
5281
  ) AS dedup_rank
5120
- FROM ${previous}
5121
- JOIN doc_edges e ON ${frontierJoinClause(previous)}
5122
- AND ${boundedEdgePredicate(previous)}
5282
+ FROM prev
5283
+ JOIN doc_edges e ON ${frontierJoinClause}
5284
+ AND ${boundedEdgePredicate()}
5123
5285
  JOIN documents src ON src.id = e.src_doc_id AND src.active = 1
5124
5286
  JOIN documents dst ON dst.id = e.dst_doc_id AND dst.active = 1
5125
5287
  WHERE 1 = 1
5126
5288
  ${edgeTypeFilter}
5127
- AND instr(${previous}.path, printf(',%d,', ${nextExpr})) = 0
5128
- AND ${nextExpr} NOT IN (${allFrontiers()})
5289
+ AND instr(prev.path, printf(',%d,', ${nextExpr})) = 0
5290
+ AND ${nextExpr} NOT IN (${visitedIds})
5129
5291
  ),
5130
- ${deduped} AS (
5292
+ deduped AS (
5131
5293
  SELECT *
5132
- FROM ${raw}
5294
+ FROM raw
5133
5295
  WHERE dedup_rank = 1
5134
5296
  ),
5135
- ${nodeDeduped} AS (
5297
+ node_deduped AS (
5136
5298
  SELECT
5137
5299
  from_doc_id,
5138
5300
  doc_id,
5139
5301
  depth,
5140
5302
  min(path) AS path,
5141
5303
  min(sort_key) AS sort_key
5142
- FROM ${deduped}
5304
+ FROM deduped
5143
5305
  GROUP BY from_doc_id, doc_id, depth
5144
5306
  ),
5145
- ${ranked} AS (
5307
+ ranked AS (
5146
5308
  SELECT
5147
5309
  *,
5148
5310
  row_number() OVER (
5149
5311
  PARTITION BY from_doc_id
5150
5312
  ORDER BY sort_key ASC, doc_id ASC
5151
5313
  ) AS expansion_rank
5152
- FROM ${nodeDeduped}
5314
+ FROM node_deduped
5153
5315
  ),
5154
- ${candidates} AS (
5316
+ candidates AS (
5155
5317
  SELECT doc_id, depth, min(path) AS path, min(sort_key) AS sort_key
5156
- FROM ${ranked}
5318
+ FROM ranked
5157
5319
  WHERE expansion_rank <= ?
5158
5320
  GROUP BY doc_id, depth
5159
5321
  ),
5160
- ${frontier} AS (
5322
+ frontier AS (
5161
5323
  SELECT doc_id, depth, path, sort_key
5162
5324
  FROM (
5163
5325
  SELECT
@@ -5166,35 +5328,88 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5166
5328
  path,
5167
5329
  sort_key,
5168
5330
  row_number() OVER (ORDER BY sort_key ASC, doc_id ASC) AS depth_rank
5169
- FROM ${candidates}
5331
+ FROM candidates
5170
5332
  )
5171
5333
  WHERE depth_rank <= ?
5172
- AND depth_rank <= ? - (
5173
- SELECT count(*)
5174
- FROM (${allFrontiers()})
5175
- )
5176
- )`);
5177
- if (direction === "both") {
5178
- if (edgeType) {
5179
- frontierParams.push(edgeType);
5180
- }
5181
- frontierParams.push(candidateEdgeLimit);
5182
- if (edgeType) {
5183
- frontierParams.push(edgeType);
5184
- }
5185
- frontierParams.push(candidateEdgeLimit);
5186
- } else {
5187
- if (edgeType) {
5188
- frontierParams.push(edgeType);
5189
- }
5190
- frontierParams.push(candidateEdgeLimit);
5334
+ AND depth_rank <= ? - (SELECT count(*) FROM visited)
5335
+ )
5336
+ SELECT
5337
+ stats.candidate_count,
5338
+ stats.has_candidates,
5339
+ stats.expansion_overflow,
5340
+ frontier.doc_id,
5341
+ frontier.depth,
5342
+ frontier.path,
5343
+ frontier.sort_key
5344
+ FROM (
5345
+ SELECT
5346
+ (SELECT count(*) FROM candidates) AS candidate_count,
5347
+ EXISTS (SELECT 1 FROM ranked) AS has_candidates,
5348
+ EXISTS (SELECT 1 FROM ranked WHERE expansion_rank > ?) AS expansion_overflow
5349
+ ) stats
5350
+ LEFT JOIN frontier ON 1
5351
+ ORDER BY frontier.sort_key ASC, frontier.doc_id ASC`);
5352
+
5353
+ // Walk level by level; the extra level past maxDepth only reports
5354
+ // whether the depth cap cut anything off.
5355
+ type FrontierRow = {
5356
+ doc_id: number;
5357
+ depth: number;
5358
+ path: string;
5359
+ sort_key: string;
5360
+ };
5361
+ let previous: FrontierRow[] = [
5362
+ {
5363
+ doc_id: rootDocumentId,
5364
+ depth: 0,
5365
+ path: `,${rootDocumentId},`,
5366
+ sort_key: "",
5367
+ },
5368
+ ];
5369
+ const walked: FrontierRow[] = [...previous];
5370
+ const visited = new Set([rootDocumentId]);
5371
+ let visitedOverflow = false;
5372
+ let frontierOverflow = false;
5373
+ let depthOverflow = false;
5374
+ for (let depth = 1; depth <= maxDepth + 1; depth += 1) {
5375
+ const rows = levelQuery(depth).all(
5376
+ JSON.stringify(previous.map((row) => [row.doc_id, row.path])),
5377
+ JSON.stringify([...visited]),
5378
+ ...levelParams
5379
+ );
5380
+ const stats = rows[0];
5381
+ if (depth > maxDepth) {
5382
+ depthOverflow = Boolean(stats?.has_candidates);
5383
+ break;
5191
5384
  }
5192
- if (edgeType) {
5193
- frontierParams.push(edgeType);
5385
+ const candidateCount = stats?.candidate_count ?? 0;
5386
+ if (
5387
+ candidateCount > 0 &&
5388
+ candidateCount > visitedLimit - visited.size
5389
+ ) {
5390
+ visitedOverflow = true;
5194
5391
  }
5195
- frontierParams.push(frontierLimit, frontierLimit, visitedLimit);
5196
- if (depth <= maxDepth) {
5197
- frontierNames.push(frontier);
5392
+ if (
5393
+ (candidateCount > 0 && candidateCount > frontierLimit) ||
5394
+ stats?.expansion_overflow
5395
+ ) {
5396
+ frontierOverflow = true;
5397
+ }
5398
+ previous = rows.flatMap((row) =>
5399
+ row.doc_id === null
5400
+ ? []
5401
+ : [
5402
+ {
5403
+ doc_id: row.doc_id,
5404
+ depth: row.depth ?? depth,
5405
+ path: row.path ?? "",
5406
+ sort_key: row.sort_key ?? "",
5407
+ },
5408
+ ]
5409
+ );
5410
+ for (const row of previous) {
5411
+ visited.add(row.doc_id);
5412
+ walked.push(row);
5198
5413
  }
5199
5414
  }
5200
5415
 
@@ -5204,62 +5419,18 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5204
5419
  : direction === "in"
5205
5420
  ? "AND ns.depth > nt.depth"
5206
5421
  : "";
5207
- const frontierUnion = frontierNames
5208
- .map((name) => `SELECT doc_id, depth, sort_key FROM ${name}`)
5209
- .join(" UNION ALL ");
5210
- const visitedOverflowChecks = Array.from(
5211
- { length: maxDepth },
5212
- (_, index) => {
5213
- const previousFrontiers = Array.from(
5214
- { length: index + 1 },
5215
- (__, frontierIndex) => `SELECT doc_id FROM f${frontierIndex}`
5216
- ).join(" UNION ");
5217
- return `
5218
- SELECT 1 AS has_more
5219
- FROM c${index + 1}
5220
- GROUP BY 1
5221
- HAVING count(*) > ? - (
5222
- SELECT count(*)
5223
- FROM (${previousFrontiers})
5224
- )`;
5225
- }
5226
- ).join(" UNION ALL ");
5227
- const frontierOverflowChecks = Array.from(
5228
- { length: maxDepth },
5229
- (_, index) => `
5230
- SELECT 1 AS has_more
5231
- FROM c${index + 1}
5232
- GROUP BY 1
5233
- HAVING count(*) > ?
5234
- UNION ALL
5235
- SELECT 1 AS has_more
5236
- FROM c${index + 1}_ranked
5237
- WHERE expansion_rank > ?`
5238
- ).join(" UNION ALL ");
5239
- const walkCte = `
5240
- WITH RECURSIVE
5241
- f0(doc_id, depth, path, sort_key) AS (
5242
- SELECT ?, 0, printf(',%d,', ?), ''
5243
- ),
5244
- ${frontierCtes.join(",")},
5422
+ const walkedJson = JSON.stringify(
5423
+ walked.map((row) => [row.doc_id, row.depth, row.sort_key])
5424
+ );
5425
+ const nodeDepthCte = `
5245
5426
  node_depth AS (
5246
- SELECT doc_id, min(depth) AS depth, min(sort_key) AS sort_key
5247
- FROM (${frontierUnion})
5248
- GROUP BY doc_id
5249
- ),
5250
- ranked_nodes AS (
5251
5427
  SELECT
5252
- d.*,
5253
- nd.depth,
5254
- row_number() OVER (ORDER BY nd.depth ASC, nd.sort_key ASC, d.id ASC) AS global_rank,
5255
- count(*) OVER () AS total_count
5256
- FROM node_depth nd
5257
- JOIN documents d ON d.id = nd.doc_id AND d.active = 1
5258
- )
5259
- `;
5260
-
5261
- const baseParams: (number | string)[] = [];
5262
- baseParams.push(rootDocumentId, rootDocumentId, ...frontierParams);
5428
+ json_extract(value, '$[0]') AS doc_id,
5429
+ min(json_extract(value, '$[1]')) AS depth,
5430
+ min(json_extract(value, '$[2]')) AS sort_key
5431
+ FROM json_each(?)
5432
+ GROUP BY 1
5433
+ )`;
5263
5434
 
5264
5435
  const nodeRows = db
5265
5436
  .query<
@@ -5270,14 +5441,23 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5270
5441
  },
5271
5442
  (number | string)[]
5272
5443
  >(
5273
- `${walkCte}
5444
+ `WITH ${nodeDepthCte},
5445
+ ranked_nodes AS (
5446
+ SELECT
5447
+ d.*,
5448
+ nd.depth,
5449
+ row_number() OVER (ORDER BY nd.depth ASC, nd.sort_key ASC, d.id ASC) AS global_rank,
5450
+ count(*) OVER () AS total_count
5451
+ FROM node_depth nd
5452
+ JOIN documents d ON d.id = nd.doc_id AND d.active = 1
5453
+ )
5274
5454
  SELECT *
5275
5455
  FROM ranked_nodes
5276
5456
  WHERE global_rank <= ?
5277
5457
  ORDER BY depth ASC, uri ASC, docid ASC
5278
5458
  LIMIT ?`
5279
5459
  )
5280
- .all(...baseParams, nodeLimit + 1, nodeLimit + 1);
5460
+ .all(walkedJson, nodeLimit + 1, nodeLimit + 1);
5281
5461
 
5282
5462
  const returnedNodeRows = nodeRows.slice(0, nodeLimit);
5283
5463
  const returnedIds = new Set(returnedNodeRows.map((row) => row.id));
@@ -5288,41 +5468,19 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5288
5468
  truncated = true;
5289
5469
  warnings.push("maxNodes reached");
5290
5470
  }
5291
- if (totalNodes > visitedLimit) {
5471
+ if (totalNodes > visitedLimit || visitedOverflow) {
5292
5472
  truncated = true;
5293
5473
  warnings.push("visitedLimit reached");
5294
5474
  }
5295
-
5296
- const visitedRows = db
5297
- .query<{ has_more: number }, (number | string)[]>(
5298
- `${walkCte}
5299
- SELECT 1 AS has_more
5300
- FROM (${visitedOverflowChecks})
5301
- LIMIT 1`
5302
- )
5303
- .get(...baseParams, ...Array(maxDepth).fill(visitedLimit));
5304
- if (visitedRows) {
5305
- truncated = true;
5306
- warnings.push("visitedLimit reached");
5307
- }
5308
-
5309
- const frontierRows = db
5310
- .query<{ has_more: number }, (number | string)[]>(
5311
- `${walkCte}
5312
- SELECT 1 AS has_more
5313
- FROM (${frontierOverflowChecks})
5314
- LIMIT 1`
5315
- )
5316
- .get(...baseParams, ...Array(maxDepth * 2).fill(frontierLimit));
5317
- if (frontierRows) {
5475
+ if (frontierOverflow) {
5318
5476
  truncated = true;
5319
5477
  warnings.push("frontierLimit reached");
5320
5478
  }
5321
5479
 
5322
5480
  const edgeRows = db
5323
5481
  .query<DbDocEdgeRow & { traversal_depth: number }, (number | string)[]>(
5324
- `${walkCte}
5325
- , returned_edges AS (
5482
+ `WITH ${nodeDepthCte},
5483
+ returned_edges AS (
5326
5484
  SELECT
5327
5485
  e.src_doc_id,
5328
5486
  src.docid AS source_docid,
@@ -5369,22 +5527,13 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5369
5527
  ORDER BY traversal_depth ASC, edge_type ASC, source_docid ASC, target_docid ASC`
5370
5528
  )
5371
5529
  .all(
5372
- ...baseParams,
5530
+ walkedJson,
5373
5531
  ...returnedIds,
5374
5532
  ...returnedIds,
5375
5533
  ...(edgeType ? [edgeType] : [])
5376
5534
  );
5377
5535
 
5378
- const boundaryRows = db
5379
- .query<{ has_more: number }, (number | string)[]>(
5380
- `${walkCte}
5381
- SELECT 1 AS has_more
5382
- FROM c${boundaryCandidateDepth}_ranked
5383
- LIMIT 1`
5384
- )
5385
- .get(...baseParams);
5386
-
5387
- if (boundaryRows) {
5536
+ if (depthOverflow) {
5388
5537
  truncated = true;
5389
5538
  warnings.push("maxDepth reached");
5390
5539
  }