micro-models-agent 0.51.1 → 0.52.0

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 (107) hide show
  1. package/dist/cli/commands.js +162 -38
  2. package/dist/cli/completer.js +5 -5
  3. package/dist/cli/main.js +42 -54
  4. package/dist/cli/repl-commands.js +138 -38
  5. package/dist/cli/repl.js +175 -89
  6. package/dist/cli/run-result.js +11 -0
  7. package/dist/cli/security-commands.js +6 -6
  8. package/dist/cli/setup.js +21 -15
  9. package/dist/config/config.js +54 -27
  10. package/dist/config/defaults.js +17 -0
  11. package/dist/config/domains.js +179 -0
  12. package/dist/config/index.js +2 -1
  13. package/dist/config/security.js +28 -8
  14. package/dist/core/agent.js +162 -30
  15. package/dist/core/bootstrap.js +94 -17
  16. package/dist/core/crash-handler.js +51 -0
  17. package/dist/core/environment.js +199 -0
  18. package/dist/core/session-logger.js +60 -6
  19. package/dist/core/version.js +2 -0
  20. package/dist/i18n/en.json +120 -39
  21. package/dist/i18n/ru.json +91 -10
  22. package/dist/llm/openai-compat.js +191 -53
  23. package/dist/llm/orchestrator.js +5 -3
  24. package/dist/logger/app-logger.js +50 -4
  25. package/dist/main.js +1288 -635
  26. package/dist/modules/browser/session.js +4 -0
  27. package/dist/modules/certification/cli.js +58 -19
  28. package/dist/modules/certification/loader.js +2 -1
  29. package/dist/modules/certification/manifest.js +22 -14
  30. package/dist/modules/certification/runner.js +91 -5
  31. package/dist/modules/certification/scenarios.js +290 -7
  32. package/dist/modules/context/fact-extractor.js +6 -0
  33. package/dist/modules/context/manager.js +19 -2
  34. package/dist/modules/execution/audit-runners.js +61 -7
  35. package/dist/modules/execution/execution-plugin.js +219 -60
  36. package/dist/modules/execution/module.js +207 -18
  37. package/dist/modules/execution/moe-executor.js +33 -20
  38. package/dist/modules/execution/plan-store.js +39 -0
  39. package/dist/modules/execution/plan-tool.js +188 -19
  40. package/dist/modules/execution/planner.js +27 -23
  41. package/dist/modules/execution/stuck-detector.js +244 -8
  42. package/dist/modules/execution/tracker.js +8 -6
  43. package/dist/modules/execution/verifier.js +15 -2
  44. package/dist/modules/hallucination/detector.js +4 -0
  45. package/dist/modules/hallucination/factual.js +45 -5
  46. package/dist/modules/indexer/module.js +1 -0
  47. package/dist/modules/lsp/client.js +123 -12
  48. package/dist/modules/lsp/index.js +1 -1
  49. package/dist/modules/lsp/module.js +30 -2
  50. package/dist/modules/lsp/probe.js +11 -1
  51. package/dist/modules/lsp/startup-check.js +5 -2
  52. package/dist/modules/plugins/builtin/lint-on-write.js +144 -41
  53. package/dist/modules/plugins/manager.js +57 -13
  54. package/dist/modules/pricing/index.js +61 -0
  55. package/dist/modules/pricing/prices.js +129 -0
  56. package/dist/modules/providers/create.js +22 -0
  57. package/dist/modules/providers/fallback.js +79 -0
  58. package/dist/modules/providers/health.js +46 -0
  59. package/dist/modules/providers/index.js +5 -0
  60. package/dist/modules/providers/manager.js +161 -0
  61. package/dist/modules/providers/presets.js +128 -0
  62. package/dist/modules/providers/registry.js +22 -0
  63. package/dist/modules/providers/types.js +1 -0
  64. package/dist/modules/registry.js +1 -0
  65. package/dist/modules/security/command-validator.js +14 -0
  66. package/dist/modules/security/encryption.js +6 -6
  67. package/dist/modules/security/network-validator.js +17 -0
  68. package/dist/modules/security/path-validator.js +22 -26
  69. package/dist/modules/session/store.js +10 -10
  70. package/dist/tools/approve.js +1 -0
  71. package/dist/tools/attach-image.js +12 -0
  72. package/dist/tools/bash.js +27 -4
  73. package/dist/tools/browser.js +1 -0
  74. package/dist/tools/chunk-query.js +1 -0
  75. package/dist/tools/create-dir.js +1 -0
  76. package/dist/tools/delete-file.js +1 -0
  77. package/dist/tools/download-file.js +1 -0
  78. package/dist/tools/edit-file.js +2 -1
  79. package/dist/tools/enable-tools.js +1 -0
  80. package/dist/tools/executor.js +17 -7
  81. package/dist/tools/file-info.js +1 -0
  82. package/dist/tools/glob-tool.js +1 -0
  83. package/dist/tools/grep-tool.js +54 -13
  84. package/dist/tools/list-dir.js +1 -0
  85. package/dist/tools/load-skill.js +1 -0
  86. package/dist/tools/mcp-call.js +1 -0
  87. package/dist/tools/move-file.js +1 -0
  88. package/dist/tools/path-utils.js +51 -1
  89. package/dist/tools/pipeline-run.js +1 -0
  90. package/dist/tools/process-kill.js +11 -0
  91. package/dist/tools/process-list.js +1 -0
  92. package/dist/tools/process-log.js +9 -0
  93. package/dist/tools/question.js +1 -0
  94. package/dist/tools/read-file.js +94 -6
  95. package/dist/tools/recall.js +1 -0
  96. package/dist/tools/remember.js +1 -0
  97. package/dist/tools/scope-check.js +7 -5
  98. package/dist/tools/search-history.js +1 -0
  99. package/dist/tools/subagent.js +4 -4
  100. package/dist/tools/web-browse.js +1 -0
  101. package/dist/tools/web-fetch.js +27 -6
  102. package/dist/tools/web-search.js +70 -43
  103. package/dist/tools/write-file.js +1 -0
  104. package/dist/ui/line-editor.js +142 -23
  105. package/dist/ui/line-math.js +8 -4
  106. package/dist/ui/renderer.js +57 -7
  107. package/package.json +50 -48
