@sayknow-cli/agent-core 0.3.0 → 0.3.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.4] - 2026-06-27
6
+
7
+ ### Added
8
+
9
+ - Added `pruneAssistantToolArguments`: an isolated pre-compaction pruning pass that redacts stale `edit`/`write`/`apply_patch`/`ast_edit` tool-call argument payloads only when every touched path group has a later successful mutation, preserving tool-call identity (id/name/customWireName/signatures/intent/path hints), protecting latest/failed/ambiguous calls, and reporting separate stats from tool-result pruning. Reduces pre-compaction context pressure and the resident footprint of superseded large edit arguments.
10
+
11
+ ## [0.7.3] - 2026-06-25
12
+ ### Added
13
+
14
+ - Added Composer evidence publication gates in the agent loop, so Composer-harness turns emit structured evidence under defined publication conditions (#1106).
15
+
16
+ ### Fixed
17
+
18
+ - Wired the previously-dead GPT-5 harmony-leak detector into the streamed assistant-message path for openai-codex turns: recoverable tool-argument leaks are now recovered and everything else is routed through the existing abort-retry/audit loop, and the contaminated streamed message is removed (abort-retry) or replaced (truncate-resume) from working context so the model does not replay its own leak as history. Added detection of the leaked Anthropic-style `<invoke name="…">` envelope dialect that gpt-5.5 intermittently emits as visible assistant text instead of a native function call (#1069).
19
+ - Detect proxy-level context overflow from empty responses: some proxies (notably LiteLLM) return an empty `content: []` with `stopReason: "stop"` and fabricated near-zero usage when the upstream context window is exceeded; the agent loop now recognizes this pattern and promotes it to an error so the existing overflow/compaction recovery path fires instead of freezing the session as a clean completion (#1102).
20
+ - Hardened the Composer trace mutation classifier and its recovery-target guard (#1105).
21
+
22
+ ## [0.7.2] - 2026-06-24
23
+
24
+ ### Fixed
25
+
26
+ - Reserved headroom when trimming OpenAI remote compaction input so `/responses/compact` requests stay below the model context window instead of filling the entire window.
27
+
5
28
  ## [0.6.2] - 2026-06-19
6
29
 
7
30
  ### Changed
@@ -43,6 +43,7 @@ export declare function getPreservedOpenAiRemoteCompactionData(preserveData: Rec
43
43
  export declare function withOpenAiRemoteCompactionPreserveData(preserveData: Record<string, unknown> | undefined, remoteCompaction: OpenAiRemoteCompactionPreserveData | undefined): Record<string, unknown> | undefined;
44
44
  export declare function estimateOpenAiCompactInputTokens(input: Array<Record<string, unknown>>, instructions: string): number;
45
45
  export declare function trimOpenAiCompactInput(input: Array<Record<string, unknown>>, contextWindow: number, instructions: string): Array<Record<string, unknown>>;
46
+ export declare function resolveOpenAiCompactInputBudget(contextWindow: number, maxOutputTokens?: number): number;
46
47
  /**
47
48
  * Build the OpenAI Responses-API native history array from LLM messages.
48
49
  *
@@ -34,4 +34,14 @@ export interface PruneResult {
34
34
  */
35
35
  prunedEntries: SessionMessageEntry[];
36
36
  }
37
+ export interface AssistantArgumentPruneResult {
38
+ argumentPrunedCount: number;
39
+ argumentTokensSaved: number;
40
+ /**
41
+ * The mutated assistant message entries. Callers whose entry source returns
42
+ * materialized copies must write these back into their canonical store by id.
43
+ */
44
+ prunedEntries: SessionMessageEntry[];
45
+ }
46
+ export declare function pruneAssistantToolArguments(entries: SessionEntry[], config?: PruneConfig): AssistantArgumentPruneResult;
37
47
  export declare function pruneToolOutputs(entries: SessionEntry[], config?: PruneConfig): PruneResult;
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import type { AssistantMessage, Model } from "@sayknow-cli/ai";
11
11
  declare const SIGNAL_ORDER: readonly ["M", "C", "G", "S", "B", "R", "T"];
12
- export type HarmonySignalClass = "H" | (typeof SIGNAL_ORDER)[number];
12
+ export type HarmonySignalClass = "H" | "I" | (typeof SIGNAL_ORDER)[number];
13
13
  export type HarmonySurface = "assistant_text" | "assistant_thinking" | "tool_arg";
14
14
  export interface HarmonySignal {
15
15
  classes: HarmonySignalClass[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sayknow-cli/agent-core",
4
- "version": "0.3.0",
4
+ "version": "0.3.2",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://github.com/jaybeyond/Sayknow_CLI",
7
7
  "author": "jaybeyond",
@@ -35,9 +35,9 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@sayknow-cli/ai": "0.3.0",
39
- "@sayknow-cli/natives": "0.3.0",
40
- "@sayknow-cli/utils": "0.3.0",
38
+ "@sayknow-cli/ai": "0.3.2",
39
+ "@sayknow-cli/natives": "0.3.2",
40
+ "@sayknow-cli/utils": "0.3.2",
41
41
  "@opentelemetry/api": "^1.9.0"
42
42
  },
43
43
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  type AssistantMessageEvent,
8
8
  type Context,
9
9
  EventStream,
10
+ isContextOverflow,
10
11
  isZodSchema,
11
12
  streamSimple,
12
13
  type ToolResultMessage,
@@ -17,9 +18,12 @@ import {
17
18
  import { sanitizeText } from "@sayknow-cli/utils";
18
19
  import {
19
20
  createHarmonyAuditEvent,
21
+ detectHarmonyLeakInAssistantMessage,
22
+ extractHarmonyRemoved,
20
23
  type HarmonyDetection,
21
24
  type HarmonyRecoveredToolCall,
22
25
  isHarmonyLeakMitigationTarget,
26
+ recoverHarmonyToolCall,
23
27
  signalListLabel,
24
28
  } from "./harmony-leak";
25
29
  import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
@@ -51,6 +55,15 @@ import type {
51
55
 
52
56
  /** Sentinel returned by the abort race in `streamAssistantResponse`. */
53
57
  const ABORTED: unique symbol = Symbol("agent-loop-aborted");
58
+ /**
59
+ * Detect empty "successful" responses that indicate a proxy-level context
60
+ * overflow (e.g. LiteLLM returning `content: []`, `stopReason: "stop"`, and a
61
+ * fabricated near-zero usage). We delegate to {@link isContextOverflow} which
62
+ * has the threshold constant, so the detection logic stays in one place.
63
+ */
64
+ function isEmptyResponseOverflow(message: AssistantMessage): boolean {
65
+ return isContextOverflow(message);
66
+ }
54
67
 
55
68
  class HarmonyLeakInterruption extends Error {
56
69
  constructor(
@@ -502,6 +515,20 @@ async function runLoopBody(
502
515
  streamFn,
503
516
  harmonyRetryAttempt,
504
517
  );
518
+ // Post-stream harmony-leak detection. The mitigation scaffolding
519
+ // below (HarmonyLeakInterruption catch + retry/recover/audit,
520
+ // plus harmonyAbortController) existed but nothing invoked the
521
+ // detector, so leaks on openai-codex models were never caught.
522
+ // Detect on the completed message and route recoverable tool-arg
523
+ // leaks through recovery, everything else through abort-retry.
524
+ if (isHarmonyLeakMitigationTarget(config.model)) {
525
+ const detection = detectHarmonyLeakInAssistantMessage(message);
526
+ if (detection) {
527
+ const rec = recoverHarmonyToolCall(message, detection);
528
+ const removed = rec ? rec.removed : extractHarmonyRemoved(message, detection);
529
+ throw new HarmonyLeakInterruption(detection, removed, rec);
530
+ }
531
+ }
505
532
  harmonyRetryAttempt = 0;
506
533
  harmonyTruncateResumeCount = 0;
507
534
  } catch (err) {
@@ -516,6 +543,15 @@ async function runLoopBody(
516
543
  harmonyTruncateResumeCount++;
517
544
  recovered = err.recovered;
518
545
  message = recovered.message;
546
+ // Replace the contaminated assistant message committed during
547
+ // streaming with the recovered (truncated) one so the retry
548
+ // sees clean history.
549
+ {
550
+ const idx = currentContext.messages.length - 1;
551
+ if (idx >= 0 && currentContext.messages[idx]?.role === "assistant") {
552
+ currentContext.messages[idx] = recovered.message;
553
+ }
554
+ }
519
555
  await emitHarmonyAudit(config, err, "truncate_resume", harmonyRetryAttempt);
520
556
  } else {
521
557
  if (harmonyRetryAttempt >= 2) {
@@ -526,12 +562,34 @@ async function runLoopBody(
526
562
  }
527
563
  await emitHarmonyAudit(config, err, "abort_retry", harmonyRetryAttempt);
528
564
  harmonyRetryAttempt++;
565
+ // Drop the contaminated assistant message committed during
566
+ // streaming so the retry does not replay the model's own leak
567
+ // back to it as history.
568
+ {
569
+ const idx = currentContext.messages.length - 1;
570
+ if (idx >= 0 && currentContext.messages[idx]?.role === "assistant") {
571
+ currentContext.messages.splice(idx, 1);
572
+ }
573
+ }
529
574
  continue;
530
575
  }
531
576
  }
532
577
  newMessages.push(message);
533
578
  let steeringMessagesFromExecution: AgentMessage[] | undefined;
534
579
 
580
+ // Detect empty "successful" responses (stopReason "stop" + empty content).
581
+ // Some proxies (e.g. LiteLLM) return this when the upstream model's context
582
+ // window is exceeded, fabricating a near-zero usage instead of surfacing an
583
+ // error. Without this guard the agent loop treats the empty response as a
584
+ // natural turn completion and stops, leaving the user with a frozen session.
585
+ // Promote it to an error so the overflow/compaction recovery path can fire.
586
+ if (message.stopReason === "stop" && message.content.length === 0 && isEmptyResponseOverflow(message)) {
587
+ message.stopReason = "error";
588
+ message.errorMessage = message.errorMessage
589
+ ? `${message.errorMessage} | Provider returned an empty response with anomalously low token usage (possible context overflow via proxy)`
590
+ : "Provider returned an empty response with anomalously low token usage (possible context overflow via proxy)";
591
+ }
592
+
535
593
  if (message.stopReason === "error" || message.stopReason === "aborted") {
536
594
  // Create placeholder tool results for any tool calls in the aborted message
537
595
  // This maintains the tool_use/tool_result pairing that the API requires
@@ -1084,6 +1142,17 @@ async function executeToolCalls(
1084
1142
 
1085
1143
  await runInActiveSpan(toolSpan, async () => {
1086
1144
  try {
1145
+ if (toolCall.incompleteArguments) {
1146
+ // The provider flagged this call's argument JSON as truncated
1147
+ // (the model hit its output-token limit mid-call). Executing the
1148
+ // best-effort partial parse would run the tool on wrong input, so
1149
+ // reject with a retryable, actionable error instead.
1150
+ throw new Error(
1151
+ `Tool call "${toolCall.name}" was cut off before its arguments finished streaming ` +
1152
+ `(the response hit its output token limit). The partial arguments cannot be executed. ` +
1153
+ `Re-issue the call with complete arguments, splitting the work into smaller steps if needed.`,
1154
+ );
1155
+ }
1087
1156
  if (!tool) throw new Error(`Tool ${toolCall.name} not found`);
1088
1157
 
1089
1158
  let effectiveArgs: Record<string, unknown>;
@@ -237,6 +237,12 @@ export function trimOpenAiCompactInput(
237
237
  return trimmed;
238
238
  }
239
239
 
240
+ export function resolveOpenAiCompactInputBudget(contextWindow: number, maxOutputTokens = 0): number {
241
+ if (contextWindow <= 0) return 0;
242
+ const reservedTokens = Math.max(Math.floor(contextWindow * 0.15), maxOutputTokens, 1);
243
+ return Math.max(1, contextWindow - reservedTokens);
244
+ }
245
+
240
246
  function collectKnownOpenAiCallIds(items: Array<Record<string, unknown>>): Set<string> {
241
247
  const knownCallIds = new Set<string>();
242
248
  for (const item of items) {
@@ -473,7 +479,11 @@ export async function requestOpenAiRemoteCompaction(
473
479
  const endpoint = resolveOpenAiCompactEndpoint(model, options?.authCredentialType);
474
480
  const request: OpenAiRemoteCompactionRequest = {
475
481
  model: model.id,
476
- input: trimOpenAiCompactInput(compactInput, model.contextWindow, instructions),
482
+ input: trimOpenAiCompactInput(
483
+ compactInput,
484
+ resolveOpenAiCompactInputBudget(model.contextWindow, model.maxTokens),
485
+ instructions,
486
+ ),
477
487
  instructions,
478
488
  };
479
489
  const headers: Record<string, string> = {
@@ -126,6 +126,24 @@ function estimatePrunedSavings(tokens: number, notice: string): number {
126
126
  return Math.max(0, tokens - noticeTokens);
127
127
  }
128
128
 
129
+ export interface AssistantArgumentPruneResult {
130
+ argumentPrunedCount: number;
131
+ argumentTokensSaved: number;
132
+ /**
133
+ * The mutated assistant message entries. Callers whose entry source returns
134
+ * materialized copies must write these back into their canonical store by id.
135
+ */
136
+ prunedEntries: SessionMessageEntry[];
137
+ }
138
+
139
+ interface PrunedToolArgumentsSentinel {
140
+ pruned: true;
141
+ reason: "stale_tool_arguments";
142
+ pathHints: string[];
143
+ originalChars: number;
144
+ prunedAt: number;
145
+ }
146
+
129
147
  const EDIT_TOOL_NAMES = new Set(["edit", "write", "apply_patch", "ast_edit"]);
130
148
 
131
149
  /** Extract the file-path argument from a tool call, when the tool has one. */
@@ -170,6 +188,68 @@ function editToolPathGroups(call: ToolCall): string[][] {
170
188
  }
171
189
  return groups;
172
190
  }
191
+ function pathGroupKey(group: string[]): string {
192
+ return JSON.stringify([...group].sort());
193
+ }
194
+
195
+ function pathHintsForGroups(groups: string[][]): string[] {
196
+ return [...new Set(groups.flat())].sort();
197
+ }
198
+
199
+ function isPrunedToolArgumentsSentinel(value: unknown): value is PrunedToolArgumentsSentinel {
200
+ return (
201
+ typeof value === "object" &&
202
+ value !== null &&
203
+ (value as { pruned?: unknown; reason?: unknown }).pruned === true &&
204
+ (value as { pruned?: unknown; reason?: unknown }).reason === "stale_tool_arguments"
205
+ );
206
+ }
207
+
208
+ function isEditToolCall(call: ToolCall): boolean {
209
+ return EDIT_TOOL_NAMES.has(call.name) || call.customWireName === "apply_patch";
210
+ }
211
+
212
+ interface AssistantArgumentStalenessIndex {
213
+ latestSuccessfulMutationByPathGroup: Map<string, { index: number; callId: string }>;
214
+ failedCallIds: Set<string>;
215
+ }
216
+
217
+ function buildAssistantArgumentStalenessIndex(entries: SessionEntry[]): AssistantArgumentStalenessIndex {
218
+ const callsById = new Map<string, ToolCall>();
219
+ for (const entry of entries) {
220
+ if (entry.type !== "message") continue;
221
+ const message = entry.message as AgentMessage;
222
+ if (message.role !== "assistant") continue;
223
+ for (const content of message.content) {
224
+ if (content.type === "toolCall") callsById.set(content.id, content);
225
+ }
226
+ }
227
+
228
+ const latestSuccessfulMutationByPathGroup = new Map<string, { index: number; callId: string }>();
229
+ const failedCallIds = new Set<string>();
230
+ for (let i = 0; i < entries.length; i++) {
231
+ const message = getToolResultMessage(entries[i]);
232
+ if (!message) continue;
233
+ const call = callsById.get(message.toolCallId);
234
+ if (!call || !isEditToolCall(call)) continue;
235
+ const detailFiles = call.name === "ast_edit" ? resultDetailFiles(message) : [];
236
+ const groups = detailFiles.length > 0 ? detailFiles.map(file => [file]) : editToolPathGroups(call);
237
+ if (groups.length === 0) continue;
238
+ if (message.isError) {
239
+ failedCallIds.add(call.id);
240
+ continue;
241
+ }
242
+ const failed = failedEditPaths(message);
243
+ let mutated = false;
244
+ for (const group of groups) {
245
+ if (group.some(groupPath => failed.has(groupPath))) continue;
246
+ latestSuccessfulMutationByPathGroup.set(pathGroupKey(group), { index: i, callId: call.id });
247
+ mutated = true;
248
+ }
249
+ if (!mutated) failedCallIds.add(call.id);
250
+ }
251
+ return { latestSuccessfulMutationByPathGroup, failedCallIds };
252
+ }
173
253
 
174
254
  /**
175
255
  * Trailing read selectors (`:50`, `:50-200`, `:50+150`, `:5-16,960-973`,
@@ -361,6 +441,91 @@ function buildStalenessIndex(entries: SessionEntry[]): StalenessIndex {
361
441
 
362
442
  return { staleResultIndices };
363
443
  }
444
+ export function pruneAssistantToolArguments(
445
+ entries: SessionEntry[],
446
+ config: PruneConfig = DEFAULT_PRUNE_CONFIG,
447
+ ): AssistantArgumentPruneResult {
448
+ let accumulatedTokens = 0;
449
+ let argumentTokensSaved = 0;
450
+ const { latestSuccessfulMutationByPathGroup, failedCallIds } = buildAssistantArgumentStalenessIndex(entries);
451
+ const candidates: Array<{
452
+ entry: SessionMessageEntry;
453
+ call: ToolCall;
454
+ pathHints: string[];
455
+ originalChars: number;
456
+ savings: number;
457
+ }> = [];
458
+
459
+ for (let i = entries.length - 1; i >= 0; i--) {
460
+ const entry = entries[i];
461
+ if (entry.type !== "message") continue;
462
+ const message = entry.message as AgentMessage;
463
+ if (message.role !== "assistant") continue;
464
+ const entryTokens = estimateEntryTokens(entry);
465
+ const insideProtectWindow = accumulatedTokens < config.protectTokens;
466
+ accumulatedTokens += entryTokens;
467
+ for (const content of message.content) {
468
+ if (content.type !== "toolCall" || !isEditToolCall(content)) continue;
469
+ const argumentJson = JSON.stringify(content.arguments);
470
+ if (argumentJson === undefined) continue;
471
+ const originalChars = argumentJson.length;
472
+ if (isPrunedToolArgumentsSentinel(content.arguments)) continue;
473
+ if (insideProtectWindow || failedCallIds.has(content.id)) continue;
474
+ const groups = editToolPathGroups(content);
475
+ if (groups.length === 0) continue;
476
+ // Arguments are pruned as one indivisible payload, so require EVERY
477
+ // concrete path group to be stale from a later successful mutation.
478
+ // A group with no later success (failed/unknown/ambiguous) protects the
479
+ // whole call rather than dropping non-stale multi-file patch evidence.
480
+ const isStale =
481
+ groups.length > 0 &&
482
+ groups.every(group => {
483
+ const latest = latestSuccessfulMutationByPathGroup.get(pathGroupKey(group));
484
+ return latest !== undefined && latest.index > i && latest.callId !== content.id;
485
+ });
486
+ if (!isStale) continue;
487
+ const sentinelChars = JSON.stringify({
488
+ pruned: true,
489
+ reason: "stale_tool_arguments",
490
+ pathHints: pathHintsForGroups(groups),
491
+ originalChars,
492
+ prunedAt: 0,
493
+ } satisfies PrunedToolArgumentsSentinel).length;
494
+ candidates.push({
495
+ entry: entry as SessionMessageEntry,
496
+ call: content,
497
+ pathHints: pathHintsForGroups(groups),
498
+ originalChars,
499
+ savings: Math.max(0, Math.ceil((originalChars - sentinelChars) / 4)),
500
+ });
501
+ }
502
+ }
503
+
504
+ for (const candidate of candidates) {
505
+ argumentTokensSaved += candidate.savings;
506
+ }
507
+ if (argumentTokensSaved < config.minimumSavings || candidates.length === 0) {
508
+ return { argumentPrunedCount: 0, argumentTokensSaved: 0, prunedEntries: [] };
509
+ }
510
+
511
+ const prunedAt = Date.now();
512
+ const prunedEntries: SessionMessageEntry[] = [];
513
+ const prunedEntryIds = new Set<string>();
514
+ for (const candidate of candidates) {
515
+ candidate.call.arguments = {
516
+ pruned: true,
517
+ reason: "stale_tool_arguments",
518
+ pathHints: candidate.pathHints,
519
+ originalChars: candidate.originalChars,
520
+ prunedAt,
521
+ };
522
+ if (!prunedEntryIds.has(candidate.entry.id)) {
523
+ prunedEntries.push(candidate.entry);
524
+ prunedEntryIds.add(candidate.entry.id);
525
+ }
526
+ }
527
+ return { argumentPrunedCount: candidates.length, argumentTokensSaved, prunedEntries };
528
+ }
364
529
 
365
530
  export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig = DEFAULT_PRUNE_CONFIG): PruneResult {
366
531
  let accumulatedTokens = 0;
@@ -14,6 +14,19 @@ import type { AssistantMessage, Model, ToolCall } from "@sayknow-cli/ai";
14
14
  const MARKER_RE = /\bto=functions\.[A-Za-z_]\w*/g;
15
15
  const HARMONY_RE = /<\|(start|end|channel|message|call|return)\|>/g;
16
16
 
17
+ // Leaked tool-call envelope (`I`): a structurally-committed Anthropic-style
18
+ // invoke block (an opening tag carrying a name attribute). openai-codex models
19
+ // use native function calling, so such an envelope appearing as visible
20
+ // assistant text / thinking is always a leaked tool call — a different dialect
21
+ // of the §1 phenomenon where the tool-call intent collapses into the content
22
+ // channel (frequently prefixed by a glitch token such as a bare `court` line).
23
+ // High precision: requires the opening tag AND a committed body (a parameter
24
+ // tag or a closing invoke tag) within a short window, so prose that merely
25
+ // mentions the tag does not trip. Like `H`, it trips on its own (outside code
26
+ // fences). Source spelled with `\s` so this module does not self-trip.
27
+ const INVOKE_OPEN_RE = /<invoke\s+name="[^"]+"\s*>/g;
28
+ const INVOKE_BODY_RE = /<parameter\s+name="|<\/invoke>/;
29
+
17
30
  // Channel-word adjacency (`C`): channel/role name appearing immediately before the marker.
18
31
  const CHANNEL_WORD_RE = /\b(?:analysis|commentary|assistant|user|system|developer|tool)\s+to=functions\./;
19
32
 
@@ -66,7 +79,7 @@ const RECOVERY_REGISTRY: Record<string, RecoveryConfig> = {
66
79
 
67
80
  const SIGNAL_ORDER = ["M", "C", "G", "S", "B", "R", "T"] as const;
68
81
 
69
- export type HarmonySignalClass = "H" | (typeof SIGNAL_ORDER)[number];
82
+ export type HarmonySignalClass = "H" | "I" | (typeof SIGNAL_ORDER)[number];
70
83
 
71
84
  export type HarmonySurface = "assistant_text" | "assistant_thinking" | "tool_arg";
72
85
 
@@ -154,6 +167,17 @@ export function detectHarmonyLeak(
154
167
  signals.push(makeSignal(["H"], start, start + match[0].length, match[0]));
155
168
  }
156
169
 
170
+ for (const match of text.matchAll(INVOKE_OPEN_RE)) {
171
+ const start = match.index ?? 0;
172
+ if (isInsideFence(fences, start)) continue;
173
+ // Require a committed body nearby so a bare mention of the tag in prose
174
+ // does not trip; a real leaked envelope continues into parameters or a
175
+ // close tag.
176
+ const forward = text.slice(start, Math.min(text.length, start + 400));
177
+ if (!INVOKE_BODY_RE.test(forward)) continue;
178
+ signals.push(makeSignal(["I"], start, start + match[0].length, match[0]));
179
+ }
180
+
157
181
  for (const match of text.matchAll(MARKER_RE)) {
158
182
  const start = match.index ?? 0;
159
183
  if (isInsideFence(fences, start)) continue;
@@ -306,7 +330,7 @@ export function createHarmonyAuditEvent(params: {
306
330
  // ─── internals ──────────────────────────────────────────────────────────────
307
331
 
308
332
  function makeSignal(classes: HarmonySignalClass[], start: number, end: number, text: string): HarmonySignal {
309
- if (classes[0] === "H") return { classes: ["H"], start, end, text };
333
+ if (classes[0] === "H" || classes[0] === "I") return { classes: [classes[0]], start, end, text };
310
334
  const sorted: HarmonySignalClass[] = [];
311
335
  for (const cls of SIGNAL_ORDER) {
312
336
  if (classes.includes(cls)) sorted.push(cls);
@@ -402,7 +426,7 @@ function sha8(text: string): string {
402
426
 
403
427
  const PREVIEW_KEEP_RE = new RegExp(`[${SCRIPT_CLASS}\\s】【”“…」「、。]`, "u");
404
428
  const PREVIEW_TOKEN_RE =
405
- /^(?:to=functions\.[A-Za-z_]\w*|analysis|commentary|assistant|user|system|developer|tool|changedFiles|RTLU|Jsii(?:_commentary)?|\x4aapgolly)/;
429
+ /^(?:to=functions\.[A-Za-z_]\w*|<\/?invoke\b[^>]*>|<parameter\b[^>]*>|analysis|commentary|assistant|user|system|developer|tool|changedFiles|RTLU|Jsii(?:_commentary)?|\x4aapgolly)/;
406
430
 
407
431
  /**
408
432
  * Privacy-safe preview for the audit log: keeps marker/channel/glitch tokens,