moderado 0.3.0 → 0.3.7

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 (96) hide show
  1. package/README.md +182 -175
  2. package/dist/args.d.ts +1 -1
  3. package/dist/args.d.ts.map +1 -1
  4. package/dist/args.js +32 -28
  5. package/dist/args.js.map +1 -1
  6. package/dist/budget.d.ts +6 -0
  7. package/dist/budget.d.ts.map +1 -0
  8. package/dist/budget.js +25 -0
  9. package/dist/budget.js.map +1 -0
  10. package/dist/commands/chat.d.ts +5 -1
  11. package/dist/commands/chat.d.ts.map +1 -1
  12. package/dist/commands/chat.js +188 -40
  13. package/dist/commands/chat.js.map +1 -1
  14. package/dist/commands/models.d.ts.map +1 -1
  15. package/dist/commands/models.js +61 -14
  16. package/dist/commands/models.js.map +1 -1
  17. package/dist/commands/run.d.ts.map +1 -1
  18. package/dist/commands/run.js +2 -0
  19. package/dist/commands/run.js.map +1 -1
  20. package/dist/commands/skills.d.ts +2 -0
  21. package/dist/commands/skills.d.ts.map +1 -0
  22. package/dist/commands/skills.js +13 -0
  23. package/dist/commands/skills.js.map +1 -0
  24. package/dist/config.d.ts +73 -0
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/config.js +117 -1
  27. package/dist/config.js.map +1 -1
  28. package/dist/index.js +4 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/model_pricing.d.ts +14 -1
  31. package/dist/model_pricing.d.ts.map +1 -1
  32. package/dist/model_pricing.js +52 -0
  33. package/dist/model_pricing.js.map +1 -1
  34. package/dist/repo_map.d.ts +3 -0
  35. package/dist/repo_map.d.ts.map +1 -0
  36. package/dist/repo_map.js +29 -0
  37. package/dist/repo_map.js.map +1 -0
  38. package/dist/sessions.d.ts +22 -15
  39. package/dist/sessions.d.ts.map +1 -1
  40. package/dist/sessions.js +16 -1
  41. package/dist/sessions.js.map +1 -1
  42. package/dist/skills.d.ts +23 -0
  43. package/dist/skills.d.ts.map +1 -0
  44. package/dist/skills.js +78 -0
  45. package/dist/skills.js.map +1 -0
  46. package/dist/ui/help_modal.d.ts.map +1 -1
  47. package/dist/ui/help_modal.js +1 -0
  48. package/dist/ui/help_modal.js.map +1 -1
  49. package/dist/ui/model_selector.d.ts +2 -2
  50. package/dist/ui/model_selector.d.ts.map +1 -1
  51. package/dist/ui/model_selector.js +25 -16
  52. package/dist/ui/model_selector.js.map +1 -1
  53. package/dist/ui/provider_connect.d.ts +7 -3
  54. package/dist/ui/provider_connect.d.ts.map +1 -1
  55. package/dist/ui/provider_connect.js +64 -15
  56. package/dist/ui/provider_connect.js.map +1 -1
  57. package/dist/ui/renderer.d.ts +4 -0
  58. package/dist/ui/renderer.d.ts.map +1 -1
  59. package/dist/ui/renderer.js +36 -0
  60. package/dist/ui/renderer.js.map +1 -1
  61. package/dist/ui/welcome.d.ts +11 -0
  62. package/dist/ui/welcome.d.ts.map +1 -1
  63. package/dist/ui/welcome.js +40 -20
  64. package/dist/ui/welcome.js.map +1 -1
  65. package/dist/vendor/contracts/events.d.ts +159 -0
  66. package/dist/vendor/contracts/events.d.ts.map +1 -1
  67. package/dist/vendor/contracts/events.js +12 -0
  68. package/dist/vendor/contracts/events.js.map +1 -1
  69. package/dist/vendor/contracts/provider.d.ts +1 -0
  70. package/dist/vendor/contracts/provider.d.ts.map +1 -1
  71. package/dist/vendor/contracts/provider.js +6 -0
  72. package/dist/vendor/contracts/provider.js.map +1 -1
  73. package/dist/vendor/core/agent.d.ts +5 -2
  74. package/dist/vendor/core/agent.d.ts.map +1 -1
  75. package/dist/vendor/core/agent.js +148 -34
  76. package/dist/vendor/core/agent.js.map +1 -1
  77. package/dist/vendor/providers/model_discovery.d.ts +6 -2
  78. package/dist/vendor/providers/model_discovery.d.ts.map +1 -1
  79. package/dist/vendor/providers/model_discovery.js +8 -2
  80. package/dist/vendor/providers/model_discovery.js.map +1 -1
  81. package/dist/vendor/providers/nvidia/nvidia_adapter.d.ts.map +1 -1
  82. package/dist/vendor/providers/nvidia/nvidia_adapter.js +1 -0
  83. package/dist/vendor/providers/nvidia/nvidia_adapter.js.map +1 -1
  84. package/dist/vendor/providers/nvidia/sse_parser.d.ts +2 -1
  85. package/dist/vendor/providers/nvidia/sse_parser.d.ts.map +1 -1
  86. package/dist/vendor/providers/nvidia/sse_parser.js +30 -16
  87. package/dist/vendor/providers/nvidia/sse_parser.js.map +1 -1
  88. package/package.json +2 -2
  89. package/dist/ui/session_screen.d.ts +0 -32
  90. package/dist/ui/session_screen.d.ts.map +0 -1
  91. package/dist/ui/session_screen.js +0 -148
  92. package/dist/ui/session_screen.js.map +0 -1
  93. package/dist/ui/session_terminal.d.ts +0 -46
  94. package/dist/ui/session_terminal.d.ts.map +0 -1
  95. package/dist/ui/session_terminal.js +0 -342
  96. package/dist/ui/session_terminal.js.map +0 -1
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import fs from 'node:fs';
3
3
  import { AgentLoop, PolicyManager, Router, cleanConversationalFiller, DEFAULT_MAX_OUTPUT_TOKENS } from '../vendor/core/index.js';
