akm-cli 0.7.5 → 0.8.0-rc2

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 (152) hide show
  1. package/.github/CHANGELOG.md +1 -1
  2. package/dist/cli/parse-args.js +43 -0
  3. package/dist/cli.js +853 -479
  4. package/dist/commands/agent-dispatch.js +102 -0
  5. package/dist/commands/agent-support.js +62 -0
  6. package/dist/commands/config-cli.js +68 -84
  7. package/dist/commands/consolidate.js +823 -0
  8. package/dist/commands/distill-promotion-policy.js +658 -0
  9. package/dist/commands/distill.js +244 -52
  10. package/dist/commands/eval-cases.js +40 -0
  11. package/dist/commands/events.js +2 -23
  12. package/dist/commands/graph.js +222 -0
  13. package/dist/commands/health.js +376 -0
  14. package/dist/commands/help/help-accept.md +9 -0
  15. package/dist/commands/help/help-improve.md +53 -0
  16. package/dist/commands/help/help-proposals.md +15 -0
  17. package/dist/commands/help/help-propose.md +17 -0
  18. package/dist/commands/help/help-reject.md +8 -0
  19. package/dist/commands/history.js +3 -30
  20. package/dist/commands/improve.js +1170 -0
  21. package/dist/commands/info.js +2 -2
  22. package/dist/commands/init.js +2 -2
  23. package/dist/commands/install-audit.js +5 -1
  24. package/dist/commands/installed-stashes.js +118 -138
  25. package/dist/commands/knowledge.js +133 -0
  26. package/dist/commands/lint/agent-linter.js +46 -0
  27. package/dist/commands/lint/base-linter.js +285 -0
  28. package/dist/commands/lint/command-linter.js +46 -0
  29. package/dist/commands/lint/default-linter.js +13 -0
  30. package/dist/commands/lint/index.js +107 -0
  31. package/dist/commands/lint/knowledge-linter.js +13 -0
  32. package/dist/commands/lint/memory-linter.js +58 -0
  33. package/dist/commands/lint/registry.js +33 -0
  34. package/dist/commands/lint/skill-linter.js +42 -0
  35. package/dist/commands/lint/task-linter.js +47 -0
  36. package/dist/commands/lint/types.js +1 -0
  37. package/dist/commands/lint/workflow-linter.js +53 -0
  38. package/dist/commands/lint.js +1 -0
  39. package/dist/commands/proposal.js +8 -7
  40. package/dist/commands/propose.js +78 -28
  41. package/dist/commands/reflect.js +143 -35
  42. package/dist/commands/registry-search.js +2 -2
  43. package/dist/commands/remember.js +54 -0
  44. package/dist/commands/schema-repair.js +130 -0
  45. package/dist/commands/search.js +21 -5
  46. package/dist/commands/show.js +121 -17
  47. package/dist/commands/source-add.js +10 -10
  48. package/dist/commands/source-manage.js +11 -19
  49. package/dist/commands/tasks.js +385 -0
  50. package/dist/commands/url-checker.js +39 -0
  51. package/dist/commands/vault.js +8 -26
  52. package/dist/core/action-contributors.js +25 -0
  53. package/dist/core/asset-ref.js +4 -0
  54. package/dist/core/asset-registry.js +4 -16
  55. package/dist/core/asset-spec.js +10 -0
  56. package/dist/core/common.js +94 -0
  57. package/dist/core/concurrent.js +22 -0
  58. package/dist/core/config.js +222 -128
  59. package/dist/core/events.js +73 -126
  60. package/dist/core/frontmatter.js +3 -1
  61. package/dist/core/markdown.js +17 -0
  62. package/dist/core/memory-improve.js +678 -0
  63. package/dist/core/parse.js +155 -0
  64. package/dist/core/paths.js +101 -3
  65. package/dist/core/proposal-validators.js +61 -0
  66. package/dist/core/proposals.js +49 -38
  67. package/dist/core/state-db.js +775 -0
  68. package/dist/core/time.js +51 -0
  69. package/dist/core/warn.js +59 -1
  70. package/dist/indexer/db-search.js +52 -238
  71. package/dist/indexer/db.js +378 -1
  72. package/dist/indexer/ensure-index.js +61 -0
  73. package/dist/indexer/graph-boost.js +247 -94
  74. package/dist/indexer/graph-db.js +201 -0
  75. package/dist/indexer/graph-dedup.js +99 -0
  76. package/dist/indexer/graph-extraction.js +409 -76
  77. package/dist/indexer/index-context.js +10 -0
  78. package/dist/indexer/indexer.js +442 -290
  79. package/dist/indexer/llm-cache.js +47 -0
  80. package/dist/indexer/match-contributors.js +141 -0
  81. package/dist/indexer/matchers.js +24 -190
  82. package/dist/indexer/memory-inference.js +63 -29
  83. package/dist/indexer/metadata-contributors.js +26 -0
  84. package/dist/indexer/metadata.js +194 -175
  85. package/dist/indexer/path-resolver.js +89 -0
  86. package/dist/indexer/ranking-contributors.js +204 -0
  87. package/dist/indexer/ranking.js +74 -0
  88. package/dist/indexer/search-hit-enrichers.js +22 -0
  89. package/dist/indexer/search-source.js +24 -9
  90. package/dist/indexer/semantic-status.js +2 -16
  91. package/dist/indexer/walker.js +25 -0
  92. package/dist/integrations/agent/config.js +175 -3
  93. package/dist/integrations/agent/index.js +3 -1
  94. package/dist/integrations/agent/pipeline.js +39 -0
  95. package/dist/integrations/agent/profiles.js +67 -5
  96. package/dist/integrations/agent/prompts.js +77 -72
  97. package/dist/integrations/agent/runners.js +31 -0
  98. package/dist/integrations/agent/sdk-runner.js +120 -0
  99. package/dist/integrations/agent/spawn.js +71 -16
  100. package/dist/integrations/lockfile.js +10 -18
  101. package/dist/integrations/session-logs/index.js +65 -0
  102. package/dist/integrations/session-logs/providers/claude-code.js +56 -0
  103. package/dist/integrations/session-logs/providers/opencode.js +52 -0
  104. package/dist/integrations/session-logs/types.js +1 -0
  105. package/dist/llm/call-ai.js +74 -0
  106. package/dist/llm/client.js +61 -122
  107. package/dist/llm/feature-gate.js +27 -16
  108. package/dist/llm/graph-extract.js +297 -62
  109. package/dist/llm/memory-infer.js +49 -71
  110. package/dist/llm/metadata-enhance.js +39 -22
  111. package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
  112. package/dist/output/cli-hints-full.md +277 -0
  113. package/dist/output/cli-hints-short.md +65 -0
  114. package/dist/output/cli-hints.js +2 -318
  115. package/dist/output/renderers.js +190 -123
  116. package/dist/output/shapes.js +33 -0
  117. package/dist/output/text.js +239 -2
  118. package/dist/registry/providers/skills-sh.js +61 -49
  119. package/dist/registry/providers/static-index.js +44 -48
  120. package/dist/setup/setup.js +510 -11
  121. package/dist/sources/provider-factory.js +2 -1
  122. package/dist/sources/providers/git.js +2 -2
  123. package/dist/sources/website-ingest.js +4 -0
  124. package/dist/tasks/backends/cron.js +200 -0
  125. package/dist/tasks/backends/exec-utils.js +25 -0
  126. package/dist/tasks/backends/index.js +32 -0
  127. package/dist/tasks/backends/launchd-template.xml +19 -0
  128. package/dist/tasks/backends/launchd.js +184 -0
  129. package/dist/tasks/backends/schtasks-template.xml +29 -0
  130. package/dist/tasks/backends/schtasks.js +212 -0
  131. package/dist/tasks/parser.js +198 -0
  132. package/dist/tasks/resolveAkmBin.js +84 -0
  133. package/dist/tasks/runner.js +432 -0
  134. package/dist/tasks/schedule.js +208 -0
  135. package/dist/tasks/schema.js +13 -0
  136. package/dist/tasks/validator.js +59 -0
  137. package/dist/wiki/index-template.md +12 -0
  138. package/dist/wiki/ingest-workflow-template.md +54 -0
  139. package/dist/wiki/log-template.md +8 -0
  140. package/dist/wiki/schema-template.md +61 -0
  141. package/dist/wiki/wiki-templates.js +12 -0
  142. package/dist/wiki/wiki.js +10 -61
  143. package/dist/workflows/authoring.js +5 -25
  144. package/dist/workflows/renderer.js +8 -3
  145. package/dist/workflows/runs.js +59 -91
  146. package/dist/workflows/validator.js +1 -1
  147. package/dist/workflows/workflow-template.md +24 -0
  148. package/docs/README.md +3 -0
  149. package/docs/migration/release-notes/0.7.0.md +1 -1
  150. package/docs/migration/release-notes/0.8.0.md +43 -0
  151. package/package.json +3 -2
  152. package/dist/templates/wiki-templates.js +0 -100
