akm-cli 0.9.0-beta.4 → 0.9.0-beta.40

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.
Files changed (131) hide show
  1. package/CHANGELOG.md +626 -0
  2. package/dist/assets/prompts/consolidate-system.md +23 -0
  3. package/dist/assets/prompts/contradiction-judge.md +33 -0
  4. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  5. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  6. package/dist/assets/prompts/extract-session.md +6 -2
  7. package/dist/assets/prompts/graph-extract-system.md +1 -0
  8. package/dist/assets/prompts/memory-infer-system.md +1 -0
  9. package/dist/assets/prompts/memory-infer-user.md +5 -0
  10. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  11. package/dist/assets/prompts/procedural-system.md +44 -0
  12. package/dist/assets/prompts/recombine-system.md +40 -0
  13. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  14. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  15. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +22 -0
  16. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +22 -0
  17. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +24 -0
  18. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +22 -0
  19. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +25 -0
  20. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +21 -0
  21. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +21 -0
  22. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +23 -0
  23. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +22 -0
  24. package/dist/assets/templates/html/health.html +281 -111
  25. package/dist/cli.js +14 -3
  26. package/dist/commands/agent/contribute-cli.js +16 -3
  27. package/dist/commands/feedback-cli.js +15 -6
  28. package/dist/commands/graph/graph.js +75 -71
  29. package/dist/commands/health/checks.js +48 -0
  30. package/dist/commands/health/html-report.js +422 -80
  31. package/dist/commands/health.js +381 -9
  32. package/dist/commands/improve/calibration.js +161 -0
  33. package/dist/commands/improve/consolidate.js +631 -111
  34. package/dist/commands/improve/dedup.js +482 -0
  35. package/dist/commands/improve/distill.js +163 -69
  36. package/dist/commands/improve/encoding-salience.js +205 -0
  37. package/dist/commands/improve/extract-cli.js +115 -1
  38. package/dist/commands/improve/extract-prompt.js +39 -2
  39. package/dist/commands/improve/extract-watch.js +140 -0
  40. package/dist/commands/improve/extract.js +403 -40
  41. package/dist/commands/improve/feedback-valence.js +54 -0
  42. package/dist/commands/improve/homeostatic.js +467 -0
  43. package/dist/commands/improve/improve-auto-accept.js +113 -6
  44. package/dist/commands/improve/improve-profiles.js +12 -0
  45. package/dist/commands/improve/improve.js +2042 -612
  46. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  47. package/dist/commands/improve/outcome-loop.js +256 -0
  48. package/dist/commands/improve/proactive-maintenance.js +115 -0
  49. package/dist/commands/improve/procedural.js +418 -0
  50. package/dist/commands/improve/recombine.js +602 -0
  51. package/dist/commands/improve/reflect-noise.js +0 -0
  52. package/dist/commands/improve/reflect.js +46 -4
  53. package/dist/commands/improve/related-sessions.js +120 -0
  54. package/dist/commands/improve/salience.js +438 -0
  55. package/dist/commands/improve/triage.js +93 -0
  56. package/dist/commands/lint/agent-linter.js +19 -24
  57. package/dist/commands/lint/base-linter.js +173 -60
  58. package/dist/commands/lint/command-linter.js +19 -24
  59. package/dist/commands/lint/env-key-rules.js +34 -1
  60. package/dist/commands/lint/fact-linter.js +39 -0
  61. package/dist/commands/lint/index.js +31 -13
  62. package/dist/commands/lint/memory-linter.js +1 -1
  63. package/dist/commands/lint/registry.js +7 -2
  64. package/dist/commands/lint/task-linter.js +3 -3
  65. package/dist/commands/lint/workflow-linter.js +26 -1
  66. package/dist/commands/proposal/drain-policies.js +5 -0
  67. package/dist/commands/proposal/drain.js +17 -1
  68. package/dist/commands/proposal/proposal.js +5 -0
  69. package/dist/commands/proposal/propose.js +5 -0
  70. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  71. package/dist/commands/proposal/validators/proposals.js +187 -57
  72. package/dist/commands/read/curate.js +344 -80
  73. package/dist/commands/read/search-cli.js +7 -0
  74. package/dist/commands/read/search.js +1 -0
  75. package/dist/commands/read/show.js +67 -2
  76. package/dist/commands/sources/init.js +36 -9
  77. package/dist/commands/sources/installed-stashes.js +5 -1
  78. package/dist/commands/sources/schema-repair.js +13 -1
  79. package/dist/commands/sources/stash-cli.js +19 -3
  80. package/dist/commands/sources/stash-skeleton.js +23 -8
  81. package/dist/core/asset/asset-registry.js +2 -0
  82. package/dist/core/asset/asset-spec.js +14 -0
  83. package/dist/core/asset/frontmatter.js +166 -167
  84. package/dist/core/asset/markdown.js +8 -0
  85. package/dist/core/authoring-rules.js +83 -0
  86. package/dist/core/config/config-schema.js +274 -2
  87. package/dist/core/config/config.js +2 -2
  88. package/dist/core/logs-db.js +4 -3
  89. package/dist/core/paths.js +3 -0
  90. package/dist/core/standards/resolve-standards-context.js +87 -0
  91. package/dist/core/standards/resolve-stash-standards.js +99 -0
  92. package/dist/core/standards/resolve-type-conventions.js +66 -0
  93. package/dist/core/state-db.js +691 -30
  94. package/dist/indexer/db/db.js +364 -38
  95. package/dist/indexer/db/graph-db.js +129 -86
  96. package/dist/indexer/ensure-index.js +152 -17
  97. package/dist/indexer/graph/graph-boost.js +51 -41
  98. package/dist/indexer/graph/graph-extraction.js +203 -3
  99. package/dist/indexer/index-writer-lock.js +99 -0
  100. package/dist/indexer/indexer.js +114 -111
  101. package/dist/indexer/passes/memory-inference.js +10 -3
  102. package/dist/indexer/passes/staleness-detect.js +2 -5
  103. package/dist/indexer/search/db-search.js +15 -4
  104. package/dist/indexer/search/ranking-contributors.js +22 -0
  105. package/dist/indexer/search/ranking.js +4 -0
  106. package/dist/indexer/walk/matchers.js +9 -0
  107. package/dist/integrations/agent/prompts.js +33 -0
  108. package/dist/integrations/harnesses/claude/session-log.js +11 -1
  109. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  110. package/dist/integrations/session-logs/index.js +16 -0
  111. package/dist/llm/client.js +23 -4
  112. package/dist/llm/embedder.js +27 -3
  113. package/dist/llm/embedders/local.js +66 -2
  114. package/dist/llm/feature-gate.js +8 -4
  115. package/dist/llm/graph-extract.js +2 -1
  116. package/dist/llm/memory-infer.js +4 -8
  117. package/dist/llm/metadata-enhance.js +9 -1
  118. package/dist/output/renderers.js +73 -1
  119. package/dist/output/shapes/curate.js +14 -2
  120. package/dist/output/text/helpers.js +16 -1
  121. package/dist/runtime.js +25 -1
  122. package/dist/scripts/migrate-storage.js +1378 -599
  123. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +479 -270
  124. package/dist/setup/setup.js +3 -3
  125. package/dist/sources/providers/tar-utils.js +16 -8
  126. package/dist/storage/sqlite-pragmas.js +146 -0
  127. package/dist/wiki/wiki.js +37 -0
  128. package/dist/workflows/db.js +3 -4
  129. package/dist/workflows/validate-summary.js +2 -7
  130. package/docs/data-and-telemetry.md +1 -0
  131. package/package.json +8 -6
