volute 0.17.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 (116) 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-CE7WMOVW.js → chunk-2TJGRJ4O.js} +236 -103
  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-3FC42ZBM.js → chunk-RHEGSQFJ.js} +4 -1
  17. package/dist/{chunk-MVSXRMJJ.js → chunk-SCUDS4US.js} +1 -1
  18. package/dist/{chunk-MIJIAGGG.js → chunk-UJ6GHNR7.js} +8 -6
  19. package/dist/{chunk-OYSZNX5I.js → chunk-VDWCHYTS.js} +1 -1
  20. package/dist/{chunk-77ISBIKI.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 +44 -24
  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-VRQMZLBK.js → daemon-restart-JMZM3QY4.js} +8 -8
  33. package/dist/daemon.js +1624 -940
  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-5F4WQW7S.js → history-WNK3DFUM.js} +10 -7
  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-4GTJGUXI.js → package-MYE2ZJLV.js} +7 -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-4GKDO26C.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-BCVNI6TV.js +287 -0
  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 +35 -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-LT3X5Q26.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/0007_system_prompts.sql +5 -0
  82. package/drizzle/0008_volute_channels.sql +24 -0
  83. package/drizzle/0009_shared_skills.sql +9 -0
  84. package/drizzle/0010_delivery_queue.sql +12 -0
  85. package/drizzle/0011_rename_human_to_brain.sql +1 -0
  86. package/drizzle/meta/0007_snapshot.json +7 -0
  87. package/drizzle/meta/0008_snapshot.json +7 -0
  88. package/drizzle/meta/0009_snapshot.json +7 -0
  89. package/drizzle/meta/0010_snapshot.json +7 -0
  90. package/drizzle/meta/0011_snapshot.json +7 -0
  91. package/drizzle/meta/_journal.json +35 -0
  92. package/package.json +7 -3
  93. package/templates/_base/.init/.config/hooks/startup-context.sh +1 -1
  94. package/templates/_base/.init/.config/prompts.json +5 -0
  95. package/templates/_base/.init/.config/scripts/session-reader.ts +3 -3
  96. package/templates/_base/home/VOLUTE.md +16 -1
  97. package/templates/_base/src/lib/auto-commit.ts +51 -14
  98. package/templates/_base/src/lib/router.ts +168 -29
  99. package/templates/_base/src/lib/routing.ts +4 -1
  100. package/templates/_base/src/lib/startup.ts +43 -0
  101. package/templates/_base/src/lib/types.ts +4 -0
  102. package/templates/_base/src/lib/volute-server.ts +91 -2
  103. package/templates/claude/src/agent.ts +4 -3
  104. package/templates/claude/src/lib/hooks/reply-instructions.ts +3 -1
  105. package/templates/claude/src/server.ts +2 -2
  106. package/templates/claude/volute-template.json +1 -2
  107. package/templates/pi/src/agent.ts +6 -7
  108. package/templates/pi/src/lib/reply-instructions-extension.ts +3 -1
  109. package/templates/pi/src/lib/session-context-extension.ts +2 -2
  110. package/templates/pi/volute-template.json +1 -2
  111. package/dist/chunk-PO5Q2AYN.js +0 -121
  112. package/dist/down-A56B5JLK.js +0 -14
  113. package/dist/mind-manager-ETNCPQJN.js +0 -15
  114. package/dist/web-assets/assets/index-BcmT7Qxo.js +0 -63
  115. package/dist/web-assets/assets/index-DG01TyLb.css +0 -1
  116. /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-BcmT7Qxo.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-DG01TyLb.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,5 @@
