clementine-agent 1.18.200 → 1.18.201

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.
@@ -97,6 +97,7 @@ export function invalidateMcpStatusEntry(name) {
97
97
  import { BASE_DIR, PKG_DIR, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY as CONFIG_ANTHROPIC_API_KEY, normalizeClaudeSdkOptionsForOneMillionContext, TOOL_OUTPUT_GUARD, } from '../config.js';
98
98
  import { buildGuardHooks } from './tool-output-guard.js';
99
99
  import { buildDedupHook } from './tool-call-dedup.js';
100
+ import { buildSideEffectIdempotencyHook } from './side-effect-idempotency.js';
100
101
  import { buildChatStopHook } from './chat-stop-hook.js';
101
102
  import { buildAgentMap } from './agent-definitions.js';
102
103
  import { buildExecutionToolPolicy, } from './execution-policy.js';
@@ -449,6 +450,25 @@ export async function runAgent(prompt, opts) {
449
450
  });
450
451
  },
451
452
  });
453
+ // ── Side-effect idempotency hook (1.18.201) ────────────────────────
454
+ // Prevents exact duplicate high-confidence external mutations across
455
+ // context-overflow resumes/retries. This is intentionally narrow:
456
+ // confident email sends and CRM mutations only, keyed by stable identity
457
+ // fields. Unknown side effects remain event-log/audit data, not blocks.
458
+ const idempotency = buildSideEffectIdempotencyHook({
459
+ runId,
460
+ sessionKey: opts.sessionKey,
461
+ onDecision: (info) => {
462
+ if (info.decision !== 'block')
463
+ return;
464
+ writeEvent({
465
+ kind: 'error',
466
+ ts: new Date().toISOString(),
467
+ sessionId,
468
+ toolError: `_clementine_idempotency:block ${info.toolName} ${info.summary ?? ''}`.trim(),
469
+ });
470
+ },
471
+ });
452
472
  // ── Chat persistence Stop hook (1.18.184, source='chat' only) ─────
453
473
  // Keeps chat-initiated multi-step jobs running until they finish.
454
474
  // Inspects the model's last assistant message for continuation
@@ -480,6 +500,10 @@ export async function runAgent(prompt, opts) {
480
500
  // Merge hook maps from the modules. SDK accepts arrays of
481
501
  // HookCallbackMatcher per event; we concatenate.
482
502
  const mergedHooks = { ...guard.hooks };
503
+ for (const [evt, matchers] of Object.entries(idempotency.hooks)) {
504
+ const existing = mergedHooks[evt] ?? [];
505
+ mergedHooks[evt] = [...existing, ...matchers];
506
+ }
483
507
  for (const [evt, matchers] of Object.entries(dedup.hooks)) {
484
508
  const existing = mergedHooks[evt] ?? [];
485
509
  mergedHooks[evt] = [...existing, ...matchers];
@@ -831,6 +855,12 @@ export async function runAgent(prompt, opts) {
831
855
  warned: dedup.stats.warned,
832
856
  blocked: dedup.stats.blocked,
833
857
  } : undefined,
858
+ idempotency: idempotency.stats.guarded > 0 ? {
859
+ guarded: idempotency.stats.guarded,
860
+ blocked: idempotency.stats.blocked,
861
+ recorded: idempotency.stats.recorded,
862
+ failedNotRecorded: idempotency.stats.failedNotRecorded,
863
+ } : undefined,
834
864
  }, 'runAgent: query complete');
835
865
  // PRD §6 Phase 4e: subagent transcript backfill (Path C). The SDK persists
