backpass 0.1.11 → 0.1.12
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/README.md +49 -12
- package/package.json +1 -1
- package/src/analyze.js +32 -11
- package/src/cli.js +3 -3
- package/src/commands/bootstrap.js +4 -2
- package/src/commands/propose.js +21 -1
- package/src/config.js +4 -3
- package/src/consolidate.js +110 -0
- package/src/diff.js +2 -2
- package/src/discovery/index.js +13 -4
- package/src/fold.js +50 -9
- package/src/gap-ledger.js +137 -20
- package/src/prompts/analysis.md +28 -7
- package/src/prompts/annotate.md +14 -8
- package/src/prompts/consolidate.md +29 -0
- package/src/prompts/synthesis.md +14 -5
- package/src/proposal.js +64 -0
- package/src/sample.js +39 -17
- package/src/state.js +57 -12
- package/src/synthesize.js +6 -4
- package/src/transcript.js +15 -0
- package/src/workspace.js +129 -1
package/README.md
CHANGED
|
@@ -143,16 +143,30 @@ Calculating loss costs one call per transcript, so the set is capped first: past
|
|
|
143
143
|
everything. Each transcript's weight halves every `sampleHalfLife` (default 14d), and the
|
|
144
144
|
sample is drawn without replacement, so recent sessions are almost always kept and old
|
|
145
145
|
ones stay represented in proportion. When this happens the run says so on stderr
|
|
146
|
-
(`discovered 340 transcript(s), analyzing a recency-weighted sample of 100`)
|
|
147
|
-
|
|
146
|
+
(`discovered 340 transcript(s), analyzing a recency-weighted sample of 100`).
|
|
147
|
+
|
|
148
|
+
The draw is deterministic and sticky: it is derived from each transcript's own durable
|
|
149
|
+
identity, not from a fresh random seed, so rerunning does not trigger a fresh draw that
|
|
150
|
+
randomly reshuffles the sample and wastes model calls. Existing transcripts keep their
|
|
151
|
+
draws as the corpus grows, while new sessions compete for room on the same footing. Recency
|
|
152
|
+
weights still evolve as transcripts age, so the selected set can change over time. Pass
|
|
153
|
+
`--max-transcripts all` to analyze every transcript, or `--seed <n>` to draw a different,
|
|
154
|
+
equally reproducible sample.
|
|
148
155
|
|
|
149
156
|
Each distilled trace goes to a cheap model with the memory file and a rubric. It returns
|
|
150
157
|
strict JSON: which instructions helped, which were violated, and what mistakes no current
|
|
151
|
-
instruction covers.
|
|
158
|
+
instruction covers. Every negative carries a class - `harm` (following the instruction
|
|
159
|
+
caused damage), `non-compliance` (the agent ignored it), or `irrelevant` - because those
|
|
160
|
+
argue for opposite fates: harm argues against an instruction, non-compliance argues for
|
|
161
|
+
reinforcing it. Every gap carries a domain - `project` for this repository's own
|
|
162
|
+
engineering, `orchestration` for the task-management layer around the session - and the
|
|
163
|
+
analysis is shown the ledger's open gaps so it can cite an existing gap id instead of
|
|
164
|
+
coining a paraphrase of it.
|
|
152
165
|
|
|
153
166
|
**Every claim must carry a verbatim quote.** Quoteless items are discarded - the single
|
|
154
|
-
most important defence against a model confabulating influence. Negative evidence
|
|
155
|
-
|
|
167
|
+
most important defence against a model confabulating influence. Negative evidence is
|
|
168
|
+
weighted highest, but its class determines what it supports: non-compliance supports
|
|
169
|
+
reinforcement, while only harm supports removal.
|
|
156
170
|
|
|
157
171
|
Results are cached per transcript, keyed to both the transcript's content _and_ the effective
|
|
158
172
|
memory-file set hash: edit the weights and the evidence correctly re-computes; change nothing
|
|
@@ -163,12 +177,24 @@ broken." Evidence files that are not refreshed remain on disk but are excluded w
|
|
|
163
177
|
hash is stale. They become eligible again if the memory-file set returns to that hash;
|
|
164
178
|
evidence for transcripts included in the new analysis is replaced with fresh judgments.
|
|
165
179
|
|
|
166
|
-
### 4. Aggregate gradients -
|
|
167
|
-
|
|
168
|
-
Evidence is grouped by instruction, giving each one a positive/negative count
|
|
169
|
-
**relevance** figure: the share of
|
|
170
|
-
|
|
171
|
-
|
|
180
|
+
### 4. Aggregate gradients - and one judged consolidation call
|
|
181
|
+
|
|
182
|
+
Evidence is grouped by instruction, giving each one a positive/negative count, a count of
|
|
183
|
+
distinct sessions with harm-class negatives, and a **relevance** figure: the share of
|
|
184
|
+
analyzed sessions in which it mattered at all. Duplicate gaps across sessions are
|
|
185
|
+
clustered, and clusters seen in fewer than `minGapEvidence` sessions (default 2) are
|
|
186
|
+
dropped. One bad session never rewrites the weights.
|
|
187
|
+
|
|
188
|
+
Whether two sightings are one gap is a judgment call, not a word-overlap score - models
|
|
189
|
+
paraphrase, and a paraphrase that fails a lexical match would hide real recurrence.
|
|
190
|
+
Identity is judged twice: the quote-anchored analysis turn cites an existing gap id when
|
|
191
|
+
it sees a gap already on the books, and, when at least two open entries exist, one bounded
|
|
192
|
+
consolidation call sees the full open gap set and merges entries that describe the same
|
|
193
|
+
mistake. That second judgment is what lets two sightings of a brand-new gap in the same
|
|
194
|
+
run's parallel fan-out corroborate. A failed consolidation call degrades the run to
|
|
195
|
+
lexical identity and says so; it never aborts. Orchestration-domain gaps are counted and
|
|
196
|
+
reported but never cluster: mistakes about the task harness around a session do not
|
|
197
|
+
become instructions in the project's memory file.
|
|
172
198
|
|
|
173
199
|
Only evidence judged against the _current_ memory-file set hash is folded into a proposal. A
|
|
174
200
|
transcript that fell out of this run's sample - the time window, `maxTranscripts`, or the
|
|
@@ -204,6 +230,12 @@ Then mechanical gates run, and they are not negotiable:
|
|
|
204
230
|
is a violation, so is an edit that names no change
|
|
205
231
|
- new instructions need evidence from `minGapEvidence` distinct sessions (an edit that
|
|
206
232
|
only adds text is a new instruction, whatever the model calls it)
|
|
233
|
+
- removing an instruction outright needs harm-class negatives from `minGapEvidence`
|
|
234
|
+
distinct sessions - non-compliance never counts, because a rule that was skipped needs
|
|
235
|
+
reinforcement, not deletion (a change that only deletes text outside an extraction is a
|
|
236
|
+
removal, whatever the model calls it)
|
|
237
|
+
- an extraction preserves every line it removes in the skills it creates; a deletion is
|
|
238
|
+
never part of an extract
|
|
207
239
|
- every edit carries a verbatim quote
|
|
208
240
|
- the post-edit file must fit the budget, measured on the staged file
|
|
209
241
|
|
|
@@ -212,6 +244,10 @@ Neighbouring removals are merged into one measured change, and a merged change c
|
|
|
212
244
|
accepted in halves - so when several sections leave together, their skills arrive as one
|
|
213
245
|
extract with several skills, which is one honest accept/reject decision. Skills whose
|
|
214
246
|
removals were measured separately stay separate decisions, and bundling them is refused.
|
|
247
|
+
The measurement splits a contiguous removal at an extraction-vs-deletion boundary when
|
|
248
|
+
both resulting changes can be anchored safely, so accepting the extraction does not
|
|
249
|
+
silently accept the deletion beside it. If either change cannot be anchored uniquely, it
|
|
250
|
+
keeps the merged change rather than guessing.
|
|
215
251
|
|
|
216
252
|
A malformed answer or gate violation triggers a re-prompt naming the exact breach (at
|
|
217
253
|
most two). If those also fail, backpass **fails loudly** and preserves the latest parseable
|
|
@@ -415,6 +451,7 @@ CLI flags on top:
|
|
|
415
451
|
"gapLedgerMaxAge": "90d",
|
|
416
452
|
"maxTranscripts": 100,
|
|
417
453
|
"sampleHalfLife": "14d",
|
|
454
|
+
"seed": null,
|
|
418
455
|
"analysis": { "agent": null, "model": null, "effort": null },
|
|
419
456
|
"synthesis": { "agent": null, "model": null, "effort": null },
|
|
420
457
|
"ladders": {
|
|
@@ -447,7 +484,7 @@ Everything mutable lives in `.backpass/`, kept out of git via the repo's local e
|
|
|
447
484
|
```
|
|
448
485
|
.backpass/
|
|
449
486
|
scan-cache.json collect-samples verdicts by path + mtime + size
|
|
450
|
-
evidence/<
|
|
487
|
+
evidence/<identity>.json per-transcript loss
|
|
451
488
|
evidence-summary.json aggregated gradients
|
|
452
489
|
proposal.json the latest parseable gradient-descent step (absent if none was produced)
|
|
453
490
|
synthesis/ the staging copy the gradient-descent agent edited (memory file + skills)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backpass",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"packageManager": "pnpm@11.5.0",
|
|
5
5
|
"description": "Gradient descent for your agent memory - analyzes past agent session transcripts and proposes evidence-backed edits to AGENTS.md / CLAUDE.md",
|
|
6
6
|
"type": "module",
|
package/src/analyze.js
CHANGED
|
@@ -6,9 +6,11 @@ import { distill } from "./distill.js";
|
|
|
6
6
|
import { readTranscript } from "./discovery/index.js";
|
|
7
7
|
import { renderInstructionIndex } from "./memory.js";
|
|
8
8
|
import { renderPrompt } from "./prompts.js";
|
|
9
|
+
import { renderOpenGapIndex } from "./gap-ledger.js";
|
|
9
10
|
import { evidenceKey, isEvidenceFresh, safeFileName } from "./state.js";
|
|
10
11
|
import { emitProgress } from "./progress.js";
|
|
11
12
|
import { UserError, color, info, warn } from "./logger.js";
|
|
13
|
+
import { transcriptIdentity } from "./transcript.js";
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Stage 1 of the pipeline (design section 3): one cheap model call per transcript,
|
|
@@ -32,6 +34,9 @@ function noteOnce(note) {
|
|
|
32
34
|
warn(note);
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
/** Negative evidence carries one of these classes; anything else is dropped as unjudged. */
|
|
38
|
+
export const NEGATIVE_CLASSES = ["harm", "non-compliance", "irrelevant"];
|
|
39
|
+
|
|
35
40
|
/** Evidence items without a verbatim quote are dropped - the rubric's central rule. */
|
|
36
41
|
export function sanitizeEvidence(parsed) {
|
|
37
42
|
const clean = { positive: [], negative: [], gaps: [], usedRawTranscript: Boolean(parsed?.usedRawTranscript) };
|
|
@@ -42,23 +47,33 @@ export function sanitizeEvidence(parsed) {
|
|
|
42
47
|
for (const key of ["positive", "negative"]) {
|
|
43
48
|
for (const item of Array.isArray(parsed[key]) ? parsed[key] : []) {
|
|
44
49
|
if (!hasQuote(item) || typeof item.instruction !== "string") continue;
|
|
45
|
-
|
|
50
|
+
const entry = {
|
|
46
51
|
instruction: item.instruction.trim(),
|
|
47
52
|
moment: String(item.moment ?? "").slice(0, 80),
|
|
48
53
|
effect: String(item.effect ?? "").slice(0, 400),
|
|
49
54
|
quote: item.quote.trim().slice(0, 600),
|
|
50
|
-
}
|
|
55
|
+
};
|
|
56
|
+
// The class is what keeps "the agent skipped the rule" from being read as "the
|
|
57
|
+
// rule caused harm" downstream. Only an explicit judged value is kept; records
|
|
58
|
+
// from before the field existed simply carry none, and none never counts as harm.
|
|
59
|
+
if (key === "negative" && NEGATIVE_CLASSES.includes(item.class)) entry.class = item.class;
|
|
60
|
+
clean[key].push(entry);
|
|
51
61
|
}
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
for (const item of Array.isArray(parsed.gaps) ? parsed.gaps : []) {
|
|
55
65
|
if (!hasQuote(item) || typeof item.proposedInstruction !== "string") continue;
|
|
56
|
-
|
|
66
|
+
const gap = {
|
|
57
67
|
mistake: String(item.mistake ?? "").slice(0, 400),
|
|
58
68
|
proposedInstruction: item.proposedInstruction.trim().slice(0, 400),
|
|
59
69
|
recurrenceRisk: ["high", "medium", "low"].includes(item.recurrenceRisk) ? item.recurrenceRisk : "medium",
|
|
60
70
|
quote: item.quote.trim().slice(0, 600),
|
|
61
|
-
|
|
71
|
+
domain: item.domain === "orchestration" ? "orchestration" : "project",
|
|
72
|
+
};
|
|
73
|
+
if (typeof item.matchesGap === "string" && /^[0-9a-f]{16}$/.test(item.matchesGap.trim())) {
|
|
74
|
+
gap.matchesGap = item.matchesGap.trim();
|
|
75
|
+
}
|
|
76
|
+
clean.gaps.push(gap);
|
|
62
77
|
}
|
|
63
78
|
|
|
64
79
|
return clean;
|
|
@@ -80,10 +95,10 @@ export function transcriptLabel(transcript) {
|
|
|
80
95
|
}
|
|
81
96
|
|
|
82
97
|
function promptPathFor(state, transcript) {
|
|
83
|
-
return path.join(state.applyDir, "..", "prompts", `${safeFileName(transcript
|
|
98
|
+
return path.join(state.applyDir, "..", "prompts", `${safeFileName(transcriptIdentity(transcript))}.md`);
|
|
84
99
|
}
|
|
85
100
|
|
|
86
|
-
async function analyzeOne({ transcript, memoryFile, config, repo, slot = 0 }) {
|
|
101
|
+
async function analyzeOne({ transcript, memoryFile, config, repo, slot = 0, openGapIndex = "(none yet)" }) {
|
|
87
102
|
const raw = await readTranscript(transcript);
|
|
88
103
|
const distilled = distill(raw.events, {
|
|
89
104
|
...transcript,
|
|
@@ -119,6 +134,7 @@ async function analyzeOne({ transcript, memoryFile, config, repo, slot = 0 }) {
|
|
|
119
134
|
const prompt = renderPrompt("analysis", {
|
|
120
135
|
MEMORY_PATH: memoryFile.path,
|
|
121
136
|
INSTRUCTION_INDEX: renderInstructionIndex(memoryFile),
|
|
137
|
+
OPEN_GAPS: openGapIndex,
|
|
122
138
|
TRACE: distilled.trace,
|
|
123
139
|
});
|
|
124
140
|
|
|
@@ -197,7 +213,7 @@ export async function analyzeTranscripts({ transcripts, memoryFile, config, repo
|
|
|
197
213
|
const priorHashes = new Set();
|
|
198
214
|
|
|
199
215
|
for (const transcript of transcripts) {
|
|
200
|
-
const existing = state.readEvidence(transcript
|
|
216
|
+
const existing = state.readEvidence(transcript);
|
|
201
217
|
if (!force && isEvidenceFresh(existing, transcript, memoryHash)) {
|
|
202
218
|
summary.cached += 1;
|
|
203
219
|
continue;
|
|
@@ -241,6 +257,10 @@ export async function analyzeTranscripts({ transcripts, memoryFile, config, repo
|
|
|
241
257
|
`${pick.model ? ` (${pick.model})` : ""}${pick.effort ? ` effort=${pick.effort}` : ""} at jobs=${config.jobs}`,
|
|
242
258
|
);
|
|
243
259
|
|
|
260
|
+
// Rendered once per run: the ledger's open gaps, so each analysis can cite an existing
|
|
261
|
+
// gap id instead of coining a paraphrase of it (`matchesGap` in the reply schema).
|
|
262
|
+
const openGapIndex = renderOpenGapIndex(state.readGapLedger(), memoryFile.path);
|
|
263
|
+
|
|
244
264
|
let done = 0;
|
|
245
265
|
const evidenceTotals = { positive: 0, negative: 0, gaps: 0 };
|
|
246
266
|
await pool(pending, config.jobs, async (transcript, _index, slot) => {
|
|
@@ -248,6 +268,7 @@ export async function analyzeTranscripts({ transcripts, memoryFile, config, repo
|
|
|
248
268
|
transcript: {
|
|
249
269
|
harness: transcript.harness,
|
|
250
270
|
id: transcript.id,
|
|
271
|
+
identity: transcriptIdentity(transcript),
|
|
251
272
|
path: transcript.path,
|
|
252
273
|
mtimeMs: transcript.mtimeMs,
|
|
253
274
|
bytes: transcript.bytes,
|
|
@@ -269,14 +290,14 @@ export async function analyzeTranscripts({ transcripts, memoryFile, config, repo
|
|
|
269
290
|
});
|
|
270
291
|
|
|
271
292
|
try {
|
|
272
|
-
const result = await analyzeOne({ transcript, memoryFile, config, repo, slot });
|
|
293
|
+
const result = await analyzeOne({ transcript, memoryFile, config, repo, slot, openGapIndex });
|
|
273
294
|
if (result.status === "skipped") {
|
|
274
295
|
summary.skipped += 1;
|
|
275
|
-
state.writeEvidence(transcript
|
|
296
|
+
state.writeEvidence(transcript, { ...base, status: "skipped", reason: result.reason });
|
|
276
297
|
} else {
|
|
277
298
|
summary.analyzed += 1;
|
|
278
299
|
summary.usage.push(result.usage);
|
|
279
|
-
state.writeEvidence(transcript
|
|
300
|
+
state.writeEvidence(transcript, {
|
|
280
301
|
...base,
|
|
281
302
|
status: "ok",
|
|
282
303
|
stats: result.distilled.stats,
|
|
@@ -292,7 +313,7 @@ export async function analyzeTranscripts({ transcripts, memoryFile, config, repo
|
|
|
292
313
|
// Per-transcript fail-soft: recorded, listed by `backpass status`, retried next run.
|
|
293
314
|
summary.failed += 1;
|
|
294
315
|
warn(`${transcript.harness} ${transcriptLabel(transcript)}: ${err.message}`);
|
|
295
|
-
state.writeEvidence(transcript
|
|
316
|
+
state.writeEvidence(transcript, { ...base, status: "failed", error: err.message });
|
|
296
317
|
} finally {
|
|
297
318
|
done += 1;
|
|
298
319
|
emitProgress("analyze:tick", {
|
package/src/cli.js
CHANGED
|
@@ -87,9 +87,9 @@ COLLECT SAMPLES
|
|
|
87
87
|
--strict deterministic associations only (tiers 1 and 2)
|
|
88
88
|
--include-cursor-ide also scan the Cursor IDE store (best-effort, v1.1 preview)
|
|
89
89
|
--limit <n> analyze at most N transcripts this run (newest first)
|
|
90
|
-
--max-transcripts <n> cap per run; past it a recency-weighted
|
|
90
|
+
--max-transcripts <n> cap per run; past it a recency-weighted sticky sample
|
|
91
91
|
is analyzed. 0 or "all" disables the cap [100]
|
|
92
|
-
--seed <n>
|
|
92
|
+
--seed <n> draw a different reproducible transcript sample
|
|
93
93
|
|
|
94
94
|
MODELS (two-tier: cheap analysis, smart synthesis - all through acpx)
|
|
95
95
|
By default each pass auto-picks the first harness in its ladder that is installed,
|
|
@@ -109,7 +109,7 @@ MODELS (two-tier: cheap analysis, smart synthesis - all through acpx)
|
|
|
109
109
|
BUDGET AND SHAPE
|
|
110
110
|
--budget <tokens> always-loaded budget per memory file [5000]
|
|
111
111
|
--max-edits <n> edits per run - the learning rate [adaptive]
|
|
112
|
-
--min-gap-evidence <n> sessions needed
|
|
112
|
+
--min-gap-evidence <n> sessions needed to add or remove instruction [2]
|
|
113
113
|
--memory-file <path> memory file to optimize (repeatable)
|
|
114
114
|
--skills-dir <path> where skill extractions are written [.agents/skills]
|
|
115
115
|
|
|
@@ -8,7 +8,7 @@ import { ProposalViolation } from "../proposal.js";
|
|
|
8
8
|
import { synthesizeProposal } from "../synthesize.js";
|
|
9
9
|
import { budgetBar, formatTokens } from "../tokens.js";
|
|
10
10
|
import { discoverForRun } from "./scan.js";
|
|
11
|
-
import { foldForRun, printProposal } from "./propose.js";
|
|
11
|
+
import { accountForConsolidationUsage, foldForRun, printProposal } from "./propose.js";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Bootstrap: the default run when the repo has no memory file at all.
|
|
@@ -41,6 +41,7 @@ export async function bootstrapRun(ctx, deps = {}) {
|
|
|
41
41
|
const discover = deps.discover || discoverForRun;
|
|
42
42
|
const analyze = deps.analyze || analyzeTranscripts;
|
|
43
43
|
const synthesize = deps.synthesize || synthesizeProposal;
|
|
44
|
+
const fold = deps.fold || foldForRun;
|
|
44
45
|
const { canonical, pointer } = bootstrapTargets(config.memoryFiles);
|
|
45
46
|
|
|
46
47
|
const { transcripts, perHarness } = await discover(ctx);
|
|
@@ -101,7 +102,7 @@ export async function bootstrapRun(ctx, deps = {}) {
|
|
|
101
102
|
`${result.summary.skipped} too short · ${result.summary.failed} failed`,
|
|
102
103
|
);
|
|
103
104
|
|
|
104
|
-
const folded = await
|
|
105
|
+
const folded = await fold(ctx, memoryFile, memoryHash);
|
|
105
106
|
config.state.writeSummary(folded);
|
|
106
107
|
emitProgress("fold:done", {
|
|
107
108
|
instructions: folded.instructions.length,
|
|
@@ -121,6 +122,7 @@ export async function bootstrapRun(ctx, deps = {}) {
|
|
|
121
122
|
transcripts,
|
|
122
123
|
runNote: BOOTSTRAP_RUN_NOTE,
|
|
123
124
|
});
|
|
125
|
+
accountForConsolidationUsage(proposal, folded);
|
|
124
126
|
const decisions = Object.fromEntries(proposal.edits.map((e) => [e.id, "accepted"]));
|
|
125
127
|
const applied = applyDecisions({ proposal, decisions, repo, state: config.state, config });
|
|
126
128
|
proposal.appliedAt = new Date().toISOString();
|
package/src/commands/propose.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { consolidateGapLedger } from "../consolidate.js";
|
|
1
2
|
import { foldEvidence } from "../fold.js";
|
|
2
3
|
import { ledgerGapObservations, pruneGapLedger, recordGapObservations } from "../gap-ledger.js";
|
|
3
4
|
import { synthesizeProposal } from "../synthesize.js";
|
|
@@ -33,14 +34,32 @@ export async function foldForRun(ctx, memoryFile, memoryHash) {
|
|
|
33
34
|
|
|
34
35
|
const ledger = state.readGapLedger();
|
|
35
36
|
recordGapObservations(ledger, relevant);
|
|
37
|
+
// Consolidate after recording, so the pass sees this run's sightings too: two
|
|
38
|
+
// sessions coining the same brand-new gap in one parallel fan-out can only line up
|
|
39
|
+
// here. One bounded judged call; a failure degrades to lexical identity and the run
|
|
40
|
+
// continues. Prune afterwards so a merged-then-covered gap retires as one entry.
|
|
41
|
+
const consolidation = await consolidateGapLedger({
|
|
42
|
+
ledger,
|
|
43
|
+
memoryPath: memoryFile.path,
|
|
44
|
+
config: ctx.config,
|
|
45
|
+
repo: ctx.repo,
|
|
46
|
+
});
|
|
36
47
|
pruneGapLedger(ledger, { memoryFile, memoryPath: memoryFile.path, maxAge: gapLedgerMaxAge });
|
|
37
48
|
state.writeGapLedger(ledger);
|
|
38
49
|
|
|
39
|
-
|
|
50
|
+
const summary = foldEvidence(relevant, {
|
|
40
51
|
minGapEvidence,
|
|
41
52
|
memoryFile,
|
|
42
53
|
gapObservations: ledgerGapObservations(ledger, memoryFile.path),
|
|
43
54
|
});
|
|
55
|
+
summary.consolidation = consolidation;
|
|
56
|
+
return summary;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function accountForConsolidationUsage(proposal, summary) {
|
|
60
|
+
if (summary.consolidation?.usage) {
|
|
61
|
+
proposal.usage = [summary.consolidation.usage, ...(proposal.usage || [])];
|
|
62
|
+
}
|
|
44
63
|
}
|
|
45
64
|
|
|
46
65
|
export async function runProposal(ctx, precomputed = null) {
|
|
@@ -78,6 +97,7 @@ export async function runProposal(ctx, precomputed = null) {
|
|
|
78
97
|
transcripts,
|
|
79
98
|
});
|
|
80
99
|
|
|
100
|
+
accountForConsolidationUsage(proposal, summary);
|
|
81
101
|
config.state.writeProposal(proposal);
|
|
82
102
|
return { proposal, summary, memoryFile: file };
|
|
83
103
|
}
|
package/src/config.js
CHANGED
|
@@ -53,9 +53,10 @@ export const DEFAULT_CONFIG = {
|
|
|
53
53
|
*/
|
|
54
54
|
gapLedgerMaxAge: "90d",
|
|
55
55
|
/**
|
|
56
|
-
* Cap on transcripts analyzed per run; past it a recency-weighted sample
|
|
57
|
-
* (`src/sample.js`). `0` or "all" disables the cap. `sampleHalfLife` is the age
|
|
58
|
-
* which a transcript's sampling weight halves; `seed`
|
|
56
|
+
* Cap on transcripts analyzed per run; past it a deterministic, recency-weighted sample
|
|
57
|
+
* is drawn (`src/sample.js`). `0` or "all" disables the cap. `sampleHalfLife` is the age
|
|
58
|
+
* at which a transcript's sampling weight halves; `seed` selects a different reproducible
|
|
59
|
+
* sample.
|
|
59
60
|
*/
|
|
60
61
|
maxTranscripts: 100,
|
|
61
62
|
sampleHalfLife: "14d",
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { execOneShot, extractJson, sessionPrompt, usageRecord } from "./acpx.js";
|
|
5
|
+
import { mergeGapEntries } from "./gap-ledger.js";
|
|
6
|
+
import { renderPrompt } from "./prompts.js";
|
|
7
|
+
import { warn } from "./logger.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pre-synthesis gap consolidation: one bounded model call that sees the full open gap
|
|
11
|
+
* set for the memory file and merges entries that are paraphrases of one gap.
|
|
12
|
+
*
|
|
13
|
+
* This is the second half of gap identity. The first half - the analysis turn citing an
|
|
14
|
+
* existing gap id (`matchesGap`) - covers a sighting that arrives AFTER its partner is
|
|
15
|
+
* already on the books. It cannot cover two sightings of a brand-new gap landing in the
|
|
16
|
+
* same run's parallel fan-out: neither analysis saw the other's entry, so both coin one.
|
|
17
|
+
* Consolidation runs after this run's observations are recorded and before the fold
|
|
18
|
+
* clusters, so those same-run paraphrases still corroborate.
|
|
19
|
+
*
|
|
20
|
+
* Judgment is required here by design: word-bigram similarity cannot recognize a real
|
|
21
|
+
* paraphrase (measured on a production ledger: 89 entries containing at least six
|
|
22
|
+
* multi-session gaps, highest cross-session score 0.34 against the 0.45 bar). The model
|
|
23
|
+
* is asked only "same gap or not", the merge itself is mechanical (`mergeGapEntries`),
|
|
24
|
+
* unknown ids and malformed groups are dropped, and a session is never double-counted
|
|
25
|
+
* however the entries merge. Strict admission is untouched: merging changes how
|
|
26
|
+
* sightings line up, never how many distinct sessions a proposal needs.
|
|
27
|
+
*
|
|
28
|
+
* Exactly one call per run, and only when at least two entries exist. Failure is
|
|
29
|
+
* fail-soft with a warning: the run degrades to lexical identity (the pre-consolidation
|
|
30
|
+
* behavior), it never aborts - the next run re-judges the same open entries.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Ledger entries below this count never need a call; nothing could merge. */
|
|
34
|
+
const MIN_ENTRIES = 2;
|
|
35
|
+
|
|
36
|
+
let callCounter = 0;
|
|
37
|
+
|
|
38
|
+
function renderEntries(entries) {
|
|
39
|
+
return entries
|
|
40
|
+
.map((entry) => {
|
|
41
|
+
const sessions = Object.keys(entry.sessions).length;
|
|
42
|
+
const mistake = firstMistake(entry);
|
|
43
|
+
return (
|
|
44
|
+
`[${entry.id}] (sessions=${sessions}) ${entry.proposedInstruction}` +
|
|
45
|
+
(mistake ? `\n mistake: ${mistake}` : "")
|
|
46
|
+
);
|
|
47
|
+
})
|
|
48
|
+
.join("\n");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function firstMistake(entry) {
|
|
52
|
+
for (const obs of Object.values(entry.sessions)) {
|
|
53
|
+
const flat = String(obs.mistake || "")
|
|
54
|
+
.replace(/\s+/g, " ")
|
|
55
|
+
.trim();
|
|
56
|
+
if (flat) return flat.length > 160 ? `${flat.slice(0, 160)}...` : flat;
|
|
57
|
+
}
|
|
58
|
+
return "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run the consolidation call and apply the merges to `ledger` in place.
|
|
63
|
+
* Returns `{ merged, usage }`, `{ skipped }`, or `{ failed }` - never throws for a
|
|
64
|
+
* model-side failure, because a run without consolidation is still a valid run.
|
|
65
|
+
*/
|
|
66
|
+
export async function consolidateGapLedger({ ledger, memoryPath, config, repo }) {
|
|
67
|
+
const entries = Object.values(ledger.entries).filter((e) => e.memoryPath === memoryPath);
|
|
68
|
+
if (entries.length < MIN_ENTRIES) return { skipped: "fewer than two open gaps" };
|
|
69
|
+
if (!config.agents) return { skipped: "no agent resolver" };
|
|
70
|
+
|
|
71
|
+
const prompt = renderPrompt("consolidate", { GAP_ENTRIES: renderEntries(entries) });
|
|
72
|
+
const promptFile = path.join(config.state.root, "prompts", "consolidate-gaps.md");
|
|
73
|
+
fs.mkdirSync(path.dirname(promptFile), { recursive: true });
|
|
74
|
+
fs.writeFileSync(promptFile, prompt);
|
|
75
|
+
|
|
76
|
+
let ranWith = null;
|
|
77
|
+
let result;
|
|
78
|
+
try {
|
|
79
|
+
result = await config.agents.withFallthrough("analysis", async (pick) => {
|
|
80
|
+
ranWith = pick.agent;
|
|
81
|
+
const call = {
|
|
82
|
+
agent: pick.agent,
|
|
83
|
+
model: pick.model,
|
|
84
|
+
promptFile,
|
|
85
|
+
cwd: repo.root,
|
|
86
|
+
timeoutSeconds: config.timeoutSeconds,
|
|
87
|
+
promptRetries: config.promptRetries,
|
|
88
|
+
};
|
|
89
|
+
if (!pick.effort) return execOneShot(call);
|
|
90
|
+
callCounter += 1;
|
|
91
|
+
return sessionPrompt({
|
|
92
|
+
...call,
|
|
93
|
+
effort: pick.effort,
|
|
94
|
+
sessionName: `backpass-consolidate-${process.pid}-${callCounter}`,
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
} catch (err) {
|
|
98
|
+
warn(`gap consolidation failed (${err.message}); continuing with lexical gap identity for this run`);
|
|
99
|
+
return { failed: err.message };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const parsed = extractJson(result.text);
|
|
103
|
+
if (!parsed || !Array.isArray(parsed.merges)) {
|
|
104
|
+
warn("gap consolidation returned no parseable merge list; continuing with lexical gap identity for this run");
|
|
105
|
+
return { failed: "no parseable merge list", usage: usageRecord(ranWith, result) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const merged = mergeGapEntries(ledger, parsed.merges);
|
|
109
|
+
return { merged, usage: usageRecord(ranWith, result) };
|
|
110
|
+
}
|
package/src/diff.js
CHANGED
|
@@ -161,7 +161,7 @@ export function rawHunks(ops) {
|
|
|
161
161
|
* Occurrences may overlap (a run of identical lines): counting them non-overlapping
|
|
162
162
|
* would call a window unique while the first match sits somewhere else entirely.
|
|
163
163
|
*/
|
|
164
|
-
function countOccurrences(haystack, needle) {
|
|
164
|
+
export function countOccurrences(haystack, needle) {
|
|
165
165
|
if (!needle) return 0;
|
|
166
166
|
let count = 0;
|
|
167
167
|
let at = haystack.indexOf(needle);
|
|
@@ -178,7 +178,7 @@ function countOccurrences(haystack, needle) {
|
|
|
178
178
|
* newline *before* it instead. Both sides of a hunk share tail-ness (the suffix after the
|
|
179
179
|
* change is identical), so `find` and `replace` built this way splice cleanly.
|
|
180
180
|
*/
|
|
181
|
-
function span(lines, start, end) {
|
|
181
|
+
export function span(lines, start, end) {
|
|
182
182
|
if (end <= start) return "";
|
|
183
183
|
const body = lines.slice(start, end).join("\n");
|
|
184
184
|
if (end === lines.length) return `${start > 0 ? "\n" : ""}${body}`;
|
package/src/discovery/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { isSelfSession } from "./self.js";
|
|
|
12
12
|
import { sinceCutoff } from "../config.js";
|
|
13
13
|
import { emitProgress } from "../progress.js";
|
|
14
14
|
import { warn } from "../logger.js";
|
|
15
|
+
import { transcriptIdentity } from "../transcript.js";
|
|
15
16
|
|
|
16
17
|
export const ADAPTERS = {
|
|
17
18
|
claude,
|
|
@@ -52,6 +53,7 @@ export async function discoverTranscripts({ repo, config, strict = false, harnes
|
|
|
52
53
|
const cache = config.state.readScanCache();
|
|
53
54
|
|
|
54
55
|
const transcripts = [];
|
|
56
|
+
const identities = new Set();
|
|
55
57
|
const perHarness = {};
|
|
56
58
|
let cacheDirty = false;
|
|
57
59
|
|
|
@@ -82,15 +84,20 @@ export async function discoverTranscripts({ repo, config, strict = false, harnes
|
|
|
82
84
|
cacheDirty = true;
|
|
83
85
|
},
|
|
84
86
|
});
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
const unique = found.filter((transcript) => {
|
|
88
|
+
if (identities.has(transcript.identity)) return false;
|
|
89
|
+
identities.add(transcript.identity);
|
|
90
|
+
return true;
|
|
91
|
+
});
|
|
92
|
+
transcripts.push(...unique);
|
|
93
|
+
stats.matched = unique.length;
|
|
87
94
|
emitProgress("discover:harness:done", {
|
|
88
95
|
harness,
|
|
89
96
|
scanned: stats.scanned,
|
|
90
97
|
cached: stats.cached,
|
|
91
98
|
matched: stats.matched,
|
|
92
99
|
self: stats.self,
|
|
93
|
-
tiers: tierCounts(
|
|
100
|
+
tiers: tierCounts(unique),
|
|
94
101
|
});
|
|
95
102
|
} catch (err) {
|
|
96
103
|
stats.error = err.message;
|
|
@@ -197,7 +204,7 @@ function discoverFiles(adapter, { repo, config, cutoffMs, strict, stats, cache,
|
|
|
197
204
|
}
|
|
198
205
|
|
|
199
206
|
function toTranscript(adapter, row, association, id) {
|
|
200
|
-
|
|
207
|
+
const transcript = {
|
|
201
208
|
harness: adapter.name,
|
|
202
209
|
id: `${adapter.name}-${id}`,
|
|
203
210
|
nativeId: id,
|
|
@@ -213,6 +220,8 @@ function toTranscript(adapter, row, association, id) {
|
|
|
213
220
|
association,
|
|
214
221
|
extra: row.extra || {},
|
|
215
222
|
};
|
|
223
|
+
transcript.identity = transcriptIdentity(transcript);
|
|
224
|
+
return transcript;
|
|
216
225
|
}
|
|
217
226
|
|
|
218
227
|
/** Read one transcript through its adapter and normalize it to distiller events. */
|