@gmickel/gno 2.7.1 → 2.8.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 (96) hide show
  1. package/README.md +3 -2
  2. package/assets/skill/SKILL.md +11 -1
  3. package/assets/skill/cli-reference.md +8 -1
  4. package/assets/skill/examples.md +2 -1
  5. package/assets/skill/mcp-reference.md +3 -1
  6. package/assets/skill/recipes/memory-scoped-recall.md +10 -5
  7. package/assets/spa-production.json.gz +0 -0
  8. package/browser-extension/artifacts/{gno-browser-clipper-v2.7.1.zip → gno-browser-clipper-v2.8.1.zip} +0 -0
  9. package/browser-extension/artifacts/gno-browser-clipper-v2.8.1.zip.sha256 +1 -0
  10. package/browser-extension/dist/manifest.json +1 -1
  11. package/package.json +1 -1
  12. package/spec/cli.md +86 -9
  13. package/spec/db/schema.sql +0 -1
  14. package/spec/mcp.md +26 -7
  15. package/spec/output-schemas/audit-report.schema.json +18 -4
  16. package/spec/output-schemas/backlinks.schema.json +4 -0
  17. package/spec/output-schemas/collection-list.schema.json +13 -0
  18. package/spec/output-schemas/graph.schema.json +2 -0
  19. package/spec/output-schemas/links-list.schema.json +4 -0
  20. package/spec/output-schemas/memory-recall.schema.json +1 -1
  21. package/spec/output-schemas/status.schema.json +18 -3
  22. package/src/cli/commands/audit.ts +23 -4
  23. package/src/cli/commands/collection/list.ts +39 -5
  24. package/src/cli/commands/embed.ts +3 -3
  25. package/src/cli/commands/graph.ts +3 -1
  26. package/src/cli/commands/links.ts +61 -180
  27. package/src/cli/commands/shared.ts +7 -0
  28. package/src/cli/commands/status.ts +6 -0
  29. package/src/cli/program.ts +12 -2
  30. package/src/config/loader.ts +43 -0
  31. package/src/config/types.ts +8 -0
  32. package/src/core/audit-contract.ts +16 -4
  33. package/src/core/audit-freshness.ts +11 -1
  34. package/src/core/audit-links.ts +197 -25
  35. package/src/core/audit-outside-index.ts +215 -0
  36. package/src/core/audit-provenance.ts +11 -4
  37. package/src/core/audit-workspace.ts +30 -9
  38. package/src/core/audit.ts +76 -16
  39. package/src/core/context-compiler.ts +3 -0
  40. package/src/core/context-evidence.ts +11 -0
  41. package/src/core/graph-edge-confidence.ts +23 -1
  42. package/src/core/host-paths.ts +1 -0
  43. package/src/core/knowledge-impact.ts +28 -0
  44. package/src/core/link-inventory-markdown.ts +2 -3
  45. package/src/core/link-workspace.ts +324 -0
  46. package/src/core/links.ts +40 -17
  47. package/src/core/memory-recall.ts +254 -15
  48. package/src/core/memory-types.ts +12 -0
  49. package/src/core/memory.ts +2 -0
  50. package/src/core/retrieval-replay-candidate.ts +6 -0
  51. package/src/core/retrieval-trace-request.ts +3 -0
  52. package/src/index.ts +14 -1
  53. package/src/ingestion/graph-reconciliation.ts +77 -15
  54. package/src/ingestion/source-availability/darwin-path.ts +9 -3
  55. package/src/ingestion/sync.ts +27 -4
  56. package/src/ingestion/types.ts +14 -0
  57. package/src/llm/inference-scope.ts +4 -3
  58. package/src/mcp/http-egress.ts +42 -3
  59. package/src/mcp/tools/audit.ts +11 -2
  60. package/src/mcp/tools/changes.ts +1 -0
  61. package/src/mcp/tools/links.ts +74 -93
  62. package/src/mcp/tools/sessions.ts +33 -4
  63. package/src/mcp/tools/status.ts +4 -0
  64. package/src/pipeline/expansion.ts +19 -31
  65. package/src/pipeline/graph-retrieval.ts +22 -2
  66. package/src/pipeline/hybrid.ts +1 -1
  67. package/src/pipeline/search.ts +2 -0
  68. package/src/pipeline/types.ts +10 -3
  69. package/src/sdk/client.ts +1 -0
  70. package/src/serve/findings-pass.ts +1 -1
  71. package/src/serve/public/components/editor/MarkdownPreview.tsx +5 -3
  72. package/src/serve/public/pages/GraphView.tsx +2 -0
  73. package/src/serve/routes/changes.ts +6 -1
  74. package/src/serve/routes/graph.ts +3 -1
  75. package/src/serve/routes/links.ts +45 -50
  76. package/src/serve/routes/sessions.ts +41 -53
  77. package/src/serve/server.ts +2 -1
  78. package/src/serve/status.ts +1 -0
  79. package/src/sessions/config-refresh.ts +111 -0
  80. package/src/store/migrations/033-drop-documents-active-index.ts +30 -0
  81. package/src/store/migrations/034-collection-link-workspace.ts +47 -0
  82. package/src/store/migrations/index.ts +4 -0
  83. package/src/store/sqlite/adapter.ts +477 -337
  84. package/src/store/sqlite/eligibility.ts +8 -2
  85. package/src/store/sqlite/graph-link-resolver.ts +259 -5
  86. package/src/store/sqlite/graph-neighbors.ts +147 -40
  87. package/src/store/sqlite/graph-reference-state.ts +13 -2
  88. package/src/store/sqlite/graph-similarity.ts +96 -0
  89. package/src/store/sqlite/workspace-link-resolver.ts +742 -0
  90. package/src/store/types.ts +64 -5
  91. package/src/store/vector/stats.ts +1 -1
  92. package/src/store/vector/status.ts +27 -0
  93. package/src/store/vector/stored-vectors.ts +158 -0
  94. package/src/store/vector/types.ts +6 -0
  95. package/src/store/vector/variant-search.ts +30 -14
  96. package/browser-extension/artifacts/gno-browser-clipper-v2.7.1.zip.sha256 +0 -1
@@ -138,8 +138,12 @@ import {
138
138
  classifyResolvedGraphEdge,
139
139
  mergeGraphEdgeAudit,
140
140
  } from "../../core/graph-edge-confidence";
141
- import { buildWikiBestMatchSubquery } from "../../core/graph-resolver";
142
141
  import { buildContentPrefilterNeedles } from "../../core/link-relevance";
142
+ import {
143
+ detectCollectionWorkspace,
144
+ detectNestedWorkspacePrefixes,
145
+ type LinkWorkspaceSource,
146
+ } from "../../core/link-workspace";
143
147
  import { normalizeWikiName, stripWikiMdExt } from "../../core/links";
