@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,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
+ }