akm-cli 0.9.5 → 0.9.7

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 (130) hide show
  1. package/CHANGELOG.md +295 -0
  2. package/dist/assets/hints/cli-hints-full.md +3 -3
  3. package/dist/assets/improve-strategies/catchup.json +40 -11
  4. package/dist/assets/improve-strategies/thorough.json +45 -7
  5. package/dist/assets/tasks/improve/akm-improve-frequent.yml +2 -2
  6. package/dist/commands/agent/contribute-cli.js +11 -0
  7. package/dist/commands/env/env-cli.js +1 -2
  8. package/dist/commands/env/secret-cli.js +1 -5
  9. package/dist/commands/feedback-cli.js +0 -4
  10. package/dist/commands/health/checks.js +0 -32
  11. package/dist/commands/health/surfaces.js +2 -2
  12. package/dist/commands/health.js +6 -15
  13. package/dist/commands/improve/autonomy-gate.js +1 -1
  14. package/dist/commands/improve/consolidate.js +25 -26
  15. package/dist/commands/improve/distill.js +2 -2
  16. package/dist/commands/improve/extract.js +8 -1
  17. package/dist/commands/improve/improve-cli.js +1 -1
  18. package/dist/commands/improve/improve-strategies.js +0 -4
  19. package/dist/commands/improve/improve.js +3 -5
  20. package/dist/commands/improve/locks.js +13 -5
  21. package/dist/commands/improve/loop-stages.js +8 -9
  22. package/dist/commands/improve/memory/memory-contradiction-detect.js +1 -19
  23. package/dist/commands/improve/memory/memory-improve.js +2 -1
  24. package/dist/commands/improve/outcome-loop.js +0 -6
  25. package/dist/commands/improve/preparation.js +1 -1
  26. package/dist/commands/improve/reflect.js +21 -10
  27. package/dist/commands/lint/base-linter.js +141 -18
  28. package/dist/commands/lint/index.js +21 -10
  29. package/dist/commands/proposal/validators/proposal-quality-validators.js +0 -7
  30. package/dist/commands/read/curate.js +47 -0
  31. package/dist/commands/read/search-cli.js +24 -1
  32. package/dist/commands/sources/bundle-config-ops.js +9 -4
  33. package/dist/commands/sources/info.js +14 -10
  34. package/dist/commands/tasks/tasks.js +12 -16
  35. package/dist/commands/workflow-cli.js +2 -2
  36. package/dist/core/asset/asset-placement.js +13 -2
  37. package/dist/core/asset/frontmatter.js +116 -0
  38. package/dist/core/asset/memory-archive.js +97 -0
  39. package/dist/core/common.js +61 -24
  40. package/dist/core/config/config-io.js +2 -2
  41. package/dist/core/config/config-sources.js +32 -2
  42. package/dist/core/config/config.js +1 -1
  43. package/dist/core/config/engine-semantics.js +0 -2
  44. package/dist/core/config/schema/engines.js +9 -7
  45. package/dist/core/config/schema/primitives.js +0 -5
  46. package/dist/core/config/schema/search.js +2 -1
  47. package/dist/core/file-lock.js +2 -1
  48. package/dist/core/maintenance-barrier.js +2 -14
  49. package/dist/core/paths.js +0 -3
  50. package/dist/core/redaction.js +2 -2
  51. package/dist/core/spawn-env.js +8 -12
  52. package/dist/core/state/migrations.js +1 -12
  53. package/dist/core/state-db.js +9 -27
  54. package/dist/core/write-source.js +12 -19
  55. package/dist/execution/directory-identity.js +36 -10
  56. package/dist/indexer/graph/graph-boost.js +0 -4
  57. package/dist/indexer/index-writer-lock.js +43 -24
  58. package/dist/indexer/index-written-assets.js +5 -6
  59. package/dist/indexer/indexer.js +2 -39
  60. package/dist/indexer/materialize-embeddings.js +85 -41
  61. package/dist/indexer/search/db-search.js +15 -48
  62. package/dist/indexer/search/ranking-contributors.js +0 -25
  63. package/dist/indexer/search/ranking.js +3 -13
  64. package/dist/integrations/agent/builder-shared.js +0 -25
  65. package/dist/integrations/agent/model-map.js +2 -60
  66. package/dist/integrations/harnesses/aider/agent-builder.js +1 -3
  67. package/dist/integrations/harnesses/amazonq/agent-builder.js +2 -7
  68. package/dist/integrations/harnesses/claude/agent-builder.js +1 -4
  69. package/dist/integrations/harnesses/codex/agent-builder.js +1 -4
  70. package/dist/integrations/harnesses/copilot/agent-builder.js +2 -6
  71. package/dist/integrations/harnesses/gemini/agent-builder.js +2 -7
  72. package/dist/integrations/harnesses/opencode/agent-builder.js +1 -4
  73. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +2 -16
  74. package/dist/integrations/harnesses/openhands/agent-builder.js +1 -3
  75. package/dist/integrations/harnesses/pi/agent-builder.js +2 -4
  76. package/dist/integrations/session-logs/index.js +0 -9
  77. package/dist/llm/client.js +75 -42
  78. package/dist/llm/embedder.js +7 -3
  79. package/dist/llm/embedders/remote.js +141 -42
  80. package/dist/registry/network.js +5 -37
  81. package/dist/runtime.js +2 -10
  82. package/dist/scripts/akm-migrate-node.js +73 -133
  83. package/dist/scripts/akm-migrate.js +73 -133
  84. package/dist/setup/engine-config.js +2 -5
  85. package/dist/setup/registry-stash-loader.js +0 -8
  86. package/dist/setup/setup.js +9 -46
  87. package/dist/setup/steps/connection-shared.js +10 -13
  88. package/dist/sources/providers/git-install.js +1 -1
  89. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -0
  90. package/dist/storage/engines/sqlite-migrations.js +20 -1
  91. package/dist/storage/repositories/index-connection.js +45 -3
  92. package/dist/storage/repositories/index-entries-repository.js +0 -15
  93. package/dist/tasks/backends/cron.js +49 -9
  94. package/dist/tasks/backends/launchd.js +15 -20
  95. package/dist/tasks/backends/schtasks.js +18 -8
  96. package/dist/tasks/resolve-akm-bin.js +17 -2
  97. package/dist/tasks/run/run-native-task.js +8 -6
  98. package/dist/tasks/scheduler-invocation.js +8 -1
  99. package/dist/tasks/source/bounded-document.js +2 -4
  100. package/dist/tasks/source/parse-task-source.js +23 -9
  101. package/dist/tasks/source/task-source-v3-frozen.js +5 -7
  102. package/dist/tasks/source/task-source-v4.js +5 -10
  103. package/dist/tasks/source/task-to-v3.js +5 -10
  104. package/dist/tasks/source/task-to-v4.js +1 -4
  105. package/dist/tasks/source-v3.js +6 -6
  106. package/dist/workflows/exec/native-executor.js +21 -31
  107. package/dist/workflows/exec/run-workflow.js +5 -6
  108. package/dist/workflows/exec/scheduler.js +3 -19
  109. package/dist/workflows/exec/step-work.js +1 -4
  110. package/dist/workflows/exec/unit-dispatch.js +2 -2
  111. package/dist/workflows/exec/worktree.js +1 -13
  112. package/dist/workflows/freeze/targets/child-workflow.js +2 -10
  113. package/dist/workflows/ir/plan-hash.js +4 -6
  114. package/dist/workflows/ir/schema-v4.js +0 -12
  115. package/dist/workflows/ir/schema.js +20 -31
  116. package/dist/workflows/parser.js +11 -52
  117. package/dist/workflows/renderer.js +2 -3
  118. package/dist/workflows/resource-limits.js +11 -41
  119. package/dist/workflows/runtime/runs.js +3 -4
  120. package/dist/workflows/source-ir/schema.js +14 -30
  121. package/dist/workflows/validator.js +1 -7
  122. package/docs/reference/cli.md +5 -1
  123. package/docs/reference/configuration.md +1 -1
  124. package/package.json +1 -1
  125. package/schemas/akm-config.json +4 -23
  126. package/schemas/akm-task.json +1 -2
  127. package/schemas/akm-workflow.json +1 -13
  128. package/dist/assets/improve-strategies/frequent.json +0 -15
  129. package/dist/assets/improve-strategies/memory-focus.json +0 -15
  130. package/dist/indexer/search/semantic-status.js +0 -142
