@oh-my-pi/pi-coding-agent 15.12.4 → 15.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/CHANGELOG.md +304 -6
  2. package/dist/cli.js +1015 -881
  3. package/dist/types/async/job-manager.d.ts +15 -0
  4. package/dist/types/autolearn/controller.d.ts +25 -0
  5. package/dist/types/autolearn/managed-skills.d.ts +45 -0
  6. package/dist/types/autoresearch/state.d.ts +1 -1
  7. package/dist/types/autoresearch/types.d.ts +1 -1
  8. package/dist/types/cli/args.d.ts +19 -1
  9. package/dist/types/cli/session-picker.d.ts +1 -1
  10. package/dist/types/cli/setup-cli.d.ts +1 -1
  11. package/dist/types/cli/setup-model-picker.d.ts +14 -0
  12. package/dist/types/collab/protocol.d.ts +1 -1
  13. package/dist/types/commands/say.d.ts +24 -0
  14. package/dist/types/config/keybindings.d.ts +3 -3
  15. package/dist/types/config/model-registry.d.ts +10 -0
  16. package/dist/types/config/models-config-schema.d.ts +12 -0
  17. package/dist/types/config/models-config.d.ts +8 -2
  18. package/dist/types/config/settings-schema.d.ts +261 -58
  19. package/dist/types/export/html/index.d.ts +2 -1
  20. package/dist/types/extensibility/extensions/model-api.d.ts +17 -0
  21. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  22. package/dist/types/extensibility/extensions/types.d.ts +47 -1
  23. package/dist/types/extensibility/hooks/index.d.ts +2 -1
  24. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +9 -0
  25. package/dist/types/extensibility/plugins/loader.d.ts +11 -0
  26. package/dist/types/extensibility/shared-events.d.ts +1 -1
  27. package/dist/types/extensibility/skills.d.ts +10 -0
  28. package/dist/types/goals/guided-setup.d.ts +18 -0
  29. package/dist/types/goals/state.d.ts +1 -1
  30. package/dist/types/hindsight/transcript.d.ts +1 -1
  31. package/dist/types/index.d.ts +5 -0
  32. package/dist/types/internal-urls/local-protocol.d.ts +4 -2
  33. package/dist/types/main.d.ts +4 -3
  34. package/dist/types/mcp/startup-events.d.ts +11 -0
  35. package/dist/types/memories/index.d.ts +7 -0
  36. package/dist/types/memory-backend/local-backend.d.ts +4 -3
  37. package/dist/types/mnemopi/config.d.ts +4 -4
  38. package/dist/types/modes/components/agent-hub.d.ts +6 -0
  39. package/dist/types/modes/components/assistant-message.d.ts +1 -2
  40. package/dist/types/modes/components/compaction-summary-message.d.ts +15 -1
  41. package/dist/types/modes/components/custom-editor.d.ts +39 -1
  42. package/dist/types/modes/components/custom-editor.test.d.ts +1 -0
  43. package/dist/types/modes/components/session-selector.d.ts +1 -1
  44. package/dist/types/modes/components/tool-execution.d.ts +26 -16
  45. package/dist/types/modes/components/transcript-container.d.ts +23 -2
  46. package/dist/types/modes/components/tree-selector.d.ts +1 -1
  47. package/dist/types/modes/components/usage-row.d.ts +3 -0
  48. package/dist/types/modes/controllers/command-controller.d.ts +2 -2
  49. package/dist/types/modes/controllers/input-controller.d.ts +14 -0
  50. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  51. package/dist/types/modes/gradient-highlight.d.ts +9 -4
  52. package/dist/types/modes/image-references.d.ts +6 -0
  53. package/dist/types/modes/interactive-mode.d.ts +27 -3
  54. package/dist/types/modes/magic-keywords.d.ts +13 -1
  55. package/dist/types/modes/rpc/rpc-mode.d.ts +35 -1
  56. package/dist/types/modes/rpc/rpc-types.d.ts +9 -1
  57. package/dist/types/modes/runtime-init.d.ts +4 -0
  58. package/dist/types/modes/theme/theme.d.ts +13 -2
  59. package/dist/types/modes/types.d.ts +8 -2
  60. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  61. package/dist/types/registry/agent-registry.d.ts +17 -0
  62. package/dist/types/secrets/obfuscator.d.ts +1 -1
  63. package/dist/types/session/agent-session.d.ts +14 -2
  64. package/dist/types/session/indexed-session-storage.d.ts +3 -4
  65. package/dist/types/session/session-context.d.ts +39 -0
  66. package/dist/types/session/session-entries.d.ts +159 -0
  67. package/dist/types/session/session-listing.d.ts +69 -0
  68. package/dist/types/session/session-loader.d.ts +16 -0
  69. package/dist/types/session/session-manager.d.ts +82 -474
  70. package/dist/types/session/session-migrations.d.ts +12 -0
  71. package/dist/types/session/session-paths.d.ts +25 -0
  72. package/dist/types/session/session-persistence.d.ts +8 -0
  73. package/dist/types/session/session-storage.d.ts +11 -12
  74. package/dist/types/session/snapcompact-inline.d.ts +12 -1
  75. package/dist/types/session/snapcompact-savings-journal.d.ts +46 -0
  76. package/dist/types/session/tool-choice-queue.d.ts +6 -6
  77. package/dist/types/stt/asr-client.d.ts +90 -0
  78. package/dist/types/stt/asr-protocol.d.ts +97 -0
  79. package/dist/types/stt/asr-worker.d.ts +2 -0
  80. package/dist/types/stt/downloader.d.ts +38 -0
  81. package/dist/types/stt/endpointer.d.ts +59 -0
  82. package/dist/types/stt/index.d.ts +5 -1
  83. package/dist/types/stt/models.d.ts +120 -0
  84. package/dist/types/stt/recorder.d.ts +17 -0
  85. package/dist/types/stt/stt-controller.d.ts +6 -0
  86. package/dist/types/stt/transcriber.d.ts +5 -7
  87. package/dist/types/stt/wav.d.ts +29 -0
  88. package/dist/types/system-prompt.d.ts +4 -0
  89. package/dist/types/task/executor.d.ts +2 -0
  90. package/dist/types/task/index.d.ts +9 -1
  91. package/dist/types/task/types.d.ts +36 -0
  92. package/dist/types/tools/bash.d.ts +2 -2
  93. package/dist/types/tools/eval-render.d.ts +1 -1
  94. package/dist/types/tools/index.d.ts +11 -1
  95. package/dist/types/tools/irc.d.ts +1 -0
  96. package/dist/types/tools/learn.d.ts +51 -0
  97. package/dist/types/tools/manage-skill.d.ts +40 -0
  98. package/dist/types/tools/plan-mode-guard.d.ts +10 -0
  99. package/dist/types/tools/renderers.d.ts +7 -11
  100. package/dist/types/tools/ssh.d.ts +1 -1
  101. package/dist/types/tools/todo.d.ts +1 -1
  102. package/dist/types/tools/tts.d.ts +25 -0
  103. package/dist/types/tools/write.d.ts +1 -1
  104. package/dist/types/tts/downloader.d.ts +20 -0
  105. package/dist/types/tts/index.d.ts +8 -0
  106. package/dist/types/tts/models.d.ts +82 -0
  107. package/dist/types/tts/player.d.ts +32 -0
  108. package/dist/types/tts/runtime.d.ts +6 -0
  109. package/dist/types/tts/streaming-player.d.ts +41 -0
  110. package/dist/types/tts/tts-client.d.ts +93 -0
  111. package/dist/types/tts/tts-protocol.d.ts +95 -0
  112. package/dist/types/tts/tts-worker.d.ts +2 -0
  113. package/dist/types/tts/vocalizer.d.ts +41 -0
  114. package/dist/types/tts/wav.d.ts +8 -0
  115. package/dist/types/utils/tool-choice.d.ts +8 -0
  116. package/dist/types/utils/tools-manager.d.ts +2 -1
  117. package/dist/types/utils/tools-manager.test.d.ts +1 -0
  118. package/dist/types/web/scrapers/github.d.ts +1 -1
  119. package/package.json +15 -14
  120. package/src/async/job-manager.ts +49 -0
  121. package/src/autolearn/controller.ts +139 -0
  122. package/src/autolearn/managed-skills.ts +257 -0
  123. package/src/autoresearch/state.ts +1 -1
  124. package/src/autoresearch/types.ts +1 -1
  125. package/src/cli/args.ts +56 -2
  126. package/src/cli/session-picker.ts +2 -1
  127. package/src/cli/setup-cli.ts +148 -47
  128. package/src/cli/setup-model-picker.ts +43 -0
  129. package/src/cli-commands.ts +1 -0
  130. package/src/cli.ts +45 -13
  131. package/src/collab/host.ts +1 -1
  132. package/src/collab/protocol.ts +1 -1
  133. package/src/commands/say.ts +102 -0
  134. package/src/commands/setup.ts +1 -1
  135. package/src/commit/agentic/tools/analyze-file.ts +3 -0
  136. package/src/config/keybindings.ts +2 -2
  137. package/src/config/model-discovery.ts +11 -5
  138. package/src/config/model-registry.ts +64 -9
  139. package/src/config/models-config-schema.ts +4 -1
  140. package/src/config/models-config.ts +2 -1
  141. package/src/config/settings-schema.ts +248 -32
  142. package/src/config/settings.ts +10 -0
  143. package/src/discovery/builtin.ts +23 -1
  144. package/src/discovery/claude-plugins.ts +44 -5
  145. package/src/discovery/helpers.ts +41 -1
  146. package/src/eval/__tests__/budget-bridge.test.ts +1 -1
  147. package/src/eval/js/shared/prelude.txt +69 -17
  148. package/src/export/html/index.ts +3 -6
  149. package/src/extensibility/extensions/model-api.ts +41 -0
  150. package/src/extensibility/extensions/runner.ts +4 -0
  151. package/src/extensibility/extensions/types.ts +52 -1
  152. package/src/extensibility/extensions/wrapper.ts +41 -5
  153. package/src/extensibility/hooks/index.ts +2 -1
  154. package/src/extensibility/plugins/legacy-pi-compat.ts +43 -13
  155. package/src/extensibility/plugins/loader.ts +30 -19
  156. package/src/extensibility/plugins/manager.ts +221 -90
  157. package/src/extensibility/shared-events.ts +1 -1
  158. package/src/extensibility/skills.ts +96 -15
  159. package/src/goals/guided-setup.ts +133 -0
  160. package/src/goals/state.ts +1 -1
  161. package/src/hindsight/transcript.ts +1 -1
  162. package/src/index.ts +5 -0
  163. package/src/internal-urls/docs-index.generated.ts +10 -10
  164. package/src/internal-urls/history-protocol.ts +1 -1
  165. package/src/internal-urls/local-protocol.ts +29 -7
  166. package/src/main.ts +27 -7
  167. package/src/mcp/startup-events.ts +21 -0
  168. package/src/mcp/transports/stdio.ts +2 -1
  169. package/src/memories/index.ts +146 -11
  170. package/src/memory-backend/local-backend.ts +11 -5
  171. package/src/mnemopi/backend.ts +1 -0
  172. package/src/mnemopi/config.ts +26 -10
  173. package/src/modes/acp/acp-agent.ts +3 -5
  174. package/src/modes/components/agent-hub.ts +49 -4
  175. package/src/modes/components/assistant-message.ts +4 -37
  176. package/src/modes/components/compaction-summary-message.ts +125 -26
  177. package/src/modes/components/custom-editor.test.ts +96 -0
  178. package/src/modes/components/custom-editor.ts +164 -8
  179. package/src/modes/components/session-selector.ts +1 -1
  180. package/src/modes/components/settings-defs.ts +7 -0
  181. package/src/modes/components/tool-execution.ts +82 -43
  182. package/src/modes/components/transcript-container.ts +70 -1
  183. package/src/modes/components/tree-selector.ts +1 -1
  184. package/src/modes/components/usage-row.ts +18 -0
  185. package/src/modes/components/user-message.ts +4 -2
  186. package/src/modes/controllers/command-controller.ts +14 -4
  187. package/src/modes/controllers/event-controller.ts +78 -11
  188. package/src/modes/controllers/extension-ui-controller.ts +6 -0
  189. package/src/modes/controllers/input-controller.ts +258 -27
  190. package/src/modes/controllers/selector-controller.ts +12 -2
  191. package/src/modes/gradient-highlight.ts +21 -9
  192. package/src/modes/image-references.ts +20 -0
  193. package/src/modes/interactive-mode.ts +286 -40
  194. package/src/modes/magic-keywords.ts +27 -5
  195. package/src/modes/rpc/rpc-mode.ts +146 -14
  196. package/src/modes/rpc/rpc-subagents.ts +2 -2
  197. package/src/modes/rpc/rpc-types.ts +8 -2
  198. package/src/modes/runtime-init.ts +28 -3
  199. package/src/modes/theme/theme.ts +98 -50
  200. package/src/modes/types.ts +6 -2
  201. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  202. package/src/modes/utils/ui-helpers.ts +34 -6
  203. package/src/priority.json +5 -1
  204. package/src/prompts/agents/task.md +1 -0
  205. package/src/prompts/goals/guided-goal-interview.md +8 -0
  206. package/src/prompts/goals/guided-goal-system.md +12 -0
  207. package/src/prompts/memories/read-path.md +6 -0
  208. package/src/prompts/system/autolearn-guidance-learn.md +1 -0
  209. package/src/prompts/system/autolearn-guidance.md +7 -0
  210. package/src/prompts/system/autolearn-nudge.md +3 -0
  211. package/src/prompts/system/eager-task.md +7 -0
  212. package/src/prompts/system/eager-todo.md +11 -6
  213. package/src/prompts/system/subagent-system-prompt.md +4 -0
  214. package/src/prompts/system/system-prompt.md +10 -5
  215. package/src/prompts/system/title-marker-instruction.md +1 -0
  216. package/src/prompts/system/title-system-marker.md +16 -0
  217. package/src/prompts/tools/job.md +1 -0
  218. package/src/prompts/tools/learn.md +7 -0
  219. package/src/prompts/tools/manage-skill.md +9 -0
  220. package/src/prompts/tools/task.md +3 -0
  221. package/src/registry/agent-registry.ts +30 -0
  222. package/src/sdk.ts +88 -24
  223. package/src/secrets/obfuscator.ts +1 -1
  224. package/src/session/agent-session.ts +209 -87
  225. package/src/session/history-storage.ts +2 -2
  226. package/src/session/indexed-session-storage.ts +7 -17
  227. package/src/session/session-context.ts +352 -0
  228. package/src/session/session-entries.ts +194 -0
  229. package/src/session/session-listing.ts +588 -0
  230. package/src/session/session-loader.ts +106 -0
  231. package/src/session/session-manager.ts +933 -3145
  232. package/src/session/session-migrations.ts +78 -0
  233. package/src/session/session-paths.ts +193 -0
  234. package/src/session/session-persistence.ts +131 -0
  235. package/src/session/session-storage.ts +91 -50
  236. package/src/session/snapcompact-inline.ts +21 -1
  237. package/src/session/snapcompact-savings-journal.ts +113 -0
  238. package/src/session/tool-choice-queue.ts +23 -11
  239. package/src/slash-commands/builtin-registry.ts +25 -3
  240. package/src/stt/asr-client.ts +520 -0
  241. package/src/stt/asr-protocol.ts +65 -0
  242. package/src/stt/asr-worker.ts +790 -0
  243. package/src/stt/downloader.ts +107 -47
  244. package/src/stt/endpointer.ts +259 -0
  245. package/src/stt/index.ts +5 -1
  246. package/src/stt/models.ts +150 -0
  247. package/src/stt/recorder.ts +247 -60
  248. package/src/stt/stt-controller.ts +201 -22
  249. package/src/stt/transcriber.ts +37 -68
  250. package/src/stt/wav.ts +173 -0
  251. package/src/system-prompt.ts +8 -0
  252. package/src/task/agents.ts +1 -2
  253. package/src/task/executor.ts +49 -15
  254. package/src/task/index.ts +60 -6
  255. package/src/task/render.ts +83 -8
  256. package/src/task/types.ts +53 -0
  257. package/src/tools/ask.ts +8 -0
  258. package/src/tools/bash.ts +4 -3
  259. package/src/tools/eval-render.ts +4 -3
  260. package/src/tools/index.ts +40 -4
  261. package/src/tools/irc.ts +10 -2
  262. package/src/tools/job.ts +14 -2
  263. package/src/tools/learn.ts +144 -0
  264. package/src/tools/manage-skill.ts +104 -0
  265. package/src/tools/plan-mode-guard.ts +53 -19
  266. package/src/tools/renderers.ts +7 -11
  267. package/src/tools/ssh.ts +4 -3
  268. package/src/tools/todo.ts +1 -1
  269. package/src/tools/tts.ts +203 -92
  270. package/src/tools/write.ts +18 -2
  271. package/src/tts/downloader.ts +64 -0
  272. package/src/tts/index.ts +8 -0
  273. package/src/tts/models.ts +137 -0
  274. package/src/tts/player.ts +137 -0
  275. package/src/tts/runtime.ts +21 -0
  276. package/src/tts/streaming-player.ts +266 -0
  277. package/src/tts/tts-client.ts +647 -0
  278. package/src/tts/tts-protocol.ts +60 -0
  279. package/src/tts/tts-worker.ts +497 -0
  280. package/src/tts/vocalizer.ts +162 -0
  281. package/src/tts/wav.ts +58 -0
  282. package/src/utils/title-generator.ts +48 -5
  283. package/src/utils/tool-choice.ts +16 -0
  284. package/src/utils/tools-manager.test.ts +25 -0
  285. package/src/utils/tools-manager.ts +19 -1
  286. package/src/web/scrapers/github.ts +96 -0
  287. package/src/web/search/index.ts +13 -0
  288. package/src/web/search/providers/searxng.ts +13 -1
  289. package/dist/types/stt/setup.d.ts +0 -18
  290. package/src/stt/setup.ts +0 -52
  291. package/src/stt/transcribe.py +0 -70
