rewound 0.2.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/LICENSE +31 -0
- package/README.md +126 -0
- package/dist/adapters/claude-code.js +184 -0
- package/dist/cli.js +226 -0
- package/dist/db.js +441 -0
- package/dist/indexer.js +100 -0
- package/dist/mcp.js +192 -0
- package/dist/pricing.js +21 -0
- package/dist/search.js +62 -0
- package/dist/server.js +176 -0
- package/dist/types.js +1 -0
- package/dist/web/html.js +14 -0
- package/dist/web/layout.js +342 -0
- package/dist/web/pages/search.js +110 -0
- package/dist/web/pages/session.js +49 -0
- package/dist/web/pages/stats.js +53 -0
- package/dist/web/pages/timeline.js +38 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
rewound — Source-Available License (v0.1)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Erner
|
|
4
|
+
|
|
5
|
+
Personal use
|
|
6
|
+
------------
|
|
7
|
+
You may use, copy, and modify this software free of charge for your own
|
|
8
|
+
personal projects, on your own machines.
|
|
9
|
+
|
|
10
|
+
Commercial / team use
|
|
11
|
+
----------------------
|
|
12
|
+
Use by a company, team, or on behalf of an organization requires a paid
|
|
13
|
+
license: USD $29 per individual, or USD $99 per team of up to 10 people —
|
|
14
|
+
one-time, per major version. See the README for how to purchase, or contact
|
|
15
|
+
the author. Version 0.x has no license-key enforcement; purchasing a license
|
|
16
|
+
is how you comply with these terms.
|
|
17
|
+
|
|
18
|
+
Source availability
|
|
19
|
+
--------------------
|
|
20
|
+
The source is provided so you can read it, audit it, and build it yourself.
|
|
21
|
+
This is not an open-source license: no OSI-approved license grant is made,
|
|
22
|
+
and redistribution (including publishing forks or copies) is not permitted
|
|
23
|
+
without written permission from the author.
|
|
24
|
+
|
|
25
|
+
No warranty
|
|
26
|
+
-----------
|
|
27
|
+
This software is provided "as is", without warranty of any kind, express or
|
|
28
|
+
implied, including but not limited to the warranties of merchantability,
|
|
29
|
+
fitness for a particular purpose, and noninfringement. In no event shall the
|
|
30
|
+
author be liable for any claim, damages, or other liability arising from the
|
|
31
|
+
use of this software.
|
package/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# rewound
|
|
2
|
+
|
|
3
|
+
Grep for everything your AI coding agents ever did.
|
|
4
|
+
|
|
5
|
+
## The problem
|
|
6
|
+
|
|
7
|
+
Heavy users of coding agents accumulate gigabytes of session transcripts that are effectively write-only memory:
|
|
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.
|
|
10
|
+
- **History is perishable.** Claude Code deletes transcripts older than 30 days by default (`cleanupPeriodDays`, swept at startup) — your reasoning history evaporates.
|
|
11
|
+
- **Agents can't remember.** Every new session rediscovers what a previous session already solved. Nobody serves the agent itself.
|
|
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.
|
|
14
|
+
|
|
15
|
+
## 60-second demo
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
$ rewound index
|
|
19
|
+
files scanned: 3310 new: 3310 updated: 0
|
|
20
|
+
messages indexed: 368687 parse errors: 1
|
|
21
|
+
elapsed: 29962ms
|
|
22
|
+
|
|
23
|
+
$ rewound search "fts5 trigger bug"
|
|
24
|
+
/home/dev/myapp · Fix fts5 trigger bug · 2026-07-01T10:00:05.000Z
|
|
25
|
+
found and fixed the **fts5** **trigger** bug by rewriting the AFTER UPDATE **trigger**
|
|
26
|
+
↳ resume: claude --resume 3f1a2c9e-...
|
|
27
|
+
|
|
28
|
+
(1 hit in 5ms)
|
|
29
|
+
```
|
|
30
|
+
|
|
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
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install -g rewound
|
|
37
|
+
rewound index
|
|
38
|
+
```
|
|
39
|
+
|
|
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"`).
|
|
42
|
+
|
|
43
|
+
<details>
|
|
44
|
+
<summary>Build from source</summary>
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
git clone <this-repo> && cd rewound
|
|
48
|
+
npm install
|
|
49
|
+
npm run build
|
|
50
|
+
node dist/cli.js index # or: npm link, then `rewound index`
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
</details>
|
|
54
|
+
|
|
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>`.
|
|
56
|
+
|
|
57
|
+
### Your history outlives Claude Code's cleanup
|
|
58
|
+
|
|
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:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
@hourly rewound index
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Incremental runs take well under a second when little has changed.
|
|
66
|
+
|
|
67
|
+
## The surfaces
|
|
68
|
+
|
|
69
|
+
### CLI — available now
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
rewound index [--roots <dir...>] [--db <path>] [--json]
|
|
73
|
+
rewound search <query> [--project <substr>] [--since <ISO|7d|24h>] [--role user|assistant] [--sidechains] [--limit N] [--json]
|
|
74
|
+
rewound sessions [--project <substr>] [--limit N] [--json]
|
|
75
|
+
rewound show <session-id-or-prefix> [--json]
|
|
76
|
+
rewound stats [--json]
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`search` supports relative time windows (`--since 7d`), project filtering, and role filtering. Query terms are quoted automatically so punctuation never throws an FTS syntax error; pass `--raw` if you want real FTS5 query syntax.
|
|
80
|
+
|
|
81
|
+
Results are built to be scanned by a human eye: your own words and the agent's prose rank above tool output that merely mentions the term (a `cat` of a file no longer buries the sentence where you actually discussed the bug), and each session appears once — its best hit plus a `+N more matches in this session` count. Pass `--all-matches` to disassemble a session into every matching message.
|
|
82
|
+
|
|
83
|
+
Two search tips from real-corpus testing: scope with `--project` when your memory is project-specific — cross-cutting terms (preferences, conventions, tool names) appear in *every* project's sessions and will drown an unscoped query. And run `rewound index` before hunting for recent work; indexing is incremental and takes well under a second when little has changed.
|
|
84
|
+
|
|
85
|
+
### MCP server — available now
|
|
86
|
+
|
|
87
|
+
`rewound mcp` starts a stdio MCP server so a Claude Code agent can search its own history mid-session — the moat feature. Three tools:
|
|
88
|
+
|
|
89
|
+
- `search_history({query, project?, since?, limit?, all_matches?})` — ranked excerpts (≤700 chars each) with session id/title/date, one best hit per session by default, and a hint to fetch more context.
|
|
90
|
+
- `get_session_summary({session_id})` — title, project, dates, message count, tools/models used, first prompt and last response (truncated).
|
|
91
|
+
- `get_session_excerpt({session_id, match_uuid?, context?})` — the matched message plus surrounding context, readable.
|
|
92
|
+
|
|
93
|
+
Every response is capped near 8KB so agent context isn't blown out by a single tool call. Add it to your Claude Code MCP config:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{ "mcpServers": { "rewound": { "command": "npx", "args": ["-y", "rewound", "mcp"] } } }
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
(Running from a source checkout instead? Point `command`/`args` at your local build, e.g. `"command": "node", "args": ["/path/to/rewound/dist/cli.js", "mcp"]`.)
|
|
100
|
+
|
|
101
|
+
### Web UI — available now
|
|
102
|
+
|
|
103
|
+
`rewound serve` starts a local, phone-friendly web UI: full-text search with filters, readable session transcripts with one-tap resume-command copy, per-project timeline, and cost rollups.
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
rewound serve # http://127.0.0.1:4321
|
|
107
|
+
rewound serve --host 0.0.0.0 # reachable over Tailscale — search your history from your phone
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Server-rendered with zero frontend build step, colorblind-safe palette (blue/orange, no red/green status pairs), and everything — including clipboard copy — works over plain HTTP on your tailnet.
|
|
111
|
+
|
|
112
|
+
## Privacy
|
|
113
|
+
|
|
114
|
+
100% local. No telemetry, no network calls, no account. Your transcripts never leave your machine — rewound reads `~/.claude/projects` read-only and writes its own database to `~/.rewound/rewound.db`. Even "archive mode" (kept history after Claude Code prunes the source file) lives entirely in your local SQLite file.
|
|
115
|
+
|
|
116
|
+
## Cost estimates
|
|
117
|
+
|
|
118
|
+
`rewound stats` and search results include an **estimated** cost per session, computed from a static $/Mtok table (updated by hand, not live pricing). Treat it as a rough API-equivalent value, not a bill.
|
|
119
|
+
|
|
120
|
+
## Roadmap
|
|
121
|
+
|
|
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`).
|
|
123
|
+
|
|
124
|
+
## License & pricing
|
|
125
|
+
|
|
126
|
+
Source-available; **free for personal use**. Commercial/team use requires a paid license: **$29 individual · $99 team (up to 10 seats)** — one-time, per major version. v0.x ships with no license-key enforcement; buying a license is how you comply, and how you keep this maintained. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
function extractBlockText(block) {
|
|
4
|
+
if (block == null)
|
|
5
|
+
return "";
|
|
6
|
+
if (typeof block === "string")
|
|
7
|
+
return block;
|
|
8
|
+
if (Array.isArray(block)) {
|
|
9
|
+
return block.map(extractBlockText).filter(Boolean).join("\n\n");
|
|
10
|
+
}
|
|
11
|
+
if (typeof block === "object") {
|
|
12
|
+
const b = block;
|
|
13
|
+
if (b.type === "text" && typeof b.text === "string")
|
|
14
|
+
return b.text;
|
|
15
|
+
if (b.type === "thinking" && typeof b.thinking === "string")
|
|
16
|
+
return b.thinking;
|
|
17
|
+
if (b.type === "tool_result")
|
|
18
|
+
return extractBlockText(b.content);
|
|
19
|
+
// tool_use, image, and any unknown block type contribute no searchable text.
|
|
20
|
+
}
|
|
21
|
+
return "";
|
|
22
|
+
}
|
|
23
|
+
function extractUserContent(content) {
|
|
24
|
+
if (typeof content === "string")
|
|
25
|
+
return { text: content, toolText: "" };
|
|
26
|
+
if (!Array.isArray(content))
|
|
27
|
+
return { text: extractBlockText(content), toolText: "" };
|
|
28
|
+
const prose = [];
|
|
29
|
+
const tool = [];
|
|
30
|
+
for (const block of content) {
|
|
31
|
+
const isToolResult = block != null && typeof block === "object" && block.type === "tool_result";
|
|
32
|
+
const t = extractBlockText(block);
|
|
33
|
+
if (!t)
|
|
34
|
+
continue;
|
|
35
|
+
(isToolResult ? tool : prose).push(t);
|
|
36
|
+
}
|
|
37
|
+
return { text: prose.join("\n\n"), toolText: tool.join("\n\n") };
|
|
38
|
+
}
|
|
39
|
+
function extractAssistantContent(content) {
|
|
40
|
+
const tools = [];
|
|
41
|
+
if (!Array.isArray(content))
|
|
42
|
+
return { text: extractBlockText(content), tools };
|
|
43
|
+
const parts = [];
|
|
44
|
+
for (const block of content) {
|
|
45
|
+
if (block && typeof block === "object") {
|
|
46
|
+
const b = block;
|
|
47
|
+
if (b.type === "tool_use" && typeof b.name === "string") {
|
|
48
|
+
tools.push(b.name);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const t = extractBlockText(block);
|
|
53
|
+
if (t)
|
|
54
|
+
parts.push(t);
|
|
55
|
+
}
|
|
56
|
+
return { text: parts.join("\n\n"), tools };
|
|
57
|
+
}
|
|
58
|
+
function decodeProjectDir(filePath) {
|
|
59
|
+
const dirName = path.basename(path.dirname(filePath));
|
|
60
|
+
return dirName.replace(/-/g, "/");
|
|
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
|
+
export class ClaudeCodeAdapter {
|
|
81
|
+
id = "claude-code";
|
|
82
|
+
discover(roots) {
|
|
83
|
+
const found = [];
|
|
84
|
+
for (const root of roots)
|
|
85
|
+
walk(root, found);
|
|
86
|
+
return found;
|
|
87
|
+
}
|
|
88
|
+
parse(filePath, fromByte = 0) {
|
|
89
|
+
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") : [];
|
|
102
|
+
const messages = [];
|
|
103
|
+
let title;
|
|
104
|
+
let gitBranch;
|
|
105
|
+
let projectDir;
|
|
106
|
+
let startedAt;
|
|
107
|
+
let endedAt;
|
|
108
|
+
let parseErrors = 0;
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
const trimmed = line.trim();
|
|
111
|
+
if (!trimmed)
|
|
112
|
+
continue;
|
|
113
|
+
let record;
|
|
114
|
+
try {
|
|
115
|
+
record = JSON.parse(trimmed);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
parseErrors++;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (!record || typeof record !== "object" || typeof record.type !== "string") {
|
|
122
|
+
parseErrors++;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (record.cwd && !projectDir)
|
|
126
|
+
projectDir = record.cwd;
|
|
127
|
+
if (record.gitBranch && !gitBranch)
|
|
128
|
+
gitBranch = record.gitBranch;
|
|
129
|
+
if (record.type === "user" || record.type === "assistant") {
|
|
130
|
+
const msg = record.message;
|
|
131
|
+
const isAssistant = record.type === "assistant";
|
|
132
|
+
const userContent = isAssistant ? undefined : extractUserContent(msg?.content);
|
|
133
|
+
const { text: msgText, tools } = isAssistant
|
|
134
|
+
? extractAssistantContent(msg?.content)
|
|
135
|
+
: { text: userContent.text, tools: [] };
|
|
136
|
+
const usage = isAssistant && msg?.usage
|
|
137
|
+
? {
|
|
138
|
+
input: msg.usage.input_tokens ?? 0,
|
|
139
|
+
output: msg.usage.output_tokens ?? 0,
|
|
140
|
+
cacheRead: msg.usage.cache_read_input_tokens ?? 0,
|
|
141
|
+
cacheWrite: msg.usage.cache_creation_input_tokens ?? 0,
|
|
142
|
+
}
|
|
143
|
+
: undefined;
|
|
144
|
+
messages.push({
|
|
145
|
+
uuid: record.uuid ?? "",
|
|
146
|
+
role: record.type,
|
|
147
|
+
ts: record.timestamp ?? "",
|
|
148
|
+
text: msgText,
|
|
149
|
+
toolText: userContent?.toolText || undefined,
|
|
150
|
+
tools,
|
|
151
|
+
model: isAssistant ? msg?.model : undefined,
|
|
152
|
+
isSidechain: Boolean(record.isSidechain),
|
|
153
|
+
usage,
|
|
154
|
+
});
|
|
155
|
+
if (record.timestamp) {
|
|
156
|
+
if (!startedAt || record.timestamp < startedAt)
|
|
157
|
+
startedAt = record.timestamp;
|
|
158
|
+
if (!endedAt || record.timestamp > endedAt)
|
|
159
|
+
endedAt = record.timestamp;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else if (record.type === "ai-title") {
|
|
163
|
+
if (typeof record.aiTitle === "string")
|
|
164
|
+
title = record.aiTitle;
|
|
165
|
+
}
|
|
166
|
+
// system, attachment, file-history-snapshot, last-prompt, mode,
|
|
167
|
+
// permission-mode, bridge-session, and any unrecognized type: skip silently.
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
id,
|
|
171
|
+
source: "claude-code",
|
|
172
|
+
projectDir: projectDir ?? decodeProjectDir(filePath),
|
|
173
|
+
projectDirSource: projectDir ? "cwd" : "fallback",
|
|
174
|
+
filePath,
|
|
175
|
+
title,
|
|
176
|
+
gitBranch,
|
|
177
|
+
startedAt,
|
|
178
|
+
endedAt,
|
|
179
|
+
messages,
|
|
180
|
+
parseErrors,
|
|
181
|
+
bytesConsumed,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { openDb, resolveDbPath, getSessionByIdOrPrefix, getMessagesForSession, listSessions, getStats, getNewestMessageTs, parseJsonStringArray, } from "./db.js";
|
|
8
|
+
import { ClaudeCodeAdapter } from "./adapters/claude-code.js";
|
|
9
|
+
import { indexAll } from "./indexer.js";
|
|
10
|
+
import { search, collapseSnippetWhitespace } from "./search.js";
|
|
11
|
+
import { startMcpServer } from "./mcp.js";
|
|
12
|
+
import { buildServer } from "./server.js";
|
|
13
|
+
const DEFAULT_ROOTS = [path.join(os.homedir(), ".claude", "projects")];
|
|
14
|
+
const defaultLog = (line) => console.log(line);
|
|
15
|
+
export function highlightSnippet(snippet) {
|
|
16
|
+
return snippet.replace(/\x01/g, "\x1b[1m").replace(/\x02/g, "\x1b[0m");
|
|
17
|
+
}
|
|
18
|
+
export function stripSnippetMarkers(snippet) {
|
|
19
|
+
return snippet.replace(/[\x01\x02]/g, "");
|
|
20
|
+
}
|
|
21
|
+
export function parsePositiveInt(value) {
|
|
22
|
+
const n = Number(value);
|
|
23
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
24
|
+
throw new InvalidArgumentError(`"${value}" is not a positive integer.`);
|
|
25
|
+
}
|
|
26
|
+
return n;
|
|
27
|
+
}
|
|
28
|
+
export function runIndex(opts, log = defaultLog) {
|
|
29
|
+
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);
|
|
33
|
+
db.close();
|
|
34
|
+
if (opts.json) {
|
|
35
|
+
log(JSON.stringify(stats));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
log(`files scanned: ${stats.filesScanned} new: ${stats.filesNew} updated: ${stats.filesUpdated}`);
|
|
39
|
+
log(`messages indexed: ${stats.messagesIndexed} parse errors: ${stats.parseErrors}`);
|
|
40
|
+
log(`elapsed: ${stats.elapsedMs}ms`);
|
|
41
|
+
}
|
|
42
|
+
export function runSearch(query, opts, log = defaultLog) {
|
|
43
|
+
const start = Date.now();
|
|
44
|
+
const db = openDb(resolveDbPath(opts.db));
|
|
45
|
+
const hits = search(db, query, opts);
|
|
46
|
+
// A stale index misses recent work silently; on a zero-hit search, say how far
|
|
47
|
+
// the index actually covers so "it's not indexed yet" is distinguishable from
|
|
48
|
+
// "it doesn't exist". Text mode only — JSON output stays machine-clean.
|
|
49
|
+
const newestTs = !opts.json && hits.length === 0 ? getNewestMessageTs(db) : undefined;
|
|
50
|
+
db.close();
|
|
51
|
+
const elapsedMs = Date.now() - start;
|
|
52
|
+
if (opts.json) {
|
|
53
|
+
log(JSON.stringify({
|
|
54
|
+
hits: hits.map(({ text, ...rest }) => ({ ...rest, snippet: stripSnippetMarkers(rest.snippet) })),
|
|
55
|
+
elapsedMs,
|
|
56
|
+
}));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
for (const hit of hits) {
|
|
60
|
+
log(`${hit.projectDir} · ${hit.title ?? hit.sessionId} · ${hit.ts}`);
|
|
61
|
+
log(highlightSnippet(collapseSnippetWhitespace(hit.snippet)));
|
|
62
|
+
if (!opts.allMatches && hit.matchesInSession > 1) {
|
|
63
|
+
const extra = hit.matchesInSession - 1;
|
|
64
|
+
log(` (+${extra} more ${extra === 1 ? "match" : "matches"} in this session)`);
|
|
65
|
+
}
|
|
66
|
+
log(` ↳ resume: claude --resume ${hit.sessionId}`);
|
|
67
|
+
log("");
|
|
68
|
+
}
|
|
69
|
+
if (newestTs) {
|
|
70
|
+
log(`index covers through ${newestTs} — looking for something newer? run \`rewound index\` first`);
|
|
71
|
+
}
|
|
72
|
+
log(`(${hits.length} ${hits.length === 1 ? "hit" : "hits"} in ${elapsedMs}ms)`);
|
|
73
|
+
}
|
|
74
|
+
export function runSessions(opts, log = defaultLog) {
|
|
75
|
+
const db = openDb(resolveDbPath(opts.db));
|
|
76
|
+
const rows = listSessions(db, { project: opts.project, limit: opts.limit });
|
|
77
|
+
db.close();
|
|
78
|
+
if (opts.json) {
|
|
79
|
+
log(JSON.stringify(rows));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
for (const r of rows) {
|
|
83
|
+
log(`${r.startedAt ?? "?"} ${r.projectDir} ${r.title ?? r.id} msgs=${r.messageCount} estApiCost=$${r.estCostUsd.toFixed(4)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export function runShow(idOrPrefix, opts, log = defaultLog) {
|
|
87
|
+
const db = openDb(resolveDbPath(opts.db));
|
|
88
|
+
const session = getSessionByIdOrPrefix(db, idOrPrefix);
|
|
89
|
+
if (!session) {
|
|
90
|
+
db.close();
|
|
91
|
+
log(`no session found matching "${idOrPrefix}"`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const messages = getMessagesForSession(db, session.id);
|
|
95
|
+
db.close();
|
|
96
|
+
if (opts.json) {
|
|
97
|
+
log(JSON.stringify({
|
|
98
|
+
session,
|
|
99
|
+
messages: messages.map((m) => ({
|
|
100
|
+
uuid: m.uuid,
|
|
101
|
+
role: m.role,
|
|
102
|
+
ts: m.ts,
|
|
103
|
+
text: m.text,
|
|
104
|
+
tools: parseJsonStringArray(m.tools),
|
|
105
|
+
model: m.model ?? undefined,
|
|
106
|
+
isSidechain: Boolean(m.is_sidechain),
|
|
107
|
+
})),
|
|
108
|
+
}));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
log(`# ${session.title ?? session.id} (${session.projectDir}, ${session.gitBranch ?? "?"})`);
|
|
112
|
+
for (const m of messages) {
|
|
113
|
+
const tools = parseJsonStringArray(m.tools);
|
|
114
|
+
const toolSummary = tools.map((t) => `[tool: ${t}]`).join(" ");
|
|
115
|
+
const sidechain = m.is_sidechain ? " (sidechain)" : "";
|
|
116
|
+
log(`[${m.ts}] ${m.role}${sidechain}: ${m.text}${toolSummary ? " " + toolSummary : ""}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export function runStats(opts, log = defaultLog) {
|
|
120
|
+
const db = openDb(resolveDbPath(opts.db));
|
|
121
|
+
const stats = getStats(db);
|
|
122
|
+
db.close();
|
|
123
|
+
if (opts.json) {
|
|
124
|
+
log(JSON.stringify(stats));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// "est. API cost" not "cost": figures are token usage at API list prices — a heavy
|
|
128
|
+
// subscription user's total can read like absurd spend without that framing.
|
|
129
|
+
log(`sessions: ${stats.totalSessions} messages: ${stats.totalMessages} est. API cost: $${stats.totalCostUsd.toFixed(2)}`);
|
|
130
|
+
for (const p of stats.byProject) {
|
|
131
|
+
log(` ${p.projectDir}: sessions=${p.sessions} messages=${p.messages} estApiCost=$${p.estCostUsd.toFixed(4)}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
export async function runMcp(opts) {
|
|
135
|
+
const db = openDb(resolveDbPath(opts.db));
|
|
136
|
+
await startMcpServer(db);
|
|
137
|
+
}
|
|
138
|
+
// Exported for tests: a bad --port parse (NaN/negative/non-integer) must fall back
|
|
139
|
+
// to the default instead of crashing fastify's listen().
|
|
140
|
+
export function resolveServePort(port) {
|
|
141
|
+
return port !== undefined && Number.isInteger(port) && port >= 0 ? port : 4321;
|
|
142
|
+
}
|
|
143
|
+
export async function runServe(opts, log = defaultLog) {
|
|
144
|
+
const db = openDb(resolveDbPath(opts.db));
|
|
145
|
+
const app = buildServer({ db });
|
|
146
|
+
app.addHook("onClose", async () => {
|
|
147
|
+
db.close();
|
|
148
|
+
});
|
|
149
|
+
const port = resolveServePort(opts.port);
|
|
150
|
+
const host = opts.host ?? "127.0.0.1";
|
|
151
|
+
const address = await app.listen({ port, host });
|
|
152
|
+
log(`rewound serve listening on ${address}`);
|
|
153
|
+
if (host === "0.0.0.0") {
|
|
154
|
+
log("bound to 0.0.0.0 (Tailscale/phone mode) — reachable from other devices on your network");
|
|
155
|
+
}
|
|
156
|
+
return app;
|
|
157
|
+
}
|
|
158
|
+
export function buildProgram() {
|
|
159
|
+
const program = new Command();
|
|
160
|
+
program.name("rewound").description("Grep for everything your AI coding agents ever did.");
|
|
161
|
+
program
|
|
162
|
+
.command("index")
|
|
163
|
+
.option("--roots <dirs...>", "root directories to scan")
|
|
164
|
+
.option("--db <path>", "database path")
|
|
165
|
+
.option("--json", "output JSON")
|
|
166
|
+
.action((opts) => runIndex(opts));
|
|
167
|
+
program
|
|
168
|
+
.command("search <query>")
|
|
169
|
+
.option("--project <substr>", "filter by project directory substring")
|
|
170
|
+
.option("--since <iso-or-relative>", "ISO timestamp or relative like 7d / 24h")
|
|
171
|
+
.option("--role <role>", "filter by role: user or assistant")
|
|
172
|
+
.option("--sidechains", "include sidechain (subagent) messages")
|
|
173
|
+
.option("--all-matches", "show every matching message, not one best hit per session")
|
|
174
|
+
.option("--limit <n>", "max results", parsePositiveInt)
|
|
175
|
+
.option("--raw", "treat query as raw FTS5 match syntax")
|
|
176
|
+
.option("--db <path>", "database path")
|
|
177
|
+
.option("--json", "output JSON")
|
|
178
|
+
.action((query, opts) => runSearch(query, opts));
|
|
179
|
+
program
|
|
180
|
+
.command("sessions")
|
|
181
|
+
.option("--project <substr>", "filter by project directory substring")
|
|
182
|
+
.option("--limit <n>", "max results", parsePositiveInt)
|
|
183
|
+
.option("--db <path>", "database path")
|
|
184
|
+
.option("--json", "output JSON")
|
|
185
|
+
.action((opts) => runSessions(opts));
|
|
186
|
+
program
|
|
187
|
+
.command("show <session-id-or-prefix>")
|
|
188
|
+
.option("--db <path>", "database path")
|
|
189
|
+
.option("--json", "output JSON")
|
|
190
|
+
.action((idOrPrefix, opts) => runShow(idOrPrefix, opts));
|
|
191
|
+
program
|
|
192
|
+
.command("stats")
|
|
193
|
+
.option("--db <path>", "database path")
|
|
194
|
+
.option("--json", "output JSON")
|
|
195
|
+
.action((opts) => runStats(opts));
|
|
196
|
+
program
|
|
197
|
+
.command("mcp")
|
|
198
|
+
.description("start an MCP stdio server exposing search_history, get_session_summary, get_session_excerpt")
|
|
199
|
+
.option("--db <path>", "database path")
|
|
200
|
+
.action((opts) => runMcp(opts));
|
|
201
|
+
program
|
|
202
|
+
.command("serve")
|
|
203
|
+
.description("start the local web UI (search, session detail, timeline, stats)")
|
|
204
|
+
.option("--port <n>", "port to listen on", (v) => parseInt(v, 10), 4321)
|
|
205
|
+
.option("--host <host>", "host to bind (use 0.0.0.0 for Tailscale/phone access)", "127.0.0.1")
|
|
206
|
+
.option("--db <path>", "database path")
|
|
207
|
+
.action(async (opts) => {
|
|
208
|
+
await runServe(opts);
|
|
209
|
+
});
|
|
210
|
+
return program;
|
|
211
|
+
}
|
|
212
|
+
// npm always installs `bin` entries as symlinks, so argv[1] is the symlink path while
|
|
213
|
+
// import.meta.url resolves through it to the real file — must compare real paths.
|
|
214
|
+
export function isMainModule(argv1, moduleUrl) {
|
|
215
|
+
if (argv1 === undefined)
|
|
216
|
+
return false;
|
|
217
|
+
try {
|
|
218
|
+
return fs.realpathSync(argv1) === fileURLToPath(moduleUrl);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (isMainModule(process.argv[1], import.meta.url)) {
|
|
225
|
+
buildProgram().parseAsync(process.argv);
|
|
226
|
+
}
|