@@ -1,10 +1,11 @@
1
+ import { ProviderManager } from "../modules/providers/manager";
1
2
  import { t } from "../i18n/index";
2
3
  import { pc } from "../ui/colors";
3
4
  import { PromptBuilder } from "./prompt-builder";
4
5
  import { processRegistry } from "../modules/processes";
5
6
  import { SessionLogger } from "./session-logger";
6
7
  import { runWithMoE } from "./agent-moe";
7
- import { StepVerifier } from "../modules/execution/verifier";
8
+ import { CostTracker } from "../modules/pricing/index";
8
9
  const TOOL_RESULT_MAX_TOKENS_RATIO = 0.3;
9
10
  const TOOL_RESULT_ABSOLUTE_MAX_CHARS = 15000;
10
11
  const QUALITY_TRIGGER_THRESHOLD = 40;
@@ -13,6 +14,7 @@ const QUALITY_TRIGGER_THRESHOLD = 40;
13
14
  * (observed: 56 compactions in ~28 min) and the compaction itself can't
14
15
  * restore quality, so the agent burns the whole budget compacting. */
15
16
  const FORCED_COMPACTION_COOLDOWN = 3;
17
+ const MAX_LLM_ERROR_RETRIES = 2;
16
18
  /** True when the text looks like a raw JSON tool payload (garbage to display). */
