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
|
@@ -2,14 +2,17 @@
|
|
|
2
2
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
4
|
import fs from "node:fs";
|
|
5
|
-
import
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { isDeepStrictEqual } from "node:util";
|
|
7
|
+
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
|
|
6
8
|
import { defineJsonCommand, output, parseAllFlagValues } from "../cli/shared.js";
|
|
9
|
+
import { assetPathForName, stashDirFor } from "../core/asset/asset-placement.js";
|
|
7
10
|
import { makeBundleRef, parseBundleRef } from "../core/asset/asset-ref.js";
|
|
8
|
-
import { assembleAsset } from "../core/asset/asset-serialize.js";
|
|
9
|
-
import { parseFrontmatter, parseFrontmatterBlock } from "../core/asset/frontmatter.js";
|
|
10
|
-
import { conceptIdFromTypeName, parseRefInput } from "../core/asset/resolve-ref.js";
|
|
11
|
-
import { isWithin, resolveStashDir, writeFileAtomic } from "../core/common.js";
|
|
12
|
-
import {
|
|
11
|
+
import { assembleAsset, serializeFrontmatter } from "../core/asset/asset-serialize.js";
|
|
12
|
+
import { parseFrontmatter, parseFrontmatterBlock, spliceFrontmatterLine } from "../core/asset/frontmatter.js";
|
|
13
|
+
import { conceptIdFromTypeName, parseRefInput, typeNameFromConceptId } from "../core/asset/resolve-ref.js";
|
|
14
|
+
import { isWithin, resolveStashDir, safeRealpath, writeFileAtomic } from "../core/common.js";
|
|
15
|
+
import { loadConfig } from "../core/config/config.js";
|
|
13
16
|
import { NotFoundError, UsageError } from "../core/errors.js";
|
|
14
17
|
import { appendEvent } from "../core/events.js";
|
|
15
18
|
import { resolveMutationTarget } from "../core/mutation-target.js";
|
|
@@ -24,6 +27,8 @@ import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
|
|
|
24
27
|
import { closeDatabase, openExistingDatabase } from "../storage/repositories/index-connection.js";
|
|
25
28
|
import { findEntryIdByRef, getEntryFilePathById, getItemRefById, } from "../storage/repositories/index-entries-repository.js";
|
|
26
29
|
import { applyFeedbackToUtilityScore } from "../storage/repositories/index-utility-repository.js";
|
|
30
|
+
import { contentHash } from "./improve/content-hash.js";
|
|
31
|
+
import { readEdgeList } from "./improve/memory/memory-belief.js";
|
|
27
32
|
import { createProposal } from "./proposal/repository.js";
|
|
28
33
|
// ── Tag validation ────────────────────────────────────────────────────────────
|
|
29
34
|
const TAG_KEY_RE = /^[a-z_][a-z0-9_]*$/;
|
|
@@ -187,6 +192,24 @@ function recordFeedbackUsage(indexDb, entryId, durableEntryRef, signal, metadata
|
|
|
187
192
|
});
|
|
188
193
|
return { utilityResult, rankingUpdateApplied, rankingUpdateSkippedReason };
|
|
189
194
|
}
|
|
195
|
+
// ── Judged text ──────────────────────────────────────────────────────────────
|
|
196
|
+
/**
|
|
197
|
+
* The body hash of the text a piece of feedback judges: the asset's file as it
|
|
198
|
+
* stands when the feedback is given. `undefined` when the file cannot be read.
|
|
199
|
+
* Reflect compares it with the asset's current body to tell feedback given on
|
|
200
|
+
* an earlier version of the text. An env or secret file is never read.
|
|
201
|
+
*/
|
|
202
|
+
function judgedTextHash(itemRef, filePath) {
|
|
203
|
+
try {
|
|
204
|
+
const type = typeNameFromConceptId(parseBundleRef(itemRef).conceptId)?.type;
|
|
205
|
+
if (!filePath || type === "env" || type === "secret")
|
|
206
|
+
return undefined;
|
|
207
|
+
return contentHash(fs.readFileSync(filePath, "utf8"), "body");
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
190
213
|
// ── Exact fixes ──────────────────────────────────────────────────────────────
|
|
191
214
|
/** 1-based numbers of the lines on which `needle` starts. */
|
|
192
215
|
function startLines(text, needle) {
|
|
@@ -242,6 +265,146 @@ function assertFrontmatterStillParses(before, after, file) {
|
|
|
242
265
|
throw new UsageError(`The fix breaks the frontmatter of ${file}: ${problem}.`, "INVALID_FLAG_VALUE", 'Quote a value that contains ": ", or leave the frontmatter alone.');
|
|
243
266
|
}
|
|
244
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* Refuse a fix that a proposal could not apply to the asset's file. A proposal
|
|
270
|
+
* writes the file `createProposal` computes from the ref's type and name under
|
|
271
|
+
* the bundle's root. For an asset indexed anywhere else (a git bundle's
|
|
272
|
+
* `tasks/README.md` is `knowledge/tasks/README`) nothing is there: accepting
|
|
273
|
+
* the proposal would create a second file and leave the real one unfixed.
|
|
274
|
+
*/
|
|
275
|
+
export function assertProposalWritesFile(itemRef, root, filePath) {
|
|
276
|
+
const { type, name } = parseRefInput(itemRef);
|
|
277
|
+
const typeDir = stashDirFor(type);
|
|
278
|
+
const writes = typeDir === undefined ? undefined : assetPathForName(type, path.join(path.resolve(root), typeDir), name);
|
|
279
|
+
if (writes !== undefined && safeRealpath(writes) === safeRealpath(filePath))
|
|
280
|
+
return;
|
|
281
|
+
throw new UsageError(`akm cannot queue a fix for ${itemRef}: its file is ${filePath}, but a proposal would write ${writes ?? `no file (akm has no directory for "${type}" assets)`}. Edit the file directly.`, "INVALID_PROPOSAL");
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* The durable ref of the asset `--superseded-by` names. It must be indexed and
|
|
285
|
+
* must not be the asset itself, or nothing is recorded.
|
|
286
|
+
*/
|
|
287
|
+
function resolveSuccessorRef(db, input, itemRef) {
|
|
288
|
+
const parsed = parseBundleRef(input);
|
|
289
|
+
const id = findEntryIdByRef(db, makeBundleRef(parsed.bundle, parsed.conceptId), parsed.bundle);
|
|
290
|
+
const successor = id === undefined ? null : getItemRefById(db, id);
|
|
291
|
+
if (!successor) {
|
|
292
|
+
throw new UsageError(`--superseded-by ${input} is not in the index.`, "INVALID_FLAG_VALUE", "Run 'akm search' to find the asset that replaces it, then 'akm index' if it was recently added.");
|
|
293
|
+
}
|
|
294
|
+
if (successor === itemRef) {
|
|
295
|
+
throw new UsageError(`--superseded-by ${input} is the asset itself.`, "INVALID_FLAG_VALUE");
|
|
296
|
+
}
|
|
297
|
+
return successor;
|
|
298
|
+
}
|
|
299
|
+
/** The frontmatter of `text` as a YAML mapping: `{}` when it has none (or only comments), `undefined` when it is not valid YAML or not a mapping. */
|
|
300
|
+
function frontmatterMapping(text) {
|
|
301
|
+
const block = parseFrontmatterBlock(text);
|
|
302
|
+
if (!block?.frontmatter.trim())
|
|
303
|
+
return {};
|
|
304
|
+
try {
|
|
305
|
+
const data = (yamlParse(block.frontmatter) ?? {});
|
|
306
|
+
return typeof data === "object" && data !== null && !Array.isArray(data)
|
|
307
|
+
? data
|
|
308
|
+
: undefined;
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* What `mark` sets on frontmatter `data`, `{}` when it already says so. The rules
|
|
316
|
+
* of `writeSupersededEdge`: a ref already listed is not added again,
|
|
317
|
+
* `contradicted` and `archived` stay, and a scalar `supersededBy` joins the list.
|
|
318
|
+
* `--outdated` also leaves `superseded`, which says more than `deprecated`.
|
|
319
|
+
*/
|
|
320
|
+
function historyMarkChanges(data, mark) {
|
|
321
|
+
const state = data.beliefState;
|
|
322
|
+
if ("outdated" in mark) {
|
|
323
|
+
return state === "deprecated" || state === "superseded" || state === "contradicted" || state === "archived"
|
|
324
|
+
? {}
|
|
325
|
+
: { beliefState: "deprecated" };
|
|
326
|
+
}
|
|
327
|
+
const existing = readEdgeList(data.supersededBy);
|
|
328
|
+
return {
|
|
329
|
+
...(state === "superseded" || state === "contradicted" || state === "archived"
|
|
330
|
+
? {}
|
|
331
|
+
: { beliefState: "superseded" }),
|
|
332
|
+
...(existing.includes(mark.supersededBy) ? {} : { supersededBy: [...existing, mark.supersededBy] }),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/** Index of the top-level `key:` line inside the frontmatter block of `lines`, or -1. */
|
|
336
|
+
function topLevelKeyLine(lines, key) {
|
|
337
|
+
const close = lines.findIndex((line, i) => i > 0 && line.trim() === "---");
|
|
338
|
+
if (lines[0]?.trim() !== "---" || close === -1)
|
|
339
|
+
return -1;
|
|
340
|
+
return lines.findIndex((line, i) => i > 0 && i < close && line.startsWith(`${key}:`));
|
|
341
|
+
}
|
|
342
|
+
/** Put a one-line `beliefState` in the frontmatter of `text`: in place of its line, or just before the closing fence. */
|
|
343
|
+
function setBeliefState(text, state) {
|
|
344
|
+
const lines = text.split("\n");
|
|
345
|
+
const at = topLevelKeyLine(lines, "beliefState");
|
|
346
|
+
if (at === -1)
|
|
347
|
+
return spliceFrontmatterLine(text, `beliefState: ${state}`) ?? text;
|
|
348
|
+
lines[at] = `beliefState: ${state}`;
|
|
349
|
+
return lines.join("\n");
|
|
350
|
+
}
|
|
351
|
+
/** Put the `supersededBy` list `list`, whose last item is `ref`, in the frontmatter of `text`. */
|
|
352
|
+
function setSupersededBy(text, list, ref) {
|
|
353
|
+
const item = (value) => `- ${yamlStringify(value, { lineWidth: 0 }).trimEnd()}`;
|
|
354
|
+
const whole = ["supersededBy:", ...list.map((value) => ` ${item(value)}`)];
|
|
355
|
+
const lines = text.split("\n");
|
|
356
|
+
const at = topLevelKeyLine(lines, "supersededBy");
|
|
357
|
+
if (at === -1)
|
|
358
|
+
return whole.reduce((out, line) => spliceFrontmatterLine(out, line) ?? out, text);
|
|
359
|
+
// The item lines of a block list, which may have blank lines and comments between them.
|
|
360
|
+
const items = [];
|
|
361
|
+
for (let i = at + 1; i < lines.length && /^\s*(-(\s|$)|#|$)/.test(lines[i]); i++) {
|
|
362
|
+
if (/^\s*-(\s|$)/.test(lines[i]))
|
|
363
|
+
items.push(i);
|
|
364
|
+
}
|
|
365
|
+
if (items.length > 0 && /^supersededBy:\s*(#.*)?$/.test(lines[at])) {
|
|
366
|
+
// A block list: the new item follows the last one, indented like the first.
|
|
367
|
+
const indent = /^\s*/.exec(lines[items[0]])?.[0] ?? "";
|
|
368
|
+
lines.splice(items[items.length - 1] + 1, 0, `${indent}${item(ref)}`);
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
// A flow list, a scalar or no value: this one key is written out as a block list.
|
|
372
|
+
lines.splice(at, 1, ...whole);
|
|
373
|
+
}
|
|
374
|
+
return lines.join("\n");
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Mark an asset's history in the frontmatter of its text, as a proposal's content:
|
|
378
|
+
* `{ supersededBy }` sets `beliefState: superseded` and adds the ref to the
|
|
379
|
+
* `supersededBy` list, `{ outdated }` sets `beliefState: deprecated`. Only those
|
|
380
|
+
* two keys change, by line edits that keep every other byte (comments, quoting,
|
|
381
|
+
* key order, line endings, the body). When a line edit does not give exactly the
|
|
382
|
+
* intended frontmatter (an unusual spelling of a key), the frontmatter is
|
|
383
|
+
* written out again instead. Returns `raw` when it already says what the mark sets.
|
|
384
|
+
*/
|
|
385
|
+
export function applyHistoryMark(raw, mark, file) {
|
|
386
|
+
const crlf = raw.includes("\r\n");
|
|
387
|
+
const text = crlf ? raw.replace(/\r\n/g, "\n") : raw;
|
|
388
|
+
const data = frontmatterMapping(text);
|
|
389
|
+
if (!data) {
|
|
390
|
+
throw new UsageError(`The frontmatter of ${file} is not a valid YAML mapping, so akm cannot mark it.`, "INVALID_FLAG_VALUE", "Fix the frontmatter first.");
|
|
391
|
+
}
|
|
392
|
+
const changes = historyMarkChanges(data, mark);
|
|
393
|
+
if (Object.keys(changes).length === 0)
|
|
394
|
+
return raw;
|
|
395
|
+
let out = text;
|
|
396
|
+
if (changes.beliefState !== undefined)
|
|
397
|
+
out = setBeliefState(out, changes.beliefState);
|
|
398
|
+
if (changes.supersededBy !== undefined && "supersededBy" in mark) {
|
|
399
|
+
out = setSupersededBy(out, changes.supersededBy, mark.supersededBy);
|
|
400
|
+
}
|
|
401
|
+
const intended = { ...data, ...changes };
|
|
402
|
+
if (!isDeepStrictEqual(frontmatterMapping(out), intended)) {
|
|
403
|
+
const block = parseFrontmatterBlock(text);
|
|
404
|
+
out = `---\n${serializeFrontmatter(intended)}\n---\n${block ? block.content : text}`;
|
|
405
|
+
}
|
|
406
|
+
return crlf ? out.replace(/\n/g, "\r\n") : out;
|
|
407
|
+
}
|
|
245
408
|
// ── Command definition ────────────────────────────────────────────────────────
|
|
246
409
|
export const feedbackCommand = defineJsonCommand({
|
|
247
410
|
meta: {
|
|
@@ -253,8 +416,11 @@ export const feedbackCommand = defineJsonCommand({
|
|
|
253
416
|
'the exact fix: --replace "<exact current text>" --with "<corrected text>" --source "<URL,\n' +
|
|
254
417
|
'command or file that shows it>" (repeat --replace/--with for several edits; use --with=...\n' +
|
|
255
418
|
"for a value that starts with -). akm checks that each --replace text appears exactly once\n" +
|
|
256
|
-
"and queues the edit as a proposal for review.
|
|
257
|
-
"(
|
|
419
|
+
"and queues the edit as a proposal for review. To mark the asset's history instead, add\n" +
|
|
420
|
+
"--superseded-by <ref> (the asset that replaces it) or --outdated (it describes a past\n" +
|
|
421
|
+
"state and nothing replaces it), with --reason and --source: the same proposal sets its\n" +
|
|
422
|
+
"beliefState and, for --superseded-by, adds the ref to supersededBy. `--positive` records\n" +
|
|
423
|
+
"that an asset helped (it raises its ranking) and does not trigger a rewrite.\n\n" +
|
|
258
424
|
"Both signals adjust the asset's usefulness score right away, in the same\n" +
|
|
259
425
|
"process: positive feedback raises it, negative lowers it, and recent\n" +
|
|
260
426
|
"feedback counts for more than old feedback. No reindex is needed — the new\n" +
|
|
@@ -272,7 +438,7 @@ export const feedbackCommand = defineJsonCommand({
|
|
|
272
438
|
},
|
|
273
439
|
negative: {
|
|
274
440
|
type: "boolean",
|
|
275
|
-
description: "Flag the asset: lowers its ranking immediately (no reindex needed), and the next improve run may repair its frontmatter from --reason. Attach --replace/--with/--source to correct its text.",
|
|
441
|
+
description: "Flag the asset: lowers its ranking immediately (no reindex needed), and the next improve run may repair its frontmatter from --reason. Attach --replace/--with/--source to correct its text, or --superseded-by/--outdated to mark its history.",
|
|
276
442
|
default: false,
|
|
277
443
|
},
|
|
278
444
|
reason: {
|
|
@@ -289,13 +455,16 @@ export const feedbackCommand = defineJsonCommand({
|
|
|
289
455
|
},
|
|
290
456
|
source: {
|
|
291
457
|
type: "string",
|
|
292
|
-
description: "Where the correct fact comes from: a URL, command or file. Required with --replace.",
|
|
458
|
+
description: "Where the correct fact comes from: a URL, command or file. Required with --replace, --superseded-by and --outdated.",
|
|
293
459
|
},
|
|
294
|
-
"
|
|
460
|
+
"superseded-by": {
|
|
295
461
|
type: "string",
|
|
296
|
-
description: "
|
|
297
|
-
|
|
298
|
-
|
|
462
|
+
description: "Ref of the asset that replaces this one. The fix sets beliefState: superseded and adds the ref to supersededBy (contradicted and archived stay). The ref must be indexed and not this asset. Negative feedback on a markdown asset only; may be combined with --replace/--with.",
|
|
463
|
+
},
|
|
464
|
+
outdated: {
|
|
465
|
+
type: "boolean",
|
|
466
|
+
description: "The asset describes a past state and no single asset replaces it. The fix sets beliefState: deprecated (superseded, contradicted and archived stay). Negative feedback on a markdown asset only; may be combined with --replace/--with, not with --superseded-by.",
|
|
467
|
+
default: false,
|
|
299
468
|
},
|
|
300
469
|
tag: {
|
|
301
470
|
type: "string",
|
|
@@ -323,28 +492,29 @@ export const feedbackCommand = defineJsonCommand({
|
|
|
323
492
|
}
|
|
324
493
|
const signal = args.positive ? "positive" : "negative";
|
|
325
494
|
const reason = args.reason;
|
|
326
|
-
//
|
|
327
|
-
|
|
328
|
-
if (failureMode) {
|
|
329
|
-
if (args.positive) {
|
|
330
|
-
throw new UsageError("--failure-mode is only valid for negative feedback.", "INVALID_FLAG_VALUE", "Remove --failure-mode or switch to --negative.");
|
|
331
|
-
}
|
|
332
|
-
const cfg = loadConfig();
|
|
333
|
-
const allowedModes = cfg.feedback?.allowedFailureModes ?? FEEDBACK_FAILURE_MODES;
|
|
334
|
-
if (allowedModes.length > 0 && !allowedModes.includes(failureMode)) {
|
|
335
|
-
throw new UsageError(`Invalid --failure-mode "${failureMode}". Accepted values: ${allowedModes.join(", ")}.`, "INVALID_FLAG_VALUE", `Use one of: ${allowedModes.join(", ")}`);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
// An exact fix for the asset's text: each --replace pairs with a --with, in order.
|
|
495
|
+
// A fix: an exact edit of the asset's text (each --replace pairs with a --with, in order),
|
|
496
|
+
// a mark of its history (--superseded-by or --outdated), or both.
|
|
339
497
|
const replaces = parseAllFlagValues("--replace");
|
|
340
498
|
const withs = parseAllFlagValues("--with");
|
|
341
499
|
const fixSource = args.source?.trim() || undefined;
|
|
342
500
|
const fixPairs = replaces.map((old, i) => ({ old, new: withs[i] ?? "" }));
|
|
343
|
-
|
|
501
|
+
const supersededBy = args["superseded-by"];
|
|
502
|
+
const outdated = args.outdated === true;
|
|
503
|
+
const hasMark = supersededBy !== undefined || outdated;
|
|
504
|
+
if (replaces.length > 0 || withs.length > 0 || fixSource !== undefined || hasMark) {
|
|
344
505
|
if (!args.negative) {
|
|
345
|
-
throw new UsageError("--replace, --with and --
|
|
506
|
+
throw new UsageError("--replace, --with, --source, --superseded-by and --outdated are only for negative feedback.", "INVALID_FLAG_VALUE");
|
|
507
|
+
}
|
|
508
|
+
if (supersededBy !== undefined && outdated) {
|
|
509
|
+
throw new UsageError("--superseded-by names the asset that replaces this one and --outdated says none does; use one.", "INVALID_FLAG_VALUE");
|
|
510
|
+
}
|
|
511
|
+
if (parseAllFlagValues("--superseded-by").length > 1) {
|
|
512
|
+
throw new UsageError("--superseded-by takes one ref.", "INVALID_FLAG_VALUE");
|
|
346
513
|
}
|
|
347
|
-
if (
|
|
514
|
+
if (supersededBy !== undefined && !supersededBy.trim()) {
|
|
515
|
+
throw new UsageError("--superseded-by needs the ref of the asset that replaces this one.", "MISSING_REQUIRED_ARGUMENT");
|
|
516
|
+
}
|
|
517
|
+
if (replaces.length !== withs.length || (replaces.length === 0 && !hasMark)) {
|
|
348
518
|
throw new UsageError(`Each --replace needs one --with (got ${replaces.length} --replace and ${withs.length} --with).`, "INVALID_FLAG_VALUE");
|
|
349
519
|
}
|
|
350
520
|
if (!fixSource) {
|
|
@@ -361,8 +531,7 @@ export const feedbackCommand = defineJsonCommand({
|
|
|
361
531
|
const requireReason = cfg.feedback?.requireReason ?? true; // Default: true (F-3 / #384)
|
|
362
532
|
if (requireReason) {
|
|
363
533
|
throw new UsageError("Negative feedback requires --reason: say what is wrong and what should change. " +
|
|
364
|
-
"
|
|
365
|
-
"Set feedback.requireReason: false in akm.json to downgrade to a warning.", "MISSING_REQUIRED_ARGUMENT", `Hint: akm feedback ${ref} --negative --reason "<what is wrong and what should change>" [--failure-mode incorrect|outdated|dangerous|incomplete|redundant]`);
|
|
534
|
+
"Set feedback.requireReason: false in akm.json to downgrade to a warning.", "MISSING_REQUIRED_ARGUMENT", `Hint: akm feedback ${ref} --negative --reason "<what is wrong and what should change>"`);
|
|
366
535
|
}
|
|
367
536
|
else {
|
|
368
537
|
warn("Warning: negative feedback without --reason says nothing about what is wrong.");
|
|
@@ -373,11 +542,8 @@ export const feedbackCommand = defineJsonCommand({
|
|
|
373
542
|
const metadataObj = {
|
|
374
543
|
signal,
|
|
375
544
|
...(reason?.trim() ? { reason: reason.trim() } : {}),
|
|
376
|
-
...(failureMode ? { failureMode } : {}),
|
|
377
545
|
...(validatedTags.length > 0 ? { tags: validatedTags } : {}),
|
|
378
|
-
...(fixPairs.length > 0 ? { fix: { source: fixSource, replacements: fixPairs.length } } : {}),
|
|
379
546
|
};
|
|
380
|
-
const metadataStr = Object.keys(metadataObj).length > 1 ? JSON.stringify(metadataObj) : undefined;
|
|
381
547
|
// Feedback only needs the index to exist, not to be current. A stale index
|
|
382
548
|
// is fine — the ref lookup works against any populated DB. We do NOT call
|
|
383
549
|
// ensureIndex here: it either blocks (3+ min inline reindex) or spawns a
|
|
@@ -432,23 +598,51 @@ export const feedbackCommand = defineJsonCommand({
|
|
|
432
598
|
if (!itemRef)
|
|
433
599
|
throw new UsageError(`Indexed ref "${ref}" has no durable item ref.`, "INVALID_PROPOSAL");
|
|
434
600
|
durableRef = itemRef;
|
|
435
|
-
|
|
601
|
+
const filePath = getEntryFilePathById(db, entryId);
|
|
602
|
+
const textHash = judgedTextHash(itemRef, filePath);
|
|
603
|
+
if (textHash)
|
|
604
|
+
metadataObj.contentHash = textHash;
|
|
605
|
+
if (fixPairs.length > 0 || hasMark) {
|
|
436
606
|
// Checked before anything is recorded, so a fix that does not apply leaves no trace.
|
|
437
|
-
const filePath = getEntryFilePathById(db, entryId);
|
|
438
607
|
if (!filePath || !fs.existsSync(filePath)) {
|
|
439
608
|
throw new NotFoundError(`The file for ${itemRef} is missing on disk.`, "ASSET_NOT_FOUND");
|
|
440
609
|
}
|
|
441
|
-
const
|
|
610
|
+
const assetRef = parseRefInput(itemRef);
|
|
611
|
+
const resolved = resolveMutationTarget(config, assetRef, undefined, { requireWritable: true });
|
|
442
612
|
if (!isWithin(filePath, resolved.target.source.path)) {
|
|
443
613
|
throw new UsageError(`${itemRef} is outside bundle "${resolved.target.source.name}".`);
|
|
444
614
|
}
|
|
615
|
+
assertProposalWritesFile(itemRef, resolved.target.source.path, filePath);
|
|
616
|
+
let mark;
|
|
617
|
+
if (hasMark) {
|
|
618
|
+
if (assetRef.type === "env" || assetRef.type === "secret" || !filePath.toLowerCase().endsWith(".md")) {
|
|
619
|
+
throw new UsageError(`--superseded-by and --outdated mark the frontmatter of a markdown asset, and ${itemRef} is not one.`, "INVALID_FLAG_VALUE");
|
|
620
|
+
}
|
|
621
|
+
mark =
|
|
622
|
+
supersededBy !== undefined
|
|
623
|
+
? { supersededBy: resolveSuccessorRef(db, supersededBy.trim(), itemRef) }
|
|
624
|
+
: { outdated: true };
|
|
625
|
+
}
|
|
445
626
|
const before = fs.readFileSync(filePath, "utf8");
|
|
446
|
-
|
|
627
|
+
let after = applyExactReplacements(before, fixPairs, filePath);
|
|
628
|
+
if (mark)
|
|
629
|
+
after = applyHistoryMark(after, mark, filePath);
|
|
447
630
|
if (after === before)
|
|
448
631
|
throw new UsageError("The fix changes nothing.", "INVALID_FLAG_VALUE");
|
|
449
632
|
assertFrontmatterStillParses(before, after, filePath);
|
|
450
|
-
|
|
633
|
+
// What the proposal leaves the asset saying, for the output and the event.
|
|
634
|
+
const marked = mark && {
|
|
635
|
+
beliefState: String(parseFrontmatter(after).data.beliefState),
|
|
636
|
+
...("supersededBy" in mark ? { supersededBy: mark.supersededBy } : {}),
|
|
637
|
+
};
|
|
638
|
+
fix = {
|
|
639
|
+
content: after,
|
|
640
|
+
target: { source: resolved.target.source.name, root: resolved.target.source.path },
|
|
641
|
+
...(marked ? { marked } : {}),
|
|
642
|
+
};
|
|
643
|
+
metadataObj.fix = { source: fixSource, replacements: fixPairs.length, ...marked };
|
|
451
644
|
}
|
|
645
|
+
const metadataStr = Object.keys(metadataObj).length > 1 ? JSON.stringify(metadataObj) : undefined;
|
|
452
646
|
const recordResult = recordFeedbackUsage(db, entryId, itemRef, signal, metadataStr);
|
|
453
647
|
utilityResult = recordResult.utilityResult;
|
|
454
648
|
rankingUpdateApplied = recordResult.rankingUpdateApplied;
|
|
@@ -489,7 +683,6 @@ export const feedbackCommand = defineJsonCommand({
|
|
|
489
683
|
previousUtility: utilityResult.previousUtility,
|
|
490
684
|
nextUtility: utilityResult.nextUtility,
|
|
491
685
|
reason: reason?.trim() ?? null,
|
|
492
|
-
failureMode: failureMode ?? null,
|
|
493
686
|
},
|
|
494
687
|
});
|
|
495
688
|
}
|
|
@@ -535,12 +728,20 @@ export const feedbackCommand = defineJsonCommand({
|
|
|
535
728
|
ref,
|
|
536
729
|
signal,
|
|
537
730
|
reason: reason?.trim() ?? null,
|
|
538
|
-
failureMode: failureMode ?? null,
|
|
539
731
|
tags: validatedTags,
|
|
540
732
|
rankingUpdate: rankingUpdateApplied
|
|
541
733
|
? { applied: true }
|
|
542
734
|
: { applied: false, reason: rankingUpdateSkippedReason ?? "unknown" },
|
|
543
|
-
...(fixProposal
|
|
735
|
+
...(fixProposal
|
|
736
|
+
? {
|
|
737
|
+
fix: {
|
|
738
|
+
proposalId: fixProposal.id,
|
|
739
|
+
replacements: fixPairs.length,
|
|
740
|
+
source: fixSource,
|
|
741
|
+
...fix?.marked,
|
|
742
|
+
},
|
|
743
|
+
}
|
|
744
|
+
: {}),
|
|
544
745
|
...(appliedToResult
|
|
545
746
|
? { appliedTo: { ref: appliedToResult.lessonRef, lessonStrength: appliedToResult.strength } }
|
|
546
747
|
: {}),
|
|
@@ -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
|
},
|
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
* unscoped input stays flat.
|
|
14
14
|
*/
|
|
15
15
|
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
16
17
|
import distillKnowledgeSystemPrompt from "../../assets/prompts/distill-knowledge-system.md" with { type: "text" };
|
|
17
18
|
import distillLessonSystemPrompt from "../../assets/prompts/distill-lesson-system.md" with { type: "text" };
|
|
19
|
+
import { assetPathForName, stashDirFor } from "../../core/asset/asset-placement.js";
|
|
18
20
|
import { assembleAsset, assembleAssetFromString, serializeFrontmatterQuoted } from "../../core/asset/asset-serialize.js";
|
|
19
21
|
import { parseFrontmatter, writeSalienceToFrontmatter } from "../../core/asset/frontmatter.js";
|
|
20
22
|
import { stripMarkdownFences } from "../../core/asset/markdown.js";
|
|
@@ -70,9 +72,13 @@ export function deriveLessonRef(inputRef) {
|
|
|
70
72
|
return `lessons/${safeScope ? `${safeScope}/` : ""}${clean(`${parsed.type}-${parts.join("-")}`)}-lesson`;
|
|
71
73
|
}
|
|
72
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).
|
|
73
79
|
export const DISTILL_LESSON_JSON_SCHEMA = {
|
|
74
80
|
type: "object",
|
|
75
|
-
required: ["description", "when_to_use", "body"],
|
|
81
|
+
required: ["description", "when_to_use", "body", "tags"],
|
|
76
82
|
additionalProperties: false,
|
|
77
83
|
properties: {
|
|
78
84
|
description: {
|
|
@@ -93,13 +99,13 @@ export const DISTILL_LESSON_JSON_SCHEMA = {
|
|
|
93
99
|
tags: {
|
|
94
100
|
type: "array",
|
|
95
101
|
items: { type: "string" },
|
|
96
|
-
description: "
|
|
102
|
+
description: "Tag list. Use an empty array for none; the post-processor drops it if empty.",
|
|
97
103
|
},
|
|
98
104
|
},
|
|
99
105
|
};
|
|
100
106
|
export const DISTILL_KNOWLEDGE_JSON_SCHEMA = {
|
|
101
107
|
type: "object",
|
|
102
|
-
required: ["description", "body"],
|
|
108
|
+
required: ["description", "body", "tags", "sources"],
|
|
103
109
|
additionalProperties: false,
|
|
104
110
|
properties: {
|
|
105
111
|
description: { type: "string", minLength: 1, description: "One-line summary of the knowledge asset." },
|
|
@@ -111,12 +117,12 @@ export const DISTILL_KNOWLEDGE_JSON_SCHEMA = {
|
|
|
111
117
|
tags: {
|
|
112
118
|
type: "array",
|
|
113
119
|
items: { type: "string" },
|
|
114
|
-
description: "
|
|
120
|
+
description: "Tag list. Use an empty array for none; the post-processor drops it if empty.",
|
|
115
121
|
},
|
|
116
122
|
sources: {
|
|
117
123
|
type: "array",
|
|
118
124
|
items: { type: "string" },
|
|
119
|
-
description: "
|
|
125
|
+
description: "Source refs the knowledge was distilled from. Use an empty array for none.",
|
|
120
126
|
},
|
|
121
127
|
},
|
|
122
128
|
};
|
|
@@ -253,6 +259,31 @@ const DISABLED_MESSAGE = "distill is disabled in config; enable processes.distil
|
|
|
253
259
|
function emitDistill(run, meta) {
|
|
254
260
|
appendEvent({ eventType: "distill_invoked", ref: run.ledgerRef, metadata: { ...meta, ...run.eligMeta } }, run.options.eventsCtx);
|
|
255
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* End a distill with no proposal and no failure: reported as `skipped`, which the improve loop
|
|
264
|
+
* leaves in the ledger as unchanged, as it does a reflect that changed nothing.
|
|
265
|
+
*/
|
|
266
|
+
function skipDistill(run, proposalRef, kind, skipReason, message) {
|
|
267
|
+
emitDistill(run, {
|
|
268
|
+
outcome: "skipped",
|
|
269
|
+
proposalRef,
|
|
270
|
+
proposalKind: kind,
|
|
271
|
+
skipReason,
|
|
272
|
+
message,
|
|
273
|
+
...exclusionMeta(run, false),
|
|
274
|
+
});
|
|
275
|
+
return {
|
|
276
|
+
schemaVersion: 1,
|
|
277
|
+
ok: true,
|
|
278
|
+
outcome: "skipped",
|
|
279
|
+
inputRef: run.inputRef,
|
|
280
|
+
proposalRef,
|
|
281
|
+
proposalKind: kind,
|
|
282
|
+
skipReason,
|
|
283
|
+
message,
|
|
284
|
+
...exclusionMeta(run, true),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
256
287
|
/** The exclusion diagnostics for an event (count only) or a result (count + fully-filtered). */
|
|
257
288
|
function exclusionMeta(run, forResult) {
|
|
258
289
|
if (!run.exclusion)
|
|
@@ -332,6 +363,11 @@ async function distill(run, targetKind, kind, outputRef, feedbackEvents) {
|
|
|
332
363
|
stampInputSalience(run);
|
|
333
364
|
return promoted;
|
|
334
365
|
}
|
|
366
|
+
// A lesson already at the target ref is left as it is: the proposal would overwrite it, and all 5 recorded
|
|
367
|
+
// overwrites were rejected. Checked before the call, which it would waste.
|
|
368
|
+
if (kind === "lesson" && lessonExists(run, outputRef)) {
|
|
369
|
+
return skipDistill(run, outputRef, kind, "lesson_exists", `${outputRef} already exists; distill does not overwrite a lesson.`);
|
|
370
|
+
}
|
|
335
371
|
const feedback = feedbackEvents.slice(-20).map((event) => ({
|
|
336
372
|
ts: event.ts,
|
|
337
373
|
eventType: event.eventType,
|
|
@@ -388,6 +424,19 @@ async function distill(run, targetKind, kind, outputRef, feedbackEvents) {
|
|
|
388
424
|
descriptionSwapped: assembled.descriptionSwapped,
|
|
389
425
|
});
|
|
390
426
|
}
|
|
427
|
+
/** Whether a file already holds the lesson `ref` in the stash the proposal would be filed in. */
|
|
428
|
+
function lessonExists(run, ref) {
|
|
429
|
+
const { type, name } = parseRefInput(ref);
|
|
430
|
+
const typeDir = stashDirFor(type);
|
|
431
|
+
if (!typeDir)
|
|
432
|
+
return false;
|
|
433
|
+
try {
|
|
434
|
+
return fs.statSync(assetPathForName(type, path.join(run.stash, typeDir), name)).isFile();
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
391
440
|
/** Turn the response into validated content: structured JSON or markdown, then lesson repairs and lint. */
|
|
392
441
|
function assembleDistilledContent(run, raw, kind, outputRef) {
|
|
393
442
|
const structured = parseEmbeddedJsonResponse(raw);
|
|
@@ -432,7 +481,7 @@ function qualityGateEnabled(run) {
|
|
|
432
481
|
return run.profile.processes?.distill?.qualityGate?.enabled ?? true;
|
|
433
482
|
}
|
|
434
483
|
/**
|
|
435
|
-
* Judge the distilled content, then queue it. A rejected, uncertain or
|
|
484
|
+
* Judge the distilled content, then queue it for review. A rejected, uncertain or
|
|
436
485
|
* source-contradicting result is recorded instead (see {@link writeQualityRejection}).
|
|
437
486
|
*/
|
|
438
487
|
async function judgeAndQueue(run, out) {
|
|
@@ -495,7 +544,21 @@ async function judgeAndQueue(run, out) {
|
|
|
495
544
|
...(run.options.eligibilitySource ? { eligibilitySource: run.options.eligibilitySource } : {}),
|
|
496
545
|
// The ledger keys the attempt by the input, not the output.
|
|
497
546
|
attemptedRefs: [run.ledgerRef],
|
|
498
|
-
},
|
|
547
|
+
},
|
|
548
|
+
// A pass goes to a person, never to the triage drain or its judgment tier. Staged precision was 2 of 12 on
|
|
549
|
+
// 2026-10-05: ten of the staged lessons restated their memory, claimed what it does not say, or filed a dated
|
|
550
|
+
// status as a lesson, and no judge score separated them from the two good ones. The judge's evidence stays on
|
|
551
|
+
// the decision for the reviewer. With the gate off nothing was judged, and the drain decides as before.
|
|
552
|
+
judged
|
|
553
|
+
? {
|
|
554
|
+
review: {
|
|
555
|
+
reason: "distill-review",
|
|
556
|
+
gate: "quality-gate",
|
|
557
|
+
...(judged.criteria ? { scores: judged.criteria } : {}),
|
|
558
|
+
judgeReason: judged.reason,
|
|
559
|
+
},
|
|
560
|
+
}
|
|
561
|
+
: {});
|
|
499
562
|
persistOutputEncodingSalience(run, out.ref, content);
|
|
500
563
|
const swapped = out.descriptionSwapped ? { descriptionSwapped: out.descriptionSwapped } : {};
|
|
501
564
|
emitDistill(run, {
|