144
148
  import {
145
149
  TYPED_METADATA_INGEST_VERSION,
@@ -163,6 +167,7 @@ import {
163
167
  listVectorPartitions,
164
168
  vectorRuntimeStatus,
165
169
  } from "../vector/status";
170
+ import { loadSqliteVec } from "../vector/variants";
166
171
  import {
167
172
  deleteSavedCapsuleRegistration as deleteStoredSavedCapsuleRegistration,
168
173
  getSavedCapsuleRegistration as getStoredSavedCapsuleRegistration,
@@ -213,9 +218,15 @@ import {
213
218
  applyGraphEdges,
214
219
  type DesiredGraphEdge,
215
220
  } from "./graph-edge-application";
216
- import { resolveGraphLinkTargets } from "./graph-link-resolver";
221
+ import {
222
+ type GraphLinkSourceIdentity,
223
+ isTraversableResolution,
224
+ linkSourceIdentity,
225
+ resolveGraphLinkTargets,
226
+ } from "./graph-link-resolver";
217
227
  import { queryGraphNeighborsForSeeds } from "./graph-neighbors";
218
228
  import { createGraphReferenceStore } from "./graph-reference-state";
229
+ import { hasSqliteVec, storedSimilarityEdges } from "./graph-similarity";
219
230
  import {
220
231
  snapshotLegacyTitles,
221
232
  reconcileLegacyTitles,
@@ -243,6 +254,11 @@ import {
243
254
  listTraces as listStoredTraces,
244
255
  mergeTraceEgressLineage as mergeStoredTraceEgressLineage,
245
256
  } from "./retrieval-trace-store";
257
+ import {
258
+ loadLinkWorkspaceMemberships,
259
+ workspaceKeyForDocument,
260
+ workspaceMemberCollections,
261
+ } from "./workspace-link-resolver";
246
262
 
247
263
  // ─────────────────────────────────────────────────────────────────────────────
248
264
  // FTS5 Query Escaping
@@ -903,11 +919,13 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
903
919
  INSERT INTO collections (
904
920
  name, path, pattern, include, exclude, update_cmd, language_hint,
905
921
  egress_policy, egress_policy_source, egress_policy_revision,
922
+ real_path, workspace_root, workspace_source,
906
923
  synced_at
907
924
  )
908
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
925
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
909
926
  ON CONFLICT(name) DO UPDATE SET
910
927
  path = excluded.path,
928
+ ${COLLECTION_WORKSPACE_UPSERT_SET}
911
929
  pattern = excluded.pattern,
912
930
  include = excluded.include,
913
931
  exclude = excluded.exclude,
@@ -936,6 +954,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
936
954
 
937
955
  for (const c of collections) {
938
956
  const egress = resolveConfiguredEgressPolicy(c);
957
+ const workspace = detectCollectionWorkspace(c);
939
958
  stmt.run(
940
959
  c.name,
941
960
  c.path,
@@ -946,7 +965,10 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
946
965
  c.languageHint ?? null,
947
966
  egress.policy,
948
967
  egress.source,
949
- c.egressPolicyRevision ?? 0
968
+ c.egressPolicyRevision ?? 0,
969
+ workspace.realPath,
970
+ workspace.root,
971
+ workspace.source
950
972
  );
951
973
  }
952
974
  });
@@ -971,11 +993,13 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
971
993
  INSERT INTO collections (
972
994
  name, path, pattern, include, exclude, update_cmd, language_hint,
973
995
  egress_policy, egress_policy_source, egress_policy_revision,
996
+ real_path, workspace_root, workspace_source,
974
997
  synced_at
975
998
  )
976
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
999
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
977
1000
  ON CONFLICT(name) DO UPDATE SET
978
1001
  path = excluded.path,
1002
+ ${COLLECTION_WORKSPACE_UPSERT_SET}
979
1003
  pattern = excluded.pattern,
980
1004
  include = excluded.include,
981
1005
  exclude = excluded.exclude,
@@ -1001,6 +1025,9 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1001
1025
  END,
1002
1026
  synced_at = datetime('now')
1003
1027
  WHERE collections.path IS NOT excluded.path
1028
+ OR collections.real_path IS NOT excluded.real_path
1029
+ OR collections.workspace_root IS NOT excluded.workspace_root
1030
+ OR collections.workspace_source IS NOT excluded.workspace_source
1004
1031
  OR collections.pattern IS NOT excluded.pattern
1005
1032
  OR collections.include IS NOT excluded.include
1006
1033
  OR collections.exclude IS NOT excluded.exclude
@@ -1021,6 +1048,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1021
1048
  const transaction = db.transaction(() => {
1022
1049
  for (const collection of collections) {
1023
1050
  const egress = resolveConfiguredEgressPolicy(collection);
1051
+ const workspace = detectCollectionWorkspace(collection);
1024
1052
  stmt.run(
1025
1053
  collection.name,
1026
1054
  collection.path,
@@ -1035,7 +1063,10 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1035
1063
  collection.languageHint ?? null,
1036
1064
  egress.policy,
1037
1065
  egress.source,
1038
- collection.egressPolicyRevision ?? 0
1066
+ collection.egressPolicyRevision ?? 0,
1067
+ workspace.realPath,
1068
+ workspace.root,
1069
+ workspace.source
1039
1070
  );
1040
1071
  }
1041
1072
  });
@@ -1129,6 +1160,62 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1129
1160
  }
1130
1161
  }
1131
1162
 
1163
+ /**
1164
+ * Re-detect nested vaults (`.obsidian/` below the collection root) from the
1165
+ * directories of the collection's active documents. Returns whether the
1166
+ * stored membership changed.
1167
+ */
1168
+ async refreshCollectionNestedWorkspaces(
1169
+ collection: string
1170
+ ): Promise<StoreResult<boolean>> {
1171
+ try {
1172
+ const db = this.ensureOpen();
1173
+ const row = db
1174
+ .query<
1175
+ {
1176
+ real_path: string | null;
1177
+ workspace_source: LinkWorkspaceSource | null;
1178
+ workspace_nested: string | null;
1179
+ },
1180
+ [string]
1181
+ >(
1182
+ "SELECT real_path, workspace_source, workspace_nested FROM collections WHERE name = ?"
1183
+ )
1184
+ .get(collection);
1185
+ if (!row) return ok(false);
1186
+ const eligible =
1187
+ row.real_path !== null &&
1188
+ row.workspace_source !== "disabled" &&
1189
+ row.workspace_source !== "unavailable";
1190
+ const nested = eligible
1191
+ ? detectNestedWorkspacePrefixes(
1192
+ row.real_path as string,
1193
+ db
1194
+ .query<{ rel_path: string }, [string]>(
1195
+ "SELECT rel_path FROM documents WHERE collection = ? AND active = 1"
1196
+ )
1197
+ .all(collection)
1198
+ .map((document) => document.rel_path)
1199
+ )
1200
+ : [];
1201
+ const next = nested.length > 0 ? JSON.stringify(nested) : null;
1202
+ if (next === row.workspace_nested) return ok(false);
1203
+ db.run("UPDATE collections SET workspace_nested = ? WHERE name = ?", [
1204
+ next,
1205
+ collection,
1206
+ ]);
1207
+ return ok(true);
1208
+ } catch (cause) {
1209
+ return err(
1210
+ "QUERY_FAILED",
1211
+ cause instanceof Error
1212
+ ? cause.message
1213
+ : "Failed to refresh nested link workspaces",
1214
+ cause
1215
+ );
1216
+ }
1217
+ }
1218
+
1132
1219
  async getContexts(): Promise<StoreResult<ContextRow[]>> {
1133
1220
  try {
1134
1221
  const db = this.ensureOpen();
@@ -2183,7 +2270,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
2183
2270
  params.push(options.collection);
2184
2271
  }
2185
2272
 
2186
- const sql = `SELECT * FROM documents WHERE ${clauses.join(" AND ")} ORDER BY id`;
2273
+ const sql = `SELECT * FROM documents INDEXED BY idx_documents_mirror_hash WHERE ${clauses.join(" AND ")} ORDER BY id`;
2187
2274
  rows.push(...db.query<DbDocumentRow, string[]>(sql).all(...params));
2188
2275
  }
2189
2276
 
@@ -3133,9 +3220,18 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3133
3220
  ...(options.chunkLanguage ? [options.chunkLanguage] : []),
3134
3221
  limit,
3135
3222
  ];
3136
- const rows = db
3223
+ const allRows = db
3137
3224
  .query<FtsRow, (string | number)[]>(sql)
3138
3225
  .all(...queryParams);
