@oh-my-pi/pi-coding-agent 16.2.1 → 16.2.3

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.
Files changed (187) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/cli.js +3621 -3579
  3. package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
  4. package/dist/types/advisor/advise-tool.d.ts +8 -4
  5. package/dist/types/advisor/config.d.ts +88 -0
  6. package/dist/types/advisor/index.d.ts +1 -0
  7. package/dist/types/advisor/runtime.d.ts +15 -1
  8. package/dist/types/advisor/transcript-recorder.d.ts +13 -2
  9. package/dist/types/advisor/watchdog.d.ts +20 -0
  10. package/dist/types/collab/guest.d.ts +29 -0
  11. package/dist/types/config/model-roles.d.ts +1 -1
  12. package/dist/types/config/settings-schema.d.ts +113 -12
  13. package/dist/types/debug/log-viewer.d.ts +1 -0
  14. package/dist/types/debug/raw-sse.d.ts +1 -0
  15. package/dist/types/discovery/omp-extension-roots.d.ts +3 -3
  16. package/dist/types/edit/hashline/diff.d.ts +0 -11
  17. package/dist/types/edit/index.d.ts +18 -0
  18. package/dist/types/edit/streaming.d.ts +30 -0
  19. package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
  20. package/dist/types/extensibility/shared-events.d.ts +1 -0
  21. package/dist/types/extensibility/tool-event-input.d.ts +7 -0
  22. package/dist/types/extensibility/utils.d.ts +12 -0
  23. package/dist/types/mcp/oauth-discovery.d.ts +0 -11
  24. package/dist/types/mcp/transports/index.d.ts +1 -0
  25. package/dist/types/mcp/transports/sse.d.ts +20 -0
  26. package/dist/types/modes/components/advisor-config.d.ts +59 -0
  27. package/dist/types/modes/components/index.d.ts +1 -0
  28. package/dist/types/modes/components/model-selector.d.ts +9 -1
  29. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  30. package/dist/types/modes/components/status-line/component.d.ts +30 -3
  31. package/dist/types/modes/components/status-line/types.d.ts +13 -1
  32. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  33. package/dist/types/modes/interactive-mode.d.ts +10 -4
  34. package/dist/types/modes/skill-command.d.ts +32 -0
  35. package/dist/types/modes/types.d.ts +7 -2
  36. package/dist/types/sdk.d.ts +1 -1
  37. package/dist/types/session/agent-session.d.ts +84 -12
  38. package/dist/types/session/indexed-session-storage.d.ts +7 -1
  39. package/dist/types/session/messages.d.ts +32 -7
  40. package/dist/types/session/messages.test.d.ts +1 -0
  41. package/dist/types/session/session-entries.d.ts +31 -3
  42. package/dist/types/session/session-history-format.d.ts +6 -0
  43. package/dist/types/session/session-loader.d.ts +9 -1
  44. package/dist/types/session/session-manager.d.ts +6 -4
  45. package/dist/types/session/session-storage.d.ts +11 -0
  46. package/dist/types/session/session-title-slot.d.ts +19 -0
  47. package/dist/types/session/settings-stream-fn.d.ts +21 -0
  48. package/dist/types/session/turn-persistence.d.ts +88 -0
  49. package/dist/types/ssh/connection-manager.d.ts +47 -0
  50. package/dist/types/ssh/utils.d.ts +16 -0
  51. package/dist/types/task/executor.d.ts +3 -16
  52. package/dist/types/task/render.d.ts +0 -5
  53. package/dist/types/task/renderer.d.ts +13 -0
  54. package/dist/types/task/types.d.ts +16 -0
  55. package/dist/types/task/yield-assembly.d.ts +28 -0
  56. package/dist/types/tiny/text.d.ts +8 -0
  57. package/dist/types/tools/render-utils.d.ts +2 -0
  58. package/dist/types/tools/review.d.ts +6 -4
  59. package/dist/types/tools/ssh.d.ts +1 -1
  60. package/dist/types/tools/todo.d.ts +6 -0
  61. package/dist/types/tools/yield.d.ts +8 -3
  62. package/dist/types/utils/thinking-display.d.ts +4 -0
  63. package/package.json +12 -12
  64. package/src/advisor/__tests__/advisor.test.ts +438 -10
  65. package/src/advisor/__tests__/config.test.ts +173 -0
  66. package/src/advisor/advise-tool.ts +11 -6
  67. package/src/advisor/config.ts +256 -0
  68. package/src/advisor/index.ts +1 -0
  69. package/src/advisor/runtime.ts +77 -4
  70. package/src/advisor/transcript-recorder.ts +25 -2
  71. package/src/advisor/watchdog.ts +57 -31
  72. package/src/auto-thinking/classifier.ts +2 -2
  73. package/src/autoresearch/index.ts +7 -2
  74. package/src/cli/gc-cli.ts +17 -10
  75. package/src/collab/guest.ts +43 -7
  76. package/src/config/model-registry.ts +80 -18
  77. package/src/config/model-resolver.ts +5 -1
  78. package/src/config/model-roles.ts +3 -3
  79. package/src/config/settings-schema.ts +107 -8
  80. package/src/debug/index.ts +32 -7
  81. package/src/debug/log-viewer.ts +111 -53
  82. package/src/debug/raw-sse.ts +68 -48
  83. package/src/discovery/codex.ts +13 -5
  84. package/src/discovery/omp-extension-roots.ts +38 -13
  85. package/src/edit/hashline/diff.ts +57 -4
  86. package/src/edit/index.ts +21 -0
  87. package/src/edit/streaming.ts +170 -0
  88. package/src/eval/js/shared/local-module-loader.ts +23 -1
  89. package/src/export/html/template.js +13 -7
  90. package/src/extensibility/custom-tools/types.ts +1 -0
  91. package/src/extensibility/extensions/loader.ts +5 -3
  92. package/src/extensibility/extensions/wrapper.ts +9 -3
  93. package/src/extensibility/hooks/loader.ts +3 -3
  94. package/src/extensibility/hooks/tool-wrapper.ts +13 -4
  95. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +18 -1
  96. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +59 -2
  97. package/src/extensibility/plugins/manager.ts +76 -5
  98. package/src/extensibility/shared-events.ts +1 -0
  99. package/src/extensibility/tool-event-input.ts +23 -0
  100. package/src/extensibility/utils.ts +74 -0
  101. package/src/internal-urls/docs-index.generated.txt +1 -1
  102. package/src/mcp/client.ts +3 -1
  103. package/src/mcp/manager.ts +12 -5
  104. package/src/mcp/oauth-discovery.ts +5 -29
  105. package/src/mcp/transports/http.ts +3 -1
  106. package/src/mcp/transports/index.ts +1 -0
  107. package/src/mcp/transports/sse.ts +377 -0
  108. package/src/memories/index.ts +15 -6
  109. package/src/mnemopi/backend.ts +2 -2
  110. package/src/modes/acp/acp-agent.ts +1 -1
  111. package/src/modes/components/advisor-config.ts +555 -0
  112. package/src/modes/components/advisor-message.ts +9 -2
  113. package/src/modes/components/agent-hub.ts +9 -4
  114. package/src/modes/components/assistant-message.ts +5 -5
  115. package/src/modes/components/index.ts +2 -0
  116. package/src/modes/components/model-selector.ts +79 -48
  117. package/src/modes/components/settings-selector.ts +1 -0
  118. package/src/modes/components/status-line/component.ts +145 -14
  119. package/src/modes/components/status-line/segments.ts +47 -22
  120. package/src/modes/components/status-line/types.ts +13 -1
  121. package/src/modes/components/tool-execution.ts +47 -6
  122. package/src/modes/controllers/command-controller.ts +23 -2
  123. package/src/modes/controllers/event-controller.ts +114 -11
  124. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  125. package/src/modes/controllers/input-controller.ts +61 -61
  126. package/src/modes/controllers/selector-controller.ts +100 -9
  127. package/src/modes/interactive-mode.ts +65 -10
  128. package/src/modes/print-mode.ts +1 -1
  129. package/src/modes/skill-command.ts +116 -0
  130. package/src/modes/types.ts +7 -2
  131. package/src/modes/utils/transcript-render-helpers.ts +2 -2
  132. package/src/modes/utils/ui-helpers.ts +46 -27
  133. package/src/prompts/agents/reviewer.md +11 -10
  134. package/src/prompts/review-custom-request.md +1 -2
  135. package/src/prompts/review-request.md +1 -2
  136. package/src/prompts/system/interrupted-thinking.md +7 -0
  137. package/src/prompts/system/recap-user.md +9 -0
  138. package/src/prompts/system/subagent-system-prompt.md +8 -5
  139. package/src/prompts/system/subagent-yield-reminder.md +6 -5
  140. package/src/prompts/system/system-prompt.md +0 -1
  141. package/src/prompts/tools/irc.md +2 -2
  142. package/src/prompts/tools/read.md +2 -2
  143. package/src/sdk.ts +40 -50
  144. package/src/session/agent-session.ts +1139 -600
  145. package/src/session/indexed-session-storage.ts +86 -13
  146. package/src/session/messages.test.ts +125 -0
  147. package/src/session/messages.ts +192 -21
  148. package/src/session/redis-session-storage.ts +49 -2
  149. package/src/session/session-entries.ts +39 -2
  150. package/src/session/session-history-format.ts +29 -2
  151. package/src/session/session-listing.ts +54 -24
  152. package/src/session/session-loader.ts +66 -3
  153. package/src/session/session-manager.ts +113 -19
  154. package/src/session/session-persistence.ts +96 -3
  155. package/src/session/session-storage.ts +36 -0
  156. package/src/session/session-title-slot.ts +141 -0
  157. package/src/session/settings-stream-fn.ts +49 -0
  158. package/src/session/sql-session-storage.ts +71 -11
  159. package/src/session/turn-persistence.ts +142 -0
  160. package/src/session/unexpected-stop-classifier.ts +2 -2
  161. package/src/slash-commands/builtin-registry.ts +16 -3
  162. package/src/slash-commands/helpers/mcp.ts +2 -1
  163. package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
  164. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
  165. package/src/ssh/connection-manager.ts +139 -12
  166. package/src/ssh/file-transfer.ts +23 -18
  167. package/src/ssh/ssh-executor.ts +2 -13
  168. package/src/ssh/utils.ts +19 -0
  169. package/src/task/executor.ts +21 -23
  170. package/src/task/render.ts +162 -20
  171. package/src/task/renderer.ts +14 -0
  172. package/src/task/types.ts +17 -0
  173. package/src/task/yield-assembly.ts +207 -0
  174. package/src/tiny/models.ts +8 -6
  175. package/src/tiny/text.ts +37 -7
  176. package/src/tools/ask.ts +55 -4
  177. package/src/tools/image-gen.ts +2 -1
  178. package/src/tools/render-utils.ts +2 -0
  179. package/src/tools/renderers.ts +8 -2
  180. package/src/tools/review.ts +17 -7
  181. package/src/tools/ssh.ts +8 -4
  182. package/src/tools/todo.ts +17 -1
  183. package/src/tools/tts.ts +2 -1
  184. package/src/tools/yield.ts +140 -31
  185. package/src/utils/thinking-display.ts +15 -0
  186. package/src/utils/title-generator.ts +1 -1
  187. package/src/web/search/providers/tavily.ts +36 -19
