groove-dev 0.27.181 → 0.27.183
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/scripts/repair-tokens.mjs +78 -0
- package/node_modules/@groove-dev/daemon/src/api.js +1 -1
- package/node_modules/@groove-dev/daemon/src/deliver.js +130 -0
- package/node_modules/@groove-dev/daemon/src/index.js +8 -3
- package/node_modules/@groove-dev/daemon/src/innerchat.js +213 -62
- package/node_modules/@groove-dev/daemon/src/journalist.js +15 -8
- package/node_modules/@groove-dev/daemon/src/process.js +59 -34
- package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +9 -1
- package/node_modules/@groove-dev/daemon/src/registry.js +5 -1
- package/node_modules/@groove-dev/daemon/src/rename.js +72 -0
- package/node_modules/@groove-dev/daemon/src/rotator.js +62 -5
- package/node_modules/@groove-dev/daemon/src/routes/agents.js +22 -100
- package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +12 -9
- package/node_modules/@groove-dev/daemon/src/teams.js +7 -1
- package/node_modules/@groove-dev/daemon/src/tokentracker.js +46 -6
- package/node_modules/@groove-dev/daemon/test/claude-code-provider.test.js +52 -0
- package/node_modules/@groove-dev/daemon/test/innerchat.test.js +197 -111
- package/node_modules/@groove-dev/daemon/test/journalist.test.js +4 -2
- package/node_modules/@groove-dev/daemon/test/rename.test.js +108 -0
- package/node_modules/@groove-dev/daemon/test/rotator.test.js +131 -0
- package/node_modules/@groove-dev/daemon/test/tokentracker.test.js +4 -0
- package/node_modules/@groove-dev/gui/dist/assets/{index-DTFtRtkx.css → index-CiOy7wVS.css} +1 -1
- package/node_modules/@groove-dev/gui/dist/assets/{index-CTer01Vg.js → index-DPjGBQ5X.js} +225 -225
- 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/components/agents/agent-panel.jsx +3 -68
- package/node_modules/@groove-dev/gui/src/components/agents/innerchat-relay.jsx +145 -0
- package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +3 -1
- package/node_modules/@groove-dev/gui/src/components/fleet/fleet-pane.jsx +15 -1
- package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +33 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +29 -44
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +27 -4
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/scripts/repair-tokens.mjs +78 -0
- package/packages/daemon/src/api.js +1 -1
- package/packages/daemon/src/deliver.js +130 -0
- package/packages/daemon/src/index.js +8 -3
- package/packages/daemon/src/innerchat.js +213 -62
- package/packages/daemon/src/journalist.js +15 -8
- package/packages/daemon/src/process.js +59 -34
- package/packages/daemon/src/providers/claude-code.js +9 -1
- package/packages/daemon/src/registry.js +5 -1
- package/packages/daemon/src/rename.js +72 -0
- package/packages/daemon/src/rotator.js +62 -5
- package/packages/daemon/src/routes/agents.js +22 -100
- package/packages/daemon/src/routes/innerchat.js +12 -9
- package/packages/daemon/src/teams.js +7 -1
- package/packages/daemon/src/tokentracker.js +46 -6
- package/packages/gui/dist/assets/{index-DTFtRtkx.css → index-CiOy7wVS.css} +1 -1
- package/packages/gui/dist/assets/{index-CTer01Vg.js → index-DPjGBQ5X.js} +225 -225
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/agents/agent-panel.jsx +3 -68
- package/packages/gui/src/components/agents/innerchat-relay.jsx +145 -0
- package/packages/gui/src/components/chat/chat-messages.jsx +3 -1
- package/packages/gui/src/components/fleet/fleet-pane.jsx +15 -1
- package/packages/gui/src/components/fleet/fleet-sidebar.jsx +33 -1
- package/packages/gui/src/stores/groove.js +29 -44
- package/packages/gui/src/stores/slices/agents-slice.js +27 -4
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// One-off ledger repair for ~/.groove/tokens.json (2026-07 efficiency audit).
|
|
5
|
+
//
|
|
6
|
+
// Entry c3789b6f recorded $20,536.57 from a duplicate-ingestion loop (4,631
|
|
7
|
+
// session records in 380s, payloads duplicated up to 10×). The ingestion bug
|
|
8
|
+
// is fixed in claude-code.js, but the poisoned data still dominates every cost
|
|
9
|
+
// dashboard: recorded all-time spend ~$24K vs ~$3.5K real. This script:
|
|
10
|
+
// 1. Sets the poisoned entry's totalCostUsd to its audited real value (~$325)
|
|
11
|
+
// 2. Truncates every entry's sessions array to the last 500 records
|
|
12
|
+
// (matches SESSION_CAP in tokentracker.js; aggregates are untouched)
|
|
13
|
+
// 3. Writes a timestamped .bak of the original file first
|
|
14
|
+
//
|
|
15
|
+
// MUST run with the daemon STOPPED — the daemon holds the ledger in memory and
|
|
16
|
+
// rewrites the file, so a live edit gets clobbered. The script checks and aborts.
|
|
17
|
+
//
|
|
18
|
+
// Usage: node repair-tokens.mjs [path-to-tokens.json] [--entry c3789b6f] [--cost 325]
|
|
19
|
+
|
|
20
|
+
import { readFileSync, writeFileSync, copyFileSync, existsSync } from 'fs';
|
|
21
|
+
import { resolve } from 'path';
|
|
22
|
+
import { homedir } from 'os';
|
|
23
|
+
|
|
24
|
+
const args = process.argv.slice(2);
|
|
25
|
+
const flag = (name, dflt) => {
|
|
26
|
+
const i = args.indexOf(`--${name}`);
|
|
27
|
+
return i !== -1 && args[i + 1] ? args[i + 1] : dflt;
|
|
28
|
+
};
|
|
29
|
+
const tokensPath = args[0] && !args[0].startsWith('--')
|
|
30
|
+
? resolve(args[0])
|
|
31
|
+
: resolve(homedir(), '.groove', 'tokens.json');
|
|
32
|
+
const entryId = flag('entry', 'c3789b6f');
|
|
33
|
+
const correctedCost = Number(flag('cost', '325'));
|
|
34
|
+
const SESSION_CAP = 500;
|
|
35
|
+
|
|
36
|
+
// Refuse to edit under a live daemon
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetch('http://127.0.0.1:31415/api/health', { signal: AbortSignal.timeout(1500) });
|
|
39
|
+
if (res.ok) {
|
|
40
|
+
console.error('ABORT: GROOVE daemon is running on :31415. Stop it first (groove stop) — it rewrites tokens.json and will clobber this repair.');
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
} catch { /* no daemon — safe to proceed */ }
|
|
44
|
+
|
|
45
|
+
if (!existsSync(tokensPath)) {
|
|
46
|
+
console.error(`ABORT: ${tokensPath} not found. Pass the path explicitly: node repair-tokens.mjs /path/to/tokens.json`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const backupPath = `${tokensPath}.${new Date().toISOString().replace(/[:.]/g, '-')}.bak`;
|
|
51
|
+
copyFileSync(tokensPath, backupPath);
|
|
52
|
+
console.log(`Backup written: ${backupPath}`);
|
|
53
|
+
|
|
54
|
+
const data = JSON.parse(readFileSync(tokensPath, 'utf8'));
|
|
55
|
+
const usage = data.usage || data;
|
|
56
|
+
|
|
57
|
+
const target = usage[entryId];
|
|
58
|
+
if (target) {
|
|
59
|
+
const before = target.totalCostUsd;
|
|
60
|
+
target.totalCostUsd = correctedCost;
|
|
61
|
+
target.note = `totalCostUsd corrected ${before} -> ${correctedCost} on ${new Date().toISOString().slice(0, 10)} (duplicate-ingestion inflation, see 2026-07 efficiency audit)`;
|
|
62
|
+
console.log(`Entry ${entryId}: totalCostUsd ${before} -> ${correctedCost}`);
|
|
63
|
+
} else {
|
|
64
|
+
console.warn(`Entry ${entryId} not found — skipping cost correction.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let trimmed = 0;
|
|
68
|
+
for (const entry of Object.values(usage)) {
|
|
69
|
+
if (Array.isArray(entry?.sessions) && entry.sessions.length > SESSION_CAP) {
|
|
70
|
+
trimmed += entry.sessions.length - SESSION_CAP;
|
|
71
|
+
entry.sessions.splice(0, entry.sessions.length - SESSION_CAP);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
console.log(`Trimmed ${trimmed} session records across all entries (cap ${SESSION_CAP}).`);
|
|
75
|
+
|
|
76
|
+
writeFileSync(tokensPath, JSON.stringify(data, null, 2));
|
|
77
|
+
const sizeMb = (Buffer.byteLength(JSON.stringify(data)) / 1024 / 1024).toFixed(1);
|
|
78
|
+
console.log(`Repaired ledger written: ${tokensPath} (${sizeMb}MB)`);
|
|
@@ -1438,7 +1438,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
1438
1438
|
'port', 'journalistInterval', 'rotationThreshold', 'autoRotation',
|
|
1439
1439
|
'qcThreshold', 'maxAgents', 'defaultProvider', 'defaultWorkingDir',
|
|
1440
1440
|
'onboardingDismissed', 'defaultModel', 'defaultChatProvider', 'defaultChatModel',
|
|
1441
|
-
'dataSharingDismissed',
|
|
1441
|
+
'dataSharingDismissed', 'replayCeiling', 'velocityCeiling', 'journalistModelTier',
|
|
1442
1442
|
];
|
|
1443
1443
|
for (const key of Object.keys(req.body)) {
|
|
1444
1444
|
if (!ALLOWED_KEYS.includes(key)) {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
|
|
3
|
+
import { wrapWithRoleReminder } from './process.js';
|
|
4
|
+
import { getProvider } from './providers/index.js';
|
|
5
|
+
|
|
6
|
+
// Reviving a >5M-token claude session has crashed the CLI mid-HTTP-parse
|
|
7
|
+
// (V8 fatal in JsonStringifier) — past this ceiling the rotator's handoff
|
|
8
|
+
// brief sidesteps the resume entirely.
|
|
9
|
+
const SESSION_RESUME_CEILING = 5_000_000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Deliver a message to an agent, whatever state it's in.
|
|
13
|
+
*
|
|
14
|
+
* This is the single pipe every inbound message travels — user chat from the
|
|
15
|
+
* GUI and InnerChat relays alike. Handles agent-loop delivery, one-shot and
|
|
16
|
+
* non-interactive providers, queueing behind a running task, session resume,
|
|
17
|
+
* and full rotation when the session is too large to revive.
|
|
18
|
+
*
|
|
19
|
+
* Respawn paths mint a NEW agent id. Callers that track the agent afterwards
|
|
20
|
+
* (InnerChat keys response capture on it) must use the returned `agentId`,
|
|
21
|
+
* not the one they passed in.
|
|
22
|
+
*
|
|
23
|
+
* @returns {Promise<{agentId: string, status: string, resumed: boolean, agent: object|null}>}
|
|
24
|
+
*/
|
|
25
|
+
export async function deliverInstruction(daemon, agentId, message, opts = {}) {
|
|
26
|
+
const { recordFeedback = true } = opts;
|
|
27
|
+
|
|
28
|
+
const agent = daemon.registry.get(agentId);
|
|
29
|
+
if (!agent) throw new Error('Agent not found');
|
|
30
|
+
|
|
31
|
+
const finalMessage = message.trim();
|
|
32
|
+
if (!finalMessage) throw new Error('message is required');
|
|
33
|
+
|
|
34
|
+
// Journalist/rotator treat this as a human steering signal — InnerChat
|
|
35
|
+
// relays opt out so agent-to-agent chatter doesn't pollute user feedback.
|
|
36
|
+
if (recordFeedback) {
|
|
37
|
+
if (daemon.journalist) daemon.journalist.recordUserFeedback(agent, finalMessage);
|
|
38
|
+
if (daemon.rotator) daemon.rotator.recordUserMessage(agentId);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const wrappedMessage = wrapWithRoleReminder(agent.role, finalMessage);
|
|
42
|
+
|
|
43
|
+
// Agent loop path — send straight to the running loop.
|
|
44
|
+
if (daemon.processes.hasAgentLoop(agentId)) {
|
|
45
|
+
const sent = await daemon.processes.sendMessage(agentId, wrappedMessage);
|
|
46
|
+
if (sent) {
|
|
47
|
+
daemon.audit.log('agent.chat', { id: agentId });
|
|
48
|
+
return { agentId, status: 'message_sent', resumed: false, agent };
|
|
49
|
+
}
|
|
50
|
+
// Loop exists but isn't running — fall through to resume/rotate.
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const provider = getProvider(agent.provider);
|
|
54
|
+
|
|
55
|
+
// One-shot providers (groove-network): kill any running instance and respawn
|
|
56
|
+
// with the message as --prompt. No handoff brief, no resume, no queue.
|
|
57
|
+
if (provider?.constructor?.isOneShot) {
|
|
58
|
+
const oldConfig = { ...agent };
|
|
59
|
+
if (daemon.processes.isRunning(agentId)) {
|
|
60
|
+
await daemon.processes.kill(agentId);
|
|
61
|
+
}
|
|
62
|
+
daemon.registry.remove(agentId, { silent: true });
|
|
63
|
+
daemon.locks.release(agentId);
|
|
64
|
+
|
|
65
|
+
const newAgent = await respawn(daemon, {
|
|
66
|
+
role: oldConfig.role,
|
|
67
|
+
scope: oldConfig.scope,
|
|
68
|
+
provider: oldConfig.provider,
|
|
69
|
+
model: oldConfig.model,
|
|
70
|
+
prompt: finalMessage,
|
|
71
|
+
permission: oldConfig.permission || 'full',
|
|
72
|
+
workingDir: oldConfig.workingDir,
|
|
73
|
+
name: oldConfig.name,
|
|
74
|
+
teamId: oldConfig.teamId,
|
|
75
|
+
});
|
|
76
|
+
daemon.audit.log('agent.instruct', { id: agentId, newId: newAgent.id, resumed: false });
|
|
77
|
+
return { agentId: newAgent.id, status: 'respawned', resumed: false, agent: newAgent };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Non-interactive CLI providers (e.g. Gemini): one prompt per spawn, cannot
|
|
81
|
+
// resume — respawn preserving the original introContext.
|
|
82
|
+
if (provider?.constructor?.nonInteractive && !daemon.processes.isRunning(agentId)) {
|
|
83
|
+
const oldConfig = { ...agent };
|
|
84
|
+
daemon.registry.remove(agentId, { silent: true });
|
|
85
|
+
daemon.locks.release(agentId);
|
|
86
|
+
|
|
87
|
+
const newAgent = await respawn(daemon, {
|
|
88
|
+
role: oldConfig.role,
|
|
89
|
+
scope: oldConfig.scope,
|
|
90
|
+
provider: oldConfig.provider,
|
|
91
|
+
model: oldConfig.model,
|
|
92
|
+
prompt: finalMessage,
|
|
93
|
+
introContext: oldConfig.introContext,
|
|
94
|
+
permission: oldConfig.permission || 'full',
|
|
95
|
+
workingDir: oldConfig.workingDir,
|
|
96
|
+
name: oldConfig.name,
|
|
97
|
+
teamId: oldConfig.teamId,
|
|
98
|
+
});
|
|
99
|
+
daemon.audit.log('agent.instruct', { id: agentId, newId: newAgent.id, resumed: false });
|
|
100
|
+
return { agentId: newAgent.id, status: 'respawned', resumed: false, agent: newAgent };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Running CLI agent (no loop) — queue behind the current task rather than
|
|
104
|
+
// killing and respawning mid-work.
|
|
105
|
+
if (daemon.processes.isRunning(agentId)) {
|
|
106
|
+
daemon.processes.queueMessage(agentId, wrappedMessage);
|
|
107
|
+
daemon.audit.log('agent.chat.queued', { id: agentId });
|
|
108
|
+
return { agentId, status: 'message_queued', resumed: false, agent };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Stopped CLI agent — resume the session, or rotate past the ceiling.
|
|
112
|
+
const resumed = !!agent.sessionId && (agent.tokensUsed || 0) < SESSION_RESUME_CEILING;
|
|
113
|
+
const newAgent = resumed
|
|
114
|
+
? await daemon.processes.resume(agentId, wrappedMessage)
|
|
115
|
+
: await daemon.rotator.rotate(agentId, { additionalPrompt: wrappedMessage });
|
|
116
|
+
|
|
117
|
+
daemon.audit.log('agent.instruct', { id: agentId, newId: newAgent.id, resumed });
|
|
118
|
+
return { agentId: newAgent.id, status: resumed ? 'resumed' : 'rotated', resumed, agent: newAgent };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Spawn, flushing the registry's pending removals if it fails so a failed
|
|
122
|
+
// respawn doesn't silently erase the agent it was replacing.
|
|
123
|
+
async function respawn(daemon, config) {
|
|
124
|
+
try {
|
|
125
|
+
return await daemon.processes.spawn(config);
|
|
126
|
+
} catch (spawnErr) {
|
|
127
|
+
daemon.registry.flushPendingRemovals();
|
|
128
|
+
throw spawnErr;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -10,7 +10,7 @@ import express from 'express';
|
|
|
10
10
|
import { WebSocketServer } from 'ws';
|
|
11
11
|
import { Registry } from './registry.js';
|
|
12
12
|
import { createApi } from './api.js';
|
|
13
|
-
import { ProcessManager } from './process.js';
|
|
13
|
+
import { ProcessManager, sanitizeFilename } from './process.js';
|
|
14
14
|
import { StateManager } from './state.js';
|
|
15
15
|
import { Introducer } from './introducer.js';
|
|
16
16
|
import { LockManager } from './lockmanager.js';
|
|
@@ -750,7 +750,11 @@ export class Daemon {
|
|
|
750
750
|
try {
|
|
751
751
|
// Build set of agent names still in the registry — never remove their logs
|
|
752
752
|
const allAgents = this.registry.getAll();
|
|
753
|
-
|
|
753
|
+
// Raw logs are keyed by sanitized name; GROOVE_AGENT_LOGS/ subdirectories
|
|
754
|
+
// are keyed by agent id (journalist.js). Comparing either against the
|
|
755
|
+
// wrong set deletes live agents' history.
|
|
756
|
+
const activeNames = new Set(allAgents.map((a) => sanitizeFilename(a.name)));
|
|
757
|
+
const activeIds = new Set(allAgents.map((a) => a.id));
|
|
754
758
|
|
|
755
759
|
// Safety: if registry is empty but log files exist, state may have been
|
|
756
760
|
// lost (corrupt JSON, partial write). Skip log cleanup to prevent
|
|
@@ -778,7 +782,7 @@ export class Daemon {
|
|
|
778
782
|
if (existsSync(agentLogsDir)) {
|
|
779
783
|
for (const dir of readdirSync(agentLogsDir, { withFileTypes: true })) {
|
|
780
784
|
if (!dir.isDirectory()) continue;
|
|
781
|
-
if (
|
|
785
|
+
if (activeIds.has(dir.name)) continue;
|
|
782
786
|
try { rmSync(resolve(agentLogsDir, dir.name), { recursive: true }); cleaned++; } catch { /* skip */ }
|
|
783
787
|
}
|
|
784
788
|
// Remove GROOVE_AGENT_LOGS/ itself if empty
|
|
@@ -836,6 +840,7 @@ export class Daemon {
|
|
|
836
840
|
// Persist state before shutdown
|
|
837
841
|
this.state.set('agents', this.registry.getAll());
|
|
838
842
|
await this.state.save();
|
|
843
|
+
try { this.tokens.flush(); } catch { /* best-effort */ }
|
|
839
844
|
|
|
840
845
|
// Stop background services
|
|
841
846
|
await this.gateways.stop();
|
|
@@ -1,100 +1,251 @@
|
|
|
1
1
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
2
|
|
|
3
3
|
import { randomUUID } from 'crypto';
|
|
4
|
-
|
|
4
|
+
import { deliverInstruction } from './deliver.js';
|
|
5
|
+
|
|
6
|
+
// How much prior conversation to replay into each relay turn. Enough for the
|
|
7
|
+
// recipient to pick up the thread without re-reading the whole exchange.
|
|
8
|
+
const CONTEXT_TURNS = 4;
|
|
9
|
+
const MAX_TURN_CHARS = 1200;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Agent-to-agent relay, user-initiated.
|
|
13
|
+
*
|
|
14
|
+
* The A→B hop is always driven by a human clicking Relay. The B→A hop is
|
|
15
|
+
* automatic: B's answer is fed straight back into A so A can process it and
|
|
16
|
+
* report to the user, who then decides whether to run another turn.
|
|
17
|
+
*
|
|
18
|
+
* Delivery goes through deliverInstruction — the same pipe as user chat — so
|
|
19
|
+
* a relay reaches its target whether it's mid-task, idle, or stopped.
|
|
20
|
+
*/
|
|
5
21
|
export class InnerChat {
|
|
6
22
|
constructor(daemon) {
|
|
7
23
|
this.daemon = daemon;
|
|
8
|
-
this.
|
|
9
|
-
|
|
24
|
+
this.threads = new Map();
|
|
25
|
+
// agentId -> { threadId, sentAt } — which agent we're awaiting a reply from.
|
|
26
|
+
this.awaiting = new Map();
|
|
10
27
|
}
|
|
11
28
|
|
|
12
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Relay a message from one agent to another, opening a thread or continuing
|
|
31
|
+
* an existing one.
|
|
32
|
+
*/
|
|
33
|
+
async send(fromAgentId, toAgentId, message, threadId = null) {
|
|
13
34
|
const fromAgent = this.daemon.registry.get(fromAgentId);
|
|
14
35
|
const toAgent = this.daemon.registry.get(toAgentId);
|
|
15
36
|
if (!fromAgent) throw new Error(`Sender agent ${fromAgentId} not found`);
|
|
16
37
|
if (!toAgent) throw new Error(`Target agent ${toAgentId} not found`);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
38
|
+
if (fromAgentId === toAgentId) throw new Error('Cannot relay an agent to itself');
|
|
39
|
+
if (!message || !message.trim()) throw new Error('message is required');
|
|
40
|
+
|
|
41
|
+
const thread = threadId ? this.threads.get(threadId) : null;
|
|
42
|
+
if (threadId && !thread) throw new Error(`Thread ${threadId} not found`);
|
|
43
|
+
|
|
44
|
+
const t = thread || this._createThread(fromAgent, toAgent);
|
|
45
|
+
const turn = {
|
|
46
|
+
id: randomUUID().slice(0, 12),
|
|
47
|
+
from: peer(fromAgent),
|
|
48
|
+
to: peer(toAgent),
|
|
49
|
+
text: message.trim(),
|
|
50
|
+
kind: 'relay',
|
|
51
|
+
status: 'sending',
|
|
26
52
|
timestamp: Date.now(),
|
|
27
|
-
respondedAt: null,
|
|
28
53
|
};
|
|
54
|
+
t.turns.push(turn);
|
|
55
|
+
|
|
56
|
+
const wrapped = this._wrap(t, fromAgent, toAgent, message.trim());
|
|
57
|
+
|
|
58
|
+
let result;
|
|
59
|
+
try {
|
|
60
|
+
result = await deliverInstruction(this.daemon, toAgentId, wrapped, { recordFeedback: false });
|
|
61
|
+
} catch (err) {
|
|
62
|
+
turn.status = 'failed';
|
|
63
|
+
turn.error = err.message;
|
|
64
|
+
t.status = 'failed';
|
|
65
|
+
this.daemon.broadcast({ type: 'innerchat:turn', data: { thread: t, turn } });
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
29
68
|
|
|
30
|
-
|
|
31
|
-
|
|
69
|
+
// A stopped target gets resumed or rotated, which mints a new agent id.
|
|
70
|
+
// Re-key everything onto it or the reply will never be captured.
|
|
71
|
+
if (result.agentId !== toAgentId) {
|
|
72
|
+
this._remapParticipant(t, toAgentId, result.agentId);
|
|
73
|
+
turn.to.id = result.agentId;
|
|
74
|
+
}
|
|
32
75
|
|
|
33
|
-
|
|
76
|
+
turn.status = result.status;
|
|
77
|
+
t.status = 'awaiting_reply';
|
|
78
|
+
t.updatedAt = Date.now();
|
|
34
79
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
} else if (this.daemon.processes.isRunning(toAgentId)) {
|
|
40
|
-
this.daemon.processes.queueMessage(toAgentId, wrapped);
|
|
41
|
-
deliveryStatus = 'queued';
|
|
42
|
-
} else {
|
|
43
|
-
throw new Error(`Target agent ${toAgent.name} is not running`);
|
|
44
|
-
}
|
|
80
|
+
// A queued relay sits behind whatever the agent is already doing, so the
|
|
81
|
+
// next result belongs to that prior task, not to us — skip it.
|
|
82
|
+
const skipResults = result.status === 'message_queued' ? 1 : 0;
|
|
83
|
+
this.awaiting.set(turn.to.id, { threadId: t.id, sentAt: Date.now(), skipResults });
|
|
45
84
|
|
|
46
|
-
|
|
47
|
-
this.daemon.
|
|
48
|
-
this.daemon.audit.log('innerchat.send', { id, from: fromAgentId, to: toAgentId });
|
|
85
|
+
this.daemon.broadcast({ type: 'innerchat:turn', data: { thread: t, turn } });
|
|
86
|
+
this.daemon.audit.log('innerchat.send', { thread: t.id, from: fromAgentId, to: turn.to.id });
|
|
49
87
|
|
|
50
|
-
return
|
|
88
|
+
return { thread: t, turn };
|
|
51
89
|
}
|
|
52
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Watch agent output for the reply to an outstanding relay, then forward it
|
|
93
|
+
* back to the agent that asked.
|
|
94
|
+
*/
|
|
53
95
|
onAgentOutput(agentId, output) {
|
|
54
|
-
const
|
|
55
|
-
if (!
|
|
96
|
+
const pending = this.awaiting.get(agentId);
|
|
97
|
+
if (!pending) return;
|
|
56
98
|
if (output.type !== 'result') return;
|
|
57
99
|
|
|
58
|
-
const
|
|
59
|
-
if (!
|
|
100
|
+
const thread = this.threads.get(pending.threadId);
|
|
101
|
+
if (!thread) { this.awaiting.delete(agentId); return; }
|
|
102
|
+
|
|
103
|
+
const responseText = extractText(output.data);
|
|
104
|
+
if (!responseText) return;
|
|
105
|
+
|
|
106
|
+
// Burn off results belonging to work that was already underway when the
|
|
107
|
+
// relay was queued behind it.
|
|
108
|
+
if (pending.skipResults > 0) {
|
|
109
|
+
pending.skipResults -= 1;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Claim the reply before the async forward so a second result arriving
|
|
114
|
+
// mid-flight can't be captured as a duplicate.
|
|
115
|
+
this.awaiting.delete(agentId);
|
|
60
116
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
117
|
+
const responder = this.daemon.registry.get(agentId);
|
|
118
|
+
const asker = this._otherParticipant(thread, agentId);
|
|
119
|
+
|
|
120
|
+
const turn = {
|
|
121
|
+
id: randomUUID().slice(0, 12),
|
|
122
|
+
from: responder ? peer(responder) : { id: agentId, name: agentId, role: 'agent' },
|
|
123
|
+
to: asker,
|
|
124
|
+
text: responseText,
|
|
125
|
+
kind: 'reply',
|
|
126
|
+
status: 'forwarding',
|
|
127
|
+
timestamp: Date.now(),
|
|
128
|
+
};
|
|
129
|
+
thread.turns.push(turn);
|
|
130
|
+
thread.status = 'forwarding';
|
|
131
|
+
thread.updatedAt = Date.now();
|
|
132
|
+
|
|
133
|
+
this._forwardReply(thread, turn, asker.id, responseText);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async _forwardReply(thread, turn, askerId, responseText) {
|
|
137
|
+
const from = turn.from;
|
|
138
|
+
const relay = [
|
|
139
|
+
`[InnerChat reply from ${from.name} (${from.role})]`,
|
|
140
|
+
'',
|
|
141
|
+
responseText,
|
|
142
|
+
'',
|
|
143
|
+
`This is the answer to what you relayed to ${from.name}. Process it and report back — `
|
|
144
|
+
+ 'the user will decide whether to send another turn.',
|
|
145
|
+
].join('\n');
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const result = await deliverInstruction(this.daemon, askerId, relay, { recordFeedback: false });
|
|
149
|
+
if (result.agentId !== askerId) {
|
|
150
|
+
this._remapParticipant(thread, askerId, result.agentId);
|
|
151
|
+
turn.to.id = result.agentId;
|
|
152
|
+
}
|
|
153
|
+
turn.status = result.status;
|
|
154
|
+
thread.status = 'idle';
|
|
155
|
+
} catch (err) {
|
|
156
|
+
turn.status = 'failed';
|
|
157
|
+
turn.error = err.message;
|
|
158
|
+
thread.status = 'failed';
|
|
66
159
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
160
|
+
|
|
161
|
+
thread.updatedAt = Date.now();
|
|
162
|
+
this.daemon.broadcast({ type: 'innerchat:turn', data: { thread, turn } });
|
|
163
|
+
this.daemon.audit.log('innerchat.reply', { thread: thread.id, from: turn.from.id, to: turn.to.id });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Thread helpers ──────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
_createThread(fromAgent, toAgent) {
|
|
169
|
+
const thread = {
|
|
170
|
+
id: randomUUID().slice(0, 12),
|
|
171
|
+
participants: [peer(fromAgent), peer(toAgent)],
|
|
172
|
+
turns: [],
|
|
173
|
+
status: 'idle',
|
|
174
|
+
createdAt: Date.now(),
|
|
175
|
+
updatedAt: Date.now(),
|
|
176
|
+
};
|
|
177
|
+
this.threads.set(thread.id, thread);
|
|
178
|
+
return thread;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Rebuild the message the target sees. It reads as a direct message from the
|
|
182
|
+
// other agent, with enough prior turns that a continuing thread makes sense.
|
|
183
|
+
_wrap(thread, fromAgent, toAgent, message) {
|
|
184
|
+
const prior = thread.turns.slice(0, -1).slice(-CONTEXT_TURNS);
|
|
185
|
+
const lines = [`[InnerChat from ${fromAgent.name} (${fromAgent.role})]`, ''];
|
|
186
|
+
|
|
187
|
+
if (prior.length) {
|
|
188
|
+
lines.push('Earlier in this conversation:');
|
|
189
|
+
for (const t of prior) {
|
|
190
|
+
lines.push(` ${t.from.name}: ${truncate(t.text, MAX_TURN_CHARS)}`);
|
|
191
|
+
}
|
|
192
|
+
lines.push('');
|
|
80
193
|
}
|
|
81
194
|
|
|
82
|
-
|
|
83
|
-
|
|
195
|
+
lines.push(message, '');
|
|
196
|
+
lines.push(
|
|
197
|
+
`Reply normally — your response is relayed straight back to ${fromAgent.name}. `
|
|
198
|
+
+ 'Answer them directly; do not address the user.',
|
|
199
|
+
);
|
|
200
|
+
return lines.join('\n');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
_otherParticipant(thread, agentId) {
|
|
204
|
+
return thread.participants.find((p) => p.id !== agentId) || thread.participants[0];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
_remapParticipant(thread, oldId, newId) {
|
|
208
|
+
for (const p of thread.participants) {
|
|
209
|
+
if (p.id === oldId) p.id = newId;
|
|
210
|
+
}
|
|
211
|
+
// Any relay still awaiting a reply from the old id must follow it forward.
|
|
212
|
+
const pending = this.awaiting.get(oldId);
|
|
213
|
+
if (pending) {
|
|
214
|
+
this.awaiting.delete(oldId);
|
|
215
|
+
this.awaiting.set(newId, pending);
|
|
216
|
+
}
|
|
84
217
|
}
|
|
85
218
|
|
|
86
|
-
|
|
87
|
-
|
|
219
|
+
// ── Queries ─────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
getThreads(agentId = null) {
|
|
222
|
+
const all = Array.from(this.threads.values()).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
88
223
|
if (!agentId) return all;
|
|
89
|
-
return all.filter(
|
|
224
|
+
return all.filter((t) => t.participants.some((p) => p.id === agentId));
|
|
90
225
|
}
|
|
91
226
|
|
|
92
|
-
|
|
93
|
-
return this.
|
|
227
|
+
getThread(id) {
|
|
228
|
+
return this.threads.get(id) || null;
|
|
94
229
|
}
|
|
95
230
|
|
|
96
231
|
getPending(agentId) {
|
|
97
|
-
const
|
|
98
|
-
return
|
|
232
|
+
const pending = this.awaiting.get(agentId);
|
|
233
|
+
return pending ? this.threads.get(pending.threadId) : null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function peer(agent) {
|
|
238
|
+
return { id: agent.id, name: agent.name, role: agent.role };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function truncate(text, max) {
|
|
242
|
+
return text.length <= max ? text : `${text.slice(0, max)}… [truncated]`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function extractText(data) {
|
|
246
|
+
if (typeof data === 'string') return data.trim();
|
|
247
|
+
if (Array.isArray(data)) {
|
|
248
|
+
return data.filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim();
|
|
99
249
|
}
|
|
250
|
+
return '';
|
|
100
251
|
}
|
|
@@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'fs
|
|
|
5
5
|
import { resolve } from 'path';
|
|
6
6
|
import { execFile, spawn as cpSpawn } from 'child_process';
|
|
7
7
|
import { getProvider, getInstalledProviders, resolveProviderCommand } from './providers/index.js';
|
|
8
|
+
import { agentLogPath } from './process.js';
|
|
8
9
|
|
|
9
10
|
const DEFAULT_INTERVAL = 300_000; // 5 minutes (safety-net fallback; event-driven triggers handle the normal case)
|
|
10
11
|
const MAX_LOG_CHARS = 100_000; // ~25k tokens budget for synthesis input (captures 80-90% of recent activity)
|
|
@@ -168,7 +169,7 @@ export class Journalist {
|
|
|
168
169
|
|
|
169
170
|
hasNewActivity(agents) {
|
|
170
171
|
for (const agent of agents) {
|
|
171
|
-
const logPath =
|
|
172
|
+
const logPath = agentLogPath(this.daemon.grooveDir, agent);
|
|
172
173
|
if (!existsSync(logPath)) continue;
|
|
173
174
|
try {
|
|
174
175
|
const size = statSync(logPath).size;
|
|
@@ -182,7 +183,7 @@ export class Journalist {
|
|
|
182
183
|
const result = {};
|
|
183
184
|
|
|
184
185
|
for (const agent of agents) {
|
|
185
|
-
const logPath =
|
|
186
|
+
const logPath = agentLogPath(this.daemon.grooveDir, agent);
|
|
186
187
|
if (!existsSync(logPath)) {
|
|
187
188
|
result[agent.id] = { agent, entries: [], explorationEntries: [] };
|
|
188
189
|
continue;
|
|
@@ -574,9 +575,15 @@ export class Journalist {
|
|
|
574
575
|
const provider = getProvider(providerId);
|
|
575
576
|
if (!provider) continue;
|
|
576
577
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
578
|
+
// Digests are light-tier work (Haiku-shaped): default internal overhead to
|
|
579
|
+
// the cheapest tier, escalate via config only on evidence of quality issues.
|
|
580
|
+
// On Claude, medium→light cuts the journalist's marginal cost ~90%.
|
|
581
|
+
const models = provider.constructor.models || [];
|
|
582
|
+
const preferredTier = this.daemon.config?.journalistModelTier || 'light';
|
|
583
|
+
const selectedModel = models.find((m) => m.tier === preferredTier)
|
|
584
|
+
|| models.find((m) => m.tier === 'light')
|
|
585
|
+
|| models.find((m) => m.tier === 'medium')
|
|
586
|
+
|| models[0];
|
|
580
587
|
const modelId = selectedModel?.id || null;
|
|
581
588
|
|
|
582
589
|
// Try CLI headless command first
|
|
@@ -1055,7 +1062,7 @@ export class Journalist {
|
|
|
1055
1062
|
* Budget: keeps recent turns verbatim, summarizes oldest if over maxChars.
|
|
1056
1063
|
*/
|
|
1057
1064
|
extractConversationThread(agent, { maxChars = 60000 } = {}) {
|
|
1058
|
-
const logPath =
|
|
1065
|
+
const logPath = agentLogPath(this.daemon.grooveDir, agent);
|
|
1059
1066
|
if (!existsSync(logPath)) return null;
|
|
1060
1067
|
|
|
1061
1068
|
let content;
|
|
@@ -1297,7 +1304,7 @@ export class Journalist {
|
|
|
1297
1304
|
* Used by the Introducer to tell new agents what their teammates built.
|
|
1298
1305
|
*/
|
|
1299
1306
|
getAgentFiles(agent) {
|
|
1300
|
-
const logPath =
|
|
1307
|
+
const logPath = agentLogPath(this.daemon.grooveDir, agent);
|
|
1301
1308
|
if (!existsSync(logPath)) return [];
|
|
1302
1309
|
|
|
1303
1310
|
try {
|
|
@@ -1335,7 +1342,7 @@ export class Journalist {
|
|
|
1335
1342
|
* Used to capture planner conclusions, build summaries, etc.
|
|
1336
1343
|
*/
|
|
1337
1344
|
getAgentResult(agent) {
|
|
1338
|
-
const logPath =
|
|
1345
|
+
const logPath = agentLogPath(this.daemon.grooveDir, agent);
|
|
1339
1346
|
if (!existsSync(logPath)) return '';
|
|
1340
1347
|
|
|
1341
1348
|
try {
|