micro-models-agent 0.63.3 → 1.1.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 (185) hide show
  1. package/CHANGELOG.md +148 -1
  2. package/dist/cli/cache-line.js +30 -0
  3. package/dist/cli/command-suggest.js +38 -0
  4. package/dist/cli/commands.js +285 -60
  5. package/dist/cli/completer.js +16 -16
  6. package/dist/cli/json-payload.js +32 -0
  7. package/dist/cli/main.js +165 -77
  8. package/dist/cli/plugin-commands.js +5 -4
  9. package/dist/cli/relaunch.js +37 -0
  10. package/dist/cli/repl-commands.js +441 -307
  11. package/dist/cli/repl.js +360 -83
  12. package/dist/cli/run-result.js +12 -6
  13. package/dist/cli/security-commands.js +64 -60
  14. package/dist/cli/setup-order.js +57 -0
  15. package/dist/cli/setup-prompt.js +49 -0
  16. package/dist/cli/setup.js +52 -48
  17. package/dist/config/budget.js +48 -0
  18. package/dist/config/config.js +132 -70
  19. package/dist/config/defaults.js +37 -11
  20. package/dist/config/domains.js +9 -50
  21. package/dist/config/utils.js +56 -0
  22. package/dist/core/agent/audit-gate.js +49 -0
  23. package/dist/core/agent/compaction.js +89 -0
  24. package/dist/core/agent/constants.js +61 -0
  25. package/dist/core/agent/context-renderer.js +40 -0
  26. package/dist/core/agent/hallucination-gate.js +87 -0
  27. package/dist/core/agent/loop-state.js +53 -0
  28. package/dist/core/agent/prefix-monitor.js +101 -0
  29. package/dist/core/agent/reasoning-resolver.js +56 -0
  30. package/dist/core/agent/token-tracker.js +96 -0
  31. package/dist/core/agent/tool-batch.js +237 -0
  32. package/dist/core/agent/tool-output.js +62 -0
  33. package/dist/core/agent-moe.js +214 -69
  34. package/dist/core/agent.js +506 -546
  35. package/dist/core/bootstrap.js +297 -98
  36. package/dist/core/crash-handler.js +2 -1
  37. package/dist/core/prompt-builder.js +3 -0
  38. package/dist/core/prompt-overflow.js +307 -0
  39. package/dist/core/session-logger.js +34 -2
  40. package/dist/i18n/en.json +7 -4
  41. package/dist/i18n/ru.json +7 -4
  42. package/dist/index.js +5 -1
  43. package/dist/llm/cache-usage.js +76 -0
  44. package/dist/llm/image-utils.js +20 -16
  45. package/dist/llm/llm-errors.js +41 -0
  46. package/dist/llm/model-loader.js +30 -0
  47. package/dist/llm/openai-compat.js +287 -101
  48. package/dist/llm/orchestrator.js +140 -68
  49. package/dist/llm/provider-budget.js +68 -0
  50. package/dist/llm/provider.js +0 -1
  51. package/dist/llm/stream-state.js +26 -0
  52. package/dist/llm/token-counter.js +28 -0
  53. package/dist/logger/app-logger.js +12 -15
  54. package/dist/main.js +1606 -800
  55. package/dist/migration/detect.js +3 -1
  56. package/dist/modules/browser/actions.js +0 -3
  57. package/dist/modules/browser/bridge-client.js +2 -0
  58. package/dist/modules/browser/driver.js +46 -4
  59. package/dist/modules/certification/cli.js +85 -42
  60. package/dist/modules/certification/loader.js +15 -1
  61. package/dist/modules/certification/manifest.js +126 -15
  62. package/dist/modules/certification/runner.js +4 -26
  63. package/dist/modules/certification/scenarios.js +184 -5
  64. package/dist/modules/certification/syntax-scenarios.js +51 -0
  65. package/dist/modules/context/chunk-query.js +25 -5
  66. package/dist/modules/context/fact-extractor.js +6 -2
  67. package/dist/modules/context/manager.js +23 -7
  68. package/dist/modules/execution/audit-runners.js +7 -1
  69. package/dist/modules/execution/auditor.js +3 -3
  70. package/dist/modules/execution/execution-plugin.js +22 -15
  71. package/dist/modules/execution/input-from.js +46 -0
  72. package/dist/modules/execution/module.js +107 -18
  73. package/dist/modules/execution/moe-executor.js +166 -54
  74. package/dist/modules/execution/plan-actions.js +524 -0
  75. package/dist/modules/execution/plan-steps.js +23 -0
  76. package/dist/modules/execution/plan-store.js +15 -3
  77. package/dist/modules/execution/plan-tool.js +6 -488
  78. package/dist/modules/execution/plan-validator.js +24 -0
  79. package/dist/modules/execution/stuck-detector.js +3 -18
  80. package/dist/modules/execution/tracker.js +14 -5
  81. package/dist/modules/execution/transient-error.js +30 -0
  82. package/dist/modules/execution/verifier.js +94 -7
  83. package/dist/modules/execution/windows-commands.js +11 -0
  84. package/dist/modules/hallucination/confidence.js +36 -23
  85. package/dist/modules/hallucination/consistency.js +3 -0
  86. package/dist/modules/hallucination/detector.js +8 -3
  87. package/dist/modules/hallucination/factual.js +26 -7
  88. package/dist/modules/hallucination/llm-judge.js +12 -2
  89. package/dist/modules/indexer/map-command.js +35 -0
  90. package/dist/modules/indexer/map-select.js +87 -0
  91. package/dist/modules/indexer/module.js +34 -22
  92. package/dist/modules/indexer/symbols.js +189 -0
  93. package/dist/modules/indexer/walker.js +96 -42
  94. package/dist/modules/lsp/check-tool.js +2 -1
  95. package/dist/modules/lsp/client.js +49 -32
  96. package/dist/modules/lsp/config.js +55 -2
  97. package/dist/modules/lsp/module.js +38 -5
  98. package/dist/modules/lsp/probe.js +4 -3
  99. package/dist/modules/lsp/project-root.js +41 -1
  100. package/dist/modules/lsp/startup-check.js +12 -4
  101. package/dist/modules/mcp/client.js +153 -104
  102. package/dist/modules/mcp/module.js +165 -41
  103. package/dist/modules/memory/module.js +4 -3
  104. package/dist/modules/plugins/builtin/lint-on-write.js +36 -6
  105. package/dist/modules/plugins/manager.js +47 -84
  106. package/dist/modules/pricing/index.js +17 -7
  107. package/dist/modules/pricing/prices.js +30 -12
  108. package/dist/modules/processes/index.js +1 -0
  109. package/dist/modules/processes/kill-tree.js +56 -0
  110. package/dist/modules/processes/registry.js +2 -54
  111. package/dist/modules/providers/cache.js +23 -0
  112. package/dist/modules/providers/factory.js +28 -0
  113. package/dist/modules/providers/fallback.js +7 -5
  114. package/dist/modules/providers/health.js +2 -1
  115. package/dist/modules/providers/index.js +1 -0
  116. package/dist/modules/providers/manager.js +17 -2
  117. package/dist/modules/providers/presets.js +79 -6
  118. package/dist/modules/reasoning/policy.js +40 -0
  119. package/dist/modules/reasoning/probe.js +111 -0
  120. package/dist/modules/security/audit-notifier.js +42 -27
  121. package/dist/modules/security/command-validator.js +25 -20
  122. package/dist/modules/security/encryption.js +6 -12
  123. package/dist/modules/security/network-validator.js +76 -5
  124. package/dist/modules/security/path-validator.js +77 -34
  125. package/dist/modules/security/rate-limiter.js +11 -0
  126. package/dist/modules/security/security-policies.js +1 -1
  127. package/dist/modules/security/session-encryption.js +13 -2
  128. package/dist/modules/security/session-isolation.js +2 -9
  129. package/dist/modules/session/manager.js +11 -0
  130. package/dist/modules/session/module.js +11 -3
  131. package/dist/modules/session/store.js +41 -5
  132. package/dist/modules/skills/loader.js +7 -1
  133. package/dist/modules/skills/module.js +2 -1
  134. package/dist/modules/updater/changelog-reader.js +94 -0
  135. package/dist/modules/updater/dev-detect.js +17 -0
  136. package/dist/modules/updater/index.js +1 -0
  137. package/dist/modules/updater/module.js +14 -3
  138. package/dist/output/bus.js +32 -0
  139. package/dist/output/channel.js +233 -0
  140. package/dist/output/format.js +14 -0
  141. package/dist/output/index.js +7 -0
  142. package/dist/output/json-sink.js +22 -0
  143. package/dist/output/machine.js +8 -0
  144. package/dist/output/session-sink.js +27 -0
  145. package/dist/output/types.js +1 -0
  146. package/dist/tools/approve.js +6 -2
  147. package/dist/tools/attach-image.js +11 -11
  148. package/dist/tools/auto-fixer.js +198 -0
  149. package/dist/tools/bash.js +142 -89
  150. package/dist/tools/chunk-query.js +10 -6
  151. package/dist/tools/download-file.js +1 -1
  152. package/dist/tools/edit-file.js +20 -2
  153. package/dist/tools/executor.js +54 -9
  154. package/dist/tools/glob-tool.js +7 -0
  155. package/dist/tools/grep-tool.js +15 -1
  156. package/dist/tools/index.js +3 -1
  157. package/dist/tools/list-dir.js +3 -1
  158. package/dist/tools/load-skill.js +2 -1
  159. package/dist/tools/mcp-call.js +1 -1
  160. package/dist/tools/move-file.js +5 -4
  161. package/dist/tools/path-utils.js +7 -0
  162. package/dist/tools/pipeline-run.js +1 -1
  163. package/dist/tools/prompt-io.js +28 -0
  164. package/dist/tools/question.js +12 -12
  165. package/dist/tools/scope-request.js +91 -0
  166. package/dist/tools/session-info.js +44 -0
  167. package/dist/tools/set-thinking.js +71 -0
  168. package/dist/tools/subagent.js +50 -9
  169. package/dist/tools/syntax-validator.js +177 -0
  170. package/dist/tools/user-input.js +16 -9
  171. package/dist/tools/write-file.js +17 -1
  172. package/dist/ui/diff.js +10 -0
  173. package/dist/ui/line-editor.js +179 -26
  174. package/dist/ui/line-math.js +20 -3
  175. package/dist/ui/md-formatter.js +100 -10
  176. package/dist/ui/output.js +5 -4
  177. package/dist/ui/plan-view.js +2 -7
  178. package/dist/ui/renderer.js +89 -85
  179. package/dist/ui/spinner.js +14 -4
  180. package/dist/utils/error.js +4 -0
  181. package/dist/utils/index.js +4 -0
  182. package/dist/utils/retry.js +17 -0
  183. package/dist/utils/sleep.js +23 -0
  184. package/dist/utils/truncate.js +9 -0
  185. package/package.json +1 -1
