volute 0.16.0 → 0.18.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 (49) hide show
  1. package/dist/chunk-AYB7XAWO.js +812 -0
  2. package/dist/{chunk-3FD4ZZUL.js → chunk-FW5API7X.js} +116 -10
  3. package/dist/{chunk-3FC42ZBM.js → chunk-GK4E7LM7.js} +3 -0
  4. package/dist/cli.js +18 -6
  5. package/dist/connectors/discord.js +1 -1
  6. package/dist/connectors/slack.js +1 -1
  7. package/dist/connectors/telegram.js +1 -1
  8. package/dist/{daemon-restart-MS5FI44G.js → daemon-restart-2HVTHZAT.js} +1 -1
  9. package/dist/daemon.js +1443 -592
  10. package/dist/history-YUEKTJ2N.js +108 -0
  11. package/dist/{mind-manager-PN5SUDJ4.js → mind-manager-Z7O7PN2O.js} +1 -1
  12. package/dist/{package-3QGV3KX6.js → package-OKLFO7UY.js} +8 -9
  13. package/dist/{send-KBBZNYG6.js → send-BNDTLUPM.js} +41 -9
  14. package/dist/skill-2Y42P4JY.js +287 -0
  15. package/dist/{up-GZLWZAQE.js → up-7B3BWF2U.js} +1 -1
  16. package/dist/web-assets/assets/index-CtiimdWK.css +1 -0
  17. package/dist/web-assets/assets/index-kt1_EcuO.js +63 -0
  18. package/dist/web-assets/index.html +2 -1
  19. package/drizzle/0006_mind_history.sql +20 -0
  20. package/drizzle/0007_system_prompts.sql +5 -0
  21. package/drizzle/0008_volute_channels.sql +24 -0
  22. package/drizzle/0009_shared_skills.sql +9 -0
  23. package/drizzle/meta/0006_snapshot.json +7 -0
  24. package/drizzle/meta/0007_snapshot.json +7 -0
  25. package/drizzle/meta/0008_snapshot.json +7 -0
  26. package/drizzle/meta/0009_snapshot.json +7 -0
  27. package/drizzle/meta/_journal.json +28 -0
  28. package/package.json +8 -9
  29. package/templates/_base/.init/.config/prompts.json +5 -0
  30. package/templates/_base/_skills/volute-mind/SKILL.md +19 -5
  31. package/templates/_base/src/lib/daemon-client.ts +45 -0
  32. package/templates/_base/src/lib/logger.ts +19 -0
  33. package/templates/_base/src/lib/router.ts +48 -41
  34. package/templates/_base/src/lib/routing.ts +5 -8
  35. package/templates/_base/src/lib/startup.ts +43 -0
  36. package/templates/_base/src/lib/transparency.ts +89 -0
  37. package/templates/_base/src/lib/types.ts +0 -1
  38. package/templates/_base/src/lib/volute-server.ts +3 -35
  39. package/templates/claude/src/agent.ts +9 -22
  40. package/templates/claude/src/lib/hooks/reply-instructions.ts +6 -9
  41. package/templates/claude/src/lib/stream-consumer.ts +39 -12
  42. package/templates/pi/src/agent.ts +9 -22
  43. package/templates/pi/src/lib/event-handler.ts +58 -7
  44. package/templates/pi/src/lib/reply-instructions-extension.ts +6 -9
  45. package/dist/chunk-J52CJCVI.js +0 -447
  46. package/dist/history-LKCJJMUV.js +0 -50
  47. package/dist/web-assets/assets/index-B1XIIGCh.js +0 -307
  48. package/templates/_base/src/lib/auto-reply.ts +0 -38
  49. /package/dist/{chunk-LLBBVTEY.js → chunk-6DVBMLVN.js} +0 -0
@@ -7,7 +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-B1XIIGCh.js"></script>
10
+ <script type="module" crossorigin src="/assets/index-kt1_EcuO.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-CtiimdWK.css">
11
12
  </head>
12
13
  <body>
13
14
  <div id="root"></div>
