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.
- package/README.md +1 -1
- package/dist/archive-ZCFOSTKB.js +15 -0
- package/dist/{channel-SLURLIRV.js → channel-PUQKGSQM.js} +60 -7
- package/dist/{chunk-AYB7XAWO.js → chunk-2TJGRJ4O.js} +114 -279
- package/dist/{chunk-6BDNWYKG.js → chunk-32VR2EOH.js} +2 -2
- package/dist/chunk-4KPUF5JD.js +214 -0
- package/dist/{chunk-QJIIHU32.js → chunk-7NO7EV5Z.js} +2 -2
- package/dist/chunk-AW7P4EVV.js +159 -0
- package/dist/{chunk-2Y77MCFG.js → chunk-DYZGP3EW.js} +2 -2
- package/dist/{chunk-M77QBTEH.js → chunk-EBGCNDMM.js} +24 -14
- package/dist/{chunk-GSPWIM5E.js → chunk-EMQSAY3B.js} +77 -6
- package/dist/{chunk-37X7ECMF.js → chunk-FCDU5BFX.js} +1 -1
- package/dist/chunk-FGV2H4TX.js +803 -0
- package/dist/{chunk-ZCEYUUID.js → chunk-OGXOMR65.js} +2 -1
- package/dist/chunk-OTWLI7F4.js +375 -0
- package/dist/{chunk-GK4E7LM7.js → chunk-RHEGSQFJ.js} +1 -1
- package/dist/{chunk-MVSXRMJJ.js → chunk-SCUDS4US.js} +1 -1
- package/dist/{chunk-FW5API7X.js → chunk-UJ6GHNR7.js} +2 -2
- package/dist/{chunk-OYSZNX5I.js → chunk-VDWCHYTS.js} +1 -1
- package/dist/{chunk-6DVBMLVN.js → chunk-VE4D3GOP.js} +2 -2
- package/dist/chunk-VQWDC6UK.js +142 -0
- package/dist/{chunk-OJQ47SCA.js → chunk-WC6ZHVRL.js} +1 -1
- package/dist/chunk-YUIHSKR6.js +72 -0
- package/dist/chunk-Z524RFCJ.js +36 -0
- package/dist/cli.js +33 -25
- package/dist/{connector-3ELFMI2R.js → connector-JBVNZ7VK.js} +6 -6
- package/dist/connectors/discord.js +2 -2
- package/dist/connectors/slack.js +2 -2
- package/dist/connectors/telegram.js +2 -2
- package/dist/{create-ZWHCRT5F.js → create-HP4OVVHF.js} +6 -4
- package/dist/{daemon-client-ODKDUYDE.js → daemon-client-ITWUCNFO.js} +2 -2
- package/dist/{daemon-restart-2HVTHZAT.js → daemon-restart-JMZM3QY4.js} +8 -8
- package/dist/daemon.js +1144 -1108
- package/dist/db-5ZVC6MQF.js +10 -0
- package/dist/{delete-6G6WEX4F.js → delete-BSU7K3RY.js} +1 -1
- package/dist/delivery-manager-ISTJMZDW.js +16 -0
- package/dist/down-ZY35KMHR.js +14 -0
- package/dist/{env-6IDWGBUH.js → env-A3LMO777.js} +6 -6
- package/dist/export-GCDNQCF3.js +100 -0
- package/dist/{history-YUEKTJ2N.js → history-WNK3DFUM.js} +6 -6
- package/dist/{import-EDGRLIGO.js → import-M63VIUJ5.js} +3 -3
- package/dist/log-PPPZDVEF.js +39 -0
- package/dist/{login-ORQDXLBM.js → login-HNH3EUQV.js} +2 -2
- package/dist/{logout-XC5AUO5I.js → logout-I5CB5UZS.js} +2 -2
- package/dist/{logs-GYOR3L2L.js → logs-SF2IMJN4.js} +6 -6
- package/dist/merge-33C237A4.js +46 -0
- package/dist/{mind-OJN6RBZW.js → mind-PQ5NCPSU.js} +14 -10
- package/dist/mind-manager-RVCFROAY.js +18 -0
- package/dist/{package-OKLFO7UY.js → package-MYE2ZJLV.js} +5 -3
- package/dist/{pages-6IV4VQTU.js → pages-AXCOSY3P.js} +2 -2
- package/dist/{publish-Q4RPSJLL.js → publish-YB377JB7.js} +18 -4
- package/dist/pull-XAEWQJ47.js +39 -0
- package/dist/{register-LDE6LRXY.js → register-VSPCMHKX.js} +2 -2
- package/dist/{restart-YFAWFS5T.js → restart-IQKMCK5M.js} +6 -6
- package/dist/{schedule-AGYLDMNS.js → schedule-LMX7GAQZ.js} +6 -6
- package/dist/schema-5BW7DFZI.js +24 -0
- package/dist/{seed-AP4Q7RZ7.js → seed-J43YDKXG.js} +7 -4
- package/dist/{send-BNDTLUPM.js → send-KVIZIGCE.js} +8 -8
- package/dist/{service-U7MZ2H7F.js → service-LUR7WDO7.js} +6 -6
- package/dist/{setup-DJKIZKGW.js → setup-OH3PJUJO.js} +7 -7
- package/dist/shared-KO35ZM44.js +39 -0
- package/dist/{skill-2Y42P4JY.js → skill-BCVNI6TV.js} +6 -6
- package/{templates/_base/_skills → dist/skills}/orientation/SKILL.md +1 -1
- package/{templates/_base/_skills → dist/skills}/sessions/SKILL.md +2 -2
- package/{templates/_base/_skills → dist/skills}/volute-mind/SKILL.md +19 -1
- package/dist/{sprout-TJ3BHVOG.js → sprout-VBEX63LX.js} +38 -20
- package/dist/{start-3YYRXBKP.js → start-I5JYB65M.js} +6 -6
- package/dist/{status-VSFZYX7S.js → status-4ESFLGH4.js} +5 -5
- package/dist/status-D7E5HHBV.js +35 -0
- package/dist/{status-OKNA6AR3.js → status-JCJAOXTW.js} +2 -2
- package/dist/{stop-AA5K5LYG.js → stop-NBVKEFQQ.js} +6 -6
- package/dist/{up-7B3BWF2U.js → up-WG65SWJU.js} +5 -5
- package/dist/{update-YAGN5ODG.js → update-FJIHDJKM.js} +5 -5
- package/dist/{update-check-APLTH4IN.js → update-check-MWE5AH4U.js} +2 -2
- package/dist/{upgrade-KXZCQSZN.js → upgrade-AIT24B5I.js} +1 -1
- package/dist/{variant-X5QFG6KK.js → variant-63ZWO2W7.js} +4 -4
- package/dist/variants-JAGWGBXG.js +26 -0
- package/dist/web-assets/assets/index-BAbuRsVF.css +1 -0
- package/dist/web-assets/assets/index-CiQhSKi_.js +63 -0
- package/dist/web-assets/index.html +2 -2
- package/drizzle/0010_delivery_queue.sql +12 -0
- package/drizzle/0011_rename_human_to_brain.sql +1 -0
- package/drizzle/meta/0010_snapshot.json +7 -0
- package/drizzle/meta/0011_snapshot.json +7 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +5 -3
- package/templates/_base/.init/.config/hooks/startup-context.sh +1 -1
- package/templates/_base/.init/.config/scripts/session-reader.ts +3 -3
- package/templates/_base/home/VOLUTE.md +16 -1
- package/templates/_base/src/lib/auto-commit.ts +51 -14
- package/templates/_base/src/lib/router.ts +123 -1
- package/templates/_base/src/lib/types.ts +4 -0
- package/templates/_base/src/lib/volute-server.ts +91 -2
- package/templates/claude/src/server.ts +2 -2
- package/templates/claude/volute-template.json +1 -2
- package/templates/pi/src/agent.ts +1 -1
- package/templates/pi/src/lib/session-context-extension.ts +2 -2
- package/templates/pi/volute-template.json +1 -2
- package/dist/chunk-PO5Q2AYN.js +0 -121
- package/dist/down-A56B5JLK.js +0 -14
- package/dist/mind-manager-Z7O7PN2O.js +0 -15
- package/dist/web-assets/assets/index-CtiimdWK.css +0 -1
- package/dist/web-assets/assets/index-kt1_EcuO.js +0 -63
- /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-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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';
|
|
@@ -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.
|
|
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=
|
|
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=".
|
|
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, "../.
|
|
37
|
-
const piSessions = resolve(cwd, "../.
|
|
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 .
|
|
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
|
|
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 (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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(".
|
|
23
|
+
const sessionsDir = resolve(".mind/sessions");
|
|
24
24
|
|
|
25
25
|
// Migrate old single session.json → sessions/main.json
|
|
26
|
-
const oldSessionPath = resolve(".
|
|
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, `.
|
|
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, ".
|
|
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, ".
|
|
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
|
}
|