claude-session-skill 1.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,983 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+ var __defProp = Object.defineProperty;
4
+ var __export = (target, all) => {
5
+ for (var name in all)
6
+ __defProp(target, name, {
7
+ get: all[name],
8
+ enumerable: true,
9
+ configurable: true,
10
+ set: (newValue) => all[name] = () => newValue
11
+ });
12
+ };
13
+
14
+ // lib/search.ts
15
+ var exports_search = {};
16
+ __export(exports_search, {
17
+ searchSessions: () => searchSessions
18
+ });
19
+ function searchSessions(sessions, query) {
20
+ const now = Date.now();
21
+ const ONE_DAY = 86400000;
22
+ const ONE_WEEK = 7 * ONE_DAY;
23
+ const phrases = [];
24
+ const stripped = query.replace(/"([^"]+)"/g, (_, phrase) => {
25
+ phrases.push(phrase.toLowerCase());
26
+ return "";
27
+ });
28
+ const tokens = stripped.toLowerCase().split(/\s+/).filter((t) => t.length > 1);
29
+ if (tokens.length === 0 && phrases.length === 0)
30
+ return sessions;
31
+ const scored = [];
32
+ for (const session of sessions) {
33
+ let score = 0;
34
+ const nameLower = (session.name || "").toLowerCase();
35
+ const topicLower = (session.topic || session.firstMessage || "").toLowerCase();
36
+ const firstLower = session.firstMessage.toLowerCase();
37
+ const lastLower = session.lastMessage.toLowerCase();
38
+ const allLower = session.allMessages.toLowerCase();
39
+ const projectLower = session.project.toLowerCase();
40
+ const cwdLower = session.cwd.toLowerCase();
41
+ for (const token of tokens) {
42
+ if (nameLower.includes(token))
43
+ score += 15;
44
+ if (topicLower.includes(token))
45
+ score += 12;
46
+ if (firstLower.includes(token))
47
+ score += 10;
48
+ if (lastLower.includes(token))
49
+ score += 5;
50
+ if (allLower.includes(token))
51
+ score += 2;
52
+ if (projectLower.includes(token) || cwdLower.includes(token))
53
+ score += 3;
54
+ }
55
+ for (const phrase of phrases) {
56
+ if (nameLower.includes(phrase))
57
+ score += 30;
58
+ if (topicLower.includes(phrase))
59
+ score += 24;
60
+ if (firstLower.includes(phrase))
61
+ score += 20;
62
+ if (lastLower.includes(phrase))
63
+ score += 10;
64
+ if (allLower.includes(phrase))
65
+ score += 4;
66
+ if (projectLower.includes(phrase) || cwdLower.includes(phrase))
67
+ score += 6;
68
+ }
69
+ if (score === 0)
70
+ continue;
71
+ const age = now - session.lastTimestamp;
72
+ if (age < ONE_DAY) {
73
+ score *= 1.5;
74
+ } else if (age < ONE_WEEK) {
75
+ score *= 1.2;
76
+ }
77
+ scored.push({ session, score });
78
+ }
79
+ scored.sort((a, b) => b.score - a.score || b.session.lastTimestamp - a.session.lastTimestamp);
80
+ return scored.map((s) => s.session);
81
+ }
82
+
83
+ // lib/indexer.ts
84
+ import { readdir, stat, mkdir, rename, unlink, readFile, writeFile, open } from "fs/promises";
85
+ import { join, basename } from "path";
86
+ var HOME = process.env.HOME;
87
+ if (!HOME) {
88
+ throw new Error("HOME environment variable is not set");
89
+ }
90
+ var SUMMARY_MODEL = process.env.SESSION_SUMMARY_MODEL || "claude-haiku-4-5-20251001";
91
+ var DEBUG = Boolean(process.env.SESSION_DEBUG);
92
+ function debug(msg) {
93
+ if (DEBUG)
94
+ process.stderr.write(`[session] ${msg}
95
+ `);
96
+ }
97
+ var CLAUDE_DIR = join(HOME, ".claude");
98
+ var HISTORY_FILE = join(CLAUDE_DIR, "history.jsonl");
99
+ var PROJECTS_DIR = join(CLAUDE_DIR, "projects");
100
+ var DATA_DIR = join(CLAUDE_DIR, "skills", "session", "data");
101
+ var CACHE_FILE = join(DATA_DIR, "index.json");
102
+ var SUMMARIES_FILE = join(DATA_DIR, "summaries.json");
103
+ var NAMES_FILE = join(DATA_DIR, "names.json");
104
+ var dataDirReady = false;
105
+ async function ensureDataDir() {
106
+ if (dataDirReady)
107
+ return;
108
+ await mkdir(DATA_DIR, { recursive: true });
109
+ dataDirReady = true;
110
+ }
111
+ function shortProject(project) {
112
+ if (!project || project === HOME)
113
+ return "~";
114
+ const p = project.startsWith(HOME) ? project.slice(HOME.length + 1) : project;
115
+ return p || "~";
116
+ }
117
+ function decodeProjectDir(dirName) {
118
+ return "/" + dirName.replace(/^-/, "").replace(/-/g, "/");
119
+ }
120
+ function isTopical(msg) {
121
+ if (msg.length < 5)
122
+ return false;
123
+ if (msg.startsWith("/"))
124
+ return false;
125
+ if (msg.startsWith("<"))
126
+ return false;
127
+ if (msg.startsWith("[MEMORY]"))
128
+ return false;
129
+ return true;
130
+ }
131
+ function isGarbageSummary(s) {
132
+ if (!s)
133
+ return true;
134
+ const lower = s.toLowerCase();
135
+ return lower.startsWith("i don't have") || lower.startsWith("i don't see") || lower.startsWith("i'm afraid") || lower.startsWith("i cannot") || lower.startsWith("(80 chars max)") || lower.includes("don't have a transcript") || lower.includes("don't have access");
136
+ }
137
+ function resolveSession(sessions, id) {
138
+ const matches = sessions.filter((s) => s.id === id || s.id.startsWith(id));
139
+ if (matches.length === 0) {
140
+ return { ok: false, error: `No session found matching "${id}"` };
141
+ }
142
+ const exact = matches.find((s) => s.id === id);
143
+ if (exact)
144
+ return { ok: true, match: exact };
145
+ if (matches.length > 1) {
146
+ return { ok: false, error: `Ambiguous prefix "${id}" matches ${matches.length} sessions. Provide more characters.` };
147
+ }
148
+ return { ok: true, match: matches[0] };
149
+ }
150
+ async function getHistoryMtime() {
151
+ try {
152
+ return (await stat(HISTORY_FILE)).mtimeMs;
153
+ } catch (e) {
154
+ debug(`Cannot stat history file: ${e.message}`);
155
+ return 0;
156
+ }
157
+ }
158
+ async function getSessionFilesFingerprint() {
159
+ let count = 0;
160
+ let maxMtime = 0;
161
+ try {
162
+ const dirs = await readdir(PROJECTS_DIR);
163
+ for (const dir of dirs) {
164
+ try {
165
+ const dirPath = join(PROJECTS_DIR, dir);
166
+ const files = await readdir(dirPath);
167
+ for (const f of files) {
168
+ if (!f.endsWith(".jsonl"))
169
+ continue;
170
+ count++;
171
+ try {
172
+ const info = await stat(join(dirPath, f));
173
+ if (info.mtimeMs > maxMtime)
174
+ maxMtime = info.mtimeMs;
175
+ } catch (e) {
176
+ debug(`Cannot stat ${dir}/${f}: ${e.message}`);
177
+ }
178
+ }
179
+ } catch (e) {
180
+ debug(`Cannot read project dir ${dir}: ${e.message}`);
181
+ }
182
+ }
183
+ } catch (e) {
184
+ debug(`Cannot read projects dir: ${e.message}`);
185
+ }
186
+ return { count, maxMtime };
187
+ }
188
+ async function loadCache() {
189
+ try {
190
+ const raw = JSON.parse(await readFile(CACHE_FILE, "utf-8"));
191
+ if (!raw?.meta || !Array.isArray(raw?.sessions))
192
+ return null;
193
+ return raw;
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+ async function atomicWrite(path, data) {
199
+ const tmp = path + ".tmp";
200
+ await writeFile(tmp, data);
201
+ await rename(tmp, path);
202
+ }
203
+ async function saveCache(data) {
204
+ await ensureDataDir();
205
+ await atomicWrite(CACHE_FILE, JSON.stringify(data));
206
+ }
207
+ async function loadSummaries() {
208
+ try {
209
+ return JSON.parse(await readFile(SUMMARIES_FILE, "utf-8"));
210
+ } catch {
211
+ return {};
212
+ }
213
+ }
214
+ async function saveSummaries(summaries) {
215
+ await ensureDataDir();
216
+ await atomicWrite(SUMMARIES_FILE, JSON.stringify(summaries));
217
+ }
218
+ var _namesCache = null;
219
+ var _namesMtime = 0;
220
+ async function loadNames() {
221
+ try {
222
+ const currentMtime = (await stat(NAMES_FILE)).mtimeMs;
223
+ if (_namesCache && currentMtime === _namesMtime)
224
+ return { ..._namesCache };
225
+ const raw = JSON.parse(await readFile(NAMES_FILE, "utf-8"));
226
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
227
+ return {};
228
+ const cleaned = {};
229
+ for (const [k, v] of Object.entries(raw)) {
230
+ if (typeof v === "string")
231
+ cleaned[k] = v;
232
+ }
233
+ _namesCache = cleaned;
234
+ _namesMtime = currentMtime;
235
+ return { ...cleaned };
236
+ } catch {
237
+ return {};
238
+ }
239
+ }
240
+ async function saveNames(names) {
241
+ await ensureDataDir();
242
+ await atomicWrite(NAMES_FILE, JSON.stringify(names));
243
+ _namesCache = null;
244
+ _namesMtime = 0;
245
+ }
246
+ var LOCK_FILE = NAMES_FILE + ".lock";
247
+ var LOCK_TIMEOUT = 5000;
248
+ async function acquireLock() {
249
+ const start = Date.now();
250
+ while (Date.now() - start < LOCK_TIMEOUT) {
251
+ try {
252
+ const fh = await open(LOCK_FILE, "wx");
253
+ await fh.write(String(process.pid));
254
+ await fh.close();
255
+ return true;
256
+ } catch {
257
+ try {
258
+ const lockStat = await stat(LOCK_FILE);
259
+ if (Date.now() - lockStat.mtimeMs > 1e4) {
260
+ await unlink(LOCK_FILE).catch(() => {});
261
+ continue;
262
+ }
263
+ } catch {}
264
+ await new Promise((r) => setTimeout(r, 50));
265
+ }
266
+ }
267
+ return false;
268
+ }
269
+ async function releaseLock() {
270
+ await unlink(LOCK_FILE).catch(() => {});
271
+ }
272
+ async function nameSession(sessionId, name) {
273
+ const trimmed = name.trim();
274
+ if (!trimmed)
275
+ return { ok: false, error: "Name cannot be empty." };
276
+ if (trimmed.length > 50)
277
+ return { ok: false, error: `Name too long (${trimmed.length} chars, max 50).` };
278
+ const cache = await loadCache();
279
+ if (!cache)
280
+ return { ok: false, error: "No index found. Run `/session list` first." };
281
+ const resolved = resolveSession(cache.sessions, sessionId);
282
+ if (!resolved.ok)
283
+ return { ok: false, error: resolved.error };
284
+ const match = resolved.match;
285
+ const locked = await acquireLock();
286
+ if (!locked)
287
+ return { ok: false, error: "Could not acquire lock. Another naming operation may be in progress." };
288
+ try {
289
+ const names = await loadNames();
290
+ names[match.id] = trimmed;
291
+ await saveNames(names);
292
+ return { ok: true, fullId: match.id };
293
+ } finally {
294
+ await releaseLock();
295
+ }
296
+ }
297
+ async function clearSessionName(sessionId) {
298
+ const cache = await loadCache();
299
+ if (!cache)
300
+ return { ok: false, error: "No index found. Run `/session list` first." };
301
+ const resolved = resolveSession(cache.sessions, sessionId);
302
+ if (!resolved.ok)
303
+ return { ok: false, error: resolved.error };
304
+ const match = resolved.match;
305
+ const locked = await acquireLock();
306
+ if (!locked)
307
+ return { ok: false, error: "Could not acquire lock." };
308
+ try {
309
+ const names = await loadNames();
310
+ if (!names[match.id])
311
+ return { ok: false, error: `Session ${match.id.slice(0, 8)}... has no name to clear.` };
312
+ delete names[match.id];
313
+ await saveNames(names);
314
+ return { ok: true, fullId: match.id };
315
+ } finally {
316
+ await releaseLock();
317
+ }
318
+ }
319
+ function extractConversation(text, maxMessages = 40) {
320
+ const lines = text.split(`
321
+ `).filter(Boolean);
322
+ const allMessages = [];
323
+ for (const line of lines) {
324
+ try {
325
+ const entry = JSON.parse(line);
326
+ if (entry.type === "user" && !entry.isMeta && entry.message?.role === "user" && typeof entry.message.content === "string") {
327
+ const content = entry.message.content;
328
+ if (isTopical(content)) {
329
+ allMessages.push(`USER: ${content.slice(0, 300)}`);
330
+ }
331
+ }
332
+ if (entry.type === "assistant" && entry.message?.role === "assistant") {
333
+ const content = entry.message.content;
334
+ if (typeof content === "string" && content.length > 20) {
335
+ allMessages.push(`ASSISTANT: ${content.slice(0, 400)}`);
336
+ } else if (Array.isArray(content)) {
337
+ for (const block of content) {
338
+ if (block.type === "text" && block.text && block.text.length > 20) {
339
+ allMessages.push(`ASSISTANT: ${block.text.slice(0, 400)}`);
340
+ break;
341
+ }
342
+ }
343
+ }
344
+ }
345
+ } catch (e) {
346
+ debug(`Malformed JSONL line: ${e.message}`);
347
+ }
348
+ }
349
+ if (allMessages.length <= maxMessages)
350
+ return allMessages;
351
+ return allMessages.slice(-maxMessages);
352
+ }
353
+ async function summarizeSession(project, conversation) {
354
+ const apiKey = process.env.ANTHROPIC_API_KEY;
355
+ if (!apiKey) {
356
+ debug("ANTHROPIC_API_KEY not set, skipping summarization");
357
+ return "";
358
+ }
359
+ const transcript = conversation.join(`
360
+ `).slice(0, 6000);
361
+ if (transcript.length < 50)
362
+ return "";
363
+ try {
364
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
365
+ method: "POST",
366
+ headers: {
367
+ "x-api-key": apiKey,
368
+ "anthropic-version": "2023-06-01",
369
+ "content-type": "application/json"
370
+ },
371
+ body: JSON.stringify({
372
+ model: SUMMARY_MODEL,
373
+ max_tokens: 250,
374
+ messages: [
375
+ {
376
+ role: "user",
377
+ content: `You are summarizing a Claude Code session transcript. Write exactly 5 bullet points (using "- " prefix) describing what was DONE in this session. Focus on concrete outcomes: what was built, fixed, configured, discussed, or decided. Each bullet should be 8-15 words. No intro text, no commentary — ONLY the 5 bullets.
378
+
379
+ If the transcript is mostly commands like /session or slash commands with no real work, write "- Session management only (no substantive work)" as the single bullet.
380
+
381
+ Project: ${project}
382
+
383
+ Transcript:
384
+ ${transcript}`
385
+ }
386
+ ]
387
+ })
388
+ });
389
+ if (!res.ok) {
390
+ if (res.status === 429) {
391
+ process.stderr.write(`[session] Rate limited by Anthropic API, slowing down
392
+ `);
393
+ } else if (res.status === 401) {
394
+ process.stderr.write(`[session] Invalid ANTHROPIC_API_KEY
395
+ `);
396
+ } else {
397
+ debug(`API returned ${res.status}: ${res.statusText}`);
398
+ }
399
+ return "";
400
+ }
401
+ const data = await res.json();
402
+ let text = data.content?.[0]?.text?.trim() || "";
403
+ text = text.replace(/^\*\*(.+)\*\*$/gm, "$1");
404
+ text = text.replace(/^#+\s*/gm, "");
405
+ text = text.replace(/```[\s\S]*?```/g, "");
406
+ text = text.replace(/\*\*/g, "");
407
+ text = text.replace(/^[•●◦]\s*/gm, "- ");
408
+ text = text.replace(/^\d+\.\s+/gm, "- ");
409
+ const bullets = text.split(`
410
+ `).map((l) => l.trim()).filter((l) => l.startsWith("- ")).slice(0, 5);
411
+ return bullets.join(`
412
+ `) || "";
413
+ } catch (e) {
414
+ debug(`API call failed: ${e.message}`);
415
+ return "";
416
+ }
417
+ }
418
+ async function parseHistory() {
419
+ const sessions = new Map;
420
+ try {
421
+ const raw = await readFile(HISTORY_FILE, "utf-8");
422
+ const lines = raw.split(`
423
+ `).filter(Boolean);
424
+ for (const line of lines) {
425
+ try {
426
+ const entry = JSON.parse(line);
427
+ if (!entry.sessionId || !entry.display)
428
+ continue;
429
+ const id = entry.sessionId;
430
+ const msg = entry.display.trim();
431
+ if (!msg)
432
+ continue;
433
+ const topical = isTopical(msg);
434
+ const existing = sessions.get(id);
435
+ if (existing) {
436
+ existing.messageCount++;
437
+ if (topical)
438
+ existing.lastMessage = msg;
439
+ if (topical && existing.firstMessage === existing.id) {
440
+ existing.firstMessage = msg;
441
+ }
442
+ existing.lastTimestamp = entry.timestamp;
443
+ if (existing.allMessages.length < 2000) {
444
+ existing.allMessages += " " + msg;
445
+ }
446
+ } else {
447
+ sessions.set(id, {
448
+ id,
449
+ name: "",
450
+ project: shortProject(entry.project || ""),
451
+ projectDir: "",
452
+ topic: "",
453
+ firstMessage: topical ? msg : id,
454
+ lastMessage: topical ? msg : "",
455
+ allMessages: msg,
456
+ messageCount: 1,
457
+ firstTimestamp: entry.timestamp,
458
+ lastTimestamp: entry.timestamp,
459
+ cwd: entry.project || "",
460
+ gitBranch: ""
461
+ });
462
+ }
463
+ } catch (e) {
464
+ debug(`Malformed history line: ${e.message}`);
465
+ }
466
+ }
467
+ } catch (e) {
468
+ debug(`Cannot read history file: ${e.message}`);
469
+ }
470
+ for (const [, session] of sessions) {
471
+ if (session.firstMessage === session.id) {
472
+ session.firstMessage = session.allMessages.trim().slice(0, 100) || "(no messages)";
473
+ }
474
+ if (!session.lastMessage) {
475
+ session.lastMessage = session.firstMessage;
476
+ }
477
+ }
478
+ return sessions;
479
+ }
480
+ async function enrichFromFiles(sessions, conversationMap) {
481
+ try {
482
+ const dirs = await readdir(PROJECTS_DIR);
483
+ for (const dir of dirs) {
484
+ const dirPath = join(PROJECTS_DIR, dir);
485
+ let files;
486
+ try {
487
+ files = (await readdir(dirPath)).filter((f) => f.endsWith(".jsonl"));
488
+ } catch (e) {
489
+ debug(`Cannot read ${dir}: ${e.message}`);
490
+ continue;
491
+ }
492
+ for (const file of files) {
493
+ const sessionId = basename(file, ".jsonl");
494
+ const filePath = join(dirPath, file);
495
+ try {
496
+ const fileInfo = await stat(filePath);
497
+ const fileSize = fileInfo.size;
498
+ let text;
499
+ if (fileSize <= 60000) {
500
+ text = await readFile(filePath, "utf-8");
501
+ } else {
502
+ const allText = await readFile(filePath, "utf-8");
503
+ const first = allText.slice(0, 1e4);
504
+ const last = allText.slice(-50000);
505
+ text = first + `
506
+ ` + last;
507
+ }
508
+ const conversation = extractConversation(text, 40);
509
+ if (conversation.length > 0) {
510
+ conversationMap.set(sessionId, conversation);
511
+ }
512
+ const lines = text.split(`
513
+ `).filter(Boolean);
514
+ let cwd = "";
515
+ let gitBranch = "";
516
+ const userMessages = [];
517
+ let firstTs = Infinity;
518
+ let lastTs = 0;
519
+ for (const line of lines) {
520
+ try {
521
+ const entry = JSON.parse(line);
522
+ if (entry.type === "user" && entry.cwd) {
523
+ if (!cwd)
524
+ cwd = entry.cwd;
525
+ if (entry.gitBranch && entry.gitBranch !== "HEAD") {
526
+ gitBranch = entry.gitBranch;
527
+ }
528
+ }
529
+ if (entry.timestamp) {
530
+ const ts = typeof entry.timestamp === "string" ? new Date(entry.timestamp).getTime() : entry.timestamp;
531
+ if (ts < firstTs)
532
+ firstTs = ts;
533
+ if (ts > lastTs)
534
+ lastTs = ts;
535
+ }
536
+ if (entry.type === "user" && !entry.isMeta && entry.message?.role === "user" && typeof entry.message.content === "string") {
537
+ if (isTopical(entry.message.content)) {
538
+ userMessages.push(entry.message.content.slice(0, 200));
539
+ }
540
+ }
541
+ } catch (e) {
542
+ debug(`Malformed session line in ${sessionId}: ${e.message}`);
543
+ }
544
+ }
545
+ const existing = sessions.get(sessionId);
546
+ if (existing) {
547
+ if (cwd)
548
+ existing.cwd = cwd;
549
+ if (gitBranch)
550
+ existing.gitBranch = gitBranch;
551
+ existing.projectDir = dir;
552
+ if (!existing.project || existing.project === "~") {
553
+ existing.project = shortProject(cwd || decodeProjectDir(dir));
554
+ }
555
+ } else if (userMessages.length > 0) {
556
+ sessions.set(sessionId, {
557
+ id: sessionId,
558
+ name: "",
559
+ project: shortProject(cwd || decodeProjectDir(dir)),
560
+ projectDir: dir,
561
+ topic: "",
562
+ firstMessage: userMessages[0] || "",
563
+ lastMessage: userMessages[userMessages.length - 1] || "",
564
+ allMessages: userMessages.join(" ").slice(0, 2000),
565
+ messageCount: userMessages.length,
566
+ firstTimestamp: firstTs === Infinity ? 0 : firstTs,
567
+ lastTimestamp: lastTs,
568
+ cwd,
569
+ gitBranch
570
+ });
571
+ }
572
+ } catch (e) {
573
+ debug(`Cannot process session ${sessionId}: ${e.message}`);
574
+ }
575
+ }
576
+ }
577
+ } catch (e) {
578
+ debug(`Cannot read projects dir: ${e.message}`);
579
+ }
580
+ }
581
+ async function generateSummaries(sessions, conversationMap, existingSummaries) {
582
+ const summaries = { ...existingSummaries };
583
+ for (const [id, summary] of Object.entries(summaries)) {
584
+ if (isGarbageSummary(summary)) {
585
+ delete summaries[id];
586
+ }
587
+ }
588
+ const needsSummary = sessions.filter((s) => !summaries[s.id] && conversationMap.has(s.id));
589
+ if (needsSummary.length === 0)
590
+ return summaries;
591
+ if (!process.env.ANTHROPIC_API_KEY) {
592
+ process.stderr.write(`[session] ${needsSummary.length} sessions need summaries but ANTHROPIC_API_KEY is not set
593
+ `);
594
+ return summaries;
595
+ }
596
+ const total = needsSummary.length;
597
+ process.stderr.write(`Summarizing ${total} sessions with ${SUMMARY_MODEL}...
598
+ `);
599
+ const BATCH_SIZE = 10;
600
+ let done = 0;
601
+ for (let i = 0;i < needsSummary.length; i += BATCH_SIZE) {
602
+ const batch = needsSummary.slice(i, i + BATCH_SIZE);
603
+ const results = await Promise.all(batch.map(async (session) => {
604
+ const conversation = conversationMap.get(session.id) || [];
605
+ const summary = await summarizeSession(session.project, conversation);
606
+ return { id: session.id, summary };
607
+ }));
608
+ for (const { id, summary } of results) {
609
+ if (summary && !isGarbageSummary(summary)) {
610
+ summaries[id] = summary;
611
+ }
612
+ }
613
+ done += batch.length;
614
+ process.stderr.write(` ${done}/${total}
615
+ `);
616
+ }
617
+ return summaries;
618
+ }
619
+ async function buildIndex(force = false) {
620
+ const [historyMtime, fingerprint] = await Promise.all([
621
+ getHistoryMtime(),
622
+ getSessionFilesFingerprint()
623
+ ]);
624
+ if (!force) {
625
+ const cache = await loadCache();
626
+ if (cache && cache.meta.historyMtime === historyMtime && cache.meta.sessionFileCount === fingerprint.count && cache.meta.maxSessionMtime === fingerprint.maxMtime) {
627
+ const names2 = await loadNames();
628
+ for (const s of cache.sessions) {
629
+ s.name = names2[s.id] || "";
630
+ }
631
+ return cache.sessions;
632
+ }
633
+ }
634
+ const existingSummaries = await loadSummaries();
635
+ const sessions = await parseHistory();
636
+ const conversationMap = new Map;
637
+ await enrichFromFiles(sessions, conversationMap);
638
+ const entries = Array.from(sessions.values()).filter((s) => s.messageCount > 0 && s.firstMessage.length > 0);
639
+ entries.sort((a, b) => b.lastTimestamp - a.lastTimestamp);
640
+ const summaries = await generateSummaries(entries, conversationMap, existingSummaries);
641
+ await saveSummaries(summaries);
642
+ const names = await loadNames();
643
+ for (const entry of entries) {
644
+ entry.topic = summaries[entry.id] || "";
645
+ entry.name = names[entry.id] || "";
646
+ }
647
+ await saveCache({
648
+ meta: {
649
+ historyMtime,
650
+ sessionFileCount: fingerprint.count,
651
+ maxSessionMtime: fingerprint.maxMtime,
652
+ builtAt: Date.now()
653
+ },
654
+ sessions: entries
655
+ });
656
+ return entries;
657
+ }
658
+ // lib/indexer.ts
659
+ import { join as join2, basename as basename2 } from "path";
660
+ var HOME2 = process.env.HOME;
661
+ if (!HOME2) {
662
+ throw new Error("HOME environment variable is not set");
663
+ }
664
+ var SUMMARY_MODEL2 = process.env.SESSION_SUMMARY_MODEL || "claude-haiku-4-5-20251001";
665
+ var DEBUG2 = Boolean(process.env.SESSION_DEBUG);
666
+ var CLAUDE_DIR2 = join2(HOME2, ".claude");
667
+ var HISTORY_FILE2 = join2(CLAUDE_DIR2, "history.jsonl");
668
+ var PROJECTS_DIR2 = join2(CLAUDE_DIR2, "projects");
669
+ var DATA_DIR2 = join2(CLAUDE_DIR2, "skills", "session", "data");
670
+ var CACHE_FILE2 = join2(DATA_DIR2, "index.json");
671
+ var SUMMARIES_FILE2 = join2(DATA_DIR2, "summaries.json");
672
+ var NAMES_FILE2 = join2(DATA_DIR2, "names.json");
673
+ function isGarbageSummary2(s) {
674
+ if (!s)
675
+ return true;
676
+ const lower = s.toLowerCase();
677
+ return lower.startsWith("i don't have") || lower.startsWith("i don't see") || lower.startsWith("i'm afraid") || lower.startsWith("i cannot") || lower.startsWith("(80 chars max)") || lower.includes("don't have a transcript") || lower.includes("don't have access");
678
+ }
679
+ var LOCK_FILE2 = NAMES_FILE2 + ".lock";
680
+
681
+ // lib/format.ts
682
+ function truncate(s, max) {
683
+ if (s.length <= max)
684
+ return s;
685
+ const chars = Array.from(s);
686
+ if (chars.length <= max)
687
+ return s;
688
+ return chars.slice(0, max - 3).join("") + "...";
689
+ }
690
+ function formatDate(ts) {
691
+ if (!ts)
692
+ return "unknown";
693
+ const d = new Date(ts);
694
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
695
+ const month = months[d.getMonth()];
696
+ const day = d.getDate();
697
+ const hours = d.getHours();
698
+ const mins = d.getMinutes().toString().padStart(2, "0");
699
+ const ampm = hours >= 12 ? "PM" : "AM";
700
+ const h = hours % 12 || 12;
701
+ return `${month} ${day}, ${h}:${mins} ${ampm}`;
702
+ }
703
+ function displayLine(s) {
704
+ if (s.topic && !isGarbageSummary2(s.topic)) {
705
+ const lines = s.topic.split(`
706
+ `).filter((l) => l.trim().length > 0);
707
+ const firstBullet = lines.find((l) => l.startsWith("- "));
708
+ if (firstBullet)
709
+ return firstBullet;
710
+ return lines[0] || s.topic;
711
+ }
712
+ if (s.lastMessage)
713
+ return s.lastMessage;
714
+ return s.firstMessage || "(no messages)";
715
+ }
716
+ function makeLabel(s, summary) {
717
+ if (!s.name)
718
+ return summary;
719
+ const clean = summary.startsWith("- ") ? summary.slice(2) : summary;
720
+ return `${s.name} — ${clean}`;
721
+ }
722
+ function fullSummary(s) {
723
+ if (s.topic && !isGarbageSummary2(s.topic))
724
+ return s.topic;
725
+ if (s.lastMessage)
726
+ return s.lastMessage;
727
+ return s.firstMessage || "(no messages)";
728
+ }
729
+ function formatSessionList(sessions, showAll) {
730
+ const list = showAll ? sessions : sessions.slice(0, 20);
731
+ const lines = [];
732
+ if (!showAll && sessions.length > 20) {
733
+ lines.push(`${sessions.length} sessions (showing 20, use --all for all)
734
+ `);
735
+ } else {
736
+ lines.push(`${list.length} session(s)
737
+ `);
738
+ }
739
+ for (const s of list) {
740
+ const date = formatDate(s.lastTimestamp);
741
+ const msgs = `${s.messageCount} msgs`;
742
+ const summary = displayLine(s);
743
+ const label = makeLabel(s, summary);
744
+ const lastMsg = s.lastMessage && s.lastMessage !== summary ? s.lastMessage : "";
745
+ lines.push(`${s.id} ${msgs.padStart(7)} | ${date}`);
746
+ lines.push(` ${truncate(label, 100)}`);
747
+ if (lastMsg) {
748
+ lines.push(` Left off: "${truncate(lastMsg, 90)}"`);
749
+ }
750
+ lines.push("");
751
+ }
752
+ return lines.join(`
753
+ `);
754
+ }
755
+ function formatSearchResults(sessions, query) {
756
+ if (sessions.length === 0) {
757
+ return `No sessions found matching "${query}"`;
758
+ }
759
+ const lines = [];
760
+ const shown = sessions.slice(0, 15);
761
+ lines.push(`${sessions.length} session(s) matching "${query}"${sessions.length > 15 ? " (showing 15)" : ""}:
762
+ `);
763
+ for (const s of shown) {
764
+ const date = formatDate(s.lastTimestamp);
765
+ const msgs = `${s.messageCount} msgs`;
766
+ const summary = displayLine(s);
767
+ const label = makeLabel(s, summary);
768
+ const lastMsg = s.lastMessage && s.lastMessage !== summary ? s.lastMessage : "";
769
+ lines.push(`${s.id} ${msgs.padStart(7)} | ${date}`);
770
+ lines.push(` ${truncate(label, 100)}`);
771
+ if (lastMsg) {
772
+ lines.push(` Left off: "${truncate(lastMsg, 90)}"`);
773
+ }
774
+ if (s.gitBranch) {
775
+ lines.push(` Branch: ${s.gitBranch}`);
776
+ }
777
+ lines.push("");
778
+ }
779
+ return lines.join(`
780
+ `);
781
+ }
782
+ function formatSessionDetail(session) {
783
+ const s = session;
784
+ const lines = [];
785
+ lines.push(`${s.id}
786
+ `);
787
+ if (s.name) {
788
+ lines.push(`Name: ${s.name}`);
789
+ }
790
+ lines.push(`Project: ${s.project}`);
791
+ lines.push(`CWD: ${s.cwd}`);
792
+ if (s.gitBranch) {
793
+ lines.push(`Branch: ${s.gitBranch}`);
794
+ }
795
+ lines.push(`Messages: ${s.messageCount}`);
796
+ lines.push(`Started: ${formatDate(s.firstTimestamp)}`);
797
+ lines.push(`Last: ${formatDate(s.lastTimestamp)}`);
798
+ lines.push("");
799
+ lines.push(`What was done:`);
800
+ lines.push(fullSummary(s));
801
+ lines.push("");
802
+ if (s.lastMessage) {
803
+ lines.push(`Left off: "${s.lastMessage.slice(0, 300)}"`);
804
+ lines.push("");
805
+ }
806
+ return lines.join(`
807
+ `);
808
+ }
809
+ function formatStats(sessions) {
810
+ const byProject = new Map;
811
+ for (const s of sessions) {
812
+ const key = s.project || "~";
813
+ const existing = byProject.get(key);
814
+ if (existing) {
815
+ existing.count++;
816
+ existing.messages += s.messageCount;
817
+ if (s.lastTimestamp > existing.lastActivity) {
818
+ existing.lastActivity = s.lastTimestamp;
819
+ }
820
+ } else {
821
+ byProject.set(key, {
822
+ count: 1,
823
+ messages: s.messageCount,
824
+ lastActivity: s.lastTimestamp
825
+ });
826
+ }
827
+ }
828
+ const sorted = Array.from(byProject.entries()).sort((a, b) => b[1].count - a[1].count);
829
+ const lines = [];
830
+ lines.push(`${sessions.length} sessions across ${sorted.length} projects
831
+ `);
832
+ lines.push(` ${"Project".padEnd(30)} ${"Sessions".padStart(8)} ${"Messages".padStart(8)} Last Activity`);
833
+ lines.push(` ${"-".repeat(30)} ${"-".repeat(8)} ${"-".repeat(8)} ${"-".repeat(18)}`);
834
+ for (const [project, data] of sorted) {
835
+ const p = truncate(project, 30).padEnd(30);
836
+ lines.push(` ${p} ${data.count.toString().padStart(8)} ${data.messages.toString().padStart(8)} ${formatDate(data.lastActivity)}`);
837
+ }
838
+ return lines.join(`
839
+ `);
840
+ }
841
+
842
+ // session.ts
843
+ var args = process.argv.slice(2);
844
+ var command = args[0] || "help";
845
+ async function main() {
846
+ switch (command) {
847
+ case "list": {
848
+ const showAll = args.includes("--all");
849
+ const sessions = await buildIndex();
850
+ console.log(formatSessionList(sessions, showAll));
851
+ break;
852
+ }
853
+ case "show": {
854
+ const partial = args[1];
855
+ if (!partial) {
856
+ console.error("Usage: session show <session-id-or-prefix>");
857
+ process.exit(1);
858
+ }
859
+ const sessions = await buildIndex();
860
+ const resolved = resolveSession(sessions, partial);
861
+ if (!resolved.ok) {
862
+ console.error(resolved.error);
863
+ process.exit(1);
864
+ }
865
+ console.log(formatSessionDetail(resolved.match));
866
+ break;
867
+ }
868
+ case "rebuild": {
869
+ const t0 = performance.now();
870
+ const sessions = await buildIndex(true);
871
+ const elapsed = Math.round(performance.now() - t0);
872
+ console.log(`Index rebuilt: ${sessions.length} sessions in ${elapsed}ms`);
873
+ break;
874
+ }
875
+ case "stats": {
876
+ const sessions = await buildIndex();
877
+ console.log(formatStats(sessions));
878
+ break;
879
+ }
880
+ case "name": {
881
+ const rest = args.slice(1);
882
+ if (rest.length === 0) {
883
+ console.error(`Usage: session name <name>
884
+ session name <id> <name>`);
885
+ process.exit(1);
886
+ }
887
+ const first = rest[0];
888
+ const looksLikeId = /^[0-9a-f]{8}(-[0-9a-f]{4}(-[0-9a-f]{4}(-[0-9a-f]{4}(-[0-9a-f]{12})?)?)?)?$/i.test(first);
889
+ let sessionId;
890
+ let sessionName;
891
+ if (looksLikeId && rest.length > 1) {
892
+ sessionId = first;
893
+ sessionName = rest.slice(1).join(" ");
894
+ } else {
895
+ const sessions = await buildIndex();
896
+ if (sessions.length === 0) {
897
+ console.error("No sessions found.");
898
+ process.exit(1);
899
+ }
900
+ sessionId = sessions[0].id;
901
+ sessionName = rest.join(" ");
902
+ }
903
+ const result = await nameSession(sessionId, sessionName);
904
+ if (!result.ok) {
905
+ console.error(result.error);
906
+ process.exit(1);
907
+ }
908
+ console.log(`Named session ${(result.fullId ?? "").slice(0, 8)}... \u2192 "${sessionName.trim()}"`);
909
+ break;
910
+ }
911
+ case "unname":
912
+ case "clear-name": {
913
+ const rest = args.slice(1);
914
+ let sessionId;
915
+ if (rest.length === 0) {
916
+ const sessions = await buildIndex();
917
+ if (sessions.length === 0) {
918
+ console.error("No sessions found.");
919
+ process.exit(1);
920
+ }
921
+ sessionId = sessions[0].id;
922
+ } else {
923
+ const first = rest[0];
924
+ const looksLikeId = /^[0-9a-f]{8}(-[0-9a-f]{4}(-[0-9a-f]{4}(-[0-9a-f]{4}(-[0-9a-f]{12})?)?)?)?$/i.test(first);
925
+ if (looksLikeId) {
926
+ sessionId = first;
927
+ } else {
928
+ const sessions = await buildIndex();
929
+ const { searchSessions: search } = await Promise.resolve().then(() => exports_search);
930
+ const results = search(sessions, rest.join(" "));
931
+ if (results.length === 0) {
932
+ console.error(`No session found matching "${rest.join(" ")}"`);
933
+ process.exit(1);
934
+ }
935
+ sessionId = results[0].id;
936
+ }
937
+ }
938
+ const result = await clearSessionName(sessionId);
939
+ if (!result.ok) {
940
+ console.error(result.error);
941
+ process.exit(1);
942
+ }
943
+ console.log(`Cleared name from session ${(result.fullId ?? "").slice(0, 8)}...`);
944
+ break;
945
+ }
946
+ case "search": {
947
+ const query = args.slice(1).join(" ");
948
+ if (!query) {
949
+ console.error("Usage: session search <query>");
950
+ process.exit(1);
951
+ }
952
+ const sessions = await buildIndex();
953
+ const results = searchSessions(sessions, query);
954
+ console.log(formatSearchResults(results, query));
955
+ break;
956
+ }
957
+ case "help":
958
+ default: {
959
+ if (command !== "help" && command !== "--help" && command !== "-h") {
960
+ const query = args.join(" ");
961
+ const sessions = await buildIndex();
962
+ const results = searchSessions(sessions, query);
963
+ console.log(formatSearchResults(results, query));
964
+ } else {
965
+ console.log(`Usage:
966
+ session <query> Search sessions by keyword
967
+ session list [--all] Show recent sessions (default: 20)
968
+ session show <id> Show session details (partial ID ok)
969
+ session name <name> Name the most recent session
970
+ session name <id> <name> Name a specific session (partial ID ok)
971
+ session unname [<id>] Clear a session's name
972
+ session search <query> Search sessions by keyword
973
+ session rebuild Force rebuild the index
974
+ session stats Show index statistics`);
975
+ }
976
+ break;
977
+ }
978
+ }
979
+ }
980
+ main().catch((err) => {
981
+ console.error("Error:", err.message);
982
+ process.exit(1);
983
+ });