@zzusp/ccsm 1.0.0 → 1.0.2

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 (83) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +236 -232
  3. package/dist/assets/DiskUsage-BY6XwffG.js +2 -0
  4. package/dist/assets/DiskUsage-BY6XwffG.js.map +1 -0
  5. package/dist/assets/{ImportPage-b8NORa8b.js → ImportPage-Cwq5bx7G.js} +2 -2
  6. package/dist/assets/ImportPage-Cwq5bx7G.js.map +1 -0
  7. package/dist/assets/MarkdownContent-BFu7Nkk_.js +2 -0
  8. package/dist/assets/MarkdownContent-BFu7Nkk_.js.map +1 -0
  9. package/dist/assets/{ProjectMemory-aSV8UzQ9.js → ProjectMemory-CcE3KbUK.js} +2 -2
  10. package/dist/assets/ProjectMemory-CcE3KbUK.js.map +1 -0
  11. package/dist/assets/{charts-A5eNHLjX.js → charts-jxJqXXUr.js} +2 -2
  12. package/dist/assets/{charts-A5eNHLjX.js.map → charts-jxJqXXUr.js.map} +1 -1
  13. package/dist/assets/index-CrWxV6sb.css +1 -0
  14. package/dist/assets/index-DTbWl1jb.js +11 -0
  15. package/dist/assets/index-DTbWl1jb.js.map +1 -0
  16. package/dist/assets/markdown-Bag5rX3T.js +30 -0
  17. package/dist/assets/markdown-Bag5rX3T.js.map +1 -0
  18. package/dist/assets/{query-C1K1uQRu.js → query-CS7JQ86v.js} +2 -2
  19. package/dist/assets/{query-C1K1uQRu.js.map → query-CS7JQ86v.js.map} +1 -1
  20. package/dist/assets/{react-W0jzChlo.js → react-CPkiFScu.js} +10 -10
  21. package/dist/assets/{react-W0jzChlo.js.map → react-CPkiFScu.js.map} +1 -1
  22. package/dist/assets/{router-DfbutHY3.js → router-DwaHAh1G.js} +2 -2
  23. package/dist/assets/{router-DfbutHY3.js.map → router-DwaHAh1G.js.map} +1 -1
  24. package/dist/assets/vendor-Cs8vYp-N.js +27 -0
  25. package/dist/assets/vendor-Cs8vYp-N.js.map +1 -0
  26. package/dist/favicon.svg +7 -7
  27. package/dist/index.html +30 -30
  28. package/package.json +24 -11
  29. package/server/index.ts +4 -0
  30. package/server/lib/active-sessions.test.ts +119 -0
  31. package/server/lib/active-sessions.ts +95 -95
  32. package/server/lib/bundle.test.ts +182 -0
  33. package/server/lib/bundle.ts +86 -86
  34. package/server/lib/claude-paths.test.ts +126 -0
  35. package/server/lib/claude-paths.ts +43 -36
  36. package/server/lib/cleanup-suggestions.ts +131 -0
  37. package/server/lib/constants.ts +8 -7
  38. package/server/lib/delete-project.ts +100 -100
  39. package/server/lib/delete.test.ts +244 -0
  40. package/server/lib/delete.ts +192 -203
  41. package/server/lib/disk-usage.ts +81 -83
  42. package/server/lib/encode-cwd.ts +24 -24
  43. package/server/lib/export-bundle.ts +236 -236
  44. package/server/lib/export-import-bundle.test.ts +337 -0
  45. package/server/lib/fs-size.ts +38 -38
  46. package/server/lib/import-bundle.ts +488 -488
  47. package/server/lib/load-memory.ts +120 -120
  48. package/server/lib/load-session.ts +209 -209
  49. package/server/lib/modified-files.test.ts +280 -0
  50. package/server/lib/modified-files.ts +228 -0
  51. package/server/lib/open-folder.ts +47 -40
  52. package/server/lib/parse-jsonl.ts +160 -107
  53. package/server/lib/port.ts +23 -23
  54. package/server/lib/safe-id.test.ts +41 -0
  55. package/server/lib/safe-id.ts +6 -6
  56. package/server/lib/safe-remove.test.ts +73 -0
  57. package/server/lib/safe-remove.ts +25 -0
  58. package/server/lib/scan.ts +289 -183
  59. package/server/lib/search-all.ts +130 -130
  60. package/server/lib/search-session.ts +203 -203
  61. package/server/lib/system-tags.ts +20 -20
  62. package/server/lib/update.ts +67 -0
  63. package/server/lib/version.test.ts +39 -0
  64. package/server/lib/version.ts +117 -0
  65. package/server/routes/disk-cleanup.ts +54 -0
  66. package/server/routes/disk.ts +9 -9
  67. package/server/routes/import.ts +87 -87
  68. package/server/routes/projects.ts +104 -104
  69. package/server/routes/search.ts +79 -79
  70. package/server/routes/sessions.ts +130 -81
  71. package/server/routes/version.ts +34 -0
  72. package/server/types.ts +1 -1
  73. package/shared/constants.ts +7 -2
  74. package/shared/types.ts +513 -359
  75. package/dist/assets/DiskUsage-Bq4VaoUA.js +0 -2
  76. package/dist/assets/DiskUsage-Bq4VaoUA.js.map +0 -1
  77. package/dist/assets/ImportPage-b8NORa8b.js.map +0 -1
  78. package/dist/assets/ProjectMemory-aSV8UzQ9.js.map +0 -1
  79. package/dist/assets/index-DLATR3tZ.js +0 -5
  80. package/dist/assets/index-DLATR3tZ.js.map +0 -1
  81. package/dist/assets/index-DLDtbkux.css +0 -1
  82. package/dist/assets/vendor-CH80ylbS.js +0 -19
  83. package/dist/assets/vendor-CH80ylbS.js.map +0 -1