@@ -23,9 +23,7 @@
23
23
  * - **systemPrompt** — Gemini CLI has no system-prompt flag in headless mode
24
24
  * (system text comes from `GEMINI.md` context files / `GEMINI_SYSTEM_MD`),
25
25
  * so the system prompt is folded into the `-p` payload ahead of the task
26
- * prompt, separated by a blank line. `assertNotFlag` still guards it so a
27
- * `--`-prefixed system prompt cannot turn the front of the `-p` value into
28
- * a flag.
26
+ * prompt, separated by a blank line.
29
27
  * - **schema** — the matrix places Gemini in the "via prompt+validate" tier
30
28
  * (no native `--output-schema` equivalent, unlike Codex — so no temp-file
31
29
  * plumbing here), so the JSON Schema is passed through the prompt: a
@@ -49,7 +47,7 @@
49
47
  * that registry, so this builder is reachable under the `"gemini"` platform
50
48
  * name without any further wiring.
51
49
  */
52
- import { assertNotFlag, resolveDispatchModel, } from "../../agent/builder-shared.js";
50
+ import { resolveDispatchModel } from "../../agent/builder-shared.js";
53
51
  import { createAgentRequestLowerer } from "../../agent/request-lowering.js";
54
52
  /** Canonical harness/platform id used for model-alias resolution. */
55
53
  export const GEMINI_PLATFORM = "gemini";
@@ -102,8 +100,6 @@ export const geminiBuilder = {
102
100
  outputSchema: true,
103
101
  }),