17
19
  function isToolCallJson(text) {
18
20
  const trimmed = text.trim();
@@ -46,13 +48,25 @@ export class Agent {
46
48
  shutdownRequested = false;
47
49
  abortController = null;
48
50
  lastCompactionShown = 0;
51
+ costTracker;
49
52
  constructor(deps) {
50
53
  this.deps = deps;
54
+ this.costTracker = new CostTracker(deps.config.model, deps.config.pricing);
51
55
  }
52
56
  /** Expose context manager for REPL image attachment and other direct access. */
53
57
  get contextManager() {
54
58
  return this.deps.contextManager;
55
59
  }
60
+ callerProvenance() {
61
+ return {
62
+ provider: this.deps.config.provider?.type || "unknown",
63
+ model: this.deps.config.model,
64
+ };
65
+ }
66
+ /** Typed module accessor for CLI/REPL commands (e.g. /lsp). */
67
+ getModule(name) {
68
+ return this.deps.moduleRegistry?.get(name);
69
+ }
56
70
  setScope() {
57
71
  if (this.deps.scope) {
58
72
  this.deps.toolExecutor.setScope(this.deps.scope);
@@ -168,15 +182,25 @@ export class Agent {
168
182
  onPhase?.(phase);
169
183
  }
170
184
  async run(input, onChunk, onMeta, onTool, onPhase) {
185
+ // Reset shutdown flag from previous interrupt
186
+ this.shutdownRequested = false;
171
187
  this.setScope();
172
188
  const { config, llmProvider, toolExecutor, pluginManager, contextManager, logger, sessionManager, baseDir, } = this.deps;
173
- const slog = new SessionLogger(sessionManager, logger);
189
+ const slog = new SessionLogger(sessionManager, logger, () => this.callerProvenance());
174
190
  if (sessionManager && !sessionManager.getActive()) {
175
191
  sessionManager.create();
176
192
  logger.debug(`Session started: ${sessionManager.getActive()}`);
177
193
  }
178
194
  if (!this.systemPromptAdded &&
179
195
  !contextManager.getActiveHistory().some((m) => m.role === "system")) {
196
+ // Log session startup diagnostics (environment, plugins, skills) as the
197
+ // first entry in session.jsonl so the file is self-describing for
198
+ // post-mortem analysis. LSP probe is logged separately from repl.ts
199
+ // since it may complete after this point. Dedup: logSessionStart
200
+ // checks for existing session_start entry to avoid double-logging.
201
+ slog.logSessionStart({
202
+ environment: this.deps.envReport,
203
+ });
180
204
  const lazy = this.deps.lazyPromptBlocks ? await this.deps.lazyPromptBlocks() : [];
181
205
  if (lazy.length > 0) {
182
206
  this.deps.promptBlocks.push(...lazy);
@@ -193,18 +217,6 @@ export class Agent {
193
217
  sessionManager: sessionManager?.getActiveMeta(),
194
218
  contextManager,
195
219
  });
196
- if (config.session?.baselineCheck !== false) {
197
- const verifier = new StepVerifier(baseDir);
198
- verifier
199
- .runTypeCheck()
200
- .then((tc) => {
201
- if (!tc.passed) {
202
- logger.warn(`Baseline typecheck has issues: ${tc.message?.slice(0, 500)}`);
203
- onMeta?.(pc.yellow(`\n⚠ Baseline typecheck has issues\n`));
204
- }
205
- })
206
- .catch(() => { });
207
- }
208
220
  }
209
221
  // A new user message starts a fresh compaction interval — iterations
210
222
  // from the previous turn must not trigger a compaction on iteration 1
@@ -215,20 +227,50 @@ export class Agent {
215
227
  }
216
228
  contextManager.addMessage({ role: "user", content: input });
217
229
  slog.logUser(input);
218
- if (config.moe?.enabled) {
219
- return runWithMoE({
220
- config,
221
- llmProvider,
222
- toolExecutor,
230
+ // Turn lifecycle for plugins: count tool events without touching the loop,
231
+ // and dispatch onTurnEnd exactly once — success, error or interrupt.
232
+ const startedAt = Date.now();
233
+ let toolCalls = 0;
234
+ const countTool = (ev) => {
235
+ if (ev.type === "end")
236
+ toolCalls++;
237
+ onTool?.(ev);
238
+ };
239
+ const emitTurnEnd = (result, err) => {
240
+ const interrupted = (err instanceof Error && err.name === "AbortError") || this.shutdownRequested;
241
+ pluginManager.runOnTurnEnd?.({
223
242
  logger,
224
- baseDir: this.deps.baseDir,
225
- }, input, () => this.executeSingleAgentLoop(input, onChunk, onMeta, onTool, onPhase), { onMeta, onTool, onPhase });
243
+ sessionManager: sessionManager?.getActiveMeta(),
244
+ contextManager,
245
+ }, {
246
+ success: !!result && result.success !== false && !err,
247
+ durationMs: Date.now() - startedAt,
248
+ textLength: (result?.text || "").length,
249
+ toolCalls,
250
+ interrupted: interrupted || undefined,
251
+ });
252
+ };
253
+ try {
254
+ const result = config.moe?.enabled === true
255
+ ? await runWithMoE({
256
+ config,
257
+ llmProvider,
258
+ toolExecutor,
259
+ logger,
260
+ baseDir: this.deps.baseDir,
261
+ }, input, () => this.executeSingleAgentLoop(input, onChunk, onMeta, countTool, onPhase), { onMeta, onTool: countTool, onPhase })
262
+ : await this.executeSingleAgentLoop(input, onChunk, onMeta, countTool, onPhase);
263
+ emitTurnEnd(result);
264
+ return result;
265
+ }
266
+ catch (err) {
267
+ emitTurnEnd(null, err);
268
+ throw err;
226
269
  }
227
- return this.executeSingleAgentLoop(input, onChunk, onMeta, onTool, onPhase);
228
270
  }
229
271
  async executeSingleAgentLoop(input, onChunk, onMeta, onTool, onPhase) {
230
272
  const { config, llmProvider, toolExecutor, pluginManager, contextManager, hallucinationDetector, logger, sessionManager, baseDir, } = this.deps;
231
- const slog = new SessionLogger(sessionManager, logger);
273
+ const slog = new SessionLogger(sessionManager, logger, () => this.callerProvenance());
232
274
  this.abortController = new AbortController();
233
275
  let iteration = 0;
234
276
  let lastText = "";
@@ -263,6 +305,7 @@ export class Agent {
263
305
  let suppressRepetitionRetry = false;
264
306
  let repeatedToolCount = 0;
265
307
  const MAX_REPEATED_TOOL_CALLS = 2;
308
+ let llmErrorRetries = 0;
266
309
  // Account for tool definitions in context budget (they're sent via body.tools, not messages)
267
310
  // Tool definitions are recomputed each iteration so `enable_tools`
268
311
  // (which mutates the shared activeToolTags array) can grow the
@@ -295,6 +338,8 @@ export class Agent {
295
338
  if (result) {
296
339
  logger.debug("Context compacted");
297
340
  slog.logCompaction({ reason: "interval", iteration, ...result });
341
+ // Seed hallucination checker with files from compaction summary
342
+ hallucinationDetector.addKnownFiles(contextManager.getKnownFiles());
298
343
  }
299
344
  }
300
345
  const currentTokens = contextManager.getEstimatedTokens();
@@ -312,6 +357,7 @@ export class Agent {
312
357
  iteration,
313
358
  ...result,
314
359
  });
360
+ hallucinationDetector.addKnownFiles(contextManager.getKnownFiles());
315
361
  }
316
362
  }
317
363
  if (currentTokens > budget.history) {
@@ -323,6 +369,7 @@ export class Agent {
323
369
  iteration,
324
370
  ...result,
325
371
  });
372
+ hallucinationDetector.addKnownFiles(contextManager.getKnownFiles());
326
373
  }
327
374
  }
328
375
  this.refreshSystemPrompt();
@@ -393,6 +440,21 @@ export class Agent {
393
440
  logger.info("LLM call aborted (interrupt)");
394
441
  break;
395
442
  }
443
+ // Recoverable LLM errors (e.g. the completion hit the token limit
444
+ // mid-tool_call): feed the localized reason back into context so the
445
+ // model can adapt (split the write into smaller chunks) instead of
446
+ // the whole session dying. Bounded to avoid infinite loops.
447
+ if (err?.recoverableLlm && llmErrorRetries < MAX_LLM_ERROR_RETRIES) {
448
+ llmErrorRetries++;
449
+ logger.warn(`Recoverable LLM error, feeding back (${llmErrorRetries}/${MAX_LLM_ERROR_RETRIES}): ${err.message}`);
450
+ slog.logError(err.message);
451
+ pluginManager.runOnError({ iteration, logger, contextManager }, err);
452
+ contextManager.addMessage({
453
+ role: "user",
454
+ content: `<system-summary>${err.message}</system-summary>`,
455
+ });
456
+ continue;
457
+ }
396
458
  logger.logLLMResponse(config.model, textContent.length, Date.now() - llmStart, err.message, "agent");
397
459
  logger.error(`LLM call failed: ${err.message}`);
398
460
  slog.logError(err.message);
@@ -430,6 +492,7 @@ export class Agent {
430
492
  source,
431
493
  durationMs: Date.now() - llmStart,
432
494
  });
495
+ this.costTracker.record(prompt, completion, this.callerProvenance().provider);
433
496
  }