@@ -1,120 +1,120 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { isUnderClaudeRoot, PATHS } from './claude-paths.ts';
4
- import type { MemoryEntry, MemoryResponse, MemoryType } from '../types.ts';
5
-
6
- const KNOWN_TYPES: ReadonlySet<MemoryType> = new Set([
7
- 'user',
8
- 'feedback',
9
- 'project',
10
- 'reference',
11
- ]);
12
-
13
- const TYPE_ORDER: ReadonlyArray<MemoryType> = ['user', 'feedback', 'project', 'reference'];
14
-
15
- export async function loadProjectMemory(projectId: string): Promise<MemoryResponse> {
16
- const dir = path.join(PATHS.projects, projectId, 'memory');
17
- if (!isUnderClaudeRoot(dir)) return { index: null, entries: [] };
18
-
19
- let dirents: string[];
20
- try {
21
- dirents = await fs.readdir(dir);
22
- } catch (err) {
23
- if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
24
- return { index: null, entries: [] };
25
- }
26
- throw err;
27
- }
28
-
29
- let index: string | null = null;
30
- const entries: MemoryEntry[] = [];
31
-
32
- for (const filename of dirents) {
33
- if (!filename.toLowerCase().endsWith('.md')) continue;
34
- const full = path.join(dir, filename);
35
- let stat;
36
- try {
37
- stat = await fs.stat(full);
38
- } catch {
39
- continue;
40
- }
41
- if (!stat.isFile()) continue;
42
-
43
- let raw: string;
44
- try {
45
- raw = await fs.readFile(full, 'utf8');
46
- } catch {
47
- continue;
48
- }
49
-
50
- if (filename.toLowerCase() === 'memory.md') {
51
- index = raw;
52
- continue;
53
- }
54
-
55
- const { name, description, type, body } = parseFrontmatter(raw);
56
- entries.push({
57
- filename,
58
- name,
59
- description,
60
- type,
61
- body,
62
- bytes: stat.size,
63
- mtime: stat.mtime.toISOString(),
64
- });
65
- }
66
-
67
- entries.sort((a, b) => {
68
- const ai = a.type ? TYPE_ORDER.indexOf(a.type) : TYPE_ORDER.length;
69
- const bi = b.type ? TYPE_ORDER.indexOf(b.type) : TYPE_ORDER.length;
70
- if (ai !== bi) return ai - bi;
71
- const an = a.name ?? a.filename;
72
- const bn = b.name ?? b.filename;
73
- return an.localeCompare(bn);
74
- });
75
-
76
- return { index, entries };
77
- }
78
-
79
- interface Parsed {
80
- name: string | null;
81
- description: string | null;
82
- type: MemoryType | null;
83
- body: string;
84
- }
85
-
86
- function parseFrontmatter(raw: string): Parsed {
87
- const result: Parsed = { name: null, description: null, type: null, body: raw };
88
- const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw);
89
- if (!m) return result;
90
-
91
- const fmBlock = m[1] ?? '';
92
- result.body = (m[2] ?? '').replace(/^\r?\n/, '');
93
-
94
- for (const line of fmBlock.split(/\r?\n/)) {
95
- const colon = line.indexOf(':');
96
- if (colon <= 0) continue;
97
- const key = line.slice(0, colon).trim().toLowerCase();
98
- const value = stripQuotes(line.slice(colon + 1).trim());
99
- if (!value) continue;
100
- if (key === 'name') result.name = value;
101
- else if (key === 'description') result.description = value;
102
- else if (key === 'type') {
103
- const lower = value.toLowerCase() as MemoryType;
104
- if (KNOWN_TYPES.has(lower)) result.type = lower;
105
- }
106
- }
107
-
108
- return result;
109
- }
110
-
111
- function stripQuotes(s: string): string {
112
- if (s.length >= 2) {
113
- const first = s[0];
114
- const last = s[s.length - 1];
115
- if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
116
- return s.slice(1, -1);
117
- }
118
- }
119
- return s;
120
- }
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { isUnderClaudeRoot, PATHS } from './claude-paths.ts';
4
+ import type { MemoryEntry, MemoryResponse, MemoryType } from '../types.ts';
5
+
6
+ const KNOWN_TYPES: ReadonlySet<MemoryType> = new Set([
7
+ 'user',
8
+ 'feedback',
9
+ 'project',
10
+ 'reference',
11
+ ]);
12
+
13
+ const TYPE_ORDER: ReadonlyArray<MemoryType> = ['user', 'feedback', 'project', 'reference'];
14
+
15
+ export async function loadProjectMemory(projectId: string): Promise<MemoryResponse> {
16
+ const dir = path.join(PATHS.projects, projectId, 'memory');
17
+ if (!isUnderClaudeRoot(dir)) return { index: null, entries: [] };
18
+
19
+ let dirents: string[];
20
+ try {
21
+ dirents = await fs.readdir(dir);
22
+ } catch (err) {
23
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
24
+ return { index: null, entries: [] };
25
+ }
26
+ throw err;
27
+ }
28
+
29
+ let index: string | null = null;
30
+ const entries: MemoryEntry[] = [];
31
+
32
+ for (const filename of dirents) {
33
+ if (!filename.toLowerCase().endsWith('.md')) continue;
34
+ const full = path.join(dir, filename);
35
+ let stat;
36
+ try {
37
+ stat = await fs.stat(full);
38
+ } catch {
39
+ continue;
40
+ }
41
+ if (!stat.isFile()) continue;
42
+
43
+ let raw: string;
44
+ try {
45
+ raw = await fs.readFile(full, 'utf8');
46
+ } catch {
47
+ continue;
48
+ }
49
+
50
+ if (filename.toLowerCase() === 'memory.md') {
51
+ index = raw;
52
+ continue;
53
+ }
54
+
55
+ const { name, description, type, body } = parseFrontmatter(raw);
56
+ entries.push({
57
+ filename,
58
+ name,
59
+ description,
60
+ type,
61
+ body,
62
+ bytes: stat.size,
63
+ mtime: stat.mtime.toISOString(),
64
+ });
65
+ }
66
+
67
+ entries.sort((a, b) => {
68
+ const ai = a.type ? TYPE_ORDER.indexOf(a.type) : TYPE_ORDER.length;
69
+ const bi = b.type ? TYPE_ORDER.indexOf(b.type) : TYPE_ORDER.length;
70
+ if (ai !== bi) return ai - bi;
71
+ const an = a.name ?? a.filename;
72
+ const bn = b.name ?? b.filename;
73
+ return an.localeCompare(bn);
74
+ });
75
+
76
+ return { index, entries };
77
+ }
78
+
79
+ interface Parsed {
80
+ name: string | null;
81
+ description: string | null;
82
+ type: MemoryType | null;
83
+ body: string;
84
+ }
85
+
86
+ function parseFrontmatter(raw: string): Parsed {
87
+ const result: Parsed = { name: null, description: null, type: null, body: raw };
88
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw);
89
+ if (!m) return result;
90
+
91
+ const fmBlock = m[1] ?? '';
92
+ result.body = (m[2] ?? '').replace(/^\r?\n/, '');
93
+
94
+ for (const line of fmBlock.split(/\r?\n/)) {
95
+ const colon = line.indexOf(':');
96
+ if (colon <= 0) continue;
97
+ const key = line.slice(0, colon).trim().toLowerCase();
98
+ const value = stripQuotes(line.slice(colon + 1).trim());
99
+ if (!value) continue;
100
+ if (key === 'name') result.name = value;
101
+ else if (key === 'description') result.description = value;
102
+ else if (key === 'type') {
103
+ const lower = value.toLowerCase() as MemoryType;
104
+ if (KNOWN_TYPES.has(lower)) result.type = lower;
105
+ }
106
+ }
107
+
108
+ return result;
109
+ }
110
+
111
+ function stripQuotes(s: string): string {
112
+ if (s.length >= 2) {
113
+ const first = s[0];
114
+ const last = s[s.length - 1];
115
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
116
+ return s.slice(1, -1);
117
+ }
118
+ }
119
+ return s;
120
+ }
@@ -1,209 +1,209 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import readline from 'node:readline';
4
- import { PATHS } from './claude-paths.ts';
5
- import { MAX_SESSION_MESSAGES } from './constants.ts';
6
- import { SYSTEM_TAG_RE, pickTitleText } from './system-tags.ts';
7
- import type { Block, Message, SessionDetail, SessionMeta } from '../types.ts';
8
-
9
- export async function loadSessionDetail(
10
- projectId: string,
11
- sessionId: string,
12
- ): Promise<SessionDetail | null> {
13
- const jsonlPath = path.join(PATHS.projects, projectId, `${sessionId}.jsonl`);
14
- if (!fs.existsSync(jsonlPath)) return null;
15
-
16
- let bytes = 0;
17
- let mtimeIso: string | null = null;
18
- try {
19
- const stat = fs.statSync(jsonlPath);
20
- bytes = stat.size;
21
- mtimeIso = stat.mtime.toISOString();
22
- } catch {
23
- /* ignore */
24
- }
25
-
26
- const meta: SessionMeta = {
27
- sessionId,
28
- projectId,
29
- cwd: null,
30
- gitBranch: null,
31
- version: null,
32
- firstAt: null,
33
- lastAt: null,
34
- messageCount: 0,
35
- bytes,
36
- title: '(untitled)',
37
- customTitle: null,
38
- };
39
-
40
- const messages: Message[] = [];
41
- let truncated = false;
42
- // Latest `ai-title` record wins (Claude rewrites it every turn). Kept off
43
- // the wire shape; just used to seed `meta.title` once parsing finishes.
44
- let aiTitle: string | null = null;
45
-
46
- const rl = readline.createInterface({
47
- input: fs.createReadStream(jsonlPath, { encoding: 'utf8' }),
48
- crlfDelay: Infinity,
49
- });
50
-
51
- for await (const raw of rl) {
52
- const line = raw.trim();
53
- if (!line) continue;
54
- let obj: Record<string, unknown>;
55
- try {
56
- obj = JSON.parse(line) as Record<string, unknown>;
57
- } catch {
58
- continue;
59
- }
60
-
61
- captureMeta(obj, meta);
62
- if (obj.type === 'ai-title' && typeof obj.aiTitle === 'string') {
63
- aiTitle = obj.aiTitle;
64
- }
65
-
66
- if (obj.type !== 'user' && obj.type !== 'assistant') continue;
67
- if (messages.length >= MAX_SESSION_MESSAGES) {
68
- truncated = true;
69
- continue;
70
- }
71
-
72
- const msg = buildMessage(obj);
73
- if (msg) messages.push(msg);
74
- }
75
-
76
- messages.sort((a, b) => (a.ts ?? '').localeCompare(b.ts ?? ''));
77
-
78
- // Match `parseJsonlMeta`: file mtime advances on untimestamped meta rewrites
79
- // (ai-title rotate, rename), so fold it into lastAt to stay in sync with
80
- // `claude code resume`.
81
- if (mtimeIso && (!meta.lastAt || mtimeIso > meta.lastAt)) {
82
- meta.lastAt = mtimeIso;
83
- }
84
-
85
- meta.messageCount = messages.length;
86
- meta.title = aiTitle || deriveAutoTitle(messages);
87
- return { meta, messages, truncated };
88
- }
89
-
90
- function captureMeta(obj: Record<string, unknown>, meta: SessionMeta): void {
91
- if (typeof obj.cwd === 'string' && !meta.cwd) meta.cwd = obj.cwd;
92
- if (typeof obj.gitBranch === 'string' && !meta.gitBranch) meta.gitBranch = obj.gitBranch;
93
- if (typeof obj.version === 'string' && !meta.version) meta.version = obj.version;
94
- if (obj.type === 'custom-title' && typeof obj.customTitle === 'string') {
95
- meta.customTitle = obj.customTitle;
96
- }
97
- const ts = typeof obj.timestamp === 'string' ? obj.timestamp : null;
98
- if (ts) {
99
- if (!meta.firstAt) meta.firstAt = ts;
100
- meta.lastAt = ts;
101
- }
102
- }
103
-
104
- function deriveAutoTitle(messages: Message[]): string {
105
- for (const m of messages) {
106
- if (m.type !== 'user' || m.isMeta) continue;
107
- for (const block of m.blocks) {
108
- if (block.type !== 'text') continue;
109
- const usable = pickTitleText(block.text);
110
- if (!usable) continue;
111
- const line = usable.trim().split('\n')[0] ?? '';
112
- if (!line) continue;
113
- return line.length > 80 ? line.slice(0, 80) + '…' : line;
114
- }
115
- }
116
- return '(untitled)';
117
- }
118
-
119
- function buildMessage(obj: Record<string, unknown>): Message | null {
120
- const type = obj.type === 'user' ? 'user' : 'assistant';
121
- const message = (obj.message ?? {}) as { content?: unknown; model?: unknown };
122
- const blocks = parseContent(message.content);
123
-
124
- let isMeta = false;
125
- if (type === 'user' && blocks.length === 1 && blocks[0]!.type === 'text') {
126
- if (SYSTEM_TAG_RE.test(blocks[0]!.text)) isMeta = true;
127
- }
128
-
129
- return {
130
- uuid: typeof obj.uuid === 'string' ? obj.uuid : '',
131
- parentUuid: typeof obj.parentUuid === 'string' ? obj.parentUuid : null,
132
- type,
133
- ts: typeof obj.timestamp === 'string' ? obj.timestamp : null,
134
- model: typeof message.model === 'string' ? message.model : null,
135
- blocks,
136
- isMeta,
137
- };
138
- }
139
-
140
- function parseContent(content: unknown): Block[] {
141
- if (typeof content === 'string') {
142
- return [{ type: 'text', text: content }];
143
- }
144
- if (!Array.isArray(content)) return [];
145
-
146
- const out: Block[] = [];
147
- for (const raw of content) {
148
- if (!raw || typeof raw !== 'object') continue;
149
- const b = raw as Record<string, unknown>;
150
- switch (b.type) {
151
- case 'text':
152
- out.push({ type: 'text', text: typeof b.text === 'string' ? b.text : '' });
153
- break;
154
- case 'tool_use':
155
- out.push({
156
- type: 'tool_use',
157
- id: typeof b.id === 'string' ? b.id : '',
158
- name: typeof b.name === 'string' ? b.name : '(unknown)',
159
- input: b.input ?? null,
160
- });
161
- break;
162
- case 'tool_result':
163
- out.push({
164
- type: 'tool_result',
165
- toolUseId: typeof b.tool_use_id === 'string' ? b.tool_use_id : '',
166
- content: stringifyToolResult(b.content),
167
- isError: b.is_error === true,
168
- });
169
- break;
170
- case 'thinking':
171
- out.push({
172
- type: 'thinking',
173
- text: typeof b.thinking === 'string' ? b.thinking : '',
174
- });
175
- break;
176
- case 'image': {
177
- const src = b.source as { media_type?: unknown } | undefined;
178
- out.push({
179
- type: 'image',
180
- mediaType: typeof src?.media_type === 'string' ? src.media_type : null,
181
- });
182
- break;
183
- }
184
- default:
185
- out.push({ type: 'unknown', raw: b });
186
- }
187
- }
188
- return out;
189
- }
190
-
191
- function stringifyToolResult(content: unknown): string {
192
- if (typeof content === 'string') return content;
193
- if (Array.isArray(content)) {
194
- return content
195
- .map((b) => {
196
- if (b && typeof b === 'object' && (b as { type?: unknown }).type === 'text') {
197
- const t = (b as { text?: unknown }).text;
198
- return typeof t === 'string' ? t : '';
199
- }
200
- if (b && typeof b === 'object' && (b as { type?: unknown }).type === 'image') {
201
- return '[image]';
202
- }
203
- return '';
204
- })
205
- .filter(Boolean)
206
- .join('\n');
207
- }
208
- return '';
209
- }
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline';
4
+ import { PATHS } from './claude-paths.ts';
5
+ import { MAX_SESSION_MESSAGES } from './constants.ts';
6
+ import { SYSTEM_TAG_RE, pickTitleText } from './system-tags.ts';
7
+ import type { Block, Message, SessionDetail, SessionMeta } from '../types.ts';
8
+
9
+ export async function loadSessionDetail(
10
+ projectId: string,
11
+ sessionId: string,
12
+ ): Promise<SessionDetail | null> {
13
+ const jsonlPath = path.join(PATHS.projects, projectId, `${sessionId}.jsonl`);
14
+ if (!fs.existsSync(jsonlPath)) return null;
15
+
16
+ let bytes = 0;
17
+ let mtimeIso: string | null = null;
18
+ try {
19
+ const stat = fs.statSync(jsonlPath);
20
+ bytes = stat.size;
21
+ mtimeIso = stat.mtime.toISOString();
22
+ } catch {
23
+ /* ignore */
24
+ }
25
+
26
+ const meta: SessionMeta = {
27
+ sessionId,
28
+ projectId,
29
+ cwd: null,
30
+ gitBranch: null,
31
+ version: null,
32
+ firstAt: null,
33
+ lastAt: null,
34
+ messageCount: 0,
35
+ bytes,
36
+ title: '(untitled)',
37
+ customTitle: null,
38
+ };
39
+
40
+ const messages: Message[] = [];
41
+ let truncated = false;
42
+ // Latest `ai-title` record wins (Claude rewrites it every turn). Kept off
43
+ // the wire shape; just used to seed `meta.title` once parsing finishes.
44
+ let aiTitle: string | null = null;
45
+
46
+ const rl = readline.createInterface({
47
+ input: fs.createReadStream(jsonlPath, { encoding: 'utf8' }),
48
+ crlfDelay: Infinity,
49
+ });
50
+
51
+ for await (const raw of rl) {
52
+ const line = raw.trim();
53
+ if (!line) continue;
54
+ let obj: Record<string, unknown>;
55
+ try {
56
+ obj = JSON.parse(line) as Record<string, unknown>;
57
+ } catch {
58
+ continue;
59
+ }
60
+
61
+ captureMeta(obj, meta);
62
+ if (obj.type === 'ai-title' && typeof obj.aiTitle === 'string') {
63
+ aiTitle = obj.aiTitle;
64
+ }
65
+
66
+ if (obj.type !== 'user' && obj.type !== 'assistant') continue;
67
+ if (messages.length >= MAX_SESSION_MESSAGES) {
68
+ truncated = true;
69
+ continue;
70
+ }
71
+
72
+ const msg = buildMessage(obj);
73
+ if (msg) messages.push(msg);
74
+ }
75
+
76
+ messages.sort((a, b) => (a.ts ?? '').localeCompare(b.ts ?? ''));
77
+
78
+ // Match `parseJsonlMeta`: file mtime advances on untimestamped meta rewrites
79
+ // (ai-title rotate, rename), so fold it into lastAt to stay in sync with
80
+ // `claude code resume`.
81
+ if (mtimeIso && (!meta.lastAt || mtimeIso > meta.lastAt)) {
82
+ meta.lastAt = mtimeIso;
83
+ }
84
+
85
+ meta.messageCount = messages.length;
86
+ meta.title = aiTitle || deriveAutoTitle(messages);
87
+ return { meta, messages, truncated };
88
+ }
89
+
90
+ function captureMeta(obj: Record<string, unknown>, meta: SessionMeta): void {
91
+ if (typeof obj.cwd === 'string' && !meta.cwd) meta.cwd = obj.cwd;
92
+ if (typeof obj.gitBranch === 'string' && !meta.gitBranch) meta.gitBranch = obj.gitBranch;
93
+ if (typeof obj.version === 'string' && !meta.version) meta.version = obj.version;
94
+ if (obj.type === 'custom-title' && typeof obj.customTitle === 'string') {
95
+ meta.customTitle = obj.customTitle;
96
+ }
97
+ const ts = typeof obj.timestamp === 'string' ? obj.timestamp : null;
98
+ if (ts) {
99
+ if (!meta.firstAt) meta.firstAt = ts;
100
+ meta.lastAt = ts;
101
+ }
102
+ }
103
+
104
+ function deriveAutoTitle(messages: Message[]): string {
105
+ for (const m of messages) {
106
+ if (m.type !== 'user' || m.isMeta) continue;
107
+ for (const block of m.blocks) {
108
+ if (block.type !== 'text') continue;
109
+ const usable = pickTitleText(block.text);
110
+ if (!usable) continue;
111
+ const line = usable.trim().split('\n')[0] ?? '';
112
+ if (!line) continue;
113
+ return line.length > 80 ? line.slice(0, 80) + '…' : line;
114
+ }
115
+ }
116
+ return '(untitled)';
117
+ }
118
+
119
+ function buildMessage(obj: Record<string, unknown>): Message | null {
120
+ const type = obj.type === 'user' ? 'user' : 'assistant';
121
+ const message = (obj.message ?? {}) as { content?: unknown; model?: unknown };
122
+ const blocks = parseContent(message.content);
123
+
124
+ let isMeta = false;
125
+ if (type === 'user' && blocks.length === 1 && blocks[0]!.type === 'text') {
126
+ if (SYSTEM_TAG_RE.test(blocks[0]!.text)) isMeta = true;
127
+ }
128
+
129
+ return {
130
+ uuid: typeof obj.uuid === 'string' ? obj.uuid : '',
131
+ parentUuid: typeof obj.parentUuid === 'string' ? obj.parentUuid : null,
132
+ type,
133
+ ts: typeof obj.timestamp === 'string' ? obj.timestamp : null,
134
+ model: typeof message.model === 'string' ? message.model : null,
135
+ blocks,
136
+ isMeta,
137
+ };
138
+ }
139
+
140
+ function parseContent(content: unknown): Block[] {
141
+ if (typeof content === 'string') {
142
+ return [{ type: 'text', text: content }];
143
+ }
144
+ if (!Array.isArray(content)) return [];
145
+
146
+ const out: Block[] = [];
147
+ for (const raw of content) {
148
+ if (!raw || typeof raw !== 'object') continue;
149
+ const b = raw as Record<string, unknown>;
150
+ switch (b.type) {
151
+ case 'text':
152
+ out.push({ type: 'text', text: typeof b.text === 'string' ? b.text : '' });
153
+ break;
154
+ case 'tool_use':
155
+ out.push({
156
+ type: 'tool_use',
157
+ id: typeof b.id === 'string' ? b.id : '',
158
+ name: typeof b.name === 'string' ? b.name : '(unknown)',
159
+ input: b.input ?? null,
160
+ });
161
+ break;
162
+ case 'tool_result':
163
+ out.push({
164
+ type: 'tool_result',
165
+ toolUseId: typeof b.tool_use_id === 'string' ? b.tool_use_id : '',
166
+ content: stringifyToolResult(b.content),
167
+ isError: b.is_error === true,
168
+ });
169
+ break;
170
+ case 'thinking':
171
+ out.push({
172
+ type: 'thinking',
173
+ text: typeof b.thinking === 'string' ? b.thinking : '',
174
+ });
175
+ break;
176
+ case 'image': {
177
+ const src = b.source as { media_type?: unknown } | undefined;
178
+ out.push({
179
+ type: 'image',
180
+ mediaType: typeof src?.media_type === 'string' ? src.media_type : null,
181
+ });
182
+ break;
183
+ }
184
+ default:
185
+ out.push({ type: 'unknown', raw: b });
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+
191
+ function stringifyToolResult(content: unknown): string {
192
+ if (typeof content === 'string') return content;
193
+ if (Array.isArray(content)) {
194
+ return content
195
+ .map((b) => {
196
+ if (b && typeof b === 'object' && (b as { type?: unknown }).type === 'text') {
197
+ const t = (b as { text?: unknown }).text;
198
+ return typeof t === 'string' ? t : '';
199
+ }
200
+ if (b && typeof b === 'object' && (b as { type?: unknown }).type === 'image') {
201
+ return '[image]';
202
+ }
203
+ return '';
204
+ })
205
+ .filter(Boolean)
206
+ .join('\n');
207
+ }
208
+ return '';
209
+ }