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
@@ -17,16 +17,18 @@ import { warnVerbose } from "../../core/warn.js";
17
17
  import { resolveSecretFromStore } from "../../sources/snapshot-fetchers/secret-seam.js";
18
18
  /**
19
19
  * Upper bound on the number of documents in one HTTP request, independent of
20
- * the token budget below. Overridable via `config.batchSize`. Purely a
21
- * safety cap (very many tiny documents could otherwise pack one request) —
22
- * the token budget is what actually keeps a request inside the endpoint's
23
- * context window and inside the timeout (#874).
20
+ * the token budget below. Overridable via `packing.maxCount` (index
21
+ * redesign, B5 — the retired `embedding.batchSize` config key's replacement,
22
+ * sourced by `drain.ts` from the provider's own probed limits rather than
23
+ * config). Purely a safety cap (very many tiny documents could otherwise
24
+ * pack one request) — the token budget is what actually keeps a request
25
+ * inside the endpoint's context window and inside the timeout (#874).
24
26
  */
25
27
  export const DEFAULT_REMOTE_BATCH_SIZE = 100;
26
28
  /**
27
- * Conservative default token budget per HTTP request when the config gives
28
- * no better number (`maxTokens` — see #956 for why `contextLength`
29
- * no longer feeds this). #874's measurements:
29
+ * Conservative default token budget per HTTP request when no better number
30
+ * is known (`packing.tokenBudget`, sourced from `probeProviderLimits` —
31
+ * see {@link EmbeddingRequestPacking}). #874's measurements:
30
32
  * a batch of 100 small docs (~400 KB, ~100K tokens) took 14.8s against a
31
33
  * healthy local endpoint — half the 30s request timeout — and a single
32
34
  * 128 KB (~24K token) document alone was rejected by the endpoint as
@@ -45,38 +47,6 @@ export const DEFAULT_TOKEN_BUDGET = 6000;
45
47
  export function estimateTokenCount(text) {
46
48
  return Math.round(text.length / 4);
47
49
  }
48
- /**
49
- * Default per-document embedding cap (`embedding.maxInputTokens`, #956)
50
- * — the materializer truncates a document's embedded text to
51
- * this cap (head only) instead of skipping it outright, so one oversized
52
- * entry can no longer fail a whole batch. Fragments are not embedded at all
53
- * (only the entry's own search text is), so this is the only lever on how
54
- * much of a large document contributes to its vector.
55
- */
56
- export const DEFAULT_MAX_INPUT_TOKENS = 512;
57
- /**
58
- * Truncate `text` to at most `maxTokens` (estimated via
59
- * {@link estimateTokenCount}, the same 4-chars≈1-token rule the batching
60
- * budget uses), keeping only its head. The cut never splits a UTF-16
61
- * surrogate pair. Text already at or under the cap is returned unchanged
62
- * (`truncated: false`) — including empty text, which is never itself
63
- * "truncated".
64
- */
65
- export function capEmbeddingText(text, maxTokens) {
66
- if (estimateTokenCount(text) <= maxTokens)
67
- return { text, truncated: false };
68
- const charBudget = Math.max(0, maxTokens * 4);
69
- let cut = Math.min(charBudget, text.length);
70
- if (cut > 0 && cut < text.length) {
71
- const code = text.charCodeAt(cut);
72
- // A low surrogate (0xDC00-0xDFFF) at the cut point means its high
73
- // surrogate is the character just before it — back off one position so
74
- // the pair stays together rather than yielding a lone surrogate.
75
- if (code >= 0xdc00 && code <= 0xdfff)
76
- cut -= 1;
77
- }
78
- return { text: text.slice(0, cut), truncated: true };
79
- }
80
50
  /**
81
51
  * Default per-request timeout when `embedding.timeoutMs` is unset (#954).
82
52
  * The prior fixed 30s cut off exactly the field-report case: a
@@ -194,9 +164,10 @@ export function isContextExceededResponse(status, body) {
194
164
  * either direction, bounded 1-16 at the config schema — added after field
195
165
  * evidence that a multi-slot local server (llama.cpp `--parallel N`, vLLM)
196
166
  * genuinely serves parallel requests and was left idle by the fixed default.
197
- * Request SIZE remains the first throughput lever regardless:
198
- * `embedding.batchSize` (document cap) and `embedding.maxTokens` (request
199
- * token budget — see #956; `contextLength` no longer feeds it)
167
+ * Request SIZE remains the first throughput lever regardless: the packed
168
+ * request's document count and token budget (`EmbeddingRequestPacking`,
169
+ * sourced from `probeProviderLimits` — the retired `embedding.batchSize`/
170
+ * `maxTokens`/`contextLength` config keys' replacement, index redesign B5)
200
171
  * reach a larger batch per request, which is where most of the win is for a
201
172
  * single-slot server — a 32-input batch takes about the same wall time as
202
173
  * one input against a healthy endpoint.
@@ -207,15 +178,22 @@ export function resolveEmbeddingConcurrency(config) {
207
178
  return defaultConcurrencyForEndpoint(config.endpoint);
208
179
  }
209
180
  /**
210
- * Group `texts` into request-sized batches bounded by BOTH an estimated
211
- * token budget and a document-count cap, so one large document does not
212
- * silently blow the batch past the endpoint's context window (#874).
181
+ * Group `texts` into request-sized batches bounded by BOTH a token budget and
182
+ * a document-count cap, so one large document does not silently blow the
183
+ * batch past the endpoint's context window (#874).
184
+ *
185
+ * `tokenCounts[i]`, when given, is the count to use for `texts[i]` instead of
186
+ * {@link estimateTokenCount}'s fixed 4-chars≈1-token guess — `embedBatch`
187
+ * passes the calibrated `charsPerToken` estimate for every text
188
+ * (`EmbeddingRequestPacking.charsPerToken`, sourced from the provider's own
189
+ * probed limits). Omitted (or shorter than `texts`, e.g. a caller with no
190
+ * packing at all) falls back to the estimate for the texts it does not cover.
213
191
  *
214
- * A single document whose own estimate exceeds `tokenBudget` can never fit
215
- * any batch — it is reported as its own oversized "batch" so the caller can
216
- * skip it without ever making an HTTP request for it.
192
+ * A single document whose own count exceeds `tokenBudget` can never fit any
193
+ * batch — it is reported as its own oversized "batch" so the caller can skip
194
+ * it without ever making an HTTP request for it.
217
195
  */
