@semalt-ai/code 1.6.0 → 1.8.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.
package/lib/api.js CHANGED
@@ -14,9 +14,8 @@ function createApiClient({ getConfig, saveConfig, ui }) {
14
14
  FG_RED,
15
15
  FG_TEAL,
16
16
  RST,
17
+ StatusBar,
17
18
  StreamRenderer,
18
- getCols,
19
- printStatusBar,
20
19
  } = ui;
21
20
 
22
21
  function apiUrl(urlPath) {
@@ -27,8 +26,21 @@ function createApiClient({ getConfig, saveConfig, ui }) {
27
26
  return `${normalizedBase}${normalizedPath}`;
28
27
  }
29
28
 
30
- function describeModelProfile(profile) {
31
- return `${profile.model} @ ${profile.api_base}`;
29
+ function dashboardUrl(urlPath) {
30
+ const config = getConfig();
31
+ const base = (config.dashboard_url || '').replace(/\/$/, '');
32
+ const normalizedPath = urlPath.startsWith('/') ? urlPath : `/${urlPath}`;
33
+ return `${base}${normalizedPath}`;
34
+ }
35
+
36
+ function requireAuthToken() {
37
+ const config = getConfig();
38
+ if (!config.auth_token) {
39
+ const error = new Error('Not logged in. Run semalt login first.');
40
+ error.statusCode = 401;
41
+ throw error;
42
+ }
43
+ return config.auth_token;
32
44
  }
33
45
 
34
46
  function setActiveModelProfile(profile) {
@@ -39,46 +51,14 @@ function createApiClient({ getConfig, saveConfig, ui }) {
39
51
  saveConfig(config);
40
52
  }
41
53
 
42
- function chooseSavedModelProfile(rl, currentModel, cwd, onDone) {
43
- const config = getConfig();
44
- if (!config.models.length) {
45
- console.log(` ${FG_RED}✗${RST} ${FG_GRAY}No saved model profiles. Use semalt-code models add first.${RST}`);
46
- onDone(currentModel);
47
- return;
48
- }
49
-
50
- console.log();
51
- console.log(` ${FG_TEAL}${BOLD}◆ Saved Models${RST}`);
52
- console.log(` ${FG_DARK}${'─'.repeat(40)}${RST}`);
53
- config.models.forEach((profile, index) => {
54
- const active = profile.api_base === config.api_base &&
55
- profile.api_key === config.api_key &&
56
- profile.model === currentModel;
57
- const marker = active ? `${FG_GREEN}●${RST}` : `${FG_DARK}○${RST}`;
58
- console.log(` ${marker} ${ui.FG_CYAN}${index + 1}.${RST} ${describeModelProfile(profile)}`);
59
- });
60
- console.log();
61
-
62
- rl.question(` ${FG_TEAL}${BOLD}Select model>${RST} `, (answer) => {
63
- const selected = Number((answer || '').trim());
64
- if (!Number.isInteger(selected) || selected < 1 || selected > config.models.length) {
65
- console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Invalid selection${RST}`);
66
- onDone(currentModel);
67
- return;
68
- }
69
-
70
- const profile = config.models[selected - 1];
71
- setActiveModelProfile(profile);
72
- console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Model profile → ${describeModelProfile(profile)}${RST}`);
73
- printStatusBar(profile.model, cwd);
74
- onDone(profile.model);
75
- });
76
- }
77
-
78
54
  function estimateTokens(text) {
79
55
  return Math.floor((text || '').length / 4);
80
56
  }
81
57
 
58
+ // Discovered context limit for this process lifetime.
59
+ // Set on the first context-overflow 400; used to proactively trim all subsequent calls.
60
+ let _sessionInputLimit = null;
61
+
82
62
  function httpRequest(urlStr, options, body) {
83
63
  return new Promise((resolve, reject) => {
84
64
  const url = new URL(urlStr);
@@ -105,50 +85,309 @@ function createApiClient({ getConfig, saveConfig, ui }) {
105
85
  });
106
86
  }
107
87
 
108
- async function chatStream(messages, { model, temperature, maxTokens } = {}) {
88
+ async function requestJson(urlStr, { method = 'GET', timeout, headers = {}, body } = {}) {
89
+ const requestBody = body === undefined ? undefined : JSON.stringify(body);
90
+ const finalHeaders = { ...headers };
91
+ if (requestBody !== undefined) {
92
+ finalHeaders['Content-Type'] = 'application/json';
93
+ finalHeaders['Content-Length'] = Buffer.byteLength(requestBody);
94
+ }
95
+
96
+ const res = await httpRequest(urlStr, {
97
+ method,
98
+ timeout: timeout || getConfig().request_timeout_ms,
99
+ headers: finalHeaders,
100
+ }, requestBody);
101
+
102
+ return new Promise((resolve, reject) => {
103
+ let data = '';
104
+ res.setEncoding('utf8');
105
+ res.on('data', (chunk) => {
106
+ data += chunk;
107
+ });
108
+ res.on('end', () => {
109
+ let parsed = null;
110
+ try {
111
+ parsed = data ? JSON.parse(data) : null;
112
+ } catch {
113
+ parsed = data ? { error: data } : null;
114
+ }
115
+
116
+ if (res.statusCode < 200 || res.statusCode >= 300) {
117
+ const error = new Error((parsed && parsed.error) || `HTTP ${res.statusCode}`);
118
+ error.statusCode = res.statusCode;
119
+ error.data = parsed;
120
+ reject(error);
121
+ return;
122
+ }
123
+
124
+ resolve(parsed);
125
+ });
126
+ res.on('error', reject);
127
+ });
128
+ }
129
+
130
+ function requestCliLogin() {
131
+ return requestJson(dashboardUrl('/api/auth/cli/request'), {
132
+ method: 'POST',
133
+ timeout: 15000,
134
+ });
135
+ }
136
+
137
+ function getCliLoginStatus(id, hash) {
138
+ return requestJson(dashboardUrl('/api/auth/cli/status'), {
139
+ method: 'POST',
140
+ timeout: 15000,
141
+ body: { id, hash },
142
+ });
143
+ }
144
+
145
+ function dashboardWhoAmI() {
146
+ const authToken = requireAuthToken();
147
+ return requestJson(dashboardUrl('/api/auth/me'), {
148
+ method: 'GET',
149
+ timeout: 15000,
150
+ headers: {
151
+ 'Authorization': `Bearer ${authToken}`,
152
+ },
153
+ });
154
+ }
155
+
156
+ function dashboardLogout() {
157
+ const authToken = requireAuthToken();
158
+ return requestJson(dashboardUrl('/api/auth/logout'), {
159
+ method: 'POST',
160
+ timeout: 15000,
161
+ headers: {
162
+ 'Authorization': `Bearer ${authToken}`,
163
+ },
164
+ });
165
+ }
166
+
167
+ function dashboardListModels() {
168
+ const authToken = requireAuthToken();
169
+ return requestJson(dashboardUrl('/api/models'), {
170
+ method: 'GET',
171
+ timeout: 15000,
172
+ headers: {
173
+ 'Authorization': `Bearer ${authToken}`,
174
+ },
175
+ });
176
+ }
177
+
178
+ function dashboardGetModelForCli(id) {
179
+ const authToken = requireAuthToken();
180
+ return requestJson(dashboardUrl(`/api/models/${encodeURIComponent(String(id))}/cli`), {
181
+ method: 'GET',
182
+ timeout: 15000,
183
+ headers: {
184
+ 'Authorization': `Bearer ${authToken}`,
185
+ },
186
+ });
187
+ }
188
+
189
+ function dashboardCreateChat(title, modelDbId) {
190
+ const authToken = requireAuthToken();
191
+ return requestJson(dashboardUrl('/api/chats'), {
192
+ method: 'POST',
193
+ timeout: 15000,
194
+ headers: { 'Authorization': `Bearer ${authToken}` },
195
+ body: { title, model_id: modelDbId },
196
+ });
197
+ }
198
+
199
+ function dashboardListChats() {
200
+ const authToken = requireAuthToken();
201
+ return requestJson(dashboardUrl('/api/chats'), {
202
+ method: 'GET',
203
+ timeout: 15000,
204
+ headers: { 'Authorization': `Bearer ${authToken}` },
205
+ });
206
+ }
207
+
208
+ function dashboardGetChat(id) {
209
+ const authToken = requireAuthToken();
210
+ return requestJson(dashboardUrl(`/api/chats/${encodeURIComponent(String(id))}`), {
211
+ method: 'GET',
212
+ timeout: 15000,
213
+ headers: { 'Authorization': `Bearer ${authToken}` },
214
+ });
215
+ }
216
+
217
+ function dashboardSaveMessages(chatId, messages) {
218
+ const authToken = requireAuthToken();
219
+ return requestJson(dashboardUrl(`/api/chats/${encodeURIComponent(String(chatId))}/messages/batch`), {
220
+ method: 'POST',
221
+ timeout: 15000,
222
+ headers: { 'Authorization': `Bearer ${authToken}` },
223
+ body: { messages },
224
+ });
225
+ }
226
+
227
+ async function chatStream(messages, { model, temperature, maxTokens, linePrefix = '', showThink = false, onToken = null, silent = false } = {}) {
109
228
  const config = getConfig();
229
+
230
+ // Fit messages into tokenBudget tokens.
231
+ // Uses chars/3 — conservative for token-dense content (code, JSON, HTML).
232
+ //
233
+ // Always keeps: system prompt + first non-system message (original task).
234
+ // Drops intermediate messages oldest-first, then truncates the last tail
235
+ // message (typically a large tool result) if still over budget.
236
+ function trimToTokenBudget(msgs, tokenBudget) {
237
+ const CHARS_PER_TOKEN = 3;
238
+ const system = msgs.filter((m) => m.role === 'system');
239
+ const nonSystem = msgs.filter((m) => m.role !== 'system');
240
+ if (nonSystem.length === 0) return [...system];
241
+
242
+ const pinned = nonSystem[0]; // original task — never dropped
243
+ let tail = nonSystem.slice(1);
244
+
245
+ const estimate = () => {
246
+ const all = tail.length > 0 ? [...system, pinned, ...tail] : [...system, pinned];
247
+ return Math.floor(JSON.stringify(all).length / CHARS_PER_TOKEN);
248
+ };
249
+
250
+ while (tail.length > 1 && estimate() > tokenBudget) {
251
+ tail = tail.slice(1);
252
+ }
253
+
254
+ if (tail.length === 1 && estimate() > tokenBudget) {
255
+ const msg = tail[0];
256
+ const otherChars = JSON.stringify([...system, pinned]).length;
257
+ const available = tokenBudget * CHARS_PER_TOKEN - otherChars - 200;
258
+ if (available > 0 && typeof msg.content === 'string' && msg.content.length > available) {
259
+ tail = [{ ...msg, content: '[…content truncated to fit model limit…]\n' + msg.content.slice(-available) }];
260
+ }
261
+ }
262
+
263
+ if (tail.length === 0 && estimate() > tokenBudget) {
264
+ const systemChars = JSON.stringify(system).length;
265
+ const available = tokenBudget * CHARS_PER_TOKEN - systemChars - 200;
266
+ if (available > 0 && typeof pinned.content === 'string' && pinned.content.length > available) {
267
+ return [...system, { ...pinned, content: '[…content truncated to fit model limit…]\n' + pinned.content.slice(-available) }];
268
+ }
269
+ }
270
+
271
+ return tail.length > 0 ? [...system, pinned, ...tail] : [...system, pinned];
272
+ }
273
+
274
+ // Proactive trim: apply the session input limit discovered from a prior 400.
275
+ let trimmedMessages = messages;
276
+ if (_sessionInputLimit !== null) {
277
+ if (Math.floor(JSON.stringify(messages).length / 3) > _sessionInputLimit) {
278
+ trimmedMessages = trimToTokenBudget(messages, _sessionInputLimit);
279
+ }
280
+ }
281
+
110
282
  const payload = {
111
283
  model: model || config.default_model,
112
- messages,
284
+ messages: trimmedMessages,
113
285
  temperature: temperature !== undefined ? temperature : config.temperature,
114
286
  stream: true,
115
287
  };
116
288
 
117
289
  if (maxTokens !== undefined) payload.max_tokens = maxTokens;
118
290
 
119
- const body = JSON.stringify(payload);
120
- let res;
121
-
122
- try {
123
- res = await httpRequest(apiUrl('/v1/chat/completions'), {
291
+ async function doRequest(msgs) {
292
+ const reqPayload = { ...payload, messages: msgs };
293
+ const reqBody = JSON.stringify(reqPayload);
294
+ const res = await httpRequest(apiUrl('/v1/chat/completions'), {
124
295
  method: 'POST',
125
296
  timeout: config.request_timeout_ms,
126
297
  headers: {
127
298
  'Content-Type': 'application/json',
128
299
  'Authorization': `Bearer ${config.api_key}`,
129
- 'Content-Length': Buffer.byteLength(body),
300
+ 'Content-Length': Buffer.byteLength(reqBody),
130
301
  },
131
- }, body);
132
- } catch (error) {
133
- process.stdout.write(`\n ${FG_RED}✗ ${error.message}${RST}\n`);
134
- return '';
302
+ }, reqBody);
303
+
304
+ if (res.statusCode !== 200) {
305
+ const errBody = await new Promise((resolve) => {
306
+ let d = '';
307
+ res.setEncoding('utf8');
308
+ res.on('data', (c) => { d += c; });
309
+ res.on('end', () => resolve(d));
310
+ res.on('error', () => resolve(''));
311
+ });
312
+ let detail = '';
313
+ let parsedErr = null;
314
+ try {
315
+ parsedErr = JSON.parse(errBody);
316
+ detail = (parsedErr && (parsedErr.error?.message || parsedErr.error || parsedErr.message)) || '';
317
+ } catch { detail = errBody.slice(0, 200); }
318
+ const err = new Error(`HTTP ${res.statusCode}${detail ? `: ${detail}` : ''}`);
319
+ err.statusCode = res.statusCode;
320
+ err.parsedErr = parsedErr;
321
+ err.detail = detail;
322
+ throw err;
323
+ }
324
+ return res;
135
325
  }
136
326
 
137
- if (res.statusCode !== 200) {
138
- process.stdout.write(`\n ${FG_RED}✗ Error: HTTP ${res.statusCode}${RST}\n`);
139
- res.resume();
140
- return '';
327
+ // On payload-too-large errors, trim and retry.
328
+ // 400 with context-overflow detail → parse exact context window, budget = window/2
329
+ // 413 Request Entity Too Large (Nginx/proxy) → no size hint, halve current estimate
330
+ // In both cases _sessionInputLimit is set so all subsequent calls are proactively trimmed.
331
+ let res;
332
+ try {
333
+ res = await doRequest(trimmedMessages);
334
+ } catch (err) {
335
+ const is400Overflow = err.statusCode === 400 && err.detail &&
336
+ /context.length|input.token|context_length|maximum.*token|token.*limit/i.test(err.detail);
337
+ const is413 = err.statusCode === 413;
338
+
339
+ if (is400Overflow || is413) {
340
+ let budget;
341
+ if (is400Overflow) {
342
+ const limitMatch = err.detail.match(/context length is only (\d+)/i) ||
343
+ err.detail.match(/maximum.*?(\d+)\s*token/i);
344
+ const contextWindow = limitMatch ? parseInt(limitMatch[1], 10) : null;
345
+ budget = contextWindow
346
+ ? Math.floor(contextWindow / 2)
347
+ : Math.floor(Math.floor(JSON.stringify(trimmedMessages).length / 3) * 0.5);
348
+ } else {
349
+ // 413: no token info available — halve the estimated size of the current payload.
350
+ budget = Math.floor(Math.floor(JSON.stringify(trimmedMessages).length / 3) * 0.5);
351
+ }
352
+ _sessionInputLimit = budget;
353
+ trimmedMessages = trimToTokenBudget(trimmedMessages, budget);
354
+ res = await doRequest(trimmedMessages);
355
+ } else {
356
+ throw err;
357
+ }
141
358
  }
142
359
 
143
- return new Promise((resolve) => {
360
+ return new Promise((resolve, reject) => {
144
361
  const startTime = Date.now();
145
362
  let fullText = '';
146
363
  let reasoningText = '';
147
364
  let tokenCount = 0;
148
365
  let inReasoning = false;
149
- const renderer = new StreamRenderer();
366
+ let streamUsage = null;
367
+ let resolved = false;
368
+ const renderer = new StreamRenderer({ firstLinePrefix: linePrefix, showThink });
369
+ if (!silent) {
370
+ process.stdout.write('\n');
371
+ renderer._linesWritten = 1;
372
+ }
373
+ let firstContentToken = true;
150
374
  let lineBuffer = '';
151
375
 
376
+ function finalize() {
377
+ if (resolved) return;
378
+ resolved = true;
379
+ if (!silent) renderer.flush();
380
+ const elapsed = (Date.now() - startTime) / 1000;
381
+ const tps = tokenCount / (elapsed || 1);
382
+ if (StatusBar.current) {
383
+ let latency = `${Math.round(tps)} tok/s · ${elapsed.toFixed(1)}s`;
384
+ if (reasoningText) latency += ` · ${estimateTokens(reasoningText)} think`;
385
+ StatusBar.current.liveUpdate({ tokens: `${tokenCount} tok`, latency });
386
+ StatusBar.current.render();
387
+ }
388
+ resolve({ content: fullText, usage: streamUsage });
389
+ }
390
+
152
391
  res.setEncoding('utf8');
153
392
 
154
393
  res.on('data', (chunk) => {
@@ -159,53 +398,76 @@ function createApiClient({ getConfig, saveConfig, ui }) {
159
398
  for (const line of lines) {
160
399
  if (!line.startsWith('data: ')) continue;
161
400
  const data = line.slice(6).trim();
162
- if (data === '[DONE]') continue;
401
+ if (data === '[DONE]') {
402
+ finalize();
403
+ res.destroy();
404
+ return;
405
+ }
163
406
 
164
407
  try {
165
408
  const obj = JSON.parse(data);
409
+ if (obj.usage && (obj.usage.prompt_tokens !== undefined || obj.usage.completion_tokens !== undefined)) {
410
+ streamUsage = obj.usage;
411
+ }
166
412
  const delta = ((obj.choices || [])[0] || {}).delta || {};
167
413
 
168
414
  const reasoning = delta.reasoning_content || '';
169
415
  if (reasoning) {
170
416
  if (!inReasoning) {
171
417
  inReasoning = true;
172
- process.stdout.write(`\n ${FG_DARK}${DIM}⟨thinking⟩${RST}`);
418
+ if (showThink) {
419
+ process.stdout.write(`\n ${FG_DARK}${DIM}⟨thinking⟩${RST}`);
420
+ renderer._linesWritten++;
421
+ }
173
422
  }
174
423
  reasoningText += reasoning;
175
424
  tokenCount++;
176
- if (tokenCount % 20 === 0) process.stdout.write(`${FG_DARK}.${RST}`);
425
+ if (showThink) {
426
+ process.stdout.write(`${FG_DARK}${DIM}${reasoning}${RST}`);
427
+ }
177
428
  }
178
429
 
179
430
  const content = delta.content || '';
180
431
  if (content) {
181
432
  if (inReasoning) {
182
433
  inReasoning = false;
183
- process.stdout.write(`${FG_DARK}⟨/thinking⟩${RST}\n`);
434
+ if (showThink && !silent) {
435
+ process.stdout.write(`${FG_DARK}⟨/thinking⟩${RST}\n`);
436
+ renderer._linesWritten++;
437
+ }
438
+ }
439
+ if (onToken) {
440
+ if (firstContentToken) {
441
+ firstContentToken = false;
442
+ if (StatusBar.current) StatusBar.current.update({ status: 'streaming' });
443
+ }
444
+ onToken(content);
445
+ } else {
446
+ renderer.feed(content);
184
447
  }
185
- renderer.feed(content);
186
448
  fullText += content;
187
449
  tokenCount++;
450
+ if (tokenCount % 20 === 0 && StatusBar.current) {
451
+ const elapsedSec = (Date.now() - startTime) / 1000 || 0.001;
452
+ StatusBar.current.liveUpdate({
453
+ tokens: `${tokenCount} tok`,
454
+ latency: `${Math.round(tokenCount / elapsedSec)} tok/s`,
455
+ });
456
+ }
188
457
  }
189
458
  } catch {}
190
459
  }
191
460
  });
192
461
 
193
462
  res.on('end', () => {
194
- renderer.flush();
195
- const elapsed = (Date.now() - startTime) / 1000;
196
- const estTokens = estimateTokens(fullText + reasoningText);
197
- const tps = tokenCount / (elapsed || 1);
198
- const cols = getCols();
199
- process.stdout.write(`\n ${FG_DARK}${'─'.repeat(Math.min(cols, 60) - 4)}${RST}\n`);
200
- let costLine = `${FG_DARK}~${estTokens} tokens · ${elapsed.toFixed(1)}s · ${Math.round(tps)} tok/s${RST}`;
201
- if (reasoningText) costLine += ` ${FG_DARK}· ${estimateTokens(reasoningText)} thinking${RST}`;
202
- process.stdout.write(` ${costLine}\n`);
203
- resolve(fullText);
463
+ finalize();
204
464
  });
205
465
 
206
466
  res.on('error', (error) => {
207
- process.stdout.write(`\n ${FG_RED}✗ ${error.message}${RST}\n`);
208
- resolve('');
467
+ if (!resolved) {
468
+ resolved = true;
469
+ reject(error);
470
+ }
209
471
  });
210
472
  });
211
473
  }
@@ -270,9 +532,17 @@ function createApiClient({ getConfig, saveConfig, ui }) {
270
532
  return {
271
533
  chatStream,
272
534
  chatSync,
273
- chooseSavedModelProfile,
274
- describeModelProfile,
535
+ dashboardCreateChat,
536
+ dashboardGetChat,
537
+ dashboardGetModelForCli,
538
+ dashboardListChats,
539
+ dashboardListModels,
540
+ dashboardLogout,
541
+ dashboardSaveMessages,
542
+ dashboardWhoAmI,
275
543
  estimateTokens,
544
+ getCliLoginStatus,
545
+ requestCliLogin,
276
546
  setActiveModelProfile,
277
547
  };
278
548
  }
package/lib/args.js CHANGED
@@ -28,9 +28,43 @@ function parseArgs(argv) {
28
28
  case '--api-key':
29
29
  opts.apiKey = argv[++i];
30
30
  break;
31
+ case '--dashboard-url':
32
+ opts.dashboardUrl = argv[++i];
33
+ break;
31
34
  case '--default-model':
32
35
  opts.defaultModel = argv[++i];
33
36
  break;
37
+ case '-r':
38
+ case '--resume':
39
+ opts.resume = argv[++i];
40
+ break;
41
+ case '--allow-fs':
42
+ (opts.allowedTiers = opts.allowedTiers || []).push('fs');
43
+ break;
44
+ case '--allow-exec':
45
+ (opts.allowedTiers = opts.allowedTiers || []).push('exec');
46
+ break;
47
+ case '--allow-net':
48
+ (opts.allowedTiers = opts.allowedTiers || []).push('net');
49
+ break;
50
+ case '--allow-all':
51
+ opts.allowedTiers = ['fs', 'exec', 'net', 'sys'];
52
+ break;
53
+ case '--readonly':
54
+ opts.readonly = true;
55
+ break;
56
+ case '--new':
57
+ opts.new = true;
58
+ break;
59
+ case '--show-think':
60
+ opts.showThink = true;
61
+ break;
62
+ case '--debug':
63
+ opts.debug = true;
64
+ break;
65
+ case '--system-prompt':
66
+ opts.systemPromptFile = argv[++i];
67
+ break;
34
68
  default:
35
69
  positional.push(argv[i]);
36
70
  }
package/lib/audit.js ADDED
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ const AUDIT_LOG = path.join(os.homedir(), '.semalt-ai', 'audit.log');
8
+
9
+ function logToolCall(tag, input, approved, resultStatus) {
10
+ try {
11
+ let safeInput = input;
12
+ if (tag === 'write_file' && input !== null && typeof input === 'object' && 'content' in input) {
13
+ const n = typeof input.content === 'string' ? input.content.length : 0;
14
+ safeInput = { ...input, content: `<${n} bytes>` };
15
+ }
16
+ let inputStr = typeof safeInput === 'string' ? safeInput : JSON.stringify(safeInput);
17
+ if (inputStr.length > 200) inputStr = inputStr.slice(0, 197) + '...';
18
+ const entry = JSON.stringify({
19
+ ts: new Date().toISOString(),
20
+ tag,
21
+ input: inputStr,
22
+ approved: Boolean(approved),
23
+ result: resultStatus,
24
+ });
25
+ fs.appendFileSync(AUDIT_LOG, entry + '\n');
26
+ } catch {
27
+ // never throw
28
+ }
29
+ }
30
+
31
+ module.exports = { AUDIT_LOG, logToolCall };