akm-cli 0.9.15 → 0.9.16-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/dist/assets/tasks/core/index-refresh.yml +1 -1
  3. package/dist/cli/retired-commands.js +2 -0
  4. package/dist/cli/unknown-flags.js +36 -3
  5. package/dist/commands/improve/collapse-detector.js +2 -2
  6. package/dist/commands/improve/consolidate.js +6 -4
  7. package/dist/commands/improve/improve-cli.js +1 -1
  8. package/dist/commands/proposal/repository.js +12 -3
  9. package/dist/commands/read/curate.js +34 -44
  10. package/dist/commands/read/search.js +50 -2
  11. package/dist/commands/sources/index-status.js +99 -0
  12. package/dist/commands/sources/info.js +8 -8
  13. package/dist/commands/sources/installed-stashes.js +33 -12
  14. package/dist/commands/sources/source-add.js +21 -6
  15. package/dist/commands/sources/stash-cli.js +119 -111
  16. package/dist/core/adapter/adapters/akm-adapter.js +35 -3
  17. package/dist/core/adapter/adapters/akm-metadata.js +11 -1
  18. package/dist/core/asset/asset-placement.js +35 -0
  19. package/dist/core/config/schema/embedding.js +7 -30
  20. package/dist/core/config/schema/search.js +11 -9
  21. package/dist/core/errors.js +5 -2
  22. package/dist/core/hash.js +18 -0
  23. package/dist/core/maintenance-barrier.js +8 -6
  24. package/dist/core/paths.js +0 -11
  25. package/dist/core/run-lock.js +5 -2
  26. package/dist/core/state/migrations.js +26 -1
  27. package/dist/core/state-db.js +63 -27
  28. package/dist/indexer/drain.js +306 -0
  29. package/dist/indexer/embedding-identity.js +20 -0
  30. package/dist/indexer/enrich.js +260 -0
  31. package/dist/indexer/ensure-index.js +5 -0
  32. package/dist/indexer/index-written-assets.js +133 -171
  33. package/dist/indexer/indexer.js +458 -1621
  34. package/dist/indexer/lookup/adapter-concept-owner.js +19 -5
  35. package/dist/indexer/passes/metadata.js +18 -1
  36. package/dist/indexer/reconcile.js +890 -0
  37. package/dist/indexer/scan/drain-dir.js +27 -70
  38. package/dist/indexer/scan/parse-file.js +66 -0
  39. package/dist/indexer/search/db-search.js +373 -89
  40. package/dist/indexer/search/ranking-contributors.js +21 -16
  41. package/dist/indexer/search/ranking.js +135 -57
  42. package/dist/indexer/units/unit.js +159 -0
  43. package/dist/llm/client.js +10 -1
  44. package/dist/llm/embedder.js +10 -3
  45. package/dist/llm/embedders/provider-limits.js +288 -0
  46. package/dist/llm/embedders/remote.js +133 -104
  47. package/dist/llm/feature-gate.js +4 -2
  48. package/dist/llm/rerank-client.js +3 -3
  49. package/dist/output/shapes/passthrough.js +1 -0
  50. package/dist/output/text/command-format.js +19 -13
  51. package/dist/output/text/helpers.js +1 -1
  52. package/dist/output/text/index.js +5 -2
  53. package/dist/scripts/akm-migrate-node.js +1141 -1237
  54. package/dist/scripts/akm-migrate.js +1141 -1237
  55. package/dist/setup/semantic-assets.js +2 -2
  56. package/dist/setup/steps/connection.js +3 -2
  57. package/dist/storage/repositories/files-repository.js +181 -0
  58. package/dist/storage/repositories/index-connection.js +1 -3
  59. package/dist/storage/repositories/index-entries-repository.js +77 -68
  60. package/dist/storage/repositories/index-entry-schema.js +16 -25
  61. package/dist/storage/repositories/index-fts-repository.js +29 -263
  62. package/dist/storage/repositories/index-meta-repository.js +0 -29
  63. package/dist/storage/repositories/index-schema.js +115 -122
  64. package/dist/storage/repositories/index-utility-repository.js +1 -1
  65. package/dist/storage/repositories/index-vec-repository.js +21 -334
  66. package/dist/storage/repositories/units-repository.js +510 -0
  67. package/docs/migration/release-notes/0.9.15.md +34 -36
  68. package/docs/migration/release-notes/0.9.16.md +110 -0
  69. package/docs/migration/release-notes/README.md +5 -0
  70. package/docs/reference/cli.md +93 -87
  71. package/docs/reference/configuration.md +128 -89
  72. package/docs/reference/data-and-telemetry.md +2 -1
  73. package/package.json +1 -1
  74. package/schemas/akm-config.json +2 -58
  75. package/dist/indexer/index-db-contention.js +0 -56
  76. package/dist/indexer/index-rebuild-lock.js +0 -73
  77. package/dist/indexer/materialize-embeddings.js +0 -771
  78. package/dist/indexer/passes/dir-staleness.js +0 -161
  79. package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
