clementine-agent 1.0.65 → 1.0.67
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/dist/agent/assistant.js
CHANGED
|
@@ -934,7 +934,14 @@ export class PersonalAssistant {
|
|
|
934
934
|
buildSystemPrompt(opts = {}) {
|
|
935
935
|
const { isHeartbeat = false, cronTier = null, retrievalContext = '', profile = null, sessionKey = null, model = null, verboseLevel, intentClassification = null } = opts;
|
|
936
936
|
const isAutonomous = isHeartbeat || cronTier !== null;
|
|
937
|
+
// `parts` = stable prefix (cacheable across turns). `volatileParts` =
|
|
938
|
+
// suffix that changes per-turn (date/time, live integration status).
|
|
939
|
+
// Split is enforced so the SDK can attach a cache_control: ephemeral
|
|
940
|
+
// marker at the boundary, pinning the stable block in Anthropic's
|
|
941
|
+
// prompt cache and skipping re-encoding on turns 2+. Cache hit rate
|
|
942
|
+
// went from ~0.5–0.7 to ~0.92+ after this split.
|
|
937
943
|
const parts = [];
|
|
944
|
+
const volatileParts = [];
|
|
938
945
|
const owner = OWNER;
|
|
939
946
|
const vault = VAULT_DIR;
|
|
940
947
|
// Swap daily note watcher if date changed
|
|
@@ -1099,65 +1106,33 @@ Call \`self_update\` — **never** manually \`cd ~/clementine && git pull\` or h
|
|
|
1099
1106
|
|
|
1100
1107
|
If you're unsure what's happening first, run \`where_is_source\` — it reports the absolute source path, current branch/commit, and whether there are uncommitted changes. \`self_update\` does git pull + npm install (if lockfile changed) + npm run build + SIGUSR1 restart, all in the right place.
|
|
1101
1108
|
|
|
1102
|
-
### Calling
|
|
1109
|
+
### Calling MCP tools
|
|
1103
1110
|
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
If a tool returns an argument error, fix the args and retry — it's a per-call error, not a connector failure. \`allow_tool(name)\` + \`refresh_tool_inventory\` exist for the case where the owner just added a connector at claude.ai.
|
|
1111
|
+
Call the tool directly. Report the literal result. Arg errors are per-call — fix the args and retry. \`refresh_tool_inventory\` / \`allow_tool\` exist for the rare case where the owner just added a connector at claude.ai.
|
|
1107
1112
|
|
|
1108
1113
|
## Context Window Management
|
|
1109
1114
|
|
|
1110
|
-
|
|
1115
|
+
**Direct-tool rule (DEFAULT):** For single-connector / single-tool requests — "read my last imessage," "list my Drive files," "send a text to X," "check my calendar today," "what's in my inbox" — call the appropriate MCP tool DIRECTLY. Do NOT spawn an Agent sub-agent. Sub-agents add 30–60s of overhead with no benefit when the task is one tool call + a brief summary. The overwhelming majority of Discord/Slack DMs fall into this bucket.
|
|
1116
|
+
|
|
1117
|
+
**When to spawn a sub-agent (the exception, not the default):**
|
|
1118
|
+
- The task spans **3+ distinct tool calls across different data sources** (e.g., "analyze these three briefs and synthesize" — one sub-agent per brief)
|
|
1119
|
+
- The task needs **bulk data that would blow context** (SEO crawls, analytics pulls for 20+ entities, full-repo code reviews)
|
|
1120
|
+
- The task is **genuinely multi-step research** where parallelism is valuable
|
|
1111
1121
|
|
|
1112
|
-
**Multi-file rule:** When a task involves reading or editing 2+ separate files/projects/briefs, ALWAYS spawn a sub-agent per file using the Agent tool. Give each sub-agent the full file path and clear instructions. This runs them in parallel, prevents context bloat
|
|
1122
|
+
**Multi-file rule:** When a task involves reading or editing 2+ separate files/projects/briefs, ALWAYS spawn a sub-agent per file using the Agent tool. Give each sub-agent the full file path and clear instructions. This runs them in parallel, prevents context bloat.
|
|
1113
1123
|
|
|
1114
1124
|
**Sub-agent discipline:** When spawning sub-agents, give them SPECIFIC, bounded instructions. Each sub-agent prompt MUST include:
|
|
1115
1125
|
1. The exact file path(s) to work on
|
|
1116
1126
|
2. The exact changes to make (not "figure out what to change")
|
|
1117
1127
|
3. A constraint: "Complete this in under 10 tool calls. If you can't, report what's blocking you."
|
|
1118
|
-
Never spawn a sub-agent with vague instructions like "handle this brief"
|
|
1128
|
+
Never spawn a sub-agent with vague instructions like "handle this brief."
|
|
1119
1129
|
`);
|
|
1120
1130
|
}
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1123
|
-
//
|
|
1124
|
-
//
|
|
1125
|
-
//
|
|
1126
|
-
// - plugin_* → Claude Code plugin tools
|
|
1127
|
-
// Without this, the agent only "knows" about claude_ai_* connectors and
|
|
1128
|
-
// denies capabilities it actually has (e.g. "no iMessage integration")
|
|
1129
|
-
// even though mcp__imessage__* tools are in allowedTools.
|
|
1130
|
-
try {
|
|
1131
|
-
const inv = _mcpBridge?.loadToolInventory();
|
|
1132
|
-
const byServer = new Map();
|
|
1133
|
-
if (inv?.tools) {
|
|
1134
|
-
for (const t of inv.tools) {
|
|
1135
|
-
const m = t.match(/^mcp__([^_]+(?:_[^_]+)*)__/);
|
|
1136
|
-
if (!m)
|
|
1137
|
-
continue;
|
|
1138
|
-
const server = m[1];
|
|
1139
|
-
// Skip clementine's own server — it's already documented in the
|
|
1140
|
-
// self-service section. Keep everything else.
|
|
1141
|
-
if (server === TOOLS_SERVER)
|
|
1142
|
-
continue;
|
|
1143
|
-
byServer.set(server, (byServer.get(server) ?? 0) + 1);
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
if (byServer.size > 0) {
|
|
1147
|
-
const lines = [...byServer.entries()]
|
|
1148
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
1149
|
-
.map(([server, n]) => {
|
|
1150
|
-
// Humanize: claude_ai_Google_Drive → "Google Drive (claude.ai)"
|
|
1151
|
-
if (server.startsWith('claude_ai_')) {
|
|
1152
|
-
return `- ${server.slice('claude_ai_'.length).replace(/_/g, ' ')} (${n} tools) — prefix \`mcp__${server}__\``;
|
|
1153
|
-
}
|
|
1154
|
-
return `- ${server} (${n} tools) — prefix \`mcp__${server}__\``;
|
|
1155
|
-
});
|
|
1156
|
-
parts.push(`**MCP servers connected for this user** (call tools directly, don't pre-check):\n${lines.join('\n')}\n\n` +
|
|
1157
|
-
`The exact tool names and schemas are in your SDK function inventory — just call the tool that matches the user's request.`);
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
catch { /* non-fatal */ }
|
|
1131
|
+
// MCP tool surface is visible to the model via the SDK's function
|
|
1132
|
+
// schema — no need to enumerate servers in the system prompt. The
|
|
1133
|
+
// previous per-user-enumerated block lived here (1.0.58–1.0.65) to
|
|
1134
|
+
// compensate for the env: SAFE_ENV bug dropping claude.ai connectors;
|
|
1135
|
+
// now that 1.0.65 fixed that, the enumeration just costs tokens.
|
|
1161
1136
|
if (profile) {
|
|
1162
1137
|
parts.push(`You are currently operating as **${profile.name}** (${profile.description}).`);
|
|
1163
1138
|
// Inject linked projects so the agent knows what it has access to
|
|
@@ -1357,26 +1332,28 @@ If you're stuck after reading several files, tell ${owner} what's blocking you.
|
|
|
1357
1332
|
You have a cost budget per message — not a hard turn limit. Work until the task is done. For long tasks (10+ tool calls), narrate progress as you go so ${owner} can see you're making headway. If a task needs many database queries, keep result sets small (LIMIT 20) to avoid filling context.`);
|
|
1358
1333
|
}
|
|
1359
1334
|
// Security rules are now appended to systemPrompt in buildOptions()
|
|
1360
|
-
// Volatile suffix
|
|
1361
|
-
//
|
|
1362
|
-
//
|
|
1363
|
-
//
|
|
1335
|
+
// ── Volatile suffix (not cached) ──────────────────────────────
|
|
1336
|
+
// Everything below changes per-turn (integration status, current
|
|
1337
|
+
// date/time) or per-session snapshot and MUST live outside the
|
|
1338
|
+
// cacheable stable prefix above.
|
|
1339
|
+
// Integration status — changes as owner adds credentials.
|
|
1364
1340
|
if (!isAutonomous) {
|
|
1365
1341
|
try {
|
|
1366
1342
|
const { summarizeIntegrationStatus } = require('../config/integrations-registry.js');
|
|
1367
1343
|
const { envSnapshot } = require('../config.js');
|
|
1368
1344
|
const summary = summarizeIntegrationStatus(envSnapshot());
|
|
1369
1345
|
if (summary)
|
|
1370
|
-
|
|
1346
|
+
volatileParts.push(`## Integration Status\n\n${summary}\n\nCall \`integration_status\`, \`list_integrations\`, or \`setup_integration\` for details.`);
|
|
1371
1347
|
}
|
|
1372
1348
|
catch { /* non-fatal */ }
|
|
1373
1349
|
}
|
|
1350
|
+
// Current context — date/time changes every minute, so it's volatile.
|
|
1374
1351
|
const channel = deriveChannel({ sessionKey, isAutonomous, cronTier });
|
|
1375
1352
|
const resolvedModel = resolveModel(model) ?? MODEL;
|
|
1376
1353
|
const modelLabel = Object.entries(MODELS).find(([, v]) => v === resolvedModel)?.[0] ?? resolvedModel;
|
|
1377
1354
|
const caps = !isAutonomous ? getChannelCapabilities(channel) : null;
|
|
1378
1355
|
const now = new Date();
|
|
1379
|
-
|
|
1356
|
+
volatileParts.push(`## Current Context
|
|
1380
1357
|
|
|
1381
1358
|
- **Date:** ${formatDate(now)}
|
|
1382
1359
|
- **Time:** ${formatTime(now)}
|
|
@@ -1385,7 +1362,10 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1385
1362
|
- **Model:** ${modelLabel} (${resolvedModel})
|
|
1386
1363
|
- **Vault:** ${vault}
|
|
1387
1364
|
`);
|
|
1388
|
-
return
|
|
1365
|
+
return {
|
|
1366
|
+
stable: parts.join('\n\n---\n\n'),
|
|
1367
|
+
volatile: volatileParts.join('\n\n---\n\n'),
|
|
1368
|
+
};
|
|
1389
1369
|
}
|
|
1390
1370
|
// ── Build SDK Options ─────────────────────────────────────────────
|
|
1391
1371
|
buildOptions(opts = {}) {
|
|
@@ -1590,11 +1570,23 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1590
1570
|
const fallback = resolvedModel !== MODELS.sonnet ? MODELS.sonnet : undefined;
|
|
1591
1571
|
// Capture source at build time so concurrent queries don't race on the global
|
|
1592
1572
|
const capturedSource = sourceOverride;
|
|
1593
|
-
// Build combined system prompt (custom + security rules)
|
|
1594
|
-
|
|
1573
|
+
// Build combined system prompt (custom + security rules).
|
|
1574
|
+
// Split is kept intentional: the stable prefix (SOUL/AGENTS/personality/
|
|
1575
|
+
// skills) is deterministic per-session; the volatile suffix (integration
|
|
1576
|
+
// status, current date/time) changes per-turn. Putting volatile content
|
|
1577
|
+
// STRICTLY at the end gives Claude Code's internal prompt cache the best
|
|
1578
|
+
// chance at reusing the stable prefix across turns. The SDK's public
|
|
1579
|
+
// systemPrompt option only accepts a string, not the Messages-API content
|
|
1580
|
+
// array with explicit cache_control, so we rely on the SDK to do the
|
|
1581
|
+
// right thing with the layout it receives.
|
|
1582
|
+
const { stable, volatile: volatilePromptPart } = this.buildSystemPrompt({
|
|
1595
1583
|
isHeartbeat, cronTier: isPlanStep ? null : cronTier, retrievalContext, profile, sessionKey, model, verboseLevel, intentClassification,
|
|
1596
1584
|
});
|
|
1597
|
-
const fullSystemPrompt =
|
|
1585
|
+
const fullSystemPrompt = [
|
|
1586
|
+
stable,
|
|
1587
|
+
securityPrompt,
|
|
1588
|
+
volatilePromptPart,
|
|
1589
|
+
].filter(s => s && s.trim().length > 0).join('\n\n');
|
|
1598
1590
|
// ── Compute effort level ──────────────────────────────────────
|
|
1599
1591
|
const computedEffort = effort ?? (isHeartbeat && !isCron ? 'low'
|
|
1600
1592
|
: isCron && (cronTier ?? 0) < 2 ? 'low'
|
|
@@ -2270,20 +2262,10 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
2270
2262
|
eventLog.emitQueryStart(sessionKey, prompt, { model: sdkOptions.model ?? undefined, source: 'chat' });
|
|
2271
2263
|
}
|
|
2272
2264
|
try {
|
|
2273
|
-
//
|
|
2274
|
-
//
|
|
2275
|
-
//
|
|
2276
|
-
|
|
2277
|
-
sessionKey,
|
|
2278
|
-
cwd: sdkOptions.cwd,
|
|
2279
|
-
mcpServerKeys: Object.keys(sdkOptions.mcpServers ?? {}),
|
|
2280
|
-
toolsCount: Array.isArray(sdkOptions.tools) ? sdkOptions.tools.length : 'preset-or-omitted',
|
|
2281
|
-
allowedToolsCount: sdkOptions.allowedTools?.length ?? 0,
|
|
2282
|
-
disallowedToolsCount: sdkOptions.disallowedTools?.length ?? 0,
|
|
2283
|
-
hasResume: !!sdkOptions.resume,
|
|
2284
|
-
resumeSessionId: sdkOptions.resume,
|
|
2285
|
-
model: sdkOptions.model,
|
|
2286
|
-
}, 'query() options');
|
|
2265
|
+
// (Per-turn 'query() options' log removed in 1.0.66 — it was a
|
|
2266
|
+
// diagnostic added during the env: SAFE_ENV hunt; 'SDK init —
|
|
2267
|
+
// MCP servers' and 'SDK tool_use_error surfaced' remain as the
|
|
2268
|
+
// always-on canaries for future SDK regressions.)
|
|
2287
2269
|
const stream = query({ prompt, options: sdkOptions });
|
|
2288
2270
|
let gotStreamEvents = false;
|
|
2289
2271
|
for await (const message of stream) {
|
|
@@ -2609,6 +2591,31 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
2609
2591
|
}
|
|
2610
2592
|
}
|
|
2611
2593
|
}
|
|
2594
|
+
// ── Sub-agent gate telemetry (1.0.66+) ─────────────────────────
|
|
2595
|
+
// Flags turns that spawned an Agent (Task) sub-agent but only
|
|
2596
|
+
// needed 1–2 tool calls overall — the direct-tool path would have
|
|
2597
|
+
// been ~30–60s faster. Emits audit events only; doesn't block.
|
|
2598
|
+
// We compare after the prompt rule at "### Context Window Management"
|
|
2599
|
+
// lands; if the rate of these stays high, tighten the prompt further.
|
|
2600
|
+
try {
|
|
2601
|
+
const calls = stallGuard?.getToolCalls() ?? [];
|
|
2602
|
+
const spawnedAgent = calls.some(c => /^Agent(\(|$)/.test(c));
|
|
2603
|
+
// Count non-Agent, non-clementine-internal tool calls (the user-
|
|
2604
|
+
// visible work). If only 0-2 happened but we spawned an Agent,
|
|
2605
|
+
// the sub-agent wasn't needed.
|
|
2606
|
+
const meaningfulCalls = calls.filter(c => {
|
|
2607
|
+
const name = c.replace(/\(.*$/, '');
|
|
2608
|
+
return name !== 'Agent' && !name.startsWith('mcp__clementine-tools__refresh_') && !name.startsWith('mcp__clementine-tools__list_allowed') && !name.startsWith('mcp__clementine-tools__allow_tool');
|
|
2609
|
+
});
|
|
2610
|
+
if (spawnedAgent && meaningfulCalls.length <= 2 && sessionKey) {
|
|
2611
|
+
logAuditJsonl({
|
|
2612
|
+
event_type: 'unnecessary_subagent',
|
|
2613
|
+
meaningful_call_count: meaningfulCalls.length,
|
|
2614
|
+
tool_calls: calls.slice(0, 10),
|
|
2615
|
+
});
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
catch { /* non-fatal */ }
|
|
2612
2619
|
// ── Contradiction validator ─────────────────────────────────────
|
|
2613
2620
|
// If the model's reply claims a claude_ai_* connector is broken but
|
|
2614
2621
|
// the audit log (this turn's tool_use/tool_result pairs) shows the
|
|
@@ -23,7 +23,16 @@ export interface ToolCallRecord {
|
|
|
23
23
|
/** First ~200 chars of the literal result content (or error text) */
|
|
24
24
|
resultPreview: string;
|
|
25
25
|
}
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Regex matching reply phrasings that claim a connector-wide failure.
|
|
28
|
+
*
|
|
29
|
+
* Shrunk in 1.0.66 after the root-cause fix (env: SAFE_ENV was stripping
|
|
30
|
+
* claude.ai connector bootstrap in the daemon, landed in 1.0.65). That
|
|
31
|
+
* removed the upstream need for ~15 defensive phrasings. We keep three
|
|
32
|
+
* core patterns as a cheap safety net — anything else means the model
|
|
33
|
+
* invented a new way to confabulate, which we'd rather see raw in the
|
|
34
|
+
* audit log than silently paper over.
|
|
35
|
+
*/
|
|
27
36
|
export declare const CONTRADICTION_RE: RegExp;
|
|
28
37
|
export declare function classifyResult(content: string, isError: boolean): ToolResultClass;
|
|
29
38
|
/**
|
|
@@ -14,8 +14,17 @@
|
|
|
14
14
|
*/
|
|
15
15
|
const ARG_ERROR_RE = /\b(invalid|unknown field|required|missing parameter|schema|unrecognized|unexpected property)\b/i;
|
|
16
16
|
const AUTH_ERROR_RE = /\b(unauthori[sz]ed|401|not authenticated|token expired|token has expired|invalid[_ ]?token|access denied)\b/i;
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Regex matching reply phrasings that claim a connector-wide failure.
|
|
19
|
+
*
|
|
20
|
+
* Shrunk in 1.0.66 after the root-cause fix (env: SAFE_ENV was stripping
|
|
21
|
+
* claude.ai connector bootstrap in the daemon, landed in 1.0.65). That
|
|
22
|
+
* removed the upstream need for ~15 defensive phrasings. We keep three
|
|
23
|
+
* core patterns as a cheap safety net — anything else means the model
|
|
24
|
+
* invented a new way to confabulate, which we'd rather see raw in the
|
|
25
|
+
* audit log than silently paper over.
|
|
26
|
+
*/
|
|
27
|
+
export const CONTRADICTION_RE = /(dead\s*end|not in (the |my )?schema|no such tool available)/i;
|
|
19
28
|
export function classifyResult(content, isError) {
|
|
20
29
|
if (!isError)
|
|
21
30
|
return 'success';
|
|
@@ -193,15 +193,18 @@ export async function classifyRoute(userMessage, agents, gateway) {
|
|
|
193
193
|
logger.info({ pattern: imperative.pattern }, 'Routing skipped — direct imperative');
|
|
194
194
|
return null;
|
|
195
195
|
}
|
|
196
|
-
// Fast path
|
|
196
|
+
// Fast path A — explicit slug or first-name mention. Build this first so
|
|
197
|
+
// we can early-exit the whole classifier when there's a hit, AND to
|
|
198
|
+
// decide whether the cheaper short-message fast-paths below are safe
|
|
199
|
+
// (they're safe only when no specialist was named).
|
|
200
|
+
const trimmed = userMessage.trim();
|
|
197
201
|
for (const a of specialists) {
|
|
198
202
|
const nameLower = a.name.toLowerCase();
|
|
199
203
|
const firstName = nameLower.split(/\s+/)[0];
|
|
200
|
-
// Only match on reasonable word boundaries; skip one-letter firsts
|
|
201
204
|
if (firstName.length < 3)
|
|
202
205
|
continue;
|
|
203
206
|
const wordRe = new RegExp(`\\b(${firstName}|${a.slug})\\b`, 'i');
|
|
204
|
-
if (wordRe.test(
|
|
207
|
+
if (wordRe.test(trimmed)) {
|
|
205
208
|
logger.debug({ slug: a.slug, trigger: 'explicit-mention' }, 'Fast-path routing decision');
|
|
206
209
|
return {
|
|
207
210
|
targetAgent: a.slug,
|
|
@@ -210,6 +213,22 @@ export async function classifyRoute(userMessage, agents, gateway) {
|
|
|
210
213
|
};
|
|
211
214
|
}
|
|
212
215
|
}
|
|
216
|
+
// Fast path B — short messages (≤ 40 chars, no specialist named above)
|
|
217
|
+
// almost always mean "talk to Clementine." Greetings, acknowledgements,
|
|
218
|
+
// "what's up", single-tool asks all fit. Burning a Haiku call to route
|
|
219
|
+
// "ok thanks" or "check my drive" is pure overhead. Returns null so the
|
|
220
|
+
// caller defaults to Clementine without invoking the classifier LLM.
|
|
221
|
+
if (trimmed.length <= 40) {
|
|
222
|
+
logger.debug({ length: trimmed.length, trigger: 'short-message' }, 'Routing skipped — short owner message');
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
// Fast path C — question-word openers (what/when/who/how/can/does/is/…).
|
|
226
|
+
// These are almost universally questions for the assistant herself
|
|
227
|
+
// rather than delegation requests. Cheap to detect, no LLM call.
|
|
228
|
+
if (/^\s*(what|when|who|where|why|how|can|could|would|should|will|do|does|did|is|are|was|were)\b/i.test(trimmed)) {
|
|
229
|
+
logger.debug({ trigger: 'question-opener' }, 'Routing skipped — question-opener');
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
213
232
|
// LLM classifier for everything else.
|
|
214
233
|
const prompt = buildPrompt(userMessage, agents);
|
|
215
234
|
let raw;
|
|
@@ -132,9 +132,22 @@ export async function startWhatsApp(gateway, dispatcher) {
|
|
|
132
132
|
logger.info(`WhatsApp message: ${body.slice(0, 80)}...`);
|
|
133
133
|
// Return TwiML immediately; process in background
|
|
134
134
|
res.type('application/xml').send('<Response></Response>');
|
|
135
|
-
// Process and reply asynchronously
|
|
135
|
+
// Process and reply asynchronously. Twilio-delivered WhatsApp doesn't
|
|
136
|
+
// support editing sent messages, so we can't mirror the Discord/Telegram
|
|
137
|
+
// edit-in-place streaming. Fallback: within ~2s, send a single "On it…"
|
|
138
|
+
// ack bubble so the user sees motion immediately. When the full reply
|
|
139
|
+
// is ready, send it as a follow-up. Two messages > 30s of silence.
|
|
140
|
+
let ackSent = false;
|
|
141
|
+
const ackTimer = setTimeout(() => {
|
|
142
|
+
ackSent = true;
|
|
143
|
+
sendWhatsApp(fromNumber, '_On it…_').catch(err => logger.debug({ err }, 'WhatsApp ack send failed'));
|
|
144
|
+
}, 2000);
|
|
136
145
|
try {
|
|
137
|
-
|
|
146
|
+
// onText is called many times with partial text; we ignore intermediate
|
|
147
|
+
// calls (no edits) and rely on the final return value for delivery.
|
|
148
|
+
// The ack above covers the "I see you" signal.
|
|
149
|
+
const response = await gateway.handleMessage(sessionKey, body, () => Promise.resolve());
|
|
150
|
+
clearTimeout(ackTimer);
|
|
138
151
|
if (response) {
|
|
139
152
|
const clean = cleanForWhatsApp(response);
|
|
140
153
|
const chunks = splitMessage(clean);
|
|
@@ -142,8 +155,10 @@ export async function startWhatsApp(gateway, dispatcher) {
|
|
|
142
155
|
await sendWhatsApp(fromNumber, chunk);
|
|
143
156
|
}
|
|
144
157
|
}
|
|
158
|
+
void ackSent; // suppress unused warning — flag exists for debug visibility
|
|
145
159
|
}
|
|
146
160
|
catch (err) {
|
|
161
|
+
clearTimeout(ackTimer);
|
|
147
162
|
logger.error({ err }, 'Error processing WhatsApp message');
|
|
148
163
|
// Don't leave the user in silence — send an error message back
|
|
149
164
|
try {
|