akm-cli 0.9.26 → 0.9.27-alpha.2
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 +237 -0
- package/LICENSE +3 -4
- package/dist/assets/prompts/consolidate-system.md +2 -2
- package/dist/assets/prompts/distill-lesson-system.md +29 -7
- package/dist/assets/prompts/extract-session.md +2 -2
- package/dist/commands/improve/consolidate/coverage.js +71 -17
- package/dist/commands/improve/consolidate/pair-pass.js +12 -9
- package/dist/commands/improve/consolidate.js +77 -5
- package/dist/commands/improve/distill-guards.js +9 -10
- package/dist/commands/improve/distill.js +70 -22
- package/dist/commands/improve/extract-prompt.js +61 -39
- package/dist/commands/improve/extract.js +2 -1
- package/dist/commands/improve/preparation.js +14 -1
- package/dist/commands/improve/reflect.js +36 -4
- package/dist/commands/improve/retrieval-gate.js +1 -1
- package/dist/commands/improve/session-asset.js +3 -2
- package/dist/commands/improve/stage.js +35 -53
- package/dist/commands/proposal/drain.js +85 -21
- package/dist/commands/proposal/proposal-types.js +1 -1
- package/dist/core/config/schema/improve-processes.js +3 -3
- package/dist/core/paths.js +0 -9
- package/dist/indexer/indexer.js +35 -19
- package/dist/integrations/harnesses/codex/agent-builder.js +23 -15
- package/dist/llm/client.js +44 -14
- package/dist/llm/memory-infer.js +1 -1
- package/dist/scripts/akm-migrate-node.js +25 -20
- package/dist/scripts/akm-migrate.js +25 -20
- package/dist/storage/repositories/improve-ledger-repository.js +4 -1
- package/dist/storage/repositories/index-entry-schema.js +20 -4
- package/dist/storage/repositories/index-fts-repository.js +44 -3
- package/dist/storage/repositories/index-schema.js +14 -8
- package/docs/README.md +1 -2
- package/docs/integration/bundling-akm.md +1 -1
- package/docs/migration/v0.8-to-v0.9.md +3 -1
- package/docs/reference/README.md +1 -1
- package/docs/reference/cli.md +8 -4
- package/docs/reference/configuration.md +5 -2
- package/docs/reference/data-and-telemetry.md +1 -1
- package/package.json +1 -1
|
@@ -93,7 +93,9 @@ export function isHotCapturedMemory(filePath) {
|
|
|
93
93
|
/**
|
|
94
94
|
* Structured-output schema for a plan. Promote-only: merge/delete/contradict
|
|
95
95
|
* were removed in 0.9.17-alpha.1 (`e82eec811`) after running in production —
|
|
96
|
-
* they cost thousands of completion tokens.
|
|
96
|
+
* they cost thousands of completion tokens. Every property is required, as a
|
|
97
|
+
* strict structured-output provider needs: an empty `description` keeps the
|
|
98
|
+
* memory's own, a null `confidence` is none.
|
|
97
99
|
*/
|
|
98
100
|
export const CONSOLIDATE_PLAN_JSON_SCHEMA = {
|
|
99
101
|
type: "object",
|
|
@@ -105,15 +107,23 @@ export const CONSOLIDATE_PLAN_JSON_SCHEMA = {
|
|
|
105
107
|
description: "Ordered list of promote operations the planner proposes.",
|
|
106
108
|
items: {
|
|
107
109
|
type: "object",
|
|
108
|
-
required: ["op", "ref", "knowledgeRef", "reason"],
|
|
110
|
+
required: ["op", "ref", "knowledgeRef", "reason", "description", "confidence"],
|
|
109
111
|
additionalProperties: false,
|
|
110
112
|
properties: {
|
|
111
113
|
op: { type: "string", enum: ["promote"] },
|
|
112
114
|
ref: { type: "string", minLength: 1 },
|
|
113
115
|
knowledgeRef: { type: "string", minLength: 1 },
|
|
114
116
|
reason: { type: "string", minLength: 1, maxLength: 200 },
|
|
115
|
-
description: {
|
|
116
|
-
|
|
117
|
+
description: {
|
|
118
|
+
type: "string",
|
|
119
|
+
description: "One sentence describing the new knowledge asset; an empty string keeps the memory's own.",
|
|
120
|
+
},
|
|
121
|
+
confidence: {
|
|
122
|
+
type: ["number", "null"],
|
|
123
|
+
minimum: 0,
|
|
124
|
+
maximum: 1,
|
|
125
|
+
description: "Certainty in [0, 1] that the operation is correct and safe; null when unsure.",
|
|
126
|
+
},
|
|
117
127
|
},
|
|
118
128
|
},
|
|
119
129
|
},
|
|
@@ -250,6 +260,39 @@ function loadPendingConsolidateProposalHashes(stashDir, proposalsCtx) {
|
|
|
250
260
|
}
|
|
251
261
|
return hashes;
|
|
252
262
|
}
|
|
263
|
+
/**
|
|
264
|
+
* Rejections decided before this are not a verdict on the memory's text: the
|
|
265
|
+
* 2026-08-02 and 2026-08-18 bulk audits rejected hundreds of promotions
|
|
266
|
+
* wholesale, and holding their bodies would skip good memories for good.
|
|
267
|
+
*/
|
|
268
|
+
const REJECTED_BODY_HOLD_FROM = "2026-09-29";
|
|
269
|
+
/**
|
|
270
|
+
* Body hashes of the memories whose promotion was rejected on review. A
|
|
271
|
+
* proposal minted with `promotionSourceHash` names the memory's raw body; an
|
|
272
|
+
* older one is hashed from its own body, which is the memory's unless
|
|
273
|
+
* sanitization changed it.
|
|
274
|
+
*/
|
|
275
|
+
function loadRejectedPromotionBodyHashes(stashDir, proposalsCtx) {
|
|
276
|
+
const hashes = new Set();
|
|
277
|
+
try {
|
|
278
|
+
for (const p of listProposalsReadOnly(stashDir, { status: "rejected", includeArchive: true }, proposalsCtx)) {
|
|
279
|
+
if (p.source !== "consolidate")
|
|
280
|
+
continue;
|
|
281
|
+
if ((p.review?.decidedAt ?? p.updatedAt) < REJECTED_BODY_HOLD_FROM)
|
|
282
|
+
continue;
|
|
283
|
+
try {
|
|
284
|
+
hashes.add(p.promotionSourceHash ?? contentHash(proposalContent(p), "body"));
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// A malformed payload cannot hold a memory.
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// Best-effort: a failed read never blocks judging.
|
|
293
|
+
}
|
|
294
|
+
return hashes;
|
|
295
|
+
}
|
|
253
296
|
/**
|
|
254
297
|
* Body hashes of the live knowledge assets, read from disk (the index may lag
|
|
255
298
|
* a just-written asset), so an accepted promotion is not proposed again.
|
|
@@ -459,6 +502,18 @@ export function inspectConsolidationPool(opts, stashDir, warnings, existingKnowl
|
|
|
459
502
|
return !isLedgerBlocked(row, nowIso, changedAt);
|
|
460
503
|
});
|
|
461
504
|
}
|
|
505
|
+
// A memory whose text a reviewer already rejected as a promotion waits for an edit, whatever it is called.
|
|
506
|
+
const rejectedBodies = loadRejectedPromotionBodyHashes(stashDir, opts.proposalsCtx);
|
|
507
|
+
if (rejectedBodies.size > 0) {
|
|
508
|
+
memories = memories.filter((memory) => {
|
|
509
|
+
try {
|
|
510
|
+
return !rejectedBodies.has(contentHash(fs.readFileSync(memory.filePath, "utf8"), "body"));
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
}
|
|
462
517
|
const judgedUnchanged = poolSize - memories.length;
|
|
463
518
|
// Only what retrieval returned or new material improve never processed (#986).
|
|
464
519
|
const retrievalScope = loadRetrievalScope({ proposalsCtx: opts.proposalsCtx, readOnly }, stashDir);
|
|
@@ -758,7 +813,13 @@ async function consolidate(opts, config, stashDir, startMs, stateDb) {
|
|
|
758
813
|
recordLedgerAttempt({ proposalsCtx: opts.proposalsCtx }, [...acc.judgedRefs]
|
|
759
814
|
.filter((ref) => !ctx.promotedSourceRefs.has(ref) &&
|
|
760
815
|
!acc.skipReasonByRef.get(ref)?.skips.some((skip) => skip.reason === "promote_create_failed"))
|
|
761
|
-
.map((ref) => ({
|
|
816
|
+
.map((ref) => ({
|
|
817
|
+
stashDir,
|
|
818
|
+
ref,
|
|
819
|
+
source: "consolidate",
|
|
820
|
+
outcome: "judged_no_action",
|
|
821
|
+
...bodyHashOf(ctx.memoryByRef.get(ref)),
|
|
822
|
+
})));
|
|
762
823
|
return makeConsolidateResult({
|
|
763
824
|
...summary(),
|
|
764
825
|
promoted: ctx.promoted,
|
|
@@ -773,6 +834,17 @@ async function consolidate(opts, config, stashDir, startMs, stateDb) {
|
|
|
773
834
|
},
|
|
774
835
|
});
|
|
775
836
|
}
|
|
837
|
+
/** The memory's current body hash as a ledger input field; empty when it cannot be read (the row then keeps its 7-day window). */
|
|
838
|
+
function bodyHashOf(memory) {
|
|
839
|
+
if (!memory)
|
|
840
|
+
return {};
|
|
841
|
+
try {
|
|
842
|
+
return { contentHash: contentHash(fs.readFileSync(memory.filePath, "utf8"), "body") };
|
|
843
|
+
}
|
|
844
|
+
catch {
|
|
845
|
+
return {};
|
|
846
|
+
}
|
|
847
|
+
}
|
|
776
848
|
/** The conceptId a ref maps to, or undefined for an invalid ref. */
|
|
777
849
|
function conceptIdForRef(ref) {
|
|
778
850
|
try {
|
|
@@ -2,25 +2,24 @@
|
|
|
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
|
/**
|
|
5
|
-
* Distill guards: related lessons
|
|
6
|
-
* overwrite
|
|
7
|
-
* proposal does not contradict the memories it came from.
|
|
5
|
+
* Distill guards: the related lessons, knowledge notes and skills shown to the
|
|
6
|
+
* writer so it does not repeat or overwrite them (CLS context), and a cheap check
|
|
7
|
+
* that a proposal does not contradict the memories it came from.
|
|
8
8
|
*/
|
|
9
9
|
export const DEFAULT_CLS_ADJACENT_COUNT = 3;
|
|
10
|
-
/** The CLS prompt section (each entry capped at
|
|
10
|
+
/** The CLS prompt section (each entry capped at 600 chars); empty when disabled (on unless `enabled: false`) or nothing is related. */
|
|
11
11
|
export function buildClsContext(adjacentItems, config) {
|
|
12
|
-
if (
|
|
12
|
+
if (config.enabled === false || adjacentItems.length === 0)
|
|
13
13
|
return "";
|
|
14
14
|
const lines = [
|
|
15
15
|
"",
|
|
16
|
-
"##
|
|
17
|
-
"The
|
|
18
|
-
"
|
|
19
|
-
"disagree with one, flag it as contradicted (do not ignore it).",
|
|
16
|
+
"## Related assets already in the library",
|
|
17
|
+
"The library already holds these lessons, knowledge notes and skills near this memory. They may be about another subject.",
|
|
18
|
+
"If one of them already states the rule the memory would give, answer NONE. Do not contradict or overwrite them.",
|
|
20
19
|
"",
|
|
21
20
|
];
|
|
22
21
|
for (const item of adjacentItems)
|
|
23
|
-
lines.push(`### ${item.ref}`, item.content.trim().slice(0,
|
|
22
|
+
lines.push(`### ${item.ref}`, item.content.trim().slice(0, 600), "");
|
|
24
23
|
return lines.join("\n");
|
|
25
24
|
}
|
|
26
25
|
/**
|
|
@@ -72,36 +72,46 @@ export function deriveLessonRef(inputRef) {
|
|
|
72
72
|
return `lessons/${safeScope ? `${safeScope}/` : ""}${clean(`${parsed.type}-${parts.join("-")}`)}-lesson`;
|
|
73
73
|
}
|
|
74
74
|
// ── Output contract ──────────────────────────────────────────────────────────
|
|
75
|
+
//
|
|
76
|
+
// The client sends a response schema `strict: true`, and a strict provider
|
|
77
|
+
// (OpenAI's) rejects an object whose `required` leaves out any of its
|
|
78
|
+
// properties, so every property is required and "none" is an empty array (#1046).
|
|
75
79
|
export const DISTILL_LESSON_JSON_SCHEMA = {
|
|
76
80
|
type: "object",
|
|
77
|
-
required: ["description", "when_to_use", "body"],
|
|
81
|
+
required: ["reason", "decision", "description", "when_to_use", "body", "tags"],
|
|
78
82
|
additionalProperties: false,
|
|
79
83
|
properties: {
|
|
84
|
+
reason: {
|
|
85
|
+
type: "string",
|
|
86
|
+
description: "One sentence, written first: the cause and the fix the memory states, or why it states none.",
|
|
87
|
+
},
|
|
88
|
+
decision: {
|
|
89
|
+
type: "string",
|
|
90
|
+
enum: ["lesson", "none"],
|
|
91
|
+
description: "`none` when the memory holds no lesson (it records what was done, a design, or steps already written elsewhere): leave the other fields empty. Otherwise `lesson`.",
|
|
92
|
+
},
|
|
80
93
|
description: {
|
|
81
94
|
type: "string",
|
|
82
|
-
|
|
83
|
-
description: "Single complete sentence (80-200 chars) summarising what the lesson teaches. No markdown, no leading 'When'/'If'.",
|
|
95
|
+
description: "Single complete sentence summarising what the lesson teaches. No markdown, no leading 'When'/'If'. Empty for `none`.",
|
|
84
96
|
},
|
|
85
97
|
when_to_use: {
|
|
86
98
|
type: "string",
|
|
87
|
-
|
|
88
|
-
description: "Single complete sentence describing the concrete trigger condition for the lesson.",
|
|
99
|
+
description: "Single complete sentence describing the concrete trigger condition for the lesson. Empty for `none`.",
|
|
89
100
|
},
|
|
90
101
|
body: {
|
|
91
102
|
type: "string",
|
|
92
|
-
|
|
93
|
-
description: "Lesson body — plain markdown, 1-3 short paragraphs of practical guidance.",
|
|
103
|
+
description: "Lesson body: plain markdown, shorter than the memory, stating only what it and its feedback say. Empty for `none`.",
|
|
94
104
|
},
|
|
95
105
|
tags: {
|
|
96
106
|
type: "array",
|
|
97
107
|
items: { type: "string" },
|
|
98
|
-
description: "
|
|
108
|
+
description: "Tag list. Use an empty array for none; the post-processor drops it if empty.",
|
|
99
109
|
},
|
|
100
110
|
},
|
|
101
111
|
};
|
|
102
112
|
export const DISTILL_KNOWLEDGE_JSON_SCHEMA = {
|
|
103
113
|
type: "object",
|
|
104
|
-
required: ["description", "body"],
|
|
114
|
+
required: ["description", "body", "tags", "sources"],
|
|
105
115
|
additionalProperties: false,
|
|
106
116
|
properties: {
|
|
107
117
|
description: { type: "string", minLength: 1, description: "One-line summary of the knowledge asset." },
|
|
@@ -113,12 +123,12 @@ export const DISTILL_KNOWLEDGE_JSON_SCHEMA = {
|
|
|
113
123
|
tags: {
|
|
114
124
|
type: "array",
|
|
115
125
|
items: { type: "string" },
|
|
116
|
-
description: "
|
|
126
|
+
description: "Tag list. Use an empty array for none; the post-processor drops it if empty.",
|
|
117
127
|
},
|
|
118
128
|
sources: {
|
|
119
129
|
type: "array",
|
|
120
130
|
items: { type: "string" },
|
|
121
|
-
description: "
|
|
131
|
+
description: "Source refs the knowledge was distilled from. Use an empty array for none.",
|
|
122
132
|
},
|
|
123
133
|
},
|
|
124
134
|
};
|
|
@@ -150,6 +160,19 @@ export function assembleStructuredDistillMarkdown(payload, kind) {
|
|
|
150
160
|
fm.xrefs = sources;
|
|
151
161
|
return assembleAssetFromString(serializeFrontmatterQuoted(fm), body);
|
|
152
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* The writer's answer when it found no lesson: the word NONE, or `decision: "none"` in a reply bound to the schema,
|
|
165
|
+
* with the reason it gave (`""` for the bare word). `null` for any other reply.
|
|
166
|
+
*/
|
|
167
|
+
function answeredNone(raw) {
|
|
168
|
+
if (/^[\s"'`*_]*none[\s.!"'`*_]*$/i.test(stripMarkdownFences(raw)))
|
|
169
|
+
return { reason: "" };
|
|
170
|
+
const payload = parseEmbeddedJsonResponse(raw);
|
|
171
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload) || payload.decision !== "none") {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
return { reason: typeof payload.reason === "string" ? payload.reason.trim() : "" };
|
|
175
|
+
}
|
|
153
176
|
function validateKnowledgeContent(content, inputRef) {
|
|
154
177
|
const findings = [];
|
|
155
178
|
const parsed = parseFrontmatter(content);
|
|
@@ -247,7 +270,7 @@ export function buildDistillPrompt(input) {
|
|
|
247
270
|
}
|
|
248
271
|
lines.push(input.proposalKind === "knowledge"
|
|
249
272
|
? "Produce the knowledge markdown file now. Start your response with `---` on the first line, followed by a `description:` field whose value is a 1-sentence summary (20–400 chars). Never use placeholder values like `---`, `tbd`, `n/a`, or a single dash. If the source has nothing meaningful to summarize, do NOT produce a proposal — return an empty response instead. The frontmatter block ends with a second `---` line; do not emit any additional `---` fences in the body."
|
|
250
|
-
: "Produce the lesson markdown file now. Start your response with `---` on the first line, followed by `description:` and `when_to_use:` fields. Both must be real one-sentence summaries (20–400 chars) — never placeholder values like `---`, `tbd`, or `n/a`. The frontmatter block ends with a second `---` line; do not emit any additional `---` fences in the body.");
|
|
273
|
+
: "Produce the lesson markdown file now. Start your response with `---` on the first line, followed by `description:` and `when_to_use:` fields. Both must be real one-sentence summaries (20–400 chars) — never placeholder values like `---`, `tbd`, or `n/a`. The frontmatter block ends with a second `---` line; do not emit any additional `---` fences in the body. If the memory holds no lesson, answer NONE instead.");
|
|
251
274
|
return lines.join("\n");
|
|
252
275
|
}
|
|
253
276
|
// ── Invocation ───────────────────────────────────────────────────────────────
|
|
@@ -339,7 +362,7 @@ export async function akmDistill(options) {
|
|
|
339
362
|
asset,
|
|
340
363
|
vocabulary: loadRefVocabulary(),
|
|
341
364
|
outcomeWeightEnabled: config.improve?.salience?.outcomeWeightEnabled !== false,
|
|
342
|
-
|
|
365
|
+
related: options.fetchRelatedFn ?? fetchRelatedAssets,
|
|
343
366
|
lookup,
|
|
344
367
|
};
|
|
345
368
|
const feedbackEvents = readDistillFeedback(run);
|
|
@@ -377,6 +400,8 @@ async function distill(run, targetKind, kind, outputRef, feedbackEvents) {
|
|
|
377
400
|
system,
|
|
378
401
|
prompt,
|
|
379
402
|
gate: { config: run.config, enabled: true },
|
|
403
|
+
// NONE is an answer: a parser that rejected it would ask for a lesson again.
|
|
404
|
+
parse: (raw) => (answeredNone(raw) ? raw : parseEmbeddedJsonResponse(raw)),
|
|
380
405
|
// The injected test transport never sees the schema.
|
|
381
406
|
request: {
|
|
382
407
|
...(run.options.chat === undefined
|
|
@@ -409,6 +434,10 @@ async function distill(run, targetKind, kind, outputRef, feedbackEvents) {
|
|
|
409
434
|
...exclusionMeta(run, true),
|
|
410
435
|
};
|
|
411
436
|
}
|
|
437
|
+
const none = answeredNone(call.raw);
|
|
438
|
+
if (none) {
|
|
439
|
+
return skipDistill(run, outputRef, kind, "nothing_reusable", `The writer found no lesson in ${run.inputRef}${none.reason ? `: ${none.reason}` : "."}`);
|
|
440
|
+
}
|
|
412
441
|
const assembled = assembleDistilledContent(run, call.raw, kind, outputRef);
|
|
413
442
|
if ("rejection" in assembled)
|
|
414
443
|
return assembled.rejection;
|
|
@@ -418,8 +447,20 @@ async function distill(run, targetKind, kind, outputRef, feedbackEvents) {
|
|
|
418
447
|
content: assembled.content,
|
|
419
448
|
source: run.asset.content,
|
|
420
449
|
descriptionSwapped: assembled.descriptionSwapped,
|
|
450
|
+
feedback: feedbackLines(feedback),
|
|
421
451
|
});
|
|
422
452
|
}
|
|
453
|
+
/** The feedback that says something, one line each, for the judge. A bare signal says nothing the writer could use. */
|
|
454
|
+
function feedbackLines(feedback) {
|
|
455
|
+
const lines = [];
|
|
456
|
+
for (const event of feedback) {
|
|
457
|
+
const meta = event.metadata ?? {};
|
|
458
|
+
const detail = (typeof meta.reason === "string" ? meta.reason : "") || (typeof meta.note === "string" ? meta.note : "");
|
|
459
|
+
if (detail.trim())
|
|
460
|
+
lines.push(`- [${typeof meta.signal === "string" ? meta.signal : event.eventType}] ${detail.trim()}`);
|
|
461
|
+
}
|
|
462
|
+
return lines;
|
|
463
|
+
}
|
|
423
464
|
/** Whether a file already holds the lesson `ref` in the stash the proposal would be filed in. */
|
|
424
465
|
function lessonExists(run, ref) {
|
|
425
466
|
const { type, name } = parseRefInput(ref);
|
|
@@ -485,11 +526,12 @@ async function judgeAndQueue(run, out) {
|
|
|
485
526
|
let confidence;
|
|
486
527
|
let judged;
|
|
487
528
|
if (qualityGateEnabled(run)) {
|
|
488
|
-
const
|
|
529
|
+
const related = await run.related(content.slice(0, 500), RELATED_COUNT);
|
|
489
530
|
// The judge reads what the generator read: the source body, without its frontmatter (buildDistillPrompt).
|
|
490
531
|
const source = out.source ? parseFrontmatter(out.source).content.trim() : "";
|
|
491
532
|
const verdict = await runLessonQualityJudge(run.config, content, source, run.options.chat, {
|
|
492
|
-
...(
|
|
533
|
+
...(related.length > 0 ? { related } : {}),
|
|
534
|
+
...(out.feedback && out.feedback.length > 0 ? { feedback: out.feedback } : {}),
|
|
493
535
|
...((run.judgeRunner ?? run.runner) ? { llmRunner: run.judgeRunner ?? run.runner } : {}),
|
|
494
536
|
...(run.options.signal ? { signal: run.options.signal } : {}),
|
|
495
537
|
onNotices: run.notices.add,
|
|
@@ -869,13 +911,13 @@ function readDistillFeedback(run) {
|
|
|
869
911
|
/** System + user prompt: rejected-proposal context, optional CLS neighbours, stash standards. */
|
|
870
912
|
async function buildDistillMessages(run, feedback, kind, outputRef) {
|
|
871
913
|
const rejectedProposals = rejectedProposalContext(run.stash, run.inputRef, run.options.ctx, run.options.eventsCtx);
|
|
872
|
-
// CLS interleaving (default
|
|
914
|
+
// CLS interleaving (default on): show the related lessons, knowledge notes and skills, so the writer neither repeats nor overwrites them.
|
|
873
915
|
const cls = getImproveProcessConfig("distill", run.profile)?.cls ?? {};
|
|
874
916
|
let clsContext = "";
|
|
875
|
-
if (cls.enabled) {
|
|
917
|
+
if (cls.enabled !== false) {
|
|
876
918
|
try {
|
|
877
919
|
const query = run.asset.content ? run.asset.content.slice(0, 500) : run.inputRef;
|
|
878
|
-
clsContext = buildClsContext(await run.
|
|
920
|
+
clsContext = buildClsContext(await run.related(query, cls.adjacentCount ?? DEFAULT_CLS_ADJACENT_COUNT), cls);
|
|
879
921
|
}
|
|
880
922
|
catch {
|
|
881
923
|
// CLS context is supplemental.
|
|
@@ -904,12 +946,18 @@ async function defaultLookup(ref, stashDir) {
|
|
|
904
946
|
honorOrigin: false,
|
|
905
947
|
});
|
|
906
948
|
}
|
|
907
|
-
/**
|
|
908
|
-
|
|
949
|
+
/** What the library already holds on a subject: lessons and knowledge notes say it, a skill is how to do it. */
|
|
950
|
+
const RELATED_TYPES = ["lesson", "knowledge", "skill"];
|
|
951
|
+
const RELATED_COUNT = 3;
|
|
952
|
+
/** The top-N lessons, knowledge notes and skills related to `query`, best first (empty when search is unavailable). */
|
|
953
|
+
async function fetchRelatedAssets(query, n) {
|
|
909
954
|
try {
|
|
910
|
-
|
|
911
|
-
|
|
955
|
+
// One search per type: memories outnumber the rest and would fill an untyped list.
|
|
956
|
+
const results = await Promise.all(RELATED_TYPES.map((type) => akmSearch({ query, type, limit: n, skipLogging: true, eventSource: "improve" })));
|
|
957
|
+
return results
|
|
958
|
+
.flatMap((result) => result?.hits ?? [])
|
|
912
959
|
.filter((h) => "path" in h && typeof h.path === "string")
|
|
960
|
+
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
|
913
961
|
.slice(0, n)
|
|
914
962
|
.map((h) => {
|
|
915
963
|
let content = "";
|
|
@@ -24,16 +24,21 @@ const EXTRACT_CANDIDATE_NAME_RE = new RegExp(EXTRACT_CANDIDATE_NAME_PATTERN);
|
|
|
24
24
|
*
|
|
25
25
|
* Shape:
|
|
26
26
|
* {
|
|
27
|
-
* "candidates": [{type, name, description, when_to_use
|
|
28
|
-
* "rationale_if_empty"
|
|
27
|
+
* "candidates": [{type, name, description, when_to_use, body, confidence, evidence}, ...],
|
|
28
|
+
* "rationale_if_empty": string
|
|
29
29
|
* }
|
|
30
30
|
*
|
|
31
31
|
* `additionalProperties: false` at each level so any hallucinated keys are
|
|
32
|
-
* dropped before parsing.
|
|
32
|
+
* dropped before parsing. Every property is required, as a strict structured-
|
|
33
|
+
* output provider needs (#1046), and an empty string stands for "none": a
|
|
34
|
+
* `when_to_use` only a lesson needs, a `rationale_if_empty` only an empty
|
|
35
|
+
* answer needs. Before, `when_to_use` was optional, so a model that followed
|
|
36
|
+
* the schema could leave it out of a lesson and the parser dropped the lesson
|
|
37
|
+
* (#1047).
|
|
33
38
|
*/
|
|
34
39
|
export const EXTRACT_JSON_SCHEMA = {
|
|
35
40
|
type: "object",
|
|
36
|
-
required: ["candidates"],
|
|
41
|
+
required: ["candidates", "rationale_if_empty"],
|
|
37
42
|
additionalProperties: false,
|
|
38
43
|
properties: {
|
|
39
44
|
candidates: {
|
|
@@ -41,7 +46,7 @@ export const EXTRACT_JSON_SCHEMA = {
|
|
|
41
46
|
description: "Zero or more durable-insight candidates extracted from the session.",
|
|
42
47
|
items: {
|
|
43
48
|
type: "object",
|
|
44
|
-
required: ["type", "name", "description", "body", "confidence", "evidence"],
|
|
49
|
+
required: ["type", "name", "description", "when_to_use", "body", "confidence", "evidence"],
|
|
45
50
|
additionalProperties: false,
|
|
46
51
|
properties: {
|
|
47
52
|
type: {
|
|
@@ -62,9 +67,8 @@ export const EXTRACT_JSON_SCHEMA = {
|
|
|
62
67
|
},
|
|
63
68
|
when_to_use: {
|
|
64
69
|
type: "string",
|
|
65
|
-
minLength: 15,
|
|
66
70
|
maxLength: 400,
|
|
67
|
-
description: "Trigger sentence
|
|
71
|
+
description: "Trigger sentence of at least 15 characters; REQUIRED when type=lesson (a lesson without one is dropped). An empty string for a memory or knowledge candidate.",
|
|
68
72
|
},
|
|
69
73
|
body: {
|
|
70
74
|
type: "string",
|
|
@@ -87,8 +91,7 @@ export const EXTRACT_JSON_SCHEMA = {
|
|
|
87
91
|
},
|
|
88
92
|
rationale_if_empty: {
|
|
89
93
|
type: "string",
|
|
90
|
-
|
|
91
|
-
description: "Required when `candidates` is empty — explains why nothing rose to durable-insight level.",
|
|
94
|
+
description: "When `candidates` is empty, one sentence on why nothing rose to durable-insight level; an empty string otherwise.",
|
|
92
95
|
},
|
|
93
96
|
},
|
|
94
97
|
};
|
|
@@ -214,10 +217,47 @@ function parseFirstJsonObject(stdout) {
|
|
|
214
217
|
}
|
|
215
218
|
return { objectFound: true };
|
|
216
219
|
}
|
|
220
|
+
/** The candidate the model wrote when the contract keeps it, else the first rule it breaks. */
|
|
221
|
+
function readCandidate(c) {
|
|
222
|
+
const { type, name, description, body, confidence, evidence } = c;
|
|
223
|
+
if (type !== "memory" && type !== "lesson" && type !== "knowledge") {
|
|
224
|
+
return { problem: "type is not memory, lesson or knowledge" };
|
|
225
|
+
}
|
|
226
|
+
if (typeof name !== "string" || !EXTRACT_CANDIDATE_NAME_RE.test(name)) {
|
|
227
|
+
return { problem: "name is not a kebab-case slug" };
|
|
228
|
+
}
|
|
229
|
+
if (typeof description !== "string" || description.trim().length < 20) {
|
|
230
|
+
return { problem: "description is shorter than 20 characters" };
|
|
231
|
+
}
|
|
232
|
+
if (typeof body !== "string" || body.trim().length < 50)
|
|
233
|
+
return { problem: "body is shorter than 50 characters" };
|
|
234
|
+
if (typeof confidence !== "number" || !Number.isFinite(confidence))
|
|
235
|
+
return { problem: "confidence is not a number" };
|
|
236
|
+
if (typeof evidence !== "string" || evidence.trim().length < 5) {
|
|
237
|
+
return { problem: "evidence is shorter than 5 characters" };
|
|
238
|
+
}
|
|
239
|
+
// An empty string is how a strict-schema reply says "none".
|
|
240
|
+
const whenToUse = typeof c.when_to_use === "string" ? c.when_to_use.trim() : "";
|
|
241
|
+
if (type === "lesson" && whenToUse.length < 15) {
|
|
242
|
+
return { problem: "a lesson needs a when_to_use of at least 15 characters" };
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
candidate: {
|
|
246
|
+
type,
|
|
247
|
+
name,
|
|
248
|
+
description: description.trim(),
|
|
249
|
+
...(whenToUse ? { when_to_use: whenToUse } : {}),
|
|
250
|
+
body,
|
|
251
|
+
confidence: Math.max(0, Math.min(1, confidence)),
|
|
252
|
+
evidence: evidence.trim(),
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
217
256
|
/**
|
|
218
257
|
* Parse the LLM's JSON response into a structured {@link ExtractPayload}.
|
|
219
258
|
* Defensive — drops candidates that violate the shape rather than failing
|
|
220
|
-
* the whole call
|
|
259
|
+
* the whole call, and names each one in `dropped` so the run can say so.
|
|
260
|
+
* Returns the empty-candidates payload when nothing parses.
|
|
221
261
|
*/
|
|
222
262
|
export function parseExtractPayload(stdout) {
|
|
223
263
|
if (!stdout || stdout.trim().length === 0) {
|
|
@@ -238,42 +278,24 @@ export function parseExtractPayload(stdout) {
|
|
|
238
278
|
}
|
|
239
279
|
const rawCandidates = obj.candidates;
|
|
240
280
|
const candidates = [];
|
|
281
|
+
const dropped = [];
|
|
241
282
|
for (const raw of rawCandidates) {
|
|
242
|
-
if (!raw || typeof raw !== "object")
|
|
283
|
+
if (!raw || typeof raw !== "object") {
|
|
284
|
+
dropped.push("candidate dropped: not an object");
|
|
243
285
|
continue;
|
|
286
|
+
}
|
|
244
287
|
const c = raw;
|
|
245
|
-
const
|
|
246
|
-
if (
|
|
247
|
-
|
|
248
|
-
if (typeof c.name !== "string" || !EXTRACT_CANDIDATE_NAME_RE.test(c.name))
|
|
249
|
-
continue;
|
|
250
|
-
if (typeof c.description !== "string" || c.description.trim().length < 20)
|
|
251
|
-
continue;
|
|
252
|
-
if (typeof c.body !== "string" || c.body.trim().length < 50)
|
|
288
|
+
const read = readCandidate(c);
|
|
289
|
+
if ("problem" in read) {
|
|
290
|
+
dropped.push(`${typeof c.type === "string" ? c.type : "candidate"}:${typeof c.name === "string" ? c.name : "(unnamed)"} dropped: ${read.problem}`);
|
|
253
291
|
continue;
|
|
254
|
-
if (typeof c.confidence !== "number" || !Number.isFinite(c.confidence))
|
|
255
|
-
continue;
|
|
256
|
-
if (typeof c.evidence !== "string" || c.evidence.trim().length < 5)
|
|
257
|
-
continue;
|
|
258
|
-
if (type === "lesson") {
|
|
259
|
-
if (typeof c.when_to_use !== "string" || c.when_to_use.trim().length < 15)
|
|
260
|
-
continue;
|
|
261
292
|
}
|
|
262
|
-
|
|
263
|
-
const candidate = {
|
|
264
|
-
type,
|
|
265
|
-
name: c.name,
|
|
266
|
-
description: c.description.trim(),
|
|
267
|
-
body: c.body,
|
|
268
|
-
confidence,
|
|
269
|
-
evidence: c.evidence.trim(),
|
|
270
|
-
};
|
|
271
|
-
if (typeof c.when_to_use === "string")
|
|
272
|
-
candidate.when_to_use = c.when_to_use.trim();
|
|
273
|
-
candidates.push(candidate);
|
|
293
|
+
candidates.push(read.candidate);
|
|
274
294
|
}
|
|
275
295
|
const result = { candidates };
|
|
276
|
-
if (
|
|
296
|
+
if (dropped.length > 0)
|
|
297
|
+
result.dropped = dropped;
|
|
298
|
+
if (typeof obj.rationale_if_empty === "string" && obj.rationale_if_empty.trim()) {
|
|
277
299
|
result.rationale_if_empty = obj.rationale_if_empty.trim();
|
|
278
300
|
}
|
|
279
301
|
return result;
|
|
@@ -468,7 +468,8 @@ async function processSession(run, sessionRef, gate) {
|
|
|
468
468
|
});
|
|
469
469
|
}
|
|
470
470
|
const { payload } = extraction;
|
|
471
|
-
|
|
471
|
+
// A candidate the contract refused is reported, not silently lost (#1047).
|
|
472
|
+
const warnings = [...(payload.dropped ?? [])];
|
|
472
473
|
// Provenance xrefs are added only after the cited session asset exists.
|
|
473
474
|
const { warning, ...sessionAsset } = await maybeWriteSessionAsset(run, data);
|
|
474
475
|
if (warning)
|
|
@@ -601,6 +601,16 @@ export function buildSnapshotManifest(args) {
|
|
|
601
601
|
const latestNegativeTs = new Map();
|
|
602
602
|
const feedback = new Map(candidates.map((r) => [r.ref, { hasSignal: false, positive: 0, negative: 0 }]));
|
|
603
603
|
if (candidates.length > 0) {
|
|
604
|
+
// When each ref's accepted feedback proposals were created: a fix event is acted on once one exists at or after it.
|
|
605
|
+
const fixedAt = new Map();
|
|
606
|
+
if (stashDir) {
|
|
607
|
+
withRunState(eventsCtx, args.readOnly !== true, (db) => {
|
|
608
|
+
for (const p of listStateProposals(db, { stashDir, status: "accepted" })) {
|
|
609
|
+
if (p.source === "feedback")
|
|
610
|
+
fixedAt.set(p.ref, [...(fixedAt.get(p.ref) ?? []), p.createdAt]);
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
}
|
|
604
614
|
for (const e of readEvents({ type: "feedback" }, eventsCtx).events) {
|
|
605
615
|
const ref = e.ref ? refByKey.get(e.ref) : undefined;
|
|
606
616
|
const entry = ref ? feedback.get(ref) : undefined;
|
|
@@ -612,8 +622,11 @@ export function buildSnapshotManifest(args) {
|
|
|
612
622
|
entry.hasSignal = true;
|
|
613
623
|
if (ts > (latestFeedbackTs.get(ref) ?? ""))
|
|
614
624
|
latestFeedbackTs.set(ref, ts);
|
|
615
|
-
|
|
625
|
+
const fixApplied = e.metadata?.fix !== undefined &&
|
|
626
|
+
(fixedAt.get(e.ref ?? "") ?? []).some((createdAt) => createdAt >= ts);
|
|
627
|
+
if (signal === "negative" && !fixApplied && ts > (latestNegativeTs.get(ref) ?? "")) {
|
|
616
628
|
latestNegativeTs.set(ref, ts);
|
|
629
|
+
}
|
|
617
630
|
}
|
|
618
631
|
if (signal === "positive")
|
|
619
632
|
entry.positive++;
|
|
@@ -13,11 +13,13 @@
|
|
|
13
13
|
* pre-dispatch refusals still emit both).
|
|
14
14
|
*/
|
|
15
15
|
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { assetPathForName, stashDirFor } from "../../core/asset/asset-placement.js";
|
|
16
18
|
import { serializeFrontmatter } from "../../core/asset/asset-serialize.js";
|
|
17
19
|
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
18
20
|
import { parseRefInput } from "../../core/asset/resolve-ref.js";
|
|
19
21
|
import { DESCRIPTION_MAX_CHARS, requiresDescription } from "../../core/authoring-rules.js";
|
|
20
|
-
import { resolveStashDir } from "../../core/common.js";
|
|
22
|
+
import { isWithin, resolveStashDir, safeRealpath } from "../../core/common.js";
|
|
21
23
|
import { loadConfig } from "../../core/config/config.js";
|
|
22
24
|
import { generatedContentRejection } from "../../core/content-safety.js";
|
|
23
25
|
import { ConfigError, UsageError } from "../../core/errors.js";
|
|
@@ -248,7 +250,7 @@ export const REFLECT_JSON_SCHEMA = {
|
|
|
248
250
|
frontmatterPatch: REFLECT_FRONTMATTER_PATCH_JSON_SCHEMA,
|
|
249
251
|
},
|
|
250
252
|
};
|
|
251
|
-
const REFLECT_UNSCOPED_JSON_SCHEMA = {
|
|
253
|
+
export const REFLECT_UNSCOPED_JSON_SCHEMA = {
|
|
252
254
|
type: "object",
|
|
253
255
|
required: ["ref", "confidence", "frontmatterPatch"],
|
|
254
256
|
additionalProperties: false,
|
|
@@ -539,6 +541,24 @@ function unsupportedTypeFailure(ref, type, detail, emitFailed) {
|
|
|
539
541
|
},
|
|
540
542
|
};
|
|
541
543
|
}
|
|
544
|
+
/**
|
|
545
|
+
* A proposal writes the file derived from the ref's type and name under the bundle's root. An asset indexed
|
|
546
|
+
* anywhere else (a skill's `references/a.md` is `knowledge/skills/<name>/references/a`) has nothing there, so the
|
|
547
|
+
* proposal would be a `create` and accepting it would add a second file for the ref (#1052).
|
|
548
|
+
*/
|
|
549
|
+
function fileOutsideLayoutFailure(ref, file, writes, emitFailed) {
|
|
550
|
+
emitFailed("unsupported_type", "file_outside_layout", ref);
|
|
551
|
+
return {
|
|
552
|
+
failure: {
|
|
553
|
+
schemaVersion: 2,
|
|
554
|
+
ok: false,
|
|
555
|
+
reason: "unsupported_type",
|
|
556
|
+
error: `Reflect refused: the file for ${ref} is ${file}, but a proposal would write ${writes}. Edit the file directly.`,
|
|
557
|
+
ref,
|
|
558
|
+
exitCode: null,
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
}
|
|
542
562
|
/** The target's parsed ref and current content, or a refusal for a type reflect cannot patch. */
|
|
543
563
|
async function resolveReflectSource(options, stash, emitFailed) {
|
|
544
564
|
if (!options.ref)
|
|
@@ -549,18 +569,21 @@ async function resolveReflectSource(options, stash, emitFailed) {
|
|
|
549
569
|
return unsupportedTypeFailure(options.ref, parsedRef.type, "secret material is never read or sent to an LLM", emitFailed);
|
|
550
570
|
}
|
|
551
571
|
let assetContent = options.assetContent;
|
|
572
|
+
let assetFile;
|
|
552
573
|
if (assetContent === undefined) {
|
|
553
574
|
try {
|
|
554
575
|
const qualifiedRef = options.itemRef ?? options.ref;
|
|
555
576
|
const localFilePath = await findAssetFilePath(qualifiedRef, stash);
|
|
556
577
|
if (localFilePath && fs.existsSync(localFilePath)) {
|
|
557
|
-
|
|
578
|
+
assetFile = localFilePath;
|
|
558
579
|
}
|
|
559
580
|
else {
|
|
560
581
|
const entry = await lookup(parseRefInput(qualifiedRef));
|
|
561
582
|
if (entry?.filePath && fs.existsSync(entry.filePath))
|
|
562
|
-
|
|
583
|
+
assetFile = entry.filePath;
|
|
563
584
|
}
|
|
585
|
+
if (assetFile !== undefined)
|
|
586
|
+
assetContent = fs.readFileSync(assetFile, "utf8");
|
|
564
587
|
}
|
|
565
588
|
catch {
|
|
566
589
|
// An index miss is not fatal: reflect then has no content to patch.
|
|
@@ -570,6 +593,15 @@ async function resolveReflectSource(options, stash, emitFailed) {
|
|
|
570
593
|
(assetContent === undefined || parseFrontmatter(assetContent).frontmatter === null)) {
|
|
571
594
|
return unsupportedTypeFailure(options.ref, parsedRef.type, "its content is not frontmatter + markdown", emitFailed);
|
|
572
595
|
}
|
|
596
|
+
// A file in another bundle is `createProposal`'s to refuse (#1000); this one is in the proposal's own bundle.
|
|
597
|
+
const root = path.resolve(options.target?.root ?? stash);
|
|
598
|
+
const typeDir = stashDirFor(parsedRef.type);
|
|
599
|
+
if (assetFile !== undefined && typeDir !== undefined && isWithin(assetFile, root)) {
|
|
600
|
+
const writes = assetPathForName(parsedRef.type, path.join(root, typeDir), parsedRef.name);
|
|
601
|
+
if (safeRealpath(writes) !== safeRealpath(assetFile)) {
|
|
602
|
+
return fileOutsideLayoutFailure(options.ref, assetFile, writes, emitFailed);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
573
605
|
return { assetContent, parsedRef };
|
|
574
606
|
}
|
|
575
607
|
/**
|