@@ -0,0 +1,78 @@
1
+ import { Snowflake } from "@oh-my-pi/pi-utils";
2
+ import { type CompactionEntry, CURRENT_SESSION_VERSION, type FileEntry, type SessionHeader } from "./session-entries";
3
+
4
+ /** Generate a unique short ID (8 hex chars, collision-checked) */
5
+ export function generateId(byId: { has(id: string): boolean }): string {
6
+ for (let i = 0; i < 100; i++) {
7
+ const id = crypto.randomUUID().slice(-8);
8
+ if (!byId.has(id)) return id;
9
+ }
10
+ return Snowflake.next(); // fallback to full snowflake id
11
+ }
12
+
13
+ /** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */
14
+ function migrateV1ToV2(entries: FileEntry[]): void {
15
+ const ids = new Set<string>();
16
+ let prevId: string | null = null;
17
+
18
+ for (const entry of entries) {
19
+ if (entry.type === "session") {
20
+ entry.version = 2;
21
+ continue;
22
+ }
23
+
24
+ entry.id = generateId(ids);
25
+ entry.parentId = prevId;
26
+ prevId = entry.id;
27
+
28
+ // Convert firstKeptEntryIndex to firstKeptEntryId for compaction
29
+ if (entry.type === "compaction") {
30
+ const comp = entry as CompactionEntry & { firstKeptEntryIndex?: number };
31
+ if (typeof comp.firstKeptEntryIndex === "number") {
32
+ const targetEntry = entries[comp.firstKeptEntryIndex];
33
+ if (targetEntry && targetEntry.type !== "session") {
34
+ comp.firstKeptEntryId = targetEntry.id;
35
+ }
36
+ delete comp.firstKeptEntryIndex;
37
+ }
38
+ }
39
+ }
40
+ }
41
+
42
+ /** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */
43
+ function migrateV2ToV3(entries: FileEntry[]): void {
44
+ for (const entry of entries) {
45
+ if (entry.type === "session") {
46
+ entry.version = 3;
47
+ continue;
48
+ }
49
+
50
+ if (entry.type === "message") {
51
+ const msg = entry.message as { role?: string };
52
+ if (msg.role === "hookMessage") {
53
+ (entry.message as { role: string }).role = "custom";
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Run all necessary migrations to bring entries to current version.
61
+ * Mutates entries in place. Returns true if any migration was applied.
62
+ */
63
+ export function migrateToCurrentVersion(entries: FileEntry[]): boolean {
64
+ const header = entries.find(e => e.type === "session") as SessionHeader | undefined;
65
+ const version = header?.version ?? 1;
66
+
67
+ if (version >= CURRENT_SESSION_VERSION) return false;
68
+
69
+ if (version < 2) migrateV1ToV2(entries);
70
+ if (version < 3) migrateV2ToV3(entries);
71
+
72
+ return true;
73
+ }
74
+
75
+ /** Exported for testing */
76
+ export function migrateSessionEntries(entries: FileEntry[]): void {
77
+ migrateToCurrentVersion(entries);
78
+ }
@@ -0,0 +1,193 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { getTerminalId } from "@oh-my-pi/pi-tui";
5
+ import { getSessionsDir, getTerminalSessionsDir, isEnoent, logger, resolveEquivalentPath } from "@oh-my-pi/pi-utils";
6
+ import type { SessionStorage } from "./session-storage";
7
+
8
+ const migratedSessionRoots = new Set<string>();
9
+
10
+ /**
11
+ * Merge or rename a legacy session directory into its canonical target.
12
+ * Best effort: callers decide whether migration failures should surface.
13
+ */
14
+ function migrateSessionDirPath(oldPath: string, newPath: string): void {
15
+ const existing = fs.statSync(newPath, { throwIfNoEntry: false });
16
+ if (existing?.isDirectory()) {
17
+ for (const file of fs.readdirSync(oldPath)) {
18
+ const src = path.join(oldPath, file);
19
+ const dst = path.join(newPath, file);
20
+ if (!fs.existsSync(dst)) {
21
+ fs.renameSync(src, dst);
22
+ }
23
+ }
24
+ fs.rmSync(oldPath, { recursive: true, force: true });
25
+ return;
26
+ }
27
+ if (existing) {
28
+ fs.rmSync(newPath, { recursive: true, force: true });
29
+ }
30
+ fs.renameSync(oldPath, newPath);
31
+ }
32
+
33
+ function encodeLegacyAbsoluteSessionDirName(cwd: string): string {
34
+ const resolvedCwd = path.resolve(cwd);
35
+ return `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
36
+ }
37
+
38
+ function encodeRelativeSessionDirName(prefix: string, relative: string): string {
39
+ const encoded = relative.replace(/[/\\:]/g, "-");
40
+ return encoded ? (prefix.endsWith("-") ? `${prefix}${encoded}` : `${prefix}-${encoded}`) : prefix;
41
+ }
42
+
43
+ function getDefaultSessionDirName(cwd: string): { encodedDirName: string; resolvedCwd: string } {
44
+ const resolvedCwd = path.resolve(cwd);
45
+ const canonicalCwd = resolveEquivalentPath(resolvedCwd);
46
+ const home = os.homedir();
47
+ const canonicalHome = resolveEquivalentPath(home);
48
+ const tempRoot = os.tmpdir();
49
+ const canonicalTempRoot = resolveEquivalentPath(tempRoot);
50
+ const homeRelative = path.relative(canonicalHome, canonicalCwd);
51
+ const tempRelative = path.relative(canonicalTempRoot, canonicalCwd);
52
+ const encodedDirName =
53
+ homeRelative === "" || (!homeRelative.startsWith("..") && !path.isAbsolute(homeRelative))
54
+ ? encodeRelativeSessionDirName("-", homeRelative)
55
+ : tempRelative === "" || (!tempRelative.startsWith("..") && !path.isAbsolute(tempRelative))
56
+ ? encodeRelativeSessionDirName("-tmp", tempRelative)
57
+ : encodeLegacyAbsoluteSessionDirName(canonicalCwd);
58
+ return { encodedDirName, resolvedCwd };
59
+ }
60
+
61
+ /**
62
+ * Migrate old `--<home-encoded>-*--` session dirs to the new `-*` format.
63
+ * Runs once per sessions root on first access, best-effort.
64
+ */
65
+ function migrateHomeSessionDirs(sessionsRoot: string): void {
66
+ if (migratedSessionRoots.has(sessionsRoot)) return;
67
+ migratedSessionRoots.add(sessionsRoot);
68
+
69
+ const home = os.homedir();
70
+ const homeEncoded = home.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-");
71
+ const oldPrefix = `--${homeEncoded}-`;
72
+ const oldExact = `--${homeEncoded}--`;
73
+
74
+ let entries: string[];
75
+ try {
76
+ entries = fs.readdirSync(sessionsRoot);
77
+ } catch {
78
+ return;
79
+ }
80
+
81
+ for (const entry of entries) {
82
+ let remainder: string;
83
+ if (entry === oldExact) {
84
+ remainder = "";
85
+ } else if (entry.startsWith(oldPrefix) && entry.endsWith("--")) {
86
+ remainder = entry.slice(oldPrefix.length, -2);
87
+ } else {
88
+ continue;
89
+ }
90
+
91
+ const newName = remainder ? `-${remainder}` : "-";
92
+ const oldPath = path.join(sessionsRoot, entry);
93
+ const newPath = path.join(sessionsRoot, newName);
94
+
95
+ try {
96
+ migrateSessionDirPath(oldPath, newPath);
97
+ } catch {
98
+ // Best effort
99
+ }
100
+ }
101
+ }
102
+
103
+ function migrateLegacyAbsoluteSessionDir(cwd: string, sessionDir: string, sessionsRoot: string): void {
104
+ const legacyDir = path.join(sessionsRoot, encodeLegacyAbsoluteSessionDirName(cwd));
105
+ if (legacyDir === sessionDir || !fs.existsSync(legacyDir)) return;
106
+
107
+ try {
108
+ migrateSessionDirPath(legacyDir, sessionDir);
109
+ } catch {
110
+ // Best effort
111
+ }
112
+ }
113
+
114
+ export function resolveManagedSessionRoot(sessionDir: string, cwd: string): string | undefined {
115
+ const currentDirName = path.basename(sessionDir);
116
+ const { encodedDirName } = getDefaultSessionDirName(cwd);
117
+ if (currentDirName !== encodedDirName && currentDirName !== encodeLegacyAbsoluteSessionDirName(cwd)) {
118
+ return undefined;
119
+ }
120
+ return path.dirname(sessionDir);
121
+ }
122
+
123
+ /**
124
+ * Compute the default session directory for a cwd.
125
+ * Classifies cwd by canonical location so symlink/alias paths resolve to the
126
+ * same home-relative or temp-root directory names as their real targets.
127
+ */
128
+ export function computeDefaultSessionDir(
129
+ cwd: string,
130
+ storage: SessionStorage,
131
+ sessionsRoot: string = getSessionsDir(),
132
+ ): string {
133
+ const { encodedDirName, resolvedCwd } = getDefaultSessionDirName(cwd);
134
+ migrateHomeSessionDirs(sessionsRoot);
135
+ const sessionDir = path.join(sessionsRoot, encodedDirName);
136
+ migrateLegacyAbsoluteSessionDir(resolvedCwd, sessionDir, sessionsRoot);
137
+ storage.ensureDirSync(sessionDir);
138
+ return sessionDir;
139
+ }
140
+
141
+ // =============================================================================
142
+ // Terminal breadcrumbs: maps terminal (TTY) -> last session file for --continue
143
+ // =============================================================================
144
+
145
+ /**
146
+ * Write a breadcrumb linking the current terminal to a session file.
147
+ * The breadcrumb contains the cwd and session path so --continue can
148
+ * find "this terminal's last session" even when running concurrent instances.
149
+ */
150
+ export function writeTerminalBreadcrumb(cwd: string, sessionFile: string): void {
151
+ const terminalId = getTerminalId();
152
+ if (!terminalId) return;
153
+
154
+ const breadcrumbDir = getTerminalSessionsDir();
155
+ const breadcrumbFile = path.join(breadcrumbDir, terminalId);
156
+ const content = `${cwd}\n${sessionFile}\n`;
157
+ // Best-effort — don't break session creation if breadcrumb fails
158
+ Bun.write(breadcrumbFile, content).catch(() => {});
159
+ }
160
+
161
+ export interface TerminalBreadcrumb {
162
+ cwd: string;
163
+ sessionFile: string;
164
+ }
165
+
166
+ /**
167
+ * Read the raw terminal breadcrumb for the current terminal.
168
+ * Returns the recorded cwd + session file (verified to exist) regardless of
169
+ * whether the recorded cwd still matches the current one. Callers decide how
170
+ * to interpret a cwd mismatch (e.g. a moved/renamed worktree).
171
+ */
172
+ export async function readTerminalBreadcrumbEntry(): Promise<TerminalBreadcrumb | null> {
173
+ const terminalId = getTerminalId();
174
+ if (!terminalId) return null;
175
+
176
+ try {
177
+ const breadcrumbFile = path.join(getTerminalSessionsDir(), terminalId);
178
+ const content = await Bun.file(breadcrumbFile).text();
179
+ const lines = content.trim().split("\n");
180
+ if (lines.length < 2) return null;
181
+
182
+ const breadcrumbCwd = lines[0];
183
+ const sessionFile = lines[1];
184
+
185
+ // Verify the session file still exists
186
+ const stat = fs.statSync(sessionFile, { throwIfNoEntry: false });
187
+ if (stat?.isFile()) return { cwd: breadcrumbCwd, sessionFile };
188
+ } catch (err) {
189
+ if (!isEnoent(err)) logger.debug("Terminal breadcrumb read failed", { err });
190
+ // Breadcrumb doesn't exist or is corrupt — fall through
191
+ }
192
+ return null;
193
+ }
@@ -0,0 +1,131 @@
1
+ import {
2
+ type BlobStore,
3
+ externalizeImageDataSync,
4
+ externalizeImageDataUrlSync,
5
+ isBlobRef,
6
+ isImageDataUrl,
7
+ } from "./blob-store";
8
+ import type { FileEntry } from "./session-entries";
9
+
10
+ const MAX_PERSIST_CHARS = 500_000;
11
+ const TRUNCATION_NOTICE = "\n\n[Session persistence truncated large content]";
12
+ /** Minimum base64 length to externalize to blob store (skip tiny inline images) */
13
+ const BLOB_EXTERNALIZE_THRESHOLD = 1024;
14
+ const TEXT_CONTENT_KEY = "content";
15
+
16
+ function truncateString(value: string, maxLength: number): string {
17
+ if (value.length <= maxLength) return value;
18
+ let truncated = value.slice(0, maxLength);
19
+ if (truncated.length > 0) {
20
+ const last = truncated.charCodeAt(truncated.length - 1);
21
+ if (last >= 0xd800 && last <= 0xdbff) {
22
+ truncated = truncated.slice(0, -1);
23
+ }
24
+ }
25
+ return truncated;
26
+ }
27
+
28
+ export function isImageBlock(value: unknown): value is { type: "image"; data: string; mimeType?: string } {
29
+ return (
30
+ typeof value === "object" &&
31
+ value !== null &&
32
+ "type" in value &&
33
+ (value as { type?: string }).type === "image" &&
34
+ "data" in value &&
35
+ typeof (value as { data?: string }).data === "string"
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Recursively truncate large strings in an object for session persistence.
41
+ * - Truncates any oversized string fields (key-agnostic)
42
+ * - Replaces oversized image blocks with text notices
43
+ * - Updates lineCount when content is truncated
44
+ * - Returns original object if no changes needed (structural sharing)
45
+ *
46
+ * Runs in one synchronous tick so an OOM/SIGKILL landing right after a persist
47
+ * call returns cannot lose the entry. Image externalization happens via the
48
+ * synchronous blob-store path (`fs.writeFileSync`), so blob bytes are in the
49
+ * kernel page cache before the JSONL line referencing them is written.
50
+ */
51
+ function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string): unknown {
52
+ if (obj === null || obj === undefined) return obj;
53
+
54
+ if (typeof obj === "string") {
55
+ if (key === "image_url" && isImageDataUrl(obj)) {
56
+ return externalizeImageDataUrlSync(blobStore, obj);
57
+ }
58
+ if (obj.length > MAX_PERSIST_CHARS) {
59
+ // Cryptographic signatures must be preserved exactly or cleared entirely — never truncated.
60
+ // Truncation would produce an invalid signature that the API rejects.
61
+ if (key === "thinkingSignature" || key === "thoughtSignature" || key === "textSignature") {
62
+ return "";
63
+ }
64
+ const limit = Math.max(0, MAX_PERSIST_CHARS - TRUNCATION_NOTICE.length);
65
+ return `${truncateString(obj, limit)}${TRUNCATION_NOTICE}`;
66
+ }
67
+ return obj;
68
+ }
69
+
70
+ if (Array.isArray(obj)) {
71
+ let changed = false;
72
+ const result: unknown[] = new Array(obj.length);
73
+ for (let i = 0; i < obj.length; i++) {
74
+ const item = obj[i];
75
+ if (
76
+ key === TEXT_CONTENT_KEY &&
77
+ isImageBlock(item) &&
78
+ !isBlobRef(item.data) &&
79
+ item.data.length >= BLOB_EXTERNALIZE_THRESHOLD
80
+ ) {
81
+ changed = true;
82
+ result[i] = { ...item, data: externalizeImageDataSync(blobStore, item.data, item.mimeType) };
83
+ continue;
84
+ }
85
+ const newItem = truncateForPersistence(item, blobStore, key);
86
+ if (newItem !== item) changed = true;
87
+ result[i] = newItem;
88
+ }
89
+ return changed ? result : obj;
90
+ }
91
+
92
+ if (typeof obj === "object") {
93
+ let changed = false;
94
+ const entries: Array<readonly [string, unknown]> = [];
95
+ for (const [childKey, value] of Object.entries(obj)) {
96
+ // Strip transient/redundant properties that shouldn't be persisted.
97
+ // - partialJson: streaming accumulator for tool call JSON parsing
98
+ // - jsonlEvents: raw subprocess streaming events (already saved to artifact files)
99
+ if (childKey === "partialJson" || childKey === "jsonlEvents") {
100
+ changed = true;
101
+ continue;
102
+ }
103
+ const newValue = truncateForPersistence(value, blobStore, childKey);
104
+ if (newValue !== value) changed = true;
105
+ entries.push([childKey, newValue]);
106
+ }
107
+ if (!changed) return obj;
108
+
109
+ const contentEntry = entries.find(([childKey]) => childKey === "content");
110
+ const lineCountEntry = entries.find(([childKey]) => childKey === "lineCount");
111
+ if (
112
+ contentEntry &&
113
+ typeof contentEntry[1] === "string" &&
114
+ lineCountEntry &&
115
+ typeof lineCountEntry[1] === "number"
116
+ ) {
117
+ const content = contentEntry[1];
118
+ const updatedEntries = entries.map(([childKey, value]) =>
119
+ childKey === "lineCount" ? ([childKey, content.split("\n").length] as const) : ([childKey, value] as const),
120
+ );
121
+ return Object.fromEntries(updatedEntries);
122
+ }
123
+ return Object.fromEntries(entries);
124
+ }
125
+
126
+ return obj;
127
+ }
128
+
129
+ export function prepareEntryForPersistence(entry: FileEntry, blobStore: BlobStore): FileEntry {
130
+ return truncateForPersistence(entry, blobStore) as FileEntry;
131
+ }
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as fsp from "node:fs/promises";
3
3
  import * as path from "node:path";
4
- import { isEnoent, peekFileEnds, toError } from "@oh-my-pi/pi-utils";
4
+ import { hasFsCode, isEnoent, logger, peekFileEnds, Snowflake, toError } from "@oh-my-pi/pi-utils";
5
5
 
6
6
  const utf8Decoder = new TextDecoder("utf-8");
7
7
 
@@ -12,22 +12,17 @@ export interface SessionStorageStat {
12
12
  }
13
13
 
14
14
  export interface SessionStorageWriter {
15
- writeLine(line: string): Promise<void>;
16
15
  /**
17
- * Synchronously append a single line. Returns once the bytes are handed to the kernel
18
- * (page cache), so the data survives a non-graceful process death (OOM, SIGKILL, etc.)
19
- * even though it has not yet been fsynced to the underlying disk.
16
+ * Append one newline-terminated line. File and memory storage perform the
17
+ * write synchronously in-body; indexed backends queue in call order.
20
18
  *
21
- * `line` MUST already include the trailing newline. Throws synchronously on I/O error.
19
+ * `line` MUST include the trailing newline.
22
20
  */
23
- writeLineSync(line: string): void;
21
+ append(line: string): Promise<void>;
22
+ /** Resolve once all queued appends complete. No fsync. */
24
23
  flush(): Promise<void>;
25
- fsync(): Promise<void>;
26
- /**
27
- * Synchronously fsync the underlying file descriptor. Returns once the data
28
- * is on the physical disk. Throws synchronously on I/O error.
29
- */
30
- fsyncSync(): void;
24
+ /** False once close() has begun/finished. */
25
+ isOpen(): boolean;
31
26
  close(): Promise<void>;
32
27
  getError(): Error | undefined;
33
28
  }
@@ -44,6 +39,7 @@ export interface SessionStorage {
44
39
  /** Read the requested UTF-8 byte windows from the head and tail of the file. */
45
40
  readTextSlices(path: string, prefixBytes: number, suffixBytes: number): Promise<[string, string]>;
46
41
  writeText(path: string, content: string): Promise<void>;
42
+ writeTextAtomic(path: string, content: string): Promise<void>;
47
43
  rename(path: string, nextPath: string): Promise<void>;
48
44
  unlink(path: string): Promise<void>;
49
45
  deleteSessionWithArtifacts(sessionPath: string): Promise<void>;
@@ -86,7 +82,7 @@ class FileSessionStorageWriter implements SessionStorageWriter {
86
82
  return error;
87
83
  }
88
84
 
89
- writeLineSync(line: string): void {
85
+ async append(line: string): Promise<void> {
90
86
  if (this.#closed) throw new Error("Writer closed");
91
87
  if (this.#error) throw this.#error;
92
88
  try {
@@ -104,33 +100,12 @@ class FileSessionStorageWriter implements SessionStorageWriter {
104
100
  }
105
101
  }
106
102
 
107
- async writeLine(line: string): Promise<void> {
108
- this.writeLineSync(line);
109
- }
110
-
111
103
  async flush(): Promise<void> {
112
104
  if (this.#error) throw this.#error;
113
- // OS buffers are flushed on fsync, nothing to do here
114
105
  }
115
106
 
116
- async fsync(): Promise<void> {
117
- if (this.#closed) throw new Error("Writer closed");
118
- if (this.#error) throw this.#error;
119
- try {
120
- fs.fsyncSync(this.#fd);
121
- } catch (err) {
122
- throw this.#recordError(err);
123
- }
124
- }
125
-
126
- fsyncSync(): void {
127
- if (this.#closed) throw new Error("Writer closed");
128
- if (this.#error) throw this.#error;
129
- try {
130
- fs.fsyncSync(this.#fd);
131
- } catch (err) {
132
- throw this.#recordError(err);
133
- }
107
+ isOpen(): boolean {
108
+ return !this.#closed;
134
109
  }
135
110
 
136
111
  async close(): Promise<void> {
@@ -204,6 +179,77 @@ export class FileSessionStorage implements SessionStorage {
204
179
  await Bun.write(path, content, { createPath: true });
205
180
  }
206
181
 
182
+ async writeTextAtomic(fpath: string, content: string): Promise<void> {
183
+ const dir = path.resolve(fpath, "..");
184
+ const tempPath = path.join(dir, `.${path.basename(fpath)}.${Snowflake.next()}.tmp`);
185
+ await fs.promises.mkdir(dir, { recursive: true });
186
+ try {
187
+ await fs.promises.writeFile(tempPath, content);
188
+ try {
189
+ await this.rename(tempPath, fpath);
190
+ return;
191
+ } catch (err) {
192
+ if (!hasFsCode(err, "EPERM")) throw toError(err);
193
+ await this.#replaceSessionFileAfterEperm(tempPath, fpath, err);
194
+ return;
195
+ }
196
+ } catch (err) {
197
+ try {
198
+ await this.unlink(tempPath);
199
+ } catch (cleanupErr) {
200
+ if (!isEnoent(cleanupErr)) {
201
+ logger.warn("Failed to remove session rewrite temp file", {
202
+ sessionFile: fpath,
203
+ tempPath,
204
+ error: toError(cleanupErr).message,
205
+ });
206
+ }
207
+ }
208
+ throw toError(err);
209
+ }
210
+ }
211
+
212
+ async #replaceSessionFileAfterEperm(tempPath: string, targetPath: string, renameError: unknown): Promise<void> {
213
+ const dir = path.resolve(targetPath, "..");
214
+ const backupPath = path.join(dir, `${path.basename(targetPath)}.${Snowflake.next()}.bak`);
215
+ try {
216
+ await this.rename(targetPath, backupPath);
217
+ } catch (moveAsideError) {
218
+ if (isEnoent(moveAsideError)) {
219
+ await this.rename(tempPath, targetPath);
220
+ return;
221
+ }
222
+ throw toError(renameError);
223
+ }
224
+ try {
225
+ await this.rename(tempPath, targetPath);
226
+ } catch (replaceError) {
227
+ try {
228
+ await this.rename(backupPath, targetPath);
229
+ } catch (rollbackErr) {
230
+ const rollbackError = toError(rollbackErr);
231
+ throw new Error(
232
+ `Failed to replace session file after EPERM (original: ${toError(renameError).message}; retry: ${
233
+ toError(replaceError).message
234
+ }; rollback: ${rollbackError.message})`,
235
+ { cause: toError(renameError) },
236
+ );
237
+ }
238
+ throw toError(replaceError);
239
+ }
240
+ try {
241
+ await this.unlink(backupPath);
242
+ } catch (err) {
243
+ if (!isEnoent(err)) {
244
+ logger.warn("Failed to remove session rewrite backup", {
245
+ sessionFile: targetPath,
246
+ backupPath,
247
+ error: toError(err).message,
248
+ });
249
+ }
250
+ }
251
+ }
252
+
207
253
  async rename(path: string, nextPath: string): Promise<void> {
208
254
  try {
209
255
  await fs.promises.rename(path, nextPath);
@@ -282,7 +328,7 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
282
328
  return error;
283
329
  }
284
330
 
285
- writeLineSync(line: string): void {
331
+ async append(line: string): Promise<void> {
286
332
  if (this.#closed) throw new Error("Writer closed");
287
333
  if (this.#error) throw this.#error;
288
334
  try {
@@ -293,22 +339,12 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
293
339
  }
294
340
  }
295
341
 
296
- async writeLine(line: string): Promise<void> {
297
- this.writeLineSync(line);
298
- }
299
-
300
342
  async flush(): Promise<void> {
301
343
  if (this.#error) throw this.#error;
302
344
  }
303
345
 
304
- async fsync(): Promise<void> {
305
- // No-op for in-memory storage
306
- if (this.#error) throw this.#error;
307
- }
308
-
309
- fsyncSync(): void {
310
- // No-op for in-memory storage
311
- if (this.#error) throw this.#error;
346
+ isOpen(): boolean {
347
+ return !this.#closed;
312
348
  }
313
349
 
314
350
  async close(): Promise<void> {
@@ -527,6 +563,11 @@ export class MemorySessionStorage implements SessionStorage {
527
563
  return Promise.resolve();
528
564
  }
529
565
 
566
+ writeTextAtomic(path: string, content: string): Promise<void> {
567
+ this.writeTextSync(path, content);
568
+ return Promise.resolve();
569
+ }
570
+
530
571
  rename(path: string, nextPath: string): Promise<void> {
531
572
  const entry = this.#files.get(path);
532
573
  if (!entry) return Promise.reject(new Error(`File not found: ${path}`));
@@ -32,6 +32,17 @@ export interface SnapcompactInlineOptions {
32
32
  shape?: snapcompact.ShapeVariantName | "auto";
33
33
  }
34
34
 
35
+ /**
36
+ * Reports the per-tool-result tokens kept off the wire when a swap is applied.
37
+ * `savedTokens` is `textTokens - frames * shape.frameTokenEstimate` for each
38
+ * imaged tool result (always > 0; the savings gate guarantees it). Wired to the
39
+ * append-only savings journal; never throws into the request path.
40
+ */
41
+ export type SnapcompactSavingsSink = (
42
+ savings: ReadonlyArray<{ toolCallId: string; savedTokens: number }>,
43
+ model: Model,
44
+ ) => void;
45
+
35
46
  // Per-provider image-count budgets live in @oh-my-pi/snapcompact
36
47
  // (`providerImageBudget`): snapcompact frames are 1568px (<2000px) so
37
48
  // dimension/size limits never bind; only COUNT does. Once the budget is
@@ -398,7 +409,10 @@ export class SnapcompactInlineTransformer {
398
409
  #toolCache = new Map<string, FrameCacheEntry>();
399
410
  #systemCache?: FrameCacheEntry;
400
411
 
401
- constructor(private readonly options: SnapcompactInlineOptions) {}
412
+ constructor(
413
+ private readonly options: SnapcompactInlineOptions,
414
+ private readonly onToolResultSavings?: SnapcompactSavingsSink,
415
+ ) {}
402
416
 
403
417
  transform(context: Context, model: Model): Context {
404
418
  // Vision gate: providers silently DROP images on text-only models —
@@ -464,13 +478,19 @@ export class SnapcompactInlineTransformer {
464
478
  });
465
479
 
466
480
  let changed = false;
481
+ const savings: Array<{ toolCallId: string; savedTokens: number }> = [];
467
482
  for (const swap of plan.toolResults) {
468
483
  const target = targets.get(swap.id);
469
484
  if (!target) continue;
470
485
  const frames = this.#framesFor(this.#toolCache, swap.id, target.text, shape);
471
486
  messages[target.index] = { ...target.message, content: [{ type: "text", text: toolResultNote }, ...frames] };
472
487
  changed = true;
488
+ savings.push({
489
+ toolCallId: swap.id,
490
+ savedTokens: Math.max(0, swap.textTokens - swap.frames * shape.frameTokenEstimate),
491
+ });
473
492
  }
493
+ if (savings.length > 0) this.onToolResultSavings?.(savings, model);
474
494
  if (this.options.renderToolResults) {
475
495
  // Drop cache entries for tool calls no longer in the context
476
496
  // (compacted away) so the cache stays bounded by live history.