akm-cli 0.9.0-beta.5 → 0.9.0-beta.51

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 (221) hide show
  1. package/CHANGELOG.md +711 -0
  2. package/README.md +12 -4
  3. package/dist/akm +38 -0
  4. package/dist/akm-migrate-storage +38 -0
  5. package/dist/assets/profiles/default.json +9 -4
  6. package/dist/assets/profiles/frequent.json +1 -1
  7. package/dist/assets/profiles/memory-focus.json +1 -1
  8. package/dist/assets/profiles/quick.json +1 -1
  9. package/dist/assets/profiles/synthesize.json +15 -0
  10. package/dist/assets/profiles/thorough.json +1 -1
  11. package/dist/assets/prompts/consolidate-system.md +23 -0
  12. package/dist/assets/prompts/contradiction-judge.md +33 -0
  13. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  14. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  15. package/dist/assets/prompts/extract-session.md +6 -2
  16. package/dist/assets/prompts/graph-extract-system.md +1 -0
  17. package/dist/assets/prompts/graph-extract-user-prompt.md +1 -1
  18. package/dist/assets/prompts/memory-infer-system.md +1 -0
  19. package/dist/assets/prompts/memory-infer-user.md +5 -0
  20. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  21. package/dist/assets/prompts/procedural-system.md +44 -0
  22. package/dist/assets/prompts/recombine-system.md +40 -0
  23. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  24. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  25. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +38 -0
  26. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
  27. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
  28. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
  29. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
  30. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
  31. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
  32. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
  33. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
  34. package/dist/assets/templates/html/health.html +281 -111
  35. package/dist/assets/wiki/ingest-workflow-template.md +38 -10
  36. package/dist/cli/parse-args.js +46 -1
  37. package/dist/cli/shared.js +28 -0
  38. package/dist/cli.js +27 -11
  39. package/dist/commands/agent/agent-dispatch.js +2 -2
  40. package/dist/commands/agent/agent-support.js +0 -7
  41. package/dist/commands/agent/contribute-cli.js +17 -4
  42. package/dist/commands/config-cli.js +18 -2
  43. package/dist/commands/env/child-env.js +47 -0
  44. package/dist/commands/env/env-cli.js +33 -26
  45. package/dist/commands/env/secret-cli.js +36 -22
  46. package/dist/commands/feedback-cli.js +15 -6
  47. package/dist/commands/graph/graph-cli.js +5 -13
  48. package/dist/commands/graph/graph.js +76 -72
  49. package/dist/commands/health/checks.js +49 -1
  50. package/dist/commands/health/html-report.js +422 -80
  51. package/dist/commands/health.js +386 -9
  52. package/dist/commands/improve/calibration.js +161 -0
  53. package/dist/commands/improve/consolidate/chunking.js +141 -0
  54. package/dist/commands/improve/consolidate/eligibility.js +81 -0
  55. package/dist/commands/improve/consolidate/merge.js +145 -0
  56. package/dist/commands/improve/consolidate/sanitize.js +231 -0
  57. package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
  58. package/dist/commands/improve/consolidate.js +635 -660
  59. package/dist/commands/improve/dedup.js +482 -0
  60. package/dist/commands/improve/distill.js +159 -69
  61. package/dist/commands/improve/eligibility.js +434 -0
  62. package/dist/commands/improve/encoding-salience.js +205 -0
  63. package/dist/commands/improve/extract-cli.js +124 -2
  64. package/dist/commands/improve/extract-prompt.js +39 -2
  65. package/dist/commands/improve/extract-watch.js +140 -0
  66. package/dist/commands/improve/extract.js +389 -40
  67. package/dist/commands/improve/feedback-valence.js +54 -0
  68. package/dist/commands/improve/homeostatic.js +467 -0
  69. package/dist/commands/improve/improve-auto-accept.js +138 -7
  70. package/dist/commands/improve/improve-cli.js +36 -61
  71. package/dist/commands/improve/improve-profiles.js +14 -0
  72. package/dist/commands/improve/improve-result-file.js +14 -25
  73. package/dist/commands/improve/improve-session.js +58 -0
  74. package/dist/commands/improve/improve.js +485 -2498
  75. package/dist/commands/improve/locks.js +154 -0
  76. package/dist/commands/improve/loop-stages.js +1083 -0
  77. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  78. package/dist/commands/improve/outcome-loop.js +256 -0
  79. package/dist/commands/improve/preparation.js +1966 -0
  80. package/dist/commands/improve/proactive-maintenance.js +115 -0
  81. package/dist/commands/improve/procedural.js +418 -0
  82. package/dist/commands/improve/recombine.js +850 -0
  83. package/dist/commands/improve/reflect-noise.js +0 -0
  84. package/dist/commands/improve/reflect.js +183 -40
  85. package/dist/commands/improve/salience.js +438 -0
  86. package/dist/commands/improve/triage.js +93 -0
  87. package/dist/commands/lint/agent-linter.js +19 -24
  88. package/dist/commands/lint/base-linter.js +173 -60
  89. package/dist/commands/lint/command-linter.js +19 -24
  90. package/dist/commands/lint/env-key-rules.js +38 -1
  91. package/dist/commands/lint/fact-linter.js +39 -0
  92. package/dist/commands/lint/index.js +31 -13
  93. package/dist/commands/lint/memory-linter.js +1 -1
  94. package/dist/commands/lint/registry.js +7 -2
  95. package/dist/commands/lint/task-linter.js +3 -3
  96. package/dist/commands/lint/workflow-linter.js +26 -1
  97. package/dist/commands/proposal/drain-policies.js +5 -0
  98. package/dist/commands/proposal/drain.js +43 -50
  99. package/dist/commands/proposal/proposal-cli.js +21 -31
  100. package/dist/commands/proposal/proposal.js +5 -0
  101. package/dist/commands/proposal/propose.js +7 -2
  102. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  103. package/dist/commands/proposal/validators/proposals.js +189 -63
  104. package/dist/commands/read/curate.js +414 -94
  105. package/dist/commands/read/knowledge.js +6 -3
  106. package/dist/commands/read/search-cli.js +9 -4
  107. package/dist/commands/read/search.js +10 -6
  108. package/dist/commands/read/show.js +86 -7
  109. package/dist/commands/sources/init.js +49 -17
  110. package/dist/commands/sources/installed-stashes.js +11 -3
  111. package/dist/commands/sources/schema-repair.js +43 -45
  112. package/dist/commands/sources/self-update.js +2 -2
  113. package/dist/commands/sources/source-add.js +7 -3
  114. package/dist/commands/sources/stash-cli.js +28 -40
  115. package/dist/commands/sources/stash-skeleton.js +23 -8
  116. package/dist/commands/tasks/tasks-cli.js +19 -27
  117. package/dist/commands/tasks/tasks.js +39 -11
  118. package/dist/commands/wiki-cli.js +21 -35
  119. package/dist/core/asset/asset-registry.js +3 -1
  120. package/dist/core/asset/asset-spec.js +18 -2
  121. package/dist/core/asset/frontmatter.js +166 -167
  122. package/dist/core/asset/markdown.js +8 -0
  123. package/dist/core/authoring-rules.js +92 -0
  124. package/dist/core/common.js +0 -5
  125. package/dist/core/config/config-migration.js +12 -11
  126. package/dist/core/config/config-schema.js +340 -56
  127. package/dist/core/config/config-types.js +3 -3
  128. package/dist/core/config/config.js +28 -7
  129. package/dist/core/events.js +3 -7
  130. package/dist/core/improve-types.js +11 -8
  131. package/dist/core/logs-db.js +10 -66
  132. package/dist/core/parse.js +36 -16
  133. package/dist/core/paths.js +3 -0
  134. package/dist/core/standards/resolve-standards-context.js +87 -0
  135. package/dist/core/standards/resolve-stash-standards.js +99 -0
  136. package/dist/core/standards/resolve-type-conventions.js +66 -0
  137. package/dist/core/state/migrations.js +714 -0
  138. package/dist/core/state-db.js +525 -474
  139. package/dist/indexer/db/db.js +439 -247
  140. package/dist/indexer/db/graph-db.js +129 -86
  141. package/dist/indexer/ensure-index.js +152 -17
  142. package/dist/indexer/graph/graph-boost.js +51 -41
  143. package/dist/indexer/graph/graph-extraction.js +218 -4
  144. package/dist/indexer/index-writer-lock.js +99 -0
  145. package/dist/indexer/indexer.js +123 -221
  146. package/dist/indexer/passes/dir-staleness.js +114 -0
  147. package/dist/indexer/passes/memory-inference.js +13 -5
  148. package/dist/indexer/passes/staleness-detect.js +2 -5
  149. package/dist/indexer/search/db-search.js +19 -6
  150. package/dist/indexer/search/ranking-contributors.js +22 -0
  151. package/dist/indexer/search/ranking.js +4 -0
  152. package/dist/indexer/search/search-source.js +17 -18
  153. package/dist/indexer/search/semantic-status.js +4 -0
  154. package/dist/indexer/walk/matchers.js +9 -0
  155. package/dist/integrations/agent/config.js +6 -53
  156. package/dist/integrations/agent/index.js +2 -18
  157. package/dist/integrations/agent/prompts.js +75 -9
  158. package/dist/integrations/agent/runner-dispatch.js +59 -0
  159. package/dist/integrations/harnesses/claude/session-log.js +11 -1
  160. package/dist/integrations/harnesses/index.js +2 -3
  161. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  162. package/dist/integrations/harnesses/opencode-sdk/index.js +2 -2
  163. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +0 -2
  164. package/dist/integrations/session-logs/index.js +16 -0
  165. package/dist/llm/client.js +45 -15
  166. package/dist/llm/embedder.js +42 -3
  167. package/dist/llm/embedders/deterministic.js +66 -0
  168. package/dist/llm/embedders/local.js +66 -2
  169. package/dist/llm/feature-gate.js +8 -4
  170. package/dist/llm/graph-extract.js +67 -44
  171. package/dist/llm/memory-infer-impl.js +138 -0
  172. package/dist/llm/memory-infer.js +1 -127
  173. package/dist/llm/metadata-enhance.js +44 -31
  174. package/dist/llm/structured-call.js +49 -0
  175. package/dist/migrate-storage-node.mjs +8 -0
  176. package/dist/output/context.js +5 -5
  177. package/dist/output/renderers.js +74 -2
  178. package/dist/output/shapes/curate.js +14 -2
  179. package/dist/output/shapes/passthrough.js +0 -1
  180. package/dist/output/text/helpers.js +16 -1
  181. package/dist/registry/providers/skills-sh.js +21 -147
  182. package/dist/registry/providers/static-index.js +15 -157
  183. package/dist/registry/resolve.js +22 -9
  184. package/dist/runtime.js +25 -1
  185. package/dist/scripts/migrate-storage.js +2617 -1961
  186. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +759 -510
  187. package/dist/setup/setup.js +29 -8
  188. package/dist/sources/include.js +6 -2
  189. package/dist/sources/providers/filesystem.js +0 -1
  190. package/dist/sources/providers/git-install.js +210 -0
  191. package/dist/sources/providers/git-provider.js +234 -0
  192. package/dist/sources/providers/git-stash.js +248 -0
  193. package/dist/sources/providers/git.js +10 -661
  194. package/dist/sources/providers/npm.js +2 -6
  195. package/dist/sources/providers/provider-utils.js +13 -7
  196. package/dist/sources/providers/sync-from-ref.js +9 -1
  197. package/dist/sources/providers/tar-utils.js +16 -8
  198. package/dist/sources/providers/website.js +9 -5
  199. package/dist/sources/website-ingest.js +187 -29
  200. package/dist/sources/wiki-fetchers/registry.js +53 -0
  201. package/dist/sources/wiki-fetchers/youtube.js +239 -0
  202. package/dist/storage/database.js +45 -10
  203. package/dist/storage/managed-db.js +82 -0
  204. package/dist/storage/repositories/registry-cache.js +92 -0
  205. package/dist/storage/sqlite-pragmas.js +146 -0
  206. package/dist/tasks/backends/cron.js +1 -1
  207. package/dist/tasks/backends/launchd.js +1 -1
  208. package/dist/tasks/backends/schtasks.js +1 -1
  209. package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
  210. package/dist/tasks/runner.js +5 -13
  211. package/dist/text-import-hook.mjs +0 -0
  212. package/dist/wiki/wiki.js +37 -0
  213. package/dist/workflows/db.js +3 -4
  214. package/dist/workflows/runtime/runs.js +1 -117
  215. package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
  216. package/dist/workflows/validate-summary.js +2 -7
  217. package/docs/data-and-telemetry.md +3 -2
  218. package/docs/migration/release-notes/0.9.0.md +39 -0
  219. package/package.json +13 -11
  220. package/dist/commands/db-cli.js +0 -23
  221. package/dist/indexer/db/db-backup.js +0 -376