@@ -0,0 +1,20 @@
1
+ DROP INDEX IF EXISTS `idx_mind_messages_mind`;--> statement-breakpoint
2
+ DROP INDEX IF EXISTS `idx_mind_messages_channel`;--> statement-breakpoint
3
+ CREATE TABLE `mind_history` (
4
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
5
+ `mind` text NOT NULL,
6
+ `channel` text,
7
+ `session` text,
8
+ `sender` text,
9
+ `message_id` text,
10
+ `type` text NOT NULL DEFAULT 'inbound',
11
+ `content` text,
12
+ `metadata` text,
13
+ `created_at` text DEFAULT (datetime('now')) NOT NULL
14
+ );--> statement-breakpoint
15
+ INSERT INTO `mind_history` (`id`, `mind`, `channel`, `sender`, `type`, `content`, `created_at`)
16
+ SELECT `id`, `mind`, `channel`, `sender`, 'inbound', `content`, `created_at` FROM `mind_messages`;--> statement-breakpoint
17
+ DROP TABLE `mind_messages`;--> statement-breakpoint
18
+ CREATE INDEX `idx_mind_history_mind` ON `mind_history` (`mind`);--> statement-breakpoint
19
+ CREATE INDEX `idx_mind_history_mind_channel` ON `mind_history` (`mind`, `channel`);--> statement-breakpoint
20
+ CREATE INDEX `idx_mind_history_mind_type` ON `mind_history` (`mind`, `type`);
@@ -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,7 @@
1
+ {
2
+ "id": "0006_mind_history",
3
+ "prevId": "0005_rename_agents_to_minds",
4
+ "version": "6",
5
+ "dialect": "sqlite",
6
+ "tables": {}
7
+ }
@@ -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
+ }
@@ -43,6 +43,34 @@
43
43
  "when": 1771200000000,
44
44
  "tag": "0005_rename_agents_to_minds",
45
45
  "breakpoints": true
46
+ },
47
+ {
48
+ "idx": 6,
49
+ "version": "6",
50
+ "when": 1771400000000,
51
+ "tag": "0006_mind_history",
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
46
74
  }
47
75
  ]