1
+ CREATE TABLE `system_prompts` (
2
+ `key` text PRIMARY KEY NOT NULL,
3
+ `content` text NOT NULL,
4
+ `updated_at` text DEFAULT (datetime('now')) NOT NULL
5
+ );
@@ -0,0 +1,24 @@
1
+ -- Rebuild conversations table: make mind_name nullable, add type + name columns
2
+ CREATE TABLE `conversations_new` (
3
+ `id` text PRIMARY KEY NOT NULL,
4
+ `mind_name` text,
5
+ `channel` text NOT NULL,
6
+ `type` text NOT NULL DEFAULT 'dm',
7
+ `name` text,
8
+ `user_id` integer REFERENCES `users`(`id`),
9
+ `title` text,
10
+ `created_at` text DEFAULT (datetime('now')) NOT NULL,
11
+ `updated_at` text DEFAULT (datetime('now')) NOT NULL
12
+ );--> statement-breakpoint
13
+ INSERT INTO `conversations_new` (`id`, `mind_name`, `channel`, `type`, `name`, `user_id`, `title`, `created_at`, `updated_at`)
14
+ SELECT `id`, `mind_name`, `channel`, 'dm', NULL, `user_id`, `title`, `created_at`, `updated_at` FROM `conversations`;--> statement-breakpoint
15
+ DROP TABLE `conversations`;--> statement-breakpoint
16
+ ALTER TABLE `conversations_new` RENAME TO `conversations`;--> statement-breakpoint
17
+ CREATE INDEX `idx_conversations_mind_name` ON `conversations` (`mind_name`);--> statement-breakpoint
18
+ CREATE INDEX `idx_conversations_user_id` ON `conversations` (`user_id`);--> statement-breakpoint
19
+ CREATE INDEX `idx_conversations_updated_at` ON `conversations` (`updated_at`);--> statement-breakpoint
20
+ CREATE UNIQUE INDEX `idx_conversations_name` ON `conversations` (`name`);--> statement-breakpoint
21
+ -- Backfill: mark conversations with 3+ participants as 'group'
22
+ UPDATE `conversations` SET `type` = 'group' WHERE `id` IN (
23
+ SELECT `conversation_id` FROM `conversation_participants` GROUP BY `conversation_id` HAVING COUNT(*) > 2
24
+ );
@@ -0,0 +1,9 @@
1
+ CREATE TABLE `shared_skills` (
2
+ `id` text PRIMARY KEY NOT NULL,
3
+ `name` text NOT NULL,
4
+ `description` text DEFAULT '' NOT NULL,
5
+ `author` text NOT NULL,
6
+ `version` integer DEFAULT 1 NOT NULL,
7
+ `created_at` text DEFAULT (datetime('now')) NOT NULL,
8
+ `updated_at` text DEFAULT (datetime('now')) NOT NULL
9
+ );
@@ -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": "0007_system_prompts",
3
+ "prevId": "0006_mind_history",
4
+ "version": "6",
5
+ "dialect": "sqlite",
6
+ "tables": {}
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "0008_volute_channels",
3
+ "prevId": "0007_system_prompts",
4
+ "version": "6",
5
+ "dialect": "sqlite",
6
+ "tables": {}
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "0009_shared_skills",
3
+ "prevId": "0008_volute_channels",
4
+ "version": "6",
5
+ "dialect": "sqlite",
6
+ "tables": {}
7
+ }
@@ -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
+ }
@@ -50,6 +50,41 @@
50
50
  "when": 1771400000000,
51
51
  "tag": "0006_mind_history",
52
52
  "breakpoints": true
53
+ },
54
+ {
55
+ "idx": 7,
56
+ "version": "6",
57
+ "when": 1771600000000,
58
+ "tag": "0007_system_prompts",
59
+ "breakpoints": true
60
+ },
61
+ {
62
+ "idx": 8,
63
+ "version": "6",
64
+ "when": 1771700000000,
65
+ "tag": "0008_volute_channels",
66
+ "breakpoints": true
67
+ },
68
+ {
69
+ "idx": 9,
70
+ "version": "6",
71
+ "when": 1771800000000,
72
+ "tag": "0009_shared_skills",
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
53
88
  }
54
89
  ]
55
90
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volute",
3
- "version": "0.17.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/",
@@ -49,6 +51,7 @@
49
51
  "@hono/zod-validator": "^0.7.6",
50
52
  "@libsql/client": "^0.17.0",
51
53
  "@slack/bolt": "^4.6.0",
54
+ "adm-zip": "^0.5.16",
52
55
  "bcryptjs": "^3.0.3",
53
56
  "cron-parser": "^5.5.0",
54
57
  "discord.js": "^14.25.1",
@@ -63,6 +66,7 @@
63
66
  "@mariozechner/pi-ai": "^0.52.7",
64
67
  "@mariozechner/pi-coding-agent": "^0.52.7",
65
68
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
69
+ "@types/adm-zip": "^0.5.7",
66
70
  "@types/bcryptjs": "^2.4.6",
67
71
  "@types/dompurify": "^3.0.5",
