akm-cli 0.9.26-alpha.2 → 0.9.27-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 +294 -0
- package/dist/assets/hints/cli-hints-full.md +16 -10
- package/dist/assets/hints/cli-hints-short.md +5 -5
- package/dist/assets/prompts/consolidate-system.md +2 -2
- package/dist/assets/prompts/extract-session.md +2 -2
- package/dist/assets/stash-skeleton/README.md +4 -3
- package/dist/commands/feedback-cli.js +244 -43
- package/dist/commands/improve/consolidate/pair-pass.js +1 -1
- package/dist/commands/improve/consolidate.js +14 -4
- package/dist/commands/improve/distill.js +70 -7
- package/dist/commands/improve/extract-prompt.js +61 -39
- package/dist/commands/improve/extract.js +2 -1
- package/dist/commands/improve/loop-stages.js +16 -1
- package/dist/commands/improve/memory/memory-belief.js +1 -1
- package/dist/commands/improve/preparation.js +46 -0
- package/dist/commands/improve/reflect.js +51 -8
- package/dist/commands/improve/retrieval-gate.js +1 -1
- package/dist/commands/improve/session-asset.js +3 -2
- package/dist/commands/improve/stage.js +1 -1
- package/dist/commands/proposal/repository.js +22 -6
- package/dist/core/asset/akm-markdown.js +40 -16
- package/dist/core/asset/frontmatter.js +67 -7
- package/dist/core/config/config-schema.js +1 -1
- package/dist/core/config/config.js +0 -4
- package/dist/core/config/schema/feedback.js +2 -19
- 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 +26 -22
- package/dist/scripts/akm-migrate.js +26 -22
- 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/reference/cli.md +36 -17
- package/docs/reference/configuration.md +12 -6
- package/docs/reference/data-and-telemetry.md +3 -3
- package/package.json +1 -1
- package/schemas/akm-config.json +0 -14
|
@@ -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)
|
|
@@ -28,7 +28,7 @@ import { checkDeadUrls } from "../url-checker.js";
|
|
|
28
28
|
import { isDistillCandidateRef } from "./eligibility.js";
|
|
29
29
|
import { shouldSkipRef } from "./improve-strategies.js";
|
|
30
30
|
import { recordLedgerAttempt, stateKey, stripBundle } from "./ledger.js";
|
|
31
|
-
import { pushRecentError } from "./preparation.js";
|
|
31
|
+
import { hasOnlyBarePositiveFeedback, isFlaggedSinceLastEdit, pushRecentError } from "./preparation.js";
|
|
32
32
|
import { recordNoOp, resetConsecutiveNoOps } from "./salience.js";
|
|
33
33
|
import { attributeStage, errMessage } from "./stage.js";
|
|
34
34
|
export function prepareImproveLoopEnv(args) {
|
|
@@ -196,6 +196,8 @@ async function runLoopReflectPass(planned, env, tally) {
|
|
|
196
196
|
}, env.eventsCtx);
|
|
197
197
|
recordPlasticity(env, planned, reason === "no_change" ? "noop" : result.ok ? "changed" : undefined);
|
|
198
198
|
}
|
|
199
|
+
const FLAGGED_WRONG_REASON = "flagged wrong since its last edit";
|
|
200
|
+
const BARE_POSITIVE_REASON = "only positive feedback, without a reason";
|
|
199
201
|
async function runLoopDistillPass(planned, refType, isDistillOnly, env, tally) {
|
|
200
202
|
const { options, primaryStashDir, improveProfile, resolvedPlan } = env;
|
|
201
203
|
const distillSkip = shouldSkipRef(planned.ref, "distill", improveProfile);
|
|
@@ -217,6 +219,19 @@ async function runLoopDistillPass(planned, refType, isDistillOnly, env, tally) {
|
|
|
217
219
|
return;
|
|
218
220
|
if (env.distillCooledRefs.has(planned.ref) && !explicitRefScope)
|
|
219
221
|
return;
|
|
222
|
+
// A memory flagged wrong and not edited since is no source for a lesson. The attempt goes in the
|
|
223
|
+
// ledger so the ref waits for newer feedback; an explicit `--scope` ref still runs.
|
|
224
|
+
if (!explicitRefScope && isFlaggedSinceLastEdit(planned, env.eventsCtx)) {
|
|
225
|
+
recordLoopAttempt(planned, env, "distill", "unchanged", FLAGGED_WRONG_REASON);
|
|
226
|
+
return recordSkip(tally, planned.ref, FLAGGED_WRONG_REASON, { env, reason: "distill_flagged_wrong" });
|
|
227
|
+
}
|
|
228
|
+
// A memory whose only recent feedback is a positive with no reason or note gives the writer nothing to distil:
|
|
229
|
+
// 10 of the 11 lessons made from one were rejected. The ledger holds it until newer feedback; an explicit `--scope`
|
|
230
|
+
// ref still runs.
|
|
231
|
+
if (!explicitRefScope && hasOnlyBarePositiveFeedback(planned, env.eventsCtx)) {
|
|
232
|
+
recordLoopAttempt(planned, env, "distill", "unchanged", BARE_POSITIVE_REASON);
|
|
233
|
+
return recordSkip(tally, planned.ref, BARE_POSITIVE_REASON, { env, reason: "distill_positive_without_reason" });
|
|
234
|
+
}
|
|
220
235
|
const result = await attributeStage(resolvedPlan, "distill", () => env.distillFn({
|
|
221
236
|
ref: planned.ref,
|
|
222
237
|
...(planned.itemRef ? { itemRef: planned.itemRef } : {}),
|
|
@@ -17,7 +17,7 @@ import { mutateFrontmatter } from "../../../core/asset/frontmatter.js";
|
|
|
17
17
|
* An edge value as a list. A scalar string is live data (the indexer accepts
|
|
18
18
|
* it), so it is promoted rather than dropped on the next write.
|
|
19
19
|
*/
|
|
20
|
-
function readEdgeList(value) {
|
|
20
|
+
export function readEdgeList(value) {
|
|
21
21
|
if (Array.isArray(value))
|
|
22
22
|
return value.filter((v) => typeof v === "string" && v.trim().length > 0);
|
|
23
23
|
if (typeof value === "string" && value.trim())
|
|
@@ -34,6 +34,7 @@ import { runSchemaRepairPass } from "../sources/schema-repair.js";
|
|
|
34
34
|
import { isAutonomyLaneAllowed } from "./autonomy-gate.js";
|
|
35
35
|
import { akmConsolidate, inspectConsolidationPool, loadExistingKnowledgeBodyHashes, makeConsolidateResult, } from "./consolidate.js";
|
|
36
36
|
import { computeSafeChunkSize, DEFAULT_CONTEXT_LENGTH_TOKENS } from "./consolidate/chunking.js";
|
|
37
|
+
import { contentHash } from "./content-hash.js";
|
|
37
38
|
import { assetTypeOf, buildUtilityMap, dedupeRefs, findAssetFilePath, isDistillCandidateRef, isLessonCandidate, resolveImproveScope, withIndexDb, } from "./eligibility.js";
|
|
38
39
|
import { akmExtract, countNewExtractCandidates } from "./extract.js";
|
|
39
40
|
import { computeValenceScore } from "./feedback-valence.js";
|
|
@@ -545,6 +546,51 @@ function isSignalEvent(metadata) {
|
|
|
545
546
|
const meta = metadata;
|
|
546
547
|
return meta !== undefined && (typeof meta.signal === "string" || typeof meta.note === "string");
|
|
547
548
|
}
|
|
549
|
+
/**
|
|
550
|
+
* Whether `candidate` was flagged wrong and not edited since. A negative
|
|
551
|
+
* feedback inside the signal window that recorded the hash of the body it
|
|
552
|
+
* judged flags it exactly when the body still has that hash, so a later write
|
|
553
|
+
* that leaves the text alone (an inference stamp, a frontmatter repair) does not
|
|
554
|
+
* lift the flag. One without a hash flags it when it is newer than the file's
|
|
555
|
+
* last write: its mtime, the one the retrieval scope reads for new material. A
|
|
556
|
+
* candidate with no readable file is not flagged.
|
|
557
|
+
*/
|
|
558
|
+
export function isFlaggedSinceLastEdit(candidate, eventsCtx) {
|
|
559
|
+
if (!candidate.filePath)
|
|
560
|
+
return false;
|
|
561
|
+
let editedAtMs;
|
|
562
|
+
let bodyHash;
|
|
563
|
+
try {
|
|
564
|
+
editedAtMs = fs.statSync(candidate.filePath).mtimeMs;
|
|
565
|
+
bodyHash = contentHash(fs.readFileSync(candidate.filePath, "utf8"), "body");
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
const since = new Date(Date.now() - daysToMs(FEEDBACK_SIGNAL_WINDOW_DAYS)).toISOString();
|
|
571
|
+
return readEvents({ type: "feedback", ref: keyOf(candidate), since }, eventsCtx).events.some((e) => {
|
|
572
|
+
const meta = e.metadata;
|
|
573
|
+
if (meta?.signal !== "negative")
|
|
574
|
+
return false;
|
|
575
|
+
return typeof meta.contentHash === "string" ? meta.contentHash === bodyHash : Date.parse(e.ts) > editedAtMs;
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Whether the only feedback `candidate` has inside the signal window is positive and says nothing: no reason, no
|
|
580
|
+
* note. A bare `--positive` only records that a note helped, which gives the writer nothing to distil, so it restates
|
|
581
|
+
* the memory: 10 of the 11 lessons distilled from a memory with nothing more were rejected (the 2026-10-05 review).
|
|
582
|
+
* A candidate with no feedback in the window is not matched.
|
|
583
|
+
*/
|
|
584
|
+
export function hasOnlyBarePositiveFeedback(candidate, eventsCtx) {
|
|
585
|
+
const since = new Date(Date.now() - daysToMs(FEEDBACK_SIGNAL_WINDOW_DAYS)).toISOString();
|
|
586
|
+
const signals = readEvents({ type: "feedback", ref: keyOf(candidate), since }, eventsCtx).events.filter((e) => isSignalEvent(e.metadata));
|
|
587
|
+
const hasText = (value) => typeof value === "string" && value.trim() !== "";
|
|
588
|
+
return (signals.length > 0 &&
|
|
589
|
+
signals.every((e) => {
|
|
590
|
+
const meta = e.metadata;
|
|
591
|
+
return meta.signal === "positive" && !hasText(meta.reason) && !hasText(meta.note);
|
|
592
|
+
}));
|
|
593
|
+
}
|
|
548
594
|
/** One read of the feedback events and the ledger's reflect/distill rows. */
|
|
549
595
|
export function buildSnapshotManifest(args) {
|
|
550
596
|
const { eventsCtx, stashDir } = args;
|
|
@@ -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";
|
|
@@ -38,6 +40,7 @@ import { isJsonSchemaKnownUnsupported, LlmCallError } from "../../llm/client.js"
|
|
|
38
40
|
import { baseFailureFields, enoentHintMessage, isEnoentFailure } from "../agent/agent-support.js";
|
|
39
41
|
import { isValidDescription } from "../proposal/validators/proposal-quality-validators.js";
|
|
40
42
|
import { CHARS_PER_TOKEN, DEFAULT_CONTEXT_LENGTH_TOKENS } from "./consolidate/chunking.js";
|
|
43
|
+
import { contentHash } from "./content-hash.js";
|
|
41
44
|
import { findAssetFilePath } from "./eligibility.js";
|
|
42
45
|
import { resolveImproveExecution } from "./execution.js";
|
|
43
46
|
import { recordLedgerAttempt } from "./ledger.js";
|
|
@@ -46,19 +49,29 @@ import { loadRetrievalQueries, runRetrievalRegressionGate } from "./retrieval-ga
|
|
|
46
49
|
import { callStageOnce, errMessage, mintProposal, noticeSet, rejectedProposalContext, resolveQualityGateJudge, runReflectQualityJudge, } from "./stage.js";
|
|
47
50
|
const MAX_FEEDBACK_LINES = 10;
|
|
48
51
|
const MAX_GLOBAL_FEEDBACK_LINES = 20;
|
|
52
|
+
/** Ends a feedback line whose event judged text the asset no longer has. */
|
|
53
|
+
const EARLIER_TEXT_MARK = " (given on an earlier version of the text)";
|
|
49
54
|
function readOnlyEventsContext(ctx) {
|
|
50
55
|
return ctx?.db ? ctx : { ...(ctx ?? {}), readOnly: true };
|
|
51
56
|
}
|
|
52
|
-
/**
|
|
53
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Recent `feedback` lines for `ref` (or across all assets without one). Given the
|
|
59
|
+
* asset's current content, a line whose event recorded the hash of a different
|
|
60
|
+
* body is marked. Best-effort.
|
|
61
|
+
*/
|
|
62
|
+
function readRecentFeedback(ref, eventsCtx, assetContent) {
|
|
54
63
|
try {
|
|
55
64
|
const events = readEvents({ type: "feedback", ...(ref ? { ref } : {}) }, readOnlyEventsContext(eventsCtx)).events;
|
|
65
|
+
const bodyHash = assetContent === undefined ? undefined : contentHash(assetContent, "body");
|
|
56
66
|
return events.slice(-(ref ? MAX_FEEDBACK_LINES : MAX_GLOBAL_FEEDBACK_LINES)).map((event) => {
|
|
57
67
|
const md = event.metadata ?? {};
|
|
58
68
|
const signal = typeof md.signal === "string" ? md.signal : "?";
|
|
59
69
|
const note = typeof md.reason === "string" ? md.reason : typeof md.note === "string" ? md.note : "";
|
|
60
70
|
const details = note ? `[${signal}] ${note}` : `[${signal}]`;
|
|
61
|
-
|
|
71
|
+
const line = !ref && event.ref ? `${event.ref} ${details}` : details;
|
|
72
|
+
return bodyHash !== undefined && typeof md.contentHash === "string" && md.contentHash !== bodyHash
|
|
73
|
+
? `${line}${EARLIER_TEXT_MARK}`
|
|
74
|
+
: line;
|
|
62
75
|
});
|
|
63
76
|
}
|
|
64
77
|
catch {
|
|
@@ -237,7 +250,7 @@ export const REFLECT_JSON_SCHEMA = {
|
|
|
237
250
|
frontmatterPatch: REFLECT_FRONTMATTER_PATCH_JSON_SCHEMA,
|
|
238
251
|
},
|
|
239
252
|
};
|
|
240
|
-
const REFLECT_UNSCOPED_JSON_SCHEMA = {
|
|
253
|
+
export const REFLECT_UNSCOPED_JSON_SCHEMA = {
|
|
241
254
|
type: "object",
|
|
242
255
|
required: ["ref", "confidence", "frontmatterPatch"],
|
|
243
256
|
additionalProperties: false,
|
|
@@ -528,6 +541,24 @@ function unsupportedTypeFailure(ref, type, detail, emitFailed) {
|
|
|
528
541
|
},
|
|
529
542
|
};
|
|
530
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
|
+
}
|
|
531
562
|
/** The target's parsed ref and current content, or a refusal for a type reflect cannot patch. */
|
|
532
563
|
async function resolveReflectSource(options, stash, emitFailed) {
|
|
533
564
|
if (!options.ref)
|
|
@@ -538,18 +569,21 @@ async function resolveReflectSource(options, stash, emitFailed) {
|
|
|
538
569
|
return unsupportedTypeFailure(options.ref, parsedRef.type, "secret material is never read or sent to an LLM", emitFailed);
|
|
539
570
|
}
|
|
540
571
|
let assetContent = options.assetContent;
|
|
572
|
+
let assetFile;
|
|
541
573
|
if (assetContent === undefined) {
|
|
542
574
|
try {
|
|
543
575
|
const qualifiedRef = options.itemRef ?? options.ref;
|
|
544
576
|
const localFilePath = await findAssetFilePath(qualifiedRef, stash);
|
|
545
577
|
if (localFilePath && fs.existsSync(localFilePath)) {
|
|
546
|
-
|
|
578
|
+
assetFile = localFilePath;
|
|
547
579
|
}
|
|
548
580
|
else {
|
|
549
581
|
const entry = await lookup(parseRefInput(qualifiedRef));
|
|
550
582
|
if (entry?.filePath && fs.existsSync(entry.filePath))
|
|
551
|
-
|
|
583
|
+
assetFile = entry.filePath;
|
|
552
584
|
}
|
|
585
|
+
if (assetFile !== undefined)
|
|
586
|
+
assetContent = fs.readFileSync(assetFile, "utf8");
|
|
553
587
|
}
|
|
554
588
|
catch {
|
|
555
589
|
// An index miss is not fatal: reflect then has no content to patch.
|
|
@@ -559,6 +593,15 @@ async function resolveReflectSource(options, stash, emitFailed) {
|
|
|
559
593
|
(assetContent === undefined || parseFrontmatter(assetContent).frontmatter === null)) {
|
|
560
594
|
return unsupportedTypeFailure(options.ref, parsedRef.type, "its content is not frontmatter + markdown", emitFailed);
|
|
561
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
|
+
}
|
|
562
605
|
return { assetContent, parsedRef };
|
|
563
606
|
}
|
|
564
607
|
/**
|
|
@@ -636,7 +679,7 @@ function computeReflectContentBudgetChars(promptInput, runnerSpec) {
|
|
|
636
679
|
/** Every read-only prompt input, shared by dispatch and `--show-prompt`. */
|
|
637
680
|
function gatherReflectPromptSources(options, stash, parsedRef, assetContent) {
|
|
638
681
|
return {
|
|
639
|
-
feedback: readRecentFeedback(options.ref ? (options.itemRef ?? options.ref) : undefined, options.eventsCtx),
|
|
682
|
+
feedback: readRecentFeedback(options.ref ? (options.itemRef ?? options.ref) : undefined, options.eventsCtx, assetContent),
|
|
640
683
|
schemaHints: buildSchemaHints(parsedRef?.type ?? "", assetContent),
|
|
641
684
|
rejectedProposals: rejectedProposalContext(stash, options.ref, options.ctx, options.eventsCtx),
|
|
642
685
|
standardsContext: resolveStandardsContext(options.ref, stash),
|
|
@@ -26,7 +26,7 @@ const MAX_QUERIES = 5;
|
|
|
26
26
|
const MAX_QUERY_CHARS = 2000;
|
|
27
27
|
/** The judge sees this much of the body, as in the retrieval eval. */
|
|
28
28
|
const MAX_DOC_CHARS = 1500;
|
|
29
|
-
const GRADE_SCHEMA = {
|
|
29
|
+
export const GRADE_SCHEMA = {
|
|
30
30
|
type: "object",
|
|
31
31
|
required: ["grade", "reason"],
|
|
32
32
|
additionalProperties: false,
|
|
@@ -14,9 +14,10 @@ import { assembleAsset } from "../../core/asset/asset-serialize.js";
|
|
|
14
14
|
import { conceptIdFromTypeName } from "../../core/asset/resolve-ref.js";
|
|
15
15
|
import { parseEmbeddedJsonResponse } from "../../core/parse.js";
|
|
16
16
|
import { recordWrittenPath } from "../../core/write-provenance.js";
|
|
17
|
+
// Every property is required, as a strict structured-output provider needs; an empty `tags` array is none.
|
|
17
18
|
export const SESSION_SUMMARY_JSON_SCHEMA = {
|
|
18
19
|
type: "object",
|
|
19
|
-
required: ["summary", "key_topics"],
|
|
20
|
+
required: ["summary", "key_topics", "tags"],
|
|
20
21
|
additionalProperties: false,
|
|
21
22
|
properties: {
|
|
22
23
|
summary: { type: "string" },
|
|
@@ -61,7 +62,7 @@ export function buildSessionSummaryPrompt(data) {
|
|
|
61
62
|
"Transcript:",
|
|
62
63
|
renderTranscriptForSummary(data.events),
|
|
63
64
|
"",
|
|
64
|
-
'Respond as JSON: {"summary": string, "key_topics": string[], "tags"
|
|
65
|
+
'Respond as JSON: {"summary": string, "key_topics": string[], "tags": string[]} (an empty array for no tags).',
|
|
65
66
|
].join("\n");
|
|
66
67
|
}
|
|
67
68
|
/** The summary JSON, tolerating prose around it; `undefined` when nothing usable parses. */
|
|
@@ -370,7 +370,7 @@ function parseJudgeResponse(raw, keys) {
|
|
|
370
370
|
}
|
|
371
371
|
return inRange(parsed.score) ? { score: parsed.score, lowest: parsed.score, reason } : undefined;
|
|
372
372
|
}
|
|
373
|
-
function judgeResponseSchema(keys) {
|
|
373
|
+
export function judgeResponseSchema(keys) {
|
|
374
374
|
return {
|
|
375
375
|
type: "object",
|
|
376
376
|
required: ["scores", "reason"],
|
|
@@ -13,12 +13,13 @@ import { randomUUID } from "node:crypto";
|
|
|
13
13
|
import fs from "node:fs";
|
|
14
14
|
import os from "node:os";
|
|
15
15
|
import path from "node:path";
|
|
16
|
+
import { isDeepStrictEqual } from "node:util";
|
|
16
17
|
import { parse as parseYaml } from "yaml";
|
|
17
18
|
import { ensureAkmMarkdownType } from "../../core/asset/akm-markdown.js";
|
|
18
19
|
import { assetPathForName, placementTypes, stashDirFor } from "../../core/asset/asset-placement.js";
|
|
19
20
|
import { isBundleSlug, parseBundleRef } from "../../core/asset/asset-ref.js";
|
|
20
21
|
import { assembleAsset, serializeFrontmatter } from "../../core/asset/asset-serialize.js";
|
|
21
|
-
import { carryForwardBookkeepingFrontmatter, parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
22
|
+
import { carryForwardBookkeepingFrontmatter, parseFrontmatter, replaceFrontmatterBlocks, } from "../../core/asset/frontmatter.js";
|
|
22
23
|
import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
|
|
23
24
|
import { loadConfig } from "../../core/config/config.js";
|
|
24
25
|
import { ConfigError, NotFoundError, rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
|
|
@@ -883,8 +884,17 @@ function isPlainRecord(value) {
|
|
|
883
884
|
* Stamp provenance onto a promoted asset's frontmatter: bare top-level
|
|
884
885
|
* `generated` and `verified` (as OKF v0.2 spells them; `verified` accumulates),
|
|
885
886
|
* and `sources` under `provenance:`, since a bare `sources:` is the wiki
|
|
886
|
-
* citation-string convention.
|
|
887
|
-
*
|
|
887
|
+
* citation-string convention.
|
|
888
|
+
*
|
|
889
|
+
* Source preservation: an existing frontmatter block is edited as text and not
|
|
890
|
+
* written out again through the YAML serializer, which rewraps long values,
|
|
891
|
+
* requotes and drops comments in lines the stamp never touched. The blocks of
|
|
892
|
+
* the keys the stamp sets (`generated`, `verified`, `provenance`) are removed
|
|
893
|
+
* wherever they were and written again, serialized, as the last lines of the
|
|
894
|
+
* frontmatter; every other byte, the body included, stays as written. The
|
|
895
|
+
* edited text is parsed back to confirm it holds exactly the intended mapping;
|
|
896
|
+
* when it does not (an indented `---` inside a block scalar), the frontmatter is
|
|
897
|
+
* written out again instead, as it always used to be.
|
|
888
898
|
*/
|
|
889
899
|
function stampProposalProvenance(content, proposal, gateDecision, ctx, nowIsoStr) {
|
|
890
900
|
const parsed = parseFrontmatter(content);
|
|
@@ -917,9 +927,15 @@ function stampProposalProvenance(content, proposal, gateDecision, ctx, nowIsoStr
|
|
|
917
927
|
fm.provenance = provenance;
|
|
918
928
|
else
|
|
919
929
|
delete fm.provenance;
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
930
|
+
if (parsed.frontmatter === null)
|
|
931
|
+
return assembleAsset(fm, parsed.content);
|
|
932
|
+
const stamped = { generated: fm.generated, verified: fm.verified };
|
|
933
|
+
if (fm.provenance !== undefined)
|
|
934
|
+
stamped.provenance = fm.provenance;
|
|
935
|
+
const edited = replaceFrontmatterBlocks(content, ["generated", "verified", "provenance"], serializeFrontmatter(stamped).split("\n"));
|
|
936
|
+
if (edited !== null && isDeepStrictEqual(parseFrontmatter(edited).data, fm))
|
|
937
|
+
return edited;
|
|
938
|
+
return `---\n${serializeFrontmatter(fm)}\n---\n${parsed.content}`;
|
|
923
939
|
}
|
|
924
940
|
/**
|
|
925
941
|
* Validate, stamp and write an accepted proposal into its bound target, then
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
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
|
+
import { isDeepStrictEqual } from "node:util";
|
|
4
5
|
import { parse as parseYaml } from "yaml";
|
|
5
6
|
import { localDateStamp } from "../common.js";
|
|
6
7
|
import { UsageError } from "../errors.js";
|
|
7
8
|
import { serializeFrontmatter } from "./asset-serialize.js";
|
|
8
|
-
import { parseFrontmatterBlock, spliceFrontmatterLine } from "./frontmatter.js";
|
|
9
|
+
import { parseFrontmatterBlock, replaceFrontmatterLine, spliceFrontmatterLine } from "./frontmatter.js";
|
|
9
10
|
/**
|
|
10
11
|
* Ensure an AKM-authored Markdown concept is also a conformant OKF concept.
|
|
11
12
|
*
|
|
@@ -20,12 +21,18 @@ import { parseFrontmatterBlock, spliceFrontmatterLine } from "./frontmatter.js";
|
|
|
20
21
|
* re-stamp on every write (which would churn timestamps and manufacture
|
|
21
22
|
* needless diffs in git-backed bundles).
|
|
22
23
|
*
|
|
23
|
-
* Source preservation:
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
24
|
+
* Source preservation: whenever the frontmatter block parses, it is edited as
|
|
25
|
+
* text and never round-tripped through the YAML serializer, which rewraps long
|
|
26
|
+
* values, reorders keys and drops comments — changes nobody made, shown to the
|
|
27
|
+
* reviewer of what may be a one-line correction. A missing `type` and a
|
|
28
|
+
* missing `updated` are each added as one line before the closing `---`, in
|
|
29
|
+
* that order; a wrong `type` is replaced on its own line. Every other byte —
|
|
30
|
+
* comments, wrapping, quoting, key order, line endings, the body — is kept as
|
|
31
|
+
* written. The edited text is parsed back to confirm it holds exactly the
|
|
32
|
+
* intended mapping; when it does not (a `type` value that spans several lines,
|
|
33
|
+
* a flow-style `{…}` block), the document is re-serialized instead, as it
|
|
34
|
+
* always used to be. A document with no frontmatter block gets a new one;
|
|
35
|
+
* malformed YAML throws.
|
|
29
36
|
*/
|
|
30
37
|
export function ensureAkmMarkdownType(content, type, now = new Date()) {
|
|
31
38
|
const block = parseFrontmatterBlock(content);
|
|
@@ -45,19 +52,36 @@ export function ensureAkmMarkdownType(content, type, now = new Date()) {
|
|
|
45
52
|
throw new UsageError("AKM Markdown frontmatter must be a YAML mapping.", "INVALID_FLAG_VALUE");
|
|
46
53
|
}
|
|
47
54
|
const data = parsed;
|
|
55
|
+
const updated = localDateStamp(now);
|
|
48
56
|
const needsUpdated = !("updated" in data);
|
|
49
|
-
if (data.type === type)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
if (data.type === type && !needsUpdated)
|
|
58
|
+
return content;
|
|
59
|
+
let edited = content;
|
|
60
|
+
if (data.type !== type) {
|
|
61
|
+
edited =
|
|
62
|
+
"type" in data
|
|
63
|
+
? replaceFrontmatterLine(edited, "type", `type: ${type}`)
|
|
64
|
+
: spliceFrontmatterLine(edited, `type: ${type}`);
|
|
57
65
|
}
|
|
66
|
+
if (edited !== null && needsUpdated)
|
|
67
|
+
edited = spliceFrontmatterLine(edited, `updated: ${updated}`);
|
|
68
|
+
const intended = needsUpdated ? { ...data, type, updated } : { ...data, type };
|
|
69
|
+
if (edited !== null && parsesTo(edited, intended))
|
|
70
|
+
return edited;
|
|
71
|
+
// The text edit could not be done safely, but a re-serialized document
|
|
72
|
+
// beats a non-conformant one.
|
|
58
73
|
const { type: _priorType, ...rest } = data;
|
|
59
74
|
const next = { type, ...rest };
|
|
60
75
|
if (needsUpdated)
|
|
61
|
-
next.updated =
|
|
76
|
+
next.updated = updated;
|
|
62
77
|
return `---\n${serializeFrontmatter(next)}\n---\n${block.content}`;
|
|
63
78
|
}
|
|
79
|
+
/** True when the frontmatter of `text` parses to exactly `intended`. */
|
|
80
|
+
function parsesTo(text, intended) {
|
|
81
|
+
try {
|
|
82
|
+
return isDeepStrictEqual(parseYaml(parseFrontmatterBlock(text)?.frontmatter ?? ""), intended);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|