pulse-for-claude-code 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.
@@ -0,0 +1,206 @@
1
+ 'use strict';
2
+
3
+ // Read a Claude Code session log straight off disk and turn it into something
4
+ // a human can read: a light markdown transcript, a phone friendly HTML page, or
5
+ // a short terminal recap. Works with no server and no live session, because the
6
+ // jsonl under ~/.claude/projects is written as the session happens and survives
7
+ // the terminal that made it.
8
+
9
+ const fs = require('fs');
10
+ const os = require('os');
11
+ const path = require('path');
12
+
13
+ const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
14
+
15
+ function listSessions() {
16
+ const out = [];
17
+ let dirs;
18
+ try { dirs = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true }); } catch (e) { return out; }
19
+ for (const d of dirs) {
20
+ if (!d.isDirectory()) continue;
21
+ const p = path.join(PROJECTS_DIR, d.name);
22
+ let files;
23
+ try { files = fs.readdirSync(p); } catch (e) { continue; }
24
+ for (const f of files) {
25
+ if (!f.endsWith('.jsonl')) continue;
26
+ const fp = path.join(p, f);
27
+ let st;
28
+ try { st = fs.statSync(fp); } catch (e) { continue; }
29
+ out.push({ sid: f.replace(/\.jsonl$/, ''), file: fp, mtimeMs: st.mtimeMs, size: st.size });
30
+ }
31
+ }
32
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
33
+ return out;
34
+ }
35
+
36
+ function findSession(arg) {
37
+ const all = listSessions();
38
+ if (!arg || arg === 'latest') return all[0] || null;
39
+ return all.find((s) => s.sid === arg) || all.find((s) => s.sid.startsWith(arg)) || null;
40
+ }
41
+
42
+ function textOf(content) {
43
+ if (typeof content === 'string') return content;
44
+ if (Array.isArray(content)) return content.filter((p) => p.type === 'text').map((p) => p.text).join('\n');
45
+ return '';
46
+ }
47
+
48
+ function toolHint(input) {
49
+ if (!input || typeof input !== 'object') return '';
50
+ const h = input.file_path || input.command || input.pattern || input.description ||
51
+ input.url || input.path || input.query || '';
52
+ return String(h).replace(/\s+/g, ' ').trim().slice(0, 100);
53
+ }
54
+
55
+ // Parse a session file into { meta, blocks } where blocks is an ordered list of
56
+ // { role: 'user'|'claude', text, tools: [{name, hint, input}] }.
57
+ function parseLog(file) {
58
+ const lines = fs.readFileSync(file, 'utf8').split('\n');
59
+ const blocks = [];
60
+ const meta = { sid: path.basename(file).replace(/\.jsonl$/, ''), title: null, project: 'unknown',
61
+ cwd: null, model: null, firstT: null, lastT: null, users: 0, assists: 0, tools: 0 };
62
+
63
+ for (const line of lines) {
64
+ if (!line) continue;
65
+ let o;
66
+ try { o = JSON.parse(line); } catch (e) { continue; }
67
+ const m = o.message;
68
+ if (o.timestamp) { if (!meta.firstT) meta.firstT = o.timestamp; meta.lastT = o.timestamp; }
69
+ if (o.cwd) { meta.cwd = o.cwd; meta.project = path.basename(o.cwd); }
70
+ if (!m) continue;
71
+
72
+ if (o.type === 'user') {
73
+ const t = textOf(m.content).trim();
74
+ if (!t || t.startsWith('<') || t.startsWith('Caveat')) continue; // skip system-injected
75
+ meta.users++;
76
+ if (!meta.title) meta.title = t.slice(0, 80).replace(/\s+/g, ' ');
77
+ blocks.push({ role: 'user', text: t, tools: [] });
78
+ } else if (o.type === 'assistant' && Array.isArray(m.content)) {
79
+ if (m.model) meta.model = m.model;
80
+ let text = '', tools = [];
81
+ for (const p of m.content) {
82
+ if (p.type === 'text' && p.text && p.text.trim()) text += (text ? '\n' : '') + p.text;
83
+ else if (p.type === 'tool_use') { meta.tools++; tools.push({ name: p.name, hint: toolHint(p.input), input: p.input }); }
84
+ }
85
+ if (text || tools.length) { meta.assists++; blocks.push({ role: 'claude', text, tools }); }
86
+ }
87
+ }
88
+ return { meta, blocks };
89
+ }
90
+
91
+ function metaLines(meta) {
92
+ return [
93
+ `- project: ${meta.project}${meta.cwd ? ` (${meta.cwd})` : ''}`,
94
+ `- model: ${meta.model || 'unknown'}`,
95
+ `- started: ${meta.firstT || '?'}`,
96
+ `- last activity: ${meta.lastT || '?'}`,
97
+ `- ${meta.users} prompts, ${meta.assists} replies, ${meta.tools} tool calls`,
98
+ ];
99
+ }
100
+
101
+ function renderMarkdown(file, opts = {}) {
102
+ const { meta, blocks } = parseLog(file);
103
+ const parts = [`# Session ${meta.sid}`, '', ...metaLines(meta),
104
+ meta.title ? `- title: ${meta.title}` : '', '', '---'];
105
+ for (const b of blocks) {
106
+ if (b.role === 'user') { parts.push(`\n### You\n\n${b.text}`); continue; }
107
+ parts.push('\n### Claude\n');
108
+ if (b.text) parts.push(b.text);
109
+ for (const t of b.tools) {
110
+ parts.push(`- \`${t.name}\`${t.hint ? ' ' + t.hint : ''}`);
111
+ if (opts.full && t.input) parts.push(' ```\n ' + JSON.stringify(t.input).slice(0, 2000) + '\n ```');
112
+ }
113
+ }
114
+ return parts.filter((x) => x !== '').join('\n');
115
+ }
116
+
117
+ // A short, terminal friendly recap: header plus the last n exchanges.
118
+ function recapText(file, n = 6) {
119
+ const { meta, blocks } = parseLog(file);
120
+ const out = [];
121
+ out.push(`session ${meta.sid.slice(0, 8)} ${meta.project}`);
122
+ if (meta.title) out.push(`title: ${meta.title}`);
123
+ out.push(`${meta.users} prompts, ${meta.assists} replies, last activity ${meta.lastT || '?'}`);
124
+ out.push('');
125
+ out.push(`last ${Math.min(n, blocks.length)} exchanges:`);
126
+ for (const b of blocks.slice(-n)) {
127
+ const who = b.role === 'user' ? 'YOU ' : 'CLAUDE';
128
+ let line = b.text ? b.text.replace(/\s+/g, ' ').trim() : '';
129
+ if (!line && b.tools.length) line = b.tools.map((t) => t.name).join(', ');
130
+ out.push(` [${who}] ${line.slice(0, 160)}`);
131
+ }
132
+ return out.join('\n');
133
+ }
134
+
135
+ function escHtml(s) {
136
+ return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
137
+ }
138
+
139
+ // A self contained, phone readable HTML page in the Pulse palette.
140
+ function renderHtmlPage(file) {
141
+ const { meta, blocks } = parseLog(file);
142
+ const body = blocks.map((b) => {
143
+ if (b.role === 'user') {
144
+ return '<div class="t t--you"><div class="who">You</div><div class="body">' + escHtml(b.text) + '</div></div>';
145
+ }
146
+ let inner = b.text ? '<div class="body">' + escHtml(b.text) + '</div>' : '';
147
+ if (b.tools.length) {
148
+ inner += '<div class="tools">' + b.tools.map((t) =>
149
+ '<span class="tool"><b>' + escHtml(t.name) + '</b>' + (t.hint ? ' ' + escHtml(t.hint) : '') + '</span>').join('') + '</div>';
150
+ }
151
+ return '<div class="t t--claude"><div class="who">Claude</div>' + inner + '</div>';
152
+ }).join('\n');
153
+
154
+ return `<!doctype html><html><head><meta charset="utf-8">
155
+ <meta name="viewport" content="width=device-width, initial-scale=1">
156
+ <title>${escHtml(meta.title || meta.sid)} · Pulse transcript</title>
157
+ <style>
158
+ :root{--bg:#1c1b19;--card:#26241f;--ink:#ece7df;--dim:#9a958c;--accent:#d97757;--line:#3a372f}
159
+ *{box-sizing:border-box}
160
+ body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.55 -apple-system,system-ui,Segoe UI,Roboto,sans-serif;padding:16px;max-width:820px;margin:0 auto}
161
+ h1{font-size:18px;margin:4px 0 2px}
162
+ .meta{color:var(--dim);font-size:13px;margin-bottom:16px}
163
+ .meta a{color:var(--accent);text-decoration:none}
164
+ .t{border:1px solid var(--line);border-radius:12px;padding:10px 13px;margin:10px 0;background:var(--card)}
165
+ .t--you{border-color:var(--accent)}
166
+ .who{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--dim);margin-bottom:5px}
167
+ .t--you .who{color:var(--accent)}
168
+ .body{white-space:pre-wrap;word-wrap:break-word}
169
+ .tools{margin-top:8px;display:flex;flex-direction:column;gap:3px}
170
+ .tool{font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dim)}
171
+ .tool b{color:var(--ink)}
172
+ </style></head><body>
173
+ <h1>${escHtml(meta.title || '(untitled session)')}</h1>
174
+ <div class="meta">${escHtml(meta.project)} · ${meta.model || 'unknown'} · ${meta.users} prompts · ${meta.assists} replies ·
175
+ <a href="/api/export?sid=${encodeURIComponent(meta.sid)}&dl=1">download .md</a></div>
176
+ ${body}
177
+ </body></html>`;
178
+ }
179
+
180
+ // Write a markdown export under ~/.claude-pulse/exports and return where it went.
181
+ function saveExport(session, opts = {}) {
182
+ const zlib = require('zlib');
183
+ const outDir = path.join(os.homedir(), '.claude-pulse', 'exports');
184
+ const md = renderMarkdown(session.file, { full: opts.full });
185
+ try { fs.mkdirSync(outDir, { recursive: true }); } catch (e) {}
186
+ const stamp = new Date().toISOString().slice(0, 10);
187
+ const dest = path.join(outDir, `${session.sid.slice(0, 8)}-${stamp}.md`);
188
+ fs.writeFileSync(dest, md);
189
+ const result = { md, path: dest, gz: null };
190
+ if (opts.gz) { result.gz = dest + '.gz'; fs.writeFileSync(result.gz, zlib.gzipSync(md)); }
191
+ return result;
192
+ }
193
+
194
+ // One markdown blob of every session on disk, newest first. Gzips small.
195
+ function combinedMarkdown(opts = {}) {
196
+ const parts = [];
197
+ for (const s of listSessions()) {
198
+ try { parts.push(renderMarkdown(s.file, opts), '\n\n' + '='.repeat(72) + '\n\n'); } catch (e) {}
199
+ }
200
+ return parts.join('');
201
+ }
202
+
203
+ module.exports = {
204
+ PROJECTS_DIR, listSessions, findSession, parseLog,
205
+ renderMarkdown, renderHtmlPage, recapText, metaLines, saveExport, combinedMarkdown,
206
+ };