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,288 @@
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 { HEALTH_PROBE_TIMEOUT_MS } from "../client.js";
5
+ /**
6
+ * Window assumed for a provider that reports nothing about its own context
7
+ * size (an OpenAI-compatible server, a gateway such as Bifrost): the most
8
+ * common embedding window; the same-run adaptive shrink already in
9
+ * `src/llm/embedders/remote.ts` corrects an overestimate.
10
+ */
11
+ export const DEFAULT_WINDOW_TOKENS = 8_192;
12
+ /**
13
+ * Chars-per-token ratio assumed when the provider exposes no exact
14
+ * tokenizer to calibrate against: field-measured p99 on dense markdown
15
+ * (#954, the same field evidence `DEFAULT_TOKEN_BUDGET` in
16
+ * `src/llm/embedders/remote.ts` was tuned against).
17
+ */
18
+ export const CHARS_PER_TOKEN_TAIL = 2.6;
19
+ /**
20
+ * Reserves room, inside the provider's own token window, for the one-line
21
+ * header every unit's text is prefixed with (entry name, or
22
+ * "entry name › section title" — see A1's `deriveUnits`), so a unit's
23
+ * header plus body never together exceed the real window. 64 tokens
24
+ * comfortably covers a realistic header without materially shrinking the
25
+ * usable window on a small-context provider.
26
+ */
27
+ export const UNIT_HEADER_MARGIN_TOKENS = 64;
28
+ /**
29
+ * Percentile (of chars-per-token ratios, sorted ascending) calibration
30
+ * reports: the single densest sampled text, i.e. the smallest
31
+ * chars-per-token ratio — the same conservative, worst-case-density intent
32
+ * the {@link CHARS_PER_TOKEN_TAIL} fallback encodes as a fixed p99. Over
33
+ * {@link CALIBRATION_SHAPES}' eight samples this percentile always resolves
34
+ * to index 0 — it is simply the minimum ratio observed.
35
+ */
36
+ const CALIBRATION_PERCENTILE = 0.01;
37
+ /** Content posted to `/tokenize` purely to check the route exists, before spending the full calibration corpus on it. */
38
+ const TOKENIZE_PRESENCE_PROBE_TEXT = "ping";
39
+ /**
40
+ * Representative text shapes to calibrate a provider's chars-per-token
41
+ * ratio against, tokenized once each (eight requests total): mirrors the
42
+ * mix real markdown fragments produce — prose, code, a table, a list, a
43
+ * link, non-Latin text (which tokenizes at a very different ratio than
44
+ * English prose), SQL, and dense technical prose.
45
+ */
46
+ const CALIBRATION_SHAPES = [
47
+ "This is a short sentence describing typical prose content used to calibrate the tokenizer.",
48
+ "```ts\nfunction add(a: number, b: number): number {\n return a + b;\n}\n```",
49
+ "| Column A | Column B | Column C |\n| --- | --- | --- |\n| 1 | 2 | 3 |\n| 4 | 5 | 6 |",
50
+ "- first item in a list\n- second item in a list\n- third item, a little longer than the rest",
51
+ "See [the reference documentation](https://example.com/docs/reference) for more detail on this API.",
52
+ "日本語のテキストは英語と比べてトークンあたりの文字数が大きく異なることがあります。",
53
+ "SELECT id, name, description FROM entries WHERE tags LIKE '%embedding%' ORDER BY updated_at DESC LIMIT 50;",
54
+ "A longer paragraph mixing punctuation, numbers (like 42 and 3.14), and technical terms such as `tokenizer`, `embedding`, and `context window`.",
55
+ ];
56
+ /** Index into a `length`-element array, sorted ascending, at `percentile` (0-1). Clamped so a tiny array still yields a valid index. */
57
+ function percentileIndex(length, percentile) {
58
+ if (length <= 1)
59
+ return 0;
60
+ return Math.max(0, Math.min(length - 1, Math.floor((length - 1) * percentile)));
61
+ }
62
+ /**
63
+ * Calibrate `charsPerToken` against a real provider/model by tokenizing
64
+ * {@link CALIBRATION_SHAPES} once each and taking the
65
+ * {@link CALIBRATION_PERCENTILE} (densest-text) chars-per-token ratio across
66
+ * them — with eight samples that percentile is simply the minimum ratio
67
+ * observed. A sample whose tokenize call fails is skipped rather than
68
+ * aborting the whole calibration; only a total wipeout (every sample
69
+ * failed) falls back to {@link CHARS_PER_TOKEN_TAIL}.
70
+ */
71
+ async function calibrateCharsPerToken(countTokens) {
72
+ const ratios = [];
73
+ for (const text of CALIBRATION_SHAPES) {
74
+ try {
75
+ const tokens = await countTokens(text);
76
+ if (tokens > 0)
77
+ ratios.push(text.length / tokens);
78
+ }
79
+ catch {
80
+ // One bad calibration sample must not abort the whole probe.
81
+ }
82
+ }
83
+ if (ratios.length === 0)
84
+ return CHARS_PER_TOKEN_TAIL;
85
+ ratios.sort((a, b) => a - b);
86
+ return ratios[percentileIndex(ratios.length, CALIBRATION_PERCENTILE)];
87
+ }
88
+ /** GET/POST `url` bounded by `timeoutMs`, additionally aborting if `externalSignal` fires. Never throws on timeout/abort itself — that surfaces as a normal fetch rejection to the caller, which every call site here already catches. */
89
+ function timedFetch(fetchImpl, url, init, timeoutMs, externalSignal) {
90
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
91
+ const signal = externalSignal ? AbortSignal.any([externalSignal, timeoutSignal]) : timeoutSignal;
92
+ return fetchImpl(url, { ...init, signal });
93
+ }
94
+ /** Parse a response body as JSON, or `undefined` on anything that is not valid JSON — a malformed response is a failed probe, not a thrown error. */
95
+ async function readJson(res) {
96
+ try {
97
+ return await res.json();
98
+ }
99
+ catch {
100
+ return undefined;
101
+ }
102
+ }
103
+ /**
104
+ * Probe llama.cpp's `/tokenize` route: one presence check with a fixed
105
+ * short string, then (only if that succeeds) a reusable `countTokens`
106
+ * closure. The closure deliberately does NOT carry the probe's own
107
+ * `probeSignal` forward — that signal belongs to this one
108
+ * `probeProviderLimits` call's lifecycle, while the returned function is
109
+ * held onto and invoked much later (real indexing), so it is bound only to
110
+ * its own fresh per-call timeout.
111
+ */
112
+ async function probeLlamaCppCountTokens(origin, fetchImpl, timeoutMs, probeSignal) {
113
+ const tokenizeOnce = (text, signal) => timedFetch(fetchImpl, `${origin}/tokenize`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: text }) }, timeoutMs, signal);
114
+ const presence = await tokenizeOnce(TOKENIZE_PRESENCE_PROBE_TEXT, probeSignal);
115
+ if (!presence.ok)
116
+ return undefined;
117
+ const presenceBody = (await readJson(presence));
118
+ if (!Array.isArray(presenceBody?.tokens))
119
+ return undefined;
120
+ return async (text) => {
121
+ const res = await tokenizeOnce(text, undefined);
122
+ if (!res.ok)
123
+ throw new Error(`llama.cpp /tokenize request failed (${res.status})`);
124
+ const json = (await readJson(res));
125
+ if (!Array.isArray(json?.tokens))
126
+ throw new Error("Unexpected /tokenize response: missing tokens array");
127
+ return json.tokens.length;
128
+ };
129
+ }
130
+ async function probeLlamaCpp(origin, config, fetchImpl, timeoutMs, signal) {
131
+ const res = await timedFetch(fetchImpl, `${origin}/props`, { method: "GET" }, timeoutMs, signal);
132
+ if (!res.ok)
133
+ return undefined;
134
+ const body = (await readJson(res));
135
+ const windowTokens = body?.default_generation_settings?.n_ctx;
136
+ if (typeof windowTokens !== "number" || !Number.isFinite(windowTokens) || windowTokens <= 0)
137
+ return undefined;
138
+ const probedSlots = typeof body?.total_slots === "number" && body.total_slots > 0 ? body.total_slots : 1;
139
+ const slots = config.concurrency ?? probedSlots;
140
+ const countTokens = await probeLlamaCppCountTokens(origin, fetchImpl, timeoutMs, signal).catch(() => undefined);
141
+ const charsPerToken = countTokens ? await calibrateCharsPerToken(countTokens) : CHARS_PER_TOKEN_TAIL;
142
+ return { windowTokens, slots, source: "llama.cpp", charsPerToken };
143
+ }
144
+ /** Ollama does not expose `num_parallel` (its in-flight slot count) via any API route, so the probe always reports 1 slot unless `config.concurrency` overrides it. */
145
+ const OLLAMA_DEFAULT_SLOTS = 1;
146
+ /** Find `"<arch>.context_length"` in Ollama's `model_info` — the arch prefix varies per model family, so every key is checked rather than assuming one name. */
147
+ function findOllamaContextLength(modelInfo) {
148
+ for (const [key, value] of Object.entries(modelInfo)) {
149
+ if (key.endsWith(".context_length") && typeof value === "number" && Number.isFinite(value) && value > 0) {
150
+ return value;
151
+ }
152
+ }
153
+ return undefined;
154
+ }
155
+ async function probeOllama(origin, config, fetchImpl, timeoutMs, signal) {
156
+ if (!config.model)
157
+ return undefined;
158
+ const res = await timedFetch(fetchImpl, `${origin}/api/show`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: config.model }) }, timeoutMs, signal);
159
+ if (!res.ok)
160
+ return undefined;
161
+ const body = (await readJson(res));
162
+ if (!body?.model_info)
163
+ return undefined;
164
+ const windowTokens = findOllamaContextLength(body.model_info);
165
+ if (windowTokens === undefined)
166
+ return undefined;
167
+ return {
168
+ windowTokens,
169
+ slots: config.concurrency ?? OLLAMA_DEFAULT_SLOTS,
170
+ source: "ollama",
171
+ charsPerToken: CHARS_PER_TOKEN_TAIL,
172
+ };
173
+ }
174
+ /** The origin (`scheme://host[:port]`) to probe against, or `undefined` when `endpoint` is absent, unparseable, or not http(s) — a local-only embedder (no remote `endpoint`) never touches the network. */
175
+ function resolveOrigin(endpoint) {
176
+ if (!endpoint)
177
+ return undefined;
178
+ try {
179
+ const parsed = new URL(endpoint);
180
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
181
+ return undefined;
182
+ return parsed.origin;
183
+ }
184
+ catch {
185
+ return undefined;
186
+ }
187
+ }
188
+ /** The probe's own per-request timeout: `config.timeoutMs` when set, else the shared health-probe default (`src/llm/client.ts`'s `HEALTH_PROBE_TIMEOUT_MS`) reused rather than redefined. */
189
+ function resolveProbeTimeoutMs(config) {
190
+ return config.timeoutMs ?? HEALTH_PROBE_TIMEOUT_MS;
191
+ }
192
+ function defaultLimits(config) {
193
+ return {
194
+ windowTokens: DEFAULT_WINDOW_TOKENS,
195
+ slots: config.concurrency ?? 1,
196
+ source: "default",
197
+ charsPerToken: CHARS_PER_TOKEN_TAIL,
198
+ };
199
+ }
200
+ /**
201
+ * An implausible probed window that cannot even fit its own header margin
202
+ * plus one real character of unit text — `unitMaxChars` would floor to 0,
203
+ * and `deriveUnits` (`src/indexer/units/unit.ts`) throws on a non-positive
204
+ * `maxChars`. Reusing `unitMaxChars` itself as the usability test, rather
205
+ * than a second hand-picked threshold, keeps the two in lockstep by
206
+ * construction: a window this function accepts is, by definition, one
207
+ * `deriveUnits` can never crash on.
208
+ */
209
+ function isUsableWindow(limits) {
210
+ return unitMaxChars(limits) > 0;
211
+ }
212
+ async function probeProviderLimitsUncached(config, opts) {
213
+ const origin = resolveOrigin(config.endpoint);
214
+ if (!origin)
215
+ return defaultLimits(config);
216
+ const fetchImpl = opts?.fetch ?? fetch;
217
+ const timeoutMs = resolveProbeTimeoutMs(config);
218
+ const signal = opts?.signal;
219
+ // An endpoint that answers with a window too small to be usable (at or
220
+ // below UNIT_HEADER_MARGIN_TOKENS) is treated exactly like one that
221
+ // reported nothing recognisable: falling through here means EVERY window
222
+ // this module ever hands out is safe to feed straight into
223
+ // `unitMaxChars`/`deriveUnits`, so the crash guard lives in exactly one
224
+ // place instead of being re-defended at every downstream call site.
225
+ const llamaCpp = await probeLlamaCpp(origin, config, fetchImpl, timeoutMs, signal).catch(() => undefined);
226
+ if (llamaCpp && isUsableWindow(llamaCpp))
227
+ return llamaCpp;
228
+ const ollama = await probeOllama(origin, config, fetchImpl, timeoutMs, signal).catch(() => undefined);
229
+ if (ollama && isUsableWindow(ollama))
230
+ return ollama;
231
+ return defaultLimits(config);
232
+ }
233
+ /**
234
+ * Per-process memoisation of {@link probeProviderLimits}, keyed by the parts
235
+ * of `config` that change what gets probed (`endpoint`, `model`,
236
+ * `concurrency`, `timeoutMs`) — `reconcileRoots`, `reconcilePaths` and
237
+ * `drainEmbeddingQueue` each probe once per call, so a single `akm index` or
238
+ * `akm remember` otherwise repeated the same handful of HTTP requests two or
239
+ * three times over. The cached PROMISE is stored (not just its resolved
240
+ * value), so concurrent callers before the first probe settles share the one
241
+ * in-flight request set rather than each starting their own. A probe that
242
+ * falls back to `source: "default"` (network error, malformed response) is
243
+ * cached too: a process is one CLI run, and a flapping endpoint is the
244
+ * drain's own retry/back-off's problem, not this cache's.
245
+ */
246
+ const providerLimitsCache = new Map();
247
+ /** TEST-ONLY: clear the per-process probe cache so each test starts from a clean slate. */
248
+ export function _resetProviderLimitsCacheForTests() {
249
+ providerLimitsCache.clear();
250
+ }
251
+ /**
252
+ * Probe the configured embedding endpoint for its OWN window/slot limits.
253
+ * Tries llama.cpp's `GET /props` first, then Ollama's `POST /api/show`; an
254
+ * endpoint that answers neither (an OpenAI-compatible server, a gateway),
255
+ * one that answers with an implausibly small window (see
256
+ * {@link isUsableWindow}) — or a config with no remote `endpoint` at all (a
257
+ * local-only embedder) — gets the conservative default. Never throws: any
258
+ * probe failure (a network error, a malformed response, an unparseable
259
+ * endpoint) resolves to the same default shape rather than rejecting.
260
+ *
261
+ * Memoised per process — see {@link providerLimitsCache} — so every caller
262
+ * with the same effective config (`endpoint`/`model`/`concurrency`/
263
+ * `timeoutMs`) shares one probe's HTTP requests instead of repeating them.
264
+ */
265
+ export async function probeProviderLimits(config, opts) {
266
+ const cacheKey = JSON.stringify({
267
+ endpoint: config.endpoint,
268
+ model: config.model,
269
+ concurrency: config.concurrency,
270
+ timeoutMs: config.timeoutMs,
271
+ });
272
+ const cached = providerLimitsCache.get(cacheKey);
273
+ if (cached)
274
+ return cached;
275
+ const probe = probeProviderLimitsUncached(config, opts);
276
+ providerLimitsCache.set(cacheKey, probe);
277
+ return probe;
278
+ }
279
+ /**
280
+ * Character bound for one embedding unit's text (A1's `deriveUnits`
281
+ * `maxChars` parameter): `windowTokens` minus the header margin, converted
282
+ * to characters via `charsPerToken`. Never negative — a pathologically
283
+ * small window still yields a usable (if tiny) bound rather than a
284
+ * negative `maxChars` that would make every split trivially fail.
285
+ */
286
+ export function unitMaxChars(limits) {
287
+ return Math.max(0, Math.floor((limits.windowTokens - UNIT_HEADER_MARGIN_TOKENS) * limits.charsPerToken));
288
+ }