backpass 0.1.8 → 0.1.10
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 +26 -6
- package/package.json +1 -1
- package/src/analyze.js +24 -1
- package/src/apply/writer.js +4 -2
- package/src/commands/bootstrap.js +20 -8
- package/src/commands/propose.js +14 -4
- package/src/discovery/adapters/pi.js +63 -4
package/README.md
CHANGED
|
@@ -84,7 +84,7 @@ backpass reads the local transcript stores of seven harnesses directly. No API,
|
|
|
84
84
|
| -------------- | ---------------------------------------------- | --------------------------------------------------- |
|
|
85
85
|
| **claude** | `~/.claude/projects/<munged-cwd>/<uuid>.jsonl` | per-line `cwd` |
|
|
86
86
|
| **codex** | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | `cwd` + recorded `git.repository_url` |
|
|
87
|
-
| **pi** |
|
|
87
|
+
| **pi** | standalone and BB-managed Pi JSONL stores | session-header `cwd` |
|
|
88
88
|
| **opencode** | `~/.local/share/opencode/opencode.db` (sqlite) | `session.directory` |
|
|
89
89
|
| **grok** | `~/.grok/sessions/<encoded-cwd>/<uuid>/` | `summary.json` `cwd` + `git_remotes` |
|
|
90
90
|
| **cursor CLI** | `~/.cursor/chats/<md5(cwd)>/<uuid>/` | `meta.json` `cwd` |
|
|
@@ -95,6 +95,12 @@ relocated config dir does not hide its sessions. The variable is read from backp
|
|
|
95
95
|
environment: if you reach that profile through an alias that only prefixes `claude`, set it
|
|
96
96
|
for the backpass run too (`CLAUDE_CONFIG_DIR=~/.claude-work backpass`, or export it).
|
|
97
97
|
|
|
98
|
+
Pi collection covers standalone sessions under `~/.pi/agent/sessions/` and BB-managed Pi
|
|
99
|
+
sessions under `~/.bb/pi-bridge-sessions/`. It also honors `PI_CODING_AGENT_DIR`,
|
|
100
|
+
`PI_CODING_AGENT_SESSION_DIR`, `BB_DATA_DIR`, and `BB_PI_BRIDGE_SESSION_DIR` when they are
|
|
101
|
+
set in backpass's environment. When roots overlap, backpass scans every applicable layout
|
|
102
|
+
and reads each JSONL file once.
|
|
103
|
+
|
|
98
104
|
Hermes collection includes CLI and ACP sessions only. Gateway, cron, and WhatsApp sessions
|
|
99
105
|
are excluded because their recorded cwd belongs to the shared gateway process, not a project.
|
|
100
106
|
|
|
@@ -148,9 +154,14 @@ instruction covers.
|
|
|
148
154
|
most important defence against a model confabulating influence. Negative evidence (a
|
|
149
155
|
visible violation) is weighted highest.
|
|
150
156
|
|
|
151
|
-
Results are cached per transcript, keyed to both the transcript's content _and_ the
|
|
152
|
-
file
|
|
153
|
-
the next run is free.
|
|
157
|
+
Results are cached per transcript, keyed to both the transcript's content _and_ the effective
|
|
158
|
+
memory-file set hash: edit the weights and the evidence correctly re-computes; change nothing
|
|
159
|
+
and the next run is free. A memory-file edit therefore reanalyzes without `--force` - that is
|
|
160
|
+
not a cache miss, it is the cache doing its job - and the run says so on stderr, naming the
|
|
161
|
+
old and new hash, so a "0 reused" line reads as "the file changed" rather than "reuse is
|
|
162
|
+
broken." Evidence files that are not refreshed remain on disk but are excluded while their
|
|
163
|
+
hash is stale. They become eligible again if the memory-file set returns to that hash;
|
|
164
|
+
evidence for transcripts included in the new analysis is replaced with fresh judgments.
|
|
154
165
|
|
|
155
166
|
### 4. Aggregate gradients - deterministic, no model
|
|
156
167
|
|
|
@@ -159,6 +170,13 @@ Evidence is grouped by instruction, giving each one a positive/negative count an
|
|
|
159
170
|
gaps across sessions are clustered, and clusters seen in fewer than `minGapEvidence`
|
|
160
171
|
sessions (default 2) are dropped. One bad session never rewrites the weights.
|
|
161
172
|
|
|
173
|
+
Only evidence judged against the _current_ memory-file set hash is folded into a proposal. A
|
|
174
|
+
transcript that fell out of this run's sample - the time window, `maxTranscripts`, or the
|
|
175
|
+
transcript itself being gone - can leave an older evidence file on disk under a hash the
|
|
176
|
+
memory-file set no longer has; that file is left untouched, but it does not count toward this
|
|
177
|
+
run's session total or instruction scores, or add a gap observation, until it is current
|
|
178
|
+
again.
|
|
179
|
+
|
|
162
180
|
Those sessions are counted across runs, not per run: every gap sighting is kept in
|
|
163
181
|
`.backpass/gap-ledger.json` by gap and session, so a gap seen in one session today and in
|
|
164
182
|
another session next week graduates on the later run. The same session never counts twice,
|
|
@@ -277,8 +295,10 @@ Apply preflights every accepted edit before writing. The proposal was measured a
|
|
|
277
295
|
exact version of your memory file, so apply first checks the file still exists and is still
|
|
278
296
|
that version. If it was removed or changed since - you pulled, edited it by hand, or another
|
|
279
297
|
agent did - the edits no longer describe what is on disk, so nothing is written and you are
|
|
280
|
-
told to run `backpass` again to re-propose against the current file.
|
|
281
|
-
|
|
298
|
+
told to run `backpass` again to re-propose against the current file. That rerun reanalyzes
|
|
299
|
+
transcripts against the file that exists now; it does not reuse the stale judgments behind
|
|
300
|
+
the refused proposal. Within a run every file is composed from one version: it takes every
|
|
301
|
+
accepted edit or none of them. Apply also
|
|
282
302
|
refuses the whole write if any created skill target already exists or two accepted paths
|
|
283
303
|
resolve to the same file.
|
|
284
304
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backpass",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
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
|
@@ -185,7 +185,16 @@ async function pool(items, limit, worker) {
|
|
|
185
185
|
export async function analyzeTranscripts({ transcripts, memoryFile, config, repo, memoryHash, force = false }) {
|
|
186
186
|
const state = config.state;
|
|
187
187
|
const pending = [];
|
|
188
|
-
const summary = {
|
|
188
|
+
const summary = {
|
|
189
|
+
total: transcripts.length,
|
|
190
|
+
cached: 0,
|
|
191
|
+
analyzed: 0,
|
|
192
|
+
skipped: 0,
|
|
193
|
+
failed: 0,
|
|
194
|
+
usage: [],
|
|
195
|
+
staleMemoryHash: 0,
|
|
196
|
+
};
|
|
197
|
+
const priorHashes = new Set();
|
|
189
198
|
|
|
190
199
|
for (const transcript of transcripts) {
|
|
191
200
|
const existing = state.readEvidence(transcript.id);
|
|
@@ -193,9 +202,23 @@ export async function analyzeTranscripts({ transcripts, memoryFile, config, repo
|
|
|
193
202
|
summary.cached += 1;
|
|
194
203
|
continue;
|
|
195
204
|
}
|
|
205
|
+
// Distinguish "no prior evidence" from "prior evidence exists, but it was judged
|
|
206
|
+
// against a memory-file set that no longer matches" - a re-analysis here, not a miss.
|
|
207
|
+
if (existing?.status === "ok" && existing.memoryHash && existing.memoryHash !== memoryHash) {
|
|
208
|
+
summary.staleMemoryHash += 1;
|
|
209
|
+
priorHashes.add(existing.memoryHash);
|
|
210
|
+
}
|
|
196
211
|
pending.push(transcript);
|
|
197
212
|
}
|
|
198
213
|
|
|
214
|
+
if (summary.staleMemoryHash) {
|
|
215
|
+
info(
|
|
216
|
+
`${color.yellow("·")} ${summary.staleMemoryHash} transcript(s) have evidence from a previous ` +
|
|
217
|
+
`memory-file set (${[...priorHashes].join(", ")} -> ${memoryHash}); that evidence is stale, not ` +
|
|
218
|
+
`missing, and reuse resumes once this pass re-judges it against the current memory-file set`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
199
222
|
if (!pending.length) {
|
|
200
223
|
emitProgress("analyze:start", { pending: 0, cached: summary.cached, total: transcripts.length, jobs: config.jobs });
|
|
201
224
|
emitProgress("analyze:done", summary);
|
package/src/apply/writer.js
CHANGED
|
@@ -76,7 +76,8 @@ function memoryFileSnapshot(proposal, repo) {
|
|
|
76
76
|
file: relative,
|
|
77
77
|
error:
|
|
78
78
|
`${relative} no longer exists, so its edits no longer describe the file on disk; nothing was written. ` +
|
|
79
|
-
`Run \`backpass\` to re-propose against the current repository
|
|
79
|
+
`Run \`backpass\` to re-propose against the current repository - that pass reanalyzes transcripts ` +
|
|
80
|
+
`against the file that exists now, it does not reuse the judgments behind this proposal.`,
|
|
80
81
|
},
|
|
81
82
|
};
|
|
82
83
|
}
|
|
@@ -94,7 +95,8 @@ function memoryFileSnapshot(proposal, repo) {
|
|
|
94
95
|
error:
|
|
95
96
|
`${relative} changed after this proposal was made (${expected} -> ${observed}), so its edits ` +
|
|
96
97
|
`no longer describe the file on disk; nothing was written. Run \`backpass\` to re-propose ` +
|
|
97
|
-
`against the current ${relative}
|
|
98
|
+
`against the current ${relative} - that pass reanalyzes transcripts against the new file, it does ` +
|
|
99
|
+
`not reuse the stale judgments behind this proposal.`,
|
|
98
100
|
},
|
|
99
101
|
};
|
|
100
102
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { analyzeTranscripts } from "../analyze.js";
|
|
2
2
|
import { applyDecisions, writeBootstrapFiles } from "../apply/writer.js";
|
|
3
3
|
import { bootstrapTargets, renderPointer, starterMemoryFile } from "../bootstrap.js";
|
|
4
|
-
import { color, info, json, out, warn } from "../logger.js";
|
|
4
|
+
import { UserError, color, info, json, out, warn } from "../logger.js";
|
|
5
|
+
import { resolveMemoryFiles } from "../memory.js";
|
|
5
6
|
import { emitProgress } from "../progress.js";
|
|
6
7
|
import { ProposalViolation } from "../proposal.js";
|
|
7
8
|
import { synthesizeProposal } from "../synthesize.js";
|
|
@@ -52,14 +53,25 @@ export async function bootstrapRun(ctx, deps = {}) {
|
|
|
52
53
|
const seed = [{ path: canonical, text: starter.text }];
|
|
53
54
|
if (pointer) seed.push({ path: pointer, text: renderPointer(canonical) });
|
|
54
55
|
const seeded = writeBootstrapFiles(repo.root, seed);
|
|
56
|
+
const resolved = resolveMemoryFiles(repo.root, config.memoryFiles);
|
|
55
57
|
for (const w of seeded.written) info(`${color.green("·")} wrote ${w.file}`);
|
|
56
58
|
for (const s of seeded.skipped) warn(`${s.file} ${s.reason} - left untouched`);
|
|
57
59
|
|
|
60
|
+
const canonicalSkipped = seeded.skipped.some((entry) => entry.file === canonical);
|
|
61
|
+
if (canonicalSkipped || resolved.primary?.path !== starter.path || resolved.primary?.text !== starter.text) {
|
|
62
|
+
throw new UserError(
|
|
63
|
+
`${canonical} changed while backpass was bootstrapping it`,
|
|
64
|
+
"run `backpass` again to analyze the memory file that now exists",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const memoryFile = resolved.primary;
|
|
68
|
+
const memoryHash = resolved.hash;
|
|
69
|
+
|
|
58
70
|
emitProgress("memory", {
|
|
59
|
-
path:
|
|
60
|
-
tokens:
|
|
71
|
+
path: memoryFile.path,
|
|
72
|
+
tokens: memoryFile.tokens,
|
|
61
73
|
budget: config.budgetTokens,
|
|
62
|
-
units:
|
|
74
|
+
units: memoryFile.units.length,
|
|
63
75
|
});
|
|
64
76
|
|
|
65
77
|
const result = {
|
|
@@ -78,10 +90,10 @@ export async function bootstrapRun(ctx, deps = {}) {
|
|
|
78
90
|
|
|
79
91
|
result.summary = await analyze({
|
|
80
92
|
transcripts,
|
|
81
|
-
memoryFile
|
|
93
|
+
memoryFile,
|
|
82
94
|
config,
|
|
83
95
|
repo,
|
|
84
|
-
memoryHash
|
|
96
|
+
memoryHash,
|
|
85
97
|
force: Boolean(ctx.flags.force),
|
|
86
98
|
});
|
|
87
99
|
info(
|
|
@@ -89,7 +101,7 @@ export async function bootstrapRun(ctx, deps = {}) {
|
|
|
89
101
|
`${result.summary.skipped} too short · ${result.summary.failed} failed`,
|
|
90
102
|
);
|
|
91
103
|
|
|
92
|
-
const folded = await foldForRun(ctx,
|
|
104
|
+
const folded = await foldForRun(ctx, memoryFile, memoryHash);
|
|
93
105
|
config.state.writeSummary(folded);
|
|
94
106
|
emitProgress("fold:done", {
|
|
95
107
|
instructions: folded.instructions.length,
|
|
@@ -102,7 +114,7 @@ export async function bootstrapRun(ctx, deps = {}) {
|
|
|
102
114
|
|
|
103
115
|
try {
|
|
104
116
|
const { proposal } = await synthesize({
|
|
105
|
-
memoryFile
|
|
117
|
+
memoryFile,
|
|
106
118
|
summary: folded,
|
|
107
119
|
config,
|
|
108
120
|
repo,
|
package/src/commands/propose.js
CHANGED
|
@@ -15,11 +15,21 @@ import { discoverForRun } from "./scan.js";
|
|
|
15
15
|
* prune what the current file now covers or what aged out (after recording, because the
|
|
16
16
|
* evidence files that fed an expired sighting are still on disk and would re-add it),
|
|
17
17
|
* then cluster from the ledger.
|
|
18
|
+
*
|
|
19
|
+
* Evidence is also filtered to `memoryHash`: a transcript's evidence file is rewritten
|
|
20
|
+
* every time it is re-analyzed against a changed memory file, but a transcript that fell
|
|
21
|
+
* out of this run's sample (window, cap, or discovery drift) leaves its last evidence file
|
|
22
|
+
* on disk under whatever hash it was last judged against. That leftover file is real and
|
|
23
|
+
* reusable the moment its transcript is re-analyzed - or immediately, if the memory file's
|
|
24
|
+
* bytes return to that hash - but folding it into *this* proposal would score it against
|
|
25
|
+
* an instruction index it was never judged against (aliases are positional) and inflate
|
|
26
|
+
* `analyzedSessions` with a session this run never touched. Nothing is migrated, rewritten,
|
|
27
|
+
* or deleted here - only excluded from this run's fold.
|
|
18
28
|
*/
|
|
19
|
-
export async function foldForRun(ctx, memoryFile) {
|
|
29
|
+
export async function foldForRun(ctx, memoryFile, memoryHash) {
|
|
20
30
|
const { state, minGapEvidence, gapLedgerMaxAge } = ctx.config;
|
|
21
31
|
const evidence = state.listEvidence();
|
|
22
|
-
const relevant = evidence.filter((e) => e.memoryPath === memoryFile.path);
|
|
32
|
+
const relevant = evidence.filter((e) => e.memoryPath === memoryFile.path && e.memoryHash === memoryHash);
|
|
23
33
|
|
|
24
34
|
const ledger = state.readGapLedger();
|
|
25
35
|
recordGapObservations(ledger, relevant);
|
|
@@ -39,11 +49,11 @@ export async function runProposal(ctx, precomputed = null) {
|
|
|
39
49
|
// folding, and agent resolution can all fail before synthesis starts; none of those
|
|
40
50
|
// failures may leave an older proposal available to apply as if it came from this run.
|
|
41
51
|
config.state.clearProposal();
|
|
42
|
-
const { file } = precomputed || primaryMemoryFile(repo, config);
|
|
52
|
+
const { file, hash } = precomputed || primaryMemoryFile(repo, config);
|
|
43
53
|
const transcripts = precomputed?.transcripts || (await discoverForRun(ctx)).transcripts;
|
|
44
54
|
|
|
45
55
|
const foldStarted = Date.now();
|
|
46
|
-
const summary = await foldForRun(ctx, file);
|
|
56
|
+
const summary = await foldForRun(ctx, file, hash);
|
|
47
57
|
config.state.writeSummary(summary);
|
|
48
58
|
emitProgress("fold:done", {
|
|
49
59
|
instructions: summary.instructions.length,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
|
|
3
4
|
import {
|
|
@@ -13,7 +14,9 @@ import {
|
|
|
13
14
|
} from "./shared.js";
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
|
-
*
|
|
17
|
+
* Pi writes standalone sessions under
|
|
18
|
+
* `~/.pi/agent/sessions/<escaped-cwd>/<ISO-ts>_<uuid>.jsonl`. BB's Pi bridge writes the
|
|
19
|
+
* same JSONL shape directly under `<bb-data-dir>/pi-bridge-sessions/`.
|
|
17
20
|
*
|
|
18
21
|
* Line 1 is `{type:"session", cwd, id}`. Entries form a parent/child tree but arrive in
|
|
19
22
|
* order, so a linear read is faithful. `model_change` / `thinking_level_change` records
|
|
@@ -26,13 +29,69 @@ export function storeRoot() {
|
|
|
26
29
|
return home(".pi", "agent", "sessions");
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
function expandEnvPath(value) {
|
|
33
|
+
const trimmed = value?.trim();
|
|
34
|
+
if (!trimmed) return null;
|
|
35
|
+
if (trimmed === "~") return home();
|
|
36
|
+
if (trimmed.startsWith("~/")) return path.join(home(), trimmed.slice(2));
|
|
37
|
+
return path.resolve(trimmed);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function realpathOrResolve(value) {
|
|
41
|
+
try {
|
|
42
|
+
return fs.realpathSync(value);
|
|
43
|
+
} catch {
|
|
44
|
+
return path.resolve(value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function storeSpecs() {
|
|
49
|
+
const specs = [
|
|
50
|
+
{ path: storeRoot(), direct: false, nested: true },
|
|
51
|
+
{ path: home(".bb", "pi-bridge-sessions"), direct: true, nested: false },
|
|
52
|
+
];
|
|
53
|
+
const piAgentDir = expandEnvPath(process.env.PI_CODING_AGENT_DIR);
|
|
54
|
+
if (piAgentDir) specs.push({ path: path.join(piAgentDir, "sessions"), direct: false, nested: true });
|
|
55
|
+
const piSessionDir = expandEnvPath(process.env.PI_CODING_AGENT_SESSION_DIR);
|
|
56
|
+
if (piSessionDir) specs.push({ path: piSessionDir, direct: true, nested: false });
|
|
57
|
+
const bbDataDir = expandEnvPath(process.env.BB_DATA_DIR);
|
|
58
|
+
if (bbDataDir) specs.push({ path: path.join(bbDataDir, "pi-bridge-sessions"), direct: true, nested: false });
|
|
59
|
+
const bridgeDir = expandEnvPath(process.env.BB_PI_BRIDGE_SESSION_DIR);
|
|
60
|
+
if (bridgeDir) specs.push({ path: bridgeDir, direct: true, nested: false });
|
|
61
|
+
|
|
62
|
+
const unique = new Map();
|
|
63
|
+
for (const spec of specs) {
|
|
64
|
+
const key = realpathOrResolve(spec.path);
|
|
65
|
+
const existing = unique.get(key);
|
|
66
|
+
if (existing) {
|
|
67
|
+
existing.direct ||= spec.direct;
|
|
68
|
+
existing.nested ||= spec.nested;
|
|
69
|
+
} else {
|
|
70
|
+
unique.set(key, spec);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return [...unique.values()];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function storeRoots() {
|
|
77
|
+
return storeSpecs().map((spec) => spec.path);
|
|
78
|
+
}
|
|
79
|
+
|
|
29
80
|
export function enumerate() {
|
|
30
81
|
const out = [];
|
|
31
|
-
|
|
32
|
-
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
for (const spec of storeSpecs()) {
|
|
84
|
+
const files = [
|
|
85
|
+
...(spec.direct ? listFiles(spec.path, ".jsonl") : []),
|
|
86
|
+
...(spec.nested ? listDirs(spec.path).flatMap((dir) => listFiles(dir, ".jsonl")) : []),
|
|
87
|
+
];
|
|
88
|
+
for (const file of files) {
|
|
89
|
+
const key = realpathOrResolve(file);
|
|
90
|
+
if (seen.has(key)) continue;
|
|
91
|
+
seen.add(key);
|
|
33
92
|
const stat = statOrNull(file);
|
|
34
93
|
if (!stat) continue;
|
|
35
|
-
out.push({ key
|
|
94
|
+
out.push({ key, path: file, mtimeMs: stat.mtimeMs, bytes: stat.size });
|
|
36
95
|
}
|
|
37
96
|
}
|
|
38
97
|
return out;
|