@@ -7,29 +7,34 @@ import path from "node:path";
7
7
  import { parseAssetRef } from "../../core/asset/asset-ref.js";
8
8
  import { bestEffort } from "../../core/best-effort.js";
9
9
  import { getDbPath } from "../../core/paths.js";
10
- import { REGISTRY_INDEX_CACHE_DDL } from "../../core/state-db.js";
11
10
  import { warn } from "../../core/warn.js";
12
11
  import { cosineSimilarity } from "../../llm/embedders/types.js";
13
12
  import { sha256Hex } from "../../runtime.js";
14
- import { openDatabase as openSqlite } from "../../storage/database.js";
13
+ import { openDatabase } from "../../storage/database.js";
14
+ import { applyStandardPragmas } from "../../storage/sqlite-pragmas.js";
15
15
  import { buildSearchFields } from "../search/search-fields.js";
16
16
  import { ensureUsageEventsSchema } from "../usage/usage-events.js";
17
- import { backupDataDir, EMBEDDING_DIM_CHANGE_REASON } from "./db-backup.js";
18
17
  // ── Constants ───────────────────────────────────────────────────────────────
18
+ // NOTE: schema changes are additive. DB_VERSION is a forensic stamp only — it
19
+ // no longer gates any destructive path (the old nuclear drop-and-rebuild was
20
+ // removed; index.db's idempotent CREATE … IF NOT EXISTS schema converges any
21
+ // older/partial DB forward without dropping data). Graph re-keying uses a
22
+ // TARGETED, graph-only migration (migrateGraphFilesSchema) — the model for any
23
+ // incompatible change: migrate in place, never wipe the whole index.
19
24
  export const DB_VERSION = 17;
20
25
  export const EMBEDDING_DIM = 384;
21
- export const GRAPH_SCHEMA_VERSION = 3;
26
+ // #624-P1: graph_files re-keyed to (stash_root, file_path, body_hash). Bumped 3→4
27
+ // as a marker; the actual migration is the targeted drop in migrateGraphFilesSchema.
28
+ export const GRAPH_SCHEMA_VERSION = 4;
22
29
  // ── Database lifecycle ──────────────────────────────────────────────────────
