moderado 0.3.4 → 0.3.8

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.
Files changed (115) hide show
  1. package/README.md +215 -215
  2. package/dist/args.js +29 -29
  3. package/dist/budget.d.ts +6 -0
  4. package/dist/budget.d.ts.map +1 -0
  5. package/dist/budget.js +25 -0
  6. package/dist/budget.js.map +1 -0
  7. package/dist/chat_mode.d.ts +14 -0
  8. package/dist/chat_mode.d.ts.map +1 -0
  9. package/dist/chat_mode.js +13 -0
  10. package/dist/chat_mode.js.map +1 -0
  11. package/dist/commands/chat.d.ts +5 -1
  12. package/dist/commands/chat.d.ts.map +1 -1
  13. package/dist/commands/chat.js +172 -43
  14. package/dist/commands/chat.js.map +1 -1
  15. package/dist/commands/host.d.ts +4 -0
  16. package/dist/commands/host.d.ts.map +1 -0
  17. package/dist/commands/host.js +30 -0
  18. package/dist/commands/host.js.map +1 -0
  19. package/dist/commands/models.d.ts.map +1 -1
  20. package/dist/commands/models.js +63 -16
  21. package/dist/commands/models.js.map +1 -1
  22. package/dist/config.d.ts +73 -0
  23. package/dist/config.d.ts.map +1 -1
  24. package/dist/config.js +116 -0
  25. package/dist/config.js.map +1 -1
  26. package/dist/host/approval_queue.d.ts +9 -0
  27. package/dist/host/approval_queue.d.ts.map +1 -0
  28. package/dist/host/approval_queue.js +63 -0
  29. package/dist/host/approval_queue.js.map +1 -0
  30. package/dist/host/auto_approve.d.ts +22 -0
  31. package/dist/host/auto_approve.d.ts.map +1 -0
  32. package/dist/host/auto_approve.js +36 -0
  33. package/dist/host/auto_approve.js.map +1 -0
  34. package/dist/host/ndjson.d.ts +3 -0
  35. package/dist/host/ndjson.d.ts.map +1 -0
  36. package/dist/host/ndjson.js +68 -0
  37. package/dist/host/ndjson.js.map +1 -0
  38. package/dist/host/protocol_server.d.ts +9 -0
  39. package/dist/host/protocol_server.d.ts.map +1 -0
  40. package/dist/host/protocol_server.js +101 -0
  41. package/dist/host/protocol_server.js.map +1 -0
  42. package/dist/host/runtime.d.ts +30 -0
  43. package/dist/host/runtime.d.ts.map +1 -0
  44. package/dist/host/runtime.js +603 -0
  45. package/dist/host/runtime.js.map +1 -0
  46. package/dist/model_pricing.d.ts +14 -1
  47. package/dist/model_pricing.d.ts.map +1 -1
  48. package/dist/model_pricing.js +52 -0
  49. package/dist/model_pricing.js.map +1 -1
  50. package/dist/repo_map.d.ts +3 -0
  51. package/dist/repo_map.d.ts.map +1 -0
  52. package/dist/repo_map.js +29 -0
  53. package/dist/repo_map.js.map +1 -0
  54. package/dist/sessions.d.ts +22 -15
  55. package/dist/sessions.d.ts.map +1 -1
  56. package/dist/sessions.js +16 -1
  57. package/dist/sessions.js.map +1 -1
  58. package/dist/skills.d.ts +2 -2
  59. package/dist/skills.js +2 -2
  60. package/dist/ui/model_selector.d.ts +2 -2
  61. package/dist/ui/model_selector.d.ts.map +1 -1
  62. package/dist/ui/model_selector.js +25 -16
  63. package/dist/ui/model_selector.js.map +1 -1
  64. package/dist/ui/provider_connect.d.ts +6 -3
  65. package/dist/ui/provider_connect.d.ts.map +1 -1
  66. package/dist/ui/provider_connect.js +41 -14
  67. package/dist/ui/provider_connect.js.map +1 -1
  68. package/dist/ui/renderer.d.ts +4 -0
  69. package/dist/ui/renderer.d.ts.map +1 -1
  70. package/dist/ui/renderer.js +36 -0
  71. package/dist/ui/renderer.js.map +1 -1
  72. package/dist/ui/session_screen.d.ts +32 -0
  73. package/dist/ui/session_screen.d.ts.map +1 -0
  74. package/dist/ui/session_screen.js +148 -0
  75. package/dist/ui/session_screen.js.map +1 -0
  76. package/dist/ui/session_terminal.d.ts +46 -0
  77. package/dist/ui/session_terminal.d.ts.map +1 -0
  78. package/dist/ui/session_terminal.js +342 -0
  79. package/dist/ui/session_terminal.js.map +1 -0
  80. package/dist/ui/welcome.d.ts +11 -0
  81. package/dist/ui/welcome.d.ts.map +1 -1
  82. package/dist/ui/welcome.js +49 -32
  83. package/dist/ui/welcome.js.map +1 -1
  84. package/dist/vendor/contracts/events.d.ts +159 -0
  85. package/dist/vendor/contracts/events.d.ts.map +1 -1
  86. package/dist/vendor/contracts/events.js +12 -0
  87. package/dist/vendor/contracts/events.js.map +1 -1
  88. package/dist/vendor/contracts/host_protocol.d.ts +3545 -0
  89. package/dist/vendor/contracts/host_protocol.d.ts.map +1 -0
  90. package/dist/vendor/contracts/host_protocol.js +292 -0
  91. package/dist/vendor/contracts/host_protocol.js.map +1 -0
  92. package/dist/vendor/contracts/provider.d.ts +1 -0
  93. package/dist/vendor/contracts/provider.d.ts.map +1 -1
  94. package/dist/vendor/contracts/provider.js +6 -0
  95. package/dist/vendor/contracts/provider.js.map +1 -1
  96. package/dist/vendor/core/agent.d.ts +2 -1
  97. package/dist/vendor/core/agent.d.ts.map +1 -1
  98. package/dist/vendor/core/agent.js +145 -31
  99. package/dist/vendor/core/agent.js.map +1 -1
  100. package/dist/vendor/providers/model_discovery.d.ts +2 -2
  101. package/dist/vendor/providers/model_discovery.d.ts.map +1 -1
  102. package/dist/vendor/providers/model_discovery.js +4 -2
  103. package/dist/vendor/providers/model_discovery.js.map +1 -1
  104. package/dist/vendor/providers/nvidia/nvidia_adapter.d.ts.map +1 -1
  105. package/dist/vendor/providers/nvidia/nvidia_adapter.js +1 -0
  106. package/dist/vendor/providers/nvidia/nvidia_adapter.js.map +1 -1
  107. package/dist/vendor/providers/nvidia/sse_parser.d.ts +2 -1
  108. package/dist/vendor/providers/nvidia/sse_parser.d.ts.map +1 -1
  109. package/dist/vendor/providers/nvidia/sse_parser.js +30 -16
  110. package/dist/vendor/providers/nvidia/sse_parser.js.map +1 -1
  111. package/dist/web_search_route.d.ts +16 -0
  112. package/dist/web_search_route.d.ts.map +1 -0
  113. package/dist/web_search_route.js +60 -0
  114. package/dist/web_search_route.js.map +1 -0
  115. package/package.json +28 -28
