clementine-agent 1.18.203 → 1.18.205

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.
@@ -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.
@@ -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,
@@ -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, friendlyToolName, sanitizeResponse, rehydrateStatusEmbed, setSavedStatusEmbed } from './discord-utils.js';
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.setToolStatus(friendlyToolName(toolName, toolInput));
816
+ streamer.recordToolActivity(toolName, toolInput);
817
817
  }, async (status) => {
818
- streamer.setToolStatus(status);
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(THINKING_INDICATOR);
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 && this.toolCallCount > 3)
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.toolStatus = status;
201
- this.toolCallCount++;
202
- // Trigger a flush so the status is actually displayed during long tool chains
203
- // where no text tokens are being emitted
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 = this.formatElapsed(Date.now() - this.startTime);
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
- if (showProgress) {
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 {
@@ -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, friendlyToolName, formatCronEmbed, rehydrateStatusEmbed, setSavedStatusEmbed, } from './discord-utils.js';
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.setToolStatus(friendlyToolName(toolName, toolInput)); return Promise.resolve(); }, (status) => { streamer.setToolStatus(status); return Promise.resolve(); });
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);
@@ -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 global default
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 modelName = sess?.model
3383
- ? Object.entries(MODELS).find(([, v]) => v === sess.model)?.[0] ?? 'sonnet'
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),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.203",
3
+ "version": "1.18.205",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",