akm-cli 0.7.5 → 0.8.0-rc.11

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 (300) hide show
  1. package/{.github/CHANGELOG.md → CHANGELOG.md} +192 -2
  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 +133 -0
  8. package/dist/cli/shared.js +129 -0
  9. package/dist/cli.js +2569 -1449
  10. package/dist/commands/add-cli.js +279 -0
  11. package/dist/commands/agent-dispatch.js +110 -0
  12. package/dist/commands/agent-support.js +68 -0
  13. package/dist/commands/completions.js +3 -0
  14. package/dist/commands/config-cli.js +130 -534
  15. package/dist/commands/consolidate.js +2122 -0
  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 +660 -0
  19. package/dist/commands/distill.js +1075 -77
  20. package/dist/commands/env.js +213 -0
  21. package/dist/commands/eval-cases.js +43 -0
  22. package/dist/commands/events.js +5 -23
  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 +477 -0
  28. package/dist/commands/health.js +1302 -0
  29. package/dist/commands/help/help-accept.md +12 -0
  30. package/dist/commands/help/help-improve.md +69 -0
  31. package/dist/commands/help/help-proposals.md +18 -0
  32. package/dist/commands/help/help-propose.md +17 -0
  33. package/dist/commands/help/help-reject.md +11 -0
  34. package/dist/commands/history.js +54 -46
  35. package/dist/commands/improve-auto-accept.js +97 -0
  36. package/dist/commands/improve-cli.js +217 -0
  37. package/dist/commands/improve-profiles.js +166 -0
  38. package/dist/commands/improve-result-file.js +167 -0
  39. package/dist/commands/improve.js +2373 -0
  40. package/dist/commands/info.js +5 -2
  41. package/dist/commands/init.js +50 -2
  42. package/dist/commands/installed-stashes.js +102 -139
  43. package/dist/commands/knowledge.js +136 -0
  44. package/dist/commands/lint/agent-linter.js +49 -0
  45. package/dist/commands/lint/base-linter.js +479 -0
  46. package/dist/commands/lint/command-linter.js +49 -0
  47. package/dist/commands/lint/default-linter.js +16 -0
  48. package/dist/commands/lint/env-key-rules.js +154 -0
  49. package/dist/commands/lint/index.js +196 -0
  50. package/dist/commands/lint/knowledge-linter.js +16 -0
  51. package/dist/commands/lint/markdown-insertion.js +343 -0
  52. package/dist/commands/lint/memory-linter.js +61 -0
  53. package/dist/commands/lint/registry.js +36 -0
  54. package/dist/commands/lint/skill-linter.js +45 -0
  55. package/dist/commands/lint/task-linter.js +50 -0
  56. package/dist/commands/lint/types.js +4 -0
  57. package/dist/commands/lint/workflow-linter.js +56 -0
  58. package/dist/commands/lint.js +4 -0
  59. package/dist/commands/migration-help.js +5 -2
  60. package/dist/commands/proposal.js +67 -12
  61. package/dist/commands/propose.js +86 -31
  62. package/dist/commands/reflect.js +1091 -73
  63. package/dist/commands/registry-cli.js +150 -0
  64. package/dist/commands/registry-search.js +5 -2
  65. package/dist/commands/remember-cli.js +257 -0
  66. package/dist/commands/remember.js +69 -6
  67. package/dist/commands/schema-repair.js +203 -0
  68. package/dist/commands/search.js +115 -14
  69. package/dist/commands/secret.js +173 -0
  70. package/dist/commands/self-update.js +3 -0
  71. package/dist/commands/show.js +148 -25
  72. package/dist/commands/source-add.js +17 -45
  73. package/dist/commands/source-clone.js +3 -0
  74. package/dist/commands/source-manage.js +14 -19
  75. package/dist/commands/tasks.js +437 -0
  76. package/dist/commands/url-checker.js +42 -0
  77. package/dist/core/action-contributors.js +28 -0
  78. package/dist/core/asset-ref.js +17 -2
  79. package/dist/core/asset-registry.js +12 -17
  80. package/dist/core/asset-serialize.js +88 -0
  81. package/dist/core/asset-spec.js +67 -1
  82. package/dist/core/common.js +182 -0
  83. package/dist/core/concurrent.js +25 -0
  84. package/dist/core/config-io.js +347 -0
  85. package/dist/core/config-migration.js +622 -0
  86. package/dist/core/config-schema.js +534 -0
  87. package/dist/core/config-sources.js +108 -0
  88. package/dist/core/config-types.js +4 -0
  89. package/dist/core/config-walker.js +337 -0
  90. package/dist/core/config.js +364 -981
  91. package/dist/core/errors.js +42 -20
  92. package/dist/core/events.js +91 -138
  93. package/dist/core/file-lock.js +104 -0
  94. package/dist/core/frontmatter.js +75 -8
  95. package/dist/core/lesson-lint.js +3 -0
  96. package/dist/core/markdown.js +20 -0
  97. package/dist/core/memory-belief.js +62 -0
  98. package/dist/core/memory-contradiction-detect.js +274 -0
  99. package/dist/core/memory-improve.js +806 -0
  100. package/dist/core/parse.js +158 -0
  101. package/dist/core/paths.js +280 -14
  102. package/dist/core/proposal-quality-validators.js +380 -0
  103. package/dist/core/proposal-validators.js +69 -0
  104. package/dist/core/proposals.js +512 -42
  105. package/dist/core/state-db.js +1068 -0
  106. package/dist/core/text-truncation.js +107 -0
  107. package/dist/core/time.js +54 -0
  108. package/dist/core/tty.js +59 -0
  109. package/dist/core/warn.js +64 -1
  110. package/dist/core/write-source.js +3 -0
  111. package/dist/indexer/db-backup.js +391 -0
  112. package/dist/indexer/db-search.js +178 -256
  113. package/dist/indexer/db.js +975 -103
  114. package/dist/indexer/ensure-index.js +64 -0
  115. package/dist/indexer/file-context.js +3 -0
  116. package/dist/indexer/graph-boost.js +376 -101
  117. package/dist/indexer/graph-db.js +391 -0
  118. package/dist/indexer/graph-dedup.js +95 -0
  119. package/dist/indexer/graph-extraction.js +550 -124
  120. package/dist/indexer/index-context.js +4 -0
  121. package/dist/indexer/indexer.js +523 -301
  122. package/dist/indexer/llm-cache.js +52 -0
  123. package/dist/indexer/manifest.js +3 -0
  124. package/dist/indexer/matchers.js +167 -160
  125. package/dist/indexer/memory-inference.js +152 -74
  126. package/dist/indexer/metadata-contributors.js +29 -0
  127. package/dist/indexer/metadata.js +275 -196
  128. package/dist/indexer/path-resolver.js +92 -0
  129. package/dist/indexer/project-context.js +192 -0
  130. package/dist/indexer/ranking-contributors.js +331 -0
  131. package/dist/indexer/ranking.js +81 -0
  132. package/dist/indexer/search-fields.js +5 -9
  133. package/dist/indexer/search-hit-enrichers.js +111 -0
  134. package/dist/indexer/search-source.js +44 -10
  135. package/dist/indexer/semantic-status.js +6 -17
  136. package/dist/indexer/staleness-detect.js +447 -0
  137. package/dist/indexer/usage-events.js +12 -9
  138. package/dist/indexer/walker.js +28 -0
  139. package/dist/integrations/agent/builders.js +135 -0
  140. package/dist/integrations/agent/config.js +122 -230
  141. package/dist/integrations/agent/detect.js +3 -0
  142. package/dist/integrations/agent/index.js +7 -13
  143. package/dist/integrations/agent/model-aliases.js +55 -0
  144. package/dist/integrations/agent/profiles.js +70 -5
  145. package/dist/integrations/agent/prompts.js +214 -80
  146. package/dist/integrations/agent/runner.js +151 -0
  147. package/dist/integrations/agent/sdk-runner.js +126 -0
  148. package/dist/integrations/agent/spawn.js +118 -23
  149. package/dist/integrations/github.js +3 -0
  150. package/dist/integrations/lockfile.js +32 -69
  151. package/dist/integrations/session-logs/index.js +69 -0
  152. package/dist/integrations/session-logs/inline-refs.js +35 -0
  153. package/dist/integrations/session-logs/pre-filter.js +152 -0
  154. package/dist/integrations/session-logs/providers/claude-code.js +282 -0
  155. package/dist/integrations/session-logs/providers/opencode.js +258 -0
  156. package/dist/integrations/session-logs/types.js +4 -0
  157. package/dist/llm/call-ai.js +62 -0
  158. package/dist/llm/client.js +77 -124
  159. package/dist/llm/embedder.js +20 -29
  160. package/dist/llm/embedders/cache.js +3 -7
  161. package/dist/llm/embedders/local.js +42 -1
  162. package/dist/llm/embedders/remote.js +20 -8
  163. package/dist/llm/embedders/types.js +3 -7
  164. package/dist/llm/feature-gate.js +95 -48
  165. package/dist/llm/graph-extract.js +676 -70
  166. package/dist/llm/index-passes.js +44 -29
  167. package/dist/llm/memory-infer.js +77 -71
  168. package/dist/llm/metadata-enhance.js +42 -29
  169. package/dist/llm/prompts/extract-session.md +80 -0
  170. package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
  171. package/dist/output/cli-hints-full.md +292 -0
  172. package/dist/output/cli-hints-short.md +66 -0
  173. package/dist/output/cli-hints.js +7 -320
  174. package/dist/output/context.js +60 -8
  175. package/dist/output/renderers.js +300 -257
  176. package/dist/output/shapes/curate.js +56 -0
  177. package/dist/output/shapes/distill.js +10 -0
  178. package/dist/output/shapes/env-list.js +19 -0
  179. package/dist/output/shapes/events.js +11 -0
  180. package/dist/output/shapes/helpers.js +424 -0
  181. package/dist/output/shapes/history.js +7 -0
  182. package/dist/output/shapes/passthrough.js +102 -0
  183. package/dist/output/shapes/proposal-accept.js +7 -0
  184. package/dist/output/shapes/proposal-diff.js +7 -0
  185. package/dist/output/shapes/proposal-list.js +7 -0
  186. package/dist/output/shapes/proposal-producer.js +11 -0
  187. package/dist/output/shapes/proposal-reject.js +7 -0
  188. package/dist/output/shapes/proposal-show.js +7 -0
  189. package/dist/output/shapes/registry-search.js +6 -0
  190. package/dist/output/shapes/registry.js +30 -0
  191. package/dist/output/shapes/search.js +6 -0
  192. package/dist/output/shapes/secret-list.js +19 -0
  193. package/dist/output/shapes/show.js +6 -0
  194. package/dist/output/shapes/vault-list.js +19 -0
  195. package/dist/output/shapes.js +51 -516
  196. package/dist/output/text/add.js +6 -0
  197. package/dist/output/text/clone.js +6 -0
  198. package/dist/output/text/config.js +6 -0
  199. package/dist/output/text/curate.js +6 -0
  200. package/dist/output/text/distill.js +7 -0
  201. package/dist/output/text/enable-disable.js +7 -0
  202. package/dist/output/text/events.js +10 -0
  203. package/dist/output/text/feedback.js +6 -0
  204. package/dist/output/text/helpers.js +1039 -0
  205. package/dist/output/text/history.js +7 -0
  206. package/dist/output/text/import.js +6 -0
  207. package/dist/output/text/index.js +6 -0
  208. package/dist/output/text/info.js +6 -0
  209. package/dist/output/text/init.js +6 -0
  210. package/dist/output/text/list.js +6 -0
  211. package/dist/output/text/proposal-producer.js +8 -0
  212. package/dist/output/text/proposal.js +11 -0
  213. package/dist/output/text/registry-commands.js +11 -0
  214. package/dist/output/text/registry.js +30 -0
  215. package/dist/output/text/remember.js +6 -0
  216. package/dist/output/text/remove.js +6 -0
  217. package/dist/output/text/save.js +6 -0
  218. package/dist/output/text/search.js +6 -0
  219. package/dist/output/text/show.js +6 -0
  220. package/dist/output/text/update.js +6 -0
  221. package/dist/output/text/upgrade.js +6 -0
  222. package/dist/output/text/vault.js +16 -0
  223. package/dist/output/text/wiki.js +15 -0
  224. package/dist/output/text/workflow.js +14 -0
  225. package/dist/output/text.js +44 -1092
  226. package/dist/registry/build-index.js +3 -0
  227. package/dist/registry/create-provider-registry.js +3 -0
  228. package/dist/registry/factory.js +4 -1
  229. package/dist/registry/origin-resolve.js +3 -0
  230. package/dist/registry/providers/index.js +3 -0
  231. package/dist/registry/providers/skills-sh.js +71 -50
  232. package/dist/registry/providers/static-index.js +53 -48
  233. package/dist/registry/providers/types.js +3 -24
  234. package/dist/registry/resolve.js +11 -16
  235. package/dist/registry/types.js +3 -0
  236. package/dist/scripts/migrate-storage.js +17750 -0
  237. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9031 -0
  238. package/dist/scripts/migrations/v16-to-v17.js +141 -0
  239. package/dist/setup/detect.js +3 -0
  240. package/dist/setup/ripgrep-install.js +3 -0
  241. package/dist/setup/ripgrep-resolve.js +3 -0
  242. package/dist/setup/setup.js +775 -37
  243. package/dist/setup/steps.js +3 -15
  244. package/dist/sources/include.js +3 -0
  245. package/dist/sources/provider-factory.js +5 -12
  246. package/dist/sources/provider.js +3 -20
  247. package/dist/sources/providers/filesystem.js +19 -23
  248. package/dist/sources/providers/git.js +138 -21
  249. package/dist/sources/providers/index.js +3 -0
  250. package/dist/sources/providers/install-types.js +3 -13
  251. package/dist/sources/providers/npm.js +3 -4
  252. package/dist/sources/providers/provider-utils.js +3 -0
  253. package/dist/sources/providers/sync-from-ref.js +3 -11
  254. package/dist/sources/providers/tar-utils.js +3 -0
  255. package/dist/sources/providers/website.js +18 -22
  256. package/dist/sources/resolve.js +3 -0
  257. package/dist/sources/types.js +3 -0
  258. package/dist/sources/website-ingest.js +7 -0
  259. package/dist/tasks/backends/cron.js +203 -0
  260. package/dist/tasks/backends/exec-utils.js +28 -0
  261. package/dist/tasks/backends/index.js +24 -0
  262. package/dist/tasks/backends/launchd-template.xml +19 -0
  263. package/dist/tasks/backends/launchd.js +187 -0
  264. package/dist/tasks/backends/schtasks-template.xml +29 -0
  265. package/dist/tasks/backends/schtasks.js +215 -0
  266. package/dist/tasks/parser.js +211 -0
  267. package/dist/tasks/resolveAkmBin.js +87 -0
  268. package/dist/tasks/runner.js +458 -0
  269. package/dist/tasks/schedule.js +227 -0
  270. package/dist/tasks/schema.js +15 -0
  271. package/dist/tasks/validator.js +62 -0
  272. package/dist/version.js +3 -0
  273. package/dist/wiki/index-template.md +12 -0
  274. package/dist/wiki/ingest-workflow-template.md +54 -0
  275. package/dist/wiki/log-template.md +8 -0
  276. package/dist/wiki/schema-template.md +61 -0
  277. package/dist/wiki/wiki-templates.js +15 -0
  278. package/dist/wiki/wiki.js +13 -61
  279. package/dist/workflows/authoring.js +8 -25
  280. package/dist/workflows/cli.js +3 -0
  281. package/dist/workflows/db.js +140 -10
  282. package/dist/workflows/document-cache.js +3 -10
  283. package/dist/workflows/parser.js +3 -0
  284. package/dist/workflows/renderer.js +11 -3
  285. package/dist/workflows/runs.js +77 -92
  286. package/dist/workflows/schema.js +3 -0
  287. package/dist/workflows/scope-key.js +3 -0
  288. package/dist/workflows/validator.js +4 -8
  289. package/dist/workflows/workflow-template.md +24 -0
  290. package/docs/README.md +10 -2
  291. package/docs/data-and-telemetry.md +225 -0
  292. package/docs/migration/release-notes/0.7.0.md +1 -1
  293. package/docs/migration/release-notes/0.7.5.md +2 -2
  294. package/docs/migration/release-notes/0.8.0.md +48 -0
  295. package/docs/migration/v0.7-to-v0.8.md +1307 -0
  296. package/package.json +30 -12
  297. package/.github/LICENSE +0 -374
  298. package/dist/commands/install-audit.js +0 -381
  299. package/dist/commands/vault.js +0 -328
  300. package/dist/templates/wiki-templates.js +0 -100
