@curie-agent/daemon 0.4.2 → 0.4.4

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,22 +1,12 @@
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 { fileURLToPath } from 'node:url';
5
4
  import { Method, renderSlashCommandHelp, findSlashCommand } from '@curie-agent/protocol';
6
5
  import { TurnLoop, parseReminderTime, listSnapshots, revertTo, createIdentityFilesAuto, PURE_TOOLS } from '@curie-agent/core';
6
+ import { compactMessages, reconstructMessagesFromEvents, resolveBudget, estimateRequestTokens, breakdownChars, fillPct, formatTokens, DEFAULT_CALIBRATION, DEFAULT_SETTINGS, } from '@curie-agent/core';
7
7
  import { listSkills, discoverAllSkills } from '@curie-agent/tools';
8
8
  import { executeCd } from './slash-cd.js';
9
- /** Real package version previously hardcoded and years out of date. */
10
- const VERSION = (() => {
11
- try {
12
- const here = path.dirname(fileURLToPath(import.meta.url));
13
- const pkg = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf-8'));
14
- return pkg.version ?? '0.0.0';
15
- }
16
- catch {
17
- return '0.0.0';
18
- }
19
- })();
9
+ import { VERSION } from './version.js';
20
10
  export class JsonRpcHandler {
21
11
  sessionStore;
22
12
  settingsManager;
@@ -305,10 +295,11 @@ export class JsonRpcHandler {
305
295
  }
306
296
  case Method.CONFIG_GET: {
307
297
  const key = this.getStringParam(params, 'key');
308
- if (!key)
309
- return this.paramError('key');
310
298
  const settings = this.settingsManager.get();
311
- result = this.getNestedValue(settings, key);
299
+ // '*' or omitted → the whole tree, in one consistent snapshot. Reading
300
+ // ~23 top-level keys one at a time can tear if another client writes
301
+ // mid-fetch, yielding a snapshot that never existed on disk.
302
+ result = !key || key === '*' ? settings : this.getNestedValue(settings, key);
312
303
  break;
313
304
  }
314
305
  case Method.CONFIG_SET: {
@@ -340,14 +331,7 @@ export class JsonRpcHandler {
340
331
  else {
341
332
  this.settingsManager.update({ [key]: value });
342
333
  }
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
- });
334
+ this.emitConfigChanged(key, value);
351
335
  if (key.startsWith('heartbeat') && this.daemonApp) {
352
336
  const updatedSettings = this.settingsManager.get();
353
337
  if (updatedSettings.heartbeat?.schedule === 'on') {
@@ -363,6 +347,17 @@ export class JsonRpcHandler {
363
347
  this.daemonApp.taskManager.cancelAllHeartbeats();
364
348
  }
365
349
  }
350
+ // Keep the derived top-level `model` in sync with the active provider.
351
+ // SettingsManager.load() recomputes it from providers[current_provider].model,
352
+ // so a stale top-level value silently reverts on the next daemon start.
353
+ if (key === 'current_provider' || /^providers\.[^.]+\.model$/.test(key)) {
354
+ const synced = this.settingsManager.get();
355
+ const active = synced.providers[synced.current_provider];
356
+ if (active && synced.model !== active.model) {
357
+ this.settingsManager.update({ model: active.model });
358
+ this.emitConfigChanged('model', active.model);
359
+ }
360
+ }
366
361
  result = { status: 'ok', key, value };
367
362
  break;
368
363
  }
@@ -824,21 +819,26 @@ export class JsonRpcHandler {
824
819
  if (typeof p.status === 'string') {
825
820
  this.daemonApp.taskManager.updateTaskStatus(taskId, p.status);
826
821
  }
822
+ // Patch through updateTask() rather than mutating the object in place —
823
+ // an in-place edit followed by save() writes a stale array.
824
+ const patch = {};
827
825
  if (typeof p.priority === 'string')
828
- task.priority = p.priority;
826
+ patch.priority = p.priority;
829
827
  if (typeof p.title === 'string')
830
- task.title = p.title;
828
+ patch.title = p.title;
831
829
  if (typeof p.description === 'string')
832
- task.description = p.description;
830
+ patch.description = p.description;
833
831
  if (Array.isArray(p.tags))
834
- task.tags = p.tags;
832
+ patch.tags = p.tags;
835
833
  if (typeof p.mode === 'string')
836
- task.mode = p.mode;
834
+ patch.mode = p.mode;
837
835
  if (typeof p.scope === 'string')
838
- task.scope = p.scope;
836
+ patch.scope = p.scope;
839
837
  if (typeof p.scheduled_at === 'number')
840
- task.scheduled_at = p.scheduled_at;
841
- this.daemonApp.taskManager.save();
838
+ patch.scheduled_at = p.scheduled_at;
839
+ if (Object.keys(patch).length > 0) {
840
+ this.daemonApp.taskManager.updateTask(task.id, patch);
841
+ }
842
842
  this.sharedEventBus?.emit({ type: 'todo-changed', id: crypto.randomUUID(), timestamp: Date.now(), action: 'updated', taskId });
843
843
  result = { ok: true, task: this.daemonApp.taskManager.findTask(taskId) };
844
844
  break;
@@ -905,6 +905,7 @@ export class JsonRpcHandler {
905
905
  'tool-result', 'approval-decision', 'usage',
906
906
  'error', 'session-start', 'session-stop', 'hook', 'status',
907
907
  'session-resumed', 'context-warning', 'thinking-delta',
908
+ 'compaction', 'context-report',
908
909
  // Subagent events
909
910
  'agent-start', 'agent-text-delta', 'agent-thinking-delta',
910
911
  'agent-tool-call', 'agent-tool-result', 'agent-usage',
@@ -918,7 +919,9 @@ export class JsonRpcHandler {
918
919
  }
919
920
  try {
920
921
  const result = await loop.run(text);
921
- await this.checkContextThresholds(sessionId);
922
+ // No post-run threshold check: TurnLoop enforces the budget before every
923
+ // provider call, on every code path (channels, tasks, heartbeat,
924
+ // subagents) — not just between prompts on this one.
922
925
  return { status: 'completed', sessionId: result.sessionId, events: result.events.length };
923
926
  }
924
927
  catch (err) {
@@ -936,6 +939,21 @@ export class JsonRpcHandler {
936
939
  paramError(key) {
937
940
  return { jsonrpc: '2.0', id: 0, error: { code: -32602, message: `Missing required parameter: ${key}` } };
938
941
  }
942
+ /**
943
+ * Broadcast a settings change so every connected client re-reads config.
944
+ * 'config-changed' is already in ws-handler's newEventTypes, so this reaches
945
+ * the browser. Every settings write must emit — a write that doesn't leaves
946
+ * the web dashboard showing a stale value until the user reloads.
947
+ */
948
+ emitConfigChanged(key, value) {
949
+ this.sharedEventBus?.emit({
950
+ type: 'config-changed',
951
+ id: Math.random().toString(36).substring(7),
952
+ timestamp: Date.now(),
953
+ key,
954
+ value,
955
+ });
956
+ }
939
957
  /** Get a nested value from an object using dot notation (e.g. "providers.anthropic.model"). */
940
958
  getNestedValue(obj, path) {
941
959
  const parts = path.split('.');
@@ -1008,6 +1026,7 @@ export class JsonRpcHandler {
1008
1026
  const pricing = pConfig?.model_cost
1009
1027
  ? `\`${pConfig.model_cost}\` (per million)`
1010
1028
  : 'Not configured';
1029
+ const statusBudget = resolveBudget(settings);
1011
1030
  const statusText = `### Curie Agent Status
1012
1031
  * **Version:** \`${VERSION}\`
1013
1032
  * **Active Model:** \`${settings.model}\`
@@ -1015,11 +1034,11 @@ export class JsonRpcHandler {
1015
1034
  * **Approval Mode:** \`${settings.mode || 'auto'}\`
1016
1035
  * **Reasoning Effort:** \`${settings.effort || 'auto'}\`
1017
1036
  * **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\`
1037
+ * **Tools per Turn / Run:** \`${String(settings.tools_per_call || DEFAULT_SETTINGS.tools_per_call)}\` / \`${String(settings.tools_per_run || DEFAULT_SETTINGS.tools_per_run)}\`
1038
+ * **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)}\`
1039
+ * **Model Context Window:** \`${statusBudget.windowTokens.toLocaleString()} tokens\` (\`${statusBudget.usableTokens.toLocaleString()}\` usable, \`${statusBudget.reservedOutput.toLocaleString()}\` reserved for output)
1021
1040
  * **Model Pricing:** ${pricing}
1022
- * **Auto-Compaction:** \`${settings.auto_compact?.enabled || 'on'}\` (Threshold: \`${settings.auto_compact?.threshold ?? 80}%\`)`;
1041
+ * **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
1042
  emitDelta(statusText);
1024
1043
  break;
1025
1044
  }
@@ -1126,13 +1145,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1126
1145
  else {
1127
1146
  settings.theme = theme;
1128
1147
  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
- });
1148
+ this.emitConfigChanged('theme', theme);
1136
1149
  emitDelta(`Successfully switched theme to: **${theme}**`);
1137
1150
  }
1138
1151
  break;
@@ -1149,6 +1162,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1149
1162
  else {
1150
1163
  settings.mode = args.toLowerCase();
1151
1164
  this.settingsManager.update(settings);
1165
+ this.emitConfigChanged('mode', settings.mode);
1152
1166
  emitDelta(`Successfully switched approval mode to: **${args.toLowerCase()}**`);
1153
1167
  }
1154
1168
  break;
@@ -1165,6 +1179,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1165
1179
  else {
1166
1180
  settings.effort = args.toLowerCase();
1167
1181
  this.settingsManager.update(settings);
1182
+ this.emitConfigChanged('effort', settings.effort);
1168
1183
  emitDelta(`Successfully switched reasoning effort to: **${args.toLowerCase()}**`);
1169
1184
  }
1170
1185
  break;
@@ -1192,8 +1207,11 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1192
1207
  settings.websearch_per_call = wsVal;
1193
1208
  }
1194
1209
  }
1195
- this.settingsManager.save();
1196
- emitDelta(`Tool limits updated! Tools per turn: **${settings.tools_per_call}**, WebSearch per turn: **${settings.websearch_per_call ?? 5}**`);
1210
+ this.settingsManager.update({
1211
+ tools_per_call: settings.tools_per_call,
1212
+ websearch_per_call: settings.websearch_per_call,
1213
+ });
1214
+ emitDelta(`Tool limits updated! Tools per turn: **${String(settings.tools_per_call)}**, WebSearch per turn: **${String(settings.websearch_per_call ?? 5)}**`);
1197
1215
  }
1198
1216
  }
