micro-models-agent 0.51.1 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/dist/cli/commands.js +162 -38
  2. package/dist/cli/completer.js +5 -5
  3. package/dist/cli/main.js +42 -54
  4. package/dist/cli/repl-commands.js +138 -38
  5. package/dist/cli/repl.js +175 -89
  6. package/dist/cli/run-result.js +11 -0
  7. package/dist/cli/security-commands.js +6 -6
  8. package/dist/cli/setup.js +21 -15
  9. package/dist/config/config.js +54 -27
  10. package/dist/config/defaults.js +17 -0
  11. package/dist/config/domains.js +179 -0
  12. package/dist/config/index.js +2 -1
  13. package/dist/config/security.js +28 -8
  14. package/dist/core/agent.js +162 -30
  15. package/dist/core/bootstrap.js +94 -17
  16. package/dist/core/crash-handler.js +51 -0
  17. package/dist/core/environment.js +199 -0
  18. package/dist/core/session-logger.js +60 -6
  19. package/dist/core/version.js +2 -0
  20. package/dist/i18n/en.json +120 -39
  21. package/dist/i18n/ru.json +91 -10
  22. package/dist/llm/openai-compat.js +191 -53
  23. package/dist/llm/orchestrator.js +5 -3
  24. package/dist/logger/app-logger.js +50 -4
  25. package/dist/main.js +1288 -635
  26. package/dist/modules/browser/session.js +4 -0
  27. package/dist/modules/certification/cli.js +58 -19
  28. package/dist/modules/certification/loader.js +2 -1
  29. package/dist/modules/certification/manifest.js +22 -14
  30. package/dist/modules/certification/runner.js +91 -5
  31. package/dist/modules/certification/scenarios.js +290 -7
  32. package/dist/modules/context/fact-extractor.js +6 -0
  33. package/dist/modules/context/manager.js +19 -2
  34. package/dist/modules/execution/audit-runners.js +61 -7
  35. package/dist/modules/execution/execution-plugin.js +219 -60
  36. package/dist/modules/execution/module.js +207 -18
  37. package/dist/modules/execution/moe-executor.js +33 -20
  38. package/dist/modules/execution/plan-store.js +39 -0
  39. package/dist/modules/execution/plan-tool.js +188 -19
  40. package/dist/modules/execution/planner.js +27 -23
  41. package/dist/modules/execution/stuck-detector.js +244 -8
  42. package/dist/modules/execution/tracker.js +8 -6
  43. package/dist/modules/execution/verifier.js +15 -2
  44. package/dist/modules/hallucination/detector.js +4 -0
  45. package/dist/modules/hallucination/factual.js +45 -5
  46. package/dist/modules/indexer/module.js +1 -0
  47. package/dist/modules/lsp/client.js +123 -12
  48. package/dist/modules/lsp/index.js +1 -1
  49. package/dist/modules/lsp/module.js +30 -2
  50. package/dist/modules/lsp/probe.js +11 -1
  51. package/dist/modules/lsp/startup-check.js +5 -2
  52. package/dist/modules/plugins/builtin/lint-on-write.js +144 -41
  53. package/dist/modules/plugins/manager.js +57 -13
  54. package/dist/modules/pricing/index.js +61 -0
  55. package/dist/modules/pricing/prices.js +129 -0
  56. package/dist/modules/providers/create.js +22 -0
  57. package/dist/modules/providers/fallback.js +79 -0
  58. package/dist/modules/providers/health.js +46 -0
  59. package/dist/modules/providers/index.js +5 -0
  60. package/dist/modules/providers/manager.js +161 -0
  61. package/dist/modules/providers/presets.js +128 -0
  62. package/dist/modules/providers/registry.js +22 -0
  63. package/dist/modules/providers/types.js +1 -0
  64. package/dist/modules/registry.js +1 -0
  65. package/dist/modules/security/command-validator.js +14 -0
  66. package/dist/modules/security/encryption.js +6 -6
  67. package/dist/modules/security/network-validator.js +17 -0
  68. package/dist/modules/security/path-validator.js +22 -26
  69. package/dist/modules/session/store.js +10 -10
  70. package/dist/tools/approve.js +1 -0
  71. package/dist/tools/attach-image.js +12 -0
  72. package/dist/tools/bash.js +27 -4
  73. package/dist/tools/browser.js +1 -0
  74. package/dist/tools/chunk-query.js +1 -0
  75. package/dist/tools/create-dir.js +1 -0
  76. package/dist/tools/delete-file.js +1 -0
  77. package/dist/tools/download-file.js +1 -0
  78. package/dist/tools/edit-file.js +2 -1
  79. package/dist/tools/enable-tools.js +1 -0
  80. package/dist/tools/executor.js +17 -7
  81. package/dist/tools/file-info.js +1 -0
  82. package/dist/tools/glob-tool.js +1 -0
  83. package/dist/tools/grep-tool.js +54 -13
  84. package/dist/tools/list-dir.js +1 -0
  85. package/dist/tools/load-skill.js +1 -0
  86. package/dist/tools/mcp-call.js +1 -0
  87. package/dist/tools/move-file.js +1 -0
  88. package/dist/tools/path-utils.js +51 -1
  89. package/dist/tools/pipeline-run.js +1 -0
  90. package/dist/tools/process-kill.js +11 -0
  91. package/dist/tools/process-list.js +1 -0
  92. package/dist/tools/process-log.js +9 -0
  93. package/dist/tools/question.js +1 -0
  94. package/dist/tools/read-file.js +94 -6
  95. package/dist/tools/recall.js +1 -0
  96. package/dist/tools/remember.js +1 -0
  97. package/dist/tools/scope-check.js +7 -5
  98. package/dist/tools/search-history.js +1 -0
  99. package/dist/tools/subagent.js +4 -4
  100. package/dist/tools/web-browse.js +1 -0
  101. package/dist/tools/web-fetch.js +27 -6
  102. package/dist/tools/web-search.js +70 -43
  103. package/dist/tools/write-file.js +1 -0
  104. package/dist/ui/line-editor.js +142 -23
  105. package/dist/ui/line-math.js +8 -4
  106. package/dist/ui/renderer.js +57 -7
  107. package/package.json +50 -48