836
866
  // every subagent's full message stream to ~/.claude/projects/<encoded-cwd>/
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Cross-run idempotency guard for high-confidence external side effects.
3
+ *
4
+ * The classifier answers "is this mutating?". This module answers the
5
+ * narrower operational question: "is this the same external mutation we
6
+ * already saw succeed recently?" It intentionally fingerprints only calls
7
+ * with stable identity fields. Unknown or weakly-identified mutations are
8
+ * observed by the event log, not blocked.
9
+ */
10
+ import type { HookCallbackMatcher, HookEvent } from '@anthropic-ai/claude-agent-sdk';
11
+ export type IdempotencyKind = 'email_send' | 'crm_mutation';
12
+ export interface SideEffectFingerprint {
13
+ kind: IdempotencyKind;
14
+ fingerprint: string;
15
+ ttlMs: number;
16
+ summary: string;
17
+ guidance: string;
18
+ details: Record<string, unknown>;
19
+ }
20
+ export interface SideEffectIdempotencyRecord {
21
+ version: 1;
22
+ ts: string;
23
+ runId: string;
24
+ sessionKey?: string;
25
+ toolName: string;
26
+ toolUseId?: string;
27
+ kind: IdempotencyKind;
28
+ fingerprint: string;
29
+ ttlMs: number;
30
+ summary: string;
31
+ details: Record<string, unknown>;
32
+ result: {
33
+ successReason: string;
34
+ statusCode?: number;
35
+ logId?: string;
36
+ };
37
+ }
38
+ export interface SideEffectIdempotencyStats {
39
+ inspected: number;
40
+ guarded: number;
41
+ blocked: number;
42
+ recorded: number;
43
+ skipped: number;
44
+ failedNotRecorded: number;
45
+ }
46
+ export interface SideEffectIdempotencyHookOptions {
47
+ runId: string;
48
+ sessionKey?: string;
49
+ baseDir?: string;
50
+ now?: () => number;
51
+ onDecision?: (info: {
52
+ decision: 'allow' | 'block' | 'record' | 'skip' | 'failed';
53
+ toolName: string;
54
+ kind?: IdempotencyKind;
55
+ fingerprint?: string;
56
+ summary?: string;
57
+ prior?: SideEffectIdempotencyRecord;
58
+ reason?: string;
59
+ }) => void;
60
+ }
61
+ export interface SideEffectIdempotencyHookHandles {
62
+ hooks: Partial<Record<HookEvent, HookCallbackMatcher[]>>;
63
+ stats: SideEffectIdempotencyStats;
64
+ }
65
+ export declare function buildSideEffectFingerprint(toolName: string, input: unknown): SideEffectFingerprint | null;
66
+ export declare function readRecentSideEffectRecords(baseDir?: string, now?: number): SideEffectIdempotencyRecord[];
67
+ export declare function appendSideEffectRecord(record: SideEffectIdempotencyRecord, baseDir?: string): void;
68
+ export declare function findPriorSuccessfulSideEffect(fingerprint: SideEffectFingerprint, opts?: {
69
+ baseDir?: string;
70
+ now?: number;
71
+ }): SideEffectIdempotencyRecord | null;
72
+ export declare function buildSideEffectIdempotencyHook(opts: SideEffectIdempotencyHookOptions): SideEffectIdempotencyHookHandles;
73
+ //# sourceMappingURL=side-effect-idempotency.d.ts.map
@@ -0,0 +1,505 @@
1
+ /**
2
+ * Cross-run idempotency guard for high-confidence external side effects.
3
+ *
4
+ * The classifier answers "is this mutating?". This module answers the
5
+ * narrower operational question: "is this the same external mutation we
6
+ * already saw succeed recently?" It intentionally fingerprints only calls
7
+ * with stable identity fields. Unknown or weakly-identified mutations are
8
+ * observed by the event log, not blocked.
9
+ */
10
+ import { createHash } from 'node:crypto';
11
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
12
+ import path from 'node:path';
13
+ import pino from 'pino';
14
+ import { BASE_DIR } from '../config.js';
15
+ import { isToolResultSuccessful, normalizedToolResultPayload } from './side-effect-classifier.js';
16
+ const logger = pino({ name: 'clementine.side-effect-idempotency' });
17
+ const EMAIL_SEND_TTL_MS = 24 * 60 * 60 * 1000;
18
+ const CRM_MUTATION_TTL_MS = 60 * 60 * 1000;
19
+ const MAX_STORE_BYTES = 2_000_000;
20
+ const MAX_STORE_LINES = 5000;
21
+ const EMAIL_ADDRESS_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
22
+ const EMAIL_BODY_KEYS = ['body', 'htmlBody', 'bodyHtml', 'html', 'text', 'plainText', 'message', 'content'];
23
+ const EMAIL_SUBJECT_KEYS = ['subject', 'title'];
24
+ const EMAIL_TO_KEYS = ['to', 'toEmail', 'to_email', 'recipient', 'recipients', 'toRecipients', 'to_recipients'];
25
+ const EMAIL_CC_KEYS = ['cc', 'ccRecipients', 'cc_recipients'];
26
+ const EMAIL_BCC_KEYS = ['bcc', 'bccRecipients', 'bcc_recipients'];
27
+ const CRM_PROVIDER_TOKENS = new Set([
28
+ 'crm',
29
+ 'salesforce',
30
+ 'sfdc',
31
+ 'hubspot',
32
+ 'pipedrive',
33
+ 'zoho',
34
+ 'dynamics',
35
+ ]);
36
+ const CRM_MUTATION_TOKENS = new Set(['create', 'update', 'upsert', 'delete', 'insert', 'set']);
37
+ const CRM_OBJECT_KEYS = ['object', 'objectName', 'object_name', 'sobject', 'sObject', 's_object', 'entity', 'module'];
38
+ const CRM_RECORD_ID_KEYS = ['recordId', 'record_id', 'id', 'contactId', 'contact_id', 'leadId', 'lead_id', 'accountId', 'account_id', 'externalId', 'external_id'];
39
+ const CRM_FIELD_KEYS = ['fields', 'values', 'data', 'properties', 'record', 'payload', 'input'];
40
+ function activeBaseDir(baseDir) {
41
+ return baseDir ?? process.env.CLEMENTINE_HOME ?? BASE_DIR;
42
+ }
43
+ function storePath(baseDir) {
44
+ return path.join(activeBaseDir(baseDir), 'idempotency', 'recent-side-effects.jsonl');
45
+ }
46
+ function stableJson(value) {
47
+ return JSON.stringify(value, (_key, v) => {
48
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
49
+ const out = {};
50
+ for (const k of Object.keys(v).sort()) {
51
+ out[k] = v[k];
52
+ }
53
+ return out;
54
+ }
55
+ return v;
56
+ });
57
+ }
58
+ function sha256(value) {
59
+ return createHash('sha256').update(value).digest('hex');
60
+ }
61
+ function shortHash(value, chars = 20) {
62
+ return sha256(value).slice(0, chars);
63
+ }
64
+ function asRecord(value) {
65
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
66
+ }
67
+ function tokensForToolName(toolName) {
68
+ return toolName
69
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
70
+ .toLowerCase()
71
+ .split(/[^a-z0-9]+/)
72
+ .filter(Boolean);
73
+ }
74
+ function findFirstStringByKey(input, keys, depth = 0) {
75
+ if (depth > 4)
76
+ return undefined;
77
+ if (!input || typeof input !== 'object')
78
+ return undefined;
79
+ const obj = input;
80
+ const lowerMap = new Map(Object.keys(obj).map((k) => [k.toLowerCase(), k]));
81
+ for (const key of keys) {
82
+ const actual = lowerMap.get(key.toLowerCase());
83
+ const value = actual ? obj[actual] : undefined;
84
+ if (typeof value === 'string' && value.trim())
85
+ return value.trim();
86
+ }
87
+ for (const value of Object.values(obj)) {
88
+ if (value && typeof value === 'object') {
89
+ const found = findFirstStringByKey(value, keys, depth + 1);
90
+ if (found)
91
+ return found;
92
+ }
93
+ }
94
+ return undefined;
95
+ }
96
+ function findFirstValueByKey(input, keys, depth = 0) {
97
+ if (depth > 4)
98
+ return undefined;
99
+ if (!input || typeof input !== 'object')
100
+ return undefined;
101
+ const obj = input;
102
+ const lowerMap = new Map(Object.keys(obj).map((k) => [k.toLowerCase(), k]));
103
+ for (const key of keys) {
104
+ const actual = lowerMap.get(key.toLowerCase());
105
+ if (actual && obj[actual] != null)
106
+ return obj[actual];
107
+ }
108
+ for (const value of Object.values(obj)) {
109
+ if (value && typeof value === 'object') {
110
+ const found = findFirstValueByKey(value, keys, depth + 1);
111
+ if (found !== undefined)
112
+ return found;
113
+ }
114
+ }
115
+ return undefined;
116
+ }
117
+ function collectEmails(value, out = new Set(), depth = 0) {
118
+ if (depth > 6 || value == null)
119
+ return out;
120
+ if (typeof value === 'string') {
121
+ const matches = value.match(EMAIL_ADDRESS_RE) ?? [];
122
+ for (const m of matches)
123
+ out.add(m.toLowerCase());
124
+ return out;
125
+ }
126
+ if (Array.isArray(value)) {
127
+ for (const item of value)
128
+ collectEmails(item, out, depth + 1);
129
+ return out;
130
+ }
131
+ if (typeof value === 'object') {
132
+ const obj = value;
133
+ for (const key of ['email', 'address', 'emailAddress']) {
134
+ if (key in obj)
135
+ collectEmails(obj[key], out, depth + 1);
136
+ }
137
+ for (const nested of Object.values(obj)) {
138
+ if (nested && typeof nested === 'object')
139
+ collectEmails(nested, out, depth + 1);
140
+ }
141
+ }
142
+ return out;
143
+ }
144
+ function emailsForKeys(input, keys) {
145
+ const value = findFirstValueByKey(input, keys);
146
+ return Array.from(collectEmails(value)).sort();
147
+ }
148
+ function normalizeWhitespace(value) {
149
+ return value.replace(/\s+/g, ' ').trim();
150
+ }
151
+ function preview(value, max = 80) {
152
+ const normalized = normalizeWhitespace(value);
153
+ return normalized.length > max ? `${normalized.slice(0, max - 1)}...` : normalized;
154
+ }
155
+ function isEmailSendTool(toolName) {
156
+ const tokens = tokensForToolName(toolName);
157
+ const hasSend = tokens.includes('send') || /send_?email/i.test(toolName);
158
+ const hasEmailSurface = tokens.some((t) => t === 'email' || t === 'mail' || t === 'gmail' || t === 'outlook');
159
+ return hasSend && hasEmailSurface;
160
+ }
161
+ function buildEmailFingerprint(toolName, input) {
162
+ if (!isEmailSendTool(toolName))
163
+ return null;
164
+ const to = emailsForKeys(input, EMAIL_TO_KEYS);
165
+ const cc = emailsForKeys(input, EMAIL_CC_KEYS);
166
+ const bcc = emailsForKeys(input, EMAIL_BCC_KEYS);
167
+ const subject = findFirstStringByKey(input, EMAIL_SUBJECT_KEYS);
168
+ const body = findFirstStringByKey(input, EMAIL_BODY_KEYS);
169
+ if (to.length === 0 || !subject || !body)
170
+ return null;
171
+ const normalizedSubject = normalizeWhitespace(subject).toLowerCase();
172
+ // Collapse whitespace before hashing so harmless HTML/plain-text wrapping
173
+ // changes don't evade duplicate-send protection.
174
+ const bodyHash = shortHash(normalizeWhitespace(body));
175
+ const identity = {
176
+ kind: 'email_send',
177
+ to,
178
+ cc,
179
+ bcc,
180
+ subject: normalizedSubject,
181
+ bodyHash,
182
+ };
183
+ const recipientText = to.length <= 3 ? to.join(', ') : `${to.slice(0, 3).join(', ')} +${to.length - 3} more`;
184
+ return {
185
+ kind: 'email_send',
186
+ fingerprint: `email_send:${shortHash(stableJson(identity), 32)}`,
187
+ ttlMs: EMAIL_SEND_TTL_MS,
188
+ summary: `email send to ${recipientText} ("${preview(subject, 72)}")`,
189
+ guidance: `This email send to ${recipientText} was already accepted by the provider. Do not retry it. Continue with remaining follow-up work such as CRM stamping, task creation, or a concise status update.`,
190
+ details: {
191
+ to,
192
+ ...(cc.length ? { cc } : {}),
193
+ ...(bcc.length ? { bcc } : {}),
194
+ subject: normalizeWhitespace(subject),
195
+ bodyHash,
196
+ },
197
+ };
198
+ }
199
+ function isCrmMutationTool(toolName) {
200
+ const tokens = tokensForToolName(toolName);
201
+ return tokens.some((t) => CRM_PROVIDER_TOKENS.has(t))
202
+ && tokens.some((t) => CRM_MUTATION_TOKENS.has(t));
203
+ }
204
+ function mutationVerb(toolName) {
205
+ return tokensForToolName(toolName).find((t) => CRM_MUTATION_TOKENS.has(t));
206
+ }
207
+ function pickCrmFields(input) {
208
+ const fromKnownKey = findFirstValueByKey(input, CRM_FIELD_KEYS);
209
+ if (fromKnownKey !== undefined)
210
+ return fromKnownKey;
211
+ const shallow = {};
212
+ for (const [k, v] of Object.entries(input)) {
213
+ if ([...CRM_OBJECT_KEYS, ...CRM_RECORD_ID_KEYS].some((known) => known.toLowerCase() === k.toLowerCase()))
214
+ continue;
215
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v == null)
216
+ shallow[k] = v;
217
+ }
218
+ return shallow;
219
+ }
220
+ function buildCrmFingerprint(toolName, input) {
221
+ if (!isCrmMutationTool(toolName))
222
+ return null;
223
+ const verb = mutationVerb(toolName);
224
+ const objectName = findFirstStringByKey(input, CRM_OBJECT_KEYS);
225
+ const recordId = findFirstStringByKey(input, CRM_RECORD_ID_KEYS);
226
+ const fields = pickCrmFields(input);
227
+ if (!verb || !objectName)
228
+ return null;
229
+ if (verb !== 'create' && !recordId)
230
+ return null;
231
+ const fieldsHash = shortHash(stableJson(fields ?? {}));
232
+ const identity = {
233
+ kind: 'crm_mutation',
234
+ verb,
235
+ objectName: objectName.toLowerCase(),
236
+ recordId: recordId?.toLowerCase() ?? null,
237
+ fieldsHash,
238
+ };
239
+ const target = `${objectName}${recordId ? ` ${recordId}` : ''}`;
240
+ return {
241
+ kind: 'crm_mutation',
242
+ fingerprint: `crm_mutation:${shortHash(stableJson(identity), 32)}`,
243
+ ttlMs: CRM_MUTATION_TTL_MS,
244
+ summary: `CRM ${verb} on ${target}`,
245
+ guidance: `This CRM ${verb} already succeeded for ${target}. Do not retry the same mutation. Continue with the remaining records or report completion/pending work.`,
246
+ details: {
247
+ verb,
248
+ objectName,
249
+ ...(recordId ? { recordId } : {}),
250
+ fieldsHash,
251
+ },
252
+ };
253
+ }
254
+ function buildSfBashFingerprint(input) {
255
+ const command = typeof input.command === 'string' ? input.command.trim() : '';
256
+ // Narrow first pass: only Salesforce CLI data mutations with exact command
257
+ // identity. Known gaps to evaluate later: legacy
258
+ // `sfdx force:data:record:*`, `sf apex run`, direct REST/curl calls, and
259
+ // custom Python sender scripts. Those need per-pattern confidence before
260
+ // they can safely block across runs.
261
+ if (!/\b(?:sf|sfdx)\s+data\s+(?:update|delete|create|upsert)\b/i.test(command))
262
+ return null;
263
+ const normalizedCommand = normalizeWhitespace(command);
264
+ return {
265
+ kind: 'crm_mutation',
266
+ fingerprint: `crm_mutation:${shortHash(`bash:${normalizedCommand}`, 32)}`,
267
+ ttlMs: CRM_MUTATION_TTL_MS,
268
+ summary: `CRM CLI mutation (${preview(normalizedCommand, 88)})`,
269
+ guidance: `This CRM CLI mutation already succeeded recently. Do not retry the same command. Continue with remaining records or report completion/pending work.`,
270
+ details: {
271
+ verb: 'cli',
272
+ commandHash: shortHash(normalizedCommand),
273
+ commandPreview: preview(normalizedCommand, 160),
274
+ },
275
+ };
276
+ }
277
+ export function buildSideEffectFingerprint(toolName, input) {
278
+ const rec = asRecord(input);
279
+ if (!rec)
280
+ return null;
281
+ // Fingerprints intentionally omit agent identity. Idempotency is scoped to
282
+ // the external operation: if two agents try the same send/update, the
283
+ // second should continue the workflow instead of duplicating the effect.
284
+ if (toolName === 'Bash')
285
+ return buildSfBashFingerprint(rec);
286
+ return buildEmailFingerprint(toolName, rec) ?? buildCrmFingerprint(toolName, rec);
287
+ }
288
+ function parseRecord(line) {
289
+ try {
290
+ const raw = JSON.parse(line);
291
+ if (raw.version !== 1 || !raw.fingerprint || !raw.kind || !raw.ts || !raw.runId)
292
+ return null;
293
+ return raw;
294
+ }
295
+ catch {
296
+ return null;
297
+ }
298
+ }
299
+ export function readRecentSideEffectRecords(baseDir, now = Date.now()) {
300
+ const file = storePath(baseDir);
301
+ if (!existsSync(file))
302
+ return [];
303
+ try {
304
+ const lines = readFileSync(file, 'utf-8').split('\n').filter(Boolean);
305
+ const records = lines.map(parseRecord).filter((r) => r !== null);
306
+ return records.filter((r) => now - Date.parse(r.ts) <= r.ttlMs);
307
+ }
308
+ catch {
309
+ return [];
310
+ }
311
+ }
312
+ function maybePruneStore(baseDir, now = Date.now()) {
313
+ const file = storePath(baseDir);
314
+ if (!existsSync(file))
315
+ return;
316
+ try {
317
+ const st = statSync(file);
318
+ if (st.size <= MAX_STORE_BYTES)
319
+ return;
320
+ const recent = readRecentSideEffectRecords(baseDir, now).slice(-MAX_STORE_LINES);
321
+ writeFileSync(file, recent.map((r) => JSON.stringify(r)).join('\n') + (recent.length ? '\n' : ''));
322
+ }
323
+ catch {
324
+ // non-critical
325
+ }
326
+ }
327
+ export function appendSideEffectRecord(record, baseDir) {
328
+ const file = storePath(baseDir);
329
+ try {
330
+ mkdirSync(path.dirname(file), { recursive: true });
331
+ // Records are small JSONL rows; appendFileSync keeps each write atomic in
332
+ // practice for this size, and malformed lines are ignored on read.
333
+ appendFileSync(file, JSON.stringify(record) + '\n');
334
+ maybePruneStore(baseDir, Date.parse(record.ts));
335
+ }
336
+ catch (err) {
337
+ logger.warn({ err, kind: record.kind, runId: record.runId }, 'Failed to append side-effect idempotency record');
338
+ }
339
+ }
340
+ export function findPriorSuccessfulSideEffect(fingerprint, opts = {}) {
341
+ const now = opts.now ?? Date.now();
342
+ const records = readRecentSideEffectRecords(opts.baseDir, now);
343
+ for (let i = records.length - 1; i >= 0; i -= 1) {
344
+ const rec = records[i];
345
+ if (rec.fingerprint === fingerprint.fingerprint && now - Date.parse(rec.ts) <= fingerprint.ttlMs) {
346
+ return rec;
347
+ }
348
+ }
349
+ return null;
350
+ }
351
+ function findLogId(value, depth = 0) {
352
+ if (depth > 5 || value == null)
353
+ return undefined;
354
+ if (typeof value !== 'object')
355
+ return undefined;
356
+ const obj = value;
357
+ for (const key of ['logId', 'log_id', 'requestId', 'request_id', 'id']) {
358
+ const raw = obj[key];
359
+ if (typeof raw === 'string' && raw.trim())
360
+ return raw.trim();
361
+ }
362
+ for (const nested of Object.values(obj)) {
363
+ const found = findLogId(nested, depth + 1);
364
+ if (found)
365
+ return found;
366
+ }
367
+ return undefined;
368
+ }
369
+ function duplicateReason(fingerprint, prior, now) {
370
+ const ageMinutes = Math.max(0, Math.round((now - Date.parse(prior.ts)) / 60_000));
371
+ return JSON.stringify({
372
+ successful: false,
373
+ blocked_by: 'idempotency_guard',
374
+ operation_already_succeeded: true,
375
+ error: 'duplicate-of-prior-call',
376
+ operation: fingerprint.summary,
377
+ prior_call: {
378
+ ts: prior.ts,
379
+ runId: prior.runId,
380
+ toolName: prior.toolName,
381
+ status_code: prior.result.statusCode,
382
+ log_id: prior.result.logId,
383
+ },
384
+ guidance: `${fingerprint.guidance} Prior success was ${ageMinutes} minute(s) ago.`,
385
+ });
386
+ }
387
+ export function buildSideEffectIdempotencyHook(opts) {
388
+ const stats = {
389
+ inspected: 0,
390
+ guarded: 0,
391
+ blocked: 0,
392
+ recorded: 0,
393
+ skipped: 0,
394
+ failedNotRecorded: 0,
395
+ };
396
+ const now = opts.now ?? (() => Date.now());
397
+ const preToolUse = async (input) => {
398
+ if (input.hook_event_name !== 'PreToolUse')
399
+ return {};
400
+ const evt = input;
401
+ const toolName = String(evt.tool_name ?? 'unknown');
402
+ stats.inspected += 1;
403
+ const fingerprint = buildSideEffectFingerprint(toolName, evt.tool_input);
404
+ if (!fingerprint) {
405
+ stats.skipped += 1;
406
+ opts.onDecision?.({ decision: 'skip', toolName, reason: 'no-confident-fingerprint' });
407
+ return {};
408
+ }
409
+ stats.guarded += 1;
410
+ const ts = now();
411
+ const prior = findPriorSuccessfulSideEffect(fingerprint, { baseDir: opts.baseDir, now: ts });
412
+ if (!prior) {
413
+ opts.onDecision?.({
414
+ decision: 'allow',
415
+ toolName,
416
+ kind: fingerprint.kind,
417
+ fingerprint: fingerprint.fingerprint,
418
+ summary: fingerprint.summary,
419
+ });
420
+ return {};
421
+ }
422
+ stats.blocked += 1;
423
+ logger.warn({
424
+ runId: opts.runId,
425
+ toolName,
426
+ kind: fingerprint.kind,
427
+ priorRunId: prior.runId,
428
+ summary: fingerprint.summary,
429
+ }, 'side-effect-idempotency: blocking duplicate successful side effect');
430
+ opts.onDecision?.({
431
+ decision: 'block',
432
+ toolName,
433
+ kind: fingerprint.kind,
434
+ fingerprint: fingerprint.fingerprint,
435
+ summary: fingerprint.summary,
436
+ prior,
437
+ });
438
+ return {
439
+ hookSpecificOutput: {
440
+ hookEventName: 'PreToolUse',
441
+ permissionDecision: 'deny',
442
+ permissionDecisionReason: duplicateReason(fingerprint, prior, ts),
443
+ },
444
+ };
445
+ };
446
+ const postToolUse = async (input) => {
447
+ if (input.hook_event_name !== 'PostToolUse')
448
+ return {};
449
+ const evt = input;
450
+ const toolName = String(evt.tool_name ?? 'unknown');
451
+ const fingerprint = buildSideEffectFingerprint(toolName, evt.tool_input);
452
+ if (!fingerprint)
453
+ return {};
454
+ const result = isToolResultSuccessful(evt.tool_response, false);
455
+ if (!result.successful) {
456
+ stats.failedNotRecorded += 1;
457
+ opts.onDecision?.({
458
+ decision: 'failed',
459
+ toolName,
460
+ kind: fingerprint.kind,
461
+ fingerprint: fingerprint.fingerprint,
462
+ summary: fingerprint.summary,
463
+ reason: result.reason,
464
+ });
465
+ return {};
466
+ }
467
+ const payload = normalizedToolResultPayload(evt.tool_response);
468
+ const record = {
469
+ version: 1,
470
+ ts: new Date(now()).toISOString(),
471
+ runId: opts.runId,
472
+ ...(opts.sessionKey ? { sessionKey: opts.sessionKey } : {}),
473
+ toolName,
474
+ toolUseId: evt.tool_use_id,
475
+ kind: fingerprint.kind,
476
+ fingerprint: fingerprint.fingerprint,
477
+ ttlMs: fingerprint.ttlMs,
478
+ summary: fingerprint.summary,
479
+ details: fingerprint.details,
480
+ result: {
481
+ successReason: result.reason,
482
+ ...(result.statusCode !== undefined ? { statusCode: result.statusCode } : {}),
483
+ ...(findLogId(payload) ? { logId: findLogId(payload) } : {}),
484
+ },
485
+ };
486
+ appendSideEffectRecord(record, opts.baseDir);
487
+ stats.recorded += 1;
488
+ opts.onDecision?.({
489
+ decision: 'record',
490
+ toolName,
491
+ kind: fingerprint.kind,
492
+ fingerprint: fingerprint.fingerprint,
493
+ summary: fingerprint.summary,
494
+ });
495
+ return {};
496
+ };
497
+ return {
498
+ hooks: {
499
+ PreToolUse: [{ hooks: [preToolUse] }],
500
+ PostToolUse: [{ hooks: [postToolUse] }],
501
+ },
502
+ stats,
503
+ };
504
+ }
505
+ //# sourceMappingURL=side-effect-idempotency.js.map
@@ -45,6 +45,7 @@ export declare class DiscordStreamingMessage {
45
45
  setToolStatus(status: string): void;
46
46
  update(text: string): Promise<void>;
47
47
  finalize(text: string): Promise<void>;
48
+ discard(): Promise<void>;
48
49
  /** Format elapsed milliseconds as human-readable duration. */
49
50
  private formatElapsed;
50
51
  private flush;
@@ -270,6 +270,20 @@ export class DiscordStreamingMessage {
270
270
  catch { /* best-effort */ }
271
271
  }
272
272
  }
