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