68
72
  "@types/node": "^25.2.0",
@@ -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
@@ -0,0 +1,5 @@
1
+ {
2
+ "compaction_warning": "Context is getting long — compaction is about to summarize this conversation. Before that happens, save anything important to files (MEMORY.md, memory/journal/${date}.md, etc.) since those survive compaction. Focus on: decisions made, open tasks, and anything you'd need to pick up where you left off.",
3
+ "reply_instructions": "To reply to this message, use: volute send ${channel} \"your message\"",
4
+ "channel_invite": "[Channel Invite]\n${headers}\n\n[${sender} — ${time}]\n${preview}\n\nFurther messages will be saved to ${filePath}\n\nTo accept, add to .config/routes.json:\n Rule: { \"channel\": \"${channel}\", \"session\": \"${suggestedSession}\" }\n${batchRecommendation}To respond, use: volute send ${channel} \"your message\"\nTo reject, delete ${filePath}"
5
+ }
@@ -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
  });
@@ -6,6 +6,7 @@ import {
6
6
  resolveRoute,
7
7
  resolveSessionConfig,
8
8
  } from "./routing.js";
9
+ import { loadPrompts } from "./startup.js";
9
10
  import type { ChannelMeta, HandlerResolver, Listener, VoluteContentPart } from "./types.js";
10
11
 
