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.
Files changed (3) hide show
  1. package/README.md +37 -0
  2. package/index.js +291 -0
  3. 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
+ }