akm-cli 0.9.0-beta.11 → 0.9.0-beta.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 +163 -0
- package/dist/assets/prompts/consolidate-system.md +23 -0
- package/dist/assets/prompts/contradiction-judge.md +33 -0
- package/dist/assets/prompts/distill-knowledge-system.md +22 -0
- package/dist/assets/prompts/distill-lesson-system.md +36 -0
- package/dist/assets/prompts/extract-session.md +5 -1
- package/dist/assets/prompts/graph-extract-system.md +1 -0
- package/dist/assets/prompts/memory-infer-system.md +1 -0
- package/dist/assets/prompts/memory-infer-user.md +5 -0
- package/dist/assets/prompts/metadata-enhance-system.md +1 -0
- package/dist/assets/prompts/procedural-system.md +44 -0
- package/dist/assets/prompts/recombine-system.md +40 -0
- package/dist/assets/prompts/staleness-detect-system.md +6 -0
- package/dist/assets/prompts/validate-summary-judge.md +1 -0
- package/dist/assets/templates/html/health.html +25 -27
- package/dist/cli.js +2 -2
- package/dist/commands/agent/contribute-cli.js +16 -3
- package/dist/commands/feedback-cli.js +48 -44
- package/dist/commands/health/html-report.js +140 -16
- package/dist/commands/health.js +277 -1
- package/dist/commands/improve/calibration.js +161 -0
- package/dist/commands/improve/consolidate.js +595 -105
- package/dist/commands/improve/dedup.js +482 -0
- package/dist/commands/improve/distill.js +119 -64
- package/dist/commands/improve/encoding-salience.js +205 -0
- package/dist/commands/improve/extract-cli.js +115 -1
- package/dist/commands/improve/extract-prompt.js +32 -1
- package/dist/commands/improve/extract-watch.js +140 -0
- package/dist/commands/improve/extract.js +210 -30
- package/dist/commands/improve/feedback-valence.js +54 -0
- package/dist/commands/improve/homeostatic.js +467 -0
- package/dist/commands/improve/improve-auto-accept.js +80 -7
- package/dist/commands/improve/improve-profiles.js +8 -0
- package/dist/commands/improve/improve.js +991 -61
- package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
- package/dist/commands/improve/outcome-loop.js +256 -0
- package/dist/commands/improve/proactive-maintenance.js +9 -35
- package/dist/commands/improve/procedural.js +409 -0
- package/dist/commands/improve/recombine.js +488 -0
- package/dist/commands/improve/reflect.js +20 -1
- package/dist/commands/improve/related-sessions.js +120 -0
- package/dist/commands/improve/salience.js +386 -0
- package/dist/commands/improve/triage.js +95 -0
- package/dist/commands/lint/agent-linter.js +19 -24
- package/dist/commands/lint/base-linter.js +173 -60
- package/dist/commands/lint/command-linter.js +19 -24
- package/dist/commands/lint/env-key-rules.js +34 -1
- package/dist/commands/lint/index.js +30 -13
- package/dist/commands/lint/memory-linter.js +1 -1
- package/dist/commands/lint/registry.js +5 -2
- package/dist/commands/lint/task-linter.js +3 -3
- package/dist/commands/lint/workflow-linter.js +26 -1
- package/dist/commands/proposal/validators/proposals.js +4 -0
- package/dist/commands/read/curate.js +284 -86
- package/dist/commands/read/search-cli.js +7 -0
- package/dist/commands/read/search.js +1 -0
- package/dist/commands/sources/installed-stashes.js +5 -1
- package/dist/core/asset/frontmatter.js +166 -167
- package/dist/core/asset/markdown.js +8 -0
- package/dist/core/config/config-schema.js +211 -3
- package/dist/core/config/config.js +2 -2
- package/dist/core/logs-db.js +4 -3
- package/dist/core/state-db.js +555 -29
- package/dist/indexer/db/db.js +250 -27
- package/dist/indexer/db/graph-db.js +81 -86
- package/dist/indexer/graph/graph-boost.js +51 -41
- package/dist/indexer/passes/memory-inference.js +10 -3
- package/dist/indexer/passes/staleness-detect.js +2 -5
- package/dist/indexer/search/db-search.js +15 -4
- package/dist/indexer/search/ranking.js +4 -0
- package/dist/integrations/harnesses/claude/session-log.js +10 -0
- package/dist/integrations/harnesses/opencode/session-log.js +9 -0
- package/dist/integrations/session-logs/index.js +16 -0
- package/dist/llm/embedder.js +27 -3
- package/dist/llm/embedders/local.js +66 -2
- package/dist/llm/graph-extract.js +2 -1
- package/dist/llm/memory-infer.js +4 -8
- package/dist/llm/metadata-enhance.js +9 -1
- package/dist/output/shapes/curate.js +14 -2
- package/dist/output/text/helpers.js +9 -0
- package/dist/runtime.js +25 -1
- package/dist/scripts/migrate-storage.js +1025 -567
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +435 -269
- package/dist/storage/sqlite-pragmas.js +146 -0
- package/dist/workflows/db.js +3 -4
- package/dist/workflows/validate-summary.js +2 -7
- package/docs/data-and-telemetry.md +1 -0
- package/package.json +5 -4
|
@@ -0,0 +1,488 @@
|
|
|
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 { decayUnseenRecombineHypotheses, getRecombineHypothesis, getStateDbPath, markRecombineHypothesisPromoted, openStateDatabase, recordRecombineInduction, } from "../../core/state-db.js";
|
|
49
|
+
import { warn } from "../../core/warn.js";
|
|
50
|
+
import { closeDatabase, getAllEntries, getEntitiesByEntryIds, openExistingDatabase, } from "../../indexer/db/db.js";
|
|
51
|
+
import { resolveImproveProcessRunnerFromProfile, runnerIsLlm } from "../../integrations/agent/runner.js";
|
|
52
|
+
import { chatCompletion } from "../../llm/client.js";
|
|
53
|
+
import { validateProposalFrontmatter } from "../proposal/validators/proposal-quality-validators.js";
|
|
54
|
+
import { archiveProposal, createProposal, isProposalSkipped, listProposals } from "../proposal/validators/proposals.js";
|
|
55
|
+
import { isConsolidationEligibleMemoryName } from "./consolidate.js";
|
|
56
|
+
const RECOMBINE_SYSTEM_PROMPT = recombineSystemPrompt;
|
|
57
|
+
const DEFAULT_MIN_CLUSTER_SIZE = 3;
|
|
58
|
+
const DEFAULT_MAX_CLUSTERS_PER_RUN = 5;
|
|
59
|
+
const DEFAULT_RELATEDNESS_SOURCE = "tags";
|
|
60
|
+
/** #625 — re-induction count required before a hypothesis promotes to a lesson. */
|
|
61
|
+
const DEFAULT_CONFIRM_THRESHOLD = 2;
|
|
62
|
+
// ── Clustering by relatedness (NOT similarity) ────────────────────────────────
|
|
63
|
+
/**
|
|
64
|
+
* Build relatedness clusters from the memory pool. Clustering is driven purely
|
|
65
|
+
* by shared tags / graph entities — it MUST NOT use embedding similarity, so
|
|
66
|
+
* textually near-identical memories that share no relatedness signal never
|
|
67
|
+
* cluster together.
|
|
68
|
+
*
|
|
69
|
+
* For `relatednessSource`:
|
|
70
|
+
* - `"tags"` — group by each frontmatter tag.
|
|
71
|
+
* - `"graph"` — group by shared `graph_file_entities.entity_norm`; falls back
|
|
72
|
+
* to tags when the graph table is empty (fail-open).
|
|
73
|
+
* - `"both"` — union of the tag and entity grouping keys.
|
|
74
|
+
*
|
|
75
|
+
* A cluster is a signal whose member set is >= `minClusterSize`. Overlapping
|
|
76
|
+
* clusters are de-duplicated by member-set identity, and the result is capped
|
|
77
|
+
* to `maxClustersPerRun` by member-count descending.
|
|
78
|
+
*/
|
|
79
|
+
export function buildRelatednessClusters(entries, opts) {
|
|
80
|
+
// Only consolidation-eligible memories participate (exclude `.derived`).
|
|
81
|
+
const memories = entries.filter((e) => e.entry.type === "memory" && isConsolidationEligibleMemoryName(e.entry.name));
|
|
82
|
+
// signal -> member entries
|
|
83
|
+
const groups = new Map();
|
|
84
|
+
const add = (signal, entry) => {
|
|
85
|
+
const key = signal.trim();
|
|
86
|
+
if (!key)
|
|
87
|
+
return;
|
|
88
|
+
const list = groups.get(key);
|
|
89
|
+
if (list) {
|
|
90
|
+
if (!list.includes(entry))
|
|
91
|
+
list.push(entry);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
groups.set(key, [entry]);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const useTags = opts.relatednessSource === "tags" || opts.relatednessSource === "both";
|
|
98
|
+
// Graph relatedness falls open to tags when no entities are available.
|
|
99
|
+
const hasEntities = !!opts.entityByEntryId && opts.entityByEntryId.size > 0;
|
|
100
|
+
const useGraph = (opts.relatednessSource === "graph" || opts.relatednessSource === "both") && hasEntities;
|
|
101
|
+
const tagsFallback = !useTags && opts.relatednessSource === "graph" && !hasEntities;
|
|
102
|
+
for (const entry of memories) {
|
|
103
|
+
if (useTags || tagsFallback) {
|
|
104
|
+
for (const tag of entry.entry.tags ?? [])
|
|
105
|
+
add(`tag:${tag}`, entry);
|
|
106
|
+
}
|
|
107
|
+
if (useGraph && opts.entityByEntryId) {
|
|
108
|
+
for (const ent of opts.entityByEntryId.get(entry.id) ?? [])
|
|
109
|
+
add(`entity:${ent}`, entry);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Keep only groups at or above the minimum cluster size.
|
|
113
|
+
let clusters = [];
|
|
114
|
+
for (const [signature, members] of groups) {
|
|
115
|
+
if (members.length >= opts.minClusterSize)
|
|
116
|
+
clusters.push({ signature, members });
|
|
117
|
+
}
|
|
118
|
+
// De-duplicate clusters that share the exact same member set (e.g. a tag and
|
|
119
|
+
// an entity that co-occur on the same trio). Keep the first by signature.
|
|
120
|
+
const seenMemberKeys = new Set();
|
|
121
|
+
clusters = clusters.filter((c) => {
|
|
122
|
+
const memberKey = c.members
|
|
123
|
+
.map((m) => m.id)
|
|
124
|
+
.sort((a, b) => a - b)
|
|
125
|
+
.join(",");
|
|
126
|
+
if (seenMemberKeys.has(memberKey))
|
|
127
|
+
return false;
|
|
128
|
+
seenMemberKeys.add(memberKey);
|
|
129
|
+
return true;
|
|
130
|
+
});
|
|
131
|
+
// Cap to maxClustersPerRun, largest clusters first (deterministic tiebreak).
|
|
132
|
+
clusters.sort((a, b) => b.members.length - a.members.length || a.signature.localeCompare(b.signature));
|
|
133
|
+
return clusters.slice(0, Math.max(0, opts.maxClustersPerRun));
|
|
134
|
+
}
|
|
135
|
+
// ── Prompt + ref derivation ───────────────────────────────────────────────────
|
|
136
|
+
/** Read a memory body (frontmatter stripped) for the cluster prompt. */
|
|
137
|
+
function readBody(entry) {
|
|
138
|
+
try {
|
|
139
|
+
const raw = fs.readFileSync(entry.filePath, "utf8");
|
|
140
|
+
return parseFrontmatter(raw).content.trim();
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return "";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** Assemble the per-cluster user prompt fed to the recombine LLM. */
|
|
147
|
+
export function buildClusterPrompt(cluster) {
|
|
148
|
+
const lines = [
|
|
149
|
+
`Shared signal: ${cluster.signature}`,
|
|
150
|
+
`Cluster of ${cluster.members.length} related memories:`,
|
|
151
|
+
"",
|
|
152
|
+
];
|
|
153
|
+
for (const m of cluster.members) {
|
|
154
|
+
lines.push(`[memory:${m.entry.name}]`);
|
|
155
|
+
if (m.entry.description)
|
|
156
|
+
lines.push(`Description: ${m.entry.description}`);
|
|
157
|
+
const body = readBody(m);
|
|
158
|
+
if (body)
|
|
159
|
+
lines.push(body);
|
|
160
|
+
lines.push("");
|
|
161
|
+
}
|
|
162
|
+
lines.push("Induce ONE cross-episodic generalization these memories support, or return an explicit null if none is defensible.");
|
|
163
|
+
return lines.join("\n");
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Stable lesson ref for a cluster. The hash of the sorted member refs keeps the
|
|
167
|
+
* ref deterministic across runs (so re-induction maps to the same ref + the
|
|
168
|
+
* content-hash dedup in createProposal suppresses queue churn).
|
|
169
|
+
*/
|
|
170
|
+
export function deriveRecombineLessonRef(cluster) {
|
|
171
|
+
const slug = cluster.signature
|
|
172
|
+
.replace(/^(tag|entity):/, "")
|
|
173
|
+
.toLowerCase()
|
|
174
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
175
|
+
.replace(/-+/g, "-")
|
|
176
|
+
.replace(/^-|-$/g, "");
|
|
177
|
+
const memberKey = recombineMemberKey(cluster);
|
|
178
|
+
const hash = createHash("sha256").update(memberKey, "utf8").digest("hex").slice(0, 8);
|
|
179
|
+
return `lesson:recombined/${slug || "cluster"}-${hash}`;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The membership fingerprint of a cluster: its member entryKeys sorted and
|
|
183
|
+
* joined. Single source of truth shared by {@link deriveRecombineLessonRef}'s
|
|
184
|
+
* hash and the `recombine_hypotheses.member_key` column, so the table key and
|
|
185
|
+
* the ref hash always derive from the SAME member set. Adding/removing one
|
|
186
|
+
* memory yields a different fingerprint → a different ref → a fresh row (the
|
|
187
|
+
* old streak is correctly NOT inherited).
|
|
188
|
+
*/
|
|
189
|
+
export function recombineMemberKey(cluster) {
|
|
190
|
+
return cluster.members
|
|
191
|
+
.map((m) => m.entryKey)
|
|
192
|
+
.sort()
|
|
193
|
+
.join("|");
|
|
194
|
+
}
|
|
195
|
+
/** Parse the raw LLM output into a generalization, or `null` for the justified-null path. */
|
|
196
|
+
function parseGeneralization(raw) {
|
|
197
|
+
if (raw === null)
|
|
198
|
+
return null;
|
|
199
|
+
const trimmed = raw.trim();
|
|
200
|
+
if (!trimmed || trimmed.toLowerCase() === "null")
|
|
201
|
+
return null;
|
|
202
|
+
const parsed = parseEmbeddedJsonResponse(trimmed);
|
|
203
|
+
if (parsed === undefined || parsed === null)
|
|
204
|
+
return null;
|
|
205
|
+
if (typeof parsed !== "object")
|
|
206
|
+
return null;
|
|
207
|
+
const obj = parsed;
|
|
208
|
+
const description = typeof obj.description === "string" ? obj.description : "";
|
|
209
|
+
const body = typeof obj.body === "string" ? obj.body : "";
|
|
210
|
+
const when_to_use = typeof obj.when_to_use === "string" ? obj.when_to_use : undefined;
|
|
211
|
+
// An empty object / all-empty fields is treated as a justified null.
|
|
212
|
+
if (!description && !body)
|
|
213
|
+
return null;
|
|
214
|
+
return { description, body, ...(when_to_use ? { when_to_use } : {}) };
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Resolve the production LLM seam from the active improve profile. Returns a
|
|
218
|
+
* `RecombineLlmFn` that issues one bounded chatCompletion per call, or
|
|
219
|
+
* `undefined` when no LLM is configured (the pass then makes no calls).
|
|
220
|
+
*/
|
|
221
|
+
function resolveProductionLlmFn(config, signal) {
|
|
222
|
+
const recombineProcess = config.profiles?.improve?.default?.processes?.recombine;
|
|
223
|
+
const runnerSpec = resolveImproveProcessRunnerFromProfile(recombineProcess, config);
|
|
224
|
+
const llmConfig = runnerSpec && runnerIsLlm(runnerSpec) ? runnerSpec.connection : getDefaultLlmConfig(config);
|
|
225
|
+
if (!llmConfig)
|
|
226
|
+
return undefined;
|
|
227
|
+
return async (clusterPrompt) => {
|
|
228
|
+
const messages = [
|
|
229
|
+
{ role: "system", content: RECOMBINE_SYSTEM_PROMPT },
|
|
230
|
+
{ role: "user", content: clusterPrompt },
|
|
231
|
+
];
|
|
232
|
+
try {
|
|
233
|
+
return await chatCompletion(llmConfig, messages, { signal, enableThinking: false });
|
|
234
|
+
}
|
|
235
|
+
catch (e) {
|
|
236
|
+
warn(`[recombine] LLM call failed: ${String(e)}`);
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
// ── Main entry point ───────────────────────────────────────────────────────────
|
|
242
|
+
export async function akmRecombine(opts) {
|
|
243
|
+
const startMs = Date.now();
|
|
244
|
+
const config = opts.config ?? loadConfig();
|
|
245
|
+
const stashDir = opts.stashDir ?? resolveStashDir();
|
|
246
|
+
const sourceRun = opts.sourceRun ?? `recombine-${startMs}`;
|
|
247
|
+
const eligibilitySource = opts.eligibilitySource ?? "recombine";
|
|
248
|
+
const minClusterSize = opts.minClusterSize ?? DEFAULT_MIN_CLUSTER_SIZE;
|
|
249
|
+
const maxClustersPerRun = opts.maxClustersPerRun ?? DEFAULT_MAX_CLUSTERS_PER_RUN;
|
|
250
|
+
const relatednessSource = opts.relatednessSource ?? DEFAULT_RELATEDNESS_SOURCE;
|
|
251
|
+
const confirmThreshold = opts.confirmThreshold ?? DEFAULT_CONFIRM_THRESHOLD;
|
|
252
|
+
const warnings = [];
|
|
253
|
+
const finish = (over) => ({
|
|
254
|
+
schemaVersion: 1,
|
|
255
|
+
ok: true,
|
|
256
|
+
clustersFormed: 0,
|
|
257
|
+
proposalsEmitted: 0,
|
|
258
|
+
lessonsPromoted: 0,
|
|
259
|
+
nullsReturned: 0,
|
|
260
|
+
durationMs: Date.now() - startMs,
|
|
261
|
+
warnings,
|
|
262
|
+
...over,
|
|
263
|
+
});
|
|
264
|
+
// Budget guard: an already-aborted signal short-circuits before any LLM call.
|
|
265
|
+
if (opts.signal?.aborted) {
|
|
266
|
+
return finish({ ok: false, warnings: [...warnings, "aborted-before-start"] });
|
|
267
|
+
}
|
|
268
|
+
// Load the memory pool + (optionally) graph entities from the index.
|
|
269
|
+
let entries = [];
|
|
270
|
+
let entityByEntryId;
|
|
271
|
+
let db;
|
|
272
|
+
try {
|
|
273
|
+
db = openExistingDatabase();
|
|
274
|
+
entries = getAllEntries(db, "memory");
|
|
275
|
+
if (relatednessSource === "graph" || relatednessSource === "both") {
|
|
276
|
+
try {
|
|
277
|
+
entityByEntryId = getEntitiesByEntryIds(db, entries.map((e) => e.id));
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
// Fail open to tag relatedness.
|
|
281
|
+
entityByEntryId = undefined;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch (e) {
|
|
286
|
+
warnings.push(`recombine: failed to open index — ${String(e)}`);
|
|
287
|
+
return finish({ ok: false });
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
if (db)
|
|
291
|
+
closeDatabase(db);
|
|
292
|
+
}
|
|
293
|
+
const clusters = buildRelatednessClusters(entries, {
|
|
294
|
+
minClusterSize,
|
|
295
|
+
maxClustersPerRun,
|
|
296
|
+
relatednessSource,
|
|
297
|
+
...(entityByEntryId ? { entityByEntryId } : {}),
|
|
298
|
+
});
|
|
299
|
+
let clustersFormed = 0;
|
|
300
|
+
let proposalsEmitted = 0;
|
|
301
|
+
let lessonsPromoted = 0;
|
|
302
|
+
let nullsReturned = 0;
|
|
303
|
+
const llmFn = opts.recombineLlmFn ?? resolveProductionLlmFn(config, opts.signal);
|
|
304
|
+
if (!llmFn) {
|
|
305
|
+
warnings.push("recombine: no LLM configured — skipping");
|
|
306
|
+
return finish({ clustersFormed: 0 });
|
|
307
|
+
}
|
|
308
|
+
// #625 — open the confirmation-count store once per run via the ctx seam,
|
|
309
|
+
// reusing a long-lived ctx.db handle when the caller provided one (mirrors
|
|
310
|
+
// proposals.ts). Only handles WE opened are closed in the finally below.
|
|
311
|
+
const ownStateDb = opts.ctx?.db ? undefined : openStateDatabase(opts.ctx?.dbPath ?? getStateDbPath());
|
|
312
|
+
const stateDb = opts.ctx?.db ?? ownStateDb;
|
|
313
|
+
// Refs re-induced (defensible generalization passed the quality gate) THIS
|
|
314
|
+
// run — everything else is decayed after the loop.
|
|
315
|
+
const seenThisRun = new Set();
|
|
316
|
+
try {
|
|
317
|
+
for (const cluster of clusters) {
|
|
318
|
+
if (opts.signal?.aborted) {
|
|
319
|
+
warnings.push("aborted-mid-run");
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
clustersFormed += 1;
|
|
323
|
+
const prompt = buildClusterPrompt(cluster);
|
|
324
|
+
const raw = await llmFn(prompt);
|
|
325
|
+
const generalization = parseGeneralization(raw);
|
|
326
|
+
if (!generalization) {
|
|
327
|
+
nullsReturned += 1;
|
|
328
|
+
appendEvent({
|
|
329
|
+
eventType: "recombine_invoked",
|
|
330
|
+
ref: deriveRecombineLessonRef(cluster),
|
|
331
|
+
metadata: {
|
|
332
|
+
signal: cluster.signature,
|
|
333
|
+
memberCount: cluster.members.length,
|
|
334
|
+
outcome: "null_returned",
|
|
335
|
+
sourceRun,
|
|
336
|
+
},
|
|
337
|
+
}, opts.ctx);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const lessonRef = deriveRecombineLessonRef(cluster);
|
|
341
|
+
const sourceRefs = cluster.members.map((m) => `memory:${m.entry.name}`);
|
|
342
|
+
// Quality gate (always-run): the frontmatter description must be present
|
|
343
|
+
// and non-truncated. This runs BEFORE createProposal on BOTH the
|
|
344
|
+
// hypothesis and the promotion paths — never bypassed.
|
|
345
|
+
const fmCheck = validateProposalFrontmatter({ description: generalization.description });
|
|
346
|
+
if (!fmCheck.ok) {
|
|
347
|
+
appendEvent({
|
|
348
|
+
eventType: "recombine_invoked",
|
|
349
|
+
ref: lessonRef,
|
|
350
|
+
metadata: {
|
|
351
|
+
signal: cluster.signature,
|
|
352
|
+
memberCount: cluster.members.length,
|
|
353
|
+
outcome: "quality_rejected",
|
|
354
|
+
reason: fmCheck.reason,
|
|
355
|
+
sourceRun,
|
|
356
|
+
},
|
|
357
|
+
}, opts.ctx);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
// A defensible generalization was produced this run — record it so it is
|
|
361
|
+
// NOT decayed by the unseen sweep below.
|
|
362
|
+
seenThisRun.add(lessonRef);
|
|
363
|
+
// #625 — record the re-induction and read the prior promotion state. The
|
|
364
|
+
// member key and the ref hash derive from the SAME member set, so
|
|
365
|
+
// re-induction of an identical cluster maps back to the same row.
|
|
366
|
+
const nowIso = new Date().toISOString();
|
|
367
|
+
const priorRow = stateDb ? getRecombineHypothesis(stateDb, lessonRef) : undefined;
|
|
368
|
+
const alreadyPromoted = priorRow?.promoted_at != null;
|
|
369
|
+
const count = stateDb
|
|
370
|
+
? recordRecombineInduction(stateDb, {
|
|
371
|
+
hypothesisRef: lessonRef,
|
|
372
|
+
signature: cluster.signature,
|
|
373
|
+
memberKey: recombineMemberKey(cluster),
|
|
374
|
+
seenAt: nowIso,
|
|
375
|
+
run: sourceRun,
|
|
376
|
+
})
|
|
377
|
+
: 0;
|
|
378
|
+
// Promote to a `type: lesson` proposal when the confirmation streak
|
|
379
|
+
// reaches the threshold AND the hypothesis has not already been promoted.
|
|
380
|
+
const promote = stateDb != null && !alreadyPromoted && count >= confirmThreshold;
|
|
381
|
+
const proposalType = promote ? "lesson" : "hypothesis";
|
|
382
|
+
const frontmatter = {
|
|
383
|
+
type: proposalType,
|
|
384
|
+
description: generalization.description,
|
|
385
|
+
...(generalization.when_to_use ? { when_to_use: generalization.when_to_use } : {}),
|
|
386
|
+
source_refs: sourceRefs,
|
|
387
|
+
};
|
|
388
|
+
const content = assembleContent(frontmatter, generalization.body);
|
|
389
|
+
if (promote && stateDb) {
|
|
390
|
+
// Supersede the prior pending `type: hypothesis` proposal for this ref so
|
|
391
|
+
// the queue never shows two proposals for one ref. The promoted lesson
|
|
392
|
+
// proposal has different content (type changed), so content-hash dedup
|
|
393
|
+
// would otherwise let both co-exist.
|
|
394
|
+
for (const stale of listProposals(stashDir, { status: "pending", ref: lessonRef }, opts.ctx)) {
|
|
395
|
+
if (stale.source === "recombine") {
|
|
396
|
+
archiveProposal(stashDir, stale.id, "rejected", "superseded by recombine lesson promotion", opts.ctx);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const proposalResult = createProposal(stashDir, {
|
|
401
|
+
ref: lessonRef,
|
|
402
|
+
source: "recombine",
|
|
403
|
+
sourceRun,
|
|
404
|
+
payload: { content, frontmatter: { description: generalization.description } },
|
|
405
|
+
eligibilitySource,
|
|
406
|
+
// The promotion is a distinct asset (lesson) for the same ref; force
|
|
407
|
+
// past the duplicate-pending guard (the stale hypothesis was just
|
|
408
|
+
// superseded, but force keeps the path robust to ordering).
|
|
409
|
+
...(promote ? { force: true } : {}),
|
|
410
|
+
}, opts.ctx);
|
|
411
|
+
if (isProposalSkipped(proposalResult)) {
|
|
412
|
+
appendEvent({
|
|
413
|
+
eventType: "recombine_invoked",
|
|
414
|
+
ref: lessonRef,
|
|
415
|
+
metadata: {
|
|
416
|
+
signal: cluster.signature,
|
|
417
|
+
memberCount: cluster.members.length,
|
|
418
|
+
outcome: "skipped",
|
|
419
|
+
skipReason: proposalResult.reason,
|
|
420
|
+
sourceRun,
|
|
421
|
+
},
|
|
422
|
+
}, opts.ctx);
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (promote && stateDb) {
|
|
426
|
+
markRecombineHypothesisPromoted(stateDb, lessonRef, nowIso);
|
|
427
|
+
lessonsPromoted += 1;
|
|
428
|
+
appendEvent({
|
|
429
|
+
eventType: "recombine_invoked",
|
|
430
|
+
ref: lessonRef,
|
|
431
|
+
metadata: {
|
|
432
|
+
signal: cluster.signature,
|
|
433
|
+
memberCount: cluster.members.length,
|
|
434
|
+
outcome: "promoted",
|
|
435
|
+
proposalId: proposalResult.id,
|
|
436
|
+
confirmationCount: count,
|
|
437
|
+
sourceRun,
|
|
438
|
+
},
|
|
439
|
+
}, opts.ctx);
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
proposalsEmitted += 1;
|
|
443
|
+
appendEvent({
|
|
444
|
+
eventType: "recombine_invoked",
|
|
445
|
+
ref: lessonRef,
|
|
446
|
+
metadata: {
|
|
447
|
+
signal: cluster.signature,
|
|
448
|
+
memberCount: cluster.members.length,
|
|
449
|
+
outcome: "queued",
|
|
450
|
+
proposalId: proposalResult.id,
|
|
451
|
+
confirmationCount: count,
|
|
452
|
+
sourceRun,
|
|
453
|
+
},
|
|
454
|
+
}, opts.ctx);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
// #625 — decay hypotheses NOT re-induced this run (reset their consecutive
|
|
458
|
+
// streak) so confirmation is per-consecutive-run and conservative (AC4).
|
|
459
|
+
if (stateDb) {
|
|
460
|
+
const decayedCount = decayUnseenRecombineHypotheses(stateDb, sourceRun, [...seenThisRun]);
|
|
461
|
+
if (decayedCount > 0) {
|
|
462
|
+
appendEvent({
|
|
463
|
+
eventType: "recombine_invoked",
|
|
464
|
+
metadata: { outcome: "decayed", decayedCount, sourceRun },
|
|
465
|
+
}, opts.ctx);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
if (ownStateDb)
|
|
471
|
+
ownStateDb.close();
|
|
472
|
+
}
|
|
473
|
+
return finish({ clustersFormed, proposalsEmitted, lessonsPromoted, nullsReturned });
|
|
474
|
+
}
|
|
475
|
+
/** Serialize frontmatter + body into a markdown asset string. */
|
|
476
|
+
function assembleContent(frontmatter, body) {
|
|
477
|
+
const lines = ["---"];
|
|
478
|
+
for (const [key, value] of Object.entries(frontmatter)) {
|
|
479
|
+
if (Array.isArray(value)) {
|
|
480
|
+
lines.push(`${key}: [${value.map((v) => JSON.stringify(v)).join(", ")}]`);
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
lines.push(`${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
lines.push("---", "", body, "");
|
|
487
|
+
return lines.join("\n");
|
|
488
|
+
}
|
|
@@ -383,6 +383,25 @@ function splitFrontmatter(raw) {
|
|
|
383
383
|
return { fmText: null, body: raw };
|
|
384
384
|
return { fmText: m[1], body: m[2] };
|
|
385
385
|
}
|
|
386
|
+
/**
|
|
387
|
+
* Strip an LLM-appended duplicate frontmatter block from a body string.
|
|
388
|
+
*
|
|
389
|
+
* When the LLM echoes the original source file verbatim after its rewrite,
|
|
390
|
+
* the resulting body contains a second `---...---` YAML block. We detect it
|
|
391
|
+
* by requiring BOTH a balanced fence (opening + closing `---`) AND YAML-like
|
|
392
|
+
* `key: value` content inside, so legitimate Markdown thematic breaks and
|
|
393
|
+
* code-fence examples are never truncated.
|
|
394
|
+
*/
|
|
395
|
+
function stripAppendedFrontmatter(body) {
|
|
396
|
+
const fencePattern = /\n---\r?\n([\s\S]*?)\n---\r?\n/;
|
|
397
|
+
const match = body.match(fencePattern);
|
|
398
|
+
if (!match)
|
|
399
|
+
return body;
|
|
400
|
+
// Only strip when the captured block looks like YAML frontmatter.
|
|
401
|
+
if (!/^\w[\w-]*:/m.test(match[1]))
|
|
402
|
+
return body;
|
|
403
|
+
return body.slice(0, body.indexOf(match[0])).replace(/\s+$/, "");
|
|
404
|
+
}
|
|
386
405
|
/**
|
|
387
406
|
* Reflect post-processor — enforces the safety rails described at the top of
|
|
388
407
|
* this file:
|
|
@@ -449,7 +468,7 @@ function sanitizeReflectPayload(payload, sourceContent, targetRef) {
|
|
|
449
468
|
mergedFm[field] = sourceFm[field];
|
|
450
469
|
}
|
|
451
470
|
}
|
|
452
|
-
const cleanedBody = rawLlmBody.replace(/^\s+/, "");
|
|
471
|
+
const cleanedBody = stripAppendedFrontmatter(rawLlmBody.replace(/^\s+/, ""));
|
|
453
472
|
// Size guard — only when source body is meaningfully large. The pure
|
|
454
473
|
// predicate lives in `core/proposal-quality-validators` so the same check
|
|
455
474
|
// also runs inside `runProposalValidators` on `proposal accept`.
|
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
* #627 — getRelatedSessions helper.
|
|
6
|
+
*
|
|
7
|
+
* Finds session assets related to a seed by SHARED TAGS / GRAPH ENTITIES —
|
|
8
|
+
* NEVER embedding similarity. Modeled on recombine.ts buildRelatednessClusters:
|
|
9
|
+
* two sessions relate when they share at least one *topic* tag or graph entity;
|
|
10
|
+
* two textually near-identical sessions with no shared topic signal do NOT
|
|
11
|
+
* relate. This makes the helper a pure query-layer relatedness lookup that
|
|
12
|
+
* makes NO network / embedding calls.
|
|
13
|
+
*
|
|
14
|
+
* The generic base tags every session asset carries (`session` + the harness
|
|
15
|
+
* id, see session-asset.ts) are IGNORED for relatedness — otherwise every
|
|
16
|
+
* session would trivially relate to every other. Callers should therefore seed
|
|
17
|
+
* with derived topic tags, but the helper defensively strips the generic base
|
|
18
|
+
* tags from candidate sessions too.
|
|
19
|
+
*
|
|
20
|
+
* Relatedness source:
|
|
21
|
+
* - `"tags"` — score by count of (seedTags ∩ session tags).
|
|
22
|
+
* - `"graph"` — score by count of (seedEntities ∩ session graph entities);
|
|
23
|
+
* falls open to tag relatedness when the graph table is empty.
|
|
24
|
+
* - `"both"` — sum of the tag + entity overlap.
|
|
25
|
+
*/
|
|
26
|
+
import { makeAssetRef } from "../../core/asset/asset-ref.js";
|
|
27
|
+
import { closeDatabase, getAllEntries, getEntitiesByEntryIds, openExistingDatabase, } from "../../indexer/db/db.js";
|
|
28
|
+
/** Generic base tags every session asset carries — never a relatedness signal. */
|
|
29
|
+
const GENERIC_SESSION_TAGS = new Set(["session"]);
|
|
30
|
+
/**
|
|
31
|
+
* Build the set of topic tags for a session entry, excluding generic base tags
|
|
32
|
+
* (`session`) and the harness segment of the entry name (the harness id is the
|
|
33
|
+
* first path component of `<harness>/<id>` and is also carried as a base tag).
|
|
34
|
+
*/
|
|
35
|
+
function topicTagsForSession(entry) {
|
|
36
|
+
const harness = entry.entry.name.split("/")[0];
|
|
37
|
+
const result = new Set();
|
|
38
|
+
for (const tag of entry.entry.tags ?? []) {
|
|
39
|
+
if (GENERIC_SESSION_TAGS.has(tag))
|
|
40
|
+
continue;
|
|
41
|
+
if (harness && tag === harness)
|
|
42
|
+
continue;
|
|
43
|
+
result.add(tag);
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Find session assets related to a seed by shared tags / graph entities.
|
|
49
|
+
* Relatedness is signal-based, NOT embedding similarity.
|
|
50
|
+
*/
|
|
51
|
+
export function getRelatedSessions(opts) {
|
|
52
|
+
const seedTags = (opts.seedTags ?? [])
|
|
53
|
+
.filter((t) => !GENERIC_SESSION_TAGS.has(t))
|
|
54
|
+
.map((t) => t.trim())
|
|
55
|
+
.filter(Boolean);
|
|
56
|
+
const seedEntities = (opts.seedEntities ?? []).map((e) => e.trim().toLowerCase()).filter(Boolean);
|
|
57
|
+
const minShared = opts.minShared ?? 1;
|
|
58
|
+
const limit = opts.limit ?? 5;
|
|
59
|
+
const relatednessSource = opts.relatednessSource ?? "tags";
|
|
60
|
+
const excludeRefs = new Set(opts.excludeRefs ?? []);
|
|
61
|
+
// Empty seed input → nothing to relate to.
|
|
62
|
+
if (seedTags.length === 0 && seedEntities.length === 0)
|
|
63
|
+
return [];
|
|
64
|
+
const seedTagSet = new Set(seedTags);
|
|
65
|
+
const seedEntitySet = new Set(seedEntities);
|
|
66
|
+
let db = opts.db;
|
|
67
|
+
let ownsDb = false;
|
|
68
|
+
if (!db) {
|
|
69
|
+
db = openExistingDatabase();
|
|
70
|
+
ownsDb = true;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const sessions = getAllEntries(db, "session");
|
|
74
|
+
// Resolve graph entities for the candidate sessions when requested. Fail
|
|
75
|
+
// open to tag relatedness when the graph table is empty (no rows).
|
|
76
|
+
let entityByEntryId;
|
|
77
|
+
const wantGraph = relatednessSource === "graph" || relatednessSource === "both";
|
|
78
|
+
if (wantGraph) {
|
|
79
|
+
try {
|
|
80
|
+
entityByEntryId = getEntitiesByEntryIds(db, sessions.map((s) => s.id));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
entityByEntryId = undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const hasEntities = !!entityByEntryId && entityByEntryId.size > 0;
|
|
87
|
+
// Effective signal selection. "graph" with no entities falls open to tags.
|
|
88
|
+
const useTags = relatednessSource === "tags" || relatednessSource === "both" || (wantGraph && !hasEntities);
|
|
89
|
+
const useGraph = wantGraph && hasEntities;
|
|
90
|
+
const scored = [];
|
|
91
|
+
for (const session of sessions) {
|
|
92
|
+
const ref = makeAssetRef(session.entry.type, session.entry.name);
|
|
93
|
+
if (excludeRefs.has(ref))
|
|
94
|
+
continue;
|
|
95
|
+
let sharedCount = 0;
|
|
96
|
+
if (useTags && seedTagSet.size > 0) {
|
|
97
|
+
for (const tag of topicTagsForSession(session)) {
|
|
98
|
+
if (seedTagSet.has(tag))
|
|
99
|
+
sharedCount += 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (useGraph && seedEntitySet.size > 0 && entityByEntryId) {
|
|
103
|
+
for (const ent of entityByEntryId.get(session.id) ?? []) {
|
|
104
|
+
if (seedEntitySet.has(ent))
|
|
105
|
+
sharedCount += 1;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (sharedCount >= minShared) {
|
|
109
|
+
scored.push({ ref, name: session.entry.name, sharedCount });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Rank by shared-signal count desc, then ref for a deterministic tiebreak.
|
|
113
|
+
scored.sort((a, b) => b.sharedCount - a.sharedCount || a.ref.localeCompare(b.ref));
|
|
114
|
+
return scored.slice(0, Math.max(0, limit));
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
if (ownsDb && db)
|
|
118
|
+
closeDatabase(db);
|
|
119
|
+
}
|
|
120
|
+
}
|