comisai 1.0.15 → 1.0.16

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.
Files changed (47) hide show
  1. package/node_modules/@comis/agent/dist/bootstrap/types.d.ts +1 -1
  2. package/node_modules/@comis/agent/dist/bridge/pi-event-bridge.js +12 -11
  3. package/node_modules/@comis/agent/dist/context-engine/constants.d.ts +11 -0
  4. package/node_modules/@comis/agent/dist/context-engine/constants.js +11 -0
  5. package/node_modules/@comis/agent/dist/context-engine/index.d.ts +1 -1
  6. package/node_modules/@comis/agent/dist/context-engine/index.js +1 -1
  7. package/node_modules/@comis/agent/dist/context-engine/llm-compaction.js +32 -18
  8. package/node_modules/@comis/agent/dist/executor/cache-break-diff-writer.d.ts +2 -0
  9. package/node_modules/@comis/agent/dist/executor/cache-break-diff-writer.js +36 -0
  10. package/node_modules/@comis/agent/dist/executor/executor-post-execution.d.ts +12 -0
  11. package/node_modules/@comis/agent/dist/executor/executor-post-execution.js +117 -27
  12. package/node_modules/@comis/agent/dist/executor/executor-prompt-runner.d.ts +1 -0
  13. package/node_modules/@comis/agent/dist/executor/executor-prompt-runner.js +16 -4
  14. package/node_modules/@comis/agent/dist/executor/executor-stream-setup.js +17 -1
  15. package/node_modules/@comis/agent/dist/executor/executor-tool-assembly.js +27 -1
  16. package/node_modules/@comis/agent/dist/executor/session-snapshot-cleanup.js +3 -1
  17. package/node_modules/@comis/agent/dist/executor/stream-wrappers/index.d.ts +2 -0
  18. package/node_modules/@comis/agent/dist/executor/stream-wrappers/index.js +2 -0
  19. package/node_modules/@comis/agent/dist/executor/stream-wrappers/request-body-injector.d.ts +9 -0
  20. package/node_modules/@comis/agent/dist/executor/stream-wrappers/request-body-injector.js +65 -3
  21. package/node_modules/@comis/agent/dist/executor/stream-wrappers/stub-filter-injector.d.ts +28 -0
  22. package/node_modules/@comis/agent/dist/executor/stream-wrappers/stub-filter-injector.js +63 -0
  23. package/node_modules/@comis/agent/dist/executor/tool-deferral.d.ts +18 -1
  24. package/node_modules/@comis/agent/dist/executor/tool-deferral.js +275 -133
  25. package/node_modules/@comis/agent/dist/executor/ttl-guard.d.ts +6 -0
  26. package/node_modules/@comis/agent/dist/executor/ttl-guard.js +8 -0
  27. package/node_modules/@comis/agent/package.json +1 -1
  28. package/node_modules/@comis/channels/dist/shared/typing-controller.d.ts +7 -0
  29. package/node_modules/@comis/channels/dist/shared/typing-controller.js +33 -0
  30. package/node_modules/@comis/channels/package.json +1 -1
  31. package/node_modules/@comis/cli/dist/commands/daemon.js +116 -28
  32. package/node_modules/@comis/cli/package.json +1 -1
  33. package/node_modules/@comis/core/dist/config/schema-agent.d.ts +10 -0
  34. package/node_modules/@comis/core/dist/config/schema-agent.js +8 -0
  35. package/node_modules/@comis/core/dist/config/schema-skills.d.ts +16 -2
  36. package/node_modules/@comis/core/dist/config/schema-skills.js +9 -2
  37. package/node_modules/@comis/core/dist/config/schema.d.ts +6 -3
  38. package/node_modules/@comis/core/package.json +1 -1
  39. package/node_modules/@comis/daemon/package.json +1 -1
  40. package/node_modules/@comis/gateway/package.json +1 -1
  41. package/node_modules/@comis/infra/package.json +1 -1
  42. package/node_modules/@comis/memory/package.json +1 -1
  43. package/node_modules/@comis/scheduler/package.json +1 -1
  44. package/node_modules/@comis/shared/package.json +1 -1
  45. package/node_modules/@comis/skills/dist/builtin/platform/gateway-tool.d.ts +1 -1
  46. package/node_modules/@comis/skills/package.json +1 -1
  47. package/package.json +12 -12
