volute 0.18.0 → 0.19.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 (104) hide show
  1. package/README.md +1 -1
  2. package/dist/archive-ZCFOSTKB.js +15 -0
  3. package/dist/{channel-SLURLIRV.js → channel-PUQKGSQM.js} +60 -7
  4. package/dist/{chunk-AYB7XAWO.js → chunk-2TJGRJ4O.js} +114 -279
  5. package/dist/{chunk-6BDNWYKG.js → chunk-32VR2EOH.js} +2 -2
  6. package/dist/chunk-4KPUF5JD.js +214 -0
  7. package/dist/{chunk-QJIIHU32.js → chunk-7NO7EV5Z.js} +2 -2
  8. package/dist/chunk-AW7P4EVV.js +159 -0
  9. package/dist/{chunk-2Y77MCFG.js → chunk-DYZGP3EW.js} +2 -2
  10. package/dist/{chunk-M77QBTEH.js → chunk-EBGCNDMM.js} +24 -14
  11. package/dist/{chunk-GSPWIM5E.js → chunk-EMQSAY3B.js} +77 -6
  12. package/dist/{chunk-37X7ECMF.js → chunk-FCDU5BFX.js} +1 -1
  13. package/dist/chunk-FGV2H4TX.js +803 -0
  14. package/dist/{chunk-ZCEYUUID.js → chunk-OGXOMR65.js} +2 -1
  15. package/dist/chunk-OTWLI7F4.js +375 -0
  16. package/dist/{chunk-GK4E7LM7.js → chunk-RHEGSQFJ.js} +1 -1
  17. package/dist/{chunk-MVSXRMJJ.js → chunk-SCUDS4US.js} +1 -1
  18. package/dist/{chunk-FW5API7X.js → chunk-UJ6GHNR7.js} +2 -2
  19. package/dist/{chunk-OYSZNX5I.js → chunk-VDWCHYTS.js} +1 -1
  20. package/dist/{chunk-6DVBMLVN.js → chunk-VE4D3GOP.js} +2 -2
  21. package/dist/chunk-VQWDC6UK.js +142 -0
  22. package/dist/{chunk-OJQ47SCA.js → chunk-WC6ZHVRL.js} +1 -1
  23. package/dist/chunk-YUIHSKR6.js +72 -0
  24. package/dist/chunk-Z524RFCJ.js +36 -0
  25. package/dist/cli.js +33 -25
  26. package/dist/{connector-3ELFMI2R.js → connector-JBVNZ7VK.js} +6 -6
  27. package/dist/connectors/discord.js +2 -2
  28. package/dist/connectors/slack.js +2 -2
  29. package/dist/connectors/telegram.js +2 -2
  30. package/dist/{create-ZWHCRT5F.js → create-HP4OVVHF.js} +6 -4
  31. package/dist/{daemon-client-ODKDUYDE.js → daemon-client-ITWUCNFO.js} +2 -2
  32. package/dist/{daemon-restart-2HVTHZAT.js → daemon-restart-JMZM3QY4.js} +8 -8
  33. package/dist/daemon.js +1144 -1108
  34. package/dist/db-5ZVC6MQF.js +10 -0
  35. package/dist/{delete-6G6WEX4F.js → delete-BSU7K3RY.js} +1 -1
  36. package/dist/delivery-manager-ISTJMZDW.js +16 -0
  37. package/dist/down-ZY35KMHR.js +14 -0
  38. package/dist/{env-6IDWGBUH.js → env-A3LMO777.js} +6 -6
  39. package/dist/export-GCDNQCF3.js +100 -0
  40. package/dist/{history-YUEKTJ2N.js → history-WNK3DFUM.js} +6 -6
  41. package/dist/{import-EDGRLIGO.js → import-M63VIUJ5.js} +3 -3
  42. package/dist/log-PPPZDVEF.js +39 -0
  43. package/dist/{login-ORQDXLBM.js → login-HNH3EUQV.js} +2 -2
  44. package/dist/{logout-XC5AUO5I.js → logout-I5CB5UZS.js} +2 -2
  45. package/dist/{logs-GYOR3L2L.js → logs-SF2IMJN4.js} +6 -6
  46. package/dist/merge-33C237A4.js +46 -0
  47. package/dist/{mind-OJN6RBZW.js → mind-PQ5NCPSU.js} +14 -10
  48. package/dist/mind-manager-RVCFROAY.js +18 -0
  49. package/dist/{package-OKLFO7UY.js → package-MYE2ZJLV.js} +5 -3
  50. package/dist/{pages-6IV4VQTU.js → pages-AXCOSY3P.js} +2 -2
  51. package/dist/{publish-Q4RPSJLL.js → publish-YB377JB7.js} +18 -4
  52. package/dist/pull-XAEWQJ47.js +39 -0
  53. package/dist/{register-LDE6LRXY.js → register-VSPCMHKX.js} +2 -2
  54. package/dist/{restart-YFAWFS5T.js → restart-IQKMCK5M.js} +6 -6
  55. package/dist/{schedule-AGYLDMNS.js → schedule-LMX7GAQZ.js} +6 -6
  56. package/dist/schema-5BW7DFZI.js +24 -0
  57. package/dist/{seed-AP4Q7RZ7.js → seed-J43YDKXG.js} +7 -4
  58. package/dist/{send-BNDTLUPM.js → send-KVIZIGCE.js} +8 -8
  59. package/dist/{service-U7MZ2H7F.js → service-LUR7WDO7.js} +6 -6
  60. package/dist/{setup-DJKIZKGW.js → setup-OH3PJUJO.js} +7 -7
  61. package/dist/shared-KO35ZM44.js +39 -0
  62. package/dist/{skill-2Y42P4JY.js → skill-BCVNI6TV.js} +6 -6
  63. package/{templates/_base/_skills → dist/skills}/orientation/SKILL.md +1 -1
  64. package/{templates/_base/_skills → dist/skills}/sessions/SKILL.md +2 -2
  65. package/{templates/_base/_skills → dist/skills}/volute-mind/SKILL.md +19 -1
  66. package/dist/{sprout-TJ3BHVOG.js → sprout-VBEX63LX.js} +38 -20
  67. package/dist/{start-3YYRXBKP.js → start-I5JYB65M.js} +6 -6
  68. package/dist/{status-VSFZYX7S.js → status-4ESFLGH4.js} +5 -5
  69. package/dist/status-D7E5HHBV.js +35 -0
  70. package/dist/{status-OKNA6AR3.js → status-JCJAOXTW.js} +2 -2
  71. package/dist/{stop-AA5K5LYG.js → stop-NBVKEFQQ.js} +6 -6
  72. package/dist/{up-7B3BWF2U.js → up-WG65SWJU.js} +5 -5
  73. package/dist/{update-YAGN5ODG.js → update-FJIHDJKM.js} +5 -5
  74. package/dist/{update-check-APLTH4IN.js → update-check-MWE5AH4U.js} +2 -2
  75. package/dist/{upgrade-KXZCQSZN.js → upgrade-AIT24B5I.js} +1 -1
  76. package/dist/{variant-X5QFG6KK.js → variant-63ZWO2W7.js} +4 -4
  77. package/dist/variants-JAGWGBXG.js +26 -0
  78. package/dist/web-assets/assets/index-BAbuRsVF.css +1 -0
  79. package/dist/web-assets/assets/index-CiQhSKi_.js +63 -0
  80. package/dist/web-assets/index.html +2 -2
  81. package/drizzle/0010_delivery_queue.sql +12 -0
  82. package/drizzle/0011_rename_human_to_brain.sql +1 -0
  83. package/drizzle/meta/0010_snapshot.json +7 -0
  84. package/drizzle/meta/0011_snapshot.json +7 -0
  85. package/drizzle/meta/_journal.json +14 -0
  86. package/package.json +5 -3
  87. package/templates/_base/.init/.config/hooks/startup-context.sh +1 -1
  88. package/templates/_base/.init/.config/scripts/session-reader.ts +3 -3
  89. package/templates/_base/home/VOLUTE.md +16 -1
  90. package/templates/_base/src/lib/auto-commit.ts +51 -14
  91. package/templates/_base/src/lib/router.ts +123 -1
  92. package/templates/_base/src/lib/types.ts +4 -0
  93. package/templates/_base/src/lib/volute-server.ts +91 -2
  94. package/templates/claude/src/server.ts +2 -2
  95. package/templates/claude/volute-template.json +1 -2
  96. package/templates/pi/src/agent.ts +1 -1
  97. package/templates/pi/src/lib/session-context-extension.ts +2 -2
  98. package/templates/pi/volute-template.json +1 -2
  99. package/dist/chunk-PO5Q2AYN.js +0 -121
  100. package/dist/down-A56B5JLK.js +0 -14
  101. package/dist/mind-manager-Z7O7PN2O.js +0 -15
  102. package/dist/web-assets/assets/index-CtiimdWK.css +0 -1
  103. package/dist/web-assets/assets/index-kt1_EcuO.js +0 -63
  104. /package/{templates/_base/_skills → dist/skills}/memory/SKILL.md +0 -0
