akm-cli 0.9.25 → 0.9.26-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 CHANGED
@@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.26-alpha.1] - 2026-10-05
10
+
11
+ ### Changed
12
+
13
+ - **Consolidate's pair judge lists what each note alone holds before it
14
+ classifies the pair, and akm never retires a note the judge listed anything
15
+ for.** The judge also reads 12,000 characters of each note instead of 2,500.
16
+ On 650 reviewed pairs from the owner's library, 64% of the old judge's
17
+ retirements lost nothing; with the lists, 92% do, and it picks the wrong
18
+ note to keep far less often.
19
+ - **A confirmed duplicate retires unattended.** When the judge finds nothing
20
+ unique on either side, one more call asks only what the retired note holds
21
+ that the kept one lacks; an empty answer stages the proposal, and `triage`
22
+ `applyMode: "promote"` accepts it like a judged revision (it counts against
23
+ `maxAcceptsPerRun`, and a continuity risk keeps it for review). 109 of the
24
+ 111 duplicates that passed in the reviewed pairs were safe to retire, and an
25
+ accepted retirement can be undone with `akm proposal revert`. Every other
26
+ retirement still waits for `akm proposal accept`.
27
+
9
28
  ## [0.9.25] - 2026-10-04
10
29
 
11
30
  The stable release of the 0.9.25 line: 0.9.25-alpha.1 to alpha.4, unchanged. Their
@@ -0,0 +1,5 @@
1
+ You check whether deleting note X from one person's agent-memory library would lose anything. Note Y stays. List every durable claim of X that Y does not state.
2
+
3
+ A durable claim is a fact, decision, value, command, flag, path, file name, URL, number, version, name, error message, condition or rule someone would act on. Ignore wording, headings, dates of writing, tags and boilerplate. A claim is stated when Y says it in any wording or format, or replaces it with a newer value. It is not stated when Y is vaguer (a config file instead of the specific path) or drops a number, command, step or condition. Read X's description as part of X. Write each item as a short phrase that quotes its identifier. Use [] when Y states everything.
4
+
5
+ Answer ONLY with JSON: {"missing": ["..."]}
@@ -1,20 +1,19 @@
1
- You compare two assets from one person's agent-memory library (memories and knowledge notes an AI coding agent reads). Asset A is the OLDER one and asset B the NEWER one, by the dates shown.
1
+ You compare two assets from one person's agent-memory library (memories and knowledge notes an AI coding agent reads). Asset A is the OLDER one and asset B the NEWER one, by the dates shown. akm deletes one of them only when the other keeps every claim a reader would need, so your two lists decide what is safe to delete.
2
2
 
3
- Classify the relation between them as exactly one of:
3
+ First list what each asset has that the other lacks:
4
+ - "onlyInA": every durable claim of A that B neither states nor updates.
5
+ - "onlyInB": every durable claim of B that A neither states nor updates.
4
6
 
5
- - "duplicate": they state the same durable facts. Wording, title or formatting may differ, but neither adds a claim a reader would need that the other lacks.
6
- - "subsumed": one of them contains every durable claim of the other, plus more. The smaller one is redundant.
7
- - "supersedes": B updates, corrects, reverses or replaces a claim in A (a newer version, a changed decision, a fixed bug, a new value). A is now stale or wrong on that point.
7
+ A durable claim is a fact, decision, value, command, flag, path, file name, URL, number, version, name, error message, condition or rule someone would act on. Ignore wording, headings, dates of writing, tags and boilerplate. A claim is stated when the other asset says it in any wording or format. It is not stated when the other asset is vaguer (a config file instead of the specific path) or drops a number, command, step or condition. Write each item as a short phrase that quotes its identifier. Use [] when there is nothing.
8
+
9
+ Then classify the relation as exactly one of:
10
+ - "duplicate": both lists are empty.
11
+ - "subsumed": exactly one list is empty. The asset with the empty list is redundant.
12
+ - "supersedes": B updates, corrects, reverses or replaces a claim in A (a newer version, a changed decision, a fixed bug, a new value), and onlyInA is empty.
8
13
  - "contradicts": they make logically exclusive claims about the same thing and nothing shows which one is current.