23
- export function openDatabase(dbPath, options) {
30
+ export function openIndexDatabase(dbPath, options) {
24
31
  const resolvedPath = dbPath ?? getDbPath();
25
32
  const dir = path.dirname(resolvedPath);
26
33
  if (!fs.existsSync(dir)) {
27
34
  fs.mkdirSync(dir, { recursive: true });
28
35
  }
29
- const db = openSqlite(resolvedPath);
30
- db.exec("PRAGMA journal_mode = WAL");
31
- db.exec("PRAGMA busy_timeout = 30000");
32
- db.exec("PRAGMA foreign_keys = ON");
36
+ const db = openDatabase(resolvedPath);
37
+ applyStandardPragmas(db, { dataDir: dir });
33
38
  // Try to load sqlite-vec extension
34
39
  loadVecExtension(db);
35
40
  // Dim resolution: explicit option wins; otherwise consult the on-disk
@@ -38,7 +43,7 @@ export function openDatabase(dbPath, options) {
38
43
  // both are absent do we fall through to the no-clobber path, which keeps
39
44
  // ensureSchema from touching `index_meta.embeddingDim` at all.
40
45
  const resolvedDim = options?.embeddingDim ?? resolveConfiguredEmbeddingDim();
41
- ensureSchema(db, resolvedDim, { dataDir: dir });
46
+ ensureSchema(db, resolvedDim);
42
47
  // Warn once at init if using JS fallback with many entries
43
48
  warnIfVecMissing(db, { once: true });
44
49
  return db;
@@ -67,10 +72,9 @@ function resolveConfiguredEmbeddingDim() {
67
72
  }
68
73
  export function openExistingDatabase(dbPath) {
69
74
  const resolvedPath = dbPath ?? getDbPath();
70
- const db = openSqlite(resolvedPath);
71
- db.exec("PRAGMA journal_mode = WAL");
72
- db.exec("PRAGMA busy_timeout = 30000");
73
- db.exec("PRAGMA foreign_keys = ON");
75
+ const dir = path.dirname(resolvedPath);
76
+ const db = openDatabase(resolvedPath);
77
+ applyStandardPragmas(db, { dataDir: dir });
74
78
  // Existing-DB callers must not mutate schema or embedding metadata on open,
75
79
  // but some paths still need write access to usage_events and other tables.
76
80
  loadVecExtension(db);
@@ -123,7 +127,44 @@ export function warnIfVecMissing(db, { once } = { once: false }) {
123
127
  }
124
128
  }, "embeddings table may not exist yet during init");
125
129
  }
126
- function ensureSchema(db, embeddingDim, options) {
130
+ // ── Schema ──────────────────────────────────────────────────────────────────
131
+ /**
132
+ * DDL for the `registry_index_cache` table. This table lives in index.db
133
+ * (managed by this module), so its DDL belongs here next to the `ensureSchema`
134
+ * that applies it — not in state-db.ts.
135
+ *
136
+ * Created with CREATE TABLE IF NOT EXISTS so it is safe to call inside
137
+ * `ensureSchema()`. Caches the result of resolving and fetching remote registry
138
+ * stash indexes so `akm search` does not hit the network on every invocation.
139
+ *
140
+ * Indexed (query) columns:
141
+ * registry_url TEXT PK — canonical URL of the registry; cache key.
142
+ * fetched_at TEXT — ISO-8601; used to detect stale entries (TTL).
143
+ * etag TEXT — HTTP ETag for conditional GET (If-None-Match).
144
+ * last_modified TEXT — HTTP Last-Modified for conditional GET.
145
+ *
146
+ * Non-indexed payload:
147
+ * index_json TEXT — JSON blob of the fetched registry index document.
148
+ *
149
+ * ADD COLUMN extension points (future migrations):
150
+ * ALTER TABLE registry_index_cache ADD COLUMN schema_version INTEGER DEFAULT 1;
151
+ * ALTER TABLE registry_index_cache ADD COLUMN kit_count INTEGER DEFAULT NULL;
152
+ * ALTER TABLE registry_index_cache ADD COLUMN error_message TEXT DEFAULT NULL;
153
+ */
154
+ const REGISTRY_INDEX_CACHE_DDL = `
155
+ CREATE TABLE IF NOT EXISTS registry_index_cache (
156
+ registry_url TEXT PRIMARY KEY,
157
+ fetched_at TEXT NOT NULL,
158
+ etag TEXT,
159
+ last_modified TEXT,
160
+ index_json TEXT NOT NULL DEFAULT '{}'
161
+ );
162
+
163
+ CREATE INDEX IF NOT EXISTS idx_registry_cache_fetched
164
+ ON registry_index_cache(fetched_at);
165
+ `;
166
+ /** A row backed up out of the legacy `usage_events` table during a version upgrade. */
167
+ function ensureSchema(db, embeddingDim) {
127
168
  // Create meta table first so we can check version
128
169
  db.exec(`
129
170
  CREATE TABLE IF NOT EXISTS index_meta (
@@ -131,46 +172,16 @@ function ensureSchema(db, embeddingDim, options) {
131
172
  value TEXT NOT NULL
132
173
  );
133
174
  `);
134
- // MVP DB-backup hook (0.8.x): when the stored DB version differs from the
135
- // running binary's DB_VERSION, snapshot the data directory BEFORE
136
- // `handleVersionUpgrade()` drops tables. This is best-effort
137
- // `backupDataDir` returns null on opt-out, missing data dir, low free
138
- // space, or copy errors, and we proceed with the upgrade in all cases.
139
- // The proper migration framework lands in 0.9.0; until then this lets
140
- // operators recover with `scripts/migrations/restore-data-dir.sh`.
141
- if (options?.dataDir) {
142
- const storedVersionRaw = getMeta(db, "version");
143
- const storedVersion = storedVersionRaw !== undefined && storedVersionRaw !== "" ? Number.parseInt(storedVersionRaw, 10) : null;
144
- const willUpgrade = storedVersionRaw !== undefined && storedVersionRaw !== "" && storedVersionRaw !== String(DB_VERSION);
145
- if (willUpgrade) {
146
- try {
147
- // Pass env explicitly so tests can override AKM_DB_BACKUP / AKM_DB_BACKUP_RETAIN
148
- // without mutating process.env. Production callers default to process.env.
149
- const result = backupDataDir({
150
- dataDir: options.dataDir,
151
- sourceVersion: storedVersion !== null && !Number.isNaN(storedVersion) ? storedVersion : null,
152
- targetVersion: DB_VERSION,
153
- env: process.env,
154
- });
155
- if (result) {
156
- warn("[akm] data directory backed up to %s before v%s→v%d upgrade", result.path, storedVersionRaw, DB_VERSION);
157
- }
158
- }
159
- catch (err) {
160
- // Defensive — backupDataDir already swallows most errors, but if it
161
- // throws for an unexpected reason we must still proceed with the
162
- // upgrade so the user isn't locked out of their binary.
163
- warn("[akm] pre-upgrade data dir backup raised an unexpected error — %s; upgrade will proceed without a snapshot", err instanceof Error ? err.message : String(err));
164
- }
165
- }
166
- }
167
- // Check stored version — if it differs from DB_VERSION, drop and recreate all tables.
168
- // Usage events are preserved across version upgrades so that utility score
169
- // history is not silently lost. The backup is captured here and threaded
170
- // explicitly to `restoreUsageEventsBackup` below — the previous version
171
- // attached `__usageBackup` to the Database instance via a typeless property
172
- // injection, which was a source of fragile coupling.
173
- const usageBackup = handleVersionUpgrade(db);
175
+ // index.db is a fully regenerable derived cache, so its schema is built
176
+ // idempotently below: every table is CREATE IF NOT EXISTS and column
177
+ // additions go through guarded ALTERs (ensureDerivedFromColumn) and targeted
178
+ // migrations (migrateGraphFilesSchema / migrateGraphDataFromLegacy). Opening a
179
+ // database with an older or partial schema converges it forward WITHOUT ever
180
+ // dropping data there is intentionally no "nuclear drop the whole index on a
181
+ // DB_VERSION mismatch" path (a destructive design the regenerable index never
182
+ // needed, and whose pre-drop data-dir backup it required). A genuinely
183
+ // incompatible change is handled by an additive/targeted migration; the few
184
+ // derived tables that ever must be rebuilt are regenerated by `akm index`.
174
185
  db.exec(`
175
186
  CREATE TABLE IF NOT EXISTS entries (
176
187
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -299,15 +310,27 @@ function ensureSchema(db, embeddingDim, options) {
299
310
  CREATE INDEX IF NOT EXISTS idx_llm_cache_updated
300
311
  ON llm_enrichment_cache(updated_at);
301
312
  `);
302
- // Graph extraction tables — schema v2 (entry_id PK).
313
+ // Graph extraction tables — schema v4 ((stash_root, file_path, body_hash) PK).
303
314
  //
304
- // graph_files is keyed on entries.id so child tables cascade-delete cleanly
305
- // when an entry is removed, and so JOINs from graph rows to entries are a
306
- // direct PK lookup. (stash_root, file_path) is retained as UNIQUE so the
307
- // extractor's path-based upsert still works.
315
+ // graph_files is self-keyed on (stash_root, file_path, body_hash) and is NO
316
+ // LONGER tied to entries.id. This is the #624-P1 win: deleting and
317
+ // re-inserting an entries row during a reindex no longer cascade-wipes the
318
+ // extracted graph — as long as the file's body_hash is unchanged, the graph
319
+ // data survives. body_hash is part of the PK so a content change yields a
320
+ // distinct key; a UNIQUE index on (stash_root, file_path) still enforces
321
+ // exactly one graph_files row per path (delete-then-insert on a hash change).
308
322
  //
309
- // graph_file_entities and graph_file_relations no longer duplicate file_path;
310
- // they reference entry_id and inherit stash scoping via graph_files.
323
+ // graph_file_entities and graph_file_relations carry (stash_root, file_path,
324
+ // body_hash) and declare a composite FK -> graph_files ON DELETE CASCADE so
325
+ // child rows are removed when a graph_files row is replaced.
326
+ //
327
+ // #624-P1 targeted migration: an existing DB may still hold the OLD graph_files
328
+ // (entry_id PK). SQLite can't ALTER a primary key, so we RENAME the 3 graph
329
+ // tables aside (→ *_legacy) here — ONLY the graph tables, never the index/
330
+ // embeddings — then the CREATE block below builds the new shape, then
331
+ // migrateGraphDataFromLegacy() copies the data across so the graph is PRESERVED
332
+ // (not re-extracted).
333
+ migrateGraphFilesSchema(db);
311
334
  db.exec(`
312
335
  CREATE TABLE IF NOT EXISTS graph_meta (
313
336
  stash_root TEXT PRIMARY KEY,
@@ -331,7 +354,6 @@ function ensureSchema(db, embeddingDim, options) {
331
354
  );
332
355
 
333
356
  CREATE TABLE IF NOT EXISTS graph_files (
334
- entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
335
357
  stash_root TEXT NOT NULL,
336
358
  file_path TEXT NOT NULL,
337
359
  file_order INTEGER NOT NULL,
@@ -341,26 +363,34 @@ function ensureSchema(db, embeddingDim, options) {
341
363
  status TEXT NOT NULL DEFAULT 'extracted',
342
364
  reason TEXT,
343
365
  extraction_run_id TEXT,
344
- UNIQUE(stash_root, file_path)
366
+ PRIMARY KEY (stash_root, file_path, body_hash)
345
367
  );
346
368
 
369
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_graph_files_path
370
+ ON graph_files(stash_root, file_path);
371
+
347
372
  CREATE INDEX IF NOT EXISTS idx_graph_files_stash_order
348
373
  ON graph_files(stash_root, file_order);
349
374
 
350
375
  CREATE TABLE IF NOT EXISTS graph_file_entities (
351
- entry_id INTEGER NOT NULL REFERENCES graph_files(entry_id) ON DELETE CASCADE,
352
- entity_order INTEGER NOT NULL,
353
376
  stash_root TEXT NOT NULL,
377
+ file_path TEXT NOT NULL,
378
+ body_hash TEXT NOT NULL,
379
+ entity_order INTEGER NOT NULL,
354
380
  entity_norm TEXT NOT NULL,
355
381
  entity TEXT NOT NULL,
356
- PRIMARY KEY (entry_id, entity_order)
382
+ PRIMARY KEY (stash_root, file_path, body_hash, entity_order),
383
+ FOREIGN KEY (stash_root, file_path, body_hash)
384
+ REFERENCES graph_files(stash_root, file_path, body_hash) ON DELETE CASCADE
357
385
  );
358
386
 
359
387
  CREATE INDEX IF NOT EXISTS idx_graph_file_entities_entity_norm
360
388
  ON graph_file_entities(stash_root, entity_norm);
361
389
 
362
390
  CREATE TABLE IF NOT EXISTS graph_file_relations (
363
- entry_id INTEGER NOT NULL REFERENCES graph_files(entry_id) ON DELETE CASCADE,
391
+ stash_root TEXT NOT NULL,
392
+ file_path TEXT NOT NULL,
393
+ body_hash TEXT NOT NULL,
364
394
  relation_order INTEGER NOT NULL,
365
395
  from_entity_norm TEXT NOT NULL,
366
396
  from_entity TEXT NOT NULL,
@@ -368,9 +398,31 @@ function ensureSchema(db, embeddingDim, options) {
368
398
  to_entity TEXT NOT NULL,
369
399
  relation_type TEXT,
370
400
  confidence REAL,
371
- PRIMARY KEY (entry_id, relation_order)
401
+ PRIMARY KEY (stash_root, file_path, body_hash, relation_order),
402
+ FOREIGN KEY (stash_root, file_path, body_hash)
403
+ REFERENCES graph_files(stash_root, file_path, body_hash) ON DELETE CASCADE
404
+ );
405
+
406
+ -- #624-P3: lazy graph-extraction queue. Standalone table (NO FK to
407
+ -- graph_files — a queued file by definition has no graph row yet).
408
+ -- Idempotent on (stash_root, file_path); drained highest-priority-first.
409
+ -- CREATE TABLE IF NOT EXISTS is the forward migration (no DB_VERSION bump).
410
+ CREATE TABLE IF NOT EXISTS graph_extraction_queue (
411
+ stash_root TEXT NOT NULL,
412
+ file_path TEXT NOT NULL,
413
+ body_hash TEXT NOT NULL,
414
+ queued_at TEXT NOT NULL DEFAULT (datetime('now')),
415
+ priority INTEGER NOT NULL DEFAULT 0,
416
+ PRIMARY KEY (stash_root, file_path)
372
417
  );
418
+
419
+ CREATE INDEX IF NOT EXISTS idx_graph_extraction_queue_drain
420
+ ON graph_extraction_queue(stash_root, priority DESC, queued_at);
373
421
  `);
422
+ // #624-P1 migration step 2: copy any renamed-aside legacy graph data into the
423
+ // new-shape tables (just created above), then drop the legacy tables. No-op
424
+ // unless migrateGraphFilesSchema renamed a legacy graph_files this open.
425
+ migrateGraphDataFromLegacy(db);
374
426
  // FTS-dirty queue. Created here (not lazily on first upsert) so the
375
427
  // per-entry write path doesn't issue a CREATE TABLE IF NOT EXISTS on
376
428
  // every call — that DDL would fire thousands of times during a full
@@ -399,17 +451,10 @@ function ensureSchema(db, embeddingDim, options) {
399
451
  if (dimExplicit) {
400
452
  const storedDim = getMeta(db, "embeddingDim");
401
453
  if (storedDim && storedDim !== String(embeddingDim)) {
402
- // Re-embedding the whole stash is expensive (LLM API calls + cache
403
- // misses), so snapshot the data dir before we drop the vec table and
404
- // wipe `embeddings`. This is the SAME hook the version-upgrade path
405
- // uses earlier in this function, just gated on embedding-dim mismatch
406
- // and tagged so operators can tell the two backup kinds apart.
407
- backupBeforeEmbeddingDimChange(options?.dataDir, storedDim, String(embeddingDim));
408
- bestEffort(() => db.exec("DROP TABLE IF EXISTS entries_vec"), "drop entries_vec on dim change");
409
- // Delete stale BLOB embeddings so they don't produce silently wrong
410
- // similarity scores against the new-dimension vec table.
411
- bestEffort(() => db.exec("DELETE FROM embeddings"), "delete stale embeddings on dim change");
412
- setMeta(db, "hasEmbeddings", "0");
454
+ // Stored vectors are incompatible with the new dimension. Drop the vec
455
+ // table so the block below recreates it at the new width; the BLOB rows
456
+ // go too. Regenerable from markdown re-embedded by the next index.
457
+ purgeEmbeddings(db, { dropVecTable: true });
413
458
  }
414
459
  }
415
460
  const vecExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='entries_vec'").get();
@@ -436,9 +481,8 @@ function ensureSchema(db, embeddingDim, options) {
436
481
  if (dimExplicit) {
437
482
  const storedDim = getMeta(db, "embeddingDim");
438
483
  if (storedDim && storedDim !== String(embeddingDim)) {
439
- backupBeforeEmbeddingDimChange(options?.dataDir, storedDim, String(embeddingDim));
440
- bestEffort(() => db.exec("DELETE FROM embeddings"), "delete embeddings on explicit dim change");
441
- setMeta(db, "hasEmbeddings", "0");
484
+ // JS-fallback path: no vec table, just clear the stale BLOB vectors.
485
+ purgeEmbeddings(db);
442
486
  }
443
487
  setMeta(db, "embeddingDim", String(embeddingDim));
444
488
  }
@@ -446,157 +490,28 @@ function ensureSchema(db, embeddingDim, options) {
446
490
  // Usage telemetry table
447
491
  ensureUsageEventsSchema(db);
448
492
  // Registry index cache table — caches remote registry index documents so
449
- // `akm search` does not hit the network on every invocation. The DDL is
450
- // defined in state-db.ts and shared here to avoid duplication.
493
+ // `akm search` does not hit the network on every invocation.
451
494
  db.exec(REGISTRY_INDEX_CACHE_DDL);
452
- // Restore usage_events backed up by the version-upgrade path above.
453
- restoreUsageEventsBackup(db, usageBackup);
454
- }
455
- /**
456
- * Detect a stored DB version that differs from {@link DB_VERSION}, drop the
457
- * old schema, and return a backup of the previous `usage_events` rows so the
458
- * rest of `ensureSchema()` can restore them once the new table exists.
459
- *
460
- * Returns an empty array when no upgrade is needed or when the previous
461
- * `usage_events` table is unreadable.
462
- */
463
- function handleVersionUpgrade(db) {
464
- const storedVersion = getMeta(db, "version");
465
- // BUG-L4: distinguish "missing" (undefined) from "present but empty" — both
466
- // were previously coerced through `!storedVersion` and treated as "no
467
- // upgrade needed", which caused fresh databases (with no version row) to
468
- // skip the upgrade path correctly, but also caused the upgrade path to be
469
- // taken when a corrupted/empty version string was persisted. The current
470
- // tables get dropped only when the stored version exists AND differs from
471
- // DB_VERSION; missing or empty version means a fresh DB and no upgrade.
472
- if (storedVersion === undefined || storedVersion === "" || storedVersion === String(DB_VERSION))
473
- return [];
474
- let usageBackup = [];
475
- bestEffort(() => {
476
- usageBackup = db.prepare("SELECT * FROM usage_events").all();
477
- }, "usage_events table may not exist in older versions");
478
- db.exec("DROP TABLE IF EXISTS utility_scores");
479
- db.exec("DROP TABLE IF EXISTS utility_scores_scoped");
480
- db.exec("DROP INDEX IF EXISTS idx_utility_scores_scoped_entry_id");
481
- db.exec("DROP TABLE IF EXISTS usage_events");
482
- db.exec("DROP TABLE IF EXISTS embeddings");
483
- db.exec("DROP TABLE IF EXISTS entries_vec");
484
- db.exec("DROP TABLE IF EXISTS entries_fts");
485
- db.exec("DROP TABLE IF EXISTS index_dir_state");
486
- db.exec("DROP TABLE IF EXISTS llm_enrichment_cache");
487
- db.exec("DROP INDEX IF EXISTS idx_llm_cache_updated");
488
- db.exec("DROP TABLE IF EXISTS graph_file_relations");
489
- db.exec("DROP TABLE IF EXISTS graph_file_entities");
490
- db.exec("DROP TABLE IF EXISTS graph_files");
491
- db.exec("DROP TABLE IF EXISTS graph_meta");
492
- db.exec("DROP TABLE IF EXISTS graph_relations");
493
- db.exec("DROP TABLE IF EXISTS graph_entities");
494
- db.exec("DROP TABLE IF EXISTS graph_nodes");
495
- db.exec("DROP TABLE IF EXISTS graph_stashes");
496
- db.exec("DROP INDEX IF EXISTS idx_entries_dir");
497
- db.exec("DROP INDEX IF EXISTS idx_entries_type");
498
- db.exec("DROP TABLE IF EXISTS entries");
499
- db.exec("DELETE FROM index_meta");
500
- warn("[akm] Index rebuilt due to version upgrade. Run 'akm index' to repopulate.");
501
- return usageBackup;
502
495
  }
503
496
  /**
504
- * Snapshot the data directory before the embedding-dimension drop path wipes
505
- * `embeddings` and recreates `entries_vec`. Re-embedding a real-world stash
506
- * is expensive (LLM calls + cache misses), so we capture the pre-drop state
507
- * here using the same MVP backup helper the version-upgrade hook uses
508
- * earlier in {@link ensureSchema}.
509
- *
510
- * The backup is tagged with the `embedding-dim-change` reason so it lands in
511
- * `<dataDir>/backups/<timestamp>-embedding-dim-change/` instead of the
512
- * version-upgrade-flavored `<timestamp>-pre-v<N>/` directory. Restoration
513
- * works identically via `scripts/migrations/restore-data-dir.sh`.
497
+ * Purge stored embeddings (BLOB rows in `embeddings`, plus the `entries_vec`
498
+ * virtual table) and mark the index as embedding-free. The single place that
499
+ * invalidates embeddings used on a dimension change, a model/provider change,
500
+ * and a full rebuild.
514
501
  *
515
- * Failures are non-fatal they downgrade to a warning and the destructive
516
- * ops run anyway, matching the version-upgrade hook's behavior so a broken
517
- * backup cannot brick a binary that bumped the configured dim. Likewise,
518
- * `AKM_DB_BACKUP=0` opts out via the same path.
519
- */
520
- function backupBeforeEmbeddingDimChange(dataDir, fromDim, toDim) {
521
- if (!dataDir)
522
- return;
523
- try {
524
- const result = backupDataDir({
525
- dataDir,
526
- // The DB version isn't changing here — pass the current DB_VERSION for
527
- // both source and target so the metadata sidecar still records the
528
- // running binary's version for forensic context.
529
- sourceVersion: DB_VERSION,
530
- targetVersion: DB_VERSION,
531
- reason: EMBEDDING_DIM_CHANGE_REASON,
532
- env: process.env,
533
- });
534
- if (result) {
535
- warn("[akm] embedding dimension changed %s→%s; data directory backed up to %s; embeddings will be regenerated", fromDim, toDim, result.path);
536
- }
537
- }
538
- catch (err) {
539
- // Defensive — backupDataDir already swallows most errors, but if it
540
- // throws for an unexpected reason we must still proceed with the drop
541
- // so the user isn't locked out of their binary on a changed dim.
542
- warn("[akm] pre-embedding-dim-change data dir backup raised an unexpected error — %s; embeddings will be regenerated without a snapshot", err instanceof Error ? err.message : String(err));
543
- }
544
- }
545
- /**
546
- * Re-insert backed-up `usage_events` rows into the freshly-created table.
502
+ * No backup: embeddings are a derived cache, fully regenerable from the markdown
503
+ * by the next `akm index`. (Recovery model decided 2026-06-25.)
547
504
  *
548
- * Wrapped in an outer try/catch because schema changes across versions may
549
- * make the backup incompatible with the new table definition; in that case
550
- * the backup is discarded silently rather than blocking startup.
505
+ * `dropVecTable: true` DROPs `entries_vec` used on a DIMENSION change, where
506
+ * the vec0 table must be recreated at the new width by the caller. The default
507
+ * clears its rows in place (same dimension, stale vectors).
551
508
  */
552
- function restoreUsageEventsBackup(db, backup) {
553
- if (backup.length === 0)
554
- return;
555
- try {
556
- // BUG-H4: introspect the *target* table's columns rather than relying on
557
- // `row[0]`'s keys. The backup may carry columns the new schema dropped,
558
- // and the new schema may have NOT-NULL columns without DEFAULT that the
559
- // old backup never carried. Project the backup onto the intersection so
560
- // we don't silently lose every row to per-row INSERT errors, and warn
561
- // once if any backup column was dropped from the new schema.
562
- const targetCols = db.prepare("PRAGMA table_info(usage_events)").all().map((c) => c.name);
563
- if (targetCols.length === 0) {
564
- warn("[db] restoreUsageEventsBackup: usage_events table missing — discarding %d backup row(s)", backup.length);
565
- return;
566
- }
567
- const targetSet = new Set(targetCols);
568
- const backupCols = Object.keys(backup[0] ?? {});
569
- const projectedCols = backupCols.filter((c) => targetSet.has(c));
570
- const droppedCols = backupCols.filter((c) => !targetSet.has(c));
571
- if (projectedCols.length === 0) {
572
- warn("[db] restoreUsageEventsBackup: no overlapping columns between backup and current schema — discarding %d row(s); dropped: %s", backup.length, droppedCols.join(", ") || "(none)");
573
- return;
574
- }
575
- if (droppedCols.length > 0) {
576
- warn("[db] restoreUsageEventsBackup: dropping columns no longer in usage_events schema: %s", droppedCols.join(", "));
577
- }
578
- let restored = 0;
579
- let failed = 0;
580
- db.transaction(() => {
581
- const placeholders = projectedCols.map(() => "?").join(", ");
582
- const insert = db.prepare(`INSERT INTO usage_events (${projectedCols.join(", ")}) VALUES (${placeholders})`);
583
- for (const row of backup) {
584
- try {
585
- insert.run(...projectedCols.map((c) => row[c]));
586
- restored++;
587
- }
588
- catch {
589
- failed++;
590
- }
591
- }
592
- })();
593
- if (failed > 0) {
594
- warn("[db] restoreUsageEventsBackup: restored %d row(s); skipped %d incompatible row(s)", restored, failed);
595
- }
596
- }
597
- catch (err) {
598
- warn("[db] restoreUsageEventsBackup: discarded %d backup row(s) — %s", backup.length, err instanceof Error ? err.message : String(err));
509
+ export function purgeEmbeddings(db, opts) {
510
+ bestEffort(() => db.exec("DELETE FROM embeddings"), "purge embeddings");
511
+ if (isVecAvailable(db)) {
512
+ bestEffort(() => db.exec(opts?.dropVecTable ? "DROP TABLE IF EXISTS entries_vec" : "DELETE FROM entries_vec"), "purge entries_vec");
599
513
  }
514
+ setMeta(db, "hasEmbeddings", "0");
600
515
  }
601
516
  // ── Meta helpers ────────────────────────────────────────────────────────────
602
517
  export function getMeta(db, key) {
@@ -700,9 +615,8 @@ function getUpsertStmts(db) {
700
615
  *
701
616
  * Ensures the `entries.derived_from` column + index exist on the open
702
617
  * connection. Called from `ensureSchema()` after the entries CREATE so that
703
- * legacy databases (created against a pre-v17 binary but reopened without
704
- * triggering `handleVersionUpgrade()`) still gain the new column without
705
- * data loss. Idempotent: a `PRAGMA table_info` lookup gates the ALTER.
618
+ * legacy databases (created against a pre-v17 binary) still gain the new column
619
+ * without data loss. Idempotent: a `PRAGMA table_info` lookup gates the ALTER.
706
620
  */
707
621
  function ensureDerivedFromColumn(db) {
708
622
  bestEffort(() => {
@@ -715,6 +629,110 @@ function ensureDerivedFromColumn(db) {
715
629
  db.exec("CREATE INDEX IF NOT EXISTS idx_entries_derived_from ON entries(derived_from)");
716
630
  }, "entries table may not exist on a brand-new DB before CREATE — caller is responsible");
717
631
  }
632
+ /**
633
+ * Returns true when a table exists in the current database.
634
+ */
635
+ function tableExists(db, name) {
636
+ const row = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1").get(name);
637
+ return row !== undefined && row !== null;
638
+ }
639
+ /**
640
+ * #624-P1 targeted graph-schema migration — STEP 1 of 2 (rename).
641
+ *
642
+ * graph_files was re-keyed from `entry_id INTEGER PRIMARY KEY REFERENCES
643
+ * entries(id)` to a self-contained `(stash_root, file_path, body_hash)` PK.
644
+ * SQLite cannot ALTER a primary key, so an existing DB carrying the OLD shape
645
+ * has its 3 graph tables RENAMED to `*_legacy` here; ensureSchema's CREATE block
646
+ * then builds the new-shape tables, and {@link migrateGraphDataFromLegacy} COPIES
647
+ * the data across before dropping the legacy tables. The graph is preserved —
648
+ * NOT re-extracted (re-extraction is ~19s/file of LLM work).
649
+ *
650
+ * Crucially this is GRAPH-SCOPED: it touches ONLY the graph tables, never the
651
+ * index / embeddings / enrichment cache. So users keep their (expensive)
652
+ * embeddings instead of being forced into a full re-embed by a DB_VERSION bump.
653
+ *
654
+ * Detection: the old schema has an `entry_id` column on graph_files. Fresh DBs
655
+ * (no graph_files yet) and already-migrated DBs (no entry_id column) are no-ops.
656
+ * Idempotent.
657
+ */
658
+ function migrateGraphFilesSchema(db) {
659
+ bestEffort(() => {
660
+ const cols = db.prepare("PRAGMA table_info(graph_files)").all();
661
+ const isLegacyShape = cols.some((c) => c.name === "entry_id");
662
+ if (!isLegacyShape)
663
+ return;
664
+ // A previous interrupted migration may have left *_legacy behind — drop those
665
+ // husks first so the rename below doesn't collide.
666
+ db.exec("DROP TABLE IF EXISTS graph_file_relations_legacy");
667
+ db.exec("DROP TABLE IF EXISTS graph_file_entities_legacy");
668
+ db.exec("DROP TABLE IF EXISTS graph_files_legacy");
669
+ // Rename the 3 entry_id-keyed tables aside. graph_meta is unchanged (stash_root
670
+ // key) so it is left in place. ALTER … RENAME auto-updates child FK refs in
671
+ // SQLite ≥3.25, which is fine — the legacy children are dropped after the copy.
672
+ db.exec("ALTER TABLE graph_files RENAME TO graph_files_legacy");
673
+ if (tableExists(db, "graph_file_entities")) {
674
+ db.exec("ALTER TABLE graph_file_entities RENAME TO graph_file_entities_legacy");
675
+ }
676
+ if (tableExists(db, "graph_file_relations")) {
677
+ db.exec("ALTER TABLE graph_file_relations RENAME TO graph_file_relations_legacy");
678
+ }
679
+ }, "graph_files may not exist on a brand-new DB before CREATE — caller is responsible");
680
+ }
681
+ /**
682
+ * #624-P1 targeted graph-schema migration — STEP 2 of 2 (copy + drop legacy).
683
+ *
684
+ * Runs AFTER the graph CREATE TABLE block, so the new-shape tables exist. Copies
685
+ * every legacy row into the re-keyed tables — the old tables already carry
686
+ * (stash_root, file_path, body_hash) next to entry_id, so the projection is a
687
+ * straight column copy (children JOIN back to graph_files_legacy to resolve the
688
+ * composite key from their entry_id). Then drops the `*_legacy` tables.
689
+ *
690
+ * Best-effort: a copy failure (e.g. a pre-body_hash legacy schema) is tolerated,
691
+ * and the legacy tables are dropped regardless so they never linger. Rows whose
692
+ * body_hash is null/empty can't form the new PK and are skipped (they re-extract).
693
+ */
694
+ function migrateGraphDataFromLegacy(db) {
695
+ if (!tableExists(db, "graph_files_legacy"))
696
+ return;
697
+ let migratedFiles = 0;
698
+ bestEffort(() => {
699
+ db.transaction(() => {
700
+ const res = db
701
+ .prepare(`INSERT OR IGNORE INTO graph_files
702
+ (stash_root, file_path, body_hash, file_order, file_type, confidence, status, reason, extraction_run_id)
703
+ SELECT stash_root, file_path, body_hash, file_order, file_type, confidence, status, reason, extraction_run_id
704
+ FROM graph_files_legacy
705
+ WHERE body_hash IS NOT NULL AND body_hash != ''`)
706
+ .run();
707
+ migratedFiles = Number(res.changes);
708
+ if (tableExists(db, "graph_file_entities_legacy")) {
709
+ db.exec(`INSERT OR IGNORE INTO graph_file_entities
710
+ (stash_root, file_path, body_hash, entity_order, entity_norm, entity)
711
+ SELECT gf.stash_root, gf.file_path, gf.body_hash, e.entity_order, e.entity_norm, e.entity
712
+ FROM graph_file_entities_legacy e
713
+ JOIN graph_files_legacy gf ON gf.entry_id = e.entry_id
714
+ WHERE gf.body_hash IS NOT NULL AND gf.body_hash != ''`);
715
+ }
716
+ if (tableExists(db, "graph_file_relations_legacy")) {
717
+ db.exec(`INSERT OR IGNORE INTO graph_file_relations
718
+ (stash_root, file_path, body_hash, relation_order, from_entity_norm, from_entity, to_entity_norm, to_entity, relation_type, confidence)
719
+ SELECT gf.stash_root, gf.file_path, gf.body_hash, r.relation_order, r.from_entity_norm, r.from_entity, r.to_entity_norm, r.to_entity, r.relation_type, r.confidence
720
+ FROM graph_file_relations_legacy r
721
+ JOIN graph_files_legacy gf ON gf.entry_id = r.entry_id
722
+ WHERE gf.body_hash IS NOT NULL AND gf.body_hash != ''`);
723
+ }
724
+ })();
725
+ }, "graph data migration is best-effort; legacy tables are dropped regardless below");
726
+ // Always drop the legacy tables (children first), migrated or not.
727
+ bestEffort(() => {
728
+ db.exec("DROP TABLE IF EXISTS graph_file_relations_legacy");
729
+ db.exec("DROP TABLE IF EXISTS graph_file_entities_legacy");
730
+ db.exec("DROP TABLE IF EXISTS graph_files_legacy");
731
+ }, "drop legacy graph tables after migration");
732
+ if (migratedFiles > 0) {
733
+ warn(`[akm] graph index re-keyed (#624): migrated ${migratedFiles} extracted file(s) to the new schema — no re-extraction needed. Index + embeddings untouched.`);
734
+ }
735
+ }
718
736
  /**
719
737
  * Phase 5A / Advantage D5: look up the derived-memory child row whose
720
738
  * `derived_from` column matches `parentRef` (e.g. `"memory:claude-prefs"`).
@@ -839,6 +857,38 @@ function deleteRelatedRows(db, ids) {
839
857
  // Clean up usage events before deleting entries
840
858
  bestEffort(() => db.prepare(`DELETE FROM usage_events WHERE entry_id IN (${placeholders})`).run(...chunk), "delete usage_events for entries");
841
859
  }
860
+ // #624-P1: graph_files is NO LONGER keyed on entries.id, so deleting an
861
+ // entries row must NOT wipe the extracted graph (that is the whole point —
862
+ // the graph survives a reindex when body_hash is unchanged). We therefore do
863
+ // NOT delete graph_files here. We DO, however, recompute graph_meta counts
864
+ // for the stash roots touched by the deleted entries so the summary numbers
865
+ // stay consistent with the live child rows (the counts are derived, and the
866
+ // entries delete may have changed which files are considered/indexed).
867
+ //
868
+ // Resolve the affected stash roots from the entries rows BEFORE deletion.
869
+ const affectedStashRoots = new Set();
870
+ for (let i = 0; i < numericIds.length; i += SQLITE_CHUNK_SIZE) {
871
+ const chunk = numericIds.slice(i, i + SQLITE_CHUNK_SIZE);
872
+ const placeholders = chunk.map(() => "?").join(",");
873
+ bestEffort(() => {
874
+ const rows = db
875
+ .prepare(`SELECT DISTINCT stash_dir FROM entries WHERE id IN (${placeholders})`)
876
+ .all(...chunk);
877
+ for (const row of rows) {
878
+ if (row.stash_dir)
879
+ affectedStashRoots.add(row.stash_dir);
880
+ }
881
+ }, "resolve stash roots for graph_meta recompute");
882
+ }
883
+ for (const stashRoot of affectedStashRoots) {
884
+ bestEffort(() => db
885
+ .prepare(`UPDATE graph_meta
886
+ SET extracted_files = (SELECT COUNT(*) FROM graph_files WHERE stash_root = ?),
887
+ entity_count = (SELECT COUNT(*) FROM graph_file_entities WHERE stash_root = ?),
888
+ relation_count = (SELECT COUNT(*) FROM graph_file_relations WHERE stash_root = ?)
889
+ WHERE stash_root = ?`)
890
+ .run(stashRoot, stashRoot, stashRoot, stashRoot), "sync graph_meta counts after entries delete");
891
+ }
842
892
  }
843
893
  /**
844
894
  * Delete entries by their primary key IDs, along with all related rows
@@ -1048,12 +1098,12 @@ function searchBlobVec(db, queryEmbedding, k) {
1048
1098
  }
1049
1099
  }
1050
1100
  // ── FTS5 search ─────────────────────────────────────────────────────────────
1051
- export function searchFts(db, query, limit, entryType) {
1101
+ export function searchFts(db, query, limit, entryType, excludeTypes) {
1052
1102
  const ftsQuery = sanitizeFtsQuery(query);
1053
1103
  if (!ftsQuery)
1054
1104
  return [];
1055
1105
  // Try the exact AND query first
1056
- const exactResults = runFtsQuery(db, ftsQuery, limit, entryType);
1106
+ const exactResults = runFtsQuery(db, ftsQuery, limit, entryType, excludeTypes);
1057
1107
  if (exactResults.length > 0)
1058
1108
  return exactResults;
1059
1109
  // Exact match returned zero results — try prefix fallback.
@@ -1063,7 +1113,7 @@ export function searchFts(db, query, limit, entryType) {
1063
1113
  const prefixQuery = buildPrefixQuery(ftsQuery);
1064
1114
  if (!prefixQuery)
1065
1115
  return [];
1066
- return runFtsQuery(db, prefixQuery, limit, entryType);
1116
+ return runFtsQuery(db, prefixQuery, limit, entryType, excludeTypes);
1067
1117
  }
1068
1118
  /**
1069
1119
  * Build a prefix query from an FTS5 query string by appending `*` to each
@@ -1086,9 +1136,14 @@ function buildPrefixQuery(ftsQuery) {
1086
1136
  return null;
1087
1137
  return prefixTokens.join(" ");
1088
1138
  }
1089
- function runFtsQuery(db, ftsQuery, limit, entryType) {
1139
+ function runFtsQuery(db, ftsQuery, limit, entryType, excludeTypes) {
1090
1140
  let sql;
1091
1141
  let params;
1142
+ // #627 — exclude-type clause. Only applies on the untyped ('any') path; an
1143
+ // explicit include filter (entryType) already narrows to a single type, so
1144
+ // exclusion is redundant there. An empty list skips the clause entirely
1145
+ // (never emit `NOT IN ()`, which is a SQL error / always-false).
1146
+ const excludes = excludeTypes && excludeTypes.length > 0 ? excludeTypes : [];
1092
1147
  // Join on integer entry_id directly (no CAST needed; we store integer)
1093
1148
  // Use bm25() with per-column weights: entry_id(0), name(10), description(5), tags(3), hints(2), content(1)
1094
1149
  if (entryType && entryType !== "any") {
@@ -1105,16 +1160,19 @@ function runFtsQuery(db, ftsQuery, limit, entryType) {
1105
1160
  params = [ftsQuery, entryType, limit];
1106
1161
  }
1107
1162
  else {
1163
+ const excludeClause = excludes.length > 0 ? `AND e.entry_type NOT IN (${excludes.map(() => "?").join(", ")})` : "";
1108
1164
  sql = `
1109
1165
  SELECT e.id, e.file_path AS filePath, e.entry_json, e.search_text AS searchText,
1110
1166
  bm25(entries_fts, 0, 10.0, 5.0, 3.0, 2.0, 1.0) AS bm25Score
1111
1167
  FROM entries_fts f
1112
1168
  JOIN entries e ON e.id = f.entry_id
1113
1169
  WHERE entries_fts MATCH ?
1170
+ ${excludeClause}
1114
1171
  ORDER BY bm25Score, e.id ASC
1115
1172
  LIMIT ?
1116
1173
  `;
1117
- params = [ftsQuery, limit];
1174
+ // Param order: MATCH, then the NOT IN values, then LIMIT.
1175
+ params = [ftsQuery, ...excludes, limit];
1118
1176
  }
1119
1177
  try {
1120
1178
  const rows = db.prepare(sql).all(...params);
@@ -1183,14 +1241,21 @@ function parseEntryRows(rows, context) {
1183
1241
  }
1184
1242
  return entries;
1185
1243
  }
1186
- export function getAllEntries(db, entryType) {
1244
+ export function getAllEntries(db, entryType, excludeTypes) {
1187
1245
  let sql;
1188
1246
  let params;
1247
+ // #627 — exclude-type clause applies only on the untyped ('any') path. Empty
1248
+ // list skips the clause (never `NOT IN ()`).
1249
+ const excludes = excludeTypes && excludeTypes.length > 0 ? excludeTypes : [];
1189
1250
  if (entryType && entryType !== "any") {
1190
1251
  sql =
1191
1252
  "SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries WHERE entry_type = ?";
1192
1253
  params = [entryType];
1193
1254
  }
1255
+ else if (excludes.length > 0) {
1256
+ sql = `SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries WHERE entry_type NOT IN (${excludes.map(() => "?").join(", ")})`;
1257
+ params = [...excludes];
1258
+ }
1194
1259
  else {
1195
1260
  sql = "SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries";
1196
1261
  params = [];
@@ -1198,6 +1263,47 @@ export function getAllEntries(db, entryType) {
1198
1263
  const rows = db.prepare(sql).all(...params);
1199
1264
  return parseEntryRows(rows, "getAllEntries");
1200
1265
  }
1266
+ /**
1267
+ * #609 — read graph entities (normalized) for a set of entry ids. Used by the
1268
+ * recombine pass to cluster memories by shared graph entity ("graph"
1269
+ * relatedness source). Returns a map of `entry_id -> entity_norm[]`. Entries
1270
+ * with no graph entities (graph extraction has not run, or the file produced
1271
+ * no entities) are simply absent from the map — callers must fail open
1272
+ * (fall back to tag relatedness) when the map is empty.
1273
+ */
1274
+ export function getEntitiesByEntryIds(db, entryIds) {
1275
+ const result = new Map();
1276
+ if (entryIds.length === 0)
1277
+ return result;
1278
+ // #624-P1: graph_file_entities no longer carries entry_id. Re-derive the
1279
+ // entry_id -> entity_norm[] contract by JOINing through entries on
1280
+ // (stash_dir, file_path) -> graph_files. Chunk the IN(?) list because the
1281
+ // recombine pass can pass 10k+ entry ids (well over the SQLite param limit).
1282
+ for (let i = 0; i < entryIds.length; i += SQLITE_CHUNK_SIZE) {
1283
+ const chunk = entryIds.slice(i, i + SQLITE_CHUNK_SIZE);
1284
+ const placeholders = chunk.map(() => "?").join(", ");
1285
+ const rows = db
1286
+ .prepare(`SELECT e.id AS entry_id, gfe.entity_norm AS entity_norm
1287
+ FROM entries e
1288
+ JOIN graph_files gf
1289
+ ON gf.stash_root = e.stash_dir AND gf.file_path = e.file_path
1290
+ JOIN graph_file_entities gfe
1291
+ ON gfe.stash_root = gf.stash_root
1292
+ AND gfe.file_path = gf.file_path
1293
+ AND gfe.body_hash = gf.body_hash
1294
+ WHERE e.id IN (${placeholders})
1295
+ ORDER BY e.id, gfe.entity_order`)
1296
+ .all(...chunk);
1297
+ for (const row of rows) {
1298
+ const list = result.get(row.entry_id);
1299
+ if (list)
1300
+ list.push(row.entity_norm);
1301
+ else
1302
+ result.set(row.entry_id, [row.entity_norm]);
1303
+ }
1304
+ }
1305
+ return result;
1306
+ }
1201
1307
  export function findEntryIdByRef(db, ref) {
1202
1308
  const parsed = parseAssetRef(ref);
1203
1309
  const nameVariants = [parsed.name];
@@ -1262,6 +1368,19 @@ export function getEntryIdByFilePath(db, filePath) {
1262
1368
  const row = db.prepare("SELECT id FROM entries WHERE file_path = ? LIMIT 1").get(filePath);
1263
1369
  return row?.id;
1264
1370
  }
1371
+ /**
1372
+ * Set of every non-empty `entries.file_path` currently indexed (across all
1373
+ * stashes/sources). Used by staleness detection to spot files that exist on
1374
+ * disk but were never indexed — a clock-independent signal for newly-added
1375
+ * assets that an mtime-vs-builtAt comparison can miss when the two clocks
1376
+ * (filesystem vs wall-clock) are skewed within the same millisecond.
1377
+ */
1378
+ export function getIndexedFilePaths(db) {
1379
+ const rows = db
1380
+ .prepare("SELECT DISTINCT file_path FROM entries WHERE file_path IS NOT NULL AND file_path <> ''")
1381
+ .all();
1382
+ return new Set(rows.map((r) => r.file_path));
1383
+ }
1265
1384
  /**
1266
1385
  * Resolve a single `entries.file_path` by primary key, or `undefined` if no
1267
1386
  * row matches.
@@ -1477,25 +1596,98 @@ export function computeBodyHash(body) {
1477
1596
  return sha256Hex(body);
1478
1597
  }
1479
1598
  /**
1480
- * Count search and show events for the given entry refs.
1481
- * Returns a Map<ref, count> with only refs that have at least one event.
1482
- * Used by the improve loop to find high-retrieval assets without feedback.
1599
+ * Reduce a ref to its bare `type:name` form, dropping any `origin//` prefix.
1600
+ *
1601
+ * usage_events store entry_ref inconsistently: search/show writers persist
1602
+ * whatever ref the result carried, which is sometimes stash-prefixed
1603
+ * (`origin//type:name`) and sometimes bare (`type:name`). Retrieval counting
1604
+ * keys on the bare form so both spellings of the same asset collapse together.
1605
+ *
1606
+ * Returns the bare form, or the original string when it cannot be parsed (best
1607
+ * effort — never throws so a malformed stored ref can't break counting).
1608
+ */
1609
+ function bareRef(ref) {
1610
+ try {
1611
+ const parsed = parseAssetRef(ref);
1612
+ return `${parsed.type}:${parsed.name}`;
1613
+ }
1614
+ catch {
1615
+ return ref;
1616
+ }
1617
+ }
1618
+ /**
1619
+ * Count retrieval events for the given entry refs.
1620
+ *
1621
+ * Counts `search`, `show`, and `curate` usage events. Returns a
1622
+ * Map<inputRef, count> keyed by the *input* ref strings (only those with at
1623
+ * least one matching event appear). Used by the improve loop to find
1624
+ * high-retrieval assets without feedback.
1625
+ *
1626
+ * Matching is normalization-aware: each stored `entry_ref` is reduced to its
1627
+ * bare `type:name` form before comparison, so a stash-prefixed stored ref
1628
+ * (`origin//type:name`) still matches a bare input ref (`type:name`) and vice
1629
+ * versa. Previously the raw `entry_ref IN (...)` comparison silently dropped
1630
+ * roughly half the signal whenever the two spellings disagreed.
1631
+ *
1632
+ * `curate` events are included: their per-item rows are written with
1633
+ * entry_ref populated (see logCurateEvent), so curation is a real retrieval
1634
+ * signal here. Legacy summary-only curate rows with a NULL entry_ref simply
1635
+ * contribute nothing.
1483
1636
  */
1484
1637
  export function getRetrievalCounts(db, refs) {
1485
1638
  if (refs.length === 0)
1486
1639
  return new Map();
1487
- const result = new Map();
1640
+ // Map each distinct bare form back to the input ref(s) that produced it so we
1641
+ // can re-key DB results (grouped by bare form) onto the caller's ref strings.
1642
+ const bareToInputs = new Map();
1643
+ for (const ref of refs) {
1644
+ const bare = bareRef(ref);
1645
+ const existing = bareToInputs.get(bare);
1646
+ if (existing)
1647
+ existing.push(ref);
1648
+ else
1649
+ bareToInputs.set(bare, [ref]);
1650
+ }
1651
+ const bareForms = [...bareToInputs.keys()];
1652
+ // Accumulate counts per bare form across chunks before re-keying.
1653
+ const countsByBare = new Map();
1488
1654
  // Chunk to stay within SQLITE_MAX_VARIABLE_NUMBER (same pattern as getUtilityScoresByIds).
1489
- for (let i = 0; i < refs.length; i += SQLITE_CHUNK_SIZE) {
1490
- const chunk = refs.slice(i, i + SQLITE_CHUNK_SIZE);
1655
+ for (let i = 0; i < bareForms.length; i += SQLITE_CHUNK_SIZE) {
1656
+ const chunk = bareForms.slice(i, i + SQLITE_CHUNK_SIZE);
1491
1657
  const placeholders = chunk.map(() => "?").join(", ");
1658
+ // Normalize the stored entry_ref to its bare form inside SQL by stripping
1659
+ // everything up to and including the last `//` separator. SQLite has no
1660
+ // rfind, but stored origins never themselves contain `//`, so a stash ref
1661
+ // has exactly one `//` and `substr(... instr ...)` is exact; bare refs have
1662
+ // no `//` and pass through unchanged.
1492
1663
  const rows = db
1493
- .prepare(`SELECT entry_ref, COUNT(*) AS cnt FROM usage_events
1494
- WHERE event_type IN ('search','show') AND entry_ref IN (${placeholders})
1495
- GROUP BY entry_ref`)
1664
+ .prepare(`SELECT
1665
+ CASE
1666
+ WHEN instr(entry_ref, '//') > 0
1667
+ THEN substr(entry_ref, instr(entry_ref, '//') + 2)
1668
+ ELSE entry_ref
1669
+ END AS bare_ref,
1670
+ COUNT(*) AS cnt
1671
+ FROM usage_events
1672
+ WHERE event_type IN ('search','show','curate')
1673
+ AND entry_ref IS NOT NULL
1674
+ AND CASE
1675
+ WHEN instr(entry_ref, '//') > 0
1676
+ THEN substr(entry_ref, instr(entry_ref, '//') + 2)
1677
+ ELSE entry_ref
1678
+ END IN (${placeholders})
1679
+ GROUP BY bare_ref`)
1496
1680
  .all(...chunk);
1497
- for (const r of rows)
1498
- result.set(r.entry_ref, r.cnt);
1681
+ for (const r of rows) {
1682
+ countsByBare.set(r.bare_ref, (countsByBare.get(r.bare_ref) ?? 0) + r.cnt);
1683
+ }
1684
+ }
1685
+ // Re-key bare-form counts onto every input ref that maps to that bare form.
1686
+ const result = new Map();
1687
+ for (const [bare, count] of countsByBare) {
1688
+ for (const input of bareToInputs.get(bare) ?? []) {
1689
+ result.set(input, count);
1690
+ }
1499
1691
  }
1500
1692
  return result;
1501
1693
  }