rewound 0.2.0 → 0.4.0

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
@@ -6,11 +6,11 @@ Grep for everything your AI coding agents ever did.
6
6
 
7
7
  Heavy users of coding agents accumulate gigabytes of session transcripts that are effectively write-only memory:
8
8
 
9
- - **No search.** Claude Code has no cross-session full-text search. Finding "the session where we fixed the auth bug" means manual archaeology through JSONL files.
9
+ - **No search.** Claude Code and Codex CLI have no cross-session full-text search. Finding "the session where we fixed the auth bug" means manual archaeology through JSONL files.
10
10
  - **History is perishable.** Claude Code deletes transcripts older than 30 days by default (`cleanupPeriodDays`, swept at startup) — your reasoning history evaporates.
11
11
  - **Agents can't remember.** Every new session rediscovers what a previous session already solved. Nobody serves the agent itself.
12
12
 
13
- rewound indexes every session transcript on your machine into a local SQLite/FTS5 database and exposes it three ways: a CLI, an MCP server your agents can query directly, and a phone-friendly local web UI. Everything runs on your machine. Nothing leaves it.
13
+ rewound indexes every session transcript on your machine — Claude Code and OpenAI Codex CLI today, more harnesses next — into a local SQLite/FTS5 database and exposes it three ways: a CLI, an MCP server your agents can query directly, and a phone-friendly local web UI. Everything runs on your machine. Nothing leaves it.
14
14
 
15
15
  ## 60-second demo
16
16
 
@@ -30,6 +30,19 @@ found and fixed the **fts5** **trigger** bug by rewriting the AFTER UPDATE **tri
30
30
 
31
31
  Those numbers are real, from indexing a working machine's actual Claude Code history: 3,310 session files / 2+ GB / 368,687 messages, cold-indexed in 30 seconds (target was 5 minutes), incremental re-index in 148ms (target 5s), search in single-digit milliseconds (target 100ms).
32
32
 
33
+ ![Search results — grouped per session, prose ranked above tool output](docs/media/search.png)
34
+
35
+ Your own words rank above tool output that merely mentions the term, results are grouped one-per-session (`+N more in this session`), and every hit carries a one-tap resume command. More recall stories in [docs/use-cases.md](docs/use-cases.md).
36
+
37
+ <details>
38
+ <summary>More screenshots: session transcript · stats · phone</summary>
39
+
40
+ ![Session transcript view](docs/media/session.png)
41
+ ![Stats — est. API cost per project](docs/media/stats.png)
42
+ ![Phone-friendly over your tailnet](docs/media/phone.png)
43
+
44
+ </details>
45
+
33
46
  ## Install
34
47
 
35
48
  ```bash
@@ -37,8 +50,9 @@ npm install -g rewound
37
50
  rewound index
38
51
  ```
39
52
 
40
- Or without installing: `npx rewound index`. Node 20+ required. The CLI is also installed
41
- as `rw` same commands, fewer keystrokes (`rw search "auth bug"`).
53
+ Or with Homebrew: `brew install dashorama/tap/rewound`. Or without installing anything:
54
+ `npx rewound index`. Node 20+ required. The CLI is also installed as `rw` same
55
+ commands, fewer keystrokes (`rw search "auth bug"`).
42
56
 
43
57
  <details>
44
58
  <summary>Build from source</summary>
@@ -52,17 +66,48 @@ node dist/cli.js index # or: npm link, then `rewound index`
52
66
 
53
67
  </details>
54
68
 
55
- By default rewound reads `~/.claude/projects/**/*.jsonl` (read-only — it never modifies your transcripts) and writes its own database to `~/.rewound/rewound.db`. Override the DB path with `--db <path>` or `REWOUND_DB=<path>`.
69
+ By default rewound reads `~/.claude/projects/**/*.jsonl` and Codex CLI sessions from `~/.codex/sessions` (read-only — it never modifies your transcripts) and writes its own database to `~/.rewound/rewound.db`. Override the DB path with `--db <path>` or `REWOUND_DB=<path>`.
56
70
 
57
- ### Your history outlives Claude Code's cleanup
71
+ ### Multi-machine: your history follows you
58
72
 
59
- Claude Code deletes transcripts older than ~30 days at startup. rewound's index is permanent: once a session is indexed, it stays searchable even after the source file is gone (it's kept as an *archived* session). Anything from before you started indexing is already unrecoverable — so the best moment to run `rewound index` is now, and then regularly. A cron line makes it automatic:
73
+ If you work across machines, two commands per machine give you one merged history with
74
+ zero servers involved:
60
75
 
76
+ ```bash
77
+ rewound sync ~/GoogleDrive/rewound # once: any folder your machines already share
78
+ rewound auto --install # keeps index + sync fresh via cron, hourly
61
79
  ```
