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.
@@ -30,10 +30,17 @@ export const DEFAULT_REMOTE_BATCH_SIZE = 100;
30
30
  * a batch of 100 small docs (~400 KB, ~100K tokens) took 14.8s against a
31
31
  * healthy local endpoint — half the 30s request timeout — and a single
32
32
  * 128 KB (~24K token) document alone was rejected by the endpoint as
33
- * exceeding its context size. 8000 tokens keeps a batch's estimated size
34
- * comfortably under both the timeout and common local-model context windows.
33
+ * exceeding its context size.
34
+ *
35
+ * Lowered from 8000 to 6000 (#954, field report on beta.1): the 4-chars-
36
+ * per-token estimator undercounts dense technical text by 7-55%, so 8000
37
+ * against an 8192-token llama.cpp embedder regularly landed real requests
38
+ * over the endpoint's context window. 6000 is the value the field confirmed
39
+ * stops that steady trickle of rejections; `embedBatch`'s run-scoped
40
+ * adaptive budget below still shrinks further, for an endpoint where even
41
+ * this is not enough.
35
42
  */
36
- export const DEFAULT_TOKEN_BUDGET = 8000;
43
+ export const DEFAULT_TOKEN_BUDGET = 6000;
37
44
  /** Cheap token estimator: 4 chars ≈ 1 token. Used in verbose logging and error messages. */
38
45
  export function estimateTokenCount(text) {
39
46
  return Math.round(text.length / 4);
@@ -73,7 +80,7 @@ export function capEmbeddingText(text, maxTokens) {
73
80
  /**
74
81
  * Default per-request timeout when `embedding.timeoutMs` is unset (#954).
75
82
  * The prior fixed 30s cut off exactly the field-report case: a
76
- * local model server on an 8000-token (`DEFAULT_TOKEN_BUDGET`) batch
83
+ * local model server on a full-budget (`DEFAULT_TOKEN_BUDGET`) batch
77
84
  * legitimately takes longer than that, and the timeout fired mid-response
78
85
  * with no retry — every batch it hit was silently dropped for the rest of
79
86
  * an hours-long run. 120s comfortably covers a slow local batch while still
@@ -235,6 +242,21 @@ export function buildTokenBoundedBatches(texts, tokenBudget, maxCount) {
235
242
  flush();
236
243
  return batches;
237
244
  }
245
+ /**
246
+ * Shrink factor applied to the effective request budget on the first
247
+ * context-size rejection of an `embedBatch` run (#954, field report on
248
+ * beta.1): one 25% cut absorbs the estimator's measured undercount without
249
+ * repeatedly re-shrinking mid-run — see the "shrink at most once" rule on
250
+ * {@link RemoteEmbedder.embedBatch}.
251
+ */
252
+ const ADAPTIVE_BUDGET_SHRINK_FACTOR = 0.75;
253
+ /**
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.
258
+ */
259
+ const ADAPTIVE_BUDGET_FLOOR_MULTIPLIER = 2;
238
260
  export class RemoteEmbedder {
239
261
  config;
240
262
  endpoint;
@@ -339,6 +361,18 @@ export class RemoteEmbedder {
339
361
  * {@link scaleEmbeddingTimeoutMs}, so a dead server is detected in seconds
340
362
  * on a small batch rather than always waiting out the full configured
341
363
  * `embedding.timeoutMs`.
364
+ *
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.
342
376
  */
343
377
  async embedBatch(texts, signal, onSkip, onBatch) {
344
378
  if (texts.length === 0)
@@ -351,10 +385,59 @@ export class RemoteEmbedder {
351
385
  // request budget too, so a config author setting it for one purpose
352
386
  // silently changed the other. `maxTokens` is the sole knob for the
353
387
  // request budget now; unset falls back to DEFAULT_TOKEN_BUDGET.
354
- const tokenBudget = this.config.maxTokens ?? 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
393
+ // `concurrentMap` pool below (which reads this same array by reference)
394
+ // picks up the re-planned tail without restarting.
395
+ let effectiveTokenBudget = this.config.maxTokens ?? DEFAULT_TOKEN_BUDGET;
355
396
  const maxCount = this.config.batchSize ?? DEFAULT_REMOTE_BATCH_SIZE;
356
- const textBatches = buildTokenBoundedBatches(texts, tokenBudget, maxCount);
397
+ const maxInputTokens = this.config.maxInputTokens ?? DEFAULT_MAX_INPUT_TOKENS;
398
+ const textBatches = buildTokenBoundedBatches(texts, effectiveTokenBudget, maxCount);
357
399
  const configuredTimeoutMs = resolveEmbeddingTimeoutMs(this.config);
400
+ // How many of `textBatches` concurrentMap has already claimed (its own
401
+ // `nextIndex`, mirrored here so a budget shrink knows where the
402
+ // not-yet-dispatched tail begins). Assigned, not incremented, at the top
403
+ // of `runProviderBatch` — batches are claimed in strictly increasing
404
+ // order, so the highest `batchIndex` seen so far IS the claimed count.
405
+ let dispatchedBatchCount = 0;
406
+ // 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;
409
+ // On the FIRST context-size rejection of this `embedBatch` call, shrink
410
+ // `effectiveTokenBudget` and re-plan every batch `concurrentMap` has not
411
+ // yet claimed from the smaller budget. Never touches `rejectedIndices`
412
+ // itself — the caller's own split-and-retry handles that batch — and is
413
+ // a no-op after the first call (`budgetShrunk`).
414
+ const maybeShrinkBudget = (rejectedIndices, rejectedBatchIndex, rejectedRequestTokens) => {
415
+ if (budgetShrunk)
416
+ return;
417
+ budgetShrunk = true;
418
+ const floor = ADAPTIVE_BUDGET_FLOOR_MULTIPLIER * maxInputTokens;
419
+ effectiveTokenBudget = Math.max(Math.round(effectiveTokenBudget * ADAPTIVE_BUDGET_SHRINK_FACTOR), floor);
420
+ const notYetDispatched = textBatches.slice(dispatchedBatchCount);
421
+ const remainingIndices = notYetDispatched.flatMap((batch) => batch.indices);
422
+ if (remainingIndices.length > 0) {
423
+ const remainingTexts = remainingIndices.map((i) => texts[i]);
424
+ const replanned = buildTokenBoundedBatches(remainingTexts, effectiveTokenBudget, maxCount).map((batch) => ({
425
+ indices: batch.indices.map((localIndex) => remainingIndices[localIndex]),
426
+ oversized: batch.oversized,
427
+ }));
428
+ textBatches.splice(dispatchedBatchCount, textBatches.length - dispatchedBatchCount, ...replanned);
429
+ }
430
+ warnVerbose(`[embed] provider rejected a ${rejectedRequestTokens}-token request as over its context; request budget lowered to ${effectiveTokenBudget.toLocaleString()} for the rest of this run`);
431
+ commitBatch(rejectedIndices, rejectedIndices.map(() => undefined), undefined, {
432
+ batchIndex: rejectedBatchIndex,
433
+ batchCount: textBatches.length,
434
+ docCount: rejectedIndices.length,
435
+ requestTokens: rejectedRequestTokens,
436
+ elapsedMs: 0,
437
+ outcome: "budget-lowered",
438
+ reason: `provider rejected ${rejectedRequestTokens.toLocaleString()} tokens as over its context; request budget lowered to ${effectiveTokenBudget.toLocaleString()} for the rest of this run`,
439
+ });
440
+ };
358
441
  // Stops the pool from claiming any FURTHER provider batch once the
359
442
  // caller's onBatch has failed once (the materializer's transaction
360
443
  // failed, so a subsequent commit would just fail again) — dispatching
@@ -437,7 +520,7 @@ export class RemoteEmbedder {
437
520
  return;
438
521
  const batch = indices.map((i) => texts[i]);
439
522
  const requestTokens = batch.reduce((sum, text) => sum + estimateTokenCount(text), 0);
440
- const requestTimeoutMs = scaleEmbeddingTimeoutMs(configuredTimeoutMs, requestTokens, tokenBudget);
523
+ const requestTimeoutMs = scaleEmbeddingTimeoutMs(configuredTimeoutMs, requestTokens, effectiveTokenBudget);
441
524
  const requestStart = Date.now();
442
525
  let batchEmbeddings;
443
526
  let responseModel;
@@ -457,6 +540,15 @@ export class RemoteEmbedder {
457
540
  // failed" condition, it means stop entirely.
458
541
  if (signal?.aborted)
459
542
  throw err;
543
+ if (err instanceof ContextExceededError) {
544
+ // #954: the run's FIRST context-size rejection (any size) shrinks
545
+ // the budget for everything not yet dispatched; a no-op after the
546
+ // first call. Deliberately BEFORE the split below — it must fire
547
+ // for a single-document rejection too (which never reaches the
548
+ // `indices.length > 1` split branch), and it never touches this
549
+ // batch's own split-and-retry.
550
+ maybeShrinkBudget(indices, batchIndex, requestTokens);
551
+ }
460
552
  if (err instanceof ContextExceededError && indices.length > 1) {
461
553
  const mid = Math.ceil(indices.length / 2);
462
554
  await requestAndCommit(indices.slice(0, mid), batchIndex, false, timeoutAttempt);
@@ -554,13 +646,19 @@ export class RemoteEmbedder {
554
646
  });
555
647
  };
556
648
  const runProviderBatch = async (textBatch, batchIndex) => {
649
+ // Claimed in strictly increasing order by `concurrentMap` below, so
650
+ // the highest `batchIndex` seen so far is exactly how many batches it
651
+ // has claimed (#954) — see `maybeShrinkBudget`'s doc comment above.
652
+ // Assigned synchronously at entry, before any `await`, so this always
653
+ // matches `concurrentMap`'s own `nextIndex` at the moment of claim.
654
+ dispatchedBatchCount = batchIndex;
557
655
  if (textBatch.oversized) {
558
656
  const idx = textBatch.indices[0];
559
657
  const estTokens = estimateTokenCount(texts[idx]);
560
658
  onSkip?.({
561
659
  index: idx,
562
660
  reason: "context-window-exceeded",
563
- message: `Document estimated at ${estTokens} tokens exceeds the ${tokenBudget}-token embedding budget; skipped.`,
661
+ message: `Document estimated at ${estTokens} tokens exceeds the ${effectiveTokenBudget}-token embedding budget; skipped.`,
564
662
  batchStart: true,
565
663
  batchSize: 1,
566
664
  });
@@ -7070,7 +7070,8 @@ var init_errors = __esm(() => {
7070
7070
  };
7071
7071
  TRANSIENT_HINTS = {
7072
7072
  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.",
7073
- 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."
7073
+ 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.",
7074
+ INDEX_DB_CONTENDED: "Another akm process is writing index.db; retry shortly, or pass --skip-if-locked on scheduled runs."
7074
7075
  };
7075
7076
  NOT_FOUND_HINTS = {
7076
7077
  ASSET_NOT_FOUND: "Run `akm search <query>` or `akm index` to refresh the index.",
@@ -75920,7 +75921,6 @@ var EmbeddingConnectionConfigSchema = exports_external.object({
75920
75921
  maxInputTokens: positiveInt.optional(),
75921
75922
  maxTokens: positiveInt.optional(),
75922
75923
  batchSize: positiveInt.optional(),
75923
- chunkSize: positiveInt.optional(),
75924
75924
  contextLength: positiveInt.optional(),
75925
75925
  ollamaOptions: EmbeddingOllamaOptionsSchema.optional(),
75926
75926
  timeoutMs: positiveInt.optional(),
@@ -96987,7 +96987,7 @@ function defaultConcurrencyForEndpoint(endpoint) {
96987
96987
  // src/llm/embedders/remote.ts
96988
96988
  init_warn();
96989
96989
  var DEFAULT_REMOTE_BATCH_SIZE = 100;
96990
- var DEFAULT_TOKEN_BUDGET = 8000;
96990
+ var DEFAULT_TOKEN_BUDGET = 6000;
96991
96991
  function estimateTokenCount(text) {
96992
96992
  return Math.round(text.length / 4);
96993
96993
  }
@@ -97069,6 +97069,8 @@ function buildTokenBoundedBatches(texts, tokenBudget, maxCount) {
97069
97069
  flush();
97070
97070
  return batches;
97071
97071
  }
97072
+ var ADAPTIVE_BUDGET_SHRINK_FACTOR = 0.75;
97073
+ var ADAPTIVE_BUDGET_FLOOR_MULTIPLIER = 2;
97072
97074
 
97073
97075
  class RemoteEmbedder {
97074
97076
  config;
@@ -97121,10 +97123,42 @@ class RemoteEmbedder {
97121
97123
  const results = new Array(texts.length).fill(undefined);
97122
97124
  const headers = this.buildHeaders();
97123
97125
  const ollamaOpts = resolveOllamaOptions(this.config);
97124
- const tokenBudget = this.config.maxTokens ?? DEFAULT_TOKEN_BUDGET;
97126
+ let effectiveTokenBudget = this.config.maxTokens ?? DEFAULT_TOKEN_BUDGET;
97125
97127
  const maxCount = this.config.batchSize ?? DEFAULT_REMOTE_BATCH_SIZE;
97126
- const textBatches = buildTokenBoundedBatches(texts, tokenBudget, maxCount);
97128
+ const maxInputTokens = this.config.maxInputTokens ?? DEFAULT_MAX_INPUT_TOKENS;
97129
+ const textBatches = buildTokenBoundedBatches(texts, effectiveTokenBudget, maxCount);
97127
97130
  const configuredTimeoutMs = resolveEmbeddingTimeoutMs(this.config);
97131
+ let dispatchedBatchCount = 0;
97132
+ let budgetShrunk = false;
97133
+ const maybeShrinkBudget = (rejectedIndices, rejectedBatchIndex, rejectedRequestTokens) => {
97134
+ if (budgetShrunk)
97135
+ return;
97136
+ budgetShrunk = true;
97137
+ const floor2 = ADAPTIVE_BUDGET_FLOOR_MULTIPLIER * maxInputTokens;
97138
+ effectiveTokenBudget = Math.max(Math.round(effectiveTokenBudget * ADAPTIVE_BUDGET_SHRINK_FACTOR), floor2);
97139
+ const notYetDispatched = textBatches.slice(dispatchedBatchCount);
97140
+ const remainingIndices = notYetDispatched.flatMap((batch) => batch.indices);
97141
+ if (remainingIndices.length > 0) {
97142
+ const remainingTexts = remainingIndices.map((i) => texts[i]);
97143
+ const replanned = buildTokenBoundedBatches(remainingTexts, effectiveTokenBudget, maxCount).map((batch) => ({
97144
+ indices: batch.indices.map((localIndex) => remainingIndices[localIndex]),
97145
+ oversized: batch.oversized
97146
+ }));
97147
+ textBatches.splice(dispatchedBatchCount, textBatches.length - dispatchedBatchCount, ...replanned);
97148
+ }
97149
+ warnVerbose(`[embed] provider rejected a ${rejectedRequestTokens}-token request as over its context; request budget lowered to ${effectiveTokenBudget.toLocaleString()} for the rest of this run`);
97150
+ commitBatch(rejectedIndices, rejectedIndices.map(() => {
97151
+ return;
97152
+ }), undefined, {
97153
+ batchIndex: rejectedBatchIndex,
97154
+ batchCount: textBatches.length,
97155
+ docCount: rejectedIndices.length,
97156
+ requestTokens: rejectedRequestTokens,
97157
+ elapsedMs: 0,
97158
+ outcome: "budget-lowered",
97159
+ reason: `provider rejected ${rejectedRequestTokens.toLocaleString()} tokens as over its context; request budget lowered to ${effectiveTokenBudget.toLocaleString()} for the rest of this run`
97160
+ });
97161
+ };
97128
97162
  const dispatchAbort = new AbortController;
97129
97163
  const stopDispatch = (reason) => {
97130
97164
  if (!dispatchAbort.signal.aborted)
@@ -97157,7 +97191,7 @@ class RemoteEmbedder {
97157
97191
  return;
97158
97192
  const batch = indices.map((i) => texts[i]);
97159
97193
  const requestTokens = batch.reduce((sum, text) => sum + estimateTokenCount(text), 0);
97160
- const requestTimeoutMs = scaleEmbeddingTimeoutMs(configuredTimeoutMs, requestTokens, tokenBudget);
97194
+ const requestTimeoutMs = scaleEmbeddingTimeoutMs(configuredTimeoutMs, requestTokens, effectiveTokenBudget);
97161
97195
  const requestStart = Date.now();
97162
97196
  let batchEmbeddings;
97163
97197
  let responseModel;
@@ -97174,6 +97208,9 @@ class RemoteEmbedder {
97174
97208
  } catch (err) {
97175
97209
  if (signal?.aborted)
97176
97210
  throw err;
97211
+ if (err instanceof ContextExceededError) {
97212
+ maybeShrinkBudget(indices, batchIndex, requestTokens);
97213
+ }
97177
97214
  if (err instanceof ContextExceededError && indices.length > 1) {
97178
97215
  const mid = Math.ceil(indices.length / 2);
97179
97216
  await requestAndCommit(indices.slice(0, mid), batchIndex, false, timeoutAttempt);
@@ -97239,13 +97276,14 @@ class RemoteEmbedder {
97239
97276
  });
97240
97277
  };
97241
97278
  const runProviderBatch = async (textBatch, batchIndex) => {
97279
+ dispatchedBatchCount = batchIndex;
97242
97280
  if (textBatch.oversized) {
97243
97281
  const idx = textBatch.indices[0];
97244
97282
  const estTokens = estimateTokenCount(texts[idx]);
97245
97283
  onSkip?.({
97246
97284
  index: idx,
97247
97285
  reason: "context-window-exceeded",
97248
- message: `Document estimated at ${estTokens} tokens exceeds the ${tokenBudget}-token embedding budget; skipped.`,
97286
+ message: `Document estimated at ${estTokens} tokens exceeds the ${effectiveTokenBudget}-token embedding budget; skipped.`,
97249
97287
  batchStart: true,
97250
97288
  batchSize: 1
97251
97289
  });
@@ -97469,7 +97507,7 @@ function deriveObservedEmbeddingIdentity(embedding, observedModel, observedVecto
97469
97507
  }
97470
97508
  return `local:${embedding?.localModel ?? DEFAULT_LOCAL_MODEL}|${observedVectorLen}`;
97471
97509
  }
97472
- async function runEmbeddingCanary(db, config, signal) {
97510
+ async function runEmbeddingCanary(db, config, signal, maxInputTokens) {
97473
97511
  const samples = sampleEmbeddedEntriesForCanary(db, CANARY_SAMPLE_SIZE);
97474
97512
  if (samples.length === 0) {
97475
97513
  return { outcome: "keep", verified: false, viaIdentityMatch: false };
@@ -97478,7 +97516,7 @@ async function runEmbeddingCanary(db, config, signal) {
97478
97516
  const skips = [];
97479
97517
  let canaryVectors;
97480
97518
  try {
97481
- canaryVectors = await embedBatch(samples.map((sample) => sample.searchText), config.embedding, signal, (skip) => skips.push(skip), (_indices, _embeddings, model) => {
97519
+ canaryVectors = await embedBatch(samples.map((sample) => capEmbeddingText(sample.searchText, maxInputTokens).text), config.embedding, signal, (skip) => skips.push(skip), (_indices, _embeddings, model) => {
97482
97520
  if (model)
97483
97521
  observedModel = model;
97484
97522
  });
@@ -97548,6 +97586,7 @@ async function generateEmbeddingsForDb(db, config, onProgress, signal, entryIds,
97548
97586
  const storedFingerprint = getMeta(db, "embeddingFingerprint");
97549
97587
  let targetEntryIds = entryIds;
97550
97588
  let rebuildReason;
97589
+ const maxInputTokens = config.embedding?.maxInputTokens ?? DEFAULT_MAX_INPUT_TOKENS;
97551
97590
  if (opts?.forceReembed) {
97552
97591
  db.transaction(() => {
97553
97592
  purgeEmbeddings(db, { dropVecTable: true });
@@ -97559,7 +97598,7 @@ async function generateEmbeddingsForDb(db, config, onProgress, signal, entryIds,
97559
97598
  targetEntryIds = undefined;
97560
97599
  rebuildReason = "forced by --reembed";
97561
97600
  } else if (storedFingerprint && storedFingerprint !== currentFingerprint) {
97562
- const decision = await runEmbeddingCanary(db, config, signal);
97601
+ const decision = await runEmbeddingCanary(db, config, signal, maxInputTokens);
97563
97602
  if (decision.outcome === "unverifiable") {
97564
97603
  warn(`[embed] ${decision.message}`);
97565
97604
  onProgress({ phase: "embeddings", message: decision.message });
@@ -97623,7 +97662,6 @@ async function generateEmbeddingsForDb(db, config, onProgress, signal, entryIds,
97623
97662
  purgeEmbeddingSalvage(db);
97624
97663
  return reusedCount > 0 ? { success: true, vecInsertFailures: vecFailedCount } : { success: true };
97625
97664
  }
97626
- const maxInputTokens = config.embedding?.maxInputTokens ?? DEFAULT_MAX_INPUT_TOKENS;
97627
97665
  let truncatedCount = 0;
97628
97666
  const texts = [];
97629
97667
  const pendingEntries = [];
@@ -97718,6 +97756,15 @@ async function generateEmbeddingsForDb(db, config, onProgress, signal, entryIds,
97718
97756
  }
97719
97757
  return;
97720
97758
  }
97759
+ if (outcome?.outcome === "budget-lowered") {
97760
+ if (reportPerBatchLine) {
97761
+ onProgress({
97762
+ phase: "embeddings",
97763
+ message: `[embed] batch ${outcome.batchIndex}/${outcome.batchCount}: ${outcome.docCount} docs, ${outcome.requestTokens.toLocaleString()} tokens → ${outcome.reason}`
97764
+ });
97765
+ }
97766
+ return;
97767
+ }
97721
97768
  if (model)
97722
97769
  observedModel = model;
97723
97770
  if (batchEmbeddings.some((embedding) => embedding !== undefined)) {
@@ -97726,7 +97773,8 @@ async function generateEmbeddingsForDb(db, config, onProgress, signal, entryIds,
97726
97773
  }
97727
97774
  db.transaction(() => {
97728
97775
  for (let k2 = 0;k2 < indices.length; k2++) {
97729
- const entry = pendingEntries[indices[k2]];
97776
+ const index = indices[k2];
97777
+ const entry = pendingEntries[index];
97730
97778
  if (!entry)
97731
97779
  continue;
97732
97780
  const embedding = batchEmbeddings[k2];
@@ -97739,7 +97787,7 @@ async function generateEmbeddingsForDb(db, config, onProgress, signal, entryIds,
97739
97787
  const result = upsertEmbedding(db, entry.id, embedding);
97740
97788
  if (result.stored) {
97741
97789
  storedCount++;
97742
- storedTokens += estimateTokenCount(entry.searchText);
97790
+ storedTokens += estimateTokenCount(texts[index]);
97743
97791
  } else {
97744
97792
  skippedCount++;
97745
97793
  }
@@ -7069,7 +7069,8 @@ var init_errors = __esm(() => {
7069
7069
  };
7070
7070
  TRANSIENT_HINTS = {
7071
7071
  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.",
7072
- 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."
7072
+ 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.",
7073
+ INDEX_DB_CONTENDED: "Another akm process is writing index.db; retry shortly, or pass --skip-if-locked on scheduled runs."
7073
7074
  };
7074
7075
  NOT_FOUND_HINTS = {
7075
7076
  ASSET_NOT_FOUND: "Run `akm search <query>` or `akm index` to refresh the index.",
@@ -75248,7 +75249,6 @@ var EmbeddingConnectionConfigSchema = exports_external.object({
75248
75249
  maxInputTokens: positiveInt.optional(),
75249
75250
  maxTokens: positiveInt.optional(),
75250
75251
  batchSize: positiveInt.optional(),
75251
- chunkSize: positiveInt.optional(),
75252
75252
  contextLength: positiveInt.optional(),
75253
75253
  ollamaOptions: EmbeddingOllamaOptionsSchema.optional(),
75254
75254
  timeoutMs: positiveInt.optional(),
@@ -96943,7 +96943,7 @@ function defaultConcurrencyForEndpoint(endpoint) {
96943
96943
  // src/llm/embedders/remote.ts
96944
96944
  init_warn();
96945
96945
  var DEFAULT_REMOTE_BATCH_SIZE = 100;
96946
- var DEFAULT_TOKEN_BUDGET = 8000;
96946
+ var DEFAULT_TOKEN_BUDGET = 6000;
96947
96947
  function estimateTokenCount(text) {
96948
96948
  return Math.round(text.length / 4);
96949
96949
  }
@@ -97025,6 +97025,8 @@ function buildTokenBoundedBatches(texts, tokenBudget, maxCount) {
97025
97025
  flush();
97026
97026
  return batches;
97027
97027
  }
97028
+ var ADAPTIVE_BUDGET_SHRINK_FACTOR = 0.75;
97029
+ var ADAPTIVE_BUDGET_FLOOR_MULTIPLIER = 2;
97028
97030
 
97029
97031
  class RemoteEmbedder {
97030
97032
  config;
@@ -97077,10 +97079,42 @@ class RemoteEmbedder {
97077
97079
  const results = new Array(texts.length).fill(undefined);
97078
97080
  const headers = this.buildHeaders();
97079
97081
  const ollamaOpts = resolveOllamaOptions(this.config);
97080
- const tokenBudget = this.config.maxTokens ?? DEFAULT_TOKEN_BUDGET;
97082
+ let effectiveTokenBudget = this.config.maxTokens ?? DEFAULT_TOKEN_BUDGET;
97081
97083
  const maxCount = this.config.batchSize ?? DEFAULT_REMOTE_BATCH_SIZE;
97082
- const textBatches = buildTokenBoundedBatches(texts, tokenBudget, maxCount);
97084
+ const maxInputTokens = this.config.maxInputTokens ?? DEFAULT_MAX_INPUT_TOKENS;
97085
+ const textBatches = buildTokenBoundedBatches(texts, effectiveTokenBudget, maxCount);
97083
97086
  const configuredTimeoutMs = resolveEmbeddingTimeoutMs(this.config);
97087
+ let dispatchedBatchCount = 0;
97088
+ let budgetShrunk = false;
97089
+ const maybeShrinkBudget = (rejectedIndices, rejectedBatchIndex, rejectedRequestTokens) => {
97090
+ if (budgetShrunk)
97091
+ return;
97092
+ budgetShrunk = true;
97093
+ const floor2 = ADAPTIVE_BUDGET_FLOOR_MULTIPLIER * maxInputTokens;
97094
+ effectiveTokenBudget = Math.max(Math.round(effectiveTokenBudget * ADAPTIVE_BUDGET_SHRINK_FACTOR), floor2);
97095
+ const notYetDispatched = textBatches.slice(dispatchedBatchCount);
97096
+ const remainingIndices = notYetDispatched.flatMap((batch) => batch.indices);
97097
+ if (remainingIndices.length > 0) {
97098
+ const remainingTexts = remainingIndices.map((i) => texts[i]);
97099
+ const replanned = buildTokenBoundedBatches(remainingTexts, effectiveTokenBudget, maxCount).map((batch) => ({
97100
+ indices: batch.indices.map((localIndex) => remainingIndices[localIndex]),
97101
+ oversized: batch.oversized
97102
+ }));
97103
+ textBatches.splice(dispatchedBatchCount, textBatches.length - dispatchedBatchCount, ...replanned);
97104
+ }
97105
+ warnVerbose(`[embed] provider rejected a ${rejectedRequestTokens}-token request as over its context; request budget lowered to ${effectiveTokenBudget.toLocaleString()} for the rest of this run`);
97106
+ commitBatch(rejectedIndices, rejectedIndices.map(() => {
97107
+ return;
97108
+ }), undefined, {
97109
+ batchIndex: rejectedBatchIndex,
97110
+ batchCount: textBatches.length,
97111
+ docCount: rejectedIndices.length,
97112
+ requestTokens: rejectedRequestTokens,
97113
+ elapsedMs: 0,
97114
+ outcome: "budget-lowered",
97115
+ reason: `provider rejected ${rejectedRequestTokens.toLocaleString()} tokens as over its context; request budget lowered to ${effectiveTokenBudget.toLocaleString()} for the rest of this run`
97116
+ });
97117
+ };
97084
97118
  const dispatchAbort = new AbortController;
97085
97119
  const stopDispatch = (reason) => {
97086
97120
  if (!dispatchAbort.signal.aborted)
@@ -97113,7 +97147,7 @@ class RemoteEmbedder {
97113
97147
  return;
97114
97148
  const batch = indices.map((i) => texts[i]);
97115
97149
  const requestTokens = batch.reduce((sum, text) => sum + estimateTokenCount(text), 0);
97116
- const requestTimeoutMs = scaleEmbeddingTimeoutMs(configuredTimeoutMs, requestTokens, tokenBudget);
97150
+ const requestTimeoutMs = scaleEmbeddingTimeoutMs(configuredTimeoutMs, requestTokens, effectiveTokenBudget);
97117
97151
  const requestStart = Date.now();
97118
97152
  let batchEmbeddings;
97119
97153
  let responseModel;
@@ -97130,6 +97164,9 @@ class RemoteEmbedder {
97130
97164
  } catch (err) {
97131
97165
  if (signal?.aborted)
97132
97166
  throw err;
97167
+ if (err instanceof ContextExceededError) {
97168
+ maybeShrinkBudget(indices, batchIndex, requestTokens);
97169
+ }
97133
97170
  if (err instanceof ContextExceededError && indices.length > 1) {
97134
97171
  const mid = Math.ceil(indices.length / 2);
97135
97172
  await requestAndCommit(indices.slice(0, mid), batchIndex, false, timeoutAttempt);
@@ -97195,13 +97232,14 @@ class RemoteEmbedder {
97195
97232
  });
97196
97233
  };
97197
97234
  const runProviderBatch = async (textBatch, batchIndex) => {
97235
+ dispatchedBatchCount = batchIndex;
97198
97236
  if (textBatch.oversized) {
97199
97237
  const idx = textBatch.indices[0];
97200
97238
  const estTokens = estimateTokenCount(texts[idx]);
97201
97239
  onSkip?.({
97202
97240
  index: idx,
97203
97241
  reason: "context-window-exceeded",
97204
- message: `Document estimated at ${estTokens} tokens exceeds the ${tokenBudget}-token embedding budget; skipped.`,
97242
+ message: `Document estimated at ${estTokens} tokens exceeds the ${effectiveTokenBudget}-token embedding budget; skipped.`,
97205
97243
  batchStart: true,
97206
97244
  batchSize: 1
97207
97245
  });
@@ -97425,7 +97463,7 @@ function deriveObservedEmbeddingIdentity(embedding, observedModel, observedVecto
97425
97463
  }
97426
97464
  return `local:${embedding?.localModel ?? DEFAULT_LOCAL_MODEL}|${observedVectorLen}`;
97427
97465
  }
97428
- async function runEmbeddingCanary(db, config, signal) {
97466
+ async function runEmbeddingCanary(db, config, signal, maxInputTokens) {
97429
97467
  const samples = sampleEmbeddedEntriesForCanary(db, CANARY_SAMPLE_SIZE);
97430
97468
  if (samples.length === 0) {
97431
97469
  return { outcome: "keep", verified: false, viaIdentityMatch: false };
@@ -97434,7 +97472,7 @@ async function runEmbeddingCanary(db, config, signal) {
97434
97472
  const skips = [];
97435
97473
  let canaryVectors;
97436
97474
  try {
97437
- canaryVectors = await embedBatch(samples.map((sample) => sample.searchText), config.embedding, signal, (skip) => skips.push(skip), (_indices, _embeddings, model) => {
97475
+ canaryVectors = await embedBatch(samples.map((sample) => capEmbeddingText(sample.searchText, maxInputTokens).text), config.embedding, signal, (skip) => skips.push(skip), (_indices, _embeddings, model) => {
97438
97476
  if (model)
97439
97477
  observedModel = model;
97440
97478
  });
@@ -97504,6 +97542,7 @@ async function generateEmbeddingsForDb(db, config, onProgress, signal, entryIds,
97504
97542
  const storedFingerprint = getMeta(db, "embeddingFingerprint");
97505
97543
  let targetEntryIds = entryIds;
97506
97544
  let rebuildReason;
97545
+ const maxInputTokens = config.embedding?.maxInputTokens ?? DEFAULT_MAX_INPUT_TOKENS;
97507
97546
  if (opts?.forceReembed) {
97508
97547
  db.transaction(() => {
97509
97548
  purgeEmbeddings(db, { dropVecTable: true });
@@ -97515,7 +97554,7 @@ async function generateEmbeddingsForDb(db, config, onProgress, signal, entryIds,
97515
97554
  targetEntryIds = undefined;
97516
97555
  rebuildReason = "forced by --reembed";
97517
97556
  } else if (storedFingerprint && storedFingerprint !== currentFingerprint) {
97518
- const decision = await runEmbeddingCanary(db, config, signal);
97557
+ const decision = await runEmbeddingCanary(db, config, signal, maxInputTokens);
97519
97558
  if (decision.outcome === "unverifiable") {
97520
97559
  warn(`[embed] ${decision.message}`);
97521
97560
  onProgress({ phase: "embeddings", message: decision.message });
@@ -97579,7 +97618,6 @@ async function generateEmbeddingsForDb(db, config, onProgress, signal, entryIds,
97579
97618
  purgeEmbeddingSalvage(db);
97580
97619
  return reusedCount > 0 ? { success: true, vecInsertFailures: vecFailedCount } : { success: true };
97581
97620
  }
97582
- const maxInputTokens = config.embedding?.maxInputTokens ?? DEFAULT_MAX_INPUT_TOKENS;
97583
97621
  let truncatedCount = 0;
97584
97622
  const texts = [];
97585
97623
  const pendingEntries = [];
@@ -97674,6 +97712,15 @@ async function generateEmbeddingsForDb(db, config, onProgress, signal, entryIds,
97674
97712
  }
97675
97713
  return;
97676
97714
  }
97715
+ if (outcome?.outcome === "budget-lowered") {
97716
+ if (reportPerBatchLine) {
97717
+ onProgress({
97718
+ phase: "embeddings",
97719
+ message: `[embed] batch ${outcome.batchIndex}/${outcome.batchCount}: ${outcome.docCount} docs, ${outcome.requestTokens.toLocaleString()} tokens \u2192 ${outcome.reason}`
97720
+ });
97721
+ }
97722
+ return;
97723
+ }
97677
97724
  if (model)
97678
97725
  observedModel = model;
97679
97726
  if (batchEmbeddings.some((embedding) => embedding !== undefined)) {
@@ -97682,7 +97729,8 @@ async function generateEmbeddingsForDb(db, config, onProgress, signal, entryIds,
97682
97729
  }
97683
97730
  db.transaction(() => {
97684
97731
  for (let k2 = 0;k2 < indices.length; k2++) {
97685
- const entry = pendingEntries[indices[k2]];
97732
+ const index = indices[k2];
97733
+ const entry = pendingEntries[index];
97686
97734
  if (!entry)
97687
97735
  continue;
97688
97736
  const embedding = batchEmbeddings[k2];
@@ -97695,7 +97743,7 @@ async function generateEmbeddingsForDb(db, config, onProgress, signal, entryIds,
97695
97743
  const result = upsertEmbedding(db, entry.id, embedding);
97696
97744
  if (result.stored) {
97697
97745
  storedCount++;
97698
- storedTokens += estimateTokenCount(entry.searchText);
97746
+ storedTokens += estimateTokenCount(texts[index]);
97699
97747
  } else {
97700
97748
  skippedCount++;
97701
97749
  }
@@ -38,6 +38,17 @@ scheduled or opportunistic run step aside instead of contending with a rebuild
38
38
  already in progress; the shipped `index-refresh` scheduled task already passes
39
39
  it.
40
40
 
41
+ `embedding.maxTokens`'s default (the per-request token budget) is now 6000,
42
+ down from 8000: a field report on an 8192-token llama.cpp embedder showed the
43
+ 4-chars-per-token estimator undercounts dense technical text by 7-55%, so the
44
+ old default regularly overshot the endpoint's real context window. If you
45
+ already set `embedding.maxTokens` explicitly, this default change does not
46
+ affect you — your configured value is unchanged. `akm index` also now
47
+ recovers automatically within a run: on the first request rejected for
48
+ exceeding the endpoint's context window, it lowers its effective budget for
49
+ the rest of that run (reported with one line) rather than continuing to hit
50
+ the same wall on every following batch.
51
+
41
52
  `embedding.concurrency` (positive integer, 1-16) overrides the number of
42
53
  embedding requests kept in flight at once, which otherwise defaults to 1 for
43
54
  a loopback endpoint and 2 for a remote one. Set it only for an endpoint that
@@ -131,3 +142,10 @@ effective config and another config file or bundle-relative file. `akm config
131
142
  unset` now refuses to unset a key whose value comes only from an
132
143
  `extends`-inherited base, naming the source, since there would be nothing local
133
144
  to remove.
145
+
146
+ The scheduler runs the binary path `akm task sync` recorded at sync time, not
147
+ whichever akm your shell now resolves to. After upgrading akm through a
148
+ different installer than the one active at your last `task sync` (e.g.
149
+ npm-global to a standalone download), run `akm task sync` again so the
150
+ schedule points at the new binary; `akm health --probe` now warns via a new
151
+ `scheduler-binary` advisory when the two diverge.
@@ -117,7 +117,7 @@ Every command exits with one of the following codes:
117
117
  | 2 | Usage / bad input | `UsageError` |
118
118
  | 4 | Health warning (`akm health` only) | — |
119
119
  | 70 | Internal / unclassified error | unexpected throw |
120
- | 75 | Transient — retry shortly (sysexits `EX_TEMPFAIL`); another akm process holds a lock or is writing `state.db` right now, not a bad command line | `TransientError` |
120
+ | 75 | Transient — retry shortly (sysexits `EX_TEMPFAIL`); another akm process holds a lock or is writing `state.db` or `index.db` right now, not a bad command line | `TransientError` |
121
121
  | 78 | Configuration error | `ConfigError` |
122
122
 
123
123
  Failures classified by akm emit a JSON error envelope on **stderr** before
@@ -279,7 +279,12 @@ opt-in, PID-liveness-only rebuild lock and releases it on exit — this is
279
279
  advisory, never the blocking lock #872 removed (see
280
280
  [Locks](https://github.com/itlackey/akm/blob/main/docs/architecture/internals/indexing.md#locks)). A human-typed
281
281
  `akm index` with no flag is never gated by it: if another run already holds
282
- the lock, it warns and proceeds anyway, contending with the existing run.
282
+ the lock, it warns and proceeds anyway, contending with the existing run. If
283
+ that contention makes index.db genuinely busy (SQLite `database is locked`)
284
+ long enough to exhaust the driver's retry window, the run now fails with
285
+ exit 75 (`TransientError`, code `INDEX_DB_CONTENDED`) instead of the raw
286
+ driver error at exit 70 — the same retry-shortly contract as
287
+ `STATE_DB_CONTENDED`, so a scheduler can branch on it instead of alerting.
283
288
  `--skip-if-locked` changes that only for the invocation that passes it: if
284
289
  the lock is already held by a live process, it skips gracefully (exit 0,
285
290
  `{ ok: true, skipped: { reason: "lock-held", pid, launcherPid, startedAt } }`
@@ -358,15 +363,17 @@ akm health --report --window-compare 7d --format html
358
363
  | `--window-compare` | Compare the current window against the prior window of the same duration (e.g. `24h`, `7d`). With `--report`, overrides the default trend window. |
359
364
  | `--group-by` | Group rows by `run` (one row per `improve_runs` entry). Omit for the default summary. |
360
365
  | `--windows` | Explicit comparison window(s) as `name=...,since=ISO,until=ISO` (repeatable, up to 4). Mutually exclusive with `--window-compare`. |
361
- | `--no-probe` | Skip the `default-llm-engine` / `configured-engines` reachability probes and the `cli-version` update check (for an offline or air-gapped host). |
366
+ | `--no-probe` | Skip the `default-llm-engine` / `configured-engines` reachability probes, the `cli-version` update check, and the `scheduler-binary` version check (for an offline or air-gapped host). |
362
367
 
363
368
  The command reads `state.db`, verifies that the required tables exist, performs a
364
369
  write-read probe against the events stream, inspects `task_history`, checks the
365
370
  default agent engine, and summarizes recent `improve_*` events. Unless
366
371
  `--no-probe` is given, it also sends a bounded (3s timeout) reachability probe
367
372
  to the `default-llm-engine` and every `configured-engines` LLM connection (and
368
- an SDK engine's LLM fallback), one probe per distinct endpoint, and checks the
369
- installed akm-cli version against the latest GitHub release (`cli-version`).
373
+ an SDK engine's LLM fallback), one probe per distinct endpoint, checks the
374
+ installed akm-cli version against the latest GitHub release (`cli-version`),
375
+ and runs the scheduler's recorded akm binary with `--version` to check it
376
+ against the running CLI (`scheduler-binary`).
370
377
 
371
378
  Primary result fields:
372
379
 
@@ -1239,6 +1246,14 @@ Shipping akm inside your own product (a Docker image, a plugin's own
1239
1246
  `node_modules`)? See [Bundling akm](../integration/bundling-akm.md) for the
1240
1247
  full boot contract, JSON shapes, and exit codes.
1241
1248
 
1249
+ `akm upgrade` replaces the binary in place for its own install method, but a
1250
+ scheduler binding recorded by an earlier `akm task sync` under a *different*
1251
+ install method is not repointed automatically — the scheduler runs the
1252
+ binary path recorded at sync time, not whichever akm `upgrade` just
1253
+ installed. Run `akm task sync` after switching installers so scheduled runs
1254
+ pick up the new binary; see [`task sync`](#task) and `akm health`'s
1255
+ `scheduler-binary` advisory.
1256
+
1242
1257
  ### clone
1243
1258
 
1244
1259
  Copy an asset from any source into a managed writable bundle or an unmanaged
@@ -2872,6 +2887,13 @@ task template (both the core set and the improve-schedule set) and asks once
2872
2887
  before changing task files or scheduler state; non-interactive setup changes
2873
2888
  neither.
2874
2889
 
2890
+ Because the scheduler runs the exact binary path recorded at the last `task
2891
+ sync`, upgrading akm through a different installer than the one active at
2892
+ that sync (npm-global to a standalone download, or vice versa) leaves
2893
+ scheduled runs invoking the old, now-stale binary — `task sync` re-resolves
2894
+ the current path and repoints them. `akm health --probe`'s `scheduler-binary`
2895
+ advisory warns when the two diverge, naming both versions.
2896
+
2875
2897
  Setup reconfiguration preserves existing scheduler runtime bindings. Changing
2876
2898
  the AKM storage path or installed runtime path therefore requires an explicit
2877
2899
  `akm task sync --rebind`; setup does not silently migrate those entries.