@@ -0,0 +1,602 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * #609 — recombine / synthesize pass.
6
+ *
7
+ * A whole-corpus synthesis stage that runs AFTER consolidation and is OPT-IN
8
+ * (default disabled via `IMPROVE_PROCESS_DEFAULTS.recombine`). It clusters
9
+ * memories by RELATEDNESS (shared tags / graph entities — NEVER embedding
10
+ * similarity), issues ONE bounded LLM call per cluster to induce a single
11
+ * cross-episodic generalization, and emits the result as a NORMAL pending
12
+ * proposal with frontmatter `type: hypothesis` through the existing proposal
13
+ * queue + quality gate.
14
+ *
15
+ * Two-pass contract: the first pass ONLY ever emits `type: hypothesis`
16
+ * proposals — never a `type: lesson`. Promotion to a lesson happens on a later
17
+ * confirmation run once the same generalization has been re-induced
18
+ * `confirmThreshold` times (#625). The confirmation count is persisted in the
19
+ * `recombine_hypotheses` state.db table (migration 014), keyed by the
20
+ * deterministic `deriveRecombineLessonRef` value so re-induction of the SAME
21
+ * member-set maps back to the SAME row. When the count reaches the threshold,
22
+ * the run emits ONE `type: lesson` promotion proposal through the SAME proposal
23
+ * queue + quality gate (createProposal + validateProposalFrontmatter), NEVER a
24
+ * direct stash write, then marks the row promoted (resetting its count) so it is
25
+ * not re-promoted on every subsequent run. Hypotheses NOT re-induced in a run
26
+ * have their consecutive streak reset (decay-to-zero).
27
+ *
28
+ * NAMESPACE note: the ref stays `lesson:recombined/<slug>-<hash>` for BOTH
29
+ * passes. The ref is the promotion TARGET asset (a lesson in both the hypothesis
30
+ * and promoted states), so re-induction must map to the same ref and the ref
31
+ * cannot encode the proposal type. The hypothesis-vs-lesson distinction is
32
+ * carried ONLY by the proposal frontmatter `type` field. On promotion the prior
33
+ * pending `type: hypothesis` proposal for that ref is superseded (rejected) so
34
+ * the queue never shows two proposals for one ref.
35
+ *
36
+ * A justified null (the LLM determines no defensible generalization exists) is
37
+ * an acceptable outcome: it produces no proposal and records a
38
+ * `recombine_invoked` event with `outcome: 'null_returned'`.
39
+ */
40
+ import { createHash } from "node:crypto";
41
+ import fs from "node:fs";
42
+ import recombineSystemPrompt from "../../assets/prompts/recombine-system.md" with { type: "text" };
43
+ import { parseFrontmatter } from "../../core/asset/frontmatter.js";
44
+ import { resolveStashDir } from "../../core/common.js";
45
+ import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
46
+ import { appendEvent } from "../../core/events.js";
47
+ import { parseEmbeddedJsonResponse } from "../../core/parse.js";
48
+ import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
49
+ import { decayUnseenRecombineHypotheses, findMatchingRecombineHypothesis, getRecombineHypothesis, getStateDbPath, markRecombineHypothesisPromoted, openStateDatabase, recordRecombineInduction, } from "../../core/state-db.js";
50
+ import { warn } from "../../core/warn.js";
51
+ import { closeDatabase, getAllEntries, getEntitiesByEntryIds, openExistingDatabase, } from "../../indexer/db/db.js";
52
+ import { resolveImproveProcessRunnerFromProfile, runnerIsLlm } from "../../integrations/agent/runner.js";
53
+ import { chatCompletion } from "../../llm/client.js";
54
+ import { validateProposalFrontmatter } from "../proposal/validators/proposal-quality-validators.js";
55
+ import { archiveProposal, createProposal, isProposalSkipped, listProposals } from "../proposal/validators/proposals.js";
56
+ import { isConsolidationEligibleMemoryName } from "./consolidate.js";
57
+ const RECOMBINE_SYSTEM_PROMPT = recombineSystemPrompt;
58
+ const DEFAULT_MIN_CLUSTER_SIZE = 3;
59
+ const DEFAULT_MAX_CLUSTERS_PER_RUN = 5;
60
+ const DEFAULT_RELATEDNESS_SOURCE = "tags";
61
+ /** #625 — re-induction count required before a hypothesis promotes to a lesson. */
62
+ const DEFAULT_CONFIRM_THRESHOLD = 2;
63
+ /**
64
+ * #633 — Jaccard membership-overlap threshold for matching a freshly-induced
65
+ * hypothesis to an existing pending row under the SAME signature. A growing
66
+ * stash drifts the exact member set every run; an overlap >= this lets the
67
+ * confirmation streak keep accumulating under one row instead of resetting to 1.
68
+ */
69
+ const DEFAULT_RECOMBINE_OVERLAP = 0.7;
70
+ // ── Clustering by relatedness (NOT similarity) ────────────────────────────────
71
+ /**
72
+ * #632 — English stopwords that occasionally leak into frontmatter tags
73
+ * (`is`, `the`, `for`, …). They carry no topical signal, so a cluster keyed on
74
+ * one is meaningless. Lowercased; matched case-insensitively.
75
+ */
76
+ const JUNK_STOPWORD_TAGS = new Set([
77
+ "a",
78
+ "an",
79
+ "and",
80
+ "the",
81
+ "to",
82
+ "of",
83
+ "in",
84
+ "on",
85
+ "for",
86
+ "is",
87
+ "are",
88
+ "be",
89
+ "no",
90
+ "not",
91
+ "or",
92
+ "if",
93
+ "it",
94
+ "as",
95
+ "at",
96
+ "by",
97
+ "we",
98
+ "us",
99
+ "do",
100
+ "so",
101
+ "when",
102
+ "then",
103
+ "than",
104
+ "with",
105
+ "from",
106
+ "this",
107
+ "that",
108
+ "uses",
109
+ "use",
110
+ "via",
111
+ ]);
112
+ /**
113
+ * #632 — a tag carries no clustering signal (and must be skipped) when it is
114
+ * purely a number / date / hash / version string, a single char, or a common
115
+ * stopword. Unlike `excludeTags` (a fixed project list), this catches the
116
+ * OPEN-ENDED junk — every new date or commit hash — without config upkeep.
117
+ */
118
+ export function isJunkTag(tag) {
119
+ const t = tag.trim().toLowerCase();
120
+ if (t.length <= 1)
121
+ return true;
122
+ if (JUNK_STOPWORD_TAGS.has(t))
123
+ return true;
124
+ if (/^\d+$/.test(t))
125
+ return true; // pure numbers + dates: 2026, 05, 23, 20260529
126
+ if (/^v?\d+(?:\.\d+)+$/.test(t))
127
+ return true; // versions: 0.8.0, v1.2
128
+ if (/^v\d+$/.test(t))
129
+ return true; // v0, v2
130
+ if (/^[0-9a-f]{4,}$/.test(t) && /\d/.test(t))
131
+ return true; // short hex hashes: 002c624c, 192d
132
+ return false;
133
+ }
134
+ /**
135
+ * Build relatedness clusters from the memory pool. Clustering is driven purely
136
+ * by shared tags / graph entities — it MUST NOT use embedding similarity, so
137
+ * textually near-identical memories that share no relatedness signal never
138
+ * cluster together.
139
+ *
140
+ * For `relatednessSource`:
141
+ * - `"tags"` — group by each frontmatter tag.
142
+ * - `"graph"` — group by shared `graph_file_entities.entity_norm`; falls back
143
+ * to tags when the graph table is empty (fail-open).
144
+ * - `"both"` — union of the tag and entity grouping keys.
145
+ *
146
+ * A cluster is a signal whose member set is >= `minClusterSize`. Overlapping
147
+ * clusters are de-duplicated by member-set identity, and the result is capped
148
+ * to `maxClustersPerRun` by member-count descending.
149
+ */
150
+ export function buildRelatednessClusters(entries, opts) {
151
+ // Only consolidation-eligible memories participate (exclude `.derived`).
152
+ const memories = entries.filter((e) => e.entry.type === "memory" && isConsolidationEligibleMemoryName(e.entry.name));
153
+ // signal -> member entries
154
+ const groups = new Map();
155
+ const add = (signal, entry) => {
156
+ const key = signal.trim();
157
+ if (!key)
158
+ return;
159
+ const list = groups.get(key);
160
+ if (list) {
161
+ if (!list.includes(entry))
162
+ list.push(entry);
163
+ }
164
+ else {
165
+ groups.set(key, [entry]);
166
+ }
167
+ };
168
+ const useTags = opts.relatednessSource === "tags" || opts.relatednessSource === "both";
169
+ // Graph relatedness falls open to tags when no entities are available.
170
+ const hasEntities = !!opts.entityByEntryId && opts.entityByEntryId.size > 0;
171
+ const useGraph = (opts.relatednessSource === "graph" || opts.relatednessSource === "both") && hasEntities;
172
+ const tagsFallback = !useTags && opts.relatednessSource === "graph" && !hasEntities;
173
+ // #632 — tags excluded from tag-based clustering (applies regardless of
174
+ // source). UNSET/[] leaves clustering byte-identical to the pre-#632 path.
175
+ const excludeTags = new Set(opts.excludeTags ?? []);
176
+ for (const entry of memories) {
177
+ if (useTags || tagsFallback) {
178
+ for (const tag of entry.entry.tags ?? []) {
179
+ if (excludeTags.has(tag))
180
+ continue;
181
+ if (isJunkTag(tag))
182
+ continue; // #632 — skip numeric/date/hash/version/stopword junk
183
+ add(`tag:${tag}`, entry);
184
+ }
185
+ }
186
+ if (useGraph && opts.entityByEntryId) {
187
+ for (const ent of opts.entityByEntryId.get(entry.id) ?? [])
188
+ add(`entity:${ent}`, entry);
189
+ }
190
+ }
191
+ // Keep only groups at or above the minimum cluster size. #632 — when
192
+ // maxClusterSize is set, also SKIP groups strictly larger than the cap so an
193
+ // over-broad bucket never reaches (and starves) the largest-first slice.
194
+ // UNSET = no upper bound = identical to the pre-#632 behaviour.
195
+ let clusters = [];
196
+ for (const [signature, members] of groups) {
197
+ if (members.length < opts.minClusterSize)
198
+ continue;
199
+ if (opts.maxClusterSize != null && members.length > opts.maxClusterSize)
200
+ continue;
201
+ clusters.push({ signature, members });
202
+ }
203
+ // De-duplicate clusters that share the exact same member set (e.g. a tag and
204
+ // an entity that co-occur on the same trio). Keep the first by signature.
205
+ const seenMemberKeys = new Set();
206
+ clusters = clusters.filter((c) => {
207
+ const memberKey = c.members
208
+ .map((m) => m.id)
209
+ .sort((a, b) => a - b)
210
+ .join(",");
211
+ if (seenMemberKeys.has(memberKey))
212
+ return false;
213
+ seenMemberKeys.add(memberKey);
214
+ return true;
215
+ });
216
+ // Cap to maxClustersPerRun, largest clusters first (deterministic tiebreak).
217
+ clusters.sort((a, b) => b.members.length - a.members.length || a.signature.localeCompare(b.signature));
218
+ return clusters.slice(0, Math.max(0, opts.maxClustersPerRun));
219
+ }
220
+ // ── Prompt + ref derivation ───────────────────────────────────────────────────
221
+ /** Read a memory body (frontmatter stripped) for the cluster prompt. */
222
+ function readBody(entry) {
223
+ try {
224
+ const raw = fs.readFileSync(entry.filePath, "utf8");
225
+ return parseFrontmatter(raw).content.trim();
226
+ }
227
+ catch {
228
+ return "";
229
+ }
230
+ }
231
+ /** Assemble the per-cluster user prompt fed to the recombine LLM. */
232
+ export function buildClusterPrompt(cluster, standardsContext = "") {
233
+ const lines = [
234
+ `Shared signal: ${cluster.signature}`,
235
+ `Cluster of ${cluster.members.length} related memories:`,
236
+ "",
237
+ ];
238
+ if (standardsContext.trim()) {
239
+ lines.push("Standards to follow (the rulebook for this target):");
240
+ lines.push(standardsContext.trim());
241
+ lines.push("");
242
+ }
243
+ for (const m of cluster.members) {
244
+ lines.push(`[memory:${m.entry.name}]`);
245
+ if (m.entry.description)
246
+ lines.push(`Description: ${m.entry.description}`);
247
+ const body = readBody(m);
248
+ if (body)
249
+ lines.push(body);
250
+ lines.push("");
251
+ }
252
+ lines.push("Induce ONE cross-episodic generalization these memories support, or return an explicit null if none is defensible.");
253
+ return lines.join("\n");
254
+ }
255
+ /**
256
+ * Stable lesson ref for a cluster. The hash of the sorted member refs keeps the
257
+ * ref deterministic across runs (so re-induction maps to the same ref + the
258
+ * content-hash dedup in createProposal suppresses queue churn).
259
+ */
260
+ export function deriveRecombineLessonRef(cluster) {
261
+ const slug = cluster.signature
262
+ .replace(/^(tag|entity):/, "")
263
+ .toLowerCase()
264
+ .replace(/[^a-z0-9-]+/g, "-")
265
+ .replace(/-+/g, "-")
266
+ .replace(/^-|-$/g, "");
267
+ const memberKey = recombineMemberKey(cluster);
268
+ const hash = createHash("sha256").update(memberKey, "utf8").digest("hex").slice(0, 8);
269
+ return `lesson:recombined/${slug || "cluster"}-${hash}`;
270
+ }
271
+ /**
272
+ * The membership fingerprint of a cluster: its member entryKeys sorted and
273
+ * joined. Single source of truth shared by {@link deriveRecombineLessonRef}'s
274
+ * hash and the `recombine_hypotheses.member_key` column, so the table key and
275
+ * the ref hash always derive from the SAME member set. Adding/removing one
276
+ * memory yields a different fingerprint → a different ref → a fresh row (the
277
+ * old streak is correctly NOT inherited).
278
+ */
279
+ export function recombineMemberKey(cluster) {
280
+ return cluster.members
281
+ .map((m) => m.entryKey)
282
+ .sort()
283
+ .join("|");
284
+ }
285
+ /** Parse the raw LLM output into a generalization, or `null` for the justified-null path. */
286
+ function parseGeneralization(raw) {
287
+ if (raw === null)
288
+ return null;
289
+ const trimmed = raw.trim();
290
+ if (!trimmed || trimmed.toLowerCase() === "null")
291
+ return null;
292
+ const parsed = parseEmbeddedJsonResponse(trimmed);
293
+ if (parsed === undefined || parsed === null)
294
+ return null;
295
+ if (typeof parsed !== "object")
296
+ return null;
297
+ const obj = parsed;
298
+ const description = typeof obj.description === "string" ? obj.description : "";
299
+ const body = typeof obj.body === "string" ? obj.body : "";
300
+ const when_to_use = typeof obj.when_to_use === "string" ? obj.when_to_use : undefined;
301
+ // An empty object / all-empty fields is treated as a justified null.
302
+ if (!description && !body)
303
+ return null;
304
+ return { description, body, ...(when_to_use ? { when_to_use } : {}) };
305
+ }
306
+ /**
307
+ * Resolve the production LLM seam from the active improve profile. Returns a
308
+ * `RecombineLlmFn` that issues one bounded chatCompletion per call, or
309
+ * `undefined` when no LLM is configured (the pass then makes no calls).
310
+ */
311
+ function resolveProductionLlmFn(config, signal) {
312
+ const recombineProcess = config.profiles?.improve?.default?.processes?.recombine;
313
+ const runnerSpec = resolveImproveProcessRunnerFromProfile(recombineProcess, config);
314
+ const llmConfig = runnerSpec && runnerIsLlm(runnerSpec) ? runnerSpec.connection : getDefaultLlmConfig(config);
315
+ if (!llmConfig)
316
+ return undefined;
317
+ return async (clusterPrompt) => {
318
+ const messages = [
319
+ { role: "system", content: RECOMBINE_SYSTEM_PROMPT },
320
+ { role: "user", content: clusterPrompt },
321
+ ];
322
+ try {
323
+ return await chatCompletion(llmConfig, messages, { signal, enableThinking: false });
324
+ }
325
+ catch (e) {
326
+ warn(`[recombine] LLM call failed: ${String(e)}`);
327
+ return null;
328
+ }
329
+ };
330
+ }
331
+ // ── Main entry point ───────────────────────────────────────────────────────────
332
+ export async function akmRecombine(opts) {
333
+ const startMs = Date.now();
334
+ const config = opts.config ?? loadConfig();
335
+ const stashDir = opts.stashDir ?? resolveStashDir();
336
+ const sourceRun = opts.sourceRun ?? `recombine-${startMs}`;
337
+ const eligibilitySource = opts.eligibilitySource ?? "recombine";
338
+ const minClusterSize = opts.minClusterSize ?? DEFAULT_MIN_CLUSTER_SIZE;
339
+ const maxClustersPerRun = opts.maxClustersPerRun ?? DEFAULT_MAX_CLUSTERS_PER_RUN;
340
+ const relatednessSource = opts.relatednessSource ?? DEFAULT_RELATEDNESS_SOURCE;
341
+ const confirmThreshold = opts.confirmThreshold ?? DEFAULT_CONFIRM_THRESHOLD;
342
+ const warnings = [];
343
+ const finish = (over) => ({
344
+ schemaVersion: 1,
345
+ ok: true,
346
+ clustersFormed: 0,
347
+ proposalsEmitted: 0,
348
+ lessonsPromoted: 0,
349
+ nullsReturned: 0,
350
+ durationMs: Date.now() - startMs,
351
+ warnings,
352
+ ...over,
353
+ });
354
+ // Budget guard: an already-aborted signal short-circuits before any LLM call.
355
+ if (opts.signal?.aborted) {
356
+ return finish({ ok: false, warnings: [...warnings, "aborted-before-start"] });
357
+ }
358
+ // Load the memory pool + (optionally) graph entities from the index.
359
+ let entries = [];
360
+ let entityByEntryId;
361
+ let db;
362
+ try {
363
+ db = openExistingDatabase();
364
+ entries = getAllEntries(db, "memory");
365
+ if (relatednessSource === "graph" || relatednessSource === "both") {
366
+ try {
367
+ entityByEntryId = getEntitiesByEntryIds(db, entries.map((e) => e.id));
368
+ }
369
+ catch {
370
+ // Fail open to tag relatedness.
371
+ entityByEntryId = undefined;
372
+ }
373
+ }
374
+ }
375
+ catch (e) {
376
+ warnings.push(`recombine: failed to open index — ${String(e)}`);
377
+ return finish({ ok: false });
378
+ }
379
+ finally {
380
+ if (db)
381
+ closeDatabase(db);
382
+ }
383
+ const clusters = buildRelatednessClusters(entries, {
384
+ minClusterSize,
385
+ maxClustersPerRun,
386
+ relatednessSource,
387
+ ...(entityByEntryId ? { entityByEntryId } : {}),
388
+ ...(opts.maxClusterSize != null ? { maxClusterSize: opts.maxClusterSize } : {}),
389
+ ...(opts.excludeTags ? { excludeTags: opts.excludeTags } : {}),
390
+ });
391
+ let clustersFormed = 0;
392
+ let proposalsEmitted = 0;
393
+ let lessonsPromoted = 0;
394
+ let nullsReturned = 0;
395
+ const llmFn = opts.recombineLlmFn ?? resolveProductionLlmFn(config, opts.signal);
396
+ if (!llmFn) {
397
+ warnings.push("recombine: no LLM configured — skipping");
398
+ return finish({ clustersFormed: 0 });
399
+ }
400
+ // #625 — open the confirmation-count store once per run via the ctx seam,
401
+ // reusing a long-lived ctx.db handle when the caller provided one (mirrors
402
+ // proposals.ts). Only handles WE opened are closed in the finally below.
403
+ const ownStateDb = opts.ctx?.db ? undefined : openStateDatabase(opts.ctx?.dbPath ?? getStateDbPath());
404
+ const stateDb = opts.ctx?.db ?? ownStateDb;
405
+ // Refs re-induced (defensible generalization passed the quality gate) THIS
406
+ // run — everything else is decayed after the loop.
407
+ const seenThisRun = new Set();
408
+ // Recombine output is knowledge/lesson (non-wiki) → stash authoring
409
+ // standards. Resolved ONCE per run and passed to each cluster prompt.
410
+ const standardsContext = resolveStashStandards(stashDir);
411
+ try {
412
+ for (const cluster of clusters) {
413
+ if (opts.signal?.aborted) {
414
+ warnings.push("aborted-mid-run");
415
+ break;
416
+ }
417
+ clustersFormed += 1;
418
+ const prompt = buildClusterPrompt(cluster, standardsContext);
419
+ const raw = await llmFn(prompt);
420
+ const generalization = parseGeneralization(raw);
421
+ if (!generalization) {
422
+ nullsReturned += 1;
423
+ appendEvent({
424
+ eventType: "recombine_invoked",
425
+ ref: deriveRecombineLessonRef(cluster),
426
+ metadata: {
427
+ signal: cluster.signature,
428
+ memberCount: cluster.members.length,
429
+ outcome: "null_returned",
430
+ sourceRun,
431
+ },
432
+ }, opts.ctx);
433
+ continue;
434
+ }
435
+ // #633 — the confirmation identity is decoupled from the EXACT member
436
+ // set. We first look for an existing pending hypothesis row under the
437
+ // SAME signature whose membership overlaps this cluster (Jaccard >=
438
+ // threshold) and, if found, REUSE that row's stable ref so a
439
+ // drifting-but-overlapping cluster keeps accumulating its streak under one
440
+ // row instead of spawning a fresh row (count=1) every run. With no match
441
+ // (first induction, or membership drifted past the overlap floor) we fall
442
+ // back to the deterministic member-set ref exactly as before.
443
+ const memberKey = recombineMemberKey(cluster);
444
+ const derivedRef = deriveRecombineLessonRef(cluster);
445
+ const matchedRow = stateDb
446
+ ? findMatchingRecombineHypothesis(stateDb, {
447
+ signature: cluster.signature,
448
+ memberKey,
449
+ minOverlap: DEFAULT_RECOMBINE_OVERLAP,
450
+ })
451
+ : undefined;
452
+ const lessonRef = matchedRow?.hypothesis_ref ?? derivedRef;
453
+ const sourceRefs = cluster.members.map((m) => `memory:${m.entry.name}`);
454
+ // Quality gate (always-run): the frontmatter description must be present
455
+ // and non-truncated. This runs BEFORE createProposal on BOTH the
456
+ // hypothesis and the promotion paths — never bypassed.
457
+ const fmCheck = validateProposalFrontmatter({ description: generalization.description });
458
+ if (!fmCheck.ok) {
459
+ appendEvent({
460
+ eventType: "recombine_invoked",
461
+ ref: lessonRef,
462
+ metadata: {
463
+ signal: cluster.signature,
464
+ memberCount: cluster.members.length,
465
+ outcome: "quality_rejected",
466
+ reason: fmCheck.reason,
467
+ sourceRun,
468
+ },
469
+ }, opts.ctx);
470
+ continue;
471
+ }
472
+ // A defensible generalization was produced this run — record it so it is
473
+ // NOT decayed by the unseen sweep below.
474
+ seenThisRun.add(lessonRef);
475
+ // #625/#633 — record the re-induction and read the prior promotion state.
476
+ // `lessonRef` is the matched row's ref (overlap match) or the freshly
477
+ // derived member-set ref (first/non-overlapping induction). The induction
478
+ // refreshes the row's `member_key` to the current membership so the
479
+ // overlap window slides with the drifting cluster.
480
+ const nowIso = new Date().toISOString();
481
+ const priorRow = stateDb ? getRecombineHypothesis(stateDb, lessonRef) : undefined;
482
+ const alreadyPromoted = priorRow?.promoted_at != null;
483
+ const count = stateDb
484
+ ? recordRecombineInduction(stateDb, {
485
+ hypothesisRef: lessonRef,
486
+ signature: cluster.signature,
487
+ memberKey,
488
+ seenAt: nowIso,
489
+ run: sourceRun,
490
+ })
491
+ : 0;
492
+ // Promote to a `type: lesson` proposal when the confirmation streak
493
+ // reaches the threshold AND the hypothesis has not already been promoted.
494
+ const promote = stateDb != null && !alreadyPromoted && count >= confirmThreshold;
495
+ const proposalType = promote ? "lesson" : "hypothesis";
496
+ const frontmatter = {
497
+ type: proposalType,
498
+ description: generalization.description,
499
+ ...(generalization.when_to_use ? { when_to_use: generalization.when_to_use } : {}),
500
+ source_refs: sourceRefs,
501
+ };
502
+ const content = assembleContent(frontmatter, generalization.body);
503
+ if (promote && stateDb) {
504
+ // Supersede the prior pending `type: hypothesis` proposal for this ref so
505
+ // the queue never shows two proposals for one ref. The promoted lesson
506
+ // proposal has different content (type changed), so content-hash dedup
507
+ // would otherwise let both co-exist.
508
+ for (const stale of listProposals(stashDir, { status: "pending", ref: lessonRef }, opts.ctx)) {
509
+ if (stale.source === "recombine") {
510
+ archiveProposal(stashDir, stale.id, "rejected", "superseded by recombine lesson promotion", opts.ctx);
511
+ }
512
+ }
513
+ }
514
+ const proposalResult = createProposal(stashDir, {
515
+ ref: lessonRef,
516
+ source: "recombine",
517
+ sourceRun,
518
+ payload: { content, frontmatter: { description: generalization.description } },
519
+ eligibilitySource,
520
+ // The promotion is a distinct asset (lesson) for the same ref; force
521
+ // past the duplicate-pending guard (the stale hypothesis was just
522
+ // superseded, but force keeps the path robust to ordering).
523
+ ...(promote ? { force: true } : {}),
524
+ }, opts.ctx);
525
+ if (isProposalSkipped(proposalResult)) {
526
+ appendEvent({
527
+ eventType: "recombine_invoked",
528
+ ref: lessonRef,
529
+ metadata: {
530
+ signal: cluster.signature,
531
+ memberCount: cluster.members.length,
532
+ outcome: "skipped",
533
+ skipReason: proposalResult.reason,
534
+ sourceRun,
535
+ },
536
+ }, opts.ctx);
537
+ continue;
538
+ }
539
+ if (promote && stateDb) {
540
+ markRecombineHypothesisPromoted(stateDb, lessonRef, nowIso);
541
+ lessonsPromoted += 1;
542
+ appendEvent({
543
+ eventType: "recombine_invoked",
544
+ ref: lessonRef,
545
+ metadata: {
546
+ signal: cluster.signature,
547
+ memberCount: cluster.members.length,
548
+ outcome: "promoted",
549
+ proposalId: proposalResult.id,
550
+ confirmationCount: count,
551
+ sourceRun,
552
+ },
553
+ }, opts.ctx);
554
+ }
555
+ else {
556
+ proposalsEmitted += 1;
557
+ appendEvent({
558
+ eventType: "recombine_invoked",
559
+ ref: lessonRef,
560
+ metadata: {
561
+ signal: cluster.signature,
562
+ memberCount: cluster.members.length,
563
+ outcome: "queued",
564
+ proposalId: proposalResult.id,
565
+ confirmationCount: count,
566
+ sourceRun,
567
+ },
568
+ }, opts.ctx);
569
+ }
570
+ }
571
+ // #625 — decay hypotheses NOT re-induced this run (reset their consecutive
572
+ // streak) so confirmation is per-consecutive-run and conservative (AC4).
573
+ if (stateDb) {
574
+ const decayedCount = decayUnseenRecombineHypotheses(stateDb, sourceRun, [...seenThisRun]);
575
+ if (decayedCount > 0) {
576
+ appendEvent({
577
+ eventType: "recombine_invoked",
578
+ metadata: { outcome: "decayed", decayedCount, sourceRun },
579
+ }, opts.ctx);
580
+ }
581
+ }
582
+ }
583
+ finally {
584
+ if (ownStateDb)
585
+ ownStateDb.close();
586
+ }
587
+ return finish({ clustersFormed, proposalsEmitted, lessonsPromoted, nullsReturned });
588
+ }
589
+ /** Serialize frontmatter + body into a markdown asset string. */
590
+ function assembleContent(frontmatter, body) {
591
+ const lines = ["---"];
592
+ for (const [key, value] of Object.entries(frontmatter)) {
593
+ if (Array.isArray(value)) {
594
+ lines.push(`${key}: [${value.map((v) => JSON.stringify(v)).join(", ")}]`);
595
+ }
596
+ else {
597
+ lines.push(`${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
598
+ }
599
+ }
600
+ lines.push("---", "", body, "");
601
+ return lines.join("\n");
602
+ }