groove-dev 0.27.208 → 0.27.210
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/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/axom-runtimes.js +270 -7
- package/node_modules/@groove-dev/daemon/src/axom-server.js +3 -1
- package/node_modules/@groove-dev/daemon/src/routes/axom.js +57 -0
- package/node_modules/@groove-dev/daemon/test/axom-runtimes.test.js +232 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-DdCadtGL.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/{index-3Lzv2-To.js → index-YHDeARYl.js} +241 -236
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/axom-runtimes.js +270 -7
- package/packages/daemon/src/axom-server.js +3 -1
- package/packages/daemon/src/routes/axom.js +57 -0
- package/packages/gui/dist/assets/index-DdCadtGL.css +1 -0
- package/packages/gui/dist/assets/{index-3Lzv2-To.js → index-YHDeARYl.js} +241 -236
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-Bh_HF8ed.css +0 -1
- package/packages/gui/dist/assets/index-Bh_HF8ed.css +0 -1
|
@@ -20,6 +20,41 @@ import { validateEndpoint } from './axom-connector.js';
|
|
|
20
20
|
import { validateRemote } from './axom-remote.js';
|
|
21
21
|
import { saveConfig } from './firstrun.js';
|
|
22
22
|
|
|
23
|
+
// The blessed launch spec's env (SPARK_DEV_SETUP.md / §10). These change
|
|
24
|
+
// runtime BEHAVIOUR, not just paths — a spec missing AXOM_MAX_CTX boots a
|
|
25
|
+
// 2048-ctx runtime that looks fine and answers worse. Applied as DEFAULTS
|
|
26
|
+
// only: a value the user set in their own spec always wins, because "GROOVE
|
|
27
|
+
// never edits a spec's flags" is the standing ruling.
|
|
28
|
+
export const BLESSED_ENV = { AXOM_MAX_CTX: '8192' };
|
|
29
|
+
|
|
30
|
+
function withBlessedEnv(launch) {
|
|
31
|
+
if (!launch?.command) return launch;
|
|
32
|
+
return { ...launch, env: { ...BLESSED_ENV, ...(launch.env || {}) } };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A chat title is the opening message, trimmed to a glanceable length. It is
|
|
36
|
+
// a QUOTE, not a summary: GROOVE has no business paraphrasing what the user
|
|
37
|
+
// said, and an em-dash ellipsis makes the truncation visible rather than
|
|
38
|
+
// pretending the sentence ended there.
|
|
39
|
+
const TITLE_MAX = 48;
|
|
40
|
+
export function summarizeForTitle(text) {
|
|
41
|
+
if (typeof text !== 'string') return null;
|
|
42
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
43
|
+
if (!flat) return null;
|
|
44
|
+
if (flat.length <= TITLE_MAX) return flat;
|
|
45
|
+
// Prefer a word boundary so titles don't end mid-word.
|
|
46
|
+
const cut = flat.slice(0, TITLE_MAX);
|
|
47
|
+
const space = cut.lastIndexOf(' ');
|
|
48
|
+
return `${(space > TITLE_MAX * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Single-quote for a POSIX shell. The spec is the user's own, but it crosses
|
|
52
|
+
// an ssh command line — an unquoted path or value must not be able to end the
|
|
53
|
+
// command and start another.
|
|
54
|
+
function shellQuote(s) {
|
|
55
|
+
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
56
|
+
}
|
|
57
|
+
|
|
23
58
|
export function validateRuntime(rt) {
|
|
24
59
|
if (!rt || typeof rt !== 'object') return 'runtime must be an object';
|
|
25
60
|
if (!rt.id || !/^[a-zA-Z0-9_-]{1,40}$/.test(rt.id)) return 'invalid runtime id';
|
|
@@ -39,6 +74,18 @@ export function validateRuntime(rt) {
|
|
|
39
74
|
|| rt.launch.command.length === 0 || rt.launch.command.length > 500) {
|
|
40
75
|
return 'launch.command must be a non-empty string of at most 500 chars';
|
|
41
76
|
}
|
|
77
|
+
if (rt.launch.cwd !== undefined && typeof rt.launch.cwd !== 'string') {
|
|
78
|
+
return 'launch.cwd must be a string';
|
|
79
|
+
}
|
|
80
|
+
if (rt.launch.env !== undefined) {
|
|
81
|
+
if (typeof rt.launch.env !== 'object' || rt.launch.env === null || Array.isArray(rt.launch.env)) {
|
|
82
|
+
return 'launch.env must be an object of name/value pairs';
|
|
83
|
+
}
|
|
84
|
+
for (const [k, v] of Object.entries(rt.launch.env)) {
|
|
85
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) return `invalid env var name "${k}"`;
|
|
86
|
+
if (typeof v !== 'string' && typeof v !== 'number') return `env var "${k}" must be a string or number`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
42
89
|
}
|
|
43
90
|
return null;
|
|
44
91
|
}
|
|
@@ -74,13 +121,26 @@ export class AxomRuntimes {
|
|
|
74
121
|
// are kept until the model proves out — compat routes still read them.
|
|
75
122
|
migrate() {
|
|
76
123
|
const cfg = this._cfg();
|
|
77
|
-
|
|
124
|
+
// Marker, not mere presence of the array: an earlier migration could write
|
|
125
|
+
// an EMPTY runtimes[] and then never retry, stranding a configured host
|
|
126
|
+
// behind a first-run splash forever. Re-run until it produces something or
|
|
127
|
+
// there is genuinely nothing legacy left to fold.
|
|
128
|
+
if (cfg.runtimesMigrated) return false;
|
|
129
|
+
if (Array.isArray(cfg.runtimes) && cfg.runtimes.length) {
|
|
130
|
+
cfg.runtimesMigrated = true;
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
78
133
|
const runtimes = [];
|
|
79
134
|
const remote = cfg.remote || null;
|
|
80
135
|
const ep = (cfg.endpoints || [])[0] || null;
|
|
81
|
-
|
|
136
|
+
// A remote host is a configured runtime whether or not an endpoint entry
|
|
137
|
+
// survives beside it — the endpoint list gets cleared by a disconnect, and
|
|
138
|
+
// dropping the host on that basis would strand a machine the user set up
|
|
139
|
+
// and show them a first-run splash instead.
|
|
140
|
+
if (remote?.host && (!ep || ep.url.endsWith(`:${remote.port || 8737}`))) {
|
|
82
141
|
runtimes.push({
|
|
83
|
-
id: 'spark', name: remote.host.split('.')[0] || 'Remote',
|
|
142
|
+
id: 'spark', name: remote.host.split('.')[0] || 'Remote',
|
|
143
|
+
url: ep?.url || `http://127.0.0.1:${remote.port || 8737}`,
|
|
84
144
|
control: 'ssh',
|
|
85
145
|
ssh: { host: remote.host, user: remote.user, sshPort: remote.sshPort, autoTunnel: true },
|
|
86
146
|
launch: remote.command ? { command: remote.command } : undefined,
|
|
@@ -92,7 +152,10 @@ export class AxomRuntimes {
|
|
|
92
152
|
runtimes.push({ id: ep.name || 'axom', name, url: ep.url, control: 'none' });
|
|
93
153
|
}
|
|
94
154
|
cfg.runtimes = runtimes;
|
|
95
|
-
if (runtimes.length
|
|
155
|
+
if (runtimes.length) {
|
|
156
|
+
cfg.runtimesMigrated = true;
|
|
157
|
+
if (!cfg.activeRuntimeId) cfg.activeRuntimeId = runtimes[0].id;
|
|
158
|
+
}
|
|
96
159
|
return true;
|
|
97
160
|
}
|
|
98
161
|
|
|
@@ -101,7 +164,9 @@ export class AxomRuntimes {
|
|
|
101
164
|
}
|
|
102
165
|
|
|
103
166
|
start() {
|
|
104
|
-
|
|
167
|
+
// Persist it: an unsaved migration re-derives on every boot, so a runtime
|
|
168
|
+
// the user later removed would come back from the legacy keys each time.
|
|
169
|
+
if (this.migrate()) this._save();
|
|
105
170
|
this._syncConnector();
|
|
106
171
|
}
|
|
107
172
|
|
|
@@ -231,20 +296,218 @@ export class AxomRuntimes {
|
|
|
231
296
|
canStart: rt.control !== 'none' && derived.state === 'stopped',
|
|
232
297
|
canStop: rt.control !== 'none' && (derived.state === 'connected' || derived.state === 'running'),
|
|
233
298
|
canHeal: rt.control === 'ssh' && derived.state === 'unreachable',
|
|
299
|
+
...this.generation(rt.id),
|
|
234
300
|
};
|
|
235
301
|
}));
|
|
236
302
|
return { runtimes, activeRuntimeId: this.activeId() };
|
|
237
303
|
}
|
|
238
304
|
|
|
305
|
+
// ── Mono-Axom (§10) ───────────────────────────────────────────────────────
|
|
306
|
+
//
|
|
307
|
+
// One Axom per user per machine. Every hook — a selector entry, a tab, a new
|
|
308
|
+
// chat — is a fresh SESSION on the one runtime, never a second process. The
|
|
309
|
+
// §14 lockfile is the enforcement mechanism, so racing hooks are safe: the
|
|
310
|
+
// loser is refused cleanly and joins the winner's runtime.
|
|
311
|
+
|
|
312
|
+
// Until multi-sequence lands, hooks share ONE generation slot. Concurrent
|
|
313
|
+
// work queues, and the UI is required to say so rather than looking hung.
|
|
314
|
+
generation(id) {
|
|
315
|
+
const ep = this.daemon.axom.endpoints.get(id);
|
|
316
|
+
const busySession = (ep?.sessions ? [...ep.sessions.values()] : []).find((s) => s.live);
|
|
317
|
+
return {
|
|
318
|
+
generationBusy: !!busySession,
|
|
319
|
+
generationHolder: busySession?.id || null,
|
|
320
|
+
// The holder's human name, if it is a chat this GROOVE minted. A session
|
|
321
|
+
// opened elsewhere (the REPL, another client) has none — the UI says
|
|
322
|
+
// "another session" rather than inventing one.
|
|
323
|
+
generationHolderLabel: busySession ? (this.getChat(busySession.id)?.label || null) : null,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── Chats — the persistent hook list ─────────────────────────────────────
|
|
328
|
+
//
|
|
329
|
+
// A chat is a named hook. It lives in daemon config, not the browser: tunnel
|
|
330
|
+
// ports move, tabs reload, and a chat list that evaporates on refresh reads
|
|
331
|
+
// as data loss even though the ledger kept everything.
|
|
332
|
+
|
|
333
|
+
chats() {
|
|
334
|
+
return (this._cfg().chats || []).filter((c) => !c.hidden);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
getChat(session) {
|
|
338
|
+
return (this._cfg().chats || []).find((c) => c.session === session) || null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
_putChat(chat) {
|
|
342
|
+
const all = this._cfg().chats || [];
|
|
343
|
+
const i = all.findIndex((c) => c.session === chat.session);
|
|
344
|
+
this._cfg().chats = i >= 0 ? all.map((c) => (c.session === chat.session ? chat : c)) : [...all, chat];
|
|
345
|
+
this._save();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// A chat titles itself from what it started with — "Chat 3" tells you
|
|
349
|
+
// nothing when you have six of them. Only ever replaces a PLACEHOLDER title:
|
|
350
|
+
// a name the user typed, or one already derived from the opening message, is
|
|
351
|
+
// never overwritten by a later turn.
|
|
352
|
+
titleFromFirstMessage(session, text) {
|
|
353
|
+
const chat = this.getChat(session);
|
|
354
|
+
if (!chat || chat.titled || chat.renamed) return null;
|
|
355
|
+
const title = summarizeForTitle(text);
|
|
356
|
+
if (!title) return null;
|
|
357
|
+
this._putChat({ ...chat, label: title, titled: true });
|
|
358
|
+
this.broadcastChats();
|
|
359
|
+
return title;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
renameChat(session, label) {
|
|
363
|
+
const chat = this.getChat(session);
|
|
364
|
+
if (!chat) throw new Error(`no chat "${session}"`);
|
|
365
|
+
if (typeof label !== 'string' || !label.trim() || label.length > 80) {
|
|
366
|
+
throw new Error('label must be a non-empty string of at most 80 chars');
|
|
367
|
+
}
|
|
368
|
+
// `renamed` is sticky: once the user names a chat, no later auto-title
|
|
369
|
+
// may take it back.
|
|
370
|
+
this._putChat({ ...chat, label: label.trim(), renamed: true });
|
|
371
|
+
this.broadcastChats();
|
|
372
|
+
return this.getChat(session);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Hide, never delete. The conversation lives in the runtime's ledger and is
|
|
376
|
+
// the user's memory — GROOVE tidying its own list must never be able to
|
|
377
|
+
// destroy it. The session id is REMEMBERED so the connector's /sessions poll
|
|
378
|
+
// can't resurrect the row the user just cleared away.
|
|
379
|
+
hideChat(session) {
|
|
380
|
+
const chat = this.getChat(session);
|
|
381
|
+
if (!chat) throw new Error(`no chat "${session}"`);
|
|
382
|
+
this._putChat({ ...chat, hidden: true });
|
|
383
|
+
this._forgetPrompts(session);
|
|
384
|
+
this._save();
|
|
385
|
+
this.broadcastChats();
|
|
386
|
+
return { hidden: true, session, note: 'removed from the list; the conversation remains in Axom\'s memory' };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── Prompts — what GROOVE sent, remembered where the events are ──────────
|
|
390
|
+
//
|
|
391
|
+
// The runtime's `pipeline_start` carries no prompt text, so the user's own
|
|
392
|
+
// words exist only in GROOVE. Keeping them in the browser meant a reload
|
|
393
|
+
// replayed every turn from the daemon's ring with its bubble gone — the
|
|
394
|
+
// answer with no question above it. This is OUR record of what WE sent, not
|
|
395
|
+
// invented telemetry, so the daemon is the right place for it.
|
|
396
|
+
recordPrompt(session, ref, text) {
|
|
397
|
+
if (!session || !ref) return null;
|
|
398
|
+
const all = this._cfg().prompts || {};
|
|
399
|
+
const forSession = (all[session] || []).filter((p) => p.ref !== ref);
|
|
400
|
+
// Bounded per session: a transcript this long is scrollback, not memory.
|
|
401
|
+
const next = [...forSession, { ref, text, ts: Date.now() }].slice(-200);
|
|
402
|
+
this._cfg().prompts = { ...all, [session]: next };
|
|
403
|
+
this._save();
|
|
404
|
+
return { ref, text };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
prompts(session) {
|
|
408
|
+
return (this._cfg().prompts || {})[session] || [];
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// A hidden chat's prompts go with it — the list is tidied, the ledger keeps
|
|
412
|
+
// the conversation itself.
|
|
413
|
+
_forgetPrompts(session) {
|
|
414
|
+
const all = this._cfg().prompts || {};
|
|
415
|
+
if (!all[session]) return;
|
|
416
|
+
const next = { ...all };
|
|
417
|
+
delete next[session];
|
|
418
|
+
this._cfg().prompts = next;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
broadcastChats() {
|
|
422
|
+
this.daemon.broadcast({ type: 'axom:chats', data: { chats: this.chats() } });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Idempotent by design: if the runtime already answers, this is a no-op. We
|
|
426
|
+
// never start a second process to satisfy a hook.
|
|
427
|
+
async ensureRunning(id) {
|
|
428
|
+
const { state } = await this.state(id);
|
|
429
|
+
if (state === 'connected' || state === 'running') return { started: false, alreadyRunning: true };
|
|
430
|
+
const rt = this.get(id);
|
|
431
|
+
if (rt.control === 'none') {
|
|
432
|
+
throw new Error(`"${rt.name}" runs on another machine — start it there, then hook in`);
|
|
433
|
+
}
|
|
434
|
+
try {
|
|
435
|
+
const result = await this.startRuntime(id);
|
|
436
|
+
return { ...result, started: result.started !== false };
|
|
437
|
+
} catch (err) {
|
|
438
|
+
// A racing hook that lost the §14 lock has NOT failed: the runtime it
|
|
439
|
+
// wanted is up, someone else just got there first. Re-derive rather
|
|
440
|
+
// than surfacing a lock error the user can do nothing about.
|
|
441
|
+
const after = await this.state(id);
|
|
442
|
+
if (after.state === 'connected' || after.state === 'running') {
|
|
443
|
+
return { started: false, alreadyRunning: true, wonBy: 'another hook' };
|
|
444
|
+
}
|
|
445
|
+
throw err;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async hook(id, { session, label } = {}) {
|
|
450
|
+
const rt = this.get(id || this.activeId());
|
|
451
|
+
if (!rt) throw new Error('no Axom runtime configured');
|
|
452
|
+
const launch = await this.ensureRunning(rt.id);
|
|
453
|
+
// §9: a hook mints its own session id — its own recency thread under the
|
|
454
|
+
// one identity. Callers may pass one to rejoin an existing thread.
|
|
455
|
+
const sessionId = session || `s-${Math.random().toString(36).slice(2, 10)}`;
|
|
456
|
+
// Persist the hook as a chat so the list survives a refresh. Rejoining an
|
|
457
|
+
// existing session must NOT un-hide a chat the user cleared away.
|
|
458
|
+
const existing = this.getChat(sessionId);
|
|
459
|
+
if (!existing) {
|
|
460
|
+
this._putChat({
|
|
461
|
+
session: sessionId,
|
|
462
|
+
runtimeId: rt.id,
|
|
463
|
+
label: label || `Chat ${this.chats().length + 1}`,
|
|
464
|
+
createdAt: Date.now(),
|
|
465
|
+
});
|
|
466
|
+
} else if (label && !existing.hidden) {
|
|
467
|
+
this._putChat({ ...existing, label });
|
|
468
|
+
}
|
|
469
|
+
this.broadcastChats();
|
|
470
|
+
this.broadcastStatus();
|
|
471
|
+
return {
|
|
472
|
+
runtimeId: rt.id,
|
|
473
|
+
name: rt.name,
|
|
474
|
+
url: rt.url,
|
|
475
|
+
session: sessionId,
|
|
476
|
+
label: this.getChat(sessionId)?.label || null,
|
|
477
|
+
launched: !!launch.started,
|
|
478
|
+
...this.generation(rt.id),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
239
482
|
// ── Verbs — dispatch on control, never guess ─────────────────────────────
|
|
240
483
|
|
|
484
|
+
// A launch spec must mean exactly one thing in every control mode. The local
|
|
485
|
+
// path gets {cwd, env} as real spawn options; SSH has only a command string,
|
|
486
|
+
// so compose them INTO it here rather than dropping them — a spec whose env
|
|
487
|
+
// is silently ignored launches a subtly different runtime (wrong context
|
|
488
|
+
// window, wrong tree) while reporting success. Found in the wild: a spec
|
|
489
|
+
// without AXOM_MAX_CTX booted a 2048-ctx instance.
|
|
490
|
+
_sshCommand(rt) {
|
|
491
|
+
const launch = withBlessedEnv(rt.launch);
|
|
492
|
+
if (!launch?.command) return undefined;
|
|
493
|
+
const parts = [];
|
|
494
|
+
// `export`, not a `VAR=x prog` prefix: real specs are COMPOUND shell lines
|
|
495
|
+
// ("cd /x && prog"), and a prefix binds only to the first word — the var
|
|
496
|
+
// would decorate `cd` and never reach the runtime. Caught by its own test.
|
|
497
|
+
for (const [k, v] of Object.entries(launch.env || {})) {
|
|
498
|
+
parts.push(`export ${k}=${shellQuote(String(v))}; `);
|
|
499
|
+
}
|
|
500
|
+
if (launch.cwd) parts.push(`cd ${shellQuote(launch.cwd)} && `);
|
|
501
|
+
return `${parts.join('')}${launch.command}`;
|
|
502
|
+
}
|
|
503
|
+
|
|
241
504
|
_sshCfg(rt) {
|
|
242
505
|
let port = 8737;
|
|
243
506
|
try { port = Number(new URL(rt.url).port) || 8737; } catch { /* default */ }
|
|
244
507
|
return {
|
|
245
508
|
...rt.ssh,
|
|
246
509
|
port,
|
|
247
|
-
command: rt
|
|
510
|
+
command: this._sshCommand(rt),
|
|
248
511
|
logPath: rt.logPath,
|
|
249
512
|
};
|
|
250
513
|
}
|
|
@@ -263,7 +526,7 @@ export class AxomRuntimes {
|
|
|
263
526
|
}
|
|
264
527
|
// local: the spawned port becomes the runtime's URL.
|
|
265
528
|
const instance = await this.daemon.axomServer.start(rt.id, {
|
|
266
|
-
launch: rt.launch,
|
|
529
|
+
launch: withBlessedEnv(rt.launch) || { env: { ...BLESSED_ENV } },
|
|
267
530
|
dataDir: rt.dataDir,
|
|
268
531
|
});
|
|
269
532
|
this.update(id, { url: `http://127.0.0.1:${instance.port}` });
|
|
@@ -151,7 +151,9 @@ export class AxomServerManager {
|
|
|
151
151
|
this.instances.set(id, instance);
|
|
152
152
|
this._broadcast();
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
// A spec may carry env/cwd without naming a command (the caller wanted the
|
|
155
|
+
// configured binary plus the blessed env) — fill the command, keep the rest.
|
|
156
|
+
const launch = { ...opts.launch, command: opts.launch?.command || this._command() };
|
|
155
157
|
let proc;
|
|
156
158
|
try {
|
|
157
159
|
if (launch.cwd || launch.env || /\s/.test(launch.command)) {
|
|
@@ -46,6 +46,16 @@ export function registerAxomRoutes(app, daemon) {
|
|
|
46
46
|
return res.status(400).json({ error: 'clientRef must be a string of at most 64 chars' });
|
|
47
47
|
}
|
|
48
48
|
const result = await daemon.axom.message(endpoint, req.params.id, text, clientRef);
|
|
49
|
+
// Title the chat from its opening message — but only once the runtime
|
|
50
|
+
// ACCEPTED the turn. A message rejected with 409/413 never ran, so it
|
|
51
|
+
// must not name the conversation it failed to start.
|
|
52
|
+
if (result.status === 202) {
|
|
53
|
+
daemon.axomRuntimes.titleFromFirstMessage(req.params.id, text);
|
|
54
|
+
// Remember what we sent, keyed by the §15 ref the runtime echoes in
|
|
55
|
+
// pipeline_start. This is what lets a reloaded tab put the user's own
|
|
56
|
+
// words back above the answer instead of "prompt not identified".
|
|
57
|
+
if (clientRef) daemon.axomRuntimes.recordPrompt(req.params.id, clientRef, text);
|
|
58
|
+
}
|
|
49
59
|
daemon.audit.log('axom.message', { session: req.params.id, chars: text.length, status: result.status });
|
|
50
60
|
res.status(result.status).json(result.body);
|
|
51
61
|
} catch (err) {
|
|
@@ -158,6 +168,53 @@ export function registerAxomRoutes(app, daemon) {
|
|
|
158
168
|
}
|
|
159
169
|
});
|
|
160
170
|
|
|
171
|
+
// Mono-Axom (§10): a hook is a fresh session on the ONE runtime — never a
|
|
172
|
+
// second process. Used by the agent selector, new tabs, and new chats alike.
|
|
173
|
+
// Chats are named hooks, persisted daemon-side so the list survives a
|
|
174
|
+
// refresh. Removing one HIDES it — the conversation is the user's memory and
|
|
175
|
+
// lives in the runtime's ledger; GROOVE tidying its list never destroys it.
|
|
176
|
+
app.get('/api/axom/chats', (req, res) => {
|
|
177
|
+
res.json({ chats: daemon.axomRuntimes.chats() });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// What GROOVE sent on this session, so a reloaded tab can restore the user's
|
|
181
|
+
// bubbles. Only ever OUR OWN sends — a turn started from the REPL or another
|
|
182
|
+
// client has no entry here and must still render without a bubble.
|
|
183
|
+
app.get('/api/axom/sessions/:id/prompts', (req, res) => {
|
|
184
|
+
res.json({ prompts: daemon.axomRuntimes.prompts(req.params.id) });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
app.patch('/api/axom/chats/:session', (req, res) => {
|
|
188
|
+
try {
|
|
189
|
+
res.json(daemon.axomRuntimes.renameChat(req.params.session, req.body?.label));
|
|
190
|
+
} catch (err) {
|
|
191
|
+
res.status(400).json({ error: err.message });
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
app.delete('/api/axom/chats/:session', (req, res) => {
|
|
196
|
+
try {
|
|
197
|
+
const result = daemon.axomRuntimes.hideChat(req.params.session);
|
|
198
|
+
daemon.audit.log('axom.chat.hide', { session: req.params.session });
|
|
199
|
+
res.json(result);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
res.status(400).json({ error: err.message });
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
app.post('/api/axom/hook', async (req, res) => {
|
|
206
|
+
try {
|
|
207
|
+
const result = await daemon.axomRuntimes.hook(req.body?.runtimeId, {
|
|
208
|
+
session: req.body?.session,
|
|
209
|
+
label: req.body?.label,
|
|
210
|
+
});
|
|
211
|
+
daemon.audit.log('axom.hook', { runtime: result.runtimeId, session: result.session });
|
|
212
|
+
res.json(result);
|
|
213
|
+
} catch (err) {
|
|
214
|
+
res.status(502).json({ error: err.message });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
161
218
|
app.post('/api/axom/runtimes/:id/activate', (req, res) => {
|
|
162
219
|
try {
|
|
163
220
|
daemon.axomRuntimes.activate(req.params.id);
|