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
|
@@ -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
|
}
|
|
@@ -10,7 +10,7 @@ import { LocalProvider } from './providers/local.js';
|
|
|
10
10
|
import { OllamaProvider } from './providers/ollama.js';
|
|
11
11
|
import { AgentLoop } from './agent-loop.js';
|
|
12
12
|
import { validateAgentConfig } from './validate.js';
|
|
13
|
-
import { innerChatInstructions } from './innerchat-docs.js';
|
|
13
|
+
import { innerChatInstructions, watchInstructions } from './innerchat-docs.js';
|
|
14
14
|
|
|
15
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
16
|
const SLIDES_ENGINE_SRC = resolve(__dirname, '../templates/groove-slides.cjs');
|
|
@@ -562,12 +562,17 @@ export class ProcessManager {
|
|
|
562
562
|
const pending = this.consumePendingMessage(agent.id);
|
|
563
563
|
if (pending) {
|
|
564
564
|
const agentData = registry.get(agent.id);
|
|
565
|
-
if
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
565
|
+
// Deliver the queued message: resume if there's a session, otherwise
|
|
566
|
+
// rotate (fresh session + handoff brief). Previously a missing sessionId
|
|
567
|
+
// meant the already-consumed message was silently dropped — an
|
|
568
|
+
// InnerChat message to a busy agent would vanish and never fire it.
|
|
569
|
+
const deliver = agentData?.sessionId
|
|
570
|
+
? this.resume(agent.id, pending.message)
|
|
571
|
+
: this.daemon.rotator.rotate(agent.id, { additionalPrompt: pending.message });
|
|
572
|
+
deliver.catch((err) => {
|
|
573
|
+
console.error(`[Groove] Delivering queued message to ${agent.name} failed: ${err.message}`);
|
|
574
|
+
});
|
|
575
|
+
return;
|
|
571
576
|
}
|
|
572
577
|
}
|
|
573
578
|
|
|
@@ -1165,11 +1170,15 @@ For normal file edits within your scope, proceed without review.
|
|
|
1165
1170
|
// sandboxed providers (Codex) can't reach it.
|
|
1166
1171
|
if (!sandboxedProviders.includes(providerName) && !isOneShotProvider) {
|
|
1167
1172
|
const port = this.daemon.port || 31415;
|
|
1168
|
-
const
|
|
1173
|
+
const capabilities = [
|
|
1174
|
+
...innerChatInstructions(port, agent.name),
|
|
1175
|
+
'',
|
|
1176
|
+
...watchInstructions(port, agent.name),
|
|
1177
|
+
].join('\n') + '\n\n';
|
|
1169
1178
|
if (spawnConfig.prompt.startsWith('# Handoff Brief')) {
|
|
1170
|
-
spawnConfig.prompt += '\n\n' +
|
|
1179
|
+
spawnConfig.prompt += '\n\n' + capabilities.trim();
|
|
1171
1180
|
} else {
|
|
1172
|
-
spawnConfig.prompt =
|
|
1181
|
+
spawnConfig.prompt = capabilities + spawnConfig.prompt;
|
|
1173
1182
|
}
|
|
1174
1183
|
}
|
|
1175
1184
|
|
|
@@ -18,7 +18,12 @@ const SCORE_HISTORY_MAX = 40; // ~10 min at 15s intervals
|
|
|
18
18
|
const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes between rotations per agent
|
|
19
19
|
const QUALITY_COOLDOWN_MS = 2 * 60 * 1000; // 2 minutes for quality degradation rotations
|
|
20
20
|
const TOKEN_CEILING = 5_000_000; // 5M tokens per agent (non-self-managing only)
|
|
21
|
-
|
|
21
|
+
// Self-managing providers: rotate idle agents above this context usage.
|
|
22
|
+
// 0.80 favors context retention over cache-read cost — the conversation-resume
|
|
23
|
+
// window is a slice of the full session, so every rotation is lossy; for
|
|
24
|
+
// complex long-lived work that loss compounds faster than token savings pay off.
|
|
25
|
+
// Lower via config (replayCeiling) for cost-sensitive fleets; 0 disables.
|
|
26
|
+
const REPLAY_CEILING = 0.80;
|
|
22
27
|
const VELOCITY_CEILING = 250_000; // New (non-cache) tokens per window before force rotation
|
|
23
28
|
const VELOCITY_WINDOW_MS = 5 * 60_000;
|
|
24
29
|
const ROLE_MULTIPLIERS = {
|
|
@@ -2,15 +2,51 @@
|
|
|
2
2
|
|
|
3
3
|
import { MAX_EXCHANGES } from '../innerchat.js';
|
|
4
4
|
|
|
5
|
-
// Agents know each other by name, not id
|
|
6
|
-
//
|
|
5
|
+
// Agents know each other by name, not id. Exact matches win first so
|
|
6
|
+
// `fullstack-1` never resolves to `fullstack-14`; only if nothing matches
|
|
7
|
+
// exactly do we accept a single unambiguous partial, since agents routinely
|
|
8
|
+
// half-remember a teammate's name. An ambiguous partial resolves to nothing
|
|
9
|
+
// and the caller gets the candidate list instead of a wrong recipient.
|
|
7
10
|
function resolveAgent(daemon, ref) {
|
|
8
11
|
if (!ref || typeof ref !== 'string') return null;
|
|
9
12
|
const all = daemon.registry.getAll();
|
|
10
|
-
|
|
13
|
+
const needle = ref.trim().toLowerCase();
|
|
14
|
+
|
|
15
|
+
const exact = all.find((a) => a.id === ref)
|
|
11
16
|
|| all.find((a) => a.name === ref)
|
|
12
|
-
|| all.find((a) => a.name.toLowerCase() ===
|
|
13
|
-
|
|
17
|
+
|| all.find((a) => a.name.toLowerCase() === needle);
|
|
18
|
+
if (exact) return exact;
|
|
19
|
+
|
|
20
|
+
const partial = all.filter((a) => a.name.toLowerCase().includes(needle)
|
|
21
|
+
|| needle.includes(a.name.toLowerCase()));
|
|
22
|
+
return partial.length === 1 ? partial[0] : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Resolve the from/to pair from a request body, or write the appropriate
|
|
26
|
+
// 400/404 and return null so the caller bails.
|
|
27
|
+
function resolveParties(daemon, req, res) {
|
|
28
|
+
const { from, to, message } = req.body || {};
|
|
29
|
+
|
|
30
|
+
const fromAgent = resolveAgent(daemon, from);
|
|
31
|
+
if (!fromAgent) { res.status(404).json({ error: `Unknown calling agent: ${from}` }); return null; }
|
|
32
|
+
|
|
33
|
+
const toAgent = resolveAgent(daemon, to);
|
|
34
|
+
if (!toAgent) {
|
|
35
|
+
const others = daemon.registry.getAll().filter((a) => a.id !== fromAgent.id);
|
|
36
|
+
const needle = String(to || '').trim().toLowerCase();
|
|
37
|
+
const close = others.filter((a) => a.name.toLowerCase().includes(needle)).map((a) => a.name);
|
|
38
|
+
if (close.length > 1) {
|
|
39
|
+
res.status(404).json({ error: `"${to}" matches more than one agent — use the full name.`, didYouMean: close });
|
|
40
|
+
} else {
|
|
41
|
+
res.status(404).json({ error: `No agent named "${to}".`, availableAgents: others.map((a) => a.name) });
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!message || typeof message !== 'string' || !message.trim()) {
|
|
47
|
+
res.status(400).json({ error: 'message is required' }); return null;
|
|
48
|
+
}
|
|
49
|
+
return { fromAgent, toAgent, message: message.trim() };
|
|
14
50
|
}
|
|
15
51
|
|
|
16
52
|
export function registerInnerChatRoutes(app, daemon) {
|
|
@@ -18,39 +54,25 @@ export function registerInnerChatRoutes(app, daemon) {
|
|
|
18
54
|
* Ask another agent a question and BLOCK until it answers.
|
|
19
55
|
*
|
|
20
56
|
* This request is held open deliberately — the calling agent is waiting on
|
|
21
|
-
* it, and the response body is the other agent's reply.
|
|
22
|
-
*
|
|
57
|
+
* it, and the response body is the other agent's reply. Best for tight
|
|
58
|
+
* interface negotiation, where the finite exchange budget keeps both sides
|
|
59
|
+
* writing decision-dense messages.
|
|
23
60
|
*/
|
|
24
61
|
app.post('/api/innerchat/ask', async (req, res) => {
|
|
25
62
|
try {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
const fromAgent = resolveAgent(daemon, from);
|
|
29
|
-
if (!fromAgent) return res.status(404).json({ error: `Unknown calling agent: ${from}` });
|
|
30
|
-
|
|
31
|
-
const toAgent = resolveAgent(daemon, to);
|
|
32
|
-
if (!toAgent) {
|
|
33
|
-
const names = daemon.registry.getAll()
|
|
34
|
-
.filter((a) => a.id !== fromAgent.id)
|
|
35
|
-
.map((a) => a.name);
|
|
36
|
-
return res.status(404).json({
|
|
37
|
-
error: `No agent named "${to}".`,
|
|
38
|
-
availableAgents: names,
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (!message || typeof message !== 'string' || !message.trim()) {
|
|
43
|
-
return res.status(400).json({ error: 'message is required' });
|
|
44
|
-
}
|
|
63
|
+
const parties = resolveParties(daemon, req, res);
|
|
64
|
+
if (!parties) return;
|
|
45
65
|
|
|
46
66
|
// Held open until the target answers — see the class doc in innerchat.js.
|
|
47
67
|
req.setTimeout(0);
|
|
48
68
|
res.setTimeout(0);
|
|
49
69
|
|
|
50
|
-
const result = await daemon.innerchat.ask(fromAgent.id, toAgent.id, message
|
|
70
|
+
const result = await daemon.innerchat.ask(parties.fromAgent.id, parties.toAgent.id, parties.message, {
|
|
71
|
+
timeoutMs: req.body?.timeoutMs,
|
|
72
|
+
});
|
|
51
73
|
|
|
52
74
|
res.json({
|
|
53
|
-
from: toAgent.name,
|
|
75
|
+
from: parties.toAgent.name,
|
|
54
76
|
reply: result.reply,
|
|
55
77
|
threadId: result.threadId,
|
|
56
78
|
exchangesUsed: result.exchanges,
|
|
@@ -63,6 +85,36 @@ export function registerInnerChatRoutes(app, daemon) {
|
|
|
63
85
|
}
|
|
64
86
|
});
|
|
65
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Send a message WITHOUT blocking — returns as soon as it's delivered. If the
|
|
90
|
+
* target replies, the reply is routed back to the sender asynchronously
|
|
91
|
+
* (resuming it if its turn ended). Best for handing off to a heads-down agent
|
|
92
|
+
* where waiting out a timeout would waste the turn.
|
|
93
|
+
*/
|
|
94
|
+
app.post('/api/innerchat/tell', async (req, res) => {
|
|
95
|
+
try {
|
|
96
|
+
const parties = resolveParties(daemon, req, res);
|
|
97
|
+
if (!parties) return;
|
|
98
|
+
|
|
99
|
+
const result = await daemon.innerchat.tell(parties.fromAgent.id, parties.toAgent.id, parties.message, {
|
|
100
|
+
threadId: req.body?.threadId,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
res.json({
|
|
104
|
+
ok: true,
|
|
105
|
+
to: parties.toAgent.name,
|
|
106
|
+
delivered: result.delivered,
|
|
107
|
+
threadId: result.threadId,
|
|
108
|
+
exchangesUsed: result.exchanges,
|
|
109
|
+
exchangesRemaining: result.remaining,
|
|
110
|
+
maxExchanges: MAX_EXCHANGES,
|
|
111
|
+
note: `Message delivered. ${parties.toAgent.name}'s reply, if any, will be routed back to you — you can end your turn.`,
|
|
112
|
+
});
|
|
113
|
+
} catch (err) {
|
|
114
|
+
res.status(409).json({ error: err.message });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
66
118
|
app.get('/api/innerchat/threads', (req, res) => {
|
|
67
119
|
const { agentId } = req.query;
|
|
68
120
|
res.json({ threads: daemon.innerchat.getThreads(agentId || null) });
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
|
|
3
|
+
// Agents identify themselves by name; resolve to the live record.
|
|
4
|
+
function resolveAgent(daemon, ref) {
|
|
5
|
+
if (!ref || typeof ref !== 'string') return null;
|
|
6
|
+
const all = daemon.registry.getAll();
|
|
7
|
+
return all.find((a) => a.id === ref)
|
|
8
|
+
|| all.find((a) => a.name === ref)
|
|
9
|
+
|| all.find((a) => a.name.toLowerCase() === ref.trim().toLowerCase())
|
|
10
|
+
|| null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function registerWatchRoutes(app, daemon) {
|
|
14
|
+
// Register a watch and return immediately — the agent's turn ends, and the
|
|
15
|
+
// wake comes later when the watched thing finishes. This does NOT block.
|
|
16
|
+
app.post('/api/watch', (req, res) => {
|
|
17
|
+
try {
|
|
18
|
+
const { agent, command, until, label, timeoutMs, intervalMs } = req.body || {};
|
|
19
|
+
const who = resolveAgent(daemon, agent);
|
|
20
|
+
if (!who) return res.status(404).json({ error: `Unknown agent: ${agent}` });
|
|
21
|
+
|
|
22
|
+
const watch = daemon.watcher.create(who.id, { command, until, label, timeoutMs, intervalMs });
|
|
23
|
+
res.json({
|
|
24
|
+
ok: true,
|
|
25
|
+
watchId: watch.id,
|
|
26
|
+
message: `Watching "${watch.label}". You'll be resumed with the result when it ${watch.mode === 'command' ? 'finishes' : 'condition is met'}. You can end your turn now.`,
|
|
27
|
+
});
|
|
28
|
+
} catch (err) {
|
|
29
|
+
res.status(400).json({ error: err.message });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
app.get('/api/watch', (req, res) => {
|
|
34
|
+
res.json({ watches: daemon.watcher.list(req.query.agentId || null) });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
app.delete('/api/watch/:id', (req, res) => {
|
|
38
|
+
const ok = daemon.watcher.cancel(req.params.id);
|
|
39
|
+
if (!ok) return res.status(404).json({ error: 'Watch not found' });
|
|
40
|
+
res.json({ ok: true });
|
|
41
|
+
});
|
|
42
|
+
}
|