1199
1217
  break;
@@ -1212,9 +1230,8 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1212
1230
  emitDelta(`Invalid value: "${args}". Must be a positive integer.`);
1213
1231
  }
1214
1232
  else {
1215
- settings.websearch_per_call = val;
1216
- this.settingsManager.save();
1217
- emitDelta(`WebSearch/WebFetch limit per turn set to: **${val}**`);
1233
+ this.settingsManager.update({ websearch_per_call: val });
1234
+ emitDelta(`WebSearch/WebFetch limit per turn set to: **${String(val)}**`);
1218
1235
  }
1219
1236
  }
1220
1237
  break;
@@ -1343,122 +1360,121 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1343
1360
  const sub = parts[0]?.toLowerCase();
1344
1361
  const arg1 = parts[1]?.toLowerCase();
1345
1362
  const arg2 = parts[2]?.toLowerCase();
1363
+ const d = DEFAULT_SETTINGS.auto_compact;
1364
+ /** Persist through update() — get() returns a deep clone, so mutating it is a no-op. */
1365
+ const saveAutoCompact = (patch) => {
1366
+ this.settingsManager.update({ auto_compact: { ...settings.auto_compact, ...patch } });
1367
+ };
1346
1368
  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
- }
1369
+ const ac = settings.auto_compact ?? d;
1351
1370
  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'}\`
