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 +55 -10
- package/dist/adapters/claude-code.js +6 -33
- package/dist/adapters/codex.js +127 -0
- package/dist/adapters/jsonl.js +37 -0
- package/dist/auto.js +37 -0
- package/dist/cli.js +111 -6
- package/dist/config.js +19 -0
- package/dist/db.js +2 -1
- package/dist/search.js +7 -0
- package/dist/sync.js +91 -0
- package/dist/web/layout.js +1 -1
- package/dist/web/pages/search.js +2 -2
- package/dist/web/pages/session.js +2 -1
- package/package.json +1 -1
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
|
|
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
|
+

|
|
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
|
+

|
|
41
|
+

|
|
42
|
+

|
|
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
|
|
41
|
-
|
|
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
|
-
###
|
|
71
|
+
### Multi-machine: your history follows you
|
|
58
72
|
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
|
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
|
|
31
|
-
const
|
|
32
|
-
const
|
|
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:
|
|
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
|
+
}
|
package/dist/web/layout.js
CHANGED
|
@@ -333,7 +333,7 @@ export function renderLayout(opts) {
|
|
|
333
333
|
</head>
|
|
334
334
|
<body>
|
|
335
335
|
<header class="site">
|
|
336
|
-
<span class="brand">
|
|
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>
|
package/dist/web/pages/search.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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.
|
|
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",
|