@@ -1,8 +1,9 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs';
3
+ import readline from 'node:readline';
3
4
  import { AgentLoop, PolicyManager, Router, cleanConversationalFiller, DEFAULT_MAX_OUTPUT_TOKENS } from '../vendor/core/index.js';
4
5
  import { NvidiaAdapter } from '../vendor/providers/index.js';
5
- import { createDefaultToolRegistry, canonicalizeRoot, createMcpTools, createWebSearchTool, discoverMcpServers, resolveInJail, WorkspaceCheckpointStore, WriteFileTool } from '../vendor/tools/index.js';
6
+ import { createDefaultToolRegistry, ToolRegistry, canonicalizeRoot, createMcpTools, createWebSearchTool, discoverMcpServers, resolveInJail, WorkspaceCheckpointStore, WriteFileTool } from '../vendor/tools/index.js';
6
7
  import { McpServerConfigSchema } from '../vendor/contracts/index.js';
7
8
  import { getActiveConnection, loadConfig, removeMcpServer, resolveApiKey, resolveConnectionCredential, saveConnection, saveMcpServer, setMcpServerEnabled, storeConnectionCredential } from '../config.js';
8
9
  import { MemoryCredentialStore } from '../credentials.js';
@@ -13,13 +14,29 @@ import { connectProviderInteractive, isAuthenticationFailure, replaceProviderKey
13
14
  import { initWorkspace } from './init.js';
14
15
  import { expandMentions, createWorkspaceFileSource } from '../ui/file_mentions.js';
15
16
  import { listFiles } from '../vendor/tools/index.js';
16
- import { exitCleanly, promptInteractiveTurn, renderChatAnswerDelta, renderChatComposerCursor, renderChatThoughtTimeUpdate, renderFullWelcomeScreen, renderWelcomePopupLayer, terminalCleanExitDone, wrapText } from '../ui/welcome.js';
17
- import { calculateOutputTokenRate, calculateSessionCost, compactSessionMessages, createSession, exportSessionMarkdown, formatSessionCost, SessionStore } from '../sessions.js';
17
+ import { exitCleanly, promptInteractiveTurn, renderChatAnswerDelta, renderChatComposerCursor, renderChatThoughtTimeUpdate, renderChatUsageUpdate, renderFullWelcomeScreen, renderWelcomePopupLayer, terminalCleanExitDone, wrapText } from '../ui/welcome.js';
18
+ import { accumulateSessionUsage, compactSessionMessages, createSession, exportSessionMarkdown, formatSessionCost, SessionStore } from '../sessions.js';
18
19
  import { layerPromptBox, renderBoxLines, selectConfirmPopup, selectListPopup } from '../ui/popup.js';
19
20
  import { askModalChoice } from '../ui/prompt.js';
20
21
  import { findModelPricing } from '../model_pricing.js';
21
22
  import { inspectGitWorkspace, readGitDiff } from '../vendor/tools/index.js';
23
+ import { buildWorkspaceMap } from '../repo_map.js';
24
+ import { evaluateTokenBudget, parseBudgetCommand } from '../budget.js';
22
25
  import { discoverSkills, formatSkillContext } from '../skills.js';
26
+ export function formatSessionExitSummary(session, modelId) {
27
+ const usage = session.usage;
28
+ const tokens = usage.available
29
+ ? `Input ${usage.promptTokens.toLocaleString('en-US')} | Output ${usage.completionTokens.toLocaleString('en-US')} | Total ${usage.totalTokens.toLocaleString('en-US')}${usage.estimated ? ' (estimated)' : ''}`
30
+ : 'Usage unavailable';
31
+ return [
32
+ '\x1b[1;38;5;25mSession Summary\x1b[0m',
33
+ `ID: ${session.id}`,
34
+ `Model: ${modelId ?? session.modelId ?? 'No model selected'}`,
35
+ `Tokens: ${tokens}`,
36
+ `Cost: ${formatSessionCost(usage)}`,
37
+ 'Resume: /session resume',
38
+ ].join('\n');
39
+ }
23
40
  function mutationPaths(toolName, parameters) {
24
41
  const value = parameters;
25
42
  if (toolName === 'apply_patch' && Array.isArray(value.edits)) {
@@ -32,6 +49,19 @@ export function createAgentTask(request, mode) {
32
49
  return request;
33
50
  return `Create a concise implementation checklist for this request. Inspect files as needed, but do not modify files or run commands.\n\nUser request:\n${request}`;
34
51
  }
52
+ export function createReviewTask(changedFiles) {
53
+ return `Review the current workspace changes. Inspect staged and unstaged Git diffs with git_diff, then inspect relevant surrounding code. Also review untracked files listed below with read_file. Do not modify files or run commands. Report only actionable bugs or security issues, ordered by severity, with file paths and line numbers. If none are found, say "No findings". Keep the report concise.\n\nChanged files:\n${changedFiles.join('\n') || '(none)'}`;
54
+ }
55
+ export function createReviewToolRegistry() {
56
+ const source = createDefaultToolRegistry();
57
+ const registry = new ToolRegistry();
58
+ for (const name of ['git_diff', 'read_file', 'list_files', 'search_files', 'get_definition', 'find_references']) {
59
+ const tool = source.get(name);
60
+ if (tool)
61
+ registry.register(tool);
62
+ }
63
+ return registry;
64
+ }
35
65
  export const STANDARD_SLASH_COMMANDS = [
36
66
  '/connect',
37
67
  '/model',
@@ -41,6 +71,9 @@ export const STANDARD_SLASH_COMMANDS = [
41
71
  '/session',
42
72
  '/queue',
43
73
  '/workflow',
74
+ '/review',
75
+ '/map',
76
+ '/budget',
44
77
  '/skills',
45
78
  '/clear',
46
79
  '/help',
@@ -139,6 +172,10 @@ export function formatTurnFailureAnswer(status, errorEvent, modelId, otherConnec
139
172
  return `⚠️ **Model Error (${errorEvent?.code || 'ERR_INFERENCE_FAILED'}):**\n${cleanDetail || 'Model returned no response.'}\n\n👉 **Suggestion:** The model \`${modelId}\` encountered an error. Switch models with \`/model\` or switch providers with \`/connect\`${altSuggestion}.`;
140
173
  }
141
174
  export function resolveTurnAssistantAnswer(result, previousHistoryLength, lastErrorEvent, streamedAnswer = '', currentModel = 'unknown', otherConnections = []) {
175
+ if (result.status === 'cancelled') {
176
+ const partial = cleanConversationalFiller(streamedAnswer);
177
+ return { answer: [partial, 'Task stopped.'].filter(Boolean).join('\n\n'), isError: true };
178
+ }
142
179
  if (result.status === 'failed' || lastErrorEvent) {
143
180
  return {
144
181
  answer: formatTurnFailureAnswer(result.status, lastErrorEvent, result.selectedModel?.id || currentModel, otherConnections),
@@ -216,15 +253,9 @@ export function handleGenerationKeypress(str, key, queue, actions) {
216
253
  actions.abort();
217
254
  return;
218
255
  }
219
- // 2. Escape: if draft has text, clear draft; if draft is empty, abort generation
256
+ // 2. Escape always stops the active task; the caller clears queued input.
220
257
  if (isGenerationCancelKey(key, str)) {
221
- if (queue.currentDraft.length > 0) {
222
- queue.setDraft('');
223
- actions.onDraftChange();
224
- }
225
- else {
226
- actions.abort();
227
- }
258
+ actions.abort();
228
259
  return;
229
260
  }
230
261
  // 3. Return / Enter: commit draft or execute /btw immediately
@@ -422,9 +453,6 @@ export async function decideApproval(request, options, signal) {
422
453
  if (options.autoApprove && !request.toolName.startsWith('mcp.')) {
423
454
  return { requestId: request.requestId, status: 'approved' };
424
455
  }
425
- if (['write_file', 'edit_file', 'apply_patch', 'run_command', 'run_diagnostics'].includes(request.toolName)) {
426
- return { requestId: request.requestId, status: 'approved' };
427
- }
428
456
  return options.requestInteractiveApproval(request, signal);
429
457
  }
430
458
  export async function createMcpToolRegistry(servers, webSearch = {}) {
@@ -697,7 +725,8 @@ export async function handleChatSession(args, version, signal) {
697
725
  if (activeConnection)
698
726
  activateConnection(activeConnection, args.model);
699
727
  let activeMode = args.readOnly ? 'Plan' : 'Execute';
700
- let activeAutoApprove = Boolean(args.autoApprove);
728
+ let activeAutoApprove = !args.nonInteractive;
729
+ let tokenBudget;
701
730
  const sessionStore = new SessionStore();
702
731
  let activeSession = sessionStore.loadLatestSession(canonicalWorkspace) ??
703
732
  createSession(canonicalWorkspace, {
@@ -742,8 +771,10 @@ export async function handleChatSession(args, version, signal) {
742
771
  let lastAnswer = '';
743
772
  let lastThoughtTime = 0;
744
773
  let lastOutputTokenRate;
774
+ let lastTokenUsage;
745
775
  let activePlan;
746
776
  const costLabel = () => formatSessionCost(activeSession.usage);
777
+ const exitMessage = () => `\x1b[32mGoodbye! Stay Tuned with Moderado!\x1b[0m\n\n${formatSessionExitSummary(activeSession, currentModel)}`;
747
778
  const commandQueue = new TurnCommandQueue();
748
779
  const btwHistory = [];
749
780
  while (!signal?.aborted) {
@@ -755,8 +786,9 @@ export async function handleChatSession(args, version, signal) {
755
786
  else {
756
787
  const turn = await promptInteractiveTurn({
757
788
  model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(), workspace: canonicalWorkspace, version,
758
- usageAvailable: activeSession.usage.available, initialMode: activeMode, initialAutoApprove: activeAutoApprove, isFirstTurn: isFirst, signal, chatQuestion: lastQuestion || undefined, chatAnswer: lastAnswer || undefined, chatThoughtTime: lastThoughtTime, outputTokenRate: lastOutputTokenRate,
789
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, initialMode: activeMode, initialAutoApprove: activeAutoApprove, isFirstTurn: isFirst, signal, chatQuestion: lastQuestion || undefined, chatAnswer: lastAnswer || undefined, chatThoughtTime: lastThoughtTime, outputTokenRate: lastOutputTokenRate, tokenUsage: lastTokenUsage,
759
790
  queuedCommands: commandQueue.items,
791
+ onExit: exitMessage,
760
792
  questionHistory: conversationHistory.flatMap((message) => message.role === 'user' && message.content?.trim() ? [message.content] : []),
761
793
  onModelSelect: async (drawFrame) => {
762
794
  if (!activeConnection) {
@@ -770,7 +802,6 @@ export async function handleChatSession(args, version, signal) {
770
802
  providerId: activeConnection.id,
771
803
  providerName: activeConnection.displayName,
772
804
  currentModel,
773
- allModelsFree: activeConnection.id === 'agnes-ai',
774
805
  signal,
775
806
  drawFrame,
776
807
  });
@@ -792,7 +823,7 @@ export async function handleChatSession(args, version, signal) {
792
823
  return selection.modelId;
793
824
  },
794
825
  onConnect: async (drawFrame) => {
795
- const connection = await connectProviderInteractive({ signal, drawFrame, savedConnections: config.connections, resolveSavedConnection: (saved) => resolveConnectionCredential(saved, credentialStore) });
826
+ const connection = await connectProviderInteractive({ signal, drawFrame, savedConnections: config.connections, connectProviders: config.connectProviders, resolveSavedConnection: (saved) => resolveConnectionCredential(saved, credentialStore) });
796
827
  if (!connection)
797
828
  return currentModel;
798
829
  const runtimeConnection = process.platform === 'win32' ? await storeConnectionCredential(connection, credentialStore) : connection;
@@ -808,6 +839,7 @@ export async function handleChatSession(args, version, signal) {
808
839
  lastAnswer = '';
809
840
  lastThoughtTime = 0;
810
841
  lastOutputTokenRate = undefined;
842
+ lastTokenUsage = undefined;
811
843
  activeSession.messages = [];
812
844
  sessionStore.save(activeSession);
813
845
  },
@@ -926,6 +958,7 @@ export async function handleChatSession(args, version, signal) {
926
958
  lastAnswer = '';
927
959
  lastThoughtTime = 0;
928
960
  lastOutputTokenRate = undefined;
961
+ lastTokenUsage = undefined;
929
962
  sessionStore.save(activeSession);
930
963
  return;
931
964
  }
@@ -1026,11 +1059,46 @@ export async function handleChatSession(args, version, signal) {
1026
1059
  continue;
1027
1060
  }
1028
1061
  }
1062
+ if (trimmed === '/budget' || trimmed.startsWith('/budget ')) {
1063
+ const budget = parseBudgetCommand(trimmed, tokenBudget);
1064
+ tokenBudget = budget.limit;
1065
+ lastQuestion = trimmed;
1066
+ lastAnswer = budget.message;
1067
+ lastThoughtTime = 0.001;
1068
+ lastOutputTokenRate = undefined;
1069
+ lastTokenUsage = undefined;
1070
+ process.stdout.write('\x1b[H\x1b[J');
1071
+ process.stdout.write(renderFullWelcomeScreen({
1072
+ model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1073
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1074
+ autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1075
+ chatThoughtTime: lastThoughtTime, queuedCommands: commandQueue.items,
1076
+ }, process.stdout.rows));
1077
+ process.stdout.write(renderChatComposerCursor({ width: process.stdout.columns }, 0));
1078
+ continue;
1079
+ }
1080
+ if (trimmed === '/map') {
1081
+ lastQuestion = trimmed;
1082
+ lastAnswer = await buildWorkspaceMap(canonicalWorkspace);
1083
+ lastThoughtTime = 0.001;
1084
+ lastOutputTokenRate = undefined;
1085
+ lastTokenUsage = undefined;
1086
+ process.stdout.write('\x1b[H\x1b[J');
1087
+ process.stdout.write(renderFullWelcomeScreen({
1088
+ model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1089
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1090
+ autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1091
+ chatThoughtTime: lastThoughtTime, queuedCommands: commandQueue.items,
1092
+ }, process.stdout.rows));
1093
+ process.stdout.write(renderChatComposerCursor({ width: process.stdout.columns }, 0));
1094
+ continue;
1095
+ }
1029
1096
  if (trimmed.startsWith('/queue')) {
1030
1097
  const args = trimmed.slice('/queue'.length).trim();
1031
1098
  lastQuestion = trimmed;
1032
1099
  lastThoughtTime = 0.001;
1033
1100
  lastOutputTokenRate = undefined;
1101
+ lastTokenUsage = undefined;
1034
1102
  if (!args || args === 'list') {
1035
1103
  lastAnswer = commandQueue.length === 0
1036
1104
  ? 'Command queue is empty. Use `/queue <command>` to add commands.'
@@ -1053,7 +1121,7 @@ export async function handleChatSession(args, version, signal) {
1053
1121
  process.stdout.write('\x1b[H\x1b[J');
1054
1122
  process.stdout.write(renderFullWelcomeScreen({
1055
1123
  model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1056
- usageAvailable: activeSession.usage.available, workspace: canonicalWorkspace, mode: activeMode,
1124
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1057
1125
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1058
1126
  chatThoughtTime: lastThoughtTime,
1059
1127
  queuedCommands: commandQueue.items,
@@ -1068,6 +1136,7 @@ export async function handleChatSession(args, version, signal) {
1068
1136
  lastAnswer = '';
1069
1137
  lastThoughtTime = 0;
1070
1138
  lastOutputTokenRate = undefined;
1139
+ lastTokenUsage = undefined;
1071
1140
  activeSession.messages = [];
1072
1141
  sessionStore.save(activeSession);
1073
1142
  continue;
@@ -1077,6 +1146,7 @@ export async function handleChatSession(args, version, signal) {
1077
1146
  lastAnswer = 'To leave Moderado, type /exit.';
1078
1147
  lastThoughtTime = 0;
1079
1148
  lastOutputTokenRate = undefined;
1149
+ lastTokenUsage = undefined;
1080
1150
  continue;
1081
1151
  }
1082
1152
  // Non-standard or misspelled slash command rejection with advice
@@ -1086,10 +1156,11 @@ export async function handleChatSession(args, version, signal) {
1086
1156
  lastAnswer = slashAdvice.advice;
1087
1157
  lastThoughtTime = 0.001;
1088
1158
  lastOutputTokenRate = undefined;
1159
+ lastTokenUsage = undefined;
1089
1160
  process.stdout.write('\x1b[H\x1b[J');
1090
1161
  process.stdout.write(renderFullWelcomeScreen({
1091
1162
  model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1092
- usageAvailable: activeSession.usage.available, workspace: canonicalWorkspace, mode: activeMode,
1163
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1093
1164
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1094
1165
  chatThoughtTime: lastThoughtTime,
1095
1166
  queuedCommands: commandQueue.items,
@@ -1105,13 +1176,14 @@ export async function handleChatSession(args, version, signal) {
1105
1176
  : 'No valid user skills found in ~/.moderado/skills.';
1106
1177
  lastThoughtTime = 0.001;
1107
1178
  lastOutputTokenRate = undefined;
1179
+ lastTokenUsage = undefined;
1108
1180
  process.stdout.write('\x1b[H\x1b[J');
1109
- process.stdout.write(renderFullWelcomeScreen({ model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(), usageAvailable: activeSession.usage.available, workspace: canonicalWorkspace, mode: activeMode, autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer, chatThoughtTime: lastThoughtTime, queuedCommands: commandQueue.items }, process.stdout.rows));
1181
+ process.stdout.write(renderFullWelcomeScreen({ model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(), usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode, autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer, chatThoughtTime: lastThoughtTime, queuedCommands: commandQueue.items }, process.stdout.rows));
1110
1182
  process.stdout.write(renderChatComposerCursor({ width: process.stdout.columns }, 0));
1111
1183
  continue;
1112
1184
  }
1113
1185
  if (trimmed === '/exit' || trimmed === '/quit') {
1114
- exitCleanly('\x1b[32mGoodbye! Stay Tuned with Moderado!\x1b[0m');
1186
+ exitCleanly(exitMessage());
1115
1187
  }
1116
1188
  if (trimmed === '/help') {
1117
1189
  lastQuestion = trimmed;
@@ -1123,16 +1195,20 @@ export async function handleChatSession(args, version, signal) {
1123
1195
  ' /session - Create, resume, undo, redo, share, export, or compact sessions\n' +
1124
1196
  ' /queue - Add, inspect, or clear queued follow-up commands\n' +
1125
1197
  ' /workflow - Inspect Git, build plans, or undo agent changes\n' +
1198
+ ' /review - Review current workspace changes for bugs\n' +
1199
+ ' /map - Show a compact workspace file map\n' +
1200
+ ' /budget - Set a per-task token limit (/budget 5000, /budget off)\n' +
1126
1201
  ' /skills - List installed user skills and reload them\n' +
1127
1202
  ' /clear - Reset conversation memory\n' +
1128
1203
  ' /help - Display commands, shortcuts & version\n' +
1129
1204
  ' /exit - Exit Moderado';
1130
1205
  lastThoughtTime = 0.001;
1131
1206
  lastOutputTokenRate = undefined;
1207
+ lastTokenUsage = undefined;
1132
1208
  process.stdout.write('\x1b[H\x1b[J');
1133
1209
  process.stdout.write(renderFullWelcomeScreen({
1134
1210
  model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1135
- usageAvailable: activeSession.usage.available, workspace: canonicalWorkspace, mode: activeMode,
1211
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1136
1212
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1137
1213
  chatThoughtTime: lastThoughtTime,
1138
1214
  queuedCommands: commandQueue.items,
@@ -1158,13 +1234,14 @@ export async function handleChatSession(args, version, signal) {
1158
1234
  lastAnswer = localMetaAnswer;
1159
1235
  lastThoughtTime = 0.001;
1160
1236
  lastOutputTokenRate = undefined;
1237
+ lastTokenUsage = undefined;
1161
1238
  conversationHistory = [...conversationHistory, { role: 'user', content: trimmed }, { role: 'assistant', content: localMetaAnswer }];
1162
1239
  activeSession.messages = conversationHistory;
1163
1240
  sessionStore.save(activeSession);
1164
1241
  process.stdout.write('\x1b[H\x1b[J');
1165
1242
  process.stdout.write(renderFullWelcomeScreen({
1166
1243
  model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1167
- usageAvailable: activeSession.usage.available, workspace: canonicalWorkspace, mode: activeMode,
1244
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1168
1245
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1169
1246
  chatThoughtTime: lastThoughtTime,
1170
1247
  queuedCommands: commandQueue.items,
@@ -1177,13 +1254,14 @@ export async function handleChatSession(args, version, signal) {
1177
1254
  lastQuestion = trimmed;
1178
1255
  lastThoughtTime = 0.001;
1179
1256
  lastOutputTokenRate = undefined;
1257
+ lastTokenUsage = undefined;
1180
1258
  conversationHistory = [...conversationHistory, { role: 'user', content: trimmed }, { role: 'assistant', content: lastAnswer }];
1181
1259
  activeSession.messages = conversationHistory;
1182
1260
  sessionStore.save(activeSession);
1183
1261
  process.stdout.write('\x1b[H\x1b[J');
1184
1262
  process.stdout.write(renderFullWelcomeScreen({
1185
1263
  model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1186
- usageAvailable: activeSession.usage.available, workspace: canonicalWorkspace, mode: activeMode,
1264
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1187
1265
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1188
1266
  chatThoughtTime: lastThoughtTime,
1189
1267
  queuedCommands: commandQueue.items,
@@ -1200,10 +1278,11 @@ export async function handleChatSession(args, version, signal) {
1200
1278
  lastAnswer = 'Searching the web...';
1201
1279
  lastThoughtTime = 0;
1202
1280
  lastOutputTokenRate = undefined;
1281
+ lastTokenUsage = undefined;
1203
1282
  process.stdout.write('\x1b[H\x1b[J');
1204
1283
  process.stdout.write(renderFullWelcomeScreen({
1205
1284
  model: currentModel ?? 'Web search', tokens: Math.round(sessionTokens), cost: costLabel(),
1206
- usageAvailable: activeSession.usage.available, workspace: canonicalWorkspace, mode: activeMode,
1285
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1207
1286
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1208
1287
  }, process.stdout.rows));
1209
1288
  process.stdout.write(renderChatComposerCursor({ width: process.stdout.columns }, 0));
@@ -1224,7 +1303,7 @@ export async function handleChatSession(args, version, signal) {
1224
1303
  process.stdout.write('\x1b[H\x1b[J');
1225
1304
  process.stdout.write(renderFullWelcomeScreen({
1226
1305
  model: currentModel ?? 'Web search', tokens: Math.round(sessionTokens), cost: costLabel(),
1227
- usageAvailable: activeSession.usage.available, workspace: canonicalWorkspace, mode: activeMode,
1306
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1228
1307
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1229
1308
  chatThoughtTime: lastThoughtTime,
1230
1309
  }, process.stdout.rows));
@@ -1234,7 +1313,7 @@ export async function handleChatSession(args, version, signal) {
1234
1313
  }
1235
1314
  if (!provider) {
1236
1315
  process.stdout.write('\nNo provider is connected. Let\'s connect one before sending this task.\n');
1237
- const connection = await connectProviderInteractive({ signal, savedConnections: config.connections, resolveSavedConnection: (saved) => resolveConnectionCredential(saved, credentialStore) });
1316
+ const connection = await connectProviderInteractive({ signal, savedConnections: config.connections, connectProviders: config.connectProviders, resolveSavedConnection: (saved) => resolveConnectionCredential(saved, credentialStore) });
1238
1317
  if (!connection) {
1239
1318
  process.stdout.write('No provider connected. Use /connect whenever you are ready.\n\n');
1240
1319
  continue;
@@ -1244,10 +1323,22 @@ export async function handleChatSession(args, version, signal) {
1244
1323
  config = loadConfig();
1245
1324
  activateConnection(runtimeConnection);
1246
1325
  }
1247
- const policy = new PolicyManager({ maxSteps: args.maxSteps, readOnly: activeMode === 'Plan' || args.readOnly, nonInteractive: args.nonInteractive, timeoutSeconds: args.timeout });
1326
+ const isReview = trimmed === '/review';
1327
+ const reviewFiles = isReview ? (await inspectGitWorkspace(canonicalWorkspace)).files.map(file => `${file.status} ${file.path}`) : [];
1328
+ const policy = new PolicyManager({ maxSteps: args.maxSteps, readOnly: isReview || activeMode === 'Plan' || args.readOnly, nonInteractive: args.nonInteractive, timeoutSeconds: args.timeout });
1248
1329
  const startedAt = Date.now();
1249
1330
  let thinkingTimer;
1250
1331
  let removeGenerationListener;
1332
+ let turnUsage;
1333
+ let usageRecorded = false;
1334
+ lastTokenUsage = undefined;
1335
+ const recordTurnUsage = (pricing) => {
1336
+ if (!turnUsage || usageRecorded)
1337
+ return;
1338
+ activeSession.usage = accumulateSessionUsage(activeSession.usage, turnUsage.usage, turnUsage.estimated, pricing);
1339
+ sessionTokens = activeSession.usage.totalTokens;
1340
+ usageRecorded = true;
1341
+ };
1251
1342
  try {
1252
1343
  const requestAbort = new AbortController();
1253
1344
  const requestSignal = signal ? AbortSignal.any([signal, requestAbort.signal]) : requestAbort.signal;
@@ -1255,6 +1346,10 @@ export async function handleChatSession(args, version, signal) {
1255
1346
  lastAnswer = '';
1256
1347
  let firstAssistantDeltaAt;
1257
1348
  let outputTokenRate;
1349
+ let budgetWarningIssued = false;
1350
+ let budgetStopped = false;
1351
+ let lastUsagePaint = 0;
1352
+ const usedModels = new Set();
1258
1353
  let answerPosition = { row: 15, column: 1 };
1259
1354
  let lastErrorEvent;
1260
1355
  const redrawChatFrame = () => {
@@ -1265,7 +1360,7 @@ export async function handleChatSession(args, version, signal) {
1265
1360
  model: currentModel ?? 'No model connected — use /connect',
1266
1361
  tokens: Math.round(sessionTokens),
1267
1362
  cost: costLabel(),
1268
- usageAvailable: activeSession.usage.available,
1363
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated,
1269
1364
  workspace: canonicalWorkspace,
1270
1365
  mode: activeMode,
1271
1366
  autoApprove: activeAutoApprove,
@@ -1273,6 +1368,8 @@ export async function handleChatSession(args, version, signal) {
1273
1368
  chatAnswer: displayAnswer,
1274
1369
  chatThoughtTime: thoughtTime,
1275
1370
  outputTokenRate,
1371
+ tokenUsage: turnUsage,
1372
+ budgetStatus: budgetStopped ? 'Budget reached' : budgetWarningIssued ? 'Budget ≥80%' : undefined,
1276
1373
  input: commandQueue.currentDraft,
1277
1374
  queuedCommands: commandQueue.items,
1278
1375
  }, process.stdout.rows));
@@ -1311,7 +1408,7 @@ export async function handleChatSession(args, version, signal) {
1311
1408
  model: currentModel ?? 'No model connected',
1312
1409
  tokens: Math.round(sessionTokens),
1313
1410
  cost: costLabel(),
1314
- usageAvailable: activeSession.usage.available,
1411
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated,
1315
1412
  workspace: canonicalWorkspace,
1316
1413
  mode: activeMode,
1317
1414
  autoApprove: activeAutoApprove,
@@ -1344,9 +1441,8 @@ export async function handleChatSession(args, version, signal) {
1344
1441
  });
1345
1442
  };
1346
1443
  if (process.stdin.isTTY) {
1347
- const readlineModule = await import('node:readline');
1348
1444
  const attachGenerationListener = () => {
1349
- readlineModule.emitKeypressEvents(process.stdin);
1445
+ readline.emitKeypressEvents(process.stdin);
1350
1446
  process.stdin.resume();
1351
1447
  process.stdin.setRawMode(true);
1352
1448
  process.stdin.on('keypress', onGenerationKeypress);
@@ -1370,17 +1466,36 @@ export async function handleChatSession(args, version, signal) {
1370
1466
  // Move to the chat layout before the provider can emit its first event.
1371
1467
  redrawChatFrame();
1372
1468
  thinkingTimer = setInterval(() => {
1373
- if (!firstAssistantDeltaAt) {
1469
+ if (!firstAssistantDeltaAt && !turnUsage) {
1374
1470
  process.stdout.write(renderChatThoughtTimeUpdate((Date.now() - startedAt) / 1000));
1375
1471
  }
1376
1472
  }, 400);
1377
1473
  const evidenceTask = liveSearch ? buildSearchAnswerTask(effectivePrompt, liveSearch) : undefined;
1378
- const runAgent = () => loop.run(evidenceTask ?? createAgentTask(effectivePrompt, activeMode), {
1379
- workspaceRoot: canonicalWorkspace, provider: provider, tools, approvalHandler, router, policy,
1474
+ const runAgent = () => loop.run(isReview ? createReviewTask(reviewFiles) : evidenceTask ?? createAgentTask(effectivePrompt, activeMode), {
1475
+ workspaceRoot: canonicalWorkspace, provider: provider, tools: isReview ? createReviewToolRegistry() : tools, approvalHandler, router, policy,
1380
1476
  maxOutputTokens: args.maxTokens ?? config.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS,
1381
1477
  skillContext: skillContext(),
1382
1478
  routeOptions: { pinnedModelId: currentModel === 'auto' ? undefined : currentModel, allowPaid: config.allowPaid ?? args.allowPaid, allowUnknown: config.allowUnknown ?? args.allowUnknown, isLocalProfile: args.profile.includes('local') },
1383
1479
  eventListener: (event) => {
1480
+ if (event.type === 'model_change')
1481
+ usedModels.add(event.newModelId);
1482
+ if (event.type === 'usage') {
1483
+ turnUsage = event;
1484
+ lastTokenUsage = event;
1485
+ outputTokenRate = event.generationMs > 0 ? event.outputTokensPerSecond : undefined;
1486
+ const budgetState = evaluateTokenBudget(event.usage.totalTokens, tokenBudget);
1487
+ const firstBudgetWarning = budgetState === 'warning' && !budgetWarningIssued;
1488
+ if (budgetState === 'warning')
1489
+ budgetWarningIssued = true;
1490
+ if (budgetState === 'stop' && !budgetStopped) {
1491
+ budgetStopped = true;
1492
+ requestAbort.abort();
1493
+ }
1494
+ if (budgetStopped || firstBudgetWarning || event.final || event.timestamp - lastUsagePaint >= 400) {
1495
+ process.stdout.write(renderChatUsageUpdate(event, process.stdout.columns || 80, budgetStopped ? 'Budget reached' : budgetWarningIssued ? 'Budget ≥80%' : undefined));
1496
+ lastUsagePaint = event.timestamp;
1497
+ }
1498
+ }
1384
1499
  if (event.type === 'assistant_delta') {
1385
1500
  if (!firstAssistantDeltaAt && event.delta.trim().length > 0) {
1386
1501
  firstAssistantDeltaAt = Date.now();
@@ -1421,28 +1536,38 @@ export async function handleChatSession(args, version, signal) {
1421
1536
  const replacement = await replaceProviderKeyInteractive(activeConnection, { signal });
1422
1537
  if (!replacement)
1423
1538
  throw error;
1539
+ recordTurnUsage();
1540
+ turnUsage = undefined;
1541
+ usageRecorded = false;
1424
1542
  const secured = process.platform === 'win32' ? await storeConnectionCredential(replacement, credentialStore) : replacement;
1425
1543
  saveConnection(secured);
1426
1544
  config = loadConfig();
1427
1545
  activateConnection(secured);
1428
1546
  result = await runAgent();
1429
1547
  }
1430
- if (result.usage) {
1548
+ if (turnUsage && !turnUsage.estimated) {
1431
1549
  let pricing;
1432
1550
  try {
1433
- pricing = findModelPricing(await provider.discoverModels(signal), result.selectedModel.id);
1551
+ if (usedModels.size <= 1)
1552
+ pricing = findModelPricing(await provider.discoverModels(signal), result.selectedModel.id);
1434
1553
  }
1435
1554
  catch {
1436
1555
  // A valid answer remains usable when the provider catalog is unavailable.
1437
1556
  }
1438
- activeSession.usage = { ...result.usage, ...calculateSessionCost(result.usage, pricing), available: true };
1439
- sessionTokens = result.usage.totalTokens;
1440
- if (firstAssistantDeltaAt)
1441
- outputTokenRate = calculateOutputTokenRate(result.usage.completionTokens, Date.now() - firstAssistantDeltaAt);
1557
+ recordTurnUsage(pricing);
1442
1558
  }
1559
+ recordTurnUsage();
1560
+ // Failed/cancelled tasks can still consume tokens, even without a saved answer.
1561
+ if (turnUsage)
1562
+ sessionStore.save(activeSession);
1443
1563
  const otherConnections = Object.keys(config.connections ?? {}).filter((id) => id !== activeConnection?.id);
1444
1564
  const resolution = resolveTurnAssistantAnswer(result, conversationHistory.length, lastErrorEvent, streamedAnswer, currentModel, otherConnections);
1445
1565
  lastAnswer = resolution.answer;
1566
+ if (budgetStopped) {
1567
+ const note = `Token budget reached (${tokenBudget?.toLocaleString()} per task). Task stopped; usage may exceed the limit because providers report in chunks.`;
1568
+ lastAnswer = [lastAnswer, note].filter(Boolean).join('\n\n');
1569
+ streamedAnswer = lastAnswer;
1570
+ }
1446
1571
  if (resolution.isError) {
1447
1572
  streamedAnswer = lastAnswer;
1448
1573
  }
@@ -1469,6 +1594,9 @@ export async function handleChatSession(args, version, signal) {
1469
1594
  if (thinkingTimer)
1470
1595
  clearInterval(thinkingTimer);
1471
1596
  removeGenerationListener?.();
1597
+ recordTurnUsage();
1598
+ if (turnUsage)
1599
+ sessionStore.save(activeSession);
1472
1600
  const otherConnections = Object.keys(config.connections ?? {}).filter((id) => id !== activeConnection?.id);
1473
1601
  const altSuggestion = otherConnections.length > 0
1474
1602
  ? ` (other configured providers: ${otherConnections.join(', ')})`
@@ -1486,13 +1614,14 @@ export async function handleChatSession(args, version, signal) {
1486
1614
  model: currentModel ?? 'No model connected — use /connect',
1487
1615
  tokens: Math.round(sessionTokens),
1488
1616
  cost: costLabel(),
1489
- usageAvailable: activeSession.usage.available,
1617
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated,
1490
1618
  workspace: canonicalWorkspace,
1491
1619
  mode: activeMode,
1492
1620
  autoApprove: activeAutoApprove,
1493
1621
  chatQuestion: lastQuestion,
1494
1622
  chatAnswer: lastAnswer,
1495
1623
  chatThoughtTime: lastThoughtTime,
1624
+ tokenUsage: turnUsage,
1496
1625
  queuedCommands: commandQueue.items,
1497
1626
  }, process.stdout.rows));
1498
1627
  process.stdout.write(renderChatComposerCursor({ width: process.stdout.columns }, 0));