1371
+ emitDelta(`### Auto-Compaction Settings
1357
1372
 
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.`);
1373
+ * **Auto-compact**: \`${ac.enabled}\`
1374
+ * **Warn at**: \`${String(ac.warn_threshold ?? d.warn_threshold)}%\`
1375
+ * **Suggest at**: \`${String(ac.threshold ?? d.threshold)}%\`
1376
+ * **Compact at**: \`${String(ac.forced_threshold ?? d.forced_threshold)}%\`
1377
+ * **Summarizer model**: \`${ac.model || '(active model)'}\`
1378
+ * **Pricing warn**: \`${settings.pricing_tier_warn ?? 'off'}\`
1379
+
1380
+ **Usage**: \`/context auto [on|off|threshold N|warn N|forced N|pricing on/off]\``);
1364
1381
  }
1365
- else if (arg1 === 'off') {
1366
- s.auto_compact.enabled = 'off';
1367
- this.settingsManager.save();
1368
- emitDelta(`Autocompaction disabled.`);
1382
+ else if (arg1 === 'on' || arg1 === 'off') {
1383
+ saveAutoCompact({ enabled: arg1 });
1384
+ emitDelta(`Auto-compaction ${arg1 === 'on' ? 'enabled' : 'disabled'}.`);
1369
1385
  }
1370
- else if (arg1 === 'threshold' && arg2) {
1386
+ else if ((arg1 === 'threshold' || arg1 === 'warn' || arg1 === 'forced') && arg2) {
1371
1387
  const pct = parseInt(arg2, 10);
1372
- if (isNaN(pct) || pct < 10 || pct > 99) {
1373
- emitDelta(`Invalid threshold. Use a value between 10 and 99.`);
1388
+ if (isNaN(pct) || pct < 5 || pct > 99) {
1389
+ emitDelta(`Invalid value. Use a percentage between 5 and 99.`);
1374
1390
  }
1375
1391
  else {
1376
- s.auto_compact.threshold = pct;
1377
- this.settingsManager.save();
1378
- emitDelta(`Compaction threshold set to ${pct}%.`);
1392
+ const key = arg1 === 'threshold' ? 'threshold' : arg1 === 'warn' ? 'warn_threshold' : 'forced_threshold';
1393
+ saveAutoCompact({ [key]: pct });
1394
+ emitDelta(`${arg1 === 'threshold' ? 'Suggest' : arg1 === 'warn' ? 'Warn' : 'Forced compaction'} threshold set to ${String(pct)}%.`);
1379
1395
  }
1380
1396
  }
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
- }
1397
+ else if (arg1 === 'pricing' && (arg2 === 'on' || arg2 === 'off')) {
1398
+ this.settingsManager.update({ pricing_tier_warn: arg2 });
1399
+ emitDelta(`Pricing tier warnings ${arg2 === 'on' ? 'enabled' : 'disabled'}.`);
1406
1400
  }
1407
1401
  else {
1408
- emitDelta(`Usage: \`/context auto [on|off|threshold N|warn N|pricing on/off]\``);
1402
+ emitDelta(`Usage: \`/context auto [on|off|threshold N|warn N|forced N|pricing on/off]\``);
1409
1403
  }
1410
1404
  }
1411
1405
  else if (sub === 'compact') {
1412
- emitDelta(`### ⚡ Manual Compaction Triggered\n\nAnalyzing conversation history and building summary...`);
1406
+ emitDelta(`Compacting conversation history…`);
1413
1407
  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}`);
1408
+ const r = await this.runAutomaticCompaction(sessionId);
1409
+ emitDelta(`\n\n**Compacted.** ${String(r.summarizedMessageCount)} message(s) summarized, ` +
1410
+ `~${formatTokens(r.estimatedTokensBefore)} → ~${formatTokens(r.estimatedTokensAfter)} tokens. ` +
1411
+ `The full transcript is preserved on disk.\n\n**Summary:**\n\n${r.summary}`);
1416
1412
  }
1417
1413
  catch (err) {
1418
- emitDelta(`\n\n**Compaction Failed**: ${err instanceof Error ? err.message : String(err)}`);
1414
+ emitDelta(`\n\n**Compaction failed**: ${err instanceof Error ? err.message : String(err)}`);
1419
1415
  }
1420
1416
  }
1417
+ else if (sub === 'messages') {
1418
+ const messages = reconstructMessagesFromEvents(this.sessionStore.loadEvents(sessionId) || []);
1419
+ if (messages.length === 0) {
1420
+ emitDelta(`No messages in this session yet.`);
1421
+ break;
1422
+ }
1423
+ const lines = [`### Messages (${String(messages.length)})`, '', '| # | Role | Tokens | Detail |', '|---|---|---|---|'];
1424
+ messages.forEach((m, i) => {
1425
+ const tokens = estimateRequestTokens({ messages: [m] });
1426
+ const detail = m.role === 'tool'
1427
+ ? (m.toolName ?? 'tool')
1428
+ : m.role === 'user'
1429
+ ? `${String(m.content).slice(0, 60).replace(/[\n|]/g, ' ')}…`
1430
+ : m.content.map((b) => b.type === 'tool-use' ? `→ ${b.name}` : b.type).join(', ');
1431
+ lines.push(`| ${String(i + 1)} | ${m.role} | ${formatTokens(tokens)} | ${detail} |`);
1432
+ });
1433
+ emitDelta(lines.join('\n'));
1434
+ }
1421
1435
  else {
1436
+ // Emit measurements, not markup. The TUI and the web dashboard each
1437
+ // render this natively — the hand-written HTML this replaces showed
1438
+ // a themed gauge in the browser and nothing at all in the terminal.
1439
+ const budget = resolveBudget(settings);
1422
1440
  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);
