@vale-dinap/lucidly 0.0.0-stage → 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,101 @@
1
+ /**
2
+ * One open run: a source feeding an engine, the event log (for saving), and live subscribers.
3
+ * State lives in memory; saving writes the event log, loading replays it.
4
+ */
5
+ import { Engine } from "../core/engine.js";
6
+ export const SAVE_FORMAT = 'lucidly-run';
7
+ export const SAVE_VERSION = 1;
8
+ const FLUSH_MS = 120;
9
+ export class RunSession {
10
+ id;
11
+ key;
12
+ label;
13
+ live;
14
+ engine = new Engine();
15
+ events = [];
16
+ source;
17
+ subs = new Set();
18
+ timer = null;
19
+ constructor(id, source, opts = {}) {
20
+ this.id = id;
21
+ this.source = source;
22
+ this.key = source?.key ?? opts.key ?? `run:${id}`;
23
+ this.label = opts.label ?? source?.label ?? id;
24
+ this.live = source?.live ?? false;
25
+ }
26
+ async start() {
27
+ if (this.source)
28
+ await this.source.start((evs) => this.ingest(evs));
29
+ }
30
+ ingest(evs) {
31
+ if (!evs.length)
32
+ return;
33
+ for (const e of evs)
34
+ this.events.push(e);
35
+ this.engine.applyAll(evs);
36
+ if (this.subs.size && !this.timer)
37
+ this.timer = setTimeout(() => this.flush(), FLUSH_MS);
38
+ }
39
+ flush() {
40
+ this.timer = null;
41
+ const patch = this.engine.takePatch();
42
+ if (!patch)
43
+ return;
44
+ for (const s of this.subs)
45
+ s('patch', patch);
46
+ }
47
+ subscribe(send) {
48
+ // Bring existing subscribers up to date first, so the new snapshot and their state agree.
49
+ if (this.timer) {
50
+ clearTimeout(this.timer);
51
+ this.flush();
52
+ }
53
+ else {
54
+ this.engine.takePatch();
55
+ }
56
+ this.subs.add(send);
57
+ send('snapshot', this.engine.snapshot());
58
+ return () => this.subs.delete(send);
59
+ }
60
+ info() {
61
+ const r = this.engine.run;
62
+ return {
63
+ id: this.id,
64
+ key: this.key,
65
+ label: this.label,
66
+ title: r.title,
67
+ live: this.live,
68
+ status: r.status,
69
+ eventCount: r.eventCount,
70
+ updatedAt: r.updatedAt,
71
+ subscribers: this.subs.size,
72
+ };
73
+ }
74
+ save() {
75
+ const r = this.engine.run;
76
+ return {
77
+ format: SAVE_FORMAT,
78
+ version: SAVE_VERSION,
79
+ savedAt: new Date().toISOString(),
80
+ label: this.label,
81
+ meta: { title: r.title, cwd: r.cwd, agent: r.agent, sessionId: r.sessionId },
82
+ events: this.events,
83
+ };
84
+ }
85
+ static fromSave(id, data) {
86
+ const f = data;
87
+ if (!f || f.format !== SAVE_FORMAT || !Array.isArray(f.events))
88
+ throw new Error('Not a Lucidly save file');
89
+ if ((f.version ?? 0) > SAVE_VERSION)
90
+ throw new Error(`Save file version ${f.version} is newer than this Lucidly`);
91
+ const s = new RunSession(id, null, { label: f.label ?? f.meta?.title ?? 'Saved run' });
92
+ s.ingest(f.events);
93
+ return s;
94
+ }
95
+ close() {
96
+ if (this.timer)
97
+ clearTimeout(this.timer);
98
+ this.source?.stop();
99
+ this.subs.clear();
100
+ }
101
+ }
@@ -0,0 +1,329 @@
1
+ /** Discover and follow Claude Code session transcripts under ~/.claude/projects. */
2
+ import { homedir } from 'node:os';
3
+ import { join, basename } from 'node:path';
4
+ import { readdir, stat, open, readFile } from 'node:fs/promises';
5
+ import { ClaudeCodeAdapter, cleanPrompt, harnessNotice } from "../adapters/claude-code.js";
6
+ import { truncate } from "../core/text.js";
7
+ import { LineTailer } from "./tail.js";
8
+ export function claudeDir() {
9
+ return process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude');
10
+ }
11
+ export function projectsDir() {
12
+ return join(claudeDir(), 'projects');
13
+ }
14
+ /** Claude Code names a project folder after its cwd with every non-alphanumeric char replaced by '-'. */
15
+ export function encodeCwd(cwd) {
16
+ return cwd.replace(/[^a-zA-Z0-9]/g, '-');
17
+ }
18
+ async function readSlice(path, start, length) {
19
+ const fh = await open(path, 'r');
20
+ try {
21
+ const buf = Buffer.alloc(length);
22
+ const { bytesRead } = await fh.read(buf, 0, length, start);
23
+ return buf.subarray(0, bytesRead).toString('utf8');
24
+ }
25
+ finally {
26
+ await fh.close();
27
+ }
28
+ }
29
+ /** Cheap metadata: read the head for cwd/first prompt and the tail for the latest title. */
30
+ export async function describeSession(path) {
31
+ const st = await stat(path);
32
+ const info = {
33
+ id: basename(path, '.jsonl'),
34
+ path,
35
+ project: basename(join(path, '..')),
36
+ mtime: st.mtimeMs,
37
+ size: st.size,
38
+ };
39
+ const head = await readSlice(path, 0, Math.min(st.size, 256 * 1024));
40
+ for (const line of head.split('\n')) {
41
+ if (!line.startsWith('{'))
42
+ continue;
43
+ let r;
44
+ try {
45
+ r = JSON.parse(line);
46
+ }
47
+ catch {
48
+ continue;
49
+ }
50
+ if (!info.cwd && r.cwd)
51
+ info.cwd = r.cwd;
52
+ if (r.type === 'ai-title' && r.aiTitle)
53
+ info.title = r.aiTitle;
54
+ if (!info.firstPrompt && r.type === 'user' && !r.isMeta && !r.isSidechain) {
55
+ const c = r.message?.content;
56
+ const t = typeof c === 'string' ? c : Array.isArray(c) ? c.filter((b) => b.type === 'text').map((b) => b.text).join('\n') : '';
57
+ if (t && !harnessNotice(t)) {
58
+ const p = cleanPrompt(t);
59
+ if (p)
60
+ info.firstPrompt = truncate(p, 140);
61
+ }
62
+ }
63
+ if (info.cwd && info.firstPrompt && info.title)
64
+ break;
65
+ }
66
+ if (st.size > 256 * 1024) {
67
+ const tailLen = Math.min(st.size, 512 * 1024);
68
+ const tail = await readSlice(path, st.size - tailLen, tailLen);
69
+ const titles = [...tail.matchAll(/"aiTitle":"((?:[^"\\]|\\.)*)"/g)];
70
+ if (titles.length) {
71
+ try {
72
+ info.title = JSON.parse(`"${titles[titles.length - 1][1]}"`);
73
+ }
74
+ catch {
75
+ /* keep head title */
76
+ }
77
+ }
78
+ }
79
+ return info;
80
+ }
81
+ /** Recent sessions, newest first. With `cwd`, only that project's sessions. */
82
+ export async function listSessions(opts = {}) {
83
+ const root = projectsDir();
84
+ let projects;
85
+ try {
86
+ projects = await readdir(root);
87
+ }
88
+ catch {
89
+ return [];
90
+ }
91
+ if (opts.cwd) {
92
+ const want = encodeCwd(opts.cwd).toLowerCase();
93
+ projects = projects.filter((p) => p.toLowerCase() === want);
94
+ }
95
+ const files = [];
96
+ for (const p of projects) {
97
+ let entries;
98
+ try {
99
+ entries = await readdir(join(root, p));
100
+ }
101
+ catch {
102
+ continue;
103
+ }
104
+ for (const f of entries) {
105
+ if (!f.endsWith('.jsonl'))
106
+ continue;
107
+ const path = join(root, p, f);
108
+ try {
109
+ files.push({ path, mtime: (await stat(path)).mtimeMs });
110
+ }
111
+ catch {
112
+ /* vanished */
113
+ }
114
+ }
115
+ }
116
+ files.sort((a, b) => b.mtime - a.mtime);
117
+ const out = [];
118
+ for (const f of files.slice(0, opts.limit ?? 30)) {
119
+ try {
120
+ out.push(await describeSession(f.path));
121
+ }
122
+ catch {
123
+ /* unreadable */
124
+ }
125
+ }
126
+ return out;
127
+ }
128
+ /** Resolve a session id (or id prefix) to its transcript path. */
129
+ export async function findSession(idOrPath) {
130
+ if (idOrPath.endsWith('.jsonl'))
131
+ return idOrPath;
132
+ const root = projectsDir();
133
+ let projects = [];
134
+ try {
135
+ projects = await readdir(root);
136
+ }
137
+ catch {
138
+ return null;
139
+ }
140
+ for (const p of projects) {
141
+ let entries = [];
142
+ try {
143
+ entries = await readdir(join(root, p));
144
+ }
145
+ catch {
146
+ continue;
147
+ }
148
+ const hit = entries.find((f) => f.endsWith('.jsonl') && f.startsWith(idOrPath));
149
+ if (hit)
150
+ return join(root, p, hit);
151
+ }
152
+ return null;
153
+ }
154
+ function parseLines(lines, adapter) {
155
+ const out = [];
156
+ for (const l of lines) {
157
+ if (!l.startsWith('{'))
158
+ continue;
159
+ try {
160
+ out.push(...adapter.push(JSON.parse(l)));
161
+ }
162
+ catch {
163
+ /* a corrupt line must not stop the stream */
164
+ }
165
+ }
166
+ out.push(...adapter.flush());
167
+ return out;
168
+ }
169
+ /** Text of a transcript record's user message (string or text blocks). */
170
+ function messageText(rec) {
171
+ const c = rec?.message?.content;
172
+ if (typeof c === 'string')
173
+ return c;
174
+ if (Array.isArray(c))
175
+ return c.map((b) => (b?.type === 'text' ? b.text : '')).join('');
176
+ return '';
177
+ }
178
+ /** Reads the first complete line of a file (a subagent's first record is its task). */
179
+ async function firstLine(path) {
180
+ const fh = await open(path, 'r');
181
+ try {
182
+ const buf = Buffer.alloc(256 * 1024);
183
+ const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
184
+ const text = buf.subarray(0, bytesRead).toString('utf8');
185
+ const nl = text.indexOf('\n');
186
+ return nl >= 0 ? text.slice(0, nl) : null;
187
+ }
188
+ finally {
189
+ await fh.close();
190
+ }
191
+ }
192
+ const norm = (s) => s.replace(/\s+/g, ' ').trim();
193
+ /**
194
+ * Follows one Claude Code session: the main transcript plus its subagent transcripts.
195
+ *
196
+ * A subagent's transcript is written live, but its `.meta.json` (which names the parent
197
+ * Agent call) only appears when the subagent finishes. To show subagent work as it happens,
198
+ * a new subagent file is linked to its parent call by matching its first prompt against
199
+ * the `prompt` input of the Agent/Task calls seen in the main transcript.
200
+ */
201
+ export class ClaudeSessionSource {
202
+ live;
203
+ key;
204
+ label;
205
+ path;
206
+ tailers = [];
207
+ subagents = new Set();
208
+ /** Agent/Task calls from the main transcript: tool_use id -> normalized prompt. */
209
+ agentCalls = new Map();
210
+ claimed = new Set();
211
+ timer = null;
212
+ scanning = false;
213
+ emit = () => { };
214
+ constructor(path, live = true) {
215
+ this.path = path;
216
+ this.key = `claude:${path.toLowerCase()}`;
217
+ this.label = basename(path, '.jsonl');
218
+ this.live = live;
219
+ }
220
+ async start(emit) {
221
+ this.emit = emit;
222
+ const main = new ClaudeCodeAdapter();
223
+ const t = new LineTailer(this.path, (lines) => {
224
+ const spawned = this.noteAgentCalls(lines);
225
+ const evs = parseLines(lines, main);
226
+ if (evs.length)
227
+ this.emit(evs);
228
+ if (spawned && this.live)
229
+ setTimeout(() => void this.scanSubagents(), 250);
230
+ });
231
+ this.tailers.push(t);
232
+ await t.start(this.live);
233
+ await this.scanSubagents();
234
+ if (this.live) {
235
+ this.timer = setInterval(() => void this.scanSubagents(), 1000);
236
+ this.timer.unref?.();
237
+ }
238
+ }
239
+ stop() {
240
+ if (this.timer)
241
+ clearInterval(this.timer);
242
+ for (const t of this.tailers)
243
+ t.stop();
244
+ }
245
+ noteAgentCalls(lines) {
246
+ let found = false;
247
+ for (const line of lines) {
248
+ if (!line.includes('"tool_use"') || !/"name":"(?:Agent|Task)"/.test(line))
249
+ continue;
250
+ try {
251
+ const rec = JSON.parse(line);
252
+ for (const b of rec?.message?.content ?? []) {
253
+ if (b?.type === 'tool_use' && (b.name === 'Agent' || b.name === 'Task') && typeof b.input?.prompt === 'string') {
254
+ this.agentCalls.set(b.id, norm(b.input.prompt));
255
+ found = true;
256
+ }
257
+ }
258
+ }
259
+ catch {
260
+ /* not a full record */
261
+ }
262
+ }
263
+ return found;
264
+ }
265
+ async parentOf(dir, f) {
266
+ try {
267
+ const meta = JSON.parse(await readFile(join(dir, f.replace(/\.jsonl$/, '.meta.json')), 'utf8'));
268
+ if (meta.toolUseId)
269
+ return meta.toolUseId;
270
+ }
271
+ catch {
272
+ /* meta not written yet: the subagent is still running */
273
+ }
274
+ try {
275
+ const line = await firstLine(join(dir, f));
276
+ if (!line)
277
+ return undefined;
278
+ const prompt = norm(messageText(JSON.parse(line)));
279
+ if (!prompt)
280
+ return undefined;
281
+ const open = [...this.agentCalls].filter(([id]) => !this.claimed.has(id));
282
+ const hit = open.find(([, p]) => p === prompt) ?? open.find(([, p]) => p.startsWith(prompt.slice(0, 200)) || prompt.startsWith(p.slice(0, 200)));
283
+ return hit?.[0];
284
+ }
285
+ catch {
286
+ return undefined;
287
+ }
288
+ }
289
+ async scanSubagents() {
290
+ if (this.scanning)
291
+ return;
292
+ this.scanning = true;
293
+ try {
294
+ const dir = join(this.path.replace(/\.jsonl$/, ''), 'subagents');
295
+ let entries;
296
+ try {
297
+ entries = await readdir(dir);
298
+ }
299
+ catch {
300
+ return;
301
+ }
302
+ for (const f of entries.sort()) {
303
+ if (!f.endsWith('.jsonl') || this.subagents.has(f))
304
+ continue;
305
+ // Compaction runs replay the conversation to summarise it: not a subagent.
306
+ if (f.startsWith('agent-acompact')) {
307
+ this.subagents.add(f);
308
+ continue;
309
+ }
310
+ const toolUseId = await this.parentOf(dir, f);
311
+ if (!toolUseId)
312
+ continue; // retry on the next scan
313
+ this.subagents.add(f);
314
+ this.claimed.add(toolUseId);
315
+ const adapter = new ClaudeCodeAdapter(toolUseId);
316
+ const t = new LineTailer(join(dir, f), (lines) => {
317
+ const evs = parseLines(lines, adapter);
318
+ if (evs.length)
319
+ this.emit(evs);
320
+ });
321
+ this.tailers.push(t);
322
+ await t.start(this.live);
323
+ }
324
+ }
325
+ finally {
326
+ this.scanning = false;
327
+ }
328
+ }
329
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
2
+ import { TextAdapter } from "../adapters/text.js";
3
+ class JsonLinesAdapter {
4
+ name = 'claude-code';
5
+ inner = new ClaudeCodeAdapter();
6
+ buffer = '';
7
+ push(input) {
8
+ this.buffer += String(input);
9
+ const lines = this.buffer.split('\n');
10
+ this.buffer = lines.pop() ?? '';
11
+ return lines.flatMap((l) => this.parse(l));
12
+ }
13
+ flush() {
14
+ const out = this.parse(this.buffer);
15
+ this.buffer = '';
16
+ return out;
17
+ }
18
+ parse(l) {
19
+ const t = l.trim();
20
+ if (!t.startsWith('{'))
21
+ return [];
22
+ try {
23
+ return this.inner.push(JSON.parse(t));
24
+ }
25
+ catch {
26
+ return [];
27
+ }
28
+ }
29
+ }
30
+ export class StreamSource {
31
+ live = true;
32
+ key;
33
+ label;
34
+ stream;
35
+ format;
36
+ adapter = null;
37
+ pending = '';
38
+ constructor(stream, label = 'stdin', format = 'auto') {
39
+ this.stream = stream;
40
+ this.label = label;
41
+ this.key = `stream:${label}:${Date.now()}`;
42
+ this.format = format;
43
+ }
44
+ async start(emit) {
45
+ this.stream.setEncoding('utf8');
46
+ this.stream.on('data', (chunk) => {
47
+ if (!this.adapter) {
48
+ this.pending += chunk;
49
+ const first = this.pending.split('\n').find((l) => l.trim());
50
+ if (first === undefined || (!this.pending.includes('\n') && this.format === 'auto'))
51
+ return;
52
+ const json = this.format === 'json' || (this.format === 'auto' && first.trim().startsWith('{'));
53
+ this.adapter = json ? new JsonLinesAdapter() : new TextAdapter();
54
+ chunk = this.pending;
55
+ this.pending = '';
56
+ }
57
+ const evs = this.adapter.push(chunk);
58
+ if (evs.length)
59
+ emit(evs);
60
+ });
61
+ this.stream.on('end', () => {
62
+ if (!this.adapter && this.pending) {
63
+ this.adapter = this.pending.trim().startsWith('{') ? new JsonLinesAdapter() : new TextAdapter();
64
+ emit(this.adapter.push(this.pending));
65
+ }
66
+ const evs = this.adapter?.flush() ?? [];
67
+ if (evs.length)
68
+ emit(evs);
69
+ });
70
+ }
71
+ stop() {
72
+ this.stream.pause();
73
+ }
74
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Incremental line reader for files that grow (live transcripts).
3
+ * Reads from a byte offset in chunks, keeps a partial trailing line until it completes,
4
+ * and reacts to both fs.watch notifications and a slow poll (fs.watch is unreliable on some platforms).
5
+ */
6
+ import { open, stat } from 'node:fs/promises';
7
+ import { watch } from 'node:fs';
8
+ const CHUNK = 1 << 20;
9
+ export class LineTailer {
10
+ offset = 0;
11
+ partial = '';
12
+ timer = null;
13
+ watcher = null;
14
+ reading = false;
15
+ again = false;
16
+ stopped = false;
17
+ decoder = new TextDecoder('utf-8');
18
+ path;
19
+ onLines;
20
+ pollMs;
21
+ constructor(path, onLines, pollMs = 500) {
22
+ this.path = path;
23
+ this.onLines = onLines;
24
+ this.pollMs = pollMs;
25
+ }
26
+ /** Read everything currently in the file, then keep following it. */
27
+ async start(follow = true) {
28
+ await this.read();
29
+ if (!follow || this.stopped)
30
+ return;
31
+ try {
32
+ this.watcher = watch(this.path, () => void this.read());
33
+ this.watcher.on('error', () => {
34
+ this.watcher?.close();
35
+ this.watcher = null;
36
+ });
37
+ }
38
+ catch {
39
+ /* polling still works */
40
+ }
41
+ this.timer = setInterval(() => void this.read(), this.pollMs);
42
+ this.timer.unref?.();
43
+ }
44
+ stop() {
45
+ this.stopped = true;
46
+ if (this.timer)
47
+ clearInterval(this.timer);
48
+ this.watcher?.close();
49
+ }
50
+ /** Read new bytes. Safe to call concurrently: overlapping calls coalesce. */
51
+ async read() {
52
+ if (this.stopped)
53
+ return;
54
+ if (this.reading) {
55
+ this.again = true;
56
+ return;
57
+ }
58
+ this.reading = true;
59
+ try {
60
+ do {
61
+ this.again = false;
62
+ await this.readOnce();
63
+ } while (this.again && !this.stopped);
64
+ }
65
+ finally {
66
+ this.reading = false;
67
+ }
68
+ }
69
+ async readOnce() {
70
+ let size;
71
+ try {
72
+ size = (await stat(this.path)).size;
73
+ }
74
+ catch {
75
+ return;
76
+ }
77
+ if (size < this.offset) {
78
+ // truncated or replaced: start over
79
+ this.offset = 0;
80
+ this.partial = '';
81
+ this.decoder = new TextDecoder('utf-8');
82
+ }
83
+ if (size === this.offset)
84
+ return;
85
+ const fh = await open(this.path, 'r');
86
+ try {
87
+ const buf = Buffer.allocUnsafe(CHUNK);
88
+ while (this.offset < size && !this.stopped) {
89
+ const { bytesRead } = await fh.read(buf, 0, Math.min(CHUNK, size - this.offset), this.offset);
90
+ if (bytesRead === 0)
91
+ break;
92
+ this.offset += bytesRead;
93
+ const text = this.partial + this.decoder.decode(buf.subarray(0, bytesRead), { stream: true });
94
+ const lines = text.split('\n');
95
+ this.partial = lines.pop() ?? '';
96
+ const complete = lines.map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l)).filter((l) => l.length > 0);
97
+ if (complete.length)
98
+ this.onLines(complete);
99
+ }
100
+ }
101
+ finally {
102
+ await fh.close();
103
+ }
104
+ }
105
+ }
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@vale-dinap/lucidly",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "See what your AI agent is doing, at a glance.",
5
+ "keywords": [
6
+ "claude",
7
+ "claude-code",
8
+ "ai",
9
+ "agent",
10
+ "coding-agent",
11
+ "transcript",
12
+ "viewer",
13
+ "visualization",
14
+ "observability",
15
+ "cli"
16
+ ],
17
+ "homepage": "https://github.com/vale-dinap/lucidly#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/vale-dinap/lucidly/issues"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/vale-dinap/lucidly.git"
24
+ },
25
+ "author": "Valerio Di Napoli",
26
+ "license": "Apache-2.0",
27
+ "type": "module",
28
+ "bin": {
29
+ "lucidly": "bin/lucidly.js"
30
+ },
31
+ "engines": {
32
+ "node": ">=22.18"
33
+ },
34
+ "scripts": {
35
+ "start": "node src/cli.ts",
36
+ "test": "node --test \"test/**/*.test.ts\"",
37
+ "typecheck": "tsc --noEmit",
38
+ "build": "tsc -p tsconfig.build.json",
39
+ "prepack": "npm run build"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^24.0.0",
43
+ "typescript": "^5.9.0"
44
+ },
45
+ "files": [
46
+ "bin/",
47
+ "dist/",
48
+ "web/",
49
+ "NOTICE"
50
+ ]
51
+ }