62
- @hourly rewound index
80
+
81
+ That's the whole setup. The folder is remembered after the first run — from then on bare
82
+ `rewound sync` (and the cron entry `rewound auto` installs) just works. Any shared folder
83
+ qualifies: Google Drive, Dropbox, Syncthing, a private git repo you pull/push, a mounted
84
+ NAS.
85
+
86
+ How it works: each machine writes its own snapshot (`<hostname>.rewound.db`) into the
87
+ folder and merges everyone else's — the richer copy of a session wins and repeat runs are
88
+ no-ops. Because every host only ever writes its own file, eventually-consistent syncers
89
+ like Drive and Dropbox never see write conflicts. `rewound merge <file.db>` is the
90
+ underlying primitive if you'd rather move snapshots by hand (scp, USB stick).
91
+
92
+ **No shared folder? Using object storage (S3, Supabase Storage, R2...)?** The easy road
93
+ is still any file-sync client. If you'd rather use a bucket, wrap the sync in two rclone
94
+ copies (Supabase Storage is S3-compatible — create S3 access keys in project settings →
95
+ Storage, then `rclone config` an S3 remote with your project's storage endpoint):
96
+
97
+ ```bash
98
+ rclone copy bucket:rewound ~/.rewound/sync # pull other machines' snapshots
99
+ rewound sync ~/.rewound/sync # merge + write this machine's snapshot
100
+ rclone copy ~/.rewound/sync bucket:rewound # push
63
101
  ```
64
102
 
65
- Incremental runs take well under a second when little has changed.
103
+ Put those three lines in cron and it's just as automatic — no mounts needed.
104
+
105
+ Privacy stance unchanged: rewound itself never opens a network connection — *you* choose
106
+ the transport, and your data only ever lands on storage you control.
107
+
108
+ ### Your history outlives Claude Code's cleanup
109
+
110
+ Claude Code deletes transcripts older than ~30 days at startup. rewound's index is permanent: once a session is indexed, it stays searchable even after the source file is gone (it's kept as an *archived* session). Anything from before you started indexing is already unrecoverable — so the best moment to run `rewound index` is now, and then let `rewound auto --install` keep it fresh hourly. Incremental runs take well under a second when little has changed.
66
111
 
67
112
  ## The surfaces
68
113
 