@@ -95,7 +95,7 @@ export interface BootstrapContextFile {
95
95
  * Workspace file names allowed in sub-agent bootstrap context.
96
96
  * Sub-agents only receive AGENTS.md (instructions) and TOOLS.md (tool notes).
97
97
  */
98
- export declare const SUBAGENT_BOOTSTRAP_ALLOWLIST: Set<"SOUL.md" | "IDENTITY.md" | "USER.md" | "AGENTS.md" | "ROLE.md" | "TOOLS.md" | "HEARTBEAT.md" | "BOOTSTRAP.md" | "BOOT.md">;
98
+ export declare const SUBAGENT_BOOTSTRAP_ALLOWLIST: Set<"AGENTS.md" | "SOUL.md" | "IDENTITY.md" | "USER.md" | "ROLE.md" | "TOOLS.md" | "HEARTBEAT.md" | "BOOTSTRAP.md" | "BOOT.md">;
99
99
  /** Head portion ratio for truncation (first 70% of maxChars) */
100
100
  export declare const BOOTSTRAP_HEAD_RATIO = 0.7;
101
101
  /** Tail portion ratio for truncation (last 20% of maxChars) */
@@ -49,9 +49,14 @@ export function createPiEventBridge(deps) {
49
49
  // -----------------------------------------------------------------
50
50
  case "message_update": {
51
51
  const ame = event.assistantMessageEvent;
52
- if (ame && ame.type === "text_delta") {
53
- m.textEmitted = true; // Track that visible text was produced in some turn
54
- if (deps.onDelta) {
52
+ if (ame && (ame.type === "text_delta" || ame.type === "thinking_delta")) {
53
+ if (ame.type === "text_delta") {
54
+ // Track that visible text was produced in some turn.
55
+ // thinking_delta intentionally excluded — empty-final-turn detection
56
+ // depends on this flag reflecting user-visible text only.
57
+ m.textEmitted = true;
58
+ }
59
+ if (deps.onDelta && typeof ame.delta === "string") {
55
60
  try {
56
61
  deps.onDelta(ame.delta);
57
62
  }
@@ -394,7 +399,7 @@ export function createPiEventBridge(deps) {
394
399
  totalTokens: usage.totalTokens,
395
400
  cost,
396
401
  provider: deps.provider,
397
- model: deps.model,
402
+ model: deps.getCurrentModel?.() ?? deps.model,
398
403
  sessionKey: formatSessionKey(deps.sessionKey),
399
404
  operationType: deps.operationType,
400
405
  });
@@ -457,7 +462,7 @@ export function createPiEventBridge(deps) {
457
462
  channelId: deps.channelId,
458
463
  executionId: deps.executionId,
459
464
  provider: deps.provider,
460
- model: deps.model,
465
+ model: deps.getCurrentModel?.() ?? deps.model,
461
466
  tokens: {
462
467
  prompt: usage.input,
463
468
  completion: usage.output,
@@ -475,7 +480,7 @@ export function createPiEventBridge(deps) {
475
480
  cacheWriteTokens,
476
481
  sessionKey: formatSessionKey(deps.sessionKey),
477
482
  savedVsUncached,
478
- cacheEligible: getCacheProviderInfo(deps.provider, deps.model).cacheEligible,
483
+ cacheEligible: getCacheProviderInfo(deps.provider, deps.getCurrentModel?.() ?? deps.model).cacheEligible,
479
484
  responseId,
480
485
  cacheCreation: effectiveCacheCreation,
481
486
  });
@@ -561,17 +566,13 @@ export function createPiEventBridge(deps) {
561
566
  // SEP: Extract plan from first LLM turn that has tool calls + assistant text.
562
567
  // This runs inside the agentic loop so subsequent turns can track against the plan.
563
568
  if (deps.executionPlan && deps.sepConfig && !deps.executionPlan.current) {
564
- const hasToolCalls = Array.isArray(assistantMsg?.content) && assistantMsg.content.some((c) => {
565
- const block = c;
566
- return block.type === "toolCall" || block.type === "tool_use";
567
- });
568
569
  const assistantTextForPlan = Array.isArray(assistantMsg?.content)
569
570
  ? assistantMsg.content
570
571
  .filter((c) => c?.type === "text")
571
572
  .map((c) => c.text ?? "")
572
573
  .join(" ")
573
574
  : "";
574
- if (hasToolCalls && assistantTextForPlan.length > 0) {
575
+ if (assistantTextForPlan.length > 0) {
575
576
  const steps = extractPlanFromResponse(assistantTextForPlan, deps.sepConfig.maxSteps);
576
577
  if (steps && steps.length >= deps.sepConfig.minSteps) {
577
578
  const plan = {
@@ -96,6 +96,17 @@ export declare const MCP_DEFERRAL_THRESHOLD = 0.1;
96
96
  * this many blocks apart cannot see each other for prefix matching.
97
97
  * Used by: lookback window enforcement in stream-wrappers.ts. */
98
98
  export declare const CACHE_LOOKBACK_WINDOW = 20;
99
+ /** Maximum message blocks before cache-aware compaction trigger.
100
+ * With 4 breakpoints (3 Comis + 1 SDK) and a 20-block lookback window,
101
+ * optimal coverage spans 4 × 20 = 80 blocks. Trigger when the count
102
+ * *exceeds* 60 (i.e. first fires at 61 blocks — 75% of theoretical max)
103
+ * to leave headroom for multi-call turns.
104
+ *
105
+ * APPROXIMATION NOTE: `messages.length` (AgentMessage[]) approximates
106
+ * Anthropic's request-body `messages[]` block count but is not strictly 1:1.
107
+ * Treat 60 as a defensive setpoint, not a calibrated threshold.
108
+ * Used by: llm-compaction layer cache-aware trigger. */
109
+ export declare const CACHE_AWARE_COMPACTION_BLOCK_THRESHOLD = 60;
99
110
  /** Context utilization percentage that triggers LLM compaction. Used by: llm-compaction layer. */
100
111
  export declare const COMPACTION_TRIGGER_PERCENT = 85;
101
112
  /** Default turns to wait before re-triggering compaction. Used by: llm-compaction layer. */
@@ -155,6 +155,17 @@ export const MCP_DEFERRAL_THRESHOLD = 0.10;
155
155
  * this many blocks apart cannot see each other for prefix matching.
156
156
  * Used by: lookback window enforcement in stream-wrappers.ts. */
157
157
  export const CACHE_LOOKBACK_WINDOW = 20;
158
+ /** Maximum message blocks before cache-aware compaction trigger.
159
+ * With 4 breakpoints (3 Comis + 1 SDK) and a 20-block lookback window,
160
+ * optimal coverage spans 4 × 20 = 80 blocks. Trigger when the count
161
+ * *exceeds* 60 (i.e. first fires at 61 blocks — 75% of theoretical max)
162
+ * to leave headroom for multi-call turns.
163
+ *
164
+ * APPROXIMATION NOTE: `messages.length` (AgentMessage[]) approximates
165
+ * Anthropic's request-body `messages[]` block count but is not strictly 1:1.
166
+ * Treat 60 as a defensive setpoint, not a calibrated threshold.
167
+ * Used by: llm-compaction layer cache-aware trigger. */
168
+ export const CACHE_AWARE_COMPACTION_BLOCK_THRESHOLD = 60;
158
169
  // ---------------------------------------------------------------------------
159
170
  // LLM Compaction (Layer 5: )
160
171
  // ---------------------------------------------------------------------------
@@ -8,7 +8,7 @@
8
8
  */
9
9
  export type { TokenBudget, TokenAnchor, ContextLayer, ContextEngine, ContextEngineDeps, ContextEngineMetrics, AssembledContext, LayerCircuitBreaker, MicrocompactionGuard, ObservationMasker, CacheOptimizationMetrics, CacheSessionStats, EvictionStats, } from "./types.js";
10
10
  export type { ToolMaskingTier } from "./constants.js";
11
- export { SAFETY_MARGIN_PERCENT, MIN_SAFETY_MARGIN_TOKENS, OUTPUT_RESERVE_TOKENS, CONTEXT_ROT_BUFFER_PERCENT, LAYER_CIRCUIT_BREAKER_THRESHOLD, DEFAULT_KEEP_WINDOW_TURNS, MAX_INLINE_TOOL_RESULT_CHARS, MAX_INLINE_MCP_TOOL_RESULT_CHARS, MAX_INLINE_FILE_READ_RESULT_CHARS, TOOL_RESULT_HARD_CAP_CHARS, DEFAULT_OBSERVATION_KEEP_WINDOW, OBSERVATION_MASKING_CHAR_THRESHOLD, TOOL_MASKING_TIERS, resolveToolMaskingTier, EPHEMERAL_TOOL_KEEP_WINDOW, CHARS_PER_TOKEN_RATIO, CHARS_PER_TOKEN_RATIO_STRUCTURED, SYSTEM_PROMPT_HASH_LENGTH, BOOTSTRAP_BUDGET_WARN_PERCENT, MIN_CACHEABLE_TOKENS, DEFAULT_MIN_CACHEABLE_TOKENS, MCP_DEFERRAL_THRESHOLD, CACHE_LOOKBACK_WINDOW, COMPACTION_TRIGGER_PERCENT, COMPACTION_COOLDOWN_TURNS, COMPACTION_MAX_RETRIES, OVERSIZED_MESSAGE_CHARS_THRESHOLD, COMPACTION_REQUIRED_SECTIONS, } from "./constants.js";
11
+ export { SAFETY_MARGIN_PERCENT, MIN_SAFETY_MARGIN_TOKENS, OUTPUT_RESERVE_TOKENS, CONTEXT_ROT_BUFFER_PERCENT, LAYER_CIRCUIT_BREAKER_THRESHOLD, DEFAULT_KEEP_WINDOW_TURNS, MAX_INLINE_TOOL_RESULT_CHARS, MAX_INLINE_MCP_TOOL_RESULT_CHARS, MAX_INLINE_FILE_READ_RESULT_CHARS, TOOL_RESULT_HARD_CAP_CHARS, DEFAULT_OBSERVATION_KEEP_WINDOW, OBSERVATION_MASKING_CHAR_THRESHOLD, TOOL_MASKING_TIERS, resolveToolMaskingTier, EPHEMERAL_TOOL_KEEP_WINDOW, CHARS_PER_TOKEN_RATIO, CHARS_PER_TOKEN_RATIO_STRUCTURED, SYSTEM_PROMPT_HASH_LENGTH, BOOTSTRAP_BUDGET_WARN_PERCENT, MIN_CACHEABLE_TOKENS, DEFAULT_MIN_CACHEABLE_TOKENS, MCP_DEFERRAL_THRESHOLD, CACHE_LOOKBACK_WINDOW, CACHE_AWARE_COMPACTION_BLOCK_THRESHOLD, COMPACTION_TRIGGER_PERCENT, COMPACTION_COOLDOWN_TURNS, COMPACTION_MAX_RETRIES, OVERSIZED_MESSAGE_CHARS_THRESHOLD, COMPACTION_REQUIRED_SECTIONS, } from "./constants.js";
12
12
  export { computeTokenBudget } from "./token-budget.js";
13
13
  export { createContextEngine } from "./context-engine.js";
14
14
  export { createThinkingBlockCleaner } from "./thinking-block-cleaner.js";
@@ -8,7 +8,7 @@
8
8
  * @module
9
9
  */
10
10
  // Constants
11
- export { SAFETY_MARGIN_PERCENT, MIN_SAFETY_MARGIN_TOKENS, OUTPUT_RESERVE_TOKENS, CONTEXT_ROT_BUFFER_PERCENT, LAYER_CIRCUIT_BREAKER_THRESHOLD, DEFAULT_KEEP_WINDOW_TURNS, MAX_INLINE_TOOL_RESULT_CHARS, MAX_INLINE_MCP_TOOL_RESULT_CHARS, MAX_INLINE_FILE_READ_RESULT_CHARS, TOOL_RESULT_HARD_CAP_CHARS, DEFAULT_OBSERVATION_KEEP_WINDOW, OBSERVATION_MASKING_CHAR_THRESHOLD, TOOL_MASKING_TIERS, resolveToolMaskingTier, EPHEMERAL_TOOL_KEEP_WINDOW, CHARS_PER_TOKEN_RATIO, CHARS_PER_TOKEN_RATIO_STRUCTURED, SYSTEM_PROMPT_HASH_LENGTH, BOOTSTRAP_BUDGET_WARN_PERCENT, MIN_CACHEABLE_TOKENS, DEFAULT_MIN_CACHEABLE_TOKENS, MCP_DEFERRAL_THRESHOLD, CACHE_LOOKBACK_WINDOW, COMPACTION_TRIGGER_PERCENT, COMPACTION_COOLDOWN_TURNS, COMPACTION_MAX_RETRIES, OVERSIZED_MESSAGE_CHARS_THRESHOLD, COMPACTION_REQUIRED_SECTIONS, } from "./constants.js";
11
+ export { SAFETY_MARGIN_PERCENT, MIN_SAFETY_MARGIN_TOKENS, OUTPUT_RESERVE_TOKENS, CONTEXT_ROT_BUFFER_PERCENT, LAYER_CIRCUIT_BREAKER_THRESHOLD, DEFAULT_KEEP_WINDOW_TURNS, MAX_INLINE_TOOL_RESULT_CHARS, MAX_INLINE_MCP_TOOL_RESULT_CHARS, MAX_INLINE_FILE_READ_RESULT_CHARS, TOOL_RESULT_HARD_CAP_CHARS, DEFAULT_OBSERVATION_KEEP_WINDOW, OBSERVATION_MASKING_CHAR_THRESHOLD, TOOL_MASKING_TIERS, resolveToolMaskingTier, EPHEMERAL_TOOL_KEEP_WINDOW, CHARS_PER_TOKEN_RATIO, CHARS_PER_TOKEN_RATIO_STRUCTURED, SYSTEM_PROMPT_HASH_LENGTH, BOOTSTRAP_BUDGET_WARN_PERCENT, MIN_CACHEABLE_TOKENS, DEFAULT_MIN_CACHEABLE_TOKENS, MCP_DEFERRAL_THRESHOLD, CACHE_LOOKBACK_WINDOW, CACHE_AWARE_COMPACTION_BLOCK_THRESHOLD, COMPACTION_TRIGGER_PERCENT, COMPACTION_COOLDOWN_TURNS, COMPACTION_MAX_RETRIES, OVERSIZED_MESSAGE_CHARS_THRESHOLD, COMPACTION_REQUIRED_SECTIONS, } from "./constants.js";
12
12
  // Token budget algebra
13
13
  export { computeTokenBudget } from "./token-budget.js";
14
14
  // Context engine factory
@@ -25,7 +25,7 @@
25
25
  * @module
26
26
  */
27
27
  import { generateSummary } from "@mariozechner/pi-coding-agent";
28
- import { COMPACTION_TRIGGER_PERCENT, COMPACTION_MAX_RETRIES, OVERSIZED_MESSAGE_CHARS_THRESHOLD, COMPACTION_REQUIRED_SECTIONS, CHARS_PER_TOKEN_RATIO, MIN_MIDDLE_MESSAGES_FOR_COMPACTION, } from "./constants.js";
28
+ import { COMPACTION_TRIGGER_PERCENT, COMPACTION_MAX_RETRIES, OVERSIZED_MESSAGE_CHARS_THRESHOLD, COMPACTION_REQUIRED_SECTIONS, CHARS_PER_TOKEN_RATIO, MIN_MIDDLE_MESSAGES_FOR_COMPACTION, CACHE_AWARE_COMPACTION_BLOCK_THRESHOLD, } from "./constants.js";
29
29
  import { estimateContextCharsWithDualRatio, estimateMessageChars, estimateWithAnchor, } from "../safety/token-estimator.js";
30
30
  // ---------------------------------------------------------------------------
31
31
  // Structured output instructions
@@ -258,27 +258,41 @@ export function createLlmCompactionLayer(config, deps) {
258
258
  if (turnsSinceLastCompaction < config.compactionCooldownTurns) {
259
259
  return messages;
260
260
  }
261
- // Step 3: Threshold check
262
- // Use anchor-based estimation when available for accurate threshold check
263
- /* eslint-disable @typescript-eslint/no-explicit-any */
264
- const contextChars = estimateContextCharsWithDualRatio(messages);
265
- /* eslint-enable @typescript-eslint/no-explicit-any */
266
- const charBasedTokens = Math.ceil(contextChars / CHARS_PER_TOKEN_RATIO);
267
- const anchor = deps.getTokenAnchor?.() ?? null;
268
- const contextTokens = estimateWithAnchor(anchor, messages, charBasedTokens);
269
- const thresholdTokens = Math.floor(budget.windowTokens * COMPACTION_TRIGGER_PERCENT / 100);
270
- if (contextTokens <= thresholdTokens) {
271
- return messages;
261
+ // Step 2b: Cache-aware block count trigger.
262
+ // Fires BEFORE the token-based threshold because lookback overflow
263
+ // causes cache breaks regardless of how few tokens the messages contain.
264
+ const messageCount = messages.length;
265
+ const blockThreshold = CACHE_AWARE_COMPACTION_BLOCK_THRESHOLD;
266
+ const blockCountExceeded = messageCount > blockThreshold;
267
+ // Step 3: Token threshold check (only when block-count trigger didn't fire)
268
+ let contextTokens;
269
+ let thresholdTokens;
270
+ if (!blockCountExceeded) {
271
+ /* eslint-disable @typescript-eslint/no-explicit-any */
272
+ const contextChars = estimateContextCharsWithDualRatio(messages);
273
+ /* eslint-enable @typescript-eslint/no-explicit-any */
274
+ const charBasedTokens = Math.ceil(contextChars / CHARS_PER_TOKEN_RATIO);
275
+ const anchor = deps.getTokenAnchor?.() ?? null;
276
+ contextTokens = estimateWithAnchor(anchor, messages, charBasedTokens);
277
+ thresholdTokens = Math.floor(budget.windowTokens * COMPACTION_TRIGGER_PERCENT / 100);
278
+ if (contextTokens <= thresholdTokens) {
279
+ return messages;
280
+ }
272
281
  }
273
- // Step 4: Pipeline order guarantees observation masker ran before this layer
282
+ // Step 4: Unified log (conditional spread keeps JSON shape clean).
274
283
  deps.logger.warn({
275
- contextTokens,
276
- thresholdTokens,
284
+ messageCount,
285
+ ...(blockCountExceeded
286
+ ? { blockThreshold, trigger: "block_count" }
287
+ : { contextTokens, thresholdTokens, trigger: "token_threshold" }),
277
288
  windowTokens: budget.windowTokens,
278
- messageCount: messages.length,
279
289
  errorKind: "resource",
280
- hint: "Context approaching capacity; LLM compaction will summarize older messages to free space",
281
- }, "LLM compaction triggered: context exceeds 85% threshold");
290
+ hint: blockCountExceeded
291
+ ? "Message count approaching breakpoint lookback limit; compacting to prevent cache fragmentation"
292
+ : "Context approaching capacity; LLM compaction will summarize older messages to free space",
293
+ }, blockCountExceeded
294
+ ? "LLM compaction triggered: message count exceeds cache lookback threshold"
295
+ : "LLM compaction triggered: context exceeds 85% threshold");
282
296
  // Step 5: Resolve model
283
297
  /* eslint-disable @typescript-eslint/no-explicit-any */
284
298
  let model;
@@ -60,6 +60,8 @@ export interface CacheBreakDiffPayload {
60
60
  message: number;
61
61
  sdkAuto: number;
62
62
  };
63
+ /** Model ID for per-model cost attribution. Populated by pi-event-bridge.ts. */
64
+ model?: string;
63
65
  }
64
66
  export interface CacheBreakDiffWriterConfig {
65
67
  /** Directory for diff files (e.g., ~/.comis/cache-breaks) */
@@ -12,6 +12,7 @@
12
12
  import { writeFileSync, mkdirSync, readdirSync, unlinkSync } from "node:fs";
13
13
  import { createPatch } from "diff";
14
14
  import { safePath } from "@comis/core";
15
+ import { resolveModelPricing, ZERO_COST } from "../model/model-catalog.js";
15
16
  const MAX_DIFF_FILES = 50;
16
17
  /** DIFF-CONTENT: Maximum chars per category (system, tools) for snapshot content before diffing. */
17
18
  const MAX_SNAPSHOT_CHARS = 50_000;
@@ -40,6 +41,7 @@ export function buildDiffableContent(system, tools, model) {
40
41
  */
41
42
  export function createCacheBreakDiffWriter(config) {
42
43
  let dirEnsured = false;
44
+ const unknownModelWarnLatch = new Set();
43
45
  return (event) => {
44
46
  try {
45
47
  if (!dirEnsured) {
@@ -50,6 +52,24 @@ export function createCacheBreakDiffWriter(config) {
50
52
  const ts = new Date(event.timestamp).toISOString().replace(/[:.]/g, "-");
51
53
  const filename = `${ts}_${event.agentId}_${event.reason}.json`;
52
54
  const filePath = safePath(config.outputDir, filename);
55
+ // Cost attribution: compute estimated USD impact using per-model pricing.
56
+ const modelId = event.model ?? "";
57
+ const pricing = resolveModelPricing(event.provider, modelId);
58
+ const pricingKnown = pricing !== ZERO_COST && pricing.input > 0;
59
+ const retentionDisabled = event.ttlCategory === "none";
60
+ const writeRatePerToken = event.ttlCategory === "long" ? pricing.cacheWrite1h : pricing.cacheWrite;
61
+ const readRatePerToken = pricing.cacheRead;
62
+ const perTokenDelta = Math.max(0, writeRatePerToken - readRatePerToken);
63
+ const estimatedCostUsd = retentionDisabled
64
+ ? 0
65
+ : (pricingKnown ? event.tokenDrop * perTokenDelta : null);
66
+ if (!pricingKnown && !retentionDisabled && modelId) {
67
+ const latchKey = `${event.provider}:${modelId}`;
68
+ if (!unknownModelWarnLatch.has(latchKey)) {
69
+ unknownModelWarnLatch.add(latchKey);
70
+ config.logger.warn({ provider: event.provider, model: modelId, hint: "Cache break cost attribution unavailable for this model", errorKind: "config" }, "Unknown model pricing for cache break cost attribution");
71
+ }
72
+ }
53
73
  const diff = {
54
74
  timestamp: new Date(event.timestamp).toISOString(),
55
75
  agentId: event.agentId,
@@ -85,6 +105,22 @@ export function createCacheBreakDiffWriter(config) {
85
105
  effortValue: event.effortValue,
86
106
  // Breakpoint budget context
87
107
  ...(event.breakpointBudget && { breakpointBudget: event.breakpointBudget }),
108
+ // Cost attribution
109
+ estimatedCostUsd: estimatedCostUsd === null
110
+ ? null
111
+ : Math.round(estimatedCostUsd * 100_000) / 100_000,
112
+ costBreakdown: {
113
+ writeRatePerMTok: pricingKnown
114
+ ? Math.round(writeRatePerToken * 1_000_000 * 100) / 100
115
+ : null,
116
+ readRatePerMTok: pricingKnown
117
+ ? Math.round(readRatePerToken * 1_000_000 * 100) / 100
118
+ : null,
119
+ tokenDrop: event.tokenDrop,
120
+ model: event.model ?? null,
121
+ ttlCategory: event.ttlCategory,
122
+ pricingKnown,
123
+ },
88
124
  };
89
125
  writeFileSync(filePath, JSON.stringify(diff, null, 2) + "\n");
90
126
  // DIFF-CONTENT: Generate unified diff file alongside JSON
@@ -140,6 +140,18 @@ export interface PostExecutionParams {
140
140
  * @returns true if the turn qualifies for memory storage
141
141
  */
142
142
  export declare function shouldStorePairedMemory(userText: string, agentResponse: string): boolean;
143
+ /**
144
+ * Check whether paired memory content was stored recently for this agent.
145
+ *
146
+ * Exact-content hashing (not semantic similarity) -- targets the cron pattern
147
+ * of identical prompt + identical NO_REPLY response. Lazy eviction keeps the
148
+ * cache bounded without a timer.
149
+ *
150
+ * Exported for unit tests.
151
+ */
152
+ export declare function isDuplicatePairedMemory(content: string, agentId: string): boolean;
153
+ /** Reset the paired-memory dedup cache. Exported for unit tests. */
154
+ export declare function resetPairedMemoryDedupForTests(): void;
143
155
  /**
144
156
  * Run post-execution cleanup for a PiExecutor turn.
145
157
  *
@@ -19,7 +19,7 @@ import { mergeSessionStats } from "./pi-executor.js";
19
19
  import { recordLastResponseTs } from "./ttl-guard.js";
20
20
  import { stripDiscoverySchemas } from "./schema-stripping.js";
21
21
  import { getWorkspaceStatus } from "../workspace/index.js";
22
- import { randomUUID } from "node:crypto";
22
+ import { createHash, randomUUID } from "node:crypto";
23
23
  // ---------------------------------------------------------------------------
24
24
  // Helpers
25
25
  // ---------------------------------------------------------------------------
@@ -61,6 +61,69 @@ export function shouldStorePairedMemory(userText, agentResponse) {
61
61
  return false;
62
62
  return true;
63
63
  }
64
+ /**
65
+ * Operation types that must NOT create paired memories.
66
+ *
67
+ * Cron and heartbeat executions are stateless and repetitive -- storing their
68
+ * prompts pollutes the vector space and degrades RAG recall for interactive
69
+ * conversations. Compaction / taskExtraction / condensation are system-internal
70
+ * operations that have their own dedicated memory paths (compaction summaries,
71
+ * extracted tasks) and should not additionally create paired conversation
72
+ * entries. Interactive and subagent are deliberately excluded.
73
+ */
74
+ const MEMORY_SKIP_OPERATIONS = new Set([
75
+ "cron",
76
+ "heartbeat",
77
+ "compaction",
78
+ "taskExtraction",
79
+ "condensation",
80
+ ]);
81
+ /**
82
+ * In-memory dedup cache for paired memory content.
83
+ *
84
+ * Defense-in-depth for interactive conversations where the user sends the same
85
+ * message multiple times (retries, reconnects). The Layer-1 operationType gate
86
+ * is the primary defense against cron/heartbeat duplication; this Layer-2 hash
87
+ * dedup catches residual duplicates that slip through.
88
+ *
89
+ * Keyed by a 64-bit truncation of sha256(agentId || content). Single-process
90
+ * daemon deployment (pm2, no cluster) means this cache is always complete.
91
+ */
92
+ const DEDUP_TTL_MS = 10 * 60 * 1000;
93
+ const DEDUP_MAX_ENTRIES = 500;
94
+ const pairedMemoryDedup = new Map();
95
+ /**
96
+ * Check whether paired memory content was stored recently for this agent.
97
+ *
98
+ * Exact-content hashing (not semantic similarity) -- targets the cron pattern
99
+ * of identical prompt + identical NO_REPLY response. Lazy eviction keeps the
100
+ * cache bounded without a timer.
101
+ *
102
+ * Exported for unit tests.
103
+ */
104
+ export function isDuplicatePairedMemory(content, agentId) {
105
+ const now = Date.now();
106
+ if (pairedMemoryDedup.size > DEDUP_MAX_ENTRIES) {
107
+ for (const [key, ts] of pairedMemoryDedup) {
108
+ if (now - ts > DEDUP_TTL_MS)
109
+ pairedMemoryDedup.delete(key);
110
+ }
111
+ }
112
+ const hash = createHash("sha256")
113
+ .update(agentId)
114
+ .update(content)
115
+ .digest("hex")
116
+ .slice(0, 16);
117
+ const existing = pairedMemoryDedup.get(hash);
118
+ if (existing != null && now - existing <= DEDUP_TTL_MS)
119
+ return true;
120
+ pairedMemoryDedup.set(hash, now);
121
+ return false;
122
+ }
123
+ /** Reset the paired-memory dedup cache. Exported for unit tests. */
124
+ export function resetPairedMemoryDedupForTests() {
125
+ pairedMemoryDedup.clear();
126
+ }
64
127
  // ---------------------------------------------------------------------------
65
128
  // Implementation
66
129
  // ---------------------------------------------------------------------------
@@ -292,38 +355,65 @@ export async function postExecution(params) {
292
355
  // Pairing user message with agent response creates entries that carry enough
293
356
  // context for meaningful RAG retrieval. Standalone user messages like "Hello"
294
357
  // or "you choose" have no semantic value without the agent's response.
295
- // Runs for ALL execution paths: gateway, channels, cron.
358
+ //
359
+ // Two-layer dedup defense:
360
+ // Layer 1: operationType gate -- skip cron/heartbeat/system-internal ops
361
+ // which are stateless/repetitive and would pollute the vector
362
+ // space with near-identical entries.
363
+ // Layer 2: content-hash dedup -- safety net for interactive retries.
364
+ //
296
365
  // Non-blocking, non-fatal -- execution never fails due to memory store errors.
297
- if (deps.memoryPort && result.response && msg.text && shouldStorePairedMemory(msg.text, result.response)) {
298
- try {
299
- const now = Date.now();
300
- const userEntryId = randomUUID();
301
- const pairedContent = buildPairedMemoryContent(msg.text, result.response);
302
- const userStoreResult = await deps.memoryPort.store({
303
- id: userEntryId,
304
- tenantId: sessionKey.tenantId,
305
- agentId: agentId ?? "default",
306
- userId: sessionKey.userId,
307
- content: pairedContent,
308
- trustLevel: "learned",
309
- source: { who: sessionKey.userId, channel: msg.channelType ?? "unknown" },
310
- tags: ["conversation", "paired"],
311
- createdAt: now,
312
- });
313
- if (!userStoreResult.ok) {
314
- deps.logger.warn({ err: userStoreResult.error.message, hint: "Check database connectivity and disk space", errorKind: "dependency" }, "Memory store failed for user message");
366
+ const operationType = params.executionOverrides?.operationType;
367
+ const skipMemoryForOperation = operationType != null && MEMORY_SKIP_OPERATIONS.has(operationType);
368
+ if (deps.memoryPort &&
369
+ result.response &&
370
+ msg.text &&
371
+ !skipMemoryForOperation &&
372
+ shouldStorePairedMemory(msg.text, result.response)) {
373
+ const now = Date.now();
374
+ const pairedContent = buildPairedMemoryContent(msg.text, result.response);
375
+ const effectiveAgentId = agentId ?? "default";
376
+ if (isDuplicatePairedMemory(pairedContent, effectiveAgentId)) {
377
+ deps.logger.debug({ agentId: effectiveAgentId, sessionKey: formattedKey }, "Paired memory skipped: duplicate content within dedup window");
378
+ }
379
+ else {
380
+ try {
381
+ const userEntryId = randomUUID();
382
+ const userStoreResult = await deps.memoryPort.store({
383
+ id: userEntryId,
384
+ tenantId: sessionKey.tenantId,
385
+ agentId: effectiveAgentId,
386
+ userId: sessionKey.userId,
387
+ content: pairedContent,
388
+ trustLevel: "learned",
389
+ source: {
390
+ who: sessionKey.userId,
391
+ channel: msg.channelType ?? "unknown",
392
+ sessionKey: formattedKey,
393
+ },
394
+ tags: ["conversation", "paired"],
395
+ createdAt: now,
396
+ });
397
+ if (!userStoreResult.ok) {
398
+ deps.logger.warn({ err: userStoreResult.error.message, hint: "Check database connectivity and disk space", errorKind: "dependency" }, "Memory store failed for user message");
399
+ }
400
+ else if (deps.embeddingEnqueue) {
401
+ deps.embeddingEnqueue(userEntryId, pairedContent);
402
+ }
315
403
  }
316
- else if (deps.embeddingEnqueue) {
317
- deps.embeddingEnqueue(userEntryId, pairedContent);
404
+ catch {
405
+ // Memory storage failure is non-fatal -- errors already logged per-entry
318
406
  }
319
407
  }
320
- catch {
321
- // Memory storage failure is non-fatal -- errors already logged per-entry
322
- }
323
408
  }
324
409
  else if (deps.memoryPort && result.response && msg.text) {
325
- // Quality gate filtered this turn -- log for observability
326
- deps.logger.debug({ userLen: msg.text.trim().length, minUserChars: PAIRED_MIN_USER_CHARS, minCombinedChars: PAIRED_MIN_COMBINED_CHARS }, "Paired memory skipped: content below quality threshold");
410
+ // Memory not stored -- distinguish the two skip reasons for observability.
411
+ if (skipMemoryForOperation) {
412
+ deps.logger.debug({ operationType, sessionKey: formattedKey }, "Paired memory skipped: non-interactive operation type");
413
+ }
414
+ else {
415
+ deps.logger.debug({ userLen: msg.text.trim().length, minUserChars: PAIRED_MIN_USER_CHARS, minCombinedChars: PAIRED_MIN_COMBINED_CHARS }, "Paired memory skipped: content below quality threshold");
416
+ }
327
417
  }
328
418
  // Deregister active run before dispose
329
419
  if (deps.activeRunRegistry) {
@@ -37,6 +37,7 @@ export interface PromptRunnerBridge {
37
37
  output?: number;
38
38
  };
39
39
  stepsExecuted?: number;
40
+ toolCallHistory?: string[];
40
41
  };
41
42
  }
42
43
  /** Parameters for runPrompt(). */
@@ -515,6 +515,17 @@ export async function runPrompt(params) {
515
515
  if (plan) {
516
516
  executionPlanRef.current = plan;
517
517
  deps.logger.debug({ agentId }, "SEP plan extracted (post-loop fallback)");
518
+ // Inline backfill: post-loop extraction means no mid-loop step tracking
519
+ // ran, so completedCount is stuck at 0 and the nudge cannot fire. Use
520
+ // the bridge's recorded tool history as a proxy for work done and mark
521
+ // the first N steps as "done" (N = min(toolHistoryLen, stepCount)).
522
+ // Tool-to-step attribution is advisory/observability only; over-counting
523
+ // is strictly better than the 0/N deadlock.
524
+ const toolHistoryLen = bridge.getResult().toolCallHistory?.length ?? 0;
525
+ const doneCount = Math.min(toolHistoryLen, plan.steps.length);
526
+ for (let i = 0; i < doneCount; i++)
527
+ plan.steps[i].status = "done";
528
+ plan.completedCount = doneCount;
518
529
  }
519
530
  }
520
531
  if (sepEnabled && !executionPlanRef.current && extractedResponse && toolCallCount === 0) {
@@ -676,11 +687,12 @@ export async function runPrompt(params) {
676
687
  // Estimated cache write cost for the system prompt portion.
677
688
  // System prompt is sent as cacheable prefix; on first request it incurs cache write cost.
678
689
  const estimatedCacheWriteTokens = Math.ceil(sysPromptChars / CHARS_PER_TOKEN_RATIO);
679
- const pricing = resolveModelPricing(config.provider, config.model);
690
+ const effectiveModelId = resolvedModel?.id ?? config.model;
691
+ const pricing = resolveModelPricing(config.provider, effectiveModelId);
680
692
  if (pricing.input === 0) {
681
693
  deps.logger.warn({
682
694
  provider: config.provider,
683
- model: config.model,
695
+ model: effectiveModelId,
684
696
  hint: "Model not found in pricing catalog; timeout cost estimate is $0 -- actual provider billing may differ",
685
697
  errorKind: "config",
686
698
  }, "Unknown model for timeout cost estimation");
@@ -699,7 +711,7 @@ export async function runPrompt(params) {
699
711
  channelId: msg.channelId,
700
712
  executionId,
701
713
  provider: config.provider,
702
- model: config.model,
714
+ model: effectiveModelId,
703
715
  tokens: {
704
716
  prompt: estimatedPromptTokens,
705
717
  completion: 0,
@@ -717,7 +729,7 @@ export async function runPrompt(params) {
717
729
  cacheWriteTokens: estimatedCacheWriteTokens,
718
730
  sessionKey: formatSessionKey(sessionKey),
719
731
  savedVsUncached: 0,
720
- cacheEligible: getCacheProviderInfo(config.provider).cacheEligible,
732
+ cacheEligible: getCacheProviderInfo(config.provider, effectiveModelId).cacheEligible,
721
733
  });
722
734
  // Include ghost cost estimate in result for bridge accumulation
723
735
  ghostCost = pricing.input > 0 ? {
@@ -29,8 +29,9 @@
29
29
  import { safePath, } from "@comis/core";
30
30
  import { createToolResultSizeBouncer, createTurnResultBudgetWrapper, createConfigResolver, createCacheTraceWriter, createApiPayloadTraceWriter, createRequestBodyInjector, createValidationErrorFormatter, } from "./stream-wrappers/index.js";
31
31
  import { resolveToolCallingTemperature } from "./tool-deferral.js";
32
+ import { createStubFilterInjector } from "./stream-wrappers/stub-filter-injector.js";
32
33
  import { computeFeatureFlagHash } from "./prompt-assembly.js";
33
- import { createTtlGuard, getElapsedSinceLastResponse } from "./ttl-guard.js";
34
+ import { createTtlGuard, getElapsedSinceLastResponse, getLastResponseTs } from "./ttl-guard.js";
34
35
  import { isAnthropicFamily, isGoogleFamily } from "../provider/capabilities.js";
35
36
  import { createGeminiCacheInjector } from "./gemini-cache-injector.js";
36
37
  import { extractAnthropicPromptState, extractGeminiPromptState } from "./cache-break-detection.js";
@@ -142,6 +143,8 @@ export function setupStreamWrappers(params) {
142
143
  parentCacheRetention: executionOverrides?.spawnPacket?.cacheSafeParams?.cacheRetention,
143
144
  getCacheFenceIndex: () => getBreakpointIndex(formattedKey) ?? -1,
144
145
  getElapsedSinceLastResponse: () => getElapsedSinceLastResponse(formattedKey),
146
+ getLastResponseTs: () => getLastResponseTs(formattedKey),
147
+ promoteRecentZoneOnSlowCadence: config.advancedCacheOptimization?.enableRecentZonePromotion ?? true,
145
148
  observationKeepWindow: 25,
146
149
  microcompactTokenCeiling: 180_000,
147
150
  onContentModification: () => cacheBreakDetector.notifyContentModification(formattedKey),
@@ -231,6 +234,19 @@ export function setupStreamWrappers(params) {
231
234
  deps.logger.info({ outputDir, cacheTracePath, apiPayloadPath }, "JSONL tracing enabled");
232
235
  }
233
236
  }
237
+ // MUST be the last wrapper pushed (innermost). Runs its onPayload FIRST in
238
+ // the chain so all downstream wrappers operate on stub-free tools. Violating
239
+ // this ordering would (a) include stub schemas in the Anthropic rendered-tool
240
+ // cache hash, (b) persist stubs into the Gemini CachedContent entry for its
241
+ // whole lifetime, and (c) cause deferCount > 0 in the DEFER-TOOL block,
242
+ // unintentionally flipping Anthropic sessions to server-side tool_search.
243
+ wrappers.push(createStubFilterInjector({
244
+ getStubToolNames: () => {
245
+ if (!deferralResult?.deferredEntries.length)
246
+ return new Set();
247
+ return new Set(deferralResult.deferredEntries.map(e => e.name));
248
+ },
249
+ }, deps.logger));
234
250
  return {
235
251
  wrappers,
236
252
  contextEngineRef,
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { SettingsManager, } from "@mariozechner/pi-coding-agent";
17
17
  import { formatSessionKey, } from "@comis/core";
18
- import { applyToolDeferral, buildDeferredToolsContext, extractRecentlyUsedToolNames, resolveModelTier, CORE_TOOLS } from "./tool-deferral.js";
18
+ import { applyToolDeferral, buildDeferredToolsContext, createDiscoverTool, createAutoDiscoveryStubs, extractRecentlyUsedToolNames, resolveModelTier, CORE_TOOLS } from "./tool-deferral.js";
19
19
  import { getOrCreateDiscoveryTracker } from "./discovery-tracker.js";
20
20
  import { getOrCreateTracker, DEFAULT_LIFECYCLE_CONFIG } from "./tool-lifecycle.js";
21
21
  import { isAnthropicFamily, isGoogleFamily } from "../provider/capabilities.js";
@@ -289,10 +289,36 @@ export async function assembleTools(params) {
289
289
  : undefined,
290
290
  };
291
291
  const deferralResult = applyToolDeferral(mergedCustomTools, contextWindow, deferralCtx, deps.logger, deps.embeddingPort, config.skills?.toolDiscovery);
292
+ // Rebuild discover_tools with the now-known active set so it can answer
293
+ // "already active" queries correctly. Post-deferral set (active +
294
+ // discovered) is the only factually-accurate set -- using mergedCustomTools
295
+ // (pre-deferral) would false-positive on currently-deferred tools and tell
296
+ // the agent "call directly, no discovery needed" for tools that aren't
297
+ // actually loaded. See design §4.2, §5.5, decision log row 4:
298
+ // .planning/design/discover-tools-bm25-fallback-fix.md
299
+ if (deferralResult.discoverTool) {
300
+ const activeAfterDeferral = new Set([
301
+ ...deferralResult.activeTools.map(t => t.name),
302
+ ...deferralResult.discoveredTools.map(t => t.name),
303
+ ]);
304
+ deferralResult.discoverTool = createDiscoverTool(deferralResult.deferredEntries, deps.logger, deps.embeddingPort, config.skills?.toolDiscovery, activeAfterDeferral);
305
+ }
292
306
  mergedCustomTools = [...deferralResult.activeTools, ...deferralResult.discoveredTools];
293
307
  if (deferralResult.discoverTool) {
294
308
  mergedCustomTools.push(deferralResult.discoverTool);
295
309
  }
310
+ // 7b. Auto-discovery stubs for deferred tools.
311
+ // Lightweight stubs so the SDK's agent-loop finds deferred tools during
312
+ // tool resolution. Prevents "Tool X not found" errors when skills
313
+ // reference deferred MCP tools directly. Stubs are filtered from the
314
+ // API request by createStubFilterInjector (see stream-wrappers/
315
+ // stub-filter-injector.ts) -- zero token cost guarantee.
316
+ // Position: BEFORE JIT guide wrapping / schema pruning / sideEffects
317
+ // wrapping -- stubs receive all downstream wrappers automatically.
318
+ if (deferralResult.deferredEntries.length > 0) {
319
+ const stubs = createAutoDiscoveryStubs(deferralResult.deferredEntries, discoveryTracker, deps.logger);
320
+ mergedCustomTools.push(...stubs);
321
+ }
296
322
  // Build deferred context for dynamic preamble injection
297
323
  let deferredContext = "";
298
324
  if (deferralResult.deferredEntries.length > 0) {