synabun 2026.3.304 → 2026.3.305

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.
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Session Chunker — Pure function that splits parsed JSONL lines into task-boundary chunks.
3
+ * No I/O, no external dependencies. Fully testable.
4
+ */
5
+
6
+ const TASK_BOUNDARY_GAP_MS = 120_000; // 2 minutes between human turns = new task
7
+ const MAX_CONTENT_CHARS = 8000; // Max synthesized content per chunk
8
+ const MAX_USER_MESSAGES = 5; // Max user messages to include in synthesis
9
+ const MAX_USER_MSG_CHARS = 200; // Max chars per user message in synthesis
10
+ const MAX_ASSISTANT_CHARS = 500; // Max chars for assistant text per chunk
11
+
12
+ /**
13
+ * @typedef {Object} ParsedLine
14
+ * @property {string} type - queue-operation | file-history-snapshot | progress | user | assistant
15
+ * @property {string} [sessionId]
16
+ * @property {string} [cwd]
17
+ * @property {string} [gitBranch]
18
+ * @property {string} [timestamp]
19
+ * @property {string} [uuid]
20
+ * @property {boolean} [isMeta]
21
+ * @property {boolean} [isSidechain]
22
+ * @property {Object} [message]
23
+ * @property {Object} [toolUseResult]
24
+ * @property {string} [sourceToolAssistantUUID]
25
+ */
26
+
27
+ /**
28
+ * @typedef {Object} RawChunk
29
+ * @property {number} chunkIndex
30
+ * @property {string} startTimestamp
31
+ * @property {string} endTimestamp
32
+ * @property {string[]} userMessages - Cleaned human prompts
33
+ * @property {string[]} assistantTexts - Assistant response snippets
34
+ * @property {string[]} toolsUsed
35
+ * @property {string[]} filesModified
36
+ * @property {string[]} filesRead
37
+ * @property {number} turnCount - Human turns in this chunk
38
+ * @property {string} content - Synthesized text for embedding
39
+ * @property {string} summary - One-line human-readable summary
40
+ * @property {number} startLineIndex
41
+ * @property {number} endLineIndex
42
+ */
43
+
44
+ /**
45
+ * Check if a user message is a real human prompt (not a tool result or meta).
46
+ */
47
+ function isHumanMessage(line) {
48
+ if (line.type !== 'user') return false;
49
+ if (line.isMeta) return false;
50
+ if (line.toolUseResult || line.sourceToolAssistantUUID) return false;
51
+ // Check content for actual text (not just tool results)
52
+ const content = line.message?.content;
53
+ if (!content) return false;
54
+ if (Array.isArray(content)) {
55
+ return content.some(block =>
56
+ block.type === 'text' && block.text && block.text.length > 5
57
+ );
58
+ }
59
+ return typeof content === 'string' && content.length > 5;
60
+ }
61
+
62
+ /**
63
+ * Extract clean text from a user message, stripping IDE/system tags.
64
+ */
65
+ function extractUserText(line) {
66
+ const content = line.message?.content;
67
+ if (!content) return '';
68
+ const blocks = Array.isArray(content) ? content : [{ type: 'text', text: content }];
69
+ const texts = [];
70
+ for (const block of blocks) {
71
+ if (block.type !== 'text' || !block.text) continue;
72
+ // Strip system-reminder, ide_opened_file, and antml tags
73
+ let text = block.text
74
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
75
+ .replace(/<ide_opened_file>[\s\S]*?<\/ide_opened_file>/g, '')
76
+ .replace(/<[\s\S]*?<\/antml:[^>]+>/g, '')
77
+ .trim();
78
+ if (text.length > 5) texts.push(text);
79
+ }
80
+ return texts.join(' ').trim();
81
+ }
82
+
83
+ /**
84
+ * Extract assistant text blocks from an assistant message.
85
+ */
86
+ function extractAssistantText(line) {
87
+ const content = line.message?.content;
88
+ if (!content || !Array.isArray(content)) return '';
89
+ const texts = [];
90
+ for (const block of content) {
91
+ if (block.type === 'text' && block.text) {
92
+ texts.push(block.text);
93
+ }
94
+ // Skip 'thinking' blocks — internal reasoning
95
+ }
96
+ return texts.join(' ').trim();
97
+ }
98
+
99
+ /**
100
+ * Extract tool use info from an assistant message.
101
+ */
102
+ function extractToolUses(line) {
103
+ const content = line.message?.content;
104
+ if (!content || !Array.isArray(content)) return [];
105
+ const tools = [];
106
+ for (const block of content) {
107
+ if (block.type === 'tool_use' && block.name) {
108
+ const tool = { name: block.name, input: block.input || {} };
109
+ tools.push(tool);
110
+ }
111
+ }
112
+ return tools;
113
+ }
114
+
115
+ /**
116
+ * Extract file paths from tool use inputs.
117
+ */
118
+ function extractFilesFromToolUse(toolUse) {
119
+ const modified = [];
120
+ const read = [];
121
+ const name = toolUse.name;
122
+ const input = toolUse.input || {};
123
+
124
+ if (name === 'Write' || name === 'Edit' || name === 'NotebookEdit') {
125
+ const fp = input.file_path || input.notebook_path;
126
+ if (fp) modified.push(normalizePath(fp));
127
+ } else if (name === 'Read') {
128
+ const fp = input.file_path;
129
+ if (fp) read.push(normalizePath(fp));
130
+ } else if (name === 'Grep' || name === 'Glob') {
131
+ // These search but don't read specific files
132
+ }
133
+
134
+ return { modified, read };
135
+ }
136
+
137
+ /**
138
+ * Normalize a file path for display (strip drive letter prefix, use forward slashes).
139
+ */
140
+ function normalizePath(fp) {
141
+ return fp.replace(/\\/g, '/').replace(/^[A-Za-z]:/, '');
142
+ }
143
+
144
+ /**
145
+ * Normalize tool name (strip mcp__ prefix for display).
146
+ */
147
+ function normalizeToolName(name) {
148
+ return name.replace(/^mcp__\w+__/, '');
149
+ }
150
+
151
+ /**
152
+ * Parse a single JSONL line safely.
153
+ */
154
+ export function parseLine(line) {
155
+ if (!line || !line.trim()) return null;
156
+ try {
157
+ return JSON.parse(line);
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Main chunking function.
165
+ * @param {ParsedLine[]} lines - Parsed JSONL objects (already JSON.parse'd)
166
+ * @param {Object} sessionMeta - { sessionId, project, gitBranch, cwd }
167
+ * @returns {RawChunk[]}
168
+ */
169
+ export function chunkSession(lines, sessionMeta) {
170
+ const chunks = [];
171
+ let current = createEmptyChunk(0);
172
+ let lastTimestamp = null;
173
+
174
+ for (let i = 0; i < lines.length; i++) {
175
+ const line = lines[i];
176
+ if (!line) continue;
177
+
178
+ // Skip non-content types
179
+ if (line.type === 'queue-operation' || line.type === 'file-history-snapshot' || line.type === 'progress') {
180
+ continue;
181
+ }
182
+
183
+ // Skip sidechains (subagent messages logged in main transcript)
184
+ if (line.isSidechain) continue;
185
+
186
+ const ts = line.timestamp;
187
+
188
+ if (isHumanMessage(line)) {
189
+ // Check for task boundary: time gap > threshold
190
+ if (lastTimestamp && ts) {
191
+ const gap = new Date(ts).getTime() - new Date(lastTimestamp).getTime();
192
+ if (gap > TASK_BOUNDARY_GAP_MS && current.turnCount > 0) {
193
+ chunks.push(finalizeChunk(current, sessionMeta));
194
+ current = createEmptyChunk(chunks.length);
195
+ }
196
+ }
197
+
198
+ // Check for interruption pattern in content
199
+ const userText = extractUserText(line);
200
+ if (userText) {
201
+ current.userMessages.push(userText.slice(0, MAX_USER_MSG_CHARS));
202
+ current.turnCount++;
203
+ }
204
+ if (ts && !current.startTimestamp) current.startTimestamp = ts;
205
+ if (ts) current.endTimestamp = ts;
206
+ current.startLineIndex = Math.min(current.startLineIndex, i);
207
+ current.endLineIndex = i;
208
+
209
+ } else if (line.type === 'user' && line.isMeta) {
210
+ // Skip meta messages
211
+ continue;
212
+
213
+ } else if (line.type === 'user' && (line.toolUseResult || line.sourceToolAssistantUUID)) {
214
+ // Tool result — extract file info from toolUseResult
215
+ if (ts) current.endTimestamp = ts;
216
+ current.endLineIndex = i;
217
+
218
+ } else if (line.type === 'assistant') {
219
+ // Extract assistant text
220
+ const assistantText = extractAssistantText(line);
221
+ if (assistantText) {
222
+ current.assistantTexts.push(assistantText);
223
+ }
224
+
225
+ // Extract tool uses
226
+ const toolUses = extractToolUses(line);
227
+ for (const tu of toolUses) {
228
+ const toolName = normalizeToolName(tu.name);
229
+ if (!current._toolCounts[toolName]) current._toolCounts[toolName] = 0;
230
+ current._toolCounts[toolName]++;
231
+
232
+ const files = extractFilesFromToolUse(tu);
233
+ for (const f of files.modified) {
234
+ if (!current.filesModified.includes(f)) current.filesModified.push(f);
235
+ }
236
+ for (const f of files.read) {
237
+ if (!current.filesRead.includes(f)) current.filesRead.push(f);
238
+ }
239
+ }
240
+
241
+ if (ts && !current.startTimestamp) current.startTimestamp = ts;
242
+ if (ts) {
243
+ current.endTimestamp = ts;
244
+ lastTimestamp = ts;
245
+ }
246
+ current.endLineIndex = i;
247
+ }
248
+ }
249
+
250
+ // Finalize last chunk if it has content
251
+ if (current.turnCount > 0) {
252
+ chunks.push(finalizeChunk(current, sessionMeta));
253
+ }
254
+
255
+ // Post-process: split oversized chunks
256
+ const result = [];
257
+ for (const chunk of chunks) {
258
+ if (chunk.content.length > MAX_CONTENT_CHARS) {
259
+ // For now, just truncate — splitting at tool boundaries is complex
260
+ chunk.content = chunk.content.slice(0, MAX_CONTENT_CHARS);
261
+ }
262
+ result.push(chunk);
263
+ }
264
+
265
+ return result;
266
+ }
267
+
268
+ function createEmptyChunk(index) {
269
+ return {
270
+ chunkIndex: index,
271
+ startTimestamp: '',
272
+ endTimestamp: '',
273
+ userMessages: [],
274
+ assistantTexts: [],
275
+ toolsUsed: [],
276
+ filesModified: [],
277
+ filesRead: [],
278
+ turnCount: 0,
279
+ content: '',
280
+ summary: '',
281
+ startLineIndex: Infinity,
282
+ endLineIndex: 0,
283
+ _toolCounts: {}, // internal, removed during finalize
284
+ };
285
+ }
286
+
287
+ function finalizeChunk(chunk, sessionMeta) {
288
+ // Build tools_used from counts
289
+ chunk.toolsUsed = Object.entries(chunk._toolCounts)
290
+ .sort((a, b) => b[1] - a[1])
291
+ .map(([name, count]) => name);
292
+
293
+ // Build tool counts string for synthesis
294
+ const toolCountStr = Object.entries(chunk._toolCounts)
295
+ .sort((a, b) => b[1] - a[1])
296
+ .map(([name, count]) => `${name}(${count})`)
297
+ .join(', ');
298
+
299
+ // Build synthesized content for embedding
300
+ const parts = [];
301
+
302
+ // Header
303
+ const date = chunk.startTimestamp ? new Date(chunk.startTimestamp).toISOString().split('T')[0] : 'unknown';
304
+ parts.push(`[Project: ${sessionMeta.project || 'unknown'} | Branch: ${sessionMeta.gitBranch || 'unknown'} | ${date}]`);
305
+
306
+ // User messages (max 5)
307
+ const userMsgs = chunk.userMessages.slice(0, MAX_USER_MESSAGES);
308
+ for (const msg of userMsgs) {
309
+ parts.push(`USER: "${msg}"`);
310
+ }
311
+
312
+ // Assistant summary (combine and truncate)
313
+ const fullAssistant = chunk.assistantTexts.join(' ');
314
+ if (fullAssistant) {
315
+ const truncated = fullAssistant.slice(0, MAX_ASSISTANT_CHARS);
316
+ parts.push(`ASSISTANT: ${truncated}`);
317
+ }
318
+
319
+ // Tools and files
320
+ if (toolCountStr) {
321
+ parts.push(`TOOLS: ${toolCountStr}`);
322
+ }
323
+ if (chunk.filesModified.length > 0) {
324
+ parts.push(`FILES MODIFIED: ${chunk.filesModified.slice(0, 10).join(', ')}`);
325
+ }
326
+ if (chunk.filesRead.length > 0) {
327
+ parts.push(`FILES READ: ${chunk.filesRead.slice(0, 10).join(', ')}`);
328
+ }
329
+
330
+ chunk.content = parts.join('\n');
331
+
332
+ // Build summary (first user message, truncated)
333
+ chunk.summary = userMsgs[0]
334
+ ? userMsgs[0].slice(0, 100)
335
+ : (fullAssistant ? fullAssistant.slice(0, 100) : 'No content');
336
+
337
+ // Clean up internals
338
+ delete chunk._toolCounts;
339
+
340
+ // Limit user messages for storage
341
+ chunk.userMessages = userMsgs;
342
+
343
+ return chunk;
344
+ }