3226
+ // Raw bm25() is negative and rows are best-first.
3227
+ const floor =
3228
+ options.minRelativeScore !== undefined && allRows[0]
3229
+ ? allRows[0].score * options.minRelativeScore
3230
+ : undefined;
3231
+ const rows =
3232
+ floor === undefined
3233
+ ? allRows
3234
+ : allRows.filter((row) => row.score <= floor);
3139
3235
 
3140
3236
  return ok(
3141
3237
  rows.map((r) => ({
@@ -3786,12 +3882,16 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3786
3882
 
3787
3883
  /**
3788
3884
  * Get backlinks pointing to a document.
3789
- * Uses target_ref_norm for matching (wiki=normalized title with path fallbacks, markdown=rel_path).
3885
+ * Collection-scoped links match target_ref_norm (wiki=normalized title with
3886
+ * path fallbacks, markdown=rel_path). Plain wiki links from a document in a
3887
+ * link workspace count only when the shared resolver lands them on this
3888
+ * document (tied links never count). `collection` / `collections` restrict
3889
+ * the SOURCE collections; unset means every collection.
3790
3890
  * Only returns links from active source documents.
3791
3891
  */
3792
3892
  async getBacklinksForDoc(
3793
3893
  documentId: number,
3794
- options?: { collection?: string }
3894
+ options?: { collection?: string; collections?: string[] }
3795
3895
  ): Promise<StoreResult<BacklinkRow[]>> {
3796
3896
  try {
3797
3897
  const db = this.ensureOpen();
@@ -3841,17 +3941,34 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3841
3941
  addVariantsWithBasename(relPathKey);
3842
3942
 
3843
3943
  interface DbBacklinkRow {
3944
+ link_id: number;
3844
3945
  source_doc_id: number;
3845
3946
  docid: string;
3846
3947
  uri: string;
3847
3948
  title: string | null;
3949
+ source_collection: string;
3950
+ source_rel_path: string;
3951
+ target_ref_norm: string;
3952
+ target_collection: string | null;
3848
3953
  link_text: string | null;
3849
3954
  start_line: number;
3850
3955
  start_col: number;
3851
3956
  }
3852
3957
 
3853
3958
  const targetCollection = target.collection;
3854
- const sourceCollectionFilter = options?.collection;
3959
+ const sourceAllowlist =
3960
+ options?.collections ??
3961
+ (options?.collection ? [options.collection] : undefined);
3962
+ const sourceScopeSql = sourceAllowlist
3963
+ ? sourceAllowlist.length === 0
3964
+ ? "AND 0"
3965
+ : `AND src.collection IN (${sourceAllowlist.map(() => "?").join(",")})`
3966
+ : "";
3967
+ const sourceScopeParams = sourceAllowlist ?? [];
3968
+ const backlinkColumns = `dl.id AS link_id, dl.source_doc_id, src.docid,
3969
+ src.uri, src.title, src.collection AS source_collection,
3970
+ src.rel_path AS source_rel_path, dl.target_ref_norm,
3971
+ dl.target_collection, dl.link_text, dl.start_line, dl.start_col`;
3855
3972
 
3856
3973
  // Query wiki backlinks (link_type='wiki') with path-style fallbacks
3857
3974
  // NULL target_collection means "same collection as source" - enforce this in SQL
@@ -3881,7 +3998,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3881
3998
  wikiConditions.length > 0
3882
3999
  ? db
3883
4000
  .query<DbBacklinkRow, string[]>(
3884
- `SELECT dl.source_doc_id, src.docid, src.uri, src.title, dl.link_text, dl.start_line, dl.start_col
4001
+ `SELECT ${backlinkColumns}
3885
4002
  FROM doc_links dl
3886
4003
  JOIN documents src ON src.id = dl.source_doc_id AND src.active = 1
3887
4004
  WHERE dl.link_type = 'wiki'
@@ -3890,22 +4007,112 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3890
4007
  (dl.target_collection IS NULL AND src.collection = ?)
3891
4008
  OR dl.target_collection = ?
3892
4009
  )
3893
- ${sourceCollectionFilter ? "AND src.collection = ?" : ""}
4010
+ ${sourceScopeSql}
3894
4011
  ORDER BY src.uri, dl.start_line, dl.start_col`
3895
4012
  )
3896
4013
  .all(
3897
4014
  ...wikiParams,
3898
4015
  targetCollection,
3899
4016
  targetCollection,
3900
- ...(sourceCollectionFilter ? [sourceCollectionFilter] : [])
4017
+ ...sourceScopeParams
3901
4018
  )
3902
4019
  : [];
3903
4020
 
4021
+ // Workspace candidates: plain links from any member collection whose
4022
+ // last segment names this file. Resolution decides which count.
4023
+ const memberships = loadLinkWorkspaceMemberships(db);
4024
+ const targetWsKey = workspaceKeyForDocument(
4025
+ memberships,
4026
+ target.collection,
4027
+ target.rel_path
4028
+ );
4029
+ const workspaceBacklinks: DbBacklinkRow[] = [];
4030
+ if (targetWsKey !== null) {
4031
+ const members = workspaceMemberCollections(
4032
+ memberships,
4033
+ new Set([targetWsKey])
4034
+ );
4035
+ const base = stripWikiMdExt(
4036
+ normalizeWikiName(target.rel_path.split("/").pop() ?? target.rel_path)
4037
+ );
4038
+ const escapeLike = (value: string): string =>
4039
+ value
4040
+ .replaceAll("\\", "\\\\")
4041
+ .replaceAll("%", "\\%")
4042
+ .replaceAll("_", "\\_");
4043
+ if (members.length > 0) {
4044
+ workspaceBacklinks.push(
4045
+ ...db
4046
+ .query<DbBacklinkRow, string[]>(
4047
+ `SELECT ${backlinkColumns}
4048
+ FROM doc_links dl
4049
+ JOIN documents src ON src.id = dl.source_doc_id AND src.active = 1
4050
+ WHERE dl.link_type = 'wiki'
4051
+ AND dl.target_collection IS NULL
4052
+ AND src.collection IN (${members.map(() => "?").join(",")})
4053
+ AND (dl.target_ref_norm IN (?, ?)
4054
+ OR dl.target_ref_norm LIKE ? ESCAPE '\\'
4055
+ OR dl.target_ref_norm LIKE ? ESCAPE '\\'
4056
+ OR dl.target_ref_norm IN (${[...keySet].map(() => "?").join(",") || "NULL"}))
4057
+ ${sourceScopeSql}
4058
+ ORDER BY src.uri, dl.start_line, dl.start_col`
4059
+ )
4060
+ .all(
4061
+ ...members,
4062
+ base,
4063
+ `${base}.md`,
4064
+ `%/${escapeLike(base)}`,
4065
+ `%/${escapeLike(`${base}.md`)}`,
4066
+ ...keySet,
4067
+ ...sourceScopeParams
4068
+ )
4069
+ );
4070
+ }
4071
+ }
4072
+ const candidates = new Map<number, DbBacklinkRow>();
4073
+ for (const row of [...wikiBacklinks, ...workspaceBacklinks]) {
4074
+ candidates.set(row.link_id, row);
4075
+ }
4076
+ const candidateRows = [...candidates.values()];
4077
+ const resolutions = resolveGraphLinkTargets(
4078
+ db,
4079
+ candidateRows.map((row) => ({
4080
+ targetRefNorm: row.target_ref_norm,
4081
+ targetCollection: row.target_collection ?? row.source_collection,
4082
+ linkType: "wiki" as const,
4083
+ source: linkSourceIdentity(row),
4084
+ }))
4085
+ );
4086
+ const legacyLinkIds = new Set(wikiBacklinks.map((row) => row.link_id));
4087
+ const resolvedWikiBacklinks = candidateRows
4088
+ .filter((row, index) => {
4089
+ const workspaceLink =
4090
+ !row.target_collection &&
4091
+ workspaceKeyForDocument(
4092
+ memberships,
4093
+ row.source_collection,
4094
+ row.source_rel_path
4095
+ ) !== null;
4096
+ // Collection-scoped links keep their key-match semantics.
4097
+ if (!workspaceLink) return legacyLinkIds.has(row.link_id);
4098
+ const resolution = resolutions[index];
4099
+ return (
4100
+ isTraversableResolution(resolution) &&
4101
+ resolution.targetId === documentId
4102
+ );
4103
+ })
4104
+ .sort(
4105
+ (left, right) =>
4106
+ (left.uri < right.uri ? -1 : left.uri > right.uri ? 1 : 0) ||
4107
+ left.start_line - right.start_line ||
4108
+ left.start_col - right.start_col
4109
+ );
4110
+
3904
4111
  // Query markdown backlinks (link_type='markdown')
3905
4112
  // NULL target_collection means "same collection as source" - enforce this in SQL
3906
4113
  const mdBacklinks = db
3907
4114
  .query<DbBacklinkRow, string[]>(
3908
- `SELECT dl.source_doc_id, src.docid, src.uri, src.title, dl.link_text, dl.start_line, dl.start_col
4115
+ `SELECT ${backlinkColumns}
3909
4116
  FROM doc_links dl
3910
4117
  JOIN documents src ON src.id = dl.source_doc_id AND src.active = 1
3911
4118
  WHERE dl.link_type = 'markdown'
@@ -3914,25 +4121,28 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3914
4121
  (dl.target_collection IS NULL AND src.collection = ?)
3915
4122
  OR dl.target_collection = ?
3916
4123
  )
3917
- ${sourceCollectionFilter ? "AND src.collection = ?" : ""}
4124
+ ${sourceScopeSql}
3918
4125
  ORDER BY src.uri, dl.start_line, dl.start_col`
3919
4126
  )
3920
4127
  .all(
3921
4128
  target.rel_path,
3922
4129
  targetCollection,
3923
4130
  targetCollection,
3924
- ...(sourceCollectionFilter ? [sourceCollectionFilter] : [])
4131
+ ...sourceScopeParams
3925
4132
  );
3926
4133
 
3927
- const allBacklinks = [...wikiBacklinks, ...mdBacklinks].map((r) => ({
3928
- sourceDocId: r.source_doc_id,
3929
- sourceDocid: r.docid,
3930
- sourceDocUri: r.uri,
3931
- sourceDocTitle: r.title,
3932
- linkText: r.link_text,
3933
- startLine: r.start_line,
3934
- startCol: r.start_col,
3935
- }));
4134
+ const allBacklinks = [...resolvedWikiBacklinks, ...mdBacklinks].map(
4135
+ (r) => ({
4136
+ sourceDocId: r.source_doc_id,
4137
+ sourceDocid: r.docid,
4138
+ sourceDocUri: r.uri,
4139
+ sourceDocTitle: r.title,
4140
+ sourceCollection: r.source_collection,
4141
+ linkText: r.link_text,
4142
+ startLine: r.start_line,
4143
+ startCol: r.start_col,
4144
+ })
4145
+ );
3936
4146
 
3937
4147
  return ok(allBacklinks);
3938
4148
  } catch (cause) {
@@ -3951,206 +4161,64 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3951
4161
  targetRefNorm: string;
3952
4162
  targetCollection: string;
3953
4163
  linkType: "wiki" | "markdown";
4164
+ source?: GraphLinkSourceIdentity;
3954
4165
  }>
3955
4166
  ): Promise<
3956
4167
  StoreResult<
3957
- Array<{ docid: string; uri: string; title: string | null } | null>
3958
- >
3959
- > {
3960
- try {
3961
- const db = this.ensureOpen();
3962
-
3963
- const results: Array<{
4168
+ Array<{
3964
4169
  docid: string;
3965
4170
  uri: string;
3966
4171
  title: string | null;
3967
- } | null> = Array.from({ length: targets.length }, () => null);
3968
-
3969
- const wikiTargets: Array<{
3970
- idx: number;
3971
- collection: string;
3972
- baseRef: string;
3973
- baseRefMd: string;
3974
- }> = [];
3975
- const mdTargets: Array<{
3976
- idx: number;
3977
4172
  collection: string;
3978
- relPath: string;
3979
- }> = [];
3980
-
3981
- for (const [idx, target] of targets.entries()) {
3982
- if (target.linkType === "wiki") {
3983
- const baseRef = stripWikiMdExt(target.targetRefNorm);
3984
- wikiTargets.push({
3985
- idx,
3986
- collection: target.targetCollection,
3987
- baseRef,
3988
- baseRefMd: `${baseRef}.md`,
3989
- });
3990
- } else {
3991
- mdTargets.push({
3992
- idx,
3993
- collection: target.targetCollection,
3994
- relPath: target.targetRefNorm,
4173
+ } | null>
4174
+ >
4175
+ > {
4176
+ try {
4177
+ const db = this.ensureOpen();
4178
+ const resolutions = resolveGraphLinkTargets(db, targets);
4179
+ const ids = [
4180
+ ...new Set(
4181
+ resolutions
4182
+ .filter(isTraversableResolution)
4183
+ .map((resolution) => resolution.targetId)
4184
+ ),
4185
+ ];
4186
+ const byId = new Map<
4187
+ number,
4188
+ { docid: string; uri: string; title: string | null; collection: string }
4189
+ >();
4190
+ for (let offset = 0; offset < ids.length; offset += 900) {
4191
+ const batch = ids.slice(offset, offset + 900);
4192
+ for (const row of db
4193
+ .query<
4194
+ {
4195
+ id: number;
4196
+ docid: string;
4197
+ uri: string;
4198
+ title: string | null;
4199
+ collection: string;
4200
+ },
4201
+ number[]
4202
+ >(
4203
+ `SELECT id, docid, uri, title, collection FROM documents WHERE id IN (${batch.map(() => "?").join(",")})`
4204
+ )
4205
+ .all(...batch)) {
4206
+ byId.set(row.id, {
4207
+ docid: row.docid,
4208
+ uri: row.uri,
4209
+ title: row.title,
4210
+ collection: row.collection,
3995
4211
  });
3996
4212
  }
3997
4213
  }
3998
-
3999
- const chunkArray = <T>(items: T[], chunkSize: number): T[][] => {
4000
- const chunks: T[][] = [];
4001
- for (let i = 0; i < items.length; i += chunkSize) {
4002
- chunks.push(items.slice(i, i + chunkSize));
4003
- }
4004
- return chunks;
4005
- };
4006
-
4007
- const MAX_SQL_PARAMS = 900;
4008
- const wikiBatchSize = Math.max(1, Math.floor(MAX_SQL_PARAMS / 4));
4009
- const mdBatchSize = Math.max(1, Math.floor(MAX_SQL_PARAMS / 3));
4010
-
4011
- const titleExpr = "lower(trim(d.title))";
4012
- const relExpr = "lower(d.rel_path)";
4013
- const suffixMatchExprExpr = (
4014
- targetExpr: string,
4015
- valueExpr: string
4016
- ): string =>
4017
- `(substr(${targetExpr}, -length(${valueExpr})) = ${valueExpr}
4018
- AND (length(${targetExpr}) = length(${valueExpr})
4019
- OR substr(${targetExpr}, -length(${valueExpr}) - 1, 1) = '/'))`;
4020
-
4021
- if (wikiTargets.length > 0) {
4022
- for (const batch of chunkArray(wikiTargets, wikiBatchSize)) {
4023
- const valuesClause = batch.map(() => "(?, ?, ?, ?)").join(", ");
4024
- const wikiParams = batch.flatMap((t) => [
4025
- t.idx,
4026
- t.collection,
4027
- t.baseRef,
4028
- t.baseRefMd,
4029
- ]);
4030
-
4031
- const baseRefExpr = "t.base_ref";
4032
- const baseRefMdExpr = "t.base_ref_md";
4033
- const wikiWhere = `
4034
- ${titleExpr} = ${baseRefExpr}
4035
- OR ${titleExpr} = ${baseRefMdExpr}
4036
- OR ${suffixMatchExprExpr(baseRefExpr, titleExpr)}
4037
- OR ${suffixMatchExprExpr(baseRefMdExpr, `${titleExpr} || '.md'`)}
4038
- OR ${relExpr} = ${baseRefExpr}
4039
- OR ${relExpr} = ${baseRefMdExpr}
4040
- OR ${suffixMatchExprExpr(relExpr, baseRefMdExpr)}
4041
- OR ${suffixMatchExprExpr(relExpr, baseRefExpr)}
4042
- OR ${suffixMatchExprExpr(baseRefMdExpr, relExpr)}
4043
- OR ${suffixMatchExprExpr(baseRefExpr, relExpr)}
4044
- `;
4045
-
4046
- const wikiRank = `CASE
4047
- WHEN ${titleExpr} = ${baseRefExpr} THEN 1
4048
- WHEN ${titleExpr} = ${baseRefMdExpr} THEN 2
4049
- WHEN ${suffixMatchExprExpr(baseRefExpr, titleExpr)} THEN 3
4050
- WHEN ${suffixMatchExprExpr(
4051
- baseRefMdExpr,
4052
- `${titleExpr} || '.md'`
4053
- )} THEN 4
4054
- WHEN ${relExpr} = ${baseRefExpr} THEN 5
4055
- WHEN ${relExpr} = ${baseRefMdExpr} THEN 6
4056
- WHEN ${suffixMatchExprExpr(relExpr, baseRefMdExpr)} THEN 7
4057
- WHEN ${suffixMatchExprExpr(relExpr, baseRefExpr)} THEN 8
4058
- WHEN ${suffixMatchExprExpr(baseRefMdExpr, relExpr)} THEN 9
4059
- WHEN ${suffixMatchExprExpr(baseRefExpr, relExpr)} THEN 10
4060
- ELSE 99
4061
- END`;
4062
-
4063
- const wikiQuery = `
4064
- WITH targets(idx, collection, base_ref, base_ref_md) AS (
4065
- VALUES ${valuesClause}
4066
- ),
4067
- candidates AS (
4068
- SELECT
4069
- t.idx,
4070
- d.docid,
4071
- d.uri,
4072
- d.title,
4073
- d.id as doc_id,
4074
- ${wikiRank} as rank
4075
- FROM targets t
4076
- JOIN documents d ON d.active = 1 AND d.collection = t.collection
4077
- WHERE ${wikiWhere}
4078
- ),
4079
- ranked AS (
4080
- SELECT *,
4081
- ROW_NUMBER() OVER (PARTITION BY idx ORDER BY rank, doc_id) as rn
4082
- FROM candidates
4083
- )
4084
- SELECT idx, docid, uri, title
4085
- FROM ranked
4086
- WHERE rn = 1
4087
- `;
4088
-
4089
- const wikiRows = db
4090
- .query<
4091
- { idx: number; docid: string; uri: string; title: string | null },
4092
- (string | number)[]
4093
- >(wikiQuery)
4094
- .all(...wikiParams);
4095
-
4096
- for (const row of wikiRows) {
4097
- results[row.idx] = {
4098
- docid: row.docid,
4099
- uri: row.uri,
4100
- title: row.title,
4101
- };
4102
- }
4103
- }
4104
- }
4105
-
4106
- if (mdTargets.length > 0) {
4107
- for (const batch of chunkArray(mdTargets, mdBatchSize)) {
4108
- const valuesClause = batch.map(() => "(?, ?, ?)").join(", ");
4109
- const mdParams = batch.flatMap((t) => [
4110
- t.idx,
4111
- t.collection,
4112
- t.relPath,
4113
- ]);
4114
- const mdQuery = `
4115
- WITH targets(idx, collection, rel_path) AS (
4116
- VALUES ${valuesClause}
4117
- ),
4118
- ranked AS (
4119
- SELECT
4120
- t.idx,
4121
- d.docid,
4122
- d.uri,
4123
- d.title,
4124
- d.id as doc_id,
4125
- ROW_NUMBER() OVER (PARTITION BY t.idx ORDER BY d.id) as rn
4126
- FROM targets t
4127
- JOIN documents d ON d.active = 1
4128
- AND d.collection = t.collection
4129
- AND d.rel_path = t.rel_path
4130
- )
4131
- SELECT idx, docid, uri, title
4132
- FROM ranked
4133
- WHERE rn = 1
4134
- `;
4135
-
4136
- const mdRows = db
4137
- .query<
4138
- { idx: number; docid: string; uri: string; title: string | null },
4139
- (string | number)[]
4140
- >(mdQuery)
4141
- .all(...mdParams);
4142
-
4143
- for (const row of mdRows) {
4144
- results[row.idx] = {
4145
- docid: row.docid,
4146
- uri: row.uri,
4147
- title: row.title,
4148
- };
4149
- }
4150
- }
4151
- }
4152
-
4153
- return ok(results);
4214
+ // Tied workspace links are ambiguous: reported unresolved, never guessed.
4215
+ return ok(
4216
+ resolutions.map((resolution) =>
4217
+ isTraversableResolution(resolution)
4218
+ ? (byId.get(resolution.targetId) ?? null)
4219
+ : null
4220
+ )
4221
+ );
4154
4222
  } catch (cause) {
4155
4223
  return err(
4156
4224
  "QUERY_FAILED",
@@ -4757,6 +4825,22 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
4757
4825
  ? normalizeDocEdgeType(options.edgeType)
4758
4826
  : undefined;
4759
4827
 
4828
+ // Collection allowlist: every visited node must be in scope, so a
4829
+ // forbidden collection can never act as a traversal bridge. Names are
4830
+ // validated collection identifiers, inlined as quoted literals.
4831
+ const scopeCollections = options.collections
4832
+ ? [...new Set(options.collections)].filter((name) =>
4833
+ COLLECTION_IDENTIFIER.test(name)
4834
+ )
4835
+ : undefined;
4836
+ const nextDocScope = scopeCollections
4837
+ ? scopeCollections.length === 0
4838
+ ? " AND 0"
4839
+ : ` AND next_doc.collection IN (${scopeCollections
4840
+ .map((name) => `'${name}'`)
4841
+ .join(",")})`
4842
+ : "";
4843
+
4760
4844
  const nodeLimit = Math.min(maxNodes, visitedLimit);
4761
4845
  const edgeTypeFilter = edgeType ? "AND e.edge_type = ?" : "";
4762
4846
  const edgeTypeFilterFor = (alias: string): string =>
@@ -4795,7 +4879,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
4795
4879
  ORDER BY e2.edge_type ASC, e2.id ASC
4796
4880
  ) AS next_rank
4797
4881
  FROM doc_edges e2
4798
- JOIN documents next_doc ON next_doc.id = e2.dst_doc_id AND next_doc.active = 1
4882
+ JOIN documents next_doc ON next_doc.id = e2.dst_doc_id AND next_doc.active = 1${nextDocScope}
4799
4883
  WHERE e2.src_doc_id = ${frontierName}.doc_id
4800
4884
  ${edgeTypeFilterFor("e2")}
4801
4885
  AND instr(${frontierName}.path, printf(',%d,', e2.dst_doc_id)) = 0
@@ -4826,7 +4910,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
4826
4910
  ORDER BY e2.edge_type ASC, e2.id ASC
4827
4911
  ) AS next_rank
4828
4912
  FROM doc_edges e2
4829
- JOIN documents next_doc ON next_doc.id = e2.src_doc_id AND next_doc.active = 1
4913
+ JOIN documents next_doc ON next_doc.id = e2.src_doc_id AND next_doc.active = 1${nextDocScope}
4830
4914
  WHERE e2.dst_doc_id = ${frontierName}.doc_id
4831
4915
  ${edgeTypeFilterFor("e2")}
4832
4916
  AND instr(${frontierName}.path, printf(',%d,', e2.src_doc_id)) = 0
@@ -4857,7 +4941,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
4857
4941
  ORDER BY e2.edge_type ASC, e2.id ASC
4858
4942
  ) AS next_rank
4859
4943
  FROM doc_edges e2
4860
- JOIN documents next_doc ON next_doc.id = e2.dst_doc_id AND next_doc.active = 1
4944
+ JOIN documents next_doc ON next_doc.id = e2.dst_doc_id AND next_doc.active = 1${nextDocScope}
4861
4945
  WHERE e2.src_doc_id = ${frontierName}.doc_id
4862
4946
  ${edgeTypeFilterFor("e2")}
4863
4947
  AND instr(${frontierName}.path, printf(',%d,', e2.dst_doc_id)) = 0
@@ -4885,7 +4969,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
4885
4969
  ORDER BY e3.edge_type ASC, e3.id ASC
4886
4970
  ) AS next_rank
4887
4971
  FROM doc_edges e3
4888
- JOIN documents next_doc ON next_doc.id = e3.src_doc_id AND next_doc.active = 1
4972
+ JOIN documents next_doc ON next_doc.id = e3.src_doc_id AND next_doc.active = 1${nextDocScope}
4889
4973
  WHERE e3.dst_doc_id = ${frontierName}.doc_id
4890
4974
  ${edgeTypeFilterFor("e3")}
4891
4975
  AND instr(${frontierName}.path, printf(',%d,', e3.src_doc_id)) = 0
@@ -5255,19 +5339,53 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5255
5339
  : "";
5256
5340
  const params = sourceIds ? [JSON.stringify(sourceIds)] : [];
5257
5341
  const inserted = db.transaction(() => {
5258
- const wiki = db
5259
- .query<DesiredGraphEdge, string[]>(`
5260
- SELECT DISTINCT src.id AS sourceId, tgt.id AS targetId,
5261
- 'mentions' AS edgeType, 'parsed' AS confidence, 'wikilink' AS source
5342
+ // Wiki edges use the shared (workspace-aware) resolver in batches;
5343
+ // tied workspace links are audit-only and never become edges.
5344
+ const wikiRows = db
5345
+ .query<
5346
+ {
5347
+ source_id: number;
5348
+ source_collection: string;
5349
+ source_rel_path: string;
5350
+ target_ref_norm: string;
5351
+ target_collection: string | null;
5352
+ },
5353
+ string[]
5354
+ >(`
5355
+ SELECT src.id AS source_id, src.collection AS source_collection,
5356
+ src.rel_path AS source_rel_path, dl.target_ref_norm,
5357
+ dl.target_collection
5262
5358
  FROM documents src JOIN doc_links dl ON dl.source_doc_id = src.id
5263
- JOIN documents tgt ON tgt.id = (${buildWikiBestMatchSubquery(
5264
- "COALESCE(dl.target_collection, src.collection)",
5265
- "dl.target_ref_norm"
5266
- )})
5267
- WHERE src.active = 1 AND tgt.active = 1 AND dl.link_type = 'wiki'
5359
+ WHERE src.active = 1 AND dl.link_type = 'wiki'
5268
5360
  ${sourceFilter}
5361
+ ORDER BY src.id, dl.id
5269
5362
  `)
5270
5363
  .all(...params);
5364
+ const wikiResolutions = resolveGraphLinkTargets(
5365
+ db,
5366
+ wikiRows.map((row) => ({
5367
+ targetRefNorm: row.target_ref_norm,
5368
+ targetCollection: row.target_collection ?? row.source_collection,
5369
+ linkType: "wiki" as const,
5370
+ source: linkSourceIdentity(row),
5371
+ }))
5372
+ );
5373
+ const wikiKeys = new Set<string>();
5374
+ const wiki: DesiredGraphEdge[] = [];
5375
+ for (const [index, row] of wikiRows.entries()) {
5376
+ const resolution = wikiResolutions[index];
5377
+ if (!isTraversableResolution(resolution)) continue;
5378
+ const key = `${row.source_id}:${resolution.targetId}`;
5379
+ if (wikiKeys.has(key)) continue;
5380
+ wikiKeys.add(key);
5381
+ wiki.push({
5382
+ sourceId: row.source_id,
5383
+ targetId: resolution.targetId,
5384
+ edgeType: "mentions",
5385
+ confidence: "parsed",
5386
+ source: "wikilink",
5387
+ });
5388
+ }
5271
5389
  const markdown = db
5272
5390
  .query<DesiredGraphEdge, string[]>(`
5273
5391
  SELECT DISTINCT src.id AS sourceId, tgt.id AS targetId,
@@ -5345,13 +5463,11 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5345
5463
 
5346
5464
  const warnings: string[] = [];
5347
5465
 
5348
- // Always probe sqlite-vec availability (not just when similarity requested)
5349
- let similarAvailable = false;
5350
- try {
5351
- db.query("SELECT vec_version()").get();
5352
- similarAvailable = true;
5353
- } catch {
5354
- // sqlite-vec not loaded
5466
+ // Always report sqlite-vec availability (not just when similarity
5467
+ // requested); this connection loads it on first use.
5468
+ let similarAvailable = hasSqliteVec(db);
5469
+ if (!similarAvailable && (await loadSqliteVec(db))) {
5470
+ similarAvailable = hasSqliteVec(db);
5355
5471
  }
5356
5472
 
5357
5473
  interface ResolvedEdgeRow {
@@ -5362,12 +5478,14 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5362
5478
  link_type: "wiki" | "markdown";
5363
5479
  match_rank: number | null;
5364
5480
  match_count: number | null;
5481
+ reason?: string;
5365
5482
  }
5366
5483
 
5367
5484
  interface GraphLinkResolutionRow {
5368
5485
  source_id: number;
5369
5486
  source_docid: string;
5370
5487
  source_collection: string;
5488
+ source_rel_path: string;
5371
5489
  target_ref_norm: string;
5372
5490
  target_collection: string | null;
5373
5491
  link_type: "wiki" | "markdown";
@@ -5396,6 +5514,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5396
5514
  src.id as source_id,
5397
5515
  src.docid as source_docid,
5398
5516
  src.collection as source_collection,
5517
+ src.rel_path as source_rel_path,
5399
5518
  dl.target_ref_norm,
5400
5519
  dl.target_collection,
5401
5520
  dl.link_type
@@ -5413,6 +5532,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5413
5532
  targetRefNorm: row.target_ref_norm,
5414
5533
  targetCollection: row.target_collection ?? row.source_collection,
5415
5534
  linkType: row.link_type,
5535
+ source: linkSourceIdentity(row),
5416
5536
  }))
5417
5537
  );
5418
5538
  const resolvedEdgeRows: ResolvedEdgeRow[] = [];
@@ -5422,11 +5542,15 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5422
5542
  };
5423
5543
  for (const [index, row] of graphLinkRows.entries()) {
5424
5544
  const resolution = resolutions[index];
5425
- if (!resolution) {
5545
+ if (!isTraversableResolution(resolution)) {
5426
5546
  unresolvedByType[row.link_type] += 1;
5427
5547
  continue;
5428
5548
  }
5429
- const targetCollection = row.target_collection ?? row.source_collection;
5549
+ // Scope uses the resolved target identity, not the declared prefix.
5550
+ const targetCollection =
5551
+ resolution.targetCollection ??
5552
+ row.target_collection ??
5553
+ row.source_collection;
5430
5554
  if (collection && targetCollection !== collection) continue;
5431
5555
  resolvedEdgeRows.push({
5432
5556
  source_id: row.source_id,
@@ -5436,6 +5560,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5436
5560
  link_type: row.link_type,
5437
5561
  match_rank: resolution.matchRank,
5438
5562
  match_count: resolution.matchCount,
5563
+ reason: resolution.reason,
5439
5564
  });
5440
5565
  }
5441
5566
  resolvedEdgeRows.sort(
@@ -5617,7 +5742,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5617
5742
  const { confidence, audit } = classifyResolvedGraphEdge(
5618
5743
  row.link_type,
5619
5744
  row.match_rank,
5620
- row.match_count
5745
+ row.match_count,
5746
+ row.reason
5621
5747
  );
5622
5748
  const existing = edgeMap.get(key);
5623
5749
  if (existing) {
@@ -5650,110 +5776,45 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5650
5776
  );
5651
5777
  }
5652
5778
 
5653
- // Track if any similarity queries fail
5654
- let similarityFailures = 0;
5655
-
5656
- const mirrorByDocid = new Map<string, string>();
5657
- if (nodesForSimilarity.length > 0) {
5658
- const placeholders = nodesForSimilarity.map(() => "?").join(",");
5659
- const mirrorRows = db
5660
- .query<{ docid: string; mirror_hash: string }, string[]>(
5661
- `SELECT docid, mirror_hash
5662
- FROM documents
5663
- WHERE active = 1
5664
- AND docid IN (${placeholders})`
5665
- )
5666
- .all(...nodesForSimilarity);
5667
- for (const row of mirrorRows) {
5668
- if (row.mirror_hash) {
5669
- mirrorByDocid.set(row.docid, row.mirror_hash);
5670
- }
5671
- }
5672
- }
5673
- const allowedMirrorHashes = [...mirrorByDocid.values()];
5674
- if (allowedMirrorHashes.length === 0) {
5675
- warnings.push("Similarity unavailable: no embedded nodes in graph");
5676
- }
5677
- const allowedPlaceholders = allowedMirrorHashes
5678
- .map(() => "?")
5679
- .join(",");
5680
-
5681
- // Get kNN for each node
5682
- // Query content_vectors for embedded chunks, find similar
5683
- for (const docid of nodesForSimilarity) {
5684
- if (allowedMirrorHashes.length === 0) break;
5685
- const mirrorHash = mirrorByDocid.get(docid);
5686
- if (!mirrorHash) continue;
5687
-
5688
- // Find similar docs using vec_distance, aggregate by doc to get max score
5689
- interface SimilarRow {
5690
- target_docid: string;
5691
- score: number;
5692
- }
5693
-
5694
- // Use GROUP BY to get one best score per doc (avoids duplicate rows from multi-chunk docs)
5695
- const similarQuery = `
5696
- SELECT
5697
- d.docid as target_docid,
5698
- MAX(1 - vec_distance_cosine(v1.embedding, v2.embedding)) as score
5699
- FROM content_vectors v1
5700
- JOIN content_vectors v2 ON v2.model = v1.model
5701
- AND v2.mirror_hash != v1.mirror_hash
5702
- AND v2.seq = 0
5703
- JOIN documents d ON d.mirror_hash = v2.mirror_hash AND d.active = 1
5704
- WHERE v1.mirror_hash = ? AND v1.seq = 0
5705
- AND d.docid != ?
5706
- AND v2.mirror_hash IN (${allowedPlaceholders})
5707
- GROUP BY d.docid
5708
- HAVING score >= ?
5709
- ORDER BY score DESC
5710
- LIMIT ?
5711
- `;
5712
-
5713
- try {
5714
- const similarRows = db
5715
- .query<SimilarRow, (string | number)[]>(similarQuery)
5716
- .all(
5717
- mirrorHash,
5718
- docid,
5719
- ...allowedMirrorHashes,
5720
- threshold,
5721
- similarTopK
5722
- );
5723
-
5724
- for (const sim of similarRows) {
5725
- if (!nodeDocids.has(sim.target_docid)) continue;
5726
-
5727
- // Clamp score to [0, 1] for schema compliance
5728
- const clampedScore = Math.max(0, Math.min(1, sim.score));
5729
-
5779
+ const embedModel = options?.embedModel;
5780
+ if (embedModel) {
5781
+ const similarityEdges = storedSimilarityEdges(
5782
+ db,
5783
+ embedModel,
5784
+ nodesForSimilarity,
5785
+ threshold,
5786
+ similarTopK
5787
+ );
5788
+ if (similarityEdges === null) {
5789
+ warnings.push(
5790
+ "Similarity query failed; similarity edges are unavailable"
5791
+ );
5792
+ } else if (similarityEdges.embeddedNodes === 0) {
5793
+ warnings.push("Similarity unavailable: no embedded nodes in graph");
5794
+ } else {
5795
+ for (const edge of similarityEdges.edges) {
5730
5796
  // Canonicalize by lexicographic order (undirected edge)
5731
5797
  const [a, b] =
5732
- docid < sim.target_docid
5733
- ? [docid, sim.target_docid]
5734
- : [sim.target_docid, docid];
5798
+ edge.source < edge.target
5799
+ ? [edge.source, edge.target]
5800
+ : [edge.target, edge.source];
5735
5801
  const key = `${a}:${b}:similar`;
5736
5802
 
5737
5803
  // Keep max score
5738
5804
  const existing = edgeMap.get(key);
5739
- if (!existing || clampedScore > existing.weight) {
5805
+ if (!existing || edge.score > existing.weight) {
5740
5806
  edgeMap.set(key, {
5741
5807
  type: "similar",
5742
- weight: clampedScore,
5808
+ weight: edge.score,
5743
5809
  confidence: "similarity",
5744
- audit: { resolution: "similarity", score: clampedScore },
5810
+ audit: { resolution: "similarity", score: edge.score },
5745
5811
  });
5746
5812
  }
5747
5813
  }
5748
- } catch {
5749
- similarityFailures++;
5750
5814
  }
5751
- }
5752
-
5753
- // Report partial failures
5754
- if (similarityFailures > 0) {
5815
+ } else {
5755
5816
  warnings.push(
5756
- `Similarity query failed for ${similarityFailures} nodes; results may be incomplete`
5817
+ "Similarity unavailable: no embedding model configured"
5757
5818
  );
5758
5819
  }
5759
5820
  } else if (includeSimilar && !similarAvailable) {
@@ -5893,9 +5954,15 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5893
5954
  embedModel?: string;
5894
5955
  embedFingerprint?: string;
5895
5956
  chunking?: Partial<ChunkingParams>;
5957
+ configuredCollections?: readonly string[];
5896
5958
  }): Promise<StoreResult<IndexStatus>> {
5897
5959
  try {
5898
5960
  const db = this.ensureOpen();
5961
+ // JSON array of configured names, or null when the caller has no config
5962
+ // (every indexed collection is reported).
5963
+ const configuredJson = options?.configuredCollections
5964
+ ? JSON.stringify(options.configuredCollections)
5965
+ : null;
5899
5966
  const embedModel = options?.embedModel ?? null;
5900
5967
  const embedFingerprint =
5901
5968
  options?.embedFingerprint ??
@@ -5927,6 +5994,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5927
5994
  path: string;
5928
5995
  egress_policy: EgressPolicy;
5929
5996
  egress_policy_source: EgressPolicySource;
5997
+ workspace_root: string | null;
5998
+ workspace_source: LinkWorkspaceSource | null;
5930
5999
  total: number;
5931
6000
  active: number;
5932
6001
  errored: number;
@@ -5936,7 +6005,16 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5936
6005
  }
5937
6006
 
5938
6007
  const collectionStats = db
5939
- .query<CollectionStat, [string | null, string | null, string | null]>(
6008
+ .query<
6009
+ CollectionStat,
6010
+ [
6011
+ string | null,
6012
+ string | null,
6013
+ string | null,
6014
+ string | null,
6015
+ string | null,
6016
+ ]
6017
+ >(
5940
6018
  `
5941
6019
  WITH document_stats AS (
5942
6020
  SELECT
@@ -5980,6 +6058,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5980
6058
  c.path,
5981
6059
  c.egress_policy,
5982
6060
  c.egress_policy_source,
6061
+ c.workspace_root,
6062
+ c.workspace_source,
5983
6063
  COALESCE(ds.total, 0) AS total,
5984
6064
  COALESCE(ds.active, 0) AS active,
5985
6065
  COALESCE(ds.errored, 0) AS errored,
@@ -5989,29 +6069,51 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5989
6069
  FROM collections c
5990
6070
  LEFT JOIN document_stats ds ON ds.collection = c.name
5991
6071
  LEFT JOIN collection_chunks ch ON ch.collection = c.name
6072
+ WHERE ? IS NULL OR c.name IN (SELECT value FROM json_each(?))
5992
6073
  ORDER BY c.name
5993
6074
  `
5994
6075
  )
5995
- .all(embedModel, embedModel, embedFingerprint);
6076
+ .all(
6077
+ embedModel,
6078
+ embedModel,
6079
+ embedFingerprint,
6080
+ configuredJson,
6081
+ configuredJson
6082
+ );
5996
6083
 
5997
- // Get totals
6084
+ // Totals cover the configured collections only, so a collection removed
6085
+ // from config stops counting before the next update prunes its rows.
5998
6086
  const totalsRow = db
5999
- .query<{ total: number; active: number }, []>(
6087
+ .query<
6088
+ { total: number; active: number },
6089
+ [string | null, string | null]
6090
+ >(
6000
6091
  `
6001
6092
  SELECT
6002
6093
  COUNT(*) as total,
6003
6094
  SUM(CASE WHEN active = 1 THEN 1 ELSE 0 END) as active
6004
6095
  FROM documents
6096
+ WHERE ? IS NULL OR collection IN (SELECT value FROM json_each(?))
6005
6097
  `
6006
6098
  )
6007
- .get();
6099
+ .get(configuredJson, configuredJson);
6008
6100
 
6101
+ // Chunks of active documents only (deduplicated by canonical chunk);
6102
+ // content_chunks keeps rows of deleted documents until cleanup.
6009
6103
  const chunkCount =
6010
6104
  db
6011
- .query<{ count: number }, []>(
6012
- "SELECT COUNT(*) as count FROM content_chunks"
6105
+ .query<{ count: number }, [string | null, string | null]>(
6106
+ `
6107
+ SELECT COUNT(*) as count
6108
+ FROM content_chunks
6109
+ WHERE mirror_hash IN (
6110
+ SELECT mirror_hash FROM documents
6111
+ WHERE active = 1 AND mirror_hash IS NOT NULL
6112
+ AND (? IS NULL OR collection IN (SELECT value FROM json_each(?)))
6113
+ )
6114
+ `
6013
6115
  )
6014
- .get()?.count ?? 0;
6116
+ .get(configuredJson, configuredJson)?.count ?? 0;
6015
6117
 
6016
6118
  // Embedding backlog: chunks from active docs without vectors
6017
6119
  // Uses EXISTS to avoid duplicates when multiple docs share mirror_hash
@@ -6081,6 +6183,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
6081
6183
  path: s.path,
6082
6184
  egressPolicy: s.egress_policy,
6083
6185
  egressPolicySource: s.egress_policy_source,
6186
+ workspaceRoot: s.workspace_root,
6187
+ workspaceSource: s.workspace_source ?? "none",
6084
6188
  totalDocuments: s.total,
6085
6189
  activeDocuments: s.active,
6086
6190
  errorDocuments: s.errored,
@@ -6357,6 +6461,22 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
6357
6461
  }
6358
6462
  }
6359
6463
 
6464
+ /** Collection names as the config schema admits them (safe to inline). */
6465
+ const COLLECTION_IDENTIFIER = /^[a-z0-9][a-z0-9_-]{0,63}$/;
6466
+
6467
+ /**
6468
+ * Workspace columns on collection upsert. Nested vault prefixes are refreshed
6469
+ * by ingestion; they stay valid only while the collection's real root does.
6470
+ */
6471
+ const COLLECTION_WORKSPACE_UPSERT_SET = `real_path = excluded.real_path,
6472
+ workspace_root = excluded.workspace_root,
6473
+ workspace_source = excluded.workspace_source,
6474
+ workspace_nested = CASE
6475
+ WHEN collections.real_path IS excluded.real_path
6476
+ THEN collections.workspace_nested
6477
+ ELSE NULL
6478
+ END,`;
6479
+
6360
6480
  // ─────────────────────────────────────────────────────────────────────────────
6361
6481
  // DB Row Types (snake_case from SQLite)
6362
6482
  // ─────────────────────────────────────────────────────────────────────────────
@@ -6371,6 +6491,10 @@ interface DbCollectionRow {
6371
6491
  language_hint: string | null;
6372
6492
  egress_policy: EgressPolicy;
6373
6493
  egress_policy_source: EgressPolicySource;
6494
+ real_path?: string | null;
6495
+ workspace_root?: string | null;
6496
+ workspace_source?: LinkWorkspaceSource | null;
6497
+ workspace_nested?: string | null;
6374
6498
  synced_at: string;
6375
6499
  }
6376
6500
 
@@ -6462,6 +6586,18 @@ interface DbIngestErrorRow {
6462
6586
  // Row Mappers (snake_case -> camelCase)
6463
6587
  // ─────────────────────────────────────────────────────────────────────────────
6464
6588
 
6589
+ function parseWorkspaceNested(raw: string | null): string[] {
6590
+ if (!raw) return [];
6591
+ try {
6592
+ const parsed: unknown = JSON.parse(raw);
6593
+ return Array.isArray(parsed)
6594
+ ? parsed.filter((value): value is string => typeof value === "string")
6595
+ : [];
6596
+ } catch {
6597
+ return [];
6598
+ }
6599
+ }
6600
+
6465
6601
  function mapCollectionRow(row: DbCollectionRow): CollectionRow {
6466
6602
  return {
6467
6603
  name: row.name,
@@ -6473,6 +6609,10 @@ function mapCollectionRow(row: DbCollectionRow): CollectionRow {
6473
6609
  languageHint: row.language_hint,
6474
6610
  egressPolicy: row.egress_policy,
6475
6611
  egressPolicySource: row.egress_policy_source,
6612
+ realPath: row.real_path ?? null,
6613
+ workspaceRoot: row.workspace_root ?? null,
6614
+ workspaceSource: row.workspace_source ?? "none",
6615
+ workspaceNested: parseWorkspaceNested(row.workspace_nested ?? null),
6476
6616
  syncedAt: row.synced_at,
6477
6617
  };
6478
6618
  }