akm-cli 0.9.17 → 0.9.19-alpha.1
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 +157 -0
- package/STABILITY.md +2 -1
- package/dist/assets/hints/cli-hints-full.md +4 -2
- package/dist/assets/hints/cli-hints-short.md +5 -3
- package/dist/assets/prompts/reflect-feedback-framing.md +1 -1
- package/dist/commands/feedback-cli.js +1 -1
- package/dist/commands/health/checks.js +6 -6
- package/dist/commands/health.js +3 -3
- package/dist/commands/improve/consolidate/coverage.js +132 -0
- package/dist/commands/improve/consolidate/pair-pass.js +31 -21
- package/dist/commands/improve/consolidate.js +46 -13
- package/dist/commands/improve/distill.js +8 -4
- package/dist/commands/improve/eligibility.js +39 -9
- package/dist/commands/improve/improve-cli.js +9 -6
- package/dist/commands/improve/improve.js +13 -4
- package/dist/commands/improve/ledger.js +2 -2
- package/dist/commands/improve/loop-stages.js +2 -0
- package/dist/commands/improve/preparation.js +3 -1
- package/dist/commands/improve/reflect.js +2 -2
- package/dist/commands/improve/stage.js +43 -12
- package/dist/commands/proposal/diff-format.js +21 -0
- package/dist/commands/proposal/proposal-cli.js +48 -10
- package/dist/commands/proposal/proposal-types.js +11 -0
- package/dist/commands/proposal/proposal.js +60 -5
- package/dist/commands/proposal/repository.js +250 -18
- package/dist/commands/read/knowledge.js +13 -11
- package/dist/commands/read/remember-cli.js +7 -3
- package/dist/commands/sources/source-clone.js +1 -1
- package/dist/commands/tasks/tasks-cli.js +1 -1
- package/dist/commands/tasks/tasks.js +10 -3
- package/dist/core/mutation-target.js +8 -3
- package/dist/core/write-source.js +3 -2
- package/dist/indexer/usage/usage-events.js +2 -1
- package/dist/output/shapes/helpers.js +7 -0
- package/dist/output/shapes/passthrough.js +1 -0
- package/dist/output/shapes/proposal/reopen.js +14 -0
- package/dist/output/shapes.js +2 -0
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/proposal/proposal.js +3 -1
- package/dist/output/text/proposal-format.js +87 -32
- package/dist/scripts/akm-migrate-node.js +102 -25
- package/dist/scripts/akm-migrate.js +102 -25
- package/dist/storage/repositories/improve-ledger-repository.js +65 -6
- package/dist/storage/repositories/index-vec-repository.js +13 -8
- package/dist/storage/repositories/proposals-repository.js +23 -0
- package/dist/storage/sqlite-read-snapshot.js +46 -2
- package/dist/storage/state-db-integrity.js +12 -9
- package/dist/tasks/run/load-task.js +5 -1
- package/docs/migration/README.md +1 -0
- package/docs/migration/release-notes/0.9.19.md +134 -0
- package/docs/migration/release-notes/README.md +5 -0
- package/docs/migration/v0.7-to-v0.8.md +2 -2
- package/docs/migration/v0.8-to-v0.9.md +5 -1
- package/docs/reference/cli.md +189 -28
- package/docs/reference/configuration.md +9 -8
- package/docs/reference/data-and-telemetry.md +24 -16
- package/package.json +1 -1
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
* promoted. Promotion emits a reviewable proposal and never touches the
|
|
8
8
|
* memory directly; accepting it later retires the source memory (O1, in
|
|
9
9
|
* `proposal/repository.ts`). Memories the improve ledger judged recently and
|
|
10
|
-
* that have not changed since are not judged again
|
|
10
|
+
* that have not changed since are not judged again, and a memory whose
|
|
11
|
+
* promotion was accepted or rejected waits until its body changes. A memory
|
|
12
|
+
* that a neighbouring knowledge doc already covers is not promoted
|
|
13
|
+
* (`consolidate/coverage.ts`).
|
|
11
14
|
*
|
|
12
15
|
* Accounting invariant (the promote pass only): `processed == promoted +
|
|
13
16
|
* judgedNoAction + Σ(skipReasons) + failedChunkMemories`.
|
|
@@ -41,11 +44,12 @@ import { getAllEntries } from "../../storage/repositories/index-entries-reposito
|
|
|
41
44
|
import { listProposals, listProposalsReadOnly, proposalContent } from "../proposal/repository.js";
|
|
42
45
|
import { hasHotCaptureMode, hasSupersededStatus, validateProposalFrontmatter, } from "../proposal/validators/proposal-quality-validators.js";
|
|
43
46
|
import { buildChunkPrompt, computeSafeChunkSize, DEFAULT_CONTEXT_LENGTH_TOKENS } from "./consolidate/chunking.js";
|
|
47
|
+
import { openKnowledgeCoverage } from "./consolidate/coverage.js";
|
|
44
48
|
import { runConsolidatePairPass } from "./consolidate/pair-pass.js";
|
|
45
49
|
import { sanitizeMergedContent } from "./consolidate/sanitize.js";
|
|
46
50
|
import { contentHash } from "./content-hash.js";
|
|
47
51
|
import { resolveImproveStrategy, resolveProcessEnabled } from "./improve-strategies.js";
|
|
48
|
-
import { isLedgerBlocked, ledgerKey, loadLedgerSnapshot, recordLedgerAttempt } from "./ledger.js";
|
|
52
|
+
import { isContentDrivenRow, isLedgerBlocked, ledgerKey, loadLedgerSnapshot, recordLedgerAttempt } from "./ledger.js";
|
|
49
53
|
import { isInRetrievalScope, loadRetrievalScope } from "./retrieval-scope.js";
|
|
50
54
|
import { callStage, mintProposal, noticeSet, stageRunner } from "./stage.js";
|
|
51
55
|
/** A plan op worth acting on. Retired advisory ops (merge/delete/contradict) are dropped, never thrown on. */
|
|
@@ -222,10 +226,10 @@ function injectRandomClusterMembers(memories, profile, warnings) {
|
|
|
222
226
|
return out;
|
|
223
227
|
}
|
|
224
228
|
/** Body hashes of pending consolidate proposals, so the prompt can mark memories already queued. */
|
|
225
|
-
function loadPendingConsolidateProposalHashes(stashDir) {
|
|
229
|
+
function loadPendingConsolidateProposalHashes(stashDir, proposalsCtx) {
|
|
226
230
|
const hashes = new Set();
|
|
227
231
|
try {
|
|
228
|
-
for (const p of listProposalsReadOnly(stashDir, { status: "pending" })) {
|
|
232
|
+
for (const p of listProposalsReadOnly(stashDir, { status: "pending" }, proposalsCtx)) {
|
|
229
233
|
if (p.source !== "consolidate")
|
|
230
234
|
continue;
|
|
231
235
|
try {
|
|
@@ -420,12 +424,26 @@ export function inspectConsolidationPool(opts, stashDir, warnings, existingKnowl
|
|
|
420
424
|
}
|
|
421
425
|
memories = memories.filter((memory) => fs.existsSync(memory.filePath));
|
|
422
426
|
const poolSize = memories.length;
|
|
423
|
-
// A memory judged within its revisit window comes back once it is edited.
|
|
427
|
+
// A memory judged within its revisit window comes back once it is edited. A
|
|
428
|
+
// promotion that was accepted or rejected holds its memory until the body
|
|
429
|
+
// changes, however long that takes (#998).
|
|
424
430
|
const ledger = loadLedgerSnapshot({ proposalsCtx: opts.proposalsCtx, readOnly }, stashDir, ["consolidate"]);
|
|
425
431
|
if (ledger.size > 0) {
|
|
426
432
|
const nowIso = new Date().toISOString();
|
|
427
433
|
memories = memories.filter((memory) => {
|
|
428
434
|
const row = ledger.get(ledgerKey("consolidate", conceptIdFromTypeName("memory", memory.name)));
|
|
435
|
+
if (!row)
|
|
436
|
+
return true;
|
|
437
|
+
if (isContentDrivenRow(row)) {
|
|
438
|
+
let bodyHash;
|
|
439
|
+
try {
|
|
440
|
+
bodyHash = contentHash(fs.readFileSync(memory.filePath, "utf8"), "body");
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
bodyHash = undefined;
|
|
444
|
+
}
|
|
445
|
+
return row.contentHash !== bodyHash;
|
|
446
|
+
}
|
|
429
447
|
let changedAt;
|
|
430
448
|
try {
|
|
431
449
|
changedAt = fs.statSync(memory.filePath).mtime.toISOString();
|
|
@@ -433,7 +451,7 @@ export function inspectConsolidationPool(opts, stashDir, warnings, existingKnowl
|
|
|
433
451
|
catch {
|
|
434
452
|
changedAt = undefined;
|
|
435
453
|
}
|
|
436
|
-
return !
|
|
454
|
+
return !isLedgerBlocked(row, nowIso, changedAt);
|
|
437
455
|
});
|
|
438
456
|
}
|
|
439
457
|
const judgedUnchanged = poolSize - memories.length;
|
|
@@ -622,7 +640,7 @@ async function planConsolidation(opts, config, stashDir, memories, warnings, sta
|
|
|
622
640
|
assertRunnerCredentials(llmRunner);
|
|
623
641
|
const { ordered, embedTelemetry } = await clusterMemoriesBySimilarity(budgeted, config, stateDb, opts.signal);
|
|
624
642
|
const chunks = slice(injectRandomClusterMembers(ordered, opts.improveProfile, warnings));
|
|
625
|
-
const pendingProposalBodyHashes = loadPendingConsolidateProposalHashes(stashDir);
|
|
643
|
+
const pendingProposalBodyHashes = loadPendingConsolidateProposalHashes(stashDir, opts.proposalsCtx);
|
|
626
644
|
warn(`[consolidate] ${budgeted.length} memories / ${chunks.length} chunk(s) / chunk_size=${chunkSize}` +
|
|
627
645
|
` / pending-proposal hashes: ${pendingProposalBodyHashes.size}`);
|
|
628
646
|
const planned = await judgeConsolidationChunks({
|
|
@@ -651,7 +669,7 @@ async function consolidate(opts, config, stashDir, startMs, stateDb) {
|
|
|
651
669
|
const { memories, prefilteredAlreadyPromoted } = pool;
|
|
652
670
|
const plural = (n) => `memor${n === 1 ? "y" : "ies"}`;
|
|
653
671
|
if (pool.judgedUnchanged > 0) {
|
|
654
|
-
warnings.push(`Consolidation: skipped ${pool.judgedUnchanged} ${plural(pool.judgedUnchanged)} judged within the revisit window and unchanged since.`);
|
|
672
|
+
warnings.push(`Consolidation: skipped ${pool.judgedUnchanged} ${plural(pool.judgedUnchanged)} judged within the revisit window, or already promoted or rejected, and unchanged since.`);
|
|
655
673
|
}
|
|
656
674
|
if (pool.outsideRetrievalScope > 0) {
|
|
657
675
|
warnings.push(`Consolidation: skipped ${pool.outsideRetrievalScope} ${plural(pool.outsideRetrievalScope)} already judged that retrieval has not returned in the last ${USAGE_EVENT_RETENTION_DAYS} days.`);
|
|
@@ -664,8 +682,8 @@ async function consolidate(opts, config, stashDir, startMs, stateDb) {
|
|
|
664
682
|
// sees .derived memories, flat knowledge and lessons, not just the
|
|
665
683
|
// promote pool above), so it runs regardless of whether the promote pool
|
|
666
684
|
// is empty — every return path below carries its result.
|
|
667
|
-
const
|
|
668
|
-
const pairPass = await runConsolidatePairPass(opts, config, stashDir,
|
|
685
|
+
const bundleId = resolveConsolidationSourceOwner(opts, stashDir)?.bundleId;
|
|
686
|
+
const pairPass = await runConsolidatePairPass(opts, config, stashDir, bundleId, warnings);
|
|
669
687
|
if (memories.length === 0) {
|
|
670
688
|
return makeConsolidateResult({
|
|
671
689
|
dryRun: opts.dryRun ?? false,
|
|
@@ -719,8 +737,16 @@ async function consolidate(opts, config, stashDir, startMs, stateDb) {
|
|
|
719
737
|
warnings,
|
|
720
738
|
pushSkipReason: (op, ref, reason) => pushSkipReason(acc, op, ref, reason),
|
|
721
739
|
};
|
|
722
|
-
|
|
723
|
-
|
|
740
|
+
const coverage = plan.allOps.length > 0 ? openKnowledgeCoverage(bundleId) : undefined;
|
|
741
|
+
if (coverage)
|
|
742
|
+
ctx.coveringKnowledge = coverage.find;
|
|
743
|
+
try {
|
|
744
|
+
for (const op of plan.allOps)
|
|
745
|
+
await emitPromotionProposal(op, ctx);
|
|
746
|
+
}
|
|
747
|
+
finally {
|
|
748
|
+
coverage?.close();
|
|
749
|
+
}
|
|
724
750
|
// Every other judged memory waits out its revisit window (or its next edit);
|
|
725
751
|
// a promotion that failed to persist is retried next run.
|
|
726
752
|
recordLedgerAttempt({ proposalsCtx: opts.proposalsCtx }, [...acc.judgedRefs]
|
|
@@ -765,7 +791,8 @@ const PROMOTE_BODY_MIN_CHARS = 100;
|
|
|
765
791
|
/**
|
|
766
792
|
* Queue one promotion as a proposal. Refused (with a skip reason) when the
|
|
767
793
|
* memory is unknown, already promoted this run, already pending or present
|
|
768
|
-
* as knowledge (by concept, body hash or slug variant),
|
|
794
|
+
* as knowledge (by concept, body hash or slug variant), already covered by a
|
|
795
|
+
* neighbouring knowledge doc (`coverage.ts`), unreadable, fails
|
|
769
796
|
* sanitization, is superseded, has a body too small to be knowledge, or has
|
|
770
797
|
* no valid description.
|
|
771
798
|
* @internal Exported for promotion-path integration tests.
|
|
@@ -842,6 +869,12 @@ export async function emitPromotionProposal(op, ctx) {
|
|
|
842
869
|
if (sameBody) {
|
|
843
870
|
return skip("dedup_pending_proposal", `Skipping promote: identical body already pending as proposal ${sameBody.id} (ref: ${sameBody.ref}); skipping duplicate for ${op.ref} → ${knowledgeRef}`);
|
|
844
871
|
}
|
|
872
|
+
// #998: the copies the two exact checks above cannot see — an earlier
|
|
873
|
+
// promotion that was edited or re-slugged, a doc that quotes the memory.
|
|
874
|
+
const covering = ctx.coveringKnowledge?.(entry.filePath, sourceBody);
|
|
875
|
+
if (covering) {
|
|
876
|
+
return skip("dedup_covered_by_knowledge", `Skipping promote: ${op.ref} → ${knowledgeRef} is already covered by ${covering.ref} (${Math.round(covering.containment * 100)}% of its text appears there).`);
|
|
877
|
+
}
|
|
845
878
|
try {
|
|
846
879
|
const description = (typeof op.description === "string" && op.description.trim()
|
|
847
880
|
? op.description.trim()
|
|
@@ -20,7 +20,7 @@ import { parseFrontmatter, writeSalienceToFrontmatter } from "../../core/asset/f
|
|
|
20
20
|
import { stripMarkdownFences } from "../../core/asset/markdown.js";
|
|
21
21
|
import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
|
|
22
22
|
import { authoringRulesForType } from "../../core/authoring-rules.js";
|
|
23
|
-
import { resolveStashDir } from "../../core/common.js";
|
|
23
|
+
import { isWithin, resolveStashDir } from "../../core/common.js";
|
|
24
24
|
import { getImproveProcessConfig, loadConfig } from "../../core/config/config.js";
|
|
25
25
|
import { UsageError } from "../../core/errors.js";
|
|
26
26
|
import { appendEvent, readEvents } from "../../core/events.js";
|
|
@@ -436,7 +436,9 @@ async function judgeAndQueue(run, out) {
|
|
|
436
436
|
let confidence;
|
|
437
437
|
if (qualityGateEnabled(run)) {
|
|
438
438
|
const similarLessons = await run.similar(content.slice(0, 500), 3);
|
|
439
|
-
|
|
439
|
+
// The judge reads what the generator read: the source body, without its frontmatter (buildDistillPrompt).
|
|
440
|
+
const source = out.source ? parseFrontmatter(out.source).content.trim() : "";
|
|
441
|
+
const verdict = await runLessonQualityJudge(run.config, content, source, run.options.chat, {
|
|
440
442
|
...(similarLessons.length > 0 ? { similarLessons } : {}),
|
|
441
443
|
...(run.runner ? { llmRunner: run.runner } : {}),
|
|
442
444
|
...(run.options.signal ? { signal: run.options.signal } : {}),
|
|
@@ -599,8 +601,10 @@ async function planPromotion(run, feedbackEvents) {
|
|
|
599
601
|
const existingPath = await run.lookup(assessment.knowledgeRef);
|
|
600
602
|
let existing = null;
|
|
601
603
|
try {
|
|
602
|
-
|
|
604
|
+
// The promotion is filed in run.stash, so only that bundle's doc is its destination (#1000).
|
|
605
|
+
if (existingPath && fs.existsSync(existingPath) && isWithin(existingPath, run.stash)) {
|
|
603
606
|
existing = fs.readFileSync(existingPath, "utf8");
|
|
607
|
+
}
|
|
604
608
|
}
|
|
605
609
|
catch {
|
|
606
610
|
existing = null;
|
|
@@ -799,7 +803,7 @@ function readDistillFeedback(run) {
|
|
|
799
803
|
}
|
|
800
804
|
/** System + user prompt: rejected-proposal context, optional CLS neighbours, stash standards. */
|
|
801
805
|
async function buildDistillMessages(run, feedback, kind, outputRef) {
|
|
802
|
-
const rejectedProposals = rejectedProposalContext(run.stash, run.inputRef, run.options.ctx);
|
|
806
|
+
const rejectedProposals = rejectedProposalContext(run.stash, run.inputRef, run.options.ctx, run.options.eventsCtx);
|
|
803
807
|
// CLS interleaving (default off): show related lessons so the model does not overwrite them.
|
|
804
808
|
const cls = getImproveProcessConfig("distill", run.profile)?.cls ?? {};
|
|
805
809
|
let clsContext = "";
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
/** The refs an improve run may consider, read from the index, and the small predicates over them. */
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
|
+
import { parseBundleRef } from "../../core/asset/asset-ref.js";
|
|
7
8
|
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
8
9
|
import { conceptIdFromTypeName, parseRefInput, resolveRef, typeNameFromConceptId } from "../../core/asset/resolve-ref.js";
|
|
9
10
|
import { loadConfig } from "../../core/config/config.js";
|
|
@@ -109,6 +110,18 @@ export function dedupeRefs(refs) {
|
|
|
109
110
|
return true;
|
|
110
111
|
});
|
|
111
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* The bundle an improve run reads candidates from and files proposals into: its
|
|
115
|
+
* write target, the source rooted at `stashDir` (else the working bundle).
|
|
116
|
+
* Reflect reads a candidate from its owning bundle but the proposal lands in
|
|
117
|
+
* the write target, so a candidate owned by any other bundle would be read from
|
|
118
|
+
* one bundle and filed in another (#1000).
|
|
119
|
+
*/
|
|
120
|
+
function runBundleIdOf(sources, installations, stashDir) {
|
|
121
|
+
const root = stashDir === undefined ? undefined : path.resolve(stashDir);
|
|
122
|
+
const index = sources.findIndex((source) => root === undefined ? source.isDefault === true : path.resolve(source.path) === root);
|
|
123
|
+
return installations[index]?.id;
|
|
124
|
+
}
|
|
112
125
|
export async function collectEligibleRefs(scope, stashDir, improveProfile, config) {
|
|
113
126
|
return collectEligibleRefsFromIndex(scope, stashDir, improveProfile, false, config);
|
|
114
127
|
}
|
|
@@ -135,6 +148,9 @@ async function collectEligibleRefsFromIndex(scope, stashDir, improveProfile, rea
|
|
|
135
148
|
return empty();
|
|
136
149
|
const installations = deriveInstallations(sources);
|
|
137
150
|
const writableBundleIds = deriveWritableBundleIds(sources);
|
|
151
|
+
const runBundleId = runBundleIdOf(sources, installations, stashDir);
|
|
152
|
+
// Candidates come from the write target alone, and only while it is writable.
|
|
153
|
+
const isCandidateBundle = (bundleId) => bundleId !== undefined && bundleId === runBundleId && writableBundleIds.has(bundleId);
|
|
138
154
|
const ready = {
|
|
139
155
|
status: "ready",
|
|
140
156
|
reason: readOnly ? "loaded a non-mutating point-in-time copy of the existing index" : "loaded the prepared index",
|
|
@@ -152,20 +168,34 @@ async function collectEligibleRefsFromIndex(scope, stashDir, improveProfile, rea
|
|
|
152
168
|
return empty(missing);
|
|
153
169
|
if (scope.mode === "ref" && scope.value) {
|
|
154
170
|
const entriesByItemRef = new Map(getAllEntries(db).map((entry) => [entry.itemRef, entry]));
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
171
|
+
let resolved;
|
|
172
|
+
try {
|
|
173
|
+
resolved = resolveRef(scope.value, {
|
|
174
|
+
defaultBundle: config.defaultBundle,
|
|
175
|
+
bundles: installations.map((installation) => ({
|
|
176
|
+
id: installation.id,
|
|
177
|
+
hasConcept: (conceptId) => entriesByItemRef.has(`${installation.id}//${conceptId}`),
|
|
178
|
+
})),
|
|
179
|
+
...(runBundleId ? { only: runBundleId } : {}),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (!(error instanceof NotFoundError) || !runBundleId)
|
|
184
|
+
throw error;
|
|
185
|
+
// Name the bundle that owns the ref; a typo or an unindexed ref keeps the default advice.
|
|
186
|
+
const { conceptId } = parseBundleRef(scope.value);
|
|
187
|
+
const owner = installations.find((installation) => installation.id !== runBundleId && entriesByItemRef.has(`${installation.id}//${conceptId}`))?.id;
|
|
188
|
+
if (!owner)
|
|
189
|
+
throw error;
|
|
190
|
+
throw new NotFoundError(error.message, error.code, `"${conceptId}" is in bundle "${owner}"; this run improves bundle "${runBundleId}" only. Run \`akm improve ${owner}//${conceptId}\` (or pass \`--bundle ${owner}\`).`);
|
|
191
|
+
}
|
|
162
192
|
const indexed = entriesByItemRef.get(`${resolved.bundle}//${resolved.conceptId}`);
|
|
163
193
|
if (!indexed?.bundleId || !indexed.conceptId || !fs.existsSync(indexed.filePath)) {
|
|
164
194
|
if (await findAssetFilePath(scope.value, stashDir))
|
|
165
195
|
return empty(ready);
|
|
166
196
|
throw new NotFoundError(`Asset not found in the selected writable source: ${scope.value}`, "ASSET_NOT_FOUND");
|
|
167
197
|
}
|
|
168
|
-
if (!
|
|
198
|
+
if (!isCandidateBundle(indexed.bundleId))
|
|
169
199
|
return empty(ready);
|
|
170
200
|
const isMemory = indexed.entry.type === "memory";
|
|
171
201
|
return {
|
|
@@ -180,7 +210,7 @@ async function collectEligibleRefsFromIndex(scope, stashDir, improveProfile, rea
|
|
|
180
210
|
indexSnapshot: ready,
|
|
181
211
|
};
|
|
182
212
|
}
|
|
183
|
-
const entries = getAllEntries(db, scope.mode === "type" ? scope.value : undefined).filter((indexed) =>
|
|
213
|
+
const entries = getAllEntries(db, scope.mode === "type" ? scope.value : undefined).filter((indexed) => isCandidateBundle(indexed.bundleId));
|
|
184
214
|
const planned = new Map();
|
|
185
215
|
const strategyFiltered = new Map();
|
|
186
216
|
let memoryEligible = 0;
|
|
@@ -19,7 +19,7 @@ import { collectEngineCredentialValues } from "../../integrations/agent/engine-r
|
|
|
19
19
|
import { probeLlmReachable } from "../../llm/client.js";
|
|
20
20
|
import { getOutputMode } from "../../output/context.js";
|
|
21
21
|
import { deliverRendered } from "../../output/html-render.js";
|
|
22
|
-
import { akmImprove, resolveImproveReadSource } from "./improve.js";
|
|
22
|
+
import { akmImprove, IMPROVE_TARGET_FLAG, resolveImproveReadSource } from "./improve.js";
|
|
23
23
|
import { runImproveReportQuery } from "./improve-report.js";
|
|
24
24
|
import { buildImproveRunId, recordImproveRunResult, recordTerminatedImproveRun, } from "./improve-result-file.js";
|
|
25
25
|
import { runImproveSession } from "./improve-session.js";
|
|
@@ -209,8 +209,11 @@ export const improveCommand = defineCommand({
|
|
|
209
209
|
description: "Alias for --dry-run (#947). Sets the exact same internal flag; use it when previewing resolved process -> engine -> model routing (plan.processes) rather than checking what would write.",
|
|
210
210
|
default: false,
|
|
211
211
|
},
|
|
212
|
-
bundle: {
|
|
213
|
-
|
|
212
|
+
bundle: {
|
|
213
|
+
type: "string",
|
|
214
|
+
description: "Bundle to improve and write proposals to (default: defaultWriteTarget, else the working bundle); only its assets are planned",
|
|
215
|
+
},
|
|
216
|
+
limit: { type: "string", description: "Maximum number of assets to process (highest salience first)" },
|
|
214
217
|
"timeout-ms": {
|
|
215
218
|
type: "string",
|
|
216
219
|
description: "Wall-clock budget for the entire run in milliseconds (default: 7200000 = 2 hours)",
|
|
@@ -227,7 +230,7 @@ export const improveCommand = defineCommand({
|
|
|
227
230
|
},
|
|
228
231
|
"skip-if-locked": {
|
|
229
232
|
type: "boolean",
|
|
230
|
-
description: "If another improve run already holds the lock, skip gracefully (exit 0) instead of failing with 'already running' (exit
|
|
233
|
+
description: "If another improve run already holds the lock, skip gracefully (exit 0) instead of failing with 'already running' (exit 75). Use for high-frequency scheduled runs so they don't pile up failures while a longer run is in progress.",
|
|
231
234
|
default: false,
|
|
232
235
|
},
|
|
233
236
|
"require-engines": {
|
|
@@ -286,8 +289,8 @@ export const improveCommand = defineCommand({
|
|
|
286
289
|
const writeTarget = dryRun
|
|
287
290
|
? undefined
|
|
288
291
|
: scopeRef
|
|
289
|
-
? resolveMutationTarget(effectiveConfig, scopeRef, targetArg).target
|
|
290
|
-
: resolveWriteTarget(effectiveConfig, targetArg);
|
|
292
|
+
? resolveMutationTarget(effectiveConfig, scopeRef, targetArg, { flag: IMPROVE_TARGET_FLAG }).target
|
|
293
|
+
: resolveWriteTarget(effectiveConfig, targetArg, { flag: IMPROVE_TARGET_FLAG });
|
|
291
294
|
// Every model-backed process resolves before any side effect; a dry run
|
|
292
295
|
// never dispatches, so it tolerates every process being disabled.
|
|
293
296
|
const resolvedPlan = resolveImprovePlan(strategyArg, effectiveConfig, { allowAllDisabled: Boolean(dryRun) });
|
|
@@ -19,7 +19,7 @@ import { redactSensitiveText } from "../../core/redaction.js";
|
|
|
19
19
|
import { openStateDatabase } from "../../core/state-db.js";
|
|
20
20
|
import { info, warn, warnVerbose } from "../../core/warn.js";
|
|
21
21
|
import { beginWriteProvenance, relativeWrittenPath } from "../../core/write-provenance.js";
|
|
22
|
-
import { resolveWritable, resolveWriteTarget } from "../../core/write-source.js";
|
|
22
|
+
import { resolveWorkingStashTarget, resolveWritable, resolveWriteTarget } from "../../core/write-source.js";
|
|
23
23
|
import { ensureIndex } from "../../indexer/ensure-index.js";
|
|
24
24
|
import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
|
|
25
25
|
import { akmIndex } from "../../indexer/indexer.js";
|
|
@@ -268,17 +268,24 @@ function describeRunWrittenPaths(setup, writtenPaths) {
|
|
|
268
268
|
}
|
|
269
269
|
return [...described].sort();
|
|
270
270
|
}
|
|
271
|
+
/** How `akm improve` spells its destination flag: the shared target resolvers name it in their errors. */
|
|
272
|
+
export const IMPROVE_TARGET_FLAG = "--bundle";
|
|
271
273
|
/**
|
|
272
274
|
* The source a dry run (or `--show-prompt`) inspects, without adapting it into
|
|
273
275
|
* a write target.
|
|
274
276
|
*/
|
|
275
277
|
export function resolveImproveReadSource(config, scopedRef, explicitTarget, fallbackStashDir) {
|
|
276
278
|
if (scopedRef?.origin && explicitTarget && scopedRef.origin !== explicitTarget) {
|
|
277
|
-
throw new UsageError(`Qualified ref bundle "${scopedRef.origin}" conflicts with
|
|
279
|
+
throw new UsageError(`Qualified ref bundle "${scopedRef.origin}" conflicts with ${IMPROVE_TARGET_FLAG} "${explicitTarget}".`, "INVALID_FLAG_VALUE", `Drop ${IMPROVE_TARGET_FLAG} or use ${IMPROVE_TARGET_FLAG} ${scopedRef.origin}.`);
|
|
278
280
|
}
|
|
279
281
|
const selector = scopedRef?.origin ?? explicitTarget ?? config.defaultWriteTarget;
|
|
280
282
|
if (!selector && fallbackStashDir)
|
|
281
283
|
return { source: { name: "stash", path: fallbackStashDir } };
|
|
284
|
+
if (!selector && process.env.AKM_BUNDLE_DIR?.trim()) {
|
|
285
|
+
// A live run's working bundle starts from AKM_BUNDLE_DIR (`resolveWorkingStashTarget`), so its preview does too.
|
|
286
|
+
const { source } = resolveWorkingStashTarget(config, { requireWritable: false });
|
|
287
|
+
return { source: { name: source.name, path: source.path } };
|
|
288
|
+
}
|
|
282
289
|
const configuredSelector = selector ?? config.defaultBundle;
|
|
283
290
|
if (configuredSelector) {
|
|
284
291
|
const entry = bundlesToSourceEntries(config)?.find((source) => source.name === configuredSelector);
|
|
@@ -339,10 +346,12 @@ function resolveImproveRunSetup(options) {
|
|
|
339
346
|
const writeTarget = options.dryRun
|
|
340
347
|
? undefined
|
|
341
348
|
: scopedRef?.origin
|
|
342
|
-
? resolveMutationTarget(config, scopedRef, options.writeTarget?.source.name ?? options.target
|
|
349
|
+
? resolveMutationTarget(config, scopedRef, options.writeTarget?.source.name ?? options.target, {
|
|
350
|
+
flag: IMPROVE_TARGET_FLAG,
|
|
351
|
+
}).target
|
|
343
352
|
: (options.writeTarget ??
|
|
344
353
|
(options.target || config.defaultWriteTarget || !options.stashDir
|
|
345
|
-
? resolveWriteTarget(config, options.target)
|
|
354
|
+
? resolveWriteTarget(config, options.target, { flag: IMPROVE_TARGET_FLAG })
|
|
346
355
|
: {
|
|
347
356
|
source: { kind: "filesystem", name: "stash", path: options.stashDir },
|
|
348
357
|
config: { type: "filesystem", name: "stash", path: options.stashDir, writable: true },
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import { getStateDbPath, withImmediateTransaction, withStateDb } from "../../core/state-db.js";
|
|
11
11
|
import { warn } from "../../core/warn.js";
|
|
12
|
-
import { isLedgerBlocked, listImproveLedgerRows, PAIR_PASS_LEDGER_SOURCE, recordImproveLedger, } from "../../storage/repositories/improve-ledger-repository.js";
|
|
12
|
+
import { isContentDrivenRow, isLedgerBlocked, listImproveLedgerRows, PAIR_PASS_LEDGER_SOURCE, recordImproveLedger, } from "../../storage/repositories/improve-ledger-repository.js";
|
|
13
13
|
import { openSqliteReadSnapshot } from "../../storage/sqlite-read-snapshot.js";
|
|
14
|
-
export { isLedgerBlocked, PAIR_PASS_LEDGER_SOURCE };
|
|
14
|
+
export { isContentDrivenRow, isLedgerBlocked, PAIR_PASS_LEDGER_SOURCE };
|
|
15
15
|
/** An improve candidate's durable state key: its index item_ref, else its conceptId. */
|
|
16
16
|
export function stateKey(ref, itemRef) {
|
|
17
17
|
return itemRef ?? ref;
|
|
@@ -136,6 +136,8 @@ async function runLoopReflectPass(planned, env, tally) {
|
|
|
136
136
|
if (reflectErrors.length > 0)
|
|
137
137
|
tally.reflectsWithErrorContext++;
|
|
138
138
|
const budgetMs = env.remainingBudgetMs();
|
|
139
|
+
// `planned` was selected from the run's write target alone (eligibility.ts), so the
|
|
140
|
+
// `target` below is the bundle `planned.itemRef` is read from and the proposal is filed in.
|
|
139
141
|
const reflectArgs = {
|
|
140
142
|
ref: planned.ref,
|
|
141
143
|
...(planned.itemRef ? { itemRef: planned.itemRef } : {}),
|
|
@@ -209,6 +209,8 @@ async function runConsolidationPass(args) {
|
|
|
209
209
|
maxChunkSize: processConfig?.maxChunkSize,
|
|
210
210
|
signal: args.budgetSignal,
|
|
211
211
|
p90ChunkSecondsDefault: processConfig?.p90ChunkSecondsDefault,
|
|
212
|
+
// Its read-only proposal lookups go through the run's own state.db handle.
|
|
213
|
+
...(eventsCtx?.db ? { proposalsCtx: { db: eventsCtx.db } } : {}),
|
|
212
214
|
}));
|
|
213
215
|
}
|
|
214
216
|
return { consolidation, plan: planned.plan };
|
|
@@ -791,7 +793,7 @@ function fetchRetrievalSignals(options, signalFiltered, noFeedbackCandidates, ev
|
|
|
791
793
|
// usage_events live in state.db, entries in index.db.
|
|
792
794
|
withRunState(eventsCtx, persist, (stateDb) => {
|
|
793
795
|
if (countUsageEventsByType(stateDb, "show") === 0) {
|
|
794
|
-
warn("Warning: show events not yet in usage_events —
|
|
796
|
+
warn("Warning: show events not yet in usage_events — the retrieval scope will match only search-retrieved assets.");
|
|
795
797
|
}
|
|
796
798
|
const refs = [...new Set([...signalFiltered, ...noFeedbackCandidates].map((r) => r.ref))];
|
|
797
799
|
out.retrievalCounts = getRetrievalCounts(indexDb, stateDb, refs, { sourceName: options.sourceName });
|
|
@@ -761,7 +761,7 @@ async function gatherReflectPromptSources(options, stash, parsedRef, assetConten
|
|
|
761
761
|
relatedLessons: options.ref && parsedRef
|
|
762
762
|
? await readRelatedLessons(stash, options.ref, parsedRef, options.itemRef, options.eventsCtx)
|
|
763
763
|
: [],
|
|
764
|
-
rejectedProposals: rejectedProposalContext(stash, options.ref, options.ctx),
|
|
764
|
+
rejectedProposals: rejectedProposalContext(stash, options.ref, options.ctx, options.eventsCtx),
|
|
765
765
|
standardsContext: resolveStandardsContext(options.ref, stash),
|
|
766
766
|
};
|
|
767
767
|
}
|
|
@@ -1036,7 +1036,7 @@ async function finalizeReflectProposal(args) {
|
|
|
1036
1036
|
payload: { content: payload.content, ...(Object.keys(frontmatter).length > 0 ? { frontmatter } : {}) },
|
|
1037
1037
|
...(typeof payload.confidence === "number" ? { confidence: payload.confidence } : {}),
|
|
1038
1038
|
...(options.eligibilitySource ? { eligibilitySource: options.eligibilitySource } : {}),
|
|
1039
|
-
...(options.itemRef ? { attemptedRefs: [options.itemRef] } : {}),
|
|
1039
|
+
...(options.itemRef ? { itemRef: options.itemRef, attemptedRefs: [options.itemRef] } : {}),
|
|
1040
1040
|
}, reviewReasons.length > 0
|
|
1041
1041
|
? {
|
|
1042
1042
|
review: {
|
|
@@ -100,12 +100,14 @@ export const MAX_REJECTED_PROPOSALS = 3;
|
|
|
100
100
|
/**
|
|
101
101
|
* Reflexion context: the newest reviewer rejections for `ref`. Procedural
|
|
102
102
|
* refusals (expiry, stale target, missing asset) are not judgements on the
|
|
103
|
-
* content and are left out. Reads never create state.db
|
|
103
|
+
* content and are left out. Reads never create state.db, and an improve run's
|
|
104
|
+
* live connection (`eventsCtx.db`) is read through, not copied.
|
|
104
105
|
*/
|
|
105
|
-
export function rejectedProposalContext(stash, ref, ctx) {
|
|
106
|
+
export function rejectedProposalContext(stash, ref, ctx, eventsCtx) {
|
|
106
107
|
if (!ref)
|
|
107
108
|
return [];
|
|
108
|
-
|
|
109
|
+
const proposalsCtx = eventsCtx?.db ? { ...ctx, db: eventsCtx.db } : ctx;
|
|
110
|
+
return listProposalsReadOnly(stash, { ref, status: "rejected", includeArchive: true }, proposalsCtx)
|
|
109
111
|
.filter((p) => !isProceduralRejection(p))
|
|
110
112
|
.sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime())
|
|
111
113
|
.slice(0, MAX_REJECTED_PROPOSALS)
|
|
@@ -156,18 +158,20 @@ export function buildJudgePrompt(lessonContent, sourceContent, similarLessons) {
|
|
|
156
158
|
"Score this lesson on each criterion from 1 (poor) to 5 (excellent):",
|
|
157
159
|
"1. NOVELTY: Does the lesson add information not already present in the source asset?",
|
|
158
160
|
"2. NON-REDUNDANCY: Is this lesson meaningfully different from what the source already says?",
|
|
161
|
+
"3. GROUNDING: Is the lesson about what the source asset is about? Score 1-2 only if it is about a different subject than the source; 3 if it is on the source's subject but goes beyond or corrects what the source says (it may draw on feedback you are not shown); 4-5 if the source supports it. A lesson may generalize the source's point.",
|
|
159
162
|
"",
|
|
160
163
|
"Source asset content:",
|
|
161
164
|
"```",
|
|
162
|
-
|
|
165
|
+
// The window distill generates from (buildDistillPrompt): grounding can reject, so the judge reads all of it.
|
|
166
|
+
sourceContent.slice(0, 3000),
|
|
163
167
|
"```",
|
|
164
168
|
];
|
|
165
169
|
if (similarLessons && similarLessons.length > 0) {
|
|
166
|
-
lines.push("", "Existing similar lessons (top-3 by similarity). Rate lower if the proposed lesson is substantially similar to any of these:");
|
|
170
|
+
lines.push("", "Existing similar lessons (top-3 by similarity). Rate NOVELTY and NON-REDUNDANCY lower if the proposed lesson is substantially similar to any of these:");
|
|
167
171
|
for (const sl of similarLessons)
|
|
168
172
|
lines.push(`\nExisting lesson ref: ${sl.ref}`, "```", sl.content.slice(0, 500), "```");
|
|
169
173
|
}
|
|
170
|
-
lines.push("", "Proposed lesson content:", "```", lessonContent.slice(0, 1000), "```", "", 'Return ONLY valid JSON, no prose: {"scores": {"novelty": <1-5 integer>, "nonRedundancy": <1-5 integer>}, "reason": "<one sentence>"}');
|
|
174
|
+
lines.push("", "Proposed lesson content:", "```", lessonContent.slice(0, 1000), "```", "", 'Return ONLY valid JSON, no prose: {"scores": {"novelty": <1-5 integer>, "nonRedundancy": <1-5 integer>, "grounding": <1-5 integer>}, "reason": "<one sentence>"}');
|
|
171
175
|
return lines.join("\n");
|
|
172
176
|
}
|
|
173
177
|
function boundedDocument(content, maxChars = 6000) {
|
|
@@ -227,12 +231,26 @@ export function buildReflectJudgePrompt(candidateContent, sourceContent, feedbac
|
|
|
227
231
|
'Return ONLY valid JSON, no prose: {"scores": {"feedbackAlignment": <1-5 integer>, "preservation": <1-5 integer>, "quality": <1-5 integer>}, "reason": "<one sentence>"}',
|
|
228
232
|
].join("\n");
|
|
229
233
|
}
|
|
230
|
-
|
|
234
|
+
/**
|
|
235
|
+
* `grounding` is scored with the other lesson criteria but left out of their
|
|
236
|
+
* mean: a lesson about a different subject than its source reads as novel and
|
|
237
|
+
* non-redundant, so the mean would pass it (or, in the review band, mint it as
|
|
238
|
+
* a pending proposal). A score of {@link UNGROUNDED_MAX_SCORE} or less is a
|
|
239
|
+
* rejection whatever the mean says (#999). Only a different subject scores that
|
|
240
|
+
* low. A lesson that goes beyond or corrects its source is on its subject:
|
|
241
|
+
* distill folds feedback into the lesson, and the judge is never shown it. A
|
|
242
|
+
* contradiction of the source is the optional fidelity check's to send to a
|
|
243
|
+
* human (`judgeAndQueue` in distill.ts), so the rubric must not pre-empt it.
|
|
244
|
+
*/
|
|
245
|
+
const GROUNDING_CRITERION = "grounding";
|
|
246
|
+
const UNGROUNDED_MAX_SCORE = 2;
|
|
247
|
+
const LESSON_JUDGE_CRITERIA = ["novelty", "nonRedundancy", GROUNDING_CRITERION];
|
|
231
248
|
const REFLECT_JUDGE_CRITERIA = ["feedbackAlignment", "preservation", "quality"];
|
|
232
249
|
/**
|
|
233
|
-
* Read a judge response: the per-criterion shape (averaged here
|
|
234
|
-
* `{"score"}` shape. Only the expected criteria are read;
|
|
235
|
-
* out-of-range (1..5) value is a parse failure, extra keys are
|
|
250
|
+
* Read a judge response: the per-criterion shape (averaged here, `grounding`
|
|
251
|
+
* aside) or the older `{"score"}` shape. Only the expected criteria are read;
|
|
252
|
+
* any missing or out-of-range (1..5) value is a parse failure, extra keys are
|
|
253
|
+
* ignored.
|
|
236
254
|
*/
|
|
237
255
|
function parseJudgeResponse(raw, keys) {
|
|
238
256
|
const parsed = parseEmbeddedJsonResponse(raw);
|
|
@@ -251,7 +269,10 @@ function parseJudgeResponse(raw, keys) {
|
|
|
251
269
|
return undefined;
|
|
252
270
|
criteria[key] = value;
|
|
253
271
|
}
|
|
254
|
-
|
|
272
|
+
const averaged = Object.entries(criteria)
|
|
273
|
+
.filter(([key]) => key !== GROUNDING_CRITERION)
|
|
274
|
+
.map(([, value]) => value);
|
|
275
|
+
return { score: averaged.reduce((a, b) => a + b, 0) / averaged.length, reason, criteria };
|
|
255
276
|
}
|
|
256
277
|
return inRange(parsed.score) ? { score: parsed.score, reason } : undefined;
|
|
257
278
|
}
|
|
@@ -274,7 +295,8 @@ function judgeResponseSchema(keys) {
|
|
|
274
295
|
/**
|
|
275
296
|
* The quality judge. Fails closed: no runner, an unparseable verdict or a
|
|
276
297
|
* provider failure never passes content. Bands: >= 3.5 pass, 2.5-3.5 review,
|
|
277
|
-
* < 2.5 reject
|
|
298
|
+
* < 2.5 reject; a `grounding` score of {@link UNGROUNDED_MAX_SCORE} or less
|
|
299
|
+
* rejects whatever the mean is. Temperature is pinned to 0 so verdicts do not flip.
|
|
278
300
|
*/
|
|
279
301
|
async function runQualityJudge(feature, config, prompt, keys, chat, options) {
|
|
280
302
|
const resolved = !options.runnerSelectionFrozen && !options.llmRunner
|
|
@@ -307,6 +329,15 @@ async function runQualityJudge(feature, config, prompt, keys, chat, options) {
|
|
|
307
329
|
if (!parsed)
|
|
308
330
|
return { pass: false, score: -1, reason: "judge parse failed — routed to review", reviewNeeded: true };
|
|
309
331
|
const { score, reason, criteria } = parsed;
|
|
332
|
+
const grounding = criteria?.[GROUNDING_CRITERION];
|
|
333
|
+
if (criteria && grounding !== undefined && grounding <= UNGROUNDED_MAX_SCORE) {
|
|
334
|
+
return {
|
|
335
|
+
pass: false,
|
|
336
|
+
score,
|
|
337
|
+
reason: `Off-subject for its source (grounding ${grounding}/5): ${reason}`,
|
|
338
|
+
criteria,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
310
341
|
const verdict = score >= 3.5 ? { pass: true } : score >= 2.5 ? { pass: false, reviewNeeded: true } : { pass: false };
|
|
311
342
|
return { ...verdict, score, reason, ...(criteria ? { criteria } : {}) };
|
|
312
343
|
}
|
|
@@ -48,3 +48,24 @@ export function formatNewAssetDiff(ref, content) {
|
|
|
48
48
|
}
|
|
49
49
|
return lines.join("\n");
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Render the all-removals diff for a retire proposal (#997): the file its
|
|
53
|
+
* accept archives, line by line, and nothing added. Padding the proposed side
|
|
54
|
+
* with an empty string, as {@link formatUnifiedDiff} does, reads as "the whole
|
|
55
|
+
* file replaced by one blank line" — a retirement reviewed as data loss.
|
|
56
|
+
* `existing` is `null` when the retired file is already gone.
|
|
57
|
+
*/
|
|
58
|
+
export function formatRetireDiff(ref, existing, successorRef) {
|
|
59
|
+
const destination = `+++ /dev/null (retired: archived${successorRef ? `; successor ${successorRef}` : ""})`;
|
|
60
|
+
if (existing === null)
|
|
61
|
+
return [`--- ${ref} (missing)`, destination].join("\n");
|
|
62
|
+
const removed = existing.split("\n");
|
|
63
|
+
if (removed[removed.length - 1] === "")
|
|
64
|
+
removed.pop(); // the newline ending the file is not a line of its own
|
|
65
|
+
return [
|
|
66
|
+
`--- ${ref} (existing)`,
|
|
67
|
+
destination,
|
|
68
|
+
`@@ 1,${removed.length} 0,0 @@`,
|
|
69
|
+
...removed.map((line) => `-${line}`),
|
|
70
|
+
].join("\n");
|
|
71
|
+
}
|