218
- export function buildTokenBoundedBatches(texts, tokenBudget, maxCount) {
196
+ export function buildTokenBoundedBatches(texts, tokenBudget, maxCount, tokenCounts) {
219
197
  const batches = [];
220
198
  let current = [];
221
199
  let currentTokens = 0;
@@ -227,7 +205,7 @@ export function buildTokenBoundedBatches(texts, tokenBudget, maxCount) {
227
205
  }
228
206
  };
229
207
  for (let i = 0; i < texts.length; i++) {
230
- const tokens = estimateTokenCount(texts[i]);
208
+ const tokens = tokenCounts?.[i] ?? estimateTokenCount(texts[i]);
231
209
  if (tokens > tokenBudget) {
232
210
  flush();
233
211
  batches.push({ indices: [i], oversized: true });
@@ -247,16 +225,20 @@ export function buildTokenBoundedBatches(texts, tokenBudget, maxCount) {
247
225
  * context-size rejection of an `embedBatch` run (#954, field report on
248
226
  * beta.1): one 25% cut absorbs the estimator's measured undercount without
249
227
  * repeatedly re-shrinking mid-run — see the "shrink at most once" rule on
250
- * {@link RemoteEmbedder.embedBatch}.
228
+ * {@link RemoteEmbedder.embedBatch}. Only fires at all when the run's window
229
+ * is not already authoritative (`packing.windowIsKnown` false) — see
230
+ * {@link EmbeddingRequestPacking}.
251
231
  */
252
232
  const ADAPTIVE_BUDGET_SHRINK_FACTOR = 0.75;
253
233
  /**
254
- * Floor on the adaptive-budget shrink above, as a multiple of
255
- * `maxInputTokens` (#954): a request budget below twice the per-document cap
256
- * could no longer batch more than one document per request, defeating the
257
- * point of batching at all.
234
+ * Floor on the adaptive-budget shrink above (#954, revised by the index
235
+ * redesign's B5 — the retired `embedding.maxInputTokens` per-document cap no
236
+ * longer bounds a single document's size, so the floor is a fixed token
237
+ * count rather than a multiple of that cap): a request budget below this
238
+ * could no longer batch more than a couple of average-sized units per
239
+ * request, defeating the point of batching at all.
258
240
  */
259
- const ADAPTIVE_BUDGET_FLOOR_MULTIPLIER = 2;
241
+ const ADAPTIVE_BUDGET_FLOOR_TOKENS = 1024;
260
242
  export class RemoteEmbedder {
261
243
  config;
262
244
  endpoint;
@@ -282,6 +264,9 @@ export class RemoteEmbedder {
282
264
  if (ollamaOpts) {
283
265
  body.options = ollamaOpts;
284
266
  }
267
+ if (isOllamaNativeEmbedEndpoint(this.endpoint)) {
268
+ body.truncate = false;
269
+ }
285
270
  const timeoutMs = resolveEmbeddingTimeoutMs(this.config);
286
271
  // `signal` MUST go through fetchWithTimeout's dedicated 4th parameter, not
287
272
  // the RequestInit: fetchWithTimeout replaces `opts.signal` with its own
@@ -362,40 +347,47 @@ export class RemoteEmbedder {
362
347
  * on a small batch rather than always waiting out the full configured
363
348
  * `embedding.timeoutMs`.
364
349
  *
365
- * Run-scoped adaptive budget (#954, field report on beta.1): the FIRST
366
- * context-size rejection of the run shrinks the effective request budget
367
- * by {@link ADAPTIVE_BUDGET_SHRINK_FACTOR} (floored at
368
- * {@link ADAPTIVE_BUDGET_FLOOR_MULTIPLIER} times `maxInputTokens`) for
369
- * every batch not yet dispatched — the still-planned tail of `texts` is
370
- * re-batched with `buildTokenBoundedBatches` at the smaller budget, and a
371
- * `budget-lowered` `onBatch` event reports it once. This never touches the
372
- * split-and-retry of the rejected batch itself (above), and never fires a
373
- * second time in the same run even if a later batch is also rejected — a
374
- * static configured budget that is simply too big for the endpoint should
375
- * self-correct once, not ratchet down forever.
350
+ * Run-scoped adaptive budget (#954, field report on beta.1; gated by
351
+ * `packing.windowIsKnown` per the index redesign's B5): on the FIRST
352
+ * context-size rejection of the run, when the request window is NOT
353
+ * already an observed provider value (`packing.windowIsKnown` false/unset
354
+ * — the generic {@link DEFAULT_TOKEN_BUDGET}/8192-token default, or a
355
+ * caller with no packing at all), this shrinks the effective request
356
+ * budget by {@link ADAPTIVE_BUDGET_SHRINK_FACTOR} (floored at
357
+ * {@link ADAPTIVE_BUDGET_FLOOR_TOKENS}) for every batch not yet
358
+ * dispatched — the still-planned tail of `texts` is re-batched with
359
+ * `buildTokenBoundedBatches` at the smaller budget, and a
360
+ * `budget-lowered` `onBatch` event reports it once. A KNOWN window
361
+ * (llama.cpp/Ollama, probed via `probeProviderLimits`) is already
362
+ * authoritative, so a rejection against it is unexpected — split-and-retry
363
+ * (above) still recovers that one batch, but the run-wide budget is left
364
+ * alone rather than second-guessing a real number. This never touches the
365
+ * split-and-retry of the rejected batch itself, and never fires a second
366
+ * time in the same run even if a later batch is also rejected — a budget
367
+ * that is simply too big for the endpoint should self-correct once, not
368
+ * ratchet down forever.
376
369
  */
377
- async embedBatch(texts, signal, onSkip, onBatch) {
370
+ async embedBatch(texts, signal, onSkip, onBatch, packing) {
378
371
  if (texts.length === 0)
379
372
  return [];
380
373
  const results = new Array(texts.length).fill(undefined);
381
374
  const headers = this.buildHeaders();
382
- const ollamaOpts = resolveOllamaOptions(this.config);
383
- // #956: `contextLength` is Ollama's `num_ctx` ONLY (see
384
- // resolveOllamaOptions below) — it used to double as this client-side
385
- // request budget too, so a config author setting it for one purpose
386
- // silently changed the other. `maxTokens` is the sole knob for the
387
- // request budget now; unset falls back to DEFAULT_TOKEN_BUDGET.
388
- //
389
- // `effectiveTokenBudget` (#954) starts at the configured/default value
390
- // and MAY shrink once, on the run's first context-size rejection — see
391
- // `maybeShrinkBudget` below. `textBatches` is mutated in place (spliced)
392
- // by that shrink rather than reassigned, so the in-flight
375
+ const ollamaOpts = resolveOllamaOptions(this.config, packing?.ollamaNumCtx);
376
+ // Per-text counts from the calibrated chars-per-token ratio
377
+ // (`packing.charsPerToken`, sourced from the provider's own probed
378
+ // limits) rather than a per-text HTTP tokenize call — synchronous, so
379
+ // planning a corpus of any size costs no requests at all.
380
+ const tokenCounts = texts.map((text) => Math.ceil(text.length / (packing?.charsPerToken ?? 4)));
381
+ // `effectiveTokenBudget` (#954) starts at the probed/configured/default
382
+ // value and MAY shrink once, on the run's first context-size rejection —
383
+ // see `maybeShrinkBudget` below. `textBatches` is mutated in place
384
+ // (spliced) by that shrink rather than reassigned, so the in-flight
393
385
  // `concurrentMap` pool below (which reads this same array by reference)
394
386
  // picks up the re-planned tail without restarting.
395
- let effectiveTokenBudget = this.config.maxTokens ?? DEFAULT_TOKEN_BUDGET;
396
- const maxCount = this.config.batchSize ?? DEFAULT_REMOTE_BATCH_SIZE;
397
- const maxInputTokens = this.config.maxInputTokens ?? DEFAULT_MAX_INPUT_TOKENS;
398
- const textBatches = buildTokenBoundedBatches(texts, effectiveTokenBudget, maxCount);
387
+ let effectiveTokenBudget = packing?.tokenBudget ?? DEFAULT_TOKEN_BUDGET;
388
+ const maxCount = packing?.maxCount ?? DEFAULT_REMOTE_BATCH_SIZE;
389
+ const windowIsKnown = packing?.windowIsKnown ?? false;
390
+ const textBatches = buildTokenBoundedBatches(texts, effectiveTokenBudget, maxCount, tokenCounts);
399
391
  const configuredTimeoutMs = resolveEmbeddingTimeoutMs(this.config);
400
392
  // How many of `textBatches` concurrentMap has already claimed (its own
401
393
  // `nextIndex`, mirrored here so a budget shrink knows where the
@@ -404,24 +396,28 @@ export class RemoteEmbedder {
404
396
  // order, so the highest `batchIndex` seen so far IS the claimed count.
405
397
  let dispatchedBatchCount = 0;
406
398
  // Set once the run's first context-size rejection has shrunk the budget
407
- // (#954) — guards `maybeShrinkBudget` so it never fires twice.
408
- let budgetShrunk = false;
399
+ // (#954) — guards `maybeShrinkBudget` so it never fires twice. Also true
400
+ // (so the shrink never fires at all) when the window is already known —
401
+ // see the method's doc comment.
402
+ let budgetShrunk = windowIsKnown;
409
403
  // On the FIRST context-size rejection of this `embedBatch` call, shrink
410
404
  // `effectiveTokenBudget` and re-plan every batch `concurrentMap` has not
411
405
  // yet claimed from the smaller budget. Never touches `rejectedIndices`
412
406
  // itself — the caller's own split-and-retry handles that batch — and is
413
- // a no-op after the first call (`budgetShrunk`).
407
+ // a no-op after the first call (`budgetShrunk`) or when the window is
408
+ // already known (`windowIsKnown`, folded into `budgetShrunk`'s initial
409
+ // value above).
414
410
  const maybeShrinkBudget = (rejectedIndices, rejectedBatchIndex, rejectedRequestTokens) => {
415
411
  if (budgetShrunk)
416
412
  return;
417
413
  budgetShrunk = true;
418
- const floor = ADAPTIVE_BUDGET_FLOOR_MULTIPLIER * maxInputTokens;
419
- effectiveTokenBudget = Math.max(Math.round(effectiveTokenBudget * ADAPTIVE_BUDGET_SHRINK_FACTOR), floor);
414
+ effectiveTokenBudget = Math.max(Math.round(effectiveTokenBudget * ADAPTIVE_BUDGET_SHRINK_FACTOR), ADAPTIVE_BUDGET_FLOOR_TOKENS);
420
415
  const notYetDispatched = textBatches.slice(dispatchedBatchCount);
421
416
  const remainingIndices = notYetDispatched.flatMap((batch) => batch.indices);
422
417
  if (remainingIndices.length > 0) {
423
418
  const remainingTexts = remainingIndices.map((i) => texts[i]);
424
- const replanned = buildTokenBoundedBatches(remainingTexts, effectiveTokenBudget, maxCount).map((batch) => ({
419
+ const remainingCounts = remainingIndices.map((i) => tokenCounts[i]);
420
+ const replanned = buildTokenBoundedBatches(remainingTexts, effectiveTokenBudget, maxCount, remainingCounts).map((batch) => ({
425
421
  indices: batch.indices.map((localIndex) => remainingIndices[localIndex]),
426
422
  oversized: batch.oversized,
427
423
  }));
@@ -439,7 +435,7 @@ export class RemoteEmbedder {
439
435
  });
440
436
  };
441
437
  // Stops the pool from claiming any FURTHER provider batch once the
442
- // caller's onBatch has failed once (the materializer's transaction
438
+ // caller's onBatch has failed once (the caller's transaction
443
439
  // failed, so a subsequent commit would just fail again) — dispatching
444
440
  // real HTTP requests whose results can never be persisted is pure waste.
445
441
  // Deliberately a SEPARATE controller from the caller's own `signal`,
@@ -463,7 +459,7 @@ export class RemoteEmbedder {
463
459
  }
464
460
  }
465
461
  // First error thrown BY the caller's onBatch callback (e.g. a real
466
- // competing-process SQLITE_BUSY from the materializer's db.transaction())
462
+ // competing-process SQLITE_BUSY from the caller's own db.transaction())
467
463
  // rather than by requestBatch itself. Captured here instead of being left
468
464
  // to reach requestAndCommit's try/catch below, which exists solely to
469
465
  // classify requestBatch's own provider/network failures — a persistence
@@ -519,7 +515,7 @@ export class RemoteEmbedder {
519
515
  if (dispatchAbort.signal.aborted)
520
516
  return;
521
517
  const batch = indices.map((i) => texts[i]);
522
- const requestTokens = batch.reduce((sum, text) => sum + estimateTokenCount(text), 0);
518
+ const requestTokens = indices.reduce((sum, i) => sum + tokenCounts[i], 0);
523
519
  const requestTimeoutMs = scaleEmbeddingTimeoutMs(configuredTimeoutMs, requestTokens, effectiveTokenBudget);
524
520
  const requestStart = Date.now();
525
521
  let batchEmbeddings;
@@ -569,8 +565,8 @@ export class RemoteEmbedder {
569
565
  // verbose line above — a run silently waiting out a multi-minute
570
566
  // back-off looked identical to a hang otherwise. Nothing has
571
567
  // failed or succeeded yet, so there is nothing to persist:
572
- // `embeddings` are all `undefined` and the materializer's onBatch
573
- // must not touch storage for this event.
568
+ // `embeddings` are all `undefined` and the caller's onBatch must
569
+ // not touch storage for this event.
574
570
  commitBatch(indices, indices.map(() => undefined), undefined, {
575
571
  batchIndex,
576
572
  batchCount: textBatches.length,
@@ -605,13 +601,12 @@ export class RemoteEmbedder {
605
601
  // Default-level visibility for a failed batch (not verbose-only) is
606
602
  // still guaranteed here — just not via warn(). The `commitBatch` call
607
603
  // below carries `outcome: "failed"` and this `message` as `reason`
608
- // through `onBatch`, and materialize-embeddings.ts's per-batch line
604
+ // through `onBatch`, and the caller's (`drain.ts`) per-batch line
609
605
  // (also default-level) prints it from there. A warn() call here used
610
606
  // to print the identical event a second time on stderr — the same
611
- // class of double-print bug fixed for the truncation/re-embed-reason
612
- // lines in materialize-embeddings.ts (#954, field-report follow-up).
613
- // Per-entry batch-mapping detail stays verbose-only
614
- // (materialize-embeddings.ts).
607
+ // class of double-print bug this once guarded against in the retired
608
+ // materialize-embeddings.ts (#954, field-report follow-up).
609
+ // Per-entry batch-mapping detail stays verbose-only (the caller).
615
610
  let stopRequested = false;
616
611
  for (const [k, idx] of indices.entries()) {
617
612
  if (onSkip?.({
@@ -654,7 +649,7 @@ export class RemoteEmbedder {
654
649
  dispatchedBatchCount = batchIndex;
655
650
  if (textBatch.oversized) {
656
651
  const idx = textBatch.indices[0];
657
- const estTokens = estimateTokenCount(texts[idx]);
652
+ const estTokens = tokenCounts[idx];
658
653
  onSkip?.({
659
654
  index: idx,
660
655
  reason: "context-window-exceeded",
@@ -724,6 +719,9 @@ export class RemoteEmbedder {
724
719
  if (ollamaOpts) {
725
720
  body.options = ollamaOpts;
726
721
  }
722
+ if (isOllamaNativeEmbedEndpoint(this.endpoint)) {
723
+ body.truncate = false;
724
+ }
727
725
  // See embed(): `signal` goes through the 4th parameter, not the
728
726
  // RequestInit, or fetchWithTimeout drops it.
729
727
  const response = await fetchWithTimeout(normalizeEmbeddingEndpoint(this.endpoint), {
@@ -816,6 +814,34 @@ export function normalizeEmbeddingEndpoint(endpoint) {
816
814
  parsed.pathname = normalizedPath ? `${normalizedPath}/embeddings` : "/embeddings";
817
815
  return parsed.toString();
818
816
  }
817
+ /**
818
+ * True when `endpoint`'s normalized path is Ollama's native `/api/embed`
819
+ * route (see {@link normalizeEmbeddingEndpoint}) rather than an
820
+ * OpenAI-compatible `/embeddings` route. Gates `truncate: false` on the
821
+ * request body (round-2 field finding): akm never sent `truncate` at all, so
822
+ * Ollama's default — silently truncate an over-budget input and still return
823
+ * 200 — meant a unit denser than the calibrated chars-per-token ratio was
824
+ * embedded from a truncated prefix and stored as a complete, correct-looking
825
+ * vector: never counted `failed` or `skipped`, coverage reporting it done,
826
+ * that content's search quality silently degraded forever. `truncate: false`
827
+ * makes an over-budget request fail loudly instead, so it flows into the
828
+ * existing context-window handling (split-and-retry, ultimately a genuine
829
+ * `skipped` unit) rather than a silent truncation. Scoped to the native
830
+ * route specifically because that is the one shape this field evidence is
831
+ * about — an OpenAI-compatible endpoint ignores the unknown field either
832
+ * way, so this is not a safety boundary, just not sending an option that
833
+ * does nothing elsewhere.
834
+ */
835
+ function isOllamaNativeEmbedEndpoint(endpoint) {
836
+ let parsed;
837
+ try {
838
+ parsed = new URL(normalizeEmbeddingEndpoint(endpoint));
839
+ }
840
+ catch {
841
+ return false;
842
+ }
843
+ return parsed.pathname.replace(/\/+$/, "").endsWith("/embed");
844
+ }
819
845
  function embeddingEndpointPathHint(endpoint) {
820
846
  const normalizedEndpoint = normalizeEmbeddingEndpoint(endpoint);
821
847
  if (normalizedEndpoint !== endpoint) {
@@ -828,19 +854,22 @@ function embeddingEndpointPathHint(endpoint) {
828
854
  *
829
855
  * Resolution order:
830
856
  * 1. `ollamaOptions` — forwarded verbatim (explicit opt-in, takes precedence).
831
- * 2. `contextLength` — wrapped as `{ num_ctx: contextLength }`.
857
+ * 2. `ollamaNumCtx` — the caller-supplied, provider-probed native context
858
+ * window (`ProviderLimits.ollamaNumCtx`, sourced from
859
+ * `probeProviderLimits`, NOT the retired `embedding.contextLength`
860
+ * config key), wrapped as `{ num_ctx: ollamaNumCtx }`.
832
861
  * 3. Neither set → returns `undefined` (no `options` field in the request body).
833
862
  *
834
863
  * These options are only meaningful for Ollama's native `/api/embed` endpoint.
835
864
  * OpenAI-compatible endpoints ignore unknown request fields, so passing them to
836
865
  * other providers is harmless but has no effect.
837
866
  */
838
- function resolveOllamaOptions(config) {
867
+ function resolveOllamaOptions(config, ollamaNumCtx) {
839
868
  if (config.ollamaOptions && Object.keys(config.ollamaOptions).length > 0) {
840
869
  return config.ollamaOptions;
841
870
  }
842
- if (config.contextLength) {
843
- return { num_ctx: config.contextLength };
871
+ if (ollamaNumCtx) {
872
+ return { num_ctx: ollamaNumCtx };
844
873
  }
845
874
  return undefined;
846
875
  }
@@ -10,9 +10,11 @@ const FEATURE_LOCATION = {
10
10
  graph_extraction: (cfg) => cfg.index?.graph?.enabled ?? true,
11
11
  metadata_enhance: (cfg) => cfg.index?.metadataEnhance?.enabled ?? false,
12
12
  // #951: a real implementation of the dead `curate_rerank` key removed in
13
- // 0.8.0. Off by default — it requires a `search.curateRerank.endpoint` a
13
+ // 0.8.0, shipped in 0.9.15 wired to curate, then moved to search in 0.9.16
14
+ // (renamed `curate_rerank` → `search_rerank`; the pass was always meant for
15
+ // search). Off by default — it requires a `search.rerank.endpoint` a
14
16
  // caller must explicitly configure.
15
- curate_rerank: (cfg) => Boolean(cfg.search?.curateRerank?.enabled),
17
+ search_rerank: (cfg) => Boolean(cfg.search?.rerank?.enabled),
16
18
  // Always on at the LLM-wrapper level. Enablement is decided ONCE at the
17
19
  // extract entry point (`akmExtract`): the `extract.enabled` process toggle
18
20
  // gates extract as a STAGE of `akm improve` (the active improve strategy, per
@@ -21,7 +21,7 @@
21
21
  * `relevance_score` descending and callers treat a missing index as
22
22
  * "unscored" (kept in its original relative position, after every scored
23
23
  * document). Deliberately independent of `EngineConfigSchema`'s "llm"/"agent"
24
- * kinds — see the comment on `CurateRerankConfigSchema` in
24
+ * kinds — see the comment on `SearchRerankConfigSchema` in
25
25
  * `core/config/schema/search.ts` for why.
26
26
  */
27
27
  import { fetchWithTimeout, readBodyWithByteCap } from "../core/common.js";
@@ -45,13 +45,13 @@ export class RerankCallError extends Error {
45
45
  * after every scored document (never dropped).
46
46
  *
47
47
  * Throws {@link RerankCallError} on any transport/parse failure — callers
48
- * that want a graceful fallback should use `tryLlmFeature("curate_rerank", ...)`
48
+ * that want a graceful fallback should use `tryLlmFeature("search_rerank", ...)`
49
49
  * (`llm/feature-gate.ts`), matching every other bounded in-tree LLM/rerank
50
50
  * call site.
51
51
  */
52
52
  export async function rerankDocuments(config, query, documents) {
53
53
  if (!config.endpoint) {
54
- throw new RerankCallError("search.curateRerank.endpoint is not configured.", "provider_error");
54
+ throw new RerankCallError("search.rerank.endpoint is not configured.", "provider_error");
55
55
  }
56
56
  if (documents.length === 0)
57
57
  return [];
@@ -54,6 +54,7 @@ const PASSTHROUGH_COMMANDS = [
54
54
  "improve-report",
55
55
  "import",
56
56
  "index",
57
+ "index-status",
57
58
  "info",
58
59
  "lint",
59
60
  "list",
@@ -422,21 +422,13 @@ export function formatInitPlain(r) {
422
422
  }
423
423
  export function formatIndexPlain(r) {
424
424
  const indexResult = r;
425
- let out = `Indexed ${indexResult.totalEntries ?? 0} entries from ${indexResult.directoriesScanned ?? 0} directories (mode: ${indexResult.mode ?? "unknown"})`;
425
+ let out = `Indexed ${indexResult.totalEntries ?? 0} entries from ${indexResult.sourcesScanned ?? 0} source${indexResult.sourcesScanned === 1 ? "" : "s"} (mode: ${indexResult.mode ?? "unknown"})`;
426
426
  const warnings = indexResult.warnings;
427
427
  if (Array.isArray(warnings) && warnings.length > 0) {
428
428
  out += `\nWarnings (${warnings.length}):`;
429
429
  for (const message of warnings)
430
430
  out += `\n - ${String(message)}`;
431
431
  }
432
- const notices = Array.isArray(indexResult.notices) ? indexResult.notices : [];
433
- for (const notice of notices) {
434
- const severity = notice.severity === "info" ? "info" : "warning";
435
- const field = typeof notice.field === "string" ? ` field=${notice.field}` : "";
436
- out +=
437
- `\n notice[${severity}] ${notice.code} adapter=${notice.adapter}${field}` +
438
- (notice.message ? `: ${notice.message}` : "");
439
- }
440
432
  const verification = indexResult.verification;
441
433
  if (verification?.ok === false && verification.message) {
442
434
  out += `\nVerification: ${String(verification.message)}`;
@@ -446,16 +438,30 @@ export function formatIndexPlain(r) {
446
438
  out +=
447
439
  `\nTiming: total ${timing.totalMs}ms` +
448
440
  `, preflight ${timing.preflightMs}ms` +
449
- `, walk ${timing.walkMs}ms` +
450
- `, llm ${timing.llmMs}ms` +
441
+ `, source cache ${timing.sourceCacheMs}ms` +
442
+ `, reconcile ${timing.reconcileMs}ms` +
451
443
  `, embeddings ${timing.embedMs}ms` +
452
- `, fts ${timing.ftsMs}ms` +
453
444
  `, finalize ${timing.finalizeMs}ms` +
454
- `, clean ${timing.cleanMs}ms` +
455
445
  `, end-to-end ${timing.endToEndMs}ms`;
456
446
  }
457
447
  return out;
458
448
  }
449
+ /** Render `akm index status`'s `IndexStatusResponse` (src/commands/sources/index-status.ts). */
450
+ export function formatIndexStatusPlain(r) {
451
+ const units = (r.units ?? {});
452
+ const lines = [
453
+ `Index: ${String(r.indexPath ?? "unknown")}`,
454
+ `Files: ${Number(r.files ?? 0)}`,
455
+ `Entries: ${Number(r.entries ?? 0)}`,
456
+ `Units: ${Number(units.total ?? 0)} total, ${Number(units.withVector ?? 0)} with a vector, ${Number(units.pending ?? 0)} pending`,
457
+ `Active identity: ${typeof r.activeIdentity === "string" ? r.activeIdentity : "none yet"}`,
458
+ `Last reconcile: ${typeof r.lastReconcileAt === "string" ? r.lastReconcileAt : "never"}`,
459
+ `Built at: ${typeof r.builtAt === "string" ? r.builtAt : "never"}`,
460
+ ];
461
+ if (typeof r.unreadable === "string")
462
+ lines.push(`Unreadable: ${r.unreadable}`);
463
+ return lines.join("\n");
464
+ }
459
465
  export function formatListPlain(r) {
460
466
  const sources = Array.isArray(r.sources) ? r.sources : [];
461
467
  if (sources.length === 0)
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * No registry imports — no circular dependencies.
17
17
  */
18
- export { formatAddPlain, formatBundleShowPlain, formatClonePlain, formatConfigPlain, formatCuratePlain, formatEnvCreatePlain, formatEnvExportPlain, formatEnvListPlain, formatEnvRemovePlain, formatEventLine, formatEventsPlain, formatFeedbackPlain, formatImportPlain, formatIndexPlain, formatInfoPlain, formatInitPlain, formatListPlain, formatModelsListPlain, formatRegistryAddPlain, formatRegistryListPlain, formatRegistryRemovePlain, formatRegistrySearchPlain, formatRememberPlain, formatRemovePlain, formatSearchPlain, formatSyncPlain, formatUpdatePlain, formatUpgradePlain, } from "./command-format.js";
18
+ export { formatAddPlain, formatBundleShowPlain, formatClonePlain, formatConfigPlain, formatCuratePlain, formatEnvCreatePlain, formatEnvExportPlain, formatEnvListPlain, formatEnvRemovePlain, formatEventLine, formatEventsPlain, formatFeedbackPlain, formatImportPlain, formatIndexPlain, formatIndexStatusPlain, formatInfoPlain, formatInitPlain, formatListPlain, formatModelsListPlain, formatRegistryAddPlain, formatRegistryListPlain, formatRegistryRemovePlain, formatRegistrySearchPlain, formatRememberPlain, formatRemovePlain, formatSearchPlain, formatSyncPlain, formatUpdatePlain, formatUpgradePlain, } from "./command-format.js";
19
19
  export { formatHealthPlain } from "./health-format.js";
20
20
  export { formatLintPlain } from "./lint-format.js";
21
21
  export { formatGateDecisionSummary, formatProposalAcceptPlain, formatProposalDiffPlain, formatProposalDrainPlain, formatProposalListPlain, formatProposalProducerPlain, formatProposalRejectPlain, formatProposalShowPlain, } from "./proposal-format.js";
@@ -1,5 +1,8 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
- import { formatIndexPlain } from "./helpers.js";
5
- export const indexFormatters = [{ command: "index", handler: (r) => formatIndexPlain(r) }];
4
+ import { formatIndexPlain, formatIndexStatusPlain } from "./helpers.js";
5
+ export const indexFormatters = [
6
+ { command: "index", handler: (r) => formatIndexPlain(r) },
7
+ { command: "index-status", handler: (r) => formatIndexStatusPlain(r) },
8
+ ];