volute 0.30.1 → 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 (227) hide show
  1. package/README.md +15 -22
  2. package/dist/{accept-E3PAH3QJ.js → accept-74M7I4RZ.js} +5 -4
  3. package/dist/{activity-events-BKBPPUBP.js → activity-events-HETAODOK.js} +3 -2
  4. package/dist/{ai-service-VAJT5UBS.js → ai-service-ZIPCV3MX.js} +20 -5
  5. package/dist/api.d.ts +341 -397
  6. package/dist/{archive-WWDBWYN2.js → archive-INXYFVCW.js} +3 -2
  7. package/dist/auth-6DMGES3I.js +44 -0
  8. package/dist/{bridge-RO37CUFM.js → bridge-BVCBTGPF.js} +5 -4
  9. package/dist/{chat-TCUNPFGO.js → chat-XT4OBJBU.js} +8 -8
  10. package/dist/{chunk-P7VFDSSG.js → chunk-2FLJ63GU.js} +2 -2
  11. package/dist/{chunk-ZWKTUQEL.js → chunk-2NGTS5UU.js} +1 -1
  12. package/dist/{chunk-JGFRDMR6.js → chunk-ALEF47VT.js} +1 -1
  13. package/dist/{chunk-MDPCSXZ4.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-DTC6EH5I.js → chunk-I5KY25PQ.js} +1 -9
  17. package/dist/{chunk-NSBFETWP.js → chunk-IYDIE3HG.js} +64 -26
  18. package/dist/{chunk-W5OOPLNP.js → chunk-JJ7W6WSB.js} +3 -3
  19. package/dist/{chunk-G3GBKZGG.js → chunk-LGB6JBHI.js} +54 -2
  20. package/dist/chunk-LRCG2JLP.js +251 -0
  21. package/dist/{chunk-FXHXHI2A.js → chunk-LSGWR54X.js} +3 -6
  22. package/dist/{chunk-S5LR3XYJ.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-QVAQ5454.js → chunk-QBQ424EM.js} +3007 -2126
  26. package/dist/{chunk-P27RV5WM.js → chunk-QZANELPX.js} +6 -2
  27. package/dist/{chunk-FSM45XD5.js → chunk-R7E6CRVQ.js} +1 -1
  28. package/dist/{chunk-HHTXM4JT.js → chunk-RPZZSXV3.js} +39 -195
  29. package/dist/{chunk-UPA6COHU.js → chunk-RSX4OPZY.js} +5 -5
  30. package/dist/{chunk-2C2VXEBB.js → chunk-S6NFERDC.js} +21 -57
  31. package/dist/chunk-SKLSMHXO.js +208 -0
  32. package/dist/{chunk-IKHDUZRH.js → chunk-SX5TKJBZ.js} +2 -2
  33. package/dist/chunk-TDRYEPH4.js +185 -0
  34. package/dist/chunk-TSXLLQZW.js +46 -0
  35. package/dist/{chunk-EFVHR7KH.js → chunk-UKVWJRKN.js} +24 -5
  36. package/dist/{chunk-2NDZC3S7.js → chunk-WKF5FEFK.js} +688 -389
  37. package/dist/cli.js +93 -24
  38. package/dist/{clock-G3ALCMLJ.js → clock-2UOZ6JPU.js} +11 -8
  39. package/dist/{cloud-sync-JV4LJOK3.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-7KVQV7EZ.js → conversations-3O5O6AS3.js} +8 -7
  45. package/dist/{create-JTLS7GX3.js → create-RNLNCORE.js} +5 -4
  46. package/dist/{create-VQSQHJQW.js → create-WBBYI6V7.js} +6 -2
  47. package/dist/daemon-client-6QXHZ7US.js +12 -0
  48. package/dist/{daemon-restart-4JGBHEJ4.js → daemon-restart-NGFHFAUF.js} +7 -7
  49. package/dist/daemon.js +2446 -1999
  50. package/dist/{db-HMFPIRO2.js → db-F34YLV7D.js} +2 -1
  51. package/dist/db-RA45JBFG.js +16 -0
  52. package/dist/{delete-JESHKE7F.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-CLXXT7M2.js → env-RLYQBOOP.js} +5 -4
  57. package/dist/{export-EGA5M5PB.js → export-SUYRLI5Q.js} +4 -3
  58. package/dist/{extension-WZ4SUPJB.js → extension-FQ5D3NCC.js} +6 -6
  59. package/dist/{extensions-ECO4RPFQ.js → extensions-GDYWQXC4.js} +9 -7
  60. package/dist/{files-4VEJDASH.js → files-EAMPO2SJ.js} +6 -5
  61. package/dist/{history-EJMMLXDO.js → history-FO5PHBQ5.js} +9 -4
  62. package/dist/{import-YCGPMBSI.js → import-DDUFE7AY.js} +4 -3
  63. package/dist/{join-2GBJKZEN.js → join-I5QEE3LG.js} +1 -1
  64. package/dist/{list-Q6O7FGAN.js → list-DW2VRTOZ.js} +5 -4
  65. package/dist/{login-RL6AU2SM.js → login-7CHPW2PN.js} +5 -4
  66. package/dist/{login-RET5WESK.js → login-RIJF2F4G.js} +3 -2
  67. package/dist/{logout-CGAGJN3L.js → logout-5MLHZALK.js} +3 -2
  68. package/dist/{logout-JRPBEMMR.js → logout-UZJRGY4Z.js} +3 -2
  69. package/dist/message-delivery-2FIM7QKO.js +32 -0
  70. package/dist/{mind-LUWRQUQ5.js → mind-2B6M7Y25.js} +18 -18
  71. package/dist/{mind-activity-tracker-VYN2ZZ2M.js → mind-activity-tracker-NZZT2NTT.js} +4 -3
  72. package/dist/{mind-list-V5WW5DUA.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-R6PTNNW4.js → mind-sleep-B7BHJLH7.js} +5 -4
  76. package/dist/{mind-status-I4ISFJ6I.js → mind-status-L3EFFRPR.js} +3 -2
  77. package/dist/{mind-wake-67ZQEWAV.js → mind-wake-GY3RFX7Y.js} +5 -4
  78. package/dist/{package-OYUD4ZJ4.js → package-PK6JUFL3.js} +3 -3
  79. package/dist/read-5AMJRO3D.js +75 -0
  80. package/dist/{register-NZDSTLP3.js → register-V2JZZKFK.js} +5 -4
  81. package/dist/{registry-ODSALQQL.js → registry-PJ4S5PHQ.js} +8 -1
  82. package/dist/{reject-2HZOJEIJ.js → reject-33HEZMZ4.js} +5 -4
  83. package/dist/{restart-QHS3NT64.js → restart-3UCMRUVC.js} +5 -4
  84. package/dist/{sandbox-O5FUSF43.js → sandbox-JANNTX6U.js} +4 -3
  85. package/dist/schema-PA3M5ZKH.js +32 -0
  86. package/dist/seed-ALUQ55FF.js +112 -0
  87. package/dist/{send-OAN3RYYY.js → send-3MI36LEF.js} +58 -69
  88. package/dist/{setup-QMDK5RZX.js → setup-SZIARWI6.js} +5 -4
  89. package/dist/{setup-XJH3E7YM.js → setup-WENLVPVP.js} +9 -9
  90. package/dist/{skill-FZIN4W4Q.js → skill-TUVOTW4Z.js} +5 -4
  91. package/dist/skills/dreaming/SKILL.md +6 -4
  92. package/dist/skills/dreaming/references/INSTALL.md +4 -5
  93. package/dist/skills/dreaming/scripts/dream.ts +5 -27
  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 +12 -12
  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-EXYGGGQN.js → split-STOROBYJ.js} +1 -1
  107. package/dist/{sprout-AXQ6H5DB.js → sprout-L2GFOVF7.js} +9 -8
  108. package/dist/{start-MTOVL6SY.js → start-K2NCUUCG.js} +5 -4
  109. package/dist/{status-ZRO37MWR.js → status-TCUMUO6M.js} +5 -5
  110. package/dist/{stop-OK5WEPVC.js → stop-H26JZDXF.js} +5 -4
  111. package/dist/system-chat-NPYFYZVI.js +32 -0
  112. package/dist/{systems-W3BBMSOZ.js → systems-DHBKVYEY.js} +6 -5
  113. package/dist/{tailscale-BM72RXCJ.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-PLPHMMZ2.js → update-QVPRF6GR.js} +5 -5
  117. package/dist/{update-check-CVCN7MF6.js → update-check-ZD6OOIYQ.js} +3 -2
  118. package/dist/{upgrade-I6NPCYUU.js → upgrade-O4Q7WJM3.js} +12 -14
  119. package/dist/{version-notify-2NTWVEHL.js → version-notify-TCKWBZZG.js} +22 -23
  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 +94 -0
  123. package/dist/web-assets/index.html +2 -2
  124. package/drizzle/0000_baseline.sql +152 -0
  125. package/drizzle/0001_add_conversation_private.sql +1 -0
  126. package/drizzle/0002_turns.sql +21 -0
  127. package/drizzle/0003_turn_feed_links.sql +11 -0
  128. package/drizzle/0004_spirits.sql +5 -0
  129. package/drizzle/meta/0000_snapshot.json +3 -223
  130. package/drizzle/meta/0001_snapshot.json +3 -294
  131. package/drizzle/meta/0002_snapshot.json +3 -335
  132. package/drizzle/meta/0003_snapshot.json +3 -413
  133. package/drizzle/meta/0004_snapshot.json +3 -406
  134. package/drizzle/meta/_journal.json +10 -101
  135. package/package.json +3 -3
  136. package/packages/extensions/notes/dist/ui/assets/index-8jWEv9SA.js +61 -0
  137. package/packages/extensions/notes/dist/ui/assets/index-DkaB7Ytd.css +1 -0
  138. package/packages/extensions/notes/dist/ui/index.html +2 -2
  139. package/packages/extensions/notes/skills/notes/SKILL.md +8 -8
  140. package/packages/extensions/pages/skills/pages/SKILL.md +17 -44
  141. package/templates/_base/.init/.config/hooks/pre-prompt/session-activity.ts +40 -0
  142. package/templates/_base/.init/.local/bin/volute +27 -0
  143. package/templates/_base/.init/.local/hooks/pre-prompt/session-activity.ts +40 -0
  144. package/templates/_base/.init/.local/hooks/startup-context.ts +58 -0
  145. package/templates/_base/home/.config/routes.json +1 -1
  146. package/templates/_base/src/lib/auto-commit.ts +82 -43
  147. package/templates/_base/src/lib/daemon-client.ts +40 -36
  148. package/templates/_base/src/lib/format-prefix.ts +1 -0
  149. package/templates/_base/src/lib/hook-loader.ts +155 -0
  150. package/templates/_base/src/lib/router.ts +17 -1
  151. package/templates/_base/src/lib/startup.ts +17 -12
  152. package/templates/_base/src/lib/transparency.ts +2 -2
  153. package/templates/_base/src/lib/volute-server.ts +2 -5
  154. package/templates/claude/.init/.claude/settings.json +1 -1
  155. package/templates/claude/.init/.config/routes.json +2 -2
  156. package/templates/claude/src/agent.ts +97 -14
  157. package/templates/claude/src/lib/hooks/auto-commit.ts +7 -3
  158. package/templates/claude/src/lib/message-channel.ts +7 -2
  159. package/templates/claude/src/server.ts +0 -9
  160. package/templates/codex/.init/.config/routes.json +11 -0
  161. package/templates/codex/.init/AGENTS.md +29 -0
  162. package/templates/codex/home/.config/config.json.tmpl +7 -0
  163. package/templates/codex/package.json.tmpl +20 -0
  164. package/templates/codex/src/agent.ts +553 -0
  165. package/templates/codex/src/lib/content.ts +16 -0
  166. package/templates/codex/src/lib/session-store.ts +56 -0
  167. package/templates/codex/src/server.ts +59 -0
  168. package/templates/codex/volute-template.json +8 -0
  169. package/templates/pi/.init/.config/routes.json +2 -2
  170. package/templates/pi/package.json.tmpl +1 -1
  171. package/templates/pi/src/agent.ts +63 -9
  172. package/templates/pi/src/lib/event-handler.ts +6 -4
  173. package/templates/pi/src/lib/reply-instructions-extension.ts +32 -11
  174. package/dist/chunk-7D47T4RB.js +0 -84
  175. package/dist/chunk-CVH6Y2YG.js +0 -59
  176. package/dist/chunk-EFP3PE6C.js +0 -232
  177. package/dist/chunk-LIRWLNAK.js +0 -729
  178. package/dist/daemon-client-BCTFGVCZ.js +0 -9
  179. package/dist/down-NGBMGORS.js +0 -14
  180. package/dist/message-delivery-6YMVNOEC.js +0 -28
  181. package/dist/migrate-registry-to-db-FK35IPEH.js +0 -110
  182. package/dist/mind-manager-YFCOIAAX.js +0 -18
  183. package/dist/pages-watcher-Z3PKNROC.js +0 -21
  184. package/dist/read-WQMPTSN2.js +0 -46
  185. package/dist/seed-WUQMPLDM.js +0 -71
  186. package/dist/skills/sessions/SKILL.md +0 -49
  187. package/dist/sleep-manager-O7YQFCV5.js +0 -30
  188. package/dist/up-BXUAIDXB.js +0 -17
  189. package/dist/web-assets/assets/index--kREqKl9.js +0 -72
  190. package/dist/web-assets/assets/index-BXYTG0nJ.css +0 -1
  191. package/drizzle/0000_flaky_mariko_yashida.sql +0 -34
  192. package/drizzle/0001_careless_warpath.sql +0 -12
  193. package/drizzle/0002_wealthy_the_call.sql +0 -6
  194. package/drizzle/0003_clean_ego.sql +0 -12
  195. package/drizzle/0004_magical_silverclaw.sql +0 -1
  196. package/drizzle/0005_rename_agents_to_minds.sql +0 -11
  197. package/drizzle/0006_mind_history.sql +0 -20
  198. package/drizzle/0007_system_prompts.sql +0 -5
  199. package/drizzle/0008_volute_channels.sql +0 -24
  200. package/drizzle/0009_shared_skills.sql +0 -9
  201. package/drizzle/0010_delivery_queue.sql +0 -12
  202. package/drizzle/0011_rename_human_to_brain.sql +0 -1
  203. package/drizzle/0012_activity.sql +0 -11
  204. package/drizzle/0013_user_profiles.sql +0 -3
  205. package/drizzle/0014_conversation_reads.sql +0 -7
  206. package/drizzle/0015_notes.sql +0 -23
  207. package/drizzle/0016_note_reactions_and_replies.sql +0 -15
  208. package/drizzle/0017_minds.sql +0 -16
  209. package/drizzle/meta/0005_snapshot.json +0 -410
  210. package/drizzle/meta/0006_snapshot.json +0 -7
  211. package/drizzle/meta/0007_snapshot.json +0 -7
  212. package/drizzle/meta/0008_snapshot.json +0 -7
  213. package/drizzle/meta/0009_snapshot.json +0 -7
  214. package/drizzle/meta/0010_snapshot.json +0 -7
  215. package/drizzle/meta/0011_snapshot.json +0 -7
  216. package/drizzle/meta/0012_snapshot.json +0 -7
  217. package/drizzle/meta/0013_snapshot.json +0 -7
  218. package/packages/extensions/notes/dist/ui/assets/index-DgawVO5g.css +0 -1
  219. package/packages/extensions/notes/dist/ui/assets/index-qUWoeC4c.js +0 -2
  220. package/packages/extensions/notes/skills/notes/scripts/notes.mjs +0 -185
  221. package/templates/_base/.init/.config/hooks/startup-context.sh +0 -46
  222. package/templates/_base/.init/.config/scripts/session-reader.ts +0 -59
  223. package/templates/_base/home/public/.gitkeep +0 -0
  224. package/templates/_base/src/lib/session-monitor.ts +0 -400
  225. package/templates/claude/src/lib/hooks/session-context.ts +0 -32
  226. package/templates/pi/src/lib/session-context-extension.ts +0 -35
  227. /package/templates/_base/.init/{.config → .local}/hooks/wake-context.sh +0 -0
