@zzusp/ccsm 1.0.1 → 1.0.3

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 (70) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +236 -236
  3. package/bin/cli.mjs +52 -52
  4. package/dist/assets/{DiskUsage-CKhggLs5.js → DiskUsage-BY6XwffG.js} +2 -2
  5. package/dist/assets/DiskUsage-BY6XwffG.js.map +1 -0
  6. package/dist/assets/{ImportPage-wge4VhZ-.js → ImportPage-Cwq5bx7G.js} +2 -2
  7. package/dist/assets/ImportPage-Cwq5bx7G.js.map +1 -0
  8. package/dist/assets/MarkdownContent-BFu7Nkk_.js +2 -0
  9. package/dist/assets/MarkdownContent-BFu7Nkk_.js.map +1 -0
  10. package/dist/assets/{ProjectMemory-Q4XX40j_.js → ProjectMemory-CcE3KbUK.js} +2 -2
  11. package/dist/assets/ProjectMemory-CcE3KbUK.js.map +1 -0
  12. package/dist/assets/index-CrWxV6sb.css +1 -0
  13. package/dist/assets/index-DTbWl1jb.js +11 -0
  14. package/dist/assets/index-DTbWl1jb.js.map +1 -0
  15. package/dist/assets/markdown-Bag5rX3T.js +30 -0
  16. package/dist/assets/markdown-Bag5rX3T.js.map +1 -0
  17. package/dist/index.html +26 -26
  18. package/package.json +81 -83
  19. package/server/index.ts +130 -130
  20. package/server/lib/active-sessions.test.ts +119 -119
  21. package/server/lib/active-sessions.ts +95 -95
  22. package/server/lib/bundle.test.ts +182 -182
  23. package/server/lib/bundle.ts +86 -86
  24. package/server/lib/claude-paths.test.ts +126 -126
  25. package/server/lib/claude-paths.ts +43 -43
  26. package/server/lib/cleanup-suggestions.ts +131 -131
  27. package/server/lib/constants.ts +8 -8
  28. package/server/lib/delete-project.ts +100 -100
  29. package/server/lib/delete.test.ts +244 -244
  30. package/server/lib/delete.ts +192 -192
  31. package/server/lib/disk-usage.ts +81 -81
  32. package/server/lib/encode-cwd.ts +24 -24
  33. package/server/lib/export-bundle.ts +236 -236
  34. package/server/lib/export-import-bundle.test.ts +337 -337
  35. package/server/lib/fs-size.ts +38 -38
  36. package/server/lib/import-bundle.ts +488 -488
  37. package/server/lib/load-memory.ts +120 -120
  38. package/server/lib/load-session.ts +209 -209
  39. package/server/lib/modified-files.test.ts +280 -280
  40. package/server/lib/modified-files.ts +228 -228
  41. package/server/lib/open-folder.ts +47 -47
  42. package/server/lib/parse-jsonl.ts +160 -139
  43. package/server/lib/port.ts +23 -23
  44. package/server/lib/safe-id.test.ts +41 -41
  45. package/server/lib/safe-id.ts +6 -6
  46. package/server/lib/safe-remove.test.ts +73 -73
  47. package/server/lib/safe-remove.ts +25 -25
  48. package/server/lib/scan.ts +289 -286
  49. package/server/lib/search-all.ts +130 -130
  50. package/server/lib/search-session.ts +203 -203
  51. package/server/lib/system-tags.ts +20 -20
  52. package/server/lib/update.ts +67 -67
  53. package/server/lib/version.test.ts +39 -39
  54. package/server/lib/version.ts +117 -117
  55. package/server/routes/disk-cleanup.ts +54 -54
  56. package/server/routes/disk.ts +9 -9
  57. package/server/routes/import.ts +87 -87
  58. package/server/routes/projects.ts +104 -104
  59. package/server/routes/search.ts +79 -79
  60. package/server/routes/sessions.ts +130 -130
  61. package/server/routes/version.ts +34 -34
  62. package/server/types.ts +1 -1
  63. package/shared/constants.ts +7 -7
  64. package/shared/types.ts +513 -511
  65. package/dist/assets/DiskUsage-CKhggLs5.js.map +0 -1
  66. package/dist/assets/ImportPage-wge4VhZ-.js.map +0 -1
  67. package/dist/assets/ProjectMemory-Q4XX40j_.js.map +0 -1
  68. package/dist/assets/index-7aMrnHJG.js +0 -7
  69. package/dist/assets/index-7aMrnHJG.js.map +0 -1
  70. package/dist/assets/index-BOeI_J4B.css +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
+ }