48
76
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volute",
3
- "version": "0.16.0",
3
+ "version": "0.18.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",
@@ -29,8 +29,8 @@
29
29
  "scripts": {
30
30
  "dev": "tsx src/cli.ts",
31
31
  "build": "tsup && npm run build:web",
32
- "build:web": "vite build --config src/web/frontend/vite.config.ts",
33
- "dev:web": "vite --config src/web/frontend/vite.config.ts",
32
+ "build:web": "vite build --config src/web/ui/vite.config.ts",
33
+ "dev:web": "vite --config src/web/ui/vite.config.ts",
34
34
  "test": "node --import tsx --import ./test/setup.ts --test --test-force-exit --test-concurrency=1 test/*.test.ts",
35
35
  "lint": "biome check src/ test/",
36
36
  "lint:fix": "biome check --write src/ test/",
@@ -38,7 +38,7 @@
38
38
  "typecheck": "tsc --noEmit",
39
39
  "typecheck:templates": "tsc --noEmit -p templates/claude/tsconfig.json && tsc --noEmit -p templates/pi/tsconfig.json",
40
40
  "lint:templates": "biome check templates/",
41
- "typecheck:web": "tsc --noEmit -p src/web/frontend/tsconfig.json",
41
+ "typecheck:web": "svelte-check --tsconfig src/web/ui/tsconfig.json",
42
42
  "prepare": "lefthook install",
43
43
  "prepublishOnly": "npm run build",
44
44
  "db:generate": "drizzle-kit generate",
@@ -49,6 +49,7 @@
49
49
  "@hono/zod-validator": "^0.7.6",
50
50
  "@libsql/client": "^0.17.0",
51
51
  "@slack/bolt": "^4.6.0",
52
+ "adm-zip": "^0.5.16",
52
53
  "bcryptjs": "^3.0.3",
53
54
  "cron-parser": "^5.5.0",
54
55
  "discord.js": "^14.25.1",
@@ -62,18 +63,16 @@
62
63
  "@biomejs/biome": "2.3.14",
63
64
  "@mariozechner/pi-ai": "^0.52.7",
64
65
  "@mariozechner/pi-coding-agent": "^0.52.7",
66
+ "@sveltejs/vite-plugin-svelte": "^6.2.4",
67
+ "@types/adm-zip": "^0.5.7",
65
68
  "@types/bcryptjs": "^2.4.6",
66
69
  "@types/dompurify": "^3.0.5",
67
70
  "@types/node": "^25.2.0",
68
- "@types/react": "^19.2.11",
69
- "@types/react-dom": "^19.2.3",
70
- "@vitejs/plugin-react": "^5.1.3",
71
71
  "dompurify": "^3.3.1",
72
72
  "drizzle-kit": "^0.31.8",
73
73
  "lefthook": "^2.1.0",
74
74
  "marked": "^17.0.1",
75
- "react": "^19.2.4",
76
- "react-dom": "^19.2.4",
75
+ "svelte": "^5.53.0",
77
76
  "tsup": "^8.0.0",
78
77
  "tsx": "^4.0.0",
79
78
  "typescript": "^5.7.0",
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: Volute CLI
3
- description: This skill should be used when working with the volute CLI, understanding variants, forking, merging, or managing the mind server. Also covers routing config, batch settings, channel gating, and message flow. Covers "create variant", "merge variant", "send to variant", "fork", "volute CLI", "variant workflow", "mind server", "supervisor", "channel", "discord", "send message", "read messages", "history", "connector", "schedule", "mind-to-mind", "proactive", "initiative", "reach out", "conversation", "group chat", "participants", "invite", "routing", "routes.json", "batch", "debounce", "trigger", "gating", "gate".
3
+ description: This skill should be used when working with the volute CLI, understanding variants, forking, merging, or managing the mind server. Also covers routing config, batch settings, channel gating, message flow, and shared skills. Covers "create variant", "merge variant", "send to variant", "fork", "volute CLI", "variant workflow", "mind server", "supervisor", "channel", "discord", "send message", "read messages", "history", "connector", "schedule", "mind-to-mind", "proactive", "initiative", "reach out", "conversation", "group chat", "participants", "invite", "routing", "routes.json", "batch", "debounce", "trigger", "gating", "gate", "skill", "shared skill", "install skill", "publish skill", "update skill".
4
4
  ---
5
5
 
6
6
  # Self-Management
@@ -15,7 +15,7 @@ You manage yourself through the `volute` CLI. Your mind name is auto-detected vi
15
15
  | `volute mind stop` | Stop your server |
16
16
  | `volute mind status` | Check your status |
17
17
  | `volute mind logs [--follow] [-n N]` | Read your own logs |
18
- | `volute history [--channel <ch>] [--limit N]` | View your activity across all channels |
18
+ | `volute history [--channel <ch>] [--limit N] [--full]` | View your activity across all channels |
19
19
  | `volute send @<other-mind> "msg"` | Send a message to another mind (or pipe via stdin) |
20
20
  | `volute variant create <name> [--soul "..."] [--port N]` | Create a variant to experiment with changes |
21
21
  | `volute variant list` | List your variants |
@@ -100,6 +100,22 @@ After a merge, you receive orientation context about what changed. Update your m
100
100
 
101
101
  Create skills by writing `.claude/skills/<name>/SKILL.md` files in your `home/` directory. These are automatically available in your sessions.
102
102
 
103
+ ## Shared Skills
104
+
105
+ Your system has a shared skill repository that all minds can browse and install from.
106
+
107
+ | Command | Purpose |
108
+ |---------|---------|
109
+ | `volute skill list` | List shared skills available to install |
110
+ | `volute skill list --mind` | List your installed skills with update status |
111
+ | `volute skill install <name>` | Install a shared skill |
112
+ | `volute skill update <name>` | Update an installed skill (3-way merge preserves your changes) |
113
+ | `volute skill update --all` | Update all installed skills |
114
+ | `volute skill publish <name>` | Publish one of your skills to the shared repository |
115
+ | `volute skill uninstall <name>` | Remove an installed skill |
116
+
117
+ When you install a skill, it's copied to your skills directory. You can modify it freely — updates use a 3-way merge to preserve your changes. If there are merge conflicts, resolve them like any git conflict.
118
+
103
119
  ## MCP Configuration
104
120
 
105
121
  Edit `home/.mcp.json` to configure MCP servers for your SDK session. This gives you access to additional tools and services.
@@ -122,7 +138,6 @@ Messages are routed to sessions based on rules in `.config/routes.json`. Rules a
122
138
  ],
123
139
  "sessions": {
124
140
  "discord": { "batch": { "debounce": 20, "maxWait": 120, "triggers": ["@mymind"] }, "interrupt": false, "instructions": "Brief responses only." },
125
- "volute:*": { "autoReply": true }
126
141
  },
127
142
  "default": "main",
128
143
  "gateUnmatched": true
@@ -152,9 +167,8 @@ The `sessions` section configures behavior per session. Keys are glob patterns m
152
167
 
153
168
  | Field | Description |
154
169
  |-------|-------------|