@@ -4,6 +4,7 @@ import {
4
4
  type SessionStorageBackend,
5
5
  type SessionStorageIndexEntry,
6
6
  } from "./indexed-session-storage";
7
+ import type { SessionTitleUpdate } from "./session-title-slot";
7
8
 
8
9
  /**
9
10
  * Minimal subset of the `bun:redis` `RedisClient` surface used by
@@ -44,6 +45,28 @@ export interface RedisSessionStorageOptions {
44
45
  const DEFAULT_PREFIX = "omp:sessions:";
45
46
  const DEFAULT_SCAN_COUNT = 500;
46
47
 
48
+ function encodeTitleMeta(title: SessionTitleUpdate): string {
49
+ return JSON.stringify(title);
50
+ }
51
+
52
+ function decodeTitleMeta(raw: string | undefined): SessionTitleUpdate | undefined {
53
+ if (!raw) return undefined;
54
+ try {
55
+ const parsed = JSON.parse(raw) as unknown;
56
+ if (typeof parsed !== "object" || parsed === null) return undefined;
57
+ const record = parsed as Record<string, unknown>;
58
+ if (typeof record.updatedAt !== "string") return undefined;
59
+ const source = record.source === "auto" || record.source === "user" ? record.source : undefined;
60
+ return {
61
+ title: typeof record.title === "string" ? record.title : undefined,
62
+ source,
63
+ updatedAt: record.updatedAt,
64
+ };
65
+ } catch {
66
+ return undefined;
67
+ }
68
+ }
69
+
47
70
  /**
48
71
  * Redis-backed implementation of {@link SessionStorage}. Each session JSONL
49
72
  * file maps to a Redis STRING key, with per-key metadata (mtime) tracked in a
@@ -83,8 +106,9 @@ class RedisSessionStorageBackend implements SessionStorageBackend {
83
106
  async loadIndex(): Promise<SessionStorageIndexEntry[]> {
84
107
  const filePrefix = this.#fileKey("");
85
108
  const metaRaw = await this.#client.hgetall(this.#metaKey());
109
+ const titleRaw = await this.#client.hgetall(this.#titleMetaKey());
86
110
  const meta: Record<string, string> = metaRaw ?? {};
87
-
111
+ const titles: Record<string, string> = titleRaw ?? {};
88
112
  const seen = new Set<string>();
89
113
  let cursor = "0";
90
114
  do {
@@ -106,10 +130,14 @@ class RedisSessionStorageBackend implements SessionStorageBackend {
106
130
  const size = await this.#client.strlen(key);
107
131
  const rawMtime = meta[path];
108
132
  const parsedMtime = rawMtime === undefined ? Number.NaN : Number(rawMtime);
133
+ const title = decodeTitleMeta(titles[path]);
109
134
  return {
110
135
  path,
111
136
  size,
112
137
  mtimeMs: Number.isFinite(parsedMtime) ? parsedMtime : fallbackMtimeMs,
138
+ title: title?.title,
139
+ titleSource: title?.source,
140
+ titleUpdatedAt: title?.updatedAt,
113
141
  };
114
142
  }),
115
143
  );
@@ -126,9 +154,14 @@ class RedisSessionStorageBackend implements SessionStorageBackend {
126
154
  return Promise.all([head, tail]);
127
155
  }
128
156
 
129
- async writeFull(path: string, content: string, mtimeMs: number): Promise<void> {
157
+ async writeFull(path: string, content: string, mtimeMs: number, title?: SessionTitleUpdate): Promise<void> {
130
158
  await this.#client.set(this.#fileKey(path), content);
131
159
  await this.#client.hset(this.#metaKey(), path, String(mtimeMs));
160
+ if (title) {
161
+ await this.#client.hset(this.#titleMetaKey(), path, encodeTitleMeta(title));
162
+ } else {
163
+ await this.#client.hdel(this.#titleMetaKey(), path);
164
+ }
132
165
  }
133
166
 
134
167
  async append(path: string, line: string, mtimeMs: number): Promise<void> {
@@ -136,6 +169,11 @@ class RedisSessionStorageBackend implements SessionStorageBackend {
136
169
  await this.#client.hset(this.#metaKey(), path, String(mtimeMs));
137
170
  }
138
171
 
172
+ async updateSessionTitle(path: string, title: SessionTitleUpdate, mtimeMs: number): Promise<void> {
173
+ await this.#client.hset(this.#metaKey(), path, String(mtimeMs));
174
+ await this.#client.hset(this.#titleMetaKey(), path, encodeTitleMeta(title));
175
+ }
176
+
139
177
  async truncate(path: string, mtimeMs: number): Promise<void> {
140
178
  await this.writeFull(path, "", mtimeMs);
141
179
  }
@@ -144,13 +182,18 @@ class RedisSessionStorageBackend implements SessionStorageBackend {
144
182
  if (paths.length === 0) return;
145
183
  await this.#client.del(...paths.map(path => this.#fileKey(path)));
146
184
  await this.#client.hdel(this.#metaKey(), ...paths);
185
+ await this.#client.hdel(this.#titleMetaKey(), ...paths);
147
186
  }
148
187
 
149
188
  async move(src: string, dst: string, mtimeMs: number): Promise<void> {
150
189
  await this.#client.rename(this.#fileKey(src), this.#fileKey(dst));
151
190
  try {
191
+ const titleMeta = await this.#client.hgetall(this.#titleMetaKey());
152
192
  await this.#client.hdel(this.#metaKey(), src);
153
193
  await this.#client.hset(this.#metaKey(), dst, String(mtimeMs));
194
+ await this.#client.hdel(this.#titleMetaKey(), src);
195
+ const title = titleMeta[src];
196
+ if (title !== undefined) await this.#client.hset(this.#titleMetaKey(), dst, title);
154
197
  } catch (err) {
155
198
  logger.warn("Redis session storage meta rename failed", {
156
199
  src,
@@ -167,4 +210,8 @@ class RedisSessionStorageBackend implements SessionStorageBackend {
167
210
  #metaKey(): string {
168
211
  return `${this.#prefix}meta`;
169
212
  }
213
+
214
+ #titleMetaKey(): string {
215
+ return `${this.#prefix}title`;
216
+ }
170
217
  }
@@ -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?: "auto" | "user";
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 (includes header) */
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(toolCallLine(block.name, block.arguments, result, opts?.includeToolIntent));
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 parseSessionListHeader(
274
- content: string,
275
- entries: Array<Record<string, unknown>>,
276
- ): SessionListHeader | undefined {
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
- const firstLineEnd = content.indexOf("\n");
290
- const firstLine = firstLineEnd === -1 ? content : content.slice(0, firstLineEnd);
291
- if (extractStringProperty(firstLine, "type") !== "session") return undefined;
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
- const id = extractStringProperty(firstLine, "id");
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(firstLine, "cwd"),
300
- title: extractStringProperty(firstLine, "title"),
301
- parentSession: extractStringProperty(firstLine, "parentSession"),
302
- timestamp: extractStringProperty(firstLine, "timestamp"),
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 type { FileEntry, SessionEntry, SessionHeader } from "./session-entries";
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 parseJsonlLenient<FileEntry>(content);
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 = parseJsonlLenient<FileEntry>(content);
90
+ const { entries } = parseSessionContent(content);
28
91
 
29
92
  // Validate session header
30
93
  if (entries.length === 0) return entries;