clementine-agent 1.18.206 → 1.18.207

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.
@@ -428,8 +428,25 @@ export async function runAgent(prompt, opts) {
428
428
  },
429
429
  });
430
430
  },
431
+ onLargeWrite: (info) => {
432
+ writeEvent({
433
+ kind: 'tool_result',
434
+ ts: new Date().toISOString(),
435
+ sessionId,
436
+ toolUseId: info.toolUseId,
437
+ toolResult: {
438
+ successful: true,
439
+ _clementine_large_write_guard: true,
440
+ tool: info.toolName,
441
+ filePath: info.filePath,
442
+ contentBytes: info.contentBytes,
443
+ ...(info.archivePath ? { archivePath: info.archivePath } : {}),
444
+ message: 'Large Write completed out-of-band; native Write tool denied to protect parent context.',
445
+ },
446
+ });
447
+ },
431
448
  })
432
- : { hooks: {}, stats: { inspected: 0, compressed: 0, ceilingHits: 0, bytesShed: 0, compactions: 0 } };
449
+ : { hooks: {}, stats: { inspected: 0, compressed: 0, ceilingHits: 0, bytesShed: 0, compactions: 0, largeWrites: 0 } };
433
450
  // ── Tool-call dedup hook (1.18.173) ─────────────────────────────────
434
451
  // Breaks the "re-fetch after compaction" loop that crashed the
435
452
  // imessage-triage cron on 2026-05-11 (4× identical tool calls →