11
12
  export type Router = {
@@ -14,6 +15,18 @@ export type Router = {
14
15
  meta: ChannelMeta,
15
16
  listener?: Listener,
16
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;
17
30
  close(): void;
18
31
  };
19
32
 
@@ -115,38 +128,39 @@ function formatInviteNotification(
115
128
  messageText: string,
116
129
  ): string {
117
130
  const time = new Date().toLocaleString();
118
- const lines = ["[Channel Invite]"];
119
- if (meta.channel) lines.push(`Channel: ${meta.channel}`);
120
- if (meta.sender) lines.push(`Sender: ${meta.sender}`);
121
- if (meta.platform) lines.push(`Platform: ${meta.platform}`);
122
- if (meta.serverName) lines.push(`Server: ${meta.serverName}`);
123
- if (meta.channelName) lines.push(`Channel name: ${meta.channelName}`);
131
+ const prompts = loadPrompts();
132
+
133
+ const headerLines: string[] = [];
134
+ if (meta.channel) headerLines.push(`Channel: ${meta.channel}`);
135
+ if (meta.sender) headerLines.push(`Sender: ${meta.sender}`);
136
+ if (meta.platform) headerLines.push(`Platform: ${meta.platform}`);
137
+ if (meta.serverName) headerLines.push(`Server: ${meta.serverName}`);
138
+ if (meta.channelName) headerLines.push(`Channel name: ${meta.channelName}`);
124
139
  if (meta.participants && meta.participants.length > 0)
125
- lines.push(`Participants: ${meta.participants.join(", ")}`);
126
- lines.push("");
140
+ headerLines.push(`Participants: ${meta.participants.join(", ")}`);
141
+
127
142
  const preview = messageText.length > 200 ? `${messageText.slice(0, 200)}...` : messageText;
128
- lines.push(`[${meta.sender ?? "unknown"} — ${time}]`);
129
- lines.push(preview);
130
- lines.push("");
131
- lines.push(`Further messages will be saved to ${filePath}`);
132
- lines.push("");
133
- lines.push("To accept, add to .config/routes.json:");
134
143
  const suggestedSession = sanitizeChannelPath(meta.channel ?? "unknown");
144
+ const channel = meta.channel ?? "unknown";
135
145
  const otherCount = (meta.participantCount ?? 1) - 1;
136
- if (otherCount > 1) {
137
- lines.push(` Rule: { "channel": "${meta.channel}", "session": "${suggestedSession}" }`);
138
- lines.push(
139
- ` Session config: "${suggestedSession}": { "batch": { "debounce": 20, "maxWait": 120 } }`,
140
- );
141
- lines.push(
142
- `(batch recommended — ${otherCount} other participants may generate frequent messages)`,
143
- );
144
- } else {
145
- lines.push(` Rule: { "channel": "${meta.channel}", "session": "${suggestedSession}" }`);
146
- }
147
- lines.push(`To respond, use: volute send ${meta.channel ?? "unknown"} "your message"`);
148
- lines.push(`To reject, delete ${filePath}`);
149
- return lines.join("\n");
146
+ const batchRecommendation =
147
+ otherCount > 1
148
+ ? ` Session config: "${suggestedSession}": { "batch": { "debounce": 20, "maxWait": 120 } }\n(batch recommended — ${otherCount} other participants may generate frequent messages)\n`
149
+ : "";
150
+
151
+ const vars: Record<string, string> = {
152
+ headers: headerLines.join("\n"),
153
+ sender: meta.sender ?? "unknown",
154
+ time,
155
+ preview,
156
+ filePath,
157
+ channel,
158
+ suggestedSession,
159
+ batchRecommendation,
160
+ };
161
+ return prompts.channel_invite.replace(/\$\{(\w+)\}/g, (match, name) =>
162
+ name in vars ? vars[name] : match,
163
+ );
150
164
  }
151
165
 
152
166
  export function createRouter(options: {
@@ -245,6 +259,45 @@ export function createRouter(options: {
245
259
  }
246
260
  }
247
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
+
248
301
  function route(
249
302
  content: VoluteContentPart[],
250
303
  meta: ChannelMeta,
@@ -304,6 +357,21 @@ export function createRouter(options: {
304
357
  return { messageId, unsubscribe: noop };
305
358
  }
306
359
 
360
+ // Mention-mode filtering: skip messages that don't mention this mind
361
+ if (resolved.destination === "mind" && resolved.mode === "mention") {
362
+ const mindName = process.env.VOLUTE_MIND;
363
+ if (mindName) {
364
+ const escaped = mindName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
365
+ const pattern = new RegExp(`\\b${escaped}\\b`, "i");
366
+ if (!pattern.test(text)) {
367
+ queueMicrotask(() => safeListener({ type: "done", messageId }));
368
+ return { messageId, unsubscribe: noop };
369
+ }
370
+ } else {
371
+ log("router", "VOLUTE_MIND not set — mention filtering disabled");
372
+ }
373
+ }
374
+
307
375
  // File destination
308
376
  if (resolved.destination === "file") {
309
377
  if (options.fileHandler) {
@@ -383,6 +451,77 @@ export function createRouter(options: {
383
451
  return { messageId, unsubscribe };
384
452
  }
385
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
+
386
525
  function close() {
387
526
  for (const [key, buffer] of batchBuffers) {
388
527
  if (buffer.debounceTimer) clearTimeout(buffer.debounceTimer);
@@ -392,5 +531,5 @@ export function createRouter(options: {
392
531
  batchBuffers.clear();
393
532
  }
394
533
 
395
- return { route, close };
534
+ return { route, dispatch, dispatchBatch, close };
396
535
  }
@@ -15,6 +15,7 @@ export type RoutingRule = {
15
15
  sender?: string;
16
16
  isDM?: boolean; // match on isDM metadata
17
17
  participants?: number; // match on participant count (e.g. 2 = DM)
18
+ mode?: "all" | "mention"; // "mention" = only process if mind name appears in message
18
19
  };
19
20
 
20
21
  export type SessionConfig = {
@@ -41,6 +42,7 @@ export type ResolvedRoute =
41
42
  destination: "mind";
42
43
  session: string;
43
44
  matched: boolean;
45
+ mode?: "all" | "mention";
44
46
  }
45
47
  | { destination: "file"; path: string; matched: boolean };
46
48
 
@@ -75,7 +77,7 @@ function globMatch(pattern: string, value: string): boolean {
75
77
  }
76
78
 
77
79
  const GLOB_MATCH_KEYS = new Set(["channel", "sender"]);
78
- const NON_MATCH_KEYS = new Set(["session", "destination", "path"]);
80
+ const NON_MATCH_KEYS = new Set(["session", "destination", "path", "mode"]);
79
81
 
80
82
  type MatchMeta = { channel?: string; sender?: string; isDM?: boolean; participantCount?: number };
81
83
 
@@ -135,6 +137,7 @@ export function resolveRoute(config: RoutingConfig, meta: MatchMeta): ResolvedRo
135
137
  destination: "mind",
136
138
  session: sanitizeSessionName(expandTemplate(rule.session ?? fallback, meta)),
137
139
  matched: true,
140
+ mode: rule.mode,
138
141
  };
139
142
  }
140
143
  }