@@ -2,7 +2,7 @@
2
2
  * Search-time graph-boost integration for the `akm index` graph pass (#207).
3
3
  *
4
4
  * This module is the consumer half of the graph-extraction pass. It loads
5
- * the persisted `graph.json` (when present) and exposes a single helper,
5
+ * the persisted graph snapshot from SQLite and exposes a single helper,
6
6
  * {@link computeGraphBoost}, that the existing FTS5+boosts loop in
7
7
  * `src/indexer/db-search.ts` calls per-entry to obtain an additive boost
8
8
  * value.
@@ -13,12 +13,26 @@
13
13
  * - There is no second `SearchHit` scorer. `searchDatabase` continues to
14
14
  * own ranking; this module just answers "what additive boost does the
15
15
  * graph contribute for this (query, entry) pair?".
16
- * - Missing/stale/unparseable `graph.json` → boost is `0`. The pipeline
16
+ * - Missing graph rows → boost is `0`. The pipeline
17
17
  * degrades gracefully to its non-graph behaviour, exactly as today.
18
18
  */
19
- import fs from "node:fs";
20
- import { warn } from "../core/warn";
21
- import { GRAPH_FILE_SCHEMA_VERSION, getGraphFilePath } from "./graph-extraction";
19
+ import { loadStoredGraphMeta, loadStoredGraphSnapshot } from "./graph-db";
20
+ function normalizeGraphName(value) {
21
+ return value.trim().toLowerCase();
22
+ }
23
+ let cachedParsedGraph;
24
+ function resolveGraphBoostWeights(config) {
25
+ const configured = config?.search?.graphBoost;
26
+ return {
27
+ directBoostPerEntity: configured?.directBoostPerEntity ?? GRAPH_DIRECT_BOOST_PER_ENTITY,
28
+ directBoostCap: configured?.directBoostCap ?? GRAPH_DIRECT_BOOST_CAP,
29
+ hopBoostPerEntity: configured?.hopBoostPerEntity ?? GRAPH_HOP_BOOST_PER_ENTITY,
30
+ hopBoostCap: configured?.hopBoostCap ?? GRAPH_HOP_BOOST_CAP,
31
+ maxHops: Math.min(Math.max(configured?.maxHops ?? GRAPH_MAX_HOPS, 1), GRAPH_MAX_HOPS_HARD_CAP),
32
+ confidenceMode: configured?.confidenceMode ?? GRAPH_CONFIDENCE_MODE,
33
+ confidenceWeight: configured?.confidenceWeight ?? GRAPH_CONFIDENCE_WEIGHT,
34
+ };
35
+ }
22
36
  /**
23
37
  * Per-entry weights, exposed as constants so tests can read them and so the
24
38
  * single-source-of-truth for "how much does the graph contribute" is here
@@ -29,20 +43,47 @@ export const GRAPH_DIRECT_BOOST_PER_ENTITY = 0.25;
29
43
  export const GRAPH_DIRECT_BOOST_CAP = 0.75;
30
44
  export const GRAPH_HOP_BOOST_PER_ENTITY = 0.1;
31
45
  export const GRAPH_HOP_BOOST_CAP = 0.3;
46
+ export const GRAPH_MAX_HOPS = 1;
47
+ export const GRAPH_CONFIDENCE_MODE = "blend";
48
+ export const GRAPH_CONFIDENCE_WEIGHT = 0.2;
49
+ const GRAPH_MAX_HOPS_HARD_CAP = 3;
50
+ function normalizeConfidence(raw) {
51
+ if (typeof raw !== "number" || !Number.isFinite(raw))
52
+ return undefined;
53
+ return Math.max(0, Math.min(1, raw));
54
+ }
55
+ function combineConfidence(...parts) {
56
+ let out;
57
+ for (const part of parts) {
58
+ const value = normalizeConfidence(part);
59
+ if (value === undefined)
60
+ continue;
61
+ out = out === undefined ? value : out * value;
62
+ }
63
+ return out;
64
+ }
65
+ function toConfidenceMultiplier(rawConfidence, weights) {
66
+ if (weights.confidenceMode === "off")
67
+ return 1;
68
+ const confidence = normalizeConfidence(rawConfidence) ?? 1;
69
+ if (weights.confidenceMode === "multiply")
70
+ return confidence;
71
+ const blendWeight = Math.max(0, Math.min(1, weights.confidenceWeight));
72
+ return 1 - blendWeight + blendWeight * confidence;
73
+ }
32
74
  /**
33
75
  * Load the graph file for a stash root and pre-compute everything that's
34
76
  * shared across all entries scored for one query. Returns `null` when:
35
- * - `graph.json` does not exist.
36
- * - The file fails to parse.
37
- * - The schema version doesn't match (treated like "missing" so an old
38
- * index keeps working until the next `akm index --full`).
77
+ * - No graph snapshot exists in SQLite.
39
78
  * - The query produces no token-level entity matches (no boost is
40
79
  * possible, so we skip the per-entry overhead entirely).
41
80
  */
42
- export function loadGraphBoostContext(stashRoot, query) {
43
- const graph = readGraphFile(stashRoot);
44
- if (!graph)
81
+ export function loadGraphBoostContext(stashRoot, query, config, db) {
82
+ const stashRoots = Array.isArray(stashRoot) ? stashRoot : [stashRoot];
83
+ const parsed = readParsedGraphContext(stashRoots, db);
84
+ if (!parsed)
45
85
  return null;
86
+ const weights = resolveGraphBoostWeights(config);
46
87
  const queryTokens = query
47
88
  .toLowerCase()
48
89
  .split(/[\s\-_/]+/)
@@ -53,20 +94,21 @@ export function loadGraphBoostContext(stashRoot, query) {
53
94
  // is small (capped per-asset at extract time) and lets the per-entry
54
95
  // path do a single set membership test.
55
96
  const allEntities = new Set();
56
- const nodesByPath = new Map();
57
- for (const node of graph.files) {
58
- nodesByPath.set(node.path, node);
97
+ for (const node of parsed.graph.files) {
59
98
  for (const entity of node.entities)
60
99
  allEntities.add(entity);
61
100
  }
62
101
  // An entity matches the query when any of its sub-tokens equals or
63
- // contains a query token. Cheap and forgiving exact substring match is
64
- // sufficient because both sides are already lower-cased at extract time.
102
+ // contains a query token. Matching is case-insensitive; the graph keeps
103
+ // canonical display strings and we normalize only for comparisons here.
65
104
  const matchedEntities = new Set();
66
105
  for (const entity of allEntities) {
67
- const entityTokens = entity.split(/[\s\-_/]+/).filter(Boolean);
106
+ const normalizedEntity = normalizeGraphName(entity);
107
+ const entityTokens = normalizedEntity.split(/[\s\-_/]+/).filter(Boolean);
68
108
  for (const qt of queryTokens) {
69
- if (entity === qt || entity.includes(qt) || entityTokens.some((et) => et === qt)) {
109
+ if (normalizedEntity === qt ||
110
+ normalizedEntity.includes(qt) ||
111
+ entityTokens.some((et) => et === qt || et.includes(qt))) {
70
112
  matchedEntities.add(entity);
71
113
  break;
72
114
  }
@@ -74,20 +116,47 @@ export function loadGraphBoostContext(stashRoot, query) {
74
116
  }
75
117
  if (matchedEntities.size === 0)
76
118
  return null;
77
- // One-hop neighbours: any entity that appears on the other end of a
78
- // relation whose other endpoint is in matchedEntities.
79
- const oneHopEntities = new Set();
80
- for (const node of graph.files) {
81
- for (const rel of node.relations) {
82
- if (matchedEntities.has(rel.from) && !matchedEntities.has(rel.to)) {
83
- oneHopEntities.add(rel.to);
84
- }
85
- else if (matchedEntities.has(rel.to) && !matchedEntities.has(rel.from)) {
86
- oneHopEntities.add(rel.from);
119
+ const connectedEntities = new Set();
120
+ const connectedConfidence = new Map();
121
+ const visited = new Set();
122
+ let frontier = new Map();
123
+ for (const entity of matchedEntities) {
124
+ const seed = parsed.entityConfidence.get(entity) ?? 1;
125
+ frontier.set(entity, seed);
126
+ visited.add(entity);
127
+ }
128
+ for (let hop = 1; hop <= weights.maxHops; hop += 1) {
129
+ const next = new Map();
130
+ for (const [entity, pathConfidence] of frontier.entries()) {
131
+ const neighbors = parsed.adjacency.get(entity);
132
+ if (!neighbors)
133
+ continue;
134
+ for (const [neighbor, edgeConfidence] of neighbors.entries()) {
135
+ const neighborPathConfidence = Math.max(0, Math.min(1, pathConfidence * edgeConfidence));
136
+ const currentBest = connectedConfidence.get(neighbor) ?? 0;
137
+ if (neighborPathConfidence > currentBest)
138
+ connectedConfidence.set(neighbor, neighborPathConfidence);
139
+ if (visited.has(neighbor))
140
+ continue;
141
+ visited.add(neighbor);
142
+ next.set(neighbor, Math.max(next.get(neighbor) ?? 0, neighborPathConfidence));
143
+ connectedEntities.add(neighbor);
87
144
  }
88
145
  }
146
+ if (next.size === 0)
147
+ break;
148
+ frontier = next;
89
149
  }
90
- return { nodesByPath, matchedEntities, oneHopEntities };
150
+ return {
151
+ graph: parsed.graph,
152
+ nodesByPath: parsed.nodesByPath,
153
+ matchedEntities,
154
+ connectedEntities,
155
+ connectedConfidence,
156
+ entityConfidence: parsed.entityConfidence,
157
+ adjacency: parsed.adjacency,
158
+ weights,
159
+ };
91
160
  }
92
161
  /**
93
162
  * Compute the graph-boost contribution for a single scored entry.
@@ -100,80 +169,164 @@ export function computeGraphBoost(context, filePath) {
100
169
  const node = context.nodesByPath.get(filePath);
101
170
  if (!node)
102
171
  return 0;
103
- let directHits = 0;
104
- let hopHits = 0;
172
+ let directBoostRaw = 0;
173
+ let hopBoostRaw = 0;
105
174
  for (const entity of node.entities) {
106
- if (context.matchedEntities.has(entity))
107
- directHits += 1;
108
- else if (context.oneHopEntities.has(entity))
109
- hopHits += 1;
175
+ if (context.matchedEntities.has(entity)) {
176
+ const directConfidence = combineConfidence(node.confidence, context.entityConfidence.get(entity));
177
+ directBoostRaw +=
178
+ context.weights.directBoostPerEntity * toConfidenceMultiplier(directConfidence, context.weights);
179
+ }
180
+ else if (context.connectedEntities.has(entity)) {
181
+ const hopConfidence = combineConfidence(node.confidence, context.entityConfidence.get(entity), context.connectedConfidence.get(entity));
182
+ hopBoostRaw += context.weights.hopBoostPerEntity * toConfidenceMultiplier(hopConfidence, context.weights);
183
+ }
110
184
  }
111
- const directBoost = Math.min(GRAPH_DIRECT_BOOST_CAP, directHits * GRAPH_DIRECT_BOOST_PER_ENTITY);
112
- const hopBoost = Math.min(GRAPH_HOP_BOOST_CAP, hopHits * GRAPH_HOP_BOOST_PER_ENTITY);
185
+ const directBoost = Math.min(context.weights.directBoostCap, directBoostRaw);
186
+ const hopBoost = Math.min(context.weights.hopBoostCap, hopBoostRaw);
113
187
  return directBoost + hopBoost;
114
188
  }
115
- /**
116
- * Lightweight reader — extracted so the boost loader and tests share one
117
- * code path. Tolerant of missing files (returns null) but logs a warning
118
- * when an existing file fails to parse so corruption is visible.
119
- */
120
- function readGraphFile(stashRoot) {
121
- const target = getGraphFilePath(stashRoot);
122
- let raw;
123
- try {
124
- raw = fs.readFileSync(target, "utf8");
125
- }
126
- catch {
127
- // Missing → no boost. Not an error: the user simply hasn't enabled
128
- // graph extraction yet, or the pass hasn't run.
189
+ export function collectGraphRelatedHit(context, filePath) {
190
+ const node = context.nodesByPath.get(filePath);
191
+ if (!node)
129
192
  return null;
193
+ const entities = [];
194
+ for (const entity of node.entities) {
195
+ if (context.matchedEntities.has(entity)) {
196
+ entities.push({
197
+ name: entity,
198
+ kind: "matched",
199
+ ...(context.entityConfidence.get(entity) !== undefined
200
+ ? { confidence: context.entityConfidence.get(entity) }
201
+ : {}),
202
+ });
203
+ continue;
204
+ }
205
+ if (context.connectedEntities.has(entity)) {
206
+ entities.push({
207
+ name: entity,
208
+ kind: "connected",
209
+ ...(context.connectedConfidence.get(entity) !== undefined
210
+ ? { confidence: context.connectedConfidence.get(entity) }
211
+ : {}),
212
+ });
213
+ }
130
214
  }
131
- let parsed;
132
- try {
133
- parsed = JSON.parse(raw);
134
- }
135
- catch (err) {
136
- warn(`graph boost: failed to parse ${target}: ${err instanceof Error ? err.message : String(err)}`);
215
+ if (entities.length === 0)
137
216
  return null;
217
+ const relatedNames = new Set(entities.map((entity) => entity.name));
218
+ const relations = node.relations
219
+ .filter((relation) => relatedNames.has(relation.from) || relatedNames.has(relation.to))
220
+ .map((relation) => ({
221
+ from: relation.from,
222
+ to: relation.to,
223
+ ...(relation.type ? { type: relation.type } : {}),
224
+ ...(normalizeConfidence(relation.confidence) !== undefined
225
+ ? { confidence: normalizeConfidence(relation.confidence) }
226
+ : {}),
227
+ }));
228
+ return {
229
+ path: filePath,
230
+ type: node.type,
231
+ entities: entities.sort((a, b) => a.name.localeCompare(b.name)),
232
+ relations,
233
+ };
234
+ }
235
+ export function listRelatedPathsForFile(stashRoot, filePath, limit = 5, db) {
236
+ const parsed = readParsedGraphContext([stashRoot], db);
237
+ if (!parsed)
238
+ return [];
239
+ const node = parsed.nodesByPath.get(filePath);
240
+ if (!node)
241
+ return [];
242
+ const entitySet = new Set(node.entities.map(normalizeGraphName));
243
+ const results = [];
244
+ for (const candidate of parsed.graph.files) {
245
+ if (candidate.path === filePath)
246
+ continue;
247
+ const sharedEntities = candidate.entities.filter((entity) => entitySet.has(normalizeGraphName(entity)));
248
+ if (sharedEntities.length === 0)
249
+ continue;
250
+ const relationCount = candidate.relations.filter((relation) => entitySet.has(normalizeGraphName(relation.from)) || entitySet.has(normalizeGraphName(relation.to))).length;
251
+ results.push({
252
+ path: candidate.path,
253
+ type: candidate.type,
254
+ sharedEntities: [...new Set(sharedEntities)].sort((a, b) => a.localeCompare(b)),
255
+ relationCount,
256
+ });
138
257
  }
139
- if (!isGraphFile(parsed) || parsed.schemaVersion !== GRAPH_FILE_SCHEMA_VERSION) {
258
+ results.sort((a, b) => b.sharedEntities.length - a.sharedEntities.length ||
259
+ b.relationCount - a.relationCount ||
260
+ a.path.localeCompare(b.path));
261
+ return results.slice(0, Math.max(1, limit));
262
+ }
263
+ /**
264
+ * Load and normalize graph data from SQLite once, then reuse it across all
265
+ * per-entry boost lookups in the current query.
266
+ */
267
+ function readParsedGraphContext(stashRoots, db) {
268
+ const sortedRoots = [...new Set(stashRoots)].sort((a, b) => a.localeCompare(b));
269
+ if (sortedRoots.length === 0)
140
270
  return null;
271
+ const metas = sortedRoots
272
+ .map((stashRoot) => loadStoredGraphMeta(stashRoot, db))
273
+ .filter((meta) => meta !== null);
274
+ if (metas.length === 0)
275
+ return null;
276
+ const cacheKey = metas.map((meta) => `${meta.stashPath}\u0000${meta.generatedAt}`).join("\u0001");
277
+ if (cachedParsedGraph && cachedParsedGraph.cacheKey === cacheKey)
278
+ return cachedParsedGraph.context;
279
+ const snapshots = metas
280
+ .map((meta) => loadStoredGraphSnapshot(meta.stashPath, db))
281
+ .filter((snapshot) => snapshot !== null);
282
+ if (snapshots.length === 0)
283
+ return null;
284
+ const graph = {
285
+ schemaVersion: Math.max(...snapshots.map((snapshot) => snapshot.schemaVersion)),
286
+ generatedAt: snapshots
287
+ .map((snapshot) => snapshot.generatedAt)
288
+ .sort()
289
+ .at(-1) ?? new Date(0).toISOString(),
290
+ stashRoot: snapshots[0]?.stashPath ?? "",
291
+ files: snapshots.flatMap((snapshot) => snapshot.files),
292
+ entities: [...new Set(snapshots.flatMap((snapshot) => snapshot.entities))],
293
+ relations: snapshots.flatMap((snapshot) => snapshot.relations),
294
+ };
295
+ const nodesByPath = new Map();
296
+ const entityConfidence = new Map();
297
+ const adjacency = new Map();
298
+ function setBestEntityConfidence(entity, confidence) {
299
+ const normalized = normalizeConfidence(confidence);
300
+ if (normalized === undefined)
301
+ return;
302
+ const current = entityConfidence.get(entity);
303
+ if (current === undefined || normalized > current)
304
+ entityConfidence.set(entity, normalized);
141
305
  }
142
- return parsed;
143
- }
144
- function isGraphFile(value) {
145
- if (typeof value !== "object" || value === null)
146
- return false;
147
- const obj = value;
148
- if (typeof obj.schemaVersion !== "number")
149
- return false;
150
- if (typeof obj.generatedAt !== "string")
151
- return false;
152
- if (typeof obj.stashRoot !== "string")
153
- return false;
154
- if (!Array.isArray(obj.files))
155
- return false;
156
- for (const f of obj.files) {
157
- if (typeof f !== "object" || f === null)
158
- return false;
159
- const node = f;
160
- if (typeof node.path !== "string")
161
- return false;
162
- if (typeof node.type !== "string")
163
- return false;
164
- if (!Array.isArray(node.entities) || !node.entities.every((e) => typeof e === "string"))
165
- return false;
166
- if (!Array.isArray(node.relations))
167
- return false;
168
- for (const r of node.relations) {
169
- if (typeof r !== "object" || r === null)
170
- return false;
171
- const rel = r;
172
- if (typeof rel.from !== "string" || typeof rel.to !== "string")
173
- return false;
174
- if (rel.type !== undefined && typeof rel.type !== "string")
175
- return false;
306
+ function setBestEdgeConfidence(from, to, confidence) {
307
+ const normalized = normalizeConfidence(confidence);
308
+ if (!adjacency.has(from))
309
+ adjacency.set(from, new Map());
310
+ const neighbors = adjacency.get(from);
311
+ if (!neighbors)
312
+ return;
313
+ const current = neighbors.get(to);
314
+ const next = normalized ?? 1;
315
+ if (current === undefined || next > current)
316
+ neighbors.set(to, next);
317
+ }
318
+ for (const node of graph.files) {
319
+ nodesByPath.set(node.path, node);
320
+ for (const entity of node.entities) {
321
+ setBestEntityConfidence(entity, node.confidence);
322
+ }
323
+ for (const rel of node.relations) {
324
+ const edgeConfidence = combineConfidence(node.confidence, rel.confidence);
325
+ setBestEdgeConfidence(rel.from, rel.to, edgeConfidence);
326
+ setBestEdgeConfidence(rel.to, rel.from, edgeConfidence);
176
327
  }
177
328
  }
178
- return true;
329
+ const context = { graph, nodesByPath, entityConfidence, adjacency };
330
+ cachedParsedGraph = { cacheKey, context };
331
+ return context;
179
332
  }
@@ -0,0 +1,201 @@
1
+ import fs from "node:fs";
2
+ import { getDbPath } from "../core/paths";
3
+ import { closeDatabase, openExistingDatabase } from "./db";
4
+ function withReadableGraphDb(db, fn) {
5
+ if (db)
6
+ return fn(db);
7
+ const dbPath = getDbPath();
8
+ if (!fs.existsSync(dbPath))
9
+ throw new Error("GRAPH_DB_MISSING");
10
+ const opened = openExistingDatabase(dbPath);
11
+ try {
12
+ return fn(opened);
13
+ }
14
+ finally {
15
+ closeDatabase(opened);
16
+ }
17
+ }
18
+ function uniqueSorted(values) {
19
+ return [...new Set(values)].sort((a, b) => a.localeCompare(b));
20
+ }
21
+ export function replaceStoredGraph(db, graph) {
22
+ const upsertMeta = db.prepare(`INSERT INTO graph_meta (
23
+ stash_root,
24
+ schema_version,
25
+ generated_at,
26
+ considered_files,
27
+ extracted_files,
28
+ entity_count,
29
+ relation_count,
30
+ extraction_coverage,
31
+ density
32
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
33
+ ON CONFLICT(stash_root) DO UPDATE SET
34
+ schema_version = excluded.schema_version,
35
+ generated_at = excluded.generated_at,
36
+ considered_files = excluded.considered_files,
37
+ extracted_files = excluded.extracted_files,
38
+ entity_count = excluded.entity_count,
39
+ relation_count = excluded.relation_count,
40
+ extraction_coverage = excluded.extraction_coverage,
41
+ density = excluded.density`);
42
+ const deleteRelations = db.prepare("DELETE FROM graph_file_relations WHERE stash_root = ?");
43
+ const deleteEntities = db.prepare("DELETE FROM graph_file_entities WHERE stash_root = ?");
44
+ const deleteFiles = db.prepare("DELETE FROM graph_files WHERE stash_root = ?");
45
+ const insertFile = db.prepare(`INSERT INTO graph_files (stash_root, file_path, file_order, file_type, body_hash, confidence)
46
+ VALUES (?, ?, ?, ?, ?, ?)`);
47
+ const insertEntity = db.prepare(`INSERT INTO graph_file_entities (stash_root, file_path, entity_order, entity)
48
+ VALUES (?, ?, ?, ?)`);
49
+ const insertRelation = db.prepare(`INSERT INTO graph_file_relations (
50
+ stash_root,
51
+ file_path,
52
+ relation_order,
53
+ from_entity,
54
+ to_entity,
55
+ relation_type,
56
+ confidence
57
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`);
58
+ const quality = graph.quality;
59
+ db.transaction(() => {
60
+ upsertMeta.run(graph.stashRoot, graph.schemaVersion, graph.generatedAt, quality?.consideredFiles ?? graph.files.length, quality?.extractedFiles ?? graph.files.length, quality?.entityCount ?? graph.entities?.length ?? 0, quality?.relationCount ?? graph.relations?.length ?? 0, quality?.extractionCoverage ?? 0, quality?.density ?? 0);
61
+ deleteRelations.run(graph.stashRoot);
62
+ deleteEntities.run(graph.stashRoot);
63
+ deleteFiles.run(graph.stashRoot);
64
+ for (const [fileOrder, node] of graph.files.entries()) {
65
+ insertFile.run(graph.stashRoot, node.path, fileOrder, node.type, node.bodyHash ?? null, node.confidence ?? null);
66
+ for (const [entityOrder, entity] of node.entities.entries()) {
67
+ insertEntity.run(graph.stashRoot, node.path, entityOrder, entity);
68
+ }
69
+ for (const [relationOrder, relation] of node.relations.entries()) {
70
+ insertRelation.run(graph.stashRoot, node.path, relationOrder, relation.from, relation.to, relation.type ?? null, relation.confidence ?? null);
71
+ }
72
+ }
73
+ })();
74
+ }
75
+ export function deleteStoredGraph(db, stashPath) {
76
+ db.transaction(() => {
77
+ db.prepare("DELETE FROM graph_file_relations WHERE stash_root = ?").run(stashPath);
78
+ db.prepare("DELETE FROM graph_file_entities WHERE stash_root = ?").run(stashPath);
79
+ db.prepare("DELETE FROM graph_files WHERE stash_root = ?").run(stashPath);
80
+ db.prepare("DELETE FROM graph_meta WHERE stash_root = ?").run(stashPath);
81
+ })();
82
+ }
83
+ export function loadStoredGraphMeta(stashPath, db) {
84
+ try {
85
+ return withReadableGraphDb(db, (readDb) => {
86
+ try {
87
+ const row = readDb
88
+ .prepare(`SELECT
89
+ stash_root,
90
+ schema_version,
91
+ generated_at,
92
+ considered_files,
93
+ extracted_files,
94
+ entity_count,
95
+ relation_count,
96
+ extraction_coverage,
97
+ density
98
+ FROM graph_meta
99
+ WHERE stash_root = ?`)
100
+ .get(stashPath);
101
+ if (!row)
102
+ return null;
103
+ return {
104
+ stashPath: row.stash_root,
105
+ graphPath: getDbPath(),
106
+ schemaVersion: row.schema_version,
107
+ generatedAt: row.generated_at,
108
+ quality: {
109
+ consideredFiles: row.considered_files,
110
+ extractedFiles: row.extracted_files,
111
+ entityCount: row.entity_count,
112
+ relationCount: row.relation_count,
113
+ extractionCoverage: row.extraction_coverage,
114
+ density: row.density,
115
+ },
116
+ };
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ });
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ export function loadStoredGraphSnapshot(stashPath, db) {
128
+ try {
129
+ return withReadableGraphDb(db, (readDb) => {
130
+ const meta = loadStoredGraphMeta(stashPath, readDb);
131
+ if (!meta)
132
+ return null;
133
+ try {
134
+ const fileRows = readDb
135
+ .prepare(`SELECT file_path, file_type, body_hash, confidence
136
+ FROM graph_files
137
+ WHERE stash_root = ?
138
+ ORDER BY file_order`)
139
+ .all(stashPath);
140
+ const entityRows = readDb
141
+ .prepare(`SELECT file_path, entity
142
+ FROM graph_file_entities
143
+ WHERE stash_root = ?
144
+ ORDER BY file_path, entity_order`)
145
+ .all(stashPath);
146
+ const relationRows = readDb
147
+ .prepare(`SELECT file_path, from_entity, to_entity, relation_type, confidence
148
+ FROM graph_file_relations
149
+ WHERE stash_root = ?
150
+ ORDER BY file_path, relation_order`)
151
+ .all(stashPath);
152
+ const entitiesByPath = new Map();
153
+ for (const row of entityRows) {
154
+ const bucket = entitiesByPath.get(row.file_path);
155
+ if (bucket)
156
+ bucket.push(row.entity);
157
+ else
158
+ entitiesByPath.set(row.file_path, [row.entity]);
159
+ }
160
+ const relationsByPath = new Map();
161
+ for (const row of relationRows) {
162
+ const relation = {
163
+ from: row.from_entity,
164
+ to: row.to_entity,
165
+ ...(row.relation_type ? { type: row.relation_type } : {}),
166
+ ...(typeof row.confidence === "number" ? { confidence: row.confidence } : {}),
167
+ };
168
+ const bucket = relationsByPath.get(row.file_path);
169
+ if (bucket)
170
+ bucket.push(relation);
171
+ else
172
+ relationsByPath.set(row.file_path, [relation]);
173
+ }
174
+ const files = fileRows.map((row) => ({
175
+ path: row.file_path,
176
+ type: row.file_type,
177
+ ...(row.body_hash ? { bodyHash: row.body_hash } : {}),
178
+ entities: entitiesByPath.get(row.file_path) ?? [],
179
+ relations: relationsByPath.get(row.file_path) ?? [],
180
+ ...(typeof row.confidence === "number" ? { confidence: row.confidence } : {}),
181
+ }));
182
+ return {
183
+ stashPath: meta.stashPath,
184
+ graphPath: meta.graphPath,
185
+ schemaVersion: meta.schemaVersion,
186
+ generatedAt: meta.generatedAt,
187
+ ...(meta.quality ? { quality: meta.quality } : {}),
188
+ files,
189
+ entities: uniqueSorted(files.flatMap((file) => file.entities)),
190
+ relations: files.flatMap((file) => file.relations),
191
+ };
192
+ }
193
+ catch {
194
+ return null;
195
+ }
196
+ });
197
+ }
198
+ catch {
199
+ return null;
200
+ }
201
+ }