@@ -854,12 +871,13 @@ export async function runAgent(prompt, opts) {
854
871
  finalTextChars: finalText.length,
855
872
  // 1.18.169 — tool-output guard summary, surfaced for observability.
856
873
  // Non-zero `compressed` means the guard kept the SDK from thrashing.
857
- guard: guard.stats.inspected > 0 ? {
874
+ guard: (guard.stats.inspected > 0 || guard.stats.largeWrites > 0) ? {
858
875
  inspected: guard.stats.inspected,
859
876
  compressed: guard.stats.compressed,
860
877
  bytesShed: guard.stats.bytesShed,
861
878
  compactions: guard.stats.compactions,
862
879
  ceilingHits: guard.stats.ceilingHits,
880
+ largeWrites: guard.stats.largeWrites,
863
881
  } : undefined,
864
882
  // 1.18.173 — tool-call dedup summary. Non-zero warned/blocked means
865
883
  // the model tried to re-fetch identical data (typically a
@@ -198,6 +198,12 @@ export function extractRecipients(input) {
198
198
  function extractSubject(input) {
199
199
  return firstString(input.subject, input.title);
200
200
  }
201
+ function extractFilePath(input, raw) {
202
+ return firstString(input.file_path, input.filePath, input.path, input.target_path, input.targetPath)
203
+ ?? (raw && typeof raw === 'object'
204
+ ? firstString(raw.filePath, raw.file_path, raw.path)
205
+ : undefined);
206
+ }
201
207
  function extractProviderLogId(raw) {
202
208
  if (!raw || typeof raw !== 'object')
203
209
  return undefined;
@@ -305,9 +311,11 @@ export function formatOverflowRecoveryMessage(summary) {
305
311
  function formatDetailedCall(call) {
306
312
  const recipients = extractRecipients(call.input);
307
313
  const subject = extractSubject(call.input);
314
+ const filePath = extractFilePath(call.input, call.result?.raw);
308
315
  const logId = call.result ? extractProviderLogId(call.result.raw) : undefined;
309
316
  const parts = [
310
317
  toolKindLabel(call.toolName),
318
+ filePath ? `file ${filePath}` : undefined,
311
319
  recipients.length ? `to ${recipients.join(', ')}` : undefined,
312
320
  subject ? `subject "${subject}"` : undefined,
313
321
  call.result ? statusPhrase(call) : 'started, no confirmation',
@@ -1,6 +1,7 @@
1
1
  /**
2
- * tool-output-guard — PostToolUse hook that bounds per-call tool output size
3
- * so the SDK's auto-compactor can never thrash on a runaway MCP result.
2
+ * tool-output-guard — SDK hooks that bound per-call tool output size and
3
+ * out-of-band very large artifact writes so the SDK's auto-compactor can
4
+ * never thrash on runaway MCP results or generated files.
4
5
  *
5
6
  * Why this exists
6
7
  * ───────────────
@@ -20,8 +21,9 @@
20
21
  *
21
22
  * The fix is the canonical Anthropic primitive: a `PostToolUse` hook that
22
23
  * returns `hookSpecificOutput.updatedToolOutput` to replace the result
23
- * before it reaches the model. From sdk.d.ts:1979 "Replaces the tool
24
- * output before it is sent to the model."
24
+ * before it reaches the model. A companion `PreToolUse` hook handles large
25
+ * `Write` inputs by writing the artifact to disk before the native tool can
26
+ * echo a giant file body into the parent conversation.
25
27
  *
26
28
  * Design properties
27
29
  * ─────────────────
@@ -70,6 +72,8 @@ export interface GuardRunStats {
70
72
  bytesShed: number;
71
73
  /** Number of SDK auto-compactions observed for this run. */
72
74
  compactions: number;
75
+ /** Large file writes completed out-of-band before reaching the SDK context. */
76
+ largeWrites: number;
73
77
  }
74
78
  /**
75
79
  * Approximate the byte size of a tool_response as it will appear in the
@@ -138,6 +142,15 @@ export interface GuardHookOptions {
138
142
  ceilingHit: boolean;
139
143
  archivePath: string | null;
140
144
  }) => void;
145
+ /** Optional callback fired when a large Write input is completed
146
+ * out-of-band by the guard before the native Write tool runs. */
147
+ onLargeWrite?: (info: {
148
+ toolName: string;
149
+ toolUseId: string;
150
+ filePath: string;
151
+ contentBytes: number;
152
+ archivePath: string | null;
153
+ }) => void;
141
154
  /** Optional source of the current cumulative context-usage ratio
142
155
  * (cache_read + input) / window. Returns a number in [0,1]. The
143
156
  * guard calls this once per tool result to adapt the cap. When
@@ -1,6 +1,7 @@
1
1
  /**
2
- * tool-output-guard — PostToolUse hook that bounds per-call tool output size
3
- * so the SDK's auto-compactor can never thrash on a runaway MCP result.
2
+ * tool-output-guard — SDK hooks that bound per-call tool output size and
3
+ * out-of-band very large artifact writes so the SDK's auto-compactor can
4
+ * never thrash on runaway MCP results or generated files.
4
5
  *
5
6
  * Why this exists
6
7
  * ───────────────
@@ -20,8 +21,9 @@
20
21
  *
21
22
  * The fix is the canonical Anthropic primitive: a `PostToolUse` hook that
22
23
  * returns `hookSpecificOutput.updatedToolOutput` to replace the result
23
- * before it reaches the model. From sdk.d.ts:1979 "Replaces the tool
24
- * output before it is sent to the model."
24
+ * before it reaches the model. A companion `PreToolUse` hook handles large
25
+ * `Write` inputs by writing the artifact to disk before the native tool can
26
+ * echo a giant file body into the parent conversation.
25
27
  *
26
28
  * Design properties
27
29
  * ─────────────────
@@ -45,7 +47,7 @@
45
47
  * continues. Telemetry must never break execution.
46
48
  */
47
49
  import { mkdirSync, writeFileSync } from 'node:fs';
48
- import { join } from 'node:path';
50
+ import { dirname, isAbsolute, join } from 'node:path';
49
51
  import pino from 'pino';
50
52
  import { BASE_DIR, TOOL_OUTPUT_GUARD } from '../config.js';
51
53
  const logger = pino({ name: 'clementine.tool-output-guard' });
@@ -60,7 +62,7 @@ export function defaultGuardConfig() {
60
62
  };
61
63
  }
62
64
  function freshStats() {
63
- return { inspected: 0, compressed: 0, ceilingHits: 0, bytesShed: 0, compactions: 0 };
65
+ return { inspected: 0, compressed: 0, ceilingHits: 0, bytesShed: 0, compactions: 0, largeWrites: 0 };
64
66
  }
65
67
  // ── Size estimation ───────────────────────────────────────────────────
66
68
  /**
@@ -97,6 +99,25 @@ const VERBOSE_FIELDS = [
97
99
  'body', 'html', 'html_body', 'htmlBody', 'bodyHtml', 'content', 'text', 'snippet',
98
100
  'message', 'transcript', 'raw', 'rawBody', 'rawMessage', 'contentText', 'plainText',
99
101
  ];
102
+ const LARGE_WRITE_INPUT_BYTES = 8_000;
103
+ function writeArchiveFile(baseDir, runId, toolUseId, toolName, suffix, payload) {
104
+ try {
105
+ const dir = join(baseDir, 'tool-archive', runId);
106
+ mkdirSync(dir, { recursive: true });
107
+ const safeName = toolName.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 80);
108
+ const safeSuffix = suffix.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 30);
109
+ const file = join(dir, `${safeName}__${toolUseId}${safeSuffix ? `__${safeSuffix}` : ''}.json`);
110
+ const body = typeof payload === 'string'
111
+ ? payload
112
+ : JSON.stringify(payload, null, 2);
113
+ writeFileSync(file, body, 'utf8');
114
+ return file;
115
+ }
116
+ catch (err) {
117
+ logger.debug({ err, toolName, runId }, 'tool-output-guard: archive write failed (non-fatal)');
118
+ return null;
119
+ }
120
+ }
100
121
  /** First attempt: trim the list inside the response down to head + tail items. */
101
122
  function tryListShrink(value, ctx) {
102
123
  if (Array.isArray(value)) {
@@ -280,21 +301,7 @@ function formatBytes(n) {
280
301
  * Returns the absolute path, or null on any failure (archive is opt-in
281
302
  * convenience — never blocks compression). */
282
303
  function archivePayload(baseDir, runId, toolUseId, toolName, payload) {
283
- try {
284
- const dir = join(baseDir, 'tool-archive', runId);
285
- mkdirSync(dir, { recursive: true });
286
- const safeName = toolName.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 80);
287
- const file = join(dir, `${safeName}__${toolUseId}.json`);
288
- const body = typeof payload === 'string'
289
- ? payload
290
- : JSON.stringify(payload, null, 2);
291
- writeFileSync(file, body, 'utf8');
292
- return file;
293
- }
294
- catch (err) {
295
- logger.debug({ err, toolName, runId }, 'tool-output-guard: archive write failed (non-fatal)');
296
- return null;
297
- }
304
+ return writeArchiveFile(baseDir, runId, toolUseId, toolName, '', payload);
298
305
  }
299
306
  // ── Adaptive cap computation ──────────────────────────────────────────
300
307
  /**
@@ -375,6 +382,26 @@ export function compressToolOutput(_toolName, rawOutput, ctx) {
375
382
  passthrough: false,
376
383
  };
377
384
  }
385
+ function asRecord(value) {
386
+ return value && typeof value === 'object' && !Array.isArray(value)
387
+ ? value
388
+ : {};
389
+ }
390
+ function largeWriteInput(input) {
391
+ const obj = asRecord(input);
392
+ const filePath = typeof obj.file_path === 'string' ? obj.file_path.trim() : '';
393
+ const content = typeof obj.content === 'string' ? obj.content : '';
394
+ if (!filePath || !content || !isAbsolute(filePath))
395
+ return null;
396
+ const contentBytes = estimateBytes(content);
397
+ if (contentBytes <= LARGE_WRITE_INPUT_BYTES)
398
+ return null;
399
+ return { filePath, content, contentBytes };
400
+ }
401
+ function writeLargeFileOutOfBand(filePath, content) {
402
+ mkdirSync(dirname(filePath), { recursive: true });
403
+ writeFileSync(filePath, content, 'utf8');
404
+ }
378
405
  /**
379
406
  * Build the hook handles that runAgent will hand to the SDK.
380
407
  *
@@ -391,6 +418,71 @@ export function buildGuardHooks(opts) {
391
418
  return { hooks: {}, stats };
392
419
  }
393
420
  const config = opts.config ?? defaultGuardConfig();
421
+ const preToolUse = async (input, toolUseID) => {
422
+ if (input.hook_event_name !== 'PreToolUse') {
423
+ return {};
424
+ }
425
+ const evt = input;
426
+ const toolName = String(evt.tool_name ?? 'unknown');
427
+ if (toolName !== 'Write')
428
+ return {};
429
+ const toolUseId = String(toolUseID ?? evt.tool_use_id ?? 'unknown');
430
+ const large = largeWriteInput(evt.tool_input);
431
+ if (!large)
432
+ return {};
433
+ const archivePath = writeArchiveFile(opts.archiveBaseDir ?? BASE_DIR, opts.runId, toolUseId, toolName, 'input', evt.tool_input);
434
+ try {
435
+ writeLargeFileOutOfBand(large.filePath, large.content);
436
+ }
437
+ catch (err) {
438
+ logger.warn({
439
+ err,
440
+ toolName,
441
+ toolUseId,
442
+ filePath: large.filePath,
443
+ contentBytes: large.contentBytes,
444
+ }, 'tool-output-guard: large Write out-of-band write failed; allowing native tool');
445
+ return {};
446
+ }
447
+ stats.largeWrites += 1;
448
+ stats.bytesShed += Math.max(0, large.contentBytes - 400);
449
+ logger.info({
450
+ toolName,
451
+ toolUseId,
452
+ filePath: large.filePath,
453
+ contentBytes: large.contentBytes,
454
+ archivePath,
455
+ }, 'tool-output-guard: completed large Write out-of-band');
456
+ if (opts.onLargeWrite) {
457
+ try {
458
+ opts.onLargeWrite({
459
+ toolName,
460
+ toolUseId,
461
+ filePath: large.filePath,
462
+ contentBytes: large.contentBytes,
463
+ archivePath,
464
+ });
465
+ }
466
+ catch { /* best-effort */ }
467
+ }
468
+ const reason = [
469
+ `Clementine large-write guard already wrote ${formatBytes(large.contentBytes)} to ${large.filePath}.`,
470
+ archivePath ? `Full original Write input archived at ${archivePath}.` : undefined,
471
+ 'Do not retry Write. Treat the file creation as complete and continue with the remaining requested steps, such as verification or deploy.',
472
+ ].filter(Boolean).join(' ');
473
+ return {
474
+ hookSpecificOutput: {
475
+ hookEventName: 'PreToolUse',
476
+ permissionDecision: 'deny',
477
+ permissionDecisionReason: reason,
478
+ additionalContext: reason,
479
+ updatedInput: {
480
+ file_path: large.filePath,
481
+ content: `[Clementine large-write guard wrote the full ${formatBytes(large.contentBytes)} content out-of-band. ${archivePath ? `Original input: ${archivePath}` : 'Original input was not archived.'}]`,
482
+ },
483
+ },
484
+ };
485
+ };
394
486
  const postToolUse = async (input, toolUseID) => {
395
487
  // We only react to PostToolUse — the hook list is keyed by event,
396
488
  // but the callback signature is shared, so guard the cast.
@@ -474,6 +566,7 @@ export function buildGuardHooks(opts) {
474
566
  };
475
567
  return {
476
568
  hooks: {
569
+ PreToolUse: [{ hooks: [preToolUse] }],
477
570
  PostToolUse: [{ hooks: [postToolUse] }],
478
571
  PreCompact: [{ hooks: [preCompact] }],
479
572
  PostCompact: [{ hooks: [postCompact] }],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.206",
3
+ "version": "1.18.207",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",