akm-cli 0.8.0-rc2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/{.github/CHANGELOG.md → CHANGELOG.md} +191 -3
  2. package/README.md +22 -6
  3. package/SECURITY.md +93 -0
  4. package/dist/cli/config-migrate.js +144 -0
  5. package/dist/cli/config-validate.js +39 -0
  6. package/dist/cli/confirm.js +73 -0
  7. package/dist/cli/parse-args.js +93 -3
  8. package/dist/cli/shared.js +129 -0
  9. package/dist/cli.js +2141 -1268
  10. package/dist/commands/add-cli.js +279 -0
  11. package/dist/commands/agent-dispatch.js +20 -12
  12. package/dist/commands/agent-support.js +11 -5
  13. package/dist/commands/completions.js +3 -0
  14. package/dist/commands/config-cli.js +129 -517
  15. package/dist/commands/consolidate.js +1533 -144
  16. package/dist/commands/curate.js +44 -3
  17. package/dist/commands/db-cli.js +23 -0
  18. package/dist/commands/distill-promotion-policy.js +5 -3
  19. package/dist/commands/distill.js +906 -100
  20. package/dist/commands/env.js +213 -0
  21. package/dist/commands/eval-cases.js +3 -0
  22. package/dist/commands/events.js +3 -0
  23. package/dist/commands/extract-cli.js +127 -0
  24. package/dist/commands/extract-prompt.js +204 -0
  25. package/dist/commands/extract.js +477 -0
  26. package/dist/commands/feedback-cli.js +331 -0
  27. package/dist/commands/graph.js +260 -5
  28. package/dist/commands/health.js +977 -51
  29. package/dist/commands/help/help-accept.md +6 -3
  30. package/dist/commands/help/help-improve.md +36 -8
  31. package/dist/commands/help/help-proposals.md +7 -4
  32. package/dist/commands/help/help-reject.md +5 -2
  33. package/dist/commands/history.js +51 -16
  34. package/dist/commands/improve-auto-accept.js +97 -0
  35. package/dist/commands/improve-cli.js +236 -0
  36. package/dist/commands/improve-profiles.js +184 -0
  37. package/dist/commands/improve-result-file.js +167 -0
  38. package/dist/commands/improve.js +1725 -332
  39. package/dist/commands/info.js +3 -0
  40. package/dist/commands/init.js +49 -1
  41. package/dist/commands/installed-stashes.js +6 -23
  42. package/dist/commands/knowledge.js +3 -0
  43. package/dist/commands/lint/agent-linter.js +3 -0
  44. package/dist/commands/lint/base-linter.js +199 -5
  45. package/dist/commands/lint/command-linter.js +3 -0
  46. package/dist/commands/lint/default-linter.js +3 -0
  47. package/dist/commands/lint/env-key-rules.js +154 -0
  48. package/dist/commands/lint/index.js +92 -3
  49. package/dist/commands/lint/knowledge-linter.js +3 -0
  50. package/dist/commands/lint/markdown-insertion.js +343 -0
  51. package/dist/commands/lint/memory-linter.js +3 -0
  52. package/dist/commands/lint/registry.js +3 -0
  53. package/dist/commands/lint/skill-linter.js +3 -0
  54. package/dist/commands/lint/task-linter.js +15 -12
  55. package/dist/commands/lint/types.js +3 -0
  56. package/dist/commands/lint/workflow-linter.js +3 -0
  57. package/dist/commands/lint.js +3 -0
  58. package/dist/commands/migration-help.js +5 -2
  59. package/dist/commands/proposal-drain-policies.js +128 -0
  60. package/dist/commands/proposal-drain.js +477 -0
  61. package/dist/commands/proposal.js +60 -6
  62. package/dist/commands/propose.js +24 -19
  63. package/dist/commands/reflect.js +1004 -94
  64. package/dist/commands/registry-cli.js +150 -0
  65. package/dist/commands/registry-search.js +3 -0
  66. package/dist/commands/remember-cli.js +257 -0
  67. package/dist/commands/remember.js +15 -6
  68. package/dist/commands/schema-repair.js +88 -15
  69. package/dist/commands/search.js +99 -14
  70. package/dist/commands/secret.js +173 -0
  71. package/dist/commands/self-update.js +3 -0
  72. package/dist/commands/show.js +32 -13
  73. package/dist/commands/source-add.js +7 -35
  74. package/dist/commands/source-clone.js +3 -0
  75. package/dist/commands/source-manage.js +3 -0
  76. package/dist/commands/tasks.js +161 -95
  77. package/dist/commands/url-checker.js +3 -0
  78. package/dist/core/action-contributors.js +3 -0
  79. package/dist/core/asset-ref.js +13 -2
  80. package/dist/core/asset-registry.js +9 -2
  81. package/dist/core/asset-serialize.js +88 -0
  82. package/dist/core/asset-spec.js +61 -5
  83. package/dist/core/common.js +93 -5
  84. package/dist/core/concurrent.js +3 -0
  85. package/dist/core/config-io.js +347 -0
  86. package/dist/core/config-migration.js +622 -0
  87. package/dist/core/config-schema.js +558 -0
  88. package/dist/core/config-sources.js +108 -0
  89. package/dist/core/config-types.js +4 -0
  90. package/dist/core/config-walker.js +337 -0
  91. package/dist/core/config.js +366 -1077
  92. package/dist/core/errors.js +42 -20
  93. package/dist/core/events.js +31 -25
  94. package/dist/core/file-lock.js +104 -0
  95. package/dist/core/frontmatter.js +75 -10
  96. package/dist/core/lesson-lint.js +3 -0
  97. package/dist/core/markdown.js +3 -0
  98. package/dist/core/memory-belief.js +62 -0
  99. package/dist/core/memory-contradiction-detect.js +274 -0
  100. package/dist/core/memory-improve.js +142 -14
  101. package/dist/core/parse.js +3 -0
  102. package/dist/core/paths.js +218 -50
  103. package/dist/core/proposal-quality-validators.js +380 -0
  104. package/dist/core/proposal-validators.js +11 -3
  105. package/dist/core/proposals.js +464 -5
  106. package/dist/core/state-db.js +349 -56
  107. package/dist/core/text-truncation.js +107 -0
  108. package/dist/core/time.js +3 -0
  109. package/dist/core/tty.js +59 -0
  110. package/dist/core/warn.js +7 -2
  111. package/dist/core/write-source.js +12 -0
  112. package/dist/indexer/db-backup.js +391 -0
  113. package/dist/indexer/db-search.js +136 -28
  114. package/dist/indexer/db.js +661 -166
  115. package/dist/indexer/ensure-index.js +3 -0
  116. package/dist/indexer/file-context.js +3 -0
  117. package/dist/indexer/graph-boost.js +162 -40
  118. package/dist/indexer/graph-db.js +241 -51
  119. package/dist/indexer/graph-dedup.js +3 -7
  120. package/dist/indexer/graph-extraction.js +242 -149
  121. package/dist/indexer/index-context.js +3 -9
  122. package/dist/indexer/indexer.js +84 -14
  123. package/dist/indexer/llm-cache.js +24 -19
  124. package/dist/indexer/manifest.js +3 -0
  125. package/dist/indexer/matchers.js +184 -11
  126. package/dist/indexer/memory-inference.js +94 -50
  127. package/dist/indexer/metadata-contributors.js +3 -0
  128. package/dist/indexer/metadata.js +110 -50
  129. package/dist/indexer/path-resolver.js +3 -0
  130. package/dist/indexer/project-context.js +192 -0
  131. package/dist/indexer/ranking-contributors.js +134 -7
  132. package/dist/indexer/ranking.js +8 -1
  133. package/dist/indexer/search-fields.js +5 -9
  134. package/dist/indexer/search-hit-enrichers.js +91 -2
  135. package/dist/indexer/search-source.js +20 -1
  136. package/dist/indexer/semantic-status.js +4 -1
  137. package/dist/indexer/staleness-detect.js +447 -0
  138. package/dist/indexer/usage-events.js +12 -9
  139. package/dist/indexer/walker.js +3 -0
  140. package/dist/integrations/agent/builders.js +135 -0
  141. package/dist/integrations/agent/config.js +121 -401
  142. package/dist/integrations/agent/detect.js +3 -0
  143. package/dist/integrations/agent/index.js +6 -14
  144. package/dist/integrations/agent/model-aliases.js +55 -0
  145. package/dist/integrations/agent/profiles.js +3 -0
  146. package/dist/integrations/agent/prompts.js +137 -8
  147. package/dist/integrations/agent/runner.js +208 -0
  148. package/dist/integrations/agent/sdk-runner.js +8 -2
  149. package/dist/integrations/agent/spawn.js +54 -14
  150. package/dist/integrations/github.js +3 -0
  151. package/dist/integrations/lockfile.js +22 -51
  152. package/dist/integrations/session-logs/index.js +4 -0
  153. package/dist/integrations/session-logs/inline-refs.js +35 -0
  154. package/dist/integrations/session-logs/pre-filter.js +152 -0
  155. package/dist/integrations/session-logs/providers/claude-code.js +226 -0
  156. package/dist/integrations/session-logs/providers/opencode.js +231 -25
  157. package/dist/integrations/session-logs/types.js +3 -0
  158. package/dist/llm/call-ai.js +14 -26
  159. package/dist/llm/client.js +16 -2
  160. package/dist/llm/embedder.js +20 -29
  161. package/dist/llm/embedders/cache.js +3 -7
  162. package/dist/llm/embedders/local.js +42 -1
  163. package/dist/llm/embedders/remote.js +20 -8
  164. package/dist/llm/embedders/types.js +3 -7
  165. package/dist/llm/feature-gate.js +92 -56
  166. package/dist/llm/graph-extract.js +401 -30
  167. package/dist/llm/index-passes.js +44 -29
  168. package/dist/llm/memory-infer.js +30 -2
  169. package/dist/llm/metadata-enhance.js +3 -7
  170. package/dist/llm/prompts/extract-session.md +80 -0
  171. package/dist/llm/prompts/graph-extract-user-prompt.md +24 -1
  172. package/dist/output/cli-hints-full.md +60 -32
  173. package/dist/output/cli-hints-short.md +10 -7
  174. package/dist/output/cli-hints.js +5 -2
  175. package/dist/output/context.js +60 -8
  176. package/dist/output/renderers.js +170 -194
  177. package/dist/output/shapes/curate.js +56 -0
  178. package/dist/output/shapes/distill.js +10 -0
  179. package/dist/output/shapes/env-list.js +19 -0
  180. package/dist/output/shapes/events.js +11 -0
  181. package/dist/output/shapes/helpers.js +424 -0
  182. package/dist/output/shapes/history.js +7 -0
  183. package/dist/output/shapes/passthrough.js +105 -0
  184. package/dist/output/shapes/proposal-accept.js +7 -0
  185. package/dist/output/shapes/proposal-diff.js +7 -0
  186. package/dist/output/shapes/proposal-list.js +7 -0
  187. package/dist/output/shapes/proposal-producer.js +11 -0
  188. package/dist/output/shapes/proposal-reject.js +7 -0
  189. package/dist/output/shapes/proposal-show.js +7 -0
  190. package/dist/output/shapes/registry-search.js +6 -0
  191. package/dist/output/shapes/registry.js +30 -0
  192. package/dist/output/shapes/search.js +6 -0
  193. package/dist/output/shapes/secret-list.js +19 -0
  194. package/dist/output/shapes/show.js +6 -0
  195. package/dist/output/shapes/vault-list.js +19 -0
  196. package/dist/output/shapes.js +51 -549
  197. package/dist/output/text/add.js +6 -0
  198. package/dist/output/text/clone.js +6 -0
  199. package/dist/output/text/config.js +6 -0
  200. package/dist/output/text/curate.js +6 -0
  201. package/dist/output/text/distill.js +7 -0
  202. package/dist/output/text/enable-disable.js +7 -0
  203. package/dist/output/text/events.js +10 -0
  204. package/dist/output/text/feedback.js +6 -0
  205. package/dist/output/text/helpers.js +1059 -0
  206. package/dist/output/text/history.js +7 -0
  207. package/dist/output/text/import.js +6 -0
  208. package/dist/output/text/index.js +6 -0
  209. package/dist/output/text/info.js +6 -0
  210. package/dist/output/text/init.js +6 -0
  211. package/dist/output/text/list.js +6 -0
  212. package/dist/output/text/proposal-producer.js +8 -0
  213. package/dist/output/text/proposal.js +12 -0
  214. package/dist/output/text/registry-commands.js +11 -0
  215. package/dist/output/text/registry.js +30 -0
  216. package/dist/output/text/remember.js +6 -0
  217. package/dist/output/text/remove.js +6 -0
  218. package/dist/output/text/save.js +6 -0
  219. package/dist/output/text/search.js +6 -0
  220. package/dist/output/text/show.js +6 -0
  221. package/dist/output/text/update.js +6 -0
  222. package/dist/output/text/upgrade.js +6 -0
  223. package/dist/output/text/vault.js +16 -0
  224. package/dist/output/text/wiki.js +15 -0
  225. package/dist/output/text/workflow.js +14 -0
  226. package/dist/output/text.js +44 -1329
  227. package/dist/registry/build-index.js +3 -0
  228. package/dist/registry/create-provider-registry.js +3 -0
  229. package/dist/registry/factory.js +4 -1
  230. package/dist/registry/origin-resolve.js +3 -0
  231. package/dist/registry/providers/index.js +3 -0
  232. package/dist/registry/providers/skills-sh.js +11 -2
  233. package/dist/registry/providers/static-index.js +10 -1
  234. package/dist/registry/providers/types.js +3 -24
  235. package/dist/registry/resolve.js +11 -16
  236. package/dist/registry/types.js +3 -0
  237. package/dist/scripts/migrate-storage.js +17767 -0
  238. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9031 -0
  239. package/dist/scripts/migrations/v16-to-v17.js +141 -0
  240. package/dist/setup/detect.js +3 -0
  241. package/dist/setup/ripgrep-install.js +3 -0
  242. package/dist/setup/ripgrep-resolve.js +3 -0
  243. package/dist/setup/setup.js +306 -67
  244. package/dist/setup/steps.js +3 -15
  245. package/dist/sources/include.js +3 -0
  246. package/dist/sources/provider-factory.js +3 -11
  247. package/dist/sources/provider.js +3 -20
  248. package/dist/sources/providers/filesystem.js +19 -23
  249. package/dist/sources/providers/git.js +171 -21
  250. package/dist/sources/providers/index.js +3 -0
  251. package/dist/sources/providers/install-types.js +3 -13
  252. package/dist/sources/providers/npm.js +3 -4
  253. package/dist/sources/providers/provider-utils.js +3 -0
  254. package/dist/sources/providers/sync-from-ref.js +3 -11
  255. package/dist/sources/providers/tar-utils.js +3 -0
  256. package/dist/sources/providers/website.js +18 -22
  257. package/dist/sources/resolve.js +3 -0
  258. package/dist/sources/types.js +3 -0
  259. package/dist/sources/website-ingest.js +3 -0
  260. package/dist/tasks/backends/cron.js +3 -0
  261. package/dist/tasks/backends/exec-utils.js +3 -0
  262. package/dist/tasks/backends/index.js +3 -11
  263. package/dist/tasks/backends/launchd.js +3 -0
  264. package/dist/tasks/backends/schtasks.js +3 -0
  265. package/dist/tasks/parser.js +51 -38
  266. package/dist/tasks/resolveAkmBin.js +3 -0
  267. package/dist/tasks/runner.js +35 -9
  268. package/dist/tasks/schedule.js +20 -1
  269. package/dist/tasks/schema.js +5 -3
  270. package/dist/tasks/validator.js +6 -3
  271. package/dist/version.js +3 -0
  272. package/dist/wiki/wiki-templates.js +3 -0
  273. package/dist/wiki/wiki.js +3 -0
  274. package/dist/workflows/authoring.js +3 -0
  275. package/dist/workflows/cli.js +3 -0
  276. package/dist/workflows/db.js +140 -10
  277. package/dist/workflows/document-cache.js +3 -10
  278. package/dist/workflows/parser.js +3 -0
  279. package/dist/workflows/renderer.js +3 -0
  280. package/dist/workflows/runs.js +18 -1
  281. package/dist/workflows/schema.js +3 -0
  282. package/dist/workflows/scope-key.js +3 -0
  283. package/dist/workflows/validator.js +5 -9
  284. package/docs/README.md +7 -2
  285. package/docs/data-and-telemetry.md +225 -0
  286. package/docs/migration/release-notes/0.7.5.md +2 -2
  287. package/docs/migration/release-notes/0.8.0.md +57 -5
  288. package/docs/migration/v0.7-to-v0.8.md +1378 -0
  289. package/package.json +28 -11
  290. package/.github/LICENSE +0 -374
  291. package/dist/commands/install-audit.js +0 -385
  292. package/dist/commands/vault.js +0 -310
  293. package/dist/indexer/match-contributors.js +0 -141
  294. package/dist/integrations/agent/pipeline.js +0 -39
  295. package/dist/integrations/agent/runners.js +0 -31
