@curie-agent/daemon 0.4.2 → 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.
@@ -4,6 +4,7 @@ import { readFileSync, existsSync, readdirSync, statSync, writeFileSync, mkdirSy
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { Method, renderSlashCommandHelp, findSlashCommand } from '@curie-agent/protocol';
6
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';
7
8
  import { listSkills, discoverAllSkills } from '@curie-agent/tools';
8
9
  import { executeCd } from './slash-cd.js';
9
10
  /** Real package version — previously hardcoded and years out of date. */
@@ -305,10 +306,11 @@ export class JsonRpcHandler {
305
306
  }
306
307
  case Method.CONFIG_GET: {
307
308
  const key = this.getStringParam(params, 'key');
308
- if (!key)
309
- return this.paramError('key');
310
309
  const settings = this.settingsManager.get();
311
- 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);
312
314
  break;
313
315
  }
314
316
  case Method.CONFIG_SET: {
@@ -340,14 +342,7 @@ export class JsonRpcHandler {
340
342
  else {
341
343
  this.settingsManager.update({ [key]: value });
342
344
  }
343
- this.settingsManager.save();
344
- this.sharedEventBus?.emit({
345
- type: 'config-changed',
346
- id: Math.random().toString(36).substring(7),
347
- timestamp: Date.now(),
348
- key,
349
- value,
350
- });
345
+ this.emitConfigChanged(key, value);
351
346
  if (key.startsWith('heartbeat') && this.daemonApp) {
352
347
  const updatedSettings = this.settingsManager.get();
353
348
  if (updatedSettings.heartbeat?.schedule === 'on') {
@@ -363,6 +358,17 @@ export class JsonRpcHandler {
363
358
  this.daemonApp.taskManager.cancelAllHeartbeats();
364
359
  }
365
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
+ }
366
372
  result = { status: 'ok', key, value };
367
373
  break;
368
374
  }
@@ -905,6 +911,7 @@ export class JsonRpcHandler {
905
911
  'tool-result', 'approval-decision', 'usage',
906
912
  'error', 'session-start', 'session-stop', 'hook', 'status',
907
913
  'session-resumed', 'context-warning', 'thinking-delta',
914
+ 'compaction', 'context-report',
908
915
  // Subagent events
909
916
  'agent-start', 'agent-text-delta', 'agent-thinking-delta',
910
917
  'agent-tool-call', 'agent-tool-result', 'agent-usage',
@@ -918,7 +925,9 @@ export class JsonRpcHandler {
918
925
  }
919
926
  try {
920
927
  const result = await loop.run(text);
921
- 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.
922
931
  return { status: 'completed', sessionId: result.sessionId, events: result.events.length };
923
932
  }
924
933
  catch (err) {
@@ -936,6 +945,21 @@ export class JsonRpcHandler {
936
945
  paramError(key) {
937
946
  return { jsonrpc: '2.0', id: 0, error: { code: -32602, message: `Missing required parameter: ${key}` } };
938
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
+ }
939
963
  /** Get a nested value from an object using dot notation (e.g. "providers.anthropic.model"). */
940
964
  getNestedValue(obj, path) {
941
965
  const parts = path.split('.');
@@ -1008,6 +1032,7 @@ export class JsonRpcHandler {
1008
1032
  const pricing = pConfig?.model_cost
1009
1033
  ? `\`${pConfig.model_cost}\` (per million)`
1010
1034
  : 'Not configured';
1035
+ const statusBudget = resolveBudget(settings);
1011
1036
  const statusText = `### Curie Agent Status
1012
1037
  * **Version:** \`${VERSION}\`
1013
1038
  * **Active Model:** \`${settings.model}\`
@@ -1015,11 +1040,11 @@ export class JsonRpcHandler {
1015
1040
  * **Approval Mode:** \`${settings.mode || 'auto'}\`
1016
1041
  * **Reasoning Effort:** \`${settings.effort || 'auto'}\`
1017
1042
  * **Workspace CWD:** \`${this.getSessionCwd(sessionId) || process.cwd()}\`
1018
- * **Tools per Turn Limit:** \`${settings.tools_per_call || 10}\`
1019
- * **Web Search per Turn Limit:** \`${settings.websearch_per_call || 5}\`
1020
- * **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)
1021
1046
  * **Model Pricing:** ${pricing}
1022
- * **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)}%\`)`;
1023
1048
  emitDelta(statusText);
1024
1049
  break;
1025
1050
  }
@@ -1126,13 +1151,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1126
1151
  else {
1127
1152
  settings.theme = theme;
1128
1153
  this.settingsManager.update(settings);
1129
- this.sharedEventBus?.emit({
1130
- type: 'config-changed',
1131
- id: Math.random().toString(36).substring(7),
1132
- timestamp: Date.now(),
1133
- key: 'theme',
1134
- value: theme,
1135
- });
1154
+ this.emitConfigChanged('theme', theme);
1136
1155
  emitDelta(`Successfully switched theme to: **${theme}**`);
1137
1156
  }
1138
1157
  break;
@@ -1149,6 +1168,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1149
1168
  else {
1150
1169
  settings.mode = args.toLowerCase();
1151
1170
  this.settingsManager.update(settings);
1171
+ this.emitConfigChanged('mode', settings.mode);
1152
1172
  emitDelta(`Successfully switched approval mode to: **${args.toLowerCase()}**`);
1153
1173
  }
1154
1174
  break;
@@ -1165,6 +1185,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1165
1185
  else {
1166
1186
  settings.effort = args.toLowerCase();
1167
1187
  this.settingsManager.update(settings);
1188
+ this.emitConfigChanged('effort', settings.effort);
1168
1189
  emitDelta(`Successfully switched reasoning effort to: **${args.toLowerCase()}**`);
1169
1190
  }
1170
1191
  break;
@@ -1192,8 +1213,11 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1192
1213
  settings.websearch_per_call = wsVal;
1193
1214
  }
1194
1215
  }
1195
- this.settingsManager.save();
1196
- 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)}**`);
1197
1221
  }
1198
1222
  }
1199
1223
  break;
@@ -1212,9 +1236,8 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1212
1236
  emitDelta(`Invalid value: "${args}". Must be a positive integer.`);
1213
1237
  }
1214
1238
  else {
1215
- settings.websearch_per_call = val;
1216
- this.settingsManager.save();
1217
- 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)}**`);
1218
1241
  }
