backpass 0.1.1 → 0.1.2
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 +15 -11
- package/package.json +1 -1
- package/src/cli.js +1 -1
- package/src/config.js +1 -1
- package/src/discovery/adapters/hermes.js +222 -0
- package/src/discovery/adapters/opencode.js +1 -1
- package/src/discovery/index.js +4 -2
- package/src/discovery/self.js +1 -1
- package/src/tui/render.js +2 -1
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ The loop only closes when a human happens to remember a failure and edits the fi
|
|
|
32
32
|
what happened in them, and proposes evidence-backed edits to your memory file - under a
|
|
33
33
|
token budget, gated by you.
|
|
34
34
|
|
|
35
|
-
- **Local-first** - Reads the transcript stores of
|
|
35
|
+
- **Local-first** - Reads the transcript stores of seven agent harnesses directly from disk.
|
|
36
36
|
No API, no upload; transcripts never leave your machine except into an agent you already
|
|
37
37
|
authenticated, and obvious secrets are redacted before they do.
|
|
38
38
|
- **Evidence-gated** - Every proposed edit carries verbatim quotes from real sessions, a
|
|
@@ -78,16 +78,20 @@ backpass apply # review each edit, accept or reject, then write
|
|
|
78
78
|
|
|
79
79
|
### 1. Collect samples - which sessions belong to this repo
|
|
80
80
|
|
|
81
|
-
backpass reads the local transcript stores of
|
|
81
|
+
backpass reads the local transcript stores of seven harnesses directly. No API, no upload.
|
|
82
82
|
|
|
83
|
-
| Harness | Store | Repo tie
|
|
84
|
-
| -------------- | ---------------------------------------------- |
|
|
85
|
-
| **claude** | `~/.claude/projects/<munged-cwd>/<uuid>.jsonl` | per-line `cwd`
|
|
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`
|
|
88
|
-
| **opencode** | `~/.local/share/opencode/opencode.db` (sqlite) | `session.directory`
|
|
89
|
-
| **grok** | `~/.grok/sessions/<encoded-cwd>/<uuid>/` | `summary.json` `cwd` + `git_remotes`
|
|
90
|
-
| **cursor CLI** | `~/.cursor/chats/<md5(cwd)>/<uuid>/` | `meta.json` `cwd`
|
|
83
|
+
| Harness | Store | Repo tie |
|
|
84
|
+
| -------------- | ---------------------------------------------- | --------------------------------------- |
|
|
85
|
+
| **claude** | `~/.claude/projects/<munged-cwd>/<uuid>.jsonl` | per-line `cwd` |
|
|
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` |
|
|
88
|
+
| **opencode** | `~/.local/share/opencode/opencode.db` (sqlite) | `session.directory` |
|
|
89
|
+
| **grok** | `~/.grok/sessions/<encoded-cwd>/<uuid>/` | `summary.json` `cwd` + `git_remotes` |
|
|
90
|
+
| **cursor CLI** | `~/.cursor/chats/<md5(cwd)>/<uuid>/` | `meta.json` `cwd` |
|
|
91
|
+
| **hermes** | `~/.hermes/state.db` (sqlite) | CLI prompt cwd / ACP `model_config.cwd` |
|
|
92
|
+
|
|
93
|
+
Hermes collection includes CLI and ACP sessions only. Gateway, cron, and WhatsApp sessions
|
|
94
|
+
are excluded because their recorded cwd belongs to the shared gateway process, not a project.
|
|
91
95
|
|
|
92
96
|
Association runs in three tiers:
|
|
93
97
|
|
|
@@ -355,7 +359,7 @@ CLI flags on top:
|
|
|
355
359
|
]
|
|
356
360
|
},
|
|
357
361
|
"discovery": {
|
|
358
|
-
"harnesses": ["claude", "codex", "pi", "opencode", "grok", "cursor"],
|
|
362
|
+
"harnesses": ["claude", "codex", "pi", "opencode", "grok", "cursor", "hermes"],
|
|
359
363
|
"since": "30d",
|
|
360
364
|
"worktreeGlobs": [],
|
|
361
365
|
"minUserTurns": 2
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backpass",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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/cli.js
CHANGED
|
@@ -83,7 +83,7 @@ COMMANDS
|
|
|
83
83
|
COLLECT SAMPLES
|
|
84
84
|
--since <dur> only sessions newer than this (30d, 12h, 2w, all) [30d]
|
|
85
85
|
--harness <a,b> limit to these harnesses
|
|
86
|
-
(claude, codex, pi, opencode, grok, cursor)
|
|
86
|
+
(claude, codex, pi, opencode, grok, cursor, hermes)
|
|
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)
|
package/src/config.js
CHANGED
|
@@ -7,7 +7,7 @@ import { UserError, warn } from "./logger.js";
|
|
|
7
7
|
export const CONFIG_FILENAME = ".backpassrc.json";
|
|
8
8
|
export const STATE_DIRNAME = ".backpass";
|
|
9
9
|
|
|
10
|
-
export const ALL_HARNESSES = ["claude", "codex", "pi", "opencode", "grok", "cursor"];
|
|
10
|
+
export const ALL_HARNESSES = ["claude", "codex", "pi", "opencode", "grok", "cursor", "hermes"];
|
|
11
11
|
/** Cursor IDE is deferred to v1.1 and only ever runs behind --include-cursor-ide. */
|
|
12
12
|
export const OPT_IN_HARNESSES = ["cursor-ide"];
|
|
13
13
|
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { home, contentToEvents, attachToolResults } from "./shared.js";
|
|
4
|
+
import { openReadOnly, safeJsonParse } from "./sqlite.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* hermes: ~/.hermes/state.db (sqlite)
|
|
8
|
+
*
|
|
9
|
+
* Observed schema version 13 (upstream is 26; SELECT named columns so additive
|
|
10
|
+
* columns are tolerated). Hermes has no cwd, git branch, or git remote column.
|
|
11
|
+
*
|
|
12
|
+
* Association is recovered only for source in ('cli', 'acp'):
|
|
13
|
+
* acp - model_config.cwd when it is an absolute path
|
|
14
|
+
* cli - first `Current working directory:` / `Working directory:` line in
|
|
15
|
+
* system_prompt
|
|
16
|
+
* Gateway / cron / whatsapp sessions are skipped: their prompt cwd is the
|
|
17
|
+
* gateway process cwd, not a project, and would pin unrelated sessions to one
|
|
18
|
+
* repo. Sessions with no recoverable cwd are skipped.
|
|
19
|
+
*
|
|
20
|
+
* Timestamps are epoch seconds; backpass uses milliseconds (x1000).
|
|
21
|
+
* Structured content uses a `\x00json:` prefix; node:sqlite truncates TEXT at
|
|
22
|
+
* NUL, so message content is read as BLOB and decoded.
|
|
23
|
+
* JSONL leftovers under ~/.hermes/sessions/ are abandoned and not read.
|
|
24
|
+
* Honor HERMES_HOME; do not walk profile directories.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export const name = "hermes";
|
|
28
|
+
export const sqliteBacked = true;
|
|
29
|
+
|
|
30
|
+
const CLI_ACP = new Set(["cli", "acp"]);
|
|
31
|
+
const JSON_PREFIX = "\x00json:";
|
|
32
|
+
const CWD_LINE = /^(?:Current working directory|Working directory):\s*(.+)$/m;
|
|
33
|
+
|
|
34
|
+
export function storeRoot() {
|
|
35
|
+
return process.env.HERMES_HOME || home(".hermes");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function dbPath() {
|
|
39
|
+
return path.join(storeRoot(), "state.db");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function toMs(epochSeconds) {
|
|
43
|
+
const n = Number(epochSeconds);
|
|
44
|
+
return Number.isFinite(n) ? Math.round(n * 1000) : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function looksAbsolute(value) {
|
|
48
|
+
return typeof value === "string" && value.length > 0 && path.isAbsolute(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function cwdFromConfig(raw) {
|
|
52
|
+
const parsed = typeof raw === "string" ? safeJsonParse(raw) : raw;
|
|
53
|
+
return looksAbsolute(parsed?.cwd) ? parsed.cwd : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function cwdFromPrompt(prompt) {
|
|
57
|
+
if (typeof prompt !== "string" || !prompt) return null;
|
|
58
|
+
const match = prompt.match(CWD_LINE);
|
|
59
|
+
if (!match) return null;
|
|
60
|
+
const cwd = match[1].trim();
|
|
61
|
+
return looksAbsolute(cwd) ? cwd : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function recoverCwd(row, source) {
|
|
65
|
+
if (source === "acp") return cwdFromConfig(row.model_config);
|
|
66
|
+
if (source === "cli") return cwdFromPrompt(row.system_prompt);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function activeMessageFilter(db, alias = "") {
|
|
71
|
+
const hasActive = db
|
|
72
|
+
.prepare("PRAGMA table_info(messages)")
|
|
73
|
+
.all()
|
|
74
|
+
.some((column) => column.name === "active");
|
|
75
|
+
return hasActive ? ` AND ${alias}active = 1` : "";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Discovery is one indexed query. The caller applies the shared association
|
|
80
|
+
* tiers and handles schema errors per harness. A missing DB yields an empty list.
|
|
81
|
+
* @param {{ cutoffMs?: number }} [options]
|
|
82
|
+
*/
|
|
83
|
+
export async function discover({ cutoffMs } = {}) {
|
|
84
|
+
const db = await openReadOnly(dbPath());
|
|
85
|
+
if (!db) return [];
|
|
86
|
+
|
|
87
|
+
const cutoffSec = cutoffMs == null ? null : cutoffMs / 1000;
|
|
88
|
+
try {
|
|
89
|
+
const activeFilter = activeMessageFilter(db, "m.");
|
|
90
|
+
const rows = db
|
|
91
|
+
.prepare(
|
|
92
|
+
`SELECT s.id, s.source, s.model, s.model_config, s.system_prompt, s.title,
|
|
93
|
+
s.started_at, s.ended_at,
|
|
94
|
+
MAX(s.started_at,
|
|
95
|
+
COALESCE(s.ended_at, s.started_at),
|
|
96
|
+
COALESCE((SELECT MAX(m.timestamp)
|
|
97
|
+
FROM messages m
|
|
98
|
+
WHERE m.session_id = s.id${activeFilter}),
|
|
99
|
+
s.started_at)) AS activity_at
|
|
100
|
+
FROM sessions s
|
|
101
|
+
WHERE lower(s.source) IN ('cli', 'acp')
|
|
102
|
+
AND (? IS NULL OR
|
|
103
|
+
MAX(s.started_at,
|
|
104
|
+
COALESCE(s.ended_at, s.started_at),
|
|
105
|
+
COALESCE((SELECT MAX(m.timestamp)
|
|
106
|
+
FROM messages m
|
|
107
|
+
WHERE m.session_id = s.id${activeFilter}),
|
|
108
|
+
s.started_at)) >= ?)`,
|
|
109
|
+
)
|
|
110
|
+
.all(cutoffSec, cutoffSec ?? 0);
|
|
111
|
+
|
|
112
|
+
const out = [];
|
|
113
|
+
for (const row of rows) {
|
|
114
|
+
const source = String(row.source || "").toLowerCase();
|
|
115
|
+
if (!CLI_ACP.has(source)) continue;
|
|
116
|
+
const cwd = recoverCwd(row, source);
|
|
117
|
+
if (!cwd) continue;
|
|
118
|
+
const startedAt = toMs(row.started_at);
|
|
119
|
+
const activityAt = toMs(row.activity_at);
|
|
120
|
+
out.push({
|
|
121
|
+
key: `hermes:${row.id}`,
|
|
122
|
+
id: row.id,
|
|
123
|
+
path: dbPath(),
|
|
124
|
+
cwd,
|
|
125
|
+
gitRoot: null,
|
|
126
|
+
gitBranch: null,
|
|
127
|
+
remotes: [],
|
|
128
|
+
title: row.title || null,
|
|
129
|
+
startedAt,
|
|
130
|
+
mtimeMs: activityAt || startedAt || 0,
|
|
131
|
+
bytes: 0,
|
|
132
|
+
model: row.model || null,
|
|
133
|
+
extra: { sessionId: row.id, source },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
} finally {
|
|
138
|
+
db.close();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** node:sqlite truncates TEXT at a NUL, so `\x00json:` payloads must be read as BLOB. */
|
|
143
|
+
function sqliteText(value) {
|
|
144
|
+
if (value == null) return value;
|
|
145
|
+
if (typeof value === "string") return value;
|
|
146
|
+
if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) {
|
|
147
|
+
return new TextDecoder("utf8").decode(value);
|
|
148
|
+
}
|
|
149
|
+
return String(value);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function decodeContent(content) {
|
|
153
|
+
const text = sqliteText(content);
|
|
154
|
+
if (typeof text !== "string") return content;
|
|
155
|
+
if (!text.startsWith(JSON_PREFIX)) return text;
|
|
156
|
+
const parsed = safeJsonParse(text.slice(JSON_PREFIX.length));
|
|
157
|
+
return parsed == null ? text : parsed;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function emitTools(raw, events, idAlias) {
|
|
161
|
+
const text = sqliteText(raw);
|
|
162
|
+
const parsed = typeof text === "string" ? safeJsonParse(text) : text;
|
|
163
|
+
if (!Array.isArray(parsed)) return;
|
|
164
|
+
for (const call of parsed) {
|
|
165
|
+
if (!call || typeof call !== "object") continue;
|
|
166
|
+
const fn = call.function && typeof call.function === "object" ? call.function : {};
|
|
167
|
+
const name = fn.name || call.name;
|
|
168
|
+
let input = fn.arguments ?? call.arguments;
|
|
169
|
+
if (typeof input === "string") {
|
|
170
|
+
const parsedInput = safeJsonParse(input);
|
|
171
|
+
if (parsedInput !== null) input = parsedInput;
|
|
172
|
+
}
|
|
173
|
+
const pendingId = call.id || call.call_id;
|
|
174
|
+
if (call.id) idAlias.set(call.id, pendingId);
|
|
175
|
+
if (call.call_id) idAlias.set(call.call_id, pendingId);
|
|
176
|
+
events.push({ kind: "tool", name, input, pendingId });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function read(ref) {
|
|
181
|
+
const db = await openReadOnly(dbPath());
|
|
182
|
+
if (!db) return { events: [], model: ref.model || null };
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const sessionId = ref.extra?.sessionId || ref.id;
|
|
186
|
+
const activeFilter = activeMessageFilter(db);
|
|
187
|
+
const rows = db
|
|
188
|
+
.prepare(
|
|
189
|
+
`SELECT role, CAST(content AS BLOB) AS content, tool_call_id, tool_calls, tool_name
|
|
190
|
+
FROM messages
|
|
191
|
+
WHERE session_id = ?${activeFilter}
|
|
192
|
+
ORDER BY timestamp, id`,
|
|
193
|
+
)
|
|
194
|
+
.all(sessionId);
|
|
195
|
+
|
|
196
|
+
const events = [];
|
|
197
|
+
const idAlias = new Map();
|
|
198
|
+
for (const row of rows) {
|
|
199
|
+
const role = row.role;
|
|
200
|
+
if (role === "session_meta") continue;
|
|
201
|
+
const content = decodeContent(row.content);
|
|
202
|
+
if (role === "user" || role === "assistant") {
|
|
203
|
+
contentToEvents(role, content, events);
|
|
204
|
+
if (role === "assistant") emitTools(row.tool_calls, events, idAlias);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (role === "tool") {
|
|
208
|
+
events.push({
|
|
209
|
+
kind: "tool-result",
|
|
210
|
+
id: idAlias.get(row.tool_call_id) || row.tool_call_id,
|
|
211
|
+
result: content,
|
|
212
|
+
status: "completed",
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { events: attachToolResults(events), model: ref.model || null };
|
|
217
|
+
} catch {
|
|
218
|
+
return { events: [], model: ref.model || null };
|
|
219
|
+
} finally {
|
|
220
|
+
db.close();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -11,7 +11,7 @@ import { openReadOnly, safeJsonParse } from "./sqlite.js";
|
|
|
11
11
|
* message(id, session_id, data) - data is JSON: {role, model, time, ...}
|
|
12
12
|
* part(id, message_id, session_id, data) - data is JSON: {type: text|tool|reasoning|...}
|
|
13
13
|
*
|
|
14
|
-
* This is the best-behaved store
|
|
14
|
+
* This is the best-behaved store: association is a SQL predicate on
|
|
15
15
|
* `session.directory`, deleted worktrees included, and both listing and reading are
|
|
16
16
|
* indexed. Older opencode versions used file storage under `storage/`; that layout is
|
|
17
17
|
* handled as a fallback so long-lived machines still yield transcripts.
|
package/src/discovery/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as codex from "./adapters/codex.js";
|
|
|
3
3
|
import * as pi from "./adapters/pi.js";
|
|
4
4
|
import * as grok from "./adapters/grok.js";
|
|
5
5
|
import * as opencode from "./adapters/opencode.js";
|
|
6
|
+
import * as hermes from "./adapters/hermes.js";
|
|
6
7
|
import * as cursorCli from "./adapters/cursor-cli.js";
|
|
7
8
|
import * as cursorIde from "./adapters/cursor-ide.js";
|
|
8
9
|
|
|
@@ -18,6 +19,7 @@ export const ADAPTERS = {
|
|
|
18
19
|
pi,
|
|
19
20
|
grok,
|
|
20
21
|
opencode,
|
|
22
|
+
hermes,
|
|
21
23
|
cursor: cursorCli,
|
|
22
24
|
"cursor-ide": cursorIde,
|
|
23
25
|
};
|
|
@@ -34,8 +36,8 @@ export function getAdapter(harness) {
|
|
|
34
36
|
* Re-scans are then O(new files) - which matters: codex alone had 10,317 rollouts on
|
|
35
37
|
* the machine this was designed against.
|
|
36
38
|
*
|
|
37
|
-
* SQLite-backed stores (opencode, cursor IDE) answer the same question with one
|
|
38
|
-
* query, so they skip the cache entirely.
|
|
39
|
+
* SQLite-backed stores (opencode, hermes, cursor IDE) answer the same question with one
|
|
40
|
+
* indexed query, so they skip the cache entirely.
|
|
39
41
|
*
|
|
40
42
|
* Every harness is fail-soft: a store that is missing, unreadable, or has drifted into
|
|
41
43
|
* an unrecognised format produces a named warning and is skipped, never a failed run.
|
package/src/discovery/self.js
CHANGED
|
@@ -16,7 +16,7 @@ import { SELF_SESSION_SENTINEL } from "../prompts.js";
|
|
|
16
16
|
*
|
|
17
17
|
* The check reads only the head of the file and keys on the JSON-encoded user text as
|
|
18
18
|
* every file-backed harness records it (`"text":"..."` / `"content":"..."` /
|
|
19
|
-
* `"message":"..."`). SQLite-backed stores (opencode, cursor IDE) have no file to
|
|
19
|
+
* `"message":"..."`). SQLite-backed stores (opencode, hermes, cursor IDE) have no file to
|
|
20
20
|
* inspect and acpx does not drive them, so they are passed through.
|
|
21
21
|
*/
|
|
22
22
|
|
package/src/tui/render.js
CHANGED
|
@@ -33,6 +33,7 @@ const HARNESS_HUES = {
|
|
|
33
33
|
grok: "magenta",
|
|
34
34
|
cursor: "blue",
|
|
35
35
|
"cursor-ide": "blue",
|
|
36
|
+
hermes: "yellow",
|
|
36
37
|
};
|
|
37
38
|
|
|
38
39
|
/**
|
|
@@ -302,7 +303,7 @@ function discoverDetail(state, theme, width, spin) {
|
|
|
302
303
|
const scanned = `${formatCount(h.scanned)} sessions`;
|
|
303
304
|
const fresh = h.newCount > 0 || h.scanned > 0 ? ` · ${formatCount(h.newCount)} new` : "";
|
|
304
305
|
const self = h.self > 0 ? ` · ${formatCount(h.self)} self` : "";
|
|
305
|
-
const query = harness === "opencode" ? "1 sqlite query" : `${scanned}${fresh}${self}`;
|
|
306
|
+
const query = harness === "opencode" || harness === "hermes" ? "1 sqlite query" : `${scanned}${fresh}${self}`;
|
|
306
307
|
activity = theme.paint(fitPlain(query, activityWidth), "dim");
|
|
307
308
|
count = padVis(
|
|
308
309
|
`${theme.paint(formatCount(h.matched), "text", { bold: true })} ${theme.paint("this repo", "faint")}`,
|