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 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 six agent harnesses directly from disk.
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 six harnesses directly. No API, no upload.
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
- | Harness | Store | Repo tie |
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.0",
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
+ }
@@ -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
- const url = /(https?:\/\/\S+)/.exec(`${result.stdout}\n${result.stderr}`);
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
- // Feedback that was not a decision vector (a comment, a layout report): keep waiting.
122
- info(`${color.dim("received feedback without a decision vector; still waiting")}`);
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
@@ -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 of the six: association is a SQL predicate on
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.
@@ -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 indexed
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.
@@ -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")}`,