1219
1242
  }
1220
1243
  break;
@@ -1343,122 +1366,121 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1343
1366
  const sub = parts[0]?.toLowerCase();
1344
1367
  const arg1 = parts[1]?.toLowerCase();
1345
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
+ };
1346
1374
  if (sub === 'auto') {
1347
- const s = settings;
1348
- if (!s.auto_compact) {
1349
- s.auto_compact = { enabled: 'on', threshold: 80, warn_threshold: 15, forced_threshold: 85 };
1350
- }
1375
+ const ac = settings.auto_compact ?? d;
1351
1376
  if (!arg1) {
1352
- emitDelta(`### Auto-Compaction Settings:
1353
- * **Auto-compact**: \`${s.auto_compact.enabled}\`
1354
- * **Threshold**: \`${s.auto_compact.threshold ?? 80}%\`
1355
- * **Warning Threshold**: \`${s.auto_compact.warn_threshold ?? 15}%\`
1356
- * **Pricing Warn**: \`${s.pricing_tier_warn ?? 'off'}\`
1377
+ emitDelta(`### Auto-Compaction Settings
1357
1378
 
1358
- **Usage**: \`/context auto [on|off|threshold N|warn N|pricing on/off]\``);
1359
- }
1360
- else if (arg1 === 'on') {
1361
- s.auto_compact.enabled = 'on';
1362
- this.settingsManager.save();
1363
- 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]\``);
1364
1387
  }
1365
- else if (arg1 === 'off') {
1366
- s.auto_compact.enabled = 'off';
1367
- this.settingsManager.save();
1368
- emitDelta(`Autocompaction disabled.`);
1388
+ else if (arg1 === 'on' || arg1 === 'off') {
1389
+ saveAutoCompact({ enabled: arg1 });
1390
+ emitDelta(`Auto-compaction ${arg1 === 'on' ? 'enabled' : 'disabled'}.`);
1369
1391
  }
1370
- else if (arg1 === 'threshold' && arg2) {
1392
+ else if ((arg1 === 'threshold' || arg1 === 'warn' || arg1 === 'forced') && arg2) {
1371
1393
  const pct = parseInt(arg2, 10);
1372
- if (isNaN(pct) || pct < 10 || pct > 99) {
1373
- 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.`);
1374
1396
  }
1375
1397
  else {
1376
- s.auto_compact.threshold = pct;
1377
- this.settingsManager.save();
1378
- 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)}%.`);
1379
1401
  }
1380
1402
  }
1381
- else if (arg1 === 'warn' && arg2) {
1382
- const pct = parseInt(arg2, 10);
1383
- if (isNaN(pct) || pct < 5 || pct > 95) {
1384
- emitDelta(`Invalid warning threshold. Use a value between 5 and 95.`);
1385
- }
1386
- else {
1387
- s.auto_compact.warn_threshold = pct;
1388
- this.settingsManager.save();
1389
- emitDelta(`Warning threshold set to ${pct}%.`);
1390
- }
1391
- }
1392
- else if (arg1 === 'pricing') {
1393
- if (arg2 === 'on') {
1394
- s.pricing_tier_warn = 'on';
1395
- this.settingsManager.save();
1396
- emitDelta(`Pricing tier warnings enabled.`);
1397
- }
1398
- else if (arg2 === 'off') {
1399
- s.pricing_tier_warn = 'off';
1400
- this.settingsManager.save();
1401
- emitDelta(`Pricing tier warnings disabled.`);
1402
- }
1403
- else {
1404
- emitDelta(`Usage: \`/context auto pricing on/off\``);
1405
- }
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'}.`);
1406
1406
  }
1407
1407
  else {
1408
- 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]\``);
1409
1409
  }
