@zzusp/ccsm 1.0.1 → 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 (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 +85 -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,130 +1,130 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { PATHS, isUnderClaudeRoot } from './claude-paths.ts';
4
- import { decodeCwd } from './encode-cwd.ts';
5
- import { isSafeId } from './safe-id.ts';
6
- import { parseJsonlMeta } from './parse-jsonl.ts';
7
- import { searchSessionFile, type SearchSessionOpts } from './search-session.ts';
8
- import type { SearchBlockKind, SearchEvent, SearchSessionHit } from '../types.ts';
9
-
10
- const JSONL_EXT = '.jsonl';
11
-
12
- export interface SearchAllOpts {
13
- query: string;
14
- include: ReadonlySet<SearchBlockKind>;
15
- perSession: number;
16
- maxSessions: number;
17
- }
18
-
19
- interface SessionFileEntry {
20
- projectId: string;
21
- sessionId: string;
22
- filePath: string;
23
- mtimeMs: number;
24
- }
25
-
26
- export async function* searchAll(opts: SearchAllOpts): AsyncGenerator<SearchEvent> {
27
- const startedAt = Date.now();
28
- const files = enumerateSessionFiles();
29
- files.sort((a, b) => b.mtimeMs - a.mtimeMs);
30
-
31
- const sessionOpts: SearchSessionOpts = {
32
- include: opts.include,
33
- perSession: opts.perSession,
34
- };
35
-
36
- let scanned = 0;
37
- let matched = 0;
38
- let truncated = false;
39
-
40
- for (const entry of files) {
41
- if (matched >= opts.maxSessions) {
42
- truncated = true;
43
- break;
44
- }
45
- scanned++;
46
-
47
- let result;
48
- try {
49
- result = await searchSessionFile(entry.filePath, opts.query, sessionOpts);
50
- } catch {
51
- continue;
52
- }
53
- if (result.snippets.length === 0) continue;
54
-
55
- let meta;
56
- try {
57
- meta = await parseJsonlMeta(entry.filePath);
58
- } catch {
59
- continue;
60
- }
61
-
62
- matched++;
63
- const decodedCwd = meta.cwdFromMessages ?? decodeCwd(entry.projectId);
64
- const hit: SearchSessionHit = {
65
- type: 'session',
66
- projectId: entry.projectId,
67
- sessionId: entry.sessionId,
68
- projectDecodedCwd: decodedCwd,
69
- title: meta.title,
70
- customTitle: meta.customTitle,
71
- lastAt: meta.lastAt,
72
- hasMore: result.hasMore,
73
- snippets: result.snippets,
74
- };
75
- yield hit;
76
- }
77
-
78
- yield {
79
- type: 'done',
80
- scanned,
81
- matched,
82
- durationMs: Date.now() - startedAt,
83
- truncated,
84
- };
85
- }
86
-
87
- function enumerateSessionFiles(): SessionFileEntry[] {
88
- if (!fs.existsSync(PATHS.projects)) return [];
89
- const out: SessionFileEntry[] = [];
90
-
91
- let projects: fs.Dirent[];
92
- try {
93
- projects = fs.readdirSync(PATHS.projects, { withFileTypes: true });
94
- } catch {
95
- return out;
96
- }
97
-
98
- for (const projEnt of projects) {
99
- if (!projEnt.isDirectory()) continue;
100
- if (!isSafeId(projEnt.name)) continue;
101
- const projectId = projEnt.name;
102
- const projectDir = path.join(PATHS.projects, projectId);
103
- if (!isUnderClaudeRoot(projectDir)) continue;
104
-
105
- let files: fs.Dirent[];
106
- try {
107
- files = fs.readdirSync(projectDir, { withFileTypes: true });
108
- } catch {
109
- continue;
110
- }
111
-
112
- for (const fileEnt of files) {
113
- if (!fileEnt.isFile() || !fileEnt.name.endsWith(JSONL_EXT)) continue;
114
- const sessionId = fileEnt.name.slice(0, -JSONL_EXT.length);
115
- if (!isSafeId(sessionId)) continue;
116
- const filePath = path.join(projectDir, fileEnt.name);
117
- if (!isUnderClaudeRoot(filePath)) continue;
118
-
119
- let mtimeMs = 0;
120
- try {
121
- mtimeMs = fs.statSync(filePath).mtimeMs;
122
- } catch {
123
- continue;
124
- }
125
- out.push({ projectId, sessionId, filePath, mtimeMs });
126
- }
127
- }
128
-
129
- return out;
130
- }
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { PATHS, isUnderClaudeRoot } from './claude-paths.ts';
4
+ import { decodeCwd } from './encode-cwd.ts';
5
+ import { isSafeId } from './safe-id.ts';
6
+ import { parseJsonlMeta } from './parse-jsonl.ts';
7
+ import { searchSessionFile, type SearchSessionOpts } from './search-session.ts';
8
+ import type { SearchBlockKind, SearchEvent, SearchSessionHit } from '../types.ts';
9
+
10
+ const JSONL_EXT = '.jsonl';
11
+
12
+ export interface SearchAllOpts {
13
+ query: string;
14
+ include: ReadonlySet<SearchBlockKind>;
15
+ perSession: number;
16
+ maxSessions: number;
17
+ }
18
+
19
+ interface SessionFileEntry {
20
+ projectId: string;
21
+ sessionId: string;
22
+ filePath: string;
23
+ mtimeMs: number;
24
+ }
25
+
26
+ export async function* searchAll(opts: SearchAllOpts): AsyncGenerator<SearchEvent> {
27
+ const startedAt = Date.now();
28
+ const files = enumerateSessionFiles();
29
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs);
30
+
31
+ const sessionOpts: SearchSessionOpts = {
32
+ include: opts.include,
33
+ perSession: opts.perSession,
34
+ };
35
+
36
+ let scanned = 0;
37
+ let matched = 0;
38
+ let truncated = false;
39
+
40
+ for (const entry of files) {
41
+ if (matched >= opts.maxSessions) {
42
+ truncated = true;
43
+ break;
44
+ }
45
+ scanned++;
46
+
47
+ let result;
48
+ try {
49
+ result = await searchSessionFile(entry.filePath, opts.query, sessionOpts);
50
+ } catch {
51
+ continue;
52
+ }
53
+ if (result.snippets.length === 0) continue;
54
+
55
+ let meta;
56
+ try {
57
+ meta = await parseJsonlMeta(entry.filePath);
58
+ } catch {
59
+ continue;
60
+ }
61
+
62
+ matched++;
63
+ const decodedCwd = meta.cwdFromMessages ?? decodeCwd(entry.projectId);
64
+ const hit: SearchSessionHit = {
65
+ type: 'session',
66
+ projectId: entry.projectId,
67
+ sessionId: entry.sessionId,
68
+ projectDecodedCwd: decodedCwd,
69
+ title: meta.title,
70
+ customTitle: meta.customTitle,
71
+ lastAt: meta.lastAt,
72
+ hasMore: result.hasMore,
73
+ snippets: result.snippets,
74
+ };
75
+ yield hit;
76
+ }
77
+
78
+ yield {
79
+ type: 'done',
80
+ scanned,
81
+ matched,
82
+ durationMs: Date.now() - startedAt,
83
+ truncated,
84
+ };
85
+ }
86
+
87
+ function enumerateSessionFiles(): SessionFileEntry[] {
88
+ if (!fs.existsSync(PATHS.projects)) return [];
89
+ const out: SessionFileEntry[] = [];
90
+
91
+ let projects: fs.Dirent[];
92
+ try {
93
+ projects = fs.readdirSync(PATHS.projects, { withFileTypes: true });
94
+ } catch {
95
+ return out;
96
+ }
97
+
98
+ for (const projEnt of projects) {
99
+ if (!projEnt.isDirectory()) continue;
100
+ if (!isSafeId(projEnt.name)) continue;
101
+ const projectId = projEnt.name;
102
+ const projectDir = path.join(PATHS.projects, projectId);
103
+ if (!isUnderClaudeRoot(projectDir)) continue;
104
+
105
+ let files: fs.Dirent[];
106
+ try {
107
+ files = fs.readdirSync(projectDir, { withFileTypes: true });
108
+ } catch {
109
+ continue;
110
+ }
111
+
112
+ for (const fileEnt of files) {
113
+ if (!fileEnt.isFile() || !fileEnt.name.endsWith(JSONL_EXT)) continue;
114
+ const sessionId = fileEnt.name.slice(0, -JSONL_EXT.length);
115
+ if (!isSafeId(sessionId)) continue;
116
+ const filePath = path.join(projectDir, fileEnt.name);
117
+ if (!isUnderClaudeRoot(filePath)) continue;
118
+
119
+ let mtimeMs = 0;
120
+ try {
121
+ mtimeMs = fs.statSync(filePath).mtimeMs;
122
+ } catch {
123
+ continue;
124
+ }
125
+ out.push({ projectId, sessionId, filePath, mtimeMs });
126
+ }
127
+ }
128
+
129
+ return out;
130
+ }
@@ -1,203 +1,203 @@
1
- import fs from 'node:fs';
2
- import readline from 'node:readline';
3
- import type { SearchBlockKind, SearchSnippet } from '../types.ts';
4
-
5
- export interface SearchSessionResult {
6
- snippets: SearchSnippet[];
7
- hasMore: boolean;
8
- }
9
-
10
- export interface SearchSessionOpts {
11
- include: ReadonlySet<SearchBlockKind>;
12
- perSession: number;
13
- windowChars?: number;
14
- }
15
-
16
- const DEFAULT_WINDOW = 60;
17
- const ADJACENT_GAP = 120;
18
-
19
- export async function searchSessionFile(
20
- filePath: string,
21
- pattern: string,
22
- opts: SearchSessionOpts,
23
- ): Promise<SearchSessionResult> {
24
- const windowChars = opts.windowChars ?? DEFAULT_WINDOW;
25
- const snippets: SearchSnippet[] = [];
26
- let hasMore = false;
27
-
28
- const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
29
- const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
30
-
31
- try {
32
- for await (const raw of rl) {
33
- if (snippets.length >= opts.perSession) {
34
- hasMore = true;
35
- break;
36
- }
37
- const line = raw.trim();
38
- if (!line) continue;
39
-
40
- let obj: Record<string, unknown>;
41
- try {
42
- obj = JSON.parse(line) as Record<string, unknown>;
43
- } catch {
44
- continue;
45
- }
46
-
47
- if (obj.type !== 'user' && obj.type !== 'assistant') continue;
48
- const role = obj.type;
49
- const uuid = typeof obj.uuid === 'string' ? obj.uuid : '';
50
- if (!uuid) continue;
51
- const ts = typeof obj.timestamp === 'string' ? obj.timestamp : null;
52
- const message = (obj.message ?? {}) as { content?: unknown };
53
-
54
- const blocks = extractSearchableBlocks(message.content, opts.include);
55
- for (const block of blocks) {
56
- if (snippets.length >= opts.perSession) {
57
- hasMore = true;
58
- break;
59
- }
60
- const matches = findMatches(block.text, pattern);
61
- for (const match of matches) {
62
- if (snippets.length >= opts.perSession) {
63
- hasMore = true;
64
- break;
65
- }
66
- snippets.push({
67
- uuid,
68
- ts,
69
- role,
70
- blockKind: block.kind,
71
- ...sliceWindow(block.text, match.start, match.end, windowChars),
72
- });
73
- }
74
- }
75
- }
76
- } finally {
77
- rl.close();
78
- stream.destroy();
79
- }
80
-
81
- return { snippets, hasMore };
82
- }
83
-
84
- interface SearchableBlock {
85
- kind: SearchBlockKind;
86
- text: string;
87
- }
88
-
89
- function extractSearchableBlocks(
90
- content: unknown,
91
- include: ReadonlySet<SearchBlockKind>,
92
- ): SearchableBlock[] {
93
- if (typeof content === 'string') {
94
- return include.has('text') && content ? [{ kind: 'text', text: content }] : [];
95
- }
96
- if (!Array.isArray(content)) return [];
97
-
98
- const out: SearchableBlock[] = [];
99
- for (const raw of content) {
100
- if (!raw || typeof raw !== 'object') continue;
101
- const b = raw as Record<string, unknown>;
102
- switch (b.type) {
103
- case 'text':
104
- if (include.has('text') && typeof b.text === 'string' && b.text) {
105
- out.push({ kind: 'text', text: b.text });
106
- }
107
- break;
108
- case 'tool_use':
109
- if (include.has('tool_use')) {
110
- const name = typeof b.name === 'string' ? b.name : '';
111
- let inputStr = '';
112
- try {
113
- inputStr = b.input == null ? '' : JSON.stringify(b.input);
114
- } catch {
115
- inputStr = '';
116
- }
117
- const text = inputStr ? `${name} ${inputStr}` : name;
118
- if (text) out.push({ kind: 'tool_use', text });
119
- }
120
- break;
121
- case 'tool_result':
122
- if (include.has('tool_result')) {
123
- const text = stringifyToolResult(b.content);
124
- if (text) out.push({ kind: 'tool_result', text });
125
- }
126
- break;
127
- case 'thinking':
128
- if (include.has('thinking') && typeof b.thinking === 'string' && b.thinking) {
129
- out.push({ kind: 'thinking', text: b.thinking });
130
- }
131
- break;
132
- }
133
- }
134
- return out;
135
- }
136
-
137
- function stringifyToolResult(content: unknown): string {
138
- if (typeof content === 'string') return content;
139
- if (Array.isArray(content)) {
140
- return content
141
- .map((b) => {
142
- if (b && typeof b === 'object' && (b as { type?: unknown }).type === 'text') {
143
- const t = (b as { text?: unknown }).text;
144
- return typeof t === 'string' ? t : '';
145
- }
146
- return '';
147
- })
148
- .filter(Boolean)
149
- .join('\n');
150
- }
151
- return '';
152
- }
153
-
154
- interface MatchPos {
155
- start: number;
156
- end: number;
157
- }
158
-
159
- function findMatches(text: string, pattern: string): MatchPos[] {
160
- const out: MatchPos[] = [];
161
- if (!pattern) return out;
162
- const lower = text.toLowerCase();
163
- const needle = pattern.toLowerCase();
164
- let from = 0;
165
- let lastEnd = -ADJACENT_GAP - 1;
166
- while (from <= lower.length) {
167
- const idx = lower.indexOf(needle, from);
168
- if (idx === -1) break;
169
- const end = idx + needle.length;
170
- if (idx - lastEnd >= ADJACENT_GAP) {
171
- out.push({ start: idx, end });
172
- lastEnd = end;
173
- }
174
- from = end > from ? end : from + 1;
175
- }
176
- return out;
177
- }
178
-
179
- function sliceWindow(
180
- text: string,
181
- start: number,
182
- end: number,
183
- windowChars: number,
184
- ): { before: string; match: string; after: string } {
185
- let beforeStart = Math.max(0, start - windowChars);
186
- let afterEnd = Math.min(text.length, end + windowChars);
187
-
188
- if (beforeStart > 0) {
189
- const ws = text.lastIndexOf(' ', beforeStart);
190
- if (ws !== -1 && start - ws < windowChars * 2) beforeStart = ws + 1;
191
- }
192
- if (afterEnd < text.length) {
193
- const ws = text.indexOf(' ', afterEnd);
194
- if (ws !== -1 && ws - end < windowChars * 2) afterEnd = ws;
195
- }
196
-
197
- let before = text.slice(beforeStart, start).replace(/\s+/g, ' ');
198
- let after = text.slice(end, afterEnd).replace(/\s+/g, ' ');
199
- if (beforeStart > 0) before = '… ' + before;
200
- if (afterEnd < text.length) after = after + ' …';
201
-
202
- return { before, match: text.slice(start, end), after };
203
- }
1
+ import fs from 'node:fs';
2
+ import readline from 'node:readline';
3
+ import type { SearchBlockKind, SearchSnippet } from '../types.ts';
4
+
5
+ export interface SearchSessionResult {
6
+ snippets: SearchSnippet[];
7
+ hasMore: boolean;
8
+ }
9
+
10
+ export interface SearchSessionOpts {
11
+ include: ReadonlySet<SearchBlockKind>;
12
+ perSession: number;
13
+ windowChars?: number;
14
+ }
15
+
16
+ const DEFAULT_WINDOW = 60;
17
+ const ADJACENT_GAP = 120;
18
+
19
+ export async function searchSessionFile(
20
+ filePath: string,
21
+ pattern: string,
22
+ opts: SearchSessionOpts,
23
+ ): Promise<SearchSessionResult> {
24
+ const windowChars = opts.windowChars ?? DEFAULT_WINDOW;
25
+ const snippets: SearchSnippet[] = [];
26
+ let hasMore = false;
27
+
28
+ const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
29
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
30
+
31
+ try {
32
+ for await (const raw of rl) {
33
+ if (snippets.length >= opts.perSession) {
34
+ hasMore = true;
35
+ break;
36
+ }
37
+ const line = raw.trim();
38
+ if (!line) continue;
39
+
40
+ let obj: Record<string, unknown>;
41
+ try {
42
+ obj = JSON.parse(line) as Record<string, unknown>;
43
+ } catch {
44
+ continue;
45
+ }
46
+
47
+ if (obj.type !== 'user' && obj.type !== 'assistant') continue;
48
+ const role = obj.type;
49
+ const uuid = typeof obj.uuid === 'string' ? obj.uuid : '';
50
+ if (!uuid) continue;
51
+ const ts = typeof obj.timestamp === 'string' ? obj.timestamp : null;
52
+ const message = (obj.message ?? {}) as { content?: unknown };
53
+
54
+ const blocks = extractSearchableBlocks(message.content, opts.include);
55
+ for (const block of blocks) {
56
+ if (snippets.length >= opts.perSession) {
57
+ hasMore = true;
58
+ break;
59
+ }
60
+ const matches = findMatches(block.text, pattern);
61
+ for (const match of matches) {
62
+ if (snippets.length >= opts.perSession) {
63
+ hasMore = true;
64
+ break;
65
+ }
66
+ snippets.push({
67
+ uuid,
68
+ ts,
69
+ role,
70
+ blockKind: block.kind,
71
+ ...sliceWindow(block.text, match.start, match.end, windowChars),
72
+ });
73
+ }
74
+ }
75
+ }
76
+ } finally {
77
+ rl.close();
78
+ stream.destroy();
79
+ }
80
+
81
+ return { snippets, hasMore };
82
+ }
83
+
84
+ interface SearchableBlock {
85
+ kind: SearchBlockKind;
86
+ text: string;
87
+ }
88
+
89
+ function extractSearchableBlocks(
90
+ content: unknown,
91
+ include: ReadonlySet<SearchBlockKind>,
92
+ ): SearchableBlock[] {
93
+ if (typeof content === 'string') {
94
+ return include.has('text') && content ? [{ kind: 'text', text: content }] : [];
95
+ }
96
+ if (!Array.isArray(content)) return [];
97
+
98
+ const out: SearchableBlock[] = [];
99
+ for (const raw of content) {
100
+ if (!raw || typeof raw !== 'object') continue;
101
+ const b = raw as Record<string, unknown>;
102
+ switch (b.type) {
103
+ case 'text':
104
+ if (include.has('text') && typeof b.text === 'string' && b.text) {
105
+ out.push({ kind: 'text', text: b.text });
106
+ }
107
+ break;
108
+ case 'tool_use':
109
+ if (include.has('tool_use')) {
110
+ const name = typeof b.name === 'string' ? b.name : '';
111
+ let inputStr = '';
112
+ try {
113
+ inputStr = b.input == null ? '' : JSON.stringify(b.input);
114
+ } catch {
115
+ inputStr = '';
116
+ }
117
+ const text = inputStr ? `${name} ${inputStr}` : name;
118
+ if (text) out.push({ kind: 'tool_use', text });
119
+ }
120
+ break;
121
+ case 'tool_result':
122
+ if (include.has('tool_result')) {
123
+ const text = stringifyToolResult(b.content);
124
+ if (text) out.push({ kind: 'tool_result', text });
125
+ }
126
+ break;
127
+ case 'thinking':
128
+ if (include.has('thinking') && typeof b.thinking === 'string' && b.thinking) {
129
+ out.push({ kind: 'thinking', text: b.thinking });
130
+ }
131
+ break;
132
+ }
133
+ }
134
+ return out;
135
+ }
136
+
137
+ function stringifyToolResult(content: unknown): string {
138
+ if (typeof content === 'string') return content;
139
+ if (Array.isArray(content)) {
140
+ return content
141
+ .map((b) => {
142
+ if (b && typeof b === 'object' && (b as { type?: unknown }).type === 'text') {
143
+ const t = (b as { text?: unknown }).text;
144
+ return typeof t === 'string' ? t : '';
145
+ }
146
+ return '';
147
+ })
148
+ .filter(Boolean)
149
+ .join('\n');
150
+ }
151
+ return '';
152
+ }
153
+
154
+ interface MatchPos {
155
+ start: number;
156
+ end: number;
157
+ }
158
+
159
+ function findMatches(text: string, pattern: string): MatchPos[] {
160
+ const out: MatchPos[] = [];
161
+ if (!pattern) return out;
162
+ const lower = text.toLowerCase();
163
+ const needle = pattern.toLowerCase();
164
+ let from = 0;
165
+ let lastEnd = -ADJACENT_GAP - 1;
166
+ while (from <= lower.length) {
167
+ const idx = lower.indexOf(needle, from);
168
+ if (idx === -1) break;
169
+ const end = idx + needle.length;
170
+ if (idx - lastEnd >= ADJACENT_GAP) {
171
+ out.push({ start: idx, end });
172
+ lastEnd = end;
173
+ }
174
+ from = end > from ? end : from + 1;
175
+ }
176
+ return out;
177
+ }
178
+
179
+ function sliceWindow(
180
+ text: string,
181
+ start: number,
182
+ end: number,
183
+ windowChars: number,
184
+ ): { before: string; match: string; after: string } {
185
+ let beforeStart = Math.max(0, start - windowChars);
186
+ let afterEnd = Math.min(text.length, end + windowChars);
187
+
188
+ if (beforeStart > 0) {
189
+ const ws = text.lastIndexOf(' ', beforeStart);
190
+ if (ws !== -1 && start - ws < windowChars * 2) beforeStart = ws + 1;
191
+ }
192
+ if (afterEnd < text.length) {
193
+ const ws = text.indexOf(' ', afterEnd);
194
+ if (ws !== -1 && ws - end < windowChars * 2) afterEnd = ws;
195
+ }
196
+
197
+ let before = text.slice(beforeStart, start).replace(/\s+/g, ' ');
198
+ let after = text.slice(end, afterEnd).replace(/\s+/g, ' ');
199
+ if (beforeStart > 0) before = '… ' + before;
200
+ if (afterEnd < text.length) after = after + ' …';
201
+
202
+ return { before, match: text.slice(start, end), after };
203
+ }