backpass 0.1.0 → 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 +23 -11
- package/package.json +1 -1
- package/src/apply/browser.js +49 -0
- package/src/apply/lavish.js +30 -5
- package/src/cli.js +3 -1
- package/src/commands/apply.js +3 -0
- 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
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
<a href="https://x.com/kunchenguid"
|
|
16
16
|
><img alt="X" src="https://img.shields.io/badge/X-@kunchenguid-black?style=flat-square"
|
|
17
17
|
/></a>
|
|
18
|
+
<a href="https://discord.gg/Wsy2NpnZDu"
|
|
19
|
+
><img
|
|
20
|
+
alt="Discord"
|
|
21
|
+
src="https://img.shields.io/discord/1439901831038763092?style=flat-square&label=discord"
|
|
22
|
+
/></a>
|
|
18
23
|
</p>
|
|
19
24
|
|
|
20
25
|
<h3 align="center">Gradient descent for your agent memory.</h3>
|
|
@@ -27,7 +32,7 @@ The loop only closes when a human happens to remember a failure and edits the fi
|
|
|
27
32
|
what happened in them, and proposes evidence-backed edits to your memory file - under a
|
|
28
33
|
token budget, gated by you.
|
|
29
34
|
|
|
30
|
-
- **Local-first** - Reads the transcript stores of
|
|
35
|
+
- **Local-first** - Reads the transcript stores of seven agent harnesses directly from disk.
|
|
31
36
|
No API, no upload; transcripts never leave your machine except into an agent you already
|
|
32
37
|
authenticated, and obvious secrets are redacted before they do.
|
|
33
38
|
- **Evidence-gated** - Every proposed edit carries verbatim quotes from real sessions, a
|
|
@@ -73,16 +78,20 @@ backpass apply # review each edit, accept or reject, then write
|
|
|
73
78
|
|
|
74
79
|
### 1. Collect samples - which sessions belong to this repo
|
|
75
80
|
|
|
76
|
-
backpass reads the local transcript stores of
|
|
81
|
+
backpass reads the local transcript stores of seven harnesses directly. No API, no upload.
|
|
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` |
|
|
91
|
+
| **hermes** | `~/.hermes/state.db` (sqlite) | CLI prompt cwd / ACP `model_config.cwd` |
|
|
77
92
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
| **claude** | `~/.claude/projects/<munged-cwd>/<uuid>.jsonl` | per-line `cwd` |
|
|
81
|
-
| **codex** | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | `cwd` + recorded `git.repository_url` |
|
|
82
|
-
| **pi** | `~/.pi/agent/sessions/<escaped-cwd>/*.jsonl` | session-header `cwd` |
|
|
83
|
-
| **opencode** | `~/.local/share/opencode/opencode.db` (sqlite) | `session.directory` |
|
|
84
|
-
| **grok** | `~/.grok/sessions/<encoded-cwd>/<uuid>/` | `summary.json` `cwd` + `git_remotes` |
|
|
85
|
-
| **cursor CLI** | `~/.cursor/chats/<md5(cwd)>/<uuid>/` | `meta.json` `cwd` |
|
|
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.
|
|
86
95
|
|
|
87
96
|
Association runs in three tiers:
|
|
88
97
|
|
|
@@ -226,12 +235,15 @@ the evidence quotes and their sources, a live budget gauge, and ACCEPT / REJECT.
|
|
|
226
235
|
|
|
227
236
|
The surface is a static template shipped in the package - the CLI injects one JSON payload,
|
|
228
237
|
so it is instant, deterministic, and identical every run. Nothing there is model-generated.
|
|
238
|
+
It opens in your default browser when one is available; the URL is always printed too, so
|
|
239
|
+
a headless box or `--no-open` just hands you the link.
|
|
229
240
|
|
|
230
241
|
There is no DEFER button, and it isn't missing: **rejections are remembered.** A rejected
|
|
231
242
|
edit is not proposed again unless materially new evidence arrives.
|
|
232
243
|
|
|
233
244
|
```sh
|
|
234
245
|
backpass apply --no-ui # same decision, in the terminal
|
|
246
|
+
backpass apply --no-open # print the surface URL, don't launch a browser
|
|
235
247
|
backpass apply --dry-run # show what would be written
|
|
236
248
|
```
|
|
237
249
|
|
|
@@ -347,7 +359,7 @@ CLI flags on top:
|
|
|
347
359
|
]
|
|
348
360
|
},
|
|
349
361
|
"discovery": {
|
|
350
|
-
"harnesses": ["claude", "codex", "pi", "opencode", "grok", "cursor"],
|
|
362
|
+
"harnesses": ["claude", "codex", "pi", "opencode", "grok", "cursor", "hermes"],
|
|
351
363
|
"since": "30d",
|
|
352
364
|
"worktreeGlobs": [],
|
|
353
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",
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Open a URL in the user's default browser, best effort.
|
|
5
|
+
*
|
|
6
|
+
* The review surface URL is always printed as the fallback, so this must never throw or
|
|
7
|
+
* block: a missing opener, a headless box, or a crashing helper all degrade to "print the
|
|
8
|
+
* URL only". Returns true when an opener was launched, false when the environment opted
|
|
9
|
+
* out or had no display.
|
|
10
|
+
*
|
|
11
|
+
* Dependencies are injectable so the decision logic is testable without a real browser.
|
|
12
|
+
*
|
|
13
|
+
* @typedef {(bin: string, args: string[], options: object) => { on?: Function, unref?: Function }} Spawner
|
|
14
|
+
* @param {string | null} url
|
|
15
|
+
* @param {{ platform?: string, env?: Record<string, string | undefined>, spawnFn?: Spawner }} [deps]
|
|
16
|
+
*/
|
|
17
|
+
export function openInBrowser(url, { platform = process.platform, env = process.env, spawnFn = spawn } = {}) {
|
|
18
|
+
if (!url || !/^https?:\/\//.test(url)) return false;
|
|
19
|
+
if (!canOpenBrowser({ platform, env })) return false;
|
|
20
|
+
|
|
21
|
+
const { bin, args } = openerCommand(url, platform);
|
|
22
|
+
try {
|
|
23
|
+
const child = spawnFn(bin, args, { stdio: "ignore", detached: true, windowsHide: true });
|
|
24
|
+
// A missing or failing opener must not surface as an unhandled error.
|
|
25
|
+
child.on?.("error", () => {});
|
|
26
|
+
child.unref?.();
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Headless detection: honor explicit opt-outs, CI, and display-less Linux.
|
|
35
|
+
* @param {{ platform?: string, env?: Record<string, string | undefined> }} [deps]
|
|
36
|
+
*/
|
|
37
|
+
export function canOpenBrowser({ platform = process.platform, env = process.env } = {}) {
|
|
38
|
+
if (env.BACKPASS_NO_BROWSER || env.CI) return false;
|
|
39
|
+
if (platform === "darwin" || platform === "win32") return true;
|
|
40
|
+
return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @returns {{ bin: string, args: string[] }} */
|
|
44
|
+
function openerCommand(url, platform) {
|
|
45
|
+
if (platform === "darwin") return { bin: "open", args: [url] };
|
|
46
|
+
// `start` treats its first quoted argument as the window title; pass an empty one.
|
|
47
|
+
if (platform === "win32") return { bin: "cmd", args: ["/c", "start", "", url] };
|
|
48
|
+
return { bin: "xdg-open", args: [url] };
|
|
49
|
+
}
|
package/src/apply/lavish.js
CHANGED
|
@@ -88,18 +88,37 @@ export async function openApplySurface(file) {
|
|
|
88
88
|
if (result.code !== 0) {
|
|
89
89
|
throw new UserError(`${LAVISH_BIN} failed to open the apply surface`, result.stderr.trim().slice(0, 400));
|
|
90
90
|
}
|
|
91
|
-
|
|
92
|
-
return url ? url[1] : null;
|
|
91
|
+
return extractUrl(`${result.stdout}\n${result.stderr}`);
|
|
93
92
|
}
|
|
94
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Pull the session URL out of lavish-axi's output. The CLI prints it YAML-style as
|
|
96
|
+
* `url: "http://..."`, so a bare `\S+` would swallow the closing quote; stop at any
|
|
97
|
+
* quote or bracket and drop trailing punctuation.
|
|
98
|
+
*/
|
|
99
|
+
export function extractUrl(text) {
|
|
100
|
+
const match = /https?:\/\/[^\s"'<>()[\]]+/.exec(text || "");
|
|
101
|
+
if (!match) return null;
|
|
102
|
+
return match[0].replace(/[.,;:!?]+$/, "");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Breathing room between polls that came back without a decision vector. */
|
|
106
|
+
export const POLL_RETRY_DELAY_MS = 1000;
|
|
107
|
+
|
|
95
108
|
/**
|
|
96
109
|
* Long-poll for the human's decision vector. `lavish-axi poll` blocks until the reviewer
|
|
97
110
|
* sends feedback, so this is intentionally a foreground wait.
|
|
111
|
+
*
|
|
112
|
+
* Feedback that is not a decision vector (a comment, a queued layout report) keeps the
|
|
113
|
+
* wait going. Each state is announced once: the wait line on entry, and a single note the
|
|
114
|
+
* first time non-decision feedback arrives - never one line per poll cycle, which on a
|
|
115
|
+
* chatty surface floods the terminal.
|
|
98
116
|
*/
|
|
99
|
-
export async function pollDecisions(file, editIds) {
|
|
117
|
+
export async function pollDecisions(file, editIds, { delayMs = POLL_RETRY_DELAY_MS } = {}) {
|
|
100
118
|
info(
|
|
101
119
|
`${color.dim("waiting for your decisions in the browser (Ctrl-C to abort; nothing is written until you send)")}`,
|
|
102
120
|
);
|
|
121
|
+
let notedOtherFeedback = false;
|
|
103
122
|
|
|
104
123
|
for (;;) {
|
|
105
124
|
const result = await runLavish(["poll", file]);
|
|
@@ -118,8 +137,14 @@ export async function pollDecisions(file, editIds) {
|
|
|
118
137
|
warn("review session ended without a decision vector - nothing applied");
|
|
119
138
|
return null;
|
|
120
139
|
}
|
|
121
|
-
|
|
122
|
-
|
|
140
|
+
|
|
141
|
+
if (!notedOtherFeedback) {
|
|
142
|
+
notedOtherFeedback = true;
|
|
143
|
+
info(
|
|
144
|
+
`${color.dim("feedback arrived without a decision vector - click APPLY in the browser and send from the panel; still waiting")}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
123
148
|
}
|
|
124
149
|
}
|
|
125
150
|
|
package/src/cli.js
CHANGED
|
@@ -53,6 +53,7 @@ const OPTIONS = {
|
|
|
53
53
|
|
|
54
54
|
"dry-run": { type: "boolean" },
|
|
55
55
|
"no-ui": { type: "boolean" },
|
|
56
|
+
"no-open": { type: "boolean" },
|
|
56
57
|
"no-auto-agent": { type: "boolean" },
|
|
57
58
|
force: { type: "boolean" },
|
|
58
59
|
limit: { type: "string" },
|
|
@@ -82,7 +83,7 @@ COMMANDS
|
|
|
82
83
|
COLLECT SAMPLES
|
|
83
84
|
--since <dur> only sessions newer than this (30d, 12h, 2w, all) [30d]
|
|
84
85
|
--harness <a,b> limit to these harnesses
|
|
85
|
-
(claude, codex, pi, opencode, grok, cursor)
|
|
86
|
+
(claude, codex, pi, opencode, grok, cursor, hermes)
|
|
86
87
|
--strict deterministic associations only (tiers 1 and 2)
|
|
87
88
|
--include-cursor-ide also scan the Cursor IDE store (best-effort, v1.1 preview)
|
|
88
89
|
--limit <n> analyze at most N transcripts this run (newest first)
|
|
@@ -114,6 +115,7 @@ BUDGET AND SHAPE
|
|
|
114
115
|
|
|
115
116
|
APPLY
|
|
116
117
|
--no-ui terminal accept/reject instead of the Lavish surface
|
|
118
|
+
--no-open print the review surface URL without opening a browser
|
|
117
119
|
--dry-run show what would be written, write nothing
|
|
118
120
|
--force re-analyze transcripts that already have fresh evidence,
|
|
119
121
|
and re-probe agents instead of trusting the probe cache
|
package/src/commands/apply.js
CHANGED
|
@@ -2,6 +2,7 @@ import { UserError, color, info, json, out, warn } from "../logger.js";
|
|
|
2
2
|
import { applyDecisions } from "../apply/writer.js";
|
|
3
3
|
import { closeApplySurface, openApplySurface, pollDecisions, renderApplySurface } from "../apply/lavish.js";
|
|
4
4
|
import { reviewInTerminal } from "../apply/terminal.js";
|
|
5
|
+
import { openInBrowser } from "../apply/browser.js";
|
|
5
6
|
import { budgetBar, formatTokens } from "../tokens.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -45,6 +46,8 @@ export async function cmdApply(ctx) {
|
|
|
45
46
|
surfaceFile = renderApplySurface(proposal, config.state, ctx.version);
|
|
46
47
|
const url = await openApplySurface(surfaceFile);
|
|
47
48
|
info(`${color.cyan("·")} review surface: ${url || surfaceFile}`);
|
|
49
|
+
// Best effort: the printed URL above is the fallback when nothing opens.
|
|
50
|
+
if (!ctx.flags["no-open"]) openInBrowser(url);
|
|
48
51
|
decisions = await pollDecisions(surfaceFile, editIds);
|
|
49
52
|
}
|
|
50
53
|
|
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")}`,
|