akm-cli 0.9.26-alpha.1 → 0.9.26
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 +189 -0
- package/dist/assets/hints/cli-hints-full.md +18 -8
- package/dist/assets/hints/cli-hints-short.md +6 -5
- package/dist/assets/stash-skeleton/README.md +6 -2
- package/dist/commands/feedback-cli.js +354 -31
- package/dist/commands/improve/distill.js +61 -2
- 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 +15 -4
- package/dist/commands/proposal/repository.js +23 -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/output/shapes/helpers.js +2 -0
- package/dist/output/text/proposal-format.js +5 -0
- package/dist/scripts/akm-migrate-node.js +8 -2
- package/dist/scripts/akm-migrate.js +8 -2
- package/dist/storage/repositories/proposals-repository.js +9 -0
- package/docs/reference/cli.md +43 -16
- package/docs/reference/configuration.md +7 -4
- package/docs/reference/data-and-telemetry.md +3 -3
- package/package.json +1 -1
- package/schemas/akm-config.json +0 -14
|
@@ -2,13 +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 path from "node:path";
|
|
6
|
+
import { isDeepStrictEqual } from "node:util";
|
|
7
|
+
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
|
|
5
8
|
import { defineJsonCommand, output, parseAllFlagValues } from "../cli/shared.js";
|
|
9
|
+
import { assetPathForName, stashDirFor } from "../core/asset/asset-placement.js";
|
|
6
10
|
import { makeBundleRef, parseBundleRef } from "../core/asset/asset-ref.js";
|
|
7
|
-
import { assembleAsset } from "../core/asset/asset-serialize.js";
|
|
8
|
-
import { parseFrontmatter, parseFrontmatterBlock } from "../core/asset/frontmatter.js";
|
|
9
|
-
import { conceptIdFromTypeName, parseRefInput } from "../core/asset/resolve-ref.js";
|
|
10
|
-
import { isWithin, writeFileAtomic } from "../core/common.js";
|
|
11
|
-
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";
|
|
12
16
|
import { NotFoundError, UsageError } from "../core/errors.js";
|
|
13
17
|
import { appendEvent } from "../core/events.js";
|
|
14
18
|
import { resolveMutationTarget } from "../core/mutation-target.js";
|
|
@@ -23,6 +27,9 @@ import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
|
|
|
23
27
|
import { closeDatabase, openExistingDatabase } from "../storage/repositories/index-connection.js";
|
|
24
28
|
import { findEntryIdByRef, getEntryFilePathById, getItemRefById, } from "../storage/repositories/index-entries-repository.js";
|
|
25
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";
|
|
32
|
+
import { createProposal } from "./proposal/repository.js";
|
|
26
33
|
// ── Tag validation ────────────────────────────────────────────────────────────
|
|
27
34
|
const TAG_KEY_RE = /^[a-z_][a-z0-9_]*$/;
|
|
28
35
|
function validateFeedbackTags(raw) {
|
|
@@ -185,15 +192,235 @@ function recordFeedbackUsage(indexDb, entryId, durableEntryRef, signal, metadata
|
|
|
185
192
|
});
|
|
186
193
|
return { utilityResult, rankingUpdateApplied, rankingUpdateSkippedReason };
|
|
187
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
|
+
}
|
|
213
|
+
// ── Exact fixes ──────────────────────────────────────────────────────────────
|
|
214
|
+
/** 1-based numbers of the lines on which `needle` starts. */
|
|
215
|
+
function startLines(text, needle) {
|
|
216
|
+
const lines = [];
|
|
217
|
+
for (let at = text.indexOf(needle); at !== -1; at = text.indexOf(needle, at + 1)) {
|
|
218
|
+
lines.push(text.slice(0, at).split("\n").length);
|
|
219
|
+
}
|
|
220
|
+
return lines;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Apply `--replace`/`--with` pairs in order. Each `--replace` text must appear
|
|
224
|
+
* exactly once in the text as it stands at that point, so the edit can only
|
|
225
|
+
* land where the caller meant it.
|
|
226
|
+
*/
|
|
227
|
+
export function applyExactReplacements(text, pairs, file) {
|
|
228
|
+
let out = text;
|
|
229
|
+
pairs.forEach((pair, i) => {
|
|
230
|
+
const label = `--replace #${i + 1}`;
|
|
231
|
+
if (!pair.old)
|
|
232
|
+
throw new UsageError(`${label} is empty.`, "INVALID_FLAG_VALUE");
|
|
233
|
+
const lines = startLines(out, pair.old);
|
|
234
|
+
if (lines.length === 0) {
|
|
235
|
+
throw new UsageError(`${label} was not found in ${file}.`, "INVALID_FLAG_VALUE", "Copy the text verbatim from the file, including whitespace and punctuation.");
|
|
236
|
+
}
|
|
237
|
+
if (lines.length > 1) {
|
|
238
|
+
throw new UsageError(`${label} appears ${lines.length} times in ${file} (lines ${lines.join(", ")}).`, "INVALID_FLAG_VALUE", "Include more of the surrounding text so it appears once.");
|
|
239
|
+
}
|
|
240
|
+
const at = out.indexOf(pair.old);
|
|
241
|
+
out = out.slice(0, at) + pair.new + out.slice(at + pair.old.length);
|
|
242
|
+
});
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
/** A fix may change the frontmatter's values, but it must still parse as YAML. */
|
|
246
|
+
function assertFrontmatterStillParses(before, after, file) {
|
|
247
|
+
if (!parseFrontmatterBlock(before))
|
|
248
|
+
return;
|
|
249
|
+
const block = parseFrontmatterBlock(after);
|
|
250
|
+
let problem;
|
|
251
|
+
if (!block) {
|
|
252
|
+
problem = "the frontmatter block is gone";
|
|
253
|
+
}
|
|
254
|
+
else if (block.frontmatter.trim()) {
|
|
255
|
+
try {
|
|
256
|
+
const data = yamlParse(block.frontmatter);
|
|
257
|
+
if (typeof data !== "object" || data === null || Array.isArray(data))
|
|
258
|
+
problem = "it is no longer a mapping";
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
problem = (err instanceof Error ? err.message : String(err)).split("\n")[0];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (problem) {
|
|
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.');
|
|
266
|
+
}
|
|
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
|
+
}
|
|
188
408
|
// ── Command definition ────────────────────────────────────────────────────────
|
|
189
409
|
export const feedbackCommand = defineJsonCommand({
|
|
190
410
|
meta: {
|
|
191
411
|
name: "feedback",
|
|
192
412
|
description: "Record positive or negative feedback for any indexed bundle asset.\n\n" +
|
|
193
413
|
'`akm feedback <ref> --negative --reason "<what is wrong and what should change>"` flags\n' +
|
|
194
|
-
"the asset
|
|
195
|
-
"
|
|
196
|
-
"
|
|
414
|
+
"the asset: the next improve run may repair its description, title or when_to_use from\n" +
|
|
415
|
+
"your reason, but it does not rewrite the text. To correct a wrong fact in the text, attach\n" +
|
|
416
|
+
'the exact fix: --replace "<exact current text>" --with "<corrected text>" --source "<URL,\n' +
|
|
417
|
+
'command or file that shows it>" (repeat --replace/--with for several edits; use --with=...\n' +
|
|
418
|
+
"for a value that starts with -). akm checks that each --replace text appears exactly once\n" +
|
|
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" +
|
|
197
424
|
"Both signals adjust the asset's usefulness score right away, in the same\n" +
|
|
198
425
|
"process: positive feedback raises it, negative lowers it, and recent\n" +
|
|
199
426
|
"feedback counts for more than old feedback. No reindex is needed — the new\n" +
|
|
@@ -211,18 +438,33 @@ export const feedbackCommand = defineJsonCommand({
|
|
|
211
438
|
},
|
|
212
439
|
negative: {
|
|
213
440
|
type: "boolean",
|
|
214
|
-
description: "Flag the asset
|
|
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.",
|
|
215
442
|
default: false,
|
|
216
443
|
},
|
|
217
444
|
reason: {
|
|
218
445
|
type: "string",
|
|
219
|
-
description: "What is wrong with the asset's content and what should change
|
|
446
|
+
description: "What is wrong with the asset's content and what should change, specifically (required for negative feedback by default). Not for akm command errors.",
|
|
220
447
|
},
|
|
221
|
-
|
|
448
|
+
replace: {
|
|
222
449
|
type: "string",
|
|
223
|
-
description: "
|
|
224
|
-
|
|
225
|
-
|
|
450
|
+
description: "Exact text to correct, copied verbatim from the asset file; it must appear exactly once (repeatable, each paired with a --with in order). Negative feedback only.",
|
|
451
|
+
},
|
|
452
|
+
with: {
|
|
453
|
+
type: "string",
|
|
454
|
+
description: "Corrected text for the matching --replace (repeatable, in the same order).",
|
|
455
|
+
},
|
|
456
|
+
source: {
|
|
457
|
+
type: "string",
|
|
458
|
+
description: "Where the correct fact comes from: a URL, command or file. Required with --replace, --superseded-by and --outdated.",
|
|
459
|
+
},
|
|
460
|
+
"superseded-by": {
|
|
461
|
+
type: "string",
|
|
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,
|
|
226
468
|
},
|
|
227
469
|
tag: {
|
|
228
470
|
type: "string",
|
|
@@ -250,16 +492,36 @@ export const feedbackCommand = defineJsonCommand({
|
|
|
250
492
|
}
|
|
251
493
|
const signal = args.positive ? "positive" : "negative";
|
|
252
494
|
const reason = args.reason;
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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.
|
|
497
|
+
const replaces = parseAllFlagValues("--replace");
|
|
498
|
+
const withs = parseAllFlagValues("--with");
|
|
499
|
+
const fixSource = args.source?.trim() || undefined;
|
|
500
|
+
const fixPairs = replaces.map((old, i) => ({ old, new: withs[i] ?? "" }));
|
|
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) {
|
|
505
|
+
if (!args.negative) {
|
|
506
|
+
throw new UsageError("--replace, --with, --source, --superseded-by and --outdated are only for negative feedback.", "INVALID_FLAG_VALUE");
|
|
258
507
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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");
|
|
513
|
+
}
|
|
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)) {
|
|
518
|
+
throw new UsageError(`Each --replace needs one --with (got ${replaces.length} --replace and ${withs.length} --with).`, "INVALID_FLAG_VALUE");
|
|
519
|
+
}
|
|
520
|
+
if (!fixSource) {
|
|
521
|
+
throw new UsageError("A fix needs --source: the URL, command or file that shows the correct fact.", "MISSING_REQUIRED_ARGUMENT");
|
|
522
|
+
}
|
|
523
|
+
if (!reason?.trim()) {
|
|
524
|
+
throw new UsageError("A fix needs --reason: say what is wrong.", "MISSING_REQUIRED_ARGUMENT");
|
|
263
525
|
}
|
|
264
526
|
}
|
|
265
527
|
if (args.negative === true && !reason?.trim()) {
|
|
@@ -268,12 +530,11 @@ export const feedbackCommand = defineJsonCommand({
|
|
|
268
530
|
const cfg = loadConfig();
|
|
269
531
|
const requireReason = cfg.feedback?.requireReason ?? true; // Default: true (F-3 / #384)
|
|
270
532
|
if (requireReason) {
|
|
271
|
-
throw new UsageError("Negative feedback requires --reason:
|
|
272
|
-
"
|
|
273
|
-
"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]`);
|
|
533
|
+
throw new UsageError("Negative feedback requires --reason: say what is wrong and what should change. " +
|
|
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>"`);
|
|
274
535
|
}
|
|
275
536
|
else {
|
|
276
|
-
warn("Warning: negative feedback without --reason
|
|
537
|
+
warn("Warning: negative feedback without --reason says nothing about what is wrong.");
|
|
277
538
|
}
|
|
278
539
|
}
|
|
279
540
|
const rawTags = parseAllFlagValues("--tag");
|
|
@@ -281,10 +542,8 @@ export const feedbackCommand = defineJsonCommand({
|
|
|
281
542
|
const metadataObj = {
|
|
282
543
|
signal,
|
|
283
544
|
...(reason?.trim() ? { reason: reason.trim() } : {}),
|
|
284
|
-
...(failureMode ? { failureMode } : {}),
|
|
285
545
|
...(validatedTags.length > 0 ? { tags: validatedTags } : {}),
|
|
286
546
|
};
|
|
287
|
-
const metadataStr = Object.keys(metadataObj).length > 1 ? JSON.stringify(metadataObj) : undefined;
|
|
288
547
|
// Feedback only needs the index to exist, not to be current. A stale index
|
|
289
548
|
// is fine — the ref lookup works against any populated DB. We do NOT call
|
|
290
549
|
// ensureIndex here: it either blocks (3+ min inline reindex) or spawns a
|
|
@@ -309,6 +568,7 @@ export const feedbackCommand = defineJsonCommand({
|
|
|
309
568
|
let rankingUpdateApplied = false;
|
|
310
569
|
let rankingUpdateSkippedReason;
|
|
311
570
|
let durableRef = ref;
|
|
571
|
+
let fix;
|
|
312
572
|
const db = openExistingDatabase();
|
|
313
573
|
try {
|
|
314
574
|
const config = loadConfig();
|
|
@@ -338,6 +598,51 @@ export const feedbackCommand = defineJsonCommand({
|
|
|
338
598
|
if (!itemRef)
|
|
339
599
|
throw new UsageError(`Indexed ref "${ref}" has no durable item ref.`, "INVALID_PROPOSAL");
|
|
340
600
|
durableRef = itemRef;
|
|
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) {
|
|
606
|
+
// Checked before anything is recorded, so a fix that does not apply leaves no trace.
|
|
607
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
608
|
+
throw new NotFoundError(`The file for ${itemRef} is missing on disk.`, "ASSET_NOT_FOUND");
|
|
609
|
+
}
|
|
610
|
+
const assetRef = parseRefInput(itemRef);
|
|
611
|
+
const resolved = resolveMutationTarget(config, assetRef, undefined, { requireWritable: true });
|
|
612
|
+
if (!isWithin(filePath, resolved.target.source.path)) {
|
|
613
|
+
throw new UsageError(`${itemRef} is outside bundle "${resolved.target.source.name}".`);
|
|
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
|
+
}
|
|
626
|
+
const before = fs.readFileSync(filePath, "utf8");
|
|
627
|
+
let after = applyExactReplacements(before, fixPairs, filePath);
|
|
628
|
+
if (mark)
|
|
629
|
+
after = applyHistoryMark(after, mark, filePath);
|
|
630
|
+
if (after === before)
|
|
631
|
+
throw new UsageError("The fix changes nothing.", "INVALID_FLAG_VALUE");
|
|
632
|
+
assertFrontmatterStillParses(before, after, filePath);
|
|
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 };
|
|
644
|
+
}
|
|
645
|
+
const metadataStr = Object.keys(metadataObj).length > 1 ? JSON.stringify(metadataObj) : undefined;
|
|
341
646
|
const recordResult = recordFeedbackUsage(db, entryId, itemRef, signal, metadataStr);
|
|
342
647
|
utilityResult = recordResult.utilityResult;
|
|
343
648
|
rankingUpdateApplied = recordResult.rankingUpdateApplied;
|
|
@@ -351,6 +656,16 @@ export const feedbackCommand = defineJsonCommand({
|
|
|
351
656
|
ref: durableRef,
|
|
352
657
|
metadata: metadataObj,
|
|
353
658
|
});
|
|
659
|
+
const fixProposal = fix && reason?.trim() && fixSource
|
|
660
|
+
? createProposal(resolveStashDir(), {
|
|
661
|
+
ref: durableRef,
|
|
662
|
+
itemRef: durableRef,
|
|
663
|
+
target: fix.target,
|
|
664
|
+
source: "feedback",
|
|
665
|
+
payload: { content: fix.content },
|
|
666
|
+
feedback: { reason: reason.trim(), source: fixSource },
|
|
667
|
+
})
|
|
668
|
+
: undefined;
|
|
354
669
|
// F-5 / #386: When a high-utility asset crosses below the review threshold,
|
|
355
670
|
// auto-create a review-needed escalation proposal so a human can confirm
|
|
356
671
|
// whether the negative feedback is valid before the asset falls out of
|
|
@@ -368,7 +683,6 @@ export const feedbackCommand = defineJsonCommand({
|
|
|
368
683
|
previousUtility: utilityResult.previousUtility,
|
|
369
684
|
nextUtility: utilityResult.nextUtility,
|
|
370
685
|
reason: reason?.trim() ?? null,
|
|
371
|
-
failureMode: failureMode ?? null,
|
|
372
686
|
},
|
|
373
687
|
});
|
|
374
688
|
}
|
|
@@ -414,11 +728,20 @@ export const feedbackCommand = defineJsonCommand({
|
|
|
414
728
|
ref,
|
|
415
729
|
signal,
|
|
416
730
|
reason: reason?.trim() ?? null,
|
|
417
|
-
failureMode: failureMode ?? null,
|
|
418
731
|
tags: validatedTags,
|
|
419
732
|
rankingUpdate: rankingUpdateApplied
|
|
420
733
|
? { applied: true }
|
|
421
734
|
: { applied: false, reason: rankingUpdateSkippedReason ?? "unknown" },
|
|
735
|
+
...(fixProposal
|
|
736
|
+
? {
|
|
737
|
+
fix: {
|
|
738
|
+
proposalId: fixProposal.id,
|
|
739
|
+
replacements: fixPairs.length,
|
|
740
|
+
source: fixSource,
|
|
741
|
+
...fix?.marked,
|
|
742
|
+
},
|
|
743
|
+
}
|
|
744
|
+
: {}),
|
|
422
745
|
...(appliedToResult
|
|
423
746
|
? { appliedTo: { ref: appliedToResult.lessonRef, lessonStrength: appliedToResult.strength } }
|
|
424
747
|
: {}),
|
|
@@ -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";
|
|
@@ -253,6 +255,31 @@ const DISABLED_MESSAGE = "distill is disabled in config; enable processes.distil
|
|
|
253
255
|
function emitDistill(run, meta) {
|
|
254
256
|
appendEvent({ eventType: "distill_invoked", ref: run.ledgerRef, metadata: { ...meta, ...run.eligMeta } }, run.options.eventsCtx);
|
|
255
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* End a distill with no proposal and no failure: reported as `skipped`, which the improve loop
|
|
260
|
+
* leaves in the ledger as unchanged, as it does a reflect that changed nothing.
|
|
261
|
+
*/
|
|
262
|
+
function skipDistill(run, proposalRef, kind, skipReason, message) {
|
|
263
|
+
emitDistill(run, {
|
|
264
|
+
outcome: "skipped",
|
|
265
|
+
proposalRef,
|
|
266
|
+
proposalKind: kind,
|
|
267
|
+
skipReason,
|
|
268
|
+
message,
|
|
269
|
+
...exclusionMeta(run, false),
|
|
270
|
+
});
|
|
271
|
+
return {
|
|
272
|
+
schemaVersion: 1,
|
|
273
|
+
ok: true,
|
|
274
|
+
outcome: "skipped",
|
|
275
|
+
inputRef: run.inputRef,
|
|
276
|
+
proposalRef,
|
|
277
|
+
proposalKind: kind,
|
|
278
|
+
skipReason,
|
|
279
|
+
message,
|
|
280
|
+
...exclusionMeta(run, true),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
256
283
|
/** The exclusion diagnostics for an event (count only) or a result (count + fully-filtered). */
|
|
257
284
|
function exclusionMeta(run, forResult) {
|
|
258
285
|
if (!run.exclusion)
|
|
@@ -332,6 +359,11 @@ async function distill(run, targetKind, kind, outputRef, feedbackEvents) {
|
|
|
332
359
|
stampInputSalience(run);
|
|
333
360
|
return promoted;
|
|
334
361
|
}
|
|
362
|
+
// A lesson already at the target ref is left as it is: the proposal would overwrite it, and all 5 recorded
|
|
363
|
+
// overwrites were rejected. Checked before the call, which it would waste.
|
|
364
|
+
if (kind === "lesson" && lessonExists(run, outputRef)) {
|
|
365
|
+
return skipDistill(run, outputRef, kind, "lesson_exists", `${outputRef} already exists; distill does not overwrite a lesson.`);
|
|
366
|
+
}
|
|
335
367
|
const feedback = feedbackEvents.slice(-20).map((event) => ({
|
|
336
368
|
ts: event.ts,
|
|
337
369
|
eventType: event.eventType,
|
|
@@ -388,6 +420,19 @@ async function distill(run, targetKind, kind, outputRef, feedbackEvents) {
|
|
|
388
420
|
descriptionSwapped: assembled.descriptionSwapped,
|
|
389
421
|
});
|
|
390
422
|
}
|
|
423
|
+
/** Whether a file already holds the lesson `ref` in the stash the proposal would be filed in. */
|
|
424
|
+
function lessonExists(run, ref) {
|
|
425
|
+
const { type, name } = parseRefInput(ref);
|
|
426
|
+
const typeDir = stashDirFor(type);
|
|
427
|
+
if (!typeDir)
|
|
428
|
+
return false;
|
|
429
|
+
try {
|
|
430
|
+
return fs.statSync(assetPathForName(type, path.join(run.stash, typeDir), name)).isFile();
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
391
436
|
/** Turn the response into validated content: structured JSON or markdown, then lesson repairs and lint. */
|
|
392
437
|
function assembleDistilledContent(run, raw, kind, outputRef) {
|
|
393
438
|
const structured = parseEmbeddedJsonResponse(raw);
|
|
@@ -432,7 +477,7 @@ function qualityGateEnabled(run) {
|
|
|
432
477
|
return run.profile.processes?.distill?.qualityGate?.enabled ?? true;
|
|
433
478
|
}
|
|
434
479
|
/**
|
|
435
|
-
* Judge the distilled content, then queue it. A rejected, uncertain or
|
|
480
|
+
* Judge the distilled content, then queue it for review. A rejected, uncertain or
|
|
436
481
|
* source-contradicting result is recorded instead (see {@link writeQualityRejection}).
|
|
437
482
|
*/
|
|
438
483
|
async function judgeAndQueue(run, out) {
|
|
@@ -495,7 +540,21 @@ async function judgeAndQueue(run, out) {
|
|
|
495
540
|
...(run.options.eligibilitySource ? { eligibilitySource: run.options.eligibilitySource } : {}),
|
|
496
541
|
// The ledger keys the attempt by the input, not the output.
|
|
497
542
|
attemptedRefs: [run.ledgerRef],
|
|
498
|
-
},
|
|
543
|
+
},
|
|
544
|
+
// A pass goes to a person, never to the triage drain or its judgment tier. Staged precision was 2 of 12 on
|
|
545
|
+
// 2026-10-05: ten of the staged lessons restated their memory, claimed what it does not say, or filed a dated
|
|
546
|
+
// status as a lesson, and no judge score separated them from the two good ones. The judge's evidence stays on
|
|
547
|
+
// the decision for the reviewer. With the gate off nothing was judged, and the drain decides as before.
|
|
548
|
+
judged
|
|
549
|
+
? {
|
|
550
|
+
review: {
|
|
551
|
+
reason: "distill-review",
|
|
552
|
+
gate: "quality-gate",
|
|
553
|
+
...(judged.criteria ? { scores: judged.criteria } : {}),
|
|
554
|
+
judgeReason: judged.reason,
|
|
555
|
+
},
|
|
556
|
+
}
|
|
557
|
+
: {});
|
|
499
558
|
persistOutputEncodingSalience(run, out.ref, content);
|
|
500
559
|
const swapped = out.descriptionSwapped ? { descriptionSwapped: out.descriptionSwapped } : {};
|
|
501
560
|
emitDistill(run, {
|
|
@@ -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())
|