groove-dev 0.27.184 → 0.27.186
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/.watch-test-flag-1784767927904 +1 -0
- package/.watch-test-flag-1784768055729 +1 -0
- package/.watch-test-flag-1784768066364 +1 -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 +3 -0
- package/node_modules/@groove-dev/daemon/src/index.js +17 -0
- package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +60 -5
- package/node_modules/@groove-dev/daemon/src/innerchat.js +167 -54
- package/node_modules/@groove-dev/daemon/src/introducer.js +3 -2
- package/node_modules/@groove-dev/daemon/src/journalist.js +83 -42
- package/node_modules/@groove-dev/daemon/src/process.js +19 -10
- package/node_modules/@groove-dev/daemon/src/rotator.js +6 -1
- package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +80 -28
- package/node_modules/@groove-dev/daemon/src/routes/watch.js +42 -0
- package/node_modules/@groove-dev/daemon/src/watcher.js +258 -0
- package/node_modules/@groove-dev/daemon/test/innerchat.test.js +71 -1
- package/node_modules/@groove-dev/daemon/test/journalist.test.js +59 -0
- package/node_modules/@groove-dev/daemon/test/rotator.test.js +22 -4
- package/node_modules/@groove-dev/daemon/test/watcher.test.js +158 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-CU8L_r5f.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/{index-BpOyN6Zf.js → index-DOOaCFRS.js} +227 -222
- 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.css +1 -0
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +92 -4
- package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +25 -0
- package/node_modules/@groove-dev/gui/src/lib/logpaths.js +45 -0
- package/node_modules/@groove-dev/gui/src/stores/groove.js +30 -16
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +6 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/ui-slice.js +11 -0
- package/node_modules/@groove-dev/gui/src/views/memory.jsx +56 -31
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +3 -0
- package/packages/daemon/src/index.js +17 -0
- package/packages/daemon/src/innerchat-docs.js +60 -5
- package/packages/daemon/src/innerchat.js +167 -54
- package/packages/daemon/src/introducer.js +3 -2
- package/packages/daemon/src/journalist.js +83 -42
- package/packages/daemon/src/process.js +19 -10
- package/packages/daemon/src/rotator.js +6 -1
- package/packages/daemon/src/routes/innerchat.js +80 -28
- package/packages/daemon/src/routes/watch.js +42 -0
- package/packages/daemon/src/watcher.js +258 -0
- package/packages/gui/dist/assets/index-CU8L_r5f.css +1 -0
- package/packages/gui/dist/assets/{index-BpOyN6Zf.js → index-DOOaCFRS.js} +227 -222
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/app.css +1 -0
- package/packages/gui/src/components/agents/agent-feed.jsx +92 -4
- package/packages/gui/src/components/editor/terminal.jsx +25 -0
- package/packages/gui/src/lib/logpaths.js +45 -0
- package/packages/gui/src/stores/groove.js +30 -16
- package/packages/gui/src/stores/slices/agents-slice.js +6 -0
- package/packages/gui/src/stores/slices/ui-slice.js +11 -0
- package/packages/gui/src/views/memory.jsx +56 -31
- package/node_modules/@groove-dev/gui/dist/assets/index-DiXB7yry.css +0 -1
- package/packages/gui/dist/assets/index-DiXB7yry.css +0 -1
|
@@ -17,25 +17,80 @@ export function innerChatInstructions(port = 31415, agentName = 'YOUR_NAME') {
|
|
|
17
17
|
'When the user asks you to reach out to, coordinate with, or get input from',
|
|
18
18
|
'another agent, use this — it sends them a message and waits for their reply:',
|
|
19
19
|
'',
|
|
20
|
+
'> **Do NOT use your built-in `SendMessage` / Agent tools to reach a GROOVE agent.**',
|
|
21
|
+
'> Those only address sub-agents you spawned yourself in this session, so they will',
|
|
22
|
+
'> fail with "not reachable" or ask you for an `a…-…` agent ID that does not exist',
|
|
23
|
+
'> here. GROOVE agents are separate processes. The curl below is the only way to',
|
|
24
|
+
'> reach them. Do not invent a fallback (writing a message into a file, etc.) —',
|
|
25
|
+
'> if the curl fails, read the error and report it to the user.',
|
|
26
|
+
'',
|
|
20
27
|
'```bash',
|
|
21
28
|
`curl -s http://localhost:${port}/api/innerchat/ask -X POST -H 'Content-Type: application/json' \\`,
|
|
22
29
|
` -d '{"from":"${agentName}","to":"AGENT_NAME","message":"YOUR_QUESTION"}'`,
|
|
23
30
|
'```',
|
|
24
31
|
'',
|
|
25
|
-
'
|
|
32
|
+
'This BLOCKS until they answer, then returns:',
|
|
26
33
|
'`{"from":"...","reply":"...","exchangesUsed":N,"exchangesRemaining":N}`',
|
|
27
34
|
'',
|
|
35
|
+
'> **Run the curl in the FOREGROUND and let it block.** Do not background it with',
|
|
36
|
+
"> `&`, and never put `&` inside the command — that detaches the curl and throws away",
|
|
37
|
+
"> the reply, so you get no answer and can't confirm delivery. Blocking for a minute or",
|
|
38
|
+
'> two is expected and correct; just wait for it. If you truly cannot wait, use `tell`',
|
|
39
|
+
'> (below) instead — that is the right tool for fire-and-forget, not a backgrounded ask.',
|
|
40
|
+
'',
|
|
28
41
|
'Call it again to continue — prior turns are included automatically, so just say',
|
|
29
42
|
'the next thing. Keep going until you reach a conclusion together, then report',
|
|
30
|
-
'that conclusion to the user.',
|
|
43
|
+
'that conclusion to the user. `ask` is best for tight back-and-forth (negotiating',
|
|
44
|
+
'an interface, resolving a blocker) where you need the answer before you proceed.',
|
|
45
|
+
'',
|
|
46
|
+
'If the other agent is heads-down and you do NOT need the answer right now, send it',
|
|
47
|
+
'without blocking instead — this returns immediately, and their reply (if any) is',
|
|
48
|
+
'delivered back to you later, waking you if your turn has ended:',
|
|
49
|
+
'',
|
|
50
|
+
'```bash',
|
|
51
|
+
`curl -s http://localhost:${port}/api/innerchat/tell -X POST -H 'Content-Type: application/json' \\`,
|
|
52
|
+
` -d '{"from":"${agentName}","to":"AGENT_NAME","message":"YOUR_MESSAGE"}'`,
|
|
53
|
+
'```',
|
|
31
54
|
'',
|
|
32
55
|
'**Rules:**',
|
|
33
56
|
'- Only start a conversation when the user asks you to. Never consult other agents on your own initiative.',
|
|
34
57
|
`- \`from\` must be your own agent name${agentName === 'YOUR_NAME' ? ' (it is in your $GROOVE_AGENT_NAME environment variable)' : ''}.`,
|
|
35
58
|
'- Agent names are listed in `AGENTS_REGISTRY.md`, including agents on other teams.',
|
|
36
|
-
'-
|
|
59
|
+
'- Use `ask` when you need the reply now; use `tell` to hand off or notify without waiting.',
|
|
60
|
+
'- One message per call. With `ask`, wait for the answer before deciding what to ask next.',
|
|
61
|
+
'- Ground truth lives in files (specs, code, run output), not in chat — use InnerChat to',
|
|
62
|
+
' negotiate and hand off, and verify claims against the actual artifacts.',
|
|
37
63
|
'- Read any error you get back — it tells you what to do (unknown name, cap reached, or they are waiting on you).',
|
|
38
|
-
'- `exchangesRemaining` counts down. At 0 the conversation is over: stop and report
|
|
39
|
-
'- If another agent asks YOU something, answer it directly in your next message
|
|
64
|
+
'- `exchangesRemaining` counts down (shared by ask and tell). At 0 the conversation is over: stop and report.',
|
|
65
|
+
'- If another agent asks YOU something (a blocking ask), answer it directly in your next message — they are blocked until you do.',
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Wake-on-completion. Same delivery/injection story as InnerChat above.
|
|
70
|
+
export function watchInstructions(port = 31415, agentName = 'YOUR_NAME') {
|
|
71
|
+
const nameHint = agentName === 'YOUR_NAME' ? ' ($GROOVE_AGENT_NAME)' : '';
|
|
72
|
+
return [
|
|
73
|
+
'## Getting Notified When Something Finishes (Watch)',
|
|
74
|
+
'',
|
|
75
|
+
'Your process ends when your turn ends, so a test suite or long job you kicked',
|
|
76
|
+
'off has nothing to notify you — telling the user "I\'ll report back when it lands"',
|
|
77
|
+
'does not work on its own; the session is gone. Instead, set a Watch. The daemon',
|
|
78
|
+
'outlives your turn and RESUMES you with the result when it finishes:',
|
|
79
|
+
'',
|
|
80
|
+
'```bash',
|
|
81
|
+
`curl -s http://localhost:${port}/api/watch -X POST -H 'Content-Type: application/json' \\`,
|
|
82
|
+
` -d '{"agent":"${agentName}","label":"test suite","command":"npm test"}'`,
|
|
83
|
+
'```',
|
|
84
|
+
'',
|
|
85
|
+
'This returns immediately with a `watchId`. **End your turn** — you will be woken',
|
|
86
|
+
'with the exit code and output tail when the command finishes.',
|
|
87
|
+
'',
|
|
88
|
+
'- Prefer `command` (let the daemon run it) over launching it yourself — that way',
|
|
89
|
+
' it captures the real exit code and output.',
|
|
90
|
+
'- For something ALREADY running, poll instead with `until` (fires when the check',
|
|
91
|
+
` exits 0): \`-d '{"agent":"${agentName}","label":"server up","until":"curl -sf localhost:3000/health"}'\`.`,
|
|
92
|
+
`- \`agent\` must be your own name${nameHint}.`,
|
|
93
|
+
'- Watches time out (default 30 min). One clear task per watch; do not watch trivial commands you could just run.',
|
|
94
|
+
'- Do not poll or sleep-loop waiting yourself — set the watch and end the turn. That is the whole point.',
|
|
40
95
|
];
|
|
41
96
|
}
|
|
@@ -65,81 +65,127 @@ export class InnerChat {
|
|
|
65
65
|
throw new Error(`${toAgent.name} is already answering another agent — try again shortly`);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
const
|
|
69
|
-
|
|
68
|
+
const t = this._openThread(fromAgent, toAgent, opts.threadId);
|
|
69
|
+
this._checkExchangeCap(t, toAgent);
|
|
70
70
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
const timeoutMs = Math.min(Number(opts.timeoutMs) || DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
|
72
|
+
|
|
73
|
+
const { turn, targetId, skipResults } = await this._deliver(t, fromAgent, toAgent, message.trim(), 'ask');
|
|
74
|
+
this.daemon.audit.log('innerchat.ask', { thread: t.id, from: fromAgentId, to: targetId });
|
|
75
|
+
|
|
76
|
+
const reply = await this._awaitReply(t, fromAgentId, targetId, { skipResults, timeoutMs });
|
|
77
|
+
const exchanges = t.turns.filter((x) => x.kind === 'ask' || x.kind === 'tell').length;
|
|
78
|
+
|
|
79
|
+
return { reply, threadId: t.id, exchanges, remaining: MAX_EXCHANGES - exchanges };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Send a message to another agent WITHOUT blocking. Returns as soon as it's
|
|
84
|
+
* delivered. If the target later replies, the reply is routed back to the
|
|
85
|
+
* sender asynchronously — resuming the sender if its turn has ended.
|
|
86
|
+
*
|
|
87
|
+
* This is the fire-and-forget counterpart to ask(): use it to hand off to an
|
|
88
|
+
* agent that's heads-down, where waiting out a timeout would waste the turn.
|
|
89
|
+
*/
|
|
90
|
+
async tell(fromAgentId, toAgentId, message, opts = {}) {
|
|
91
|
+
const fromAgent = this.daemon.registry.get(fromAgentId);
|
|
92
|
+
const toAgent = this.daemon.registry.get(toAgentId);
|
|
93
|
+
if (!fromAgent) throw new Error(`Calling agent ${fromAgentId} not found`);
|
|
94
|
+
if (!toAgent) throw new Error(`Target agent ${toAgentId} not found`);
|
|
95
|
+
if (fromAgentId === toAgentId) throw new Error('An agent cannot message itself');
|
|
96
|
+
if (!message || !message.trim()) throw new Error('message is required');
|
|
97
|
+
if (this.awaiting.has(toAgentId)) {
|
|
98
|
+
throw new Error(`${toAgent.name} already has an unanswered message — wait for its reply before sending another`);
|
|
77
99
|
}
|
|
78
100
|
|
|
79
|
-
const
|
|
101
|
+
const t = this._openThread(fromAgent, toAgent, opts.threadId);
|
|
102
|
+
this._checkExchangeCap(t, toAgent);
|
|
80
103
|
|
|
104
|
+
const { turn, targetId, skipResults } = await this._deliver(t, fromAgent, toAgent, message.trim(), 'tell');
|
|
105
|
+
this.daemon.audit.log('innerchat.tell', { thread: t.id, from: fromAgentId, to: targetId });
|
|
106
|
+
|
|
107
|
+
// Register async capture: the reply (if any) is forwarded, not awaited.
|
|
108
|
+
this.awaiting.set(targetId, {
|
|
109
|
+
mode: 'async',
|
|
110
|
+
threadId: t.id,
|
|
111
|
+
senderId: fromAgentId,
|
|
112
|
+
skipResults,
|
|
113
|
+
timer: setTimeout(() => this._clearAwait(targetId), MAX_TIMEOUT_MS),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const exchanges = t.turns.filter((x) => x.kind === 'ask' || x.kind === 'tell').length;
|
|
117
|
+
return { delivered: true, threadId: t.id, exchanges, remaining: MAX_EXCHANGES - exchanges };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Shared send: push a turn, deliver it, and remap the target id if delivery
|
|
121
|
+
// resumed/rotated the agent. Returns the queued-skip count for reply capture.
|
|
122
|
+
async _deliver(thread, fromAgent, toAgent, message, kind) {
|
|
81
123
|
const turn = {
|
|
82
124
|
id: randomUUID().slice(0, 12),
|
|
83
125
|
from: peer(fromAgent),
|
|
84
126
|
to: peer(toAgent),
|
|
85
|
-
text: message
|
|
86
|
-
kind
|
|
127
|
+
text: message,
|
|
128
|
+
kind,
|
|
87
129
|
status: 'sending',
|
|
88
130
|
timestamp: Date.now(),
|
|
89
131
|
};
|
|
90
|
-
|
|
132
|
+
thread.turns.push(turn);
|
|
91
133
|
|
|
92
|
-
const wrapped = this._wrap(
|
|
134
|
+
const wrapped = this._wrap(thread, fromAgent, toAgent, message, kind);
|
|
93
135
|
|
|
94
136
|
let result;
|
|
95
137
|
try {
|
|
96
|
-
result = await deliverInstruction(this.daemon,
|
|
138
|
+
result = await deliverInstruction(this.daemon, toAgent.id, wrapped, { recordFeedback: false });
|
|
97
139
|
} catch (err) {
|
|
98
140
|
turn.status = 'failed';
|
|
99
141
|
turn.error = err.message;
|
|
100
|
-
|
|
101
|
-
this._broadcast(
|
|
142
|
+
thread.status = 'failed';
|
|
143
|
+
this._broadcast(thread, turn);
|
|
102
144
|
throw new Error(`Could not reach ${toAgent.name}: ${err.message}`);
|
|
103
145
|
}
|
|
104
146
|
|
|
105
|
-
// A stopped target gets resumed or rotated,
|
|
106
|
-
// Re-key onto it or the answer will never be matched.
|
|
147
|
+
// A stopped target gets resumed or rotated, minting a new agent id.
|
|
107
148
|
const targetId = result.agentId;
|
|
108
|
-
if (targetId !==
|
|
109
|
-
this._remapParticipant(
|
|
149
|
+
if (targetId !== toAgent.id) {
|
|
150
|
+
this._remapParticipant(thread, toAgent.id, targetId);
|
|
110
151
|
turn.to.id = targetId;
|
|
111
152
|
}
|
|
112
153
|
|
|
113
154
|
turn.status = result.status;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
this._broadcast(
|
|
117
|
-
this.daemon.audit.log('innerchat.ask', { thread: t.id, from: fromAgentId, to: targetId });
|
|
155
|
+
thread.status = 'awaiting_reply';
|
|
156
|
+
thread.updatedAt = Date.now();
|
|
157
|
+
this._broadcast(thread, turn);
|
|
118
158
|
|
|
119
|
-
// A queued
|
|
120
|
-
//
|
|
121
|
-
|
|
159
|
+
// A queued message sits behind the target's current work, so the next
|
|
160
|
+
// result belongs to that prior task, not to us — skip it.
|
|
161
|
+
return { turn, targetId, skipResults: result.status === 'message_queued' ? 1 : 0 };
|
|
162
|
+
}
|
|
122
163
|
|
|
123
|
-
|
|
164
|
+
_openThread(fromAgent, toAgent, threadId) {
|
|
165
|
+
const thread = threadId ? this.threads.get(threadId) : this._findThread(fromAgent.id, toAgent.id);
|
|
166
|
+
return thread || this._createThread(fromAgent, toAgent);
|
|
167
|
+
}
|
|
124
168
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
169
|
+
_checkExchangeCap(thread, toAgent) {
|
|
170
|
+
const used = thread.turns.filter((x) => x.kind === 'ask' || x.kind === 'tell').length;
|
|
171
|
+
if (used >= MAX_EXCHANGES) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`This conversation has reached its ${MAX_EXCHANGES}-exchange limit. Stop consulting `
|
|
174
|
+
+ `${toAgent.name} and report your conclusion to the user.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
131
177
|
}
|
|
132
178
|
|
|
133
179
|
_awaitReply(thread, askerId, targetId, { skipResults, timeoutMs }) {
|
|
134
180
|
return new Promise((resolve, reject) => {
|
|
135
181
|
const settle = (fn, value) => {
|
|
136
|
-
|
|
137
|
-
this.awaiting.delete(targetId);
|
|
182
|
+
this._clearAwait(targetId);
|
|
138
183
|
this.blockedOn.delete(askerId);
|
|
139
184
|
fn(value);
|
|
140
185
|
};
|
|
141
186
|
|
|
142
187
|
const record = {
|
|
188
|
+
mode: 'block',
|
|
143
189
|
threadId: thread.id,
|
|
144
190
|
askerId,
|
|
145
191
|
skipResults,
|
|
@@ -150,7 +196,7 @@ export class InnerChat {
|
|
|
150
196
|
thread.updatedAt = Date.now();
|
|
151
197
|
record.reject(new Error(
|
|
152
198
|
`No answer within ${Math.round(timeoutMs / 1000)}s. The agent may still be working — `
|
|
153
|
-
+ 'proceed on your own judgement or
|
|
199
|
+
+ 'proceed on your own judgement, or use a non-blocking send (tell) next time.',
|
|
154
200
|
));
|
|
155
201
|
}, timeoutMs),
|
|
156
202
|
};
|
|
@@ -161,7 +207,7 @@ export class InnerChat {
|
|
|
161
207
|
}
|
|
162
208
|
|
|
163
209
|
/**
|
|
164
|
-
* Watch agent output for the
|
|
210
|
+
* Watch agent output for the reply to an outstanding message.
|
|
165
211
|
* Called from the process manager's output handler.
|
|
166
212
|
*/
|
|
167
213
|
onAgentOutput(agentId, output) {
|
|
@@ -170,7 +216,7 @@ export class InnerChat {
|
|
|
170
216
|
if (output.type !== 'result') return;
|
|
171
217
|
|
|
172
218
|
const thread = this.threads.get(pending.threadId);
|
|
173
|
-
if (!thread) {
|
|
219
|
+
if (!thread) { this._settlePending(agentId, pending, null, new Error('Conversation was lost')); return; }
|
|
174
220
|
|
|
175
221
|
const text = extractText(output.data);
|
|
176
222
|
if (!text) return;
|
|
@@ -182,7 +228,7 @@ export class InnerChat {
|
|
|
182
228
|
}
|
|
183
229
|
|
|
184
230
|
const responder = this.daemon.registry.get(agentId);
|
|
185
|
-
|
|
231
|
+
const answerTurn = {
|
|
186
232
|
id: randomUUID().slice(0, 12),
|
|
187
233
|
from: responder ? peer(responder) : { id: agentId, name: agentId, role: 'agent' },
|
|
188
234
|
to: this._otherParticipant(thread, agentId),
|
|
@@ -190,27 +236,67 @@ export class InnerChat {
|
|
|
190
236
|
kind: 'answer',
|
|
191
237
|
status: 'delivered',
|
|
192
238
|
timestamp: Date.now(),
|
|
193
|
-
}
|
|
239
|
+
};
|
|
240
|
+
thread.turns.push(answerTurn);
|
|
194
241
|
thread.status = 'idle';
|
|
195
242
|
thread.updatedAt = Date.now();
|
|
196
|
-
|
|
197
|
-
this._broadcast(thread, thread.turns.at(-1));
|
|
243
|
+
this._broadcast(thread, answerTurn);
|
|
198
244
|
this.daemon.audit.log('innerchat.answer', { thread: thread.id, from: agentId });
|
|
199
245
|
|
|
200
|
-
pending.
|
|
246
|
+
if (pending.mode === 'async') {
|
|
247
|
+
this._clearAwait(agentId);
|
|
248
|
+
this._forwardAsyncReply(pending, answerTurn, text);
|
|
249
|
+
} else {
|
|
250
|
+
pending.resolve(text);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Deliver an async (tell) reply back to the original sender, resuming it if
|
|
255
|
+
// its turn has ended. The sender is not blocked, so this just wakes them.
|
|
256
|
+
async _forwardAsyncReply(pending, answerTurn, text) {
|
|
257
|
+
const sender = this.daemon.registry.get(pending.senderId)
|
|
258
|
+
|| this.daemon.registry.getAll().find((a) => a.name === answerTurn.to.name);
|
|
259
|
+
if (!sender) return; // sender is gone — nothing to route back to
|
|
260
|
+
|
|
261
|
+
const msg = [
|
|
262
|
+
`[InnerChat reply from ${answerTurn.from.name} (${answerTurn.from.role})]`,
|
|
263
|
+
'',
|
|
264
|
+
text,
|
|
265
|
+
'',
|
|
266
|
+
'This is a reply to a message you sent earlier (you were not blocking on it). '
|
|
267
|
+
+ 'Fold it into your work, or reply again to continue.',
|
|
268
|
+
].join('\n');
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
await deliverInstruction(this.daemon, sender.id, msg, { recordFeedback: false });
|
|
272
|
+
} catch (err) {
|
|
273
|
+
this.daemon.audit.log('innerchat.forward_failed', { to: sender.id, error: err.message });
|
|
274
|
+
}
|
|
201
275
|
}
|
|
202
276
|
|
|
203
277
|
/**
|
|
204
|
-
* An agent that dies mid-question would otherwise leave
|
|
205
|
-
* until
|
|
278
|
+
* An agent that dies mid-question would otherwise leave a blocking asker
|
|
279
|
+
* stuck until timeout. Async senders aren't waiting, so just clear those.
|
|
206
280
|
*/
|
|
207
281
|
onAgentGone(agentId, reason = 'stopped') {
|
|
208
282
|
const pending = this.awaiting.get(agentId);
|
|
209
283
|
if (!pending) return;
|
|
284
|
+
if (pending.mode === 'async') { this._clearAwait(agentId); return; }
|
|
210
285
|
const agent = this.daemon.registry.get(agentId);
|
|
211
286
|
pending.reject(new Error(`${agent?.name || agentId} ${reason} before answering`));
|
|
212
287
|
}
|
|
213
288
|
|
|
289
|
+
_clearAwait(targetId) {
|
|
290
|
+
const pending = this.awaiting.get(targetId);
|
|
291
|
+
if (pending?.timer) clearTimeout(pending.timer);
|
|
292
|
+
this.awaiting.delete(targetId);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
_settlePending(agentId, pending, value, err) {
|
|
296
|
+
if (pending.mode === 'async') { this._clearAwait(agentId); return; }
|
|
297
|
+
if (err) pending.reject(err); else pending.resolve(value);
|
|
298
|
+
}
|
|
299
|
+
|
|
214
300
|
// ── Threads ─────────────────────────────────────────────────
|
|
215
301
|
|
|
216
302
|
_createThread(fromAgent, toAgent) {
|
|
@@ -233,10 +319,15 @@ export class InnerChat {
|
|
|
233
319
|
}
|
|
234
320
|
|
|
235
321
|
// The target sees a direct message from the other agent, with enough prior
|
|
236
|
-
// turns to follow a continuing conversation.
|
|
237
|
-
|
|
322
|
+
// turns to follow a continuing conversation. The closing instruction differs
|
|
323
|
+
// by kind: an ask blocks the sender (answer now), a tell does not (answer
|
|
324
|
+
// when you reach a good stopping point).
|
|
325
|
+
_wrap(thread, fromAgent, toAgent, message, kind = 'ask') {
|
|
326
|
+
const header = kind === 'tell'
|
|
327
|
+
? `[InnerChat — ${fromAgent.name} (${fromAgent.role}) sent you a message]`
|
|
328
|
+
: `[InnerChat — ${fromAgent.name} (${fromAgent.role}) is asking you a question]`;
|
|
238
329
|
const prior = thread.turns.slice(0, -1).slice(-CONTEXT_TURNS);
|
|
239
|
-
const lines = [
|
|
330
|
+
const lines = [header, ''];
|
|
240
331
|
|
|
241
332
|
if (prior.length) {
|
|
242
333
|
lines.push('Earlier in this conversation:');
|
|
@@ -245,11 +336,19 @@ export class InnerChat {
|
|
|
245
336
|
}
|
|
246
337
|
|
|
247
338
|
lines.push(message, '');
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
339
|
+
if (kind === 'tell') {
|
|
340
|
+
lines.push(
|
|
341
|
+
`${fromAgent.name} is NOT blocked on this — finish what you're doing first if you're `
|
|
342
|
+
+ 'mid-task. When you reach a good stopping point, answer directly in a message; your '
|
|
343
|
+
+ `reply is relayed back to ${fromAgent.name}. If no reply is needed, just carry on.`,
|
|
344
|
+
);
|
|
345
|
+
} else {
|
|
346
|
+
lines.push(
|
|
347
|
+
`${fromAgent.name} is BLOCKED waiting on your answer — it cannot continue until you `
|
|
348
|
+
+ 'respond. Answer directly and concisely in your next message; that message is sent '
|
|
349
|
+
+ 'back to it verbatim. Do not address the user, and do not start unrelated work first.',
|
|
350
|
+
);
|
|
351
|
+
}
|
|
253
352
|
return lines.join('\n');
|
|
254
353
|
}
|
|
255
354
|
|
|
@@ -286,6 +385,20 @@ export class InnerChat {
|
|
|
286
385
|
const pending = this.awaiting.get(agentId);
|
|
287
386
|
return pending ? this.threads.get(pending.threadId) : null;
|
|
288
387
|
}
|
|
388
|
+
|
|
389
|
+
// Clear all outstanding timers so a shutdown (or a test) doesn't hang on
|
|
390
|
+
// pending ask/tell timeouts. Blocking asks are rejected so their HTTP
|
|
391
|
+
// requests don't hang either.
|
|
392
|
+
stop() {
|
|
393
|
+
for (const [targetId, pending] of this.awaiting) {
|
|
394
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
395
|
+
if (pending.mode === 'block') {
|
|
396
|
+
try { pending.reject(new Error('Daemon shutting down')); } catch { /* already settled */ }
|
|
397
|
+
}
|
|
398
|
+
this.awaiting.delete(targetId);
|
|
399
|
+
}
|
|
400
|
+
this.blockedOn.clear();
|
|
401
|
+
}
|
|
289
402
|
}
|
|
290
403
|
|
|
291
404
|
function peer(agent) {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { writeFileSync, readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
5
5
|
import { resolve, dirname, basename } from 'path';
|
|
6
6
|
import { escapeMd } from './validate.js';
|
|
7
|
-
import { innerChatInstructions } from './innerchat-docs.js';
|
|
7
|
+
import { innerChatInstructions, watchInstructions } from './innerchat-docs.js';
|
|
8
8
|
|
|
9
9
|
const GROOVE_SECTION_START = '<!-- GROOVE:START -->';
|
|
10
10
|
const GROOVE_SECTION_END = '<!-- GROOVE:END -->';
|
|
@@ -560,7 +560,8 @@ export class Introducer {
|
|
|
560
560
|
// Written into every AGENTS_REGISTRY.md so the capability survives context
|
|
561
561
|
// compaction — the spawn prompt alone can scroll out of a long session.
|
|
562
562
|
_innerChatSection() {
|
|
563
|
-
|
|
563
|
+
const port = this.daemon.port || 31415;
|
|
564
|
+
return [...innerChatInstructions(port), '', ...watchInstructions(port)];
|
|
564
565
|
}
|
|
565
566
|
|
|
566
567
|
writeRegistryFile(projectDir) {
|
|
@@ -10,6 +10,12 @@ import { agentLogPath } from './process.js';
|
|
|
10
10
|
const DEFAULT_INTERVAL = 300_000; // 5 minutes (safety-net fallback; event-driven triggers handle the normal case)
|
|
11
11
|
const MAX_LOG_CHARS = 100_000; // ~25k tokens budget for synthesis input (captures 80-90% of recent activity)
|
|
12
12
|
const DEBOUNCE_MS = 10_000; // requestSynthesis debounce window
|
|
13
|
+
// Conversation carried across a rotation (~37K tokens at 150K chars). This is
|
|
14
|
+
// deliberately generous: a rotation is the one lossy step in an infinite
|
|
15
|
+
// session, and re-reading the recent dialogue once per rotation is far cheaper
|
|
16
|
+
// than the degradation from an agent that forgot its own history. Tune via
|
|
17
|
+
// config resumeBudgetChars (lower for cost-sensitive fleets).
|
|
18
|
+
const DEFAULT_RESUME_BUDGET_CHARS = 150_000;
|
|
13
19
|
|
|
14
20
|
export class Journalist {
|
|
15
21
|
constructor(daemon) {
|
|
@@ -1061,7 +1067,51 @@ export class Journalist {
|
|
|
1061
1067
|
*
|
|
1062
1068
|
* Budget: keeps recent turns verbatim, summarizes oldest if over maxChars.
|
|
1063
1069
|
*/
|
|
1064
|
-
extractConversationThread(agent, { maxChars
|
|
1070
|
+
extractConversationThread(agent, { maxChars } = {}) {
|
|
1071
|
+
// Budget is configurable (resumeBudgetChars): this is the "how much memory
|
|
1072
|
+
// survives a rotation" dial. Bigger costs more tokens per rotation but
|
|
1073
|
+
// preserves more of the session; a rotation is lossy exactly here.
|
|
1074
|
+
if (!maxChars) maxChars = this.daemon.config?.resumeBudgetChars ?? DEFAULT_RESUME_BUDGET_CHARS;
|
|
1075
|
+
const merged = this._parseConversationTurns(agent);
|
|
1076
|
+
if (!merged || merged.length === 0) return null;
|
|
1077
|
+
|
|
1078
|
+
// Build the thread — keep recent turns verbatim, truncate old ones if over budget
|
|
1079
|
+
let thread = '';
|
|
1080
|
+
const formatted = merged.map((t) => {
|
|
1081
|
+
const label = t.role === 'user' ? 'USER' : 'CLAUDE';
|
|
1082
|
+
return `[${label}]:\n${t.text}`;
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
// Start from the end (most recent) and work backwards to fill budget
|
|
1086
|
+
const recentFirst = [...formatted].reverse();
|
|
1087
|
+
const kept = [];
|
|
1088
|
+
let totalLen = 0;
|
|
1089
|
+
|
|
1090
|
+
for (const entry of recentFirst) {
|
|
1091
|
+
if (totalLen + entry.length > maxChars) {
|
|
1092
|
+
// Truncate this entry to fit remaining budget
|
|
1093
|
+
const remaining = maxChars - totalLen;
|
|
1094
|
+
if (remaining > 200) {
|
|
1095
|
+
kept.push(entry.slice(0, remaining) + '\n[...truncated]');
|
|
1096
|
+
}
|
|
1097
|
+
break;
|
|
1098
|
+
}
|
|
1099
|
+
kept.push(entry);
|
|
1100
|
+
totalLen += entry.length;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Reverse back to chronological order
|
|
1104
|
+
kept.reverse();
|
|
1105
|
+
thread = kept.join('\n\n---\n\n');
|
|
1106
|
+
|
|
1107
|
+
return thread;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Parse the full user↔assistant turn list from an agent's stream-json log.
|
|
1112
|
+
* Returns merged turns in chronological order, or null if no log/turns.
|
|
1113
|
+
*/
|
|
1114
|
+
_parseConversationTurns(agent) {
|
|
1065
1115
|
const logPath = agentLogPath(this.daemon.grooveDir, agent);
|
|
1066
1116
|
if (!existsSync(logPath)) return null;
|
|
1067
1117
|
|
|
@@ -1121,37 +1171,23 @@ export class Journalist {
|
|
|
1121
1171
|
merged.push({ ...turn });
|
|
1122
1172
|
}
|
|
1123
1173
|
}
|
|
1174
|
+
return merged;
|
|
1175
|
+
}
|
|
1124
1176
|
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
const
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
// Truncate this entry to fit remaining budget
|
|
1140
|
-
const remaining = maxChars - totalLen;
|
|
1141
|
-
if (remaining > 200) {
|
|
1142
|
-
kept.push(entry.slice(0, remaining) + '\n[...truncated]');
|
|
1143
|
-
}
|
|
1144
|
-
break;
|
|
1145
|
-
}
|
|
1146
|
-
kept.push(entry);
|
|
1147
|
-
totalLen += entry.length;
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
// Reverse back to chronological order
|
|
1151
|
-
kept.reverse();
|
|
1152
|
-
thread = kept.join('\n\n---\n\n');
|
|
1153
|
-
|
|
1154
|
-
return thread;
|
|
1177
|
+
/**
|
|
1178
|
+
* The agent's ORIGINAL task, from the first substantial user message in the
|
|
1179
|
+
* FULL log — never from the truncated resume window. When weeks of dialogue
|
|
1180
|
+
* exceed the resume budget, the window holds only recent work; anchoring the
|
|
1181
|
+
* task there makes the agent's identity drift onto whatever happened last.
|
|
1182
|
+
* The name-keyed log spans all rotations, so the true origin is always here.
|
|
1183
|
+
*/
|
|
1184
|
+
extractOriginalTask(agent) {
|
|
1185
|
+
const merged = this._parseConversationTurns(agent);
|
|
1186
|
+
if (!merged) return '';
|
|
1187
|
+
const first = merged.find((t) => t.role === 'user' && t.text.trim().length >= 10);
|
|
1188
|
+
if (!first) return '';
|
|
1189
|
+
const text = first.text.trim();
|
|
1190
|
+
return text.length > 500 ? text.slice(0, 500) + '...' : text;
|
|
1155
1191
|
}
|
|
1156
1192
|
|
|
1157
1193
|
/**
|
|
@@ -1159,15 +1195,17 @@ export class Journalist {
|
|
|
1159
1195
|
* thread so a fresh agent picks up where the previous session left off.
|
|
1160
1196
|
*/
|
|
1161
1197
|
buildConversationResumePrompt(agent, userMessage, { isRotation = false, reason } = {}) {
|
|
1162
|
-
const
|
|
1198
|
+
const budget = this.daemon.config?.resumeBudgetChars ?? DEFAULT_RESUME_BUDGET_CHARS;
|
|
1199
|
+
const thread = this.extractConversationThread(agent, { maxChars: budget });
|
|
1163
1200
|
if (!thread) return null;
|
|
1164
1201
|
|
|
1165
1202
|
const constraints = this.daemon.memory?.getConstraintsMarkdown(2000) || '';
|
|
1166
1203
|
const discoveries = this.daemon.memory?.getDiscoveriesMarkdown(agent.role, 5, 1000, agent.scope, agent.teamId) || '';
|
|
1167
1204
|
|
|
1168
|
-
//
|
|
1169
|
-
//
|
|
1170
|
-
|
|
1205
|
+
// Anchor the agent to its ORIGINAL task from the full log — the truncated
|
|
1206
|
+
// thread's first message may be mid-project (or another agent's task) once
|
|
1207
|
+
// history outgrows the budget. Fall back to the window only if the log parse fails.
|
|
1208
|
+
const originalTask = this.extractOriginalTask(agent) || this._extractOriginalTask(thread);
|
|
1171
1209
|
|
|
1172
1210
|
// Rotation and idle-resume need very different framing. During rotation the agent
|
|
1173
1211
|
// has no new user message — it must continue the exact in-progress task without
|
|
@@ -1181,6 +1219,7 @@ export class Journalist {
|
|
|
1181
1219
|
originalTask ? `Your task: ${originalTask}` : '',
|
|
1182
1220
|
``,
|
|
1183
1221
|
`Rules:`,
|
|
1222
|
+
`- NEVER mention the rotation, context refresh, restart, or any session discontinuity to the user — from the user's perspective you are one continuous agent and this refresh must be invisible`,
|
|
1184
1223
|
`- Continue ONLY the task described in the conversation below`,
|
|
1185
1224
|
`- Do NOT explore the codebase looking for other things to fix or improve`,
|
|
1186
1225
|
`- Do NOT start new work that was not part of the original task`,
|
|
@@ -1210,14 +1249,16 @@ export class Journalist {
|
|
|
1210
1249
|
isIdleResume ? `## New Message From User\n\n${userMessage}` : '',
|
|
1211
1250
|
``,
|
|
1212
1251
|
isRotation
|
|
1213
|
-
? `Continue the in-progress task from the conversation above. Stay focused on that task only. Do not ask the user to repeat anything. If the task was already completed, state that and wait for new instructions.`
|
|
1214
|
-
: `Continue seamlessly from the conversation above. You have the full context of what was discussed, what was tried, what worked and what didn't. Do not ask the user to repeat anything.`,
|
|
1252
|
+
? `Continue the in-progress task from the conversation above. Stay focused on that task only. Do not ask the user to repeat anything, and never reference this context refresh — continue as if the conversation above simply never stopped. If the task was already completed, state that and wait for new instructions.`
|
|
1253
|
+
: `Continue seamlessly from the conversation above. You have the full context of what was discussed, what was tried, what worked and what didn't. Do not ask the user to repeat anything, and never mention the interruption or that you are resuming — to the user this is one continuous conversation.`,
|
|
1215
1254
|
].filter(Boolean).join('\n');
|
|
1216
1255
|
|
|
1217
|
-
//
|
|
1218
|
-
|
|
1256
|
+
// Cap scales with the configured budget (framing adds constraints/discoveries
|
|
1257
|
+
// on top of the thread) to leave room in the context window
|
|
1258
|
+
const promptCap = budget + 20_000;
|
|
1259
|
+
if (prompt.length > promptCap) {
|
|
1219
1260
|
// Re-extract with smaller budget and rebuild
|
|
1220
|
-
const smallerThread = this.extractConversationThread(agent, { maxChars:
|
|
1261
|
+
const smallerThread = this.extractConversationThread(agent, { maxChars: Math.floor(budget * 2 / 3) });
|
|
1221
1262
|
if (smallerThread) {
|
|
1222
1263
|
prompt = [
|
|
1223
1264
|
`# Session Context Resume`,
|
|
@@ -1238,8 +1279,8 @@ export class Journalist {
|
|
|
1238
1279
|
isIdleResume ? `## New Message From User\n\n${userMessage}` : '',
|
|
1239
1280
|
``,
|
|
1240
1281
|
isRotation
|
|
1241
|
-
? `Continue the in-progress task only. Do not explore or start new work. If done, state that and wait.`
|
|
1242
|
-
: `Continue seamlessly. Do not ask the user to repeat anything.`,
|
|
1282
|
+
? `Continue the in-progress task only. Do not explore or start new work. Never mention this context refresh to the user. If done, state that and wait.`
|
|
1283
|
+
: `Continue seamlessly. Do not ask the user to repeat anything, and never mention the interruption — to the user this is one continuous conversation.`,
|
|
1243
1284
|
].filter(Boolean).join('\n');
|
|
1244
1285
|
}
|
|
1245
1286
|
}
|