@curie-agent/daemon 0.4.1 → 0.4.3

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.
@@ -1,10 +1,23 @@
1
1
  import os, { homedir } from 'node:os';
2
2
  import path, { join } from 'node:path';
3
3
  import { readFileSync, existsSync, readdirSync, statSync, writeFileSync, mkdirSync, realpathSync } from 'node:fs';
4
- import { Method } from '@curie-agent/protocol';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { Method, renderSlashCommandHelp, findSlashCommand } from '@curie-agent/protocol';
5
6
  import { TurnLoop, parseReminderTime, listSnapshots, revertTo, createIdentityFilesAuto, PURE_TOOLS } from '@curie-agent/core';
7
+ import { compactMessages, reconstructMessagesFromEvents, resolveBudget, estimateRequestTokens, breakdownChars, fillPct, formatTokens, DEFAULT_CALIBRATION, DEFAULT_SETTINGS, } from '@curie-agent/core';
6
8
  import { listSkills, discoverAllSkills } from '@curie-agent/tools';
7
9
  import { executeCd } from './slash-cd.js';
10
+ /** Real package version — previously hardcoded and years out of date. */
11
+ const VERSION = (() => {
12
+ try {
13
+ const here = path.dirname(fileURLToPath(import.meta.url));
14
+ const pkg = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf-8'));
15
+ return pkg.version ?? '0.0.0';
16
+ }
17
+ catch {
18
+ return '0.0.0';
19
+ }
20
+ })();
8
21
  export class JsonRpcHandler {
9
22
  sessionStore;
10
23
  settingsManager;
@@ -293,10 +306,11 @@ export class JsonRpcHandler {
293
306
  }
294
307
  case Method.CONFIG_GET: {
295
308
  const key = this.getStringParam(params, 'key');
296
- if (!key)
297
- return this.paramError('key');
298
309
  const settings = this.settingsManager.get();
299
- result = this.getNestedValue(settings, key);
310
+ // '*' or omitted → the whole tree, in one consistent snapshot. Reading
311
+ // ~23 top-level keys one at a time can tear if another client writes
312
+ // mid-fetch, yielding a snapshot that never existed on disk.
313
+ result = !key || key === '*' ? settings : this.getNestedValue(settings, key);
300
314
  break;
301
315
  }
302
316
  case Method.CONFIG_SET: {
@@ -328,14 +342,7 @@ export class JsonRpcHandler {
328
342
  else {
329
343
  this.settingsManager.update({ [key]: value });
330
344
  }
331
- this.settingsManager.save();
332
- this.sharedEventBus?.emit({
333
- type: 'config-changed',
334
- id: Math.random().toString(36).substring(7),
335
- timestamp: Date.now(),
336
- key,
337
- value,
338
- });
345
+ this.emitConfigChanged(key, value);
339
346
  if (key.startsWith('heartbeat') && this.daemonApp) {
340
347
  const updatedSettings = this.settingsManager.get();
341
348
  if (updatedSettings.heartbeat?.schedule === 'on') {
@@ -351,6 +358,17 @@ export class JsonRpcHandler {
351
358
  this.daemonApp.taskManager.cancelAllHeartbeats();
352
359
  }
353
360
  }
361
+ // Keep the derived top-level `model` in sync with the active provider.
362
+ // SettingsManager.load() recomputes it from providers[current_provider].model,
363
+ // so a stale top-level value silently reverts on the next daemon start.
364
+ if (key === 'current_provider' || /^providers\.[^.]+\.model$/.test(key)) {
365
+ const synced = this.settingsManager.get();
366
+ const active = synced.providers[synced.current_provider];
367
+ if (active && synced.model !== active.model) {
368
+ this.settingsManager.update({ model: active.model });
369
+ this.emitConfigChanged('model', active.model);
370
+ }
371
+ }
354
372
  result = { status: 'ok', key, value };
355
373
  break;
356
374
  }
@@ -395,7 +413,7 @@ export class JsonRpcHandler {
395
413
  case Method.DAEMON_STATUS:
396
414
  result = {
397
415
  status: 'ok',
398
- version: '0.2.4',
416
+ version: VERSION,
399
417
  clients: 0, // will be set by server
400
418
  };
401
419
  break;
@@ -893,6 +911,7 @@ export class JsonRpcHandler {
893
911
  'tool-result', 'approval-decision', 'usage',
894
912
  'error', 'session-start', 'session-stop', 'hook', 'status',
895
913
  'session-resumed', 'context-warning', 'thinking-delta',
914
+ 'compaction', 'context-report',
896
915
  // Subagent events
897
916
  'agent-start', 'agent-text-delta', 'agent-thinking-delta',
898
917
  'agent-tool-call', 'agent-tool-result', 'agent-usage',
@@ -906,7 +925,9 @@ export class JsonRpcHandler {
906
925
  }
907
926
  try {
908
927
  const result = await loop.run(text);
909
- await this.checkContextThresholds(sessionId);
928
+ // No post-run threshold check: TurnLoop enforces the budget before every
929
+ // provider call, on every code path (channels, tasks, heartbeat,
930
+ // subagents) — not just between prompts on this one.
910
931
  return { status: 'completed', sessionId: result.sessionId, events: result.events.length };
911
932
  }
912
933
  catch (err) {
@@ -924,6 +945,21 @@ export class JsonRpcHandler {
924
945
  paramError(key) {
925
946
  return { jsonrpc: '2.0', id: 0, error: { code: -32602, message: `Missing required parameter: ${key}` } };
926
947
  }
948
+ /**
949
+ * Broadcast a settings change so every connected client re-reads config.
950
+ * 'config-changed' is already in ws-handler's newEventTypes, so this reaches
951
+ * the browser. Every settings write must emit — a write that doesn't leaves
952
+ * the web dashboard showing a stale value until the user reloads.
953
+ */
954
+ emitConfigChanged(key, value) {
955
+ this.sharedEventBus?.emit({
956
+ type: 'config-changed',
957
+ id: Math.random().toString(36).substring(7),
958
+ timestamp: Date.now(),
959
+ key,
960
+ value,
961
+ });
962
+ }
927
963
  /** Get a nested value from an object using dot notation (e.g. "providers.anthropic.model"). */
928
964
  getNestedValue(obj, path) {
929
965
  const parts = path.split('.');
@@ -984,44 +1020,9 @@ export class JsonRpcHandler {
984
1020
  const args = parts.slice(1).join(' ').trim();
985
1021
  switch (command) {
986
1022
  case 'help': {
987
- const helpText = `### Available Slash Commands
988
-
989
- **General & Status**
990
- * \`/status\` — Show version, active model, provider, approval mode, CWD, active settings, and pricing.
991
- * \`/help\` — List all available commands with usage details.
992
-
993
- **Model & Config**
994
- * \`/provider <name>\` — Switch AI provider (\`anthropic | openai | google | local | openrouter | ollama\`).
995
- * \`/model <name>\` — Switch active model. Or use subcommands:
996
- * \`/model pricing <in;out>\` — Customize pricing format per million tokens.
997
- * \`/model window <tokens>\` — Adjust model context window capacity.
998
- * \`/effort <low|medium|high|max|auto>\` — Set reasoning effort level.
999
- * \`/mode <plan|edit|auto|yolo>\` — Set agent approval mode.
1000
- * \`/tools <max_tools> [max_websearch]\` — Configure dynamic tool limit per turn.
1001
- * \`/websearch <limit>\` — Configure maximum web search limits per turn.
1002
-
1003
- **Skills & MCP**
1004
- * \`/skill [name]\` — List all globally and project-registered skills or view a specific skill's instructions.
1005
- * \`/mcp [list|reload]\` — List connected Model Context Protocol (MCP) servers and their tools, or reload configuration.
1006
-
1007
- **Memory & Context**
1008
- * \`/memory [status|add <text>]\` — View active memory files or add new memories to be organized on next turn.
1009
-
1010
- **System Info**
1011
- * \`/system\` — Show OS, platform, Node version, home dir, CWD, and PathGuard status.
1012
- * \`/context [auto [on|off|threshold N|warn N|pricing on/off]]\` — View visual token capacity fill percentage bar or configure auto-compaction.
1013
-
1014
- **Automation & Scheduling**
1015
- * \`/remind <message at time>\` — Create a scheduled reminder (e.g., \`/remind review current pull request in 30 mins\`).
1016
- * \`/cron [list|delete <id>|clear]\` — View list of active reminders or manage completed ones.
1017
- * \`/heartbeat [status|enable|disable|now|daily <H:MM>|weekly <day@H:MM>...]\` — Control scheduled heartbeat cycles or run immediately.
1018
- * \`/task [create <instruction at time>|list [status]|delete <id>]\` — Schedule background autonomous agent tasks.
1019
-
1020
- **Workspace Safety**
1021
- * \`/snapshots\` — List Git-backed state snapshots.
1022
- * \`/revert <index>\` — Revert workspace to a specific snapshot index.
1023
- * \`/cd <path>\` — Change working directory with safety checks.`;
1024
- emitDelta(helpText);
1023
+ // Rendered from the shared registry so help can never drift from the
1024
+ // set of commands that actually exist.
1025
+ emitDelta(renderSlashCommandHelp());
1025
1026
  break;
1026
1027
  }
1027
1028
  case 'status': {
@@ -1031,18 +1032,19 @@ export class JsonRpcHandler {
1031
1032
  const pricing = pConfig?.model_cost
1032
1033
  ? `\`${pConfig.model_cost}\` (per million)`
1033
1034
  : 'Not configured';
1035
+ const statusBudget = resolveBudget(settings);
1034
1036
  const statusText = `### Curie Agent Status
1035
- * **Version:** \`0.2.4\`
1037
+ * **Version:** \`${VERSION}\`
1036
1038
  * **Active Model:** \`${settings.model}\`
1037
1039
  * **Active Provider:** \`${provider}\`
1038
1040
  * **Approval Mode:** \`${settings.mode || 'auto'}\`
1039
1041
  * **Reasoning Effort:** \`${settings.effort || 'auto'}\`
1040
1042
  * **Workspace CWD:** \`${this.getSessionCwd(sessionId) || process.cwd()}\`
1041
- * **Tools per Turn Limit:** \`${settings.tools_per_call || 10}\`
1042
- * **Web Search per Turn Limit:** \`${settings.websearch_per_call || 5}\`
1043
- * **Model Context Window:** \`${(pConfig?.model_context_window || 200000).toLocaleString()} tokens\`
1043
+ * **Tools per Turn / Run:** \`${String(settings.tools_per_call || DEFAULT_SETTINGS.tools_per_call)}\` / \`${String(settings.tools_per_run || DEFAULT_SETTINGS.tools_per_run)}\`
1044
+ * **Web Search per Turn / Run:** \`${String(settings.websearch_per_call || DEFAULT_SETTINGS.websearch_per_call)}\` / \`${String(settings.websearch_per_run || DEFAULT_SETTINGS.websearch_per_run)}\`
1045
+ * **Model Context Window:** \`${statusBudget.windowTokens.toLocaleString()} tokens\` (\`${statusBudget.usableTokens.toLocaleString()}\` usable, \`${statusBudget.reservedOutput.toLocaleString()}\` reserved for output)
1044
1046
  * **Model Pricing:** ${pricing}
1045
- * **Auto-Compaction:** \`${settings.auto_compact?.enabled || 'on'}\` (Threshold: \`${settings.auto_compact?.threshold ?? 80}%\`)`;
1047
+ * **Auto-Compaction:** \`${settings.auto_compact?.enabled ?? DEFAULT_SETTINGS.auto_compact.enabled}\` (warn \`${String(settings.auto_compact?.warn_threshold ?? DEFAULT_SETTINGS.auto_compact.warn_threshold)}%\`, suggest \`${String(settings.auto_compact?.threshold ?? DEFAULT_SETTINGS.auto_compact.threshold)}%\`, compact \`${String(settings.auto_compact?.forced_threshold ?? DEFAULT_SETTINGS.auto_compact.forced_threshold)}%\`)`;
1046
1048
  emitDelta(statusText);
1047
1049
  break;
1048
1050
  }
@@ -1149,13 +1151,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1149
1151
  else {
1150
1152
  settings.theme = theme;
1151
1153
  this.settingsManager.update(settings);
1152
- this.sharedEventBus?.emit({
1153
- type: 'config-changed',
1154
- id: Math.random().toString(36).substring(7),
1155
- timestamp: Date.now(),
1156
- key: 'theme',
1157
- value: theme,
1158
- });
1154
+ this.emitConfigChanged('theme', theme);
1159
1155
  emitDelta(`Successfully switched theme to: **${theme}**`);
1160
1156
  }
1161
1157
  break;
@@ -1172,6 +1168,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1172
1168
  else {
1173
1169
  settings.mode = args.toLowerCase();
1174
1170
  this.settingsManager.update(settings);
1171
+ this.emitConfigChanged('mode', settings.mode);
1175
1172
  emitDelta(`Successfully switched approval mode to: **${args.toLowerCase()}**`);
1176
1173
  }
1177
1174
  break;
@@ -1188,6 +1185,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1188
1185
  else {
1189
1186
  settings.effort = args.toLowerCase();
1190
1187
  this.settingsManager.update(settings);
1188
+ this.emitConfigChanged('effort', settings.effort);
1191
1189
  emitDelta(`Successfully switched reasoning effort to: **${args.toLowerCase()}**`);
1192
1190
  }
1193
1191
  break;
@@ -1215,8 +1213,11 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1215
1213
  settings.websearch_per_call = wsVal;
1216
1214
  }
1217
1215
  }
1218
- this.settingsManager.save();
1219
- emitDelta(`Tool limits updated! Tools per turn: **${settings.tools_per_call}**, WebSearch per turn: **${settings.websearch_per_call ?? 5}**`);
1216
+ this.settingsManager.update({
1217
+ tools_per_call: settings.tools_per_call,
1218
+ websearch_per_call: settings.websearch_per_call,
1219
+ });
1220
+ emitDelta(`Tool limits updated! Tools per turn: **${String(settings.tools_per_call)}**, WebSearch per turn: **${String(settings.websearch_per_call ?? 5)}**`);
1220
1221
  }
1221
1222
  }
1222
1223
  break;
@@ -1235,9 +1236,8 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1235
1236
  emitDelta(`Invalid value: "${args}". Must be a positive integer.`);
1236
1237
  }
1237
1238
  else {
1238
- settings.websearch_per_call = val;
1239
- this.settingsManager.save();
1240
- emitDelta(`WebSearch/WebFetch limit per turn set to: **${val}**`);
1239
+ this.settingsManager.update({ websearch_per_call: val });
1240
+ emitDelta(`WebSearch/WebFetch limit per turn set to: **${String(val)}**`);
1241
1241
  }
1242
1242
  }
1243
1243
  break;
@@ -1255,9 +1255,13 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1255
1255
  const lines = [`### Configured MCP Servers (${keys.length}):`];
1256
1256
  keys.forEach(k => {
1257
1257
  const cfg = servers[k];
1258
- const status = this.daemonApp?.mcpStatus?.find(s => s.serverId === k);
1259
- const connectedLabel = status?.connected ? '✅ Connected' : '❌ Disconnected';
1260
- const toolsList = status?.tools?.join(', ') || 'none';
1258
+ const status = this.daemonApp?.mcpStatus.find(s => s.serverId === k);
1259
+ // No recorded status is not the same as a failed connection —
1260
+ // say so rather than claiming the server is down.
1261
+ const connectedLabel = status === undefined
1262
+ ? '❔ Unknown (no connection status recorded)'
1263
+ : status.connected ? '✅ Connected' : '❌ Disconnected';
1264
+ const toolsList = status?.tools.length ? status.tools.join(', ') : 'none';
1261
1265
  lines.push(`* **${k}**: \`${cfg?.command}\` ${cfg?.args?.join(' ') ?? ''}`);
1262
1266
  lines.push(` └─ Status: ${connectedLabel}`);
1263
1267
  lines.push(` └─ Tools: _${toolsList}_`);
@@ -1362,122 +1366,121 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1362
1366
  const sub = parts[0]?.toLowerCase();
1363
1367
  const arg1 = parts[1]?.toLowerCase();
1364
1368
  const arg2 = parts[2]?.toLowerCase();
1369
+ const d = DEFAULT_SETTINGS.auto_compact;
1370
+ /** Persist through update() — get() returns a deep clone, so mutating it is a no-op. */
1371
+ const saveAutoCompact = (patch) => {
1372
+ this.settingsManager.update({ auto_compact: { ...settings.auto_compact, ...patch } });
1373
+ };
1365
1374
  if (sub === 'auto') {
1366
- const s = settings;
1367
- if (!s.auto_compact) {
1368
- s.auto_compact = { enabled: 'on', threshold: 80, warn_threshold: 15, forced_threshold: 85 };
1369
- }
1375
+ const ac = settings.auto_compact ?? d;
1370
1376
  if (!arg1) {
1371
- emitDelta(`### Auto-Compaction Settings:
1372
- * **Auto-compact**: \`${s.auto_compact.enabled}\`
1373
- * **Threshold**: \`${s.auto_compact.threshold ?? 80}%\`
1374
- * **Warning Threshold**: \`${s.auto_compact.warn_threshold ?? 15}%\`
1375
- * **Pricing Warn**: \`${s.pricing_tier_warn ?? 'off'}\`
1377
+ emitDelta(`### Auto-Compaction Settings
1376
1378
 
1377
- **Usage**: \`/context auto [on|off|threshold N|warn N|pricing on/off]\``);
1378
- }
1379
- else if (arg1 === 'on') {
1380
- s.auto_compact.enabled = 'on';
1381
- this.settingsManager.save();
1382
- emitDelta(`Autocompaction enabled.`);
1379
+ * **Auto-compact**: \`${ac.enabled}\`
1380
+ * **Warn at**: \`${String(ac.warn_threshold ?? d.warn_threshold)}%\`
1381
+ * **Suggest at**: \`${String(ac.threshold ?? d.threshold)}%\`
1382
+ * **Compact at**: \`${String(ac.forced_threshold ?? d.forced_threshold)}%\`
1383
+ * **Summarizer model**: \`${ac.model || '(active model)'}\`
1384
+ * **Pricing warn**: \`${settings.pricing_tier_warn ?? 'off'}\`
1385
+
1386
+ **Usage**: \`/context auto [on|off|threshold N|warn N|forced N|pricing on/off]\``);
1383
1387
  }
1384
- else if (arg1 === 'off') {
1385
- s.auto_compact.enabled = 'off';
1386
- this.settingsManager.save();
1387
- emitDelta(`Autocompaction disabled.`);
1388
+ else if (arg1 === 'on' || arg1 === 'off') {
1389
+ saveAutoCompact({ enabled: arg1 });
1390
+ emitDelta(`Auto-compaction ${arg1 === 'on' ? 'enabled' : 'disabled'}.`);
1388
1391
  }
1389
- else if (arg1 === 'threshold' && arg2) {
1392
+ else if ((arg1 === 'threshold' || arg1 === 'warn' || arg1 === 'forced') && arg2) {
1390
1393
  const pct = parseInt(arg2, 10);
1391
- if (isNaN(pct) || pct < 10 || pct > 99) {
1392
- emitDelta(`Invalid threshold. Use a value between 10 and 99.`);
1394
+ if (isNaN(pct) || pct < 5 || pct > 99) {
1395
+ emitDelta(`Invalid value. Use a percentage between 5 and 99.`);
1393
1396
  }
1394
1397
  else {
1395
- s.auto_compact.threshold = pct;
1396
- this.settingsManager.save();
1397
- emitDelta(`Compaction threshold set to ${pct}%.`);
1398
+ const key = arg1 === 'threshold' ? 'threshold' : arg1 === 'warn' ? 'warn_threshold' : 'forced_threshold';
1399
+ saveAutoCompact({ [key]: pct });
1400
+ emitDelta(`${arg1 === 'threshold' ? 'Suggest' : arg1 === 'warn' ? 'Warn' : 'Forced compaction'} threshold set to ${String(pct)}%.`);
1398
1401
  }
1399
1402
  }
1400
- else if (arg1 === 'warn' && arg2) {
1401
- const pct = parseInt(arg2, 10);
1402
- if (isNaN(pct) || pct < 5 || pct > 95) {
1403
- emitDelta(`Invalid warning threshold. Use a value between 5 and 95.`);
1404
- }
1405
- else {
1406
- s.auto_compact.warn_threshold = pct;
1407
- this.settingsManager.save();
1408
- emitDelta(`Warning threshold set to ${pct}%.`);
1409
- }
1410
- }
1411
- else if (arg1 === 'pricing') {
1412
- if (arg2 === 'on') {
1413
- s.pricing_tier_warn = 'on';
1414
- this.settingsManager.save();
1415
- emitDelta(`Pricing tier warnings enabled.`);
1416
- }
1417
- else if (arg2 === 'off') {
1418
- s.pricing_tier_warn = 'off';
1419
- this.settingsManager.save();
1420
- emitDelta(`Pricing tier warnings disabled.`);
1421
- }
1422
- else {
1423
- emitDelta(`Usage: \`/context auto pricing on/off\``);
1424
- }
1403
+ else if (arg1 === 'pricing' && (arg2 === 'on' || arg2 === 'off')) {
1404
+ this.settingsManager.update({ pricing_tier_warn: arg2 });
1405
+ emitDelta(`Pricing tier warnings ${arg2 === 'on' ? 'enabled' : 'disabled'}.`);
1425
1406
  }
1426
1407
  else {
1427
- emitDelta(`Usage: \`/context auto [on|off|threshold N|warn N|pricing on/off]\``);
1408
+ emitDelta(`Usage: \`/context auto [on|off|threshold N|warn N|forced N|pricing on/off]\``);
1428
1409
  }
1429
1410
  }
1430
1411
  else if (sub === 'compact') {
1431
- emitDelta(`### ⚡ Manual Compaction Triggered\n\nAnalyzing conversation history and building summary...`);
1412
+ emitDelta(`Compacting conversation history…`);
1432
1413
  try {
1433
- const summary = await this.runAutomaticCompaction(sessionId, 'detailed');
1434
- emitDelta(`\n\n**Manual Compaction Executed Successfully!**\n\nConversation history has been summarized, reducing the active context usage to just ~500 tokens. The agent will continue seamlessly!\n\n**Restored Context Summary:**\n\n${summary}`);
1414
+ const r = await this.runAutomaticCompaction(sessionId);
1415
+ emitDelta(`\n\n**Compacted.** ${String(r.summarizedMessageCount)} message(s) summarized, ` +
1416
+ `~${formatTokens(r.estimatedTokensBefore)} → ~${formatTokens(r.estimatedTokensAfter)} tokens. ` +
1417
+ `The full transcript is preserved on disk.\n\n**Summary:**\n\n${r.summary}`);
1435
1418
  }
1436
1419
  catch (err) {
1437
- emitDelta(`\n\n**Compaction Failed**: ${err instanceof Error ? err.message : String(err)}`);
1420
+ emitDelta(`\n\n**Compaction failed**: ${err instanceof Error ? err.message : String(err)}`);
1438
1421
  }
1439
1422
  }
1423
+ else if (sub === 'messages') {
1424
+ const messages = reconstructMessagesFromEvents(this.sessionStore.loadEvents(sessionId) || []);
1425
+ if (messages.length === 0) {
1426
+ emitDelta(`No messages in this session yet.`);
1427
+ break;
1428
+ }
1429
+ const lines = [`### Messages (${String(messages.length)})`, '', '| # | Role | Tokens | Detail |', '|---|---|---|---|'];
1430
+ messages.forEach((m, i) => {
1431
+ const tokens = estimateRequestTokens({ messages: [m] });
1432
+ const detail = m.role === 'tool'
1433
+ ? (m.toolName ?? 'tool')
1434
+ : m.role === 'user'
1435
+ ? `${String(m.content).slice(0, 60).replace(/[\n|]/g, ' ')}…`
1436
+ : m.content.map((b) => b.type === 'tool-use' ? `→ ${b.name}` : b.type).join(', ');
1437
+ lines.push(`| ${String(i + 1)} | ${m.role} | ${formatTokens(tokens)} | ${detail} |`);
1438
+ });
1439
+ emitDelta(lines.join('\n'));
1440
+ }
1440
1441
  else {
1442
+ // Emit measurements, not markup. The TUI and the web dashboard each
1443
+ // render this natively — the hand-written HTML this replaces showed
1444
+ // a themed gauge in the browser and nothing at all in the terminal.
1445
+ const budget = resolveBudget(settings);
1441
1446
  const activeLoop = this.turnLoops.get(sessionId);
1442
- const history = activeLoop
1443
- ? activeLoop.eventBus.history()
1444
- : (this.sessionStore.loadEvents(sessionId) || []);
1445
- const usageEvents = history.filter((e) => e.type === 'usage');
1446
- const latestUsage = usageEvents[usageEvents.length - 1];
1447
- const input = latestUsage ? (latestUsage.inputTokens || 0) : 0;
1448
- const output = latestUsage ? (latestUsage.outputTokens || 0) : 0;
1449
- const model = settings.model || 'unknown';
1450
- const windowSize = settings.providers?.[settings.current_provider]?.model_context_window ?? 200000;
1451
- const pct = input > 0 ? Math.min(100, Math.round((input / windowSize) * 100)) : 0;
1452
- const fmt = (n) => {
1453
- if (n >= 1_000_000)
1454
- return `${(n / 1_000_000).toFixed(1)}m`;
1455
- if (n >= 1_000)
1456
- return `${(n / 1_000).toFixed(1)}k`;
1457
- return String(n);
1447
+ const model = settings.providers[settings.current_provider]?.model || settings.model;
1448
+ const messages = activeLoop
1449
+ ? activeLoop.getMessages()
1450
+ : reconstructMessagesFromEvents(this.sessionStore.loadEvents(sessionId) || []);
1451
+ const chars = breakdownChars({
1452
+ system: this.systemPrompt,
1453
+ messages,
1454
+ toolDefinitions: this.tools.map((t) => t.definition),
1455
+ });
1456
+ const toTokens = (c) => Math.ceil(c / DEFAULT_CALIBRATION);
1457
+ const breakdown = [
1458
+ { label: 'System prompt', tokens: toTokens(chars.system) },
1459
+ { label: 'Tool definitions', tokens: toTokens(chars.toolDefinitions) },
1460
+ { label: 'Conversation', tokens: toTokens(chars.conversation) },
1461
+ { label: 'Tool results', tokens: toTokens(chars.toolResults) },
1462
+ ];
1463
+ const usedTokens = breakdown.reduce((sum, b) => sum + b.tokens, 0);
1464
+ const report = {
1465
+ type: 'context-report',
1466
+ id: crypto.randomUUID(),
1467
+ model,
1468
+ windowTokens: budget.windowTokens,
1469
+ usedTokens,
1470
+ reservedOutput: budget.reservedOutput,
1471
+ breakdown,
1472
+ timestamp: Date.now(),
1458
1473
  };
1459
- if (input === 0 && output === 0) {
1460
- emitDelta(`No token data yet. Start a conversation to see context window usage.\n\nActive model: \`${model}\` (Max Context: \`${fmt(windowSize)}\` tokens).`);
1461
- }
1462
- else {
1463
- const barColor = pct > 80 ? 'var(--red)' : pct > 50 ? 'var(--yellow)' : 'var(--green)';
1464
- const barGradient = pct > 80
1465
- ? 'linear-gradient(90deg, var(--red), #e08070)'
1466
- : pct > 50
1467
- ? 'linear-gradient(90deg, var(--yellow), #f0d090)'
1468
- : 'linear-gradient(90deg, var(--green), #c0d8a8)';
1469
- emitDelta(`<div style="background:var(--s3);border-radius:8px;padding:12px;margin:8px 0">` +
1470
- `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">` +
1471
- `<span style="font-family:monospace;font-size:12px;color:var(--text)">Context Window (${model})</span>` +
1472
- `<span style="font-family:monospace;font-size:13px;font-weight:bold;color:${barColor}">${pct}%</span>` +
1473
- `</div>` +
1474
- `<div style="background:var(--s2);border-radius:4px;height:8px;overflow:hidden">` +
1475
- `<div style="width:${pct}%;height:100%;background:${barGradient};border-radius:4px"></div>` +
1476
- `</div>` +
1477
- `<div style="display:flex;justify-content:space-between;margin-top:8px;font-family:monospace;font-size:11px;color:var(--muted)">` +
1478
- `<span>In: ${fmt(input)}</span><span>Out: ${fmt(output)}</span><span>Max: ${fmt(windowSize)}</span>` +
1479
- `</div></div>`);
1480
- }
1474
+ this.sharedEventBus?.emit({ ...report, sessionId });
1475
+ // Plain-text fallback so the command still says something useful on a
1476
+ // surface that has not yet learned the event.
1477
+ const pct = fillPct(usedTokens, budget);
1478
+ const rows = breakdown
1479
+ .map((b) => `* ${b.label}: ${formatTokens(b.tokens)} (${String(usedTokens > 0 ? Math.round((b.tokens / usedTokens) * 100) : 0)}%)`)
1480
+ .join('\n');
1481
+ emitDelta(`### Context Window — \`${model}\`\n\n` +
1482
+ `**${String(pct)}%** — ${formatTokens(usedTokens)} of ${formatTokens(budget.usableTokens)} usable ` +
1483
+ `(${formatTokens(budget.windowTokens)} window, ${formatTokens(budget.reservedOutput)} reserved for output)\n\n${rows}`);
1481
1484
  }
1482
1485
  break;
1483
1486
  }
@@ -1581,7 +1584,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1581
1584
  }
1582
1585
  case 'enable': {
1583
1586
  settings.heartbeat.schedule = 'on';
1584
- this.settingsManager.save();
1587
+ this.settingsManager.update({ heartbeat: settings.heartbeat });
1585
1588
  if (this.daemonApp) {
1586
1589
  this.daemonApp.taskManager.rescheduleFromSettings({
1587
1590
  HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
@@ -1596,7 +1599,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1596
1599
  }
1597
1600
  case 'disable': {
1598
1601
  settings.heartbeat.schedule = 'off';
1599
- this.settingsManager.save();
1602
+ this.settingsManager.update({ heartbeat: settings.heartbeat });
1600
1603
  if (this.daemonApp) {
1601
1604
  this.daemonApp.taskManager.cancelAllHeartbeats();
1602
1605
  }
@@ -1624,7 +1627,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1624
1627
  }
1625
1628
  else {
1626
1629
  settings.heartbeat.intraday = tokens.join(',');
1627
- this.settingsManager.save();
1630
+ this.settingsManager.update({ heartbeat: settings.heartbeat });
1628
1631
  if (this.daemonApp && settings.heartbeat.schedule === 'on') {
1629
1632
  this.daemonApp.taskManager.rescheduleFromSettings({
1630
1633
  HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
@@ -1652,7 +1655,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1652
1655
  }
1653
1656
  else {
1654
1657
  settings.heartbeat.daily = rest;
1655
- this.settingsManager.save();
1658
+ this.settingsManager.update({ heartbeat: settings.heartbeat });
1656
1659
  if (this.daemonApp && settings.heartbeat.schedule === 'on') {
1657
1660
  this.daemonApp.taskManager.rescheduleFromSettings({
1658
1661
  HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
@@ -1681,7 +1684,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1681
1684
  }
1682
1685
  else {
1683
1686
  settings.heartbeat.weekly = rest;
1684
- this.settingsManager.save();
1687
+ this.settingsManager.update({ heartbeat: settings.heartbeat });
1685
1688
  if (this.daemonApp && settings.heartbeat.schedule === 'on') {
1686
1689
  this.daemonApp.taskManager.rescheduleFromSettings({
1687
1690
  HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
@@ -1709,7 +1712,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1709
1712
  }
1710
1713
  else {
1711
1714
  settings.heartbeat.monthly = rest;
1712
- this.settingsManager.save();
1715
+ this.settingsManager.update({ heartbeat: settings.heartbeat });
1713
1716
  if (this.daemonApp && settings.heartbeat.schedule === 'on') {
1714
1717
  this.daemonApp.taskManager.rescheduleFromSettings({
1715
1718
  HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
@@ -1737,7 +1740,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1737
1740
  }
1738
1741
  else {
1739
1742
  settings.heartbeat.dreaming = rest;
1740
- this.settingsManager.save();
1743
+ this.settingsManager.update({ heartbeat: settings.heartbeat });
1741
1744
  if (this.daemonApp && settings.heartbeat.schedule === 'on') {
1742
1745
  this.daemonApp.taskManager.rescheduleFromSettings({
1743
1746
  HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
@@ -2182,7 +2185,16 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
2182
2185
  break;
2183
2186
  }
2184
2187
  default: {
2185
- emitDelta(`Unknown slash command: **${text}**. Type \`/help\` to see all available commands.`);
2188
+ // Distinguish "no such command" from "real command, wrong surface"
2189
+ // client-handled commands need terminal/browser state the daemon
2190
+ // doesn't have.
2191
+ const known = findSlashCommand(command);
2192
+ if (known?.handler === 'client') {
2193
+ emitDelta(`\`/${known.name}\` is handled by the interface, not the daemon — ${known.description.toLowerCase()}. Usage: \`${known.usage}\``);
2194
+ }
2195
+ else {
2196
+ emitDelta(`Unknown slash command: **${text}**. Type \`/help\` to see all available commands.`);
2197
+ }
2186
2198
  break;
2187
2199
  }
2188
2200
  }
@@ -2224,129 +2236,50 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
2224
2236
  }
2225
2237
  return true;
2226
2238
  }
2227
- async runAutomaticCompaction(sessionId, depth) {
2239
+ /**
2240
+ * Compact a session's history.
2241
+ *
2242
+ * The summarization itself lives in `@curie-agent/core` so the TurnLoop can
2243
+ * run it mid-run — this is the out-of-band entry point for `/context compact`
2244
+ * on an idle session.
2245
+ *
2246
+ * Nothing is deleted. A `compaction` marker is appended and message
2247
+ * reconstruction replays forward from it, so the full transcript survives for
2248
+ * the UI, resume and audit while the model carries only the summary.
2249
+ */
2250
+ async runAutomaticCompaction(sessionId) {
2228
2251
  if (!this.createProvider) {
2229
2252
  throw new Error('No provider configured');
2230
2253
  }
2231
2254
  const settings = this.settingsManager.get();
2232
2255
  const provider = this.createProvider(settings);
2233
- const model = settings.providers[settings.current_provider]?.model || settings.model;
2256
+ const activeModel = settings.providers[settings.current_provider]?.model || settings.model;
2234
2257
  const events = this.sessionStore.loadEvents(sessionId) || [];
2235
2258
  if (events.length === 0) {
2236
2259
  throw new Error('No events to compact');
2237
2260
  }
2238
- // Build human-readable transcript
2239
- let transcriptParts = [];
2240
- for (const e of events) {
2241
- if (e.type === 'user-prompt' && e.text) {
2242
- transcriptParts.push(`User: ${e.text}`);
2243
- }
2244
- else if (e.type === 'assistant-delta' && e.text) {
2245
- const lastIdx = transcriptParts.length - 1;
2246
- if (lastIdx >= 0 && transcriptParts[lastIdx]?.startsWith('Assistant:')) {
2247
- transcriptParts[lastIdx] += e.text;
2248
- }
2249
- else {
2250
- transcriptParts.push(`Assistant: ${e.text}`);
2251
- }
2252
- }
2253
- }
2254
- const transcript = transcriptParts.join('\n\n');
2255
- if (!transcript.trim()) {
2261
+ const messages = reconstructMessagesFromEvents(events);
2262
+ if (messages.length === 0) {
2256
2263
  throw new Error('No conversational history found to compact');
2257
2264
  }
2258
- const systemPrompt = `You are a conversation summarizer. Summarize the provided dialogue in a dense, detailed, high-fidelity paragraph or two. Focus on capturing the original goals, what was accomplished, any modified files or configurations, current settings, and what the pending next steps are. Ensure all key technical details (like file paths, specific code adjustments, command names) are preserved. Do not add any conversational intros or outros; output ONLY the raw summary text.`;
2259
- const prompt = `Please summarize this conversation history:\n\n${transcript}`;
2260
- const summary = await provider.check(prompt, { model, system: systemPrompt });
2261
- const cleanSummary = summary.trim();
2262
- // Overwrite events log
2263
- const eventsPath = this.sessionStore.eventsPath(sessionId);
2264
- const newEvents = [
2265
- {
2266
- type: 'session-start',
2267
- id: crypto.randomUUID(),
2268
- model,
2269
- provider: settings.current_provider || 'unknown',
2270
- cwd: process.cwd(),
2271
- timestamp: Date.now(),
2272
- },
2273
- {
2274
- type: 'user-prompt',
2275
- id: crypto.randomUUID(),
2276
- text: `This is a continuation of a compacted conversation. Here is the high-fidelity summary of our session so far:\n\n${cleanSummary}\n\nLet's continue!`,
2277
- cwd: process.cwd(),
2278
- timestamp: Date.now() + 1,
2279
- },
2280
- {
2281
- type: 'assistant-delta',
2282
- id: crypto.randomUUID(),
2283
- text: `Got it! I have fully restored our conversation summary and details. Let let me know what you would like to do next!`,
2284
- timestamp: Date.now() + 2,
2285
- },
2286
- {
2287
- type: 'assistant-stop',
2288
- id: crypto.randomUUID(),
2289
- timestamp: Date.now() + 3,
2290
- }
2291
- ];
2292
- const data = newEvents.map((e) => JSON.stringify(e)).join('\n') + '\n';
2293
- writeFileSync(eventsPath, data, 'utf-8');
2294
- return cleanSummary;
2295
- }
2296
- async checkContextThresholds(sessionId) {
2297
- const settings = this.settingsManager.get();
2298
- const history = this.sessionStore.loadEvents(sessionId) || [];
2299
- const usageEvents = history.filter((e) => e.type === 'usage');
2300
- const latestUsage = usageEvents[usageEvents.length - 1];
2301
- const input = latestUsage?.inputTokens ?? 0;
2302
- if (input === 0)
2303
- return; // No token data yet
2304
- const windowSize = settings.providers?.[settings.current_provider]?.model_context_window ?? 200000;
2305
- const pct = Math.min(100, Math.round((input / windowSize) * 100));
2306
- const autoCompact = settings.auto_compact || { enabled: 'on', threshold: 80, warn_threshold: 60, forced_threshold: 85 };
2307
- const warnThresh = autoCompact.warn_threshold ?? 60;
2308
- const compactThresh = autoCompact.threshold ?? 80;
2309
- const forcedThresh = autoCompact.forced_threshold ?? 85;
2310
- const enabled = autoCompact.enabled ?? 'on';
2311
- if (pct >= forcedThresh && enabled === 'on') {
2312
- try {
2313
- const summary = await this.runAutomaticCompaction(sessionId, 'detailed');
2314
- const successMessage = `⚡ **Auto-Compaction Executed Successfully!**\n\nContext usage was at **${pct}%** (forced threshold: **${forcedThresh}%**).\nWe have summarized the conversation, reducing the history size down to just ~500 tokens. The agent will continue seamlessly!\n\n**Restored Context Summary:**\n\n${summary}`;
2315
- const warningEvent = {
2316
- type: 'context-warning',
2317
- id: crypto.randomUUID(),
2318
- message: successMessage,
2319
- timestamp: Date.now(),
2320
- };
2321
- this.sharedEventBus?.emit({ ...warningEvent, sessionId });
2322
- this.sessionStore.appendEvent(sessionId, { ...warningEvent, sessionId });
2323
- }
2324
- catch (err) {
2325
- console.error('[compaction] Auto-compaction failed:', err);
2326
- }
2327
- }
2328
- else if (pct >= compactThresh) {
2329
- const suggestMessage = `⚠️ **Context Fill High (${pct}%)**\n\nYour context fill is at **${pct}%** (Threshold: **${compactThresh}%**). Suggesting conversation compaction.\n\nType \`/context compact\` to run compaction, summarize history, and free memory immediately!`;
2330
- const warningEvent = {
2331
- type: 'context-warning',
2332
- id: crypto.randomUUID(),
2333
- message: suggestMessage,
2334
- timestamp: Date.now(),
2335
- };
2336
- this.sharedEventBus?.emit({ ...warningEvent, sessionId });
2337
- this.sessionStore.appendEvent(sessionId, { ...warningEvent, sessionId });
2338
- }
2339
- else if (pct >= warnThresh) {
2340
- const warnMessage = `⚠️ **Context Warning (${pct}%)**\n\nContext window is **${pct}%** full (Warning threshold: **${warnThresh}%**).`;
2341
- const warningEvent = {
2342
- type: 'context-warning',
2343
- id: crypto.randomUUID(),
2344
- message: warnMessage,
2345
- timestamp: Date.now(),
2346
- };
2347
- this.sharedEventBus?.emit({ ...warningEvent, sessionId });
2348
- this.sessionStore.appendEvent(sessionId, { ...warningEvent, sessionId });
2349
- }
2265
+ const result = await compactMessages({
2266
+ messages,
2267
+ provider: provider,
2268
+ model: settings.auto_compact?.model || activeModel,
2269
+ budget: resolveBudget(settings),
2270
+ });
2271
+ const marker = {
2272
+ type: 'compaction',
2273
+ id: crypto.randomUUID(),
2274
+ summary: result.summary,
2275
+ summarizedMessageCount: result.summarizedMessageCount,
2276
+ tokensBefore: result.estimatedTokensBefore,
2277
+ tokensAfter: result.estimatedTokensAfter,
2278
+ timestamp: Date.now(),
2279
+ };
2280
+ this.sessionStore.appendEvent(sessionId, { ...marker, sessionId });
2281
+ this.sharedEventBus?.emit({ ...marker, sessionId });
2282
+ return result;
2350
2283
  }
2351
2284
  }
2352
2285
  //# sourceMappingURL=jsonrpc-handler.js.map