1410
1410
  }
1411
1411
  else if (sub === 'compact') {
1412
- emitDelta(`### ⚡ Manual Compaction Triggered\n\nAnalyzing conversation history and building summary...`);
1412
+ emitDelta(`Compacting conversation history…`);
1413
1413
  try {
1414
- const summary = await this.runAutomaticCompaction(sessionId, 'detailed');
1415
- 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}`);
1416
1418
  }
1417
1419
  catch (err) {
1418
- 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)}`);
1419
1421
  }
1420
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
+ }
1421
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);
1422
1446
  const activeLoop = this.turnLoops.get(sessionId);
1423
- const history = activeLoop
1424
- ? activeLoop.eventBus.history()
1425
- : (this.sessionStore.loadEvents(sessionId) || []);
1426
- const usageEvents = history.filter((e) => e.type === 'usage');
1427
- const latestUsage = usageEvents[usageEvents.length - 1];
1428
- const input = latestUsage ? (latestUsage.inputTokens || 0) : 0;
1429
- const output = latestUsage ? (latestUsage.outputTokens || 0) : 0;
1430
- const model = settings.model || 'unknown';
1431
- const windowSize = settings.providers?.[settings.current_provider]?.model_context_window ?? 200000;
1432
- const pct = input > 0 ? Math.min(100, Math.round((input / windowSize) * 100)) : 0;
1433
- const fmt = (n) => {
1434
- if (n >= 1_000_000)
1435
- return `${(n / 1_000_000).toFixed(1)}m`;
1436
- if (n >= 1_000)
1437
- return `${(n / 1_000).toFixed(1)}k`;
1438
- 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(),
1439
1473
  };
