backpass 0.1.6 → 0.1.7
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 +18 -1
- package/package.json +1 -1
- package/src/apply/writer.js +139 -16
- package/src/commands/apply.js +2 -2
- package/src/memory.js +10 -1
- package/src/tokens.js +11 -4
package/README.md
CHANGED
|
@@ -249,7 +249,24 @@ edit is not proposed again unless materially new evidence arrives.
|
|
|
249
249
|
The live budget gauge is not just a readout. Apply rechecks the accepted subset against
|
|
250
250
|
the same budget gate as synthesis: stay under the cap, or shrink if the file is already
|
|
251
251
|
over. An incompatible set writes nothing and does not record rejections, so you can pick
|
|
252
|
-
a compatible set and try again.
|
|
252
|
+
a compatible set and try again. If the run shrinks the file but leaves it above the cap,
|
|
253
|
+
that is progress, not a failure: it is written, and the remaining overage is printed.
|
|
254
|
+
|
|
255
|
+
Apply preflights every accepted edit before writing. The proposal was measured against one
|
|
256
|
+
exact version of your memory file, so apply first checks the file still exists and is still
|
|
257
|
+
that version. If it was removed or changed since - you pulled, edited it by hand, or another
|
|
258
|
+
agent did - the edits no longer describe what is on disk, so nothing is written and you are
|
|
259
|
+
told to run `backpass` again to re-propose against the current file. Within a run every file
|
|
260
|
+
is composed from one version: it takes every accepted edit or none of them.
|
|
261
|
+
|
|
262
|
+
Skills are written only after every edit has composed, and before the memory file, so a
|
|
263
|
+
write failure cannot leave the memory file pointing at a missing skill. If one skill write
|
|
264
|
+
fails after another succeeded, apply names the unreferenced skill paths to remove before
|
|
265
|
+
retrying.
|
|
266
|
+
|
|
267
|
+
For compatibility, proposals created by older backpass versions that do not contain a
|
|
268
|
+
memory-file hash skip the freshness check. Regenerate such a proposal before applying it if
|
|
269
|
+
the repository may have changed.
|
|
253
270
|
|
|
254
271
|
```sh
|
|
255
272
|
backpass apply --no-ui # same decision, in the terminal
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backpass",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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/apply/writer.js
CHANGED
|
@@ -2,18 +2,19 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
import { applyEdit, projectWithDecisions } from "../proposal.js";
|
|
5
|
-
import {
|
|
5
|
+
import { memoryTextHash } from "../memory.js";
|
|
6
|
+
import { budgetGateKind, budgetStatus, formatTokens } from "../tokens.js";
|
|
6
7
|
import { recordRejection } from "../state.js";
|
|
7
8
|
import { writeSkill } from "../skills.js";
|
|
8
9
|
|
|
9
|
-
function acceptedSubsetBudgetFailure({ proposal, accepted, repo, capTokens }) {
|
|
10
|
+
function acceptedSubsetBudgetFailure({ proposal, accepted, repo, capTokens, memoryText }) {
|
|
10
11
|
if (!accepted.length) return null;
|
|
11
12
|
|
|
12
13
|
const relative = proposal.memoryFile.path;
|
|
13
14
|
const absolute = path.join(repo.root, relative);
|
|
14
|
-
if (!fs.existsSync(absolute)) return null;
|
|
15
|
+
if (memoryText === null && !fs.existsSync(absolute)) return null;
|
|
15
16
|
|
|
16
|
-
const before = fs.readFileSync(absolute, "utf8");
|
|
17
|
+
const before = memoryText ?? fs.readFileSync(absolute, "utf8");
|
|
17
18
|
const { budget } = projectWithDecisions(
|
|
18
19
|
before,
|
|
19
20
|
accepted,
|
|
@@ -41,14 +42,75 @@ function acceptedSubsetBudgetFailure({ proposal, accepted, repo, capTokens }) {
|
|
|
41
42
|
return null;
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Freshness before mutation.
|
|
47
|
+
*
|
|
48
|
+
* Every hunk was cut from one exact image of the memory file, and the proposal records
|
|
49
|
+
* that image's hash. If the file has disappeared or changed since - an upstream merge,
|
|
50
|
+
* a hand edit, another agent - then the hunks describe text that may no longer exist, and the ones
|
|
51
|
+
* that still happen to match would leave the file half-descended: part of a shrink plan
|
|
52
|
+
* applied against a file the plan was never measured against. So the run is refused
|
|
53
|
+
* before it writes anything, and the fix is to re-measure, not to salvage.
|
|
54
|
+
*
|
|
55
|
+
* A proposal saved before this field existed carries no hash and is left alone.
|
|
56
|
+
*/
|
|
57
|
+
function memoryFileSnapshot(proposal, repo) {
|
|
58
|
+
const expected = proposal.memoryFile?.hash;
|
|
59
|
+
const relative = proposal.memoryFile?.path;
|
|
60
|
+
if (!relative) return { text: null };
|
|
61
|
+
|
|
62
|
+
const absolute = path.join(repo.root, relative);
|
|
63
|
+
if (!fs.existsSync(absolute)) {
|
|
64
|
+
if (!expected) return { text: null };
|
|
65
|
+
return {
|
|
66
|
+
text: null,
|
|
67
|
+
failure: {
|
|
68
|
+
file: relative,
|
|
69
|
+
error:
|
|
70
|
+
`${relative} no longer exists, so its edits no longer describe the file on disk; nothing was written. ` +
|
|
71
|
+
`Run \`backpass\` to re-propose against the current repository.`,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const text = fs.readFileSync(absolute, "utf8");
|
|
77
|
+
if (!expected) return { text };
|
|
78
|
+
|
|
79
|
+
const observed = memoryTextHash(text);
|
|
80
|
+
if (observed === expected) return { text };
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
text,
|
|
84
|
+
failure: {
|
|
85
|
+
file: relative,
|
|
86
|
+
error:
|
|
87
|
+
`${relative} changed after this proposal was made (${expected} -> ${observed}), so its edits ` +
|
|
88
|
+
`no longer describe the file on disk; nothing was written. Run \`backpass\` to re-propose ` +
|
|
89
|
+
`against the current ${relative}.`,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function overBudgetWarning(relative, budget) {
|
|
95
|
+
return (
|
|
96
|
+
`${relative} is still ${formatTokens(budget.over)} tokens over the ${formatTokens(budget.capTokens)}-token ` +
|
|
97
|
+
"budget; run `backpass` again for the next shrink step"
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
44
101
|
/**
|
|
45
102
|
* The only place in backpass that writes to the repo.
|
|
46
103
|
*
|
|
47
104
|
* Everything upstream is read-only analysis; a run only changes the weights here, after
|
|
48
|
-
* a human accepted specific edits.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
105
|
+
* a human accepted specific edits. Three gates run before the first byte is written:
|
|
106
|
+
* the memory file must still be the file the proposal was measured against
|
|
107
|
+
* (`memoryFileSnapshot`), the accepted subset must clear the same cap/shrink budget gate as
|
|
108
|
+
* the full proposal (`budgetGateKind`), and every accepted edit for a file must compose
|
|
109
|
+
* against that file's single pre-write image. Any of them failing writes nothing and
|
|
110
|
+
* records no rejection.
|
|
111
|
+
*
|
|
112
|
+
* A file is therefore applied all at once or not at all. Skills are written only after
|
|
113
|
+
* every accepted edit has composed, and before the files that reference them.
|
|
52
114
|
*/
|
|
53
115
|
export function applyDecisions({ proposal, decisions, repo, state, config, dryRun = false }) {
|
|
54
116
|
const accepted = proposal.edits.filter((e) => decisions[e.id] === "accepted");
|
|
@@ -65,11 +127,22 @@ export function applyDecisions({ proposal, decisions, repo, state, config, dryRu
|
|
|
65
127
|
rejectionsRecorded: false,
|
|
66
128
|
};
|
|
67
129
|
|
|
130
|
+
let memoryText = null;
|
|
131
|
+
if (accepted.length || rejected.length) {
|
|
132
|
+
const snapshot = memoryFileSnapshot(proposal, repo);
|
|
133
|
+
if (snapshot.failure) {
|
|
134
|
+
results.failed.push(snapshot.failure);
|
|
135
|
+
return results;
|
|
136
|
+
}
|
|
137
|
+
memoryText = snapshot.text;
|
|
138
|
+
}
|
|
139
|
+
|
|
68
140
|
const budgetFailure = acceptedSubsetBudgetFailure({
|
|
69
141
|
proposal,
|
|
70
142
|
accepted,
|
|
71
143
|
repo,
|
|
72
144
|
capTokens: config.budgetTokens,
|
|
145
|
+
memoryText,
|
|
73
146
|
});
|
|
74
147
|
if (budgetFailure) {
|
|
75
148
|
results.failed.push(budgetFailure);
|
|
@@ -81,6 +154,11 @@ export function applyDecisions({ proposal, decisions, repo, state, config, dryRu
|
|
|
81
154
|
byFile.get(edit.file).push(edit);
|
|
82
155
|
}
|
|
83
156
|
|
|
157
|
+
// Compose first, write later. Each file's accepted edits are applied to one immutable
|
|
158
|
+
// image of that file; only a set that composes completely earns a write.
|
|
159
|
+
const planned = [];
|
|
160
|
+
const landed = new Set();
|
|
161
|
+
|
|
84
162
|
for (const [relative, edits] of byFile) {
|
|
85
163
|
const absolute = path.join(repo.root, relative);
|
|
86
164
|
if (!fs.existsSync(absolute)) {
|
|
@@ -88,38 +166,83 @@ export function applyDecisions({ proposal, decisions, repo, state, config, dryRu
|
|
|
88
166
|
continue;
|
|
89
167
|
}
|
|
90
168
|
|
|
91
|
-
const before =
|
|
169
|
+
const before =
|
|
170
|
+
relative === proposal.memoryFile?.path && memoryText !== null ? memoryText : fs.readFileSync(absolute, "utf8");
|
|
92
171
|
let text = before;
|
|
93
172
|
const applied = [];
|
|
173
|
+
const failures = [];
|
|
94
174
|
|
|
95
175
|
for (const edit of edits) {
|
|
96
176
|
try {
|
|
97
177
|
text = applyEdit(text, edit);
|
|
98
178
|
applied.push(edit.id);
|
|
99
179
|
} catch (err) {
|
|
100
|
-
|
|
180
|
+
failures.push({ file: relative, edit: edit.id, error: err.message });
|
|
101
181
|
}
|
|
102
182
|
}
|
|
103
183
|
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
184
|
+
if (failures.length) {
|
|
185
|
+
results.failed.push(...failures);
|
|
186
|
+
if (applied.length) {
|
|
187
|
+
results.failed.push({
|
|
188
|
+
file: relative,
|
|
189
|
+
error: `${relative} was left unchanged: a file takes every accepted edit or none of them`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
107
194
|
|
|
108
|
-
if (
|
|
109
|
-
|
|
195
|
+
if (text === before) continue;
|
|
196
|
+
planned.push({ relative, absolute, before, text, applied });
|
|
197
|
+
for (const id of applied) landed.add(id);
|
|
110
198
|
}
|
|
111
199
|
|
|
200
|
+
if (results.failed.length) return results;
|
|
201
|
+
|
|
202
|
+
// Skills go in before the memory file. A skill nothing points at yet is inert, while a
|
|
203
|
+
// memory file pointing at a skill that is not there is actively wrong - so if a skill
|
|
204
|
+
// cannot be written, the files that would reference it are left alone.
|
|
205
|
+
const skillFailures = [];
|
|
206
|
+
const writtenSkillPaths = [];
|
|
112
207
|
for (const edit of accepted) {
|
|
113
208
|
if (edit.kind !== "extract" || !edit.skill) continue;
|
|
209
|
+
if (!landed.has(edit.id)) continue;
|
|
114
210
|
try {
|
|
115
211
|
const layout = dryRun ? { created: [], warnings: [] } : writeSkill(repo.root, edit.skill);
|
|
116
212
|
results.skills.push({ path: edit.skill.path, dryRun, created: layout.created });
|
|
213
|
+
if (!dryRun) writtenSkillPaths.push(edit.skill.path);
|
|
117
214
|
for (const w of layout.warnings) if (!results.warnings.includes(w)) results.warnings.push(w);
|
|
118
215
|
} catch (err) {
|
|
119
|
-
|
|
216
|
+
skillFailures.push({ file: edit.skill.path, edit: edit.id, error: err.message });
|
|
120
217
|
}
|
|
121
218
|
}
|
|
122
219
|
|
|
220
|
+
if (skillFailures.length) {
|
|
221
|
+
results.failed.push(...skillFailures);
|
|
222
|
+
if (writtenSkillPaths.length) {
|
|
223
|
+
results.failed.push({
|
|
224
|
+
error: `skill paths already written in this round: ${writtenSkillPaths.join(", ")}; remove them before retrying`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
for (const { relative } of planned) {
|
|
228
|
+
results.failed.push({
|
|
229
|
+
file: relative,
|
|
230
|
+
error: `${relative} was left unchanged: its edits point at a skill that could not be written`,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return results;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (const { relative, absolute, before, text, applied } of planned) {
|
|
237
|
+
const budget = relative === proposal.memoryFile.path ? budgetStatus(before, text, config.budgetTokens) : null;
|
|
238
|
+
|
|
239
|
+
if (!dryRun) fs.writeFileSync(absolute, text);
|
|
240
|
+
results.written.push({ file: relative, edits: applied, budget, dryRun });
|
|
241
|
+
|
|
242
|
+
// Shrinking over several runs is the design, so this is a heading, not a failure.
|
|
243
|
+
if (budget && !budget.withinBudget) results.warnings.push(overBudgetWarning(relative, budget));
|
|
244
|
+
}
|
|
245
|
+
|
|
123
246
|
// Rejections are remembered so the same edit is not re-proposed without new evidence.
|
|
124
247
|
if (!dryRun && rejected.length) {
|
|
125
248
|
const rejections = state.readRejections();
|
package/src/commands/apply.js
CHANGED
|
@@ -10,8 +10,8 @@ import { budgetBar, formatTokens } from "../tokens.js";
|
|
|
10
10
|
*
|
|
11
11
|
* By default it serves the shipped static template through lavish-axi and waits for one
|
|
12
12
|
* structured decision vector; `--no-ui` keeps the same ACCEPT/REJECT decision in the
|
|
13
|
-
* terminal. `applyDecisions`
|
|
14
|
-
*
|
|
13
|
+
* terminal. `applyDecisions` owns the pre-write freshness, budget, and composition gates;
|
|
14
|
+
* a failing gate records no rejections.
|
|
15
15
|
*/
|
|
16
16
|
export async function cmdApply(ctx) {
|
|
17
17
|
const { config, repo } = ctx;
|
package/src/memory.js
CHANGED
|
@@ -116,6 +116,15 @@ export function parseMemoryUnits(text) {
|
|
|
116
116
|
}));
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The fingerprint of one memory file's exact bytes. It is written into the proposal and
|
|
121
|
+
* re-checked at apply, so it has exactly one definition: a proposal and the freshness
|
|
122
|
+
* check that guards it can never disagree about what "unchanged" means.
|
|
123
|
+
*/
|
|
124
|
+
export function memoryTextHash(text) {
|
|
125
|
+
return `sha256:${sha256(text).slice(0, 16)}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
119
128
|
export function readMemoryFile(repoRoot, relativePath) {
|
|
120
129
|
const absolute = path.join(repoRoot, relativePath);
|
|
121
130
|
if (!fs.existsSync(absolute)) return null;
|
|
@@ -124,7 +133,7 @@ export function readMemoryFile(repoRoot, relativePath) {
|
|
|
124
133
|
path: relativePath,
|
|
125
134
|
absolute,
|
|
126
135
|
text,
|
|
127
|
-
hash:
|
|
136
|
+
hash: memoryTextHash(text),
|
|
128
137
|
tokens: estimateTokens(text),
|
|
129
138
|
units: parseMemoryUnits(text),
|
|
130
139
|
};
|
package/src/tokens.js
CHANGED
|
@@ -47,9 +47,16 @@ export function budgetGateKind(budget) {
|
|
|
47
47
|
return null;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Fixed-width ASCII gauge for `backpass status`.
|
|
52
|
+
*
|
|
53
|
+
* Over budget the `!!` marker gets its own cells rather than the leftover ones, because
|
|
54
|
+
* past 100% there are no leftover cells: a file at twice its cap must not render as a
|
|
55
|
+
* merely-full bar.
|
|
56
|
+
*/
|
|
51
57
|
export function budgetBar(status, width = 32) {
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
58
|
+
const overflow = status.withinBudget ? 0 : Math.min(width, 2);
|
|
59
|
+
const cells = width - overflow;
|
|
60
|
+
const filled = Math.min(cells, Math.round(status.utilization * width));
|
|
61
|
+
return `[${"#".repeat(filled)}${"!".repeat(overflow)}${".".repeat(Math.max(0, cells - filled))}]`;
|
|
55
62
|
}
|