clementine-agent 1.18.203 → 1.18.206
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/dist/agent/agent-definitions.js +3 -1
- package/dist/agent/run-agent-context.js +4 -0
- package/dist/agent/run-agent.js +1 -0
- package/dist/agent/run-summary.d.ts +3 -0
- package/dist/agent/run-summary.js +77 -2
- package/dist/agent/tool-output-guard.d.ts +2 -0
- package/dist/agent/tool-output-guard.js +110 -1
- package/dist/channels/discord-agent-bot.js +3 -3
- package/dist/channels/discord-utils.d.ts +18 -2
- package/dist/channels/discord-utils.js +110 -24
- package/dist/channels/discord.js +2 -2
- package/dist/config.js +4 -1
- package/dist/gateway/router.js +5 -5
- package/package.json +1 -1
|
@@ -61,7 +61,7 @@ const RESEARCHER_PROMPT = [
|
|
|
61
61
|
'',
|
|
62
62
|
'## Output discipline',
|
|
63
63
|
'',
|
|
64
|
-
'Return a ONE-PARAGRAPH summary in the format the parent specified. Never raw tool output, never full lists, never unbounded data dumps. If a tool returns 50KB of JSON, extract only the requested fields and discard the rest — your job is to compress.',
|
|
64
|
+
'Return a ONE-PARAGRAPH summary in the format the parent specified. Hard limit: 250 words unless the parent explicitly asks for a longer artifact. Never raw tool output, never full lists, never unbounded data dumps. If a tool returns 50KB of JSON, extract only the requested fields and discard the rest — your job is to compress.',
|
|
65
65
|
'',
|
|
66
66
|
'If you cannot find the requested data, say so in one line. Do not speculate.',
|
|
67
67
|
].join('\n');
|
|
@@ -106,6 +106,8 @@ const DISCOVERY_PROMPT = [
|
|
|
106
106
|
'Recommendation: <which path the orchestrator should fetch next, if any>',
|
|
107
107
|
'```',
|
|
108
108
|
'',
|
|
109
|
+
'Hard output limit: 250 words. Return decision-grade handoff only: paths, counts, sizes, and the next file/action the orchestrator should inspect. Do NOT return a full project report, long markdown excerpts, repeated file inventories, or narrative analysis. If the parent needs detail, point it at the exact file path to read next.',
|
|
110
|
+
'',
|
|
109
111
|
'If nothing matches, say so in one line.',
|
|
110
112
|
'',
|
|
111
113
|
'You are bounded by max 15 turns. Use them wisely — list, scope, summarize, return.',
|
|
@@ -127,6 +127,10 @@ const BEHAVIORAL_POSTURE = `## How you operate
|
|
|
127
127
|
|
|
128
128
|
**Orchestrator posture (1.18.197).** You are the orchestrator, not the worker. Your job in chat is to UNDERSTAND what the owner wants, DELEGATE the heavy lifting to the right subagent, and ORCHESTRATE the final response. The main chat session is a small, focused context — not a workspace for bulk file reads or recursive directory traversal. Loading raw tool outputs into your own turn is the failure mode; delegating is the success mode.
|
|
129
129
|
|
|
130
|
+
**Three-tier model discipline (1.18.204).** The default chat path is the Opus orchestrator tier: read the full request, hold memory/project context, decide the route, dispatch workers, and synthesize results. Do not grind large reads, recursive searches, batch lookups, or long tool sequences in your own turn. Use Sonnet workers for substantive subtask execution (hired agents such as Ross/Sasha/Nora, or \`cron-fixer\`). Use Haiku workers for grunt work: \`researcher\` for per-item fan-out and \`discovery\` for file-system locate. Pick the tier by the nature of the work, not by speed.
|
|
131
|
+
|
|
132
|
+
**Cron-creation guidance.** When creating scheduled tasks or skills, recommend the model tier in frontmatter: \`haiku\` for lookups, classification, simple checks, and lean digests; \`sonnet\` for typical multi-tool work, composing, and summarizing; \`opus\` only for rare crons that genuinely need complex reasoning across many inputs. Most crons should be Sonnet.
|
|
133
|
+
|
|
130
134
|
**Tool-selection rubric.** Before running tools yourself, ask which bucket the request falls into:
|
|
131
135
|
|
|
132
136
|
1. **Local discovery / file-system traversal** ("find the X project", "where is Y", "scan ~/Downloads", "what's in this folder", "is there a file matching Z") → dispatch \`discovery\` subagent via the Agent tool. It has its own 200K context and returns paths + summaries. Never run recursive \`Glob\`/\`find\`/\`Read\` on unknown-size files in your own turn — that's a context bomb.
|
package/dist/agent/run-agent.js
CHANGED
|
@@ -579,6 +579,7 @@ export async function runAgent(prompt, opts) {
|
|
|
579
579
|
source,
|
|
580
580
|
profile: opts.profile?.slug,
|
|
581
581
|
forceSubagent: opts.forceSubagent,
|
|
582
|
+
model: sdkOptions.model,
|
|
582
583
|
effort,
|
|
583
584
|
maxBudgetUsd: maxBudgetUsd ?? 'uncapped',
|
|
584
585
|
agentCount: Object.keys(agents).length,
|
|
@@ -31,6 +31,9 @@ export interface RunSummary {
|
|
|
31
31
|
failedSideEffects: SideEffectCall[];
|
|
32
32
|
pendingSideEffects: SideEffectCall[];
|
|
33
33
|
unknownEffectCalls: SideEffectCall[];
|
|
34
|
+
successfulDelegations: SideEffectCall[];
|
|
35
|
+
failedDelegations: SideEffectCall[];
|
|
36
|
+
pendingDelegations: SideEffectCall[];
|
|
34
37
|
readOnlyCount: number;
|
|
35
38
|
errors: Array<{
|
|
36
39
|
runId: string;
|
|
@@ -66,11 +66,23 @@ export function summarizeRunSideEffects(runIds, eventLog = new EventLog()) {
|
|
|
66
66
|
const failedSideEffects = [];
|
|
67
67
|
const pendingSideEffects = [];
|
|
68
68
|
const unknownEffectCalls = [];
|
|
69
|
+
const successfulDelegations = [];
|
|
70
|
+
const failedDelegations = [];
|
|
71
|
+
const pendingDelegations = [];
|
|
69
72
|
let readOnlyCount = 0;
|
|
70
73
|
for (const call of events.filter(isToolCall)) {
|
|
71
74
|
const verdict = classifyToolCall(call.toolName, asInput(call.toolInput));
|
|
72
75
|
const result = resultForToolUse(events, call.toolUseId);
|
|
73
76
|
const item = makeCall(call, result, verdict);
|
|
77
|
+
if (call.toolName === 'Agent') {
|
|
78
|
+
if (!result)
|
|
79
|
+
pendingDelegations.push(item);
|
|
80
|
+
else if (item.result?.successful)
|
|
81
|
+
successfulDelegations.push(item);
|
|
82
|
+
else
|
|
83
|
+
failedDelegations.push(item);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
74
86
|
if (verdict.kind === 'read_only') {
|
|
75
87
|
readOnlyCount += 1;
|
|
76
88
|
continue;
|
|
@@ -110,6 +122,9 @@ export function summarizeRunSideEffects(runIds, eventLog = new EventLog()) {
|
|
|
110
122
|
failedSideEffects,
|
|
111
123
|
pendingSideEffects,
|
|
112
124
|
unknownEffectCalls,
|
|
125
|
+
successfulDelegations,
|
|
126
|
+
failedDelegations,
|
|
127
|
+
pendingDelegations,
|
|
113
128
|
readOnlyCount,
|
|
114
129
|
errors,
|
|
115
130
|
...(lastAssistantText ? { lastAssistantText } : {}),
|
|
@@ -120,7 +135,10 @@ export function hasOperationalActivity(summary) {
|
|
|
120
135
|
return summary.successfulSideEffects.length > 0
|
|
121
136
|
|| summary.failedSideEffects.length > 0
|
|
122
137
|
|| summary.pendingSideEffects.length > 0
|
|
123
|
-
|| summary.unknownEffectCalls.length > 0
|
|
138
|
+
|| summary.unknownEffectCalls.length > 0
|
|
139
|
+
|| summary.successfulDelegations.length > 0
|
|
140
|
+
|| summary.failedDelegations.length > 0
|
|
141
|
+
|| summary.pendingDelegations.length > 0;
|
|
124
142
|
}
|
|
125
143
|
function toolKindLabel(toolName) {
|
|
126
144
|
const lower = toolName.toLowerCase();
|
|
@@ -206,6 +224,41 @@ function recipientPreview(calls, max = 3) {
|
|
|
206
224
|
function formatGroupedLines(prefix, calls) {
|
|
207
225
|
return groupCounts(calls).map((group) => `- ${group.count} ${group.label} ${prefix}${recipientPreview(group.calls)}`);
|
|
208
226
|
}
|
|
227
|
+
function collectResultText(value) {
|
|
228
|
+
if (typeof value === 'string')
|
|
229
|
+
return value;
|
|
230
|
+
if (Array.isArray(value))
|
|
231
|
+
return value.map(collectResultText).filter(Boolean).join('\n');
|
|
232
|
+
if (!value || typeof value !== 'object')
|
|
233
|
+
return '';
|
|
234
|
+
const obj = value;
|
|
235
|
+
return ['text', 'content', 'result', 'message']
|
|
236
|
+
.map((key) => collectResultText(obj[key]))
|
|
237
|
+
.filter(Boolean)
|
|
238
|
+
.join('\n');
|
|
239
|
+
}
|
|
240
|
+
function extractAgentArchivePath(text) {
|
|
241
|
+
return text.match(/Full payload archived at `([^`]+)`/)?.[1]
|
|
242
|
+
?? text.match(/Full output:\s*([^\n]+)/)?.[1]?.trim();
|
|
243
|
+
}
|
|
244
|
+
function extractAgentId(text) {
|
|
245
|
+
return text.match(/\bagentId:\s*([a-zA-Z0-9_-]+)/)?.[1];
|
|
246
|
+
}
|
|
247
|
+
function formatDelegationCall(call, status) {
|
|
248
|
+
const description = firstString(call.input.description, call.input.task, call.input.prompt)?.slice(0, 120);
|
|
249
|
+
const subagentType = firstString(call.input.subagent_type, call.input.subagentType);
|
|
250
|
+
const resultText = call.result ? collectResultText(call.result.raw) : '';
|
|
251
|
+
const agentId = extractAgentId(resultText);
|
|
252
|
+
const archivePath = extractAgentArchivePath(resultText);
|
|
253
|
+
const pieces = [
|
|
254
|
+
subagentType ? `${subagentType} subagent` : 'subagent',
|
|
255
|
+
status,
|
|
256
|
+
description ? `for "${description}"` : undefined,
|
|
257
|
+
agentId ? `agentId ${agentId}` : undefined,
|
|
258
|
+
archivePath ? `archive ${archivePath}` : undefined,
|
|
259
|
+
].filter(Boolean);
|
|
260
|
+
return `- ${pieces.join(' · ')}`;
|
|
261
|
+
}
|
|
209
262
|
export function formatOverflowRecoveryMessage(summary) {
|
|
210
263
|
const lines = [
|
|
211
264
|
'That run hit the context limit after some work had already happened.',
|
|
@@ -216,12 +269,28 @@ export function formatOverflowRecoveryMessage(summary) {
|
|
|
216
269
|
lines.push(...formatGroupedLines('completed', summary.successfulSideEffects));
|
|
217
270
|
lines.push('');
|
|
218
271
|
}
|
|
219
|
-
if (summary.
|
|
272
|
+
if (summary.successfulDelegations.length > 0) {
|
|
273
|
+
lines.push('Delegated work completed before overflow:');
|
|
274
|
+
for (const call of summary.successfulDelegations.slice(0, 5))
|
|
275
|
+
lines.push(formatDelegationCall(call, 'completed'));
|
|
276
|
+
if (summary.successfulDelegations.length > 5)
|
|
277
|
+
lines.push(`- ...and ${summary.successfulDelegations.length - 5} more completed subagent calls`);
|
|
278
|
+
lines.push('');
|
|
279
|
+
}
|
|
280
|
+
if (summary.failedSideEffects.length > 0
|
|
281
|
+
|| summary.pendingSideEffects.length > 0
|
|
282
|
+
|| summary.unknownEffectCalls.length > 0
|
|
283
|
+
|| summary.failedDelegations.length > 0
|
|
284
|
+
|| summary.pendingDelegations.length > 0) {
|
|
220
285
|
lines.push('Needs attention:');
|
|
221
286
|
if (summary.failedSideEffects.length > 0)
|
|
222
287
|
lines.push(...formatGroupedLines('failed', summary.failedSideEffects));
|
|
223
288
|
if (summary.pendingSideEffects.length > 0)
|
|
224
289
|
lines.push(...formatGroupedLines('started, no confirmation', summary.pendingSideEffects));
|
|
290
|
+
for (const call of summary.failedDelegations.slice(0, 5))
|
|
291
|
+
lines.push(formatDelegationCall(call, 'failed'));
|
|
292
|
+
for (const call of summary.pendingDelegations.slice(0, 5))
|
|
293
|
+
lines.push(formatDelegationCall(call, 'started, no confirmation'));
|
|
225
294
|
if (summary.unknownEffectCalls.length > 0)
|
|
226
295
|
lines.push(`- ${summary.unknownEffectCalls.length} tool call(s) had unknown external effect`);
|
|
227
296
|
lines.push('');
|
|
@@ -261,6 +330,12 @@ export function buildContinuationPrompt(summary, originalRequest) {
|
|
|
261
330
|
lines.push(`- ...and ${summary.successfulSideEffects.length - 80} more completed side effects in the event log.`);
|
|
262
331
|
lines.push('');
|
|
263
332
|
}
|
|
333
|
+
if (summary.successfulDelegations.length > 0) {
|
|
334
|
+
lines.push('Completed delegated work. Do not repeat discovery/research already done unless the archive is insufficient:');
|
|
335
|
+
for (const call of summary.successfulDelegations.slice(0, 20))
|
|
336
|
+
lines.push(formatDelegationCall(call, 'completed'));
|
|
337
|
+
lines.push('');
|
|
338
|
+
}
|
|
264
339
|
if (summary.failedSideEffects.length > 0) {
|
|
265
340
|
lines.push('Failed side effects that may need retry or reconciliation:');
|
|
266
341
|
for (const call of summary.failedSideEffects.slice(0, 30))
|
|
@@ -117,6 +117,101 @@ function tryListShrink(value, ctx) {
|
|
|
117
117
|
}
|
|
118
118
|
return null;
|
|
119
119
|
}
|
|
120
|
+
function collectTextFragments(value) {
|
|
121
|
+
if (typeof value === 'string')
|
|
122
|
+
return [value];
|
|
123
|
+
if (Array.isArray(value))
|
|
124
|
+
return value.flatMap((item) => collectTextFragments(item));
|
|
125
|
+
if (!value || typeof value !== 'object')
|
|
126
|
+
return [];
|
|
127
|
+
const obj = value;
|
|
128
|
+
const out = [];
|
|
129
|
+
for (const key of ['text', 'content', 'result', 'message']) {
|
|
130
|
+
const v = obj[key];
|
|
131
|
+
if (typeof v === 'string')
|
|
132
|
+
out.push(v);
|
|
133
|
+
else if (Array.isArray(v) || (v && typeof v === 'object'))
|
|
134
|
+
out.push(...collectTextFragments(v));
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
function objectField(value, key) {
|
|
139
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
140
|
+
? typeof value[key] === 'string'
|
|
141
|
+
? String(value[key]).trim()
|
|
142
|
+
: undefined
|
|
143
|
+
: undefined;
|
|
144
|
+
}
|
|
145
|
+
function extractAgentId(text) {
|
|
146
|
+
return text.match(/\bagentId:\s*([a-zA-Z0-9_-]+)/)?.[1];
|
|
147
|
+
}
|
|
148
|
+
function extractUsageLine(text) {
|
|
149
|
+
const match = text.match(/<usage>[\s\S]*?(?:<\/usage>|$)/);
|
|
150
|
+
return match?.[0]?.replace(/\s+/g, ' ').trim().slice(0, 220);
|
|
151
|
+
}
|
|
152
|
+
function stripAgentBoilerplate(text) {
|
|
153
|
+
return text
|
|
154
|
+
.replace(/agentId:\s*[a-zA-Z0-9_-]+[\s\S]*$/i, '')
|
|
155
|
+
.replace(/<usage>[\s\S]*$/i, '')
|
|
156
|
+
.replace(/^\s*(perfect|great|okay|ok)[.!]?\s+now\s+i\s+have[^\n]*\n+/i, '')
|
|
157
|
+
.trim();
|
|
158
|
+
}
|
|
159
|
+
function compactMarkdownLines(text) {
|
|
160
|
+
const lines = stripAgentBoilerplate(text)
|
|
161
|
+
.split(/\r?\n/)
|
|
162
|
+
.map((line) => line.trim())
|
|
163
|
+
.filter((line) => line && line !== '---' && line !== '```');
|
|
164
|
+
const keep = [];
|
|
165
|
+
for (const line of lines) {
|
|
166
|
+
if (keep.length >= 26)
|
|
167
|
+
break;
|
|
168
|
+
if (/^#{1,4}\s/.test(line) || /^[-*]\s/.test(line) || /^\d+\.\s/.test(line) || /^[A-Z][^:]{2,60}:/.test(line)) {
|
|
169
|
+
keep.push(line);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (keep.length < 8 && line.length <= 220)
|
|
173
|
+
keep.push(line);
|
|
174
|
+
}
|
|
175
|
+
return keep.join('\n');
|
|
176
|
+
}
|
|
177
|
+
function fitUnderBytes(text, maxBytes) {
|
|
178
|
+
if (estimateBytes(text) <= maxBytes)
|
|
179
|
+
return text;
|
|
180
|
+
const marker = '\n\n[...compact handoff truncated; read the archived Agent result for full detail.]';
|
|
181
|
+
let head = text.slice(0, Math.max(200, maxBytes - estimateBytes(marker) - 200));
|
|
182
|
+
while (head.length > 200 && estimateBytes(head + marker) > maxBytes) {
|
|
183
|
+
head = head.slice(0, Math.floor(head.length * 0.9));
|
|
184
|
+
}
|
|
185
|
+
return `${head.trimEnd()}${marker}`;
|
|
186
|
+
}
|
|
187
|
+
function tryAgentShrink(value, ctx) {
|
|
188
|
+
if (ctx.toolName !== 'Agent')
|
|
189
|
+
return null;
|
|
190
|
+
const fragments = collectTextFragments(value);
|
|
191
|
+
const text = fragments.join('\n\n').trim();
|
|
192
|
+
if (!text)
|
|
193
|
+
return null;
|
|
194
|
+
const subagentType = objectField(ctx.toolInput, 'subagent_type');
|
|
195
|
+
const description = objectField(ctx.toolInput, 'description');
|
|
196
|
+
const agentId = extractAgentId(text);
|
|
197
|
+
const usage = extractUsageLine(text);
|
|
198
|
+
const summary = compactMarkdownLines(text);
|
|
199
|
+
const archive = archiveHint(ctx, 'full Agent result');
|
|
200
|
+
const lines = [
|
|
201
|
+
'[Clementine compacted this Agent result to protect the parent chat context.]',
|
|
202
|
+
subagentType ? `Subagent: ${subagentType}` : undefined,
|
|
203
|
+
description ? `Task: ${description}` : undefined,
|
|
204
|
+
agentId ? `agentId: ${agentId}` : undefined,
|
|
205
|
+
usage,
|
|
206
|
+
archive,
|
|
207
|
+
'',
|
|
208
|
+
'Decision-grade handoff:',
|
|
209
|
+
summary || fitUnderBytes(stripAgentBoilerplate(text), Math.max(1_000, Math.floor(ctx.cap * 0.6))),
|
|
210
|
+
'',
|
|
211
|
+
'Use this handoff to continue. Read the archived result only if the missing detail is necessary.',
|
|
212
|
+
].filter((line) => typeof line === 'string' && line.length > 0);
|
|
213
|
+
return fitUnderBytes(lines.join('\n'), ctx.cap);
|
|
214
|
+
}
|
|
120
215
|
function shrinkArray(arr, ctx) {
|
|
121
216
|
if (arr.length <= 6) {
|
|
122
217
|
// Don't trim short lists; the bloat is somewhere else (likely a fat body).
|
|
@@ -244,6 +339,19 @@ export function compressToolOutput(_toolName, rawOutput, ctx) {
|
|
|
244
339
|
if (originalBytes <= ctx.cap) {
|
|
245
340
|
return { output: rawOutput, bytesShed: 0, ceilingHit: false, passthrough: true };
|
|
246
341
|
}
|
|
342
|
+
// Agent tool results are subagent handoffs to the parent orchestrator.
|
|
343
|
+
// Preserve the decision-grade summary and archive the full result instead
|
|
344
|
+
// of letting a verbose report refill the parent context after compaction.
|
|
345
|
+
const agentShrunk = tryAgentShrink(rawOutput, ctx);
|
|
346
|
+
if (agentShrunk !== null) {
|
|
347
|
+
const bytes = estimateBytes(agentShrunk);
|
|
348
|
+
return {
|
|
349
|
+
output: agentShrunk,
|
|
350
|
+
bytesShed: Math.max(0, originalBytes - bytes),
|
|
351
|
+
ceilingHit: originalBytes > ctx.cap * 2,
|
|
352
|
+
passthrough: false,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
247
355
|
// Pass 1: list-shape shrink (preserves structure).
|
|
248
356
|
const shrunk1 = tryListShrink(rawOutput, ctx);
|
|
249
357
|
if (shrunk1 !== null) {
|
|
@@ -294,7 +402,7 @@ export function buildGuardHooks(opts) {
|
|
|
294
402
|
const toolUseId = String(toolUseID ?? evt.tool_use_id ?? 'unknown');
|
|
295
403
|
const rawOutput = evt.tool_response;
|
|
296
404
|
stats.inspected += 1;
|
|
297
|
-
const usageRatio = opts.usageRatio ? safeRatio(opts.usageRatio) : 0;
|
|
405
|
+
const usageRatio = Math.max(opts.usageRatio ? safeRatio(opts.usageRatio) : 0, stats.compactions > 0 ? 0.75 : 0);
|
|
298
406
|
const { softCap } = resolveCap(toolName, config, usageRatio);
|
|
299
407
|
const originalBytes = estimateBytes(rawOutput);
|
|
300
408
|
if (originalBytes <= softCap) {
|
|
@@ -307,6 +415,7 @@ export function buildGuardHooks(opts) {
|
|
|
307
415
|
const archivePath = archivePayload(opts.archiveBaseDir ?? BASE_DIR, opts.runId, toolUseId, toolName, rawOutput);
|
|
308
416
|
const outcome = compressToolOutput(toolName, rawOutput, {
|
|
309
417
|
toolName,
|
|
418
|
+
toolInput: evt.tool_input,
|
|
310
419
|
toolUseId,
|
|
311
420
|
archivePath,
|
|
312
421
|
cap: softCap,
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { ActionRowBuilder, ActivityType, ChannelType, Client, EmbedBuilder, Events, GatewayIntentBits, ModalBuilder, Partials, REST, Routes, SlashCommandBuilder, TextInputBuilder, TextInputStyle, } from 'discord.js';
|
|
16
16
|
import pino from 'pino';
|
|
17
|
-
import { chunkText, DiscordStreamingMessage,
|
|
17
|
+
import { chunkText, DiscordStreamingMessage, sanitizeResponse, rehydrateStatusEmbed, setSavedStatusEmbed } from './discord-utils.js';
|
|
18
18
|
import { MODELS } from '../config.js';
|
|
19
19
|
import * as cronParser from 'cron-parser';
|
|
20
20
|
const logger = pino({ name: 'clementine.agent-bot' });
|
|
@@ -813,9 +813,9 @@ export class AgentBotClient {
|
|
|
813
813
|
}, undefined, // model
|
|
814
814
|
undefined, // maxTurns
|
|
815
815
|
async (toolName, toolInput) => {
|
|
816
|
-
streamer.
|
|
816
|
+
streamer.recordToolActivity(toolName, toolInput);
|
|
817
817
|
}, async (status) => {
|
|
818
|
-
streamer.
|
|
818
|
+
streamer.setProgressStatus(status);
|
|
819
819
|
});
|
|
820
820
|
await streamer.finalize(response);
|
|
821
821
|
}
|
|
@@ -24,11 +24,22 @@ export declare function sanitizeResponse(text: string): string;
|
|
|
24
24
|
export declare function chunkText(text: string, maxLen?: number): string[];
|
|
25
25
|
export declare function sendChunked(channel: Message['channel'], text: string): Promise<void>;
|
|
26
26
|
export declare function friendlyToolName(name: string, input?: Record<string, unknown>): string;
|
|
27
|
+
export type DiscordWorkActivityKind = 'read' | 'write' | 'command' | 'delegate' | 'external' | 'memory' | 'other';
|
|
28
|
+
export interface DiscordWorkCardState {
|
|
29
|
+
startedAt: number;
|
|
30
|
+
status: string;
|
|
31
|
+
toolCallCount: number;
|
|
32
|
+
counts: Record<DiscordWorkActivityKind, number>;
|
|
33
|
+
recentActivities: string[];
|
|
34
|
+
}
|
|
35
|
+
export declare function classifyDiscordWorkActivity(toolName: string): DiscordWorkActivityKind;
|
|
36
|
+
export declare function buildDiscordWorkCard(state: DiscordWorkCardState, now?: number): string;
|
|
27
37
|
export declare class DiscordStreamingMessage {
|
|
28
38
|
private message;
|
|
29
39
|
private lastEdit;
|
|
30
40
|
private pendingText;
|
|
31
41
|
private lastFlushedText;
|
|
42
|
+
private lastFlushedDisplay;
|
|
32
43
|
private isFinal;
|
|
33
44
|
private channel;
|
|
34
45
|
private flushTimer;
|
|
@@ -37,17 +48,22 @@ export declare class DiscordStreamingMessage {
|
|
|
37
48
|
private startTime;
|
|
38
49
|
private toolCallCount;
|
|
39
50
|
private lastTextTime;
|
|
51
|
+
private workCard;
|
|
40
52
|
/** The message ID of the final bot response (available after finalize). */
|
|
41
53
|
messageId: string | null;
|
|
42
54
|
constructor(channel: Message['channel']);
|
|
43
55
|
start(): Promise<void>;
|
|
44
56
|
/** Update the tool activity status line shown during streaming. */
|
|
45
57
|
setToolStatus(status: string): void;
|
|
58
|
+
/** Update non-tool progress, such as queueing/routing/session-reset stages. */
|
|
59
|
+
setProgressStatus(status: string): void;
|
|
60
|
+
/** Record a concrete tool start so the live work card shows real activity. */
|
|
61
|
+
recordToolActivity(toolName: string, input?: Record<string, unknown>): void;
|
|
62
|
+
private recordWorkActivity;
|
|
63
|
+
private requestFlush;
|
|
46
64
|
update(text: string): Promise<void>;
|
|
47
65
|
finalize(text: string): Promise<void>;
|
|
48
66
|
discard(): Promise<void>;
|
|
49
|
-
/** Format elapsed milliseconds as human-readable duration. */
|
|
50
|
-
private formatElapsed;
|
|
51
67
|
private flush;
|
|
52
68
|
}
|
|
53
69
|
export type CronEmbedType = 'success' | 'progress' | 'error';
|
|
@@ -166,11 +166,80 @@ export function friendlyToolName(name, input) {
|
|
|
166
166
|
const short = name.includes('__') ? name.split('__').pop() : name;
|
|
167
167
|
return `\ud83d\udd27 ${short.replace(/_/g, ' ')}`;
|
|
168
168
|
}
|
|
169
|
+
function emptyWorkCounts() {
|
|
170
|
+
return {
|
|
171
|
+
read: 0,
|
|
172
|
+
write: 0,
|
|
173
|
+
command: 0,
|
|
174
|
+
delegate: 0,
|
|
175
|
+
external: 0,
|
|
176
|
+
memory: 0,
|
|
177
|
+
other: 0,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
function formatElapsed(ms) {
|
|
181
|
+
const s = Math.max(0, Math.floor(ms / 1000));
|
|
182
|
+
if (s < 60)
|
|
183
|
+
return `${s}s`;
|
|
184
|
+
const m = Math.floor(s / 60);
|
|
185
|
+
const rem = s % 60;
|
|
186
|
+
return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
|
|
187
|
+
}
|
|
188
|
+
function cleanCardText(value) {
|
|
189
|
+
return sanitizeResponse(value.replace(/\s+/g, ' ').trim()).slice(0, 120);
|
|
190
|
+
}
|
|
191
|
+
export function classifyDiscordWorkActivity(toolName) {
|
|
192
|
+
const name = toolName.toLowerCase();
|
|
193
|
+
if (name === 'agent' || name.includes('agent') || name.includes('researcher') || name.includes('discovery'))
|
|
194
|
+
return 'delegate';
|
|
195
|
+
if (name === 'bash')
|
|
196
|
+
return 'command';
|
|
197
|
+
if (name === 'write' || name === 'edit' || name === 'notebookedit')
|
|
198
|
+
return 'write';
|
|
199
|
+
if (name.includes('memory_') || name.includes('transcript_') || name.includes('note_'))
|
|
200
|
+
return 'memory';
|
|
201
|
+
if (name === 'read' || name === 'glob' || name === 'grep' || name.includes('search') || name.includes('fetch') || name.includes('list') || name.includes('get_') || name.includes('read_'))
|
|
202
|
+
return 'read';
|
|
203
|
+
if (/(^|[_\W])(send|create|update|delete|post|apply|move|rename|archive|remove|enable|disable|assign|cancel|approve|reply|forward|publish|push|insert|upsert|set)($|[_\W])/i.test(toolName))
|
|
204
|
+
return 'external';
|
|
205
|
+
return 'other';
|
|
206
|
+
}
|
|
207
|
+
function describeCounts(counts) {
|
|
208
|
+
const parts = [
|
|
209
|
+
['reads', counts.read],
|
|
210
|
+
['writes', counts.write],
|
|
211
|
+
['commands', counts.command],
|
|
212
|
+
['delegations', counts.delegate],
|
|
213
|
+
['external', counts.external],
|
|
214
|
+
['memory', counts.memory],
|
|
215
|
+
]
|
|
216
|
+
.filter(([, count]) => Number(count) > 0)
|
|
217
|
+
.map(([label, count]) => `${label}: ${count}`);
|
|
218
|
+
return parts.length > 0 ? parts.join(' | ') : 'no tools yet';
|
|
219
|
+
}
|
|
220
|
+
export function buildDiscordWorkCard(state, now = Date.now()) {
|
|
221
|
+
const status = cleanCardText(state.status || 'thinking...');
|
|
222
|
+
const lines = [
|
|
223
|
+
'**Working**',
|
|
224
|
+
'',
|
|
225
|
+
`Status: ${status}`,
|
|
226
|
+
`Elapsed: ${formatElapsed(now - state.startedAt)} | Steps: ${state.toolCallCount}`,
|
|
227
|
+
`Tools: ${describeCounts(state.counts)}`,
|
|
228
|
+
];
|
|
229
|
+
const recent = state.recentActivities.map(cleanCardText).filter(Boolean).slice(-5);
|
|
230
|
+
if (recent.length > 0) {
|
|
231
|
+
lines.push('', 'Recent activity:');
|
|
232
|
+
for (const item of recent)
|
|
233
|
+
lines.push(`- ${item}`);
|
|
234
|
+
}
|
|
235
|
+
return lines.join('\n').slice(0, 1900);
|
|
236
|
+
}
|
|
169
237
|
export class DiscordStreamingMessage {
|
|
170
238
|
message = null;
|
|
171
239
|
lastEdit = 0;
|
|
172
240
|
pendingText = '';
|
|
173
241
|
lastFlushedText = '';
|
|
242
|
+
lastFlushedDisplay = '';
|
|
174
243
|
isFinal = false;
|
|
175
244
|
channel;
|
|
176
245
|
flushTimer = null;
|
|
@@ -179,6 +248,13 @@ export class DiscordStreamingMessage {
|
|
|
179
248
|
startTime = Date.now();
|
|
180
249
|
toolCallCount = 0;
|
|
181
250
|
lastTextTime = 0;
|
|
251
|
+
workCard = {
|
|
252
|
+
startedAt: this.startTime,
|
|
253
|
+
status: 'thinking...',
|
|
254
|
+
toolCallCount: 0,
|
|
255
|
+
counts: emptyWorkCounts(),
|
|
256
|
+
recentActivities: [],
|
|
257
|
+
};
|
|
182
258
|
/** The message ID of the final bot response (available after finalize). */
|
|
183
259
|
messageId = null;
|
|
184
260
|
constructor(channel) {
|
|
@@ -187,20 +263,43 @@ export class DiscordStreamingMessage {
|
|
|
187
263
|
async start() {
|
|
188
264
|
if (!('send' in this.channel))
|
|
189
265
|
return;
|
|
190
|
-
this.message = await this.channel.send(
|
|
266
|
+
this.message = await this.channel.send(buildDiscordWorkCard(this.workCard));
|
|
191
267
|
this.lastEdit = Date.now();
|
|
192
268
|
// Periodic refresh keeps elapsed time display current during long silent stretches
|
|
193
269
|
this.progressTimer = setInterval(() => {
|
|
194
|
-
if (!this.isFinal
|
|
270
|
+
if (!this.isFinal)
|
|
195
271
|
this.flush().catch(() => { });
|
|
196
272
|
}, 30_000);
|
|
197
273
|
}
|
|
198
274
|
/** Update the tool activity status line shown during streaming. */
|
|
199
275
|
setToolStatus(status) {
|
|
200
|
-
this.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
276
|
+
this.recordWorkActivity(cleanCardText(status), 'other', true);
|
|
277
|
+
}
|
|
278
|
+
/** Update non-tool progress, such as queueing/routing/session-reset stages. */
|
|
279
|
+
setProgressStatus(status) {
|
|
280
|
+
this.toolStatus = cleanCardText(status);
|
|
281
|
+
this.workCard.status = this.toolStatus;
|
|
282
|
+
this.requestFlush();
|
|
283
|
+
}
|
|
284
|
+
/** Record a concrete tool start so the live work card shows real activity. */
|
|
285
|
+
recordToolActivity(toolName, input) {
|
|
286
|
+
const label = friendlyToolName(toolName, input);
|
|
287
|
+
this.recordWorkActivity(label, classifyDiscordWorkActivity(toolName), true);
|
|
288
|
+
}
|
|
289
|
+
recordWorkActivity(label, kind, countStep) {
|
|
290
|
+
const cleaned = cleanCardText(label);
|
|
291
|
+
this.toolStatus = cleaned;
|
|
292
|
+
this.workCard.status = cleaned;
|
|
293
|
+
if (countStep) {
|
|
294
|
+
this.toolCallCount++;
|
|
295
|
+
this.workCard.toolCallCount = this.toolCallCount;
|
|
296
|
+
this.workCard.counts[kind] = (this.workCard.counts[kind] ?? 0) + 1;
|
|
297
|
+
this.workCard.recentActivities.push(cleaned);
|
|
298
|
+
this.workCard.recentActivities = this.workCard.recentActivities.slice(-5);
|
|
299
|
+
}
|
|
300
|
+
this.requestFlush();
|
|
301
|
+
}
|
|
302
|
+
requestFlush() {
|
|
204
303
|
const elapsed = Date.now() - this.lastEdit;
|
|
205
304
|
if (elapsed >= STREAM_EDIT_INTERVAL) {
|
|
206
305
|
this.flush().catch(() => { });
|
|
@@ -284,15 +383,6 @@ export class DiscordStreamingMessage {
|
|
|
284
383
|
await this.message.delete().catch(() => { });
|
|
285
384
|
}
|
|
286
385
|
}
|
|
287
|
-
/** Format elapsed milliseconds as human-readable duration. */
|
|
288
|
-
formatElapsed(ms) {
|
|
289
|
-
const s = Math.floor(ms / 1000);
|
|
290
|
-
if (s < 60)
|
|
291
|
-
return `${s}s`;
|
|
292
|
-
const m = Math.floor(s / 60);
|
|
293
|
-
const rem = s % 60;
|
|
294
|
-
return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
|
|
295
|
-
}
|
|
296
386
|
async flush() {
|
|
297
387
|
if (!this.message || this.isFinal)
|
|
298
388
|
return;
|
|
@@ -309,7 +399,7 @@ export class DiscordStreamingMessage {
|
|
|
309
399
|
let display = this.pendingText;
|
|
310
400
|
let statusLine;
|
|
311
401
|
if (showProgress) {
|
|
312
|
-
const elapsed =
|
|
402
|
+
const elapsed = formatElapsed(Date.now() - this.startTime);
|
|
313
403
|
const current = this.toolStatus ? ` \u2014 ${this.toolStatus}` : '';
|
|
314
404
|
statusLine = `\n\n*\ud83d\udd27 Working... (${this.toolCallCount} steps, ${elapsed})${current}*`;
|
|
315
405
|
}
|
|
@@ -324,18 +414,14 @@ export class DiscordStreamingMessage {
|
|
|
324
414
|
}
|
|
325
415
|
else {
|
|
326
416
|
// No text yet — show tool status or progress as the main content
|
|
327
|
-
|
|
328
|
-
const elapsed = this.formatElapsed(Date.now() - this.startTime);
|
|
329
|
-
const current = this.toolStatus ? ` \u2014 ${this.toolStatus}` : '';
|
|
330
|
-
display = `\u2728 *Working... (${this.toolCallCount} steps, ${elapsed})${current}*`;
|
|
331
|
-
}
|
|
332
|
-
else {
|
|
333
|
-
display = this.toolStatus ? `\u2728 *${this.toolStatus}*` : THINKING_INDICATOR;
|
|
334
|
-
}
|
|
417
|
+
display = buildDiscordWorkCard(this.workCard);
|
|
335
418
|
}
|
|
419
|
+
if (display === this.lastFlushedDisplay)
|
|
420
|
+
return;
|
|
336
421
|
try {
|
|
337
422
|
await this.message.edit(display);
|
|
338
423
|
this.lastFlushedText = this.pendingText;
|
|
424
|
+
this.lastFlushedDisplay = display;
|
|
339
425
|
this.lastEdit = Date.now();
|
|
340
426
|
}
|
|
341
427
|
catch {
|
package/dist/channels/discord.js
CHANGED
|
@@ -10,7 +10,7 @@ import pino from 'pino';
|
|
|
10
10
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
11
11
|
import os from 'node:os';
|
|
12
12
|
import path from 'node:path';
|
|
13
|
-
import { chunkText, DiscordStreamingMessage,
|
|
13
|
+
import { chunkText, DiscordStreamingMessage, formatCronEmbed, rehydrateStatusEmbed, setSavedStatusEmbed, } from './discord-utils.js';
|
|
14
14
|
import { DISCORD_TOKEN, DISCORD_OWNER_ID, DISCORD_WATCHED_CHANNELS, MODELS, ASSISTANT_NAME, OWNER_NAME, PKG_DIR, VAULT_DIR, BASE_DIR, DEFAULT_MODEL_TIER, } from '../config.js';
|
|
15
15
|
import { isSilentGatewayResponse } from '../gateway/router.js';
|
|
16
16
|
import { findProjectByName, getLinkedProjects } from '../agent/assistant.js';
|
|
@@ -1164,7 +1164,7 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
|
|
|
1164
1164
|
const streamer = new DiscordStreamingMessage(message.channel);
|
|
1165
1165
|
await streamer.start();
|
|
1166
1166
|
try {
|
|
1167
|
-
const response = await gateway.handleMessage(sessionKey, effectiveText, (t) => streamer.update(t), oneOffModel, oneOffMaxTurns, (toolName, toolInput) => { streamer.
|
|
1167
|
+
const response = await gateway.handleMessage(sessionKey, effectiveText, (t) => streamer.update(t), oneOffModel, oneOffMaxTurns, (toolName, toolInput) => { streamer.recordToolActivity(toolName, toolInput); return Promise.resolve(); }, (status) => { streamer.setProgressStatus(status); return Promise.resolve(); });
|
|
1168
1168
|
if (isSilentGatewayResponse(response)) {
|
|
1169
1169
|
await streamer.discard();
|
|
1170
1170
|
updatePresence(sessionKey);
|
package/dist/config.js
CHANGED
|
@@ -552,7 +552,10 @@ export const TOOL_OUTPUT_GUARD = {
|
|
|
552
552
|
softLimitBytes: getEnvOrJsonNumber('TOOL_OUTPUT_GUARD_SOFT_BYTES', json.toolOutputGuard?.softLimitBytes, 30_000),
|
|
553
553
|
hardLimitBytes: getEnvOrJsonNumber('TOOL_OUTPUT_GUARD_HARD_BYTES', json.toolOutputGuard?.hardLimitBytes, 200_000),
|
|
554
554
|
adaptive: boolEnv('TOOL_OUTPUT_GUARD_ADAPTIVE', json.toolOutputGuard?.adaptive, true),
|
|
555
|
-
|
|
555
|
+
// Agent results are especially dangerous: even a "medium" subagent report
|
|
556
|
+
// refills the parent orchestrator after compaction. Keep the handoff tight;
|
|
557
|
+
// the full result is archived by tool-output-guard.
|
|
558
|
+
perTool: { Agent: 4_000, ...(json.toolOutputGuard?.perTool ?? {}) },
|
|
556
559
|
};
|
|
557
560
|
export const DEFAULT_MODEL_TIER = (getEnvOrJson('DEFAULT_MODEL_TIER', json.models?.default, 'sonnet'));
|
|
558
561
|
export const MODEL = MODELS[DEFAULT_MODEL_TIER] ?? MODELS.sonnet;
|
package/dist/gateway/router.js
CHANGED
|
@@ -2190,9 +2190,10 @@ export class Gateway {
|
|
|
2190
2190
|
// 'yes' — respond this time but don't persist
|
|
2191
2191
|
}
|
|
2192
2192
|
}
|
|
2193
|
-
// Use per-message override, then session default, then
|
|
2193
|
+
// Use per-message override, then session default, then Opus as the
|
|
2194
|
+
// chat-orchestrator default. Builder sessions still force Haiku below.
|
|
2194
2195
|
const sess = this.sessions.get(sessionKey);
|
|
2195
|
-
const effectiveModel = model ?? sess?.model;
|
|
2196
|
+
const effectiveModel = model ?? sess?.model ?? MODELS.opus;
|
|
2196
2197
|
const pendingOverflow = sess?.pendingOverflowResume;
|
|
2197
2198
|
if (pendingOverflow) {
|
|
2198
2199
|
const ageMs = Date.now() - pendingOverflow.summarizedAt;
|
|
@@ -3379,9 +3380,8 @@ export class Gateway {
|
|
|
3379
3380
|
}
|
|
3380
3381
|
getPresenceInfo(sessionKey) {
|
|
3381
3382
|
const sess = this.sessions.get(sessionKey);
|
|
3382
|
-
const
|
|
3383
|
-
|
|
3384
|
-
: 'sonnet';
|
|
3383
|
+
const modelId = sess?.model ?? MODELS.opus;
|
|
3384
|
+
const modelName = Object.entries(MODELS).find(([, v]) => v === modelId)?.[0] ?? modelId;
|
|
3385
3385
|
const project = sess?.project;
|
|
3386
3386
|
return {
|
|
3387
3387
|
model: modelName.charAt(0).toUpperCase() + modelName.slice(1),
|