1441
+ const model = settings.providers[settings.current_provider]?.model || settings.model;
1442
+ const messages = activeLoop
1443
+ ? activeLoop.getMessages()
1444
+ : reconstructMessagesFromEvents(this.sessionStore.loadEvents(sessionId) || []);
1445
+ const chars = breakdownChars({
1446
+ system: this.systemPrompt,
1447
+ messages,
1448
+ toolDefinitions: this.tools.map((t) => t.definition),
1449
+ });
1450
+ const toTokens = (c) => Math.ceil(c / DEFAULT_CALIBRATION);
1451
+ const breakdown = [
1452
+ { label: 'System prompt', tokens: toTokens(chars.system) },
1453
+ { label: 'Tool definitions', tokens: toTokens(chars.toolDefinitions) },
1454
+ { label: 'Conversation', tokens: toTokens(chars.conversation) },
1455
+ { label: 'Tool results', tokens: toTokens(chars.toolResults) },
1456
+ ];
1457
+ const usedTokens = breakdown.reduce((sum, b) => sum + b.tokens, 0);
1458
+ const report = {
1459
+ type: 'context-report',
1460
+ id: crypto.randomUUID(),
1461
+ model,
1462
+ windowTokens: budget.windowTokens,
1463
+ usedTokens,
1464
+ reservedOutput: budget.reservedOutput,
1465
+ breakdown,
1466
+ timestamp: Date.now(),
1439
1467
  };
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
- }
1468
+ this.sharedEventBus?.emit({ ...report, sessionId });
1469
+ // Plain-text fallback so the command still says something useful on a
1470
+ // surface that has not yet learned the event.
1471
+ const pct = fillPct(usedTokens, budget);
1472
+ const rows = breakdown
1473
+ .map((b) => `* ${b.label}: ${formatTokens(b.tokens)} (${String(usedTokens > 0 ? Math.round((b.tokens / usedTokens) * 100) : 0)}%)`)
1474
+ .join('\n');
1475
+ emitDelta(`### Context Window — \`${model}\`\n\n` +
1476
+ `**${String(pct)}%** — ${formatTokens(usedTokens)} of ${formatTokens(budget.usableTokens)} usable ` +
1477
+ `(${formatTokens(budget.windowTokens)} window, ${formatTokens(budget.reservedOutput)} reserved for output)\n\n${rows}`);
1462
1478
  }
