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 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** | `~/.pi/agent/sessions/<escaped-cwd>/*.jsonl` | session-header `cwd` |
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 memory
152
- file's hash: edit the weights and the evidence correctly re-computes; change nothing and
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. Within a run every file
281
- is composed from one version: it takes every accepted edit or none of them. Apply also
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.8",
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 = { total: transcripts.length, cached: 0, analyzed: 0, skipped: 0, failed: 0, usage: [] };
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);
@@ -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: starter.path,
60
- tokens: starter.tokens,
71
+ path: memoryFile.path,
72
+ tokens: memoryFile.tokens,
61
73
  budget: config.budgetTokens,
62
- units: starter.units.length,
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: starter,
93
+ memoryFile,
82
94
  config,
83
95
  repo,
84
- memoryHash: starter.hash,
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, starter);
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: starter,
117
+ memoryFile,
106
118
  summary: folded,
107
119
  config,
108
120
  repo,
@@ -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
- * pi: ~/.pi/agent/sessions/<escaped-cwd>/<ISO-ts>_<uuid>.jsonl
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
- for (const dir of listDirs(storeRoot())) {
32
- for (const file of listFiles(dir, ".jsonl")) {
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: file, path: file, mtimeMs: stat.mtimeMs, bytes: stat.size });
94
+ out.push({ key, path: file, mtimeMs: stat.mtimeMs, bytes: stat.size });
36
95
  }
37
96
  }
38
97
  return out;