9
- - "overlap": same subject, but each has durable claims the other lacks. Keeping both loses nothing; merging them would keep both sets of claims.
14
+ - "overlap": same subject, and both lists have items.
10
15
  - "unrelated": different subjects or different facts that happen to share words.
11
16
 
12
- Rules:
13
- - A durable claim is a fact, decision, value, command, path, number or rule someone would act on. Ignore dates, headings, tags and phrasing.
14
- - Prefer "overlap" over "duplicate" when either asset has a specific detail (a number, command, file, version or condition) that the other lacks.
15
- - "supersedes" needs a specific claim in A that B changes. Being newer or longer is not enough.
16
- - "contradicts" needs two claims that cannot both be true. Different scope, project or time is not a contradiction.
17
-
18
- Set "redundant" to "A" or "B" when that asset could be removed with no loss (only for "duplicate" or "subsumed"; for "duplicate" name the less complete or older one), else null. Set "stale" to "A" when the relation is "supersedes", else null.
17
+ Set "redundant" to the asset that could be deleted with no loss: "A" for "duplicate", the asset with the empty list for "subsumed", "A" for "supersedes"; else null. Set "stale" to "A" when the relation is "supersedes", else null.
19
18
 
20
- Answer ONLY with JSON: {"relation": "...", "redundant": "A"|"B"|null, "stale": "A"|null, "confidence": 0.0-1.0, "reason": "<at most 25 words>"}
19
+ Answer ONLY with JSON: {"onlyInA": ["..."], "onlyInB": ["..."], "relation": "...", "redundant": "A"|"B"|null, "stale": "A"|null, "confidence": 0.0-1.0, "reason": "<at most 25 words>"}
@@ -29,6 +29,7 @@
29
29
  import fs from "node:fs";
30
30
  import path from "node:path";
31
31
  import consolidatePairPrompt from "../../../assets/prompts/consolidate-pair.md" with { type: "text" };
32
+ import consolidatePairCheckPrompt from "../../../assets/prompts/consolidate-pair-check.md" with { type: "text" };
32
33
  import { parseFrontmatter } from "../../../core/asset/frontmatter.js";
33
34
  import { conceptIdFromTypeName } from "../../../core/asset/resolve-ref.js";
34
35
  import { asNonEmptyString } from "../../../core/common.js";
@@ -42,8 +43,8 @@ import { runGit } from "../../../sources/providers/git-install.js";
42
43
  import { closeDatabase, openExistingDatabase, openReadonlyExistingDatabase, } from "../../../storage/repositories/index-connection.js";
43
44
  import { getAllEntries, getEntryById } from "../../../storage/repositories/index-entries-repository.js";
44
45
  import { getNeighborsByEntryId } from "../../../storage/repositories/index-vec-repository.js";
45
- import { isRetireProposal } from "../../proposal/proposal-types.js";
46
- import { createRetireProposal, listProposalsReadOnly } from "../../proposal/repository.js";
46
+ import { isRetireProposal, PAIR_PASS_GATE, } from "../../proposal/proposal-types.js";
47
+ import { createRetireProposal, listProposalsReadOnly, proposalContentHash, recordGateDecision, } from "../../proposal/repository.js";
47
48
  import { isHotCapturedMemory } from "../consolidate.js";
48
49
  import { contentHash, stripFrontmatterBody } from "../content-hash.js";
49
50
  import { loadLedgerSnapshot, PAIR_PASS_LEDGER_SOURCE, recordLedgerAttempt, stripBundle } from "../ledger.js";
@@ -67,8 +68,8 @@ export const BACKFILL_FLOOR = 0.95;
67
68
  export const NEW_MATERIAL_DAYS = 7;
68
69
  /** Pairs judged per run, highest cosine first (plan §7's nightly cost budget). */
69
70
  export const MAX_PAIRS_PER_RUN = 300;
70
- /** Body characters sent to the judge per side (plan §4.3: bodies were truncated at this length for calibration). */
71
- const PAIR_BODY_TRUNCATE_CHARS = 2500;
71
+ /** Body characters sent to the judge per side: enough for nearly every note, so a retirement is judged on the whole text. */
72
+ const PAIR_BODY_TRUNCATE_CHARS = 12_000;
72
73
  const MS_PER_DAY = 86_400_000;