@@ -0,0 +1,40 @@
1
+ // Cross-session activity — shows what happened in other sessions since last check.
2
+ // Uses the daemon history API. Customize or remove this hook as you like.
3
+
4
+ const input = await new Promise<string>((resolve) => {
5
+ let data = "";
6
+ process.stdin.on("data", (chunk) => {
7
+ data += chunk;
8
+ });
9
+ process.stdin.on("end", () => resolve(data));
10
+ });
11
+
12
+ const { VOLUTE_DAEMON_PORT, VOLUTE_DAEMON_TOKEN, VOLUTE_MIND } = process.env;
13
+ if (!VOLUTE_DAEMON_PORT || !VOLUTE_DAEMON_TOKEN || !VOLUTE_MIND) {
14
+ console.log("{}");
15
+ process.exit(0);
16
+ }
17
+
18
+ let session = "";
19
+ try {
20
+ session = JSON.parse(input).session ?? "";
21
+ } catch {}
22
+
23
+ try {
24
+ const res = await fetch(
25
+ `http://127.0.0.1:${VOLUTE_DAEMON_PORT}/api/minds/${VOLUTE_MIND}/history/cross-session?session=${encodeURIComponent(session)}`,
26
+ { headers: { Authorization: `Bearer ${VOLUTE_DAEMON_TOKEN}` } },
27
+ );
28
+ if (!res.ok) {
29
+ console.log("{}");
30
+ process.exit(0);
31
+ }
32
+ const { context } = (await res.json()) as { context: string | null };
33
+ if (context) {
34
+ console.log(JSON.stringify({ additionalContext: context }));
35
+ } else {
36
+ console.log("{}");
37
+ }
38
+ } catch {
39
+ console.log("{}");
40
+ }
@@ -0,0 +1,58 @@
1
+ // Startup context hook — generates orientation context for new sessions.
2
+ // Edit this script to customize what you see when your session starts.
3
+ // Input: JSON on stdin with { "source": "startup" | "SessionStart" }
4
+ // Output: JSON with hookSpecificOutput.additionalContext (for SessionStart hook)
5
+ // or plain text (for direct execution by pi template)
6
+
7
+ import { readdirSync } from "node:fs";
8
+
9
+ const input = await new Promise<string>((resolve) => {
10
+ let data = "";
11
+ process.stdin.on("data", (chunk: Buffer) => {
12
+ data += chunk;
13
+ });
14
+ process.stdin.on("end", () => resolve(data));
15
+ });
16
+
17
+ let source = "startup";
18
+ try {
19
+ source = JSON.parse(input).source ?? "startup";
20
+ } catch {}
21
+
22
+ const parts: string[] = [`Session ${source} at ${new Date().toLocaleString()}.`];
23
+
24
+ // Active sessions
25
+ try {
26
+ const files = readdirSync(".mind/sessions").filter((f) => f.endsWith(".json"));
27
+ if (files.length > 0) {
28
+ const names = files.map((f) => f.replace(/\.json$/, "")).sort();
29
+ parts.push(`Active sessions: ${names.join(", ")}.`);
30
+ }
31
+ } catch {}
32
+
33
+ // Last journal entry
34
+ try {
35
+ const entries = readdirSync("home/memory/journal").filter((f) => f.endsWith(".md"));
36
+ if (entries.length > 0) {
37
+ const latest = entries.sort().pop()!.replace(/\.md$/, "");
38
+ parts.push(`Last journal entry: ${latest}.`);
39
+ }
40
+ } catch {}
41
+
42
+ // Pending channel invites
43
+ try {
44
+ const invites = readdirSync("home/inbox").filter((f) => f.endsWith(".md"));
45
+ if (invites.length > 0) {
46
+ parts.push(`Pending channel invites: ${invites.length} (check inbox/).`);
47
+ }
48
+ } catch {}
49
+
50
+ const context = parts.join(" ");
51
+ console.log(
52
+ JSON.stringify({
53
+ hookSpecificOutput: {
54
+ hookEventName: "SessionStart",
55
+ additionalContext: context,
56
+ },
57
+ }),
58
+ );
@@ -1,5 +1,5 @@
1
1
  {
2
- "rules": [{ "channel": "volute:*", "isDM": false, "session": "group-${channel}" }],
2
+ "rules": [{ "channel": "*", "isDM": false, "session": "group-${channel}" }],
3
3
  "sessions": {
4
4
  "group-*": { "batch": { "debounce": 20, "maxWait": 120, "triggers": ["@{{name}}"] } }
5
5
  }
@@ -17,15 +17,19 @@ function exec(cmd: string, args: string[], cwd: string): Promise<{ code: number;
17
17
  // Serialize git operations to prevent concurrent commits from conflicting
18
18
  let pending = Promise.resolve();
19
19
 
20
+ // Pending file changes accumulated across all sessions, flushed on turn end
21
+ const pendingFiles = new Set<string>();
22
+ const pendingSharedFiles = new Set<string>();
23
+
20
24
  /**
21
- * Commit a file change in the mind's home directory.
25
+ * Track a file change in the mind's home directory for batched commit.
22
26
  * Called by the PostToolUse hook when Edit or Write completes.
23
27
  *
24
- * Files under home/shared/ are committed to the shared worktree repo
25
- * with mind attribution. All other files go to the mind's own repo.
28
+ * Files under home/shared/ are tracked separately for the shared worktree repo.
29
+ * All other files go to the mind's own repo.
26
30
  */
27
- export function commitFileChange(filePath: string, cwd: string): void {
28
- // Only commit files under the home directory
31
+ export function trackFileChange(filePath: string, cwd: string): void {
32
+ // Only track files under the home directory
29
33
  const homeDir = resolve(cwd);
30
34
  const resolved = resolve(cwd, filePath);
31
35
  if (!resolved.startsWith(`${homeDir}/`) && resolved !== homeDir) return;
@@ -33,56 +37,91 @@ export function commitFileChange(filePath: string, cwd: string): void {
33
37
  const relativePath = resolved.slice(homeDir.length + 1);
34
38
  if (!relativePath) return;
35
39
 
36
- // Check if this file is under the shared/ worktree
37
40
  const sharedPrefix = "shared/";
38
- const isShared = relativePath.startsWith(sharedPrefix);
41
+ if (relativePath.startsWith(sharedPrefix)) {
42
+ pendingSharedFiles.add(relativePath);
43
+ } else {
44
+ pendingFiles.add(relativePath);
45
+ }
46
+ }
39
47
 
40
- pending = pending.then(async () => {
41
- if (isShared) {
42
- // Route to shared worktree
43
- const sharedCwd = resolve(cwd, "shared");
44
- const sharedRelative = relativePath.slice(sharedPrefix.length);
45
- const mindName = process.env.VOLUTE_MIND ?? "unknown";
48
+ /**
49
+ * Flush all pending file changes into batched commits.
50
+ * Called at the end of each turn. Produces up to two commits:
51
+ * one for the mind's own repo and one for the shared worktree.
52
+ */
53
+ export function flushFileChanges(cwd?: string): Promise<void> {
54
+ const filesToCommit = [...pendingFiles];
55
+ const sharedToCommit = [...pendingSharedFiles];
56
+ pendingFiles.clear();
57
+ pendingSharedFiles.clear();
46
58
 
47
- if ((await exec("git", gitArgs(["add", sharedRelative]), sharedCwd)).code !== 0) {
48
- log("auto-commit", `git add failed for shared/${sharedRelative}`);
49
- return;
50
- }
51
- if ((await exec("git", gitArgs(["diff", "--cached", "--quiet"]), sharedCwd)).code === 0)
52
- return;
59
+ if (filesToCommit.length === 0 && sharedToCommit.length === 0) {
60
+ return pending.then(() => {});
61
+ }
62
+
63
+ const effectiveCwd = cwd ?? process.cwd();
53
64
 
54
- const message = `Update ${sharedRelative}`;
55
- const authorFlag = `${mindName} <${mindName}@volute>`;
56
- if (
57
- (await exec("git", gitArgs(["commit", "--author", authorFlag, "-m", message]), sharedCwd))
58
- .code === 0
59
- ) {
60
- log("auto-commit", `[shared] ${message}`);
61
- } else {
62
- log("auto-commit", `[shared] commit failed for ${sharedRelative}`);
65
+ pending = pending.then(async () => {
66
+ // Commit mind's own files
67
+ if (filesToCommit.length > 0) {
68
+ for (const f of filesToCommit) {
69
+ if ((await exec("git", ["add", f], effectiveCwd)).code !== 0) {
70
+ log("auto-commit", `git add failed for ${f}`);
71
+ }
63
72
  }
64
- // No auto-push for shared files sharing is deliberate
65
- } else {
66
- // Existing behavior: commit to mind's own repo
67
- if ((await exec("git", ["add", relativePath], cwd)).code !== 0) {
68
- log("auto-commit", `git add failed for ${relativePath}`);
69
- return;
73
+ if ((await exec("git", ["diff", "--cached", "--quiet"], effectiveCwd)).code !== 0) {
74
+ const names = filesToCommit.map((f) => f.replace(/^.*\//, "")).join(", ");
75
+ const message = `Update ${names}`;
76
+ if ((await exec("git", ["commit", "-m", message], effectiveCwd)).code === 0) {
77
+ log("auto-commit", message);
78
+ // Push if a remote is configured
79
+ const { stdout: remote } = await exec("git", ["remote"], effectiveCwd);
80
+ if (remote) {
81
+ const pushResult = await exec("git", ["push"], effectiveCwd);
82
+ if (pushResult.code !== 0) {
83
+ log("auto-commit", `git push failed`);
84
+ }
85
+ }
86
+ } else {
87
+ log("auto-commit", `commit failed for: ${names}`);
88
+ }
70
89
  }
71
- if ((await exec("git", ["diff", "--cached", "--quiet"], cwd)).code === 0) return;
90
+ }
72
91
 
73
- const message = `Update ${relativePath}`;
74
- if ((await exec("git", ["commit", "-m", message], cwd)).code === 0) {
75
- log("auto-commit", message);
76
- // Push if a remote is configured
77
- const { stdout: remote } = await exec("git", ["remote"], cwd);
78
- if (remote) {
79
- await exec("git", ["push"], cwd);
92
+ // Commit shared worktree files
93
+ if (sharedToCommit.length > 0) {
94
+ const sharedCwd = resolve(effectiveCwd, "shared");
95
+ const sharedPrefix = "shared/";
96
+ const mindName = process.env.VOLUTE_MIND ?? "unknown";
97
+
98
+ for (const f of sharedToCommit) {
99
+ const sharedRelative = f.slice(sharedPrefix.length);
100
+ if ((await exec("git", gitArgs(["add", sharedRelative]), sharedCwd)).code !== 0) {
101
+ log("auto-commit", `git add failed for shared/${sharedRelative}`);
102
+ }
103
+ }
104
+ if ((await exec("git", gitArgs(["diff", "--cached", "--quiet"]), sharedCwd)).code !== 0) {
105
+ const names = sharedToCommit
106
+ .map((f) => f.slice(sharedPrefix.length).replace(/^.*\//, ""))
107
+ .join(", ");
108
+ const message = `Update ${names}`;
109
+ const authorFlag = `${mindName} <${mindName}@volute>`;
110
+ if (
111
+ (await exec("git", gitArgs(["commit", "--author", authorFlag, "-m", message]), sharedCwd))
112
+ .code === 0
113
+ ) {
114
+ log("auto-commit", `[shared] ${message}`);
115
+ } else {
116
+ log("auto-commit", `[shared] commit failed`);
80
117
  }
81
118
  }
82
119
  }
83
120
  });
121
+
122
+ return pending.then(() => {});
84
123
  }
85
124
 
86
125
  export function waitForCommits(): Promise<void> {
87
- return pending.then(() => {});
126
+ return flushFileChanges();
88
127
  }
@@ -1,12 +1,31 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
1
4
  const port = process.env.VOLUTE_DAEMON_PORT;
2
5
  const mind = process.env.VOLUTE_MIND;
3
6
  const token = process.env.VOLUTE_DAEMON_TOKEN;
4
7
 
8
+ /** Read session from file (fallback for sandbox where env vars don't propagate). */
9
+ function readSessionFile(): string | undefined {
10
+ const mindDir = process.env.VOLUTE_MIND_DIR;
11
+ if (!mindDir) return undefined;
12
+ try {
13
+ const p = resolve(mindDir, ".mind", "current-session");
14
+ if (existsSync(p)) return readFileSync(p, "utf-8").trim() || undefined;
15
+ } catch (err) {
16
+ console.warn(`[volute] failed to read session file: ${err}`);
17
+ }
18
+ return undefined;
19
+ }
20
+
5
21
  function headers(): Record<string, string> {
6
22
  const h: Record<string, string> = { "Content-Type": "application/json" };
7
23
  if (token) h.Authorization = `Bearer ${token}`;
8
24
  // Origin header required for CSRF checks on mutation requests
9
25
  if (port) h.Origin = `http://127.0.0.1:${port}`;
26
+ // Tag requests with the current session for turn resolution
27
+ const session = process.env.VOLUTE_SESSION ?? readSessionFile();
28
+ if (session) h["X-Volute-Session"] = session;
10
29
  return h;
11
30
  }
12
31
 
@@ -39,7 +58,8 @@ export type EventType =
39
58
  | "session_start"
40
59
  | "done"
41
60
  | "inbound"
42
- | "outbound";
61
+ | "outbound"
62
+ | "context";
43
63
 
44
64
  export type DaemonEvent = {
45
65
  type: EventType;
@@ -57,20 +77,27 @@ export async function daemonEmit(event: DaemonEvent): Promise<void> {
57
77
  }
58
78
  return;
59
79
  }
60
- try {
61
- const res = await fetch(
62
- `http://127.0.0.1:${port}/api/minds/${encodeURIComponent(mind)}/events`,
63
- {
64
- method: "POST",
65
- headers: headers(),
66
- body: JSON.stringify(event),
67
- },
68
- );
69
- if (!res.ok) {
80
+ const url = `http://127.0.0.1:${port}/api/minds/${encodeURIComponent(mind)}/events`;
81
+ const body = JSON.stringify(event);
82
+ // Critical events (done) get retries — if lost, turns stay stuck until daemon restart
83
+ const maxAttempts = event.type === "done" ? 3 : 1;
84
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
85
+ try {
86
+ const res = await fetch(url, { method: "POST", headers: headers(), body });
87
+ if (res.ok) return;
70
88
  console.error(`[volute] event emit failed: ${res.status}`);
89
+ // Don't retry client errors — they won't succeed on retry
90
+ if (res.status >= 400 && res.status < 500) return;
91
+ if (attempt < maxAttempts) {
92
+ await new Promise((r) => setTimeout(r, 500 * attempt));
93
+ }
94
+ } catch (err) {
95
+ if (attempt >= maxAttempts) {
96
+ console.error(`[volute] event emit failed after ${maxAttempts} attempts:`, err);
97
+ } else {
98
+ await new Promise((r) => setTimeout(r, 500 * attempt));
99
+ }
71
100
  }
72
- } catch {
73
- // Best-effort — don't let event emission failures break the mind
74
101
  }
75
102
  }
76
103
 
@@ -95,26 +122,3 @@ export async function daemonSendFile(
95
122
  }
96
123
  return (await res.json()) as { status: string; id?: string; destPath?: string };
97
124
  }
98
-
99
- export async function daemonSend(channel: string, text: string): Promise<void> {
100
- if (!port || !mind) {
101
- console.error("[volute] daemonSend: VOLUTE_DAEMON_PORT or VOLUTE_MIND not set");
102
- return;
103
- }
104
- const res = await fetch(
105
- `http://127.0.0.1:${port}/api/minds/${encodeURIComponent(mind)}/message`,
106
- {
107
- method: "POST",
108
- headers: headers(),
109
- body: JSON.stringify({
110
- content: text,
111
- channel,
112
- sender: mind,
113
- }),
114
- },
115
- );
116
- if (!res.ok) {
117
- const body = await res.text().catch(() => "");
118
- throw new Error(`daemonSend failed (${res.status}): ${body}`);
119
- }
120
- }
@@ -1,6 +1,7 @@
1
1
  import type { ChannelMeta, ParticipantProfile } from "./types.js";
2
2
 
3
3
  function derivePlatform(channel: string): string {
4
+ if (!channel.includes(":")) return "Volute";
4
5
  const name = channel.split(":")[0];
5
6
  return name.charAt(0).toUpperCase() + name.slice(1);
6
7
  }
@@ -0,0 +1,155 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, readdirSync } from "node:fs";
3
+ import { extname, join, resolve } from "node:path";
4
+ import { log } from "./logger.js";
5
+
6
+ export type HookResult = {
7
+ additionalContext?: string;
8
+ metadata?: Record<string, unknown>;
9
+ decision?: "block";
10
+ };
11
+
12
+ export type AggregatedResult = {
13
+ additionalContext?: string;
14
+ metadata: Record<string, unknown>;
15
+ blocked: boolean;
16
+ };
17
+
18
+ const DEFAULT_TIMEOUT = 5000;
19
+
20
+ /**
21
+ * Discover hook scripts in `.local/hooks/<event>/`, sorted alphabetically.
22
+ */
23
+ export function discoverHooks(hooksDir: string, event: string): string[] {
24
+ const dir = resolve(hooksDir, event);
25
+ if (!existsSync(dir)) return [];
26
+
27
+ try {
28
+ return readdirSync(dir)
29
+ .filter((f) => /\.(sh|ts|js)$/.test(f))
30
+ .sort()
31
+ .map((f) => join(dir, f));
32
+ } catch (err) {
33
+ log(
34
+ "hooks",
35
+ `failed to read hooks directory ${dir}: ${err instanceof Error ? err.message : err}`,
36
+ );
37
+ return [];
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Select the runner command for a hook script based on its extension.
43
+ */
44
+ function getRunner(scriptPath: string): { cmd: string; args: string[] } {
45
+ const ext = extname(scriptPath);
46
+ if (ext === ".ts") return { cmd: process.execPath, args: ["--import", "tsx", scriptPath] };
47
+ if (ext === ".js") return { cmd: "node", args: [scriptPath] };
48
+ return { cmd: "bash", args: [scriptPath] };
49
+ }
50
+
51
+ /**
52
+ * Execute a single hook script with JSON on stdin, parse JSON from stdout.
53
+ */
54
+ export function executeHook(
55
+ scriptPath: string,
56
+ input: object,
57
+ timeout = DEFAULT_TIMEOUT,
58
+ ): Promise<HookResult> {
59
+ return new Promise((resolve) => {
60
+ const { cmd, args } = getRunner(scriptPath);
61
+ const child = spawn(cmd, args, {
62
+ timeout,
63
+ stdio: ["pipe", "pipe", "pipe"],
64
+ cwd: process.cwd(),
65
+ env: process.env,
66
+ });
67
+
68
+ let stdout = "";
69
+ let stderr = "";
70
+
71
+ child.stdout.on("data", (d: Buffer) => {
72
+ stdout += d.toString();
73
+ });
74
+ child.stderr.on("data", (d: Buffer) => {
75
+ stderr += d.toString();
76
+ });
77
+
78
+ // Ignore stdin errors — child may exit before reading (EPIPE)
79
+ child.stdin.on("error", () => {});
80
+ child.stdin.write(JSON.stringify(input));
81
+ child.stdin.end();
82
+
83
+ let settled = false;
84
+ child.on("close", (code) => {
85
+ if (settled) return;
86
+ settled = true;
87
+ if (code !== 0) {
88
+ log("hooks", `hook ${scriptPath} exited with code ${code}: ${stderr.trim()}`);
89
+ resolve({});
90
+ return;
91
+ }
92
+
93
+ const trimmed = stdout.trim();
94
+ if (!trimmed) {
95
+ resolve({});
96
+ return;
97
+ }
98
+
99
+ try {
100
+ const parsed = JSON.parse(trimmed);
101
+ resolve({
102
+ additionalContext: parsed.additionalContext,
103
+ metadata: parsed.metadata,
104
+ decision: parsed.decision,
105
+ });
106
+ } catch {
107
+ log("hooks", `hook ${scriptPath} returned invalid JSON: ${trimmed.slice(0, 200)}`);
108
+ resolve({});
109
+ }
110
+ });
111
+
112
+ child.on("error", (err) => {
113
+ if (settled) return;
114
+ settled = true;
115
+ log("hooks", `hook ${scriptPath} failed to spawn: ${err.message}`);
116
+ resolve({});
117
+ });
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Discover and run all hooks for an event, aggregating results.
123
+ */
124
+ export async function runHooks(
125
+ hooksDir: string,
126
+ event: string,
127
+ input: object,
128
+ timeout = DEFAULT_TIMEOUT,
129
+ ): Promise<AggregatedResult> {
130
+ const scripts = discoverHooks(hooksDir, event);
131
+ if (scripts.length === 0) return { metadata: {}, blocked: false };
132
+
133
+ const contextParts: string[] = [];
134
+ const metadata: Record<string, unknown> = {};
135
+ let blocked = false;
136
+
137
+ for (const script of scripts) {
138
+ const result = await executeHook(script, input, timeout);
139
+ if (result.additionalContext) {
140
+ contextParts.push(result.additionalContext);
141
+ }
142
+ if (result.metadata) {
143
+ Object.assign(metadata, result.metadata);
144
+ }
145
+ if (result.decision === "block") {
146
+ blocked = true;
147
+ }
148
+ }
149
+
150
+ return {
151
+ additionalContext: contextParts.length > 0 ? contextParts.join("\n\n") : undefined,
152
+ metadata,
153
+ blocked,
154
+ };
155
+ }
@@ -1,5 +1,7 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
1
3
  import { formatPrefix, formatTypingSuffix } from "./format-prefix.js";
2
- import { log } from "./logger.js";
4
+ import { log, warn } from "./logger.js";
3
5
  import {
4
6
  type BatchConfig,
5
7
  loadRoutingConfig,
@@ -273,6 +275,20 @@ export function createRouter(options: {
273
275
  const noop = () => {};
274
276
  const safeListener = listener ?? noop;
275
277
 
278
+ // Expose session to child processes (daemon-client reads this for X-Volute-Session)
279
+ process.env.VOLUTE_SESSION = session;
280
+ // Also write to file for sandbox environments where env vars don't propagate
281
+ try {
282
+ const mindDir = process.env.VOLUTE_MIND_DIR;
283
+ if (mindDir) {
284
+ const sessionFile = resolve(mindDir, ".mind", "current-session");
285
+ mkdirSync(resolve(mindDir, ".mind"), { recursive: true });
286
+ writeFileSync(sessionFile, session, "utf-8");
287
+ }
288
+ } catch (err) {
289
+ warn("router", `failed to write session file: ${err}`);
290
+ }
291
+
276
292
  // Apply formatting
277
293
  const formatted = applyPrefix(content, { ...meta, sessionName: session });
278
294
  const withTyping = appendTypingSuffix(formatted, meta.typing);
@@ -30,16 +30,14 @@ export function loadConfig(): {
30
30
  compaction?: { maxContextTokens?: number };
31
31
  subagents?: Record<string, SubagentConfig>;
32
32
  } {
33
- // Mind-own config lives in config.json; fall back to volute.json for older minds
34
- for (const file of ["home/.config/config.json", "home/.config/volute.json"]) {
35
- try {
36
- return JSON.parse(readFileSync(resolve(file), "utf-8"));
37
- } catch (err: any) {
38
- if (err?.code === "ENOENT") continue;
39
- log("startup", `failed to parse ${file}:`, err);
33
+ try {
34
+ return JSON.parse(readFileSync(resolve("home/.config/config.json"), "utf-8"));
35
+ } catch (err: any) {
36
+ if (err?.code !== "ENOENT") {
37
+ log("startup", "failed to parse config.json:", err);
40
38
  }
39
+ return {};
41
40
  }
42
- return {};
43
41
  }
44
42
 
45
43
  function loadFile(path: string): string {
@@ -80,12 +78,19 @@ export function loadPackageInfo(): { name: string; version: string } {
80
78
  }
81
79
 
82
80
  export async function handleStartupContext(sendMessage: (content: string) => void): Promise<void> {
83
- const scriptPath = resolve("home/.config/hooks/startup-context.sh");
84
- if (!existsSync(scriptPath)) return;
81
+ // Prefer .ts, fall back to .sh for backwards compatibility
82
+ const tsPath = resolve("home/.local/hooks/startup-context.ts");
83
+ const shPath = resolve("home/.local/hooks/startup-context.sh");
84
+ const scriptPath = existsSync(tsPath) ? tsPath : existsSync(shPath) ? shPath : null;
85
+ if (!scriptPath) return;
86
+
87
+ const isTs = scriptPath.endsWith(".ts");
85
88
 
86
89
  try {
87
90
  const stdout = await new Promise<string>((resolve, reject) => {
88
- const child = spawn("bash", [scriptPath], { timeout: 5000 });
91
+ const child = isTs
92
+ ? spawn(process.execPath, ["--import", "tsx", scriptPath], { timeout: 5000 })
93
+ : spawn("bash", [scriptPath], { timeout: 5000 });
89
94
  let out = "";
90
95
  child.stdout.on("data", (d: Buffer) => {
91
96
  out += d.toString();
@@ -112,7 +117,7 @@ export async function handleStartupContext(sendMessage: (content: string) => voi
112
117
  log("server", "sent startup context");
113
118
  }
114
119
  } catch (e) {
115
- log("server", "failed to run startup-context.sh:", e);
120
+ log("server", "failed to run startup context hook:", e);
116
121
  }
117
122
  }
118
123
 
@@ -4,7 +4,7 @@ import type { DaemonEvent, EventType } from "./daemon-client.js";
4
4
 
5
5
  export type TransparencyPreset = "transparent" | "standard" | "private" | "silent";
6
6
 
7
- type FilterableEventType = Exclude<EventType, "inbound" | "outbound">;
7
+ type FilterableEventType = Exclude<EventType, "inbound" | "outbound" | "context">;
8
8
 
9
9
  const PRESET_RULES: Record<
10
10
  TransparencyPreset,
@@ -53,7 +53,7 @@ const PRESET_RULES: Record<
53
53
  };
54
54
 
55
55
  // Communication records are always emitted (bypass transparency filtering)
56
- const ALWAYS_ALLOWED: ReadonlySet<string> = new Set(["inbound", "outbound"]);
56
+ const ALWAYS_ALLOWED: ReadonlySet<string> = new Set(["inbound", "outbound", "context"]);
57
57
 
58
58
  export function loadTransparencyPreset(): TransparencyPreset {
59
59
  for (const file of ["home/.config/config.json", "home/.config/volute.json"]) {
@@ -115,12 +115,9 @@ export function createVoluteServer(options: {
115
115
  channels: Record<string, any[]>;
116
116
  };
117
117
  router.dispatchBatch(batch, body.session ?? "main", body);
118
- } else if (body.session) {
119
- // Pre-routed by daemon delivery manager — dispatch directly
120
- router.dispatch(body.content, body.session, body);
121
118
  } else {
122
- // Legacy: local routing (for minds running with old daemon)
123
- router.route(body.content, body);
119
+ // Pre-routed by daemon delivery manager dispatch directly
120
+ router.dispatch(body.content, body.session ?? "main", body);
124
121
  }
125
122
  res.writeHead(200, { "Content-Type": "application/json" });
126
123
  res.end(JSON.stringify({ ok: true }));
@@ -5,7 +5,7 @@
5
5
  "hooks": [
6
6
  {
7
7
  "type": "command",
8
- "command": "\"$CLAUDE_PROJECT_DIR\"/.config/hooks/startup-context.sh"
8
+ "command": "node --import tsx \"$CLAUDE_PROJECT_DIR\"/.local/hooks/startup-context.ts"
9
9
  }
10
10
  ]
11
11
  }