session-preserver 0.1.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 +37 -0
- package/index.js +291 -0
- package/package.json +10 -0
package/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# session-preserver
|
|
2
|
+
|
|
3
|
+
Centralized session history for AI coding CLIs — **Claude Code, Codex, OpenCode** — in one terminal view, sorted recent-first. Read-only: it indexes each tool's own history files, no tracking or wrapping.
|
|
4
|
+
|
|
5
|
+
Requires Node.js ≥ 18 (22+ for the built-in SQLite reader). No dependencies.
|
|
6
|
+
|
|
7
|
+
## Run
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx session-preserver # last 20 sessions across all tools
|
|
11
|
+
npx session-preserver -n 30 # more rows
|
|
12
|
+
npx session-preserver --since 2d # only last 2 days
|
|
13
|
+
npx session-preserver --tool codex # one tool only
|
|
14
|
+
npx session-preserver DSA # search title/project
|
|
15
|
+
npx session-preserver summary <id> # last exchanges — paste into another CLI to continue
|
|
16
|
+
npx session-preserver show <id> # full user/assistant conversation
|
|
17
|
+
npx session-preserver path <id> # raw transcript/db path
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`<id>` accepts a unique prefix, e.g. `cx:019efd32`. IDs are prefixed by tool: `cc:` Claude Code, `cx:` Codex, `oc:` OpenCode.
|
|
21
|
+
|
|
22
|
+
Or install globally and use the short alias:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm i -g session-preserver
|
|
26
|
+
sp --since 1d
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## What it reads
|
|
30
|
+
|
|
31
|
+
| Tool | Source |
|
|
32
|
+
|-------------|--------|
|
|
33
|
+
| Claude Code | `~/.claude/projects/**/*.jsonl` |
|
|
34
|
+
| Codex | `~/.codex/session_index.jsonl` + `~/.codex/sessions/` |
|
|
35
|
+
| OpenCode | `~/.local/share/opencode/opencode.db` |
|
|
36
|
+
|
|
37
|
+
Nothing is written, modified, or sent anywhere.
|
package/index.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
#!/usr/bin/env -S node --no-warnings
|
|
2
|
+
"use strict";
|
|
3
|
+
process.removeAllListeners("warning"); // node:sqlite experimental warning
|
|
4
|
+
process.on("warning", (w) => { if (w.name !== "ExperimentalWarning") console.warn(w); });
|
|
5
|
+
/* session-preserver - centralized session history for AI CLIs (Claude Code, Codex, OpenCode).
|
|
6
|
+
Read-only indexer over each tool's own history files. No tracking, no wrapping.
|
|
7
|
+
Usage:
|
|
8
|
+
npx session-preserver recent sessions across all tools
|
|
9
|
+
npx session-preserver -n 30 --since 2d --tool codex [query]
|
|
10
|
+
npx session-preserver show <id> full user/assistant conversation
|
|
11
|
+
npx session-preserver summary <id> last few exchanges (paste into another CLI)
|
|
12
|
+
npx session-preserver path <id> raw transcript/db path
|
|
13
|
+
*/
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
const os = require("os");
|
|
17
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
18
|
+
|
|
19
|
+
const HOME = os.homedir();
|
|
20
|
+
|
|
21
|
+
// ---------- helpers ----------
|
|
22
|
+
|
|
23
|
+
function reltime(d) {
|
|
24
|
+
const s = (Date.now() - d.getTime()) / 1000;
|
|
25
|
+
if (s < 0) return "in the future";
|
|
26
|
+
if (s < 90) return `${Math.floor(s)}s ago`;
|
|
27
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
28
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
29
|
+
if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`;
|
|
30
|
+
return d.toISOString().slice(0, 10);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const shortHome = (p) => (p || "").replace(new RegExp("^" + HOME.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), "~");
|
|
34
|
+
|
|
35
|
+
function* walk(dir, re) {
|
|
36
|
+
let ents;
|
|
37
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
38
|
+
for (const e of ents) {
|
|
39
|
+
const p = path.join(dir, e.name);
|
|
40
|
+
if (e.isDirectory()) yield* walk(p, re);
|
|
41
|
+
else if (re.test(e.name)) yield p;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function* lines(file) {
|
|
46
|
+
// ponytail: whole-file read; transcripts are a few MB max. Stream if one ever feels slow.
|
|
47
|
+
let data;
|
|
48
|
+
try { data = fs.readFileSync(file, "utf8"); } catch { return; }
|
|
49
|
+
for (const line of data.split("\n")) if (line.trim()) yield line;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseSince(s) {
|
|
53
|
+
const m = /^(\d+)([smhdw])$/.exec(s.trim());
|
|
54
|
+
if (!m) { console.error(`--since: bad value '${s}' (use e.g. 30m, 12h, 2d, 1w)`); process.exit(1); }
|
|
55
|
+
const mult = { s: 1e3, m: 6e4, h: 36e5, d: 864e5, w: 6048e5 }[m[2]];
|
|
56
|
+
return new Date(Date.now() - Number(m[1]) * mult);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const textOf = (c) =>
|
|
60
|
+
typeof c === "string"
|
|
61
|
+
? c
|
|
62
|
+
: (Array.isArray(c) ? c : [])
|
|
63
|
+
.filter((x) => x && typeof x === "object" && (x.type === "text" ? true : "text" in x))
|
|
64
|
+
.map((x) => x.text || "")
|
|
65
|
+
.join("\n");
|
|
66
|
+
|
|
67
|
+
const SKIP = (t) => !t.trim() || t.startsWith("<") || t.startsWith("#");
|
|
68
|
+
const oneline = (t, n) => t.replace(/\s+/g, " ").trim().slice(0, n);
|
|
69
|
+
|
|
70
|
+
// ---------- sources ----------
|
|
71
|
+
|
|
72
|
+
function claudeSessions() {
|
|
73
|
+
const base = path.join(HOME, ".claude/projects");
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const f of walk(base, /\.jsonl$/)) {
|
|
76
|
+
try {
|
|
77
|
+
const proj = "/" + path.basename(path.dirname(f)).replace(/^-/, "").replace(/-/g, "/");
|
|
78
|
+
let firstUser = "", lastA = "";
|
|
79
|
+
for (const line of lines(f)) {
|
|
80
|
+
let rec; try { rec = JSON.parse(line); } catch { continue; }
|
|
81
|
+
if (rec.type === "user" && !firstUser) {
|
|
82
|
+
const t = textOf(rec.message && rec.message.content);
|
|
83
|
+
if (!SKIP(t)) firstUser = oneline(t, 60);
|
|
84
|
+
} else if (rec.type === "assistant") {
|
|
85
|
+
const t = textOf(rec.message && rec.message.content);
|
|
86
|
+
if (!SKIP(t)) lastA = oneline(t, 60);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
out.push({
|
|
90
|
+
id: "cc:" + path.basename(f, ".jsonl"), tool: "claude", source: f,
|
|
91
|
+
project: shortHome(proj), title: firstUser || lastA,
|
|
92
|
+
updated: new Date(fs.statSync(f).mtime),
|
|
93
|
+
});
|
|
94
|
+
} catch { /* skip unreadable file */ }
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function codexSessions() {
|
|
100
|
+
const names = {};
|
|
101
|
+
const idx = path.join(HOME, ".codex/session_index.jsonl");
|
|
102
|
+
for (const line of lines(idx)) {
|
|
103
|
+
try {
|
|
104
|
+
const r = JSON.parse(line);
|
|
105
|
+
if (r.id) names[r.id] = { title: r.thread_name || "", updated: r.updated_at || "" };
|
|
106
|
+
} catch { /* skip */ }
|
|
107
|
+
}
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const f of walk(path.join(HOME, ".codex/sessions"), /^rollout-.*\.jsonl$/)) {
|
|
110
|
+
try {
|
|
111
|
+
let meta = null, firstUser = "";
|
|
112
|
+
for (const line of lines(f)) {
|
|
113
|
+
let rec; try { rec = JSON.parse(line); } catch { continue; }
|
|
114
|
+
if (rec.type === "session_meta") meta = rec.payload;
|
|
115
|
+
else if (rec.type === "response_item" && !firstUser) {
|
|
116
|
+
const pl = rec.payload || {};
|
|
117
|
+
if (pl.type === "message" && pl.role === "user") {
|
|
118
|
+
const t = textOf(pl.content);
|
|
119
|
+
if (!SKIP(t)) firstUser = oneline(t, 60);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const sid = meta ? meta.id || meta.session_id : path.basename(f, ".jsonl");
|
|
124
|
+
const idxRec = names[sid] || {};
|
|
125
|
+
let updated;
|
|
126
|
+
try { updated = new Date(idxRec.updated || (meta && meta.timestamp)); }
|
|
127
|
+
catch { updated = new Date(fs.statSync(f).mtime); }
|
|
128
|
+
if (isNaN(updated)) updated = new Date(fs.statSync(f).mtime);
|
|
129
|
+
out.push({
|
|
130
|
+
id: "cx:" + sid, tool: "codex", source: f,
|
|
131
|
+
project: shortHome(meta ? meta.cwd : ""),
|
|
132
|
+
title: idxRec.title || firstUser, updated,
|
|
133
|
+
});
|
|
134
|
+
} catch { /* skip */ }
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function opencodeSessions() {
|
|
140
|
+
const dbPath = path.join(HOME, ".local/share/opencode/opencode.db");
|
|
141
|
+
if (!fs.existsSync(dbPath)) return [];
|
|
142
|
+
try {
|
|
143
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
144
|
+
const rows = db
|
|
145
|
+
.prepare("SELECT id, title, directory, time_updated FROM session ORDER BY time_updated DESC LIMIT 500")
|
|
146
|
+
.all();
|
|
147
|
+
db.close();
|
|
148
|
+
return rows.map((r) => ({
|
|
149
|
+
id: "oc:" + r.id, tool: "opencode", source: dbPath,
|
|
150
|
+
project: shortHome(r.directory || ""), title: oneline(r.title || "", 60),
|
|
151
|
+
updated: new Date(Number(r.time_updated)),
|
|
152
|
+
}));
|
|
153
|
+
} catch { return []; }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const SOURCES = { claude: claudeSessions, codex: codexSessions, opencode: opencodeSessions };
|
|
157
|
+
|
|
158
|
+
function allSessions() {
|
|
159
|
+
return Object.values(SOURCES)
|
|
160
|
+
.flatMap((fn) => { try { return fn(); } catch { return []; } })
|
|
161
|
+
.sort((a, b) => b.updated - a.updated);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function find(prefix) {
|
|
165
|
+
const hits = allSessions().filter((s) => s.id.startsWith(prefix));
|
|
166
|
+
if (!hits.length) { console.error(`no session matches '${prefix}'`); process.exit(1); }
|
|
167
|
+
if (hits.length > 1) {
|
|
168
|
+
console.error("ambiguous id, matches:\n" + hits.map((h) => ` ${h.id} ${h.title.slice(0, 50)}`).join("\n"));
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
return hits[0];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------- conversation readers ----------
|
|
175
|
+
|
|
176
|
+
function* conversation(sess) {
|
|
177
|
+
if (sess.tool === "claude") {
|
|
178
|
+
for (const line of lines(sess.source)) {
|
|
179
|
+
let rec; try { rec = JSON.parse(line); } catch { continue; }
|
|
180
|
+
if (rec.type === "user" || rec.type === "assistant") {
|
|
181
|
+
const t = textOf(rec.message && rec.message.content).trim();
|
|
182
|
+
if (t && !t.startsWith("<")) yield [rec.type, t];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} else if (sess.tool === "codex") {
|
|
186
|
+
for (const line of lines(sess.source)) {
|
|
187
|
+
let rec; try { rec = JSON.parse(line); } catch { continue; }
|
|
188
|
+
if (rec.type === "response_item") {
|
|
189
|
+
const pl = rec.payload || {};
|
|
190
|
+
if (pl.type === "message") {
|
|
191
|
+
const t = textOf(pl.content).trim();
|
|
192
|
+
if (t && !t.startsWith("<")) yield [pl.role || "?", t];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} else if (sess.tool === "opencode") {
|
|
197
|
+
const db = new DatabaseSync(sess.source, { readOnly: true });
|
|
198
|
+
const sid = sess.id.slice(3);
|
|
199
|
+
try {
|
|
200
|
+
const msgs = db.prepare("SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created").all(sid);
|
|
201
|
+
const partStmt = db.prepare("SELECT data FROM part WHERE message_id = ? ORDER BY time_created");
|
|
202
|
+
for (const m of msgs) {
|
|
203
|
+
let role = "?";
|
|
204
|
+
try { role = JSON.parse(m.data).role || "?"; } catch { /* keep ? */ }
|
|
205
|
+
for (const p of partStmt.all(m.id)) {
|
|
206
|
+
try {
|
|
207
|
+
const d = JSON.parse(p.data);
|
|
208
|
+
if (d.type === "text" && d.text && d.text.trim()) yield [role, d.text];
|
|
209
|
+
} catch { /* skip */ }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} finally { db.close(); }
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function render(sess, pairs) {
|
|
217
|
+
const head = `# ${sess.tool} session ${sess.id}\nproject: ${sess.project} updated: ${sess.updated.toISOString().slice(0, 16).replace("T", " ")} UTC\n`;
|
|
218
|
+
return head + pairs.map(([r, t]) => `\n## ${r}\n\n${t}\n`).join("");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------- commands ----------
|
|
222
|
+
|
|
223
|
+
function cmdLs(args) {
|
|
224
|
+
let rows = allSessions();
|
|
225
|
+
if (args.tool) rows = rows.filter((r) => r.tool === args.tool);
|
|
226
|
+
if (args.since) { const cut = parseSince(args.since); rows = rows.filter((r) => r.updated >= cut); }
|
|
227
|
+
if (args.query) { const q = args.query.toLowerCase(); rows = rows.filter((r) => (r.title + " " + r.project).toLowerCase().includes(q)); }
|
|
228
|
+
rows = rows.slice(0, args.n);
|
|
229
|
+
if (!rows.length) { console.log("no sessions found"); return; }
|
|
230
|
+
const idW = Math.max(...rows.map((r) => r.id.length)) ;
|
|
231
|
+
console.log(`${"ID".padEnd(idW + 1)} ${"TOOL".padEnd(9)} ${"WHEN".padEnd(10)} ${"PROJECT".padEnd(38)} TITLE`);
|
|
232
|
+
for (const r of rows)
|
|
233
|
+
console.log(`${r.id.padEnd(idW + 1)} ${r.tool.padEnd(9)} ${reltime(r.updated).padEnd(10)} ${r.project.slice(-37).padEnd(38)} ${r.title.slice(0, 60)}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function cmdShow(id, n) {
|
|
237
|
+
const sess = find(id);
|
|
238
|
+
let pairs = [...conversation(sess)];
|
|
239
|
+
if (n != null) pairs = pairs.slice(-n);
|
|
240
|
+
process.stdout.write(render(sess, pairs));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function cmdSummary(id) {
|
|
244
|
+
const sess = find(id);
|
|
245
|
+
const pairs = [...conversation(sess)].filter(([r]) => r === "user" || r === "assistant").slice(-6);
|
|
246
|
+
process.stdout.write(render(sess, pairs));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------- main ----------
|
|
250
|
+
|
|
251
|
+
function usage(code) {
|
|
252
|
+
console.log(`session-preserver - centralized AI CLI session history
|
|
253
|
+
|
|
254
|
+
Usage:
|
|
255
|
+
session-preserver [ls] [-n N] [--since 2d] [--tool claude|codex|opencode] [query]
|
|
256
|
+
session-preserver show <id> [-n N]
|
|
257
|
+
session-preserver summary <id>
|
|
258
|
+
session-preserver path <id>
|
|
259
|
+
|
|
260
|
+
ids accept unique prefixes, e.g. cx:019efd32`);
|
|
261
|
+
process.exit(code);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const argv = process.argv.slice(2);
|
|
265
|
+
if (argv.includes("-h") || argv.includes("--help")) usage(0);
|
|
266
|
+
|
|
267
|
+
const COMMANDS = new Set(["ls", "show", "summary", "path"]);
|
|
268
|
+
let cmd = "ls";
|
|
269
|
+
if (COMMANDS.has(argv[0])) cmd = argv.shift();
|
|
270
|
+
|
|
271
|
+
if (cmd === "ls") {
|
|
272
|
+
const args = { n: 20, since: null, tool: null, query: null };
|
|
273
|
+
for (let i = 0; i < argv.length; i++) {
|
|
274
|
+
if (argv[i] === "-n") args.n = Number(argv[++i]);
|
|
275
|
+
else if (argv[i] === "--since") args.since = argv[++i];
|
|
276
|
+
else if (argv[i] === "--tool") {
|
|
277
|
+
args.tool = argv[++i];
|
|
278
|
+
if (!SOURCES[args.tool]) { console.error(`unknown tool '${args.tool}' (claude|codex|opencode)`); process.exit(1); }
|
|
279
|
+
} else if (!args.query) args.query = argv[i];
|
|
280
|
+
else usage(1);
|
|
281
|
+
}
|
|
282
|
+
cmdLs(args);
|
|
283
|
+
} else {
|
|
284
|
+
const id = argv[0];
|
|
285
|
+
if (!id) usage(1);
|
|
286
|
+
if (cmd === "show") {
|
|
287
|
+
const i = argv.indexOf("-n");
|
|
288
|
+
cmdShow(id, i > -1 ? Number(argv[i + 1]) : null);
|
|
289
|
+
} else if (cmd === "summary") cmdSummary(id);
|
|
290
|
+
else if (cmd === "path") console.log(find(id).source);
|
|
291
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "session-preserver",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Centralized session history for AI CLIs — Claude Code, Codex, OpenCode. Read-only.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": { "session-preserver": "index.js", "sp": "index.js" },
|
|
7
|
+
"engines": { "node": ">=18" },
|
|
8
|
+
"files": ["index.js", "README.md"],
|
|
9
|
+
"keywords": ["claude", "codex", "opencode", "ai", "cli", "history", "session"]
|
|
10
|
+
}
|