73
74
  const RELATION_LABELS = ["duplicate", "subsumed", "supersedes", "contradicts", "overlap", "unrelated"];
74
75
  const RETIRE_LABELS = new Set(["duplicate", "subsumed", "supersedes"]);
@@ -76,11 +77,14 @@ const RETIRE_LABELS = new Set(["duplicate", "subsumed", "supersedes"]);
76
77
  function isRetireLabel(label) {
77
78
  return RETIRE_LABELS.has(label);
78
79
  }
79
- const PAIR_JUDGE_JSON_SCHEMA = {
80
+ /** Exported, with {@link buildPairUserPrompt}, so a replay can drive the exact judge call. */
81
+ export const PAIR_JUDGE_JSON_SCHEMA = {
80
82
  type: "object",
81
- required: ["relation", "redundant", "stale", "confidence", "reason"],
83
+ required: ["onlyInA", "onlyInB", "relation", "redundant", "stale", "confidence", "reason"],
82
84
  additionalProperties: false,
83
85
  properties: {
86
+ onlyInA: { type: "array", items: { type: "string", maxLength: 200 }, maxItems: 20 },
87
+ onlyInB: { type: "array", items: { type: "string", maxLength: 200 }, maxItems: 20 },
84
88
  relation: { type: "string", enum: [...RELATION_LABELS] },
85
89
  redundant: { type: ["string", "null"], enum: ["A", "B", null] },
86
90
  stale: { type: ["string", "null"], enum: ["A", null] },
@@ -88,6 +92,12 @@ const PAIR_JUDGE_JSON_SCHEMA = {
88
92
  reason: { type: "string", maxLength: 400 },
89
93
  },
90
94
  };
95
+ const PAIR_CHECK_JSON_SCHEMA = {
96
+ type: "object",
97
+ required: ["missing"],
98
+ additionalProperties: false,
99
+ properties: { missing: { type: "array", items: { type: "string", maxLength: 200 }, maxItems: 20 } },
100
+ };
91
101
  /** Hand-validates the judge's JSON, independent of whatever the provider's own schema enforcement did. */
92
102
  export function parsePairJudgeResponse(raw) {
93
103
  const parsed = parseEmbeddedJsonResponse(raw);
@@ -103,7 +113,16 @@ export function parsePairJudgeResponse(raw) {
103
113
  return undefined;
104
114
  const confidence = Math.max(0, Math.min(1, parsed.confidence));
105
115
  const reason = typeof parsed.reason === "string" ? parsed.reason : "";
106
- return { relation: parsed.relation, redundant, confidence, reason };
116
+ const onlyInA = claimList(parsed.onlyInA);
117
+ const onlyInB = claimList(parsed.onlyInB);
118
+ if (!onlyInA || !onlyInB)
119
+ return undefined;
120
+ return { onlyInA, onlyInB, relation: parsed.relation, redundant, confidence, reason };
121
+ }
122
+ function claimList(value) {
123
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string"))
124
+ return undefined;
125
+ return value.map((v) => v.trim()).filter(Boolean);
107
126
  }
108
127
  function isFlatName(name) {
109
128
  return !name.includes("/");
@@ -445,7 +464,7 @@ function orderByAge(x, y) {
445
464
  return xIsOlder ? { older: x, newer: y } : { older: y, newer: x };
446
465
  }
447
466
  /** The user message: dates decide "A (older)" / "B (newer)" (plan Appendix A), matching the calibration sample's own ordering. */
448
- function buildPairUserPrompt(older, newer) {
467
+ export function buildPairUserPrompt(older, newer) {
449
468
  return [...sideSection("A (older)", older), ...sideSection("B (newer)", newer)].join("\n");
450
469
  }
451
470
  /** The tombstone-vocabulary reason a judge label maps to (`supersedes` -> `superseded`; the rest unchanged). */
@@ -456,19 +475,59 @@ export function tombstoneReason(label) {
456
475
  * The calibrated outcome table (owner grades, replacing plan §5.2's
457
476
  * "shorter body" rule): `duplicate`/`supersedes` keep the newer copy;
458
477
  * `subsumed` keeps the side the judge did NOT name `redundant` (no proposal
459
- * if that pointer is missing or invalid).
478
+ * if that pointer is missing or invalid). A side is retired only when the
479
+ * judge listed nothing that it alone holds.
460
480
  */
461
- export function decideRetirement(label, redundant, older, newer) {
481
+ export function decideRetirement(label, redundant, older, newer, only) {
482
+ let decision;
462
483
  if (label === "duplicate" || label === "supersedes")
463
- return { retired: older, successor: newer };
464
- if (label === "subsumed") {
465
- if (redundant === "A")
466
- return { retired: older, successor: newer };
467
- if (redundant === "B")
468
- return { retired: newer, successor: older };
469
- return undefined; // the judge's pointer is missing or invalid — no proposal
470
- }
471
- return undefined;
484
+ decision = { retired: older, successor: newer };
485
+ else if (label === "subsumed" && redundant === "A")
486
+ decision = { retired: older, successor: newer };
487
+ else if (label === "subsumed" && redundant === "B")
488
+ decision = { retired: newer, successor: older };
489
+ if (!decision)
490
+ return undefined;
491
+ return (decision.retired === older ? only.onlyInA : only.onlyInB).length === 0 ? decision : undefined;
492
+ }
493
+ function checkSection(label, side) {
494
+ return [
495
+ `Note ${label}:`,
496
+ `Ref: ${side.asset.ref}`,
497
+ `Description: ${asNonEmptyString(side.frontmatter.description) ?? "(none)"}`,
498
+ "Content:",
499
+ "```",
500
+ stripFrontmatterBody(side.raw).slice(0, PAIR_BODY_TRUNCATE_CHARS),
501
+ "```",
502
+ "",
503
+ ].join("\n");
504
+ }
505
+ /**
506
+ * The second look a duplicate gets before it may retire unattended: one call
507
+ * that asks only what the retired note holds that the kept one lacks. True
508
+ * only on a clean, empty answer (it caught 2 of 4 duplicates the judge got
509
+ * wrong, and held back none of 109 right ones).
510
+ */
511
+ async function confirmNothingLost(ctx, retired, successor) {
512
+ const outcome = await callStage({
513
+ feature: "memory_consolidation",
514
+ runner: ctx.llmRunner,
515
+ system: consolidatePairCheckPrompt,
516
+ prompt: `${checkSection("X (to delete)", retired)}\n${checkSection("Y (kept)", successor)}`,
517
+ gate: { config: ctx.config, enabled: true },
518
+ request: {
519
+ responseSchema: PAIR_CHECK_JSON_SCHEMA,
520
+ enableThinking: false,
521
+ ...(Object.hasOwn(ctx.llmRunner, "timeoutMs") ? { timeoutMs: ctx.llmRunner.timeoutMs } : {}),
522
+ signal: ctx.opts.signal,
523
+ ...(ctx.chat ? { chat: ctx.chat } : {}),
524
+ },
525
+ parse: (raw) => claimList(parseEmbeddedJsonResponse(raw)?.missing),
526
+ ...(ctx.opts.onNotices ? { onNotices: ctx.opts.onNotices } : {}),
527
+ });
528
+ if (!outcome.ok)
529
+ return false;
530
+ return claimList(parseEmbeddedJsonResponse(outcome.raw)?.missing)?.length === 0;
472
531
  }
473
532
  /**
474
533
  * One pair: judge it, then (for a retire class) apply the guards and mint
@@ -519,7 +578,7 @@ async function judgeOne(ctx, candidate) {
519
578
  return { failed: false }; // counted; stays human — no proposal, no belief write
520
579
  if (!isRetireLabel(verdict.relation))
521
580
  return { failed: false }; // overlap / unrelated: judged_no_action
522
- const decision = decideRetirement(verdict.relation, verdict.redundant, older, newer);
581
+ const decision = decideRetirement(verdict.relation, verdict.redundant, older, newer, verdict);
523
582
  if (!decision)
524
583
  return { failed: false };
525
584
  const { retired, successor } = decision;
@@ -599,6 +658,21 @@ async function judgeOne(ctx, candidate) {
599
658
  }, ctx.opts.proposalsCtx);
600
659
  ctx.retired.push(proposal.id);
601
660
  ctx.perInitiatorProposed.add(candidate.initiator.ref);
661
+ // A duplicate with nothing unique on either side, confirmed by a second
662
+ // look, is the one class that retires unattended (109 of 111 safe on the
663
+ // owner's reviewed pairs, 2026-10-04): the triage drain accepts it under
664
+ // its usual applyMode. Every other retirement waits for a person.
665
+ if (verdict.relation === "duplicate" &&
666
+ verdict.onlyInA.length + verdict.onlyInB.length === 0 &&
667
+ !continuityRisk &&
668
+ (await confirmNothingLost(ctx, retired, successor))) {
669
+ recordGateDecision(ctx.stashDir, proposal.id, {
670
+ outcome: "staged",
671
+ reason: "duplicate",
672
+ gate: PAIR_PASS_GATE,
673
+ contentHash: proposalContentHash(proposal),
674
+ }, ctx.opts.proposalsCtx);
675
+ }
602
676
  return { failed: false };
603
677
  }
604
678
  catch (error) {
@@ -30,7 +30,7 @@ import { buildExecution, resolveExecution } from "../../integrations/agent/execu
30
30
  import { assertRunnerCredentials, runExecution, } from "../../integrations/agent/runner-dispatch.js";
31
31
  import { errMessage, noticeSet } from "../improve/stage.js";
32
32
  import { akmProposalAccept, akmProposalReject } from "./proposal.js";
33
- import { isRetireProposal, STALE_TARGET_GATE_REASON } from "./proposal-types.js";
33
+ import { isRetireProposal, PAIR_PASS_GATE, STALE_TARGET_GATE_REASON } from "./proposal-types.js";
34
34
  import { listProposals, listProposalsReadOnly, preflightProposalPromotion, proposalContent, proposalContentHash, readFreshProposalTarget, recordGateDecision, } from "./repository.js";
35
35
  /** The gate label on every decision the drain records. */
36
36
  const DRAIN_GATE = "triage";
@@ -313,13 +313,21 @@ export async function drainProposals(opts, promoteFn = akmProposalAccept, reject
313
313
  const accepts = [];
314
314
  const empties = [];
315
315
  for (const proposal of pending) {
316
- // A consolidate pair-pass `retire` proposal is never auto-decided here,
317
- // whatever `applyMode` says (alpha.9 brief §A "Review"; spec §25.6):
318
- // untouched, still pending, waiting for a direct `akm proposal accept`.
319
- // Checked before isEmptyDiff, which reads proposalContent() and has
320
- // nothing meaningful to read on a delete-primary change anyway.
321
- if (isRetireProposal(proposal))
316
+ // A consolidate pair-pass `retire` proposal is auto-accepted only when the
317
+ // pair judge staged it as a duplicate with nothing unique on either side
318
+ // (spec §25.9, equivalent content); every other one waits for a direct
319
+ // `akm proposal accept` (spec §25.6). Checked before isEmptyDiff, which
320
+ // has nothing meaningful to read on a delete-primary change.
321
+ if (isRetireProposal(proposal)) {
322
+ const staged = proposal.gateDecision;
323
+ if (staged?.outcome === "staged" &&
324
+ staged.gate === PAIR_PASS_GATE &&
325
+ staged.contentHash === proposalContentHash(proposal) &&
326
+ !proposal.retirement?.continuityRisk) {
327
+ accepts.push({ id: proposal.id, reason: "duplicate" });
328
+ }
322
329
  continue;
330
+ }
323
331
  const decision = proposal.gateDecision;
324
332
  // Another gate's rejection stands, and another gate's deferral is a
325
333
  // generating stage's hand-off to a person: it is left for that person.
@@ -53,6 +53,8 @@ export function isRetireProposal(proposal) {
53
53
  }
54
54
  /** A promote refused because the target changed after mint (STALE, R20) — not a merit judgement. */
55
55
  export const STALE_TARGET_GATE_REASON = "stale-target";
56
+ /** The gate on a retire proposal the triage drain may accept unattended: a pair-judged duplicate. */
57
+ export const PAIR_PASS_GATE = "consolidate-pair";
56
58
  export const EXPIRED_GATE_REASON = "expired";
57
59
  export const ASSET_MISSING_GATE_REASON = "asset-missing";
58
60
  const PROCEDURAL_GATE_REASONS = new Set([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.25",
3
+ "version": "0.9.26-alpha.1",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cursor.",
6
6
  "keywords": [