@@ -1,3 +1,6 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
1
4
  /**
2
5
  * Auto-index: silently run an incremental `akm index` when the local index
3
6
  * is stale or absent, so that `search`, `show`, and `feedback` always operate
@@ -1,3 +1,6 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
1
4
  /**
2
5
  * Flexible asset resolution system.
3
6
  *
@@ -1,26 +1,31 @@
1
- /**
2
- * Search-time graph-boost integration for the `akm index` graph pass (#207).
3
- *
4
- * This module is the consumer half of the graph-extraction pass. It loads
5
- * the persisted graph snapshot from SQLite and exposes a single helper,
6
- * {@link computeGraphBoost}, that the existing FTS5+boosts loop in
7
- * `src/indexer/db-search.ts` calls per-entry to obtain an additive boost
8
- * value.
9
- *
10
- * CLAUDE.md / v1 spec compliance:
11
- * - The graph signal feeds the **single** FTS5+boosts pipeline as one
12
- * additive boost component. There is no parallel scoring track.
13
- * - There is no second `SearchHit` scorer. `searchDatabase` continues to
14
- * own ranking; this module just answers "what additive boost does the
15
- * graph contribute for this (query, entry) pair?".
16
- * - Missing graph rows → boost is `0`. The pipeline
17
- * degrades gracefully to its non-graph behaviour, exactly as today.
18
- */
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
19
4
  import { loadStoredGraphMeta, loadStoredGraphSnapshot } from "./graph-db";
