akm-cli 0.9.15-beta.2 → 0.9.15-beta.4
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 +116 -3
- package/dist/cli.js +3 -3
- package/dist/commands/health/checks.js +13 -9
- package/dist/commands/health/scheduler-binary.js +120 -0
- package/dist/commands/health.js +9 -0
- package/dist/commands/improve/improve-cli.js +107 -6
- package/dist/commands/improve/improve.js +47 -3
- package/dist/commands/improve/reflect.js +159 -64
- package/dist/core/config/schema/embedding.js +2 -3
- package/dist/core/errors.js +2 -0
- package/dist/core/maintenance-barrier.js +35 -6
- package/dist/indexer/indexer.js +44 -3
- package/dist/indexer/materialize-embeddings.js +51 -9
- package/dist/llm/client.js +18 -0
- package/dist/llm/embedders/remote.js +106 -8
- package/dist/llm/usage-persist.js +7 -1
- package/dist/scripts/akm-migrate-node.js +77 -17
- package/dist/scripts/akm-migrate.js +77 -17
- package/docs/migration/release-notes/0.9.15.md +83 -0
- package/docs/reference/cli.md +60 -6
- package/docs/reference/configuration.md +55 -5
- package/package.json +1 -1
- package/schemas/akm-config.json +0 -12
|
@@ -29,7 +29,7 @@ import { parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
|
29
29
|
import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
|
|
30
30
|
import { DESCRIPTION_MAX_CHARS, requiresDescription } from "../../core/authoring-rules.js";
|
|
31
31
|
import { loadConfig } from "../../core/config/config.js";
|
|
32
|
-
import { ConfigError } from "../../core/errors.js";
|
|
32
|
+
import { ConfigError, UsageError } from "../../core/errors.js";
|
|
33
33
|
import { appendEvent, readEvents } from "../../core/events.js";
|
|
34
34
|
import { lintLessonContent } from "../../core/lesson-lint.js";
|
|
35
35
|
import { parseEmbeddedJsonResponse } from "../../core/parse.js";
|
|
@@ -1371,6 +1371,92 @@ async function resolveReflectSource(options, stash, emitReflectFailed) {
|
|
|
1371
1371
|
}
|
|
1372
1372
|
return { assetContent, parsedRef };
|
|
1373
1373
|
}
|
|
1374
|
+
/**
|
|
1375
|
+
* #952 — the flat REFLECT_CONTENT_CAP (12 000 chars) exists only to avoid
|
|
1376
|
+
* E2BIG when the prompt travels through CLI argv (agent/SDK runners). The
|
|
1377
|
+
* direct-LLM HTTP path never touches argv, so it can use the resolved
|
|
1378
|
+
* engine's own context window instead. The reserve for "the rest of the
|
|
1379
|
+
* prompt" is measured directly (not guessed): build the same prompt with
|
|
1380
|
+
* the content cap forced to zero and use its length as the overhead, so
|
|
1381
|
+
* feedback/standards/schema-hints/prior-draft size is accounted for
|
|
1382
|
+
* exactly, per this call. A reflect rewrite returns a body roughly the
|
|
1383
|
+
* size of the input, so the budget only spends HALF of the usable window
|
|
1384
|
+
* on input content and reserves the other half for the model's own
|
|
1385
|
+
* output — otherwise a full-context request leaves no room for a
|
|
1386
|
+
* response. Never drops below the flat floor.
|
|
1387
|
+
*
|
|
1388
|
+
* Shared by the real dispatch path ({@link runReflectRefineIterations}) and
|
|
1389
|
+
* `renderReflectPromptPreview`'s `--show-prompt` preview, so the preview
|
|
1390
|
+
* renders the exact prompt reflect would actually send for LLM runners
|
|
1391
|
+
* instead of always the flat-cap prompt.
|
|
1392
|
+
*/
|
|
1393
|
+
function computeReflectContentBudgetChars(promptInput, runnerSpec) {
|
|
1394
|
+
return runnerIsLlm(runnerSpec) && promptInput.assetContent?.trim()
|
|
1395
|
+
? Math.max(REFLECT_CONTENT_CAP, Math.floor(((runnerSpec.connection.contextLength ?? DEFAULT_CONTEXT_LENGTH_TOKENS) * CHARS_PER_TOKEN -
|
|
1396
|
+
buildReflectPrompt({ ...promptInput, contentBudgetChars: 0 }).prompt.length) /
|
|
1397
|
+
2))
|
|
1398
|
+
: undefined;
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* #952 — gather every read-only prompt-input source {@link buildReflectPromptInput}
|
|
1402
|
+
* folds into a `ReflectPromptInput`: recent feedback, schema/lint hints, related
|
|
1403
|
+
* lessons, previously-rejected proposals, and stash standards context.
|
|
1404
|
+
*
|
|
1405
|
+
* Shared by the real dispatch path (`akmReflect`'s step 4, via
|
|
1406
|
+
* {@link runReflectRefineIterations}) and `renderReflectPromptPreview`'s
|
|
1407
|
+
* `--show-prompt` preview, so both gather from exactly one definition instead
|
|
1408
|
+
* of two copies that can drift out of agreement.
|
|
1409
|
+
*/
|
|
1410
|
+
async function gatherReflectPromptSources(options, stash, parsedRef, assetContent, assetCtx) {
|
|
1411
|
+
const feedback = readRecentFeedback(options.ref ? (options.itemRef ?? durableImproveRef(options.ref)) : undefined, options.eventsCtx);
|
|
1412
|
+
const schemaHints = buildSchemaHints(parsedRef?.type ?? "", assetContent);
|
|
1413
|
+
const relatedLessons = options.ref && parsedRef ? await readRelatedLessons(assetCtx, stash, options.ref, parsedRef, options.itemRef) : [];
|
|
1414
|
+
// Reflexion-style verbal-RL: inject rejected proposals so the agent avoids
|
|
1415
|
+
// reproducing proposals that have already been reviewed and refused.
|
|
1416
|
+
const rejectedProposals = readRejectedProposals(stash, options.ref, options.ctx);
|
|
1417
|
+
// Standards "rulebook" for this target — stash convention/meta facts; empty
|
|
1418
|
+
// when none fire.
|
|
1419
|
+
const standardsContext = resolveStandardsContext(options.ref, stash);
|
|
1420
|
+
return { feedback, schemaHints, relatedLessons, rejectedProposals, standardsContext };
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* #952 — assemble the `ReflectPromptInput` object literal reflect actually
|
|
1424
|
+
* sends, from gathered sources plus the per-call values (draft path, prior
|
|
1425
|
+
* draft). Shared by the real dispatch path ({@link runReflectRefineIterations})
|
|
1426
|
+
* and `renderReflectPromptPreview`'s `--show-prompt` preview — including
|
|
1427
|
+
* `avoidPatterns`, which the preview previously omitted even though a live
|
|
1428
|
+
* improve loop passes it (recent-error context, O-5 / #378).
|
|
1429
|
+
*/
|
|
1430
|
+
function buildReflectPromptInput(args) {
|
|
1431
|
+
const { options, parsedRef, assetContent, sources, runnerSpec, draftFilePath, priorDraft } = args;
|
|
1432
|
+
const { feedback, schemaHints, relatedLessons, rejectedProposals, standardsContext } = sources;
|
|
1433
|
+
const outputMode = runnerIsLlm(runnerSpec)
|
|
1434
|
+
? wantsJsonSchemaOutput(runnerSpec.connection)
|
|
1435
|
+
? "json_schema"
|
|
1436
|
+
: "framed_markdown"
|
|
1437
|
+
: undefined;
|
|
1438
|
+
return {
|
|
1439
|
+
...(options.ref ? { ref: options.ref } : {}),
|
|
1440
|
+
...(parsedRef?.type ? { type: parsedRef.type } : {}),
|
|
1441
|
+
...(parsedRef?.name ? { name: parsedRef.name } : {}),
|
|
1442
|
+
...(assetContent !== undefined ? { assetContent } : {}),
|
|
1443
|
+
...(feedback.length > 0 ? { feedback } : {}),
|
|
1444
|
+
...(schemaHints.length > 0 ? { schemaHints } : {}),
|
|
1445
|
+
...(relatedLessons.length > 0 ? { relatedLessons } : {}),
|
|
1446
|
+
...(options.task ? { task: options.task } : {}),
|
|
1447
|
+
...(standardsContext.trim() ? { standardsContext } : {}),
|
|
1448
|
+
...(options.avoidPatterns && options.avoidPatterns.length > 0 ? { avoidPatterns: options.avoidPatterns } : {}),
|
|
1449
|
+
...(rejectedProposals.length > 0 ? { rejectedProposals } : {}),
|
|
1450
|
+
// R-1: inject prior draft as self-critique target on iterations > 0
|
|
1451
|
+
...(priorDraft !== undefined ? { priorDraft } : {}),
|
|
1452
|
+
// Issue A (#reflect-pipeline file-write contract): when the runner can
|
|
1453
|
+
// touch the filesystem, instruct the agent to write the proposal body
|
|
1454
|
+
// to a tmp file instead of inlining it in JSON. Avoids parse failures
|
|
1455
|
+
// on long bodies (e.g. knowledge/systems/KOKORO_USAGE_GUIDE 8.4KB).
|
|
1456
|
+
...(draftFilePath ? { draftFilePath } : {}),
|
|
1457
|
+
...(outputMode ? { outputMode } : {}),
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1374
1460
|
/**
|
|
1375
1461
|
* Run the agent with the optional Self-Refine loop (R-1 / #372): up to
|
|
1376
1462
|
* `maxRefineIters` invocations, each injecting the prior draft as self-critique
|
|
@@ -1379,17 +1465,12 @@ async function resolveReflectSource(options, stash, emitReflectFailed) {
|
|
|
1379
1465
|
* result + last draft path. Extracted verbatim from `akmReflect`.
|
|
1380
1466
|
*/
|
|
1381
1467
|
async function runReflectRefineIterations(args) {
|
|
1382
|
-
const { options, parsedRef, assetContent,
|
|
1468
|
+
const { options, parsedRef, assetContent, sources, runnerSpec, lease, agentEnv, draftPathsToCleanup, onNotices } = args;
|
|
1383
1469
|
const maxRefineIters = Math.max(1, options.maxRefineIters ?? 1);
|
|
1384
1470
|
// Determine whether this dispatch can honour the file-write contract.
|
|
1385
1471
|
// Agent CLI + OpenCode SDK runners both have filesystem access; the direct
|
|
1386
1472
|
// LLM HTTP runner does NOT.
|
|
1387
1473
|
const canRunnerWriteFile = runnerSupportsFileWrite(runnerSpec);
|
|
1388
|
-
const outputMode = runnerIsLlm(runnerSpec)
|
|
1389
|
-
? wantsJsonSchemaOutput(runnerSpec.connection)
|
|
1390
|
-
? "json_schema"
|
|
1391
|
-
: "framed_markdown"
|
|
1392
|
-
: undefined;
|
|
1393
1474
|
// Initialized to a sentinel; always overwritten in the first loop iteration
|
|
1394
1475
|
// (maxRefineIters is clamped to >= 1 above).
|
|
1395
1476
|
let result = {};
|
|
@@ -1404,44 +1485,16 @@ async function runReflectRefineIterations(args) {
|
|
|
1404
1485
|
draftPathsToCleanup.push(iterDraftPath);
|
|
1405
1486
|
lastDraftPath = iterDraftPath;
|
|
1406
1487
|
}
|
|
1407
|
-
const promptInput = {
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
...(options.avoidPatterns && options.avoidPatterns.length > 0 ? { avoidPatterns: options.avoidPatterns } : {}),
|
|
1418
|
-
...(rejectedProposals.length > 0 ? { rejectedProposals } : {}),
|
|
1419
|
-
// R-1: inject prior draft as self-critique target on iterations > 0
|
|
1420
|
-
...(priorDraft !== undefined ? { priorDraft } : {}),
|
|
1421
|
-
// Issue A (#reflect-pipeline file-write contract): when the runner can
|
|
1422
|
-
// touch the filesystem, instruct the agent to write the proposal body
|
|
1423
|
-
// to a tmp file instead of inlining it in JSON. Avoids parse failures
|
|
1424
|
-
// on long bodies (e.g. knowledge/systems/KOKORO_USAGE_GUIDE 8.4KB).
|
|
1425
|
-
...(iterDraftPath ? { draftFilePath: iterDraftPath } : {}),
|
|
1426
|
-
...(outputMode ? { outputMode } : {}),
|
|
1427
|
-
};
|
|
1428
|
-
// #952 — the flat REFLECT_CONTENT_CAP (12 000 chars) exists only to avoid
|
|
1429
|
-
// E2BIG when the prompt travels through CLI argv (agent/SDK runners). The
|
|
1430
|
-
// direct-LLM HTTP path never touches argv, so it can use the resolved
|
|
1431
|
-
// engine's own context window instead. The reserve for "the rest of the
|
|
1432
|
-
// prompt" is measured directly (not guessed): build the same prompt with
|
|
1433
|
-
// the content cap forced to zero and use its length as the overhead, so
|
|
1434
|
-
// feedback/standards/schema-hints/prior-draft size is accounted for
|
|
1435
|
-
// exactly, per this call. A reflect rewrite returns a body roughly the
|
|
1436
|
-
// size of the input, so the budget only spends HALF of the usable window
|
|
1437
|
-
// on input content and reserves the other half for the model's own
|
|
1438
|
-
// output — otherwise a full-context request leaves no room for a
|
|
1439
|
-
// response. Never drops below the flat floor.
|
|
1440
|
-
const contentBudgetChars = runnerIsLlm(runnerSpec) && assetContent?.trim()
|
|
1441
|
-
? Math.max(REFLECT_CONTENT_CAP, Math.floor(((runnerSpec.connection.contextLength ?? DEFAULT_CONTEXT_LENGTH_TOKENS) * CHARS_PER_TOKEN -
|
|
1442
|
-
buildReflectPrompt({ ...promptInput, contentBudgetChars: 0 }).prompt.length) /
|
|
1443
|
-
2))
|
|
1444
|
-
: undefined;
|
|
1488
|
+
const promptInput = buildReflectPromptInput({
|
|
1489
|
+
options,
|
|
1490
|
+
parsedRef,
|
|
1491
|
+
assetContent,
|
|
1492
|
+
sources,
|
|
1493
|
+
runnerSpec,
|
|
1494
|
+
draftFilePath: iterDraftPath,
|
|
1495
|
+
priorDraft,
|
|
1496
|
+
});
|
|
1497
|
+
const contentBudgetChars = computeReflectContentBudgetChars(promptInput, runnerSpec);
|
|
1445
1498
|
const { prompt } = buildReflectPrompt({
|
|
1446
1499
|
...promptInput,
|
|
1447
1500
|
...(contentBudgetChars !== undefined ? { contentBudgetChars } : {}),
|
|
@@ -1459,10 +1512,10 @@ async function runReflectRefineIterations(args) {
|
|
|
1459
1512
|
...(options.signal ? { signal: options.signal } : {}),
|
|
1460
1513
|
priorDraft,
|
|
1461
1514
|
iteration: iter,
|
|
1462
|
-
...(outputMode === "json_schema"
|
|
1515
|
+
...(promptInput.outputMode === "json_schema"
|
|
1463
1516
|
? { responseSchema: options.ref ? REFLECT_JSON_SCHEMA : REFLECT_UNSCOPED_JSON_SCHEMA }
|
|
1464
1517
|
: {}),
|
|
1465
|
-
outputMode: outputMode ?? "framed_markdown",
|
|
1518
|
+
outputMode: promptInput.outputMode ?? "framed_markdown",
|
|
1466
1519
|
...(options.ref ? { targetRef: options.ref } : {}),
|
|
1467
1520
|
allowRepair: repairAttempts === 0,
|
|
1468
1521
|
...(options.chat ? { chat: options.chat } : {}),
|
|
@@ -1650,6 +1703,62 @@ function validateReflectPayloadRef(args) {
|
|
|
1650
1703
|
return undefined;
|
|
1651
1704
|
}
|
|
1652
1705
|
}
|
|
1706
|
+
/**
|
|
1707
|
+
* #952 — render the composed reflect prompt for exactly one asset with no
|
|
1708
|
+
* engine dispatch. Reuses every read-only step `akmReflect` performs before
|
|
1709
|
+
* {@link buildReflectPrompt} (source resolution, runner resolution, feedback /
|
|
1710
|
+
* schema-hint / related-lesson / rejected-proposal gathering) and stops right
|
|
1711
|
+
* there: no dispatch lease is acquired, no request is sent, and — because the
|
|
1712
|
+
* `emitReflectFailed` callback passed to {@link resolveReflectSource} here is
|
|
1713
|
+
* a no-op — no `reflect_invoked`/`reflect_completed` event is appended either.
|
|
1714
|
+
*
|
|
1715
|
+
* `akm improve <ref> --show-prompt` (`improve-cli.ts`) is the CLI surface: a
|
|
1716
|
+
* field operator uses it to see the exact prompt reflect would send, in
|
|
1717
|
+
* seconds, without running a full improve cycle or needing a reachable
|
|
1718
|
+
* engine.
|
|
1719
|
+
*/
|
|
1720
|
+
export async function renderReflectPromptPreview(options) {
|
|
1721
|
+
if (!options.ref) {
|
|
1722
|
+
throw new UsageError("renderReflectPromptPreview requires options.ref.", "INVALID_FLAG_VALUE");
|
|
1723
|
+
}
|
|
1724
|
+
const ref = options.ref;
|
|
1725
|
+
const stash = resolveRunStashDir(options.stashDir);
|
|
1726
|
+
const sourceResolved = await resolveReflectSource(options, stash, () => {
|
|
1727
|
+
// No event emitted: this is a read-only preview, not a real invocation.
|
|
1728
|
+
});
|
|
1729
|
+
if ("failure" in sourceResolved) {
|
|
1730
|
+
const { failure } = sourceResolved;
|
|
1731
|
+
throw new UsageError((!failure.ok && failure.error) || `Reflect cannot preview ref "${ref}".`, "INVALID_FLAG_VALUE");
|
|
1732
|
+
}
|
|
1733
|
+
const { assetContent, parsedRef } = sourceResolved;
|
|
1734
|
+
const { runnerSpec, engineName } = resolveReflectRunner(options);
|
|
1735
|
+
const ctx = buildReflectRunContext({ options, stash, config: options.config ?? loadConfig(), runnerSpec });
|
|
1736
|
+
const assetCtx = ctx.withFreshAssetMemo();
|
|
1737
|
+
const sources = await gatherReflectPromptSources(options, stash, parsedRef, assetContent, assetCtx);
|
|
1738
|
+
const canRunnerWriteFile = runnerSupportsFileWrite(runnerSpec);
|
|
1739
|
+
// Same tmp-path synthesis a real dispatch would use (Issue A) — never
|
|
1740
|
+
// written to, since this preview never runs the agent.
|
|
1741
|
+
const draftFilePath = canRunnerWriteFile ? synthesizeReflectDraftPath(ref) : undefined;
|
|
1742
|
+
const previewPromptInput = buildReflectPromptInput({
|
|
1743
|
+
options,
|
|
1744
|
+
parsedRef,
|
|
1745
|
+
assetContent,
|
|
1746
|
+
sources,
|
|
1747
|
+
runnerSpec,
|
|
1748
|
+
draftFilePath,
|
|
1749
|
+
priorDraft: undefined,
|
|
1750
|
+
});
|
|
1751
|
+
// #952 — mirror the real dispatch path's context-aware content budget (see
|
|
1752
|
+
// computeReflectContentBudgetChars) so the preview shows the exact prompt
|
|
1753
|
+
// reflect would send: an LLM engine with a large context window gets the
|
|
1754
|
+
// full asset with no truncation marker, not the flat 12 000-char cap.
|
|
1755
|
+
const contentBudgetChars = computeReflectContentBudgetChars(previewPromptInput, runnerSpec);
|
|
1756
|
+
const { prompt } = buildReflectPrompt({
|
|
1757
|
+
...previewPromptInput,
|
|
1758
|
+
...(contentBudgetChars !== undefined ? { contentBudgetChars } : {}),
|
|
1759
|
+
});
|
|
1760
|
+
return { ref, prompt, engine: engineName, engineKind: runnerSpec.kind };
|
|
1761
|
+
}
|
|
1653
1762
|
export async function akmReflect(options = {}) {
|
|
1654
1763
|
const stash = resolveRunStashDir(options.stashDir);
|
|
1655
1764
|
// Build lazy event emitters. The invocation row is committed only after the
|
|
@@ -1695,17 +1804,7 @@ export async function akmReflect(options = {}) {
|
|
|
1695
1804
|
// 4. Build the shared prompt inputs — feedback, hints, lessons, rejected
|
|
1696
1805
|
// proposals. These are stable across refinement iterations; only the
|
|
1697
1806
|
// `priorDraft` field changes per-iteration (R-1 / #372).
|
|
1698
|
-
const
|
|
1699
|
-
const schemaHints = buildSchemaHints(parsedRef?.type ?? "", assetContent);
|
|
1700
|
-
const relatedLessons = options.ref && parsedRef
|
|
1701
|
-
? await readRelatedLessons(assetCtx, stash, options.ref, parsedRef, options.itemRef)
|
|
1702
|
-
: [];
|
|
1703
|
-
// Reflexion-style verbal-RL: inject rejected proposals so the agent avoids
|
|
1704
|
-
// reproducing proposals that have already been reviewed and refused.
|
|
1705
|
-
const rejectedProposals = readRejectedProposals(stash, options.ref, options.ctx);
|
|
1706
|
-
// Standards "rulebook" for this target — stash convention/meta facts; empty
|
|
1707
|
-
// when none fire.
|
|
1708
|
-
const standardsContext = resolveStandardsContext(options.ref, stash);
|
|
1807
|
+
const sources = await gatherReflectPromptSources(options, stash, parsedRef, assetContent, assetCtx);
|
|
1709
1808
|
// 5. Spawn the agent — with the optional Self-Refine loop (R-1 / #372),
|
|
1710
1809
|
// extracted to {@link runReflectRefineIterations}.
|
|
1711
1810
|
const agentEnv = options.eventSource === "improve" ? { AKM_EVENT_SOURCE: "improve" } : {};
|
|
@@ -1725,11 +1824,7 @@ export async function akmReflect(options = {}) {
|
|
|
1725
1824
|
options,
|
|
1726
1825
|
parsedRef,
|
|
1727
1826
|
assetContent,
|
|
1728
|
-
|
|
1729
|
-
schemaHints,
|
|
1730
|
-
relatedLessons,
|
|
1731
|
-
rejectedProposals,
|
|
1732
|
-
standardsContext,
|
|
1827
|
+
sources,
|
|
1733
1828
|
runnerSpec,
|
|
1734
1829
|
lease: generationLease,
|
|
1735
1830
|
agentEnv,
|
|
@@ -1808,7 +1903,7 @@ export async function akmReflect(options = {}) {
|
|
|
1808
1903
|
qualityGateSkippedNoJudge,
|
|
1809
1904
|
qualityJudgeRunner,
|
|
1810
1905
|
qualityJudgeLease,
|
|
1811
|
-
feedback,
|
|
1906
|
+
feedback: sources.feedback,
|
|
1812
1907
|
stash,
|
|
1813
1908
|
emitReflectFailed,
|
|
1814
1909
|
onNotices: collectExecutionNotices,
|
|
@@ -45,13 +45,12 @@ export const EmbeddingConnectionConfigSchema = z
|
|
|
45
45
|
maxInputTokens: positiveInt.optional(),
|
|
46
46
|
/**
|
|
47
47
|
* Client-side per-request token budget — how many documents' estimated
|
|
48
|
-
* tokens fit in one HTTP request (default `DEFAULT_TOKEN_BUDGET` =
|
|
48
|
+
* tokens fit in one HTTP request (default `DEFAULT_TOKEN_BUDGET` = 6000
|
|
49
49
|
* in `src/llm/embedders/remote.ts`). With the 512-token `maxInputTokens`
|
|
50
|
-
* cap above, a request carries about
|
|
50
|
+
* cap above, a request carries about 11 documents by default.
|
|
51
51
|
*/
|
|
52
52
|
maxTokens: positiveInt.optional(),
|
|
53
53
|
batchSize: positiveInt.optional(),
|
|
54
|
-
chunkSize: positiveInt.optional(),
|
|
55
54
|
/**
|
|
56
55
|
* Ollama's `num_ctx` ONLY (#956) — sent verbatim as
|
|
57
56
|
* `options.num_ctx` on the native `/api/embed` request. It no longer also
|
package/dist/core/errors.js
CHANGED
|
@@ -82,6 +82,8 @@ const USAGE_HINTS = {
|
|
|
82
82
|
const TRANSIENT_HINTS = {
|
|
83
83
|
RUN_LEASE_HELD: "Wait for the named engine invocation to finish or for the lease to expire, then retry. `akm workflow status <id>` shows the current lease.",
|
|
84
84
|
STATE_DB_CONTENDED: "Another akm process is writing state.db right now. Wait a few seconds and retry; commands that support --skip-if-locked can skip instead of failing.",
|
|
85
|
+
INDEX_DB_CONTENDED: "Another akm process is writing index.db; retry shortly, or pass --skip-if-locked on scheduled runs.",
|
|
86
|
+
MAINTENANCE_BARRIER_BUSY: "Another akm process is registering a lock or lease right now. Retry shortly, or pass --skip-if-locked on scheduled index/improve/workflow runs.",
|
|
85
87
|
};
|
|
86
88
|
/** Default hint for each NotFoundError code. */
|
|
87
89
|
const NOT_FOUND_HINTS = {
|
|
@@ -6,7 +6,8 @@ import { randomUUID } from "node:crypto";
|
|
|
6
6
|
import fs from "node:fs";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { sleepSync } from "../runtime.js";
|
|
9
|
-
import {
|
|
9
|
+
import { backoffDelay } from "./common.js";
|
|
10
|
+
import { ConfigError, TransientError } from "./errors.js";
|
|
10
11
|
import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "./file-lock.js";
|
|
11
12
|
import { getMaintenanceBarrierPath } from "./paths.js";
|
|
12
13
|
const heldBarrierContext = new AsyncLocalStorage();
|
|
@@ -24,6 +25,26 @@ const heldBarrierContext = new AsyncLocalStorage();
|
|
|
24
25
|
* (`commands/improve/extract.ts`).
|
|
25
26
|
*/
|
|
26
27
|
const MAINTENANCE_BARRIER_STALE_AFTER_MS = 5 * 60 * 1000;
|
|
28
|
+
/**
|
|
29
|
+
* The barrier normally holds for one lock-file write — sub-millisecond on
|
|
30
|
+
* any real filesystem. Two akm processes racing to register a lock in the
|
|
31
|
+
* very same instant (e.g. two `akm index` runs a scheduler launched back to
|
|
32
|
+
* back) can still collide on it; retrying briefly resolves that ordinary
|
|
33
|
+
* case instead of failing a legitimate concurrent invocation outright
|
|
34
|
+
* (field follow-up to #956, G1). Bounded short so a genuinely wedged holder
|
|
35
|
+
* still surfaces the busy error promptly rather than making a losing
|
|
36
|
+
* process hang — comfortably above the barrier's normal hold time, well
|
|
37
|
+
* below a length that would make this feel like the blocking lock #872
|
|
38
|
+
* removed. Never applies to the rebuild lock itself, which stays
|
|
39
|
+
* non-blocking (#872).
|
|
40
|
+
*/
|
|
41
|
+
const MAINTENANCE_BARRIER_BUSY_RETRY_BOUND_MS = 1_500;
|
|
42
|
+
let busyRetryBoundMsForTests;
|
|
43
|
+
/** Test-only override for {@link MAINTENANCE_BARRIER_BUSY_RETRY_BOUND_MS}, so a unit test can exercise the
|
|
44
|
+
* exhausted-retry throw without a real ~1.5s wait. Restored via tests/_helpers/seams.ts's resetAllSeams(). */
|
|
45
|
+
export function _setMaintenanceBarrierBusyRetryBoundMsForTests(ms) {
|
|
46
|
+
busyRetryBoundMsForTests = ms;
|
|
47
|
+
}
|
|
27
48
|
/**
|
|
28
49
|
* Serialize the short critical section that creates each long-lived AKM lock,
|
|
29
50
|
* lease, or state activity. The operation keeps its own ownership record; this
|
|
@@ -44,11 +65,19 @@ export function tryAcquireMaintenanceBarrier() {
|
|
|
44
65
|
return undefined;
|
|
45
66
|
}
|
|
46
67
|
export function acquireMaintenanceBarrier() {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
68
|
+
const boundMs = busyRetryBoundMsForTests ?? MAINTENANCE_BARRIER_BUSY_RETRY_BOUND_MS;
|
|
69
|
+
const deadline = Date.now() + boundMs;
|
|
70
|
+
for (let attempt = 0;; attempt += 1) {
|
|
71
|
+
const release = tryAcquireMaintenanceBarrier();
|
|
72
|
+
if (release)
|
|
73
|
+
return release;
|
|
74
|
+
const remainingMs = deadline - Date.now();
|
|
75
|
+
if (remainingMs <= 0)
|
|
76
|
+
break;
|
|
77
|
+
sleepSync(Math.min(backoffDelay(attempt), remainingMs));
|
|
78
|
+
}
|
|
79
|
+
throw new TransientError(`AKM maintenance is in progress (barrier ${getMaintenanceBarrierPath()}); retry shortly. ` +
|
|
80
|
+
`A sentinel older than ${MAINTENANCE_BARRIER_STALE_AFTER_MS / 60_000} minute(s) is reclaimed automatically on the next attempt.`, "MAINTENANCE_BARRIER_BUSY");
|
|
52
81
|
}
|
|
53
82
|
export function withMaintenanceStartBarrier(run) {
|
|
54
83
|
if (heldBarrierContext.getStore()?.active)
|
package/dist/indexer/indexer.js
CHANGED
|
@@ -7,12 +7,14 @@ import { detectAdapterId } from "../core/adapter/detect-adapter.js";
|
|
|
7
7
|
import { adapterForId } from "../core/adapter/registry.js";
|
|
8
8
|
import { isHttpUrl, toErrorMessage } from "../core/common.js";
|
|
9
9
|
import { concurrentMap } from "../core/concurrent.js";
|
|
10
|
-
import { ConfigError } from "../core/errors.js";
|
|
10
|
+
import { AkmError, ConfigError, TransientError } from "../core/errors.js";
|
|
11
|
+
import { probeLock } from "../core/file-lock.js";
|
|
11
12
|
import { defaultConcurrencyForEndpoint } from "../core/loopback.js";
|
|
12
13
|
import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
|
|
13
14
|
import { getDbPath } from "../core/paths.js";
|
|
14
15
|
import { SCRIPT_EXTENSIONS } from "../core/recognition-util.js";
|
|
15
|
-
import {
|
|
16
|
+
import { formatLockHolderPid } from "../core/run-lock.js";
|
|
17
|
+
import { isSqliteContentionError, withStateDb } from "../core/state-db.js";
|
|
16
18
|
import { isVerbose, warn, warnOnce, warnVerbose } from "../core/warn.js";
|
|
17
19
|
import { disposeLoweredExecutionDispatchLease, } from "../integrations/agent/execution-lowering.js";
|
|
18
20
|
import { isLlmFeatureEnabled } from "../llm/feature-gate.js";
|
|
@@ -28,6 +30,7 @@ import { upsertUtilityScore } from "../storage/repositories/index-utility-reposi
|
|
|
28
30
|
import { getEmbeddingCount, isVecAvailable, isVecFastPathReady, warnIfVecMissing, } from "../storage/repositories/index-vec-repository.js";
|
|
29
31
|
import { assertIndexedWorkflowSourceIdentity, WorkflowSourceIdentityError } from "../workflows/source-files.js";
|
|
30
32
|
import { deleteStoredGraph } from "./db/graph-db.js";
|
|
33
|
+
import { indexRebuildLockPath } from "./index-rebuild-lock.js";
|
|
31
34
|
import { deriveEntryProvenance, deriveInstallations } from "./installations.js";
|
|
32
35
|
import { indexedPathMatchesOwner, resolveAdapterConceptOwner, } from "./lookup/adapter-concept-owner.js";
|
|
33
36
|
import { generateEmbeddingsForDb } from "./materialize-embeddings.js";
|
|
@@ -371,6 +374,44 @@ let akmIndexOverride;
|
|
|
371
374
|
export function _setAkmIndexForTests(fake) {
|
|
372
375
|
akmIndexOverride = fake;
|
|
373
376
|
}
|
|
377
|
+
/**
|
|
378
|
+
* Read-only description of the rebuild lock's current holder, appended to a
|
|
379
|
+
* reclassified index.db contention message when known (field follow-up to
|
|
380
|
+
* #956). `probeLock` only inspects the sentinel — it never acquires or
|
|
381
|
+
* mutates it — so this is safe to call from inside an error path.
|
|
382
|
+
*/
|
|
383
|
+
function describeIndexRebuildLockHolder() {
|
|
384
|
+
const probe = probeLock(indexRebuildLockPath());
|
|
385
|
+
if (probe.state !== "held")
|
|
386
|
+
return "";
|
|
387
|
+
return ` The rebuild lock is currently held by pid ${formatLockHolderPid({
|
|
388
|
+
pid: probe.holderPid,
|
|
389
|
+
launcherPid: probe.launcherPid ?? null,
|
|
390
|
+
})}.`;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Reclassify a contention-shaped error escaping the walk, index, or
|
|
394
|
+
* embedding phase into a retryable-shortly `TransientError` (field
|
|
395
|
+
* follow-up to #956, dev-team field review 2026-09-10): a concurrent writer
|
|
396
|
+
* (another `akm index`, a source-update embedding pass, the per-command
|
|
397
|
+
* background reindex) can make index.db busy, and the raw SQLite driver
|
|
398
|
+
* error ("database is locked") used to escape as exit 70
|
|
399
|
+
* (internal/unclassified) instead of the "retry shortly" contract exit 75
|
|
400
|
+
* gives a scheduler to branch on — mirroring `STATE_DB_CONTENDED`'s
|
|
401
|
+
* precedent for state.db (`core/state-db.ts`). Reuses the ONE shared
|
|
402
|
+
* classifier, `isSqliteContentionError`, rather than a second one. An error
|
|
403
|
+
* that is already a classified akm error (e.g. a `STATE_DB_CONTENDED`
|
|
404
|
+
* TransientError from an inner state.db write) is never re-wrapped — only a
|
|
405
|
+
* raw, unclassified error matching the shared contention shape is
|
|
406
|
+
* reclassified. Every other error is rethrown unchanged.
|
|
407
|
+
*/
|
|
408
|
+
export function reclassifyIndexDbContention(error) {
|
|
409
|
+
if (error instanceof AkmError || !isSqliteContentionError(error))
|
|
410
|
+
return error;
|
|
411
|
+
const contended = new TransientError(`akm's index database is busy (another akm process is writing it); retry shortly.${describeIndexRebuildLockHolder()}`, "INDEX_DB_CONTENDED");
|
|
412
|
+
contended.cause = error;
|
|
413
|
+
return contended;
|
|
414
|
+
}
|
|
374
415
|
export async function akmIndex(options) {
|
|
375
416
|
try {
|
|
376
417
|
const override = akmIndexOverride;
|
|
@@ -387,7 +428,7 @@ export async function akmIndex(options) {
|
|
|
387
428
|
// rollback before closing its borrowed unified handle.
|
|
388
429
|
}
|
|
389
430
|
}
|
|
390
|
-
throw error;
|
|
431
|
+
throw reclassifyIndexDbContention(error);
|
|
391
432
|
}
|
|
392
433
|
}
|
|
393
434
|
let indexTransactionHookForTests;
|
|
@@ -136,8 +136,14 @@ function deriveObservedEmbeddingIdentity(embedding, observedModel, observedVecto
|
|
|
136
136
|
* direct `RemoteEmbedder`) so every embedder branch — remote, local,
|
|
137
137
|
* deterministic, and test overrides via `_setEmbedderForTests` — is
|
|
138
138
|
* exercised identically to the main embedding pass.
|
|
139
|
+
*
|
|
140
|
+
* `maxInputTokens` must be the SAME cap the main pass below applies via
|
|
141
|
+
* {@link capEmbeddingText} — the stored vector for each sampled entry was
|
|
142
|
+
* produced from its capped text, so comparing against a fresh vector of the
|
|
143
|
+
* uncapped text would compare unlike inputs for any entry over the cap
|
|
144
|
+
* (#955).
|
|
139
145
|
*/
|
|
140
|
-
async function runEmbeddingCanary(db, config, signal) {
|
|
146
|
+
async function runEmbeddingCanary(db, config, signal, maxInputTokens) {
|
|
141
147
|
const samples = sampleEmbeddedEntriesForCanary(db, CANARY_SAMPLE_SIZE);
|
|
142
148
|
if (samples.length === 0) {
|
|
143
149
|
return { outcome: "keep", verified: false, viaIdentityMatch: false };
|
|
@@ -146,7 +152,15 @@ async function runEmbeddingCanary(db, config, signal) {
|
|
|
146
152
|
const skips = [];
|
|
147
153
|
let canaryVectors;
|
|
148
154
|
try {
|
|
149
|
-
canaryVectors = await embedBatch(
|
|
155
|
+
canaryVectors = await embedBatch(
|
|
156
|
+
// #955: the stored vector for each sample was produced from
|
|
157
|
+
// capEmbeddingText(searchText, maxInputTokens) — the main pass below
|
|
158
|
+
// caps every document before embedding it. The canary must re-embed
|
|
159
|
+
// the SAME capped text, or an entry over the cap compares a fresh
|
|
160
|
+
// vector of a different input against a stored vector of the capped
|
|
161
|
+
// one, and a genuine model match can read as a rebuild-worthy
|
|
162
|
+
// mismatch for reasons unrelated to the model.
|
|
163
|
+
samples.map((sample) => capEmbeddingText(sample.searchText, maxInputTokens).text), config.embedding, signal, (skip) => skips.push(skip), (_indices, _embeddings, model) => {
|
|
150
164
|
if (model)
|
|
151
165
|
observedModel = model;
|
|
152
166
|
});
|
|
@@ -252,6 +266,10 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
|
|
|
252
266
|
let targetEntryIds = entryIds;
|
|
253
267
|
/** Set only on an actual rebuild, so the up-front "Re-embedding N entries" line names why. */
|
|
254
268
|
let rebuildReason;
|
|
269
|
+
// Resolved once and reused by both the canary (below) and the main pass's
|
|
270
|
+
// cap loop (further down) — the same cap must apply to both, or the canary
|
|
271
|
+
// compares a differently-capped text against the stored vector (#955).
|
|
272
|
+
const maxInputTokens = config.embedding?.maxInputTokens ?? DEFAULT_MAX_INPUT_TOKENS;
|
|
255
273
|
if (opts?.forceReembed) {
|
|
256
274
|
// `akm index --reembed`: an explicit operator override, skips the canary
|
|
257
275
|
// entirely. The new fingerprint (and identity, now stale/unknown until
|
|
@@ -272,7 +290,7 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
|
|
|
272
290
|
rebuildReason = "forced by --reembed";
|
|
273
291
|
}
|
|
274
292
|
else if (storedFingerprint && storedFingerprint !== currentFingerprint) {
|
|
275
|
-
const decision = await runEmbeddingCanary(db, config, signal);
|
|
293
|
+
const decision = await runEmbeddingCanary(db, config, signal, maxInputTokens);
|
|
276
294
|
if (decision.outcome === "unverifiable") {
|
|
277
295
|
// Destroying a good index because the server happens to be down right
|
|
278
296
|
// now is worse than leaving a rename unverified until the next run —
|
|
@@ -377,12 +395,12 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
|
|
|
377
395
|
return reusedCount > 0 ? { success: true, vecInsertFailures: vecFailedCount } : { success: true };
|
|
378
396
|
}
|
|
379
397
|
// Cap each document's embedded text at
|
|
380
|
-
// embedding.maxInputTokens (default DEFAULT_MAX_INPUT_TOKENS
|
|
381
|
-
//
|
|
382
|
-
//
|
|
398
|
+
// embedding.maxInputTokens (default DEFAULT_MAX_INPUT_TOKENS, resolved
|
|
399
|
+
// once above so the canary uses the identical cap) instead of ever
|
|
400
|
+
// failing a whole batch over one oversized entry — truncation keeps the
|
|
401
|
+
// head of the text, unicode-safe. A document is skipped only when its
|
|
383
402
|
// head is empty (the impossible case: nothing left to embed), never
|
|
384
403
|
// merely for being long.
|
|
385
|
-
const maxInputTokens = config.embedding?.maxInputTokens ?? DEFAULT_MAX_INPUT_TOKENS;
|
|
386
404
|
let truncatedCount = 0;
|
|
387
405
|
const texts = [];
|
|
388
406
|
const pendingEntries = [];
|
|
@@ -529,6 +547,23 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
|
|
|
529
547
|
}
|
|
530
548
|
return;
|
|
531
549
|
}
|
|
550
|
+
// #954: a "budget-lowered" event is the same kind of notice as
|
|
551
|
+
// "retrying" above — the run's first context-size rejection just
|
|
552
|
+
// shrank the request budget for everything not yet dispatched, but
|
|
553
|
+
// THIS rejected batch's own indices are still being split and
|
|
554
|
+
// retried by the embedder (their real stored/failed outcome lands in
|
|
555
|
+
// a later onBatch call). Nothing here has settled, so it must never
|
|
556
|
+
// touch storage, only report the notice — one line, at most once per
|
|
557
|
+
// run.
|
|
558
|
+
if (outcome?.outcome === "budget-lowered") {
|
|
559
|
+
if (reportPerBatchLine) {
|
|
560
|
+
onProgress({
|
|
561
|
+
phase: "embeddings",
|
|
562
|
+
message: `[embed] batch ${outcome.batchIndex}/${outcome.batchCount}: ${outcome.docCount} docs, ${outcome.requestTokens.toLocaleString()} tokens → ${outcome.reason}`,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
532
567
|
if (model)
|
|
533
568
|
observedModel = model;
|
|
534
569
|
// A batch that delivered at least one real embedding proves the
|
|
@@ -542,7 +577,8 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
|
|
|
542
577
|
}
|
|
543
578
|
db.transaction(() => {
|
|
544
579
|
for (let k = 0; k < indices.length; k++) {
|
|
545
|
-
const
|
|
580
|
+
const index = indices[k];
|
|
581
|
+
const entry = pendingEntries[index];
|
|
546
582
|
if (!entry)
|
|
547
583
|
continue;
|
|
548
584
|
const embedding = batchEmbeddings[k];
|
|
@@ -555,7 +591,13 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
|
|
|
555
591
|
const result = upsertEmbedding(db, entry.id, embedding);
|
|
556
592
|
if (result.stored) {
|
|
557
593
|
storedCount++;
|
|
558
|
-
|
|
594
|
+
// #954: sum the estimate of the text actually sent —
|
|
595
|
+
// `texts[index]` is the capped string `embedBatch` was handed,
|
|
596
|
+
// parallel to `pendingEntries` by construction above (the
|
|
597
|
+
// `entry` guard covers both) — not `entry.searchText`, which is
|
|
598
|
+
// the pre-cap original and overstates throughput for every
|
|
599
|
+
// entry over the cap.
|
|
600
|
+
storedTokens += estimateTokenCount(texts[index]);
|
|
559
601
|
}
|
|
560
602
|
else {
|
|
561
603
|
skippedCount++;
|
package/dist/llm/client.js
CHANGED
|
@@ -472,3 +472,21 @@ export async function probeLlmEndpoint(config, timeoutMs = 3_000) {
|
|
|
472
472
|
return { reachable: false, error: err instanceof Error ? err.message : String(err) };
|
|
473
473
|
}
|
|
474
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* Endpoint-keyed probe memoization (#957): every caller that probes several
|
|
477
|
+
* engine connections in one pass (`akm health`'s engine checks, `akm
|
|
478
|
+
* improve --require-engines`) shares one in-flight probe per distinct
|
|
479
|
+
* endpoint (trailing slashes normalized) instead of firing a duplicate probe
|
|
480
|
+
* when two engines point at the same server. `cache` must be scoped to one
|
|
481
|
+
* invocation and never shared across calls — a stale "reachable" surviving
|
|
482
|
+
* past the run that produced it is the failure mode this exists to avoid.
|
|
483
|
+
*/
|
|
484
|
+
export function probeEndpointOnce(connection, cache, probe) {
|
|
485
|
+
const key = connection.endpoint.replace(/\/+$/, "");
|
|
486
|
+
let pending = cache.get(key);
|
|
487
|
+
if (!pending) {
|
|
488
|
+
pending = probe(connection);
|
|
489
|
+
cache.set(key, pending);
|
|
490
|
+
}
|
|
491
|
+
return pending;
|
|
492
|
+
}
|