434
497
  if (this.shutdownRequested) {
435
498
  break;
@@ -497,6 +560,10 @@ export class Agent {
497
560
  });
498
561
  const summaries = [];
499
562
  let anyToolFailed = false;
563
+ // Informational read-only tools whose "failure" is expected behavior
564
+ // (e.g. file_info "Not found" after delete_file) — excluded from
565
+ // per-tool failure counting and consecutive failure tracking.
566
+ const infoTools = new Set(["file_info"]);
500
567
  for (const call of toolCalls) {
501
568
  this.setScope();
502
569
  const startTime = Date.now();
@@ -505,12 +572,19 @@ export class Agent {
505
572
  args: call.arguments,
506
573
  });
507
574
  pluginManager.runOnToolStart({ iteration, logger, contextManager }, { id: call.id, name: call.name, arguments: call.arguments });
508
- onTool?.({ type: "start", tool: call.name, args: call.arguments });
575
+ const toolIcon = this.deps.toolExecutor.getRegistry().get(call.name)?.icon;
576
+ onTool?.({ type: "start", tool: call.name, args: call.arguments, icon: toolIcon });
509
577
  slog.logToolCall(call, iteration);
510
578
  const tokensBeforeTool = contextManager.getEstimatedTokens();
511
579
  const result = await toolExecutor.execute(call, this.abortController?.signal);
580
+ // Interrupt during the tool call: the session is already shutting
581
+ // down (Esc/Ctrl+C), so stop immediately — no rendering, no session
582
+ // log writes, no context update for a result nobody will see. This
583
+ // fixes the "Replaced in ..." lines appearing AFTER "Session ended".
584
+ if (this.shutdownRequested)
585
+ break;
512
586
  const duration = Date.now() - startTime;
513
- if (!result.success)
587
+ if (!result.success && !infoTools.has(call.name))
514
588
  anyToolFailed = true;
515
589
  if (result.success && call.arguments.path) {
516
590
  const filePath = String(call.arguments.path);
@@ -522,8 +596,14 @@ export class Agent {
522
596
  }
523
597
  }
524
598
  pluginManager.runOnToolEnd({ iteration, logger, contextManager }, { id: call.id, name: call.name, arguments: call.arguments }, result, duration);
525
- if (result.display) {
526
- onMeta?.("\n" + result.display + "\n");
599
+ // Presence check, not truthiness: read_file returns display ""
600
+ // on a full read to suppress output entirely (the tool header
601
+ // already shows the path) — falling back on empty string would
602
+ // dump the whole file content into the REPL.
603
+ if (result.display !== undefined) {
604
+ if (result.display.length > 0) {
605
+ onMeta?.("\n" + result.display + "\n");
606
+ }
527
607
  }
528
608
  else {
529
609
  const metaOut = pluginManager.runOnMeta({ iteration, logger, contextManager }, result.output);
@@ -551,6 +631,7 @@ export class Agent {
551
631
  duration,
552
632
  error: !result.success,
553
633
  ctxDelta: tokensAfterTool - tokensBeforeTool,
634
+ costUsd: this.costTracker.total,
554
635
  });
555
636
  summaries.push(`[Tool: ${call.name} (${JSON.stringify(call.arguments)}) → ${truncatedOutput.slice(0, 200)}]`);
556
637
  if (config.session.autoSave) {
@@ -565,6 +646,7 @@ export class Agent {
565
646
  iteration,
566
647
  ...result,
567
648
  });
649
+ hallucinationDetector.addKnownFiles(contextManager.getKnownFiles());
568
650
  }
569
651
  }
570
652
  // Track per-tool failure counts (NOT just consecutive) so a tool
