pi-sessions 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/extensions/session-ask.ts +297 -0
- package/extensions/session-auto-title/command.ts +109 -0
- package/extensions/session-auto-title/context.ts +41 -0
- package/extensions/session-auto-title/controller.ts +298 -0
- package/extensions/session-auto-title/model.ts +61 -0
- package/extensions/session-auto-title/prompt.ts +54 -0
- package/extensions/session-auto-title/retitle.ts +641 -0
- package/extensions/session-auto-title/state.ts +69 -0
- package/extensions/session-auto-title/wizard.ts +583 -0
- package/extensions/session-auto-title.ts +209 -0
- package/extensions/session-handoff/extract.ts +278 -0
- package/extensions/session-handoff/metadata.ts +96 -0
- package/extensions/session-handoff/picker.ts +394 -0
- package/extensions/session-handoff/query.ts +318 -0
- package/extensions/session-handoff/refs.ts +151 -0
- package/extensions/session-handoff/review.ts +268 -0
- package/extensions/session-handoff.ts +203 -0
- package/extensions/session-hooks.ts +55 -0
- package/extensions/session-index.ts +159 -0
- package/extensions/session-search/extract.ts +997 -0
- package/extensions/session-search/hooks.ts +350 -0
- package/extensions/session-search/normalize.ts +170 -0
- package/extensions/session-search/reindex.ts +93 -0
- package/extensions/session-search.ts +390 -0
- package/extensions/shared/search-snippet.ts +40 -0
- package/extensions/shared/session-index/common.ts +222 -0
- package/extensions/shared/session-index/index.ts +5 -0
- package/extensions/shared/session-index/lineage.ts +417 -0
- package/extensions/shared/session-index/schema.ts +178 -0
- package/extensions/shared/session-index/search.ts +688 -0
- package/extensions/shared/session-index/store.ts +173 -0
- package/extensions/shared/session-ui.ts +15 -0
- package/extensions/shared/settings.ts +141 -0
- package/extensions/shared/time.ts +38 -0
- package/extensions/shared/typebox.ts +61 -0
- package/images/handoff.png +0 -0
- package/images/session-title.png +0 -0
- package/images/session_ask.png +0 -0
- package/images/session_picker.png +0 -0
- package/images/session_search.png +0 -0
- package/package.json +64 -0
|
@@ -0,0 +1,997 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
4
|
+
import type { AssistantMessage, ToolCall, ToolResultMessage } from "@mariozechner/pi-ai";
|
|
5
|
+
import {
|
|
6
|
+
type CustomEntry,
|
|
7
|
+
parseSessionEntries,
|
|
8
|
+
type SessionEntry,
|
|
9
|
+
type SessionHeader,
|
|
10
|
+
type SessionMessageEntry,
|
|
11
|
+
} from "@mariozechner/pi-coding-agent";
|
|
12
|
+
import { Type } from "@sinclair/typebox";
|
|
13
|
+
import {
|
|
14
|
+
HANDOFF_METADATA_CUSTOM_TYPE,
|
|
15
|
+
type HandoffSessionMetadata,
|
|
16
|
+
parseHandoffSessionMetadata,
|
|
17
|
+
} from "../session-handoff/metadata.js";
|
|
18
|
+
import type { SessionOrigin } from "../shared/session-index/index.js";
|
|
19
|
+
import { safeParseTypeBoxValue } from "../shared/typebox.js";
|
|
20
|
+
import {
|
|
21
|
+
deriveSessionRepoRoots,
|
|
22
|
+
type FileTouchOp,
|
|
23
|
+
type FileTouchSource,
|
|
24
|
+
normalizePathRecord,
|
|
25
|
+
type PathScope,
|
|
26
|
+
} from "./normalize.js";
|
|
27
|
+
|
|
28
|
+
export interface SearchTextChunk {
|
|
29
|
+
entryId?: string | undefined;
|
|
30
|
+
entryType: string;
|
|
31
|
+
role?: string | undefined;
|
|
32
|
+
ts: string;
|
|
33
|
+
sourceKind: string;
|
|
34
|
+
text: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface DurableHandoffMetadataRecord {
|
|
38
|
+
entryId?: string | undefined;
|
|
39
|
+
ts: string;
|
|
40
|
+
metadata: HandoffSessionMetadata;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SessionFileTouch {
|
|
44
|
+
entryId?: string | undefined;
|
|
45
|
+
op: FileTouchOp;
|
|
46
|
+
source: FileTouchSource;
|
|
47
|
+
rawPath: string;
|
|
48
|
+
absPath?: string | undefined;
|
|
49
|
+
cwdRelPath?: string | undefined;
|
|
50
|
+
repoRoot?: string | undefined;
|
|
51
|
+
repoRelPath?: string | undefined;
|
|
52
|
+
basename: string;
|
|
53
|
+
pathScope: PathScope;
|
|
54
|
+
ts: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ExtractedSessionRecord {
|
|
58
|
+
sessionId: string;
|
|
59
|
+
sessionPath: string;
|
|
60
|
+
sessionName: string;
|
|
61
|
+
firstUserPrompt?: string | undefined;
|
|
62
|
+
cwd: string;
|
|
63
|
+
repoRoots: string[];
|
|
64
|
+
startedAt: string;
|
|
65
|
+
modifiedAt: string;
|
|
66
|
+
messageCount: number;
|
|
67
|
+
entryCount: number;
|
|
68
|
+
parentSessionPath?: string | undefined;
|
|
69
|
+
parentSessionId?: string | undefined;
|
|
70
|
+
sessionOrigin?: SessionOrigin | undefined;
|
|
71
|
+
handoffGoal?: string | undefined;
|
|
72
|
+
handoffNextTask?: string | undefined;
|
|
73
|
+
chunks: SearchTextChunk[];
|
|
74
|
+
fileTouches: SessionFileTouch[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ParsedSessionFile {
|
|
78
|
+
header: SessionHeader;
|
|
79
|
+
entries: SessionEntry[];
|
|
80
|
+
sessionName: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const TOOL_RESULT_TEXT_LIMIT = 500;
|
|
84
|
+
const BASH_OUTPUT_TEXT_LIMIT = 500;
|
|
85
|
+
const SUMMARY_DETAILS_SCHEMA = Type.Object({
|
|
86
|
+
readFiles: Type.Optional(Type.Array(Type.String())),
|
|
87
|
+
modifiedFiles: Type.Optional(Type.Array(Type.String())),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export function listSessionFiles(sessionsDir: string): string[] {
|
|
91
|
+
if (!existsSync(sessionsDir)) {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const results: string[] = [];
|
|
96
|
+
const stack = [sessionsDir];
|
|
97
|
+
|
|
98
|
+
while (stack.length > 0) {
|
|
99
|
+
const current = stack.pop();
|
|
100
|
+
if (!current) continue;
|
|
101
|
+
|
|
102
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
103
|
+
const absolute = path.join(current, entry.name);
|
|
104
|
+
if (entry.isDirectory()) {
|
|
105
|
+
stack.push(absolute);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
109
|
+
results.push(absolute);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return results.sort();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function extractSessionRecord(sessionPath: string): ExtractedSessionRecord | undefined {
|
|
118
|
+
const parsed = parseSessionFile(sessionPath);
|
|
119
|
+
if (!parsed) return undefined;
|
|
120
|
+
|
|
121
|
+
const fallbackTs = parsed.header.timestamp;
|
|
122
|
+
const chunks: SearchTextChunk[] = [];
|
|
123
|
+
const fileTouches: SessionFileTouch[] = [];
|
|
124
|
+
let modifiedAt = fallbackTs;
|
|
125
|
+
let messageCount = 0;
|
|
126
|
+
let firstUserPrompt: string | undefined;
|
|
127
|
+
let durableHandoffMetadata: DurableHandoffMetadataRecord | undefined;
|
|
128
|
+
|
|
129
|
+
if (parsed.sessionName) {
|
|
130
|
+
chunks.push({
|
|
131
|
+
entryType: "session_info",
|
|
132
|
+
ts: parsed.header.timestamp,
|
|
133
|
+
sourceKind: "session_name",
|
|
134
|
+
text: parsed.sessionName,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const entry of parsed.entries) {
|
|
139
|
+
const entryTs = getEntryTimestamp(entry, fallbackTs);
|
|
140
|
+
if (entryTs > modifiedAt) {
|
|
141
|
+
modifiedAt = entryTs;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
switch (entry.type) {
|
|
145
|
+
case "message": {
|
|
146
|
+
const { message } = entry;
|
|
147
|
+
|
|
148
|
+
messageCount += 1;
|
|
149
|
+
if (!firstUserPrompt && message.role === "user") {
|
|
150
|
+
firstUserPrompt = contentToText(message.content);
|
|
151
|
+
}
|
|
152
|
+
chunks.push(...extractMessageChunks(entry.id, entryTs, message));
|
|
153
|
+
fileTouches.push(
|
|
154
|
+
...extractMessageFileTouches(entry.id, entryTs, message, parsed.header.cwd),
|
|
155
|
+
);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
case "custom": {
|
|
159
|
+
const nextHandoffMetadata = extractHandoffMetadata(entry, entryTs);
|
|
160
|
+
if (nextHandoffMetadata) {
|
|
161
|
+
durableHandoffMetadata = nextHandoffMetadata;
|
|
162
|
+
}
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
case "custom_message":
|
|
166
|
+
appendEntryTextChunk(
|
|
167
|
+
chunks,
|
|
168
|
+
entry,
|
|
169
|
+
entryTs,
|
|
170
|
+
"custom_message",
|
|
171
|
+
contentToText(entry.content),
|
|
172
|
+
);
|
|
173
|
+
continue;
|
|
174
|
+
case "branch_summary": {
|
|
175
|
+
appendEntryTextChunk(chunks, entry, entryTs, "branch_summary", trimmedText(entry.summary));
|
|
176
|
+
fileTouches.push(
|
|
177
|
+
...extractDetailFileTouches(
|
|
178
|
+
entry.id,
|
|
179
|
+
entryTs,
|
|
180
|
+
"branch_summary_details",
|
|
181
|
+
entry.details,
|
|
182
|
+
parsed.header.cwd,
|
|
183
|
+
),
|
|
184
|
+
);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
case "compaction": {
|
|
188
|
+
appendEntryTextChunk(
|
|
189
|
+
chunks,
|
|
190
|
+
entry,
|
|
191
|
+
entryTs,
|
|
192
|
+
"compaction_summary",
|
|
193
|
+
trimmedText(entry.summary),
|
|
194
|
+
);
|
|
195
|
+
fileTouches.push(
|
|
196
|
+
...extractDetailFileTouches(
|
|
197
|
+
entry.id,
|
|
198
|
+
entryTs,
|
|
199
|
+
"compaction_details",
|
|
200
|
+
entry.details,
|
|
201
|
+
parsed.header.cwd,
|
|
202
|
+
),
|
|
203
|
+
);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
default:
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
appendDurableHandoffMetadataChunks(chunks, durableHandoffMetadata);
|
|
212
|
+
|
|
213
|
+
const parentSessionPath = normalizeParentSessionPath(parsed.header.parentSession);
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
sessionId: parsed.header.id,
|
|
217
|
+
sessionPath,
|
|
218
|
+
sessionName: parsed.sessionName,
|
|
219
|
+
firstUserPrompt: trimmedText(firstUserPrompt),
|
|
220
|
+
cwd: parsed.header.cwd,
|
|
221
|
+
repoRoots: deriveSessionRepoRoots(parsed.header.cwd, fileTouches),
|
|
222
|
+
startedAt: parsed.header.timestamp,
|
|
223
|
+
modifiedAt,
|
|
224
|
+
messageCount,
|
|
225
|
+
entryCount: parsed.entries.length,
|
|
226
|
+
parentSessionPath,
|
|
227
|
+
parentSessionId: parentSessionPath ? readSessionIdFromPath(parentSessionPath) : undefined,
|
|
228
|
+
sessionOrigin: inferSessionOrigin(parentSessionPath, durableHandoffMetadata?.metadata),
|
|
229
|
+
handoffGoal: durableHandoffMetadata?.metadata.goal,
|
|
230
|
+
handoffNextTask: durableHandoffMetadata?.metadata.nextTask,
|
|
231
|
+
chunks,
|
|
232
|
+
fileTouches,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function parseSessionFile(sessionPath: string): ParsedSessionFile | undefined {
|
|
237
|
+
const raw = readFileSync(sessionPath, "utf8");
|
|
238
|
+
const fileEntries = parseSessionEntries(raw);
|
|
239
|
+
if (fileEntries.length === 0) {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const header = fileEntries[0];
|
|
244
|
+
if (!header || header.type !== "session") {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const entries = fileEntries.slice(1).filter(isSessionEntry);
|
|
249
|
+
|
|
250
|
+
let sessionName = "";
|
|
251
|
+
for (const entry of entries) {
|
|
252
|
+
if (entry.type !== "session_info") {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (typeof entry.name === "string") {
|
|
257
|
+
sessionName = entry.name.trim();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { header, entries, sessionName };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function normalizeParentSessionPath(parentSession: string | undefined): string | undefined {
|
|
265
|
+
if (typeof parentSession !== "string") {
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const trimmed = parentSession.trim();
|
|
270
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function findDurableHandoffMetadata(
|
|
274
|
+
entries: SessionEntry[],
|
|
275
|
+
fallbackTs: string,
|
|
276
|
+
): DurableHandoffMetadataRecord | undefined {
|
|
277
|
+
let durableHandoffMetadata: DurableHandoffMetadataRecord | undefined;
|
|
278
|
+
|
|
279
|
+
for (const entry of entries) {
|
|
280
|
+
if (entry.type !== "custom") {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const parsed = extractHandoffMetadata(entry, getEntryTimestamp(entry, fallbackTs));
|
|
285
|
+
if (parsed) {
|
|
286
|
+
durableHandoffMetadata = parsed;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return durableHandoffMetadata;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function readSessionIdFromPath(sessionPath: string): string | undefined {
|
|
294
|
+
try {
|
|
295
|
+
const parsed = parseSessionFile(sessionPath);
|
|
296
|
+
return parsed?.header.id;
|
|
297
|
+
} catch {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function inferSessionOrigin(
|
|
303
|
+
parentSessionPath: string | undefined,
|
|
304
|
+
handoffMetadata: HandoffSessionMetadata | undefined,
|
|
305
|
+
): SessionOrigin | undefined {
|
|
306
|
+
if (!parentSessionPath) {
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return handoffMetadata?.origin === "handoff" ? "handoff" : "unknown_child";
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function extractHandoffMetadata(
|
|
314
|
+
entry: CustomEntry,
|
|
315
|
+
ts: string,
|
|
316
|
+
): DurableHandoffMetadataRecord | undefined {
|
|
317
|
+
if (entry.customType !== HANDOFF_METADATA_CUSTOM_TYPE) {
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const metadata = parseHandoffSessionMetadata(entry.data);
|
|
322
|
+
if (!metadata) {
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
entryId: entry.id,
|
|
328
|
+
ts,
|
|
329
|
+
metadata,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function appendDurableHandoffMetadataChunks(
|
|
334
|
+
chunks: SearchTextChunk[],
|
|
335
|
+
durableHandoffMetadata: DurableHandoffMetadataRecord | undefined,
|
|
336
|
+
): void {
|
|
337
|
+
if (!durableHandoffMetadata) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const { entryId, ts, metadata } = durableHandoffMetadata;
|
|
342
|
+
chunks.push(
|
|
343
|
+
createMetadataChunk(entryId, ts, "handoff_goal", metadata.goal),
|
|
344
|
+
createMetadataChunk(entryId, ts, "handoff_next_task", metadata.nextTask),
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function createMetadataChunk(
|
|
349
|
+
entryId: string | undefined,
|
|
350
|
+
ts: string,
|
|
351
|
+
sourceKind: string,
|
|
352
|
+
text: string,
|
|
353
|
+
): SearchTextChunk {
|
|
354
|
+
return {
|
|
355
|
+
entryId,
|
|
356
|
+
entryType: "custom",
|
|
357
|
+
ts,
|
|
358
|
+
sourceKind,
|
|
359
|
+
text,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function getEntryTimestamp(entry: SessionEntry, fallbackTs: string): string {
|
|
364
|
+
return entry.timestamp ?? fallbackTs;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function appendEntryTextChunk(
|
|
368
|
+
chunks: SearchTextChunk[],
|
|
369
|
+
entry: SessionEntry,
|
|
370
|
+
ts: string,
|
|
371
|
+
sourceKind: string,
|
|
372
|
+
text: string,
|
|
373
|
+
): void {
|
|
374
|
+
if (!text) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
chunks.push({
|
|
379
|
+
entryId: entry.id,
|
|
380
|
+
entryType: entry.type,
|
|
381
|
+
ts,
|
|
382
|
+
sourceKind,
|
|
383
|
+
text,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function extractMessageFileTouches(
|
|
388
|
+
entryId: string | undefined,
|
|
389
|
+
ts: string,
|
|
390
|
+
message: AgentMessage,
|
|
391
|
+
cwd: string,
|
|
392
|
+
): SessionFileTouch[] {
|
|
393
|
+
if (message.role !== "assistant" || !Array.isArray(message.content)) {
|
|
394
|
+
return [];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return message.content.filter(isToolCallBlock).flatMap((toolCall) => {
|
|
398
|
+
const rawPath = stringValue(toolCall.arguments?.path);
|
|
399
|
+
const op = getToolCallFileTouchOp(toolCall.name);
|
|
400
|
+
if (!rawPath || !op) {
|
|
401
|
+
return [];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return [createFileTouch(entryId, ts, op, "tool_call", rawPath, cwd)];
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function extractDetailFileTouches(
|
|
409
|
+
entryId: string | undefined,
|
|
410
|
+
ts: string,
|
|
411
|
+
source: FileTouchSource,
|
|
412
|
+
details: unknown,
|
|
413
|
+
cwd: string,
|
|
414
|
+
): SessionFileTouch[] {
|
|
415
|
+
const normalizedDetails = getSummaryDetails(details);
|
|
416
|
+
if (!normalizedDetails) {
|
|
417
|
+
return [];
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return [
|
|
421
|
+
...extractDetailFileTouchGroup(entryId, ts, source, "read", normalizedDetails.readFiles, cwd),
|
|
422
|
+
...extractDetailFileTouchGroup(
|
|
423
|
+
entryId,
|
|
424
|
+
ts,
|
|
425
|
+
source,
|
|
426
|
+
"changed",
|
|
427
|
+
normalizedDetails.modifiedFiles,
|
|
428
|
+
cwd,
|
|
429
|
+
),
|
|
430
|
+
];
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function extractDetailFileTouchGroup(
|
|
434
|
+
entryId: string | undefined,
|
|
435
|
+
ts: string,
|
|
436
|
+
source: FileTouchSource,
|
|
437
|
+
op: FileTouchOp,
|
|
438
|
+
paths: unknown,
|
|
439
|
+
cwd: string,
|
|
440
|
+
): SessionFileTouch[] {
|
|
441
|
+
if (!Array.isArray(paths)) {
|
|
442
|
+
return [];
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return paths
|
|
446
|
+
.filter(
|
|
447
|
+
(rawPath): rawPath is string => typeof rawPath === "string" && rawPath.trim().length > 0,
|
|
448
|
+
)
|
|
449
|
+
.map((rawPath) => createFileTouch(entryId, ts, op, source, rawPath, cwd));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function createFileTouch(
|
|
453
|
+
entryId: string | undefined,
|
|
454
|
+
ts: string,
|
|
455
|
+
op: FileTouchOp,
|
|
456
|
+
source: FileTouchSource,
|
|
457
|
+
rawPath: string,
|
|
458
|
+
cwd: string,
|
|
459
|
+
): SessionFileTouch {
|
|
460
|
+
return {
|
|
461
|
+
entryId,
|
|
462
|
+
source,
|
|
463
|
+
...normalizePathRecord(rawPath, cwd),
|
|
464
|
+
op,
|
|
465
|
+
ts,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function getToolCallFileTouchOp(toolName: string): FileTouchOp | undefined {
|
|
470
|
+
switch (toolName) {
|
|
471
|
+
case "read":
|
|
472
|
+
return "read";
|
|
473
|
+
case "edit":
|
|
474
|
+
case "write":
|
|
475
|
+
return "changed";
|
|
476
|
+
default:
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function trimmedText(value: unknown): string {
|
|
482
|
+
return typeof value === "string" ? value.trim() : "";
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function groupEntriesByParent(entries: SessionEntry[]): Map<string | null, SessionEntry[]> {
|
|
486
|
+
const byParent = new Map<string | null, SessionEntry[]>();
|
|
487
|
+
|
|
488
|
+
for (const entry of entries) {
|
|
489
|
+
const parentId = entry.parentId ?? null;
|
|
490
|
+
const bucket = byParent.get(parentId);
|
|
491
|
+
if (bucket) {
|
|
492
|
+
bucket.push(entry);
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
byParent.set(parentId, [entry]);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
for (const childEntries of byParent.values()) {
|
|
500
|
+
childEntries.sort((a, b) => (a.timestamp ?? "").localeCompare(b.timestamp ?? ""));
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return byParent;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function buildSessionTreeHeader(parsed: ParsedSessionFile, sessionPath: string): string[] {
|
|
507
|
+
const durableHandoffMetadata = findDurableHandoffMetadata(
|
|
508
|
+
parsed.entries,
|
|
509
|
+
parsed.header.timestamp,
|
|
510
|
+
);
|
|
511
|
+
const lines = [
|
|
512
|
+
`# Session ${parsed.sessionName || parsed.header.id}`,
|
|
513
|
+
"",
|
|
514
|
+
`- session_id: ${parsed.header.id}`,
|
|
515
|
+
`- session_path: ${sessionPath}`,
|
|
516
|
+
`- cwd: ${parsed.header.cwd}`,
|
|
517
|
+
`- started_at: ${parsed.header.timestamp}`,
|
|
518
|
+
];
|
|
519
|
+
|
|
520
|
+
if (parsed.header.parentSession) {
|
|
521
|
+
lines.push(`- parent_session: ${parsed.header.parentSession}`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (durableHandoffMetadata) {
|
|
525
|
+
lines.push(
|
|
526
|
+
`- session_origin: ${durableHandoffMetadata.metadata.origin}`,
|
|
527
|
+
`- handoff_goal: ${durableHandoffMetadata.metadata.goal}`,
|
|
528
|
+
`- handoff_next_task: ${durableHandoffMetadata.metadata.nextTask}`,
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
lines.push("", "## Session Tree", "");
|
|
533
|
+
return lines;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export interface RenderedSessionTree {
|
|
537
|
+
markdown: string;
|
|
538
|
+
sessionId: string;
|
|
539
|
+
sessionName: string;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export function renderSessionTreeMarkdown(
|
|
543
|
+
sessionPath: string,
|
|
544
|
+
options?: { maxChars?: number },
|
|
545
|
+
): RenderedSessionTree {
|
|
546
|
+
const parsed = parseSessionFile(sessionPath);
|
|
547
|
+
if (!parsed) {
|
|
548
|
+
throw new Error(`Unable to parse session file: ${sessionPath}`);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const byParent = groupEntriesByParent(parsed.entries);
|
|
552
|
+
const lines = buildSessionTreeHeader(parsed, sessionPath);
|
|
553
|
+
|
|
554
|
+
const roots = byParent.get(null) ?? [];
|
|
555
|
+
for (const root of roots) {
|
|
556
|
+
renderTreeSegment(root, byParent, lines);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const markdown = lines.join("\n");
|
|
560
|
+
const maxChars = options?.maxChars;
|
|
561
|
+
if (maxChars === undefined || markdown.length <= maxChars) {
|
|
562
|
+
return { markdown, sessionId: parsed.header.id, sessionName: parsed.sessionName };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return {
|
|
566
|
+
markdown: `${markdown.slice(0, maxChars)}\n\n[session tree truncated to ${maxChars} characters]`,
|
|
567
|
+
sessionId: parsed.header.id,
|
|
568
|
+
sessionName: parsed.sessionName,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function renderTreeSegment(
|
|
573
|
+
start: SessionEntry,
|
|
574
|
+
byParent: Map<string | null, SessionEntry[]>,
|
|
575
|
+
lines: string[],
|
|
576
|
+
depth = 0,
|
|
577
|
+
): void {
|
|
578
|
+
let current: SessionEntry | undefined = start;
|
|
579
|
+
|
|
580
|
+
while (current) {
|
|
581
|
+
appendEntryLines(lines, describeEntry(current, byParent), depth);
|
|
582
|
+
|
|
583
|
+
const children = getRenderableChildren(current, byParent);
|
|
584
|
+
if (children.length === 0) {
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (children.length === 1) {
|
|
589
|
+
current = children[0];
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
lines.push(`${" ".repeat(depth)} branches:`);
|
|
594
|
+
for (const child of children) {
|
|
595
|
+
renderTreeSegment(child, byParent, lines, depth + 1);
|
|
596
|
+
}
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function appendEntryLines(lines: string[], entryLines: string[], depth: number): void {
|
|
602
|
+
if (entryLines.length === 0) return;
|
|
603
|
+
|
|
604
|
+
const indent = " ".repeat(depth);
|
|
605
|
+
lines.push(`${indent}- ${entryLines[0]}`);
|
|
606
|
+
for (let i = 1; i < entryLines.length; i++) {
|
|
607
|
+
lines.push(`${indent} ${entryLines[i]}`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function describeEntry(
|
|
612
|
+
entry: SessionEntry,
|
|
613
|
+
byParent: Map<string | null, SessionEntry[]>,
|
|
614
|
+
): string[] {
|
|
615
|
+
switch (entry.type) {
|
|
616
|
+
case "message":
|
|
617
|
+
return describeMessageEntry(entry, byParent);
|
|
618
|
+
case "branch_summary":
|
|
619
|
+
return [describeLabeledText("Branch summary", entry.summary, 220)];
|
|
620
|
+
case "compaction":
|
|
621
|
+
return [describeLabeledText("Compaction summary", entry.summary, 220)];
|
|
622
|
+
case "session_info":
|
|
623
|
+
return [describeLabeledText("Session name", entry.name, 180)];
|
|
624
|
+
case "model_change":
|
|
625
|
+
return [`Model: ${entry.provider}/${entry.modelId}`];
|
|
626
|
+
case "thinking_level_change":
|
|
627
|
+
return [`Thinking: ${entry.thinkingLevel}`];
|
|
628
|
+
default:
|
|
629
|
+
return [entry.type];
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function describeMessageEntry(
|
|
634
|
+
entry: MessageEntry,
|
|
635
|
+
byParent: Map<string | null, SessionEntry[]>,
|
|
636
|
+
): string[] {
|
|
637
|
+
const { message } = entry;
|
|
638
|
+
|
|
639
|
+
switch (message.role) {
|
|
640
|
+
case "user":
|
|
641
|
+
return [`User: ${fullText(contentToText(message.content))}`];
|
|
642
|
+
case "assistant":
|
|
643
|
+
return describeAssistantMessage(entry, byParent, message);
|
|
644
|
+
case "toolResult":
|
|
645
|
+
return [];
|
|
646
|
+
case "bashExecution":
|
|
647
|
+
return [`Bash ${previewText(message.command, 120)}: ${previewText(message.output, 220)}`];
|
|
648
|
+
case "custom":
|
|
649
|
+
return [`Custom: ${fullText(contentToText(message.content))}`];
|
|
650
|
+
default:
|
|
651
|
+
return [describeFallbackMessage(message)];
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function describeLabeledText(label: string, value: unknown, limit: number): string {
|
|
656
|
+
return `${label}: ${previewText(trimmedText(value), limit)}`;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function extractMessageChunks(
|
|
660
|
+
entryId: string | undefined,
|
|
661
|
+
fallbackTs: string,
|
|
662
|
+
message: AgentMessage,
|
|
663
|
+
): SearchTextChunk[] {
|
|
664
|
+
const ts = message.timestamp ? new Date(message.timestamp).toISOString() : fallbackTs;
|
|
665
|
+
|
|
666
|
+
switch (message.role) {
|
|
667
|
+
case "user": {
|
|
668
|
+
return buildOptionalMessageChunk(
|
|
669
|
+
entryId,
|
|
670
|
+
"user",
|
|
671
|
+
ts,
|
|
672
|
+
"user_text",
|
|
673
|
+
contentToText(message.content),
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
case "assistant": {
|
|
677
|
+
return buildOptionalMessageChunk(
|
|
678
|
+
entryId,
|
|
679
|
+
"assistant",
|
|
680
|
+
ts,
|
|
681
|
+
"assistant_text",
|
|
682
|
+
contentToText(message.content),
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
case "toolResult": {
|
|
686
|
+
return buildOptionalMessageChunk(
|
|
687
|
+
entryId,
|
|
688
|
+
"toolResult",
|
|
689
|
+
ts,
|
|
690
|
+
"tool_result",
|
|
691
|
+
truncateText(contentToText(message.content), TOOL_RESULT_TEXT_LIMIT),
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
case "bashExecution": {
|
|
695
|
+
const chunks: SearchTextChunk[] = [];
|
|
696
|
+
|
|
697
|
+
if (message.command) {
|
|
698
|
+
chunks.push(
|
|
699
|
+
createMessageChunk(entryId, "bashExecution", ts, "bash_command", message.command),
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const output = message.output ? truncateText(message.output, BASH_OUTPUT_TEXT_LIMIT) : "";
|
|
704
|
+
if (output) {
|
|
705
|
+
chunks.push(createMessageChunk(entryId, "bashExecution", ts, "bash_output", output));
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return chunks;
|
|
709
|
+
}
|
|
710
|
+
case "custom": {
|
|
711
|
+
return buildOptionalMessageChunk(
|
|
712
|
+
entryId,
|
|
713
|
+
"custom",
|
|
714
|
+
ts,
|
|
715
|
+
"custom_message",
|
|
716
|
+
contentToText(message.content),
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
default:
|
|
720
|
+
return [];
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function createMessageChunk(
|
|
725
|
+
entryId: string | undefined,
|
|
726
|
+
role: string,
|
|
727
|
+
ts: string,
|
|
728
|
+
sourceKind: string,
|
|
729
|
+
text: string,
|
|
730
|
+
): SearchTextChunk {
|
|
731
|
+
return {
|
|
732
|
+
entryId,
|
|
733
|
+
entryType: "message",
|
|
734
|
+
role,
|
|
735
|
+
ts,
|
|
736
|
+
sourceKind,
|
|
737
|
+
text,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function buildOptionalMessageChunk(
|
|
742
|
+
entryId: string | undefined,
|
|
743
|
+
role: string,
|
|
744
|
+
ts: string,
|
|
745
|
+
sourceKind: string,
|
|
746
|
+
text: string,
|
|
747
|
+
): SearchTextChunk[] {
|
|
748
|
+
if (!text) {
|
|
749
|
+
return [];
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return [createMessageChunk(entryId, role, ts, sourceKind, text)];
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function contentToText(content: unknown): string {
|
|
756
|
+
if (typeof content === "string") {
|
|
757
|
+
return content.trim();
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
if (!Array.isArray(content)) {
|
|
761
|
+
return "";
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return content
|
|
765
|
+
.filter(isTextBlock)
|
|
766
|
+
.map((part) => part.text)
|
|
767
|
+
.join("\n")
|
|
768
|
+
.trim();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function isTextBlock(part: unknown): part is TextBlock {
|
|
772
|
+
return isRecord(part) && part.type === "text" && typeof part.text === "string";
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
776
|
+
return typeof value === "object" && value !== null;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function describeAssistantMessage(
|
|
780
|
+
entry: MessageEntry,
|
|
781
|
+
byParent: Map<string | null, SessionEntry[]>,
|
|
782
|
+
assistantMessage: AssistantMessage,
|
|
783
|
+
): string[] {
|
|
784
|
+
const blocks: string[] = [];
|
|
785
|
+
const text = contentToText(assistantMessage.content);
|
|
786
|
+
if (text) {
|
|
787
|
+
blocks.push(`Assistant: ${fullText(text)}`);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const toolCalls = getAssistantToolCalls(assistantMessage.content);
|
|
791
|
+
const toolResults = getToolResults(entry, byParent);
|
|
792
|
+
const resultsByCallId = new Map<string, ToolResultEntry[]>();
|
|
793
|
+
for (const result of toolResults) {
|
|
794
|
+
const toolCallId = result.message.toolCallId;
|
|
795
|
+
if (!toolCallId) continue;
|
|
796
|
+
const existing = resultsByCallId.get(toolCallId) ?? [];
|
|
797
|
+
existing.push(result);
|
|
798
|
+
resultsByCallId.set(toolCallId, existing);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
for (const toolCall of toolCalls) {
|
|
802
|
+
const toolResultGroup = resultsByCallId.get(toolCall.id) ?? [];
|
|
803
|
+
blocks.push(...describeToolOperation(toolCall, toolResultGroup));
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return blocks.length > 0 ? blocks : ["Assistant"];
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function getAssistantToolCalls(content: unknown): ToolCallBlock[] {
|
|
810
|
+
if (!Array.isArray(content)) {
|
|
811
|
+
return [];
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return content.filter(isToolCallBlock);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function isToolCallBlock(part: unknown): part is ToolCallBlock {
|
|
818
|
+
return (
|
|
819
|
+
isRecord(part) &&
|
|
820
|
+
part.type === "toolCall" &&
|
|
821
|
+
typeof part.id === "string" &&
|
|
822
|
+
typeof part.name === "string" &&
|
|
823
|
+
isRecord(part.arguments)
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function getChildEntries(
|
|
828
|
+
entryId: string | undefined,
|
|
829
|
+
byParent: Map<string | null, SessionEntry[]>,
|
|
830
|
+
): SessionEntry[] {
|
|
831
|
+
return byParent.get(entryId ?? null) ?? [];
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function getDescendantChildren(
|
|
835
|
+
entries: SessionEntry[],
|
|
836
|
+
byParent: Map<string | null, SessionEntry[]>,
|
|
837
|
+
): SessionEntry[] {
|
|
838
|
+
const descendants: SessionEntry[] = [];
|
|
839
|
+
|
|
840
|
+
for (const entry of entries) {
|
|
841
|
+
descendants.push(...getChildEntries(entry.id, byParent));
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
return descendants;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function isToolResultEntry(entry: SessionEntry): entry is ToolResultEntry {
|
|
848
|
+
return entry.type === "message" && entry.message.role === "toolResult";
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function getToolResults(
|
|
852
|
+
entry: MessageEntry,
|
|
853
|
+
byParent: Map<string | null, SessionEntry[]>,
|
|
854
|
+
): ToolResultEntry[] {
|
|
855
|
+
const results: ToolResultEntry[] = [];
|
|
856
|
+
let currentChildren = getChildEntries(entry.id, byParent);
|
|
857
|
+
|
|
858
|
+
while (currentChildren.length > 0) {
|
|
859
|
+
const toolResults = currentChildren.filter(isToolResultEntry);
|
|
860
|
+
|
|
861
|
+
if (toolResults.length === 0) {
|
|
862
|
+
break;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
results.push(...toolResults);
|
|
866
|
+
currentChildren = getDescendantChildren(toolResults, byParent);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
return results;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function getRenderableChildren(
|
|
873
|
+
entry: SessionEntry,
|
|
874
|
+
byParent: Map<string | null, SessionEntry[]>,
|
|
875
|
+
): SessionEntry[] {
|
|
876
|
+
let currentChildren = getChildEntries(entry.id, byParent);
|
|
877
|
+
|
|
878
|
+
while (currentChildren.length > 0) {
|
|
879
|
+
const nonToolResultChildren = currentChildren.filter((child) => !isToolResultEntry(child));
|
|
880
|
+
if (nonToolResultChildren.length > 0) {
|
|
881
|
+
return nonToolResultChildren;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
currentChildren = getDescendantChildren(currentChildren, byParent);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
return [];
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function describeToolOperation(toolCall: ToolCallBlock, toolResults: ToolResultEntry[]): string[] {
|
|
891
|
+
const args = toolCall.arguments ?? {};
|
|
892
|
+
const resultLines = summarizeToolResults(toolCall.name, toolResults);
|
|
893
|
+
|
|
894
|
+
switch (toolCall.name) {
|
|
895
|
+
case "read":
|
|
896
|
+
return formatToolOperation(`Read ${stringArg(args.path, "(unknown path)")}`, resultLines);
|
|
897
|
+
case "bash":
|
|
898
|
+
return formatToolOperation(
|
|
899
|
+
`Bash ${fullText(stringArg(args.command, "(no command)"))}`,
|
|
900
|
+
resultLines,
|
|
901
|
+
);
|
|
902
|
+
case "search_web":
|
|
903
|
+
return formatToolOperation(`Search web ${fullText(JSON.stringify(args))}`, resultLines);
|
|
904
|
+
case "fetch_web":
|
|
905
|
+
return formatToolOperation(`Fetch web ${fullText(JSON.stringify(args))}`, resultLines);
|
|
906
|
+
case "edit":
|
|
907
|
+
return formatToolOperation(`Edit ${stringArg(args.path, "(unknown path)")}`, resultLines);
|
|
908
|
+
case "write":
|
|
909
|
+
return formatToolOperation(`Write ${stringArg(args.path, "(unknown path)")}`, resultLines);
|
|
910
|
+
default:
|
|
911
|
+
return formatToolOperation(toolCall.name, resultLines);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function stringArg(value: unknown, fallback: string): string {
|
|
916
|
+
return typeof value === "string" ? value : fallback;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function stringValue(value: unknown): string | undefined {
|
|
920
|
+
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function summarizeToolResults(toolName: string, toolResults: ToolResultEntry[]): string[] {
|
|
924
|
+
if (toolResults.length === 0) {
|
|
925
|
+
return ["(pending result)"];
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const shouldTruncate = toolName !== "write";
|
|
929
|
+
const parts = toolResults
|
|
930
|
+
.map((toolResult) => {
|
|
931
|
+
const text = contentToText(toolResult.message.content);
|
|
932
|
+
return shouldTruncate ? truncateText(text, TOOL_RESULT_TEXT_LIMIT) : text;
|
|
933
|
+
})
|
|
934
|
+
.filter((text) => text.trim().length > 0);
|
|
935
|
+
|
|
936
|
+
if (parts.length === 0) {
|
|
937
|
+
return ["(no text output)"];
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
return parts;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function formatToolOperation(label: string, resultLines: string[]): string[] {
|
|
944
|
+
return [label, "```", ...resultLines, "```"];
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function truncateText(text: string, limit: number): string {
|
|
948
|
+
if (text.length <= limit) return text;
|
|
949
|
+
return `${text.slice(0, limit)}…`;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function fullText(text: string): string {
|
|
953
|
+
const cleaned = text.replace(/\s+/g, " ").trim();
|
|
954
|
+
return cleaned || "(no text)";
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function previewText(text: string, limit: number): string {
|
|
958
|
+
return truncateText(fullText(text), limit);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
type MessageEntry = SessionMessageEntry;
|
|
962
|
+
|
|
963
|
+
interface SummaryDetails {
|
|
964
|
+
readFiles?: unknown;
|
|
965
|
+
modifiedFiles?: unknown;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
interface TextBlock {
|
|
969
|
+
type: "text";
|
|
970
|
+
text: string;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
type ToolCallBlock = ToolCall & {
|
|
974
|
+
arguments: Record<string, unknown>;
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
type ToolResultEntry = MessageEntry & {
|
|
978
|
+
message: ToolResultMessage;
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
function isSessionEntry(entry: SessionHeader | SessionEntry): entry is SessionEntry {
|
|
982
|
+
return entry.type !== "session";
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function getSummaryDetails(details: unknown): SummaryDetails | undefined {
|
|
986
|
+
return safeParseTypeBoxValue(SUMMARY_DETAILS_SCHEMA, details);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function describeFallbackMessage(message: AgentMessage): string {
|
|
990
|
+
return hasMessageContent(message)
|
|
991
|
+
? `${message.role}: ${fullText(contentToText(message.content))}`
|
|
992
|
+
: message.role;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function hasMessageContent(message: AgentMessage): message is AgentMessage & { content: unknown } {
|
|
996
|
+
return "content" in message;
|
|
997
|
+
}
|