@@ -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
@@ -9,9 +12,67 @@
9
12
  * behind a single entry point.
10
13
  */
11
14
  import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { ASSET_SPECS, TYPE_DIRS } from "../core/asset-spec";
12
17
  import { getDbPath } from "../core/paths";
13
18
  import { warn } from "../core/warn";
14
19
  import { closeDatabase, getEntryCount, getMeta, openExistingDatabase } from "./db";
20
+ function getIndexableFiles(root, spec) {
21
+ if (!fs.existsSync(root))
22
+ return [];
23
+ const files = [];
24
+ const stack = [root];
25
+ while (stack.length > 0) {
26
+ const current = stack.pop();
27
+ if (!current)
28
+ continue;
29
+ let entries;
30
+ try {
31
+ entries = fs.readdirSync(current, { withFileTypes: true });
32
+ }
33
+ catch {
34
+ continue;
35
+ }
36
+ for (const entry of entries) {
37
+ if (entry.name === ".stash.json")
38
+ continue;
39
+ const fullPath = path.join(current, entry.name);
40
+ if (entry.isSymbolicLink())
41
+ continue;
42
+ if (entry.isDirectory()) {
43
+ if (entry.name.startsWith("."))
44
+ continue;
45
+ stack.push(fullPath);
46
+ continue;
47
+ }
48
+ if (entry.isFile() && spec.isRelevantFile(entry.name)) {
49
+ files.push(fullPath);
50
+ }
51
+ }
52
+ }
53
+ return files;
54
+ }
55
+ function hasNewerIndexableFiles(stashDir, builtAt) {
56
+ if (!builtAt)
57
+ return true;
58
+ const builtAtMs = new Date(builtAt).getTime();
59
+ if (!Number.isFinite(builtAtMs))
60
+ return true;
61
+ for (const [type, spec] of Object.entries(ASSET_SPECS)) {
62
+ const typeRoot = path.join(stashDir, TYPE_DIRS[type] ?? spec.stashDir);
63
+ const files = getIndexableFiles(typeRoot, spec);
64
+ for (const file of files) {
65
+ try {
66
+ if (fs.statSync(file).mtimeMs > builtAtMs)
67
+ return true;
68
+ }
69
+ catch {
70
+ return true;
71
+ }
72
+ }
73
+ }
74
+ return false;
75
+ }
15
76
  /**
16
77
  * Check whether the local index is stale relative to the given stash directory.
17
78
  * Returns `true` when the index is missing, empty, or was built against a
@@ -27,6 +88,9 @@ export function isIndexStale(stashDir) {
27
88
  const entryCount = getEntryCount(db);
28
89
  if (entryCount === 0)
29
90
  return true;
91
+ const builtAt = getMeta(db, "builtAt");
92
+ if (hasNewerIndexableFiles(stashDir, builtAt))
93
+ return true;
30
94
  const storedStashDir = getMeta(db, "stashDir");
31
95
  if (storedStashDir !== stashDir) {
32
96
  // Check if the incoming stashDir appears in the stored stashDirs array
@@ -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,24 +1,43 @@
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/.
4
+ import { loadStoredGraphMeta, loadStoredGraphSnapshot } from "./graph-db";
5
+ function normalizeGraphName(value) {
6
+ return value.trim().toLowerCase();
7
+ }
8
+ let cachedParsedGraph;
1
9
  /**
2
- * Search-time graph-boost integration for the `akm index` graph pass (#207).
10
+ * Clear the module-level parsed-graph cache.
3
11
  *
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,
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.
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.
9
19
  *
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/stale/unparseable `graph.json` → boost is `0`. The pipeline
17
- * degrades gracefully to its non-graph behaviour, exactly as today.
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.
18
25
  */