20
5
  function normalizeGraphName(value) {
21
6
  return value.trim().toLowerCase();
22
7
  }
23
8
  let cachedParsedGraph;
9
+ /**
10
+ * Clear the module-level parsed-graph cache.
11
+ *
12
+ * The cache keeps the most recently parsed `ParsedGraphContext` keyed by
13
+ * (stashPath, generatedAt) tuples so back-to-back search invocations within
14
+ * the same process don't re-read the SQLite snapshot from disk. The cache
15
+ * persists across calls — which is the desired behaviour in production but
16
+ * pathological for tests that swap the underlying stash directory between
17
+ * test cases without bumping `generatedAt`. Such tests can observe stale
18
+ * graph nodes from a previous test's stash.
19
+ *
20
+ * Tests (and any tooling that swaps stash backings) should call this between
21
+ * setups to guarantee the next `loadGraphBoostContext` reads fresh state.
22
+ * Recommended placement: `beforeEach` for test files that mutate graph
23
+ * state, or after `deleteStoredGraph` / `replaceStoredGraph` calls that
24
+ * intentionally invalidate the cache.
25
+ */
26
+ export function resetGraphBoostCache() {
27
+ cachedParsedGraph = undefined;
28
+ }
24
29
  function resolveGraphBoostWeights(config) {
25
30
  const configured = config?.search?.graphBoost;
26
31
  return {
@@ -232,33 +237,150 @@ export function collectGraphRelatedHit(context, filePath) {
232
237
  relations,
233
238
  };
234
239
  }
240
+ /**
241
+ * Find graph files that share entities with the given file.
242
+ *
243
+ * Implementation: SQL self-join on graph_file_entities, scoped by stash_root,
244
+ * grouped by entry_id, ordered by shared-entity count desc. Touches ~50-200
245
+ * rows instead of loading the entire snapshot into memory. Cold-call latency
246
+ * drops from ~30-60ms (full snapshot parse) to ~2-5ms on typical stashes.
247
+ *
248
+ * The returned `ref` field carries the canonical asset ref (`type:name`)
249
+ * resolved from entries.entry_key when the entry is indexed. Callers should
250
+ * fall back to formatting `path` when `ref` is undefined (orphan graph row).
251
+ */
235
252
  export function listRelatedPathsForFile(stashRoot, filePath, limit = 5, db) {
236
- const parsed = readParsedGraphContext([stashRoot], db);
237
- if (!parsed)
253
+ if (!db) {
254
+ // Fallback: opening a transient DB here is not currently a use case (all
255
+ // callers pass a handle), so degrade to empty rather than reopening.
238
256
  return [];
239
- const node = parsed.nodesByPath.get(filePath);
240
- if (!node)
257
+ }
258
+ // Resolve target's entry_id from the stash_root + file_path. The graph rows
259
+ // are keyed on entry_id; without it we can't run the join.
260
+ let targetEntryId;
261
+ try {
262
+ const row = db
263
+ .prepare("SELECT entry_id FROM graph_files WHERE stash_root = ? AND file_path = ? LIMIT 1")
264
+ .get(stashRoot, filePath);
265
+ targetEntryId = row?.entry_id;
266
+ }
267
+ catch {
241
268
  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)
269
+ }
270
+ if (targetEntryId == null)
271
+ return [];
272
+ const effectiveLimit = Math.max(1, limit);
273
+ // Shared-entity count per candidate entry_id.
274
+ let candidateRows;
275
+ try {
276
+ candidateRows = db
277
+ .prepare(`SELECT gf.entry_id AS entry_id,
278
+ gf.file_path AS file_path,
279
+ gf.file_type AS file_type,
280
+ COUNT(*) AS shared
281
+ FROM graph_file_entities target
282
+ JOIN graph_file_entities e
283
+ ON e.stash_root = target.stash_root
284
+ AND e.entity_norm = target.entity_norm
285
+ AND e.entry_id != target.entry_id
286
+ JOIN graph_files gf
287
+ ON gf.entry_id = e.entry_id
288
+ WHERE target.entry_id = ?
289
+ AND target.stash_root = ?
290
+ GROUP BY gf.entry_id
291
+ ORDER BY shared DESC, gf.file_path ASC
292
+ LIMIT ?`)
293
+ .all(targetEntryId, stashRoot, effectiveLimit);
294
+ }
295
+ catch {
296
+ return [];
297
+ }
298
+ if (candidateRows.length === 0)
299
+ return [];
300
+ const candidateIds = candidateRows.map((r) => r.entry_id);
301
+ const placeholders = candidateIds.map(() => "?").join(",");
302
+ // Pull the shared entity names (joined by normalized casing) for display.
303
+ const sharedRows = db
304
+ .prepare(`SELECT e.entry_id AS entry_id, e.entity AS entity
305
+ FROM graph_file_entities e
306
+ JOIN graph_file_entities target
307
+ ON target.stash_root = e.stash_root
308
+ AND target.entity_norm = e.entity_norm
309
+ WHERE e.entry_id IN (${placeholders})
310
+ AND target.entry_id = ?
311
+ AND target.stash_root = ?`)
312
+ .all(...candidateIds, targetEntryId, stashRoot);
313
+ const sharedByEntry = new Map();
314
+ for (const row of sharedRows) {
315
+ let bucket = sharedByEntry.get(row.entry_id);
316
+ if (!bucket) {
317
+ bucket = new Set();
318
+ sharedByEntry.set(row.entry_id, bucket);
319
+ }
320
+ bucket.add(row.entity);
321
+ }
322
+ // Relation count for each candidate (relations where either endpoint
323
+ // matches one of the shared entities).
324
+ const relationCountByEntry = new Map();
325
+ const relationRows = db
326
+ .prepare(`SELECT entry_id, from_entity, to_entity
327
+ FROM graph_file_relations
328
+ WHERE entry_id IN (${placeholders})`)
329
+ .all(...candidateIds);
330
+ for (const row of relationRows) {
331
+ const shared = sharedByEntry.get(row.entry_id);
332
+ if (!shared)
249
333
  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
- });
334
+ if (shared.has(row.from_entity) || shared.has(row.to_entity)) {
335
+ relationCountByEntry.set(row.entry_id, (relationCountByEntry.get(row.entry_id) ?? 0) + 1);
336
+ }
337
+ }
338
+ // Optional: ref lookup via entries.entry_key. entry_key is stored as
339
+ // `${stash_dir}:${type}:${name}` — strip the stash-dir prefix to get the
340
+ // user-facing `type:name`.
341
+ const refByEntryId = new Map();
342
+ try {
343
+ const entryRows = db
344
+ .prepare(`SELECT id, entry_key, stash_dir FROM entries WHERE id IN (${placeholders})`)
345
+ .all(...candidateIds);
346
+ for (const row of entryRows) {
347
+ const ref = stripStashPrefix(row.entry_key, row.stash_dir);
348
+ if (ref)
349
+ refByEntryId.set(row.id, ref);
350
+ }
257
351
  }
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));
352
+ catch {
353
+ /* ignore — refs are best-effort */
354
+ }
355
+ return candidateRows.map((row) => {
356
+ const sharedSet = sharedByEntry.get(row.entry_id) ?? new Set();
357
+ const sharedEntities = [...sharedSet].sort((a, b) => a.localeCompare(b));
358
+ const ref = refByEntryId.get(row.entry_id);
359
+ return {
360
+ ...(ref ? { ref } : {}),
361
+ path: row.file_path,
362
+ type: row.file_type,
363
+ sharedEntities,
364
+ relationCount: relationCountByEntry.get(row.entry_id) ?? 0,
365
+ };
366
+ });
367
+ }
368
+ /**
369
+ * Convert an entries.entry_key to a user-facing asset ref. Entry keys are
370
+ * stored as `${stash_dir}:${type}:${name}`; strip the stash-dir prefix.
371
+ */
372
+ function stripStashPrefix(entryKey, stashDir) {
373
+ const prefix = `${stashDir}:`;
374
+ if (entryKey.startsWith(prefix))
375
+ return entryKey.slice(prefix.length);
376
+ // Fall back to last two colon-separated segments.
377
+ const lastColon = entryKey.lastIndexOf(":");
378
+ if (lastColon < 0)
379
+ return entryKey;
380
+ const prevColon = entryKey.lastIndexOf(":", lastColon - 1);
381
+ if (prevColon < 0)
382
+ return entryKey;
383
+ return entryKey.slice(prevColon + 1);
262
384
  }
