@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
@@ -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: "auto" | "user" | undefined;
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: "auto" | "user" | undefined;
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.#lineFor(this.#header);
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(): "auto" | "user" | undefined {
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: "auto" | "user" = "auto"): Promise<boolean> {
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
- if (this.#persist && this.#sessionFile && this.#storage.existsSync(this.#sessionFile)) {
1118
- await this.#rewriteAtomically();
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;
@@ -1542,7 +1634,7 @@ export class SessionManager {
1542
1634
  cwd: path.resolve(cwd),
1543
1635
  };
1544
1636
  const file = path.join(sessionDir, `${fileSafeTimestamp(timestamp)}_${id}.jsonl`);
1545
- storage.writeTextSync(file, `${JSON.stringify(header)}\n`);
1637
+ storage.writeTextSync(file, `${serializeTitleSlot({ updatedAt: timestamp })}${JSON.stringify(header)}\n`);
1546
1638
  return file;
1547
1639
  }
1548
1640
 
@@ -1577,6 +1669,8 @@ export class SessionManager {
1577
1669
  manager.#header.titleSource = sourceHeader?.titleSource;
1578
1670
  manager.#sessionName = manager.#header.title;
1579
1671
  manager.#titleSource = manager.#header.titleSource;
1672
+ manager.#titleUpdatedAt = nowIso();
1673
+ manager.#hasTitleSlot = true;
1580
1674
  manager.#entries = history;
1581
1675
  manager.#index.rebuild(history);
1582
1676
  manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
@@ -110,9 +110,8 @@ function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string
110
110
  const entries: Array<readonly [string, unknown]> = [];
111
111
  for (const [childKey, value] of Object.entries(obj)) {
112
112
  // Strip transient/redundant properties that shouldn't be persisted.
113
- // - partialJson: streaming accumulator for tool call JSON parsing
114
113
  // - jsonlEvents: raw subprocess streaming events (already saved to artifact files)
115
- if (childKey === "partialJson" || childKey === "jsonlEvents") {
114
+ if (childKey === "jsonlEvents") {
116
115
  changed = true;
117
116
  continue;
118
117
  }
@@ -142,6 +141,100 @@ function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string
142
141
  return obj;
143
142
  }
144
143
 
144
+ /**
145
+ * Read the duplication-relevant fields of an OpenAI Responses reasoning item.
146
+ * Returns `undefined` for anything that is not a `type: "reasoning"` object, so
147
+ * non-reasoning payload entries and corrupt signatures are never matched.
148
+ */
149
+ function readReasoningItem(item: unknown): { encrypted_content?: string; id?: string } | undefined {
150
+ if (item === null || typeof item !== "object") return undefined;
151
+ if (!("type" in item) || item.type !== "reasoning") return undefined;
152
+ const reasoning: { encrypted_content?: string; id?: string } = {};
153
+ if ("encrypted_content" in item && typeof item.encrypted_content === "string" && item.encrypted_content.length > 0) {
154
+ reasoning.encrypted_content = item.encrypted_content;
155
+ }
156
+ if ("id" in item && typeof item.id === "string" && item.id.length > 0) reasoning.id = item.id;
157
+ return reasoning;
158
+ }
159
+
160
+ /**
161
+ * True when a `thinkingSignature` (a JSON-encoded reasoning item) is already
162
+ * carried by a reasoning item in the message's provider payload — matched on
163
+ * `encrypted_content` (the load-bearing blob) when present, else on item `id`.
164
+ * A signature the payload does not cover is never reported as recoverable, so it
165
+ * is always kept.
166
+ */
167
+ function signatureCoveredByPayload(
168
+ signature: string,
169
+ encrypted: ReadonlySet<string>,
170
+ ids: ReadonlySet<string>,
171
+ ): boolean {
172
+ let parsed: unknown;
173
+ try {
174
+ parsed = JSON.parse(signature);
175
+ } catch {
176
+ return false;
177
+ }
178
+ const reasoning = readReasoningItem(parsed);
179
+ if (!reasoning) return false;
180
+ if (reasoning.encrypted_content) return encrypted.has(reasoning.encrypted_content);
181
+ if (reasoning.id) return ids.has(reasoning.id);
182
+ return false;
183
+ }
184
+
185
+ /**
186
+ * Drop `thinkingSignature` from assistant thinking blocks whose reasoning item is
187
+ * already carried, verbatim, in the message's OpenAI Responses `providerPayload`.
188
+ *
189
+ * Responses/Codex turns mint each reasoning item once and store it twice:
190
+ * `providerPayload.items` (the authoritative native-history copy that replay and
191
+ * remote compaction read) and `content[].thinkingSignature`, which is literally
192
+ * `JSON.stringify(reasoningItem)` — including the large `encrypted_content` blob.
193
+ * Replay only ever reads the payload; the signature is a no-payload fallback that
194
+ * same-provider turns never reach and cross-model turns strip as untrustworthy.
195
+ * Persisting both stores the encrypted reasoning twice for zero token or replay
196
+ * benefit, so the on-disk copy drops the duplicate signature whenever its
197
+ * reasoning item is recoverable from the payload. The in-memory entry is left
198
+ * untouched; only the serialized line is slimmed.
199
+ */
200
+ function stripReplayedReasoningSignatures(entry: FileEntry): FileEntry {
201
+ if (entry.type !== "message" || entry.message.role !== "assistant") return entry;
202
+ const message = entry.message;
203
+ const payload = message.providerPayload;
204
+ if (payload?.type !== "openaiResponsesHistory" || !Array.isArray(payload.items)) return entry;
205
+ const hasSignedThinking = message.content.some(
206
+ block =>
207
+ block.type === "thinking" && typeof block.thinkingSignature === "string" && block.thinkingSignature.length > 0,
208
+ );
209
+ if (!hasSignedThinking) return entry;
210
+
211
+ const encrypted = new Set<string>();
212
+ const ids = new Set<string>();
213
+ for (const rawItem of payload.items) {
214
+ const reasoning = readReasoningItem(rawItem);
215
+ if (!reasoning) continue;
216
+ if (reasoning.encrypted_content) encrypted.add(reasoning.encrypted_content);
217
+ if (reasoning.id) ids.add(reasoning.id);
218
+ }
219
+ if (encrypted.size === 0 && ids.size === 0) return entry;
220
+
221
+ let changed = false;
222
+ const content = message.content.map(block => {
223
+ if (
224
+ block.type !== "thinking" ||
225
+ typeof block.thinkingSignature !== "string" ||
226
+ block.thinkingSignature.length === 0
227
+ ) {
228
+ return block;
229
+ }
230
+ if (!signatureCoveredByPayload(block.thinkingSignature, encrypted, ids)) return block;
231
+ changed = true;
232
+ return { ...block, thinkingSignature: undefined };
233
+ });
234
+ if (!changed) return entry;
235
+ return { ...entry, message: { ...message, content } };
236
+ }
237
+
145
238
  export function prepareEntryForPersistence(entry: FileEntry, blobStore: BlobStore): FileEntry {
146
- return truncateForPersistence(entry, blobStore) as FileEntry;
239
+ return truncateForPersistence(stripReplayedReasoningSignatures(entry), blobStore) as FileEntry;
147
240
  }
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as fsp from "node:fs/promises";
3
3
  import * as path from "node:path";
4
4
  import { hasFsCode, isEnoent, logger, peekFileEnds, Snowflake, toError } from "@oh-my-pi/pi-utils";
5
+ import { overlayTitleSlotContent, type SessionTitleUpdate, serializeTitleSlot } from "./session-title-slot";
5
6
 
6
7
  const utf8Decoder = new TextDecoder("utf-8");
7
8
 
@@ -31,6 +32,14 @@ export interface SessionStorage {
31
32
  ensureDirSync(dir: string): void;
32
33
  existsSync(path: string): boolean;
33
34
  writeTextSync(path: string, content: string): void;
35
+ /**
36
+ * Update the current session title through the storage backend.
37
+ *
38
+ * File-like backends rewrite the fixed-width JSONL title slot; indexed
39
+ * backends can store the semantic title fields and synthesize the slot when
40
+ * reading.
41
+ */
42
+ updateSessionTitle(path: string, update: SessionTitleUpdate): Promise<void>;
34
43
  statSync(path: string): SessionStorageStat;
35
44
  listFilesSync(dir: string, pattern: string): string[];
36
45
 
@@ -163,6 +172,25 @@ export class FileSessionStorage implements SessionStorage {
163
172
  }
164
173
  }
165
174
 
175
+ async updateSessionTitle(fpath: string, update: SessionTitleUpdate): Promise<void> {
176
+ const fd = fs.openSync(fpath, "r+");
177
+ try {
178
+ const buf = Buffer.from(serializeTitleSlot(update), "utf-8");
179
+ let offset = 0;
180
+ while (offset < buf.length) {
181
+ const written = fs.writeSync(fd, buf, offset, buf.length - offset, offset);
182
+ if (written === 0) {
183
+ throw new Error("Short write");
184
+ }
185
+ offset += written;
186
+ }
187
+ } catch (err) {
188
+ throw toError(err);
189
+ } finally {
190
+ fs.closeSync(fd);
191
+ }
192
+ }
193
+
166
194
  statSync(path: string): SessionStorageStat {
167
195
  const stats = fs.statSync(path);
168
196
  return { size: stats.size, mtimeMs: stats.mtimeMs, mtime: stats.mtime };
@@ -526,6 +554,14 @@ export class MemorySessionStorage implements SessionStorage {
526
554
  this.#files.set(path, createMemoryFileEntry(content, Date.now()));
527
555
  }
528
556
 
557
+ async updateSessionTitle(path: string, update: SessionTitleUpdate): Promise<void> {
558
+ const entry = this.#requireEntry(path);
559
+ this.#files.set(
560
+ path,
561
+ createMemoryFileEntry(overlayTitleSlotContent(materializeMemoryEntry(entry), update), Date.now()),
562
+ );
563
+ }
564
+
529
565
  /**
530
566
  * Internal O(1) append used by {@link MemorySessionStorageWriter}. Lazily
531
567
  * creates the entry. External callers should go through `openWriter()`
@@ -0,0 +1,141 @@
1
+ import {
2
+ SESSION_TITLE_SLOT_BYTES,
3
+ SESSION_TITLE_SLOT_ENTRY_TYPE,
4
+ type SessionTitleSlotEntry,
5
+ type SessionTitleSource,
6
+ } from "./session-entries";
7
+
8
+ const utf8Encoder = new TextEncoder();
9
+
10
+ /** Semantic title update persisted by session storage backends. */
11
+ export interface SessionTitleUpdate {
12
+ title?: string;
13
+ source?: SessionTitleSource;
14
+ updatedAt: string;
15
+ }
16
+
17
+ function byteLength(value: string): number {
18
+ return utf8Encoder.encode(value).byteLength;
19
+ }
20
+
21
+ function titleSlotLine(title: string, source: SessionTitleSource | undefined, updatedAt: string, pad: string): string {
22
+ const slot: SessionTitleSlotEntry = source
23
+ ? {
24
+ type: SESSION_TITLE_SLOT_ENTRY_TYPE,
25
+ v: 1,
26
+ title,
27
+ source,
28
+ updatedAt,
29
+ pad,
30
+ }
31
+ : {
32
+ type: SESSION_TITLE_SLOT_ENTRY_TYPE,
33
+ v: 1,
34
+ title,
35
+ updatedAt,
36
+ pad,
37
+ };
38
+ return `${JSON.stringify(slot)}\n`;
39
+ }
40
+
41
+ function truncateTitleForSlot(title: string, source: SessionTitleSource | undefined, updatedAt: string): string {
42
+ const codePoints = [...title];
43
+ let low = 0;
44
+ let high = codePoints.length;
45
+ let best = "";
46
+
47
+ while (low <= high) {
48
+ const mid = (low + high) >>> 1;
49
+ const candidate = codePoints.slice(0, mid).join("");
50
+ if (byteLength(titleSlotLine(candidate, source, updatedAt, "")) <= SESSION_TITLE_SLOT_BYTES) {
51
+ best = candidate;
52
+ low = mid + 1;
53
+ } else {
54
+ high = mid - 1;
55
+ }
56
+ }
57
+
58
+ return best;
59
+ }
60
+
61
+ function isSessionTitleSource(value: unknown): value is SessionTitleSource {
62
+ return value === "auto" || value === "user";
63
+ }
64
+
65
+ function parseTitleSlotObject(value: unknown): SessionTitleSlotEntry | undefined {
66
+ if (typeof value !== "object" || value === null) return undefined;
67
+ const record = value as Record<string, unknown>;
68
+ if (record.type !== SESSION_TITLE_SLOT_ENTRY_TYPE || record.v !== 1) return undefined;
69
+ if (typeof record.title !== "string" || typeof record.updatedAt !== "string" || typeof record.pad !== "string") {
70
+ return undefined;
71
+ }
72
+ const source = record.source;
73
+ if (source !== undefined && !isSessionTitleSource(source)) return undefined;
74
+ const slot: SessionTitleSlotEntry = {
75
+ type: SESSION_TITLE_SLOT_ENTRY_TYPE,
76
+ v: 1,
77
+ title: record.title,
78
+ updatedAt: record.updatedAt,
79
+ pad: record.pad,
80
+ };
81
+ if (source) slot.source = source;
82
+ return slot;
83
+ }
84
+
85
+ /** Parse a physical title slot JSONL line. Returns undefined for legacy headers. */
86
+ export function parseTitleSlotLine(line: string): SessionTitleSlotEntry | undefined {
87
+ try {
88
+ return parseTitleSlotObject(JSON.parse(line)) ?? undefined;
89
+ } catch {
90
+ return undefined;
91
+ }
92
+ }
93
+
94
+ /** Parse the fixed-width title slot from a physical session body. */
95
+ export function parseTitleSlotFromContent(content: string): SessionTitleSlotEntry | undefined {
96
+ const newlineIndex = content.indexOf("\n");
97
+ if (newlineIndex < 0) return undefined;
98
+ return parseTitleSlotLine(content.slice(0, newlineIndex));
99
+ }
100
+
101
+ /** Convert a parsed title slot to the semantic storage update shape. */
102
+ export function titleUpdateFromSlot(slot: SessionTitleSlotEntry | undefined): SessionTitleUpdate | undefined {
103
+ if (!slot) return undefined;
104
+ return {
105
+ title: slot.title,
106
+ source: slot.source,
107
+ updatedAt: slot.updatedAt,
108
+ };
109
+ }
110
+
111
+ /** Serialize the fixed-width first-line title slot, exactly 256 UTF-8 bytes including newline. */
112
+ export function serializeTitleSlot(options: SessionTitleUpdate): string {
113
+ const title = truncateTitleForSlot(options.title ?? "", options.source, options.updatedAt);
114
+ const unpadded = titleSlotLine(title, options.source, options.updatedAt, "");
115
+ const padBytes = SESSION_TITLE_SLOT_BYTES - byteLength(unpadded);
116
+ if (padBytes < 0) throw new Error("Session title slot metadata exceeds fixed slot size");
117
+ const line = titleSlotLine(title, options.source, options.updatedAt, " ".repeat(padBytes));
118
+ if (byteLength(line) !== SESSION_TITLE_SLOT_BYTES) {
119
+ throw new Error("Session title slot serialization failed to produce fixed-width output");
120
+ }
121
+ return line;
122
+ }
123
+
124
+ /** Replace the physical fixed-width title slot in a full session body. */
125
+ export function overlayTitleSlotContent(content: string, update: SessionTitleUpdate): string {
126
+ const slot = Buffer.from(serializeTitleSlot(update), "utf-8");
127
+ const existing = Buffer.from(content, "utf-8");
128
+ if (existing.length <= slot.length) return slot.toString("utf-8");
129
+ return Buffer.concat([slot, existing.subarray(slot.length)]).toString("utf-8");
130
+ }
131
+
132
+ /** Replace the physical fixed-width title slot in a prefix byte window. */
133
+ export function overlayTitleSlotPrefix(prefix: string, prefixBytes: number, update: SessionTitleUpdate): string {
134
+ if (prefixBytes <= 0) return "";
135
+ const slot = Buffer.from(serializeTitleSlot(update), "utf-8");
136
+ if (prefixBytes <= slot.length) return slot.subarray(0, prefixBytes).toString("utf-8");
137
+ const existing = Buffer.from(prefix, "utf-8");
138
+ return Buffer.concat([slot, existing.subarray(slot.length)])
139
+ .subarray(0, prefixBytes)
140
+ .toString("utf-8");
141
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Settings-aware stream wrapper shared by the main agent (sdk.ts) and the
3
+ * advisor agent (AgentSession.#buildAdvisorRuntime).
4
+ *
5
+ * Reads OpenRouter / Antigravity routing variants, Responses-family text
6
+ * verbosity, per-provider in-flight caps, and the loop guard out of `Settings`
7
+ * per request, layering them onto whatever options the caller passed. Before
8
+ * this helper existed, advisor turns called bare `streamSimple` while the main
9
+ * turn went through an inline closure that read these settings — so an advisor on
10
+ * OpenRouter never saw `providers.openrouterVariant`, breaking sticky routing
11
+ * and OpenRouter response-cache hits across advisor calls.
12
+ */
13
+ import type { StreamFn } from "@oh-my-pi/pi-agent-core";
14
+ import { type SimpleStreamOptions, streamSimple } from "@oh-my-pi/pi-ai";
15
+ import { type Settings, validateProviderMaxInFlightRequests } from "../config/settings";
16
+
17
+ /**
18
+ * Build a {@link StreamFn} that reads provider routing/guard settings from
19
+ * `settings` per call and forwards to `base` (defaults to `streamSimple`).
20
+ *
21
+ * Caller-supplied `streamOptions` always win — the helper only fills holes.
22
+ */
23
+ export function createSettingsAwareStreamFn(settings: Settings, base: StreamFn = streamSimple): StreamFn {
24
+ return (model, context, streamOptions) => {
25
+ const openrouterRoutingPreset = settings.get("providers.openrouterVariant");
26
+ const openrouterVariant =
27
+ openrouterRoutingPreset && openrouterRoutingPreset !== "default" ? openrouterRoutingPreset : undefined;
28
+ const antigravityEndpointMode = settings.get("providers.antigravityEndpoint");
29
+ const textVerbosity =
30
+ model.api === "openai-codex-responses" || model.api === "openai-responses"
31
+ ? settings.get("textVerbosity")
32
+ : undefined;
33
+ const merged: SimpleStreamOptions = {
34
+ ...streamOptions,
35
+ openrouterVariant: streamOptions?.openrouterVariant ?? openrouterVariant,
36
+ antigravityEndpointMode: streamOptions?.antigravityEndpointMode ?? antigravityEndpointMode,
37
+ textVerbosity: streamOptions?.textVerbosity ?? textVerbosity,
38
+ maxInFlightRequests: validateProviderMaxInFlightRequests(
39
+ streamOptions?.maxInFlightRequests ?? settings.get("providers.maxInFlightRequests"),
40
+ ),
41
+ loopGuard: {
42
+ enabled: settings.get("model.loopGuard.enabled"),
43
+ checkAssistantContent: settings.get("model.loopGuard.checkAssistantContent"),
44
+ ...streamOptions?.loopGuard,
45
+ },
46
+ };
47
+ return base(model, context, merged);
48
+ };
49
+ }