@@ -572,7 +654,7 @@ export class Agent {
572
654
  // is still learned from (observed: LSP spawn npx ENOENT failed
573
655
  // 8x in one session, never consecutively, so it never reached
574
656
  // memory via the 5-consecutive-failures path).
575
- if (!result.success) {
657
+ if (!result.success && !infoTools.has(call.name)) {
576
658
  const key = call.name;
577
659
  const prev = toolFailureCounts.get(key) ?? { count: 0, error: "" };
578
660
  prev.count++;
@@ -588,6 +670,8 @@ export class Agent {
588
670
  }
589
671
  }
590
672
  }
673
+ if (this.shutdownRequested)
674
+ break;
591
675
  if (anyToolFailed) {
592
676
  consecutiveToolFailures++;
593
677
  }
@@ -807,6 +891,8 @@ export class Agent {
807
891
  promptTokens: usageTokens.prompt,
808
892
  completionTokens: usageTokens.completion,
809
893
  totalTokens: usageTokens.total,
894
+ totalCost: this.costTracker.total,
895
+ costBreakdown: this.costTracker.breakdown(),
810
896
  compactionCount: contextManager.getCompactionCount(),
811
897
  contextQuality: contextManager.getQuality(),
812
898
  };
@@ -825,6 +911,8 @@ export class Agent {
825
911
  promptTokens: usageTokens.prompt,
826
912
  completionTokens: usageTokens.completion,
827
913
  totalTokens: usageTokens.total,
914
+ totalCost: this.costTracker.total,
915
+ costBreakdown: this.costTracker.breakdown(),
828
916
  compactionCount: contextManager.getCompactionCount(),
829
917
  contextQuality: contextManager.getQuality(),
830
918
  };
@@ -843,13 +931,56 @@ export class Agent {
843
931
  contextWindow: config.contextWindow,
844
932
  retry: config.retry,
845
933
  rateLimits: config.security?.rateLimits,
934
+ maxCompletionTokens: config.provider.entries?.find((e) => e.label === config.provider.active)?.maxCompletionTokens ??
935
+ config.provider.maxCompletionTokens,
846
936
  });
847
937
  this.deps.llmProvider = newProvider;
848
938
  this.deps.toolExecutor.updateProvider(newProvider);
849
- this.deps.toolExecutor.ctx.llmProvider = newProvider;
850
939
  const newTokenCounter = new TokenCounter(config.model);
851
940
  this.deps.contextManager.resize(config.contextWindow, config.contextBudget, newTokenCounter);
852
941
  this.deps.config = config;
942
+ this.costTracker.setModel(config.model);
943
+ }
944
+ setProvider(name, model) {
945
+ const manager = new ProviderManager(this.deps.config.provider, {
946
+ contextWindow: this.deps.config.contextWindow,
947
+ retry: this.deps.config.retry,
948
+ rateLimits: this.deps.config.security?.rateLimits,
949
+ });
950
+ manager.switch(name, model);
951
+ const providerCfg = manager.toConfig();
952
+ this.deps.config.provider = providerCfg;
953
+ this.deps.llmProvider = manager.active;
954
+ this.deps.toolExecutor.updateProvider(manager.active);
955
+ this.deps.toolExecutor.ctx.llmProvider = manager.active;
956
+ }
957
+ listProviders() {
958
+ const manager = new ProviderManager(this.deps.config.provider, {
959
+ contextWindow: this.deps.config.contextWindow,
960
+ retry: this.deps.config.retry,
961
+ rateLimits: this.deps.config.security?.rateLimits,
962
+ });
963
+ const activeName = this.deps.config.provider.active;
964
+ return manager.list().map((e) => ({
965
+ ...e,
966
+ active: e.label === activeName || e.type === activeName,
967
+ }));
968
+ }
969
+ /** Build (or get cached) the provider for a named entry — health checks, routing. */
970
+ getProviderFor(name) {
971
+ try {
972
+ const manager = new ProviderManager(this.deps.config.provider, {
973
+ contextWindow: this.deps.config.contextWindow,
974
+ retry: this.deps.config.retry,
975
+ rateLimits: this.deps.config.security?.rateLimits,
976
+ });
977
+ manager.setModel(this.deps.config.model);
978
+ manager.switch(name);
979
+ return manager.active;
980
+ }
981
+ catch {
982
+ return undefined;
983
+ }
853
984
  }