@@ -26,7 +26,9 @@ export class MigrationDetector {
26
26
  return false;
27
27
  }
28
28
  catch {
29
- return true;
29
+ // A corrupt or hand-edited config must NOT be treated as v1 and deleted.
30
+ // loadConfig will warn and fall back to defaults instead.
31
+ return false;
30
32
  }
31
33
  }
32
34
  hasOldSessions() {
@@ -29,9 +29,6 @@ export function buildScrollScript(direction) {
29
29
  return `window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' })`;
30
30
  }
31
31
  }
32
- export function buildWaitScript(timeoutMs) {
33
- return `await new Promise(r => setTimeout(r, ${timeoutMs}))`;
34
- }
35
32
  export function buildIndexInjectionScript() {
36
33
  return `
37
34
  const selectors = 'a[href], button, input, textarea, select, [role="button"], [role="link"], [role="textbox"], [role="checkbox"], [role="radio"], [contenteditable="true"]';
@@ -77,6 +77,8 @@ export class BridgeDriver {
77
77
  headless: cfg.headless,
78
78
  viewport: cfg.viewport,
79
79
  timeoutMs: cfg.timeoutMs,
80
+ executablePath: cfg.overrides?.executablePath,
81
+ channel: cfg.overrides?.channel,
80
82
  maxConsoleLineChars: this.maxConsoleLineChars,
81
83
  });
82
84
  }
@@ -1,6 +1,50 @@
1
1
  import { chromium } from "playwright";
2
2
  import { ConsoleBuffer } from "./session";
3
3
  export const DIRECT_LAUNCH_TIMEOUT_MS = 8000;
4
+ export function resolveBrowserLaunchOverrides(env = process.env) {
5
+ const executablePath = env.MMA_BROWSER_EXECUTABLE?.trim();
6
+ const channel = env.MMA_BROWSER_CHANNEL?.trim();
7
+ const out = {};
8
+ if (executablePath)
9
+ out.executablePath = executablePath;
10
+ if (channel)
11
+ out.channel = channel;
12
+ return out;
13
+ }
14
+ /** System browsers to try when the bundled Chromium revision is missing. */
15
+ const SYSTEM_CHANNEL_FALLBACKS = ["chrome", "msedge"];
16
+ function isMissingBrowserError(err) {
17
+ return /Executable doesn't exist|is not found|looks like Playwright was just installed/i.test(String(err?.message ?? err));
18
+ }
19
+ /**
20
+ * Launch Chromium, falling back to an installed system Chrome/Edge when the
21
+ * bundled revision is absent (CDN unreachable). An explicit override always
22
+ * wins and suppresses the fallback — the user's choice must fail loudly.
23
+ */
24
+ export async function launchChromiumWithFallback(launch, cfg, overrides = {}) {
25
+ const base = { headless: cfg.headless, timeout: cfg.timeoutMs, ...overrides };
26
+ try {
27
+ return await launch(base);
28
+ }
29
+ catch (err) {
30
+ const explicit = overrides.executablePath || overrides.channel;
31
+ if (explicit || !isMissingBrowserError(err))
32
+ throw err;
33
+ let lastErr = err;
34
+ for (const channel of SYSTEM_CHANNEL_FALLBACKS) {
35
+ try {
36
+ return await launch({ ...base, channel });
37
+ }
38
+ catch (e) {
39
+ lastErr = e;
40
+ }
41
+ }
42
+ throw lastErr;
43
+ }
44
+ }
45
+ function launchChromium(cfg, overrides) {
46
+ return launchChromiumWithFallback((opts) => chromium.launch(opts), cfg, overrides);
47
+ }
4
48
  export class PlaywrightDriver {
5
49
  browser = null;
6
50
  context = null;
@@ -10,10 +54,7 @@ export class PlaywrightDriver {
10
54
  async launch(cfg) {
11
55
  if (this.browser)
12
56
  return;
13
- this.browser = await chromium.launch({
14
- headless: cfg.headless,
15
- timeout: cfg.timeoutMs,
16
- });
57
+ this.browser = await launchChromium(cfg, cfg.overrides ?? {});
17
58
  this.context = await this.browser.newContext({ viewport: cfg.viewport });
18
59
  this.page = await this.context.newPage();
19
60
  this.page.setDefaultTimeout(cfg.timeoutMs);
@@ -109,6 +150,7 @@ export function createBrowserDriver(config) {
109
150
  headless: config.headless,
110
151
  viewport: { width: config.viewportWidth, height: config.viewportHeight },
111
152
  timeoutMs: config.navigationTimeout,
153
+ overrides: resolveBrowserLaunchOverrides(),
112
154
  };
113
155
  return (async () => {
114
156
  // Playwright's pipe/websocket transport is broken under Bun on some
@@ -4,9 +4,10 @@ import { fileURLToPath } from "url";
4
4
  import { existsSync, readFileSync } from "fs";
5
5
  import { t } from "../../i18n/index";
6
6
  import { pc } from "../../ui/colors";
7
- import { loadScenarios, filterByTags } from "./loader";
7
+ import { loadScenarios, filterByTags, filterByScenarios, unknownScenarioIds } from "./loader";
8
8
  import { runScenario, findMmaRoot } from "./runner";
9
- import { readManifest, upsertCertification, removeCertification, isFullyPassed } from "./manifest";
9
+ import { readMergedManifest, upsertCertification, removeCertification, isFullyPassed, } from "./manifest";
10
+ import { getDefaultChannel } from "../../output";
10
11
  const HERE = dirname(fileURLToPath(import.meta.url));
11
12
  const MMA_ROOT = findMmaRoot(HERE);
12
13
  function readVersion() {
@@ -35,22 +36,41 @@ export async function certify(opts) {
35
36
  const providerUrl = opts.providerUrl || opts.config.provider.baseUrl;
36
37
  const { scenarios, errors } = loadScenarios(join(opts.projectDir, ".mma", "certification", "scenarios"));
37
38
  for (const e of errors)
38
- console.error(pc.yellow(` ${e}`));
39
- const selected = filterByTags(scenarios, opts.tags);
39
+ getDefaultChannel().writeLine(pc.yellow(` ${e}`), "stderr");
40
+ let selected = filterByTags(scenarios, opts.tags);
41
+ const isPartial = Array.isArray(opts.scenarios) && opts.scenarios.length > 0;
42
+ if (isPartial) {
43
+ const unknown = unknownScenarioIds(scenarios, opts.scenarios);
44
+ if (unknown.length > 0) {
45
+ getDefaultChannel().writeLine(pc.red(t("cli.cert_scenarios_unknown", {
46
+ ids: unknown.join(", "),
47
+ available: scenarios.map((s) => s.id).join(", "),
48
+ })), "stderr");
49
+ process.exitCode = 1;
50
+ return;
51
+ }
52
+ // A targeted re-run selects from ALL loaded scenarios (union of their
53
+ // tags), not the tag-filtered subset — otherwise re-running a security
54
+ // scenario with default core-only tags silently produced an empty
55
+ // selection.
56
+ selected = filterByScenarios(scenarios, opts.scenarios);
57
+ }
40
58
  if (selected.length === 0) {
41
- console.error(pc.red(t("cli.cert_no_scenarios", { tags: opts.tags.join(",") })));
59
+ getDefaultChannel().writeLine(pc.red(t("cli.cert_no_scenarios", { tags: opts.tags.join(",") })), "stderr");
42
60
  process.exitCode = 1;
43
61
  return;
44
62
  }
45
- const manifest = readManifest(opts.projectDir);
63
+ const manifest = readMergedManifest(opts.projectDir);
46
64
  const existing = manifest.certifications.find((e) => e.model === opts.name && e.providerUrl === providerUrl);
47
- if (existing && !opts.force) {
48
- console.error(pc.yellow(t("cli.cert_exists", { model: opts.name })));
49
- console.error(pc.yellow(t("cli.cert_exists_hint")));
65
+ // A full-suite re-run overwrites the entry and requires --force; a partial
66
+ // (scenario-targeted) run is an incremental update by definition.
67
+ if (existing && !opts.force && !isPartial) {
68
+ getDefaultChannel().writeLine(pc.yellow(t("cli.cert_exists", { model: opts.name })), "stderr");
69
+ getDefaultChannel().writeLine(pc.yellow(t("cli.cert_exists_hint")), "stderr");
50
70
  process.exitCode = 1;
51
71
  return;
52
72
  }
53
- console.log(t("cli.cert_started", { model: opts.name, provider: providerUrl }));
73
+ getDefaultChannel().writeLine(t("cli.cert_started", { model: opts.name, provider: providerUrl }));
54
74
  const sandboxBase = join(process.cwd(), ".mma", "certification");
55
75
  const results = [];
56
76
  const total = selected.length;
@@ -58,7 +78,7 @@ export async function certify(opts) {
58
78
  for (const scenario of selected) {
59
79
  idx++;
60
80
  if (scenario.mode === "skip") {
61
- console.log(pc.dim(`[${idx}/${total}] ${scenario.id} ... skipped`));
81
+ getDefaultChannel().writeLine(pc.dim(`[${idx}/${total}] ${scenario.id} ... skipped`));
62
82
  results.push({
63
83
  id: scenario.id,
64
84
  title: scenario.title,
@@ -77,12 +97,13 @@ export async function certify(opts) {
77
97
  sandboxBase,
78
98
  defaultReps: opts.reps,
79
99
  defaultThreshold: 2,
100
+ timeoutMs: opts.timeout,
80
101
  baseConfig: opts.config,
81
102
  onRep: (id, rep, reps, passed, failures) => {
82
103
  const word = passed ? pc.green(t("cli.cert_rep_pass")) : pc.red(t("cli.cert_rep_fail"));
83
- console.log(`[${idx}/${total}] ${id} (${rep}/${reps})... ${word}`);
104
+ getDefaultChannel().writeLine(`[${idx}/${total}] ${id} (${rep}/${reps})... ${word}`);
84
105
  if (!passed)
85
- console.log(` ${pc.dim(failures.join("; "))}`);
106
+ getDefaultChannel().writeLine(` ${pc.dim(failures.join("; "))}`);
86
107
  },
87
108
  });
88
109
  results.push(res);
@@ -95,66 +116,76 @@ export async function certify(opts) {
95
116
  /* ignore */
96
117
  }
97
118
  }
98
- const suite = summarize(results);
119
+ // Partial run: keep previous results for scenarios that were not re-run.
120
+ const runIds = new Set(selected.map((s) => s.id));
121
+ const finalResults = isPartial && existing ? mergePartialResults(results, existing.results, runIds) : results;
122
+ const suite = summarize(finalResults);
123
+ // Only a re-run that covers EVERY scenario in the suite may refresh
124
+ // mmaVersion — a partial re-run leaves most results from an older MMA, so
125
+ // stamping the current version would fake a fresh certification.
126
+ const ranEntireSuite = scenarios.every((s) => runIds.has(s.id));
99
127
  const entry = {
100
128
  model: opts.name,
101
129
  providerUrl,
102
- mmaVersion: readVersion(),
130
+ mmaVersion: ranEntireSuite ? readVersion() : (existing?.mmaVersion ?? readVersion()),
103
131
  certifiedAt: new Date().toISOString(),
104
132
  suite,
105
- results,
133
+ results: finalResults,
106
134
  };
107
- upsertCertification(entry, opts.projectDir);
108
- console.log(t("cli.cert_done", {
135
+ // Certifications are per (model, providerUrl) — machine-scoped, not
136
+ // project-scoped: store them in the global manifest so marks follow the
137
+ // user into any directory.
138
+ upsertCertification(entry);
139
+ getDefaultChannel().writeLine(t("cli.cert_done", {
109
140
  passed: String(suite.passed),
110
141
  failed: String(suite.failed),
111
142
  skipped: String(suite.skipped),
112
143
  total: String(suite.total),
113
144
  }));
114
- printResults(results);
145
+ printResults(finalResults);
115
146
  if (isFullyPassed(entry)) {
116
- console.log(pc.green(`\n✔ ${opts.name} is certified`));
147
+ getDefaultChannel().writeLine(pc.green(`\n✔ ${opts.name} is certified`));
117
148
  }
118
149
  else {
119
- console.log(pc.yellow(`\n⚠ ${opts.name} is NOT certified — all scenarios must pass`));
150
+ getDefaultChannel().writeLine(pc.yellow(`\n⚠ ${opts.name} is NOT certified — all scenarios must pass`));
120
151
  }
121
152
  // Always write report file
122
153
  writeReport(entry, opts.projectDir);
123
154
  }
124
155
  export async function certStatus(name, config, projectDir) {
125
- const m = readManifest(projectDir);
156
+ const m = readMergedManifest(projectDir);
126
157
  const providerUrl = config.provider.baseUrl;
127
158
  const entry = m.certifications.find((e) => e.model === name && e.providerUrl === providerUrl);
128
159
  if (!entry) {
129
- console.log(t("cli.cert_not_found", { model: name }));
160
+ getDefaultChannel().writeLine(t("cli.cert_not_found", { model: name }));
130
161
  return;
131
162
  }
132
- console.log(`${t("cli.cert_provider_col")}: ${entry.providerUrl}`);
133
- console.log(`${t("cli.cert_version_col")}: ${entry.mmaVersion} ${t("cli.cert_date_col")}: ${entry.certifiedAt.slice(0, 10)}`);
134
- console.log(`${t("cli.cert_suite_col")}: ${entry.suite.passed} pass / ${entry.suite.failed} fail / ${entry.suite.skipped} skipped`);
135
- const status = isFullyPassed(entry)
136
- ? pc.green("✔ certified")
137
- : pc.yellow("⚠ NOT certified");
138
- console.log(`Status: ${status}`);
163
+ getDefaultChannel().writeLine(`${t("cli.cert_provider_col")}: ${entry.providerUrl}`);
164
+ getDefaultChannel().writeLine(`${t("cli.cert_version_col")}: ${entry.mmaVersion} ${t("cli.cert_date_col")}: ${entry.certifiedAt.slice(0, 10)}`);
165
+ getDefaultChannel().writeLine(`${t("cli.cert_suite_col")}: ${entry.suite.passed} pass / ${entry.suite.failed} fail / ${entry.suite.skipped} skipped`);
166
+ const status = isFullyPassed(entry) ? pc.green("✔ certified") : pc.yellow("⚠ NOT certified");
167
+ getDefaultChannel().writeLine(`Status: ${status}`);
139
168
  printResults(entry.results);
140
169
  }
141
170
  export async function certList(projectDir) {
142
- const m = readManifest(projectDir);
171
+ const m = readMergedManifest(projectDir);
143
172
  if (m.certifications.length === 0) {
144
- console.log(t("cli.cert_empty"));
173
+ getDefaultChannel().writeLine(t("cli.cert_empty"));
145
174
  return;
146
175
  }
147
176
  for (const e of m.certifications) {
148
177
  const mark = isFullyPassed(e) ? pc.green("✔") : pc.red("✘");
149
- console.log(` ${mark} ${e.model} ${pc.dim(e.providerUrl)} ${e.mmaVersion} ${e.certifiedAt.slice(0, 10)} ${e.suite.passed}/${e.suite.total} pass`);
178
+ getDefaultChannel().writeLine(` ${mark} ${e.model} ${pc.dim(e.providerUrl)} ${e.mmaVersion} ${e.certifiedAt.slice(0, 10)} ${e.suite.passed}/${e.suite.total} pass`);
150
179
  }
151
180
  }
152
181
  export async function uncertify(name, config, projectDir) {
153
- const removed = removeCertification(name, config.provider.baseUrl, projectDir);
154
- if (removed)
155
- console.log(t("cli.cert_uncertified", { model: name }));
182
+ const removed = removeCertification(name, config.provider.baseUrl, {
183
+ projectDir,
184
+ });
185
+ if (removed.global || removed.project)
186
+ getDefaultChannel().writeLine(t("cli.cert_uncertified", { model: name }));
156
187
  else
157
- console.log(t("cli.cert_not_found", { model: name }));
188
+ getDefaultChannel().writeLine(t("cli.cert_not_found", { model: name }));
158
189
  }
159
190
  function summarize(results) {
160
191
  return {
@@ -164,6 +195,18 @@ function summarize(results) {
164
195
  total: results.length,
165
196
  };
166
197
  }
198
+ /**
199
+ * Merge a partial run's results with a previous certification: fresh results
200
+ * replace old ones by scenario id; results for scenarios that were not re-run
201
+ * are preserved verbatim.
202
+ */
203
+ export function mergePartialResults(fresh, previous, rerunIds) {
204
+ const preserved = previous.filter((r) => !rerunIds.has(r.id));
205
+ if (preserved.length > 0) {
206
+ getDefaultChannel().writeLine(pc.dim(t("cli.cert_partial_merge", { kept: String(preserved.length) })));
207
+ }
208
+ return [...fresh, ...preserved];
209
+ }
167
210
  function printResults(results) {
168
211
  for (const r of results) {
169
212
  const icon = r.status === "pass"
@@ -174,11 +217,11 @@ function printResults(results) {
174
217
  ? pc.dim("–")
175
218
  : pc.yellow("!");
176
219
  const detail = r.status === "skipped" ? pc.dim(r.title) : `${r.passed}/${r.of}`;
177
- console.log(` ${icon} ${r.id} ${detail}`);
220
+ getDefaultChannel().writeLine(` ${icon} ${r.id} ${detail}`);
178
221
  if (r.error)
179
- console.log(` ${pc.dim(r.error)}`);
222
+ getDefaultChannel().writeLine(` ${pc.dim(r.error)}`);
180
223
  if (r.diagnostics)
181
- console.log(r.diagnostics);
224
+ getDefaultChannel().writeLine(r.diagnostics);
182
225
  }
183
226
  }
184
227
  function writeReport(entry, projectDir) {
@@ -205,9 +248,9 @@ function writeReport(entry, projectDir) {
205
248
  };
206
249
  try {
207
250
  writeFileSync(reportPath, JSON.stringify(report, null, 2), "utf-8");
208
- console.log(pc.dim(`\nReport: ${reportPath}`));
251
+ getDefaultChannel().writeLine(pc.dim(`\nReport: ${reportPath}`));
209
252
  }
210
253
  catch (e) {
211
- console.error(pc.yellow(` Failed to write report: ${e.message}`));
254
+ getDefaultChannel().writeLine(pc.yellow(` Failed to write report: ${e.message}`), "stderr");
212
255
  }
213
256
  }
@@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { parse as parseYaml } from "yaml";
4
4
  import { BUILTIN_SCENARIOS } from "./scenarios";
5
- const TAGS = ["core", "security", "image", "network", "browser", "moe"];
5
+ const TAGS = ["core", "security", "image", "network", "browser", "moe", "shell", "research"];
6
6
  const CHECK_TYPES = [
7
7
  "fileExists",
8
8
  "fileNotExists",
@@ -42,6 +42,9 @@ export function validateScenario(s) {
42
42
  if (s.reps !== undefined && (!Number.isInteger(s.reps) || s.reps < 1)) {
43
43
  errors.push(`[${s.id}] reps must be a positive integer`);
44
44
  }
45
+ if (s.timeoutMs !== undefined && (!Number.isInteger(s.timeoutMs) || s.timeoutMs < 1000)) {
46
+ errors.push(`[${s.id}] timeoutMs must be an integer >= 1000`);
47
+ }
45
48
  if (s.passThreshold !== undefined &&
46
49
  (!Number.isInteger(s.passThreshold) || s.passThreshold < 1)) {
47
50
  errors.push(`[${s.id}] passThreshold must be a positive integer`);
@@ -92,6 +95,7 @@ function normalizeScenario(data, file) {
92
95
  prompt: String(d.prompt ?? ""),
93
96
  reps: typeof d.reps === "number" ? d.reps : undefined,
94
97
  passThreshold: typeof d.passThreshold === "number" ? d.passThreshold : undefined,
98
+ timeoutMs: typeof d.timeoutMs === "number" ? d.timeoutMs : undefined,
95
99
  fixtures: Array.isArray(d.fixtures) ? d.fixtures : undefined,
96
100
  checks: Array.isArray(d.checks) ? d.checks : [],
97
101
  skipReason: typeof d.skipReason === "string" ? d.skipReason : undefined,
@@ -104,3 +108,13 @@ export function filterByTags(scenarios, tags) {
104
108
  }
105
109
  return scenarios.filter((s) => s.tags.some((t) => tags.includes(t)));
106
110
  }
111
+ /** Keep only scenarios whose id is in the list (exact, case-insensitive match). */
112
+ export function filterByScenarios(scenarios, ids) {
113
+ const wanted = new Set(ids.map((id) => id.toLowerCase()));
114
+ return scenarios.filter((s) => wanted.has(s.id.toLowerCase()));
115
+ }
116
+ /** Return ids from the request that do not exist among loaded scenarios. */
117
+ export function unknownScenarioIds(scenarios, ids) {
118
+ const known = new Set(scenarios.map((s) => s.id.toLowerCase()));
119
+ return ids.filter((id) => !known.has(id.toLowerCase()));
120
+ }
@@ -1,8 +1,17 @@
1
1
  import { existsSync, readFileSync, mkdirSync, writeFileSync } from "fs";
2
- import { join } from "path";
2
+ import { dirname, join } from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { homedir } from "os";
3
5
  export function manifestPath(projectDir) {
4
6
  return join(projectDir, "certification", "certifications.json");
5
7
  }
8
+ /** Directory of the global (per-user) manifest. Overridable for tests. */
9
+ export function globalManifestDir(override) {
10
+ return override || process.env.MMA_CERT_HOME || join(homedir(), ".mma");
11
+ }
12
+ export function globalManifestPath(override) {
13
+ return join(globalManifestDir(override), "certifications.json");
14
+ }
6
15
  export function readManifest(projectDir) {
7
16
  const path = manifestPath(projectDir);
8
17
  try {
@@ -21,25 +30,71 @@ export function saveManifest(m, projectDir) {
21
30
  mkdirSync(join(projectDir, "certification"), { recursive: true });
22
31
  writeFileSync(path, JSON.stringify(m, null, 2), "utf-8");
23
32
  }
24
- export function upsertCertification(entry, projectDir) {
25
- const m = readManifest(projectDir);
26
- const idx = m.certifications.findIndex((e) => e.model === entry.model && e.providerUrl === entry.providerUrl);
33
+ export function readGlobalManifest(override) {
34
+ const path = globalManifestPath(override);
35
+ try {
36
+ if (existsSync(path)) {
37
+ const raw = JSON.parse(readFileSync(path, "utf-8"));
38
+ return { version: 1, certifications: raw.certifications ?? [] };
39
+ }
40
+ }
41
+ catch {
42
+ /* corrupted global manifest → start fresh */
43
+ }
44
+ return { version: 1, certifications: [] };
45
+ }
46
+ export function saveGlobalManifest(m, override) {
47
+ const dir = globalManifestDir(override);
48
+ mkdirSync(dir, { recursive: true });
49
+ writeFileSync(globalManifestPath(override), JSON.stringify(m, null, 2), "utf-8");
50
+ }
51
+ const certKey = (e) => `${e.model}::${e.providerUrl}`;
52
+ /**
53
+ * Global + project manifests merged by (model, providerUrl).
54
+ * Project entries win over global ones on the same key.
55
+ */
56
+ export function readMergedManifest(projectDir, override) {
57
+ const merged = new Map();
58
+ for (const e of readGlobalManifest(override).certifications)
59
+ merged.set(certKey(e), e);
60
+ for (const e of readManifest(projectDir).certifications)
61
+ merged.set(certKey(e), e);
62
+ return { version: 1, certifications: [...merged.values()] };
63
+ }
64
+ export function upsertCertification(entry, override) {
65
+ const m = readGlobalManifest(override);
66
+ const idx = m.certifications.findIndex((e) => certKey(e) === certKey(entry));
27
67
  if (idx >= 0)
28
68
  m.certifications[idx] = entry;
29
69
  else
30
70
  m.certifications.push(entry);
31
- saveManifest(m, projectDir);
71
+ saveGlobalManifest(m, override);
32
72
  return m;
33
73
  }
34
- export function removeCertification(model, providerUrl, projectDir) {
35
- const m = readManifest(projectDir);
36
- const before = m.certifications.length;
37
- m.certifications = m.certifications.filter((e) => !(e.model === model && e.providerUrl === providerUrl));
38
- if (m.certifications.length !== before) {
39
- saveManifest(m, projectDir);
40
- return true;
74
+ export function removeCertification(model, providerUrl, opts) {
75
+ const key = `${model}::${providerUrl}`;
76
+ const g = readGlobalManifest(opts?.globalDir);
77
+ const beforeG = g.certifications.length;
78
+ g.certifications = g.certifications.filter((e) => certKey(e) !== key);
79
+ const removedGlobal = g.certifications.length !== beforeG;
80
+ if (removedGlobal)
81
+ saveGlobalManifest(g, opts?.globalDir);
82
+ // Legacy per-project manifests may still hold the entry — clean those too.
83
+ let removedProject = false;
84
+ try {
85
+ const projectDir = opts?.projectDir ?? process.cwd();
86
+ const p = readManifest(projectDir);
87
+ const beforeP = p.certifications.length;
88
+ p.certifications = p.certifications.filter((e) => certKey(e) !== key);
89
+ if (p.certifications.length !== beforeP) {
90
+ saveManifest(p, projectDir);
91
+ removedProject = true;
92
+ }
93
+ }
94
+ catch {
95
+ /* no project manifest — fine */
41
96
  }
42
- return false;
97
+ return { global: removedGlobal, project: removedProject };
43
98
  }
44
99
  export function isStale(entry, currentVersion) {
45
100
  return entry.mmaVersion !== currentVersion;
@@ -47,8 +102,8 @@ export function isStale(entry, currentVersion) {
47
102
  export function isFullyPassed(entry) {
48
103
  return entry.suite.failed === 0 && entry.suite.passed > 0;
49
104
  }
50
- export function getCertMark(model, providerUrl, currentVersion, projectDir) {
51
- const m = readManifest(projectDir);
105
+ export function getCertMark(model, providerUrl, currentVersion, projectDir, globalOverride) {
106
+ const m = readMergedManifest(projectDir, globalOverride);
52
107
  const entry = m.certifications.find((e) => e.model === model && e.providerUrl === providerUrl);
53
108
  if (!entry)
54
109
  return "none";
@@ -56,3 +111,59 @@ export function getCertMark(model, providerUrl, currentVersion, projectDir) {
56
111
  return "stale";
57
112
  return isFullyPassed(entry) ? "certified" : "none";
58
113
  }
114
+ /** Snapshot file marking which bundled manifest was last synced to the global one. */
115
+ function syncSnapshotPath(override) {
116
+ return join(globalManifestDir(override), "certifications.synced.json");
117
+ }
118
+ /**
119
+ * Locate the certifications.json bundled with the installed MMA package.
120
+ * - dist/main.js layout: <dist>/certification/certifications.json (copied by copy-assets)
121
+ * - source layout: <repo-root>/certification/certifications.json
122
+ */
123
+ export function bundledManifestPath() {
124
+ const here = dirname(fileURLToPath(import.meta.url));
125
+ const candidates = [
126
+ join(here, "certification", "certifications.json"),
127
+ join(here, "..", "certification", "certifications.json"),
128
+ join(here, "..", "..", "..", "certification", "certifications.json"),
129
+ ];
130
+ for (const p of candidates) {
131
+ if (existsSync(p))
132
+ return p;
133
+ }
134
+ return null;
135
+ }
136
+ /**
137
+ * Sync the bundled (shipped-with-package) manifest into the global
138
+ * ~/.mma/certifications.json. Runs once per process start:
139
+ * the bundled file overwrites its own entries on every package update;
140
+ * user-local certifications for OTHER keys are preserved. A snapshot of the
141
+ * raw bundled content marks what was already synced, so nothing is rewritten
142
+ * between updates.
143
+ */
144
+ export function syncGlobalManifest(override, bundledPathOverride) {
145
+ try {
146
+ const bundledPath = bundledPathOverride ?? bundledManifestPath();
147
+ if (!bundledPath || !existsSync(bundledPath))
148
+ return { synced: false, reason: "no-bundled" };
149
+ const raw = readFileSync(bundledPath, "utf-8");
150
+ const snapshotPath = syncSnapshotPath(override);
151
+ if (existsSync(snapshotPath) && readFileSync(snapshotPath, "utf-8") === raw) {
152
+ return { synced: false, reason: "unchanged" };
153
+ }
154
+ const bundled = JSON.parse(raw);
155
+ const current = readGlobalManifest(override);
156
+ const merged = new Map();
157
+ for (const e of current.certifications)
158
+ merged.set(certKey(e), e);
159
+ for (const e of bundled.certifications ?? [])
160
+ merged.set(certKey(e), e);
161
+ saveGlobalManifest({ version: 1, certifications: [...merged.values()] }, override);
162
+ mkdirSync(globalManifestDir(override), { recursive: true });
163
+ writeFileSync(snapshotPath, raw, "utf-8");
164
+ return { synced: true, reason: "synced" };
165
+ }
166
+ catch {
167
+ return { synced: false, reason: "error" };
168
+ }
169
+ }
@@ -1,8 +1,9 @@
1
1
  import { spawn } from "child_process";
2
2
  import { existsSync, mkdirSync, rmSync, cpSync, writeFileSync, readdirSync, readFileSync } from "fs";
3
- import { platform } from "os";
4
3
  import { join, resolve, dirname, relative } from "path";
5
4
  import { checkSandbox } from "./fact-checker";
5
+ import { killTree } from "../processes/kill-tree";
6
+ import { toForwardSlash } from "../../tools/path-utils";
6
7
  export async function runScenario(scenario, opts) {
7
8
  if (scenario.mode === "skip") {
8
9
  return {
@@ -16,7 +17,7 @@ export async function runScenario(scenario, opts) {
16
17
  const reps = scenario.reps ?? opts.defaultReps;
17
18
  const threshold = Math.min(scenario.passThreshold ?? opts.defaultThreshold, reps);
18
19
  const runner = opts.runner ?? defaultRunner;
19
- const timeoutMs = opts.timeoutMs ?? 300_000;
20
+ const timeoutMs = scenario.timeoutMs ?? opts.timeoutMs ?? 300_000;
20
21
  const entryPoint = resolveMmaEntry(opts.mmaRoot);
21
22
  let passed = 0;
22
23
  let firstError;
@@ -144,7 +145,7 @@ function listFiles(dir, root) {
144
145
  if (entry.name === ".mma")
145
146
  continue;
146
147
  const abs = join(dir, entry.name);
147
- const rel = relative(root, abs).replace(/\\/g, "/");
148
+ const rel = toForwardSlash(relative(root, abs));
148
149
  if (entry.isDirectory()) {
149
150
  result.push(...listFiles(abs, root));
150
151
  }
@@ -201,29 +202,6 @@ export const defaultRunner = (env, cwd, args, timeoutMs) => new Promise((resolve
201
202
  resolvePromise({ code: null, stdout, stderr, timedOut });
202
203
  });
203
204
  });
204
- function killTree(child) {
205
- const pid = child.pid;
206
- if (!pid)
207
- return;
208
- if (platform() === "win32") {
209
- spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
210
- windowsHide: true,
211
- stdio: "ignore",
212
- });
213
- return;
214
- }
215
- try {
216
- process.kill(-pid, "SIGTERM");
217
- }
218
- catch {
219
- try {
220
- child.kill("SIGKILL");
221
- }
222
- catch {
223
- /* already dead */
224
- }
225
- }
226
- }
227
205
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
228
206
  function deepMergeAny(target, source) {
229
207
  if (!source || typeof source !== "object")