micro-models-agent 0.13.1 → 0.13.2

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.
@@ -22,7 +22,7 @@ import { MCPModule } from "../modules/mcp/index";
22
22
  import { setLocale } from "../i18n/index";
23
23
  import { Agent } from "./agent";
24
24
  import { homedir } from "os";
25
- import { join } from "path";
25
+ import { join, resolve } from "path";
26
26
  import { existsSync, readFileSync, writeFileSync } from "fs";
27
27
  function buildSystemInfo(config, baseDir, profileCompressed) {
28
28
  const now = new Date().toISOString().replace("T", " ").slice(0, 19);
@@ -45,7 +45,7 @@ function buildSystemInfo(config, baseDir, profileCompressed) {
45
45
  `- Do not duplicate code, logic, or configuration (DRY — Don't Repeat Yourself). Extract shared logic into a single place, reuse existing utilities and patterns before writing new ones.`,
46
46
  ];
47
47
  if (isWin) {
48
- lines.push(`OS-specific: You are on Windows. Do not use Unix-only command flags like "mkdir -p". Use the create_dir tool to create directories.`);
48
+ lines.push(``, `Windows environment — use Windows-compatible commands:`, `- Use "dir" instead of "ls". Use "dir /b" for bare listing.`, `- Use "type" or "Get-Content" instead of "cat".`, `- Use "cd" instead of "pwd". Use "echo %cd%" to print working directory.`, `- Use "copy" instead of "cp", "move" instead of "mv", "del" instead of "rm".`, `- Do not use "mkdir -p" — Windows mkdir creates intermediate dirs by default. Use the create_dir tool instead.`, `- Use forward slashes (/) or escaped backslashes (\\\\) in file paths.`, `- Your working directory is: ${baseDir}`);
49
49
  }
50
50
  if (config.autoPlan) {
51
51
  lines.push(``, `Plan rule: For any task with 2+ steps, create a plan first using the "plan" tool. After each step, call "plan update" to mark progress. Stay focused on the current step.`);
@@ -110,7 +110,7 @@ export async function bootstrap(configDir, projectDir, noAgentsMd, exitOnComplet
110
110
  retry: config.retry,
111
111
  rateLimits: config.security?.rateLimits,
112
112
  });
113
- const baseDir = projectDir || process.cwd();
113
+ const baseDir = projectDir ? resolve(projectDir) : process.cwd();
114
114
  const projectMapCacheDir = join(baseDir, ".mma");
115
115
  const indexerModule = new IndexerModule({
116
116
  baseDir,
@@ -191,6 +191,7 @@ export async function bootstrap(configDir, projectDir, noAgentsMd, exitOnComplet
191
191
  toolCtx.trackReadPath = (p) => factualCheck.trackReadPath(p);
192
192
  toolCtx.trackCreatedPath = (p) => factualCheck.trackCreatedPath(p);
193
193
  toolCtx.trackDeletedPath = (p) => factualCheck.trackDeletedPath(p);
194
+ toolCtx.trackDocumentContent = (c) => factualCheck.trackDocumentContent(c);
194
195
  const moduleRegistry = new ModuleRegistry();
195
196
  const execModule = new ExecutionModule(baseDir, config.stuckThreshold);
196
197
  moduleRegistry.register(execModule);
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Thin wrapper around SessionManager that eliminates repetitive
3
+ * `if (sessionManager)` + `new Date().toISOString()` boilerplate
4
+ * from the agent loop.
5
+ */
6
+ export class SessionLogger {
7
+ session;
8
+ constructor(session) {
9
+ this.session = session;
10
+ }
11
+ get active() {
12
+ return !!this.session?.getActive();
13
+ }
14
+ logSystem(content) {
15
+ this.session?.appendLog({
16
+ ts: new Date().toISOString(),
17
+ type: "system",
18
+ content,
19
+ });
20
+ }
21
+ logUser(content) {
22
+ this.session?.appendMessage({
23
+ role: "user",
24
+ content,
25
+ timestamp: new Date().toISOString(),
26
+ });
27
+ this.session?.appendLog({
28
+ ts: new Date().toISOString(),
29
+ type: "user",
30
+ content,
31
+ });
32
+ }
33
+ logAssistant(content, reasoning, toolCalls, iteration) {
34
+ if (reasoning) {
35
+ this.session?.appendLog({
36
+ ts: new Date().toISOString(),
37
+ type: "reasoning",
38
+ content: reasoning,
39
+ iteration,
40
+ });
41
+ }
42
+ this.session?.appendLog({
43
+ ts: new Date().toISOString(),
44
+ type: "assistant",
45
+ content,
46
+ ...(toolCalls
47
+ ? { tool_calls: toolCalls.map((tc) => ({ id: tc.id, name: tc.name, arguments: tc.arguments })) }
48
+ : {}),
49
+ iteration,
50
+ });
51
+ }
52
+ saveAssistantMessage(content) {
53
+ this.session?.appendMessage({
54
+ role: "assistant",
55
+ content: content.slice(0, 500),
56
+ timestamp: new Date().toISOString(),
57
+ });
58
+ }
59
+ logToolDefs(toolCount, toolNames, iteration) {
60
+ this.session?.appendLog({
61
+ ts: new Date().toISOString(),
62
+ type: "tool_defs",
63
+ toolCount,
64
+ toolNames,
65
+ iteration,
66
+ });
67
+ }
68
+ logToolCall(call, iteration) {
69
+ this.session?.appendLog({
70
+ ts: new Date().toISOString(),
71
+ type: "tool_call",
72
+ tool: call.name,
73
+ tool_call_id: call.id,
74
+ args: call.arguments,
75
+ iteration,
76
+ });
77
+ }
78
+ logToolResult(call, result, duration, iteration) {
79
+ this.session?.appendMessage({
80
+ role: "tool",
81
+ content: result.output.slice(0, 500),
82
+ name: call.name,
83
+ timestamp: new Date().toISOString(),
84
+ });
85
+ this.session?.appendLog({
86
+ ts: new Date().toISOString(),
87
+ type: "tool_result",
88
+ tool: call.name,
89
+ tool_call_id: call.id,
90
+ success: result.success,
91
+ content: result.output.slice(0, 1000),
92
+ diff: result.diff,
93
+ duration,
94
+ iteration,
95
+ });
96
+ }
97
+ logCompaction(content, iteration, contextTokens, contextLimit) {
98
+ this.session?.appendLog({
99
+ ts: new Date().toISOString(),
100
+ type: "compaction",
101
+ content,
102
+ iteration,
103
+ ...(contextTokens !== undefined ? { contextTokens } : {}),
104
+ ...(contextLimit !== undefined ? { contextLimit } : {}),
105
+ });
106
+ }
107
+ logError(message) {
108
+ this.session?.appendLog({
109
+ ts: new Date().toISOString(),
110
+ type: "error",
111
+ content: message,
112
+ });
113
+ }
114
+ logAudit(summary, iteration) {
115
+ this.session?.appendLog({
116
+ ts: new Date().toISOString(),
117
+ type: "audit",
118
+ content: summary,
119
+ iteration,
120
+ });
121
+ }
122
+ }
package/dist/i18n/en.json CHANGED
@@ -411,6 +411,7 @@
411
411
  "migration.dir_bak": "- .mma/ \u2192 .mma.bak/ ({count} files)",
412
412
  "migration.migrated": "Migrated:",
413
413
  "migration.not_needed": "No migration needed",
414
+ "migration.security_updated": "[MMA] Security config force-updated from v{from} to v{to} (dangerous operators reset to defaults)",
414
415
  "ui.error_prefix": "Error: ",
415
416
  "ui.success_prefix": "\u2713 ",
416
417
  "ui.warning_prefix": "\u26a0 ",
package/dist/i18n/ru.json CHANGED
@@ -411,6 +411,7 @@
411
411
  "migration.dir_bak": "- .mma/ → .mma.bak/ ({count} файлов)",
412
412
  "migration.migrated": "Мигрировано:",
413
413
  "migration.not_needed": "Миграция не требуется",
414
+ "migration.security_updated": "[MMA] Конфигурация безопасности принудительно обновлена с v{from} до v{to} (опасные операторы сброшены)",
414
415
  "ui.error_prefix": "Ошибка: ",
415
416
  "ui.success_prefix": "✓ ",
416
417
  "ui.warning_prefix": "⚠ ",
@@ -201,13 +201,15 @@ export class OpenAICompatProvider {
201
201
  });
202
202
  if (!response.ok) {
203
203
  const errorText = await response.text();
204
- console.error("[doNonStreaming] HTTP error:", response.status, errorText.slice(0, 500));
205
- return [];
204
+ throw new Error(t("error.llm_api", {
205
+ status: response.status,
206
+ statusText: response.statusText,
207
+ errorText: errorText.slice(0, 500),
208
+ }));
206
209
  }
207
210
  const data = await response.json();
208
211
  const choice = data.choices?.[0];
209
212
  if (!choice) {
210
- console.error("[doNonStreaming] No choices in response");
211
213
  return [];
212
214
  }
213
215
  const msg = choice.message || {};
@@ -230,15 +232,10 @@ export class OpenAICompatProvider {
230
232
  });
231
233
  }
232
234
  }