854
985
  setContext(messages) {
855
986
  const { contextManager } = this.deps;
@@ -876,7 +1007,8 @@ export class Agent {
876
1007
  if (killed > 0) {
877
1008
  logger.info(`Killed ${killed} background process(es) on shutdown`);
878
1009
  }
879
- logger.closeSessionLog();
1010
+ // Note: do NOT close session log here — the session is still active.
1011
+ // Session log is closed only when the session actually ends (exit, session delete).
880
1012
  pluginManager.runOnSessionEnd({
881
1013
  logger,
882
1014
  sessionManager: sessionManager?.getActiveMeta(),
@@ -1,6 +1,7 @@
1
1
  import { loadConfig } from "../config/config";
2
2
  import { Logger } from "../logger/app-logger";
3
- import { OpenAICompatProvider } from "../llm/openai-compat";
3
+ import { ProviderManager } from "../modules/providers/index";
4
+ import { FallbackProvider } from "../modules/providers/fallback";
4
5
  import { ModelLoader } from "../llm/model-loader";
5
6
  import { ToolRegistry, registerAllTools } from "../tools/index";
6
7
  import { buildHiddenToolsBlock } from "../tools/hidden-tools-block";
@@ -18,7 +19,7 @@ import { SessionStore, SessionManager, SessionModule } from "../modules/session/
18
19
  import { UserProfile } from "../modules/user-profile/profile";
19
20
  import { SkillsLoader, SkillsModule } from "../modules/skills/index";
20
21
  import { BrowserModule } from "../modules/browser/index";
21
- import { LspModule, DEFAULT_LSP_CONFIG } from "../modules/lsp/index";
22
+ import { LspModule, FileOnlyLogger, DEFAULT_LSP_CONFIG } from "../modules/lsp/index";
22
23
  import { runStartupHealthCheck } from "../modules/lsp/startup-check";
23
24
  import { IndexerModule } from "../modules/indexer/index";
24
25
  import { MCPModule } from "../modules/mcp/index";
@@ -30,6 +31,7 @@ import { homedir } from "os";
30
31
  import { join, resolve } from "path";
31
32
  import { existsSync, readFileSync, writeFileSync } from "fs";
32
33
  import { readMmaVersion } from "./version";
34
+ import { collectEnvironment, logEnvironment } from "./environment";
33
35
  export function buildSystemInfo(config, baseDir, profileCompressed) {
34
36
  const now = new Date().toISOString().replace("T", " ").slice(0, 19);
35
37
  const isWin = profileCompressed.toLowerCase().includes("win32");
@@ -37,6 +39,8 @@ export function buildSystemInfo(config, baseDir, profileCompressed) {
37
39
  `You are MMA v2, an AI coding agent for small models (${config.model}). Date: ${now}. Workspace: ${baseDir}. ${profileCompressed}.`,
38
40
  `Reply in the user's language. Use tools for file ops (read/write/edit/delete), search (glob/grep), shell (bash), web, subagents, browser, MCP. Explain briefly if not obvious. On tool failure: analyze, fix the call, retry up to 2x with different approaches, then ask the user.`,
39
41
  `Design: YAGNI (no unneeded code), KISS (simple over clever), DRY (reuse existing utilities).`,
42
+ `SCOPE DISCIPLINE: do ONLY what the user explicitly asked — no extra features, files, refactors, "improvements", or fixes beyond the request. Read-only requests ("расскажи", "покажи", "объясни", "check") mean READ-ONLY: inspect and answer, never create/modify/delete anything. If the task is ambiguous (what to create, where, which variant) or the request implies action on something you could not find — STOP and ask the user a short clarifying question in plain text instead of guessing.`,
43
+ `NOT FOUND ≠ MISSING PROJECT: a "not found" tool result means the PATH was wrong (typo, different location), not that the project does not exist. Before creating or scaffolding ANY project/files: first run list_dir on the working directory to see what is already there; if a user-named path is not found, list its parent directory to locate the real path. NEVER create a new project when the user asked about an existing one — inspect first, create only after confirming the workspace is empty AND the user asked for creation.`,
40
44
  ];
41
45
  if (isWin) {
42
46
  lines.push(`Windows (PowerShell): use list_dir/read_file/delete_file/create_dir tools instead of dir/type/del/mkdir. No PowerShell cmdlets (Get-Content, Select-Object, Write-Output), no head/tail/grep/cat. Use forward slashes in paths. CWD: ${baseDir}`);
@@ -59,6 +63,35 @@ export function buildSystemInfo(config, baseDir, profileCompressed) {
59
63
  // postcss after the fix was on disk, while a fresh server on 5177 was
60
64
  // clean) — the fix is only real when the USER's running server is clean.
61
65
  lines.push(`Runtime verification: when a bug is described in terms of runtime behavior (what happens when the app runs), reproduce it — run the app / build / browser tool — and confirm the fix against actual runtime output before calling "plan update step=N status=done". Check existing servers first with process_list/process_log: a stale dev server started BEFORE your change still runs the old pipeline and keeps failing even after the files on disk are fixed. Restart or kill it (process_kill) and verify the exact endpoint/port the user reported — a fresh server on a new port does NOT prove the user's error is gone.`);
66
+ // Security rules: when security is enabled the model should know the
67
+ // restrictions up front — a blocked call costs an iteration, while this
68
+ // block costs ~100 tokens once. Kept compact and capped so a long user
69
+ // blacklist cannot blow the system budget.
70
+ const sec = config.security;
71
+ if (sec?.enabled) {
72
+ const secLines = [`SECURITY RESTRICTIONS (enforced — blocked calls return "[SECURITY BLOCKED]", do not retry them):`];
73
+ if (sec.bash?.enabled && sec.bash.blacklist.length > 0) {
74
+ const cmds = sec.bash.blacklist.slice(0, 15).join(", ");
75
+ secLines.push(`Forbidden bash commands: ${cmds}${sec.bash.blacklist.length > 15 ? ", …" : ""}.`);
76
+ if (sec.bash.whitelist.length > 0) {
77
+ secLines.push(`Only whitelisted bash commands are allowed: ${sec.bash.whitelist.slice(0, 15).join(", ")}.`);
78
+ }
79
+ if (sec.bash.blockDangerousFlags) {
80
+ secLines.push(`Pipes/redirects/chaining (| > >> ; &&) in bash are blocked — one plain command per call.`);
81
+ }
82
+ }
83
+ if (sec.paths?.enabled && sec.paths.denied.length > 0) {
84
+ const paths = sec.paths.denied.slice(0, 10).join(", ");
85
+ secLines.push(`Denied paths (read/write): ${paths}${sec.paths.denied.length > 10 ? ", …" : ""}.`);
86
+ }
87
+ if (sec.network?.enabled && sec.network.deniedDomains.length > 0) {
88
+ const domains = sec.network.deniedDomains.slice(0, 10).join(", ");
89
+ secLines.push(`Denied network domains: ${domains}${sec.network.deniedDomains.length > 10 ? ", …" : ""}.`);
90
+ }
91
+ if (secLines.length > 1) {
92
+ lines.push(...secLines);
93
+ }
94
+ }
62
95
  const hasMCP = config.mcpServers && Object.values(config.mcpServers).some((s) => s.enabled !== false);
63
96
  if (hasMCP) {
64
97
  lines.push(`MCP servers are available — use the named MCP tools (prefixed mcp__) to query external services.`);
@@ -66,9 +99,9 @@ export function buildSystemInfo(config, baseDir, profileCompressed) {
66
99
  return lines.join("\n");
67
100
  }
68
101
  export async function bootstrap(configDir, projectDir, noAgentsMd, exitOnComplete) {
69
- const dir = configDir || join(homedir(), ".mma");
102
+ const dir = configDir || process.env.MMA_CONFIG_DIR || join(homedir(), ".mma");
70
103
  const projectConfigPath = projectDir ? join(projectDir, ".mmrc") : join(process.cwd(), ".mmrc");
71
- const config = loadConfig({ configDir: dir, projectConfigPath });
104
+ const { config, legacyDetected } = loadConfig({ configDir: dir, projectConfigPath });
72
105
  setLocale(config.locale);
73
106
  // Update global audit notifier with config
74
107
  try {
@@ -108,15 +141,36 @@ export async function bootstrap(configDir, projectDir, noAgentsMd, exitOnComplet
108
141
  const profile = new UserProfile(join(dir));
109
142
  profile.load() || profile.collect();
110
143
  profile.save();
111
- const llmProvider = new OpenAICompatProvider({
112
- model: config.model,
113
- baseUrl: config.provider.baseUrl,
114
- apiKey: config.provider.apiKey,
144
+ // Multi-provider manager (Phase 2). Built from the (possibly legacy flat)
145
+ // config; the active provider is resolved through it. The initial provider
146
+ // comes from the manager so the active entry is honored on startup.
147
+ const providerManager = new ProviderManager(config.provider, {
115
148
  contextWindow: config.contextWindow,
116
149
  retry: config.retry,
117
150
  rateLimits: config.security?.rateLimits,
118
151
  });
152
+ providerManager.setModel(config.model);
153
+ let llmProvider = providerManager.active;
154
+ // Phase 4.2: transparent failover to the next configured provider on
155
+ // 429/5xx/network errors (only with multiple entries + fallback: true).
156
+ if (config.provider.fallback && providerManager.listNames().length > 1) {
157
+ llmProvider = new FallbackProvider(providerManager, (from, to, error) => {
158
+ logger.warn(`provider failover: ${from} -> ${to} (${error.message.slice(0, 120)})`);
159
+ });
160
+ }
119
161
  const baseDir = projectDir ? resolve(projectDir) : process.cwd();
162
+ // Cross-device diagnosis: log a full environment report (OS, runtime,
163
+ // engines, PATH tools, provider/retry, playwright) at every startup.
164
+ const envReport = collectEnvironment({
165
+ configDir: dir,
166
+ baseDir,
167
+ config,
168
+ scanTools: process.env.NODE_ENV !== "test",
169
+ });
170
+ logEnvironment(envReport, logger);
171
+ for (const warning of envReport.warnings) {
172
+ logger.warn(warning);
173
+ }
120
174
  const projectMapCacheDir = join(baseDir, ".mma");
121
175
  const indexerModule = new IndexerModule({
122
176
  baseDir,
@@ -184,8 +238,6 @@ export async function bootstrap(configDir, projectDir, noAgentsMd, exitOnComplet
184
238
  exitOnComplete,
185
239
  contextManager,
186
240
  activeToolTags,
187
- sessionId: sessionManager.getActiveMeta()?.id,
188
- sessionContext: sessionManager.getSessionContext() ?? undefined,
189
241
  recursionDepth: 0,
190
242
  fileOperationsCount: 0,
191
243
  sessionHistory: {
@@ -213,17 +265,35 @@ export async function bootstrap(configDir, projectDir, noAgentsMd, exitOnComplet
213
265
  },
214
266
  },
215
267
  };
268
+ // `sessionId`/`sessionContext` are exposed as LIVE getters over the session
269
+ // manager instead of being captured once at bootstrap. Capturing the values
270
+ // pinned them to the session active at startup, so after a REPL /new or
271
+ // /resume the new session's audit.jsonl entries were tagged with the
272
+ // PREVIOUS session's id (observed: ses_msyghz8w audit entries carrying
273
+ // ses_msyg892p_78kqhc) while sessionHistory kept reading the live meta.
274
+ // Everything that consumes ctx.sessionId — audit log, process registry,
275
+ // file-ops counters — now always sees the session that is actually active.
276
+ Object.defineProperty(toolCtx, "sessionId", {
277
+ get: () => sessionManager.getActiveMeta()?.id,
278
+ configurable: true,
279
+ });
280
+ Object.defineProperty(toolCtx, "sessionContext", {
281
+ get: () => sessionManager.getSessionContext() ?? undefined,
282
+ configurable: true,
283
+ });
216
284
  const toolExecutor = new ToolExecutor(toolRegistry, toolCtx, pluginManager);
217
285
  toolCtx.llmProvider = llmProvider;
218
286
  toolCtx.toolExecutor = toolExecutor;
219
287
  const hallucinationDetector = new HallucinationDetector(baseDir, llmProvider);
220
288
  const moduleRegistry = new ModuleRegistry();
221
- const execModule = new ExecutionModule(baseDir, config.stuckThreshold);
222
- const activeMeta = sessionManager.getActiveMeta();
223
- if (activeMeta && activeMeta.messageCount > 0) {
224
- execModule.restorePlan();
225
- }
289
+ const execModule = new ExecutionModule(baseDir, config.stuckThreshold, config.errorWebSearch?.threshold ?? 5);
290
+ // A plan in .mma/plans/active.json is PROJECT state, not session state —
291
+ // restore it in every session so `plan show` works and the checklist is
292
+ // visible. Nothing is printed unprompted: the REPL renders the checklist
293
+ // only on plan/todo tool events.
294
+ execModule.restorePlan();
226
295
  moduleRegistry.register(execModule);
296
+ execModule.setHallucinationDetector(hallucinationDetector);
227
297
  const sessionModule = new SessionModule(sessionManager);
228
298
  moduleRegistry.register(sessionModule);
229
299
  moduleRegistry.register(indexerModule);
@@ -242,7 +312,8 @@ export async function bootstrap(configDir, projectDir, noAgentsMd, exitOnComplet
242
312
  pluginManager.register(browserPlugin);
243
313
  }
244
314
  }
245
- const lspModule = new LspModule(config.lsp);
315
+ const lspLogger = new FileOnlyLogger(logger);
316
+ const lspModule = new LspModule(config.lsp, undefined, lspLogger);
246
317
  moduleRegistry.register(lspModule);
247
318
  const lspPlugin = lspModule.getPlugin();
248
319
  if (lspPlugin) {
@@ -257,7 +328,7 @@ export async function bootstrap(configDir, projectDir, noAgentsMd, exitOnComplet
257
328
  // the time the user types in the interactive REPL).
258
329
  let startupCheckBlock = null;
259
330
  const startupCheckPromise = !exitOnComplete && process.env.NODE_ENV !== "test"
260
- ? runStartupHealthCheck(config.lsp ?? DEFAULT_LSP_CONFIG, baseDir)
331
+ ? runStartupHealthCheck(config.lsp ?? DEFAULT_LSP_CONFIG, baseDir, { logger: lspLogger })
261
332
  : Promise.resolve(null);
262
333
  const moduleTools = moduleRegistry.collectToolDefinitions();
263
334
  for (const tool of moduleTools) {
@@ -356,6 +427,7 @@ export async function bootstrap(configDir, projectDir, noAgentsMd, exitOnComplet
356
427
  baseDir,
357
428
  toolTags: activeToolTags,
358
429
  promptBlocks,
430
+ envReport,
359
431
  getDynamicPromptBlocks: () => {
360
432
  const blocks = [];
361
433
  const planBlock = execModule.getSystemPromptBlock();
@@ -387,6 +459,7 @@ export async function bootstrap(configDir, projectDir, noAgentsMd, exitOnComplet
387
459
  },
388
460
  sessionManager,
389
461
  memoryStore,
462
+ moduleRegistry,
390
463
  exitOnComplete,
391
464
  };
392
465
  const agent = new Agent(agentDeps);
@@ -397,8 +470,12 @@ export async function bootstrap(configDir, projectDir, noAgentsMd, exitOnComplet
397
470
  sessionManager,
398
471
  skillsModule,
399
472
  pluginManager,
473
+ execModule,
474
+ toolCtx,
400
475
  configDir: dir,
401
476
  baseDir,
402
477
  noAgentsMd: skipAgentsMd,
478
+ envReport,
479
+ legacyDetected,
403
480
  };
404
481
  }
@@ -0,0 +1,51 @@
1
+ import { appendFileSync, mkdirSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ import { collectEnvironment } from "./environment";
5
+ import { sanitizeLogMessage } from "../modules/security/data-sanitizer";
6
+ import { t } from "../i18n/index";
7
+ export const CRASH_LOG_DIR = join(homedir(), ".mma", "logs");
8
+ export const CRASH_LOG_FILE = "crash.jsonl";
9
+ /** Build a sanitized crash entry. Environment is captured at crash time (no tool scan). */
10
+ export function formatCrashEntry(type, err) {
11
+ const message = err instanceof Error ? err.message : String(err);
12
+ const stack = err instanceof Error && err.stack ? err.stack : message;
13
+ return {
14
+ ts: new Date().toISOString(),
15
+ type,
16
+ message: sanitizeLogMessage(message),
17
+ stack: sanitizeLogMessage(stack),
18
+ environment: collectEnvironment({ configDir: homedir(), scanTools: false }),
19
+ };
20
+ }
21
+ export function writeCrashEntry(dir, entry) {
22
+ try {
23
+ mkdirSync(dir, { recursive: true });
24
+ appendFileSync(join(dir, CRASH_LOG_FILE), JSON.stringify(entry) + "\n", "utf-8");
25
+ }
26
+ catch {
27
+ // Best effort — never crash while reporting a crash
28
+ }
29
+ }
30
+ let installed = false;
31
+ /**
32
+ * Global crash handlers. uncaughtException is logged + re-raised (exit 1 so the
33
+ * process never continues in a broken state); unhandledRejection is logged only
34
+ * (the event loop keeps running). Entries land in ~/.mma/logs/crash.jsonl.
35
+ */
36
+ export function installCrashHandlers() {
37
+ if (installed)
38
+ return;
39
+ installed = true;
40
+ process.on("uncaughtException", (err) => {
41
+ const entry = formatCrashEntry("uncaughtException", err);
42
+ writeCrashEntry(CRASH_LOG_DIR, entry);
43
+ process.stderr.write(`${t("env.crash_stderr", { type: entry.type, message: entry.message })}\n`);
44
+ process.exit(1);
45
+ });
46
+ process.on("unhandledRejection", (reason) => {
47
+ const entry = formatCrashEntry("unhandledRejection", reason);
48
+ writeCrashEntry(CRASH_LOG_DIR, entry);
49
+ process.stderr.write(`${t("env.crash_stderr", { type: entry.type, message: entry.message })}\n`);
50
+ });
51
+ }