@@ -119,7 +164,7 @@ Server-rendered with zero frontend build step, colorblind-safe palette (blue/ora
119
164
 
120
165
  ## Roadmap
121
166
 
122
- Keyword-first search is deliberate for v0.x — it's fast, local, and predictable. Local hybrid/semantic search (vector index built with a local embedding model, no API keys, fused with FTS ranking) is planned once the keyword surface has proven itself; the gap it closes is vocabulary mismatch ("that time the port was already taken" vs `EADDRINUSE`).
167
+ More harness adapters are next: Copilot CLI and OpenCode transcripts are already mapped (see repo issues/roadmap). Keyword-first search is deliberate for v0.x — it's fast, local, and predictable. Local hybrid/semantic search (vector index built with a local embedding model, no API keys, fused with FTS ranking) is planned once the keyword surface has proven itself; the gap it closes is vocabulary mismatch ("that time the port was already taken" vs `EADDRINUSE`).
123
168
 
124
169
  ## License & pricing
125
170
 
@@ -1,5 +1,5 @@
1
- import fs from "node:fs";
2
1
  import path from "node:path";
2
+ import { consumeCompleteLines, walkJsonlFiles } from "./jsonl.js";
3
3
  function extractBlockText(block) {
4
4
  if (block == null)
5
5
  return "";
@@ -59,46 +59,19 @@ function decodeProjectDir(filePath) {
59
59
  const dirName = path.basename(path.dirname(filePath));
60
60
  return dirName.replace(/-/g, "/");
61
61
  }
62
- function walk(dir, found) {
63
- let entries;
64
- try {
65
- entries = fs.readdirSync(dir, { withFileTypes: true });
66
- }
67
- catch {
68
- return;
69
- }
70
- for (const entry of entries) {
71
- const full = path.join(dir, entry.name);
72
- if (entry.isDirectory()) {
73
- walk(full, found);
74
- }
75
- else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
76
- found.push(full);
77
- }
78
- }
79
- }
80
62
  export class ClaudeCodeAdapter {
81
63
  id = "claude-code";
82
64
  discover(roots) {
83
65
  const found = [];
84
66
  for (const root of roots)
85
- walk(root, found);
86
- return found;
67
+ walkJsonlFiles(root, found);
68
+ // rollout-*.jsonl are Codex CLI files (CodexAdapter's territory) — keep the
69
+ // two adapters disjoint even when a user points --roots at a mixed tree.
70
+ return found.filter((f) => !/^rollout-.*\.jsonl$/.test(path.basename(f)));
87
71
  }
88
72
  parse(filePath, fromByte = 0) {
89
73
  const id = path.basename(filePath, ".jsonl");
90
- const buf = fs.readFileSync(filePath);
91
- const slice = buf.subarray(fromByte);
92
- // Only ever consume complete, newline-terminated lines. If the file is
93
- // mid-write (the writer appended a record's bytes but hasn't flushed its
94
- // trailing "\n" yet), the last fragment in `slice` is torn — leave it
95
- // unconsumed and unparsed so the next incremental call re-reads it whole,
96
- // rather than either erroring on it or silently skipping past it.
97
- const lastNewline = slice.lastIndexOf(0x0a); // "\n"
98
- const consumedSlice = lastNewline === -1 ? slice.subarray(0, 0) : slice.subarray(0, lastNewline + 1);
99
- const bytesConsumed = fromByte + consumedSlice.length;
100
- const text = consumedSlice.length > 0 ? consumedSlice.toString("utf8") : "";
101
- const lines = text.length > 0 ? text.split("\n") : [];
74
+ const { lines, bytesConsumed } = consumeCompleteLines(filePath, fromByte);
102
75
  const messages = [];
103
76
  let title;
104
77
  let gitBranch;
@@ -0,0 +1,127 @@
1
+ import crypto from "node:crypto";
2
+ import path from "node:path";
3
+ import { consumeCompleteLines, walkJsonlFiles } from "./jsonl.js";
4
+ const ROLLOUT_RE = /rollout-.*\.jsonl$/;
5
+ const UUID_RE = /([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\.jsonl$/;
6
+ function contentText(content) {
7
+ if (typeof content === "string")
8
+ return content;
9
+ if (!Array.isArray(content))
10
+ return "";
11
+ const parts = [];
12
+ for (const item of content) {
13
+ if (item && typeof item === "object") {
14
+ const t = item.text;
15
+ if (typeof t === "string" && t)
16
+ parts.push(t);
17
+ }
18
+ }
19
+ return parts.join("\n\n");
20
+ }
21
+ function messageUuid(ts, role, text) {
22
+ return crypto.createHash("sha1").update(`${ts}|${role}|${text}`).digest("hex").slice(0, 16);
23
+ }
24
+ export class CodexAdapter {
25
+ id = "codex";
26
+ discover(roots) {
27
+ const found = [];
28
+ for (const root of roots)
29
+ walkJsonlFiles(root, found);
30
+ return found.filter((f) => ROLLOUT_RE.test(path.basename(f)));
31
+ }
32
+ parse(filePath, fromByte = 0) {
33
+ const uuidMatch = UUID_RE.exec(path.basename(filePath));
34
+ const id = uuidMatch ? uuidMatch[1] : path.basename(filePath, ".jsonl");
35
+ const { lines, bytesConsumed } = consumeCompleteLines(filePath, fromByte);
36
+ const messages = [];
37
+ let projectDir;
38
+ let gitBranch;
39
+ let model;
40
+ let startedAt;
41
+ let endedAt;
42
+ let parseErrors = 0;
43
+ const push = (m) => {
44
+ messages.push({ ...m, uuid: messageUuid(m.ts, m.role, m.text || m.toolText || ""), isSidechain: false });
45
+ if (m.ts) {
46
+ if (!startedAt || m.ts < startedAt)
47
+ startedAt = m.ts;
48
+ if (!endedAt || m.ts > endedAt)
49
+ endedAt = m.ts;
50
+ }
51
+ };
52
+ for (const line of lines) {
53
+ const trimmed = line.trim();
54
+ if (!trimmed)
55
+ continue;
56
+ let record;
57
+ try {
58
+ record = JSON.parse(trimmed);
59
+ }
60
+ catch {
61
+ parseErrors++;
62
+ continue;
63
+ }
64
+ if (!record || typeof record !== "object" || typeof record.type !== "string") {
65
+ parseErrors++;
66
+ continue;
67
+ }
68
+ const ts = record.timestamp ?? "";
69
+ const p = record.payload;
70
+ if (!p || typeof p !== "object")
71
+ continue;
72
+ if (record.type === "session_meta") {
73
+ if (typeof p.cwd === "string" && !projectDir)
74
+ projectDir = p.cwd;
75
+ const git = p.git;
76
+ if (git && typeof git.branch === "string" && !gitBranch)
77
+ gitBranch = git.branch;
78
+ continue;
79
+ }
80
+ if (record.type === "turn_context") {
81
+ if (typeof p.model === "string")
82
+ model = p.model;
83
+ continue;
84
+ }
85
+ if (record.type !== "response_item")
86
+ continue; // compacted, event_msg, drift: skip
87
+ const itemType = p.type;
88
+ if (itemType === "message") {
89
+ const role = p.role === "assistant" ? "assistant" : p.role === "user" ? "user" : undefined;
90
+ if (!role)
91
+ continue;
92
+ const text = contentText(p.content);
93
+ if (!text)
94
+ continue;
95
+ push({ role, ts, text, tools: [], model: role === "assistant" ? model : undefined });
96
+ }
97
+ else if (itemType === "reasoning") {
98
+ const text = [contentText(p.summary), contentText(p.content)].filter(Boolean).join("\n\n");
99
+ if (text)
100
+ push({ role: "assistant", ts, text, tools: [], model });
101
+ }
102
+ else if (itemType === "function_call" || itemType === "local_shell_call" || itemType === "custom_tool_call") {
103
+ const name = typeof p.name === "string" ? p.name : itemType === "local_shell_call" ? "shell" : "tool";
104
+ push({ role: "assistant", ts, text: "", tools: [name], model });
105
+ }
106
+ else if (itemType === "function_call_output" || itemType === "custom_tool_call_output") {
107
+ const out = typeof p.output === "string" ? p.output : contentText(p.output);
108
+ if (out)
109
+ push({ role: "user", ts, text: "", toolText: out, tools: [] });
110
+ }
111
+ // unknown response_item types: skip silently (format drift tolerance)
112
+ }
113
+ return {
114
+ id,
115
+ source: "codex",
116
+ projectDir: projectDir ?? "(unknown project)",
117
+ projectDirSource: projectDir ? "cwd" : "fallback",
118
+ filePath,
119
+ gitBranch,
120
+ startedAt,
121
+ endedAt,
122
+ messages,
123
+ parseErrors,
124
+ bytesConsumed,
125
+ };
126
+ }
127
+ }
@@ -0,0 +1,37 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ // Shared machinery for append-only JSONL transcript files.
4
+ //
5
+ // Only ever consume complete, newline-terminated lines. If the file is
6
+ // mid-write (the writer appended a record's bytes but hasn't flushed its
7
+ // trailing "\n" yet), the last fragment is torn — leave it unconsumed and
8
+ // unparsed so the next incremental call re-reads it whole, rather than either
9
+ // erroring on it or silently skipping past it.
10
+ export function consumeCompleteLines(filePath, fromByte) {
11
+ const buf = fs.readFileSync(filePath);
12
+ const slice = buf.subarray(fromByte);
13
+ const lastNewline = slice.lastIndexOf(0x0a); // "\n"
14
+ const consumedSlice = lastNewline === -1 ? slice.subarray(0, 0) : slice.subarray(0, lastNewline + 1);
15
+ const bytesConsumed = fromByte + consumedSlice.length;
16
+ const text = consumedSlice.length > 0 ? consumedSlice.toString("utf8") : "";
17
+ const lines = text.length > 0 ? text.split("\n") : [];
18
+ return { lines, bytesConsumed };
19
+ }
20
+ export function walkJsonlFiles(dir, found) {
21
+ let entries;
22
+ try {
23
+ entries = fs.readdirSync(dir, { withFileTypes: true });
24
+ }
25
+ catch {
26
+ return;
27
+ }
28
+ for (const entry of entries) {
29
+ const full = path.join(dir, entry.name);
30
+ if (entry.isDirectory()) {
31
+ walkJsonlFiles(full, found);
32
+ }
33
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
34
+ found.push(full);
35
+ }
36
+ }
37
+ }
package/dist/auto.js ADDED
@@ -0,0 +1,37 @@
1
+ import { spawnSync } from "node:child_process";
2
+ // Pure crontab-text manipulation for `rewound auto` — kept side-effect-free so
3
+ // the scheduling logic is testable without touching a real crontab.
4
+ export const AUTO_MARKER = "# managed-by-rewound-auto";
5
+ export function buildAutoLine(schedule, haveSyncDir) {
6
+ const cmd = haveSyncDir ? "rewound index && rewound sync" : "rewound index";
7
+ return `${schedule} ${cmd} ${AUTO_MARKER}`;
8
+ }
9
+ export function listAutoLines(crontab) {
10
+ return crontab.split("\n").filter((l) => l.includes(AUTO_MARKER));
11
+ }
12
+ export function removeAutoLines(crontab) {
13
+ const kept = crontab.split("\n").filter((l) => !l.includes(AUTO_MARKER));
14
+ while (kept.length > 0 && kept[kept.length - 1] === "")
15
+ kept.pop();
16
+ return kept.length ? kept.join("\n") + "\n" : "";
17
+ }
18
+ export function upsertAutoLines(crontab, line) {
19
+ const base = removeAutoLines(crontab);
20
+ return base + line + "\n";
21
+ }
22
+ // Thin wrappers around the crontab binary. Return undefined when crontab is
23
+ // unavailable (e.g. Windows, minimal containers) so the CLI can fall back to
24
+ // printing the line for manual setup.
25
+ export function readCrontab() {
26
+ const res = spawnSync("crontab", ["-l"], { encoding: "utf8" });
27
+ if (res.error)
28
+ return undefined;
29
+ // `crontab -l` exits 1 with "no crontab for <user>" when empty — treat as "".
30
+ if (res.status !== 0)
31
+ return "";
32
+ return res.stdout ?? "";
33
+ }
34
+ export function writeCrontab(contents) {
35
+ const res = spawnSync("crontab", ["-"], { input: contents, encoding: "utf8" });
36
+ return !res.error && res.status === 0;
37
+ }
package/dist/cli.js CHANGED
@@ -6,11 +6,16 @@ import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { openDb, resolveDbPath, getSessionByIdOrPrefix, getMessagesForSession, listSessions, getStats, getNewestMessageTs, parseJsonStringArray, } from "./db.js";
8
8
  import { ClaudeCodeAdapter } from "./adapters/claude-code.js";
9
+ import { CodexAdapter } from "./adapters/codex.js";
9
10
  import { indexAll } from "./indexer.js";
10
- import { search, collapseSnippetWhitespace } from "./search.js";
11
+ import { search, collapseSnippetWhitespace, resumeCommand } from "./search.js";
12
+ import { mergeDb, syncDir, sanitizeHostName } from "./sync.js";
13
+ import { loadConfig, saveConfig } from "./config.js";
14
+ import { buildAutoLine, upsertAutoLines, removeAutoLines, listAutoLines, readCrontab, writeCrontab, } from "./auto.js";
11
15
  import { startMcpServer } from "./mcp.js";
12
16
  import { buildServer } from "./server.js";
13
17
  const DEFAULT_ROOTS = [path.join(os.homedir(), ".claude", "projects")];
18
+ const DEFAULT_CODEX_ROOTS = [path.join(os.homedir(), ".codex", "sessions")];
14
19
  const defaultLog = (line) => console.log(line);
15
20
  export function highlightSnippet(snippet) {
16
21
  return snippet.replace(/\x01/g, "\x1b[1m").replace(/\x02/g, "\x1b[0m");
@@ -27,10 +32,19 @@ export function parsePositiveInt(value) {
27
32
  }
28
33
  export function runIndex(opts, log = defaultLog) {
29
34
  const db = openDb(resolveDbPath(opts.db));
30
- const adapter = new ClaudeCodeAdapter();
31
- const roots = opts.roots && opts.roots.length > 0 ? opts.roots : DEFAULT_ROOTS;
32
- const stats = indexAll(db, adapter, roots);
35
+ const claudeRoots = opts.roots && opts.roots.length > 0 ? opts.roots : DEFAULT_ROOTS;
36
+ const codexRoots = opts.codexRoots && opts.codexRoots.length > 0 ? opts.codexRoots : DEFAULT_CODEX_ROOTS;
37
+ const a = indexAll(db, new ClaudeCodeAdapter(), claudeRoots);
38
+ const b = indexAll(db, new CodexAdapter(), codexRoots);
33
39
  db.close();
40
+ const stats = {
41
+ filesScanned: a.filesScanned + b.filesScanned,
42
+ filesNew: a.filesNew + b.filesNew,
43
+ filesUpdated: a.filesUpdated + b.filesUpdated,
44
+ messagesIndexed: a.messagesIndexed + b.messagesIndexed,
45
+ parseErrors: a.parseErrors + b.parseErrors,
46
+ elapsedMs: a.elapsedMs + b.elapsedMs,
47
+ };
34
48
  if (opts.json) {
35
49
  log(JSON.stringify(stats));
36
50
  return;
@@ -63,7 +77,7 @@ export function runSearch(query, opts, log = defaultLog) {
63
77
  const extra = hit.matchesInSession - 1;
64
78
  log(` (+${extra} more ${extra === 1 ? "match" : "matches"} in this session)`);
65
79
  }
66
- log(` ↳ resume: claude --resume ${hit.sessionId}`);
80
+ log(` ↳ resume: ${resumeCommand(hit.source, hit.sessionId)}`);
67
81
  log("");
68
82
  }
69
83
  if (newestTs) {
@@ -71,6 +85,75 @@ export function runSearch(query, opts, log = defaultLog) {
71
85
  }
72
86
  log(`(${hits.length} ${hits.length === 1 ? "hit" : "hits"} in ${elapsedMs}ms)`);
73
87
  }
88
+ export function runMerge(otherPath, opts, log = defaultLog) {
89
+ const db = openDb(resolveDbPath(opts.db));
90
+ const stats = mergeDb(db, otherPath);
91
+ db.close();
92
+ if (opts.json) {
93
+ log(JSON.stringify(stats));
94
+ return;
95
+ }
96
+ log(`sessions added: ${stats.sessionsAdded} updated: ${stats.sessionsUpdated}`);
97
+ }
98
+ export function runSync(dir, opts, log = defaultLog) {
99
+ const dbPath = resolveDbPath(opts.db);
100
+ const cfg = loadConfig(dbPath);
101
+ const target = dir ?? cfg.syncDir;
102
+ if (!target) {
103
+ log("no sync folder configured yet — run `rewound sync <dir>` once with any folder");
104
+ log("your machines already share (Drive, Dropbox, Syncthing, a git repo...); after");
105
+ log("that, bare `rewound sync` reuses it everywhere, including cron.");
106
+ return;
107
+ }
108
+ if (dir && path.resolve(dir) !== cfg.syncDir) {
109
+ saveConfig(dbPath, { ...cfg, syncDir: path.resolve(dir) });
110
+ }
111
+ const db = openDb(dbPath);
112
+ const stats = syncDir(db, path.resolve(target), opts.host);
113
+ db.close();
114
+ if (opts.json) {
115
+ log(JSON.stringify(stats));
116
+ return;
117
+ }
118
+ const host = sanitizeHostName(opts.host ?? os.hostname());
119
+ log(`exported snapshot: ${host}.rewound.db`);
120
+ log(`snapshots merged: ${stats.snapshotsMerged} sessions added: ${stats.sessionsAdded} updated: ${stats.sessionsUpdated}`);
121
+ }
122
+ export function runAuto(opts, log = defaultLog) {
123
+ const haveSyncDir = Boolean(loadConfig(resolveDbPath(opts.db)).syncDir);
124
+ const line = buildAutoLine(opts.schedule ?? "@hourly", haveSyncDir);
125
+ const current = readCrontab();
126
+ if (current === undefined) {
127
+ log("crontab isn't available on this system — schedule this yourself:");
128
+ log(` ${line}`);
129
+ return;
130
+ }
131
+ if (opts.install) {
132
+ if (!writeCrontab(upsertAutoLines(current, line))) {
133
+ log("failed to write crontab — add manually:");
134
+ log(` ${line}`);
135
+ return;
136
+ }
137
+ log(`installed: ${line}`);
138
+ if (!haveSyncDir) {
139
+ log("(index only — run `rewound sync <dir>` once, then `rewound auto --install` again to add syncing)");
140
+ }
141
+ return;
142
+ }
143
+ if (opts.remove) {
144
+ writeCrontab(removeAutoLines(current));
145
+ log("removed rewound's cron entry");
146
+ return;
147
+ }
148
+ const managed = listAutoLines(current);
149
+ if (managed.length === 0) {
150
+ log("not scheduled — run `rewound auto --install` to keep the index fresh automatically");
151
+ }
152
+ else {
153
+ for (const l of managed)
154
+ log(l);
155
+ }
156
+ }
74
157
  export function runSessions(opts, log = defaultLog) {
75
158
  const db = openDb(resolveDbPath(opts.db));
76
159
  const rows = listSessions(db, { project: opts.project, limit: opts.limit });
@@ -160,7 +243,8 @@ export function buildProgram() {
160
243
  program.name("rewound").description("Grep for everything your AI coding agents ever did.");
161
244
  program
162
245
  .command("index")
163
- .option("--roots <dirs...>", "root directories to scan")
246
+ .option("--roots <dirs...>", "Claude Code root directories to scan")
247
+ .option("--codex-roots <dirs...>", "Codex CLI session roots (default: ~/.codex/sessions)")
164
248
  .option("--db <path>", "database path")
165
249
  .option("--json", "output JSON")
166
250
  .action((opts) => runIndex(opts));
@@ -188,6 +272,27 @@ export function buildProgram() {
188
272
  .option("--db <path>", "database path")
189
273
  .option("--json", "output JSON")
190
274
  .action((idOrPrefix, opts) => runShow(idOrPrefix, opts));
275
+ program
276
+ .command("merge <db-file>")
277
+ .description("merge another rewound database into this one (union; richer session copy wins)")
278
+ .option("--db <path>", "database path")
279
+ .option("--json", "output JSON")
280
+ .action((file, opts) => runMerge(file, opts));
281
+ program
282
+ .command("sync [dir]")
283
+ .description("multi-machine continuity: exchange snapshots via any folder you already sync (dir is remembered after first use)")
284
+ .option("--host <name>", "snapshot name for this machine (default: hostname)")
285
+ .option("--db <path>", "database path")
286
+ .option("--json", "output JSON")
287
+ .action((dir, opts) => runSync(dir, opts));
288
+ program
289
+ .command("auto")
290
+ .description("keep the index (and sync, if configured) fresh automatically via cron")
291
+ .option("--install", "install the cron entry")
292
+ .option("--remove", "remove the cron entry")
293
+ .option("--schedule <cron>", "cron schedule expression", "@hourly")
294
+ .option("--db <path>", "database path")
295
+ .action((opts) => runAuto(opts));
191
296
  program
192
297
  .command("stats")
193
298
  .option("--db <path>", "database path")
package/dist/config.js ADDED
@@ -0,0 +1,19 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ function configPath(dbPath) {
4
+ return path.join(path.dirname(dbPath), "config.json");
5
+ }
6
+ export function loadConfig(dbPath) {
7
+ try {
8
+ const raw = fs.readFileSync(configPath(dbPath), "utf8");
9
+ const parsed = JSON.parse(raw);
10
+ return parsed && typeof parsed === "object" ? parsed : {};
11
+ }
12
+ catch {
13
+ return {};
14
+ }
15
+ }
16
+ export function saveConfig(dbPath, cfg) {
17
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
18
+ fs.writeFileSync(configPath(dbPath), JSON.stringify(cfg, null, 2) + "\n");
19
+ }
package/dist/db.js CHANGED
@@ -316,7 +316,7 @@ export function searchMessagesRaw(db, matchExpr, opts) {
316
316
  FROM (
317
317
  SELECT m.session_id as sessionId, m.uuid as uuid, m.role as role, m.ts as ts,
318
318
  m.text as text, m.model as model, m.is_sidechain as isSidechain,
319
- s.project_dir as projectDir, s.title as title, s.est_cost_usd as estCostUsd,
319
+ s.project_dir as projectDir, s.title as title, s.est_cost_usd as estCostUsd, s.source as source,
320
320
  snippet(messages_fts, -1, '', '', '...', 12) as snippet,
321
321
  bm25(messages_fts, ${PROSE_BM25_WEIGHT}, ${TOOL_BM25_WEIGHT}) as rank
322
322
  FROM messages_fts
@@ -343,6 +343,7 @@ export function searchMessagesRaw(db, matchExpr, opts) {
343
343
  title: r.title ?? undefined,
344
344
  estCostUsd: r.estCostUsd,
345
345
  matchesInSession: r.matchesInSession,
346
+ source: r.source,
346
347
  }));
347
348
  }
348
349
  export function listSessions(db, opts = {}) {
package/dist/search.js CHANGED
@@ -1,4 +1,10 @@
1
1
  import { searchMessagesRaw } from "./db.js";
2
+ // Every source harness has its own resume incantation; hits know their source.
3
+ export function resumeCommand(source, sessionId) {
4
+ if (source === "codex")
5
+ return `codex resume ${sessionId}`;
6
+ return `claude --resume ${sessionId}`;
7
+ }
2
8
  // Snippets lifted from code/tool dumps carry embedded newlines, tabs and
3
9
  // indentation that wreck scannability in a result list. Collapse for display
4
10
  // only — stored text and raw snippet data are untouched.
@@ -58,5 +64,6 @@ export function search(db, query, opts) {
58
64
  isSidechain: r.isSidechain,
59
65
  estCostUsd: r.estCostUsd,
60
66
  matchesInSession: r.matchesInSession,
67
+ source: r.source,
61
68
  }));
62
69
  }
package/dist/sync.js ADDED
@@ -0,0 +1,91 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { openDb } from "./db.js";
5
+ // Merge another rewound database into `local`: union by session id, and on
6
+ // collision the richer copy wins (more messages; tie broken by newer ended_at).
7
+ // The source file is never written to — it is copied aside first, because sync
8
+ // snapshots belong to other hosts and mutating them (e.g. a schema migration)
9
+ // would fight their next export through the user's file-sync service.
10
+ export function mergeDb(local, otherPath) {
11
+ const tmpCopy = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "rewound-merge-")), "snapshot.db");
12
+ fs.copyFileSync(otherPath, tmpCopy);
13
+ // Round-trip through openDb so pre-v2 snapshots are migrated before ATTACH.
14
+ openDb(tmpCopy).close();
15
+ const stats = { sessionsAdded: 0, sessionsUpdated: 0 };
16
+ local.prepare("ATTACH DATABASE ? AS other").run(tmpCopy);
17
+ try {
18
+ const merge = local.transaction(() => {
19
+ const otherSessions = local
20
+ .prepare("SELECT id, message_count, ended_at FROM other.sessions")
21
+ .all();
22
+ const getLocal = local.prepare("SELECT message_count, ended_at FROM sessions WHERE id = ?");
23
+ const copySession = local.prepare("INSERT INTO sessions SELECT * FROM other.sessions WHERE id = ?");
24
+ const copyMessages = local.prepare(`INSERT INTO messages (session_id, uuid, role, ts, text, tools, model, is_sidechain, tool_text)
25
+ SELECT session_id, uuid, role, ts, text, tools, model, is_sidechain, tool_text
26
+ FROM other.messages WHERE session_id = ?`);
27
+ const deleteSession = local.prepare("DELETE FROM sessions WHERE id = ?");
28
+ const deleteMessages = local.prepare("DELETE FROM messages WHERE session_id = ?");
29
+ for (const o of otherSessions) {
30
+ const l = getLocal.get(o.id);
31
+ if (!l) {
32
+ copySession.run(o.id);
33
+ copyMessages.run(o.id);
34
+ stats.sessionsAdded++;
35
+ continue;
36
+ }
37
+ const richer = o.message_count > l.message_count ||
38
+ (o.message_count === l.message_count && (o.ended_at ?? "") > (l.ended_at ?? ""));
39
+ if (richer) {
40
+ deleteMessages.run(o.id); // FTS delete-triggers fire per row
41
+ deleteSession.run(o.id);
42
+ copySession.run(o.id);
43
+ copyMessages.run(o.id);
44
+ stats.sessionsUpdated++;
45
+ }
46
+ }
47
+ });
48
+ merge();
49
+ }
50
+ finally {
51
+ local.prepare("DETACH DATABASE other").run();
52
+ fs.rmSync(path.dirname(tmpCopy), { recursive: true, force: true });
53
+ }
54
+ return stats;
55
+ }
56
+ // Write a compact single-file snapshot of the database, atomically (tmp +
57
+ // rename), so eventually-consistent file syncers never observe a half-written
58
+ // or WAL-split database.
59
+ export function exportSnapshot(db, outPath) {
60
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
61
+ const tmp = `${outPath}.tmp-${process.pid}`;
62
+ fs.rmSync(tmp, { force: true });
63
+ db.prepare("VACUUM INTO ?").run(tmp);
64
+ fs.renameSync(tmp, outPath);
65
+ }
66
+ export function sanitizeHostName(name) {
67
+ return name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "host";
68
+ }
69
+ // One-command multi-host continuity over any user-synced folder (Drive,
70
+ // Dropbox, Syncthing, a git repo, an rclone mount of S3/Supabase storage...):
71
+ // merge every other host's snapshot, then export ours — so each snapshot also
72
+ // propagates what its host has learned from the others. Each host only ever
73
+ // writes its own <host>.rewound.db: no write conflicts on the sync medium.
74
+ export function syncDir(db, dir, hostName) {
75
+ const host = sanitizeHostName(hostName ?? os.hostname());
76
+ const ownSnapshot = `${host}.rewound.db`;
77
+ fs.mkdirSync(dir, { recursive: true });
78
+ const stats = { exported: false, snapshotsMerged: 0, sessionsAdded: 0, sessionsUpdated: 0 };
79
+ const snapshots = fs
80
+ .readdirSync(dir)
81
+ .filter((f) => f.endsWith(".rewound.db") && f !== ownSnapshot);
82
+ for (const snap of snapshots) {
83
+ const m = mergeDb(db, path.join(dir, snap));
84
+ stats.snapshotsMerged++;
85
+ stats.sessionsAdded += m.sessionsAdded;
86
+ stats.sessionsUpdated += m.sessionsUpdated;
87
+ }
88
+ exportSnapshot(db, path.join(dir, ownSnapshot));
89
+ stats.exported = true;
90
+ return stats;
91
+ }
@@ -333,7 +333,7 @@ export function renderLayout(opts) {
333
333
  </head>
334
334
  <body>
335
335
  <header class="site">
336
- <span class="brand">agent<span class="brand-accent">grep</span></span>
336
+ <span class="brand">re<span class="brand-accent">wound</span></span>
337
337
  <nav class="site-nav">${navHtml}</nav>
338
338
  </header>
339
339
  <main>${opts.body}</main>
@@ -1,5 +1,5 @@
1
1
  import { escapeHtml, highlightSnippetHtml } from "../html.js";
2
- import { collapseSnippetWhitespace } from "../../search.js";
2
+ import { collapseSnippetWhitespace, resumeCommand } from "../../search.js";
3
3
  const EXAMPLE_QUERIES = ["auth bug", "database migration", "failing test", "refactor", "TODO"];
4
4
  function pageQueryString(opts, page) {
5
5
  const params = new URLSearchParams();
@@ -58,7 +58,7 @@ function renderHero(opts) {
58
58
  function renderHitCard(hit, index) {
59
59
  const heading = escapeHtml(hit.title ?? hit.sessionId);
60
60
  const sidechainBadge = hit.isSidechain ? `<span class="badge accent">subagent</span>` : "";
61
- const resumeCmd = `claude --resume ${hit.sessionId}`;
61
+ const resumeCmd = resumeCommand(hit.source, hit.sessionId);
62
62
  const resumeId = `resume-hit-${index}`;
63
63
  return `
64
64
  <article class="card hit-card">
@@ -1,4 +1,5 @@
1
1
  import { escapeHtml } from "../html.js";
2
+ import { resumeCommand } from "../../search.js";
2
3
  function renderMessage(m) {
3
4
  const sidechainBadge = m.isSidechain ? `<span class="badge accent">subagent</span>` : "";
4
5
  const modelBadge = m.model ? `<span class="badge">${escapeHtml(m.model)}</span>` : "";
@@ -28,7 +29,7 @@ export function renderSessionPage(session, messages, pagination) {
28
29
  const archivedBadge = session.archived
29
30
  ? `<span class="badge accent">archived</span>`
30
31
  : "";
31
- const resumeCmd = `claude --resume ${session.id}`;
32
+ const resumeCmd = resumeCommand(session.source, session.id);
32
33
  const header = `
33
34
  <header class="session-header">
34
35
  <h1>${heading} ${archivedBadge}</h1>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rewound",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Grep for everything your AI coding agents ever did. Local SQLite/FTS5 search over Claude Code session transcripts: CLI, MCP server, and web UI.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "David Erner",