@cookielab.io/klovi 0.12.0 → 0.13.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.
@@ -61,11 +61,11 @@ code {
61
61
  --bg-system: #fff8c5;
62
62
  --bg-tool: #eaeef2;
63
63
  --bg-thinking: #eef6ee;
64
- --bg-code: #24292f;
64
+ --bg-code: #fafafa;
65
65
  --text-primary: #1f2328;
66
66
  --text-secondary: #59636e;
67
67
  --text-muted: #57606a;
68
- --text-code: #e0e0e0;
68
+ --text-code: #383a42;
69
69
  --text-inverse: #fff;
70
70
  --accent: #1a7f37;
71
71
  --accent-hover: #218c3a;
@@ -100,11 +100,11 @@ code {
100
100
  --bg-system: #1a1610;
101
101
  --bg-tool: #161b22;
102
102
  --bg-thinking: #11151a;
103
- --bg-code: #080b10;
103
+ --bg-code: #282c34;
104
104
  --text-primary: #c9d1d9;
105
105
  --text-secondary: #8b949e;
106
106
  --text-muted: #848d97;
107
- --text-code: #c9d1d9;
107
+ --text-code: #abb2bf;
108
108
  --text-inverse: #0d1117;
109
109
  --accent: #3fb950;
110
110
  --accent-hover: #56d364;
@@ -460,6 +460,142 @@ code {
460
460
  font-size: .85rem;
461
461
  }
462
462
 
463
+ .message-parse-error {
464
+ background: var(--bg-primary);
465
+ border: 1px solid var(--error);
466
+ border-left: 4px solid var(--error);
467
+ font-size: .85rem;
468
+ }
469
+
470
+ .message-parse-error .message-role {
471
+ color: var(--error);
472
+ }
473
+
474
+ .parse-error-line {
475
+ color: var(--text-secondary);
476
+ margin-left: 8px;
477
+ font-size: .8em;
478
+ font-weight: normal;
479
+ }
480
+
481
+ .parse-error-type {
482
+ display: inline-block;
483
+ color: var(--error);
484
+ margin-bottom: 4px;
485
+ font-size: .8em;
486
+ font-weight: 600;
487
+ }
488
+
489
+ .parse-error-details {
490
+ color: var(--text-secondary);
491
+ font-size: .85em;
492
+ font-family: var(--font-mono, monospace);
493
+ margin-bottom: 4px;
494
+ }
495
+
496
+ .parse-error-raw {
497
+ margin-top: 8px;
498
+ }
499
+
500
+ .parse-error-raw summary {
501
+ color: var(--text-muted);
502
+ cursor: pointer;
503
+ user-select: none;
504
+ font-size: .8em;
505
+ }
506
+
507
+ .parse-error-raw pre {
508
+ background: var(--bg-tertiary);
509
+ border-radius: var(--radius-sm);
510
+ overflow-x: auto;
511
+ white-space: pre-wrap;
512
+ word-break: break-all;
513
+ margin-top: 4px;
514
+ padding: 8px;
515
+ font-size: .8em;
516
+ }
517
+
518
+ .error-card {
519
+ background: var(--bg-primary);
520
+ border: 1px solid var(--error);
521
+ border-left: 4px solid var(--error);
522
+ border-radius: var(--radius-md);
523
+ margin: 8px 0;
524
+ padding: 12px 16px;
525
+ font-size: .85rem;
526
+ }
527
+
528
+ .error-card-header {
529
+ display: flex;
530
+ justify-content: space-between;
531
+ align-items: center;
532
+ gap: 8px;
533
+ }
534
+
535
+ .error-card-title {
536
+ color: var(--error);
537
+ font-weight: 600;
538
+ }
539
+
540
+ .error-card-details {
541
+ margin-top: 8px;
542
+ }
543
+
544
+ .error-card-details summary {
545
+ color: var(--text-muted);
546
+ cursor: pointer;
547
+ user-select: none;
548
+ font-size: .8em;
549
+ }
550
+
551
+ .error-card-details pre {
552
+ background: var(--bg-tertiary);
553
+ border-radius: var(--radius-sm);
554
+ overflow-x: auto;
555
+ white-space: pre-wrap;
556
+ word-break: break-all;
557
+ margin-top: 4px;
558
+ padding: 8px;
559
+ font-size: .8em;
560
+ }
561
+
562
+ .error-view {
563
+ display: flex;
564
+ text-align: center;
565
+ flex-direction: column;
566
+ justify-content: center;
567
+ align-items: center;
568
+ padding: 40px;
569
+ }
570
+
571
+ .error-view-title {
572
+ color: var(--error);
573
+ margin-bottom: 8px;
574
+ font-size: 1.1rem;
575
+ font-weight: 600;
576
+ }
577
+
578
+ .error-view-message {
579
+ color: var(--text-secondary);
580
+ margin-bottom: 16px;
581
+ font-size: .9rem;
582
+ }
583
+
584
+ .fetch-error {
585
+ display: flex;
586
+ color: var(--text-secondary);
587
+ flex-direction: column;
588
+ justify-content: center;
589
+ align-items: center;
590
+ gap: 12px;
591
+ padding: 40px;
592
+ font-size: .9rem;
593
+ }
594
+
595
+ .fetch-error-message {
596
+ color: var(--error);
597
+ }
598
+
463
599
  .status-notice {
464
600
  text-align: center;
465
601
  color: var(--text-muted);
@@ -630,6 +766,32 @@ code {
630
766
  padding: 12px 16px;
631
767
  }
632
768
 
769
+ .diff-view-wrapper {
770
+ position: relative;
771
+ }
772
+
773
+ .diff-view-header {
774
+ display: flex;
775
+ background: var(--bg-code);
776
+ border-radius: var(--radius-sm) var(--radius-sm) 0 0;
777
+ color: var(--text-muted);
778
+ align-items: center;
779
+ padding: 6px 12px;
780
+ font-family: SF Mono, Fira Code, Menlo, monospace;
781
+ font-size: .75rem;
782
+ }
783
+
784
+ .diff-view-content {
785
+ border-radius: 0 0 var(--radius-sm) var(--radius-sm);
786
+ overflow-x: auto;
787
+ }
788
+
789
+ .diff-view-content pre {
790
+ background: var(--bg-code);
791
+ border-radius: 0 0 var(--radius-sm) var(--radius-sm);
792
+ margin: 0;
793
+ }
794
+
633
795
  .collapsible {
634
796
  border-radius: var(--radius-sm);
635
797
  margin: 4px 0;
@@ -699,6 +861,13 @@ code {
699
861
  color: var(--error);
700
862
  }
701
863
 
864
+ .tool-call-truncated {
865
+ color: var(--text-muted);
866
+ padding: 4px 0;
867
+ font-size: .75rem;
868
+ font-style: italic;
869
+ }
870
+
702
871
  .tool-call-summary {
703
872
  color: var(--text-muted);
704
873
  font-family: SF Mono, Fira Code, Menlo, monospace;
@@ -9,7 +9,7 @@
9
9
  <link rel="apple-touch-icon" href="./apple-touch-icon-st4rb42e.png" />
10
10
 
11
11
 
12
- <link rel="stylesheet" crossorigin href="./index-0wfcphj9.css"><script type="module" crossorigin src="./index-73xxsc87.js"></script></head>
12
+ <link rel="stylesheet" crossorigin href="./index-zwpjgh70.css"><script type="module" crossorigin src="./index-42y46a3t.js"></script></head>
13
13
  <body>
14
14
  <div id="root"></div>
15
15
 
package/dist/server.js CHANGED
@@ -4,7 +4,7 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
4
4
 
5
5
  // index.ts
6
6
  import { existsSync as existsSync2 } from "node:fs";
7
- import { dirname, join as join5 } from "node:path";
7
+ import { dirname, join as join4 } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
 
10
10
  // src/server/cli.ts
@@ -27,6 +27,9 @@ function setClaudeCodeDir(dir) {
27
27
  function getProjectsDir() {
28
28
  return join(claudeCodeDir, "projects");
29
29
  }
30
+ function getStatsCachePath() {
31
+ return join(claudeCodeDir, "stats-cache.json");
32
+ }
30
33
 
31
34
  // src/server/parser/command-message.ts
32
35
  function cleanCommandMessage(text) {
@@ -35,6 +38,9 @@ function cleanCommandMessage(text) {
35
38
  const argsMatch = text.match(/<command-args>([\s\S]*?)<\/command-args>/);
36
39
  if (argsMatch?.[1])
37
40
  return argsMatch[1].trim();
41
+ const nameMatch = text.match(/<command-name>([\s\S]*?)<\/command-name>/);
42
+ if (nameMatch?.[1])
43
+ return nameMatch[1].trim();
38
44
  return text.replace(/<command-message>[\s\S]*?<\/command-message>/g, "").replace(/<command-name>[\s\S]*?<\/command-name>/g, "").trim();
39
45
  }
40
46
  function parseCommandMessage(text) {
@@ -242,10 +248,10 @@ async function handleSearchSessions() {
242
248
  // src/server/parser/session.ts
243
249
  import { join as join3 } from "node:path";
244
250
  async function parseSession(sessionId, encodedPath) {
245
- const rawLines = await readJsonlLines(join3(getProjectsDir(), encodedPath, `${sessionId}.jsonl`));
251
+ const { rawLines, parseErrors } = await readJsonlLines(join3(getProjectsDir(), encodedPath, `${sessionId}.jsonl`));
246
252
  const subAgentMap = extractSubAgentMap(rawLines);
247
253
  const slug = extractSlug(rawLines);
248
- const turns = buildTurns(rawLines);
254
+ const turns = buildTurns(rawLines, parseErrors);
249
255
  for (const turn of turns) {
250
256
  if (turn.kind !== "assistant")
251
257
  continue;
@@ -269,14 +275,14 @@ async function parseSession(sessionId, encodedPath) {
269
275
  }
270
276
  async function parseSubAgentSession(sessionId, encodedPath, agentId) {
271
277
  const filePath = join3(getProjectsDir(), encodedPath, sessionId, "subagents", `agent-${agentId}.jsonl`);
272
- let rawLines;
278
+ let parsed;
273
279
  try {
274
- rawLines = await readJsonlLines(filePath);
280
+ parsed = await readJsonlLines(filePath);
275
281
  } catch {
276
282
  return { sessionId, project: encodedPath, turns: [] };
277
283
  }
278
- const subAgentMap = extractSubAgentMap(rawLines);
279
- const turns = buildTurns(rawLines);
284
+ const subAgentMap = extractSubAgentMap(parsed.rawLines);
285
+ const turns = buildTurns(parsed.rawLines, parsed.parseErrors);
280
286
  for (const turn of turns) {
281
287
  if (turn.kind !== "assistant")
282
288
  continue;
@@ -353,14 +359,29 @@ function findImplSessionId(slug, sessions, currentSessionId) {
353
359
  async function readJsonlLines(filePath) {
354
360
  const { readFile: readFile2 } = await import("node:fs/promises");
355
361
  const text = await readFile2(filePath, "utf-8");
356
- return text.split(`
357
- `).filter((l) => l.trim()).map((l) => {
362
+ const lines = text.split(`
363
+ `);
364
+ const rawLines = [];
365
+ const parseErrors = [];
366
+ for (let i = 0;i < lines.length; i++) {
367
+ const line = lines[i];
368
+ if (!line.trim())
369
+ continue;
358
370
  try {
359
- return JSON.parse(l);
360
- } catch {
361
- return null;
371
+ rawLines.push(JSON.parse(line));
372
+ } catch (err) {
373
+ parseErrors.push({
374
+ kind: "parse_error",
375
+ uuid: `parse-error-line-${i + 1}`,
376
+ timestamp: rawLines[rawLines.length - 1]?.timestamp ?? "",
377
+ lineNumber: i + 1,
378
+ rawLine: line.length > 500 ? `${line.slice(0, 500)}… (truncated)` : line,
379
+ errorType: "json_parse",
380
+ errorDetails: err instanceof Error ? err.message : undefined
381
+ });
362
382
  }
363
- }).filter((l) => l !== null);
383
+ }
384
+ return { rawLines, parseErrors };
364
385
  }
365
386
  function isDisplayableLine(l) {
366
387
  if (l.type === "progress")
@@ -500,9 +521,21 @@ function handleUserLine(line, currentAssistant, turns) {
500
521
  turns.push(result);
501
522
  return flushed;
502
523
  }
503
- function handleAssistantLine(line, currentAssistant, toolResults) {
504
- if (!line.message || !Array.isArray(line.message.content))
524
+ function handleAssistantLine(line, currentAssistant, toolResults, structureErrors) {
525
+ if (!line.message)
505
526
  return currentAssistant;
527
+ if (!Array.isArray(line.message.content)) {
528
+ structureErrors.push({
529
+ kind: "parse_error",
530
+ uuid: `parse-error-${line.uuid || "unknown"}`,
531
+ timestamp: line.timestamp || "",
532
+ lineNumber: 0,
533
+ rawLine: JSON.stringify(line.message.content).slice(0, 500),
534
+ errorType: "invalid_structure",
535
+ errorDetails: `Assistant message content is ${typeof line.message.content}, expected array`
536
+ });
537
+ return currentAssistant;
538
+ }
506
539
  const current = currentAssistant ?? createAssistantTurn(line);
507
540
  processAssistantLine(line, current, toolResults);
508
541
  return current;
@@ -520,21 +553,26 @@ function handleSystemLine(line, currentAssistant, turns) {
520
553
  });
521
554
  return null;
522
555
  }
523
- function buildTurns(lines) {
556
+ function buildTurns(lines, parseErrors = []) {
524
557
  const displayable = lines.filter(isDisplayableLine);
525
558
  const toolResults = collectToolResults(displayable);
526
559
  const turns = [];
527
560
  let currentAssistant = null;
561
+ const structureErrors = [];
528
562
  for (const line of displayable) {
529
563
  if (line.type === "user") {
530
564
  currentAssistant = handleUserLine(line, currentAssistant, turns);
531
565
  } else if (line.type === "assistant") {
532
- currentAssistant = handleAssistantLine(line, currentAssistant, toolResults);
566
+ currentAssistant = handleAssistantLine(line, currentAssistant, toolResults, structureErrors);
533
567
  } else if (line.type === "system") {
534
568
  currentAssistant = handleSystemLine(line, currentAssistant, turns);
535
569
  }
536
570
  }
537
571
  flushAssistant(currentAssistant, turns);
572
+ const allErrors = [...parseErrors, ...structureErrors];
573
+ if (allErrors.length > 0) {
574
+ turns.push(...allErrors);
575
+ }
538
576
  return turns;
539
577
  }
540
578
  function extractToolResult(tr) {
@@ -578,11 +616,82 @@ async function handleSessions(encodedPath) {
578
616
 
579
617
  // src/server/parser/stats.ts
580
618
  import { readdir as readdir2, readFile as readFile2 } from "node:fs/promises";
581
- import { join as join4 } from "node:path";
619
+ async function loadStatsCache() {
620
+ try {
621
+ const text = await readFile2(getStatsCachePath(), "utf-8");
622
+ const data = JSON.parse(text);
623
+ if (data.version !== 2)
624
+ return null;
625
+ return data;
626
+ } catch {
627
+ return null;
628
+ }
629
+ }
630
+ async function countProjects() {
631
+ try {
632
+ const entries = await readdir2(getProjectsDir(), { withFileTypes: true });
633
+ return entries.filter((e) => e.isDirectory()).length;
634
+ } catch {
635
+ return 0;
636
+ }
637
+ }
638
+ function todayDateString() {
639
+ const d = new Date;
640
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
641
+ }
642
+ function isWithinLastWeek(dateStr) {
643
+ const now = new Date;
644
+ const weekAgo = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7);
645
+ const weekAgoStr = `${weekAgo.getFullYear()}-${String(weekAgo.getMonth() + 1).padStart(2, "0")}-${String(weekAgo.getDate()).padStart(2, "0")}`;
646
+ return dateStr >= weekAgoStr;
647
+ }
648
+ function buildFromCache(cache, projects) {
649
+ const today = todayDateString();
650
+ const todayEntry = cache.dailyActivity.find((d) => d.date === today);
651
+ const thisWeekEntries = cache.dailyActivity.filter((d) => isWithinLastWeek(d.date));
652
+ let inputTokens = 0;
653
+ let outputTokens = 0;
654
+ let cacheReadTokens = 0;
655
+ let cacheCreationTokens = 0;
656
+ const models = {};
657
+ for (const [model, usage] of Object.entries(cache.modelUsage)) {
658
+ inputTokens += usage.inputTokens;
659
+ outputTokens += usage.outputTokens;
660
+ cacheReadTokens += usage.cacheReadInputTokens;
661
+ cacheCreationTokens += usage.cacheCreationInputTokens;
662
+ models[model] = {
663
+ inputTokens: usage.inputTokens,
664
+ outputTokens: usage.outputTokens,
665
+ cacheReadTokens: usage.cacheReadInputTokens,
666
+ cacheCreationTokens: usage.cacheCreationInputTokens
667
+ };
668
+ }
669
+ const toolCalls = cache.dailyActivity.reduce((sum, d) => sum + d.toolCallCount, 0);
670
+ return {
671
+ projects,
672
+ sessions: cache.totalSessions,
673
+ messages: cache.totalMessages,
674
+ todaySessions: todayEntry?.sessionCount ?? 0,
675
+ thisWeekSessions: thisWeekEntries.reduce((sum, d) => sum + d.sessionCount, 0),
676
+ inputTokens,
677
+ outputTokens,
678
+ cacheReadTokens,
679
+ cacheCreationTokens,
680
+ toolCalls,
681
+ models
682
+ };
683
+ }
582
684
  async function scanStats() {
583
- const stats = {
584
- projects: 0,
685
+ const [cache, projects] = await Promise.all([loadStatsCache(), countProjects()]);
686
+ if (cache) {
687
+ return buildFromCache(cache, projects);
688
+ }
689
+ return {
690
+ projects,
585
691
  sessions: 0,
692
+ messages: 0,
693
+ todaySessions: 0,
694
+ thisWeekSessions: 0,
586
695
  inputTokens: 0,
587
696
  outputTokens: 0,
588
697
  cacheReadTokens: 0,
@@ -590,84 +699,11 @@ async function scanStats() {
590
699
  toolCalls: 0,
591
700
  models: {}
592
701
  };
593
- const projectsDir = getProjectsDir();
594
- let entries;
595
- try {
596
- entries = await readdir2(projectsDir, { withFileTypes: true });
597
- } catch {
598
- return stats;
599
- }
600
- for (const entry of entries) {
601
- if (!entry.isDirectory())
602
- continue;
603
- const projectDir = join4(projectsDir, entry.name);
604
- let files;
605
- try {
606
- files = (await readdir2(projectDir)).filter((f) => f.endsWith(".jsonl"));
607
- } catch {
608
- continue;
609
- }
610
- if (files.length === 0)
611
- continue;
612
- stats.projects++;
613
- stats.sessions += files.length;
614
- for (const file of files) {
615
- await scanFile(join4(projectDir, file), stats);
616
- }
617
- }
618
- return stats;
619
- }
620
- function processAssistantLine2(msg, stats) {
621
- const usage = msg.usage;
622
- if (usage) {
623
- stats.inputTokens += usage.input_tokens ?? 0;
624
- stats.outputTokens += usage.output_tokens ?? 0;
625
- stats.cacheReadTokens += usage.cache_read_input_tokens ?? 0;
626
- stats.cacheCreationTokens += usage.cache_creation_input_tokens ?? 0;
627
- }
628
- const model = msg.model;
629
- if (model) {
630
- stats.models[model] = (stats.models[model] ?? 0) + 1;
631
- }
632
- const content = msg.content;
633
- if (Array.isArray(content)) {
634
- for (const block of content) {
635
- if (block.type === "tool_use") {
636
- stats.toolCalls++;
637
- }
638
- }
639
- }
640
- }
641
- async function scanFile(filePath, stats) {
642
- let text;
643
- try {
644
- text = await readFile2(filePath, "utf-8");
645
- } catch {
646
- return;
647
- }
648
- for (const line of text.split(`
649
- `)) {
650
- if (!line.trim())
651
- continue;
652
- try {
653
- const obj = JSON.parse(line);
654
- if (obj.type === "assistant" && obj.message) {
655
- processAssistantLine2(obj.message, stats);
656
- }
657
- } catch {}
658
- }
659
702
  }
660
703
 
661
704
  // src/server/api/stats.ts
662
- var CACHE_TTL = 300000;
663
- var cache = null;
664
705
  async function handleStats() {
665
- const now = Date.now();
666
- if (cache && now - cache.timestamp < CACHE_TTL) {
667
- return Response.json({ stats: cache.data });
668
- }
669
706
  const stats = await scanStats();
670
- cache = { data: stats, timestamp: now };
671
707
  return Response.json({ stats });
672
708
  }
673
709
 
@@ -683,8 +719,8 @@ async function handleSubAgent(sessionId, agentId, encodedPath) {
683
719
 
684
720
  // src/server/version.ts
685
721
  var appVersion = {
686
- version: "0.12.0",
687
- commitHash: "f436d2f"
722
+ version: "0.13.0",
723
+ commitHash: "df6e758"
688
724
  };
689
725
 
690
726
  // src/server/api/version.ts
@@ -970,6 +1006,6 @@ if (!acceptRisks) {
970
1006
  promptSecurityWarning(port);
971
1007
  }
972
1008
  var __dirname2 = dirname(fileURLToPath(import.meta.url));
973
- var staticDir = existsSync2(join5(__dirname2, "public", "index.html")) ? join5(__dirname2, "public") : join5(__dirname2, "dist", "public");
1009
+ var staticDir = existsSync2(join4(__dirname2, "public", "index.html")) ? join4(__dirname2, "public") : join4(__dirname2, "dist", "public");
974
1010
  startServer(port, createRoutes(), staticDir);
975
1011
  printStartupBanner(port);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cookielab.io/klovi",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "A local web app for browsing and presenting Claude Code session history",
5
5
  "module": "index.ts",
6
6
  "type": "module",
@@ -50,8 +50,7 @@
50
50
  "check:fix": "bunx biome check --write ."
51
51
  },
52
52
  "devDependencies": {
53
- "@biomejs/biome": "2.3.14",
54
- "@testing-library/jest-dom": "^6.9.1",
53
+ "@biomejs/biome": "2.4.2",
55
54
  "@testing-library/react": "^16.3.2",
56
55
  "@types/bun": "^1.3.8",
57
56
  "@types/react": "^19.2.13",