4
4
  import { NvidiaAdapter } from '../vendor/providers/index.js';
5
- import { createDefaultToolRegistry, canonicalizeRoot, createMcpTools, createWebSearchTool, discoverMcpServers, resolveInJail, WorkspaceCheckpointStore, WriteFileTool } from '../vendor/tools/index.js';
5
+ import { createDefaultToolRegistry, ToolRegistry, canonicalizeRoot, createMcpTools, createWebSearchTool, discoverMcpServers, resolveInJail, WorkspaceCheckpointStore, WriteFileTool } from '../vendor/tools/index.js';
6
6
  import { McpServerConfigSchema } from '../vendor/contracts/index.js';
7
7
  import { getActiveConnection, loadConfig, removeMcpServer, resolveApiKey, resolveConnectionCredential, saveConnection, saveMcpServer, setMcpServerEnabled, storeConnectionCredential } from '../config.js';
8
8
  import { MemoryCredentialStore } from '../credentials.js';
@@ -13,12 +13,29 @@ import { connectProviderInteractive, isAuthenticationFailure, replaceProviderKey
13
13
  import { initWorkspace } from './init.js';
14
14
  import { expandMentions, createWorkspaceFileSource } from '../ui/file_mentions.js';
15
15
  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';
16
+ import { exitCleanly, promptInteractiveTurn, renderChatAnswerDelta, renderChatComposerCursor, renderChatThoughtTimeUpdate, renderChatUsageUpdate, renderFullWelcomeScreen, renderWelcomePopupLayer, terminalCleanExitDone, wrapText } from '../ui/welcome.js';
17
+ import { accumulateSessionUsage, compactSessionMessages, createSession, exportSessionMarkdown, formatSessionCost, SessionStore } from '../sessions.js';
18
18
  import { layerPromptBox, renderBoxLines, selectConfirmPopup, selectListPopup } from '../ui/popup.js';
19
19
  import { askModalChoice } from '../ui/prompt.js';
20
20
  import { findModelPricing } from '../model_pricing.js';
21
21
  import { inspectGitWorkspace, readGitDiff } from '../vendor/tools/index.js';
22
+ import { buildWorkspaceMap } from '../repo_map.js';
23
+ import { evaluateTokenBudget, parseBudgetCommand } from '../budget.js';
24
+ import { discoverSkills, formatSkillContext } from '../skills.js';
25
+ export function formatSessionExitSummary(session, modelId) {
26
+ const usage = session.usage;
27
+ const tokens = usage.available
28
+ ? `Input ${usage.promptTokens.toLocaleString('en-US')} | Output ${usage.completionTokens.toLocaleString('en-US')} | Total ${usage.totalTokens.toLocaleString('en-US')}${usage.estimated ? ' (estimated)' : ''}`
29
+ : 'Usage unavailable';
30
+ return [
31
+ '\x1b[1;38;5;25mSession Summary\x1b[0m',
32
+ `ID: ${session.id}`,
33
+ `Model: ${modelId ?? session.modelId ?? 'No model selected'}`,
34
+ `Tokens: ${tokens}`,
35
+ `Cost: ${formatSessionCost(usage)}`,
36
+ 'Resume: /session resume',
37
+ ].join('\n');
38
+ }
22
39
  function mutationPaths(toolName, parameters) {
23
40
  const value = parameters;
24
41
  if (toolName === 'apply_patch' && Array.isArray(value.edits)) {
@@ -31,6 +48,19 @@ export function createAgentTask(request, mode) {
31
48
  return request;
32
49
  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}`;
33
50
  }
51
+ export function createReviewTask(changedFiles) {
52
+ 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)'}`;
53
+ }
54
+ export function createReviewToolRegistry() {
55
+ const source = createDefaultToolRegistry();
56
+ const registry = new ToolRegistry();
57
+ for (const name of ['git_diff', 'read_file', 'list_files', 'search_files', 'get_definition', 'find_references']) {
58
+ const tool = source.get(name);
59
+ if (tool)
60
+ registry.register(tool);
61
+ }
62
+ return registry;
63
+ }
34
64
  export const STANDARD_SLASH_COMMANDS = [
35
65
  '/connect',
36
66
  '/model',
@@ -40,6 +70,10 @@ export const STANDARD_SLASH_COMMANDS = [
40
70
  '/session',
41
71
  '/queue',
42
72
  '/workflow',
73
+ '/review',
74
+ '/map',
75
+ '/budget',
76
+ '/skills',
43
77
  '/clear',
44
78
  '/help',
45
79
  '/exit',
@@ -137,6 +171,10 @@ export function formatTurnFailureAnswer(status, errorEvent, modelId, otherConnec
137
171
  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}.`;
138
172
  }
139
173
  export function resolveTurnAssistantAnswer(result, previousHistoryLength, lastErrorEvent, streamedAnswer = '', currentModel = 'unknown', otherConnections = []) {
174
+ if (result.status === 'cancelled') {
175
+ const partial = cleanConversationalFiller(streamedAnswer);
176
+ return { answer: [partial, 'Task stopped.'].filter(Boolean).join('\n\n'), isError: true };
177
+ }
140
178
  if (result.status === 'failed' || lastErrorEvent) {
141
179
  return {
142
180
  answer: formatTurnFailureAnswer(result.status, lastErrorEvent, result.selectedModel?.id || currentModel, otherConnections),
@@ -214,15 +252,9 @@ export function handleGenerationKeypress(str, key, queue, actions) {
214
252
  actions.abort();
215
253
  return;
216
254
  }
217
- // 2. Escape: if draft has text, clear draft; if draft is empty, abort generation
255
+ // 2. Escape always stops the active task; the caller clears queued input.
218
256
  if (isGenerationCancelKey(key, str)) {
219
- if (queue.currentDraft.length > 0) {
220
- queue.setDraft('');
221
- actions.onDraftChange();
222
- }
223
- else {
224
- actions.abort();
225
- }
257
+ actions.abort();
226
258
  return;
227
259
  }
228
260
  // 3. Return / Enter: commit draft or execute /btw immediately
@@ -420,9 +452,6 @@ export async function decideApproval(request, options, signal) {
420
452
  if (options.autoApprove && !request.toolName.startsWith('mcp.')) {
421
453
  return { requestId: request.requestId, status: 'approved' };
422
454
  }
423
- if (['write_file', 'edit_file', 'apply_patch', 'run_command', 'run_diagnostics'].includes(request.toolName)) {
424
- return { requestId: request.requestId, status: 'approved' };
425
- }
426
455
  return options.requestInteractiveApproval(request, signal);
427
456
  }
428
457
  export async function createMcpToolRegistry(servers, webSearch = {}) {
@@ -695,7 +724,8 @@ export async function handleChatSession(args, version, signal) {
695
724
  if (activeConnection)
696
725
  activateConnection(activeConnection, args.model);
697
726
  let activeMode = args.readOnly ? 'Plan' : 'Execute';
698
- let activeAutoApprove = Boolean(args.autoApprove);
727
+ let activeAutoApprove = !args.nonInteractive;
728
+ let tokenBudget;
699
729
  const sessionStore = new SessionStore();
700
730
  let activeSession = sessionStore.loadLatestSession(canonicalWorkspace) ??
701
731
  createSession(canonicalWorkspace, {
@@ -709,6 +739,8 @@ export async function handleChatSession(args, version, signal) {
709
739
  if (config.typescriptLanguageServer)
710
740
  process.env.MODERADO_TYPESCRIPT_LANGUAGE_SERVER = config.typescriptLanguageServer;
711
741
  let tools = createDefaultToolRegistry();
742
+ let skills = discoverSkills();
743
+ const skillContext = () => formatSkillContext(skills);
712
744
  const reloadMcpTools = async () => {
713
745
  config = loadConfig();
714
746
  tools = await createMcpToolRegistry(config.mcpServers, resolveWebSearchOptions(config));
@@ -738,8 +770,10 @@ export async function handleChatSession(args, version, signal) {
738
770
  let lastAnswer = '';
739
771
  let lastThoughtTime = 0;
740
772
  let lastOutputTokenRate;
773
+ let lastTokenUsage;
741
774
  let activePlan;
742
775
  const costLabel = () => formatSessionCost(activeSession.usage);
776
+ const exitMessage = () => `\x1b[32mGoodbye! Stay Tuned with Moderado!\x1b[0m\n\n${formatSessionExitSummary(activeSession, currentModel)}`;
743
777
  const commandQueue = new TurnCommandQueue();
744
778
  const btwHistory = [];
745
779
  while (!signal?.aborted) {
@@ -751,8 +785,9 @@ export async function handleChatSession(args, version, signal) {
751
785
  else {
752
786
  const turn = await promptInteractiveTurn({
753
787
  model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(), workspace: canonicalWorkspace, version,
754
- usageAvailable: activeSession.usage.available, initialMode: activeMode, initialAutoApprove: activeAutoApprove, isFirstTurn: isFirst, signal, chatQuestion: lastQuestion || undefined, chatAnswer: lastAnswer || undefined, chatThoughtTime: lastThoughtTime, outputTokenRate: lastOutputTokenRate,
788
+ 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,
755
789
  queuedCommands: commandQueue.items,
790
+ onExit: exitMessage,
756
791
  questionHistory: conversationHistory.flatMap((message) => message.role === 'user' && message.content?.trim() ? [message.content] : []),
757
792
  onModelSelect: async (drawFrame) => {
758
793
  if (!activeConnection) {
@@ -766,7 +801,6 @@ export async function handleChatSession(args, version, signal) {
766
801
  providerId: activeConnection.id,
767
802
  providerName: activeConnection.displayName,
768
803
  currentModel,
769
- allModelsFree: activeConnection.id === 'agnes-ai',
770
804
  signal,
771
805
  drawFrame,
772
806
  });
@@ -788,7 +822,7 @@ export async function handleChatSession(args, version, signal) {
788
822
  return selection.modelId;
789
823
  },
790
824
  onConnect: async (drawFrame) => {
791
- const connection = await connectProviderInteractive({ signal, drawFrame, savedConnections: config.connections, resolveSavedConnection: (saved) => resolveConnectionCredential(saved, credentialStore) });
825
+ const connection = await connectProviderInteractive({ signal, drawFrame, savedConnections: config.connections, connectProviders: config.connectProviders, resolveSavedConnection: (saved) => resolveConnectionCredential(saved, credentialStore) });
792
826
  if (!connection)
793
827
  return currentModel;
794
828
  const runtimeConnection = process.platform === 'win32' ? await storeConnectionCredential(connection, credentialStore) : connection;
@@ -804,6 +838,7 @@ export async function handleChatSession(args, version, signal) {
804
838
  lastAnswer = '';
805
839
  lastThoughtTime = 0;
806
840
  lastOutputTokenRate = undefined;
841
+ lastTokenUsage = undefined;
807
842
  activeSession.messages = [];
808
843
  sessionStore.save(activeSession);
809
844
  },
@@ -922,6 +957,7 @@ export async function handleChatSession(args, version, signal) {
922
957
  lastAnswer = '';
923
958
  lastThoughtTime = 0;
924
959
  lastOutputTokenRate = undefined;
960
+ lastTokenUsage = undefined;
925
961
  sessionStore.save(activeSession);
926
962
  return;
927
963
  }
@@ -1022,11 +1058,46 @@ export async function handleChatSession(args, version, signal) {
1022
1058
  continue;
1023
1059
  }
1024
1060
  }
1061
+ if (trimmed === '/budget' || trimmed.startsWith('/budget ')) {
1062
+ const budget = parseBudgetCommand(trimmed, tokenBudget);
1063
+ tokenBudget = budget.limit;
1064
+ lastQuestion = trimmed;
1065
+ lastAnswer = budget.message;
1066
+ lastThoughtTime = 0.001;
1067
+ lastOutputTokenRate = undefined;
1068
+ lastTokenUsage = undefined;
1069
+ process.stdout.write('\x1b[H\x1b[J');
1070
+ process.stdout.write(renderFullWelcomeScreen({
1071
+ model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1072
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1073
+ autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1074
+ chatThoughtTime: lastThoughtTime, queuedCommands: commandQueue.items,
1075
+ }, process.stdout.rows));
1076
+ process.stdout.write(renderChatComposerCursor({ width: process.stdout.columns }, 0));
1077
+ continue;
1078
+ }
1079
+ if (trimmed === '/map') {
1080
+ lastQuestion = trimmed;
1081
+ lastAnswer = await buildWorkspaceMap(canonicalWorkspace);
1082
+ lastThoughtTime = 0.001;
1083
+ lastOutputTokenRate = undefined;
1084
+ lastTokenUsage = undefined;
1085
+ process.stdout.write('\x1b[H\x1b[J');
1086
+ process.stdout.write(renderFullWelcomeScreen({
1087
+ model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1088
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1089
+ autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1090
+ chatThoughtTime: lastThoughtTime, queuedCommands: commandQueue.items,
1091
+ }, process.stdout.rows));
1092
+ process.stdout.write(renderChatComposerCursor({ width: process.stdout.columns }, 0));
1093
+ continue;
1094
+ }
1025
1095
  if (trimmed.startsWith('/queue')) {
1026
1096
  const args = trimmed.slice('/queue'.length).trim();
1027
1097
  lastQuestion = trimmed;
1028
1098
  lastThoughtTime = 0.001;
1029
1099
  lastOutputTokenRate = undefined;
1100
+ lastTokenUsage = undefined;
1030
1101
  if (!args || args === 'list') {
1031
1102
  lastAnswer = commandQueue.length === 0
1032
1103
  ? 'Command queue is empty. Use `/queue <command>` to add commands.'
@@ -1049,7 +1120,7 @@ export async function handleChatSession(args, version, signal) {
1049
1120
  process.stdout.write('\x1b[H\x1b[J');
1050
1121
  process.stdout.write(renderFullWelcomeScreen({
1051
1122
  model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1052
- usageAvailable: activeSession.usage.available, workspace: canonicalWorkspace, mode: activeMode,
1123
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1053
1124
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1054
1125
  chatThoughtTime: lastThoughtTime,
1055
1126
  queuedCommands: commandQueue.items,
@@ -1064,6 +1135,7 @@ export async function handleChatSession(args, version, signal) {
1064
1135
  lastAnswer = '';
1065
1136
  lastThoughtTime = 0;
1066
1137
  lastOutputTokenRate = undefined;
1138
+ lastTokenUsage = undefined;
1067
1139
  activeSession.messages = [];
1068
1140
  sessionStore.save(activeSession);
1069
1141
  continue;
@@ -1073,6 +1145,7 @@ export async function handleChatSession(args, version, signal) {
1073
1145
  lastAnswer = 'To leave Moderado, type /exit.';
1074
1146
  lastThoughtTime = 0;
1075
1147
  lastOutputTokenRate = undefined;
1148
+ lastTokenUsage = undefined;
1076
1149
  continue;
1077
1150
  }
1078
1151
  // Non-standard or misspelled slash command rejection with advice
@@ -1082,10 +1155,11 @@ export async function handleChatSession(args, version, signal) {
1082
1155
  lastAnswer = slashAdvice.advice;
1083
1156
  lastThoughtTime = 0.001;
1084
1157
  lastOutputTokenRate = undefined;
1158
+ lastTokenUsage = undefined;
1085
1159
  process.stdout.write('\x1b[H\x1b[J');
1086
1160
  process.stdout.write(renderFullWelcomeScreen({
1087
1161
  model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1088
- usageAvailable: activeSession.usage.available, workspace: canonicalWorkspace, mode: activeMode,
1162
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1089
1163
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1090
1164
  chatThoughtTime: lastThoughtTime,
1091
1165
  queuedCommands: commandQueue.items,
@@ -1093,8 +1167,22 @@ export async function handleChatSession(args, version, signal) {
1093
1167
  process.stdout.write(renderChatComposerCursor({ width: process.stdout.columns }, 0));
1094
1168
  continue;
1095
1169
  }
1170
+ if (trimmed === '/skills' || trimmed.startsWith('/skills ')) {
1171
+ skills = discoverSkills();
1172
+ lastQuestion = trimmed;
1173
+ lastAnswer = skills.length
1174
+ ? 'User skills loaded:\n' + skills.map((skill) => ` ${skill.name} — ${skill.description}`).join('\n')
1175
+ : 'No valid user skills found in ~/.moderado/skills.';
1176
+ lastThoughtTime = 0.001;
1177
+ lastOutputTokenRate = undefined;
1178
+ lastTokenUsage = undefined;
1179
+ process.stdout.write('\x1b[H\x1b[J');
1180
+ 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));
1181
+ process.stdout.write(renderChatComposerCursor({ width: process.stdout.columns }, 0));
1182
+ continue;
1183
+ }
1096
1184
  if (trimmed === '/exit' || trimmed === '/quit') {
1097
- exitCleanly('\x1b[32mGoodbye! Stay Tuned with Moderado!\x1b[0m');
1185
+ exitCleanly(exitMessage());
1098
1186
  }
1099
1187
  if (trimmed === '/help') {
1100
1188
  lastQuestion = trimmed;
@@ -1106,15 +1194,20 @@ export async function handleChatSession(args, version, signal) {
1106
1194
  ' /session - Create, resume, undo, redo, share, export, or compact sessions\n' +
1107
1195
  ' /queue - Add, inspect, or clear queued follow-up commands\n' +
1108
1196
  ' /workflow - Inspect Git, build plans, or undo agent changes\n' +
1197
+ ' /review - Review current workspace changes for bugs\n' +
1198
+ ' /map - Show a compact workspace file map\n' +
1199
+ ' /budget - Set a per-task token limit (/budget 5000, /budget off)\n' +
1200
+ ' /skills - List installed user skills and reload them\n' +
1109
1201
  ' /clear - Reset conversation memory\n' +
1110
1202
  ' /help - Display commands, shortcuts & version\n' +
1111
1203
  ' /exit - Exit Moderado';
1112
1204
  lastThoughtTime = 0.001;
1113
1205
  lastOutputTokenRate = undefined;
1206
+ lastTokenUsage = undefined;
1114
1207
  process.stdout.write('\x1b[H\x1b[J');
1115
1208
  process.stdout.write(renderFullWelcomeScreen({
1116
1209
  model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1117
- usageAvailable: activeSession.usage.available, workspace: canonicalWorkspace, mode: activeMode,
1210
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1118
1211
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1119
1212
  chatThoughtTime: lastThoughtTime,
1120
1213
  queuedCommands: commandQueue.items,
@@ -1140,13 +1233,14 @@ export async function handleChatSession(args, version, signal) {
1140
1233
  lastAnswer = localMetaAnswer;
1141
1234
  lastThoughtTime = 0.001;
1142
1235
  lastOutputTokenRate = undefined;
1236
+ lastTokenUsage = undefined;
1143
1237
  conversationHistory = [...conversationHistory, { role: 'user', content: trimmed }, { role: 'assistant', content: localMetaAnswer }];
1144
1238
  activeSession.messages = conversationHistory;
1145
1239
  sessionStore.save(activeSession);
1146
1240
  process.stdout.write('\x1b[H\x1b[J');
1147
1241
  process.stdout.write(renderFullWelcomeScreen({
1148
1242
  model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1149
- usageAvailable: activeSession.usage.available, workspace: canonicalWorkspace, mode: activeMode,
1243
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1150
1244
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1151
1245
  chatThoughtTime: lastThoughtTime,
1152
1246
  queuedCommands: commandQueue.items,
@@ -1159,13 +1253,14 @@ export async function handleChatSession(args, version, signal) {
1159
1253
  lastQuestion = trimmed;
1160
1254
  lastThoughtTime = 0.001;
1161
1255
  lastOutputTokenRate = undefined;
1256
+ lastTokenUsage = undefined;
1162
1257
  conversationHistory = [...conversationHistory, { role: 'user', content: trimmed }, { role: 'assistant', content: lastAnswer }];
1163
1258
  activeSession.messages = conversationHistory;
1164
1259
  sessionStore.save(activeSession);
1165
1260
  process.stdout.write('\x1b[H\x1b[J');
1166
1261
  process.stdout.write(renderFullWelcomeScreen({
1167
1262
  model: currentModel ?? 'No model connected — use /connect', tokens: Math.round(sessionTokens), cost: costLabel(),
1168
- usageAvailable: activeSession.usage.available, workspace: canonicalWorkspace, mode: activeMode,
1263
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1169
1264
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1170
1265
  chatThoughtTime: lastThoughtTime,
1171
1266
  queuedCommands: commandQueue.items,
@@ -1182,10 +1277,11 @@ export async function handleChatSession(args, version, signal) {
1182
1277
  lastAnswer = 'Searching the web...';
1183
1278
  lastThoughtTime = 0;
1184
1279
  lastOutputTokenRate = undefined;
1280
+ lastTokenUsage = undefined;
1185
1281
  process.stdout.write('\x1b[H\x1b[J');
1186
1282
  process.stdout.write(renderFullWelcomeScreen({
1187
1283
  model: currentModel ?? 'Web search', tokens: Math.round(sessionTokens), cost: costLabel(),
1188
- usageAvailable: activeSession.usage.available, workspace: canonicalWorkspace, mode: activeMode,
1284
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1189
1285
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1190
1286
  }, process.stdout.rows));
1191
1287
  process.stdout.write(renderChatComposerCursor({ width: process.stdout.columns }, 0));
@@ -1206,7 +1302,7 @@ export async function handleChatSession(args, version, signal) {
1206
1302
  process.stdout.write('\x1b[H\x1b[J');
1207
1303
  process.stdout.write(renderFullWelcomeScreen({
1208
1304
  model: currentModel ?? 'Web search', tokens: Math.round(sessionTokens), cost: costLabel(),
1209
- usageAvailable: activeSession.usage.available, workspace: canonicalWorkspace, mode: activeMode,
1305
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated, workspace: canonicalWorkspace, mode: activeMode,
1210
1306
  autoApprove: activeAutoApprove, chatQuestion: lastQuestion, chatAnswer: lastAnswer,
1211
1307
  chatThoughtTime: lastThoughtTime,
1212
1308
  }, process.stdout.rows));
@@ -1216,7 +1312,7 @@ export async function handleChatSession(args, version, signal) {
1216
1312
  }
1217
1313
  if (!provider) {
1218
1314
  process.stdout.write('\nNo provider is connected. Let\'s connect one before sending this task.\n');
1219
- const connection = await connectProviderInteractive({ signal, savedConnections: config.connections, resolveSavedConnection: (saved) => resolveConnectionCredential(saved, credentialStore) });
1315
+ const connection = await connectProviderInteractive({ signal, savedConnections: config.connections, connectProviders: config.connectProviders, resolveSavedConnection: (saved) => resolveConnectionCredential(saved, credentialStore) });
1220
1316
  if (!connection) {
1221
1317
  process.stdout.write('No provider connected. Use /connect whenever you are ready.\n\n');
1222
1318
  continue;
@@ -1226,10 +1322,22 @@ export async function handleChatSession(args, version, signal) {
1226
1322
  config = loadConfig();
1227
1323
  activateConnection(runtimeConnection);
1228
1324
  }
1229
- const policy = new PolicyManager({ maxSteps: args.maxSteps, readOnly: activeMode === 'Plan' || args.readOnly, nonInteractive: args.nonInteractive, timeoutSeconds: args.timeout });
1325
+ const isReview = trimmed === '/review';
1326
+ const reviewFiles = isReview ? (await inspectGitWorkspace(canonicalWorkspace)).files.map(file => `${file.status} ${file.path}`) : [];
1327
+ const policy = new PolicyManager({ maxSteps: args.maxSteps, readOnly: isReview || activeMode === 'Plan' || args.readOnly, nonInteractive: args.nonInteractive, timeoutSeconds: args.timeout });
1230
1328
  const startedAt = Date.now();
1231
1329
  let thinkingTimer;
1232
1330
  let removeGenerationListener;
1331
+ let turnUsage;
1332
+ let usageRecorded = false;
1333
+ lastTokenUsage = undefined;
1334
+ const recordTurnUsage = (pricing) => {
1335
+ if (!turnUsage || usageRecorded)
1336
+ return;
1337
+ activeSession.usage = accumulateSessionUsage(activeSession.usage, turnUsage.usage, turnUsage.estimated, pricing);
1338
+ sessionTokens = activeSession.usage.totalTokens;
1339
+ usageRecorded = true;
1340
+ };
1233
1341
  try {
1234
1342
  const requestAbort = new AbortController();
1235
1343
  const requestSignal = signal ? AbortSignal.any([signal, requestAbort.signal]) : requestAbort.signal;
@@ -1237,6 +1345,10 @@ export async function handleChatSession(args, version, signal) {
1237
1345
  lastAnswer = '';
1238
1346
  let firstAssistantDeltaAt;
1239
1347
  let outputTokenRate;
1348
+ let budgetWarningIssued = false;
1349
+ let budgetStopped = false;
1350
+ let lastUsagePaint = 0;
1351
+ const usedModels = new Set();
1240
1352
  let answerPosition = { row: 15, column: 1 };
1241
1353
  let lastErrorEvent;
1242
1354
  const redrawChatFrame = () => {
@@ -1247,7 +1359,7 @@ export async function handleChatSession(args, version, signal) {
1247
1359
  model: currentModel ?? 'No model connected — use /connect',
1248
1360
  tokens: Math.round(sessionTokens),
1249
1361
  cost: costLabel(),
1250
- usageAvailable: activeSession.usage.available,
1362
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated,
1251
1363
  workspace: canonicalWorkspace,
1252
1364
  mode: activeMode,
1253
1365
  autoApprove: activeAutoApprove,
@@ -1255,6 +1367,8 @@ export async function handleChatSession(args, version, signal) {
1255
1367
  chatAnswer: displayAnswer,
1256
1368
  chatThoughtTime: thoughtTime,
1257
1369
  outputTokenRate,
1370
+ tokenUsage: turnUsage,
1371
+ budgetStatus: budgetStopped ? 'Budget reached' : budgetWarningIssued ? 'Budget ≥80%' : undefined,
1258
1372
  input: commandQueue.currentDraft,
1259
1373
  queuedCommands: commandQueue.items,
1260
1374
  }, process.stdout.rows));
@@ -1293,7 +1407,7 @@ export async function handleChatSession(args, version, signal) {
1293
1407
  model: currentModel ?? 'No model connected',
1294
1408
  tokens: Math.round(sessionTokens),
1295
1409
  cost: costLabel(),
1296
- usageAvailable: activeSession.usage.available,
1410
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated,
1297
1411
  workspace: canonicalWorkspace,
1298
1412
  mode: activeMode,
1299
1413
  autoApprove: activeAutoApprove,
@@ -1352,16 +1466,36 @@ export async function handleChatSession(args, version, signal) {
1352
1466
  // Move to the chat layout before the provider can emit its first event.
1353
1467
  redrawChatFrame();
1354
1468
  thinkingTimer = setInterval(() => {
1355
- if (!firstAssistantDeltaAt) {
1469
+ if (!firstAssistantDeltaAt && !turnUsage) {
1356
1470
  process.stdout.write(renderChatThoughtTimeUpdate((Date.now() - startedAt) / 1000));
1357
1471
  }
1358
1472
  }, 400);
1359
1473
  const evidenceTask = liveSearch ? buildSearchAnswerTask(effectivePrompt, liveSearch) : undefined;
1360
- const runAgent = () => loop.run(evidenceTask ?? createAgentTask(effectivePrompt, activeMode), {
1361
- 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,
1362
1476
  maxOutputTokens: args.maxTokens ?? config.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS,
1477
+ skillContext: skillContext(),
1363
1478
  routeOptions: { pinnedModelId: currentModel === 'auto' ? undefined : currentModel, allowPaid: config.allowPaid ?? args.allowPaid, allowUnknown: config.allowUnknown ?? args.allowUnknown, isLocalProfile: args.profile.includes('local') },
1364
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
+ }
1365
1499
  if (event.type === 'assistant_delta') {
1366
1500
  if (!firstAssistantDeltaAt && event.delta.trim().length > 0) {
1367
1501
  firstAssistantDeltaAt = Date.now();
@@ -1402,28 +1536,38 @@ export async function handleChatSession(args, version, signal) {
1402
1536
  const replacement = await replaceProviderKeyInteractive(activeConnection, { signal });
1403
1537
  if (!replacement)
1404
1538
  throw error;
1539
+ recordTurnUsage();
1540
+ turnUsage = undefined;
1541
+ usageRecorded = false;
1405
1542
  const secured = process.platform === 'win32' ? await storeConnectionCredential(replacement, credentialStore) : replacement;
1406
1543
  saveConnection(secured);
1407
1544
  config = loadConfig();
1408
1545
  activateConnection(secured);
1409
1546
  result = await runAgent();
1410
1547
  }
1411
- if (result.usage) {
1548
+ if (turnUsage && !turnUsage.estimated) {
1412
1549
  let pricing;
1413
1550
  try {
1414
- pricing = findModelPricing(await provider.discoverModels(signal), result.selectedModel.id);
1551
+ if (usedModels.size <= 1)
1552
+ pricing = findModelPricing(await provider.discoverModels(signal), result.selectedModel.id);
1415
1553
  }
1416
1554
  catch {
1417
1555
  // A valid answer remains usable when the provider catalog is unavailable.
1418
1556
  }
1419
- activeSession.usage = { ...result.usage, ...calculateSessionCost(result.usage, pricing), available: true };
1420
- sessionTokens = result.usage.totalTokens;
1421
- if (firstAssistantDeltaAt)
1422
- outputTokenRate = calculateOutputTokenRate(result.usage.completionTokens, Date.now() - firstAssistantDeltaAt);
1557
+ recordTurnUsage(pricing);
1423
1558
  }
1559
+ recordTurnUsage();
1560
+ // Failed/cancelled tasks can still consume tokens, even without a saved answer.
1561
+ if (turnUsage)
1562
+ sessionStore.save(activeSession);
1424
1563
  const otherConnections = Object.keys(config.connections ?? {}).filter((id) => id !== activeConnection?.id);
1425
1564
  const resolution = resolveTurnAssistantAnswer(result, conversationHistory.length, lastErrorEvent, streamedAnswer, currentModel, otherConnections);
1426
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
+ }
1427
1571
  if (resolution.isError) {
1428
1572
  streamedAnswer = lastAnswer;
1429
1573
  }
@@ -1450,6 +1594,9 @@ export async function handleChatSession(args, version, signal) {
1450
1594
  if (thinkingTimer)
1451
1595
  clearInterval(thinkingTimer);
1452
1596
  removeGenerationListener?.();
1597
+ recordTurnUsage();
1598
+ if (turnUsage)
1599
+ sessionStore.save(activeSession);
1453
1600
  const otherConnections = Object.keys(config.connections ?? {}).filter((id) => id !== activeConnection?.id);
1454
1601
  const altSuggestion = otherConnections.length > 0
1455
1602
  ? ` (other configured providers: ${otherConnections.join(', ')})`
@@ -1467,13 +1614,14 @@ export async function handleChatSession(args, version, signal) {
1467
1614
  model: currentModel ?? 'No model connected — use /connect',
1468
1615
  tokens: Math.round(sessionTokens),
1469
1616
  cost: costLabel(),
1470
- usageAvailable: activeSession.usage.available,
1617
+ usageAvailable: activeSession.usage.available, usageEstimated: activeSession.usage.estimated,
1471
1618
  workspace: canonicalWorkspace,
1472
1619
  mode: activeMode,
1473
1620
  autoApprove: activeAutoApprove,
1474
1621
  chatQuestion: lastQuestion,
1475
1622
  chatAnswer: lastAnswer,
1476
1623
  chatThoughtTime: lastThoughtTime,
1624
+ tokenUsage: turnUsage,
1477
1625
  queuedCommands: commandQueue.items,
1478
1626
  }, process.stdout.rows));
1479
1627
  process.stdout.write(renderChatComposerCursor({ width: process.stdout.columns }, 0));