@@ -0,0 +1,20 @@
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 { DETERMINISTIC_EMBED_MODEL_ID, isDeterministicEmbedEnabled } from "../llm/embedders/deterministic.js";
5
+ import { DEFAULT_LOCAL_MODEL } from "../llm/embedders/local.js";
6
+ /**
7
+ * Returns `undefined` when nothing was actually observed this call (no
8
+ * vector to measure yet) — there is nothing to key an identity on.
9
+ */
10
+ export function deriveObservedEmbeddingIdentity(embedding, observedModel, observedVectorLen) {
11
+ if (isDeterministicEmbedEnabled()) {
12
+ return `deterministic:${DETERMINISTIC_EMBED_MODEL_ID}`;
13
+ }
14
+ if (observedVectorLen === undefined)
15
+ return undefined;
16
+ if (embedding?.endpoint) {
17
+ return `remote:${observedModel ?? embedding.model ?? "unknown"}|${observedVectorLen}`;
18
+ }
19
+ return `local:${embedding?.localModel ?? DEFAULT_LOCAL_MODEL}|${observedVectorLen}`;
20
+ }
@@ -0,0 +1,260 @@
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
+ /**
5
+ * LLM metadata-enrichment pass, restored on the reconcile path
6
+ * (docs/plans/index-redesign-contract.md, B5e).
7
+ *
8
+ * Before the index redesign, `akm index` ran a config-driven metadata
9
+ * enhancement pass over every "generated"-quality entry, keyed by a
10
+ * `(assetRef, cacheVariant)` cache row whose `body_hash` column happened to
11
+ * gate freshness. The reconcile rewrite (`reconcile.ts`, B1) dropped the
12
+ * call site along with the rest of the old phase pipeline. This module
13
+ * restores the feature on the NEW path, content-addressed throughout:
14
+ * `reconcileRoots` collects one {@link MetadataEnrichmentCandidate} per file
15
+ * it just upserted (added or changed) and hands the batch to
16
+ * {@link enrichReconciledEntries} once, AFTER every per-file transaction in
17
+ * this run has already committed — a provider call must never run inside
18
+ * `applyChange`'s `BEGIN IMMEDIATE` transaction (docs/plans/index-redesign.md
19
+ * rule 5: every index write stays a short, idempotent transaction; an LLM
20
+ * call can take seconds to minutes and must not hold one open).
21
+ *
22
+ * **Content-addressed cache** — `llm_enrichment_cache` (still shared with
23
+ * graph-extraction and memory-inference, which key it by absolute file path)
24
+ * is used here with `asset_ref = body_hash = candidate.blobHash`: the cache
25
+ * row IS the content address, so a cache hit means "this exact byte content
26
+ * has already been enriched" regardless of which entry or how many entries
27
+ * currently carry it, and a rename or an unrelated field edit elsewhere in
28
+ * the same file never invalidates it. `withLlmCache` (`./db/llm-cache.ts`)
29
+ * already implements exactly this hash-gated lookup/call/write shape, so this
30
+ * module reuses it rather than duplicating the pattern a third time.
31
+ *
32
+ * **Fail-soft** — `enhanceMetadata`'s `EnhanceMetadataOutcome` distinguishes
33
+ * `enriched` (real success — cache it) from `skipped` (feature gate closed)
34
+ * and `failed` (provider/network error): `withLlmCache`'s "only cache a
35
+ * defined result" contract means a `skipped`/`failed` outcome (mapped to
36
+ * `undefined` below) writes no cache row and leaves the entry's `quality`
37
+ * untouched, so a transient provider outage can never poison an entry into a
38
+ * permanent enrichment skip.
39
+ *
40
+ * **`--full` re-applies without a new provider call** — `reconcileRoots`'s
41
+ * `forceReparse` re-parses every file, so an unchanged file's fresh
42
+ * `IndexDocument` is `quality: "generated"` again (enrichment only ever
43
+ * updated the DB row, never the source file) and becomes an enrichment
44
+ * candidate again on every `--full` run. Its `blobHash` is unchanged, so the
45
+ * content-addressed cache lookup above hits and re-applies the SAME cached
46
+ * fields with no new provider call — this falls out of content-addressing
47
+ * for free and needs no `--full`-specific branch here.
48
+ */
49
+ import fs from "node:fs";
50
+ import { concurrentMap } from "../core/concurrent.js";
51
+ import { ConfigError } from "../core/errors.js";
52
+ import { defaultConcurrencyForEndpoint } from "../core/loopback.js";
53
+ import { withImmediateTransaction } from "../core/state-db.js";
54
+ import { warn } from "../core/warn.js";
55
+ import { resolveIndexPassExecution } from "../llm/index-passes.js";
56
+ import { enhanceMetadata } from "../llm/metadata-enhance.js";
57
+ import { insertNewUnitTexts } from "../storage/repositories/files-repository.js";
58
+ import { upsertEntry } from "../storage/repositories/index-entries-repository.js";
59
+ import { replaceEntryUnits } from "../storage/repositories/units-repository.js";
60
+ import { withLlmCache } from "./db/llm-cache.js";
61
+ import { getMarkdownFragmentContent, hasMarkdownFragmentContent, isEnrichmentComplete, setMarkdownFragmentContent, } from "./passes/metadata.js";
62
+ import { buildSearchText } from "./search/search-fields.js";
63
+ import { deriveUnits, toUnitSource } from "./units/unit.js";
64
+ /**
65
+ * Namespaces this pass's `llm_enrichment_cache` rows away from
66
+ * graph-extraction's and memory-inference's own `cacheVariant` values, which
67
+ * key the SAME shared table by absolute file path rather than content hash.
68
+ */
69
+ const METADATA_ENRICHMENT_CACHE_VARIANT = "metadata-enhance-v1";
70
+ function emptyCounts() {
71
+ return { attempted: 0, cacheHits: 0, enriched: 0, failed: 0, skipped: 0 };
72
+ }
73
+ /** Only "generated"-quality entries missing description/tags/searchHints are worth an LLM call — see `isEnrichmentComplete`. */
74
+ function isEligibleForEnrichment(entry) {
75
+ return entry.quality === "generated" && !isEnrichmentComplete(entry);
76
+ }
77
+ /**
78
+ * Bounded-pool width for this pass — kept as a direct call to the shared
79
+ * classifier (not `indexer.ts`'s `getDefaultLlmConcurrency` wrapper) to avoid
80
+ * an indexer.ts → enrich.ts → indexer.ts import cycle, exactly like
81
+ * `src/llm/embedders/remote.ts`'s `resolveEmbeddingConcurrency` — see that
82
+ * function's neighboring comment. `tests/indexer/llm-concurrency-default.test.ts`
83
+ * pins `getDefaultLlmConcurrency`'s behavior; this mirrors it exactly.
84
+ */
85
+ function resolveEnrichmentConcurrency(connection) {
86
+ if (typeof connection?.concurrency === "number")
87
+ return connection.concurrency;
88
+ return defaultConcurrencyForEndpoint(connection?.endpoint);
89
+ }
90
+ /**
91
+ * Run the metadata-enrichment pass over every eligible candidate
92
+ * `reconcileRoots` collected this run, with a bounded concurrency pool
93
+ * (`resolveEnrichmentConcurrency`). Only called when
94
+ * `resolveIndexPassExecution("enrichment", config)` resolves a runner — an
95
+ * unconfigured engine, or `index.enrichment.enabled: false`, is a no-op with
96
+ * zero cache reads and zero provider calls. The separate `metadata_enhance`
97
+ * feature gate (`index.metadataEnhance.enabled`, default `false`) is checked
98
+ * per-call inside `enhanceMetadata` itself, so a closed gate still shows up
99
+ * here as a cheap `skipped` outcome rather than being special-cased twice.
100
+ *
101
+ * A `ConfigError` (a required symbolic credential that resolved to nothing)
102
+ * is not fail-soft like a provider error — `enhanceMetadata` lets it escape
103
+ * `tryLlmFeature`'s normal fallback (`llm/structured-call.ts`'s
104
+ * `callStructured`) precisely so a genuinely broken config surfaces loudly
105
+ * instead of reading as an ordinary per-entry failure. `concurrentMap`
106
+ * itself swallows a thrown callback into an `undefined` slot, so this
107
+ * catches it per-candidate and rethrows the first occurrence once every
108
+ * in-flight candidate has settled.
109
+ */
110
+ export async function enrichReconciledEntries(db, config, candidates, maxChars, opts) {
111
+ const counts = emptyCounts();
112
+ const eligible = candidates.filter((candidate) => isEligibleForEnrichment(candidate.entry));
113
+ if (eligible.length === 0)
114
+ return counts;
115
+ const runner = resolveIndexPassExecution("enrichment", config).runner;
116
+ if (!runner)
117
+ return counts;
118
+ const concurrency = resolveEnrichmentConcurrency(runner.connection);
119
+ opts?.onProgress?.(`Metadata enrichment starting for ${eligible.length} entr${eligible.length === 1 ? "y" : "ies"} (concurrency ${concurrency}).`);
120
+ let configFailure;
121
+ await concurrentMap(eligible, async (candidate) => {
122
+ if (opts?.signal?.aborted)
123
+ return;
124
+ counts.attempted++;
125
+ try {
126
+ await enrichOneCandidate(db, runner, config, candidate, maxChars, counts, opts?.signal);
127
+ }
128
+ catch (err) {
129
+ if (err instanceof ConfigError) {
130
+ configFailure ??= err;
131
+ return;
132
+ }
133
+ throw err;
134
+ }
135
+ }, concurrency);
136
+ if (configFailure)
137
+ throw configFailure;
138
+ opts?.onProgress?.(`Metadata enrichment finished: ${counts.enriched} enriched (${counts.cacheHits} from cache), ` +
139
+ `${counts.failed} failed, ${counts.skipped} skipped.`);
140
+ if (counts.failed > 0 && counts.enriched === 0 && counts.skipped === 0) {
141
+ warn(`LLM metadata enrichment failed for all ${counts.failed} attempted entr${counts.failed === 1 ? "y" : "ies"} — ` +
142
+ "index built without enrichment. Check the engine selected by index.enrichment.engine (or defaults.llmEngine).");
143
+ }
144
+ return counts;
145
+ }
146
+ async function enrichOneCandidate(db, runner, config, candidate, maxChars, counts, signal) {
147
+ let sawOutcome;
148
+ let cacheHit = false;
149
+ const metadata = await withLlmCache(db, candidate.blobHash, "", false, async () => {
150
+ let fileContent;
151
+ try {
152
+ fileContent = fs.readFileSync(candidate.filePath, "utf8");
153
+ }
154
+ catch {
155
+ // Best-effort context for the prompt only — enhanceMetadata still
156
+ // runs (with less context) when the file cannot be re-read.
157
+ }
158
+ const outcome = await enhanceMetadata(runner, candidate.entry, fileContent, signal, config);
159
+ if (outcome.status !== "enriched") {
160
+ sawOutcome = outcome.status;
161
+ return undefined;
162
+ }
163
+ return outcome.metadata;
164
+ }, (raw) => (raw !== null && typeof raw === "object" ? raw : undefined), candidate.blobHash, METADATA_ENRICHMENT_CACHE_VARIANT, { onCacheHit: () => (cacheHit = true) });
165
+ if (metadata === undefined) {
166
+ if (sawOutcome === "failed")
167
+ counts.failed++;
168
+ else
169
+ counts.skipped++;
170
+ return;
171
+ }
172
+ let applied;
173
+ try {
174
+ applied = applyEnrichmentToEntry(db, candidate, maxChars, metadata);
175
+ }
176
+ catch (err) {
177
+ // A real write failure (not the stale-identity no-op below, which never
178
+ // throws): `concurrentMap` would otherwise swallow this into a silent
179
+ // undefined slot with `counts.enriched` never incremented but no record
180
+ // of the failure either. Surface it the same way a provider failure is
181
+ // already surfaced.
182
+ counts.failed++;
183
+ warn(`[index] Metadata enrichment write failed for ${candidate.filePath}: ` +
184
+ (err instanceof Error ? err.message : String(err)));
185
+ return;
186
+ }
187
+ if (!applied) {
188
+ // The live `entries` row no longer matches the identity this candidate
189
+ // was queued under (a concurrent rename or delete) — see
190
+ // `applyEnrichmentToEntry`. The metadata is real and already cached
191
+ // above, so this is reported as skipped rather than lost, and the next
192
+ // ordinary run re-applies it from cache with no new provider call.
193
+ counts.skipped++;
194
+ return;
195
+ }
196
+ if (cacheHit)
197
+ counts.cacheHits++;
198
+ counts.enriched++;
199
+ }
200
+ /**
201
+ * Merge enrichment fields onto the candidate's entry, re-derive its units,
202
+ * and write both through the SAME canonical entry/FTS mutation and
203
+ * `unit_texts`/`entry_units` maintenance `applyChange` (`reconcile.ts`) uses
204
+ * — one short `BEGIN IMMEDIATE` transaction, no `content_hash` argument so
205
+ * `upsertEntry`'s `COALESCE` preserves the scan-derived blob hash untouched.
206
+ *
207
+ * Fragment units are unaffected: only `description`/`tags`/`searchHints`
208
+ * change, which feeds solely unit ordinal 0 (`structuredFieldsText`,
209
+ * `units/unit.ts`); `replaceEntryUnits` is still a full delete-then-insert
210
+ * for the entry, so `getMarkdownFragmentContent`/`setMarkdownFragmentContent`
211
+ * re-tag the merged copy — otherwise `deriveUnits` would see no markdown
212
+ * body at all and silently drop every fragment unit `applyChange` already
213
+ * derived for this entry.
214
+ *
215
+ * **Stale-identity guard** — `candidate` was captured before the LLM round
216
+ * trip above, which can take seconds to minutes. A concurrent reconcile can
217
+ * rename (`repointEntry` updates the SAME `entries.id` in place with a new
218
+ * `item_ref`/`content_hash`/`file_path`) or delete this row while that call
219
+ * was in flight. Writing the captured values regardless would either (a)
220
+ * `upsertEntry` under the stale `item_ref`, which no longer conflicts with
221
+ * anything and INSERTs a ghost row pointing at an identity that no longer
222
+ * exists, or (b) `replaceEntryUnits(candidate.entryId)` overwriting a live
223
+ * renamed row's units with the old identity's hashes — or, if the row was
224
+ * deleted outright, throw a foreign-key error. So this re-reads the live row
225
+ * BY ID inside the same transaction and no-ops (returns `false`, counted as
226
+ * skipped by the caller) unless its `content_hash` and `item_ref` still
227
+ * match what this candidate was queued under; only then is `candidate.entryId`
228
+ * — now confirmed live, never a captured id that may no longer exist — used
229
+ * to write.
230
+ */
231
+ function applyEnrichmentToEntry(db, candidate, maxChars, metadata) {
232
+ const merged = { ...candidate.entry, quality: "enriched" };
233
+ if (metadata.description)
234
+ merged.description = metadata.description;
235
+ if (metadata.tags?.length)
236
+ merged.tags = metadata.tags;
237
+ if (metadata.searchHints?.length)
238
+ merged.searchHints = metadata.searchHints;
239
+ if (hasMarkdownFragmentContent(candidate.entry)) {
240
+ setMarkdownFragmentContent(merged, getMarkdownFragmentContent(candidate.entry));
241
+ }
242
+ const searchText = buildSearchText(merged);
243
+ return withImmediateTransaction(db, () => {
244
+ const live = db
245
+ .prepare("SELECT content_hash AS contentHash, item_ref AS itemRef FROM entries WHERE id = ?")
246
+ .get(candidate.entryId);
247
+ if (!live || live.contentHash !== candidate.blobHash || live.itemRef !== candidate.provenance.itemRef) {
248
+ return false;
249
+ }
250
+ upsertEntry(db, candidate.filePath, merged, searchText, candidate.provenance);
251
+ const units = deriveUnits(toUnitSource(candidate.entryId, merged), maxChars);
252
+ insertNewUnitTexts(db, units.map((unit) => ({
253
+ hash: unit.hash,
254
+ kind: unit.fragmentId === null ? "card" : "fragment",
255
+ text: unit.text,
256
+ })));
257
+ replaceEntryUnits(db, candidate.entryId, units.map((unit) => ({ ordinal: unit.ordinal, fragmentId: unit.fragmentId, hash: unit.hash })));
258
+ return true;
259
+ }, "index");
260
+ }
@@ -193,6 +193,11 @@ function indexCanServeStash(stashDir) {
193
193
  }
194
194
  async function runInlineReindex(stashDir, options = {}) {
195
195
  const { akmIndex } = await import("./indexer.js");
196
+ // The embedding drain on this implicit path is bounded (see
197
+ // `IndexOptions.implicit`, src/indexer/indexer.ts): a read command's
198
+ // inline bootstrap embeds one provider request's worth of units and
199
+ // leaves the rest of the durable queue to a later drain, so a first
200
+ // `search`/`show` against a fresh index never blocks on the whole corpus.
196
201
  await akmIndex({
197
202
  stashDir,
198
203
  implicit: true,