@@ -1,6 +1,7 @@
1
1
  import { TokenCounter } from "./token-counter";
2
2
  import { t } from "../i18n/index";
3
3
  import { createRateLimiter } from "../modules/security/rate-limiter";
4
+ const REQUEST_TIMEOUT_MS = 120000;
4
5
  function buildRequestBody(opts) {
5
6
  const body = {
6
7
  model: opts.model,
@@ -41,6 +42,8 @@ export class OpenAICompatProvider {
41
42
  maxRetries: 3,
42
43
  baseDelay: 1000,
43
44
  maxDelay: 30000,
45
+ maxStreamRetries: 2,
46
+ noDataTimeoutMs: 180000,
44
47
  };
45
48
  this.rateLimiter = createRateLimiter(config.rateLimits);
46
49
  }
@@ -54,15 +57,11 @@ export class OpenAICompatProvider {
54
57
  const streamResult = this.doStream(messages, tools, signal, options);
55
58
  let hasToolCall = false;
56
59
  let hasText = false;
57
- let reasoningAcc = "";
58
60
  for await (const chunk of streamResult) {
59
61
  if (chunk.type === "tool_call")
60
62
  hasToolCall = true;
61
63
  if (chunk.type === "text")
62
64
  hasText = true;
63
- if (chunk.type === "reasoning" && chunk.content) {
64
- reasoningAcc += chunk.content;
65
- }
66
65
  yield chunk;
67
66
  }
68
67
  if (!hasToolCall && !hasText) {
@@ -73,63 +72,114 @@ export class OpenAICompatProvider {
73
72
  }
74
73
  }
75
74
  async *doStream(messages, tools, signal, options) {
75
+ const { baseDelay, maxDelay, maxStreamRetries, noDataTimeoutMs } = this.retryConfig;
76
+ const streamRetries = maxStreamRetries ?? 2;
77
+ const idleTimeoutMs = noDataTimeoutMs ?? 180000;
78
+ for (let attempt = 0;; attempt++) {
79
+ let emitted = false;
80
+ const onEmit = () => {
81
+ emitted = true;
82
+ };
83
+ try {
84
+ const sawDone = yield* this.streamOnce(messages, tools, signal, options, onEmit, idleTimeoutMs);
85
+ // Clean [DONE] or any partial content → deliver what we have.
86
+ if (sawDone || emitted)
87
+ return;
88
+ // Stream closed before [DONE] with zero content → retryable truncation.
89
+ if (attempt >= streamRetries)
90
+ return;
91
+ }
92
+ catch (err) {
93
+ if (err?.name === "AbortError" || err?.llmTerminal || signal?.aborted)
94
+ throw err;
95
+ // Content was already delivered — a re-stream would duplicate chunks.
96
+ if (emitted || attempt >= streamRetries)
97
+ throw err;
98
+ // Connection dropped before any content → retry the whole request.
99
+ }
100
+ const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
101
+ const jitter = Math.random() * baseDelay * 0.1;
102
+ await this.sleep(delay + jitter, signal);
103
+ }
104
+ }
105
+ async *streamOnce(messages, tools, signal, options, onEmit, idleTimeoutMs) {
106
+ const maxTokens = options?.maxTokens ?? this.config.maxCompletionTokens ?? 4096;
76
107
  const body = buildRequestBody({
77
108
  model: this.model,
78
109
  messages,
79
110
  tools,
80
111
  stream: true,
81
- maxTokens: options?.maxTokens ?? this.config.maxCompletionTokens ?? 4096,
112
+ maxTokens,
82
113
  reasoningEffort: options?.reasoningEffort,
83
114
  });
84
- const headers = {
85
- "Content-Type": "application/json",
86
- };
87
- if (this.config.apiKey && this.config.apiKey !== "not-needed") {
88
- headers["Authorization"] = `Bearer ${this.config.apiKey}`;
115
+ const { headers, abortSignal, cleanup, isTimeout, flagTimeout, controller } = this.buildRequestSetup(signal);
116
+ let response;
117
+ try {
118
+ response = await this.fetchWithRetry(`${this.config.baseUrl}/chat/completions`, {
119
+ method: "POST",
120
+ headers,
121
+ body: JSON.stringify(body),
122
+ signal: abortSignal,
123
+ });
124
+ }
125
+ catch (err) {
126
+ // Fetch-level failures are already HTTP-retried inside fetchWithRetry —
127
+ // don't double-retry them at the stream level.
128
+ if (err?.name === "AbortError")
129
+ throw err;
130
+ const wrapped = err instanceof Error ? err : new Error(String(err));
131
+ wrapped.llmTerminal = true;
132
+ throw wrapped;
89
133
  }
90
- const controller = new AbortController();
91
- const totalTimeoutMs = 120000;
92
- const timeoutId = setTimeout(() => controller.abort(), totalTimeoutMs);
93
- const abortSignal = (() => {
94
- if (!signal)
95
- return controller.signal;
96
- try {
97
- return AbortSignal.any([controller.signal, signal]);
98
- }
99
- catch {
100
- signal.addEventListener("abort", () => controller.abort(), {
101
- once: true,
102
- });
103
- return controller.signal;
104
- }
105
- })();
106
- const response = await this.fetchWithRetry(`${this.config.baseUrl}/chat/completions`, {
107
- method: "POST",
108
- headers,
109
- body: JSON.stringify(body),
110
- signal: abortSignal,
111
- });
112
134
  if (!response.ok) {
113
- clearTimeout(timeoutId);
135
+ cleanup();
114
136
  const errorText = await response.text();
115
- throw new Error(t("error.llm_api", {
137
+ const err = new Error(t("error.llm_api", {
116
138
  status: response.status,
117
139
  statusText: response.statusText,
118
140
  errorText,
119
141
  }));
142
+ err.llmTerminal = true;
143
+ throw err;
120
144
  }
121
145
  const reader = response.body?.getReader();
122
146
  if (!reader) {
123
- clearTimeout(timeoutId);
147
+ cleanup();
124
148
  throw new Error(t("error.no_response_body"));
125
149
  }
126
150
  const decoder = new TextDecoder();
127
151
  let buffer = "";
128
152
  const toolCallAccs = new Map();
153
+ // A tool_call started accumulating — the server committed to a response.
154
+ // If the stream stalls after this, the provider most likely buffers SSE
155
+ // instead of streaming argument deltas (seen with LM Studio).
156
+ let sawToolCallStart = false;
129
157
  let usage;
158
+ let sawDone = false;
159
+ let lastFinishReason;
160
+ let sawText = false;
161
+ const readIdle = () => new Promise((resolve, reject) => {
162
+ const idleTimer = setTimeout(() => {
163
+ flagTimeout();
164
+ controller.abort();
165
+ }, idleTimeoutMs);
166
+ reader.read().then((result) => {
167
+ clearTimeout(idleTimer);
168
+ if (isTimeout())
169
+ reject(new Error(t(sawToolCallStart ? "error.llm_stream_idle_toolcall" : "error.llm_stream_idle", { timeout: idleTimeoutMs })));
170
+ else
171
+ resolve(result);
172
+ }, (err) => {
173
+ clearTimeout(idleTimer);
174
+ if (isTimeout())
175
+ reject(new Error(t(sawToolCallStart ? "error.llm_stream_idle_toolcall" : "error.llm_stream_idle", { timeout: idleTimeoutMs })));
176
+ else
177
+ reject(err);
178
+ });
179
+ });
130
180
  try {
131
181
  while (true) {
132
- const { done, value } = await reader.read();
182
+ const { done, value } = await readIdle();
133
183
  if (done)
134
184
  break;
135
185
  buffer += decoder.decode(value, { stream: true });
@@ -140,8 +190,10 @@ export class OpenAICompatProvider {
140
190
  if (!trimmed || !trimmed.startsWith("data: "))
141
191
  continue;
142
192
  const data = trimmed.slice(6);
143
- if (data === "[DONE]")
193
+ if (data === "[DONE]") {
194
+ sawDone = true;
144
195
  continue;
196
+ }
145
197
  try {
146
198
  const parsed = JSON.parse(data);
147
199
  const choice = parsed.choices?.[0];
@@ -158,10 +210,14 @@ export class OpenAICompatProvider {
158
210
  }
159
211
  const delta = choice.delta || {};
160
212
  const finishReason = choice.finish_reason;
213
+ if (finishReason)
214
+ lastFinishReason = finishReason;
161
215
  if (delta.reasoning_content) {
216
+ onEmit();
162
217
  yield { type: "reasoning", content: delta.reasoning_content };
163
218
  }
164
219
  if (delta.tool_calls) {
220
+ sawToolCallStart = true;
165
221
  for (const tc of delta.tool_calls) {
166
222
  const idx = tc.index ?? 0;
167
223
  if (!toolCallAccs.has(idx)) {
@@ -178,11 +234,14 @@ export class OpenAICompatProvider {
178
234
  }
179
235
  }
180
236
  if (delta.content) {
237
+ onEmit();
238
+ sawText = true;
181
239
  yield { type: "text", content: delta.content };
182
240
  }
183
241
  if (finishReason === "tool_calls" && toolCallAccs.size > 0) {
184
242
  for (const [, acc] of toolCallAccs) {
185
243
  if (acc.name) {
244
+ onEmit();
186
245
  yield {
187
246
  type: "tool_call",
188
247
  toolCall: {
@@ -202,13 +261,71 @@ export class OpenAICompatProvider {
202
261
  }
203
262
  }
204
263
  if (usage) {
264
+ onEmit();
205
265
  yield { type: "done", usage };
206
266
  }
267
+ // finish_reason "length": the completion hit the token limit. An
268
+ // unfinished tool_call would otherwise be silently swallowed (tool_calls
269
+ // are only yielded on finish_reason "tool_calls") and surface as an
270
+ // empty response → blind hallucination retries. Surface the real cause.
271
+ if (lastFinishReason === "length") {
272
+ const truncatedToolCall = sawToolCallStart && toolCallAccs.size > 0;
273
+ if (truncatedToolCall || !sawText) {
274
+ const err = new Error(t(truncatedToolCall ? "error.llm_truncated_toolcall" : "error.llm_truncated", {
275
+ tokens: maxTokens,
276
+ }));
277
+ err.llmTerminal = true;
278
+ // The provider is fine — the model just overran the completion
279
+ // limit. The agent loop feeds this back so the model can adapt
280
+ // (split the output) instead of the session dying.
281
+ err.recoverableLlm = true;
282
+ throw err;
283
+ }
284
+ }
207
285
  }
208
286
  finally {
209
- clearTimeout(timeoutId);
287
+ cleanup();
210
288
  reader.releaseLock();
211
289
  }
290
+ return sawDone;
291
+ }
292
+ /** Shared request setup: auth headers + timeout/abort controller wiring. */
293
+ buildRequestSetup(signal) {
294
+ const headers = {
295
+ "Content-Type": "application/json",
296
+ };
297
+ if (this.config.apiKey && this.config.apiKey !== "not-needed") {
298
+ headers["Authorization"] = `Bearer ${this.config.apiKey}`;
299
+ }
300
+ const controller = new AbortController();
301
+ let timedOut = false;
302
+ const timeoutId = setTimeout(() => {
303
+ timedOut = true;
304
+ controller.abort();
305
+ }, REQUEST_TIMEOUT_MS);
306
+ const abortSignal = (() => {
307
+ if (!signal)
308
+ return controller.signal;
309
+ try {
310
+ return AbortSignal.any([controller.signal, signal]);
311
+ }
312
+ catch {
313
+ signal.addEventListener("abort", () => controller.abort(), {
314
+ once: true,
315
+ });
316
+ return controller.signal;
317
+ }
318
+ })();
319
+ return {
320
+ headers,
321
+ abortSignal,
322
+ cleanup: () => clearTimeout(timeoutId),
323
+ isTimeout: () => timedOut,
324
+ flagTimeout: () => {
325
+ timedOut = true;
326
+ },
327
+ controller,
328
+ };
212
329
  }
213
330
  async doNonStreaming(messages, tools, signal, options) {
214
331
  const body = buildRequestBody({
@@ -219,18 +336,13 @@ export class OpenAICompatProvider {
219
336
  maxTokens: options?.maxTokens ?? this.config.maxCompletionTokens ?? 4096,
220
337
  reasoningEffort: options?.reasoningEffort,
221
338
  });
222
- const headers = {
223
- "Content-Type": "application/json",
224
- };
225
- if (this.config.apiKey && this.config.apiKey !== "not-needed") {
226
- headers["Authorization"] = `Bearer ${this.config.apiKey}`;
227
- }
339
+ const { headers, abortSignal, cleanup, isTimeout } = this.buildRequestSetup(signal);
228
340
  try {
229
341
  const response = await this.fetchWithRetry(`${this.config.baseUrl}/chat/completions`, {
230
342
  method: "POST",
231
343
  headers,
232
344
  body: JSON.stringify(body),
233
- signal,
345
+ signal: abortSignal,
234
346
  });
235
347
  if (!response.ok) {
236
348
  const errorText = await response.text();
@@ -279,8 +391,14 @@ export class OpenAICompatProvider {
279
391
  return chunks;
280
392
  }
281
393
  catch (err) {
394
+ if (isTimeout() && err?.name === "AbortError") {
395
+ throw new Error(t("error.llm_timeout", { timeout: REQUEST_TIMEOUT_MS }));
396
+ }
282
397
  throw err instanceof Error ? err : new Error(String(err));
283
398
  }
399
+ finally {
400
+ cleanup();
401
+ }
284
402
  }
285
403
  countTokens(text) {
286
404
  return this.tokenCounter.count(text);
@@ -299,7 +417,9 @@ export class OpenAICompatProvider {
299
417
  headers,
300
418
  });
301
419
  if (!response.ok) {
302
- return [];
420
+ // Surface the failure: health probes must distinguish a dead
421
+ // provider from one that serves an empty model list.
422
+ throw new Error(`HTTP ${response.status} from ${url}`);
303
423
  }
304
424
  const data = (await response.json());
305
425
  const models = (data.data || data || [])
@@ -307,33 +427,51 @@ export class OpenAICompatProvider {
307
427
  .filter(Boolean);
308
428
  return models;
309
429
  }
310
- catch {
311
- return [];
430
+ catch (err) {
431
+ throw err instanceof Error ? err : new Error(String(err));
312
432
  }
313
433
  }
314
434
  async fetchWithRetry(url, init) {
315
435
  const { maxRetries, baseDelay, maxDelay } = this.retryConfig;
316
- let lastError = null;
436
+ let lastStatus = 0;
437
+ let lastRetryAfter = 0;
317
438
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
318
439
  try {
319
440
  const response = await fetch(url, init);
320
441
  if (!this.isRetryable(response.status))
321
442
  return response;
322
- lastError = new Error(`HTTP ${response.status}: ${response.statusText}`);
443
+ lastStatus = response.status;
444
+ const retryAfter = Number(response.headers.get("retry-after") ?? 0);
445
+ lastRetryAfter = retryAfter > 0 ? retryAfter * 1000 : 0;
323
446
  }
324
447
  catch (err) {
325
448
  if (err.name === "AbortError") {
326
449
  throw err;
327
450
  }
328
- lastError = err;
451
+ if (attempt === maxRetries)
452
+ throw err;
329
453
  }
330
454
  if (attempt < maxRetries) {
331
- const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
455
+ // Prefer the provider's Retry-After window (saturating at maxDelay);
456
+ // otherwise exponential backoff. 429s from free tiers need a real wait.
457
+ const delay = lastRetryAfter > 0
458
+ ? Math.min(lastRetryAfter, maxDelay)
459
+ : Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
332
460
  const jitter = Math.random() * baseDelay * 0.1;
333
461
  await this.sleep(delay + jitter, init.signal ?? undefined);
334
462
  }
335
463
  }
336
- throw lastError ?? new Error(t("error.llm_retries"));
464
+ if (lastStatus === 429) {
465
+ const e429 = new Error(t("error.llm_429", {
466
+ model: this.model,
467
+ baseUrl: this.config.baseUrl,
468
+ }));
469
+ e429.llmStatus = lastStatus;
470
+ throw e429;
471
+ }
472
+ const eRetry = new Error(t("error.llm_retries"));
473
+ eRetry.llmStatus = lastStatus;
474
+ throw eRetry;
337
475
  }
338
476
  isRetryable(status) {
339
477
  return status === 429 || status >= 500;
@@ -1,4 +1,4 @@
1
- import { OpenAICompatProvider } from "./openai-compat";
1
+ import { createProvider } from "../modules/providers/create";
2
2
  import { jsonrepair } from "jsonrepair";
3
3
  const PLAN_SYSTEM_PROMPT = `You are a planning assistant for an agent system with multiple expert sub-agents.
4
4
  Break down the user's task into subtasks that can be executed by different expert agents.
@@ -56,10 +56,11 @@ export class OrchestratorClient {
56
56
  this.config = config;
57
57
  if (config.model) {
58
58
  if (config.provider) {
59
- this.provider = new OpenAICompatProvider({
59
+ this.provider = createProvider(config.provider.type ?? "openai-compat", {
60
60
  model: config.model,
61
61
  baseUrl: config.provider.baseUrl || "http://localhost:1234/v1",
62
62
  apiKey: config.provider.apiKey,
63
+ contextWindow: 32768,
63
64
  retry: config.retry,
64
65
  });
65
66
  }
@@ -67,9 +68,10 @@ export class OrchestratorClient {
67
68
  this.provider = defaultProvider;
68
69
  }
69
70
  else {
70
- this.provider = new OpenAICompatProvider({
71
+ this.provider = createProvider("openai-compat", {
71
72
  model: config.model,
72
73
  baseUrl: "http://localhost:1234/v1",
74
+ contextWindow: 32768,
73
75
  retry: config.retry,
74
76
  });
75
77
  }
@@ -59,19 +59,19 @@ export class Logger {
59
59
  }
60
60
  // --- Tagged helpers (legacy v1 API) ---
61
61
  logLLMRequest(model, messagesCount, promptPreview, caller) {
62
- this.fileLog.logLLMRequest(model, messagesCount, promptPreview, caller);
62
+ this.fileLog.logLLMRequest(model, messagesCount, sanitizeLogMessage(promptPreview), caller);
63
63
  }
64
64
  logLLMResponse(model, responseLength, genTimeMs, error, caller) {
65
65
  this.fileLog.logLLMResponse(model, responseLength, genTimeMs, error, caller);
66
66
  }
67
67
  logToolCall(tool, preview, result) {
68
- this.fileLog.logToolCall(tool, preview, result);
68
+ this.fileLog.logToolCall(tool, sanitizeLogMessage(preview), result !== undefined ? sanitizeLogMessage(result) : undefined);
69
69
  }
70
70
  logToolOutput(tool, output, exitCode) {
71
- this.fileLog.logToolOutput(tool, output, exitCode);
71
+ this.fileLog.logToolOutput(tool, sanitizeLogMessage(output), exitCode);
72
72
  }
73
73
  logREPL(tag, content) {
74
- this.fileLog.logREPL(tag, content);
74
+ this.fileLog.logREPL(tag, sanitizeLogMessage(content));
75
75
  }
76
76
  child(prefix) {
77
77
  const childLogger = new Logger(this.level, this.prefix ? `${this.prefix}:${prefix}` : prefix);
@@ -140,4 +140,50 @@ export class Logger {
140
140
  }
141
141
  return sanitized;
142
142
  }
143
+ /**
144
+ * Write a structured record to app.jsonl only (no console / .log output).
145
+ * Used for machine-readable reports (environment, etc.) that would be too
146
+ * noisy on the terminal but are valuable for cross-device diagnosis.
147
+ */
148
+ logStructured(type, data) {
149
+ const logTarget = this.sessionDir ?? this.logDir;
150
+ if (!logTarget)
151
+ return;
152
+ try {
153
+ appendFileSync(join(logTarget, "app.jsonl"), JSON.stringify({
154
+ level: "info",
155
+ ts: new Date().toISOString(),
156
+ type,
157
+ meta: this.sanitizeMeta(data),
158
+ }) + "\n", "utf-8");
159
+ }
160
+ catch {
161
+ /* file logging is best-effort */
162
+ }
163
+ }
164
+ /**
165
+ * Write a log entry to app.jsonl only — no console output, no .log file.
166
+ * Use this for noisy subsystems (LSP, probes) whose warnings should be
167
+ * diagnosable from session files but not pollute the user's terminal.
168
+ */
169
+ logSilent(level, msg, meta) {
170
+ const logTarget = this.sessionDir ?? this.logDir;
171
+ if (!logTarget)
172
+ return;
173
+ const ts = new Date().toISOString();
174
+ const sanitizedMsg = sanitizeLogMessage(msg);
175
+ const sanitizedMeta = meta ? this.sanitizeMeta(meta) : undefined;
176
+ try {
177
+ appendFileSync(join(logTarget, "app.jsonl"), JSON.stringify({
178
+ level,
179
+ ts,
180
+ prefix: this.prefix,
181
+ msg: sanitizedMsg,
182
+ meta: sanitizedMeta ?? null,
183
+ }) + "\n", "utf-8");
184
+ }
185
+ catch {
186
+ /* file logging is best-effort */
187
+ }
188
+ }
143
189
  }