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,229 @@
|
|
|
1
|
+
// GROOVE — Axom Server Manager (per-session local instances)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// Manages local `axom serve` processes per contract §11: one instance per
|
|
5
|
+
// GROOVE session that wants its own Axom, each with its own port and its own
|
|
6
|
+
// SOVEREIGN --data-dir (ledgers never shared — that isolation is the product,
|
|
7
|
+
// not an implementation detail). Same lifecycle family as LlamaServerManager.
|
|
8
|
+
// The serve binary refuses a locked data-dir; we surface that error verbatim
|
|
9
|
+
// rather than retrying around it.
|
|
10
|
+
|
|
11
|
+
import { spawn } from 'child_process';
|
|
12
|
+
import { mkdirSync, statfsSync } from 'fs';
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
import os from 'os';
|
|
15
|
+
import { AXOM_DEFAULT_PORT } from './axom-connector.js';
|
|
16
|
+
|
|
17
|
+
const START_TIMEOUT_MS = 60000; // model load can be slow on first start
|
|
18
|
+
|
|
19
|
+
// Local-inference floor, learned 2026-07-25: an 8GB Mac loaded the 4GB Q8
|
|
20
|
+
// chassis fine, then llama_decode failed at the Metal working-set ceiling and
|
|
21
|
+
// memory pressure took the whole machine down. Refusing is a feature. The
|
|
22
|
+
// install manifest's min_ram_gb overrides these when present.
|
|
23
|
+
export const AXOM_REQUIREMENTS = {
|
|
24
|
+
minRamGb: 12,
|
|
25
|
+
recommendedRamGb: 16,
|
|
26
|
+
minDiskGb: 6,
|
|
27
|
+
downloadGb: 4.4,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function hardwareReport(dir = os.homedir(), requirements = AXOM_REQUIREMENTS) {
|
|
31
|
+
const totalRamGb = os.totalmem() / 2 ** 30;
|
|
32
|
+
let freeDiskGb = null;
|
|
33
|
+
try {
|
|
34
|
+
const s = statfsSync(dir);
|
|
35
|
+
freeDiskGb = (s.bavail * s.bsize) / 2 ** 30;
|
|
36
|
+
} catch { /* stat unavailable — report null, never guess */ }
|
|
37
|
+
const appleSilicon = process.platform === 'darwin' && process.arch === 'arm64';
|
|
38
|
+
const verdict = totalRamGb < requirements.minRamGb ? 'insufficient'
|
|
39
|
+
: totalRamGb < requirements.recommendedRamGb ? 'marginal' : 'ready';
|
|
40
|
+
return {
|
|
41
|
+
totalRamGb: Math.round(totalRamGb * 10) / 10,
|
|
42
|
+
cpuCores: os.cpus().length,
|
|
43
|
+
arch: process.arch,
|
|
44
|
+
platform: process.platform,
|
|
45
|
+
appleSilicon,
|
|
46
|
+
gpu: appleSilicon ? 'Metal (unified memory)' : null, // CUDA detection: v1
|
|
47
|
+
freeDiskGb: freeDiskGb === null ? null : Math.round(freeDiskGb * 10) / 10,
|
|
48
|
+
diskOk: freeDiskGb === null ? null : freeDiskGb >= requirements.minDiskGb,
|
|
49
|
+
verdict,
|
|
50
|
+
requirements,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class AxomServerManager {
|
|
55
|
+
constructor(daemon, opts = {}) {
|
|
56
|
+
this.daemon = daemon;
|
|
57
|
+
this.command = opts.command || null; // resolved lazily from config
|
|
58
|
+
this.totalRamGbOverride = opts.totalRamGb; // tests inject; prod reads os
|
|
59
|
+
this.instances = new Map(); // id -> {id, proc, port, dataDir, status, startedAt, error}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_command() {
|
|
63
|
+
return this.command || this.daemon.config?.axom?.command || 'axom';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_modelDir() {
|
|
67
|
+
return this.daemon.config?.axom?.modelDir
|
|
68
|
+
|| join(this.daemon.grooveDir, 'axom', 'models');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_allocatePort() {
|
|
72
|
+
const used = new Set([...this.instances.values()].map((i) => i.port));
|
|
73
|
+
for (const ep of this.daemon.axom?.endpoints?.values?.() || []) {
|
|
74
|
+
try { used.add(Number(new URL(ep.url).port)); } catch { /* non-numeric */ }
|
|
75
|
+
}
|
|
76
|
+
let port = AXOM_DEFAULT_PORT;
|
|
77
|
+
while (used.has(port)) port += 1;
|
|
78
|
+
return port;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
list() {
|
|
82
|
+
return [...this.instances.values()].map(({ proc, ...rest }) => rest);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Start a local instance. `id` doubles as the data-dir name, so the same id
|
|
86
|
+
// across restarts resumes the same sovereign memory.
|
|
87
|
+
async start(id = 'default') {
|
|
88
|
+
if (!/^[a-zA-Z0-9_-]{1,40}$/.test(id)) throw new Error('invalid instance id');
|
|
89
|
+
const existing = this.instances.get(id);
|
|
90
|
+
if (existing && existing.status === 'running') return this.list().find((i) => i.id === id);
|
|
91
|
+
|
|
92
|
+
// Hardware floor — refuse under-spec local inference instead of letting
|
|
93
|
+
// it take the host down. Mock mode is weightless and exempt.
|
|
94
|
+
if (!this.daemon.config?.axom?.mock && !this.daemon.config?.axom?.allowUnderspec) {
|
|
95
|
+
const totalRamGb = this.totalRamGbOverride ?? os.totalmem() / 2 ** 30;
|
|
96
|
+
if (totalRamGb < AXOM_REQUIREMENTS.minRamGb) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`This machine has ${Math.round(totalRamGb)}GB RAM — below Axom's ${AXOM_REQUIREMENTS.minRamGb}GB floor for local inference. `
|
|
99
|
+
+ 'Connect to a remote Axom endpoint instead (axom.allowUnderspec overrides at your own risk).',
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const port = this._allocatePort();
|
|
105
|
+
const dataDir = join(this.daemon.grooveDir, 'axom', 'instances', id);
|
|
106
|
+
mkdirSync(dataDir, { recursive: true });
|
|
107
|
+
|
|
108
|
+
const args = [
|
|
109
|
+
'serve',
|
|
110
|
+
'--host', '127.0.0.1',
|
|
111
|
+
'--port', String(port),
|
|
112
|
+
'--data-dir', dataDir,
|
|
113
|
+
'--model-dir', this._modelDir(),
|
|
114
|
+
];
|
|
115
|
+
// config.axom.mock: run the scripted engine (§11 addendum) — the
|
|
116
|
+
// no-weights bring-up mode. /about reports family "mock" so the GUI can
|
|
117
|
+
// never mistake it for the real thing.
|
|
118
|
+
if (this.daemon.config?.axom?.mock) args.push('--mock');
|
|
119
|
+
const instance = {
|
|
120
|
+
id, port, dataDir, status: 'starting', startedAt: Date.now(), error: null, proc: null,
|
|
121
|
+
};
|
|
122
|
+
this.instances.set(id, instance);
|
|
123
|
+
this._broadcast();
|
|
124
|
+
|
|
125
|
+
let proc;
|
|
126
|
+
try {
|
|
127
|
+
proc = spawn(this._command(), args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
128
|
+
} catch (err) {
|
|
129
|
+
instance.status = 'error';
|
|
130
|
+
instance.error = err.message;
|
|
131
|
+
this._broadcast();
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
instance.proc = proc;
|
|
135
|
+
let stderrTail = '';
|
|
136
|
+
proc.stderr?.on('data', (d) => { stderrTail = (stderrTail + d.toString()).slice(-2000); });
|
|
137
|
+
proc.on('error', (err) => {
|
|
138
|
+
instance.status = 'error';
|
|
139
|
+
instance.error = err.code === 'ENOENT'
|
|
140
|
+
? `"${this._command()}" not found — install the Axom runtime first`
|
|
141
|
+
: err.message;
|
|
142
|
+
this._broadcast();
|
|
143
|
+
});
|
|
144
|
+
proc.on('exit', (code, signal) => {
|
|
145
|
+
const wasRunning = instance.status === 'running';
|
|
146
|
+
instance.status = instance.status === 'stopping' ? 'stopped' : 'error';
|
|
147
|
+
if (instance.status === 'error') {
|
|
148
|
+
// The lockfile refusal (§11: two instances on one data-dir) lands
|
|
149
|
+
// here — surface the runtime's own words, don't paraphrase.
|
|
150
|
+
instance.error = instance.error || stderrTail.trim().split('\n').pop() || `exited (code ${code}, signal ${signal})`;
|
|
151
|
+
}
|
|
152
|
+
instance.proc = null;
|
|
153
|
+
if (wasRunning) this._deregisterEndpoint(id);
|
|
154
|
+
this._broadcast();
|
|
155
|
+
this.daemon.audit.log('axom.instance.exit', { id, code, signal });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await this._waitForAbout(port, instance);
|
|
159
|
+
if (instance.status !== 'error') {
|
|
160
|
+
instance.status = 'running';
|
|
161
|
+
this._registerEndpoint(id, port);
|
|
162
|
+
this.daemon.audit.log('axom.instance.start', { id, port, dataDir });
|
|
163
|
+
}
|
|
164
|
+
this._broadcast();
|
|
165
|
+
if (instance.status === 'error') throw new Error(instance.error || 'instance failed to start');
|
|
166
|
+
return this.list().find((i) => i.id === id);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async stop(id) {
|
|
170
|
+
const instance = this.instances.get(id);
|
|
171
|
+
if (!instance) throw new Error(`no instance "${id}"`);
|
|
172
|
+
instance.status = 'stopping';
|
|
173
|
+
this._broadcast();
|
|
174
|
+
this._deregisterEndpoint(id);
|
|
175
|
+
if (instance.proc) {
|
|
176
|
+
instance.proc.kill('SIGTERM');
|
|
177
|
+
await new Promise((resolve) => {
|
|
178
|
+
const t = setTimeout(() => { instance.proc?.kill('SIGKILL'); resolve(); }, 5000);
|
|
179
|
+
instance.proc.once('exit', () => { clearTimeout(t); resolve(); });
|
|
180
|
+
});
|
|
181
|
+
} else {
|
|
182
|
+
instance.status = 'stopped';
|
|
183
|
+
}
|
|
184
|
+
this._broadcast();
|
|
185
|
+
this.daemon.audit.log('axom.instance.stop', { id });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async _waitForAbout(port, instance) {
|
|
189
|
+
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
190
|
+
while (Date.now() < deadline) {
|
|
191
|
+
if (instance.status === 'error') return;
|
|
192
|
+
try {
|
|
193
|
+
const res = await fetch(`http://127.0.0.1:${port}/about`, { signal: AbortSignal.timeout(2000) });
|
|
194
|
+
if (res.ok) return;
|
|
195
|
+
} catch { /* not up yet */ }
|
|
196
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
197
|
+
}
|
|
198
|
+
instance.status = 'error';
|
|
199
|
+
instance.error = instance.error || `no /about on port ${port} within ${START_TIMEOUT_MS / 1000}s`;
|
|
200
|
+
instance.proc?.kill('SIGKILL');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Managed instances surface through the same connector as any endpoint —
|
|
204
|
+
// one socket, every tier (contract §5).
|
|
205
|
+
_registerEndpoint(id, port) {
|
|
206
|
+
const name = `local-${id}`;
|
|
207
|
+
const entries = (this.daemon.config.axom?.endpoints || []).filter((e) => e.name !== name);
|
|
208
|
+
entries.push({ name, url: `http://127.0.0.1:${port}`, managed: true });
|
|
209
|
+
this.daemon.config.axom = { ...(this.daemon.config.axom || {}), endpoints: entries };
|
|
210
|
+
this.daemon.axom.configure(entries);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
_deregisterEndpoint(id) {
|
|
214
|
+
const name = `local-${id}`;
|
|
215
|
+
const entries = (this.daemon.config.axom?.endpoints || []).filter((e) => e.name !== name);
|
|
216
|
+
this.daemon.config.axom = { ...(this.daemon.config.axom || {}), endpoints: entries };
|
|
217
|
+
this.daemon.axom.configure(entries);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
_broadcast() {
|
|
221
|
+
this.daemon.broadcast({ type: 'axom:instances', data: this.list() });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async destroy() {
|
|
225
|
+
for (const id of [...this.instances.keys()]) {
|
|
226
|
+
try { await this.stop(id); } catch { /* shutting down */ }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
|
5
|
+
|
|
6
|
+
// Per-agent history cap and attachment handling mirror the GUI's own limits so
|
|
7
|
+
// the two stay in step.
|
|
8
|
+
const MAX_PER_AGENT = 200;
|
|
9
|
+
const SAVE_DEBOUNCE_MS = 1500;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Server-side chat history.
|
|
13
|
+
*
|
|
14
|
+
* The GUI used to keep chat history only in the browser's localStorage, which
|
|
15
|
+
* is scoped per origin (scheme://host:PORT). For a remote GUI reached over an
|
|
16
|
+
* SSH tunnel the local port changes across reconnects, so the origin — and thus
|
|
17
|
+
* the entire chat store — changed out from under the user, stranding history on
|
|
18
|
+
* old ports. Keeping it on the daemon makes it independent of port, origin, and
|
|
19
|
+
* even which machine connects: reconnect from anywhere and the chats are there.
|
|
20
|
+
*/
|
|
21
|
+
export class ChatStore {
|
|
22
|
+
constructor(daemon) {
|
|
23
|
+
this.daemon = daemon;
|
|
24
|
+
this.path = resolve(daemon.grooveDir, 'chat-history.json');
|
|
25
|
+
this.history = this._load();
|
|
26
|
+
this._saveTimer = null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_load() {
|
|
30
|
+
try {
|
|
31
|
+
if (existsSync(this.path)) {
|
|
32
|
+
const data = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
33
|
+
if (data && typeof data === 'object') return data;
|
|
34
|
+
}
|
|
35
|
+
} catch { /* corrupt or missing — start empty rather than throw */ }
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_scheduleSave() {
|
|
40
|
+
if (this._saveTimer) return;
|
|
41
|
+
this._saveTimer = setTimeout(() => {
|
|
42
|
+
this._saveTimer = null;
|
|
43
|
+
this._saveNow();
|
|
44
|
+
}, SAVE_DEBOUNCE_MS);
|
|
45
|
+
if (this._saveTimer.unref) this._saveTimer.unref();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_saveNow() {
|
|
49
|
+
try {
|
|
50
|
+
const tmp = `${this.path}.tmp`;
|
|
51
|
+
writeFileSync(tmp, JSON.stringify(this.history), { mode: 0o600 });
|
|
52
|
+
renameSync(tmp, this.path); // atomic — a reader never sees a half-written file
|
|
53
|
+
} catch { /* best effort */ }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Strip base64 attachment payloads — metadata only. Keeping data URLs would
|
|
57
|
+
// bloat the file the same way it bloated localStorage.
|
|
58
|
+
_clean(message) {
|
|
59
|
+
if (!message || typeof message !== 'object') return null;
|
|
60
|
+
const out = { ...message };
|
|
61
|
+
if (Array.isArray(out.attachments) && out.attachments.length) {
|
|
62
|
+
out.attachments = out.attachments.map(({ dataUrl, ...rest }) => rest);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
append(agentId, message) {
|
|
68
|
+
if (!agentId) return;
|
|
69
|
+
const clean = this._clean(message);
|
|
70
|
+
if (!clean) return;
|
|
71
|
+
const arr = this.history[agentId] || [];
|
|
72
|
+
arr.push(clean);
|
|
73
|
+
this.history[agentId] = arr.slice(-MAX_PER_AGENT);
|
|
74
|
+
this._scheduleSave();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Replace an agent's whole history — used when the GUI syncs a batch (e.g.
|
|
78
|
+
// messages it recorded while briefly disconnected).
|
|
79
|
+
replace(agentId, messages) {
|
|
80
|
+
if (!agentId || !Array.isArray(messages)) return;
|
|
81
|
+
this.history[agentId] = messages.map((m) => this._clean(m)).filter(Boolean).slice(-MAX_PER_AGENT);
|
|
82
|
+
this._scheduleSave();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
getAll() {
|
|
86
|
+
return this.history;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get(agentId) {
|
|
90
|
+
return this.history[agentId] || [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
remove(agentId) {
|
|
94
|
+
if (this.history[agentId]) {
|
|
95
|
+
delete this.history[agentId];
|
|
96
|
+
this._scheduleSave();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
stop() {
|
|
101
|
+
if (this._saveTimer) { clearTimeout(this._saveTimer); this._saveTimer = null; }
|
|
102
|
+
this._saveNow();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -355,6 +355,12 @@ export class Federation {
|
|
|
355
355
|
};
|
|
356
356
|
}
|
|
357
357
|
|
|
358
|
+
// Public accessor for this daemon's stable id (used by cross-daemon InnerChat
|
|
359
|
+
// relay to identify and verify the sender). Same value as _daemonId().
|
|
360
|
+
getDaemonId() {
|
|
361
|
+
return this._daemonId();
|
|
362
|
+
}
|
|
363
|
+
|
|
358
364
|
_daemonId() {
|
|
359
365
|
// Stable ID derived from keypair — fingerprint of public key
|
|
360
366
|
const idPath = resolve(this.fedDir, 'daemon.id');
|
|
@@ -44,15 +44,19 @@ import { ModelLab } from './model-lab.js';
|
|
|
44
44
|
import { LlamaServerManager } from './llama-server.js';
|
|
45
45
|
import { MLXServerManager } from './mlx-server.js';
|
|
46
46
|
import { RepoImporter } from './repo-import.js';
|
|
47
|
-
import { ConversationManager } from './conversations.js';
|
|
48
47
|
import { Toys } from './toys.js';
|
|
49
48
|
import { InnerChat } from './innerchat.js';
|
|
49
|
+
import { ChatStore } from './chatstore.js';
|
|
50
50
|
import { Watcher } from './watcher.js';
|
|
51
51
|
import { AutoState } from './autostate.js';
|
|
52
52
|
import { Orchestrator } from './orchestrator.js';
|
|
53
53
|
import { TrajectoryCapture, ConsentManager } from '../../../moe-training/client/index.js';
|
|
54
54
|
import { isFirstRun, runFirstTimeSetup, loadConfig, saveConfig, printWelcome } from './firstrun.js';
|
|
55
55
|
import { bindDaemon as bindGrooveNetworkDaemon } from './providers/groove-network.js';
|
|
56
|
+
import { bindDaemon as bindAxomDaemon } from './providers/axom.js';
|
|
57
|
+
import { AxomConnector } from './axom-connector.js';
|
|
58
|
+
import { AxomServerManager } from './axom-server.js';
|
|
59
|
+
import { AxomInstaller } from './axom-install.js';
|
|
56
60
|
import { setProviderPaths } from './providers/index.js';
|
|
57
61
|
|
|
58
62
|
const DEFAULT_PORT = 31415;
|
|
@@ -139,7 +143,6 @@ export class Daemon {
|
|
|
139
143
|
this.rotator = new Rotator(this);
|
|
140
144
|
this.adaptive = new AdaptiveThresholds(this.grooveDir);
|
|
141
145
|
this.teams = new Teams(this);
|
|
142
|
-
this.conversations = new ConversationManager(this);
|
|
143
146
|
this.credentials = new CredentialStore(this.grooveDir);
|
|
144
147
|
this.classifier = new TaskClassifier();
|
|
145
148
|
this.router = new ModelRouter(this);
|
|
@@ -163,9 +166,13 @@ export class Daemon {
|
|
|
163
166
|
this.modelLab = new ModelLab(this);
|
|
164
167
|
this.toys = new Toys(this);
|
|
165
168
|
this.innerchat = new InnerChat(this);
|
|
169
|
+
this.chatStore = new ChatStore(this);
|
|
166
170
|
this.watcher = new Watcher(this);
|
|
167
171
|
this.autoState = new AutoState(this.grooveDir);
|
|
168
172
|
this.orchestrator = new Orchestrator(this);
|
|
173
|
+
this.axom = new AxomConnector(this);
|
|
174
|
+
this.axomServer = new AxomServerManager(this);
|
|
175
|
+
this.axomInstaller = new AxomInstaller(this);
|
|
169
176
|
this.trajectoryCapture = null;
|
|
170
177
|
|
|
171
178
|
// Hook teams.delete to clean up agent-loop session files
|
|
@@ -202,6 +209,7 @@ export class Daemon {
|
|
|
202
209
|
// Give the groove-network provider a handle to the daemon so it can read
|
|
203
210
|
// networkBeta config at spawn time without a circular import.
|
|
204
211
|
bindGrooveNetworkDaemon(this);
|
|
212
|
+
bindAxomDaemon(this);
|
|
205
213
|
|
|
206
214
|
// HTTP + WebSocket server
|
|
207
215
|
this.app = express();
|
|
@@ -626,6 +634,7 @@ export class Daemon {
|
|
|
626
634
|
this.orchestrator.start();
|
|
627
635
|
this.timeline.start();
|
|
628
636
|
this.gateways.start();
|
|
637
|
+
this.axom.start();
|
|
629
638
|
this.federation.initialize();
|
|
630
639
|
this._startGarbageCollector();
|
|
631
640
|
|
|
@@ -874,8 +883,11 @@ export class Daemon {
|
|
|
874
883
|
this.scheduler.stop();
|
|
875
884
|
this.watcher.stop();
|
|
876
885
|
this.innerchat.stop();
|
|
886
|
+
this.chatStore.stop();
|
|
877
887
|
this.orchestrator.stop();
|
|
878
888
|
this.timeline.stop();
|
|
889
|
+
await this.axomServer.destroy();
|
|
890
|
+
this.axom.destroy();
|
|
879
891
|
if (this._gcInterval) clearInterval(this._gcInterval);
|
|
880
892
|
if (this._stateSaveInterval) clearInterval(this._stateSaveInterval);
|
|
881
893
|
if (this._classifierInterval) clearInterval(this._classifierInterval);
|
|
@@ -9,7 +9,13 @@
|
|
|
9
9
|
//
|
|
10
10
|
// No imports on purpose — process.js and introducer.js both pull from here.
|
|
11
11
|
|
|
12
|
-
export function innerChatInstructions(port = 31415, agentName = 'YOUR_NAME') {
|
|
12
|
+
export function innerChatInstructions(port = 31415, agentName = 'YOUR_NAME', peers = []) {
|
|
13
|
+
const aliases = Array.isArray(peers) ? peers.map((p) => p.alias).filter(Boolean) : [];
|
|
14
|
+
const peerRule = aliases.length
|
|
15
|
+
? `- Agents on peer machines are addressed as \`name@peer\` (configured peers: ${aliases.join(', ')}). `
|
|
16
|
+
+ 'A plain name is always local; `name@peer` routes the same ask/tell to that daemon and relays the reply back.'
|
|
17
|
+
: '- To reach an agent on another machine, address it as `name@peer` (a peer must be configured first with '
|
|
18
|
+
+ '`POST /api/innerchat/peers`); a plain name is always local.';
|
|
13
19
|
return [
|
|
14
20
|
'## Consulting Other Agents (InnerChat)',
|
|
15
21
|
'',
|
|
@@ -56,6 +62,7 @@ export function innerChatInstructions(port = 31415, agentName = 'YOUR_NAME') {
|
|
|
56
62
|
'- Only start a conversation when the user asks you to. Never consult other agents on your own initiative.',
|
|
57
63
|
`- \`from\` must be your own agent name${agentName === 'YOUR_NAME' ? ' (it is in your $GROOVE_AGENT_NAME environment variable)' : ''}.`,
|
|
58
64
|
'- Agent names are listed in `AGENTS_REGISTRY.md`, including agents on other teams.',
|
|
65
|
+
peerRule,
|
|
59
66
|
'- Use `ask` when you need the reply now; use `tell` to hand off or notify without waiting.',
|
|
60
67
|
'- One message per call. With `ask`, wait for the answer before deciding what to ask next.',
|
|
61
68
|
'- Ground truth lives in files (specs, code, run output), not in chat — use InnerChat to',
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
//
|
|
3
|
+
// Cross-daemon InnerChat relay transport (spec: plans/cross-daemon-innerchat-spec.md).
|
|
4
|
+
//
|
|
5
|
+
// An agent addresses a peer's agent as `name@alias`. A plain `name` stays purely
|
|
6
|
+
// local (zero behavior change); `name@alias` routes to the daemon configured
|
|
7
|
+
// under that alias. Transport is whatever the user can already reach — a
|
|
8
|
+
// tunnel-forwarded localhost port today, a Tailscale address tomorrow — so this
|
|
9
|
+
// module never assumes the topology: a peer is just a URL. The daemon stays
|
|
10
|
+
// bound to loopback; no new listener is opened.
|
|
11
|
+
|
|
12
|
+
const RELAY_FETCH_TIMEOUT_MS = 30000; // ask holds the peer's request open
|
|
13
|
+
|
|
14
|
+
// Peer config entry: { alias, url, daemonId }. Reuses the Axom connector's
|
|
15
|
+
// endpoint rules for the URL (http(s), no embedded credentials) and pins the
|
|
16
|
+
// peer's federation daemon id so replies/signatures resolve to one identity.
|
|
17
|
+
export function validatePeer(entry) {
|
|
18
|
+
if (!entry || typeof entry !== 'object') return 'peer must be an object';
|
|
19
|
+
const { alias, url, daemonId } = entry;
|
|
20
|
+
if (!alias || typeof alias !== 'string' || !/^[a-zA-Z0-9_-]{1,40}$/.test(alias)) {
|
|
21
|
+
return 'peer alias must be 1-40 chars (letters, digits, dash, underscore)';
|
|
22
|
+
}
|
|
23
|
+
if (!url || typeof url !== 'string') return 'peer url is required';
|
|
24
|
+
let parsed;
|
|
25
|
+
try { parsed = new URL(url); } catch { return `invalid url: ${url}`; }
|
|
26
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
27
|
+
return 'peer url must be http(s)';
|
|
28
|
+
}
|
|
29
|
+
if (parsed.username || parsed.password) return 'credentials in the url are not allowed';
|
|
30
|
+
if (!daemonId || typeof daemonId !== 'string' || !/^[a-f0-9]{6,64}$/.test(daemonId)) {
|
|
31
|
+
return 'peer daemonId must be lowercase hex (the peer daemon\'s federation id)';
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Split an InnerChat address. `name` → local (peer null). `name@alias` → remote.
|
|
37
|
+
// Agent names never contain '@', so it reliably marks a peer reference. Returns
|
|
38
|
+
// null on a malformed reference (empty name or empty/multi alias).
|
|
39
|
+
export function parsePeerRef(ref) {
|
|
40
|
+
if (!ref || typeof ref !== 'string' || !ref.includes('@')) return { name: ref, alias: null };
|
|
41
|
+
const parts = ref.split('@');
|
|
42
|
+
if (parts.length !== 2) return null; // transitive/garbled — one hop only
|
|
43
|
+
const [name, alias] = parts.map((s) => s.trim());
|
|
44
|
+
if (!name || !alias) return null;
|
|
45
|
+
return { name, alias };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Thin HTTP client for the relay. Kept injectable so tests can drive the relay
|
|
49
|
+
// logic without binding two real servers.
|
|
50
|
+
export class RelayClient {
|
|
51
|
+
constructor({ fetchImpl = fetch, timeoutMs = RELAY_FETCH_TIMEOUT_MS } = {}) {
|
|
52
|
+
this.fetchImpl = fetchImpl;
|
|
53
|
+
this.timeoutMs = timeoutMs;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async _post(url, body, timeoutMs) {
|
|
57
|
+
const controller = new AbortController();
|
|
58
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs || this.timeoutMs);
|
|
59
|
+
try {
|
|
60
|
+
const res = await this.fetchImpl(url, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/json' },
|
|
63
|
+
body: JSON.stringify(body),
|
|
64
|
+
signal: controller.signal,
|
|
65
|
+
});
|
|
66
|
+
const json = await res.json().catch(() => ({}));
|
|
67
|
+
return { ok: res.ok, status: res.status, json };
|
|
68
|
+
} finally {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// POST a signed relay envelope to a peer. `{ payload, signature }`.
|
|
74
|
+
sendRelay(peerUrl, envelope, timeoutMs) {
|
|
75
|
+
return this._post(`${trimUrl(peerUrl)}/api/innerchat/relay`, envelope, timeoutMs);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Drain replies queued on a peer for us (async `tell` answers). Signed so the
|
|
79
|
+
// peer can confirm which daemon is asking and hand back only its entries.
|
|
80
|
+
fetchOutbox(peerUrl, envelope, timeoutMs) {
|
|
81
|
+
return this._post(`${trimUrl(peerUrl)}/api/innerchat/relay/outbox`, envelope, timeoutMs);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function trimUrl(url) {
|
|
86
|
+
return url.replace(/\/+$/, '');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export { RELAY_FETCH_TIMEOUT_MS };
|