@@ -7,8 +7,8 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
9
  <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,300;0,400;0,500;0,600;1,400&display=swap" rel="stylesheet" />
10
- <script type="module" crossorigin src="/assets/index-kt1_EcuO.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-CtiimdWK.css">
10
+ <script type="module" crossorigin src="/assets/index-CiQhSKi_.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-BAbuRsVF.css">
12
12
  </head>
13
13
  <body>
14
14
  <div id="root"></div>
@@ -0,0 +1,12 @@
1
+ CREATE TABLE `delivery_queue` (
2
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
3
+ `mind` text NOT NULL,
4
+ `session` text NOT NULL,
5
+ `channel` text,
6
+ `sender` text,
7
+ `status` text NOT NULL DEFAULT 'pending',
8
+ `payload` text NOT NULL,
9
+ `created_at` text DEFAULT (datetime('now')) NOT NULL
10
+ );--> statement-breakpoint
11
+ CREATE INDEX `idx_delivery_queue_mind_session` ON `delivery_queue` (`mind`, `session`);--> statement-breakpoint
12
+ CREATE INDEX `idx_delivery_queue_mind_status` ON `delivery_queue` (`mind`, `status`);
@@ -0,0 +1 @@
1
+ UPDATE `users` SET `user_type` = 'brain' WHERE `user_type` = 'human';
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "0010_delivery_queue",
3
+ "prevId": "0009_shared_skills",
4
+ "version": "6",
5
+ "dialect": "sqlite",
6
+ "tables": {}
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "0011_rename_human_to_brain",
3
+ "prevId": "0010_delivery_queue",
4
+ "version": "6",
5
+ "dialect": "sqlite",
6
+ "tables": {}
7
+ }
@@ -71,6 +71,20 @@
71
71
  "when": 1771800000000,