155
- | `batch` | Batch config (see below) — incompatible with `autoReply` |
170
+ | `batch` | Batch config (see below) |
156
171
  | `interrupt` | Whether to interrupt an in-progress turn (default: `true`) |
157
- | `autoReply` | When `true`, your text output is automatically sent back to the originating channel. No need to use `volute send` for these conversations. Not supported with batch mode. |
158
172
  | `instructions` | Instructions prepended to messages for this session (e.g. `"Brief responses only."`) |
159
173
 
160
174
  ### Batch config
@@ -29,6 +29,51 @@ export async function daemonRestart(context?: {
29
29
  }
30
30
  }
31
31
 
32
+ export type EventType =
33
+ | "thinking"
34
+ | "text"
35
+ | "tool_use"
36
+ | "tool_result"
37
+ | "log"
38
+ | "usage"
39
+ | "session_start"
40
+ | "done"
41
+ | "inbound"
42
+ | "outbound";
43
+
44
+ export type DaemonEvent = {
45
+ type: EventType;
46
+ session?: string;
47
+ channel?: string;
48
+ messageId?: string;
49
+ content?: string;
50
+ metadata?: Record<string, unknown>;
51
+ };
52
+
53
+ export async function daemonEmit(event: DaemonEvent): Promise<void> {
54
+ if (!port || !mind) {
55
+ if (process.env.VOLUTE_DEBUG === "1") {
56
+ console.error("[volute] daemonEmit: missing VOLUTE_DAEMON_PORT or VOLUTE_MIND");
57
+ }
58
+ return;
59
+ }
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) {
70
+ console.error(`[volute] event emit failed: ${res.status}`);
71
+ }
72
+ } catch {
73
+ // Best-effort — don't let event emission failures break the mind
74
+ }
75
+ }
76
+
32
77
  export async function daemonSend(channel: string, text: string): Promise<void> {
33
78
  if (!port || !mind) {
34
79
  console.error("[volute] daemonSend: VOLUTE_DAEMON_PORT or VOLUTE_MIND not set");
@@ -1,4 +1,12 @@
1
+ import { daemonEmit } from "./daemon-client.js";
2
+ import { filterEvent, loadTransparencyPreset } from "./transparency.js";
3
+
1
4
  const DEBUG = process.env.VOLUTE_DEBUG === "1";
5
+ // Loaded once at startup — mind restarts on config changes
6
+ const preset = loadTransparencyPreset();
7
+
8
+ /** Categories whose log() calls are also emitted as daemon events. */
9
+ const EMIT_CATEGORIES = new Set(["mind", "server", "auto-commit"]);
2
10
 
3
11
  function truncate(str: string, maxLen = 200): string {
4
12
  return str.length > maxLen ? `${str.slice(0, maxLen)}...` : str;
@@ -11,6 +19,17 @@ export function log(category: string, ...args: unknown[]) {
11
19
  } catch {
12
20
  // EPIPE — parent closed pipes (detached mode). Ignore.
13
21
  }
22
+ if (EMIT_CATEGORIES.has(category)) {
23
+ const message = args
24
+ .map((a) => (a instanceof Error ? a.message : typeof a === "string" ? a : JSON.stringify(a)))
25
+ .join(" ");
26
+ const filtered = filterEvent(preset, {
27
+ type: "log",
28
+ content: message,
29
+ metadata: { category },
30
+ });
31
+ if (filtered) daemonEmit(filtered);
32
+ }
14
33
  }
15
34
 
16
35
  export function debug(category: string, ...args: unknown[]) {
@@ -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 = {
@@ -115,38 +116,39 @@ function formatInviteNotification(
115
116
  messageText: string,
116
117
  ): string {
117
118
  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}`);
119
+ const prompts = loadPrompts();
120
+
121
+ const headerLines: string[] = [];
122
+ if (meta.channel) headerLines.push(`Channel: ${meta.channel}`);
123
+ if (meta.sender) headerLines.push(`Sender: ${meta.sender}`);
124
+ if (meta.platform) headerLines.push(`Platform: ${meta.platform}`);
125
+ if (meta.serverName) headerLines.push(`Server: ${meta.serverName}`);
126
+ if (meta.channelName) headerLines.push(`Channel name: ${meta.channelName}`);
124
127
  if (meta.participants && meta.participants.length > 0)
125
- lines.push(`Participants: ${meta.participants.join(", ")}`);
126
- lines.push("");
128
+ headerLines.push(`Participants: ${meta.participants.join(", ")}`);
129
+
127
130
  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
131
  const suggestedSession = sanitizeChannelPath(meta.channel ?? "unknown");
132
+ const channel = meta.channel ?? "unknown";
135
133
  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");
134
+ const batchRecommendation =
135
+ otherCount > 1
136
+ ? ` Session config: "${suggestedSession}": { "batch": { "debounce": 20, "maxWait": 120 } }\n(batch recommended — ${otherCount} other participants may generate frequent messages)\n`
137
+ : "";
138
+
139
+ const vars: Record<string, string> = {
140
+ headers: headerLines.join("\n"),
141
+ sender: meta.sender ?? "unknown",
142
+ time,
143
+ preview,
144
+ filePath,
145
+ channel,
146
+ suggestedSession,
147
+ batchRecommendation,
148
+ };
149
+ return prompts.channel_invite.replace(/\$\{(\w+)\}/g, (match, name) =>
150
+ name in vars ? vars[name] : match,
151
+ );
150
152
  }
151
153
 
152
154
  export function createRouter(options: {
@@ -213,11 +215,7 @@ export function createRouter(options: {
213
215
 
214
216
  // Batch flushes are fire-and-forget — no HTTP response is waiting, so listener is a noop
215
217
  try {
216
- handler.handle(
217
- content,
218
- { sessionName: buffer.sessionName, messageId, autoReply: false },
219
- () => {},
220
- );
218
+ handler.handle(content, { sessionName: buffer.sessionName, messageId }, () => {});
221
219
  } catch (err) {
222
220
  log("router", `error flushing batch for session ${buffer.sessionName}:`, err);
223
221
  return;
@@ -284,7 +282,7 @@ export function createRouter(options: {
284
282
  if (options.fileHandler) {
285
283
  const formatted = applyPrefix(content, meta);
286
284
  const fileHandler = options.fileHandler(filePath);
287
- fileHandler.handle(formatted, { ...meta, messageId, autoReply: false }, noop);
285
+ fileHandler.handle(formatted, { ...meta, messageId }, noop);
288
286
  }
289
287
 
290
288
  // First message from this channel — send invite notification
@@ -299,7 +297,6 @@ export function createRouter(options: {
299
297
  sessionName: "main",
300
298
  messageId: generateMessageId(),
301
299
  interrupt: true,
302
- autoReply: false,
303
300
  },
304
301
  noop,
305
302
  );
@@ -309,16 +306,27 @@ export function createRouter(options: {
309
306
  return { messageId, unsubscribe: noop };
310
307
  }
311
308
 
309
+ // Mention-mode filtering: skip messages that don't mention this mind
310
+ if (resolved.destination === "mind" && resolved.mode === "mention") {
311
+ const mindName = process.env.VOLUTE_MIND;
312
+ if (mindName) {
313
+ const escaped = mindName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
314
+ const pattern = new RegExp(`\\b${escaped}\\b`, "i");
315
+ if (!pattern.test(text)) {
316
+ queueMicrotask(() => safeListener({ type: "done", messageId }));
317
+ return { messageId, unsubscribe: noop };
318
+ }
319
+ } else {
320
+ log("router", "VOLUTE_MIND not set — mention filtering disabled");
321
+ }
322
+ }
323
+
312
324
  // File destination
313
325
  if (resolved.destination === "file") {
314
326
  if (options.fileHandler) {
315
327
  const formatted = applyPrefix(content, meta);
316
328
  const handler = options.fileHandler(resolved.path);
317
- const unsubscribe = handler.handle(
318
- formatted,
319
- { ...meta, messageId, autoReply: false },
320
- safeListener,
321
- );
329
+ const unsubscribe = handler.handle(formatted, { ...meta, messageId }, safeListener);
322
330
  return { messageId, unsubscribe };
323
331
  }
324
332
  // No file handler configured — emit done and discard
@@ -386,7 +394,6 @@ export function createRouter(options: {
386
394
  sessionName,
387
395
  messageId,
388
396
  interrupt: sessionConfig.interrupt,
389
- autoReply: sessionConfig.autoReply,
390
397
  },
391
398
  safeListener,
392
399
  );
@@ -15,17 +15,16 @@ 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 = {
21
- autoReply?: boolean;
22
22
  batch?: number | BatchConfig;
23
23
  interrupt?: boolean;
24
24
  instructions?: string;
25
25
  };
26
26
 
27
27
  export type ResolvedSessionConfig = {
28
- autoReply: boolean;
29
28
  batch?: BatchConfig;
30
29
  interrupt: boolean;
31
30
  instructions?: string;
@@ -43,6 +42,7 @@ export type ResolvedRoute =
43
42
  destination: "mind";
44
43
  session: string;
45
44
  matched: boolean;
45
+ mode?: "all" | "mention";
46
46
  }
47
47
  | { destination: "file"; path: string; matched: boolean };
48
48
 
@@ -77,7 +77,7 @@ function globMatch(pattern: string, value: string): boolean {
77
77
  }
78
78
 
79
79
  const GLOB_MATCH_KEYS = new Set(["channel", "sender"]);
80
- const NON_MATCH_KEYS = new Set(["session", "destination", "path"]);
80
+ const NON_MATCH_KEYS = new Set(["session", "destination", "path", "mode"]);
81
81
 
82
82
  type MatchMeta = { channel?: string; sender?: string; isDM?: boolean; participantCount?: number };
83
83
 
@@ -137,6 +137,7 @@ export function resolveRoute(config: RoutingConfig, meta: MatchMeta): ResolvedRo
137
137
  destination: "mind",
138
138
  session: sanitizeSessionName(expandTemplate(rule.session ?? fallback, meta)),
139
139
  matched: true,
140
+ mode: rule.mode,
140
141
  };
141
142
  }
142
143
  }
@@ -152,18 +153,14 @@ export function resolveSessionConfig(
152
153
  config: RoutingConfig,
153
154
  sessionName: string,
154
155
  ): ResolvedSessionConfig {
155
- const defaults: ResolvedSessionConfig = { autoReply: false, interrupt: true };
156
+ const defaults: ResolvedSessionConfig = { interrupt: true };
156
157
 
157
158
  if (!config.sessions) return defaults;
158
159
 
159
160
  for (const [pattern, sessionConfig] of Object.entries(config.sessions)) {
160
161
  if (globMatch(pattern, sessionName)) {
161
162
  const batch = sessionConfig.batch != null ? normalizeBatch(sessionConfig.batch) : undefined;
162
- if (sessionConfig.autoReply && batch != null) {
163
- log("routing", `autoReply is not supported with batch mode — autoReply will be ignored`);
164
- }
165
163
  return {
166
- autoReply: batch != null ? false : (sessionConfig.autoReply ?? false),
167
164
  batch,
168
165
  interrupt: sessionConfig.interrupt ?? true,
169
166
  instructions: sessionConfig.instructions,
@@ -102,6 +102,49 @@ export async function handleStartupContext(sendMessage: (content: string) => voi
102
102
  }
103
103
  }
104
104
 
105
+ export type MindPrompts = {
106
+ compaction_warning: string;
107
+ reply_instructions: string;
108
+ channel_invite: string;
109
+ };
110
+
111
+ const DEFAULT_PROMPTS: MindPrompts = {
112
+ compaction_warning:
113
+ "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.",
114
+ reply_instructions: 'To reply to this message, use: volute send ${channel} "your message"',
115
+ channel_invite: `[Channel Invite]
116
+ \${headers}
117
+
118
+ [\${sender} — \${time}]
119
+ \${preview}
120
+
121
+ Further messages will be saved to \${filePath}
122
+
123
+ To accept, add to .config/routes.json:
124
+ Rule: { "channel": "\${channel}", "session": "\${suggestedSession}" }
125
+ \${batchRecommendation}To respond, use: volute send \${channel} "your message"
126
+ To reject, delete \${filePath}`,
127
+ };
128
+
129
+ export function loadPrompts(): MindPrompts {
130
+ try {
131
+ const raw = readFileSync(resolve("home/.config/prompts.json"), "utf-8");
132
+ const parsed = JSON.parse(raw);
133
+ const result = { ...DEFAULT_PROMPTS };
134
+ for (const key of Object.keys(DEFAULT_PROMPTS) as (keyof MindPrompts)[]) {
135
+ if (typeof parsed[key] === "string") {
136
+ result[key] = parsed[key];
137
+ }
138
+ }
139
+ return result;
140
+ } catch (err: any) {
141
+ if (err?.code !== "ENOENT") {
142
+ log("startup", "failed to load prompts.json, using defaults:", err);
143
+ }
144
+ return DEFAULT_PROMPTS;
145
+ }
146
+ }
147
+
105
148
  export function setupShutdown(): void {
106
149
  function shutdown() {
107
150
  log("server", "shutdown signal received");