1463
1479
  break;
1464
1480
  }
@@ -1476,7 +1492,8 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1476
1492
  emitDelta(`Failed to parse reminder time. Please format like: \`in 2 hours call developer\` or \`tomorrow at 9:00 am meeting\`.`);
1477
1493
  break;
1478
1494
  }
1479
- const task = this.daemonApp.taskManager.create({ title: parsed.message, mode: 'notify', scope: 'personal', scheduled_at: parsed.scheduledAt });
1495
+ this.daemonApp.taskManager.load();
1496
+ this.daemonApp.taskManager.create({ title: parsed.message, mode: 'notify', scope: 'personal', scheduled_at: parsed.scheduledAt });
1480
1497
  emitDelta(`Reminder scheduled! **"${parsed.message}"** at ${new Date(parsed.scheduledAt).toLocaleString()}`);
1481
1498
  break;
1482
1499
  }
@@ -1562,7 +1579,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1562
1579
  }
1563
1580
  case 'enable': {
1564
1581
  settings.heartbeat.schedule = 'on';
1565
- this.settingsManager.save();
1582
+ this.settingsManager.update({ heartbeat: settings.heartbeat });
1566
1583
  if (this.daemonApp) {
1567
1584
  this.daemonApp.taskManager.rescheduleFromSettings({
1568
1585
  HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
@@ -1577,7 +1594,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1577
1594
  }
1578
1595
  case 'disable': {
1579
1596
  settings.heartbeat.schedule = 'off';
1580
- this.settingsManager.save();
1597
+ this.settingsManager.update({ heartbeat: settings.heartbeat });
1581
1598
  if (this.daemonApp) {
1582
1599
  this.daemonApp.taskManager.cancelAllHeartbeats();
1583
1600
  }
@@ -1605,7 +1622,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1605
1622
  }
1606
1623
  else {
1607
1624
  settings.heartbeat.intraday = tokens.join(',');
1608
- this.settingsManager.save();
1625
+ this.settingsManager.update({ heartbeat: settings.heartbeat });
1609
1626
  if (this.daemonApp && settings.heartbeat.schedule === 'on') {
1610
1627
  this.daemonApp.taskManager.rescheduleFromSettings({
1611
1628
  HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
@@ -1633,7 +1650,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1633
1650
  }
1634
1651
  else {
1635
1652
  settings.heartbeat.daily = rest;
1636
- this.settingsManager.save();
1653
+ this.settingsManager.update({ heartbeat: settings.heartbeat });
1637
1654
  if (this.daemonApp && settings.heartbeat.schedule === 'on') {
1638
1655
  this.daemonApp.taskManager.rescheduleFromSettings({
1639
1656
  HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
@@ -1662,7 +1679,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1662
1679
  }
1663
1680
  else {
1664
1681
  settings.heartbeat.weekly = rest;
1665
- this.settingsManager.save();
1682
+ this.settingsManager.update({ heartbeat: settings.heartbeat });
1666
1683
  if (this.daemonApp && settings.heartbeat.schedule === 'on') {
1667
1684
  this.daemonApp.taskManager.rescheduleFromSettings({
1668
1685
  HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
@@ -1690,7 +1707,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1690
1707
  }
1691
1708
  else {
1692
1709
  settings.heartbeat.monthly = rest;
1693
- this.settingsManager.save();
1710
+ this.settingsManager.update({ heartbeat: settings.heartbeat });
1694
1711
  if (this.daemonApp && settings.heartbeat.schedule === 'on') {
1695
1712
  this.daemonApp.taskManager.rescheduleFromSettings({
1696
1713
  HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
@@ -1718,7 +1735,7 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
1718
1735
  }
1719
1736
  else {
1720
1737
  settings.heartbeat.dreaming = rest;
1721
- this.settingsManager.save();
1738
+ this.settingsManager.update({ heartbeat: settings.heartbeat });
1722
1739
  if (this.daemonApp && settings.heartbeat.schedule === 'on') {
1723
1740
  this.daemonApp.taskManager.rescheduleFromSettings({
1724
1741
  HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
@@ -2214,129 +2231,50 @@ Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
2214
2231
  }
2215
2232
  return true;
2216
2233
  }
2217
- async runAutomaticCompaction(sessionId, depth) {
2234
+ /**
2235
+ * Compact a session's history.
2236
+ *
2237
+ * The summarization itself lives in `@curie-agent/core` so the TurnLoop can
2238
+ * run it mid-run — this is the out-of-band entry point for `/context compact`
2239
+ * on an idle session.
2240
+ *
2241
+ * Nothing is deleted. A `compaction` marker is appended and message
2242
+ * reconstruction replays forward from it, so the full transcript survives for
2243
+ * the UI, resume and audit while the model carries only the summary.
2244
+ */
2245
+ async runAutomaticCompaction(sessionId) {
2218
2246
  if (!this.createProvider) {
2219
2247
  throw new Error('No provider configured');
2220
2248
  }
2221
2249
  const settings = this.settingsManager.get();
2222
2250
  const provider = this.createProvider(settings);
2223
- const model = settings.providers[settings.current_provider]?.model || settings.model;
2251
+ const activeModel = settings.providers[settings.current_provider]?.model || settings.model;
2224
2252
  const events = this.sessionStore.loadEvents(sessionId) || [];
2225
2253
  if (events.length === 0) {
2226
2254
  throw new Error('No events to compact');
2227
2255
  }
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()) {
2256
+ const messages = reconstructMessagesFromEvents(events);
2257
+ if (messages.length === 0) {
2246
2258
  throw new Error('No conversational history found to compact');
2247
2259
  }
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
- }
2260
+ const result = await compactMessages({
2261
+ messages,
2262
+ provider: provider,
2263
+ model: settings.auto_compact?.model || activeModel,
2264
+ budget: resolveBudget(settings),
2265
+ });
2266
+ const marker = {
2267
+ type: 'compaction',
2268
+ id: crypto.randomUUID(),
2269
+ summary: result.summary,
2270
+ summarizedMessageCount: result.summarizedMessageCount,
2271
+ tokensBefore: result.estimatedTokensBefore,
2272
+ tokensAfter: result.estimatedTokensAfter,
2273
+ timestamp: Date.now(),
2274
+ };
2275
+ this.sessionStore.appendEvent(sessionId, { ...marker, sessionId });
2276
+ this.sharedEventBus?.emit({ ...marker, sessionId });
2277
+ return result;
2340
2278
  }
2341
2279
  }
2342
2280
  //# sourceMappingURL=jsonrpc-handler.js.map