backpass 0.1.10 → 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 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`); pass
147
- `--max-transcripts all` to analyze every transcript, or `--seed <n>` to reproduce a sample.
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 (a
155
- visible violation) is weighted highest.
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 - deterministic, no model
167
-
168
- Evidence is grouped by instruction, giving each one a positive/negative count and a
169
- **relevance** figure: the share of analyzed sessions in which it mattered at all. Duplicate
170
- gaps across sessions are clustered, and clusters seen in fewer than `minGapEvidence`
171
- sessions (default 2) are dropped. One bad session never rewrites the weights.
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/<id>.json per-transcript loss
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.10",
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
- clean[key].push({
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
- clean.gaps.push({
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.id)}.md`);
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.id);
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.id, { ...base, status: "skipped", reason: result.reason });
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.id, {
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.id, { ...base, status: "failed", error: err.message });
316
+ state.writeEvidence(transcript, { ...base, status: "failed", error: err.message });
296
317
  } finally {
297
318
  done += 1;
298
319
  emitProgress("analyze:tick", {
@@ -26,7 +26,10 @@ export function injectPayload(template, payload, toolVersion) {
26
26
  .replace(/\u2029/g, "\\u2029");
27
27
  const tag = `<script>window.__BACKPASS_PROPOSAL__ = ${json};</script>`;
28
28
  if (!template.includes("</head>")) return `${tag}\n${template}`;
29
- return template.replace("</head>", `${tag}\n</head>`);
29
+ // A function replacer, not a string one: a string second argument to `replace` treats
30
+ // `$&`, `` $` ``, `$'`, and `$$` in it as replacement patterns, and `tag` embeds
31
+ // untrusted proposal text that can contain any of those sequences verbatim.
32
+ return template.replace("</head>", () => `${tag}\n</head>`);
30
33
  }
31
34
 
32
35
  export function renderApplySurface(proposal, state, toolVersion) {
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 random sample
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> make the transcript sample reproducible
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 before a new instruction [2]
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 foldForRun(ctx, memoryFile, memoryHash);
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();
@@ -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
- return foldEvidence(relevant, {
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 is drawn
57
- * (`src/sample.js`). `0` or "all" disables the cap. `sampleHalfLife` is the age at
58
- * which a transcript's sampling weight halves; `seed` makes the sample reproducible.
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}`;
@@ -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
- transcripts.push(...found);
86
- stats.matched = found.length;
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(found),
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
- return {
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. */