1440
- if (input === 0 && output === 0) {
1441
- emitDelta(`No token data yet. Start a conversation to see context window usage.\n\nActive model: \`${model}\` (Max Context: \`${fmt(windowSize)}\` tokens).`);
1442
- }
1443
- else {
1444
- const barColor = pct > 80 ? 'var(--red)' : pct > 50 ? 'var(--yellow)' : 'var(--green)';
1445
- const barGradient = pct > 80
1446
- ? 'linear-gradient(90deg, var(--red), #e08070)'
1447
- : pct > 50
1448
- ? 'linear-gradient(90deg, var(--yellow), #f0d090)'
1449
- : 'linear-gradient(90deg, var(--green), #c0d8a8)';
1450
- emitDelta(`<div style="background:var(--s3);border-radius:8px;padding:12px;margin:8px 0">` +
1451
- `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">` +
1452
- `<span style="font-family:monospace;font-size:12px;color:var(--text)">Context Window (${model})</span>` +
1453
- `<span style="font-family:monospace;font-size:13px;font-weight:bold;color:${barColor}">${pct}%</span>` +
1454
- `</div>` +
1455
- `<div style="background:var(--s2);border-radius:4px;height:8px;overflow:hidden">` +
1456
- `<div style="width:${pct}%;height:100%;background:${barGradient};border-radius:4px"></div>` +
1457
- `</div>` +
1458
- `<div style="display:flex;justify-content:space-between;margin-top:8px;font-family:monospace;font-size:11px;color:var(--muted)">` +
1459
- `<span>In: ${fmt(input)}</span><span>Out: ${fmt(output)}</span><span>Max: ${fmt(windowSize)}</span>` +
1460
- `</div></div>`);
1461
- }
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}`);
1462
1484
  }
1463
1485
  break;
1464
1486
  }
@@ -1562,7 +1584,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1562
1584
  }
1563
1585
  case 'enable': {
1564
1586
  settings.heartbeat.schedule = 'on';
1565
- this.settingsManager.save();
1587
+ this.settingsManager.update({ heartbeat: settings.heartbeat });
1566
1588
  if (this.daemonApp) {
1567
1589
  this.daemonApp.taskManager.rescheduleFromSettings({
1568
1590
  HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
@@ -1577,7 +1599,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1577
1599
  }
1578
1600
  case 'disable': {
1579
1601
  settings.heartbeat.schedule = 'off';
1580
- this.settingsManager.save();
1602
+ this.settingsManager.update({ heartbeat: settings.heartbeat });
1581
1603
  if (this.daemonApp) {
1582
1604
  this.daemonApp.taskManager.cancelAllHeartbeats();
1583
1605
  }
@@ -1605,7 +1627,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1605
1627
  }
1606
1628
  else {
1607
1629
  settings.heartbeat.intraday = tokens.join(',');
1608
- this.settingsManager.save();
1630
+ this.settingsManager.update({ heartbeat: settings.heartbeat });
1609
1631
  if (this.daemonApp && settings.heartbeat.schedule === 'on') {
1610
1632
  this.daemonApp.taskManager.rescheduleFromSettings({
1611
1633
  HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
@@ -1633,7 +1655,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1633
1655
  }
1634
1656
  else {
1635
1657
  settings.heartbeat.daily = rest;
1636
- this.settingsManager.save();
1658
+ this.settingsManager.update({ heartbeat: settings.heartbeat });
1637
1659
  if (this.daemonApp && settings.heartbeat.schedule === 'on') {
1638
1660
  this.daemonApp.taskManager.rescheduleFromSettings({
1639
1661
  HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
@@ -1662,7 +1684,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1662
1684
  }
1663
1685
  else {
1664
1686
  settings.heartbeat.weekly = rest;
1665
- this.settingsManager.save();
1687
+ this.settingsManager.update({ heartbeat: settings.heartbeat });
1666
1688
  if (this.daemonApp && settings.heartbeat.schedule === 'on') {
1667
1689
  this.daemonApp.taskManager.rescheduleFromSettings({
1668
1690
  HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
@@ -1690,7 +1712,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1690
1712
  }
1691
1713
  else {
1692
1714
  settings.heartbeat.monthly = rest;
1693
- this.settingsManager.save();
1715
+ this.settingsManager.update({ heartbeat: settings.heartbeat });
1694
1716
  if (this.daemonApp && settings.heartbeat.schedule === 'on') {
1695
1717
  this.daemonApp.taskManager.rescheduleFromSettings({
1696
1718
  HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
@@ -1718,7 +1740,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1718
1740
  }
1719
1741
  else {
1720
1742
  settings.heartbeat.dreaming = rest;
1721
- this.settingsManager.save();
1743
+ this.settingsManager.update({ heartbeat: settings.heartbeat });
1722
1744
  if (this.daemonApp && settings.heartbeat.schedule === 'on') {
1723
1745
  this.daemonApp.taskManager.rescheduleFromSettings({
1724
1746
  HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
@@ -2214,129 +2236,50 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
2214
2236
  }
2215
2237
  return true;
2216
2238
  }
2217
- 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) {
2218
2251
  if (!this.createProvider) {
2219
2252
  throw new Error('No provider configured');
2220
2253
  }
2221
2254
  const settings = this.settingsManager.get();
2222
2255
  const provider = this.createProvider(settings);
2223
- const model = settings.providers[settings.current_provider]?.model || settings.model;
2256
+ const activeModel = settings.providers[settings.current_provider]?.model || settings.model;
2224
2257
  const events = this.sessionStore.loadEvents(sessionId) || [];
2225
2258
  if (events.length === 0) {
2226
2259
  throw new Error('No events to compact');
2227
2260
  }
2228
- // Build human-readable transcript
2229
- let transcriptParts = [];
2230
- for (const e of events) {
2231
- if (e.type === 'user-prompt' && e.text) {
2232
- transcriptParts.push(`User: ${e.text}`);
2233
- }
2234
- else if (e.type === 'assistant-delta' && e.text) {
2235
- const lastIdx = transcriptParts.length - 1;
2236
- if (lastIdx >= 0 && transcriptParts[lastIdx]?.startsWith('Assistant:')) {
2237
- transcriptParts[lastIdx] += e.text;
2238
- }
2239
- else {
2240
- transcriptParts.push(`Assistant: ${e.text}`);
2241
- }
2242
- }
2243
- }
2244
- const transcript = transcriptParts.join('\n\n');
2245
- if (!transcript.trim()) {
2261
+ const messages = reconstructMessagesFromEvents(events);
2262
+ if (messages.length === 0) {
2246
2263
  throw new Error('No conversational history found to compact');
2247
2264
  }
2248
- 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.`;
2249
- const prompt = `Please summarize this conversation history:\n\n${transcript}`;
2250
- const summary = await provider.check(prompt, { model, system: systemPrompt });
2251
- const cleanSummary = summary.trim();
2252
- // Overwrite events log
2253
- const eventsPath = this.sessionStore.eventsPath(sessionId);
2254
- const newEvents = [
2255
- {
2256
- type: 'session-start',
2257
- id: crypto.randomUUID(),
2258
- model,
2259
- provider: settings.current_provider || 'unknown',
2260
- cwd: process.cwd(),
2261
- timestamp: Date.now(),
2262
- },
2263
- {
2264
- type: 'user-prompt',
2265
- id: crypto.randomUUID(),
2266
- 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!`,
2267
- cwd: process.cwd(),
2268
- timestamp: Date.now() + 1,
2269
- },
2270
- {
2271
- type: 'assistant-delta',
2272
- id: crypto.randomUUID(),
2273
- text: `Got it! I have fully restored our conversation summary and details. Let let me know what you would like to do next!`,
2274
- timestamp: Date.now() + 2,
2275
- },
2276
- {
2277
- type: 'assistant-stop',
2278
- id: crypto.randomUUID(),
2279
- timestamp: Date.now() + 3,
2280
- }
2281
- ];
2282
- const data = newEvents.map((e) => JSON.stringify(e)).join('\n') + '\n';
2283
- writeFileSync(eventsPath, data, 'utf-8');
2284
- return cleanSummary;
2285
- }
2286
- async checkContextThresholds(sessionId) {
2287
- const settings = this.settingsManager.get();
2288
- const history = this.sessionStore.loadEvents(sessionId) || [];
2289
- const usageEvents = history.filter((e) => e.type === 'usage');
2290
- const latestUsage = usageEvents[usageEvents.length - 1];
2291
- const input = latestUsage?.inputTokens ?? 0;
2292
- if (input === 0)
2293
- return; // No token data yet
2294
- const windowSize = settings.providers?.[settings.current_provider]?.model_context_window ?? 200000;
2295
- const pct = Math.min(100, Math.round((input / windowSize) * 100));
2296
- const autoCompact = settings.auto_compact || { enabled: 'on', threshold: 80, warn_threshold: 60, forced_threshold: 85 };
2297
- const warnThresh = autoCompact.warn_threshold ?? 60;
2298
- const compactThresh = autoCompact.threshold ?? 80;
2299
- const forcedThresh = autoCompact.forced_threshold ?? 85;
2300
- const enabled = autoCompact.enabled ?? 'on';
2301
- if (pct >= forcedThresh && enabled === 'on') {
2302
- try {
2303
- const summary = await this.runAutomaticCompaction(sessionId, 'detailed');
2304
- 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}`;
2305
- const warningEvent = {
2306
- type: 'context-warning',
2307
- id: crypto.randomUUID(),
2308
- message: successMessage,
2309
- timestamp: Date.now(),
2310
- };
2311
- this.sharedEventBus?.emit({ ...warningEvent, sessionId });
2312
- this.sessionStore.appendEvent(sessionId, { ...warningEvent, sessionId });
2313
- }
2314
- catch (err) {
2315
- console.error('[compaction] Auto-compaction failed:', err);
2316
- }
2317
- }
2318
- else if (pct >= compactThresh) {
2319
- 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!`;
2320
- const warningEvent = {
2321
- type: 'context-warning',
2322
- id: crypto.randomUUID(),
2323
- message: suggestMessage,
2324
- timestamp: Date.now(),
2325
- };
2326
- this.sharedEventBus?.emit({ ...warningEvent, sessionId });
2327
- this.sessionStore.appendEvent(sessionId, { ...warningEvent, sessionId });
2328
- }
2329
- else if (pct >= warnThresh) {
2330
- const warnMessage = `⚠️ **Context Warning (${pct}%)**\n\nContext window is **${pct}%** full (Warning threshold: **${warnThresh}%**).`;
2331
- const warningEvent = {
2332
- type: 'context-warning',
2333
- id: crypto.randomUUID(),
2334
- message: warnMessage,
2335
- timestamp: Date.now(),
2336
- };
2337
- this.sharedEventBus?.emit({ ...warningEvent, sessionId });
2338
- this.sessionStore.appendEvent(sessionId, { ...warningEvent, sessionId });
2339
- }
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;
2340
2283
  }
2341
2284
  }
2342
2285
  //# sourceMappingURL=jsonrpc-handler.js.map