19
- import fs from "node:fs";
20
- import { warn } from "../core/warn";
21
- import { GRAPH_FILE_SCHEMA_VERSION, getGraphFilePath } from "./graph-extraction";
26
+ export function resetGraphBoostCache() {
27
+ cachedParsedGraph = undefined;
28
+ }
29
+ function resolveGraphBoostWeights(config) {
30
+ const configured = config?.search?.graphBoost;
31
+ return {
32
+ directBoostPerEntity: configured?.directBoostPerEntity ?? GRAPH_DIRECT_BOOST_PER_ENTITY,
33
+ directBoostCap: configured?.directBoostCap ?? GRAPH_DIRECT_BOOST_CAP,
34
+ hopBoostPerEntity: configured?.hopBoostPerEntity ?? GRAPH_HOP_BOOST_PER_ENTITY,
35
+ hopBoostCap: configured?.hopBoostCap ?? GRAPH_HOP_BOOST_CAP,
36
+ maxHops: Math.min(Math.max(configured?.maxHops ?? GRAPH_MAX_HOPS, 1), GRAPH_MAX_HOPS_HARD_CAP),
37
+ confidenceMode: configured?.confidenceMode ?? GRAPH_CONFIDENCE_MODE,
38
+ confidenceWeight: configured?.confidenceWeight ?? GRAPH_CONFIDENCE_WEIGHT,
39
+ };
40
+ }
22
41
  /**
23
42
  * Per-entry weights, exposed as constants so tests can read them and so the
24
43
  * single-source-of-truth for "how much does the graph contribute" is here
@@ -29,20 +48,47 @@ export const GRAPH_DIRECT_BOOST_PER_ENTITY = 0.25;
29
48
  export const GRAPH_DIRECT_BOOST_CAP = 0.75;
30
49
  export const GRAPH_HOP_BOOST_PER_ENTITY = 0.1;
31
50
  export const GRAPH_HOP_BOOST_CAP = 0.3;
51
+ export const GRAPH_MAX_HOPS = 1;
52
+ export const GRAPH_CONFIDENCE_MODE = "blend";
53
+ export const GRAPH_CONFIDENCE_WEIGHT = 0.2;
54
+ const GRAPH_MAX_HOPS_HARD_CAP = 3;
55
+ function normalizeConfidence(raw) {
56
+ if (typeof raw !== "number" || !Number.isFinite(raw))
57
+ return undefined;
58
+ return Math.max(0, Math.min(1, raw));
59
+ }
60
+ function combineConfidence(...parts) {
61
+ let out;
62
+ for (const part of parts) {
63
+ const value = normalizeConfidence(part);
64
+ if (value === undefined)
65
+ continue;
66
+ out = out === undefined ? value : out * value;
67
+ }
68
+ return out;
69
+ }
70
+ function toConfidenceMultiplier(rawConfidence, weights) {
71
+ if (weights.confidenceMode === "off")
72
+ return 1;
73
+ const confidence = normalizeConfidence(rawConfidence) ?? 1;
74
+ if (weights.confidenceMode === "multiply")
75
+ return confidence;
76
+ const blendWeight = Math.max(0, Math.min(1, weights.confidenceWeight));
77
+ return 1 - blendWeight + blendWeight * confidence;
78
+ }
32
79
  /**
33
80
  * Load the graph file for a stash root and pre-compute everything that's
34
81
  * 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`).
82
+ * - No graph snapshot exists in SQLite.
39
83
  * - The query produces no token-level entity matches (no boost is
40
84
  * possible, so we skip the per-entry overhead entirely).
41
85
  */