72
72
  "tag": "0009_shared_skills",
73
73
  "breakpoints": true
74
+ },
75
+ {
76
+ "idx": 10,
77
+ "version": "6",
78
+ "when": 1771900000000,
79
+ "tag": "0010_delivery_queue",
80
+ "breakpoints": true
81
+ },
82
+ {
83
+ "idx": 11,
84
+ "version": "6",
85
+ "when": 1772000000000,
86
+ "tag": "0011_rename_human_to_brain",
87
+ "breakpoints": true
74
88
  }
75
89
  ]
76
90
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volute",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "CLI for creating and managing self-modifying AI minds powered by the Claude Agent SDK",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,10 +28,12 @@
28
28
  ],
29
29
  "scripts": {
30
30
  "dev": "tsx src/cli.ts",
31
- "build": "tsup && npm run build:web",
31
+ "build": "tsup && cp -r skills dist/skills && npm run build:web",
32
32
  "build:web": "vite build --config src/web/ui/vite.config.ts",
33
33
  "dev:web": "vite --config src/web/ui/vite.config.ts",
34
- "test": "node --import tsx --import ./test/setup.ts --test --test-force-exit --test-concurrency=1 test/*.test.ts",
34
+ "test": "node --import tsx --import ./test/setup.ts --test --test-force-exit --test-concurrency=4 $(find test -name '*.test.ts' ! -name 'daemon-e2e.test.ts' | sort)",
35
+ "test:e2e": "node --import tsx --import ./test/setup.ts --test --test-force-exit --test-concurrency=1 test/daemon-e2e.test.ts",
36
+ "test:all": "npm test && npm run test:e2e",
35
37
  "lint": "biome check src/ test/",
36
38
  "lint:fix": "biome check --write src/ test/",
37
39
  "format": "biome format --write src/ test/",
@@ -11,7 +11,7 @@ SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"')
11
11
  CONTEXT="Session ${SOURCE} at $(date '+%Y-%m-%d %H:%M')."
12
12
 
13
13
  # Active sessions
14
- SESSIONS_DIR=".volute/sessions"
14
+ SESSIONS_DIR=".mind/sessions"
15
15
  if [ -d "$SESSIONS_DIR" ]; then
16
16
  SESSION_LIST=$(ls -1 "$SESSIONS_DIR"/*.json 2>/dev/null | xargs -I{} basename {} .json | sort)
17
17
  if [ -n "$SESSION_LIST" ]; then
@@ -33,8 +33,8 @@ if (!sessionName) {
33
33
 
34
34
  // Detect template type and resolve JSONL path
35
35
  const cwd = process.cwd();
36
- const agentSdkSessions = resolve(cwd, "../.volute/sessions");
37
- const piSessions = resolve(cwd, "../.volute/pi-sessions");
36
+ const agentSdkSessions = resolve(cwd, "../.mind/sessions");
37
+ const piSessions = resolve(cwd, "../.mind/pi-sessions");
38
38
 
39
39
  let jsonlPath: string | null = null;
40
40
  let format: "agent-sdk" | "pi";
@@ -46,7 +46,7 @@ if (existsSync(agentSdkSessions)) {
46
46
  format = "pi";
47
47
  jsonlPath = resolvePiJsonl(piSessions, sessionName);
48
48
  } else {
49
- console.error("No session directory found. Expected .volute/sessions/ or .volute/pi-sessions/");
49
+ console.error("No session directory found. Expected .mind/sessions/ or .mind/pi-sessions/");
50
50
  process.exit(1);
51
51
  }
52
52
 
@@ -7,7 +7,7 @@ You are a volute mind — a persistent being that communicates across multiple c
7
7
  **Your text output stays in your session — it is not sent to anyone.** To send a message, you must use the `volute send` command:
8
8
 
9
9
  ```sh
10
- volute send @other-mind "hello" # DM another mind or user
10
+ volute send @other-mind "hello" # DM another user
11
11
  volute send discord:server/channel "hello" # send to a channel
12
12
  volute send animal-chat "hello" # send to a volute channel
13
13
  ```
@@ -34,6 +34,21 @@ Messages are routed to named sessions based on rules in `.config/routes.json`. E
34
34
 
35
35
  Messages from unrecognized channels are held until you add a routing rule. You'll receive a **[Channel Invite]** notification in your main session with the channel details, a message preview, and instructions for accepting or rejecting.
36
36
 
37
+ ## Shared Files
38
+
39
+ Your `shared/` directory is a collaborative space where all minds can work on files together. Each mind has its own branch — edits you make there are private until you deliberately share them.
40
+
41
+ ```sh
42
+ volute shared status # see what you've changed vs main
43
+ volute shared merge "msg" # share your changes with everyone
44
+ volute shared pull # get the latest from other minds
45
+ volute shared log # see recent shared history
46
+ ```
47
+
48
+ Files you edit in `shared/` are auto-committed to your branch. When you're ready to share, merge to main. Other minds get your changes by pulling. If there's a conflict, you'll be told — pull the latest, reconcile, and merge again.
49
+
50
+ The `shared/pages/` directory can be published as the system's shared website with `volute pages publish` (no `--mind` flag).
51
+
37
52
  ## Reference
38
53
 
39
54
  See the **volute-mind** skill for routing config syntax, batch options, channel management, and all CLI commands.
@@ -2,6 +2,10 @@ import { execFile } from "node:child_process";
2
2
  import { resolve } from "node:path";
3
3
  import { log } from "./logger.js";
4
4
 
5
+ function gitArgs(args: string[]): string[] {
6
+ return process.env.VOLUTE_ISOLATION === "user" ? ["-c", "safe.directory=*", ...args] : args;
7
+ }
8
+
5
9
  function exec(cmd: string, args: string[], cwd: string): Promise<{ code: number; stdout: string }> {
6
10
  return new Promise((r) => {
7
11
  execFile(cmd, args, { cwd }, (_err, stdout) => {
@@ -16,6 +20,9 @@ let pending = Promise.resolve();
16
20
  /**
17
21
  * Commit a file change in the mind's home directory.
18
22
  * Called by the PostToolUse hook when Edit or Write completes.
23
+ *
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.
19
26
  */
20
27
  export function commitFileChange(filePath: string, cwd: string): void {
21
28
  // Only commit files under the home directory
@@ -26,21 +33,51 @@ export function commitFileChange(filePath: string, cwd: string): void {
26
33
  const relativePath = resolved.slice(homeDir.length + 1);
27
34
  if (!relativePath) return;
28
35
 
36
+ // Check if this file is under the shared/ worktree
37
+ const sharedPrefix = "shared/";
38
+ const isShared = relativePath.startsWith(sharedPrefix);
39
+
29
40
  pending = pending.then(async () => {
30
- if ((await exec("git", ["add", relativePath], cwd)).code !== 0) {
31
- log("auto-commit", `git add failed for ${relativePath}`);
32
- return;
33
- }
34
- // Check if there are staged changes
35
- if ((await exec("git", ["diff", "--cached", "--quiet"], cwd)).code === 0) return;
36
-
37
- const message = `Update ${relativePath}`;
38
- if ((await exec("git", ["commit", "-m", message], cwd)).code === 0) {
39
- log("auto-commit", message);
40
- // Push if a remote is configured
41
- const { stdout: remote } = await exec("git", ["remote"], cwd);
42
- if (remote) {
43
- await exec("git", ["push"], cwd);
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";
46
+
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;
53
+
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}`);
63
+ }
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;
70
+ }
71
+ if ((await exec("git", ["diff", "--cached", "--quiet"], cwd)).code === 0) return;
72
+
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);
80
+ }
44
81
  }
45
82
  }
46
83
  });
@@ -15,6 +15,18 @@ export type Router = {
15
15
  meta: ChannelMeta,
16
16
  listener?: Listener,
17
17
  ): { messageId: string; unsubscribe: () => void };
18
+ /** Direct dispatch to a pre-routed session (daemon has already resolved the route). */
19
+ dispatch(
20
+ content: VoluteContentPart[],
21
+ session: string,
22
+ meta: ChannelMeta,
23
+ listener?: Listener,
24
+ ): { messageId: string; unsubscribe: () => void };
25
+ dispatchBatch(
26
+ batch: { channels: Record<string, any[]> },
27
+ session: string,
28
+ meta: ChannelMeta,
29
+ ): void;
18
30
  close(): void;
19
31
  };
20
32
 
@@ -247,6 +259,45 @@ export function createRouter(options: {
247
259
  }
248
260
  }
249
261
 
262
+ /**
263
+ * Direct dispatch to a pre-routed session. The daemon delivery manager has already
264
+ * resolved the route and session — just format and send.
265
+ */
266
+ function dispatch(
267
+ content: VoluteContentPart[],
268
+ session: string,
269
+ meta: ChannelMeta,
270
+ listener?: Listener,
271
+ ): { messageId: string; unsubscribe: () => void } {
272
+ const text = content
273
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
274
+ .map((p) => p.text)
275
+ .join(" ");
276
+ logMessage("in", text, meta.channel);
277
+
278
+ const messageId = generateMessageId();
279
+ const noop = () => {};
280
+ const safeListener = listener ?? noop;
281
+
282
+ // Apply formatting
283
+ const formatted = applyPrefix(content, { ...meta, sessionName: session });
284
+ const withTyping = appendTypingSuffix(formatted, meta.typing);
285
+
286
+ // Resolve session config for instructions
287
+ const config = options.configPath ? loadRoutingConfig(options.configPath) : {};
288
+ const sessionConfig = resolveSessionConfig(config, session);
289
+ const withInstructions = prependInstructions(withTyping, sessionConfig.instructions);
290
+
291
+ const handler = options.mindHandler(session);
292
+ const interrupt = (meta as any).interrupt ?? sessionConfig.interrupt;
293
+ const unsubscribe = handler.handle(
294
+ withInstructions,
295
+ { ...meta, sessionName: session, messageId, interrupt },
296
+ safeListener,
297
+ );
298
+ return { messageId, unsubscribe };
299
+ }
300
+
250
301
  function route(
251
302
  content: VoluteContentPart[],
252
303
  meta: ChannelMeta,
@@ -400,6 +451,77 @@ export function createRouter(options: {
400
451
  return { messageId, unsubscribe };
401
452
  }
402
453
 
454
+ /**
455
+ * Handle a pre-batched payload from the daemon delivery manager.
456
+ * Formats messages grouped by channel into a single SDK message.
457
+ */
458
+ function dispatchBatch(
459
+ batch: { channels: Record<string, any[]> },
460
+ session: string,
461
+ _meta: ChannelMeta,
462
+ ): void {
463
+ const allMessages: { channel: string; payload: any }[] = [];
464
+ for (const [channel, messages] of Object.entries(batch.channels)) {
465
+ for (const msg of messages) {
466
+ allMessages.push({ channel, payload: msg });
467
+ }
468
+ }
469
+
470
+ if (allMessages.length === 0) return;
471
+
472
+ // Build channel summary
473
+ const channelCounts = new Map<string, number>();
474
+ for (const msg of allMessages) {
475
+ channelCounts.set(msg.channel, (channelCounts.get(msg.channel) ?? 0) + 1);
476
+ }
477
+ const channelLabels = [...channelCounts.entries()].map(([ch, n]) => `${n} from ${ch}`);
478
+ const summary = channelLabels.join(", ");
479
+
480
+ const header = `[Batch: ${allMessages.length} message${allMessages.length === 1 ? "" : "s"} — ${summary}]`;
481
+ const multiChannel = channelCounts.size > 1;
482
+
483
+ const body = allMessages
484
+ .map((m) => {
485
+ const sender = m.payload.sender ?? "unknown";
486
+ const text =
487
+ typeof m.payload.content === "string"
488
+ ? m.payload.content
489
+ : Array.isArray(m.payload.content)
490
+ ? (m.payload.content as { type: string; text?: string }[])
491
+ .filter((p) => p.type === "text" && p.text)
492
+ .map((p) => p.text)
493
+ .join("\n")
494
+ : JSON.stringify(m.payload.content);
495
+ const time = new Date().toLocaleTimeString("en-US", {
496
+ hour: "numeric",
497
+ minute: "2-digit",
498
+ });
499
+ const prefix = multiChannel
500
+ ? `[${sender} in ${m.channel} — ${time}]`
501
+ : `[${sender} — ${time}]`;
502
+ return `${prefix}\n${text}`;
503
+ })
504
+ .join("\n\n");
505
+
506
+ const content: VoluteContentPart[] = [{ type: "text", text: `${header}\n\n${body}` }];
507
+
508
+ // Resolve session config for instructions
509
+ const config = options.configPath ? loadRoutingConfig(options.configPath) : {};
510
+ const sessionConfig = resolveSessionConfig(config, session);
511
+ const withInstructions = prependInstructions(content, sessionConfig.instructions);
512
+
513
+ const messageId = generateMessageId();
514
+ const handler = options.mindHandler(session);
515
+ const noop = () => {};
516
+
517
+ try {
518
+ handler.handle(withInstructions, { sessionName: session, messageId }, noop);
519
+ log("router", `dispatched batch for session ${session}: ${allMessages.length} messages`);
520
+ } catch (err) {
521
+ log("router", `error dispatching batch for session ${session}:`, err);
522
+ }
523
+ }
524
+
403
525
  function close() {
404
526
  for (const [key, buffer] of batchBuffers) {
405
527
  if (buffer.debounceTimer) clearTimeout(buffer.debounceTimer);
@@ -409,5 +531,5 @@ export function createRouter(options: {
409
531
  batchBuffers.clear();
410
532
  }
411
533
 
412
- return { route, close };
534
+ return { route, dispatch, dispatchBatch, close };
413
535
  }
@@ -13,6 +13,10 @@ export type ChannelMeta = {
13
13
  participants?: string[];
14
14
  participantCount?: number;
15
15
  typing?: string[];
16
+ signature?: string;
17
+ signatureTimestamp?: string;
18
+ signerFingerprint?: string;
19
+ verified?: boolean;
16
20
  };
17
21
 
18
22
  /** ChannelMeta enriched by the router with dispatch info. */
@@ -1,7 +1,8 @@
1
+ import { createHash, verify } from "node:crypto";
1
2
  import { createServer, type IncomingMessage, type Server } from "node:http";
2
3
  import { log } from "./logger.js";
3
4
  import type { Router } from "./router.js";
4
- import type { VoluteRequest } from "./types.js";
5
+ import type { VoluteContentPart, VoluteRequest } from "./types.js";
5
6
 
6
7
  function readBody(req: IncomingMessage): Promise<string> {
7
8
  return new Promise((resolve, reject) => {
@@ -12,6 +13,71 @@ function readBody(req: IncomingMessage): Promise<string> {
12
13
  });
13
14
  }
14
15
 
16
+ function extractText(content: VoluteContentPart[] | string): string {
17
+ if (typeof content === "string") return content;
18
+ return content
19
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
20
+ .map((p) => p.text)
21
+ .join("\n");
22
+ }
23
+
24
+ /** Normalize content to VoluteContentPart[] — connectors may send plain strings. */
25
+ function normalizeContent(content: unknown): VoluteContentPart[] {
26
+ if (Array.isArray(content)) return content as VoluteContentPart[];
27
+ if (typeof content === "string") return [{ type: "text", text: content }];
28
+ return [{ type: "text", text: JSON.stringify(content) }];
29
+ }
30
+
31
+ /** Verify an Ed25519 signature against a public key */
32
+ function verifySignature(
33
+ publicKeyPem: string,
34
+ content: string,
35
+ timestamp: string,
36
+ signature: string,
37
+ ): boolean {
38
+ try {
39
+ const data = `${content}\n${timestamp}`;
40
+ return verify(null, Buffer.from(data), publicKeyPem, Buffer.from(signature, "base64"));
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ /** Look up a mind's public key via the daemon API */
47
+ async function fetchPublicKey(fingerprint: string): Promise<string | null> {
48
+ const daemonPort = process.env.VOLUTE_DAEMON_PORT;
49
+ const daemonToken = process.env.VOLUTE_DAEMON_TOKEN;
50
+ if (!daemonPort || !daemonToken) return null;
51
+
52
+ try {
53
+ const res = await fetch(
54
+ `http://127.0.0.1:${daemonPort}/api/keys/${encodeURIComponent(fingerprint)}`,
55
+ { headers: { Authorization: `Bearer ${daemonToken}` }, signal: AbortSignal.timeout(2000) },
56
+ );
57
+ if (!res.ok) return null;
58
+ const data = (await res.json()) as { publicKey?: string };
59
+ return data.publicKey ?? null;
60
+ } catch (err) {
61
+ log("identity", "failed to fetch public key:", err);
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /** Best-effort signature verification */
67
+ async function verifyRequest(body: VoluteRequest): Promise<boolean | undefined> {
68
+ if (!body.signature || !body.signatureTimestamp || !body.signerFingerprint) return undefined;
69
+
70
+ const publicKey = await fetchPublicKey(body.signerFingerprint);
71
+ if (!publicKey) return false;
72
+
73
+ // Verify the fingerprint matches
74
+ const expectedFingerprint = createHash("sha256").update(publicKey).digest("hex");
75
+ if (expectedFingerprint !== body.signerFingerprint) return false;
76
+
77
+ const text = extractText(body.content);
78
+ return verifySignature(publicKey, text, body.signatureTimestamp, body.signature);
79
+ }
80
+
15
81
  export function createVoluteServer(options: {
16
82
  router: Router;
17
83
  port: number;
@@ -32,7 +98,30 @@ export function createVoluteServer(options: {
32
98
  if (req.method === "POST" && url.pathname === "/message") {
33
99
  try {
34
100
  const body = JSON.parse(await readBody(req)) as VoluteRequest;
35
- router.route(body.content, body);
101
+
102
+ // Strip any sender-provided verified field to prevent spoofing
103
+ delete body.verified;
104
+
105
+ // Best-effort signature verification (non-blocking)
106
+ const verified = await verifyRequest(body);
107
+ if (verified !== undefined) body.verified = verified;
108
+
109
+ // Normalize content — connectors may send plain strings
110
+ body.content = normalizeContent(body.content);
111
+
112
+ // Handle batch payloads from delivery manager
113
+ if ((body as any).batch) {
114
+ const batch = (body as any).batch as {
115
+ channels: Record<string, any[]>;
116
+ };
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
+ } else {
122
+ // Legacy: local routing (for minds running with old daemon)
123
+ router.route(body.content, body);
124
+ }
36
125
  res.writeHead(200, { "Content-Type": "application/json" });
37
126
  res.end(JSON.stringify({ ok: true }));
38
127
  } catch (err) {
@@ -20,10 +20,10 @@ if (config.model) log("server", `using model: ${config.model}`);
20
20
  if (config.maxThinkingTokens) log("server", `max thinking tokens: ${config.maxThinkingTokens}`);
21
21
 
22
22
  const systemPrompt = loadSystemPrompt();
23
- const sessionsDir = resolve(".volute/sessions");
23
+ const sessionsDir = resolve(".mind/sessions");
24
24
 
25
25
  // Migrate old single session.json → sessions/main.json
26
- const oldSessionPath = resolve(".volute/session.json");
26
+ const oldSessionPath = resolve(".mind/session.json");
27
27
  if (existsSync(oldSessionPath) && !existsSync(resolve(sessionsDir, "main.json"))) {
28
28
  mkdirSync(sessionsDir, { recursive: true });
29
29
  renameSync(oldSessionPath, resolve(sessionsDir, "main.json"));
@@ -4,6 +4,5 @@
4
4
  "biome.json.tmpl": "biome.json",
5
5
  "home/.config/config.json.tmpl": "home/.config/config.json"
6
6
  },
7
- "substitute": ["package.json", ".init/SOUL.md", "home/.config/routes.json"],
8
- "skillsDir": "home/.claude/skills"
7
+ "substitute": ["package.json", ".init/SOUL.md", "home/.config/routes.json"]
9
8
  }
@@ -83,7 +83,7 @@ export function createMind(options: {
83
83
 
84
84
  const sessionManager = isEphemeral
85
85
  ? SessionManager.inMemory()
86
- : SessionManager.continueRecent(options.cwd, `.volute/pi-sessions/${session.name}`);
86
+ : SessionManager.continueRecent(options.cwd, `.mind/pi-sessions/${session.name}`);
87
87
 
88
88
  log("mind", `session "${session.name}": ${isEphemeral ? "ephemeral" : "persistent"}`);
89
89
 
@@ -9,11 +9,11 @@ export function createSessionContextExtension(options: {
9
9
  return (pi) => {
10
10
  pi.on("before_agent_start", () => {
11
11
  try {
12
- const sessionsDir = resolve(options.cwd, ".volute/pi-sessions");
12
+ const sessionsDir = resolve(options.cwd, ".mind/pi-sessions");
13
13
  const summary = getSessionUpdates({
14
14
  currentSession: options.currentSession,
15
15
  sessionsDir,
16
- cursorFile: resolve(options.cwd, ".volute/session-cursors.json"),
16
+ cursorFile: resolve(options.cwd, ".mind/session-cursors.json"),
17
17
  jsonlResolver: (name) => resolvePiJsonl(sessionsDir, name),
18
18
  format: "pi",
19
19
  });
@@ -4,6 +4,5 @@
4
4
  "biome.json.tmpl": "biome.json",
5
5
  "home/.config/config.json.tmpl": "home/.config/config.json"
6
6
  },
7
- "substitute": ["package.json", ".init/SOUL.md", "home/.config/routes.json"],
8
- "skillsDir": "home/.claude/skills"
7
+ "substitute": ["package.json", ".init/SOUL.md", "home/.config/routes.json"]
9
8
  }