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
@@ -1,9 +1,9 @@
1
- import { existsSync } from "fs";
2
- import { resolve, extname, join } from "path";
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { resolve, extname, dirname, join } from "path";
3
3
  import { spawn } from "child_process";
4
4
  import { t } from "../../i18n/index";
5
5
  import { validateExpertConfig } from "../../config/experts";
6
- import { findProjectRoot } from "../lsp/project-root";
6
+ import { findProjectRoot, resolveTscCommand } from "../lsp/project-root";
7
7
  import { extractFileLikeTokens, stripUrls } from "../hallucination/js-identifiers";
8
8
  import { findExistingFile } from "./auditor";
9
9
  export class StepVerifier {
@@ -36,8 +36,11 @@ export class StepVerifier {
36
36
  if (!existsSync(tsconfigPath)) {
37
37
  return { passed: true, message: "No tsconfig.json found — skipping type check" };
38
38
  }
39
+ const tsc = resolveTscCommand(this.baseDir);
40
+ if (!tsc)
41
+ return { passed: true, message: "tsc not installed — skipping type check" };
39
42
  try {
40
- await this.runAsync("npx tsc --noEmit", this.baseDir, 60_000);
43
+ await this.runAsync(`${tsc} --noEmit`, this.baseDir, 60_000);
41
44
  return { passed: true, message: "TypeScript type check passed" };
42
45
  }
43
46
  catch (e) {
@@ -58,8 +61,11 @@ export class StepVerifier {
58
61
  if (!existsSync(tsconfigPath)) {
59
62
  return { passed: true, message: "No tsconfig.json found — skipping type check" };
60
63
  }
64
+ const tsc = resolveTscCommand(projectRoot);
65
+ if (!tsc)
66
+ return { passed: true, message: "tsc not installed — skipping type check" };
61
67
  try {
62
- await this.runAsync("npx tsc --noEmit --skipLibCheck", projectRoot, 60_000);
68
+ await this.runAsync(`${tsc} --noEmit --skipLibCheck`, projectRoot, 60_000);
63
69
  return { passed: true, message: "TypeScript type check passed" };
64
70
  }
65
71
  catch (e) {
@@ -86,6 +92,75 @@ export class StepVerifier {
86
92
  async verifyArtifactFiles(files) {
87
93
  return Promise.all(files.map((f) => this.checkFileExists(f)));
88
94
  }
95
+ /**
96
+ * Check a subtask's machine-readable success_criteria BEFORE the merge
97
+ * decision (Plan 5.1). Supported forms:
98
+ * - `file-exists:<path>`
99
+ * - `substring-in-file:<path>:<text>`
100
+ * - `command-exit-0:<command>`
101
+ * Anything else is treated as an LLM-only criterion and reported as a
102
+ * warning (never an error) so the Router still sees it in context.
103
+ */
104
+ async verifySubtaskCriteria(subtask) {
105
+ const errors = [];
106
+ const warnings = [];
107
+ const details = [];
108
+ for (const criterion of subtask.success_criteria || []) {
109
+ const sep = criterion.indexOf(":");
110
+ if (sep <= 0) {
111
+ warnings.push(`Subtask "${subtask.id}": non-machine criterion "${criterion}" (LLM-only)`);
112
+ continue;
113
+ }
114
+ const kind = criterion.slice(0, sep);
115
+ const rest = criterion.slice(sep + 1);
116
+ if (kind === "file-exists") {
117
+ const check = await this.checkFileExists(rest);
118
+ details.push(check);
119
+ if (!check.passed)
120
+ errors.push(`[${subtask.id}] ${check.message}`);
121
+ continue;
122
+ }
123
+ if (kind === "substring-in-file") {
124
+ const colon = rest.indexOf(":");
125
+ if (colon <= 0) {
126
+ warnings.push(`Subtask "${subtask.id}": malformed substring-in-file criterion "${criterion}"`);
127
+ continue;
128
+ }
129
+ const path = rest.slice(0, colon);
130
+ const needle = rest.slice(colon + 1);
131
+ const resolved = resolve(this.baseDir, path);
132
+ if (!existsSync(resolved)) {
133
+ const item = { passed: false, message: t("verify.file_not_found", { path }) };
134
+ details.push(item);
135
+ errors.push(`[${subtask.id}] ${item.message}`);
136
+ continue;
137
+ }
138
+ const content = readFileSync(resolved, "utf-8");
139
+ const passed = content.includes(needle);
140
+ details.push({ passed, message: `substring "${needle}" in ${path}: ${passed}` });
141
+ if (!passed)
142
+ errors.push(`[${subtask.id}] "${needle}" not found in ${path}`);
143
+ continue;
144
+ }
145
+ if (kind === "command-exit-0") {
146
+ try {
147
+ await this.runAsync(rest, this.baseDir, 30_000);
148
+ details.push({ passed: true, message: `command ok: ${rest}` });
149
+ }
150
+ catch (e) {
151
+ const item = {
152
+ passed: false,
153
+ message: `command failed: ${rest} (${String(e.message).slice(0, 200)})`,
154
+ };
155
+ details.push(item);
156
+ errors.push(`[${subtask.id}] ${item.message}`);
157
+ }
158
+ continue;
159
+ }
160
+ warnings.push(`Subtask "${subtask.id}": unknown criterion kind "${kind}" (LLM-only)`);
161
+ }
162
+ return { errors, warnings, details };
163
+ }
89
164
  async verifyMoEManifest(plan, config, allToolTags) {
90
165
  const errors = [];
91
166
  const warnings = [];
@@ -108,6 +183,11 @@ export class StepVerifier {
108
183
  errors.push(check.message);
109
184
  }
110
185
  }
186
+ // Machine-readable success_criteria (Plan 5.1) — checked before merge.
187
+ const criteria = await this.verifySubtaskCriteria(sub);
188
+ errors.push(...criteria.errors);
189
+ warnings.push(...criteria.warnings);
190
+ details.push(...criteria.details);
111
191
  }
112
192
  return {
113
193
  success: errors.length === 0,
@@ -161,8 +241,15 @@ export class StepVerifier {
161
241
  async validateSyntax(filePath) {
162
242
  const ext = extname(filePath);
163
243
  if (ext === ".ts" || ext === ".tsx" || ext === ".cts" || ext === ".mts") {
244
+ // Локальный tsc, а не npx: npx без локального пакета уходит в сеть и
245
+ // висит секундами. Нет tsc — пропускаем проверку (как и раньше «not
246
+ // found → true»), но мгновенно.
247
+ const tsc = resolveTscCommand(dirname(filePath));
248
+ if (!tsc)
249
+ return true;
164
250
  try {
165
- await this.runAsync(`npx tsc --noEmit --skipLibCheck ${filePath}`, this.baseDir, 10000);
251
+ // Quote the path commands run through a shell and break on spaces.
252
+ await this.runAsync(`${tsc} --noEmit --skipLibCheck "${filePath}"`, this.baseDir, 20000);
166
253
  return true;
167
254
  }
168
255
  catch (err) {
@@ -180,7 +267,7 @@ export class StepVerifier {
180
267
  }
181
268
  if (ext === ".js" || ext === ".jsx" || ext === ".cjs" || ext === ".mjs") {
182
269
  try {
183
- await this.runAsync(`node --check ${filePath}`, this.baseDir, 5000);
270
+ await this.runAsync(`node --check "${filePath}"`, this.baseDir, 5000);
184
271
  return true;
185
272
  }
186
273
  catch {
@@ -16,6 +16,17 @@
16
16
  const FORBIDDEN_COMMANDS = new Set([
17
17
  "grep",
18
18
  "sed",
19
+ "awk",
20
+ "uniq",
21
+ "xargs",
22
+ "cut",
23
+ "tr",
24
+ "basename",
25
+ "dirname",
26
+ "export",
27
+ "source",
28
+ "sleep",
29
+ "env",
19
30
  "ls",
20
31
  "find",
21
32
  "rm",
@@ -1,6 +1,12 @@
1
1
  import { t } from "../../i18n/index";
2
2
  const MIN_CHARS = 1;
3
- const MIN_WORDS = 5;
3
+ /** Both responses must be this long before repetition applies — a short
4
+ * honest answer ("Done." / "The build passes.") repeated twice is not
5
+ * degeneration and must not burn a hallucination retry. */
6
+ const MIN_REPEAT_CHARS = 120;
7
+ const MIN_REPEAT_WORDS = 20;
8
+ /** Word-overlap above this threshold counts as repetition (was 0.5). */
9
+ const REPEAT_OVERLAP_THRESHOLD = 0.8;
4
10
  export class ConfidenceCheck {
5
11
  previousResponse = "";
6
12
  setPreviousResponse(response) {
@@ -14,25 +20,28 @@ export class ConfidenceCheck {
14
20
  reason: t("hall.short_response"),
15
21
  };
16
22
  }
17
- // Language-agnostic: very short response with no structured content
23
+ // Short replies are not flagged: a greeting, a terse acknowledgement or a
24
+ // one-line confirmation is a valid answer. Real truncation is caught by the
25
+ // provider's finish_reason ("length"), and emptiness by the retry above.
18
26
  const wordCount = response.split(/\s+/).filter(Boolean).length;
19
- const hasStructure = /```|^\s*[-*]\s|^\s*\d+\.\s|<[^>]+>/m.test(response);
20
- if (wordCount < MIN_WORDS && !hasStructure) {
21
- return {
22
- status: "warn",
23
- kind: "short",
24
- reason: t("hall.short_response"),
25
- };
26
- }
27
- // Language-agnostic: repetition detection via word overlap
27
+ // Language-agnostic: repetition detection via word overlap. Only flag when
28
+ // BOTH the current and the previous response are substantive — a short
29
+ // answer after a long one (or two short answers) is not a loop.
28
30
  if (this.previousResponse) {
29
- const overlap = this.calculateOverlap(response, this.previousResponse);
30
- if (overlap > 0.5) {
31
- return {
32
- status: "retry",
33
- kind: "repetition",
34
- reason: t("hall.repetitive", { pct: Math.round(overlap * 100) }),
35
- };
31
+ const prevWords = this.previousResponse.split(/\s+/).filter(Boolean).length;
32
+ const bothSubstantive = response.length >= MIN_REPEAT_CHARS &&
33
+ this.previousResponse.length >= MIN_REPEAT_CHARS &&
34
+ wordCount >= MIN_REPEAT_WORDS &&
35
+ prevWords >= MIN_REPEAT_WORDS;
36
+ if (bothSubstantive) {
37
+ const overlap = this.calculateOverlap(response, this.previousResponse);
38
+ if (overlap > REPEAT_OVERLAP_THRESHOLD) {
39
+ return {
40
+ status: "retry",
41
+ kind: "repetition",
42
+ reason: t("hall.repetitive", { pct: Math.round(overlap * 100) }),
43
+ };
44
+ }
36
45
  }
37
46
  }
38
47
  // Language-agnostic: very low word diversity (same words repeated)
@@ -56,11 +65,15 @@ export class ConfidenceCheck {
56
65
  return { status: "pass" };
57
66
  }
58
67
  calculateOverlap(a, b) {
59
- const wordsA = new Set(a.toLowerCase().split(/\s+/));
60
- const wordsB = b.toLowerCase().split(/\s+/);
61
- if (wordsB.length === 0)
68
+ const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(Boolean));
69
+ const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(Boolean));
70
+ const smaller = Math.min(wordsA.size, wordsB.size);
71
+ if (smaller === 0)
62
72
  return 0;
63
- const matches = wordsB.filter((w) => wordsA.has(w));
64
- return matches.length / wordsB.length;
73
+ let matches = 0;
74
+ for (const w of wordsB)
75
+ if (wordsA.has(w))
76
+ matches++;
77
+ return matches / smaller;
65
78
  }
66
79
  }
@@ -7,6 +7,9 @@ export class ConsistencyCheck {
7
7
  }
8
8
  trackCreatedFile(path) {
9
9
  this.createdFiles.add(path);
10
+ // A delete followed by a re-create un-deletes the file — otherwise
11
+ // wasFileDeleted() keeps reporting it as gone after delete+create.
12
+ this.deletedFiles.delete(path);
10
13
  }
11
14
  trackDeletedFile(path) {
12
15
  this.deletedFiles.add(path);
@@ -7,11 +7,15 @@ export class HallucinationDetector {
7
7
  confidence;
8
8
  factual;
9
9
  judge;
10
- constructor(baseDir, llmProvider) {
10
+ constructor(baseDir, llmProvider, options) {
11
11
  this.consistency = new ConsistencyCheck();
12
12
  this.confidence = new ConfidenceCheck();
13
13
  this.factual = baseDir ? new FactualCheck(baseDir) : null;
14
- this.judge = llmProvider ? new LLMJudge(llmProvider) : null;
14
+ // The judge is opt-out: it only runs when a provider is supplied AND the
15
+ // caller did not explicitly disable it (the main agent disables it by
16
+ // default via config.hallucination.judge.enabled).
17
+ const judgeEnabled = options?.judgeEnabled ?? true;
18
+ this.judge = llmProvider && judgeEnabled ? new LLMJudge(llmProvider) : null;
15
19
  }
16
20
  getConsistencyCheck() {
17
21
  return this.consistency;
@@ -28,7 +32,8 @@ export class HallucinationDetector {
28
32
  if (confidenceResult.status === "retry" || confidenceResult.status === "block") {
29
33
  return confidenceResult;
30
34
  }
31
- const factualResult = this.factual?.validate(response);
35
+ const deletedFiles = new Set(this.consistency.getDeletedFiles());
36
+ const factualResult = this.factual?.validate(response, deletedFiles);
32
37
  if (factualResult && factualResult.status !== "pass") {
33
38
  return factualResult;
34
39
  }
@@ -32,12 +32,17 @@ export class FactualCheck {
32
32
  for (const f of files)
33
33
  this.knownFiles.add(f);
34
34
  }
35
- validate(response) {
35
+ validate(response, deletedFiles) {
36
36
  const filePaths = this.extractFilePaths(response);
37
37
  if (filePaths.length === 0)
38
38
  return { status: "pass" };
39
+ const deletedBasenames = deletedFiles
40
+ ? new Set([...deletedFiles].map((p) => p.split(/[/\\]/).pop() ?? p))
41
+ : undefined;
39
42
  const nonExistent = [];
40
43
  for (const fp of filePaths) {
44
+ if (deletedBasenames?.has(fp))
45
+ continue;
41
46
  if (!this.pathExists(fp)) {
42
47
  nonExistent.push(fp);
43
48
  }
@@ -81,14 +86,28 @@ export class FactualCheck {
81
86
  * non-word char and `.`), so dotfiles (.prettierrc.json, .env, …) are
82
87
  * extracted as "prettierrc.json". Try the dot-prefixed basename too before
83
88
  * declaring the file missing. "src/config.json" also yields
84
- * "src/.config.json".
89
+ * "src/.config.json". For paths like "mma/index-cache.json", also try
90
+ * ".mma/index-cache.json" (dot-prefixed parent directories).
85
91
  */
86
92
  dotfileVariants(fp) {
87
- const idx = Math.max(fp.lastIndexOf("/"), fp.lastIndexOf("\\"));
88
- const base = idx >= 0 ? fp.slice(idx + 1) : fp;
89
- if (base.startsWith("."))
90
- return [fp];
91
- return idx >= 0 ? [fp, `${fp.slice(0, idx + 1)}.${base}`] : [fp, `.${fp}`];
93
+ const variants = [fp];
94
+ const sep = fp.includes("\\") ? "\\" : "/";
95
+ const parts = fp.split(sep);
96
+ // Try dot-prefixing the basename (existing logic)
97
+ const base = parts[parts.length - 1];
98
+ if (!base.startsWith(".")) {
99
+ const withDotBase = [...parts.slice(0, -1), `.${base}`].join(sep);
100
+ variants.push(withDotBase);
101
+ }
102
+ // Try dot-prefixing each parent directory component
103
+ for (let i = 0; i < parts.length - 1; i++) {
104
+ if (!parts[i].startsWith(".")) {
105
+ const dotted = [...parts.slice(0, i), `.${parts[i]}`, ...parts.slice(i + 1)].join(sep);
106
+ if (!variants.includes(dotted))
107
+ variants.push(dotted);
108
+ }
109
+ }
110
+ return variants;
92
111
  }
93
112
  bareNameExists(name) {
94
113
  for (const cand of this.dotfileVariants(name)) {
@@ -56,17 +56,24 @@ Reply with ONLY valid JSON, no other text:
56
56
  }
57
57
  async askJudge(messages) {
58
58
  let text = "";
59
+ const controller = new AbortController();
59
60
  let timer;
60
61
  const consume = (async () => {
61
- for await (const chunk of this.provider.chat(messages, [])) {
62
+ for await (const chunk of this.provider.chat(messages, [], controller.signal)) {
62
63
  if (chunk.type === "text" && chunk.content) {
63
64
  text += chunk.content;
64
65
  }
65
66
  }
66
67
  return text;
67
68
  })();
69
+ // The timeout can settle the race while the background stream is still
70
+ // running; its later rejection must not surface as an unhandled rejection.
71
+ consume.catch(() => { });
68
72
  const timeout = new Promise((_, reject) => {
69
- timer = setTimeout(() => reject(new Error("LLM judge timed out")), JUDGE_TIMEOUT_MS);
73
+ timer = setTimeout(() => {
74
+ controller.abort();
75
+ reject(new Error("LLM judge timed out"));
76
+ }, JUDGE_TIMEOUT_MS);
70
77
  });
71
78
  try {
72
79
  return await Promise.race([consume, timeout]);
@@ -74,6 +81,9 @@ Reply with ONLY valid JSON, no other text:
74
81
  finally {
75
82
  if (timer)
76
83
  clearTimeout(timer);
84
+ // Abort the background stream on the success path too, so a judge that
85
+ // resolved early never keeps streaming in the background.
86
+ controller.abort();
77
87
  }
78
88
  }
79
89
  parseVerdict(text) {
@@ -0,0 +1,35 @@
1
+ import { t } from "../../i18n/index";
2
+ /**
3
+ * Shared implementation of the project-map command used by both `mma map`
4
+ * (CLI) and `/map` (REPL): summary (the exact text injected into the system
5
+ * prompt), refresh, and find.
6
+ */
7
+ export async function runMapAction(module, action, query) {
8
+ const summary = async () => {
9
+ const result = await module.buildIndex();
10
+ if (!result)
11
+ return { ok: false, output: t("indexer.not_indexed") };
12
+ return { ok: true, output: module.getPromptMap() ?? t("indexer.not_indexed") };
13
+ };
14
+ switch (action) {
15
+ case "refresh": {
16
+ const result = await module.refresh();
17
+ if (!result)
18
+ return { ok: false, output: t("indexer.not_indexed") };
19
+ return {
20
+ ok: true,
21
+ output: `${t("map.refreshed")}\n${module.getPromptMap() ?? ""}`.trimEnd(),
22
+ };
23
+ }
24
+ case "find": {
25
+ const q = (query ?? "").trim();
26
+ if (!q)
27
+ return { ok: false, output: t("map.find_usage") };
28
+ await module.buildIndex();
29
+ return { ok: true, output: module.formatFind(q) };
30
+ }
31
+ case "summary":
32
+ default:
33
+ return summary();
34
+ }
35
+ }
@@ -0,0 +1,87 @@
1
+ import { toForwardSlash } from "../../tools/path-utils";
2
+ import { isCodeLanguage } from "./symbols";
3
+ /** Lockfiles and generated manifests: no structural value in the map. */
4
+ const NOISE_BASENAMES = new Set([
5
+ "package-lock.json",
6
+ "bun.lock",
7
+ "bun.lockb",
8
+ "yarn.lock",
9
+ "pnpm-lock.yaml",
10
+ "cargo.lock",
11
+ "go.sum",
12
+ "composer.lock",
13
+ "poetry.lock",
14
+ ]);
15
+ export function isNoiseFile(file) {
16
+ const base = toForwardSlash(file.path).split("/").pop()?.toLowerCase() ?? "";
17
+ return NOISE_BASENAMES.has(base);
18
+ }
19
+ function isTestFile(path) {
20
+ return (path.startsWith("test/") ||
21
+ path.startsWith("tests/") ||
22
+ path.includes("/test/") ||
23
+ path.includes("/tests/") ||
24
+ path.includes("__tests__/") ||
25
+ /\.(test|spec)\.[a-z0-9]+$/i.test(path));
26
+ }
27
+ /** Lower rank = more useful for understanding the project structure. */
28
+ function rank(file) {
29
+ const path = toForwardSlash(file.path).toLowerCase();
30
+ if (isTestFile(path))
31
+ return 1;
32
+ if (isCodeLanguage(file.language))
33
+ return 0;
34
+ return 2;
35
+ }
36
+ /** Top-level segment of a path (`src/core/x.ts` → `src`; root file → its name). */
37
+ function topDir(path) {
38
+ const normalized = toForwardSlash(path);
39
+ const slash = normalized.indexOf("/");
40
+ return slash >= 0 ? normalized.slice(0, slash) : normalized;
41
+ }
42
+ /** How many source-code files live under each top-level directory. */
43
+ function codeCountByTopDir(files) {
44
+ const counts = new Map();
45
+ for (const file of files) {
46
+ if (isNoiseFile(file) || rank(file) !== 0)
47
+ continue;
48
+ const dir = topDir(file.path);
49
+ counts.set(dir, (counts.get(dir) ?? 0) + 1);
50
+ }
51
+ return counts;
52
+ }
53
+ /**
54
+ * Pick the files to list in the project-map prompt block.
55
+ *
56
+ * The index is built by an arbitrary `readdir` walk, so a naive
57
+ * `files.slice(0, N)` can end up listing only docs, config and tests while the
58
+ * actual source tree (`src/`) never appears — the model then sees a "project
59
+ * map" with no code in it. The selection:
60
+ * 1. drops lockfile noise,
61
+ * 2. ranks source before tests, tests before docs/config,
62
+ * 3. within a rank, puts directories with the most source files first (the
63
+ * densest code root is almost always the real source tree — no directory
64
+ * names hardcoded),
65
+ * 4. then shallower paths, then alphabetical — deterministic so the block
66
+ * does not change between turns and the prefix KV-cache survives.
67
+ */
68
+ export function selectMapFiles(files, max) {
69
+ const codeByDir = codeCountByTopDir(files);
70
+ return files
71
+ .filter((file) => !isNoiseFile(file))
72
+ .sort((a, b) => {
73
+ const rankDiff = rank(a) - rank(b);
74
+ if (rankDiff !== 0)
75
+ return rankDiff;
76
+ const densityDiff = (codeByDir.get(topDir(b.path)) ?? 0) - (codeByDir.get(topDir(a.path)) ?? 0);
77
+ if (densityDiff !== 0)
78
+ return densityDiff;
79
+ const pathA = toForwardSlash(a.path);
80
+ const pathB = toForwardSlash(b.path);
81
+ const depthDiff = pathA.split("/").length - pathB.split("/").length;
82
+ if (depthDiff !== 0)
83
+ return depthDiff;
84
+ return pathA < pathB ? -1 : pathA > pathB ? 1 : 0;
85
+ })
86
+ .slice(0, max);
87
+ }
@@ -1,8 +1,13 @@
1
1
  import { dirname } from "path";
2
2
  import { Indexer } from "./walker";
3
3
  import { IndexCache } from "./cache";
4
+ import { selectMapFiles } from "./map-select";
4
5
  import { buildProjectProfileLine } from "./project-profile";
5
6
  import { t } from "../../i18n/index";
7
+ import { toForwardSlash } from "../../tools/path-utils";
8
+ import { estimateTokens } from "../../llm/token-counter";
9
+ /** Max files listed in the project-map prompt block (code first; see map-select). */
10
+ const MAP_FILE_LIMIT = 80;
6
11
  export class IndexerModule {
7
12
  name = "indexer";
8
13
  indexer;
@@ -47,6 +52,25 @@ export class IndexerModule {
47
52
  return path.includes(q) || f.exports.some((e) => e.toLowerCase().includes(q));
48
53
  });
49
54
  }
55
+ /** Human/model-readable result of a `find` query (shared by the tool and `mma map`). */
56
+ formatFind(query) {
57
+ const matches = this.find(query);
58
+ if (matches.length === 0) {
59
+ return t("indexer.no_matches", { query });
60
+ }
61
+ const lines = matches
62
+ .map((f) => {
63
+ const path = toForwardSlash(f.path);
64
+ const exports = f.exports.length > 0 ? `: ${f.exports.join(", ")}` : "";
65
+ return `- ${path}${exports}`;
66
+ })
67
+ .join("\n");
68
+ return t("indexer.find_results", { count: matches.length, results: lines });
69
+ }
70
+ /** The exact project-map text injected into the system prompt (null if not indexed). */
71
+ getPromptMap() {
72
+ return this.index ? this.formatMap(this.index) : null;
73
+ }
50
74
  watch() {
51
75
  this.unwatch();
52
76
  try {
@@ -80,6 +104,7 @@ export class IndexerModule {
80
104
  priority: "normal",
81
105
  essential: false,
82
106
  estimatedTokens: this.estimateTokens(content),
107
+ kind: "project-map",
83
108
  };
84
109
  }
85
110
  getToolDefinitions() {
@@ -106,13 +131,14 @@ export class IndexerModule {
106
131
  .slice(0, 10)
107
132
  .map(([dir, count]) => `${dir} (${count})`)
108
133
  .join(", ") || "-";
109
- const fileLines = result.files.slice(0, 100).map((f) => {
110
- const path = f.path.replace(/\\/g, "/");
134
+ const listed = selectMapFiles(result.files, MAP_FILE_LIMIT);
135
+ const fileLines = listed.map((f) => {
136
+ const path = toForwardSlash(f.path);
111
137
  const exports = f.exports.length > 0 ? `: ${f.exports.join(", ")}` : "";
112
138
  return `- ${path}${exports}`;
113
139
  });
114
- const more = result.files.length > 100
115
- ? `\n${t("indexer.and_more", { count: result.files.length - 100 })}`
140
+ const more = result.files.length > listed.length
141
+ ? `\n${t("indexer.and_more", { count: result.files.length - listed.length })}`
116
142
  : "";
117
143
  const duplicates = this.findDuplicateBasenames(result);
118
144
  return [
@@ -137,7 +163,7 @@ export class IndexerModule {
137
163
  findDuplicateBasenames(result) {
138
164
  const byDir = {};
139
165
  for (const f of result.files) {
140
- const path = f.path.replace(/\\/g, "/");
166
+ const path = toForwardSlash(f.path);
141
167
  const idx = path.lastIndexOf("/");
142
168
  const dir = idx >= 0 ? path.slice(0, idx) : "";
143
169
  const name = path.slice(idx + 1);
@@ -167,7 +193,7 @@ export class IndexerModule {
167
193
  getDirectoryCounts(result) {
168
194
  const counts = {};
169
195
  for (const f of result.files) {
170
- const normalized = f.path.replace(/\\/g, "/");
196
+ const normalized = toForwardSlash(f.path);
171
197
  const dir = dirname(normalized);
172
198
  const key = dir === "." ? "(root)" : dir;
173
199
  counts[key] = (counts[key] || 0) + 1;
@@ -175,7 +201,7 @@ export class IndexerModule {
175
201
  return counts;
176
202
  }
177
203
  estimateTokens(content) {
178
- return Math.ceil(content.length / 4);
204
+ return estimateTokens(content);
179
205
  }
180
206
  createProjectMapTool() {
181
207
  return {
@@ -211,21 +237,7 @@ export class IndexerModule {
211
237
  const query = String(args.query || "").toLowerCase();
212
238
  if (!query)
213
239
  return { success: false, output: t("tool.invalid_params") };
214
- const matches = this.find(query);
215
- if (matches.length === 0) {
216
- return this.makeResult(t("indexer.no_matches", { query }));
217
- }
218
- const lines = matches
219
- .map((f) => {
220
- const path = f.path.replace(/\\/g, "/");
221
- const exports = f.exports.length > 0 ? `: ${f.exports.join(", ")}` : "";
222
- return `- ${path}${exports}`;
223
- })
224
- .join("\n");
225
- return this.makeResult(t("indexer.find_results", {
226
- count: matches.length,
227
- results: lines,
228
- }));
240
+ return this.makeResult(this.formatFind(query));
229
241
  }
230
242
  if (!this.index) {
231
243
  await this.buildIndex();