42
- export function loadGraphBoostContext(stashRoot, query) {
43
- const graph = readGraphFile(stashRoot);
44
- if (!graph)
86
+ export function loadGraphBoostContext(stashRoot, query, config, db) {
87
+ const stashRoots = Array.isArray(stashRoot) ? stashRoot : [stashRoot];
88
+ const parsed = readParsedGraphContext(stashRoots, db);
89
+ if (!parsed)
45
90
  return null;
91
+ const weights = resolveGraphBoostWeights(config);
46
92
  const queryTokens = query
47
93
  .toLowerCase()
48
94
  .split(/[\s\-_/]+/)
@@ -53,20 +99,21 @@ export function loadGraphBoostContext(stashRoot, query) {
53
99
  // is small (capped per-asset at extract time) and lets the per-entry
54
100
  // path do a single set membership test.
55
101
  const allEntities = new Set();
56
- const nodesByPath = new Map();
57
- for (const node of graph.files) {
58
- nodesByPath.set(node.path, node);
102
+ for (const node of parsed.graph.files) {
59
103
  for (const entity of node.entities)
60
104
  allEntities.add(entity);
61
105
  }
62
106
  // 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.
107
+ // contains a query token. Matching is case-insensitive; the graph keeps
108
+ // canonical display strings and we normalize only for comparisons here.
65
109
  const matchedEntities = new Set();
66
110
  for (const entity of allEntities) {
67
- const entityTokens = entity.split(/[\s\-_/]+/).filter(Boolean);
111
+ const normalizedEntity = normalizeGraphName(entity);
112
+ const entityTokens = normalizedEntity.split(/[\s\-_/]+/).filter(Boolean);
68
113
  for (const qt of queryTokens) {
69
- if (entity === qt || entity.includes(qt) || entityTokens.some((et) => et === qt)) {
114
+ if (normalizedEntity === qt ||
115
+ normalizedEntity.includes(qt) ||
116
+ entityTokens.some((et) => et === qt || et.includes(qt))) {
70
117
  matchedEntities.add(entity);
71
118
  break;
72
119
  }
@@ -74,20 +121,47 @@ export function loadGraphBoostContext(stashRoot, query) {
74
121
  }
75
122
  if (matchedEntities.size === 0)
76
123
  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);
124
+ const connectedEntities = new Set();
125
+ const connectedConfidence = new Map();
126
+ const visited = new Set();
127
+ let frontier = new Map();
128
+ for (const entity of matchedEntities) {
129
+ const seed = parsed.entityConfidence.get(entity) ?? 1;
130
+ frontier.set(entity, seed);
131
+ visited.add(entity);
132
+ }
133
+ for (let hop = 1; hop <= weights.maxHops; hop += 1) {
134
+ const next = new Map();
135
+ for (const [entity, pathConfidence] of frontier.entries()) {
136
+ const neighbors = parsed.adjacency.get(entity);
137
+ if (!neighbors)
138
+ continue;
139
+ for (const [neighbor, edgeConfidence] of neighbors.entries()) {
140
+ const neighborPathConfidence = Math.max(0, Math.min(1, pathConfidence * edgeConfidence));
141
+ const currentBest = connectedConfidence.get(neighbor) ?? 0;
142
+ if (neighborPathConfidence > currentBest)
143
+ connectedConfidence.set(neighbor, neighborPathConfidence);
144
+ if (visited.has(neighbor))
145
+ continue;
146
+ visited.add(neighbor);
147
+ next.set(neighbor, Math.max(next.get(neighbor) ?? 0, neighborPathConfidence));
148
+ connectedEntities.add(neighbor);
87
149
  }
88
150
  }
151
+ if (next.size === 0)
152
+ break;
153
+ frontier = next;
89
154
  }
90
- return { nodesByPath, matchedEntities, oneHopEntities };
155
+ return {
156
+ graph: parsed.graph,
157
+ nodesByPath: parsed.nodesByPath,
158
+ matchedEntities,
159
+ connectedEntities,
160
+ connectedConfidence,
161
+ entityConfidence: parsed.entityConfidence,
162
+ adjacency: parsed.adjacency,
163
+ weights,
164
+ };
91
165
  }
92
166
  /**
93
167
  * Compute the graph-boost contribution for a single scored entry.
@@ -100,80 +174,281 @@ export function computeGraphBoost(context, filePath) {
100
174
  const node = context.nodesByPath.get(filePath);
101
175
  if (!node)
102
176
  return 0;
103
- let directHits = 0;
104
- let hopHits = 0;
177
+ let directBoostRaw = 0;
178
+ let hopBoostRaw = 0;
105
179
  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;
180
+ if (context.matchedEntities.has(entity)) {
181
+ const directConfidence = combineConfidence(node.confidence, context.entityConfidence.get(entity));
182
+ directBoostRaw +=
183
+ context.weights.directBoostPerEntity * toConfidenceMultiplier(directConfidence, context.weights);
184
+ }
185
+ else if (context.connectedEntities.has(entity)) {
186
+ const hopConfidence = combineConfidence(node.confidence, context.entityConfidence.get(entity), context.connectedConfidence.get(entity));
187
+ hopBoostRaw += context.weights.hopBoostPerEntity * toConfidenceMultiplier(hopConfidence, context.weights);
188
+ }
110
189
  }
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);
190
+ const directBoost = Math.min(context.weights.directBoostCap, directBoostRaw);
191
+ const hopBoost = Math.min(context.weights.hopBoostCap, hopBoostRaw);
113
192
  return directBoost + hopBoost;
114
193
  }
194
+ export function collectGraphRelatedHit(context, filePath) {
195
+ const node = context.nodesByPath.get(filePath);
196
+ if (!node)
197
+ return null;
198
+ const entities = [];
199
+ for (const entity of node.entities) {
200
+ if (context.matchedEntities.has(entity)) {
201
+ entities.push({
202
+ name: entity,
203
+ kind: "matched",
204
+ ...(context.entityConfidence.get(entity) !== undefined
205
+ ? { confidence: context.entityConfidence.get(entity) }
206
+ : {}),
207
+ });
208
+ continue;
209
+ }
210
+ if (context.connectedEntities.has(entity)) {
211
+ entities.push({
212
+ name: entity,
213
+ kind: "connected",
214
+ ...(context.connectedConfidence.get(entity) !== undefined
215
+ ? { confidence: context.connectedConfidence.get(entity) }
216
+ : {}),
217
+ });
218
+ }
219
+ }
220
+ if (entities.length === 0)
221
+ return null;
222
+ const relatedNames = new Set(entities.map((entity) => entity.name));
223
+ const relations = node.relations
224
+ .filter((relation) => relatedNames.has(relation.from) || relatedNames.has(relation.to))
225
+ .map((relation) => ({
226
+ from: relation.from,
227
+ to: relation.to,
228
+ ...(relation.type ? { type: relation.type } : {}),
229
+ ...(normalizeConfidence(relation.confidence) !== undefined
230
+ ? { confidence: normalizeConfidence(relation.confidence) }
231
+ : {}),
232
+ }));
233
+ return {
234
+ path: filePath,
235
+ type: node.type,
236
+ entities: entities.sort((a, b) => a.name.localeCompare(b.name)),
237
+ relations,
238
+ };
239
+ }
115
240
  /**
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.
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).
119
251
  */
120
- function readGraphFile(stashRoot) {
121
- const target = getGraphFilePath(stashRoot);
122
- let raw;
252
+ export function listRelatedPathsForFile(stashRoot, filePath, limit = 5, db) {
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.
256
+ return [];
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;
123
261
  try {
124
- raw = fs.readFileSync(target, "utf8");
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;
125
266
  }
126
267
  catch {
127
- // Missing → no boost. Not an error: the user simply hasn't enabled
128
- // graph extraction yet, or the pass hasn't run.
129
- return null;
268
+ return [];
130
269
  }
131
- let parsed;
270
+ if (targetEntryId == null)
271
+ return [];
272
+ const effectiveLimit = Math.max(1, limit);
273
+ // Shared-entity count per candidate entry_id.
274
+ let candidateRows;
132
275
  try {
133
- parsed = JSON.parse(raw);
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);
134
294
  }
135
- catch (err) {
136
- warn(`graph boost: failed to parse ${target}: ${err instanceof Error ? err.message : String(err)}`);
137
- return null;
295
+ catch {
296
+ return [];
138
297
  }
139
- if (!isGraphFile(parsed) || parsed.schemaVersion !== GRAPH_FILE_SCHEMA_VERSION) {
140
- return null;
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)
333
+ continue;
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
+ }
141
337
  }
142
- return parsed;
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
+ }
351
+ }
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
+ });
143
367
  }
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;
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);
384
+ }
385
+ /**
386
+ * Load and normalize graph data from SQLite once, then reuse it across all
387
+ * per-entry boost lookups in the current query.
388
+ */
389
+ function readParsedGraphContext(stashRoots, db) {
390
+ const sortedRoots = [...new Set(stashRoots)].sort((a, b) => a.localeCompare(b));
391
+ if (sortedRoots.length === 0)
392
+ return null;
393
+ const metas = sortedRoots
394
+ .map((stashRoot) => loadStoredGraphMeta(stashRoot, db))
395
+ .filter((meta) => meta !== null);
396
+ if (metas.length === 0)
397
+ return null;
398
+ const cacheKey = metas.map((meta) => `${meta.stashPath}\u0000${meta.generatedAt}`).join("\u0001");
399
+ if (cachedParsedGraph && cachedParsedGraph.cacheKey === cacheKey)
400
+ return cachedParsedGraph.context;
401
+ const snapshots = metas
402
+ .map((meta) => loadStoredGraphSnapshot(meta.stashPath, db))
403
+ .filter((snapshot) => snapshot !== null);
404
+ if (snapshots.length === 0)
405
+ return null;
406
+ const graph = {
407
+ schemaVersion: Math.max(...snapshots.map((snapshot) => snapshot.schemaVersion)),
408
+ generatedAt: snapshots
409
+ .map((snapshot) => snapshot.generatedAt)
410
+ .sort()
411
+ .at(-1) ?? new Date(0).toISOString(),
412
+ stashRoot: snapshots[0]?.stashPath ?? "",
413
+ files: snapshots.flatMap((snapshot) => snapshot.files),
414
+ entities: [...new Set(snapshots.flatMap((snapshot) => snapshot.entities))],
415
+ relations: snapshots.flatMap((snapshot) => snapshot.relations),
416
+ };
417
+ const nodesByPath = new Map();
418
+ const entityConfidence = new Map();
419
+ const adjacency = new Map();
420
+ function setBestEntityConfidence(entity, confidence) {
421
+ const normalized = normalizeConfidence(confidence);
422
+ if (normalized === undefined)
423
+ return;
424
+ const current = entityConfidence.get(entity);
425
+ if (current === undefined || normalized > current)
426
+ entityConfidence.set(entity, normalized);
427
+ }
428
+ function setBestEdgeConfidence(from, to, confidence) {
429
+ const normalized = normalizeConfidence(confidence);
430
+ if (!adjacency.has(from))
431
+ adjacency.set(from, new Map());
432
+ const neighbors = adjacency.get(from);
433
+ if (!neighbors)
434
+ return;
435
+ const current = neighbors.get(to);
436
+ const next = normalized ?? 1;
437
+ if (current === undefined || next > current)
438
+ neighbors.set(to, next);
439
+ }
440
+ for (const node of graph.files) {
441
+ nodesByPath.set(node.path, node);
442
+ for (const entity of node.entities) {
443
+ setBestEntityConfidence(entity, node.confidence);
444
+ }
445
+ for (const rel of node.relations) {
446
+ const edgeConfidence = combineConfidence(node.confidence, rel.confidence);
447
+ setBestEdgeConfidence(rel.from, rel.to, edgeConfidence);
448
+ setBestEdgeConfidence(rel.to, rel.from, edgeConfidence);
176
449
  }
177
450
  }
178
- return true;
451
+ const context = { graph, nodesByPath, entityConfidence, adjacency };
452
+ cachedParsedGraph = { cacheKey, context };
453
+ return context;
179
454
  }