akm-cli 0.9.4 → 0.9.6
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 +265 -0
- package/dist/commands/env/env-cli.js +1 -2
- package/dist/commands/env/secret-cli.js +1 -5
- package/dist/commands/feedback-cli.js +0 -4
- package/dist/commands/health/checks.js +0 -32
- package/dist/commands/health/surfaces.js +2 -2
- package/dist/commands/health.js +6 -15
- package/dist/commands/improve/anti-collapse.js +4 -91
- package/dist/commands/improve/autonomy-gate.js +1 -1
- package/dist/commands/improve/consolidate.js +25 -26
- package/dist/commands/improve/distill.js +2 -2
- package/dist/commands/improve/extract.js +8 -1
- package/dist/commands/improve/improve.js +3 -5
- package/dist/commands/improve/locks.js +13 -5
- package/dist/commands/improve/loop-stages.js +8 -9
- package/dist/commands/improve/memory/memory-contradiction-detect.js +1 -19
- package/dist/commands/improve/outcome-loop.js +0 -6
- package/dist/commands/improve/reflect.js +20 -9
- package/dist/commands/lint/index.js +4 -6
- package/dist/commands/proposal/validators/proposal-quality-validators.js +0 -7
- package/dist/commands/proposal/validators/proposal-validators.js +12 -0
- package/dist/commands/read/search.js +14 -24
- package/dist/commands/sources/bundle-config-ops.js +9 -4
- package/dist/commands/sources/info.js +14 -10
- package/dist/commands/tasks/tasks-cli.js +56 -3
- package/dist/commands/tasks/tasks.js +101 -18
- package/dist/commands/workflow-cli.js +2 -2
- package/dist/core/adapter/adapters/akm-adapter.js +2 -0
- package/dist/core/common.js +61 -24
- package/dist/core/config/config-io.js +2 -2
- package/dist/core/config/config-sources.js +32 -2
- package/dist/core/config/config-version-shim.js +101 -0
- package/dist/core/config/config.js +7 -7
- package/dist/core/config/schema/engines.js +9 -7
- package/dist/core/config/schema/primitives.js +0 -5
- package/dist/core/config/schema/search.js +2 -1
- package/dist/core/file-lock.js +2 -1
- package/dist/core/improve-result.js +35 -14
- package/dist/core/maintenance-barrier.js +2 -14
- package/dist/core/paths.js +0 -3
- package/dist/core/redaction.js +2 -2
- package/dist/core/spawn-env.js +8 -12
- package/dist/core/state/migrations.js +1 -12
- package/dist/core/state-db.js +9 -27
- package/dist/core/write-source.js +12 -19
- package/dist/execution/directory-identity.js +36 -10
- package/dist/execution/guarded-source.js +0 -10
- package/dist/indexer/graph/graph-boost.js +0 -4
- package/dist/indexer/index-writer-lock.js +43 -24
- package/dist/indexer/index-written-assets.js +5 -6
- package/dist/indexer/indexer.js +2 -39
- package/dist/indexer/materialize-embeddings.js +85 -41
- package/dist/indexer/passes/metadata.js +12 -4
- package/dist/indexer/scan/doc-to-entry.js +2 -0
- package/dist/indexer/search/db-search.js +15 -48
- package/dist/indexer/search/ranking-contributors.js +0 -25
- package/dist/indexer/search/ranking.js +3 -13
- package/dist/indexer/search/search-fields.js +16 -1
- package/dist/integrations/agent/builder-shared.js +0 -25
- package/dist/integrations/agent/model-map.js +2 -60
- package/dist/integrations/harnesses/aider/agent-builder.js +1 -3
- package/dist/integrations/harnesses/amazonq/agent-builder.js +2 -7
- package/dist/integrations/harnesses/claude/agent-builder.js +1 -4
- package/dist/integrations/harnesses/codex/agent-builder.js +1 -4
- package/dist/integrations/harnesses/copilot/agent-builder.js +2 -6
- package/dist/integrations/harnesses/gemini/agent-builder.js +2 -7
- package/dist/integrations/harnesses/opencode/agent-builder.js +1 -4
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +2 -16
- package/dist/integrations/harnesses/openhands/agent-builder.js +1 -3
- package/dist/integrations/harnesses/pi/agent-builder.js +2 -4
- package/dist/integrations/session-logs/index.js +0 -9
- package/dist/llm/client.js +75 -42
- package/dist/llm/embedder.js +7 -3
- package/dist/llm/embedders/remote.js +141 -42
- package/dist/output/shapes/passthrough.js +17 -5
- package/dist/registry/network.js +5 -37
- package/dist/runtime.js +2 -10
- package/dist/scripts/akm-migrate-node.js +180 -238
- package/dist/scripts/akm-migrate.js +180 -238
- package/dist/setup/engine-config.js +2 -5
- package/dist/setup/registry-stash-loader.js +0 -8
- package/dist/setup/setup.js +9 -46
- package/dist/setup/steps/connection-shared.js +10 -13
- package/dist/sources/providers/git-install.js +1 -1
- package/dist/storage/engines/sqlite-migrations.js +20 -1
- package/dist/storage/repositories/index-entries-repository.js +0 -15
- package/dist/storage/repositories/proposals-repository.js +32 -6
- package/dist/tasks/backends/launchd.js +15 -20
- package/dist/tasks/backends/schtasks.js +18 -8
- package/dist/tasks/run/run-native-task.js +8 -6
- package/dist/tasks/scheduler-binding.js +15 -5
- package/dist/tasks/scheduler-sync-preview.js +4 -2
- package/dist/tasks/scheduler-sync.js +77 -42
- package/dist/tasks/source/bounded-document.js +2 -4
- package/dist/tasks/source/task-source-v3-frozen.js +5 -7
- package/dist/tasks/source/task-source-v4.js +5 -10
- package/dist/tasks/source/task-to-v3.js +29 -22
- package/dist/tasks/source/task-to-v4.js +1 -14
- package/dist/tasks/source-v3.js +6 -6
- package/dist/workflows/exec/native-executor.js +21 -31
- package/dist/workflows/exec/run-workflow.js +5 -6
- package/dist/workflows/exec/scheduler.js +3 -19
- package/dist/workflows/exec/step-work.js +1 -4
- package/dist/workflows/exec/unit-dispatch.js +2 -2
- package/dist/workflows/exec/worktree.js +1 -13
- package/dist/workflows/freeze/targets/child-workflow.js +2 -10
- package/dist/workflows/ir/plan-hash.js +4 -6
- package/dist/workflows/ir/schema-v4.js +0 -12
- package/dist/workflows/ir/schema.js +20 -31
- package/dist/workflows/parser.js +11 -52
- package/dist/workflows/renderer.js +2 -3
- package/dist/workflows/resource-limits.js +11 -41
- package/dist/workflows/runtime/runs.js +3 -4
- package/dist/workflows/source-ir/schema.js +14 -30
- package/dist/workflows/validator.js +1 -7
- package/docs/reference/cli.md +25 -3
- package/docs/reference/configuration.md +28 -7
- package/docs/reference/tasks.md +10 -0
- package/package.json +1 -1
- package/schemas/akm-config.json +4 -23
- package/schemas/akm-task.json +1 -2
- package/schemas/akm-workflow.json +1 -13
- package/dist/indexer/search/semantic-status.js +0 -142
package/dist/llm/client.js
CHANGED
|
@@ -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
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
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
|
|
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
|
-
// ──
|
|
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
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
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
|
|
450
|
+
export async function probeLlmReachable(config) {
|
|
402
451
|
try {
|
|
403
|
-
const raw = await chatCompletion({
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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,
|
|
459
|
+
return { reachable: false, error: err instanceof Error ? err.message : String(err) };
|
|
427
460
|
}
|
|
428
461
|
}
|
package/dist/llm/embedder.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
151
|
+
continue;
|
|
99
152
|
}
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
throw
|
|
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
|
|
129
|
-
*
|
|
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) {
|
|
@@ -4,6 +4,15 @@
|
|
|
4
4
|
// #484: stamp schemaVersion + shape discriminator on passthrough envelopes so
|
|
5
5
|
// third-party consumers can pin a schema version and dispatch on shape uniformly.
|
|
6
6
|
// Idempotent — never overwrites an existing schemaVersion or shape field.
|
|
7
|
+
//
|
|
8
|
+
// Builds a shallow copy rather than mutating `result` in place: several
|
|
9
|
+
// command results (e.g. `akm task sync --dry-run`'s `SchedulerPlanPreview`,
|
|
10
|
+
// see src/tasks/scheduler-sync-preview.ts) are deliberately `Object.freeze`d
|
|
11
|
+
// by their producer as an immutability guarantee, and an in-place `obj.shape
|
|
12
|
+
// = …` assignment throws ("Attempting to define property on object that is
|
|
13
|
+
// not extensible") the moment it hits one. Copying tolerates both frozen and
|
|
14
|
+
// mutable inputs uniformly, and `output()` never uses the result's identity
|
|
15
|
+
// past this call, so a copy is safe here.
|
|
7
16
|
function makeStampHandler(command) {
|
|
8
17
|
return (result) => {
|
|
9
18
|
if (result === null || result === undefined)
|
|
@@ -11,11 +20,13 @@ function makeStampHandler(command) {
|
|
|
11
20
|
if (typeof result !== "object" || Array.isArray(result))
|
|
12
21
|
return result;
|
|
13
22
|
const obj = result;
|
|
14
|
-
if (obj.shape
|
|
15
|
-
obj
|
|
16
|
-
|
|
17
|
-
obj
|
|
18
|
-
|
|
23
|
+
if (obj.shape !== undefined && obj.schemaVersion !== undefined)
|
|
24
|
+
return obj;
|
|
25
|
+
return {
|
|
26
|
+
...obj,
|
|
27
|
+
shape: obj.shape ?? command,
|
|
28
|
+
schemaVersion: obj.schemaVersion ?? 1,
|
|
29
|
+
};
|
|
19
30
|
};
|
|
20
31
|
}
|
|
21
32
|
const PASSTHROUGH_COMMANDS = [
|
|
@@ -55,6 +66,7 @@ const PASSTHROUGH_COMMANDS = [
|
|
|
55
66
|
"task-doctor",
|
|
56
67
|
"task-explain",
|
|
57
68
|
"task-history",
|
|
69
|
+
"task-prune",
|
|
58
70
|
"task-run",
|
|
59
71
|
"task-sync",
|
|
60
72
|
"task-sync-dry-run",
|
package/dist/registry/network.js
CHANGED
|
@@ -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 =
|
|
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
|
|
223
|
+
return computeRetryDelay(response, attempt, { maxDelayMs: MAX_REGISTRY_RETRY_DELAY_MS });
|
|
242
224
|
}
|
|
243
225
|
function abortableDelay(ms, signal) {
|
|
244
|
-
|
|
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
|
-
|
|
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);
|