233
- console.error("[doNonStreaming] chunks:", chunks.length, "tool_calls:", msg.tool_calls?.length, "content len:", msg.content?.length);
234
- if (chunks.length === 0) {
235
- console.error("[doNonStreaming] Empty response: no content, no tool_calls, no reasoning");
236
- }
237
235
  return chunks;
238
236
  }
239
237
  catch (err) {
240
- console.error("[doNonStreaming] Fetch error:", err instanceof Error ? err.message : String(err));
241
- return [];
238
+ throw err instanceof Error ? err : new Error(String(err));
242
239
  }
243
240
  }
244
241
  countTokens(text) {
@@ -163,6 +163,19 @@ export class MoEExecutor {
163
163
  if (waves.length === 0) {
164
164
  return { success: false, results: [], errors: ['Failed to topologically sort subtasks (possible cycle)'], warnings: [] };
165
165
  }
166
+ // Verify all subtasks are included — catch silent drops from unresolved dependencies
167
+ const sortedCount = waves.flat().length;
168
+ if (sortedCount < plan.subtasks.length) {
169
+ const missing = plan.subtasks
170
+ .filter(s => !waves.flat().some(w => w.id === s.id))
171
+ .map(s => s.id);
172
+ return {
173
+ success: false,
174
+ results: [],
175
+ errors: [`Missing subtasks after topological sort: ${missing.join(', ')} (dangling or invalid depends_on)`],
176
+ warnings: [],
177
+ };
178
+ }
166
179
  for (let waveIdx = 0; waveIdx < waves.length; waveIdx++) {
167
180
  const wave = waves[waveIdx];
168
181
  const wavePromises = wave.map(subtask => executeSubtask(subtask, this.deps, plan.shared_context)
@@ -1,13 +1,6 @@
1
1
  import { t } from "../../i18n/index";
2
- const UNCERTAINTY_MARKERS = [
3
- "i think",
4
- "maybe",
5
- "probably",
6
- "i believe",
7
- "not sure",
8
- "might be",
9
- ];
10
2
  const MIN_CHARS = 1;
3
+ const MIN_WORDS = 5;
11
4
  export class ConfidenceCheck {
12
5
  previousResponse = "";
13
6
  setPreviousResponse(response) {
@@ -17,6 +10,16 @@ export class ConfidenceCheck {
17
10
  if (!response || response.length < MIN_CHARS) {
18
11
  return { status: "retry", reason: t("hall.short_response") };
19
12
  }
13
+ // Language-agnostic: very short response with no structured content
14
+ const wordCount = response.split(/\s+/).filter(Boolean).length;
15
+ const hasStructure = /```|^\s*[-*]\s|^\s*\d+\.\s|<[^>]+>/m.test(response);
16
+ if (wordCount < MIN_WORDS && !hasStructure) {
17
+ return {
18
+ status: "warn",
19
+ reason: t("hall.short_response"),
20
+ };
21
+ }
22
+ // Language-agnostic: repetition detection via word overlap
20
23
  if (this.previousResponse) {
21
24
  const overlap = this.calculateOverlap(response, this.previousResponse);
22
25
  if (overlap > 0.5) {
@@ -26,13 +29,17 @@ export class ConfidenceCheck {
26
29
  };
27
30
  }
28
31
  }
29
- const lower = response.toLowerCase();
30
- const foundMarkers = UNCERTAINTY_MARKERS.filter((m) => lower.includes(m));
31
- if (foundMarkers.length > 0) {
32
- return {
33
- status: "warn",
34
- reason: t("hall.uncertainty", { markers: foundMarkers.join(", ") }),
35
- };
32
+ // Language-agnostic: very low word diversity (same words repeated)
33
+ const words = response.toLowerCase().split(/\s+/).filter(w => w.length > 2);
34
+ if (words.length >= 10) {
35
+ const unique = new Set(words);
36
+ const diversity = unique.size / words.length;
37
+ if (diversity < 0.25) {
38
+ return {
39
+ status: "warn",
40
+ reason: t("hall.repetitive", { pct: Math.round((1 - diversity) * 100) }),
41
+ };
42
+ }
36
43
  }
37
44
  return { status: "pass" };
38
45
  }
@@ -19,7 +19,35 @@ export class ConsistencyCheck {
19
19
  const lower = response.toLowerCase();
20
20
  for (const d of this.decisions) {
21
21
  const decisionWords = d.decision.toLowerCase().split(/\s+/).filter(w => w.length > 3);
22
- const contradicts = decisionWords.some(word => lower.includes(`instead of ${word}`) || lower.includes(`not ${word}`));
22
+ const contradicts = decisionWords.some(word => {
23
+ // English patterns
24
+ if (lower.includes(`instead of ${word}`))
25
+ return true;
26
+ if (lower.includes(`not ${word}`))
27
+ return true;
28
+ if (lower.includes(`replacing ${word} with`))
29
+ return true;
30
+ if (lower.includes(`switching to`))
31
+ return true;
32
+ if (lower.includes(`changing from ${word}`))
33
+ return true;
34
+ if (lower.includes(`abandoning ${word}`))
35
+ return true;
36
+ // Russian patterns
37
+ if (lower.includes(`вместо ${word}`))
38
+ return true;
39
+ if (lower.includes(`заменяя ${word}`))
40
+ return true;
41
+ if (lower.includes(`заменяем ${word}`))
42
+ return true;
43
+ if (lower.includes(`переключаемся на`))
44
+ return true;
45
+ if (lower.includes(`от ${word} к`))
46
+ return true;
47
+ if (lower.includes(`отказываемся от ${word}`))
48
+ return true;
49
+ return false;
50
+ });
23
51
  if (contradicts) {
24
52
  return {
25
53
  status: 'warn',
@@ -6,6 +6,10 @@ const FILE_EXTENSIONS = new Set([
6
6
  "tsx",
7
7
  "js",
8
8
  "jsx",
9
+ "mjs",
10
+ "cjs",
11
+ "mts",
12
+ "cts",
9
13
  "json",
10
14
  "md",
11
15
  "yaml",
@@ -32,12 +36,17 @@ const FILE_EXTENSIONS = new Set([
32
36
  "cmd",
33
37
  "txt",
34
38
  "env",
39
+ "env.local",
40
+ "env.production",
35
41
  "gitignore",
36
42
  "dockerignore",
37
43
  "dockerfile",
38
44
  "makefile",
39
45
  "cmake",
40
46
  "toml",
47
+ "lock",
48
+ "config",
49
+ "log",
41
50
  "xml",
42
51
  "sql",
43
52
  "graphql",
@@ -45,10 +54,20 @@ const FILE_EXTENSIONS = new Set([
45
54
  "wasm",
46
55
  ]);
47
56
  const VERSION_PATTERN = /^\d+(\.\d+)*$/;
48
- const COMMON_WORDS = new Set(["node.js", "Node.js"]);
57
+ const COMMON_WORDS = new Set([
58
+ "node.js", "Node.js",
59
+ "console.log", "console.error", "console.warn", "console.info",
60
+ "Math.floor", "Math.ceil", "Math.round", "Math.max", "Math.min",
61
+ "JSON.parse", "JSON.stringify",
62
+ "Object.keys", "Object.values", "Object.entries",
63
+ "Array.from", "Array.isArray",
64
+ "Date.now", "Date.parse",
65
+ "RegExp", "Promise",
66
+ ]);
49
67
  export class FactualCheck {
50
68
  knownPaths = new Set();
51
69
  createdPaths = new Set();
70
+ readFiles = new Set();
52
71
  baseDir = process.cwd();
53
72
  setBaseDir(dir) {
54
73
  this.baseDir = dir;
@@ -58,6 +77,8 @@ export class FactualCheck {
58
77
  const base = path.split(/[/\\]/).pop();
59
78
  if (base && base !== path)
60
79
  this.knownPaths.add(base);
80
+ // Track that this file was actually read by the agent
81
+ this.readFiles.add(base || path);
61
82
  }
62
83
  trackCreatedPath(path) {
63
84
  this.knownPaths.add(path);
@@ -73,12 +94,30 @@ export class FactualCheck {
73
94
  this.knownPaths.add(base);
74
95
  this.createdPaths.delete(path);
75
96
  }
97
+ /**
98
+ * Register file paths found in a document (e.g. structure.md, README).
99
+ * Files mentioned in project documentation are not hallucinations.
100
+ */
101
+ trackDocumentContent(content) {
102
+ // Match common path patterns in documentation
103
+ const pathPatterns = /(?:^|\s)([\w\-./]+\.\w{1,10})(?:\s|$|[,;)])/gm;
104
+ let match;
105
+ while ((match = pathPatterns.exec(content)) !== null) {
106
+ const file = match[1];
107
+ if (file.includes('/') || file.includes('\\')) {
108
+ this.knownPaths.add(file);
109
+ }
110
+ const base = file.split(/[/\\]/).pop();
111
+ if (base)
112
+ this.knownPaths.add(base);
113
+ }
114
+ }
76
115
  pathExistsOnDisk(path) {
77
116
  try {
78
- if (path.startsWith("/") ||
79
- path.startsWith("~") ||
80
- /^[A-Za-z]:/.test(path)) {
81
- return existsSync(path);
117
+ // Skip absolute paths — they're either system paths or outside the project.
118
+ // On Windows, existsSync('/...') can hang on certain paths.
119
+ if (path.startsWith("/") || path.startsWith("~") || /^[A-Za-z]:/.test(path)) {
120
+ return false;
82
121
  }
83
122
  return existsSync(join(this.baseDir, path));
84
123
  }
@@ -94,10 +133,13 @@ export class FactualCheck {
94
133
  return false;
95
134
  if (VERSION_PATTERN.test(p))
96
135
  return false;
97
- if (p.startsWith("/"))
98
- return false;
99
136
  if (COMMON_WORDS.has(p))
100
137
  return false;
138
+ // Skip absolute paths — agent responses use relative paths; absolute
139
+ // paths are either system paths or URLs, and existsSync can hang on
140
+ // Windows for root-relative paths like "/page.html".
141
+ if (p.startsWith("/") || p.startsWith("~") || /^[A-Za-z]:/.test(p))
142
+ return false;
101
143
  if (this.pathExistsOnDisk(p))
102
144
  return false;
103
145
  const ext = p.split(".").pop()?.toLowerCase() || "";
@@ -134,6 +134,12 @@ class ProcessRegistry {
134
134
  const sorted = Array.from(this.procs.values()).sort((a, b) => a.startedAt.localeCompare(b.startedAt));
135
135
  const toRemove = sorted.slice(0, sorted.length - MAX_KEPT_PROCESSES + 1);
136
136
  for (const entry of toRemove) {
137
+ if (entry.status === "running") {
138
+ const child = this.children.get(entry.id);
139
+ if (child) {
140
+ killTree(child);
141
+ }
142
+ }
137
143
  this.procs.delete(entry.id);
138
144
  this.children.delete(entry.id);
139
145
  }
@@ -9,6 +9,40 @@ const FALLBACK_BASH_CONFIG = {
9
9
  logCommands: true,
10
10
  };
11
11
  export const DEFAULT_BASH_CONFIG = DEFAULT_SECURITY_CONFIG?.bash || FALLBACK_BASH_CONFIG;
12
+ /**
13
+ * Extract the actual base command from a shell command string.
14
+ * Handles path prefixes, env assignments, and sudo.
15
+ *
16
+ * Examples:
17
+ * "/usr/bin/rm -rf /" → "rm"
18
+ * "sudo /usr/bin/rm file" → "rm"
19
+ * "NODE_ENV=prod node app.js" → "node"
20
+ * "env PATH=/x rm -rf /" → "rm"
21
+ * "cmd=rm; $cmd -rf /" → "cmd" (variable indirection — not expanded)
22
+ */
23
+ function extractBaseCommand(trimmed) {
24
+ const tokens = trimmed.split(/\s+/);
25
+ let i = 0;
26
+ // Skip env assignments (KEY=val, KEY="val", etc.) and prefixed commands
27
+ while (i < tokens.length) {
28
+ const tok = tokens[i];
29
+ if (/^[a-zA-Z_][a-zA-Z0-9_]*=/.test(tok)) {
30
+ i++;
31
+ continue;
32
+ }
33
+ if (tok === "sudo" || tok === "env" || tok === "command" || tok === "exec" || tok === "nohup") {
34
+ i++;
35
+ continue;
36
+ }
37
+ break;
38
+ }
39
+ if (i >= tokens.length)
40
+ return tokens[0] ?? "";
41
+ // Extract basename from path (e.g. /usr/bin/rm → rm)
42
+ const raw = tokens[i];
43
+ const parts = raw.split(/[\\/]/);
44
+ return parts[parts.length - 1] || raw;
45
+ }
12
46
  /**
13
47
  * Check if a command is allowed based on security configuration
14
48
  */
@@ -23,8 +57,20 @@ export function isCommandAllowed(command, securityConfig) {
23
57
  if (!trimmedCommand) {
24
58
  return { allowed: false, reason: "Empty command" };
25
59
  }
26
- // Extract base command (first word)
27
- const baseCommand = trimmedCommand.split(/\s+/)[0];
60
+ // Check dangerous operators FIRST — before parsing the base command.
61
+ // e.g. "curl http://x | bash" must be caught even though curl isn't blacklisted.
62
+ if (config.blockDangerousFlags) {
63
+ for (const op of config.dangerousOperators || []) {
64
+ if (trimmedCommand.includes(op)) {
65
+ return {
66
+ allowed: false,
67
+ reason: `Operator "${op}" is not allowed`,
68
+ };
69
+ }
70
+ }
71
+ }
72
+ // Extract actual base command (handles paths, env vars, sudo)
73
+ const baseCommand = extractBaseCommand(trimmedCommand);
28
74
  // Check whitelist first (if non-empty, only whitelisted commands are allowed)
29
75
  if (config.whitelist.length > 0) {
30
76
  if (!config.whitelist.includes(baseCommand)) {
@@ -34,30 +80,27 @@ export function isCommandAllowed(command, securityConfig) {
34
80
  };
35
81
  }
36
82
  }
37
- // Check blacklist
83
+ // Check blacklist (against the extracted basename, not the raw token)
38
84
  if (config.blacklist.includes(baseCommand)) {
39
85
  return {
40
86
  allowed: false,
41
87
  reason: `Command "${baseCommand}" is blacklisted`,
42
88
  };
43
89
  }
44
- // Check for dangerous operators
45
- if (config.blockDangerousFlags) {
46
- for (const op of config.dangerousOperators || []) {
47
- if (command.includes(op)) {
48
- return {
49
- allowed: false,
50
- reason: `Operator "${op}" is not allowed`,
51
- };
52
- }
53
- }
90
+ // Also check the raw first token in case it's a simple name
91
+ const rawFirst = trimmedCommand.split(/\s+/)[0];
92
+ if (rawFirst !== baseCommand && config.blacklist.includes(rawFirst)) {
93
+ return {
94
+ allowed: false,
95
+ reason: `Command "${rawFirst}" is blacklisted`,
96
+ };
54
97
  }
55
98
  // Check for dangerous flags
56
99
  if (config.blockDangerousFlags) {
57
100
  for (const flag of config.dangerousFlags || []) {
58
101
  // Check for flag as whole word or with equals
59
102
  const flagPattern = new RegExp(`(?:^|\\s)${flag}(?:\\s|$|=)`);
60
- if (flagPattern.test(command)) {
103
+ if (flagPattern.test(trimmedCommand)) {
61
104
  return {
62
105
  allowed: false,
63
106
  reason: `Dangerous flag "${flag}" is not allowed`,
@@ -1,7 +1,7 @@
1
1
  import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
2
2
  import { homedir } from 'os';
3
3
  import { join } from 'path';
4
- import { readFileSync, writeFileSync, existsSync } from 'fs';
4
+ import { readFileSync, writeFileSync, existsSync, copyFileSync } from 'fs';
5
5
  const DEFAULT_CONFIG = {
6
6
  keyPath: join(homedir(), '.mma', '.encryption-key'),
7
7
  algorithm: 'aes-256-gcm',
@@ -44,13 +44,15 @@ export function getOrCreateEncryptionKey(config) {
44
44
  return Buffer.from(keyBase64, 'base64');
45
45
  }
46
46
  catch {
47
- // If we can't read the key file, generate a new one
48
- const key = generateEncryptionKey();
47
+ // Backup the corrupted key file before throwing
49
48
  try {
50
- writeFileSync(keyPath, key.toString('base64'), { mode: 0o600 });
49
+ const backupPath = `${keyPath}.corrupted.${Date.now()}`;
50
+ copyFileSync(keyPath, backupPath);
51
51
  }
52
- catch { /* ignore */ }
53
- return key;
52
+ catch { /* backup failed, continue */ }
53
+ throw new Error(`Encryption key file is corrupted or unreadable: ${keyPath}. ` +
54
+ `All previously encrypted data will be unrecoverable. ` +
55
+ `Delete the key file to generate a new one.`);
54
56
  }
55
57
  }
56
58
  /**
@@ -19,10 +19,6 @@ export class SessionModule {
19
19
  getPlugin() {
20
20
  return {
21
21
  name: 'session',
22
- onSessionStart: (_ctx) => {
23
- },
24
- onSessionEnd: (_ctx) => {
25
- },
26
22
  };
27
23
  }
28
24
  }
@@ -19,6 +19,25 @@ function adaptCommandForWindows(command) {
19
19
  }
20
20
  return command;
21
21
  }
22
+ /** Common Unix → Windows command mapping for error hints. */
23
+ const UNIX_TO_WIN_HINTS = {
24
+ 'ls': 'Use "dir" or the list_dir tool instead.',
25
+ 'pwd': 'Use "echo %cd%" or the file_info tool instead.',
26
+ 'cat': 'Use "type" or the read_file tool instead.',
27
+ 'cp': 'Use "copy" or the move_file tool instead.',
28
+ 'mv': 'Use "move" or the move_file tool instead.',
29
+ 'rm': 'Use "del" or the delete_file tool instead.',
30
+ 'grep': 'Use "findstr" or the grep tool instead.',
31
+ 'chmod': 'Use icacls or the chmod tool instead.',
32
+ 'touch': 'Use type nul > file or the write_file tool instead.',
33
+ 'find': 'Use "dir /s" or the glob tool instead.',
34
+ 'head': 'Use the read_file tool with offset/limit instead.',
35
+ 'tail': 'Use the read_file tool instead.',
36
+ 'wc': 'Use the read_file tool instead.',
37
+ 'diff': 'Use the diff tool instead.',
38
+ 'which': 'Use "where" instead.',
39
+ 'echo': 'echo works on Windows, but avoid pipes (|).',
40
+ };
22
41
  export const bashTool = {
23
42
  name: 'bash',
24
43
  description: 'Execute a shell command and return its output. Use for running tests, build, git, and shell operations. Long-running commands (dev servers, watchers) start in the background and return a process id immediately — manage them with process_list, process_log, process_kill. Set background=true to force background execution.',
@@ -90,6 +109,14 @@ export const bashTool = {
90
109
  else if (!output && res.code !== 0) {
91
110
  output = `(exit code ${res.code})`;
92
111
  }
112
+ // On Windows, hint about Unix commands that don't work
113
+ if (platform() === 'win32' && res.code !== 0) {
114
+ const firstWord = command.trim().split(/\s+/)[0]?.split(/[\\/]/).pop();
115
+ const hint = firstWord ? UNIX_TO_WIN_HINTS[firstWord] : undefined;
116
+ if (hint) {
117
+ output = `${output}\n\nHint: "${firstWord}" may not work on Windows. ${hint}`;
118
+ }
119
+ }
93
120
  // Update audit log with result
94
121
  if (securityConfig?.logCommands) {
95
122
  logBashCommand(ctx.sessionId, sanitizeCommandForLog(command), res.code === 0, `Working directory: ${workdir}, Output length: ${res.stdout.length}`);