volute 0.31.0 → 0.32.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 (178) hide show
  1. package/README.md +15 -22
  2. package/dist/{accept-GAKQ3MEH.js → accept-74M7I4RZ.js} +3 -2
  3. package/dist/{activity-events-T5ZRCVAL.js → activity-events-HETAODOK.js} +3 -2
  4. package/dist/{ai-service-UWUPM4T6.js → ai-service-ZIPCV3MX.js} +18 -5
  5. package/dist/api.d.ts +98 -281
  6. package/dist/{archive-YBNSJYZZ.js → archive-INXYFVCW.js} +3 -2
  7. package/dist/{auth-T5AW2USD.js → auth-6DMGES3I.js} +4 -3
  8. package/dist/{bridge-4AJ3EY26.js → bridge-BVCBTGPF.js} +3 -2
  9. package/dist/{chat-7YLT7FI3.js → chat-XT4OBJBU.js} +8 -8
  10. package/dist/{chunk-BNC43CSY.js → chunk-2FLJ63GU.js} +2 -2
  11. package/dist/{chunk-NV3TYNWX.js → chunk-2NGTS5UU.js} +1 -1
  12. package/dist/{chunk-LX6T3GKQ.js → chunk-ALEF47VT.js} +1 -1
  13. package/dist/{chunk-S2TZLSDH.js → chunk-D5G5YOPL.js} +163 -15
  14. package/dist/{chunk-VGWJSNHS.js → chunk-G53F3JA4.js} +1 -35
  15. package/dist/{chunk-A6TUJJ3L.js → chunk-G6BSYHPK.js} +2 -2
  16. package/dist/{chunk-BC3P3QCK.js → chunk-I5KY25PQ.js} +1 -9
  17. package/dist/{chunk-PNQCXLSV.js → chunk-IYDIE3HG.js} +58 -1
  18. package/dist/{chunk-HDKY4TWU.js → chunk-JJ7W6WSB.js} +3 -3
  19. package/dist/{chunk-57OKQMP3.js → chunk-LGB6JBHI.js} +1 -1
  20. package/dist/chunk-LRCG2JLP.js +251 -0
  21. package/dist/{chunk-SNVPRRT7.js → chunk-LSGWR54X.js} +2 -2
  22. package/dist/{chunk-EMPFLFTG.js → chunk-M7UL5S3Q.js} +1 -1
  23. package/dist/chunk-PB65JZK2.js +85 -0
  24. package/dist/chunk-PVY5W6QN.js +41 -0
  25. package/dist/{chunk-BWKIHH7B.js → chunk-QBQ424EM.js} +318 -418
  26. package/dist/{chunk-EKDWA7E4.js → chunk-QZANELPX.js} +4 -2
  27. package/dist/{chunk-AAO77TZX.js → chunk-R7E6CRVQ.js} +1 -1
  28. package/dist/{chunk-X62AXPR7.js → chunk-RPZZSXV3.js} +8 -196
  29. package/dist/{chunk-WRS3B556.js → chunk-RSX4OPZY.js} +5 -5
  30. package/dist/{chunk-FAHDKPEH.js → chunk-S6NFERDC.js} +5 -3
  31. package/dist/chunk-SKLSMHXO.js +208 -0
  32. package/dist/{chunk-DAXJKPHZ.js → chunk-SX5TKJBZ.js} +2 -2
  33. package/dist/{chunk-R5QJBZZG.js → chunk-TDRYEPH4.js} +20 -10
  34. package/dist/{chunk-6QIUN46C.js → chunk-TSXLLQZW.js} +11 -3
  35. package/dist/{chunk-4OUOFS23.js → chunk-UKVWJRKN.js} +1 -1
  36. package/dist/{chunk-NOWVQ7AL.js → chunk-WKF5FEFK.js} +318 -167
  37. package/dist/cli.js +38 -20
  38. package/dist/{clock-LJCG426D.js → clock-2UOZ6JPU.js} +5 -4
  39. package/dist/{cloud-sync-O3LXIRN6.js → cloud-sync-JN3NWKEM.js} +16 -14
  40. package/dist/config-H2H4UIF7.js +72 -0
  41. package/dist/connectors/discord-bridge.js +1 -1
  42. package/dist/connectors/slack-bridge.js +1 -1
  43. package/dist/connectors/telegram-bridge.js +1 -1
  44. package/dist/{conversations-RKKGP5IA.js → conversations-3O5O6AS3.js} +4 -3
  45. package/dist/{create-WUTIIRI2.js → create-RNLNCORE.js} +3 -2
  46. package/dist/{create-TL623TFC.js → create-WBBYI6V7.js} +6 -2
  47. package/dist/{daemon-client-CVGM25DM.js → daemon-client-6QXHZ7US.js} +3 -2
  48. package/dist/{daemon-restart-EZP7XH3V.js → daemon-restart-NGFHFAUF.js} +7 -6
  49. package/dist/daemon.js +907 -612
  50. package/dist/{db-SW5PL6QA.js → db-F34YLV7D.js} +2 -1
  51. package/dist/db-RA45JBFG.js +16 -0
  52. package/dist/{delete-Z6HAG35F.js → delete-QTGWEDBI.js} +1 -1
  53. package/dist/delivery-manager-SDVXFD4W.js +28 -0
  54. package/dist/delivery-router-FL45JL7N.js +21 -0
  55. package/dist/down-TB3ESMNP.js +14 -0
  56. package/dist/{env-NHESNNSP.js → env-RLYQBOOP.js} +3 -2
  57. package/dist/{export-EVMP7GWY.js → export-SUYRLI5Q.js} +4 -3
  58. package/dist/{extension-LR7EW3JF.js → extension-FQ5D3NCC.js} +4 -3
  59. package/dist/{extensions-NGEJI7JH.js → extensions-GDYWQXC4.js} +9 -7
  60. package/dist/{files-3SM7V33S.js → files-EAMPO2SJ.js} +4 -3
  61. package/dist/{history-PQD3LXEP.js → history-FO5PHBQ5.js} +7 -2
  62. package/dist/{import-PR2OCGQJ.js → import-DDUFE7AY.js} +4 -3
  63. package/dist/{join-R4EN5CWQ.js → join-I5QEE3LG.js} +1 -1
  64. package/dist/{list-B4XNUOFO.js → list-DW2VRTOZ.js} +3 -2
  65. package/dist/{login-62JVY6A2.js → login-7CHPW2PN.js} +3 -2
  66. package/dist/{login-URWP6S2N.js → login-RIJF2F4G.js} +3 -2
  67. package/dist/{logout-NXJQJDLI.js → logout-5MLHZALK.js} +3 -2
  68. package/dist/{logout-ZK2N62T3.js → logout-UZJRGY4Z.js} +3 -2
  69. package/dist/message-delivery-2FIM7QKO.js +32 -0
  70. package/dist/{mind-E2ZV2WRX.js → mind-2B6M7Y25.js} +18 -18
  71. package/dist/{mind-activity-tracker-ASNZBMLC.js → mind-activity-tracker-NZZT2NTT.js} +4 -3
  72. package/dist/{mind-list-BEI7E5WY.js → mind-list-WUPMQDYQ.js} +3 -2
  73. package/dist/mind-manager-BNCMGYXW.js +28 -0
  74. package/dist/mind-service-AV273WT4.js +34 -0
  75. package/dist/{mind-sleep-CANABWJI.js → mind-sleep-B7BHJLH7.js} +3 -2
  76. package/dist/{mind-status-6WKZVUOP.js → mind-status-L3EFFRPR.js} +3 -2
  77. package/dist/{mind-wake-RZKLH2IN.js → mind-wake-GY3RFX7Y.js} +3 -2
  78. package/dist/{package-NU4CA7OU.js → package-PK6JUFL3.js} +1 -1
  79. package/dist/{read-THL362EI.js → read-5AMJRO3D.js} +3 -2
  80. package/dist/{register-QAQELAS6.js → register-V2JZZKFK.js} +3 -2
  81. package/dist/{registry-ASXCQCNH.js → registry-PJ4S5PHQ.js} +8 -1
  82. package/dist/{reject-AYPBNPNL.js → reject-33HEZMZ4.js} +3 -2
  83. package/dist/{restart-6SKPV3T2.js → restart-3UCMRUVC.js} +3 -2
  84. package/dist/{sandbox-6ZEWQDVU.js → sandbox-JANNTX6U.js} +4 -3
  85. package/dist/schema-PA3M5ZKH.js +32 -0
  86. package/dist/{seed-OWX2AW75.js → seed-ALUQ55FF.js} +26 -9
  87. package/dist/{send-ZO4BTWXK.js → send-3MI36LEF.js} +56 -67
  88. package/dist/{setup-7CFITEQN.js → setup-SZIARWI6.js} +5 -2
  89. package/dist/{setup-ZXBXG7E4.js → setup-WENLVPVP.js} +8 -6
  90. package/dist/{skill-YFXP67A2.js → skill-TUVOTW4Z.js} +3 -2
  91. package/dist/skills/dreaming/SKILL.md +6 -4
  92. package/dist/skills/dreaming/references/INSTALL.md +2 -2
  93. package/dist/skills/dreaming/scripts/dream.ts +2 -2
  94. package/dist/skills/dreaming/scripts/wake-context-dreams.sh +1 -1
  95. package/dist/skills/imagegen/SKILL.md +6 -5
  96. package/dist/skills/imagegen/references/INSTALL.md +1 -1
  97. package/dist/skills/resonance/SKILL.md +4 -1
  98. package/dist/skills/resonance/references/INSTALL.md +2 -2
  99. package/dist/skills/resonance/scripts/resonance-hook.sh +2 -0
  100. package/dist/skills/resonance/scripts/resonance.ts +35 -5
  101. package/dist/skills/volute-admin/SKILL.md +83 -0
  102. package/dist/skills/volute-mind/SKILL.md +11 -11
  103. package/dist/skills-XNZK6P4K.js +61 -0
  104. package/dist/sleep-manager-53DZOWW7.js +32 -0
  105. package/dist/spirit-N4W4UQRH.js +217 -0
  106. package/dist/{split-MI62KJUU.js → split-STOROBYJ.js} +1 -1
  107. package/dist/{sprout-FDVI2CGN.js → sprout-L2GFOVF7.js} +9 -7
  108. package/dist/{start-D64BRKPH.js → start-K2NCUUCG.js} +3 -2
  109. package/dist/{status-ZZWBYFGE.js → status-TCUMUO6M.js} +5 -4
  110. package/dist/{stop-OP2CTXCO.js → stop-H26JZDXF.js} +3 -2
  111. package/dist/system-chat-NPYFYZVI.js +32 -0
  112. package/dist/{systems-EQPPT4B7.js → systems-DHBKVYEY.js} +6 -5
  113. package/dist/{tailscale-6DJKUMNF.js → tailscale-XHQBZROW.js} +2 -1
  114. package/dist/{template-hash-3HOR4UAJ.js → template-hash-A6VVKOXJ.js} +2 -1
  115. package/dist/up-6I6BHRTO.js +17 -0
  116. package/dist/{update-KUJXATRS.js → update-QVPRF6GR.js} +5 -4
  117. package/dist/{update-check-5WVSU37T.js → update-check-ZD6OOIYQ.js} +3 -2
  118. package/dist/{upgrade-KBHCWX6T.js → upgrade-O4Q7WJM3.js} +12 -14
  119. package/dist/{version-notify-75ELVKPV.js → version-notify-TCKWBZZG.js} +21 -18
  120. package/dist/web-assets/assets/index-Bui7U9Uu.css +1 -0
  121. package/dist/web-assets/assets/index-e36DIo1b.js +73 -0
  122. package/dist/web-assets/ext-theme.css +93 -0
  123. package/dist/web-assets/index.html +2 -2
  124. package/drizzle/0004_spirits.sql +5 -0
  125. package/drizzle/meta/0004_snapshot.json +7 -0
  126. package/drizzle/meta/_journal.json +7 -0
  127. package/package.json +1 -1
  128. package/packages/extensions/notes/dist/ui/assets/index-8jWEv9SA.js +61 -0
  129. package/packages/extensions/notes/dist/ui/assets/index-DkaB7Ytd.css +1 -0
  130. package/packages/extensions/notes/dist/ui/index.html +2 -2
  131. package/packages/extensions/pages/skills/pages/SKILL.md +16 -46
  132. package/templates/_base/.init/.config/hooks/pre-prompt/session-activity.ts +40 -0
  133. package/templates/_base/.init/{.config → .local}/bin/volute +1 -1
  134. package/templates/_base/.init/.local/hooks/pre-prompt/session-activity.ts +40 -0
  135. package/templates/_base/.init/.local/hooks/startup-context.ts +58 -0
  136. package/templates/_base/home/.config/routes.json +1 -1
  137. package/templates/_base/src/lib/daemon-client.ts +21 -13
  138. package/templates/_base/src/lib/format-prefix.ts +1 -0
  139. package/templates/_base/src/lib/hook-loader.ts +155 -0
  140. package/templates/_base/src/lib/startup.ts +11 -4
  141. package/templates/_base/src/lib/transparency.ts +2 -2
  142. package/templates/claude/.init/.claude/settings.json +1 -1
  143. package/templates/claude/.init/.config/routes.json +2 -2
  144. package/templates/claude/src/agent.ts +95 -13
  145. package/templates/claude/src/lib/message-channel.ts +7 -2
  146. package/templates/codex/.init/.config/routes.json +11 -0
  147. package/templates/codex/.init/AGENTS.md +29 -0
  148. package/templates/codex/home/.config/config.json.tmpl +7 -0
  149. package/templates/codex/package.json.tmpl +20 -0
  150. package/templates/codex/src/agent.ts +553 -0
  151. package/templates/codex/src/lib/content.ts +16 -0
  152. package/templates/codex/src/lib/session-store.ts +56 -0
  153. package/templates/codex/src/server.ts +59 -0
  154. package/templates/codex/volute-template.json +8 -0
  155. package/templates/pi/.init/.config/routes.json +2 -2
  156. package/templates/pi/src/agent.ts +62 -8
  157. package/templates/pi/src/lib/event-handler.ts +1 -1
  158. package/templates/pi/src/lib/reply-instructions-extension.ts +32 -11
  159. package/dist/chunk-HR5JKIDG.js +0 -222
  160. package/dist/down-TS4XQBA4.js +0 -13
  161. package/dist/message-delivery-UJHCLVU4.js +0 -30
  162. package/dist/mind-manager-IPA6DZXD.js +0 -26
  163. package/dist/pages-watcher-72OVPRMH.js +0 -22
  164. package/dist/skills/sessions/SKILL.md +0 -49
  165. package/dist/sleep-manager-TPS6OGCA.js +0 -30
  166. package/dist/system-chat-B43GIXQU.js +0 -30
  167. package/dist/up-TDXEP3VA.js +0 -16
  168. package/dist/web-assets/assets/index-BM1cTzBg.js +0 -72
  169. package/dist/web-assets/assets/index-BfJkKTPF.css +0 -1
  170. package/packages/extensions/notes/dist/ui/assets/index-B8GdTnXs.css +0 -1
  171. package/packages/extensions/notes/dist/ui/assets/index-CDpGTCWb.js +0 -2
  172. package/packages/extensions/pages/skills/pages/scripts/pages.mjs +0 -58
  173. package/templates/_base/.init/.config/hooks/startup-context.sh +0 -46
  174. package/templates/_base/.init/.config/scripts/session-reader.ts +0 -59
  175. package/templates/_base/src/lib/session-monitor.ts +0 -400
  176. package/templates/claude/src/lib/hooks/session-context.ts +0 -32
  177. package/templates/pi/src/lib/session-context-extension.ts +0 -35
  178. /package/templates/_base/.init/{.config → .local}/hooks/wake-context.sh +0 -0
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "tsx watch src/server.ts",
7
+ "lint": "biome check src/",
8
+ "lint:fix": "biome check --write src/",
9
+ "typecheck": "tsc --noEmit"
10
+ },
11
+ "dependencies": {
12
+ "@openai/codex-sdk": "^0.115.0",
13
+ "tsx": "^4.0.0"
14
+ },
15
+ "devDependencies": {
16
+ "@biomejs/biome": "2.3.14",
17
+ "@types/node": "^25.2.0",
18
+ "typescript": "^5.7.0"
19
+ }
20
+ }
@@ -0,0 +1,553 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve as resolvePath } from "node:path";
3
+ import { Codex } from "@openai/codex-sdk";
4
+ import { flushFileChanges, trackFileChange } from "./lib/auto-commit.js";
5
+ import { extractText } from "./lib/content.js";
6
+ import { daemonEmit, daemonRestart, type EventType } from "./lib/daemon-client.js";
7
+ import { runHooks } from "./lib/hook-loader.js";
8
+ import { log, warn } from "./lib/logger.js";
9
+ import { createSessionStore } from "./lib/session-store.js";
10
+ import { loadPrompts, loadSystemPrompt } from "./lib/startup.js";
11
+ import { filterEvent, loadTransparencyPreset } from "./lib/transparency.js";
12
+ import type {
13
+ HandlerMeta,
14
+ HandlerResolver,
15
+ Listener,
16
+ MessageHandler,
17
+ VoluteContentPart,
18
+ VoluteEvent,
19
+ } from "./lib/types.js";
20
+
21
+ /** Minimal interface for a Codex SDK thread — typed to the methods we actually use */
22
+ type CodexThread = {
23
+ runStreamed(
24
+ text: string,
25
+ options?: { signal?: AbortSignal },
26
+ ): Promise<{ events: AsyncIterable<Record<string, any>> }>;
27
+ };
28
+
29
+ type CodexSession = {
30
+ name: string;
31
+ thread: CodexThread | null;
32
+ listeners: Set<Listener>;
33
+ currentMessageId?: string;
34
+ messageQueue: Array<{ text: string; meta: HandlerMeta }>;
35
+ processing: boolean;
36
+ abortController?: AbortController;
37
+ messageChannels: Map<string, string>;
38
+ firstMessagePerChannel: Set<string>;
39
+ cumulativeInputTokens: number;
40
+ };
41
+
42
+ // Loaded once at startup
43
+ const preset = loadTransparencyPreset();
44
+
45
+ function emit(
46
+ session: CodexSession,
47
+ event: { type: EventType; content?: string; metadata?: Record<string, unknown> },
48
+ ) {
49
+ const channel = session.currentMessageId
50
+ ? session.messageChannels.get(session.currentMessageId)
51
+ : undefined;
52
+ const filtered = filterEvent(preset, {
53
+ ...event,
54
+ session: session.name,
55
+ channel,
56
+ messageId: session.currentMessageId,
57
+ });
58
+ if (filtered) daemonEmit(filtered);
59
+ }
60
+
61
+ export function createMind(options: {
62
+ systemPrompt: string;
63
+ cwd: string;
64
+ mindDir: string;
65
+ model?: string;
66
+ reasoningEffort?: string;
67
+ maxContextTokens?: number;
68
+ }): { resolve: HandlerResolver } {
69
+ const sessions = new Map<string, CodexSession>();
70
+ const prompts = loadPrompts();
71
+ const maxContextTokens = options.maxContextTokens;
72
+
73
+ if (maxContextTokens) {
74
+ log("mind", `compaction threshold: ${maxContextTokens} tokens`);
75
+ }
76
+
77
+ const sessionStore = createSessionStore(resolvePath(options.mindDir, ".mind/codex-sessions"));
78
+ const hooksDir = resolvePath(options.cwd, ".local/hooks");
79
+
80
+ // Write system prompt to file for Codex model_instructions_file
81
+ const promptPath = resolvePath(options.mindDir, ".mind/system-prompt.md");
82
+ function refreshSystemPrompt() {
83
+ try {
84
+ // Re-read and re-compose the system prompt (picks up MEMORY.md changes)
85
+ writeFileSync(promptPath, loadSystemPrompt());
86
+ } catch (err) {
87
+ warn("mind", "failed to refresh system prompt, using initial prompt:", err);
88
+ writeFileSync(promptPath, options.systemPrompt);
89
+ }
90
+ }
91
+ refreshSystemPrompt();
92
+
93
+ // Use OPENAI_API_KEY if available, otherwise let codex CLI use its own auth (~/.codex/auth.json)
94
+ const apiKey = process.env.OPENAI_API_KEY;
95
+
96
+ const codex = new Codex({
97
+ ...(apiKey ? { apiKey } : {}),
98
+ config: {
99
+ model_instructions_file: promptPath,
100
+ // Let the SDK handle compaction natively when a threshold is configured
101
+ model_auto_compact_token_limit: maxContextTokens ?? 999999999,
102
+ // The codex sandbox runs commands in /bin/zsh -lc which resets the environment.
103
+ // Set ZDOTDIR so the login shell sources our .zshenv with VOLUTE env vars and PATH.
104
+ shell_environment_policy: {
105
+ inherit: "all",
106
+ ignore_default_excludes: true,
107
+ set: { ZDOTDIR: options.cwd },
108
+ },
109
+ },
110
+ });
111
+
112
+ // --- Session lifecycle ---
113
+
114
+ function getOrCreateSession(name: string): CodexSession {
115
+ const existing = sessions.get(name);
116
+ if (existing) return existing;
117
+
118
+ const session: CodexSession = {
119
+ name,
120
+ thread: null,
121
+ listeners: new Set(),
122
+ messageQueue: [],
123
+ processing: false,
124
+ messageChannels: new Map(),
125
+ firstMessagePerChannel: new Set(),
126
+ cumulativeInputTokens: 0,
127
+ };
128
+ sessions.set(name, session);
129
+
130
+ initSession(session);
131
+ return session;
132
+ }
133
+
134
+ function initSession(session: CodexSession) {
135
+ const isEphemeral = session.name.startsWith("new-");
136
+ log("mind", `session "${session.name}": ${isEphemeral ? "ephemeral" : "persistent"}`);
137
+
138
+ if (!isEphemeral) {
139
+ const savedThreadId = sessionStore.load(session.name);
140
+ if (savedThreadId) {
141
+ try {
142
+ log("mind", `session "${session.name}": resuming thread ${savedThreadId}`);
143
+ session.thread = codex.resumeThread(savedThreadId, {
144
+ workingDirectory: options.cwd,
145
+ model: options.model,
146
+ skipGitRepoCheck: true,
147
+ sandboxMode: "danger-full-access",
148
+ });
149
+ return;
150
+ } catch (err) {
151
+ warn("mind", `session "${session.name}": failed to resume thread, starting new:`, err);
152
+ }
153
+ }
154
+ }
155
+
156
+ try {
157
+ session.thread = codex.startThread({
158
+ workingDirectory: options.cwd,
159
+ model: options.model,
160
+ skipGitRepoCheck: true,
161
+ sandboxMode: "danger-full-access",
162
+ });
163
+ log("mind", `session "${session.name}": new thread started`);
164
+ } catch (err) {
165
+ warn("mind", `session "${session.name}": failed to start thread:`, err);
166
+ }
167
+ }
168
+
169
+ // --- Event broadcasting ---
170
+
171
+ function broadcast(session: CodexSession, event: VoluteEvent) {
172
+ const tagged =
173
+ session.currentMessageId != null ? { ...event, messageId: session.currentMessageId } : event;
174
+ for (const listener of session.listeners) {
175
+ try {
176
+ listener(tagged);
177
+ } catch (err) {
178
+ log("mind", "listener threw during broadcast:", err);
179
+ }
180
+ }
181
+ }
182
+
183
+ // --- Turn execution ---
184
+
185
+ async function runTurn(session: CodexSession, text: string, meta: HandlerMeta) {
186
+ if (!session.thread) {
187
+ warn("mind", `session "${session.name}": no thread, dropping message`);
188
+ broadcast(session, { type: "done" });
189
+ return;
190
+ }
191
+
192
+ // Refresh system prompt before each turn (picks up MEMORY.md changes)
193
+ refreshSystemPrompt();
194
+
195
+ // Run pre-prompt hooks
196
+ try {
197
+ const hookResult = await runHooks(hooksDir, "pre-prompt", {
198
+ event: "pre-prompt",
199
+ session: session.name,
200
+ });
201
+ if (hookResult.additionalContext) {
202
+ emit(session, {
203
+ type: "context",
204
+ content: hookResult.additionalContext,
205
+ metadata: { source: "dynamic:pre-prompt", ...hookResult.metadata },
206
+ });
207
+ text = `${hookResult.additionalContext}\n\n${text}`;
208
+ }
209
+ } catch (err) {
210
+ warn("mind", "pre-prompt hook failed:", err);
211
+ }
212
+
213
+ // Reply instructions on first message per channel
214
+ const channel = meta.channel;
215
+ if (channel && !session.firstMessagePerChannel.has(channel)) {
216
+ session.firstMessagePerChannel.add(channel);
217
+ const replyInstructions = prompts.reply_instructions.replace(/\$\{channel\}/g, channel);
218
+ emit(session, {
219
+ type: "context",
220
+ content: replyInstructions,
221
+ metadata: { source: "reply-instructions" },
222
+ });
223
+ text = `${replyInstructions}\n\n${text}`;
224
+ }
225
+
226
+ session.abortController = new AbortController();
227
+
228
+ // Sync VOLUTE_SESSION to .zshenv so codex shell commands know which session they're in.
229
+ // process.env.VOLUTE_SESSION is set by the router, but the codex sandbox doesn't inherit it.
230
+ try {
231
+ const zshenvPath = resolvePath(options.cwd, ".zshenv");
232
+ let existing: string;
233
+ try {
234
+ existing = readFileSync(zshenvPath, "utf-8");
235
+ } catch {
236
+ // .zshenv doesn't exist (non-codex template) — not critical
237
+ existing = "";
238
+ }
239
+ if (existing) {
240
+ const sessionLine = `export VOLUTE_SESSION=${JSON.stringify(session.name)}`;
241
+ const updated = existing.replace(/^export VOLUTE_SESSION=.*$/m, sessionLine);
242
+ if (updated === existing && !existing.includes("VOLUTE_SESSION")) {
243
+ writeFileSync(zshenvPath, `${existing.trimEnd()}\n${sessionLine}\n`);
244
+ } else if (updated !== existing) {
245
+ writeFileSync(zshenvPath, updated);
246
+ }
247
+ }
248
+ } catch (err) {
249
+ warn("mind", `session "${session.name}": failed to sync VOLUTE_SESSION to .zshenv:`, err);
250
+ }
251
+
252
+ try {
253
+ const { events } = await session.thread.runStreamed(text, {
254
+ signal: session.abortController.signal,
255
+ });
256
+
257
+ // Track text deltas per item for streaming
258
+ const itemText = new Map<string, string>();
259
+ // Track file paths for auto-commit and identity reload
260
+ const changedFiles: string[] = [];
261
+ let needsReload = false;
262
+
263
+ for await (const event of events) {
264
+ try {
265
+ switch (event.type) {
266
+ case "thread.started": {
267
+ // Save thread ID for session resume
268
+ const threadId = event.thread_id ?? event.threadId ?? event.thread?.id;
269
+ if (threadId && !session.name.startsWith("new-")) {
270
+ sessionStore.save(session.name, threadId);
271
+ log("mind", `session "${session.name}": saved thread ${threadId}`);
272
+ }
273
+ break;
274
+ }
275
+
276
+ case "item.started": {
277
+ const item = event.item;
278
+ if (!item) break;
279
+
280
+ if (item.type === "agent_message" || item.type === "agentMessage") {
281
+ itemText.set(event.itemId ?? item.id, "");
282
+ } else if (item.type === "reasoning") {
283
+ emit(session, { type: "thinking", content: item.content ?? "" });
284
+ } else if (item.type === "command_execution" || item.type === "commandExecution") {
285
+ const cmd = item.command ?? item.args?.join(" ") ?? "";
286
+ emit(session, {
287
+ type: "tool_use",
288
+ content: JSON.stringify({ command: cmd }),
289
+ metadata: { name: "command" },
290
+ });
291
+ broadcast(session, {
292
+ type: "tool_use",
293
+ name: "command",
294
+ input: { command: cmd },
295
+ });
296
+ } else if (item.type === "file_change" || item.type === "fileChange") {
297
+ const filePath = item.path ?? item.filePath ?? "";
298
+ emit(session, {
299
+ type: "tool_use",
300
+ content: JSON.stringify({ path: filePath }),
301
+ metadata: { name: "file_change" },
302
+ });
303
+ broadcast(session, {
304
+ type: "tool_use",
305
+ name: "file_change",
306
+ input: { path: filePath },
307
+ });
308
+ } else if (item.type === "mcp_tool_call" || item.type === "mcpToolCall") {
309
+ const toolName = `mcp:${item.serverName ?? ""}/${item.toolName ?? item.name ?? ""}`;
310
+ emit(session, {
311
+ type: "tool_use",
312
+ content: JSON.stringify(item.input ?? item.arguments ?? {}),
313
+ metadata: { name: toolName },
314
+ });
315
+ broadcast(session, {
316
+ type: "tool_use",
317
+ name: toolName,
318
+ input: item.input ?? item.arguments ?? {},
319
+ });
320
+ } else if (item.type === "web_search" || item.type === "webSearch") {
321
+ emit(session, {
322
+ type: "tool_use",
323
+ content: JSON.stringify({ query: item.query ?? "" }),
324
+ metadata: { name: "web_search" },
325
+ });
326
+ broadcast(session, {
327
+ type: "tool_use",
328
+ name: "web_search",
329
+ input: { query: item.query ?? "" },
330
+ });
331
+ }
332
+ break;
333
+ }
334
+
335
+ case "item.updated": {
336
+ const item = event.item;
337
+ if (!item) break;
338
+ const itemType = item.type;
339
+ if (itemType === "agent_message" || itemType === "agentMessage") {
340
+ const id = event.itemId ?? item.id;
341
+ const prev = itemText.get(id) ?? "";
342
+ const full = item.content ?? item.text ?? "";
343
+ if (full.length > prev.length) {
344
+ const delta = full.slice(prev.length);
345
+ itemText.set(id, full);
346
+ broadcast(session, { type: "text", content: delta });
347
+ emit(session, { type: "text", content: delta });
348
+ }
349
+ }
350
+ break;
351
+ }
352
+
353
+ case "item.completed": {
354
+ const item = event.item;
355
+ if (!item) break;
356
+ const itemType = item.type;
357
+
358
+ if (itemType === "agent_message" || itemType === "agentMessage") {
359
+ // Emit any remaining delta
360
+ const id = event.itemId ?? item.id;
361
+ const prev = itemText.get(id) ?? "";
362
+ const full = item.content ?? item.text ?? "";
363
+ if (full.length > prev.length) {
364
+ const delta = full.slice(prev.length);
365
+ broadcast(session, { type: "text", content: delta });
366
+ emit(session, { type: "text", content: delta });
367
+ }
368
+ itemText.delete(id);
369
+ } else if (itemType === "command_execution" || itemType === "commandExecution") {
370
+ const rawOutput = item.aggregated_output ?? item.output;
371
+ const output =
372
+ typeof rawOutput === "string" ? rawOutput : JSON.stringify(rawOutput ?? "");
373
+ const exitCode = item.exit_code ?? item.exitCode;
374
+ emit(session, {
375
+ type: "tool_result",
376
+ content: output,
377
+ metadata: { name: "command", is_error: exitCode !== 0 },
378
+ });
379
+ broadcast(session, {
380
+ type: "tool_result",
381
+ output,
382
+ is_error: exitCode !== 0,
383
+ });
384
+ } else if (itemType === "file_change" || itemType === "fileChange") {
385
+ const filePath = item.path ?? item.filePath ?? "";
386
+ if (filePath) {
387
+ changedFiles.push(filePath);
388
+ trackFileChange(filePath, options.cwd);
389
+ // Check for identity files
390
+ if (/\b(SOUL|MEMORY|VOLUTE)\.md$/.test(filePath)) {
391
+ needsReload = true;
392
+ }
393
+ }
394
+ emit(session, {
395
+ type: "tool_result",
396
+ content: item.diff ?? `changed: ${filePath}`,
397
+ metadata: { name: "file_change" },
398
+ });
399
+ broadcast(session, {
400
+ type: "tool_result",
401
+ output: item.diff ?? `changed: ${filePath}`,
402
+ });
403
+ } else if (itemType === "mcp_tool_call" || itemType === "mcpToolCall") {
404
+ const output =
405
+ typeof item.output === "string" ? item.output : JSON.stringify(item.output ?? "");
406
+ emit(session, {
407
+ type: "tool_result",
408
+ content: output,
409
+ metadata: {
410
+ name: `mcp:${item.serverName ?? ""}/${item.toolName ?? item.name ?? ""}`,
411
+ },
412
+ });
413
+ broadcast(session, { type: "tool_result", output });
414
+ } else if (itemType === "web_search" || itemType === "webSearch") {
415
+ emit(session, {
416
+ type: "tool_result",
417
+ content: "search completed",
418
+ metadata: { name: "web_search" },
419
+ });
420
+ broadcast(session, { type: "tool_result", output: "search completed" });
421
+ }
422
+ break;
423
+ }
424
+
425
+ case "turn.completed": {
426
+ const usage = event.usage;
427
+ if (usage) {
428
+ const inputTokens = usage.input_tokens ?? usage.inputTokens ?? 0;
429
+ const outputTokens = usage.output_tokens ?? usage.outputTokens ?? 0;
430
+ session.cumulativeInputTokens = inputTokens;
431
+ broadcast(session, {
432
+ type: "usage",
433
+ input_tokens: inputTokens,
434
+ output_tokens: outputTokens,
435
+ });
436
+ emit(session, {
437
+ type: "usage",
438
+ metadata: { input_tokens: inputTokens, output_tokens: outputTokens },
439
+ });
440
+ }
441
+ break;
442
+ }
443
+ }
444
+ } catch (err) {
445
+ warn("mind", `session "${session.name}": event handler error (${event?.type}):`, err);
446
+ }
447
+ }
448
+
449
+ // Turn complete — flush file changes
450
+ await flushFileChanges(options.cwd);
451
+
452
+ // Identity reload
453
+ if (needsReload) {
454
+ log("mind", `session "${session.name}": identity file changed, requesting restart`);
455
+ daemonRestart({ type: "reload" }).catch((err) => log("mind", "daemonRestart failed:", err));
456
+ }
457
+
458
+ // Compaction warning — actual compaction is handled by the Codex SDK via model_auto_compact_token_limit
459
+ if (maxContextTokens && session.cumulativeInputTokens >= maxContextTokens) {
460
+ log(
461
+ "mind",
462
+ `session "${session.name}": ${session.cumulativeInputTokens} tokens >= ${maxContextTokens} — SDK auto-compaction will handle`,
463
+ );
464
+ }
465
+
466
+ log("mind", `session "${session.name}": turn done`);
467
+ } catch (err: any) {
468
+ if (err?.name === "AbortError") {
469
+ log("mind", `session "${session.name}": turn aborted`);
470
+ } else {
471
+ warn("mind", `session "${session.name}": turn failed:`, err);
472
+ }
473
+ }
474
+
475
+ broadcast(session, { type: "done" });
476
+ emit(session, { type: "done" });
477
+
478
+ if (session.currentMessageId) {
479
+ session.messageChannels.delete(session.currentMessageId);
480
+ }
481
+ session.currentMessageId = undefined;
482
+ }
483
+
484
+ // --- Message queue processing ---
485
+
486
+ async function processQueue(session: CodexSession) {
487
+ if (session.processing) return;
488
+ session.processing = true;
489
+
490
+ while (session.messageQueue.length > 0) {
491
+ const next = session.messageQueue.shift()!;
492
+ session.currentMessageId = next.meta.messageId;
493
+ await runTurn(session, next.text, next.meta);
494
+ }
495
+
496
+ session.processing = false;
497
+ }
498
+
499
+ // --- MessageHandler implementation ---
500
+
501
+ function createSessionHandler(sessionName: string): MessageHandler {
502
+ return {
503
+ handle(content: VoluteContentPart[], meta: HandlerMeta, listener: Listener): () => void {
504
+ const session = getOrCreateSession(sessionName);
505
+
506
+ const filteredListener: Listener = (event) => {
507
+ if (event.messageId === meta.messageId) listener(event);
508
+ };
509
+ session.listeners.add(filteredListener);
510
+
511
+ // Track channel for reply instructions
512
+ if (meta.channel) {
513
+ session.messageChannels.set(meta.messageId, meta.channel);
514
+ }
515
+
516
+ const text = extractText(content);
517
+
518
+ if (meta.interrupt && session.processing) {
519
+ // Abort current turn and push interrupting message to front
520
+ session.abortController?.abort();
521
+ session.messageQueue.unshift({ text, meta });
522
+ } else {
523
+ session.messageQueue.push({ text, meta });
524
+ }
525
+
526
+ processQueue(session).catch((err) => {
527
+ warn("mind", `session "${sessionName}": queue processing failed:`, err);
528
+ broadcast(session, { type: "done" });
529
+ });
530
+
531
+ return () => session.listeners.delete(filteredListener);
532
+ },
533
+ };
534
+ }
535
+
536
+ // --- HandlerResolver ---
537
+
538
+ const handlers = new Map<string, MessageHandler>();
539
+
540
+ function resolve(sessionName: string): MessageHandler {
541
+ if (sessionName.startsWith("new-")) {
542
+ return createSessionHandler(sessionName);
543
+ }
544
+ let handler = handlers.get(sessionName);
545
+ if (!handler) {
546
+ handler = createSessionHandler(sessionName);
547
+ handlers.set(sessionName, handler);
548
+ }
549
+ return handler;
550
+ }
551
+
552
+ return { resolve };
553
+ }
@@ -0,0 +1,16 @@
1
+ import { warn } from "./logger.js";
2
+
3
+ export function extractText(content: unknown): string {
4
+ if (typeof content === "string") return content;
5
+ if (!Array.isArray(content)) {
6
+ warn(
7
+ "mind",
8
+ `extractText received unexpected ${typeof content} instead of VoluteContentPart[]`,
9
+ );
10
+ return JSON.stringify(content);
11
+ }
12
+ return content
13
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
14
+ .map((p) => p.text)
15
+ .join("\n");
16
+ }
@@ -0,0 +1,56 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ unlinkSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { resolve as resolvePath } from "node:path";
10
+ import { log, warn } from "./logger.js";
11
+
12
+ export type SessionStore = {
13
+ load(name: string): string | undefined;
14
+ save(name: string, threadId: string): void;
15
+ delete(name: string): void;
16
+ };
17
+
18
+ export function createSessionStore(sessionsDir: string): SessionStore {
19
+ function filePath(name: string): string {
20
+ return resolvePath(sessionsDir, `${name}.json`);
21
+ }
22
+
23
+ return {
24
+ load(name: string): string | undefined {
25
+ const path = filePath(name);
26
+ try {
27
+ const data = JSON.parse(readFileSync(path, "utf-8"));
28
+ return typeof data.threadId === "string" ? data.threadId : undefined;
29
+ } catch (err: any) {
30
+ if (err?.code === "ENOENT") return undefined;
31
+ // Corrupt or unreadable file — rename it so a fresh session can be saved
32
+ warn("mind", `corrupt session file for "${name}", renaming to .corrupt:`, err);
33
+ try {
34
+ renameSync(path, `${path}.corrupt`);
35
+ } catch {
36
+ // Best effort — ignore rename failures
37
+ }
38
+ return undefined;
39
+ }
40
+ },
41
+
42
+ save(name: string, threadId: string) {
43
+ mkdirSync(sessionsDir, { recursive: true });
44
+ writeFileSync(filePath(name), JSON.stringify({ threadId }));
45
+ },
46
+
47
+ delete(name: string) {
48
+ try {
49
+ const path = filePath(name);
50
+ if (existsSync(path)) unlinkSync(path);
51
+ } catch (err) {
52
+ log("mind", `failed to delete session file for "${name}":`, err);
53
+ }
54
+ },
55
+ };
56
+ }