akm-cli 0.9.15-beta.2 → 0.9.15-beta.3

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.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
- ## [0.9.15-beta.2] - 2026-09-09
7
+ ## [0.9.15-beta.3] - 2026-09-10
8
8
 
9
9
  ### Added
10
10
 
@@ -268,6 +268,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
268
268
  apiKey`, or `none configured` — never the credential's value, so a field
269
269
  run can compare it directly against what the gateway actually logged.
270
270
  `--verbose` also names the config file the run loaded.
271
+ - **`akm health` gains a `scheduler-binary` advisory for scheduler binary
272
+ drift (#953).** A field report found `akm task sync`'s recorded absolute
273
+ akm path can go stale after upgrading through a different installer (npm
274
+ global to a standalone download, or vice versa), leaving a scheduled run
275
+ invoking the old binary indefinitely with nothing surfacing it. The new
276
+ `--probe`-gated advisory reads the scheduler's recorded akm invocation —
277
+ the same binding `task sync`/`task doctor` already read, no crontab text
278
+ parsing — runs it with `--version`, and `warn`s naming both versions when
279
+ it differs from the running CLI, pointing at `akm task sync` as the
280
+ remedy. `unknown` when not probed, no task is installed, or the recorded
281
+ binary cannot be executed.
271
282
 
272
283
  ### Changed
273
284
 
@@ -322,6 +333,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
322
333
  single document that still fails this way is skipped, as
323
334
  `context-window-exceeded`; every other failure (network error, 5xx, malformed
324
335
  response) keeps the prior skip-the-whole-batch behavior.
336
+ - **The default per-request token budget is lower, and adapts mid-run after a
337
+ context-size rejection (#954, field report on beta.1).**
338
+ `embedding.maxTokens`'s default dropped from 8000 to 6000: the 4-chars-per-
339
+ token estimator undercounts dense technical text by 7-55%, so 8000 regularly
340
+ overshot a real 8192-token endpoint. On an `akm index` run's first
341
+ context-size rejection, the effective request budget additionally shrinks to
342
+ three quarters of its current value (floored at twice
343
+ `embedding.maxInputTokens`) for every request not yet sent, and one
344
+ default-level line reports the new value; it never shrinks a second time in
345
+ the same run. Users who set `embedding.maxTokens` explicitly keep it as the
346
+ starting point but still benefit from this same-run recovery.
325
347
  - **Embedding requests are dispatched through a small in-flight window instead of
326
348
  strictly sequentially (#954).** The window defaults to 1 request at a time for
327
349
  a loopback endpoint and 2 for a remote one; the actual throughput knob is
@@ -432,6 +454,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
432
454
  Source-cache hydration (which runs before `index.db` is even opened) now
433
455
  reports its own progress the same way: `Hydrating source i/n: <name>` per
434
456
  source, plus a 15s heartbeat while a sync is in flight.
457
+ - **`embedding.chunkSize` is retired (#954).** Nothing under `src/` ever read
458
+ it; it was declared in the config schema but had no effect. It is removed
459
+ from `EmbeddingConnectionConfigSchema` and `schemas/akm-config.json`. The
460
+ `embedding` object stays `.passthrough()`, so a config that still sets
461
+ `embedding.chunkSize` keeps loading exactly as before — the key is simply
462
+ ignored, not rejected or warned about.
435
463
 
436
464
  ### Fixed
437
465
 
@@ -533,6 +561,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
533
561
  physical batch size", which the existing context-size pattern
534
562
  (`exceed_context_size_error`, "context size", …) did not match, so the
535
563
  whole batch was dropped instead of being split and retried like a 413.
564
+ - **The end-of-run throughput line now sums the capped text actually sent to
565
+ the embedding provider (#954).** `storedTokens` accumulated
566
+ `estimateTokenCount(entry.searchText)` — the entry's pre-cap search text —
567
+ while the request `embedBatch` received held the text `capEmbeddingText`
568
+ had already truncated to `embedding.maxInputTokens`, so the reported
569
+ `tokens/s` figure overstated throughput for every entry over the cap. The
570
+ final line now sums the estimate of the capped text the batching loop
571
+ already built, matching what the provider was actually asked to embed.
536
572
  - **A `kill <launcher-pid>` no longer orphans the running `akm` process
537
573
  (#956).** The published launcher (`scripts/node-runtime/akm`/
538
574
  `akm-migrate`) now forwards SIGTERM/SIGINT/SIGHUP to its bun/node child
@@ -562,6 +598,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
562
598
  instead of reusing them. A plain `akm index` resume after an interruption
563
599
  now embeds only the entries still missing a vector, with no purge and no
564
600
  canary.
601
+ - **A concurrent `akm index` without `--skip-if-locked` now fails with a
602
+ retryable-shortly exit code instead of a raw driver error (#956).**
603
+ Contention with another writer touching index.db (a second `akm index`, a
604
+ source add/update's embedding pass, the per-command background reindex)
605
+ used to exhaust the SQLite driver's retry window and surface as
606
+ `{"ok":false,"error":"database is locked"}` at exit 70
607
+ (internal/unclassified). It is now reclassified into a `TransientError`
608
+ with a dedicated `INDEX_DB_CONTENDED` code (exit 75), naming the rebuild
609
+ lock's live holder pid when known, mirroring `STATE_DB_CONTENDED`'s
610
+ precedent (#948) for state.db; the original driver text survives as
611
+ `cause`. `--skip-if-locked` is unaffected — it already skips gracefully
612
+ before ever attempting the write.
613
+ - **The fingerprint-rename canary embeds the exact text the stored vector was
614
+ generated from (#955).** `sampleEmbeddedEntriesForCanary` handed the canary
615
+ the entry's raw `search_text`, while the main embedding pass caps it to
616
+ `embedding.maxInputTokens` before ever calling the provider — so for any
617
+ entry whose search text exceeded the cap, the canary's freshly re-embedded
618
+ vector came from a different input than the one that produced the stored
619
+ vector, and the median cosine similarity could fall below the compatibility
620
+ threshold for reasons unrelated to the model, triggering a needless full
621
+ purge and rebuild on a same-model rename. The canary now caps each sampled
622
+ entry's search text the same way, through the same `capEmbeddingText`
623
+ helper, before requesting its vector.
565
624
 
566
625
  ## [0.9.14] - 2026-09-04
567
626
 
package/dist/cli.js CHANGED
@@ -295,8 +295,8 @@ const healthCommand = defineCommand({
295
295
  probe: {
296
296
  type: "boolean",
297
297
  default: true,
298
- description: "Probe default-llm-engine / configured-engines reachability and check for a newer akm release (on by default).",
299
- negativeDescription: "Skip the reachability probes and the update check (for an offline or air-gapped host).",
298
+ description: "Probe default-llm-engine / configured-engines reachability, check for a newer akm release, and check the scheduler's recorded akm binary version (on by default).",
299
+ negativeDescription: "Skip the reachability probes, the update check, and the scheduler-binary version check (for an offline or air-gapped host).",
300
300
  },
301
301
  },
302
302
  async run({ args }) {
@@ -556,7 +556,7 @@ export const main = defineCommand({
556
556
  " 2 usage error\n" +
557
557
  " 4 health warn (akm health only)\n" +
558
558
  " 70 internal / unclassified error\n" +
559
- " 75 transient (retry shortly — another akm process holds a lock or is writing state.db)\n" +
559
+ " 75 transient (retry shortly — another akm process holds a lock or is writing state.db or index.db)\n" +
560
560
  " 78 config error",
561
561
  },
562
562
  args: {
@@ -1164,11 +1164,20 @@ export const HEALTH_CHECKS = [
1164
1164
  run: (ctx) => ctx.versionDrift,
1165
1165
  },
1166
1166
  {
1167
- // #950: registered last order is load-bearing (see the HEALTH_CHECKS
1168
- // doc comment above). Advisory channel, `kind: "deterministic"` — same
1167
+ // #950: advisory channel, `kind: "deterministic"` same
1169
1168
  // exit-code-gating rationale as thinking-control above.
1170
1169
  name: "engine-last-used",
1171
1170
  channel: "advisory",
1172
1171
  run: (ctx) => projectEngineLastUsedCheck(ctx.activeImproveStrategyEngines, ctx.engineLastUsed, ctx.improveRunsInLookbackWindow, ENGINE_LAST_USED_LOOKBACK_DAYS),
1173
1172
  },
1173
+ {
1174
+ // #953: registered last — order is load-bearing (see the HEALTH_CHECKS
1175
+ // doc comment above). Best-effort scheduler-binary-drift advisory, gated
1176
+ // behind the same --probe/--no-probe flag as engine reachability and
1177
+ // cli-version. Computed once in health.ts (process-spawn IO), projected
1178
+ // here like versionDrift/engineProbes.
1179
+ name: "scheduler-binary",
1180
+ channel: "advisory",
1181
+ run: (ctx) => ctx.schedulerBinaryDrift,
1182
+ },
1174
1183
  ];
@@ -0,0 +1,120 @@
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
+ * `scheduler-binary` advisory for `akm health` (#953).
6
+ *
7
+ * `akm task sync` records an absolute akm invocation path in the OS
8
+ * scheduler (`src/tasks/resolve-akm-bin.ts`) because cron/launchd/schtasks
9
+ * all run jobs with a minimal PATH. A field report showed that path going
10
+ * stale after upgrading akm through a different installer than the one
11
+ * active at the last `task sync` (e.g. npm-global to a standalone binary):
12
+ * the schedule kept invoking a version behind, with nothing in `akm health`
13
+ * surfacing it, until an unrelated failure (a rejected `secret://` engine
14
+ * key on the stale binary) surfaced the drift.
15
+ *
16
+ * Modelled 1:1 on `version-drift.ts`'s shape: an injectable seam,
17
+ * best-effort, `--probe`-gated so an air-gapped host's `--no-probe` habit
18
+ * suppresses this too, and `unknown` on any uncertainty rather than a false
19
+ * `pass`/`warn`. Reads the scheduler's recorded binding via
20
+ * `SchedulerBackend.list()` — the same reader `akm task doctor` uses — never
21
+ * a second crontab/launchd/schtasks text parser.
22
+ */
23
+ import { spawnSync } from "node:child_process";
24
+ import { selectBackend } from "../../tasks/backends/index.js";
25
+ import { pkgVersion } from "../../version.js";
26
+ /**
27
+ * Bound on the scheduler-recorded binary's `--version` probe. Matches the
28
+ * `--version` timeout the engine-reachability checks already use
29
+ * (checks.ts's `runConfiguredEngineProbe`), so a wedged or missing binary
30
+ * degrades this advisory to `unknown` in seconds rather than blocking
31
+ * `akm health --probe`.
32
+ */
33
+ const SCHEDULER_BINARY_VERSION_PROBE_TIMEOUT_MS = 5_000;
34
+ /**
35
+ * Build the `scheduler-binary` advisory. `probe` mirrors the
36
+ * engine-reachability and `cli-version` checks' `--probe`/`--no-probe`
37
+ * gating: only inspects the scheduler and spawns a process when `true`;
38
+ * otherwise `unknown` with "not probed", never touching the OS scheduler.
39
+ */
40
+ export async function collectSchedulerBinaryAdvisory(probe, deps = {}) {
41
+ const cliVersion = deps.cliVersion ?? pkgVersion;
42
+ if (!probe) {
43
+ return {
44
+ name: "scheduler-binary",
45
+ kind: "deterministic",
46
+ status: "unknown",
47
+ confidence: "high",
48
+ message: "Scheduler binary version drift was not probed.",
49
+ };
50
+ }
51
+ let installed;
52
+ try {
53
+ installed = await (deps.backend ?? selectBackend()).list();
54
+ }
55
+ catch (error) {
56
+ return {
57
+ name: "scheduler-binary",
58
+ kind: "deterministic",
59
+ status: "unknown",
60
+ confidence: "high",
61
+ message: `Installed scheduled tasks could not be inspected: ${error instanceof Error ? error.message : String(error)}`,
62
+ };
63
+ }
64
+ // task sync rewrites every installed binding to the same current
65
+ // invocation atomically, so the first entry's binding represents the
66
+ // whole schedule under normal operation.
67
+ const [firstInstalled] = installed;
68
+ const [binaryPath, ...leadingArgs] = firstInstalled?.binding ?? [];
69
+ if (!firstInstalled || !binaryPath) {
70
+ return {
71
+ name: "scheduler-binary",
72
+ kind: "deterministic",
73
+ status: "unknown",
74
+ confidence: "high",
75
+ message: "No scheduled task is installed.",
76
+ };
77
+ }
78
+ const binding = firstInstalled.binding;
79
+ const run = deps.spawnSync ?? spawnSync;
80
+ let scheduledVersion;
81
+ try {
82
+ const result = run(binaryPath, [...leadingArgs, "--version"], {
83
+ encoding: "utf8",
84
+ timeout: SCHEDULER_BINARY_VERSION_PROBE_TIMEOUT_MS,
85
+ });
86
+ if ((result.status ?? 1) === 0)
87
+ scheduledVersion = result.stdout?.trim() || undefined;
88
+ }
89
+ catch {
90
+ scheduledVersion = undefined;
91
+ }
92
+ if (!scheduledVersion) {
93
+ return {
94
+ name: "scheduler-binary",
95
+ kind: "deterministic",
96
+ status: "unknown",
97
+ confidence: "high",
98
+ message: "The scheduler's recorded akm binary could not be executed.",
99
+ evidence: { binding },
100
+ };
101
+ }
102
+ if (scheduledVersion === cliVersion) {
103
+ return {
104
+ name: "scheduler-binary",
105
+ kind: "deterministic",
106
+ status: "pass",
107
+ confidence: "high",
108
+ message: `Scheduled tasks are bound to akm v${scheduledVersion}, matching the running CLI.`,
109
+ evidence: { binding, scheduledVersion, cliVersion },
110
+ };
111
+ }
112
+ return {
113
+ name: "scheduler-binary",
114
+ kind: "deterministic",
115
+ status: "warn",
116
+ confidence: "high",
117
+ message: `Scheduled tasks are bound to akm v${scheduledVersion}, but the running CLI is v${cliVersion} — run \`akm task sync\` to rebind the schedule.`,
118
+ evidence: { binding, scheduledVersion, cliVersion },
119
+ };
120
+ }
@@ -27,6 +27,7 @@ import { buildImproveSkipSummary, computeWallTimeStats, countAgentFailureReasons
27
27
  import { emptyLlmUsageAggregate, readLlmUsageAggregate } from "./health/llm-usage.js";
28
28
  import { computeDegradationMetrics, computeDenominatorFixedCoverage, computeEnrichmentMintingRollup, probeStateDbRoundTrip, } from "./health/metrics.js";
29
29
  import { collectPluginStalenessAdvisories } from "./health/plugin-staleness.js";
30
+ import { collectSchedulerBinaryAdvisory } from "./health/scheduler-binary.js";
30
31
  import { collectStashExposureAdvisory } from "./health/stash-exposure.js";
31
32
  import { collectSurfacesAdvisories } from "./health/surfaces.js";
32
33
  import { buildPerRunSummaries } from "./health/task-runs.js";
@@ -542,6 +543,12 @@ export async function akmHealth(options = {}) {
542
543
  // above — started here, alongside it, and awaited later.
543
544
  const versionDriftPromise = collectVersionDriftAdvisory(Boolean(options.probe), { cliVersion: pkgVersion });
544
545
  versionDriftPromise.catch(() => undefined);
546
+ // #953: same --probe-gated, best-effort discipline as versionDriftPromise
547
+ // above.
548
+ const schedulerBinaryDriftPromise = collectSchedulerBinaryAdvisory(Boolean(options.probe), {
549
+ cliVersion: pkgVersion,
550
+ });
551
+ schedulerBinaryDriftPromise.catch(() => undefined);
545
552
  const taskHistory = gatherTaskHistoryPhase(db, logsDb, since, stateDbPath, now);
546
553
  const { tableNames, missingTables, probe } = taskHistory;
547
554
  const { egressConfigView, thinkingOffEngines } = gatherEgressConfigPhase();
@@ -559,6 +566,7 @@ export async function akmHealth(options = {}) {
559
566
  const improveRunsInLookbackWindow = countImproveRunsSince(db, engineLastUsedSinceIso);
560
567
  const engineProbes = await engineProbesPromise;
561
568
  const versionDrift = await versionDriftPromise;
569
+ const schedulerBinaryDrift = await schedulerBinaryDriftPromise;
562
570
  // Read once, shared by the `thinking-control` check (#949) and the
563
571
  // `metrics.llmUsage` report field below — same window, same aggregate.
564
572
  const llmUsage = readLlmUsageAggregate(stateDbPath, since);
@@ -592,6 +600,7 @@ export async function akmHealth(options = {}) {
592
600
  activeImproveStrategyEngines,
593
601
  engineLastUsed,
594
602
  improveRunsInLookbackWindow,
603
+ schedulerBinaryDrift,
595
604
  };
596
605
  for (const check of HEALTH_CHECKS) {
597
606
  const result = check.run(checkContext);
@@ -45,13 +45,12 @@ export const EmbeddingConnectionConfigSchema = z
45
45
  maxInputTokens: positiveInt.optional(),
46
46
  /**
47
47
  * Client-side per-request token budget — how many documents' estimated
48
- * tokens fit in one HTTP request (default `DEFAULT_TOKEN_BUDGET` = 8000
48
+ * tokens fit in one HTTP request (default `DEFAULT_TOKEN_BUDGET` = 6000
49
49
  * in `src/llm/embedders/remote.ts`). With the 512-token `maxInputTokens`
50
- * cap above, a request carries about 16 documents by default.
50
+ * cap above, a request carries about 11 documents by default.
51
51
  */
52
52
  maxTokens: positiveInt.optional(),
53
53
  batchSize: positiveInt.optional(),
54
- chunkSize: positiveInt.optional(),
55
54
  /**
56
55
  * Ollama's `num_ctx` ONLY (#956) — sent verbatim as
57
56
  * `options.num_ctx` on the native `/api/embed` request. It no longer also
@@ -82,6 +82,7 @@ const USAGE_HINTS = {
82
82
  const TRANSIENT_HINTS = {
83
83
  RUN_LEASE_HELD: "Wait for the named engine invocation to finish or for the lease to expire, then retry. `akm workflow status <id>` shows the current lease.",
84
84
  STATE_DB_CONTENDED: "Another akm process is writing state.db right now. Wait a few seconds and retry; commands that support --skip-if-locked can skip instead of failing.",
85
+ INDEX_DB_CONTENDED: "Another akm process is writing index.db; retry shortly, or pass --skip-if-locked on scheduled runs.",
85
86
  };
86
87
  /** Default hint for each NotFoundError code. */
87
88
  const NOT_FOUND_HINTS = {
@@ -7,12 +7,14 @@ import { detectAdapterId } from "../core/adapter/detect-adapter.js";
7
7
  import { adapterForId } from "../core/adapter/registry.js";
8
8
  import { isHttpUrl, toErrorMessage } from "../core/common.js";
9
9
  import { concurrentMap } from "../core/concurrent.js";
10
- import { ConfigError } from "../core/errors.js";
10
+ import { AkmError, ConfigError, TransientError } from "../core/errors.js";
11
+ import { probeLock } from "../core/file-lock.js";
11
12
  import { defaultConcurrencyForEndpoint } from "../core/loopback.js";
12
13
  import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
13
14
  import { getDbPath } from "../core/paths.js";
14
15
  import { SCRIPT_EXTENSIONS } from "../core/recognition-util.js";
15
- import { withStateDb } from "../core/state-db.js";
16
+ import { formatLockHolderPid } from "../core/run-lock.js";
17
+ import { isSqliteContentionError, withStateDb } from "../core/state-db.js";
16
18
  import { isVerbose, warn, warnOnce, warnVerbose } from "../core/warn.js";
17
19
  import { disposeLoweredExecutionDispatchLease, } from "../integrations/agent/execution-lowering.js";
18
20
  import { isLlmFeatureEnabled } from "../llm/feature-gate.js";
@@ -28,6 +30,7 @@ import { upsertUtilityScore } from "../storage/repositories/index-utility-reposi
28
30
  import { getEmbeddingCount, isVecAvailable, isVecFastPathReady, warnIfVecMissing, } from "../storage/repositories/index-vec-repository.js";
29
31
  import { assertIndexedWorkflowSourceIdentity, WorkflowSourceIdentityError } from "../workflows/source-files.js";
30
32
  import { deleteStoredGraph } from "./db/graph-db.js";
33
+ import { indexRebuildLockPath } from "./index-rebuild-lock.js";
31
34
  import { deriveEntryProvenance, deriveInstallations } from "./installations.js";
32
35
  import { indexedPathMatchesOwner, resolveAdapterConceptOwner, } from "./lookup/adapter-concept-owner.js";
33
36
  import { generateEmbeddingsForDb } from "./materialize-embeddings.js";
@@ -371,6 +374,44 @@ let akmIndexOverride;
371
374
  export function _setAkmIndexForTests(fake) {
372
375
  akmIndexOverride = fake;
373
376
  }
377
+ /**
378
+ * Read-only description of the rebuild lock's current holder, appended to a
379
+ * reclassified index.db contention message when known (field follow-up to
380
+ * #956). `probeLock` only inspects the sentinel — it never acquires or
381
+ * mutates it — so this is safe to call from inside an error path.
382
+ */
383
+ function describeIndexRebuildLockHolder() {
384
+ const probe = probeLock(indexRebuildLockPath());
385
+ if (probe.state !== "held")
386
+ return "";
387
+ return ` The rebuild lock is currently held by pid ${formatLockHolderPid({
388
+ pid: probe.holderPid,
389
+ launcherPid: probe.launcherPid ?? null,
390
+ })}.`;
391
+ }
392
+ /**
393
+ * Reclassify a contention-shaped error escaping the walk, index, or
394
+ * embedding phase into a retryable-shortly `TransientError` (field
395
+ * follow-up to #956, dev-team field review 2026-09-10): a concurrent writer
396
+ * (another `akm index`, a source-update embedding pass, the per-command
397
+ * background reindex) can make index.db busy, and the raw SQLite driver
398
+ * error ("database is locked") used to escape as exit 70
399
+ * (internal/unclassified) instead of the "retry shortly" contract exit 75
400
+ * gives a scheduler to branch on — mirroring `STATE_DB_CONTENDED`'s
401
+ * precedent for state.db (`core/state-db.ts`). Reuses the ONE shared
402
+ * classifier, `isSqliteContentionError`, rather than a second one. An error
403
+ * that is already a classified akm error (e.g. a `STATE_DB_CONTENDED`
404
+ * TransientError from an inner state.db write) is never re-wrapped — only a
405
+ * raw, unclassified error matching the shared contention shape is
406
+ * reclassified. Every other error is rethrown unchanged.
407
+ */
408
+ export function reclassifyIndexDbContention(error) {
409
+ if (error instanceof AkmError || !isSqliteContentionError(error))
410
+ return error;
411
+ const contended = new TransientError(`akm's index database is busy (another akm process is writing it); retry shortly.${describeIndexRebuildLockHolder()}`, "INDEX_DB_CONTENDED");
412
+ contended.cause = error;
413
+ return contended;
414
+ }
374
415
  export async function akmIndex(options) {
375
416
  try {
376
417
  const override = akmIndexOverride;
@@ -387,7 +428,7 @@ export async function akmIndex(options) {
387
428
  // rollback before closing its borrowed unified handle.
388
429
  }
389
430
  }
390
- throw error;
431
+ throw reclassifyIndexDbContention(error);
391
432
  }
392
433
  }
393
434
  let indexTransactionHookForTests;
@@ -136,8 +136,14 @@ function deriveObservedEmbeddingIdentity(embedding, observedModel, observedVecto
136
136
  * direct `RemoteEmbedder`) so every embedder branch — remote, local,
137
137
  * deterministic, and test overrides via `_setEmbedderForTests` — is
138
138
  * exercised identically to the main embedding pass.
139
+ *
140
+ * `maxInputTokens` must be the SAME cap the main pass below applies via
141
+ * {@link capEmbeddingText} — the stored vector for each sampled entry was
142
+ * produced from its capped text, so comparing against a fresh vector of the
143
+ * uncapped text would compare unlike inputs for any entry over the cap
144
+ * (#955).
139
145
  */
140
- async function runEmbeddingCanary(db, config, signal) {
146
+ async function runEmbeddingCanary(db, config, signal, maxInputTokens) {
141
147
  const samples = sampleEmbeddedEntriesForCanary(db, CANARY_SAMPLE_SIZE);
142
148
  if (samples.length === 0) {
143
149
  return { outcome: "keep", verified: false, viaIdentityMatch: false };
@@ -146,7 +152,15 @@ async function runEmbeddingCanary(db, config, signal) {
146
152
  const skips = [];
147
153
  let canaryVectors;
148
154
  try {
149
- canaryVectors = await embedBatch(samples.map((sample) => sample.searchText), config.embedding, signal, (skip) => skips.push(skip), (_indices, _embeddings, model) => {
155
+ canaryVectors = await embedBatch(
156
+ // #955: the stored vector for each sample was produced from
157
+ // capEmbeddingText(searchText, maxInputTokens) — the main pass below
158
+ // caps every document before embedding it. The canary must re-embed
159
+ // the SAME capped text, or an entry over the cap compares a fresh
160
+ // vector of a different input against a stored vector of the capped
161
+ // one, and a genuine model match can read as a rebuild-worthy
162
+ // mismatch for reasons unrelated to the model.
163
+ samples.map((sample) => capEmbeddingText(sample.searchText, maxInputTokens).text), config.embedding, signal, (skip) => skips.push(skip), (_indices, _embeddings, model) => {
150
164
  if (model)
151
165
  observedModel = model;
152
166
  });
@@ -252,6 +266,10 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
252
266
  let targetEntryIds = entryIds;
253
267
  /** Set only on an actual rebuild, so the up-front "Re-embedding N entries" line names why. */
254
268
  let rebuildReason;
269
+ // Resolved once and reused by both the canary (below) and the main pass's
270
+ // cap loop (further down) — the same cap must apply to both, or the canary
271
+ // compares a differently-capped text against the stored vector (#955).
272
+ const maxInputTokens = config.embedding?.maxInputTokens ?? DEFAULT_MAX_INPUT_TOKENS;
255
273
  if (opts?.forceReembed) {
256
274
  // `akm index --reembed`: an explicit operator override, skips the canary
257
275
  // entirely. The new fingerprint (and identity, now stale/unknown until
@@ -272,7 +290,7 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
272
290
  rebuildReason = "forced by --reembed";
273
291
  }
274
292
  else if (storedFingerprint && storedFingerprint !== currentFingerprint) {
275
- const decision = await runEmbeddingCanary(db, config, signal);
293
+ const decision = await runEmbeddingCanary(db, config, signal, maxInputTokens);
276
294
  if (decision.outcome === "unverifiable") {
277
295
  // Destroying a good index because the server happens to be down right
278
296
  // now is worse than leaving a rename unverified until the next run —
@@ -377,12 +395,12 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
377
395
  return reusedCount > 0 ? { success: true, vecInsertFailures: vecFailedCount } : { success: true };
378
396
  }
379
397
  // Cap each document's embedded text at
380
- // embedding.maxInputTokens (default DEFAULT_MAX_INPUT_TOKENS) instead of
381
- // ever failing a whole batch over one oversized entry truncation keeps
382
- // the head of the text, unicode-safe. A document is skipped only when its
398
+ // embedding.maxInputTokens (default DEFAULT_MAX_INPUT_TOKENS, resolved
399
+ // once above so the canary uses the identical cap) instead of ever
400
+ // failing a whole batch over one oversized entry truncation keeps the
401
+ // head of the text, unicode-safe. A document is skipped only when its
383
402
  // head is empty (the impossible case: nothing left to embed), never
384
403
  // merely for being long.
385
- const maxInputTokens = config.embedding?.maxInputTokens ?? DEFAULT_MAX_INPUT_TOKENS;
386
404
  let truncatedCount = 0;
387
405
  const texts = [];
388
406
  const pendingEntries = [];
@@ -529,6 +547,23 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
529
547
  }
530
548
  return;
531
549
  }
550
+ // #954: a "budget-lowered" event is the same kind of notice as
551
+ // "retrying" above — the run's first context-size rejection just
552
+ // shrank the request budget for everything not yet dispatched, but
553
+ // THIS rejected batch's own indices are still being split and
554
+ // retried by the embedder (their real stored/failed outcome lands in
555
+ // a later onBatch call). Nothing here has settled, so it must never
556
+ // touch storage, only report the notice — one line, at most once per
557
+ // run.
558
+ if (outcome?.outcome === "budget-lowered") {
559
+ if (reportPerBatchLine) {
560
+ onProgress({
561
+ phase: "embeddings",
562
+ message: `[embed] batch ${outcome.batchIndex}/${outcome.batchCount}: ${outcome.docCount} docs, ${outcome.requestTokens.toLocaleString()} tokens → ${outcome.reason}`,
563
+ });
564
+ }
565
+ return;
566
+ }
532
567
  if (model)
533
568
  observedModel = model;
534
569
  // A batch that delivered at least one real embedding proves the
@@ -542,7 +577,8 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
542
577
  }
543
578
  db.transaction(() => {
544
579
  for (let k = 0; k < indices.length; k++) {
545
- const entry = pendingEntries[indices[k]];
580
+ const index = indices[k];
581
+ const entry = pendingEntries[index];
546
582
  if (!entry)
547
583
  continue;
548
584
  const embedding = batchEmbeddings[k];
@@ -555,7 +591,13 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
555
591
  const result = upsertEmbedding(db, entry.id, embedding);
556
592
  if (result.stored) {
557
593
  storedCount++;
558
- storedTokens += estimateTokenCount(entry.searchText);
594
+ // #954: sum the estimate of the text actually sent —
595
+ // `texts[index]` is the capped string `embedBatch` was handed,
596
+ // parallel to `pendingEntries` by construction above (the
597
+ // `entry` guard covers both) — not `entry.searchText`, which is
598
+ // the pre-cap original and overstates throughput for every
599
+ // entry over the cap.
600
+ storedTokens += estimateTokenCount(texts[index]);
559
601
  }
560
602
  else {
561
603
  skippedCount++;