pi-sessions 0.3.2 → 0.4.1
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/extensions/session-handoff/extract.ts +130 -64
- package/extensions/session-search/extract.ts +218 -50
- package/extensions/session-search/hooks.ts +253 -103
- package/extensions/session-search/reindex.ts +48 -55
- package/extensions/shared/session-index/common.ts +4 -1
- package/extensions/shared/session-index/lineage.ts +70 -2
- package/extensions/shared/session-index/schema.ts +24 -11
- package/extensions/shared/session-index/search.ts +2 -2
- package/extensions/shared/session-index/sqlite.ts +45 -12
- package/extensions/shared/session-index/store.ts +125 -33
- package/package.json +1 -1
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
type Tool,
|
|
10
|
-
type ToolCall,
|
|
2
|
+
import type {
|
|
3
|
+
Api,
|
|
4
|
+
AssistantMessage,
|
|
5
|
+
Model,
|
|
6
|
+
TextContent,
|
|
7
|
+
ThinkingContent,
|
|
8
|
+
ToolCall,
|
|
11
9
|
} from "@earendil-works/pi-ai";
|
|
12
10
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
11
|
import {
|
|
14
12
|
buildSessionContext,
|
|
15
13
|
convertToLlm,
|
|
14
|
+
createAgentSession,
|
|
15
|
+
DefaultResourceLoader,
|
|
16
|
+
defineTool,
|
|
17
|
+
getAgentDir,
|
|
18
|
+
SessionManager,
|
|
16
19
|
serializeConversation,
|
|
17
20
|
} from "@earendil-works/pi-coding-agent";
|
|
18
21
|
import { type Static, Type } from "typebox";
|
|
@@ -56,12 +59,7 @@ const HANDOFF_EXTRACTION_PARAMETERS = Type.Object({
|
|
|
56
59
|
),
|
|
57
60
|
});
|
|
58
61
|
|
|
59
|
-
|
|
60
|
-
name: "create_handoff_context",
|
|
61
|
-
description: "Extract the structured handoff context for the next session.",
|
|
62
|
-
parameters: HANDOFF_EXTRACTION_PARAMETERS,
|
|
63
|
-
};
|
|
64
|
-
|
|
62
|
+
type HandoffExtractionArgs = Static<typeof HANDOFF_EXTRACTION_PARAMETERS>;
|
|
65
63
|
type RequiredHandoffExtractionArgs = Static<typeof REQUIRED_HANDOFF_EXTRACTION_PARAMETERS>;
|
|
66
64
|
|
|
67
65
|
const REQUIRED_HANDOFF_EXTRACTION_PARAMETERS = Type.Object({
|
|
@@ -95,11 +93,7 @@ export async function generateHandoffDraft(
|
|
|
95
93
|
throw new Error("No model is available for handoff.");
|
|
96
94
|
}
|
|
97
95
|
|
|
98
|
-
const
|
|
99
|
-
if (!auth.ok || !auth.apiKey) {
|
|
100
|
-
throw new Error(`No API key is available for ${ctx.model.provider}/${ctx.model.id}.`);
|
|
101
|
-
}
|
|
102
|
-
|
|
96
|
+
const model = ctx.model;
|
|
103
97
|
const sessionContext = buildSessionContext(
|
|
104
98
|
ctx.sessionManager.getEntries(),
|
|
105
99
|
ctx.sessionManager.getLeafId(),
|
|
@@ -109,51 +103,18 @@ export async function generateHandoffDraft(
|
|
|
109
103
|
}
|
|
110
104
|
|
|
111
105
|
const conversationText = serializeConversation(convertToLlm(sessionContext.messages));
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
],
|
|
120
|
-
timestamp: Date.now(),
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
const requestOptions: SimpleStreamOptions = {
|
|
124
|
-
apiKey: auth.apiKey,
|
|
125
|
-
...(auth.headers ? { headers: auth.headers } : {}),
|
|
126
|
-
...(signal ? { signal } : {}),
|
|
127
|
-
...(ctx.model.reasoning && thinkingLevel && thinkingLevel !== "off"
|
|
128
|
-
? { reasoning: thinkingLevel }
|
|
129
|
-
: {}),
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
const response = await completeSimple(
|
|
133
|
-
ctx.model,
|
|
134
|
-
{
|
|
135
|
-
systemPrompt: HANDOFF_SYSTEM_PROMPT,
|
|
136
|
-
messages: [userMessage],
|
|
137
|
-
tools: [HANDOFF_EXTRACTION_TOOL],
|
|
138
|
-
},
|
|
139
|
-
requestOptions,
|
|
106
|
+
const handoffContext = await runHandoffExtractionAgent(
|
|
107
|
+
ctx,
|
|
108
|
+
model,
|
|
109
|
+
conversationText,
|
|
110
|
+
goal,
|
|
111
|
+
thinkingLevel,
|
|
112
|
+
signal,
|
|
140
113
|
);
|
|
141
|
-
|
|
142
|
-
if (response.stopReason === "aborted") {
|
|
114
|
+
if (!handoffContext) {
|
|
143
115
|
return undefined;
|
|
144
116
|
}
|
|
145
117
|
|
|
146
|
-
if (response.stopReason === "error") {
|
|
147
|
-
throw new Error(response.errorMessage ?? "Handoff generation failed.");
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const extraction = extractHandoffContext(response, goal);
|
|
151
|
-
if (!extraction.context) {
|
|
152
|
-
throw new Error(extraction.error);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const handoffContext = extraction.context;
|
|
156
|
-
|
|
157
118
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
158
119
|
const sessionPath = ctx.sessionManager.getSessionFile();
|
|
159
120
|
|
|
@@ -177,6 +138,84 @@ export function buildExtractionPrompt(conversationText: string, goal: string): s
|
|
|
177
138
|
].join("\n");
|
|
178
139
|
}
|
|
179
140
|
|
|
141
|
+
async function runHandoffExtractionAgent(
|
|
142
|
+
ctx: ExtensionContext,
|
|
143
|
+
model: Model<Api>,
|
|
144
|
+
conversationText: string,
|
|
145
|
+
goal: string,
|
|
146
|
+
thinkingLevel: ThinkingLevel | undefined,
|
|
147
|
+
signal?: AbortSignal,
|
|
148
|
+
): Promise<HandoffContext | undefined> {
|
|
149
|
+
let capturedArguments: HandoffExtractionArgs | undefined;
|
|
150
|
+
const createHandoffContextTool = defineTool({
|
|
151
|
+
name: "create_handoff_context",
|
|
152
|
+
label: "Create handoff context",
|
|
153
|
+
description: "Extract the structured handoff context for the next session.",
|
|
154
|
+
parameters: HANDOFF_EXTRACTION_PARAMETERS,
|
|
155
|
+
execute: async (_toolCallId, params) => {
|
|
156
|
+
capturedArguments = params;
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: "text", text: "Handoff context captured. Stopping." }],
|
|
159
|
+
details: {},
|
|
160
|
+
terminate: true,
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
166
|
+
cwd: ctx.cwd,
|
|
167
|
+
agentDir: getAgentDir(),
|
|
168
|
+
noExtensions: true,
|
|
169
|
+
noPromptTemplates: true,
|
|
170
|
+
noSkills: true,
|
|
171
|
+
appendSystemPromptOverride: (base) => [...base, HANDOFF_SYSTEM_PROMPT],
|
|
172
|
+
});
|
|
173
|
+
await resourceLoader.reload();
|
|
174
|
+
|
|
175
|
+
const { session } = await createAgentSession({
|
|
176
|
+
cwd: ctx.cwd,
|
|
177
|
+
model,
|
|
178
|
+
modelRegistry: ctx.modelRegistry,
|
|
179
|
+
...(thinkingLevel ? { thinkingLevel } : {}),
|
|
180
|
+
tools: ["read", "grep", "find", "ls", "create_handoff_context"],
|
|
181
|
+
customTools: [createHandoffContextTool],
|
|
182
|
+
resourceLoader,
|
|
183
|
+
sessionManager: SessionManager.inMemory(ctx.cwd),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const abortHandler = (): void => {
|
|
187
|
+
void session.abort();
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
signal?.addEventListener("abort", abortHandler, { once: true });
|
|
192
|
+
if (signal?.aborted) {
|
|
193
|
+
await session.abort();
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await session.prompt(buildExtractionPrompt(conversationText, goal));
|
|
198
|
+
} finally {
|
|
199
|
+
signal?.removeEventListener("abort", abortHandler);
|
|
200
|
+
session.dispose();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (signal?.aborted) {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!capturedArguments) {
|
|
208
|
+
throw new Error("Handoff extraction did not return structured context.");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const extraction = extractHandoffContextFromArguments(capturedArguments, goal);
|
|
212
|
+
if (!extraction.context) {
|
|
213
|
+
throw new Error(extraction.error);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return extraction.context;
|
|
217
|
+
}
|
|
218
|
+
|
|
180
219
|
export function assembleHandoffDraft(
|
|
181
220
|
sessionId: string,
|
|
182
221
|
sessionPath: string | undefined,
|
|
@@ -224,11 +263,18 @@ export function extractHandoffContext(
|
|
|
224
263
|
return { error: "Handoff extraction did not return structured context." };
|
|
225
264
|
}
|
|
226
265
|
|
|
266
|
+
return extractHandoffContextFromArguments(toolCall.arguments, goal);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function extractHandoffContextFromArguments(
|
|
270
|
+
argumentsValue: unknown,
|
|
271
|
+
goal: string,
|
|
272
|
+
): { context: HandoffContext; error?: undefined } | { context?: undefined; error: string } {
|
|
227
273
|
let requiredArguments: RequiredHandoffExtractionArgs;
|
|
228
274
|
try {
|
|
229
275
|
requiredArguments = parseTypeBoxValue(
|
|
230
276
|
REQUIRED_HANDOFF_EXTRACTION_PARAMETERS,
|
|
231
|
-
|
|
277
|
+
argumentsValue,
|
|
232
278
|
"Invalid create_handoff_context arguments",
|
|
233
279
|
);
|
|
234
280
|
} catch {
|
|
@@ -241,9 +287,9 @@ export function extractHandoffContext(
|
|
|
241
287
|
}
|
|
242
288
|
|
|
243
289
|
const summary = normalizeText(requiredArguments.summary);
|
|
244
|
-
const relevantFiles =
|
|
290
|
+
const relevantFiles = getRelevantFiles(argumentsValue);
|
|
245
291
|
const nextTask = normalizeText(requiredArguments.nextTask) || goal.trim();
|
|
246
|
-
const openQuestions =
|
|
292
|
+
const openQuestions = getOpenQuestions(argumentsValue);
|
|
247
293
|
|
|
248
294
|
if (!summary || !nextTask || !title) {
|
|
249
295
|
return { error: "Handoff extraction did not return structured context." };
|
|
@@ -285,10 +331,30 @@ function normalizeStringArray(value: unknown, limit: number): string[] {
|
|
|
285
331
|
return [...uniqueValues];
|
|
286
332
|
}
|
|
287
333
|
|
|
334
|
+
function getRelevantFiles(argumentsValue: unknown): string[] {
|
|
335
|
+
if (!isRecord(argumentsValue)) {
|
|
336
|
+
return [];
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return normalizeStringArray(argumentsValue.relevantFiles, MAX_RELEVANT_FILES);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function getOpenQuestions(argumentsValue: unknown): string[] {
|
|
343
|
+
if (!isRecord(argumentsValue)) {
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return normalizeStringArray(argumentsValue.openQuestions, MAX_OPEN_QUESTIONS);
|
|
348
|
+
}
|
|
349
|
+
|
|
288
350
|
function normalizeText(value: unknown): string {
|
|
289
351
|
return typeof value === "string" ? value.trim() : "";
|
|
290
352
|
}
|
|
291
353
|
|
|
354
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
355
|
+
return typeof value === "object" && value !== null;
|
|
356
|
+
}
|
|
357
|
+
|
|
292
358
|
function isCreateHandoffContextToolCall(
|
|
293
359
|
content: TextContent | ThinkingContent | ToolCall,
|
|
294
360
|
): content is ToolCall {
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
fstatSync,
|
|
5
|
+
openSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readSync,
|
|
9
|
+
statSync,
|
|
10
|
+
} from "node:fs";
|
|
2
11
|
import path from "node:path";
|
|
3
12
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
4
13
|
import type { AssistantMessage, ToolCall, ToolResultMessage } from "@earendil-works/pi-ai";
|
|
@@ -34,7 +43,7 @@ export interface SearchTextChunk {
|
|
|
34
43
|
text: string;
|
|
35
44
|
}
|
|
36
45
|
|
|
37
|
-
interface DurableHandoffMetadataRecord {
|
|
46
|
+
export interface DurableHandoffMetadataRecord {
|
|
38
47
|
entryId?: string | undefined;
|
|
39
48
|
ts: string;
|
|
40
49
|
metadata: HandoffSessionMetadata;
|
|
@@ -70,10 +79,39 @@ export interface ExtractedSessionRecord {
|
|
|
70
79
|
sessionOrigin?: SessionOrigin | undefined;
|
|
71
80
|
handoffGoal?: string | undefined;
|
|
72
81
|
handoffNextTask?: string | undefined;
|
|
82
|
+
indexedFileSize: number;
|
|
83
|
+
indexedFileMtimeMs: number;
|
|
84
|
+
indexedFileAnchor: string;
|
|
73
85
|
chunks: SearchTextChunk[];
|
|
74
86
|
fileTouches: SessionFileTouch[];
|
|
75
87
|
}
|
|
76
88
|
|
|
89
|
+
export interface SessionEntryScan {
|
|
90
|
+
chunks: SearchTextChunk[];
|
|
91
|
+
fileTouches: SessionFileTouch[];
|
|
92
|
+
messageCount: number;
|
|
93
|
+
entryCount: number;
|
|
94
|
+
maxEntryTs?: string | undefined;
|
|
95
|
+
firstUserPrompt?: string | undefined;
|
|
96
|
+
sessionName?: string | undefined;
|
|
97
|
+
handoffMetadata?: DurableHandoffMetadataRecord | undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface SessionTailBaseline {
|
|
101
|
+
sessionId: string;
|
|
102
|
+
cwd: string;
|
|
103
|
+
startedAt: string;
|
|
104
|
+
indexedFileSize: number;
|
|
105
|
+
indexedFileAnchor: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ExtractedSessionTail {
|
|
109
|
+
scan: SessionEntryScan;
|
|
110
|
+
indexedFileSize: number;
|
|
111
|
+
indexedFileMtimeMs: number;
|
|
112
|
+
indexedFileAnchor: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
77
115
|
export interface ParsedSessionFile {
|
|
78
116
|
header: SessionHeader;
|
|
79
117
|
entries: SessionEntry[];
|
|
@@ -82,6 +120,8 @@ export interface ParsedSessionFile {
|
|
|
82
120
|
|
|
83
121
|
const TOOL_RESULT_TEXT_LIMIT = 500;
|
|
84
122
|
const BASH_OUTPUT_TEXT_LIMIT = 500;
|
|
123
|
+
const FILE_ANCHOR_BYTES = 64;
|
|
124
|
+
const NEWLINE_BYTE = 0x0a;
|
|
85
125
|
const SUMMARY_DETAILS_SCHEMA = Type.Object({
|
|
86
126
|
readFiles: Type.Optional(Type.Array(Type.String())),
|
|
87
127
|
modifiedFiles: Type.Optional(Type.Array(Type.String())),
|
|
@@ -115,30 +155,124 @@ export function listSessionFiles(sessionsDir: string): string[] {
|
|
|
115
155
|
}
|
|
116
156
|
|
|
117
157
|
export function extractSessionRecord(sessionPath: string): ExtractedSessionRecord | undefined {
|
|
118
|
-
const
|
|
158
|
+
const indexedFileMtimeMs = Math.trunc(statSync(sessionPath).mtimeMs);
|
|
159
|
+
const buffer = readFileSync(sessionPath);
|
|
160
|
+
const slice = sliceCompleteJsonlPrefix(buffer);
|
|
161
|
+
const parsed = parseSessionContent(slice.content);
|
|
119
162
|
if (!parsed) return undefined;
|
|
120
163
|
|
|
121
164
|
const fallbackTs = parsed.header.timestamp;
|
|
165
|
+
const scan = scanSessionEntries(parsed.entries, fallbackTs, parsed.header.cwd);
|
|
166
|
+
|
|
167
|
+
const chunks: SearchTextChunk[] = [];
|
|
168
|
+
if (parsed.sessionName) {
|
|
169
|
+
chunks.push(createSessionNameChunk(parsed.sessionName, fallbackTs));
|
|
170
|
+
}
|
|
171
|
+
chunks.push(...scan.chunks);
|
|
172
|
+
appendDurableHandoffMetadataChunks(chunks, scan.handoffMetadata);
|
|
173
|
+
|
|
174
|
+
const parentSessionPath = normalizeParentSessionPath(parsed.header.parentSession);
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
sessionId: parsed.header.id,
|
|
178
|
+
sessionPath,
|
|
179
|
+
sessionName: parsed.sessionName,
|
|
180
|
+
firstUserPrompt: trimmedText(scan.firstUserPrompt),
|
|
181
|
+
cwd: parsed.header.cwd,
|
|
182
|
+
repoRoots: deriveSessionRepoRoots(parsed.header.cwd, scan.fileTouches),
|
|
183
|
+
startedAt: fallbackTs,
|
|
184
|
+
modifiedAt: maxTimestamp(fallbackTs, scan.maxEntryTs),
|
|
185
|
+
messageCount: scan.messageCount,
|
|
186
|
+
entryCount: scan.entryCount,
|
|
187
|
+
parentSessionPath,
|
|
188
|
+
parentSessionId: parentSessionPath ? readSessionIdFromPath(parentSessionPath) : undefined,
|
|
189
|
+
sessionOrigin: inferSessionOrigin(parentSessionPath, scan.handoffMetadata?.metadata),
|
|
190
|
+
handoffGoal: scan.handoffMetadata?.metadata.goal,
|
|
191
|
+
handoffNextTask: scan.handoffMetadata?.metadata.nextTask,
|
|
192
|
+
indexedFileSize: slice.consumedBytes,
|
|
193
|
+
indexedFileMtimeMs,
|
|
194
|
+
indexedFileAnchor: buildFileAnchor(buffer, slice.consumedBytes),
|
|
195
|
+
chunks: chunks,
|
|
196
|
+
fileTouches: scan.fileTouches,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Reads only the bytes appended since the last sync. The stored anchor (the
|
|
201
|
+
// final bytes of the previously indexed prefix) proves the prefix is unchanged;
|
|
202
|
+
// any in-place rewrite invalidates it and the caller falls back to a full
|
|
203
|
+
// re-extract. Session files are append-only in normal pi operation.
|
|
204
|
+
export function extractSessionTail(
|
|
205
|
+
sessionPath: string,
|
|
206
|
+
baseline: SessionTailBaseline,
|
|
207
|
+
): ExtractedSessionTail | undefined {
|
|
208
|
+
const anchorBytes = Buffer.from(baseline.indexedFileAnchor, "base64");
|
|
209
|
+
const anchorStart = baseline.indexedFileSize - anchorBytes.length;
|
|
210
|
+
if (anchorBytes.length === 0 || anchorStart < 0) {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const indexedFileMtimeMs = Math.trunc(statSync(sessionPath).mtimeMs);
|
|
215
|
+
const fd = openSync(sessionPath, "r");
|
|
216
|
+
try {
|
|
217
|
+
const fileSize = fstatSync(fd).size;
|
|
218
|
+
if (fileSize < baseline.indexedFileSize) {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const window = Buffer.alloc(fileSize - anchorStart);
|
|
223
|
+
const bytesRead = readSync(fd, window, 0, window.length, anchorStart);
|
|
224
|
+
if (
|
|
225
|
+
bytesRead < anchorBytes.length ||
|
|
226
|
+
!window.subarray(0, anchorBytes.length).equals(anchorBytes)
|
|
227
|
+
) {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const tailBuffer = window.subarray(anchorBytes.length, bytesRead);
|
|
232
|
+
const slice = sliceCompleteJsonlPrefix(tailBuffer);
|
|
233
|
+
const entries = parseSessionEntries(slice.content).filter(isSessionEntry);
|
|
234
|
+
const consumedWindow = Buffer.concat([
|
|
235
|
+
anchorBytes,
|
|
236
|
+
tailBuffer.subarray(0, slice.consumedBytes),
|
|
237
|
+
]);
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
scan: scanSessionEntries(entries, baseline.startedAt, baseline.cwd),
|
|
241
|
+
indexedFileSize: baseline.indexedFileSize + slice.consumedBytes,
|
|
242
|
+
indexedFileMtimeMs,
|
|
243
|
+
indexedFileAnchor: buildFileAnchor(consumedWindow, consumedWindow.length),
|
|
244
|
+
};
|
|
245
|
+
} finally {
|
|
246
|
+
closeSync(fd);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function createSessionNameChunk(sessionName: string, ts: string): SearchTextChunk {
|
|
251
|
+
return {
|
|
252
|
+
entryType: "session_info",
|
|
253
|
+
ts,
|
|
254
|
+
sourceKind: "session_name",
|
|
255
|
+
text: sessionName,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function scanSessionEntries(
|
|
260
|
+
entries: SessionEntry[],
|
|
261
|
+
fallbackTs: string,
|
|
262
|
+
cwd: string,
|
|
263
|
+
): SessionEntryScan {
|
|
122
264
|
const chunks: SearchTextChunk[] = [];
|
|
123
265
|
const fileTouches: SessionFileTouch[] = [];
|
|
124
|
-
let modifiedAt = fallbackTs;
|
|
125
266
|
let messageCount = 0;
|
|
267
|
+
let maxEntryTs: string | undefined;
|
|
126
268
|
let firstUserPrompt: string | undefined;
|
|
127
|
-
let
|
|
269
|
+
let sessionName: string | undefined;
|
|
270
|
+
let handoffMetadata: DurableHandoffMetadataRecord | undefined;
|
|
128
271
|
|
|
129
|
-
|
|
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) {
|
|
272
|
+
for (const entry of entries) {
|
|
139
273
|
const entryTs = getEntryTimestamp(entry, fallbackTs);
|
|
140
|
-
if (entryTs >
|
|
141
|
-
|
|
274
|
+
if (maxEntryTs === undefined || entryTs > maxEntryTs) {
|
|
275
|
+
maxEntryTs = entryTs;
|
|
142
276
|
}
|
|
143
277
|
|
|
144
278
|
switch (entry.type) {
|
|
@@ -150,15 +284,19 @@ export function extractSessionRecord(sessionPath: string): ExtractedSessionRecor
|
|
|
150
284
|
firstUserPrompt = contentToText(message.content);
|
|
151
285
|
}
|
|
152
286
|
chunks.push(...extractMessageChunks(entry.id, entryTs, message));
|
|
153
|
-
fileTouches.push(
|
|
154
|
-
|
|
155
|
-
|
|
287
|
+
fileTouches.push(...extractMessageFileTouches(entry.id, entryTs, message, cwd));
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
case "session_info": {
|
|
291
|
+
if (typeof entry.name === "string") {
|
|
292
|
+
sessionName = entry.name.trim();
|
|
293
|
+
}
|
|
156
294
|
continue;
|
|
157
295
|
}
|
|
158
296
|
case "custom": {
|
|
159
297
|
const nextHandoffMetadata = extractHandoffMetadata(entry, entryTs);
|
|
160
|
-
if (nextHandoffMetadata && !
|
|
161
|
-
|
|
298
|
+
if (nextHandoffMetadata && !handoffMetadata) {
|
|
299
|
+
handoffMetadata = nextHandoffMetadata;
|
|
162
300
|
}
|
|
163
301
|
continue;
|
|
164
302
|
}
|
|
@@ -179,7 +317,7 @@ export function extractSessionRecord(sessionPath: string): ExtractedSessionRecor
|
|
|
179
317
|
entryTs,
|
|
180
318
|
"branch_summary_details",
|
|
181
319
|
entry.details,
|
|
182
|
-
|
|
320
|
+
cwd,
|
|
183
321
|
),
|
|
184
322
|
);
|
|
185
323
|
continue;
|
|
@@ -193,13 +331,7 @@ export function extractSessionRecord(sessionPath: string): ExtractedSessionRecor
|
|
|
193
331
|
trimmedText(entry.summary),
|
|
194
332
|
);
|
|
195
333
|
fileTouches.push(
|
|
196
|
-
...extractDetailFileTouches(
|
|
197
|
-
entry.id,
|
|
198
|
-
entryTs,
|
|
199
|
-
"compaction_details",
|
|
200
|
-
entry.details,
|
|
201
|
-
parsed.header.cwd,
|
|
202
|
-
),
|
|
334
|
+
...extractDetailFileTouches(entry.id, entryTs, "compaction_details", entry.details, cwd),
|
|
203
335
|
);
|
|
204
336
|
continue;
|
|
205
337
|
}
|
|
@@ -208,33 +340,69 @@ export function extractSessionRecord(sessionPath: string): ExtractedSessionRecor
|
|
|
208
340
|
}
|
|
209
341
|
}
|
|
210
342
|
|
|
211
|
-
appendDurableHandoffMetadataChunks(chunks, durableHandoffMetadata);
|
|
212
|
-
|
|
213
|
-
const parentSessionPath = normalizeParentSessionPath(parsed.header.parentSession);
|
|
214
|
-
|
|
215
343
|
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
344
|
chunks,
|
|
232
345
|
fileTouches,
|
|
346
|
+
messageCount,
|
|
347
|
+
entryCount: entries.length,
|
|
348
|
+
maxEntryTs,
|
|
349
|
+
firstUserPrompt,
|
|
350
|
+
sessionName,
|
|
351
|
+
handoffMetadata,
|
|
233
352
|
};
|
|
234
353
|
}
|
|
235
354
|
|
|
355
|
+
function maxTimestamp(fallbackTs: string, entryTs: string | undefined): string {
|
|
356
|
+
return entryTs !== undefined && entryTs > fallbackTs ? entryTs : fallbackTs;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Consumes only whole JSONL lines so the recorded byte offset always lands on
|
|
360
|
+
// an entry boundary. A final unterminated line is consumed only if it parses as
|
|
361
|
+
// complete JSON: a strict prefix of a JSON object can never parse, so a torn
|
|
362
|
+
// in-progress write is reliably excluded.
|
|
363
|
+
function sliceCompleteJsonlPrefix(buffer: Buffer): { content: string; consumedBytes: number } {
|
|
364
|
+
if (buffer.length === 0) {
|
|
365
|
+
return { content: "", consumedBytes: 0 };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (buffer[buffer.length - 1] === NEWLINE_BYTE) {
|
|
369
|
+
return { content: buffer.toString("utf8"), consumedBytes: buffer.length };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const lastNewline = buffer.lastIndexOf(NEWLINE_BYTE);
|
|
373
|
+
const finalLine = buffer.subarray(lastNewline + 1).toString("utf8");
|
|
374
|
+
if (isCompleteJsonLine(finalLine)) {
|
|
375
|
+
return { content: buffer.toString("utf8"), consumedBytes: buffer.length };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const consumedBytes = lastNewline + 1;
|
|
379
|
+
return { content: buffer.subarray(0, consumedBytes).toString("utf8"), consumedBytes };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function isCompleteJsonLine(line: string): boolean {
|
|
383
|
+
const trimmed = line.trim();
|
|
384
|
+
if (!trimmed) {
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
JSON.parse(trimmed);
|
|
390
|
+
return true;
|
|
391
|
+
} catch {
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function buildFileAnchor(buffer: Buffer, end: number): string {
|
|
397
|
+
const start = Math.max(0, end - FILE_ANCHOR_BYTES);
|
|
398
|
+
return buffer.subarray(start, end).toString("base64");
|
|
399
|
+
}
|
|
400
|
+
|
|
236
401
|
export function parseSessionFile(sessionPath: string): ParsedSessionFile | undefined {
|
|
237
|
-
|
|
402
|
+
return parseSessionContent(readFileSync(sessionPath, "utf8"));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function parseSessionContent(raw: string): ParsedSessionFile | undefined {
|
|
238
406
|
const fileEntries = parseSessionEntries(raw);
|
|
239
407
|
if (fileEntries.length === 0) {
|
|
240
408
|
return undefined;
|