104
102
  build(profile, req) {
105
- assertNotFlag(req.systemPrompt, "systemPrompt");
106
- assertNotFlag(req.model, "model");
107
103
  const args = [...profile.args];
108
104
  if (req.model) {
109
105
  const resolved = resolveDispatchModel(req, profile, GEMINI_PLATFORM);
@@ -113,7 +109,6 @@ export const geminiBuilder = {
113
109
  // Structured policy objects (entries === undefined) emit NO flags:
114
110
  // dropping a restriction must never widen to auto-approval.
115
111
  for (const tool of toolPolicyEntries(req.tools) ?? []) {
116
- assertNotFlag(tool, "tools entry");
117
112
  args.push("--allowed-tools", tool);
118
113
  }
119
114
  }
@@ -14,7 +14,7 @@
14
14
  * pre-migration `opencodeBuilder`. The builder's `platform` stays `'opencode'`
15
15
  * (the canonical harness id).
16
16
  */
17
- import { assertNotFlag, resolveDispatchModel } from "../../agent/builder-shared.js";
17
+ import { resolveDispatchModel } from "../../agent/builder-shared.js";
18
18
  import { createAgentRequestLowerer } from "../../agent/request-lowering.js";
19
19
  /**
20
20
  * OpenCode builder.
@@ -34,9 +34,6 @@ export const opencodeBuilder = {
34
34
  outputSchema: false,
35
35
  }),
36
36
  build(profile, req) {
37
- assertNotFlag(req.systemPrompt, "systemPrompt");
38
- assertNotFlag(req.model, "model");
39
- assertNotFlag(req.agent, "agent");
40
37
  const args = req.model ? [] : [...profile.args];
41
38
  if (req.model) {
42
39
  for (let index = 0; index < profile.args.length; index += 1) {
@@ -92,6 +92,7 @@
92
92
  */
93
93
  import { spawn } from "node:child_process";
94
94
  import { createHash } from "node:crypto";
95
+ import { COMMON_SPAWN_ENV_PASSTHROUGH, spawnEnvNamesFor } from "../../../core/spawn-env.js";
95
96
  import { DEFAULT_AGENT_TIMEOUT_MS } from "../../agent/config.js";
96
97
  // Server registry — one server per complete server-material signature. Caller
97
98
  // deadlines race the shared promise independently; they never become startup
@@ -269,24 +270,9 @@ function serverRegistryKey(profile, env) {
269
270
  .update(JSON.stringify(canonicalize(material)))
270
271
  .digest("hex");
271
272
  }
272
- const OPENCODE_SDK_SERVER_ENV_NAMES = [
273
- "HOME",
274
- "PATH",
275
- "USER",
276
- "LANG",
277
- "LC_ALL",
278
- "TERM",
279
- "TMPDIR",
280
- "SYSTEMROOT",
281
- "COMSPEC",
282
- "PATHEXT",
283
- "WINDIR",
284
- "TEMP",
285
- "TMP",
286
- ];
287
273
  /** @internal Exact environment allowlist used to start the OpenCode SDK server. */
288
274
  export function opencodeSdkServerEnvironmentNames(profile) {
289
- return [...new Set([...OPENCODE_SDK_SERVER_ENV_NAMES, ...(profile.envPassthrough ?? [])])];
275
+ return [...new Set([...spawnEnvNamesFor(COMMON_SPAWN_ENV_PASSTHROUGH), ...(profile.envPassthrough ?? [])])];
290
276
  }
291
277
  function buildServerEnv(profile, config, bindings, envSource) {
292
278
  const env = {};
@@ -71,7 +71,7 @@
71
71
  * `pattern: "local-runner"`, `structuredOutput: "native-json"` alongside it
72
72
  * (`./index.ts`).
73
73
  */
74
- import { assertNotFlag, resolveDispatchModel, } from "../../agent/builder-shared.js";
74
+ import { resolveDispatchModel } from "../../agent/builder-shared.js";
75
75
  import { createAgentRequestLowerer } from "../../agent/request-lowering.js";
76
76
  /** Canonical harness/platform id used for model-alias resolution. */
77
77
  export const OPENHANDS_PLATFORM = "openhands";
@@ -115,8 +115,6 @@ export const openhandsBuilder = {
115
115
  outputSchema: true,
116
116
  }),
117
117
  build(profile, req) {
118
- assertNotFlag(req.systemPrompt, "systemPrompt");
119
- assertNotFlag(req.model, "model");
120
118
  const args = [...profile.args];
121
119
  // Headless essentials (matrix shape): non-interactive run, JSONL stdout
122
120
  // for the extractor.
@@ -25,7 +25,7 @@
25
25
  * builders, so a prompt whose text begins with `-`/`--` can never be parsed
26
26
  * as flags.
27
27
  * - **systemPrompt** — passed via `--system-prompt` (Pi follows the Claude
28
- * Code flag conventions), guarded by `assertNotFlag`.
28
+ * Code flag conventions).
29
29
  * - **schema** — the matrix places Pi in the "via prompt+validate" tier (no
30
30
  * native `--output-schema` equivalent, unlike Codex), so the JSON Schema is
31
31
  * passed through the prompt: a directive matching the engine's wording
@@ -50,7 +50,7 @@
50
50
  * wiring. The `PI_SESSION_ID` identity-env marker is declared alongside it
51
51
  * (`./index.ts`).
52
52
  */
53
- import { assertNotFlag, resolveDispatchModel, } from "../../agent/builder-shared.js";
53
+ import { resolveDispatchModel } from "../../agent/builder-shared.js";
54
54
  import { createAgentRequestLowerer } from "../../agent/request-lowering.js";
55
55
  /** Canonical harness/platform id used for model-alias resolution. */
56
56
  export const PI_PLATFORM = "pi";
@@ -81,8 +81,6 @@ export const piBuilder = {
81
81
  outputSchema: true,
82
82
  }),
83
83
  build(profile, req) {
84
- assertNotFlag(req.systemPrompt, "systemPrompt");
85
- assertNotFlag(req.model, "model");
86
84
  const args = [...profile.args];
87
85
  if (req.systemPrompt) {
88
86
  args.push("--system-prompt", req.systemPrompt);
@@ -133,12 +133,3 @@ export function collectSessionEvents(harnesses, sinceMs, maxSessionsPerHarness =
133
133
  }
134
134
  return events;
135
135
  }
136
- /**
137
- * Scan recent session logs from all available harnesses and return
138
- * repeated failure patterns that might warrant new AKM assets.
139
- */
140
- export function getExecutionLogCandidates(sinceDays = 7) {
141
- const sinceMs = Date.now() - sinceDays * 24 * 60 * 60 * 1000;
142
- const events = collectSessionEvents(getAvailableHarnesses(), sinceMs);
143
- return aggregateSessionEvents(events);
144
- }
@@ -11,7 +11,6 @@ import { fetchWithTimeout, readBodyWithByteCap } from "../core/common.js";
11
11
  import { resolveSecret } from "../core/config/config.js";
12
12
  import { ENV_REFERENCE_PATTERN } from "../core/config/schema/primitives.js";
13
13
  import { formatExtraParamsIssue, validateExtraParams } from "../core/extra-params.js";
14
- import { parseJsonResponse } from "../core/parse.js";
15
14
  import { redactErrorBody, redactSensitiveText } from "../core/redaction.js";
16
15
  import { warn, warnVerbose } from "../core/warn.js";
17
16
  import { DEFAULT_LLM_TIMEOUT_MS } from "../integrations/agent/config.js";
@@ -199,12 +198,66 @@ async function chatCompletionReal(config, messages, options) {
199
198
  return await chatCompletionAttempt(config, messages, options, remaining);
200
199
  }
201
200
  }
201
+ // ── Structured-output attempt-then-fallback ─────────────────────────────────
202
202
  /**
203
- * A single chat-completion attempt: one HTTP request/response cycle with no
204
- * retry. {@link chatCompletion} wraps this with a single bounded retry for
205
- * transient failures.
203
+ * Connections that have already demonstrated (via a real 4xx response, not a
204
+ * probe) that they reject `response_format: json_schema`. In-memory only,
205
+ * per process, never persisted — the replacement for the old
206
+ * `capabilities.structuredOutput` config cache that `akm setup` wrote once
207
+ * and nothing ever invalidated on a later `akm config set`. Populated the
208
+ * first time a real call proves it, forgotten on the next process start, so
209
+ * a config/endpoint change can never leave a stale verdict in place.
210
+ */
211
+ const jsonSchemaUnsupportedConnections = new Set();
212
+ function connectionKey(config) {
213
+ return `${config.endpoint}|${config.model}`;
214
+ }
215
+ /** TEST-ONLY. Clear the in-memory json-schema-support tracker between tests. */
216
+ export function _resetJsonSchemaSupportTrackerForTests() {
217
+ jsonSchemaUnsupportedConnections.clear();
218
+ }
219
+ /**
220
+ * Whether this connection has already been proven, this process, not to
221
+ * support `response_format: json_schema`. Callers choosing a prompt-framing
222
+ * strategy up front (e.g. `improve/reflect.ts`'s `outputMode`) can consult
223
+ * this instead of a persisted config flag — it reflects only what a real
224
+ * call this run actually observed.
225
+ */
226
+ export function isJsonSchemaKnownUnsupported(config) {
227
+ return jsonSchemaUnsupportedConnections.has(connectionKey(config));
228
+ }
229
+ /**
230
+ * A single chat-completion attempt: one HTTP request/response cycle, with an
231
+ * inline fallback-once when a schema was requested and the provider 4xx's
232
+ * (never retried by the transient-failure policy below, by design — 4xx is
233
+ * a same-request-will-always-fail signal EXCEPT for this one specific,
234
+ * request-shape-dependent case). No cached verdict gates the first attempt:
235
+ * `config.supportsJsonSchema === false` is the only thing that skips it,
236
+ * and that is a value a human (or workflow author) set explicitly, not one
237
+ * a probe wrote automatically.
206
238
  */
207
239
  async function chatCompletionAttempt(config, messages, options, timeoutMs) {
240
+ const wantsSchema = Boolean(options?.responseSchema) && config.supportsJsonSchema !== false && !isJsonSchemaKnownUnsupported(config);
241
+ try {
242
+ return await chatCompletionAttemptOnce(config, messages, options, timeoutMs, wantsSchema);
243
+ }
244
+ catch (err) {
245
+ if (!wantsSchema ||
246
+ !(err instanceof LlmCallError) ||
247
+ err.code !== "provider_error" ||
248
+ typeof err.statusCode !== "number" ||
249
+ err.statusCode < 400 ||
250
+ err.statusCode >= 500 ||
251
+ err.statusCode === 429) {
252
+ throw err;
253
+ }
254
+ warnVerbose(`[akm] LLM rejected response_format:json_schema (${err.statusCode}); retrying once without it: ${err.message}`);
255
+ const fallback = await chatCompletionAttemptOnce(config, messages, options, timeoutMs, false);
256
+ jsonSchemaUnsupportedConnections.add(connectionKey(config));
257
+ return fallback;
258
+ }
259
+ }
260
+ async function chatCompletionAttemptOnce(config, messages, options, timeoutMs, includeSchema) {
208
261
  if (config.extraParams !== undefined) {
209
262
  const issue = validateExtraParams(config.extraParams)[0];
210
263
  if (issue)
@@ -225,7 +278,7 @@ async function chatCompletionAttempt(config, messages, options, timeoutMs) {
225
278
  // limits; a hardcoded default creates silent truncation failures when the
226
279
  // guess is wrong. Users who need a cap can set llm.maxTokens in config.
227
280
  const resolvedMaxTokens = options?.maxTokens ?? config.maxTokens;
228
- const responseFormat = options?.responseSchema && config.supportsJsonSchema
281
+ const responseFormat = includeSchema && options?.responseSchema
229
282
  ? {
230
283
  response_format: {
231
284
  type: "json_schema",
@@ -382,47 +435,27 @@ export async function isLlmAvailable(config) {
382
435
  return false;
383
436
  }
384
437
  }
385
- // ── Capability probe ────────────────────────────────────────────────────────
386
- const CAPABILITY_PROBE_JSON_SCHEMA = {
387
- type: "object",
388
- properties: {
389
- ok: { type: "boolean", const: true },
390
- ingest: { type: "boolean", const: true },
391
- lint: { type: "boolean", const: true },
392
- },
393
- required: ["ok", "ingest", "lint"],
394
- additionalProperties: false,
395
- };
438
+ // ── Reachability probe ──────────────────────────────────────────────────────
396
439
  /**
397
- * Ask the model to emit a strict JSON object so we know whether the knowledge
398
- * wiki ingest/lint flows can rely on structured output. Failure is non-fatal —
399
- * the caller can fall back to assist-only mode.
440
+ * Best-effort reachability check with an error message, for setup's optional
441
+ * `--probe` connectivity verification. Deliberately does NOT probe or cache
442
+ * `response_format: json_schema` support: that used to be persisted as
443
+ * `capabilities.structuredOutput` and consulted on every later call, so a
444
+ * config edit that changed the endpoint/model left a stale verdict in place
445
+ * with no invalidation, and a stale `true` sent an unsupported request that
446
+ * the retry policy explicitly does not retry (4xx). `chatCompletion` now
447
+ * attempts the schema request fresh every time and falls back once per call
448
+ * on a 4xx — see the in-memory tracker below.
400
449
  */
401
- export async function probeLlmCapabilities(config) {
450
+ export async function probeLlmReachable(config) {
402
451
  try {
403
- const raw = await chatCompletion({ ...config, supportsJsonSchema: true }, [
404
- {
405
- role: "system",
406
- content: "You return only valid JSON. No prose, no markdown fences.",
407
- },
408
- {
409
- role: "user",
410
- content: 'Return exactly this JSON object and nothing else: {"ok": true, "ingest": true, "lint": true}',
411
- },
412
- ], { maxTokens: 64, temperature: 0, responseSchema: CAPABILITY_PROBE_JSON_SCHEMA });
413
- if (!raw)
414
- return { reachable: false, structuredOutput: false, error: "empty response" };
415
- const parsed = parseJsonResponse(raw);
416
- return {
417
- reachable: true,
418
- structuredOutput: Boolean(parsed &&
419
- Object.keys(parsed).length === 3 &&
420
- parsed.ok === true &&
421
- parsed.ingest === true &&
422
- parsed.lint === true),
423
- };
452
+ const raw = await chatCompletion(config, [{ role: "user", content: "Respond with just the word: ok" }], {
453
+ maxTokens: 16,
454
+ temperature: 0,
455
+ });
456
+ return raw.length > 0 ? { reachable: true } : { reachable: false, error: "empty response" };
424
457
  }
425
458
  catch (err) {
426
- return { reachable: false, structuredOutput: false, error: err instanceof Error ? err.message : String(err) };
459
+ return { reachable: false, error: err instanceof Error ? err.message : String(err) };
427
460
  }
428
461
  }
@@ -93,11 +93,15 @@ async function embedOnce(text, embeddingConfig, signal) {
93
93
  }
94
94
  /**
95
95
  * Generate embeddings for multiple texts in batch.
96
- * Uses the OpenAI-compatible batch API for remote endpoints (batches of 100).
96
+ * Uses the OpenAI-compatible batch API for remote endpoints, batched by an
97
+ * estimated token budget (not a fixed document count, #874). A remote
98
+ * sub-batch or oversized document that fails is skipped rather than
99
+ * aborting the whole call — pass `onSkip` to learn which indices were
100
+ * skipped and why; the result array holds `undefined` at those indices.
97
101
  * Uses the LocalEmbedder.embedBatch path for the local transformer pipeline,
98
102
  * which processes texts in chunks of 32 for genuine batched inference.
99
103
  */
100
- export async function embedBatch(texts, embeddingConfig, signal) {
104
+ export async function embedBatch(texts, embeddingConfig, signal, onSkip) {
101
105
  if (embedderOverrides?.embedBatch)
102
106
  return embedderOverrides.embedBatch(texts, embeddingConfig, signal);
103
107
  if (texts.length === 0)
@@ -107,7 +111,7 @@ export async function embedBatch(texts, embeddingConfig, signal) {
107
111
  return texts.map((t) => deterministicEmbed(t));
108
112
  }
109
113
  if (embeddingConfig && hasRemoteEndpoint(embeddingConfig)) {
110
- return new RemoteEmbedder(embeddingConfig).embedBatch(texts, signal);
114
+ return new RemoteEmbedder(embeddingConfig).embedBatch(texts, signal, onSkip);
111
115
  }
112
116
  // Local transformer: use the batched path (chunks of 32 via LocalEmbedder).
113
117
  // When a localModel override is set we cannot share the singleton (which uses
@@ -10,11 +10,65 @@
10
10
  import { fetchWithTimeout, isHttpUrl, readBodyWithByteCap } from "../../core/common.js";
11
11
  import { resolveSecret } from "../../core/config/config.js";
12
12
  import { redactErrorBody, redactSensitiveText } from "../../core/redaction.js";
13
- const DEFAULT_REMOTE_BATCH_SIZE = 100;
13
+ import { warnVerbose } from "../../core/warn.js";
14
+ /**
15
+ * Upper bound on the number of documents in one HTTP request, independent of
16
+ * the token budget below. Overridable via `config.batchSize`. Purely a
17
+ * safety cap (very many tiny documents could otherwise pack one request) —
18
+ * the token budget is what actually keeps a request inside the endpoint's
19
+ * context window and inside the timeout (#874).
20
+ */
21
+ export const DEFAULT_REMOTE_BATCH_SIZE = 100;
22
+ /**
23
+ * Conservative default token budget per HTTP request when the config gives
24
+ * no better number (`maxTokens` or `contextLength`). #874's measurements:
25
+ * a batch of 100 small docs (~400 KB, ~100K tokens) took 14.8s against a
26
+ * healthy local endpoint — half the 30s request timeout — and a single
27
+ * 128 KB (~24K token) document alone was rejected by the endpoint as
28
+ * exceeding its context size. 8000 tokens keeps a batch's estimated size
29
+ * comfortably under both the timeout and common local-model context windows.
30
+ */
31
+ export const DEFAULT_TOKEN_BUDGET = 8000;
14
32
  /** Cheap token estimator: 4 chars ≈ 1 token. Used in verbose logging and error messages. */
15
33
  export function estimateTokenCount(text) {
16
34
  return Math.round(text.length / 4);
17
35
  }
36
+ /**
37
+ * Group `texts` into request-sized batches bounded by BOTH an estimated
38
+ * token budget and a document-count cap, so one large document does not
39
+ * silently blow the batch past the endpoint's context window (#874).
40
+ *
41
+ * A single document whose own estimate exceeds `tokenBudget` can never fit
42
+ * any batch — it is reported as its own oversized "batch" so the caller can
43
+ * skip it without ever making an HTTP request for it.
44
+ */
45
+ export function buildTokenBoundedBatches(texts, tokenBudget, maxCount) {
46
+ const batches = [];
47
+ let current = [];
48
+ let currentTokens = 0;
49
+ const flush = () => {
50
+ if (current.length > 0) {
51
+ batches.push({ indices: current, oversized: false });
52
+ current = [];
53
+ currentTokens = 0;
54
+ }
55
+ };
56
+ for (let i = 0; i < texts.length; i++) {
57
+ const tokens = estimateTokenCount(texts[i]);
58
+ if (tokens > tokenBudget) {
59
+ flush();
60
+ batches.push({ indices: [i], oversized: true });
61
+ continue;
62
+ }
63
+ if (current.length > 0 && (currentTokens + tokens > tokenBudget || current.length >= maxCount)) {
64
+ flush();
65
+ }
66
+ current.push(i);
67
+ currentTokens += tokens;
68
+ }
69
+ flush();
70
+ return batches;
71
+ }
18
72
  export class RemoteEmbedder {
19
73
  config;
20
74
  endpoint;
@@ -63,55 +117,102 @@ export class RemoteEmbedder {
63
117
  }
64
118
  return l2Normalize(json.data[0].embedding);
65
119
  }
66
- async embedBatch(texts, signal) {
120
+ /**
121
+ * Embed every text, batched by an estimated token budget (not a fixed
122
+ * document count) and bounded by `config.batchSize` as a document-count
123
+ * safety cap. Batching by count alone let a batch of ordinary-sized
124
+ * documents balloon past the endpoint's context window and the 30s
125
+ * request timeout (#874).
126
+ *
127
+ * A failing sub-batch or an oversized single document is SKIPPED, not
128
+ * thrown — the rest of `texts` still gets embedded. Skips are reported via
129
+ * `onSkip` (index into `texts` + a named reason) rather than silently
130
+ * dropped; the returned array holds `undefined` at every skipped index.
131
+ * A caller abort (`signal.aborted`) still propagates as a rejection.
132
+ */
133
+ async embedBatch(texts, signal, onSkip) {
67
134
  if (texts.length === 0)
68
135
  return [];
69
- const results = [];
136
+ const results = new Array(texts.length).fill(undefined);
70
137
  const headers = this.buildHeaders();
71
138
  const ollamaOpts = resolveOllamaOptions(this.config);
72
- const batchSize = this.config.batchSize ?? DEFAULT_REMOTE_BATCH_SIZE;
73
- for (let i = 0; i < texts.length; i += batchSize) {
74
- const batch = texts.slice(i, i + batchSize);
75
- const body = {
76
- input: batch,
77
- model: this.model,
78
- };
79
- if (this.config.dimension) {
80
- body.dimensions = this.config.dimension;
81
- }
82
- if (ollamaOpts) {
83
- body.options = ollamaOpts;
84
- }
85
- // See embed(): `signal` goes through the 4th parameter, not the
86
- // RequestInit, or fetchWithTimeout drops it.
87
- const response = await fetchWithTimeout(normalizeEmbeddingEndpoint(this.endpoint), {
88
- method: "POST",
89
- headers,
90
- body: JSON.stringify(body),
91
- }, 30_000, signal);
92
- if (!response.ok) {
93
- const respBody = await readBodyWithByteCap(response, undefined, { bodyTimeoutMs: 30_000, signal }).catch((err) => {
94
- if (signal?.aborted)
95
- throw err;
96
- return "";
139
+ const tokenBudget = this.config.maxTokens ?? this.config.contextLength ?? DEFAULT_TOKEN_BUDGET;
140
+ const maxCount = this.config.batchSize ?? DEFAULT_REMOTE_BATCH_SIZE;
141
+ const batches = buildTokenBoundedBatches(texts, tokenBudget, maxCount);
142
+ for (const textBatch of batches) {
143
+ if (textBatch.oversized) {
144
+ const idx = textBatch.indices[0];
145
+ const estTokens = estimateTokenCount(texts[idx]);
146
+ onSkip?.({
147
+ index: idx,
148
+ reason: "context-window-exceeded",
149
+ message: `Document estimated at ${estTokens} tokens exceeds the ${tokenBudget}-token embedding budget; skipped.`,
97
150
  });
98
- throw new Error(`Embedding batch request failed (${response.status}): ${this.safeErrorBody(respBody)}`);
151
+ continue;
99
152
  }
100
- const json = JSON.parse(await readBodyWithByteCap(response, undefined, { bodyTimeoutMs: 30_000, signal }));
101
- if (!json.data || json.data.length !== batch.length) {
102
- throw new Error(`Unexpected embedding batch response: expected ${batch.length} embeddings, got ${json.data?.length ?? 0}.${embeddingEndpointPathHint(this.endpoint)}`);
153
+ const batch = textBatch.indices.map((i) => texts[i]);
154
+ try {
155
+ const embeddings = await this.requestBatch(batch, headers, ollamaOpts, signal);
156
+ for (let k = 0; k < textBatch.indices.length; k++) {
157
+ results[textBatch.indices[k]] = embeddings[k];
158
+ }
103
159
  }
104
- // Sort by index to guarantee correct order (OpenAI API doesn't guarantee order)
105
- const sorted = [...json.data].sort((a, b) => a.index - b.index);
106
- for (const [idx, d] of sorted.entries()) {
107
- if (!Array.isArray(d.embedding)) {
108
- throw new Error(`Unexpected embedding at batch index ${idx}: missing or invalid`);
160
+ catch (err) {
161
+ // A caller abort must still propagate — it is not a "this batch
162
+ // failed" condition, it means stop entirely.
163
+ if (signal?.aborted)
164
+ throw err;
165
+ const message = err instanceof Error ? err.message : String(err);
166
+ warnVerbose(`[embed] batch of ${batch.length} document(s) failed and was skipped: ${message}`);
167
+ for (const idx of textBatch.indices) {
168
+ onSkip?.({ index: idx, reason: "batch-request-failed", message });
109
169
  }
110
- results.push(l2Normalize(d.embedding));
111
170
  }
112
171
  }
113
172
  return results;
114
173
  }
174
+ /** Send one batch request and return its embeddings in input order. Throws on any failure. */
175
+ async requestBatch(batch, headers, ollamaOpts, signal) {
176
+ const body = {
177
+ input: batch,
178
+ model: this.model,
179
+ };
180
+ if (this.config.dimension) {
181
+ body.dimensions = this.config.dimension;
182
+ }
183
+ if (ollamaOpts) {
184
+ body.options = ollamaOpts;
185
+ }
186
+ // See embed(): `signal` goes through the 4th parameter, not the
187
+ // RequestInit, or fetchWithTimeout drops it.
188
+ const response = await fetchWithTimeout(normalizeEmbeddingEndpoint(this.endpoint), {
189
+ method: "POST",
190
+ headers,
191
+ body: JSON.stringify(body),
192
+ }, 30_000, signal);
193
+ if (!response.ok) {
194
+ const respBody = await readBodyWithByteCap(response, undefined, { bodyTimeoutMs: 30_000, signal }).catch((err) => {
195
+ if (signal?.aborted)
196
+ throw err;
197
+ return "";
198
+ });
199
+ throw new Error(`Embedding batch request failed (${response.status}): ${this.safeErrorBody(respBody)}`);
200
+ }
201
+ const json = JSON.parse(await readBodyWithByteCap(response, undefined, { bodyTimeoutMs: 30_000, signal }));
202
+ if (!json.data || json.data.length !== batch.length) {
203
+ throw new Error(`Unexpected embedding batch response: expected ${batch.length} embeddings, got ${json.data?.length ?? 0}.${embeddingEndpointPathHint(this.endpoint)}`);
204
+ }
205
+ // Sort by index to guarantee correct order (OpenAI API doesn't guarantee order)
206
+ const sorted = [...json.data].sort((a, b) => a.index - b.index);
207
+ const results = [];
208
+ for (const [idx, d] of sorted.entries()) {
209
+ if (!Array.isArray(d.embedding)) {
210
+ throw new Error(`Unexpected embedding at batch index ${idx}: missing or invalid`);
211
+ }
212
+ results.push(l2Normalize(d.embedding));
213
+ }
214
+ return results;
215
+ }
115
216
  buildHeaders() {
116
217
  const headers = { "Content-Type": "application/json" };
117
218
  const resolvedKey = resolveSecret(this.config.apiKey);
@@ -125,10 +226,8 @@ export class RemoteEmbedder {
125
226
  * hardening llm/client.ts applies on the identical path: pattern-redact
126
227
  * credential shapes, exact-scrub this connection's own key, and clip.
127
228
  *
128
- * These messages are durable generateEmbeddingsForDb surfaces them as
129
- * `embeddingResult.message`, which is written to semantic-status.json and
130
- * replayed by `akm info` (including `--json`) until the next successful
131
- * index, and printed on every vector-search attempt. Raw bodies reached that
229
+ * These messages surface via generateEmbeddingsForDb's `EmbeddingGenerationResult.message`
230
+ * and are printed on every vector-search attempt. Raw bodies reached that
132
231
  * far unredacted and uncapped, at readBodyWithByteCap's 10 MB default.
133
232
  */
134
233
  safeErrorBody(body) {
@@ -2,6 +2,7 @@
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
4
  import { isIP } from "node:net";
5
+ import { backoffDelay, computeRetryDelay, abortableDelay as sharedAbortableDelay, shouldRetry } from "../core/common.js";
5
6
  import { bareHostname, classifyNetworkAddress, classifyNetworkHostname, resolveHostnameAddresses, } from "../core/network-policy.js";
6
7
  import { assertRegistryPinnedTransportAvailable, requestRegistryAddressPinned, } from "./pinned-transport.js";
7
8
  export { requestRegistryAddressPinned } from "./pinned-transport.js";
@@ -77,7 +78,7 @@ async function requestRegistryHop(url, init, options) {
77
78
  }
78
79
  const response = await transport(url, address, init, remainingMs);
79
80
  if (attempt < maxRetries && shouldRetry(response.status)) {
80
- const delay = retryDelay(response, attempt);
81
+ const delay = computeRetryDelay(response, attempt, { maxDelayMs: MAX_REGISTRY_RETRY_DELAY_MS });
81
82
  await cancelRegistryResponse(response);
82
83
  await abortableDelay(delay, init?.signal);
83
84
  continue;
@@ -87,7 +88,7 @@ async function requestRegistryHop(url, init, options) {
87
88
  catch (error) {
88
89
  if (attempt >= maxRetries || init?.signal?.aborted)
89
90
  throw error;
90
- await abortableDelay(backoffDelay(attempt), init?.signal);
91
+ await abortableDelay(backoffDelay(attempt, undefined, MAX_REGISTRY_RETRY_DELAY_MS), init?.signal);
91
92
  }
92
93
  }
93
94
  throw new Error("Registry retry loop is unreachable");
@@ -217,45 +218,12 @@ function isRedirect(status) {
217
218
  export async function cancelRegistryResponse(response) {
218
219
  await response.body?.cancel().catch(() => undefined);
219
220
  }
220
- function shouldRetry(status) {
221
- return status === 429 || status >= 500;
222
- }
223
- function retryDelay(response, attempt) {
224
- const retryAfter = response.headers.get("retry-after");
225
- if (retryAfter) {
226
- const seconds = Number(retryAfter);
227
- if (Number.isFinite(seconds)) {
228
- return seconds >= 0 ? Math.min(MAX_REGISTRY_RETRY_DELAY_MS, seconds * 1_000) : backoffDelay(attempt);
229
- }
230
- const date = Date.parse(retryAfter);
231
- if (Number.isFinite(date))
232
- return Math.min(MAX_REGISTRY_RETRY_DELAY_MS, Math.max(0, date - Date.now()));
233
- }
234
- return backoffDelay(attempt);
235
- }
236
- function backoffDelay(attempt) {
237
- return Math.min(MAX_REGISTRY_RETRY_DELAY_MS, 500 * 2 ** attempt * (0.5 + Math.random() * 0.5));
238
- }
239
221
  /** Test-only visibility for the server-controlled Retry-After clamp. */
240
222
  export function _registryRetryDelayForTests(response, attempt) {
241
- return retryDelay(response, attempt);
223
+ return computeRetryDelay(response, attempt, { maxDelayMs: MAX_REGISTRY_RETRY_DELAY_MS });
242
224
  }
243
225
  function abortableDelay(ms, signal) {
244
- if (!signal)
245
- return new Promise((resolve) => setTimeout(resolve, ms));
246
- if (signal.aborted)
247
- return Promise.reject(signal.reason ?? new Error("Registry request aborted"));
248
- return new Promise((resolve, reject) => {
249
- const timer = setTimeout(() => {
250
- signal.removeEventListener("abort", onAbort);
251
- resolve();
252
- }, ms);
253
- const onAbort = () => {
254
- clearTimeout(timer);
255
- reject(signal.reason ?? new Error("Registry request aborted"));
256
- };
257
- signal.addEventListener("abort", onAbort, { once: true });
258
- });
226
+ return sharedAbortableDelay(ms, signal, "Registry request aborted");
259
227
  }
260
228
  /** Existing local HTTP fixtures remain available only inside the test process. */
261
229
  export function allowPrivateRegistryFixtureForTests(rawUrl) {
package/dist/runtime.js CHANGED
@@ -135,18 +135,10 @@ function stdioFor(mode) {
135
135
  return mode ?? "pipe";
136
136
  }
137
137
  // ── Stdin ───────────────────────────────────────────────────────────────────
138
- /**
139
- * Read all of stdin as a single `Buffer`, enforcing a byte limit. When more
140
- * than `limitBytes` is read, `onLimitExceeded()` is invoked and its return (an
141
- * Error) is thrown — callers supply their own message so behaviour is exact.
142
- */
143
- export async function readStdin(limitBytes, onLimitExceeded) {
138
+ /** Read all of stdin as a single `Buffer`. */
139
+ export async function readStdin() {
144
140
  const chunks = [];
145
- let total = 0;
146
141
  for await (const chunk of stdinIterator()) {
147
- total += chunk.byteLength;
148
- if (total > limitBytes)
149
- throw onLimitExceeded();
150
142
  chunks.push(chunk);
151
143
  }
152
144
  return Buffer.concat(chunks);