273
+ async discard() {
274
+ this.isFinal = true;
275
+ if (this.flushTimer) {
276
+ clearTimeout(this.flushTimer);
277
+ this.flushTimer = null;
278
+ }
279
+ if (this.progressTimer) {
280
+ clearInterval(this.progressTimer);
281
+ this.progressTimer = null;
282
+ }
283
+ if (this.message) {
284
+ await this.message.delete().catch(() => { });
285
+ }
286
+ }
273
287
  /** Format elapsed milliseconds as human-readable duration. */
274
288
  formatElapsed(ms) {
275
289
  const s = Math.floor(ms / 1000);
@@ -12,6 +12,7 @@ import os from 'node:os';
12
12
  import path from 'node:path';
13
13
  import { chunkText, DiscordStreamingMessage, friendlyToolName, formatCronEmbed, rehydrateStatusEmbed, setSavedStatusEmbed, } from './discord-utils.js';
14
14
  import { DISCORD_TOKEN, DISCORD_OWNER_ID, DISCORD_WATCHED_CHANNELS, MODELS, ASSISTANT_NAME, OWNER_NAME, PKG_DIR, VAULT_DIR, BASE_DIR, DEFAULT_MODEL_TIER, } from '../config.js';
15
+ import { isSilentGatewayResponse } from '../gateway/router.js';
15
16
  import { findProjectByName, getLinkedProjects } from '../agent/assistant.js';
16
17
  import { detectApprovalReply } from '../agent/local-turn.js';
17
18
  import { normalizeToolsetName } from '../agent/toolsets.js';
@@ -776,6 +777,11 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
776
777
  const streamer = new DiscordStreamingMessage(message.channel);
777
778
  await streamer.start();
778
779
  const response = await heartbeat.runManual();
780
+ if (isSilentGatewayResponse(response)) {
781
+ await streamer.discard();
782
+ updatePresence(sessionKey);
783
+ return;
784
+ }
779
785
  await streamer.finalize(response);
780
786
  // Inject into DM session so follow-up conversation has context
781
787
  gateway.injectContext(sessionKey, '!heartbeat', response);
@@ -832,6 +838,11 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
832
838
  const streamer = new DiscordStreamingMessage(message.channel);
833
839
  await streamer.start();
834
840
  const response = await cronScheduler.runManual(jobName);
841
+ if (isSilentGatewayResponse(response)) {
842
+ await streamer.discard();
843
+ updatePresence(sessionKey);
844
+ return;
845
+ }
835
846
  await streamer.finalize(response);
836
847
  // Inject into DM session so follow-up conversation has context
837
848
  gateway.injectContext(sessionKey, `!cron run ${jobName}`, response);
@@ -875,6 +886,11 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
875
886
  const streamer = new DiscordStreamingMessage(message.channel);
876
887
  await streamer.start();
877
888
  const response = await cronScheduler.runWorkflow(wfName, inputs);
889
+ if (isSilentGatewayResponse(response)) {
890
+ await streamer.discard();
891
+ updatePresence(sessionKey);
892
+ return;
893
+ }
878
894
  await streamer.finalize(response);
879
895
  gateway.injectContext(sessionKey, `!workflow run ${wfName}`, response);
880
896
  return;
@@ -1149,6 +1165,11 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
1149
1165
  await streamer.start();
1150
1166
  try {
1151
1167
  const response = await gateway.handleMessage(sessionKey, effectiveText, (t) => streamer.update(t), oneOffModel, oneOffMaxTurns, (toolName, toolInput) => { streamer.setToolStatus(friendlyToolName(toolName, toolInput)); return Promise.resolve(); }, (status) => { streamer.setToolStatus(status); return Promise.resolve(); });
1168
+ if (isSilentGatewayResponse(response)) {
1169
+ await streamer.discard();
1170
+ updatePresence(sessionKey);
1171
+ return;
1172
+ }
1152
1173
  await streamer.finalize(response);
1153
1174
  updatePresence(sessionKey);
1154
1175
  // Track bot message for feedback reactions
@@ -1761,6 +1782,11 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
1761
1782
  await streamer.start();
1762
1783
  try {
1763
1784
  const response = await gateway.handleMessage(sessionKey, agentMessage, (t) => streamer.update(t));
1785
+ if (isSilentGatewayResponse(response)) {
1786
+ await streamer.discard();
1787
+ updatePresence(sessionKey);
1788
+ return;
1789
+ }
1764
1790
  await streamer.finalize(response);
1765
1791
  }
1766
1792
  catch (err) {
@@ -32,6 +32,7 @@ export declare class SlackStreamingMessage {
32
32
  setToolStatus(status: string): void;
33
33
  update(text: string): Promise<void>;
34
34
  finalize(text: string): Promise<void>;
35
+ discard(): Promise<void>;
35
36
  /** Format elapsed milliseconds as human-readable duration. */
36
37
  private formatElapsed;
37
38
  private flush;
@@ -131,6 +131,20 @@ export class SlackStreamingMessage {
131
131
  await sendChunkedSlack(this.client, this.channel, text, this.threadTs);
132
132
  }
133
133
  }
134
+ async discard() {
135
+ this.isFinal = true;
136
+ if (this.flushTimer) {
137
+ clearTimeout(this.flushTimer);
138
+ this.flushTimer = null;
139
+ }
140
+ if (this.progressTimer) {
141
+ clearInterval(this.progressTimer);
142
+ this.progressTimer = null;
143
+ }
144
+ if (this.ts) {
145
+ await this.client.chat.delete({ channel: this.channel, ts: this.ts }).catch(() => { });
146
+ }
147
+ }
134
148
  /** Format elapsed milliseconds as human-readable duration. */
135
149
  formatElapsed(ms) {
136
150
  const s = Math.floor(ms / 1000);
@@ -7,6 +7,7 @@
7
7
  import { App } from '@slack/bolt';
8
8
  import pino from 'pino';
9
9
  import { SLACK_BOT_TOKEN, SLACK_APP_TOKEN, SLACK_OWNER_USER_ID, VAULT_DIR, } from '../config.js';
10
+ import { isSilentGatewayResponse } from '../gateway/router.js';
10
11
  import { mdToSlack, sendChunkedSlack, SlackStreamingMessage } from './slack-utils.js';
11
12
  import { friendlyToolName } from './discord-utils.js';
12
13
  const logger = pino({ name: 'clementine.slack' });
@@ -124,6 +125,10 @@ export async function startSlack(gateway, dispatcher, slackBotManager) {
124
125
  const response = await gateway.handleMessage(sessionKey, text, (t) => streamer.update(t), undefined, // model
125
126
  undefined, // maxTurns
126
127
  async (toolName, toolInput) => { streamer.setToolStatus(friendlyToolName(toolName, toolInput)); }, async (status) => { streamer.setToolStatus(status); });
128
+ if (isSilentGatewayResponse(response)) {
129
+ await streamer.discard();
130
+ return;
131
+ }
127
132
  await streamer.finalize(response);
128
133
  // Track bot message for feedback reactions
129
134
  if (streamer.messageTs) {
@@ -7,6 +7,7 @@
7
7
  import { Bot } from 'grammy';
8
8
  import pino from 'pino';
9
9
  import { TELEGRAM_BOT_TOKEN, TELEGRAM_OWNER_ID, } from '../config.js';
10
+ import { isSilentGatewayResponse } from '../gateway/router.js';
10
11
  import { detectApprovalReply } from '../agent/local-turn.js';
11
12
  const logger = pino({ name: 'clementine.telegram' });
12
13
  const STREAM_UPDATE_INTERVAL = 1500; // ms
@@ -98,6 +99,12 @@ class TelegramStreamingMessage {
98
99
  await sendChunked(this.bot, this.chatId, text);
99
100
  }
100
101
  }
102
+ async discard() {
103
+ this.isFinal = true;
104
+ if (this.messageId !== null) {
105
+ await this.bot.api.deleteMessage(this.chatId, this.messageId).catch(() => { });
106
+ }
107
+ }
101
108
  async flush() {
102
109
  if (this.messageId === null || !this.pendingText || this.isFinal)
103
110
  return;
@@ -154,6 +161,10 @@ export async function startTelegram(gateway, dispatcher) {
154
161
  await streamer.start();
155
162
  try {
156
163
  const response = await gateway.handleMessage(sessionKey, text, (t) => streamer.update(t), undefined, undefined, async (toolName) => { streamer.setStatus(`using ${toolName}...`); }, async (status) => { streamer.setStatus(status); });
164
+ if (isSilentGatewayResponse(response)) {
165
+ await streamer.discard();
166
+ return;
167
+ }
157
168
  await streamer.finalize(response);
158
169
  }
159
170
  catch (err) {
@@ -180,6 +191,10 @@ export async function startTelegram(gateway, dispatcher) {
180
191
  await streamer.start();
181
192
  try {
182
193
  const response = await gateway.handleMessage(sessionKey, text, (t) => streamer.update(t), undefined, undefined, async (toolName) => { streamer.setStatus(`using ${toolName}...`); }, async (status) => { streamer.setStatus(status); });
194
+ if (isSilentGatewayResponse(response)) {
195
+ await streamer.discard();
196
+ return;
197
+ }
183
198
  await streamer.finalize(response);
184
199
  }
185
200
  catch (err) {
@@ -209,6 +224,10 @@ export async function startTelegram(gateway, dispatcher) {
209
224
  await streamer.start();
210
225
  try {
211
226
  const response = await gateway.handleMessage(sessionKey, text, (t) => streamer.update(t), undefined, undefined, async (toolName) => { streamer.setStatus(`using ${toolName}...`); }, async (status) => { streamer.setStatus(status); });
227
+ if (isSilentGatewayResponse(response)) {
228
+ await streamer.discard();
229
+ return;
230
+ }
212
231
  await streamer.finalize(response);
213
232
  }
214
233
  catch (err) {
@@ -35,6 +35,8 @@ export declare function runAgentResultIndicatesContextOverflow(result: {
35
35
  text?: string;
36
36
  }): boolean;
37
37
  export type ChatErrorKind = 'rate_limit' | 'one_million_context' | 'context_overflow' | 'auth' | 'billing' | 'transient' | 'unknown';
38
+ export declare const SILENT_GATEWAY_RESPONSE = "__clementine_silent_response__";
39
+ export declare function isSilentGatewayResponse(text: string): boolean;
38
40
  export declare function classifyChatError(err: unknown): ChatErrorKind;
39
41
  /** Detect auth-like errors in response text that the SDK returned as "successful" results. */
40
42
  export declare function looksLikeAuthError(text: string): boolean;
@@ -190,6 +190,10 @@ export function runAgentResultIndicatesContextOverflow(result) {
190
190
  return /^Autocompact is thrashing:\s*the context refilled to the limit/i.test(text)
191
191
  || /^rapid_refill_breaker\b/i.test(text);
192
192
  }
193
+ export const SILENT_GATEWAY_RESPONSE = '__clementine_silent_response__';
194
+ export function isSilentGatewayResponse(text) {
195
+ return text === SILENT_GATEWAY_RESPONSE;
196
+ }
193
197
  export function classifyChatError(err) {
194
198
  const msg = String(err);
195
199
  if (isCreditBalanceError(msg))
@@ -1671,7 +1675,9 @@ export class Gateway {
1671
1675
  let aborted = false;
1672
1676
  const ac = s?.abortController;
1673
1677
  if (ac && !ac.signal.aborted) {
1674
- ac.abort();
1678
+ if (s)
1679
+ s.suppressAbortResponseFor = ac.signal;
1680
+ ac.abort('user-stop');
1675
1681
  aborted = true;
1676
1682
  }
1677
1683
  if (s?.teamTaskControllers?.size) {
@@ -1700,6 +1706,7 @@ export class Gateway {
1700
1706
  if (s.abortController && !s.abortController.signal.aborted) {
1701
1707
  const partial = s.lastStreamedText ?? '';
1702
1708
  s.pendingInterrupt = { partial, interruptedAt: Date.now() };
1709
+ s.suppressAbortResponseFor = s.abortController.signal;
1703
1710
  logger.info({ sessionKey, partialLen: partial.length }, 'New message arrived — interrupting in-flight query');
1704
1711
  // Pass a reason string so assistant.ts can distinguish this from a
1705
1712
  // timeout abort and show the right final message.
@@ -1997,6 +2004,7 @@ export class Gateway {
1997
2004
  if (recentContext.responseText) {
1998
2005
  const current = this.sessions.get(sessionKey);
1999
2006
  if (current?.abortController && !current.abortController.signal.aborted) {
2007
+ current.suppressAbortResponseFor = current.abortController.signal;
2000
2008
  current.abortController.abort('replaced-by-recent-context');
2001
2009
  logger.info({ sessionKey }, 'Interrupted active chat for recent operational context response');
2002
2010
  }
@@ -2857,13 +2865,18 @@ export class Gateway {
2857
2865
  clearTimeout(chatTimer);
2858
2866
  if (hardWallTimer)
2859
2867
  clearTimeout(hardWallTimer);
2860
- {
2861
- const cs = this.sessions.get(sessionKey);
2862
- if (cs)
2863
- delete cs.abortController;
2868
+ const cs = this.sessions.get(sessionKey);
2869
+ const suppressAbortResponse = cs?.suppressAbortResponseFor === chatAc.signal
2870
+ || chatAc.signal.reason === 'interrupted-by-new-message'
2871
+ || chatAc.signal.reason === 'replaced-by-recent-context'
2872
+ || chatAc.signal.reason === 'user-stop';
2873
+ if (cs) {
2874
+ delete cs.abortController;
2875
+ if (cs.suppressAbortResponseFor === chatAc.signal)
2876
+ delete cs.suppressAbortResponseFor;
2864
2877
  }
2865
2878
  if (chatAc.signal.aborted) {
2866
- return "Stopped. What would you like to do instead?";
2879
+ return suppressAbortResponse ? SILENT_GATEWAY_RESPONSE : "Stopped. What would you like to do instead?";
2867
2880
  }
2868
2881
  const errKind = classifyChatError(err);
2869
2882
  logger.error({ err, sessionKey, errKind }, `Chat error (${errKind}) from ${sessionKey}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.200",
3
+ "version": "1.18.201",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",