263
385
  /**
264
386
  * Load and normalize graph data from SQLite once, then reuse it across all
@@ -1,5 +1,10 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
1
4
  import fs from "node:fs";
5
+ import { rethrowIfTestIsolationError } from "../core/errors";
2
6
  import { getDbPath } from "../core/paths";
7
+ import { warn } from "../core/warn";
3
8
  import { closeDatabase, openExistingDatabase } from "./db";
4
9
  function withReadableGraphDb(db, fn) {
5
10
  if (db)
@@ -18,6 +23,42 @@ function withReadableGraphDb(db, fn) {
18
23
  function uniqueSorted(values) {
19
24
  return [...new Set(values)].sort((a, b) => a.localeCompare(b));
20
25
  }
26
+ function normalizeEntity(value) {
27
+ return value.trim().toLowerCase();
28
+ }
29
+ /**
30
+ * Resolve a file_path within a stash to its entries.id. Returns null when the
31
+ * path has no indexed entry (orphan graph row).
32
+ */
33
+ export function resolveEntryIdForPath(db, stashRoot, filePath) {
34
+ try {
35
+ const row = db
36
+ .prepare("SELECT id FROM entries WHERE stash_dir = ? AND file_path = ? LIMIT 1")
37
+ .get(stashRoot, filePath);
38
+ if (row)
39
+ return row.id;
40
+ // Fall back to file_path-only match (legacy callers may pass a stash root
41
+ // that doesn't exactly match entries.stash_dir, e.g. trailing-slash diffs).
42
+ const fallback = db.prepare("SELECT id FROM entries WHERE file_path = ? LIMIT 1").get(filePath);
43
+ return fallback?.id ?? null;
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ /**
50
+ * Persist (or update) a graph snapshot for a stash root.
51
+ *
52
+ * Implementation: incremental upsert keyed on entries.id. Unchanged files
53
+ * (matching body_hash) are skipped; changed files have their child rows
54
+ * deleted (CASCADE) and re-inserted; files in DB but absent from the new
55
+ * snapshot are deleted. The old behaviour wiped every row for the stash on
56
+ * each write, which produced ~22k row writes per re-index even when one
57
+ * asset changed.
58
+ *
59
+ * Orphan files (no entries row resolvable) are skipped and counted in a
60
+ * single warn() so the caller sees the magnitude without log spam.
61
+ */
21
62
  export function replaceStoredGraph(db, graph) {
22
63
  const upsertMeta = db.prepare(`INSERT INTO graph_meta (
23
64
  stash_root,
@@ -28,58 +69,172 @@ export function replaceStoredGraph(db, graph) {
28
69
  entity_count,
29
70
  relation_count,
30
71
  extraction_coverage,
31
- density
32
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
72
+ density,
73
+ extractor_id,
74
+ extraction_run_id,
75
+ model,
76
+ prompt_version,
77
+ batch_size,
78
+ cache_hits,
79
+ cache_misses,
80
+ truncation_count,
81
+ failure_count
82
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
33
83
  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 (?, ?, ?, ?)`);
84
+ schema_version = excluded.schema_version,
85
+ generated_at = excluded.generated_at,
86
+ considered_files = excluded.considered_files,
87
+ extracted_files = excluded.extracted_files,
88
+ entity_count = excluded.entity_count,
89
+ relation_count = excluded.relation_count,
90
+ extraction_coverage = excluded.extraction_coverage,
91
+ density = excluded.density,
92
+ extractor_id = excluded.extractor_id,
93
+ extraction_run_id = excluded.extraction_run_id,
94
+ model = excluded.model,
95
+ prompt_version = excluded.prompt_version,
96
+ batch_size = excluded.batch_size,
97
+ cache_hits = excluded.cache_hits,
98
+ cache_misses = excluded.cache_misses,
99
+ truncation_count = excluded.truncation_count,
100
+ failure_count = excluded.failure_count`);
101
+ const selectExisting = db.prepare("SELECT entry_id, file_path, body_hash FROM graph_files WHERE stash_root = ?");
102
+ const deleteFile = db.prepare("DELETE FROM graph_files WHERE entry_id = ?");
103
+ const deleteEntities = db.prepare("DELETE FROM graph_file_entities WHERE entry_id = ?");
104
+ const deleteRelations = db.prepare("DELETE FROM graph_file_relations WHERE entry_id = ?");
105
+ const insertFile = db.prepare(`INSERT INTO graph_files (
106
+ entry_id, stash_root, file_path, file_order, file_type, body_hash, confidence, status, reason, extraction_run_id
107
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
108
+ const updateFileMeta = db.prepare(`UPDATE graph_files
109
+ SET file_order = ?, file_type = ?, confidence = ?, status = ?, reason = ?, extraction_run_id = ?
110
+ WHERE entry_id = ?`);
111
+ const insertEntity = db.prepare(`INSERT INTO graph_file_entities (entry_id, entity_order, stash_root, entity_norm, entity)
112
+ VALUES (?, ?, ?, ?, ?)`);
49
113
  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 (?, ?, ?, ?, ?, ?, ?)`);
114
+ entry_id, relation_order, from_entity_norm, from_entity, to_entity_norm, to_entity, relation_type, confidence
115
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
58
116
  const quality = graph.quality;
117
+ const telemetry = graph.telemetry;
59
118
  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);
119
+ 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, telemetry?.extractorId ?? null, telemetry?.extractionRunId ?? null, telemetry?.model ?? null, telemetry?.promptVersion ?? null, telemetry?.batchSize ?? null, telemetry?.cacheHits ?? 0, telemetry?.cacheMisses ?? 0, telemetry?.truncationCount ?? 0, telemetry?.failureCount ?? 0);
120
+ // Build a snapshot of existing rows for incremental compare.
121
+ const existingRows = selectExisting.all(graph.stashRoot);
122
+ const existingByPath = new Map();
123
+ for (const row of existingRows)
124
+ existingByPath.set(row.file_path, row);
125
+ let orphanCount = 0;
126
+ const presentEntryIds = new Set();
64
127
  for (const [fileOrder, node] of graph.files.entries()) {
65
- insertFile.run(graph.stashRoot, node.path, fileOrder, node.type, node.bodyHash ?? null, node.confidence ?? null);
128
+ // body_hash is NOT NULL in schema v2; default to a sentinel for inputs
129
+ // (test fixtures, legacy imports) that don't supply one. The sentinel
130
+ // never equals a real hash so subsequent staleness checks always
131
+ // re-extract — correct behaviour for "unknown" bodies.
132
+ const bodyHash = node.bodyHash && node.bodyHash.length > 0 ? node.bodyHash : "";
133
+ const entryId = resolveEntryIdForPath(db, graph.stashRoot, node.path);
134
+ if (entryId == null) {
135
+ orphanCount += 1;
136
+ continue;
137
+ }
138
+ presentEntryIds.add(entryId);
139
+ const existing = existingByPath.get(node.path);
140
+ if (existing && existing.entry_id === entryId && existing.body_hash === bodyHash) {
141
+ // Body unchanged — only fix up file_order/confidence in case they drifted.
142
+ updateFileMeta.run(fileOrder, node.type, node.confidence ?? null, node.status ?? (node.entities.length > 0 ? "extracted" : "empty"), node.reason ?? (node.entities.length > 0 ? "none" : "no_graph_content"), node.extractionRunId ?? telemetry?.extractionRunId ?? null, entryId);
143
+ continue;
144
+ }
145
+ if (existing) {
146
+ // Stale row (different body_hash, or entry_id moved to a different
147
+ // path under the same file_path). Wipe child rows; CASCADE would do
148
+ // it but explicit DELETE keeps the order deterministic.
149
+ deleteEntities.run(existing.entry_id);
150
+ deleteRelations.run(existing.entry_id);
151
+ deleteFile.run(existing.entry_id);
152
+ }
153
+ insertFile.run(entryId, graph.stashRoot, node.path, fileOrder, node.type, bodyHash, node.confidence ?? null, node.status ?? (node.entities.length > 0 ? "extracted" : "empty"), node.reason ?? (node.entities.length > 0 ? "none" : "no_graph_content"), node.extractionRunId ?? telemetry?.extractionRunId ?? null);
66
154
  for (const [entityOrder, entity] of node.entities.entries()) {
67
- insertEntity.run(graph.stashRoot, node.path, entityOrder, entity);
155
+ insertEntity.run(entryId, entityOrder, graph.stashRoot, normalizeEntity(entity), entity);
68
156
  }
69
157
  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);
158
+ insertRelation.run(entryId, relationOrder, normalizeEntity(relation.from), relation.from, normalizeEntity(relation.to), relation.to, relation.type ?? null, relation.confidence ?? null);
159
+ }
160
+ }
161
+ // Delete files present in DB but absent from the new snapshot. Child
162
+ // tables CASCADE on entry_id.
163
+ for (const row of existingRows) {
164
+ if (!presentEntryIds.has(row.entry_id)) {
165
+ deleteEntities.run(row.entry_id);
166
+ deleteRelations.run(row.entry_id);
167
+ deleteFile.run(row.entry_id);
71
168
  }
72
169
  }
170
+ if (orphanCount > 0) {
171
+ warn(`[graph] replaceStoredGraph: skipped ${orphanCount} file(s) with no resolvable entry under ${graph.stashRoot}.`);
172
+ }
73
173
  })();
74
174
  }
75
175
  export function deleteStoredGraph(db, stashPath) {
76
176
  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);
177
+ // Child rows cascade via entry_id; deleting graph_files clears them.
79
178
  db.prepare("DELETE FROM graph_files WHERE stash_root = ?").run(stashPath);
80
179
  db.prepare("DELETE FROM graph_meta WHERE stash_root = ?").run(stashPath);
81
180
  })();
82
181
  }
182
+ /**
183
+ * Scoped loader — only the graph_meta row for a stash. Used by callers that
184
+ * only need summary numbers (e.g. `akm graph summary`).
185
+ */
186
+ export function loadGraphMetaOnly(stashPath, db) {
187
+ return loadStoredGraphMeta(stashPath, db);
188
+ }
189
+ /**
190
+ * Scoped loader — graph_files rows without entities/relations. Used for
191
+ * orphan detection and entity overview commands.
192
+ */
193
+ export function loadGraphFilesOnly(stashPath, db) {
194
+ try {
195
+ return withReadableGraphDb(db, (readDb) => {
196
+ try {
197
+ const rows = readDb
198
+ .prepare(`SELECT entry_id, file_path, file_type, body_hash, confidence, status, reason
199
+ FROM graph_files
200
+ WHERE stash_root = ?
201
+ ORDER BY file_order`)
202
+ .all(stashPath);
203
+ return rows.map((row) => ({
204
+ entryId: row.entry_id,
205
+ path: row.file_path,
206
+ type: row.file_type,
207
+ bodyHash: row.body_hash,
208
+ ...(typeof row.confidence === "number" ? { confidence: row.confidence } : {}),
209
+ ...(row.status ? { status: row.status } : {}),
210
+ ...(row.reason ? { reason: row.reason } : {}),
211
+ }));
212
+ }
213
+ catch {
214
+ return [];
215
+ }
216
+ });
217
+ }
218
+ catch (err) {
219
+ // Never mask the bun-test isolation guard as "no stored graph files".
220
+ rethrowIfTestIsolationError(err);
221
+ return [];
222
+ }
223
+ }
224
+ /**
225
+ * Scoped loader — entities for a single entry_id. Used by per-asset lookups.
226
+ */
227
+ export function loadGraphEntitiesByEntry(db, entryId) {
228
+ try {
229
+ const rows = db
230
+ .prepare("SELECT entity FROM graph_file_entities WHERE entry_id = ? ORDER BY entity_order")
231
+ .all(entryId);
232
+ return rows.map((r) => r.entity);
233
+ }
234
+ catch {
235
+ return [];
236
+ }
237
+ }
83
238
  export function loadStoredGraphMeta(stashPath, db) {
84
239
  try {
85
240
  return withReadableGraphDb(db, (readDb) => {
@@ -94,9 +249,18 @@ export function loadStoredGraphMeta(stashPath, db) {
94
249
  entity_count,
95
250
  relation_count,
96
251
  extraction_coverage,
97
- density
98
- FROM graph_meta
99
- WHERE stash_root = ?`)
252
+ density,
253
+ extractor_id,
254
+ extraction_run_id,
255
+ model,
256
+ prompt_version,
257
+ batch_size,
258
+ cache_hits,
259
+ cache_misses,
260
+ truncation_count,
261
+ failure_count
262
+ FROM graph_meta
263
+ WHERE stash_root = ?`)
100
264
  .get(stashPath);
101
265
  if (!row)
102
266
  return null;
@@ -113,6 +277,17 @@ export function loadStoredGraphMeta(stashPath, db) {
113
277
  extractionCoverage: row.extraction_coverage,
114
278
  density: row.density,
115
279
  },
280
+ telemetry: {
281
+ ...(row.extractor_id ? { extractorId: row.extractor_id } : {}),
282
+ ...(row.extraction_run_id ? { extractionRunId: row.extraction_run_id } : {}),
283
+ ...(row.model ? { model: row.model } : {}),
284
+ ...(row.prompt_version ? { promptVersion: row.prompt_version } : {}),
285
+ ...(typeof row.batch_size === "number" ? { batchSize: row.batch_size } : {}),
286
+ cacheHits: row.cache_hits,
287
+ cacheMisses: row.cache_misses,
288
+ truncationCount: row.truncation_count,
289
+ failureCount: row.failure_count,
290
+ },
116
291
  };
117
292
  }
118
293
  catch {
@@ -120,7 +295,9 @@ export function loadStoredGraphMeta(stashPath, db) {
120
295
  }
121
296
  });
122
297
  }
123
- catch {
298
+ catch (err) {
299
+ // Never mask the bun-test isolation guard as "no stored graph meta".
300
+ rethrowIfTestIsolationError(err);
124
301
  return null;
125
302
  }
126
303
  }
@@ -132,22 +309,29 @@ export function loadStoredGraphSnapshot(stashPath, db) {
132
309
  return null;
133
310
  try {
134
311
  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`)
312
+ .prepare(`SELECT entry_id, file_path, file_type, body_hash, confidence, status, reason, extraction_run_id
313
+ FROM graph_files
314
+ WHERE stash_root = ?
315
+ ORDER BY file_order`)
139
316
  .all(stashPath);
140
317
  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`)
318
+ .prepare(`SELECT gfe.entry_id AS entry_id, gf.file_path AS file_path, gfe.entity AS entity
319
+ FROM graph_file_entities gfe
320
+ JOIN graph_files gf ON gf.entry_id = gfe.entry_id
321
+ WHERE gf.stash_root = ?
322
+ ORDER BY gf.file_order, gfe.entity_order`)
145
323
  .all(stashPath);
146
324
  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`)
325
+ .prepare(`SELECT gfr.entry_id AS entry_id,
326
+ gf.file_path AS file_path,
327
+ gfr.from_entity AS from_entity,
328
+ gfr.to_entity AS to_entity,
329
+ gfr.relation_type AS relation_type,
330
+ gfr.confidence AS confidence
331
+ FROM graph_file_relations gfr
332
+ JOIN graph_files gf ON gf.entry_id = gfr.entry_id
333
+ WHERE gf.stash_root = ?
334
+ ORDER BY gf.file_order, gfr.relation_order`)
151
335
  .all(stashPath);
152
336
  const entitiesByPath = new Map();
153
337
  for (const row of entityRows) {
@@ -178,6 +362,9 @@ export function loadStoredGraphSnapshot(stashPath, db) {
178
362
  entities: entitiesByPath.get(row.file_path) ?? [],
179
363
  relations: relationsByPath.get(row.file_path) ?? [],
180
364
  ...(typeof row.confidence === "number" ? { confidence: row.confidence } : {}),
365
+ ...(row.status ? { status: row.status } : {}),
366
+ ...(row.reason ? { reason: row.reason } : {}),
367
+ ...(row.extraction_run_id ? { extractionRunId: row.extraction_run_id } : {}),
181
368
  }));
