@oh-my-pi/pi-coding-agent 16.2.2 → 16.2.4
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/CHANGELOG.md +60 -0
- package/dist/cli.js +3682 -3615
- package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
- package/dist/types/advisor/advise-tool.d.ts +8 -4
- package/dist/types/advisor/config.d.ts +88 -0
- package/dist/types/advisor/index.d.ts +1 -0
- package/dist/types/advisor/transcript-recorder.d.ts +13 -2
- package/dist/types/advisor/watchdog.d.ts +20 -0
- package/dist/types/cli/update-cli.d.ts +15 -0
- package/dist/types/collab/guest.d.ts +29 -0
- package/dist/types/collab/replication-shrink.d.ts +39 -0
- package/dist/types/config/provider-globals.d.ts +7 -0
- package/dist/types/config/settings-schema.d.ts +81 -0
- package/dist/types/debug/log-viewer.d.ts +1 -0
- package/dist/types/debug/raw-sse.d.ts +1 -0
- package/dist/types/edit/hashline/diff.d.ts +0 -11
- package/dist/types/extensibility/tool-event-input.d.ts +7 -0
- package/dist/types/extensibility/utils.d.ts +12 -0
- package/dist/types/mcp/transports/index.d.ts +1 -0
- package/dist/types/mcp/transports/sse.d.ts +20 -0
- package/dist/types/memories/index.d.ts +20 -1
- package/dist/types/modes/components/advisor-config.d.ts +59 -0
- package/dist/types/modes/components/index.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +9 -1
- package/dist/types/modes/components/settings-selector.d.ts +1 -0
- package/dist/types/modes/components/status-line/component.d.ts +35 -1
- package/dist/types/modes/components/status-line/types.d.ts +13 -1
- package/dist/types/modes/controllers/command-controller.d.ts +3 -3
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +11 -4
- package/dist/types/modes/skill-command.d.ts +32 -0
- package/dist/types/modes/types.d.ts +9 -3
- package/dist/types/session/agent-session.d.ts +58 -10
- package/dist/types/session/indexed-session-storage.d.ts +7 -1
- package/dist/types/session/messages.d.ts +26 -0
- package/dist/types/session/messages.test.d.ts +1 -0
- package/dist/types/session/session-entries.d.ts +31 -3
- package/dist/types/session/session-history-format.d.ts +6 -0
- package/dist/types/session/session-loader.d.ts +9 -1
- package/dist/types/session/session-manager.d.ts +8 -7
- package/dist/types/session/session-storage.d.ts +11 -0
- package/dist/types/session/session-title-slot.d.ts +19 -0
- package/dist/types/ssh/connection-manager.d.ts +47 -0
- package/dist/types/ssh/utils.d.ts +16 -0
- package/dist/types/task/executor.d.ts +3 -16
- package/dist/types/task/render.d.ts +0 -5
- package/dist/types/task/renderer.d.ts +13 -0
- package/dist/types/task/types.d.ts +16 -0
- package/dist/types/task/yield-assembly.d.ts +28 -0
- package/dist/types/tiny/text.d.ts +8 -0
- package/dist/types/tools/render-utils.d.ts +2 -0
- package/dist/types/tools/review.d.ts +6 -4
- package/dist/types/tools/ssh.d.ts +1 -1
- package/dist/types/tools/todo.d.ts +6 -0
- package/dist/types/tools/yield.d.ts +8 -3
- package/dist/types/utils/thinking-display.d.ts +4 -0
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +242 -10
- package/src/advisor/__tests__/config.test.ts +173 -0
- package/src/advisor/advise-tool.ts +11 -6
- package/src/advisor/config.ts +256 -0
- package/src/advisor/index.ts +1 -0
- package/src/advisor/runtime.ts +12 -2
- package/src/advisor/transcript-recorder.ts +25 -2
- package/src/advisor/watchdog.ts +57 -31
- package/src/autolearn/controller.ts +13 -22
- package/src/autoresearch/index.ts +7 -2
- package/src/cli/gc-cli.ts +17 -10
- package/src/cli/update-cli.ts +254 -0
- package/src/collab/guest.ts +43 -7
- package/src/collab/host.ts +13 -8
- package/src/collab/replication-shrink.ts +111 -0
- package/src/config/model-registry.ts +80 -18
- package/src/config/provider-globals.ts +25 -0
- package/src/config/settings-schema.ts +77 -0
- package/src/debug/index.ts +32 -7
- package/src/debug/log-viewer.ts +111 -53
- package/src/debug/raw-sse.ts +68 -48
- package/src/discovery/codex.ts +13 -5
- package/src/edit/hashline/diff.ts +57 -4
- package/src/eval/__tests__/julia-prelude.test.ts +2 -2
- package/src/eval/js/shared/local-module-loader.ts +23 -1
- package/src/export/html/template.js +13 -7
- package/src/extensibility/extensions/loader.ts +5 -3
- package/src/extensibility/extensions/wrapper.ts +9 -3
- package/src/extensibility/hooks/loader.ts +3 -3
- package/src/extensibility/hooks/tool-wrapper.ts +13 -4
- package/src/extensibility/plugins/manager.ts +2 -1
- package/src/extensibility/tool-event-input.ts +23 -0
- package/src/extensibility/utils.ts +74 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/mcp/client.ts +3 -1
- package/src/mcp/manager.ts +12 -5
- package/src/mcp/transports/index.ts +1 -0
- package/src/mcp/transports/sse.ts +377 -0
- package/src/memories/index.ts +130 -15
- package/src/memory-backend/local-backend.ts +5 -3
- package/src/modes/components/advisor-config.ts +555 -0
- package/src/modes/components/advisor-message.ts +9 -2
- package/src/modes/components/agent-hub.ts +9 -4
- package/src/modes/components/index.ts +2 -0
- package/src/modes/components/model-selector.ts +79 -48
- package/src/modes/components/settings-selector.ts +1 -0
- package/src/modes/components/status-line/component.ts +150 -5
- package/src/modes/components/status-line/segments.ts +46 -21
- package/src/modes/components/status-line/types.ts +13 -1
- package/src/modes/components/tool-execution.ts +47 -6
- package/src/modes/controllers/command-controller.ts +27 -36
- package/src/modes/controllers/event-controller.ts +113 -1
- package/src/modes/controllers/extension-ui-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +61 -61
- package/src/modes/controllers/selector-controller.ts +100 -9
- package/src/modes/interactive-mode.ts +74 -25
- package/src/modes/skill-command.ts +116 -0
- package/src/modes/types.ts +9 -3
- package/src/modes/utils/ui-helpers.ts +41 -23
- package/src/prompts/agents/reviewer.md +11 -10
- package/src/prompts/goals/goal-todo-context.md +12 -0
- package/src/prompts/review-custom-request.md +1 -2
- package/src/prompts/review-request.md +1 -2
- package/src/prompts/system/interrupted-thinking.md +7 -0
- package/src/prompts/system/recap-user.md +9 -0
- package/src/prompts/system/subagent-system-prompt.md +8 -5
- package/src/prompts/system/subagent-yield-reminder.md +6 -5
- package/src/prompts/system/system-prompt.md +0 -1
- package/src/prompts/tools/irc.md +2 -2
- package/src/prompts/tools/read.md +2 -2
- package/src/sdk.ts +28 -24
- package/src/session/agent-session.ts +899 -429
- package/src/session/indexed-session-storage.ts +86 -13
- package/src/session/messages.test.ts +125 -0
- package/src/session/messages.ts +172 -9
- package/src/session/redis-session-storage.ts +49 -2
- package/src/session/session-entries.ts +39 -2
- package/src/session/session-history-format.ts +29 -2
- package/src/session/session-listing.ts +54 -24
- package/src/session/session-loader.ts +66 -3
- package/src/session/session-manager.ts +115 -22
- package/src/session/session-persistence.ts +95 -1
- package/src/session/session-storage.ts +36 -0
- package/src/session/session-title-slot.ts +141 -0
- package/src/session/sql-session-storage.ts +71 -11
- package/src/slash-commands/builtin-registry.ts +23 -24
- package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
- package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
- package/src/ssh/connection-manager.ts +139 -12
- package/src/ssh/file-transfer.ts +23 -18
- package/src/ssh/ssh-executor.ts +2 -13
- package/src/ssh/utils.ts +19 -0
- package/src/task/executor.ts +21 -23
- package/src/task/render.ts +162 -20
- package/src/task/renderer.ts +14 -0
- package/src/task/types.ts +17 -0
- package/src/task/yield-assembly.ts +207 -0
- package/src/tiny/text.ts +23 -0
- package/src/tools/ask.ts +55 -4
- package/src/tools/render-utils.ts +2 -0
- package/src/tools/renderers.ts +8 -2
- package/src/tools/review.ts +17 -7
- package/src/tools/ssh.ts +8 -4
- package/src/tools/todo.ts +17 -1
- package/src/tools/yield.ts +140 -31
- package/src/utils/thinking-display.ts +15 -0
- package/src/prompts/system/autolearn-nudge.md +0 -5
|
@@ -3,6 +3,24 @@ import type { ImageContent, MessageAttribution, ServiceTier, TextContent } from
|
|
|
3
3
|
|
|
4
4
|
export const CURRENT_SESSION_VERSION = 3;
|
|
5
5
|
|
|
6
|
+
export const SESSION_TITLE_SLOT_BYTES = 256;
|
|
7
|
+
|
|
8
|
+
export const SESSION_TITLE_SLOT_ENTRY_TYPE = "title";
|
|
9
|
+
|
|
10
|
+
export const TITLE_CHANGE_ENTRY_TYPE = "title_change";
|
|
11
|
+
|
|
12
|
+
export type SessionTitleSource = "auto" | "user";
|
|
13
|
+
|
|
14
|
+
/** Fixed-width first-line slot carrying the mutable current session title. */
|
|
15
|
+
export interface SessionTitleSlotEntry {
|
|
16
|
+
type: typeof SESSION_TITLE_SLOT_ENTRY_TYPE;
|
|
17
|
+
v: 1;
|
|
18
|
+
title: string;
|
|
19
|
+
source?: SessionTitleSource;
|
|
20
|
+
updatedAt: string;
|
|
21
|
+
pad: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
6
24
|
export const EPHEMERAL_MODEL_CHANGE_ROLE = "fallback";
|
|
7
25
|
|
|
8
26
|
export interface SessionHeader {
|
|
@@ -10,7 +28,7 @@ export interface SessionHeader {
|
|
|
10
28
|
version?: number; // v1 sessions don't have this
|
|
11
29
|
id: string;
|
|
12
30
|
title?: string; // Auto-generated title from first message
|
|
13
|
-
titleSource?:
|
|
31
|
+
titleSource?: SessionTitleSource;
|
|
14
32
|
timestamp: string;
|
|
15
33
|
cwd: string;
|
|
16
34
|
parentSession?: string;
|
|
@@ -105,6 +123,21 @@ export interface LabelEntry extends SessionEntryBase {
|
|
|
105
123
|
label: string | undefined;
|
|
106
124
|
}
|
|
107
125
|
|
|
126
|
+
/** Append-only audit entry recording a session title change. */
|
|
127
|
+
export interface TitleChangeEntry extends SessionEntryBase {
|
|
128
|
+
type: typeof TITLE_CHANGE_ENTRY_TYPE;
|
|
129
|
+
title: string;
|
|
130
|
+
previousTitle?: string;
|
|
131
|
+
source: SessionTitleSource;
|
|
132
|
+
trigger?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
declare module "@oh-my-pi/pi-agent-core/compaction/entries" {
|
|
136
|
+
interface CustomCompactionSessionEntries {
|
|
137
|
+
titleChange: TitleChangeEntry;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
108
141
|
/** TTSR injection entry - tracks which time-traveling rules have been injected this session. */
|
|
109
142
|
export interface TtsrInjectionEntry extends SessionEntryBase {
|
|
110
143
|
type: "ttsr_injection";
|
|
@@ -178,14 +211,18 @@ export type SessionEntry =
|
|
|
178
211
|
| CustomEntry
|
|
179
212
|
| CustomMessageEntry
|
|
180
213
|
| LabelEntry
|
|
214
|
+
| TitleChangeEntry
|
|
181
215
|
| TtsrInjectionEntry
|
|
182
216
|
| MCPToolSelectionEntry
|
|
183
217
|
| SessionInitEntry
|
|
184
218
|
| ModeChangeEntry;
|
|
185
219
|
|
|
186
|
-
/** Raw file entry
|
|
220
|
+
/** Raw logical file entry after loaders strip any fixed-width title slot. */
|
|
187
221
|
export type FileEntry = SessionHeader | SessionEntry;
|
|
188
222
|
|
|
223
|
+
/** Physical JSONL entry before slot-aware loaders fold the title slot. */
|
|
224
|
+
export type RawFileEntry = SessionTitleSlotEntry | FileEntry;
|
|
225
|
+
|
|
189
226
|
/** Tree node for getTree() - defensive copy of session structure */
|
|
190
227
|
export interface SessionTreeNode {
|
|
191
228
|
entry: SessionEntry;
|
|
@@ -40,6 +40,12 @@ export interface HistoryFormatOptions {
|
|
|
40
40
|
* still collapse to a one-liner.
|
|
41
41
|
*/
|
|
42
42
|
expandPrimaryContext?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Append the full unified diff (from a tool result's `details.diff`) below
|
|
45
|
+
* edit/apply_patch tool lines, instead of just the path. The advisor sets
|
|
46
|
+
* this so it sees what changed without re-reading the file.
|
|
47
|
+
*/
|
|
48
|
+
expandEditDiffs?: boolean;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
/** Max length of the primary-arg summary inside `→ tool(...)` lines. */
|
|
@@ -139,12 +145,24 @@ function primaryArg(name: string, args: Record<string, unknown> | undefined): st
|
|
|
139
145
|
}
|
|
140
146
|
}
|
|
141
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Wrap a diff body in a backtick fence sized to outlast the longest backtick
|
|
150
|
+
* run inside it, so a diff that touches markdown (triple backticks) can't break
|
|
151
|
+
* out of the fence. Info string `diff` for syntax highlighting.
|
|
152
|
+
*/
|
|
153
|
+
function fenceDiff(diff: string): string {
|
|
154
|
+
const longest = diff.match(/`+/g)?.reduce((m, run) => Math.max(m, run.length), 0) ?? 0;
|
|
155
|
+
const fence = "`".repeat(Math.max(3, longest + 1));
|
|
156
|
+
return `${fence}diff\n${diff}\n${fence}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
142
159
|
/** One line per tool call: `→ read(src/foo.ts:50-80) ⇒ ok · 31 lines`. */
|
|
143
160
|
function toolCallLine(
|
|
144
161
|
name: string,
|
|
145
162
|
args: Record<string, unknown> | undefined,
|
|
146
163
|
result: ToolResultMessage | undefined,
|
|
147
164
|
includeToolIntent?: boolean,
|
|
165
|
+
expandEditDiffs?: boolean,
|
|
148
166
|
): string {
|
|
149
167
|
const head = `→ ${name}(${primaryArg(name, args)})`;
|
|
150
168
|
let base: string;
|
|
@@ -162,6 +180,13 @@ function toolCallLine(
|
|
|
162
180
|
}
|
|
163
181
|
}
|
|
164
182
|
|
|
183
|
+
if (expandEditDiffs) {
|
|
184
|
+
const diff = (result?.details as { diff?: unknown } | undefined)?.diff;
|
|
185
|
+
if (typeof diff === "string" && diff.trim()) {
|
|
186
|
+
base = `${base}\n${fenceDiff(diff)}`;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
165
190
|
const intent = includeToolIntent ? args?.[INTENT_FIELD] : undefined;
|
|
166
191
|
if (typeof intent === "string" && intent.trim()) {
|
|
167
192
|
const formattedIntent = oneLine(intent, 80);
|
|
@@ -280,7 +305,9 @@ export function formatSessionHistoryMarkdown(messages: unknown[], opts?: History
|
|
|
280
305
|
} else if (block.type === "toolCall") {
|
|
281
306
|
const result = resultsByCallId.get(block.id);
|
|
282
307
|
if (result) consumed.add(block.id);
|
|
283
|
-
body.push(
|
|
308
|
+
body.push(
|
|
309
|
+
toolCallLine(block.name, block.arguments, result, opts?.includeToolIntent, opts?.expandEditDiffs),
|
|
310
|
+
);
|
|
284
311
|
} else if (opts?.includeThinking && block.type === "thinking" && block.thinking.trim()) {
|
|
285
312
|
body.push(`_thinking:_ ${block.thinking}`);
|
|
286
313
|
}
|
|
@@ -303,7 +330,7 @@ export function formatSessionHistoryMarkdown(messages: unknown[], opts?: History
|
|
|
303
330
|
case "toolResult": {
|
|
304
331
|
// Normally consumed by its toolCall; orphans (e.g. truncated history) get their own line.
|
|
305
332
|
if (consumed.has(msg.toolCallId)) break;
|
|
306
|
-
lines.push(toolCallLine(msg.toolName, undefined, msg, opts?.includeToolIntent), "");
|
|
333
|
+
lines.push(toolCallLine(msg.toolName, undefined, msg, opts?.includeToolIntent, opts?.expandEditDiffs), "");
|
|
307
334
|
lastWatchedLabel = undefined;
|
|
308
335
|
break;
|
|
309
336
|
}
|
|
@@ -270,39 +270,69 @@ interface SessionListHeader {
|
|
|
270
270
|
timestamp?: string;
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
-
function
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const parsedHeader = entries[0];
|
|
278
|
-
if (parsedHeader?.type === "session" && typeof parsedHeader.id === "string") {
|
|
279
|
-
return {
|
|
280
|
-
type: "session",
|
|
281
|
-
id: parsedHeader.id,
|
|
282
|
-
cwd: typeof parsedHeader.cwd === "string" ? parsedHeader.cwd : undefined,
|
|
283
|
-
title: typeof parsedHeader.title === "string" ? parsedHeader.title : undefined,
|
|
284
|
-
parentSession: typeof parsedHeader.parentSession === "string" ? parsedHeader.parentSession : undefined,
|
|
285
|
-
timestamp: typeof parsedHeader.timestamp === "string" ? parsedHeader.timestamp : undefined,
|
|
286
|
-
};
|
|
287
|
-
}
|
|
273
|
+
function normalizeTitleOverride(title: string | undefined): string | null | undefined {
|
|
274
|
+
if (title === undefined) return undefined;
|
|
275
|
+
return title.trim() ? title : null;
|
|
276
|
+
}
|
|
288
277
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
278
|
+
function sessionListHeaderFromRecord(
|
|
279
|
+
record: Record<string, unknown> | undefined,
|
|
280
|
+
titleOverride?: string | null,
|
|
281
|
+
): SessionListHeader | undefined {
|
|
282
|
+
if (record?.type !== "session" || typeof record.id !== "string") return undefined;
|
|
283
|
+
return {
|
|
284
|
+
type: "session",
|
|
285
|
+
id: record.id,
|
|
286
|
+
cwd: typeof record.cwd === "string" ? record.cwd : undefined,
|
|
287
|
+
title:
|
|
288
|
+
titleOverride === null
|
|
289
|
+
? undefined
|
|
290
|
+
: (titleOverride ?? (typeof record.title === "string" ? record.title : undefined)),
|
|
291
|
+
parentSession: typeof record.parentSession === "string" ? record.parentSession : undefined,
|
|
292
|
+
timestamp: typeof record.timestamp === "string" ? record.timestamp : undefined,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
292
295
|
|
|
293
|
-
|
|
296
|
+
function parseSessionListHeaderLine(line: string, titleOverride?: string | null): SessionListHeader | undefined {
|
|
297
|
+
if (extractStringProperty(line, "type") !== "session") return undefined;
|
|
298
|
+
const id = extractStringProperty(line, "id");
|
|
294
299
|
if (!id) return undefined;
|
|
295
|
-
|
|
296
300
|
return {
|
|
297
301
|
type: "session",
|
|
298
302
|
id,
|
|
299
|
-
cwd: extractStringProperty(
|
|
300
|
-
title: extractStringProperty(
|
|
301
|
-
parentSession: extractStringProperty(
|
|
302
|
-
timestamp: extractStringProperty(
|
|
303
|
+
cwd: extractStringProperty(line, "cwd"),
|
|
304
|
+
title: titleOverride === null ? undefined : (titleOverride ?? extractStringProperty(line, "title")),
|
|
305
|
+
parentSession: extractStringProperty(line, "parentSession"),
|
|
306
|
+
timestamp: extractStringProperty(line, "timestamp"),
|
|
303
307
|
};
|
|
304
308
|
}
|
|
305
309
|
|
|
310
|
+
function parseSessionListHeader(
|
|
311
|
+
content: string,
|
|
312
|
+
entries: Array<Record<string, unknown>>,
|
|
313
|
+
): SessionListHeader | undefined {
|
|
314
|
+
const firstEntry = entries[0];
|
|
315
|
+
const parsedSlotTitle = normalizeTitleOverride(
|
|
316
|
+
firstEntry?.type === "title" && typeof firstEntry.title === "string" ? firstEntry.title : undefined,
|
|
317
|
+
);
|
|
318
|
+
const parsedHeader = sessionListHeaderFromRecord(entries[firstEntry?.type === "title" ? 1 : 0], parsedSlotTitle);
|
|
319
|
+
if (parsedHeader) return parsedHeader;
|
|
320
|
+
|
|
321
|
+
let slotTitle: string | null | undefined;
|
|
322
|
+
let firstNonEmpty = true;
|
|
323
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
324
|
+
const line = rawLine.trim();
|
|
325
|
+
if (!line) continue;
|
|
326
|
+
if (firstNonEmpty && extractStringProperty(line, "type") === "title") {
|
|
327
|
+
slotTitle = normalizeTitleOverride(extractStringProperty(line, "title"));
|
|
328
|
+
firstNonEmpty = false;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
return parseSessionListHeaderLine(line, slotTitle);
|
|
332
|
+
}
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
306
336
|
function getSessionListWorkerCount(fileCount: number): number {
|
|
307
337
|
if (fileCount <= SESSION_LIST_PARALLEL_THRESHOLD) return 1;
|
|
308
338
|
return Math.min(
|
|
@@ -2,14 +2,77 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
|
2
2
|
import { getBlobsDir, isEnoent, parseJsonlLenient } from "@oh-my-pi/pi-utils";
|
|
3
3
|
import { BlobStore, isBlobRef, resolveImageData, resolveImageDataUrl } from "./blob-store";
|
|
4
4
|
import { buildSessionContext } from "./session-context";
|
|
5
|
-
import
|
|
5
|
+
import {
|
|
6
|
+
type FileEntry,
|
|
7
|
+
type RawFileEntry,
|
|
8
|
+
SESSION_TITLE_SLOT_BYTES,
|
|
9
|
+
type SessionEntry,
|
|
10
|
+
type SessionHeader,
|
|
11
|
+
type SessionTitleSlotEntry,
|
|
12
|
+
} from "./session-entries";
|
|
6
13
|
import { migrateToCurrentVersion } from "./session-migrations";
|
|
7
14
|
import { isImageBlock, isImageDataPayload } from "./session-persistence";
|
|
8
15
|
import { FileSessionStorage, type SessionStorage } from "./session-storage";
|
|
16
|
+
import {
|
|
17
|
+
parseTitleSlotFromContent,
|
|
18
|
+
parseTitleSlotLine,
|
|
19
|
+
type SessionTitleUpdate,
|
|
20
|
+
titleUpdateFromSlot,
|
|
21
|
+
} from "./session-title-slot";
|
|
9
22
|
|
|
23
|
+
function splitTitleSlot(content: string): { body: string; slot: SessionTitleUpdate | undefined } {
|
|
24
|
+
const slot = titleUpdateFromSlot(parseTitleSlotFromContent(content));
|
|
25
|
+
if (!slot) return { body: content, slot: undefined };
|
|
26
|
+
const newlineIndex = content.indexOf("\n");
|
|
27
|
+
return { body: content.slice(newlineIndex + 1), slot };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function foldTitleSlot(entries: FileEntry[], slot: SessionTitleUpdate | undefined): FileEntry[] {
|
|
31
|
+
if (!slot || entries.length === 0) return entries;
|
|
32
|
+
const header = entries[0] as SessionHeader;
|
|
33
|
+
if (header.type !== "session" || typeof header.id !== "string") return entries;
|
|
34
|
+
if (slot.title && slot.title.length > 0) {
|
|
35
|
+
header.title = slot.title;
|
|
36
|
+
} else {
|
|
37
|
+
delete header.title;
|
|
38
|
+
}
|
|
39
|
+
if (slot.source) {
|
|
40
|
+
header.titleSource = slot.source;
|
|
41
|
+
} else {
|
|
42
|
+
delete header.titleSource;
|
|
43
|
+
}
|
|
44
|
+
return entries;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Parse session JSONL while stripping and folding the optional fixed title slot. */
|
|
48
|
+
export function parseSessionContent(content: string): {
|
|
49
|
+
entries: FileEntry[];
|
|
50
|
+
titleSlot: SessionTitleUpdate | undefined;
|
|
51
|
+
} {
|
|
52
|
+
const { body, slot } = splitTitleSlot(content);
|
|
53
|
+
const entries = parseJsonlLenient<RawFileEntry>(body) as FileEntry[];
|
|
54
|
+
return { entries: foldTitleSlot(entries, slot), titleSlot: slot };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Read only the fixed-size head window to detect a physical title slot. */
|
|
58
|
+
export async function readTitleSlotFromFile(
|
|
59
|
+
filePath: string,
|
|
60
|
+
storage: SessionStorage = new FileSessionStorage(),
|
|
61
|
+
): Promise<SessionTitleSlotEntry | undefined> {
|
|
62
|
+
let head: string;
|
|
63
|
+
try {
|
|
64
|
+
[head] = await storage.readTextSlices(filePath, SESSION_TITLE_SLOT_BYTES, 0);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (isEnoent(err)) return undefined;
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
const newlineIndex = head.indexOf("\n");
|
|
70
|
+
if (newlineIndex < 0) return undefined;
|
|
71
|
+
return parseTitleSlotLine(head.slice(0, newlineIndex));
|
|
72
|
+
}
|
|
10
73
|
/** Exported for compaction.test.ts */
|
|
11
74
|
export function parseSessionEntries(content: string): FileEntry[] {
|
|
12
|
-
return
|
|
75
|
+
return parseSessionContent(content).entries;
|
|
13
76
|
}
|
|
14
77
|
|
|
15
78
|
/** Exported for testing */
|
|
@@ -24,7 +87,7 @@ export async function loadEntriesFromFile(
|
|
|
24
87
|
if (isEnoent(err)) return [];
|
|
25
88
|
throw err;
|
|
26
89
|
}
|
|
27
|
-
const entries =
|
|
90
|
+
const { entries } = parseSessionContent(content);
|
|
28
91
|
|
|
29
92
|
// Validate session header
|
|
30
93
|
if (entries.length === 0) return entries;
|
|
@@ -39,13 +39,16 @@ import {
|
|
|
39
39
|
type SessionHeader,
|
|
40
40
|
type SessionInitEntry,
|
|
41
41
|
type SessionMessageEntry,
|
|
42
|
+
type SessionTitleSource,
|
|
42
43
|
type SessionTreeNode,
|
|
43
44
|
type ThinkingLevelChangeEntry,
|
|
45
|
+
TITLE_CHANGE_ENTRY_TYPE,
|
|
46
|
+
type TitleChangeEntry,
|
|
44
47
|
type TtsrInjectionEntry,
|
|
45
48
|
type UsageStatistics,
|
|
46
49
|
} from "./session-entries";
|
|
47
50
|
import { findMostRecentSession, listAllSessions, listSessions, type SessionInfo } from "./session-listing";
|
|
48
|
-
import { loadEntriesFromFile, resolveBlobRefsInEntries } from "./session-loader";
|
|
51
|
+
import { loadEntriesFromFile, readTitleSlotFromFile, resolveBlobRefsInEntries } from "./session-loader";
|
|
49
52
|
import { generateId, migrateToCurrentVersion } from "./session-migrations";
|
|
50
53
|
import {
|
|
51
54
|
computeDefaultSessionDir,
|
|
@@ -60,6 +63,7 @@ import {
|
|
|
60
63
|
type SessionStorage,
|
|
61
64
|
type SessionStorageWriter,
|
|
62
65
|
} from "./session-storage";
|
|
66
|
+
import { type SessionTitleUpdate, serializeTitleSlot } from "./session-title-slot";
|
|
63
67
|
|
|
64
68
|
const JSONL_SUFFIX_LENGTH = ".jsonl".length;
|
|
65
69
|
|
|
@@ -298,8 +302,10 @@ interface SessionManagerStateSnapshot {
|
|
|
298
302
|
sessionDir: string;
|
|
299
303
|
sessionId: string;
|
|
300
304
|
sessionName: string | undefined;
|
|
301
|
-
titleSource:
|
|
305
|
+
titleSource: SessionTitleSource | undefined;
|
|
302
306
|
sessionFile: string | undefined;
|
|
307
|
+
titleUpdatedAt: string;
|
|
308
|
+
hasTitleSlot: boolean;
|
|
303
309
|
onDisk: boolean;
|
|
304
310
|
needsRewrite: boolean;
|
|
305
311
|
header: SessionHeader;
|
|
@@ -334,9 +340,11 @@ export class SessionManager {
|
|
|
334
340
|
|
|
335
341
|
#sessionId = "";
|
|
336
342
|
#sessionName: string | undefined;
|
|
337
|
-
#titleSource:
|
|
343
|
+
#titleSource: SessionTitleSource | undefined;
|
|
338
344
|
#sessionFile: string | undefined;
|
|
339
345
|
#header!: SessionHeader;
|
|
346
|
+
#titleUpdatedAt = "";
|
|
347
|
+
#hasTitleSlot = true;
|
|
340
348
|
#entries: SessionEntry[] = [];
|
|
341
349
|
#index = new SessionEntryIndex();
|
|
342
350
|
|
|
@@ -471,8 +479,17 @@ export class SessionManager {
|
|
|
471
479
|
return `${JSON.stringify(prepareEntryForPersistence(entry, this.#blobs))}\n`;
|
|
472
480
|
}
|
|
473
481
|
|
|
482
|
+
#titleSlotLine(): string {
|
|
483
|
+
return serializeTitleSlot({
|
|
484
|
+
title: this.#sessionName,
|
|
485
|
+
source: this.#titleSource,
|
|
486
|
+
updatedAt: this.#titleUpdatedAt || this.#header.timestamp,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
474
490
|
#fileBody(): string {
|
|
475
|
-
let body = this.#
|
|
491
|
+
let body = this.#titleSlotLine();
|
|
492
|
+
body += this.#lineFor(this.#header);
|
|
476
493
|
for (const entry of this.#entries) body += this.#lineFor(entry);
|
|
477
494
|
return body;
|
|
478
495
|
}
|
|
@@ -501,6 +518,7 @@ export class SessionManager {
|
|
|
501
518
|
this.#storage.writeTextSync(this.#sessionFile, body);
|
|
502
519
|
this.#fileIsCurrent = true;
|
|
503
520
|
this.#rewriteRequired = false;
|
|
521
|
+
this.#hasTitleSlot = true;
|
|
504
522
|
} catch (err) {
|
|
505
523
|
this.#noteDiskFailure(err);
|
|
506
524
|
}
|
|
@@ -523,6 +541,7 @@ export class SessionManager {
|
|
|
523
541
|
await this.#storage.writeTextAtomic(sessionFile, this.#fileBody());
|
|
524
542
|
this.#fileIsCurrent = true;
|
|
525
543
|
this.#rewriteRequired = false;
|
|
544
|
+
this.#hasTitleSlot = true;
|
|
526
545
|
},
|
|
527
546
|
{ epoch },
|
|
528
547
|
);
|
|
@@ -561,12 +580,67 @@ export class SessionManager {
|
|
|
561
580
|
}
|
|
562
581
|
}
|
|
563
582
|
|
|
583
|
+
async #persistTitleChangeEntry(entry: TitleChangeEntry, update: SessionTitleUpdate): Promise<void> {
|
|
584
|
+
if (!this.#persist || !this.#sessionFile) return;
|
|
585
|
+
if (this.#diskFailure) throw this.#diskFailure;
|
|
586
|
+
|
|
587
|
+
if (!this.#shouldHaveSessionFile()) {
|
|
588
|
+
this.#fileIsCurrent = false;
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (
|
|
593
|
+
!this.#fileIsCurrent ||
|
|
594
|
+
this.#rewriteRequired ||
|
|
595
|
+
!this.#hasTitleSlot ||
|
|
596
|
+
!this.#storage.existsSync(this.#sessionFile)
|
|
597
|
+
) {
|
|
598
|
+
await this.#rewriteAtomically();
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const epoch = this.#diskEpoch;
|
|
603
|
+
const line = this.#lineFor(entry);
|
|
604
|
+
await this.#scheduleDiskWork(
|
|
605
|
+
async () => {
|
|
606
|
+
const sessionFile = this.#sessionFile;
|
|
607
|
+
if (!sessionFile) return;
|
|
608
|
+
try {
|
|
609
|
+
await this.#appendWriter().append(line);
|
|
610
|
+
await this.#storage.updateSessionTitle(sessionFile, update);
|
|
611
|
+
this.#fileIsCurrent = true;
|
|
612
|
+
} catch {
|
|
613
|
+
await this.#closeWriterHandle();
|
|
614
|
+
await this.#storage.writeTextAtomic(sessionFile, this.#fileBody());
|
|
615
|
+
this.#clearDiskError();
|
|
616
|
+
this.#fileIsCurrent = true;
|
|
617
|
+
this.#rewriteRequired = false;
|
|
618
|
+
this.#hasTitleSlot = true;
|
|
619
|
+
}
|
|
620
|
+
},
|
|
621
|
+
{ epoch },
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
#notifyEntryAppended(entry: SessionEntry): void {
|
|
626
|
+
const callback = this.onEntryAppended;
|
|
627
|
+
if (callback) {
|
|
628
|
+
try {
|
|
629
|
+
callback(entry);
|
|
630
|
+
} catch (err) {
|
|
631
|
+
logger.warn("collab entry hook failed", { error: String(err) });
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
564
636
|
#resetToNewSession(options?: NewSessionOptions, forcedSessionFile?: string): string | undefined {
|
|
565
637
|
this.#diskTail = Promise.resolve();
|
|
566
638
|
this.#clearDiskError();
|
|
567
639
|
this.#sessionId = mintSessionId();
|
|
568
640
|
this.#sessionName = undefined;
|
|
569
641
|
this.#titleSource = undefined;
|
|
642
|
+
this.#titleUpdatedAt = "";
|
|
643
|
+
this.#hasTitleSlot = true;
|
|
570
644
|
|
|
571
645
|
const timestamp = nowIso();
|
|
572
646
|
this.#header = {
|
|
@@ -577,6 +651,7 @@ export class SessionManager {
|
|
|
577
651
|
cwd: this.#cwd,
|
|
578
652
|
parentSession: options?.parentSession,
|
|
579
653
|
};
|
|
654
|
+
this.#titleUpdatedAt = timestamp;
|
|
580
655
|
|
|
581
656
|
this.#entries = [];
|
|
582
657
|
this.#index.clear();
|
|
@@ -611,6 +686,7 @@ export class SessionManager {
|
|
|
611
686
|
this.#sessionId = header.id;
|
|
612
687
|
this.#sessionName = header.title;
|
|
613
688
|
this.#titleSource = header.titleSource;
|
|
689
|
+
this.#titleUpdatedAt = header.timestamp;
|
|
614
690
|
this.#index.rebuild(entries);
|
|
615
691
|
}
|
|
616
692
|
|
|
@@ -626,15 +702,7 @@ export class SessionManager {
|
|
|
626
702
|
this.#entries.push(entry);
|
|
627
703
|
this.#index.insert(entry);
|
|
628
704
|
this.#appendToSessionFile(entry);
|
|
629
|
-
|
|
630
|
-
const callback = this.onEntryAppended;
|
|
631
|
-
if (callback) {
|
|
632
|
-
try {
|
|
633
|
-
callback(entry);
|
|
634
|
-
} catch (err) {
|
|
635
|
-
logger.warn("collab entry hook failed", { error: String(err) });
|
|
636
|
-
}
|
|
637
|
-
}
|
|
705
|
+
this.#notifyEntryAppended(entry);
|
|
638
706
|
}
|
|
639
707
|
|
|
640
708
|
#draftPath(): string | null {
|
|
@@ -693,6 +761,8 @@ export class SessionManager {
|
|
|
693
761
|
sessionId: this.#sessionId,
|
|
694
762
|
sessionName: this.#sessionName,
|
|
695
763
|
titleSource: this.#titleSource,
|
|
764
|
+
titleUpdatedAt: this.#titleUpdatedAt,
|
|
765
|
+
hasTitleSlot: this.#hasTitleSlot,
|
|
696
766
|
sessionFile: this.#sessionFile,
|
|
697
767
|
onDisk: this.#fileIsCurrent,
|
|
698
768
|
needsRewrite: this.#rewriteRequired,
|
|
@@ -717,6 +787,8 @@ export class SessionManager {
|
|
|
717
787
|
this.#applyEntries(snapshot.header, [...snapshot.entries]);
|
|
718
788
|
this.#sessionName = snapshot.sessionName;
|
|
719
789
|
this.#titleSource = snapshot.titleSource;
|
|
790
|
+
this.#titleUpdatedAt = snapshot.titleUpdatedAt;
|
|
791
|
+
this.#hasTitleSlot = snapshot.hasTitleSlot;
|
|
720
792
|
this.#artifactManager = null;
|
|
721
793
|
this.#artifactManagerSessionFile = null;
|
|
722
794
|
this.#adoptedArtifactManager = null;
|
|
@@ -733,6 +805,7 @@ export class SessionManager {
|
|
|
733
805
|
this.#sessionFile = resolvedSessionFile;
|
|
734
806
|
this.#rememberBreadcrumb(this.#cwd, resolvedSessionFile);
|
|
735
807
|
|
|
808
|
+
const titleSlot = await readTitleSlotFromFile(resolvedSessionFile, this.#storage);
|
|
736
809
|
const fileEntries = await loadEntriesFromFile(resolvedSessionFile, this.#storage);
|
|
737
810
|
if (fileEntries.length === 0) {
|
|
738
811
|
// Explicit but empty/missing path (e.g. --session flag): start fresh but
|
|
@@ -763,6 +836,8 @@ export class SessionManager {
|
|
|
763
836
|
}
|
|
764
837
|
|
|
765
838
|
this.#applyEntries(header, fileEntries.slice(1) as SessionEntry[]);
|
|
839
|
+
this.#titleUpdatedAt = titleSlot?.updatedAt ?? header.timestamp;
|
|
840
|
+
this.#hasTitleSlot = titleSlot !== undefined;
|
|
766
841
|
this.#fileIsCurrent = true;
|
|
767
842
|
this.#rewriteRequired = migrated;
|
|
768
843
|
this.#forceFileCreation = true;
|
|
@@ -815,6 +890,8 @@ export class SessionManager {
|
|
|
815
890
|
};
|
|
816
891
|
this.#sessionName = this.#header.title;
|
|
817
892
|
this.#titleSource = this.#header.titleSource;
|
|
893
|
+
this.#titleUpdatedAt = timestamp;
|
|
894
|
+
this.#hasTitleSlot = true;
|
|
818
895
|
this.#fileIsCurrent = false;
|
|
819
896
|
this.#rewriteRequired = false;
|
|
820
897
|
this.#forceFileCreation = true;
|
|
@@ -1083,7 +1160,7 @@ export class SessionManager {
|
|
|
1083
1160
|
}
|
|
1084
1161
|
|
|
1085
1162
|
/** The source that set the session name: "user" (manual/RPC) or "auto" (generated title). */
|
|
1086
|
-
get titleSource():
|
|
1163
|
+
get titleSource(): SessionTitleSource | undefined {
|
|
1087
1164
|
return this.#titleSource;
|
|
1088
1165
|
}
|
|
1089
1166
|
|
|
@@ -1103,20 +1180,33 @@ export class SessionManager {
|
|
|
1103
1180
|
* @param source "user" for explicit renames; "auto" for generated titles.
|
|
1104
1181
|
* Auto titles are ignored once the user has set a name.
|
|
1105
1182
|
*/
|
|
1106
|
-
async setSessionName(name: string, source:
|
|
1183
|
+
async setSessionName(name: string, source: SessionTitleSource = "auto", trigger?: string): Promise<boolean> {
|
|
1107
1184
|
if (this.#titleSource === "user" && source === "auto") return false;
|
|
1108
1185
|
|
|
1109
1186
|
const title = SessionManager.#cleanTitle(name);
|
|
1110
1187
|
if (!title) return false;
|
|
1111
1188
|
|
|
1189
|
+
const previousTitle = this.#sessionName;
|
|
1190
|
+
const timestamp = nowIso();
|
|
1112
1191
|
this.#sessionName = title;
|
|
1113
1192
|
this.#titleSource = source;
|
|
1193
|
+
this.#titleUpdatedAt = timestamp;
|
|
1114
1194
|
this.#header.title = title;
|
|
1115
1195
|
this.#header.titleSource = source;
|
|
1116
1196
|
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1197
|
+
const entry: TitleChangeEntry = {
|
|
1198
|
+
type: TITLE_CHANGE_ENTRY_TYPE,
|
|
1199
|
+
...this.#freshEntryFields(),
|
|
1200
|
+
timestamp,
|
|
1201
|
+
title,
|
|
1202
|
+
source,
|
|
1203
|
+
};
|
|
1204
|
+
if (previousTitle) entry.previousTitle = previousTitle;
|
|
1205
|
+
if (trigger) entry.trigger = trigger;
|
|
1206
|
+
this.#entries.push(entry);
|
|
1207
|
+
this.#index.insert(entry);
|
|
1208
|
+
this.#notifyEntryAppended(entry);
|
|
1209
|
+
await this.#persistTitleChangeEntry(entry, { title, source, updatedAt: timestamp });
|
|
1120
1210
|
|
|
1121
1211
|
this.#notifySessionNameListeners();
|
|
1122
1212
|
return true;
|
|
@@ -1484,6 +1574,8 @@ export class SessionManager {
|
|
|
1484
1574
|
this.#sessionId = newSessionId;
|
|
1485
1575
|
this.#sessionName = header.title;
|
|
1486
1576
|
this.#titleSource = header.titleSource;
|
|
1577
|
+
this.#titleUpdatedAt = timestamp;
|
|
1578
|
+
this.#hasTitleSlot = true;
|
|
1487
1579
|
this.#index.rebuild(this.#entries);
|
|
1488
1580
|
this.#artifactManager = null;
|
|
1489
1581
|
this.#artifactManagerSessionFile = null;
|
|
@@ -1526,9 +1618,8 @@ export class SessionManager {
|
|
|
1526
1618
|
/**
|
|
1527
1619
|
* Create a fresh empty session file in the default session directory for
|
|
1528
1620
|
* `cwd`, writing only the session header. The returned path can be passed to
|
|
1529
|
-
* `setSessionFile` / `AgentSession.switchSession`
|
|
1530
|
-
*
|
|
1531
|
-
* dragging the current conversation along.
|
|
1621
|
+
* `setSessionFile` / `AgentSession.switchSession` when a caller explicitly
|
|
1622
|
+
* needs a brand-new persisted session at a cwd-derived path.
|
|
1532
1623
|
*/
|
|
1533
1624
|
static createEmptySessionFile(cwd: string, storage: SessionStorage = new FileSessionStorage()): string {
|
|
1534
1625
|
const sessionDir = SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
|
@@ -1542,7 +1633,7 @@ export class SessionManager {
|
|
|
1542
1633
|
cwd: path.resolve(cwd),
|
|
1543
1634
|
};
|
|
1544
1635
|
const file = path.join(sessionDir, `${fileSafeTimestamp(timestamp)}_${id}.jsonl`);
|
|
1545
|
-
storage.writeTextSync(file, `${JSON.stringify(header)}\n`);
|
|
1636
|
+
storage.writeTextSync(file, `${serializeTitleSlot({ updatedAt: timestamp })}${JSON.stringify(header)}\n`);
|
|
1546
1637
|
return file;
|
|
1547
1638
|
}
|
|
1548
1639
|
|
|
@@ -1577,6 +1668,8 @@ export class SessionManager {
|
|
|
1577
1668
|
manager.#header.titleSource = sourceHeader?.titleSource;
|
|
1578
1669
|
manager.#sessionName = manager.#header.title;
|
|
1579
1670
|
manager.#titleSource = manager.#header.titleSource;
|
|
1671
|
+
manager.#titleUpdatedAt = nowIso();
|
|
1672
|
+
manager.#hasTitleSlot = true;
|
|
1580
1673
|
manager.#entries = history;
|
|
1581
1674
|
manager.#index.rebuild(history);
|
|
1582
1675
|
manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
|