groove-dev 0.27.196 → 0.27.198
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/axom-integration/Screenshot_2026-07-25_at_5.25.30_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_5.33.26_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_6.11.53_PM.png +0 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +5 -5
- package/node_modules/@groove-dev/daemon/src/axom-connector.js +340 -0
- package/node_modules/@groove-dev/daemon/src/axom-install.js +140 -0
- package/node_modules/@groove-dev/daemon/src/axom-server.js +229 -0
- package/node_modules/@groove-dev/daemon/src/chatstore.js +104 -0
- package/node_modules/@groove-dev/daemon/src/federation.js +6 -0
- package/node_modules/@groove-dev/daemon/src/index.js +14 -2
- package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +8 -1
- package/node_modules/@groove-dev/daemon/src/innerchat-relay.js +89 -0
- package/node_modules/@groove-dev/daemon/src/innerchat.js +261 -1
- package/node_modules/@groove-dev/daemon/src/introducer.js +1 -1
- package/node_modules/@groove-dev/daemon/src/network-guard.js +56 -0
- package/node_modules/@groove-dev/daemon/src/process.js +2 -2
- package/node_modules/@groove-dev/daemon/src/providers/axom.js +66 -0
- package/node_modules/@groove-dev/daemon/src/providers/index.js +2 -0
- package/node_modules/@groove-dev/daemon/src/routes/agents.js +1 -175
- package/node_modules/@groove-dev/daemon/src/routes/axom.js +196 -0
- package/node_modules/@groove-dev/daemon/src/routes/chat-history.js +34 -0
- package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +152 -20
- package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +412 -0
- package/node_modules/@groove-dev/daemon/test/axom-server.test.js +187 -0
- package/node_modules/@groove-dev/daemon/test/chatstore.test.js +86 -0
- package/node_modules/@groove-dev/daemon/test/innerchat-relay.test.js +251 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-RbtaI6l7.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-b9dKN6cq.js +1074 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/App.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/app.css +53 -0
- package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +24 -163
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +52 -2
- package/node_modules/@groove-dev/gui/src/stores/slices/axom-slice.js +197 -0
- package/node_modules/@groove-dev/gui/src/views/axom.jsx +1060 -0
- package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +178 -64
- package/package.json +2 -2
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +5 -5
- package/packages/daemon/src/axom-connector.js +340 -0
- package/packages/daemon/src/axom-install.js +140 -0
- package/packages/daemon/src/axom-server.js +229 -0
- package/packages/daemon/src/chatstore.js +104 -0
- package/packages/daemon/src/federation.js +6 -0
- package/packages/daemon/src/index.js +14 -2
- package/packages/daemon/src/innerchat-docs.js +8 -1
- package/packages/daemon/src/innerchat-relay.js +89 -0
- package/packages/daemon/src/innerchat.js +261 -1
- package/packages/daemon/src/introducer.js +1 -1
- package/packages/daemon/src/network-guard.js +56 -0
- package/packages/daemon/src/process.js +2 -2
- package/packages/daemon/src/providers/axom.js +66 -0
- package/packages/daemon/src/providers/index.js +2 -0
- package/packages/daemon/src/routes/agents.js +1 -175
- package/packages/daemon/src/routes/axom.js +196 -0
- package/packages/daemon/src/routes/chat-history.js +34 -0
- package/packages/daemon/src/routes/innerchat.js +152 -20
- package/packages/gui/dist/assets/index-RbtaI6l7.css +1 -0
- package/packages/gui/dist/assets/index-b9dKN6cq.js +1074 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/App.jsx +2 -2
- package/packages/gui/src/app.css +53 -0
- package/packages/gui/src/components/agents/agent-config.jsx +2 -2
- package/packages/gui/src/components/agents/spawn-wizard.jsx +3 -3
- package/packages/gui/src/components/layout/activity-bar.jsx +3 -3
- package/packages/gui/src/components/layout/breadcrumb-bar.jsx +1 -1
- package/packages/gui/src/components/layout/command-palette.jsx +1 -1
- package/packages/gui/src/stores/groove.js +24 -163
- package/packages/gui/src/stores/slices/agents-slice.js +52 -2
- package/packages/gui/src/stores/slices/axom-slice.js +197 -0
- package/packages/gui/src/views/axom.jsx +1060 -0
- package/packages/gui/src/views/marketplace.jsx +1 -1
- package/packages/gui/src/views/settings.jsx +178 -64
- package/node_modules/@groove-dev/daemon/src/conversations.js +0 -576
- package/node_modules/@groove-dev/gui/dist/assets/index-Cat5pJUx.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-L8xSnvHj.js +0 -1070
- package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +0 -198
- package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +0 -367
- package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +0 -527
- package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +0 -212
- package/node_modules/@groove-dev/gui/src/components/chat/conversation-list.jsx +0 -163
- package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +0 -201
- package/node_modules/@groove-dev/gui/src/stores/slices/chat-slice.js +0 -227
- package/node_modules/@groove-dev/gui/src/views/chat.jsx +0 -6
- package/packages/daemon/src/conversations.js +0 -576
- package/packages/gui/dist/assets/index-Cat5pJUx.css +0 -1
- package/packages/gui/dist/assets/index-L8xSnvHj.js +0 -1070
- package/packages/gui/src/components/chat/chat-header.jsx +0 -198
- package/packages/gui/src/components/chat/chat-input.jsx +0 -367
- package/packages/gui/src/components/chat/chat-messages.jsx +0 -527
- package/packages/gui/src/components/chat/chat-view.jsx +0 -212
- package/packages/gui/src/components/chat/conversation-list.jsx +0 -163
- package/packages/gui/src/components/chat/model-picker.jsx +0 -201
- package/packages/gui/src/stores/slices/chat-slice.js +0 -227
- package/packages/gui/src/views/chat.jsx +0 -6
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { saveConfig } from '../firstrun.js';
|
|
5
|
+
import { validateEndpoint, AXOM_DEFAULT_PORT } from '../axom-connector.js';
|
|
6
|
+
import { hardwareReport } from '../axom-server.js';
|
|
7
|
+
|
|
8
|
+
// Interrupt text is capped runtime-side at 2000 chars (contract §2, with a
|
|
9
|
+
// `truncated` flag in the response); we allow headroom and let the runtime be
|
|
10
|
+
// the authority on truncation — its flag is the honest signal, not ours.
|
|
11
|
+
const MAX_INTERRUPT_CHARS = 8000;
|
|
12
|
+
|
|
13
|
+
export function registerAxomRoutes(app, daemon) {
|
|
14
|
+
app.get('/api/axom/status', (req, res) => {
|
|
15
|
+
res.json(daemon.axom.status());
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
app.get('/api/axom/sessions', (req, res) => {
|
|
19
|
+
const sessions = daemon.axom.status().endpoints.flatMap((ep) =>
|
|
20
|
+
ep.sessions.map((s) => ({ endpoint: ep.name, ...s })));
|
|
21
|
+
res.json(sessions);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// In-daemon ring backfill for the GUI (initial load / scrubber). `since` is
|
|
25
|
+
// an envelope id ("ev-000123"); events after it are returned.
|
|
26
|
+
app.get('/api/axom/sessions/:id/events', (req, res) => {
|
|
27
|
+
try {
|
|
28
|
+
const sinceSeq = /^ev-\d+$/.test(req.query.since || '')
|
|
29
|
+
? parseInt(req.query.since.slice(3), 10) : 0;
|
|
30
|
+
res.json(daemon.axom.events(req.query.endpoint, req.params.id, sinceSeq));
|
|
31
|
+
} catch (err) {
|
|
32
|
+
res.status(404).json({ error: err.message });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// §12: message starts a turn. Contract statuses (202/409/413) mirror
|
|
37
|
+
// through verbatim — the GUI reads them, we don't reinterpret.
|
|
38
|
+
app.post('/api/axom/sessions/:id/message', async (req, res) => {
|
|
39
|
+
try {
|
|
40
|
+
const { text, endpoint } = req.body || {};
|
|
41
|
+
if (!text || typeof text !== 'string' || !text.trim()) {
|
|
42
|
+
return res.status(400).json({ error: 'text is required' });
|
|
43
|
+
}
|
|
44
|
+
const result = await daemon.axom.message(endpoint, req.params.id, text);
|
|
45
|
+
daemon.audit.log('axom.message', { session: req.params.id, chars: text.length, status: result.status });
|
|
46
|
+
res.status(result.status).json(result.body);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
res.status(502).json({ error: err.message });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
app.post('/api/axom/sessions/:id/interrupt', async (req, res) => {
|
|
53
|
+
try {
|
|
54
|
+
const { text, endpoint } = req.body || {};
|
|
55
|
+
if (!text || typeof text !== 'string' || !text.trim()) {
|
|
56
|
+
return res.status(400).json({ error: 'text is required' });
|
|
57
|
+
}
|
|
58
|
+
if (text.length > MAX_INTERRUPT_CHARS) {
|
|
59
|
+
return res.status(400).json({ error: `text exceeds ${MAX_INTERRUPT_CHARS} chars` });
|
|
60
|
+
}
|
|
61
|
+
const result = await daemon.axom.interrupt(endpoint, req.params.id, text);
|
|
62
|
+
daemon.audit.log('axom.interrupt', { session: req.params.id, chars: text.length });
|
|
63
|
+
res.json(result); // verbatim {id, truncated} from the runtime
|
|
64
|
+
} catch (err) {
|
|
65
|
+
res.status(502).json({ error: err.message });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
app.post('/api/axom/sessions/:id/stop', async (req, res) => {
|
|
70
|
+
try {
|
|
71
|
+
const result = await daemon.axom.stop(req.body?.endpoint, req.params.id);
|
|
72
|
+
daemon.audit.log('axom.stop', { session: req.params.id });
|
|
73
|
+
res.json(result);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
res.status(502).json({ error: err.message });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Machine readiness for local inference — powers the welcome page's
|
|
80
|
+
// "can this device run Axom?" verdict.
|
|
81
|
+
app.get('/api/axom/hardware', (req, res) => {
|
|
82
|
+
res.json(hardwareReport(daemon.grooveDir));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// This GROOVE's own Axom endpoint — what another instance would paste.
|
|
86
|
+
// serve binds 127.0.0.1 (§6), so cross-machine reach is the user's own
|
|
87
|
+
// channel; we hand them the exact tunnel one-liner rather than pretending
|
|
88
|
+
// the URL travels on its own.
|
|
89
|
+
app.get('/api/axom/my-endpoint', (req, res) => {
|
|
90
|
+
const running = daemon.axomServer.list().find((i) => i.status === 'running');
|
|
91
|
+
const port = running?.port || AXOM_DEFAULT_PORT;
|
|
92
|
+
const host = os.hostname();
|
|
93
|
+
const user = os.userInfo().username;
|
|
94
|
+
res.json({
|
|
95
|
+
url: `http://127.0.0.1:${port}`,
|
|
96
|
+
port,
|
|
97
|
+
host,
|
|
98
|
+
running: !!running,
|
|
99
|
+
instanceId: running?.id || null,
|
|
100
|
+
tunnelCommand: `ssh -N -L ${port}:localhost:${port} ${user}@${host}`,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ── Managed local instances (contract §11) ──────────────────────────────
|
|
105
|
+
|
|
106
|
+
app.get('/api/axom/instances', (req, res) => {
|
|
107
|
+
res.json(daemon.axomServer.list());
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
app.post('/api/axom/instances', async (req, res) => {
|
|
111
|
+
try {
|
|
112
|
+
const instance = await daemon.axomServer.start(req.body?.id || 'default');
|
|
113
|
+
res.json(instance);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
res.status(500).json({ error: err.message });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
app.delete('/api/axom/instances/:id', async (req, res) => {
|
|
120
|
+
try {
|
|
121
|
+
await daemon.axomServer.stop(req.params.id);
|
|
122
|
+
res.json({ ok: true });
|
|
123
|
+
} catch (err) {
|
|
124
|
+
res.status(400).json({ error: err.message });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ── Manifest-driven install (contract §11) ──────────────────────────────
|
|
129
|
+
|
|
130
|
+
app.get('/api/axom/install', (req, res) => {
|
|
131
|
+
res.json(daemon.axomInstaller.getStatus());
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
app.post('/api/axom/install', (req, res) => {
|
|
135
|
+
// Long-running — kick off and let the axom:install:progress broadcasts
|
|
136
|
+
// carry the story; the GUI polls GET /api/axom/install as backstop.
|
|
137
|
+
daemon.axomInstaller.install(req.body?.manifestUrl).catch(() => { /* status carries the error */ });
|
|
138
|
+
res.json({ started: true });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
app.get('/api/axom/config', (req, res) => {
|
|
142
|
+
res.json({ endpoints: daemon.config.axom?.endpoints || [] });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
app.patch('/api/axom/config', (req, res) => {
|
|
146
|
+
const { endpoints, command, mock, manifestUrl, modelDir } = req.body || {};
|
|
147
|
+
|
|
148
|
+
// Runtime settings (contract §11): serve binary path, mock mode,
|
|
149
|
+
// install-manifest URL, model dir. Any subset may be patched alone.
|
|
150
|
+
const settings = {};
|
|
151
|
+
if (command !== undefined) {
|
|
152
|
+
if (command !== null && (typeof command !== 'string' || !command.trim())) {
|
|
153
|
+
return res.status(400).json({ error: 'command must be a non-empty string or null' });
|
|
154
|
+
}
|
|
155
|
+
settings.command = command;
|
|
156
|
+
}
|
|
157
|
+
if (mock !== undefined) settings.mock = !!mock;
|
|
158
|
+
if (manifestUrl !== undefined) {
|
|
159
|
+
if (manifestUrl !== null && !/^https?:\/\//.test(manifestUrl)) {
|
|
160
|
+
return res.status(400).json({ error: 'manifestUrl must be http(s) or null' });
|
|
161
|
+
}
|
|
162
|
+
settings.manifestUrl = manifestUrl;
|
|
163
|
+
}
|
|
164
|
+
if (modelDir !== undefined) settings.modelDir = modelDir;
|
|
165
|
+
if (Object.keys(settings).length > 0) {
|
|
166
|
+
daemon.config.axom = { ...(daemon.config.axom || {}), ...settings };
|
|
167
|
+
if (endpoints === undefined) {
|
|
168
|
+
saveConfig(daemon.grooveDir, daemon.config);
|
|
169
|
+
daemon.audit.log('axom.config', { keys: Object.keys(settings) });
|
|
170
|
+
return res.json({ ...daemon.config.axom });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!Array.isArray(endpoints)) {
|
|
175
|
+
return res.status(400).json({ error: 'endpoints array is required' });
|
|
176
|
+
}
|
|
177
|
+
if (endpoints.length > 16) {
|
|
178
|
+
return res.status(400).json({ error: 'too many endpoints (max 16)' });
|
|
179
|
+
}
|
|
180
|
+
const seen = new Set();
|
|
181
|
+
for (const entry of endpoints) {
|
|
182
|
+
const problem = validateEndpoint(entry);
|
|
183
|
+
if (problem) return res.status(400).json({ error: problem });
|
|
184
|
+
if (seen.has(entry.name)) {
|
|
185
|
+
return res.status(400).json({ error: `duplicate endpoint name: ${entry.name}` });
|
|
186
|
+
}
|
|
187
|
+
seen.add(entry.name);
|
|
188
|
+
}
|
|
189
|
+
const cleaned = endpoints.map(({ name, url }) => ({ name, url: url.replace(/\/+$/, '') }));
|
|
190
|
+
daemon.config.axom = { ...(daemon.config.axom || {}), endpoints: cleaned };
|
|
191
|
+
saveConfig(daemon.grooveDir, daemon.config);
|
|
192
|
+
daemon.axom.configure(cleaned);
|
|
193
|
+
daemon.audit.log('axom.config', { endpoints: cleaned.map((e) => e.name) });
|
|
194
|
+
res.json({ endpoints: cleaned });
|
|
195
|
+
});
|
|
196
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
|
|
3
|
+
export function registerChatHistoryRoutes(app, daemon) {
|
|
4
|
+
// Full history for all agents — the GUI loads this on connect so chats are
|
|
5
|
+
// present regardless of which origin/port the tunnel came up on.
|
|
6
|
+
app.get('/api/chat-history', (req, res) => {
|
|
7
|
+
res.json({ history: daemon.chatStore.getAll() });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// Append a single message for an agent.
|
|
11
|
+
app.post('/api/chat-history/:agentId', (req, res) => {
|
|
12
|
+
const { message } = req.body || {};
|
|
13
|
+
if (!message || typeof message !== 'object') {
|
|
14
|
+
return res.status(400).json({ error: 'message object required' });
|
|
15
|
+
}
|
|
16
|
+
daemon.chatStore.append(req.params.agentId, message);
|
|
17
|
+
res.json({ ok: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Replace an agent's whole history (batch sync).
|
|
21
|
+
app.put('/api/chat-history/:agentId', (req, res) => {
|
|
22
|
+
const { messages } = req.body || {};
|
|
23
|
+
if (!Array.isArray(messages)) {
|
|
24
|
+
return res.status(400).json({ error: 'messages array required' });
|
|
25
|
+
}
|
|
26
|
+
daemon.chatStore.replace(req.params.agentId, messages);
|
|
27
|
+
res.json({ ok: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
app.delete('/api/chat-history/:agentId', (req, res) => {
|
|
31
|
+
daemon.chatStore.remove(req.params.agentId);
|
|
32
|
+
res.json({ ok: true });
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
2
|
|
|
3
3
|
import { MAX_EXCHANGES } from '../innerchat.js';
|
|
4
|
+
import { validatePeer, parsePeerRef } from '../innerchat-relay.js';
|
|
5
|
+
import { saveConfig } from '../firstrun.js';
|
|
4
6
|
|
|
5
7
|
// Agents know each other by name, not id. Exact matches win first so
|
|
6
8
|
// `fullstack-1` never resolves to `fullstack-14`; only if nothing matches
|
|
@@ -22,14 +24,16 @@ function resolveAgent(daemon, ref) {
|
|
|
22
24
|
return partial.length === 1 ? partial[0] : null;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
function resolveParties(daemon, req, res) {
|
|
28
|
-
const { from, to, message } = req.body || {};
|
|
29
|
-
|
|
27
|
+
// The calling agent is always local. Writes a 404 and returns null on miss.
|
|
28
|
+
function resolveFrom(daemon, from, res) {
|
|
30
29
|
const fromAgent = resolveAgent(daemon, from);
|
|
31
30
|
if (!fromAgent) { res.status(404).json({ error: `Unknown calling agent: ${from}` }); return null; }
|
|
31
|
+
return fromAgent;
|
|
32
|
+
}
|
|
32
33
|
|
|
34
|
+
// Resolve a LOCAL to/message pair, or write the appropriate 400/404 and return
|
|
35
|
+
// null. Used only when `to` is a plain (non-peer) name.
|
|
36
|
+
function resolveLocalTo(daemon, fromAgent, to, message, res) {
|
|
33
37
|
const toAgent = resolveAgent(daemon, to);
|
|
34
38
|
if (!toAgent) {
|
|
35
39
|
const others = daemon.registry.getAll().filter((a) => a.id !== fromAgent.id);
|
|
@@ -42,32 +46,63 @@ function resolveParties(daemon, req, res) {
|
|
|
42
46
|
}
|
|
43
47
|
return null;
|
|
44
48
|
}
|
|
45
|
-
|
|
46
49
|
if (!message || typeof message !== 'string' || !message.trim()) {
|
|
47
50
|
res.status(400).json({ error: 'message is required' }); return null;
|
|
48
51
|
}
|
|
49
|
-
return {
|
|
52
|
+
return { toAgent, message: message.trim() };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Confirm a relay request is from a configured, signature-verified peer.
|
|
56
|
+
// Returns the peer record, or writes a 403 and returns null.
|
|
57
|
+
function verifyRelayCaller(daemon, body, res) {
|
|
58
|
+
const { payload, signature } = body || {};
|
|
59
|
+
if (!payload || typeof payload !== 'object' || !signature) {
|
|
60
|
+
res.status(400).json({ error: 'signed relay envelope required ({ payload, signature })' });
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const fromDaemonId = payload.fromDaemonId;
|
|
64
|
+
const peer = daemon.innerchat._peerByDaemonId(fromDaemonId);
|
|
65
|
+
if (!peer) {
|
|
66
|
+
res.status(403).json({ error: `Unknown daemon "${String(fromDaemonId).slice(0, 16)}" — not a configured InnerChat peer.` });
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (!daemon.federation.verify(fromDaemonId, payload, signature)) {
|
|
70
|
+
res.status(403).json({ error: 'Signature verification failed.' });
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return peer;
|
|
50
74
|
}
|
|
51
75
|
|
|
52
76
|
export function registerInnerChatRoutes(app, daemon) {
|
|
53
77
|
/**
|
|
54
78
|
* Ask another agent a question and BLOCK until it answers.
|
|
55
79
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* interface negotiation, where the finite exchange budget keeps both sides
|
|
59
|
-
* writing decision-dense messages.
|
|
80
|
+
* `to` may be a local name or `name@peer`. A peer target is relayed to the
|
|
81
|
+
* configured peer daemon; the reply rides the still-open response back.
|
|
60
82
|
*/
|
|
61
83
|
app.post('/api/innerchat/ask', async (req, res) => {
|
|
62
84
|
try {
|
|
63
|
-
const
|
|
64
|
-
|
|
85
|
+
const { from, to, message } = req.body || {};
|
|
86
|
+
const fromAgent = resolveFrom(daemon, from, res);
|
|
87
|
+
if (!fromAgent) return;
|
|
65
88
|
|
|
66
89
|
// Held open until the target answers — see the class doc in innerchat.js.
|
|
67
90
|
req.setTimeout(0);
|
|
68
91
|
res.setTimeout(0);
|
|
69
92
|
|
|
70
|
-
|
|
93
|
+
if (daemon.innerchat.isRemoteRef(to)) {
|
|
94
|
+
const { name, alias } = parsePeerRef(to);
|
|
95
|
+
if (!message || !String(message).trim()) return res.status(400).json({ error: 'message is required' });
|
|
96
|
+
const result = await daemon.innerchat.askRemote(fromAgent, name, alias, String(message).trim(), {
|
|
97
|
+
timeoutMs: req.body?.timeoutMs,
|
|
98
|
+
});
|
|
99
|
+
return res.json({ from: `${name}@${alias}`, reply: result.reply, threadId: result.threadId, remote: true });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const parties = resolveLocalTo(daemon, fromAgent, to, message, res);
|
|
103
|
+
if (!parties) return;
|
|
104
|
+
|
|
105
|
+
const result = await daemon.innerchat.ask(fromAgent.id, parties.toAgent.id, parties.message, {
|
|
71
106
|
timeoutMs: req.body?.timeoutMs,
|
|
72
107
|
});
|
|
73
108
|
|
|
@@ -86,17 +121,34 @@ export function registerInnerChatRoutes(app, daemon) {
|
|
|
86
121
|
});
|
|
87
122
|
|
|
88
123
|
/**
|
|
89
|
-
* Send a message WITHOUT blocking
|
|
90
|
-
* target
|
|
91
|
-
*
|
|
92
|
-
* where waiting out a timeout would waste the turn.
|
|
124
|
+
* Send a message WITHOUT blocking. `to` may be local or `name@peer`. For a
|
|
125
|
+
* peer target the reply (if any) is queued on the peer and picked up by this
|
|
126
|
+
* daemon's outbox poll, then routed back to the sender.
|
|
93
127
|
*/
|
|
94
128
|
app.post('/api/innerchat/tell', async (req, res) => {
|
|
95
129
|
try {
|
|
96
|
-
const
|
|
130
|
+
const { from, to, message } = req.body || {};
|
|
131
|
+
const fromAgent = resolveFrom(daemon, from, res);
|
|
132
|
+
if (!fromAgent) return;
|
|
133
|
+
|
|
134
|
+
if (daemon.innerchat.isRemoteRef(to)) {
|
|
135
|
+
const { name, alias } = parsePeerRef(to);
|
|
136
|
+
if (!message || !String(message).trim()) return res.status(400).json({ error: 'message is required' });
|
|
137
|
+
const result = await daemon.innerchat.tellRemote(fromAgent, name, alias, String(message).trim());
|
|
138
|
+
return res.json({
|
|
139
|
+
ok: true,
|
|
140
|
+
to: `${name}@${alias}`,
|
|
141
|
+
delivered: result.delivered,
|
|
142
|
+
threadId: result.threadId,
|
|
143
|
+
remote: true,
|
|
144
|
+
note: `Message relayed to ${name}@${alias}. Its reply, if any, will be routed back to you — you can end your turn.`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const parties = resolveLocalTo(daemon, fromAgent, to, message, res);
|
|
97
149
|
if (!parties) return;
|
|
98
150
|
|
|
99
|
-
const result = await daemon.innerchat.tell(
|
|
151
|
+
const result = await daemon.innerchat.tell(fromAgent.id, parties.toAgent.id, parties.message, {
|
|
100
152
|
threadId: req.body?.threadId,
|
|
101
153
|
});
|
|
102
154
|
|
|
@@ -115,6 +167,86 @@ export function registerInnerChatRoutes(app, daemon) {
|
|
|
115
167
|
}
|
|
116
168
|
});
|
|
117
169
|
|
|
170
|
+
// ── Cross-daemon relay endpoints (peer daemon → this daemon) ──
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Receive a relayed ask/tell from a peer daemon. Signature-verified and
|
|
174
|
+
* restricted to configured peers. Resolves the target locally and delivers
|
|
175
|
+
* with a guest identity. For 'ask' the response is held open until the local
|
|
176
|
+
* agent answers.
|
|
177
|
+
*/
|
|
178
|
+
app.post('/api/innerchat/relay', async (req, res) => {
|
|
179
|
+
const peer = verifyRelayCaller(daemon, req.body, res);
|
|
180
|
+
if (!peer) return;
|
|
181
|
+
const p = req.body.payload;
|
|
182
|
+
if (p.type !== 'innerchat.relay' || (p.kind !== 'ask' && p.kind !== 'tell')) {
|
|
183
|
+
return res.status(400).json({ error: 'invalid relay payload' });
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
req.setTimeout(0);
|
|
187
|
+
res.setTimeout(0);
|
|
188
|
+
const result = await daemon.innerchat.receiveRelay({
|
|
189
|
+
fromName: p.fromName,
|
|
190
|
+
fromRole: p.fromRole,
|
|
191
|
+
fromDaemonId: p.fromDaemonId,
|
|
192
|
+
peerAlias: peer.alias,
|
|
193
|
+
toName: p.toName,
|
|
194
|
+
message: p.message,
|
|
195
|
+
kind: p.kind,
|
|
196
|
+
threadId: p.threadId,
|
|
197
|
+
timeoutMs: p.timeoutMs,
|
|
198
|
+
});
|
|
199
|
+
res.json(result);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
const body = { error: err.message };
|
|
202
|
+
if (err.availableAgents) body.availableAgents = err.availableAgents;
|
|
203
|
+
res.status(404).json(body);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Drain this daemon's outbox of async (tell) replies queued for the calling
|
|
209
|
+
* peer. Signature-verified; returns and removes only that peer's entries.
|
|
210
|
+
*/
|
|
211
|
+
app.post('/api/innerchat/relay/outbox', (req, res) => {
|
|
212
|
+
const peer = verifyRelayCaller(daemon, req.body, res);
|
|
213
|
+
if (!peer) return;
|
|
214
|
+
if (req.body.payload.type !== 'innerchat.outbox') {
|
|
215
|
+
return res.status(400).json({ error: 'invalid outbox payload' });
|
|
216
|
+
}
|
|
217
|
+
res.json({ entries: daemon.innerchat.drainOutbox(req.body.payload.fromDaemonId) });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ── Peer configuration (alias ↔ url ↔ daemonId) ──────────────
|
|
221
|
+
|
|
222
|
+
app.get('/api/innerchat/peers', (req, res) => {
|
|
223
|
+
res.json({ peers: daemon.innerchat._peers() });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
app.post('/api/innerchat/peers', (req, res) => {
|
|
227
|
+
const entry = { alias: req.body?.alias, url: req.body?.url, daemonId: req.body?.daemonId };
|
|
228
|
+
const err = validatePeer(entry);
|
|
229
|
+
if (err) return res.status(400).json({ error: err });
|
|
230
|
+
entry.url = entry.url.replace(/\/+$/, '');
|
|
231
|
+
|
|
232
|
+
const peers = Array.isArray(daemon.config.innerchatPeers) ? daemon.config.innerchatPeers : [];
|
|
233
|
+
const next = peers.filter((p) => p.alias !== entry.alias);
|
|
234
|
+
next.push(entry);
|
|
235
|
+
daemon.config.innerchatPeers = next;
|
|
236
|
+
saveConfig(daemon.grooveDir, daemon.config);
|
|
237
|
+
daemon.audit.log('innerchat.peer.set', { alias: entry.alias, url: entry.url });
|
|
238
|
+
res.json({ peers: next });
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
app.delete('/api/innerchat/peers/:alias', (req, res) => {
|
|
242
|
+
const peers = Array.isArray(daemon.config.innerchatPeers) ? daemon.config.innerchatPeers : [];
|
|
243
|
+
const next = peers.filter((p) => p.alias !== req.params.alias);
|
|
244
|
+
daemon.config.innerchatPeers = next;
|
|
245
|
+
saveConfig(daemon.grooveDir, daemon.config);
|
|
246
|
+
daemon.audit.log('innerchat.peer.remove', { alias: req.params.alias });
|
|
247
|
+
res.json({ peers: next });
|
|
248
|
+
});
|
|
249
|
+
|
|
118
250
|
app.get('/api/innerchat/threads', (req, res) => {
|
|
119
251
|
const { agentId } = req.query;
|
|
120
252
|
res.json({ threads: daemon.innerchat.getThreads(agentId || null) });
|