182
369
  return {
183
370
  stashPath: meta.stashPath,
@@ -185,6 +372,7 @@ export function loadStoredGraphSnapshot(stashPath, db) {
185
372
  schemaVersion: meta.schemaVersion,
186
373
  generatedAt: meta.generatedAt,
187
374
  ...(meta.quality ? { quality: meta.quality } : {}),
375
+ ...(meta.telemetry ? { telemetry: meta.telemetry } : {}),
188
376
  files,
189
377
  entities: uniqueSorted(files.flatMap((file) => file.entities)),
190
378
  relations: files.flatMap((file) => file.relations),
@@ -195,7 +383,9 @@ export function loadStoredGraphSnapshot(stashPath, db) {
195
383
  }
196
384
  });
197
385
  }
198
- catch {
386
+ catch (err) {
387
+ // Never mask the bun-test isolation guard as "no stored graph snapshot".
388
+ rethrowIfTestIsolationError(err);
199
389
  return null;
200
390
  }
201
391
  }
@@ -1,10 +1,6 @@
1
- /**
2
- * Pure graph deduplication utility no LLM calls, no I/O.
3
- *
4
- * Extracted from src/llm/graph-extract.ts so it can be imported by
5
- * src/indexer/graph-extraction.ts without being replaced by test mocks
6
- * that stub the LLM layer.
7
- */
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
8
4
  function normalizeRelationType(raw) {
9
5
  const normalized = raw?.trim().toLowerCase().replace(/\s+/g, " ") ?? "";
10
6
  if (!normalized)