profclaw 2.3.0 → 2.4.0

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 (60) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +5 -2
  3. package/config/pricing.yml +26 -2
  4. package/dist/agents/command-sandbox.d.ts +93 -0
  5. package/dist/agents/command-sandbox.js +235 -0
  6. package/dist/agents/executor-runner.d.ts +135 -0
  7. package/dist/agents/executor-runner.js +303 -0
  8. package/dist/agents/executor.d.ts +2 -0
  9. package/dist/agents/executor.js +35 -2
  10. package/dist/agents/output-truncator.d.ts +59 -0
  11. package/dist/agents/output-truncator.js +128 -0
  12. package/dist/agents/receipt/receipt-cli.d.ts +43 -0
  13. package/dist/agents/receipt/receipt-cli.js +82 -0
  14. package/dist/agents/receipt/receipt-types.d.ts +136 -0
  15. package/dist/agents/receipt/receipt-types.js +19 -0
  16. package/dist/agents/receipt/receipt-verify.d.ts +19 -0
  17. package/dist/agents/receipt/receipt-verify.js +176 -0
  18. package/dist/agents/receipt/receipt-view.d.ts +29 -0
  19. package/dist/agents/receipt/receipt-view.js +227 -0
  20. package/dist/agents/receipt/receipt-writer.d.ts +47 -0
  21. package/dist/agents/receipt/receipt-writer.js +186 -0
  22. package/dist/agents/result-store.d.ts +6 -0
  23. package/dist/agents/result-store.js +43 -2
  24. package/dist/agents/session-diff.d.ts +6 -0
  25. package/dist/agents/session-diff.js +10 -19
  26. package/dist/agents/tool-selector.d.ts +123 -0
  27. package/dist/agents/tool-selector.js +321 -0
  28. package/dist/agents/verified-run-git.d.ts +49 -0
  29. package/dist/agents/verified-run-git.js +82 -0
  30. package/dist/agents/verified-run.d.ts +149 -0
  31. package/dist/agents/verified-run.js +402 -0
  32. package/dist/agents/verifier.d.ts +64 -0
  33. package/dist/agents/verifier.js +186 -0
  34. package/dist/agents/worktree-tools.d.ts +47 -0
  35. package/dist/agents/worktree-tools.js +246 -0
  36. package/dist/chat/agentic-executor.js +14 -6
  37. package/dist/chat/execution/sandbox.d.ts +8 -0
  38. package/dist/chat/execution/sandbox.js +25 -1
  39. package/dist/chat/execution/tools/tts-speak.d.ts +2 -2
  40. package/dist/chat/meta-tool-handlers.d.ts +18 -0
  41. package/dist/chat/meta-tool-handlers.js +51 -0
  42. package/dist/chat/system-prompts.js +6 -1
  43. package/dist/chat/tool-handler.d.ts +2 -0
  44. package/dist/chat/tool-handler.js +47 -3
  45. package/dist/cli/commands/receipt.d.ts +7 -0
  46. package/dist/cli/commands/receipt.js +90 -0
  47. package/dist/cli/commands/run.d.ts +28 -0
  48. package/dist/cli/commands/run.js +204 -0
  49. package/dist/cli/index.js +4 -0
  50. package/dist/costs/token-tracker.d.ts +10 -1
  51. package/dist/costs/token-tracker.js +37 -5
  52. package/dist/providers/ai-sdk.js +33 -37
  53. package/dist/providers/core/models.js +59 -17
  54. package/dist/providers/core/types.d.ts +4 -0
  55. package/dist/providers/prompt-cache.d.ts +68 -0
  56. package/dist/providers/prompt-cache.js +165 -0
  57. package/dist/providers/smart-router.js +11 -3
  58. package/dist/routes/chat.js +22 -5
  59. package/dist/server.js +3 -0
  60. package/package.json +1 -1
@@ -6,13 +6,14 @@
6
6
  *
7
7
  * @see https://sdk.vercel.ai/docs
8
8
  */
9
- import { generateText, streamText, tool as createTool, jsonSchema } from 'ai';
9
+ import { generateText, streamText, stepCountIs, tool as createTool, jsonSchema } from 'ai';
10
10
  // Provider SDKs are lazily imported inside ensureProvider() to reduce startup memory
11
11
  // (each SDK is ~15MB; loading all 6 at startup costs ~100MB even if unused)
12
12
  import { zodToJsonSchema } from 'zod-to-json-schema';
13
13
  import { randomUUID } from 'node:crypto';
14
14
  import { logger } from '../utils/logger.js';
15
15
  import { normalizeToolSchema } from './schema-utils.js';
16
+ import { aggregateStepUsage, applyAnthropicMessageCache, applyAnthropicToolCache, calculateCachedInputCost, getCacheConfigForModel, extractCacheUsage, } from './prompt-cache.js';
16
17
  // AI provider resilience settings (configurable via env)
17
18
  const AI_TIMEOUT_MS = parseInt(process.env['AI_PROVIDER_TIMEOUT_MS'] ?? '120000', 10);
18
19
  const AI_MAX_RETRIES = parseInt(process.env['AI_MAX_RETRIES'] ?? '2', 10);
@@ -866,7 +867,7 @@ class AIProviderManager {
866
867
  try {
867
868
  const result = await withRetry(() => generateText({
868
869
  model,
869
- messages,
870
+ messages: applyAnthropicMessageCache(messages, modelRef.provider),
870
871
  temperature: request.temperature ?? 0.7,
871
872
  maxOutputTokens: request.maxTokens ?? 4096,
872
873
  abortSignal: AbortSignal.timeout(AI_TIMEOUT_MS),
@@ -874,12 +875,9 @@ class AIProviderManager {
874
875
  const duration = Date.now() - startTime;
875
876
  const modelInfo = this.getModelInfo(modelRef.model);
876
877
  // Calculate cost - AI SDK v6 uses usage.totalTokens and similar
877
- const promptTokens = result.usage?.promptTokens ??
878
- result.usage?.inputTokens ?? 0;
879
- const completionTokens = result.usage?.completionTokens ??
880
- result.usage?.outputTokens ?? 0;
878
+ const { promptTokens, completionTokens, cacheReadTokens, cacheWriteTokens } = extractCacheUsage(result.usage);
881
879
  const cost = modelInfo
882
- ? (promptTokens / 1_000_000) * modelInfo.costPer1MInput +
880
+ ? calculateCachedInputCost(promptTokens, cacheReadTokens, cacheWriteTokens, modelInfo.costPer1MInput, getCacheConfigForModel(modelInfo.id)) +
883
881
  (completionTokens / 1_000_000) * modelInfo.costPer1MOutput
884
882
  : 0;
885
883
  return {
@@ -893,6 +891,8 @@ class AIProviderManager {
893
891
  completionTokens,
894
892
  totalTokens: promptTokens + completionTokens,
895
893
  cost,
894
+ cacheReadTokens,
895
+ cacheWriteTokens,
896
896
  },
897
897
  duration,
898
898
  };
@@ -923,7 +923,7 @@ class AIProviderManager {
923
923
  try {
924
924
  const result = streamText({
925
925
  model,
926
- messages,
926
+ messages: applyAnthropicMessageCache(messages, modelRef.provider),
927
927
  temperature: request.temperature ?? 0.7,
928
928
  maxOutputTokens: request.maxTokens ?? 4096,
929
929
  abortSignal: AbortSignal.timeout(AI_TIMEOUT_MS),
@@ -953,12 +953,9 @@ class AIProviderManager {
953
953
  const usage = await result.usage;
954
954
  const duration = Date.now() - startTime;
955
955
  const modelInfo = this.getModelInfo(modelRef.model);
956
- const promptTokens = usage?.promptTokens ??
957
- usage?.inputTokens ?? 0;
958
- const completionTokens = usage?.completionTokens ??
959
- usage?.outputTokens ?? 0;
956
+ const { promptTokens, completionTokens, cacheReadTokens, cacheWriteTokens } = extractCacheUsage(usage);
960
957
  const cost = modelInfo
961
- ? (promptTokens / 1_000_000) * modelInfo.costPer1MInput +
958
+ ? calculateCachedInputCost(promptTokens, cacheReadTokens, cacheWriteTokens, modelInfo.costPer1MInput, getCacheConfigForModel(modelInfo.id)) +
962
959
  (completionTokens / 1_000_000) * modelInfo.costPer1MOutput
963
960
  : 0;
964
961
  return {
@@ -972,6 +969,8 @@ class AIProviderManager {
972
969
  completionTokens,
973
970
  totalTokens: promptTokens + completionTokens,
974
971
  cost,
972
+ cacheReadTokens,
973
+ cacheWriteTokens,
975
974
  },
976
975
  duration,
977
976
  };
@@ -1039,10 +1038,10 @@ class AIProviderManager {
1039
1038
  try {
1040
1039
  const result = await withRetry(() => generateText({
1041
1040
  model,
1042
- messages,
1041
+ messages: applyAnthropicMessageCache(messages, modelRef.provider),
1043
1042
  temperature: request.temperature ?? 0.7,
1044
1043
  maxOutputTokens: request.maxTokens ?? 4096,
1045
- tools: useNativeTools ? aiSdkTools : undefined,
1044
+ tools: useNativeTools ? applyAnthropicToolCache(aiSdkTools, modelRef.provider) : undefined,
1046
1045
  abortSignal: AbortSignal.timeout(AI_TIMEOUT_MS),
1047
1046
  }));
1048
1047
  let finalContent = result.text;
@@ -1101,12 +1100,9 @@ class AIProviderManager {
1101
1100
  }
1102
1101
  const duration = Date.now() - startTime;
1103
1102
  const modelInfo = this.getModelInfo(modelRef.model);
1104
- const promptTokens = result.usage?.promptTokens ??
1105
- result.usage?.inputTokens ?? 0;
1106
- const completionTokens = result.usage?.completionTokens ??
1107
- result.usage?.outputTokens ?? 0;
1103
+ const { promptTokens, completionTokens, cacheReadTokens, cacheWriteTokens } = extractCacheUsage(result.usage);
1108
1104
  const cost = modelInfo
1109
- ? (promptTokens / 1_000_000) * modelInfo.costPer1MInput +
1105
+ ? calculateCachedInputCost(promptTokens, cacheReadTokens, cacheWriteTokens, modelInfo.costPer1MInput, getCacheConfigForModel(modelInfo.id)) +
1110
1106
  (completionTokens / 1_000_000) * modelInfo.costPer1MOutput
1111
1107
  : 0;
1112
1108
  return {
@@ -1120,6 +1116,8 @@ class AIProviderManager {
1120
1116
  completionTokens,
1121
1117
  totalTokens: promptTokens + completionTokens,
1122
1118
  cost,
1119
+ cacheReadTokens,
1120
+ cacheWriteTokens,
1123
1121
  },
1124
1122
  duration,
1125
1123
  toolCalls: detectedToolCalls.length > 0 ? detectedToolCalls : undefined,
@@ -1293,22 +1291,19 @@ class AIProviderManager {
1293
1291
  try {
1294
1292
  const result = await withRetry(() => generateText({
1295
1293
  model,
1296
- messages,
1297
- tools: Object.keys(tools).length > 0 ? tools : undefined,
1294
+ messages: applyAnthropicMessageCache(messages, modelRef.provider),
1295
+ tools: Object.keys(tools).length > 0 ? applyAnthropicToolCache(tools, modelRef.provider) : undefined,
1298
1296
  temperature: request.temperature ?? 0.7,
1299
1297
  maxOutputTokens: request.maxTokens ?? 4096,
1300
- maxSteps: request.maxToolRoundtrips ?? 5,
1298
+ stopWhen: stepCountIs(request.maxToolRoundtrips ?? 5),
1301
1299
  abortSignal: AbortSignal.timeout(AI_TIMEOUT_MS),
1302
1300
  }));
1303
1301
  const duration = Date.now() - startTime;
1304
1302
  const modelInfo = this.getModelInfo(modelRef.model);
1305
- // Calculate usage
1306
- const promptTokens = result.usage?.promptTokens ??
1307
- result.usage?.inputTokens ?? 0;
1308
- const completionTokens = result.usage?.completionTokens ??
1309
- result.usage?.outputTokens ?? 0;
1303
+ // Calculate usage, summed across all steps (result.usage is last-step only)
1304
+ const { promptTokens, completionTokens, cacheReadTokens, cacheWriteTokens } = aggregateStepUsage(result.steps, result.usage);
1310
1305
  const cost = modelInfo
1311
- ? (promptTokens / 1_000_000) * modelInfo.costPer1MInput +
1306
+ ? calculateCachedInputCost(promptTokens, cacheReadTokens, cacheWriteTokens, modelInfo.costPer1MInput, getCacheConfigForModel(modelInfo.id)) +
1312
1307
  (completionTokens / 1_000_000) * modelInfo.costPer1MOutput
1313
1308
  : 0;
1314
1309
  // Check if any tool calls require approval
@@ -1328,6 +1323,8 @@ class AIProviderManager {
1328
1323
  completionTokens,
1329
1324
  totalTokens: promptTokens + completionTokens,
1330
1325
  cost,
1326
+ cacheReadTokens,
1327
+ cacheWriteTokens,
1331
1328
  },
1332
1329
  duration,
1333
1330
  toolCalls: toolCallHistory.length > 0 ? toolCallHistory : undefined,
@@ -1438,11 +1435,11 @@ class AIProviderManager {
1438
1435
  try {
1439
1436
  const result = streamText({
1440
1437
  model,
1441
- messages,
1442
- tools: Object.keys(tools).length > 0 ? tools : undefined,
1438
+ messages: applyAnthropicMessageCache(messages, modelRef.provider),
1439
+ tools: Object.keys(tools).length > 0 ? applyAnthropicToolCache(tools, modelRef.provider) : undefined,
1443
1440
  temperature: request.temperature ?? 0.7,
1444
1441
  maxOutputTokens: request.maxTokens ?? 4096,
1445
- maxSteps: request.maxToolRoundtrips ?? 5,
1442
+ stopWhen: stepCountIs(request.maxToolRoundtrips ?? 5),
1446
1443
  abortSignal: AbortSignal.timeout(AI_TIMEOUT_MS),
1447
1444
  });
1448
1445
  let fullText = '';
@@ -1453,12 +1450,9 @@ class AIProviderManager {
1453
1450
  const usage = await result.usage;
1454
1451
  const duration = Date.now() - startTime;
1455
1452
  const modelInfo = this.getModelInfo(modelRef.model);
1456
- const promptTokens = usage?.promptTokens ??
1457
- usage?.inputTokens ?? 0;
1458
- const completionTokens = usage?.completionTokens ??
1459
- usage?.outputTokens ?? 0;
1453
+ const { promptTokens, completionTokens, cacheReadTokens, cacheWriteTokens } = extractCacheUsage(usage);
1460
1454
  const cost = modelInfo
1461
- ? (promptTokens / 1_000_000) * modelInfo.costPer1MInput +
1455
+ ? calculateCachedInputCost(promptTokens, cacheReadTokens, cacheWriteTokens, modelInfo.costPer1MInput, getCacheConfigForModel(modelInfo.id)) +
1462
1456
  (completionTokens / 1_000_000) * modelInfo.costPer1MOutput
1463
1457
  : 0;
1464
1458
  const pendingApprovals = toolResultHistory.filter(r => r.approvalRequired);
@@ -1477,6 +1471,8 @@ class AIProviderManager {
1477
1471
  completionTokens,
1478
1472
  totalTokens: promptTokens + completionTokens,
1479
1473
  cost,
1474
+ cacheReadTokens,
1475
+ cacheWriteTokens,
1480
1476
  },
1481
1477
  duration,
1482
1478
  toolCalls: toolCallHistory.length > 0 ? toolCallHistory : undefined,
@@ -7,8 +7,11 @@ import { ProviderType } from './types.js';
7
7
  // Model Aliases
8
8
  export const MODEL_ALIASES = {
9
9
  // Anthropic
10
- opus: { provider: 'anthropic', model: 'claude-opus-4-6' },
11
- sonnet: { provider: 'anthropic', model: 'claude-sonnet-4-5-20250929' },
10
+ opus: { provider: 'anthropic', model: 'claude-opus-5-5' },
11
+ sonnet: { provider: 'anthropic', model: 'claude-sonnet-5' },
12
+ fable: { provider: 'anthropic', model: 'claude-fable-5-1' },
13
+ 'opus-legacy': { provider: 'anthropic', model: 'claude-opus-4-6' },
14
+ 'sonnet-legacy': { provider: 'anthropic', model: 'claude-sonnet-4-5-20250929' },
12
15
  haiku: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001' },
13
16
  // OpenAI
14
17
  gpt: { provider: 'openai', model: 'gpt-4o' },
@@ -113,6 +116,45 @@ export const MODEL_ALIASES = {
113
116
  // Model Catalog
114
117
  export const MODEL_CATALOG = [
115
118
  // Anthropic - All support native function calling
119
+ // Current Anthropic models. ids, context, max output and prices verified against
120
+ // https://platform.claude.com/docs/en/about-claude/models/overview (2026-09-26).
121
+ {
122
+ id: 'claude-fable-5-1',
123
+ name: 'Claude Fable 5.1',
124
+ provider: 'anthropic',
125
+ contextWindow: 1000000,
126
+ maxOutput: 128000,
127
+ supportsVision: true,
128
+ supportsStreaming: true,
129
+ supportsTools: true,
130
+ costPer1MInput: 10,
131
+ costPer1MOutput: 50,
132
+ },
133
+ {
134
+ id: 'claude-opus-5-5',
135
+ name: 'Claude Opus 5.5',
136
+ provider: 'anthropic',
137
+ contextWindow: 1000000,
138
+ maxOutput: 128000,
139
+ supportsVision: true,
140
+ supportsStreaming: true,
141
+ supportsTools: true,
142
+ costPer1MInput: 4,
143
+ costPer1MOutput: 20,
144
+ },
145
+ {
146
+ id: 'claude-sonnet-5',
147
+ name: 'Claude Sonnet 5',
148
+ provider: 'anthropic',
149
+ contextWindow: 1000000,
150
+ maxOutput: 128000,
151
+ supportsVision: true,
152
+ supportsStreaming: true,
153
+ supportsTools: true,
154
+ costPer1MInput: 2,
155
+ costPer1MOutput: 10,
156
+ },
157
+ // Legacy entries kept because other code and tests reference them.
116
158
  {
117
159
  id: 'claude-opus-4-6',
118
160
  name: 'Claude Opus 4.6',
@@ -142,12 +184,12 @@ export const MODEL_CATALOG = [
142
184
  name: 'Claude Haiku 4.5',
143
185
  provider: 'anthropic',
144
186
  contextWindow: 200000,
145
- maxOutput: 8192,
187
+ maxOutput: 64000,
146
188
  supportsVision: true,
147
189
  supportsStreaming: true,
148
190
  supportsTools: true,
149
- costPer1MInput: 0.25,
150
- costPer1MOutput: 1.25,
191
+ costPer1MInput: 1,
192
+ costPer1MOutput: 5,
151
193
  },
152
194
  // OpenAI - GPT-4+ supports function calling
153
195
  {
@@ -381,23 +423,22 @@ export const MODEL_CATALOG = [
381
423
  costPer1MInput: 30.0,
382
424
  costPer1MOutput: 60.0,
383
425
  },
384
- // Qwen 3 (local) — best small model for tool calling
426
+ // Gemma 4 (local): supports native tool calling + multimodal
385
427
  {
386
- id: 'qwen3:4b',
387
- name: 'Qwen 3 4B (Local)',
428
+ id: 'gemma4:e4b',
429
+ name: 'Gemma 4 E4B (Local)',
388
430
  provider: 'ollama',
389
- contextWindow: 32768,
431
+ contextWindow: 128000,
390
432
  maxOutput: 8192,
391
- supportsVision: false,
433
+ supportsVision: true,
392
434
  supportsStreaming: true,
393
435
  supportsTools: true,
394
436
  costPer1MInput: 0,
395
437
  costPer1MOutput: 0,
396
438
  },
397
- // Gemma 4 (local) — supports native tool calling + multimodal
398
439
  {
399
- id: 'gemma4:e4b',
400
- name: 'Gemma 4 E4B (Local)',
440
+ id: 'gemma4:26b',
441
+ name: 'Gemma 4 26B (Local)',
401
442
  provider: 'ollama',
402
443
  contextWindow: 128000,
403
444
  maxOutput: 8192,
@@ -407,13 +448,14 @@ export const MODEL_CATALOG = [
407
448
  costPer1MInput: 0,
408
449
  costPer1MOutput: 0,
409
450
  },
451
+ // Qwen 3 (local): best small model for tool calling
410
452
  {
411
- id: 'gemma4:26b',
412
- name: 'Gemma 4 26B (Local)',
453
+ id: 'qwen3:4b',
454
+ name: 'Qwen 3 4B (Local)',
413
455
  provider: 'ollama',
414
- contextWindow: 128000,
456
+ contextWindow: 32768,
415
457
  maxOutput: 8192,
416
- supportsVision: true,
458
+ supportsVision: false,
417
459
  supportsStreaming: true,
418
460
  supportsTools: true,
419
461
  costPer1MInput: 0,
@@ -55,6 +55,10 @@ export interface ChatResponse {
55
55
  completionTokens: number;
56
56
  totalTokens: number;
57
57
  cost: number;
58
+ /** Prompt tokens served from the provider cache (subset of promptTokens) */
59
+ cacheReadTokens?: number;
60
+ /** Prompt tokens written to the provider cache (subset of promptTokens) */
61
+ cacheWriteTokens?: number;
58
62
  };
59
63
  duration: number;
60
64
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Prompt caching helpers
3
+ *
4
+ * Anthropic requires explicit cache breakpoints (providerOptions.anthropic.cacheControl).
5
+ * The cached prefix order is: tools, then system, then messages. A breakpoint on the
6
+ * last system message therefore caches tool schemas and the system prompt together;
7
+ * an extra breakpoint on the last tool lets the tool block hit even if the system
8
+ * prompt changes. OpenAI caches automatically and only reports cached token counts.
9
+ *
10
+ * Cache multipliers are relative to the base input price and are env-configurable.
11
+ */
12
+ import type { ModelMessage, ToolSet } from 'ai';
13
+ export interface PromptCacheConfig {
14
+ enabled: boolean;
15
+ ttl: '5m' | '1h';
16
+ /** Cost of a cache read relative to base input price */
17
+ readMultiplier: number;
18
+ /** Cost of a cache write relative to base input price */
19
+ writeMultiplier: number;
20
+ }
21
+ export interface CacheUsage {
22
+ /** Total prompt tokens, including cached reads and writes */
23
+ promptTokens: number;
24
+ completionTokens: number;
25
+ cacheReadTokens: number;
26
+ cacheWriteTokens: number;
27
+ }
28
+ /** Read cache settings from env on each call so tests and runtime changes apply. */
29
+ export declare function getPromptCacheConfig(env?: NodeJS.ProcessEnv): PromptCacheConfig;
30
+ /**
31
+ * Cache config for one model. An explicit PROFCLAW_CACHE_READ_MULTIPLIER always wins,
32
+ * otherwise the model's documented read multiplier applies.
33
+ */
34
+ export declare function getCacheConfigForModel(model: string, env?: NodeJS.ProcessEnv): PromptCacheConfig;
35
+ /**
36
+ * Add cache breakpoints to the leading system block: one on the first system message
37
+ * (the stable prompt) and one on the last leading system message. Callers put volatile
38
+ * per-request context in a later system message so the first breakpoint still hits
39
+ * across requests. History after the system block stays outside the cached prefix.
40
+ * Returns a new array; inputs are not mutated.
41
+ */
42
+ export declare function applyAnthropicMessageCache<T extends ModelMessage>(messages: T[], provider: string, config?: PromptCacheConfig): T[];
43
+ /**
44
+ * Return tools in a deterministic (name-sorted) order with a cache breakpoint on the
45
+ * last one. Object key order in the request body affects the prefix bytes, so sorting
46
+ * keeps the tool block byte-stable across steps and sessions.
47
+ */
48
+ export declare function applyAnthropicToolCache<T extends ToolSet>(tools: T, provider: string, config?: PromptCacheConfig): T;
49
+ /**
50
+ * Normalize AI SDK usage (v6 shape, legacy promptTokens shape, or raw provider
51
+ * counters) into total prompt tokens plus cache read/write counts.
52
+ * In AI SDK v6, inputTokens already includes cached reads and writes.
53
+ */
54
+ export declare function extractCacheUsage(usage: unknown): CacheUsage;
55
+ /**
56
+ * Sum usage across multi-step runs (tokens plus cache read/write). Falls back to
57
+ * `fallback` (the run-level usage) when there are no per-step usages, because
58
+ * result.usage only reflects the last step in AI SDK v6.
59
+ */
60
+ export declare function aggregateStepUsage(steps: unknown, fallback: unknown): CacheUsage;
61
+ /**
62
+ * Input cost in USD for a prompt that may include cached tokens.
63
+ * Uncached tokens bill at the base rate, reads and writes at the multiplied rate.
64
+ */
65
+ export declare function calculateCachedInputCost(promptTokens: number, cacheReadTokens: number, cacheWriteTokens: number, inputRatePer1M: number, config?: PromptCacheConfig): number;
66
+ /** Fraction of prompt tokens served from cache (0 when there were no prompt tokens). */
67
+ export declare function cacheHitRate(cacheReadTokens: number, promptTokens: number): number;
68
+ //# sourceMappingURL=prompt-cache.d.ts.map
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Prompt caching helpers
3
+ *
4
+ * Anthropic requires explicit cache breakpoints (providerOptions.anthropic.cacheControl).
5
+ * The cached prefix order is: tools, then system, then messages. A breakpoint on the
6
+ * last system message therefore caches tool schemas and the system prompt together;
7
+ * an extra breakpoint on the last tool lets the tool block hit even if the system
8
+ * prompt changes. OpenAI caches automatically and only reports cached token counts.
9
+ *
10
+ * Cache multipliers are relative to the base input price and are env-configurable.
11
+ */
12
+ const DEFAULT_READ_MULTIPLIER = 0.1;
13
+ const DEFAULT_WRITE_MULTIPLIER = 1.25;
14
+ function parseMultiplier(raw, fallback) {
15
+ if (raw === undefined || raw.trim() === '')
16
+ return fallback;
17
+ const parsed = Number(raw);
18
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
19
+ }
20
+ /** Read cache settings from env on each call so tests and runtime changes apply. */
21
+ export function getPromptCacheConfig(env = process.env) {
22
+ const disabled = (env['PROFCLAW_PROMPT_CACHE'] ?? '').toLowerCase();
23
+ return {
24
+ enabled: !['0', 'false', 'off', 'no'].includes(disabled),
25
+ ttl: env['PROFCLAW_PROMPT_CACHE_TTL'] === '1h' ? '1h' : '5m',
26
+ readMultiplier: parseMultiplier(env['PROFCLAW_CACHE_READ_MULTIPLIER'], DEFAULT_READ_MULTIPLIER),
27
+ writeMultiplier: parseMultiplier(env['PROFCLAW_CACHE_WRITE_MULTIPLIER'], DEFAULT_WRITE_MULTIPLIER),
28
+ };
29
+ }
30
+ /**
31
+ * Cache reads bill at 5% of the base input price on Opus 5.5 and 2.5% on Fable 5.1,
32
+ * versus the 10% default elsewhere (Anthropic models overview, pricing notes).
33
+ */
34
+ const MODEL_READ_MULTIPLIERS = [
35
+ [/fable-5-1/, 0.025],
36
+ [/opus-5-5/, 0.05],
37
+ ];
38
+ /**
39
+ * Cache config for one model. An explicit PROFCLAW_CACHE_READ_MULTIPLIER always wins,
40
+ * otherwise the model's documented read multiplier applies.
41
+ */
42
+ export function getCacheConfigForModel(model, env = process.env) {
43
+ const base = getPromptCacheConfig(env);
44
+ if ((env['PROFCLAW_CACHE_READ_MULTIPLIER'] ?? '').trim() !== '')
45
+ return base;
46
+ const match = MODEL_READ_MULTIPLIERS.find(([pattern]) => pattern.test(model));
47
+ return match ? { ...base, readMultiplier: match[1] } : base;
48
+ }
49
+ function cacheProviderOptions(config) {
50
+ return {
51
+ anthropic: {
52
+ cacheControl: config.ttl === '1h' ? { type: 'ephemeral', ttl: '1h' } : { type: 'ephemeral' },
53
+ },
54
+ };
55
+ }
56
+ /**
57
+ * Add cache breakpoints to the leading system block: one on the first system message
58
+ * (the stable prompt) and one on the last leading system message. Callers put volatile
59
+ * per-request context in a later system message so the first breakpoint still hits
60
+ * across requests. History after the system block stays outside the cached prefix.
61
+ * Returns a new array; inputs are not mutated.
62
+ */
63
+ export function applyAnthropicMessageCache(messages, provider, config = getPromptCacheConfig()) {
64
+ if (provider !== 'anthropic' || !config.enabled)
65
+ return messages;
66
+ let lastSystem = -1;
67
+ for (let i = 0; i < messages.length && messages[i].role === 'system'; i++) {
68
+ lastSystem = i;
69
+ }
70
+ if (lastSystem < 0)
71
+ return messages;
72
+ return messages.map((msg, i) => i === 0 || i === lastSystem
73
+ ? { ...msg, providerOptions: { ...msg.providerOptions, ...cacheProviderOptions(config) } }
74
+ : msg);
75
+ }
76
+ /**
77
+ * Return tools in a deterministic (name-sorted) order with a cache breakpoint on the
78
+ * last one. Object key order in the request body affects the prefix bytes, so sorting
79
+ * keeps the tool block byte-stable across steps and sessions.
80
+ */
81
+ export function applyAnthropicToolCache(tools, provider, config = getPromptCacheConfig()) {
82
+ if (provider !== 'anthropic' || !config.enabled)
83
+ return tools;
84
+ const names = Object.keys(tools).sort();
85
+ if (names.length === 0)
86
+ return tools;
87
+ const sorted = {};
88
+ names.forEach((name, i) => {
89
+ const def = tools[name];
90
+ sorted[name] = i === names.length - 1
91
+ ? { ...def, providerOptions: { ...def.providerOptions, ...cacheProviderOptions(config) } }
92
+ : def;
93
+ });
94
+ return sorted;
95
+ }
96
+ function num(value) {
97
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
98
+ }
99
+ function rec(value) {
100
+ return typeof value === 'object' && value !== null ? value : undefined;
101
+ }
102
+ /**
103
+ * Normalize AI SDK usage (v6 shape, legacy promptTokens shape, or raw provider
104
+ * counters) into total prompt tokens plus cache read/write counts.
105
+ * In AI SDK v6, inputTokens already includes cached reads and writes.
106
+ */
107
+ export function extractCacheUsage(usage) {
108
+ const u = rec(usage);
109
+ if (!u)
110
+ return { promptTokens: 0, completionTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
111
+ const details = rec(u['inputTokenDetails']);
112
+ const raw = rec(u['raw']);
113
+ const cacheReadTokens = num(details?.['cacheReadTokens']) ??
114
+ num(u['cachedInputTokens']) ??
115
+ num(raw?.['cache_read_input_tokens']) ??
116
+ num(rec(raw?.['prompt_tokens_details'])?.['cached_tokens']) ??
117
+ 0;
118
+ const cacheWriteTokens = num(details?.['cacheWriteTokens']) ??
119
+ num(raw?.['cache_creation_input_tokens']) ??
120
+ 0;
121
+ return {
122
+ promptTokens: num(u['inputTokens']) ?? num(u['promptTokens']) ?? 0,
123
+ completionTokens: num(u['outputTokens']) ?? num(u['completionTokens']) ?? 0,
124
+ cacheReadTokens,
125
+ cacheWriteTokens,
126
+ };
127
+ }
128
+ /**
129
+ * Sum usage across multi-step runs (tokens plus cache read/write). Falls back to
130
+ * `fallback` (the run-level usage) when there are no per-step usages, because
131
+ * result.usage only reflects the last step in AI SDK v6.
132
+ */
133
+ export function aggregateStepUsage(steps, fallback) {
134
+ const usages = Array.isArray(steps)
135
+ ? steps.map((s) => rec(s)?.['usage']).filter((u) => u !== undefined && u !== null)
136
+ : [];
137
+ if (usages.length === 0)
138
+ return extractCacheUsage(fallback);
139
+ const total = { promptTokens: 0, completionTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
140
+ for (const usage of usages) {
141
+ const u = extractCacheUsage(usage);
142
+ total.promptTokens += u.promptTokens;
143
+ total.completionTokens += u.completionTokens;
144
+ total.cacheReadTokens += u.cacheReadTokens;
145
+ total.cacheWriteTokens += u.cacheWriteTokens;
146
+ }
147
+ return total;
148
+ }
149
+ /**
150
+ * Input cost in USD for a prompt that may include cached tokens.
151
+ * Uncached tokens bill at the base rate, reads and writes at the multiplied rate.
152
+ */
153
+ export function calculateCachedInputCost(promptTokens, cacheReadTokens, cacheWriteTokens, inputRatePer1M, config = getPromptCacheConfig()) {
154
+ const cached = cacheReadTokens + cacheWriteTokens;
155
+ const uncached = Math.max(0, promptTokens - cached);
156
+ const effective = uncached +
157
+ cacheReadTokens * config.readMultiplier +
158
+ cacheWriteTokens * config.writeMultiplier;
159
+ return (effective / 1_000_000) * inputRatePer1M;
160
+ }
161
+ /** Fraction of prompt tokens served from cache (0 when there were no prompt tokens). */
162
+ export function cacheHitRate(cacheReadTokens, promptTokens) {
163
+ return promptTokens > 0 ? Math.min(1, cacheReadTokens / promptTokens) : 0;
164
+ }
165
+ //# sourceMappingURL=prompt-cache.js.map
@@ -328,9 +328,17 @@ function getCandidatesForTier(tier, availableProviders, signals) {
328
328
  });
329
329
  // Sort by tier strategy
330
330
  switch (tier) {
331
- case 'trivial':
332
- // Cheapest first (total cost = input + output rate)
333
- return eligible.sort((a, b) => (a.costPer1MInput + a.costPer1MOutput) - (b.costPer1MInput + b.costPer1MOutput));
331
+ case 'trivial': {
332
+ // Cheapest first (total cost = input + output rate). Ties (e.g. several free local
333
+ // models) prefer the configured free trivial model (default Gemma 4 via Ollama).
334
+ const preferred = process.env['SMART_ROUTER_TRIVIAL_PREFERRED_MODEL'] ?? 'gemma4:e4b';
335
+ return eligible.sort((a, b) => {
336
+ const diff = (a.costPer1MInput + a.costPer1MOutput) - (b.costPer1MInput + b.costPer1MOutput);
337
+ if (diff !== 0)
338
+ return diff;
339
+ return Number(b.id === preferred) - Number(a.id === preferred);
340
+ });
341
+ }
334
342
  case 'standard':
335
343
  // Best value: capability (context * tool support) per dollar
336
344
  return eligible.sort((a, b) => {
@@ -53,6 +53,17 @@ let getSessionModel;
53
53
  let streamAgenticChat;
54
54
  let getGroupChatManager;
55
55
  let trackChatUsage;
56
+ /** Best-effort removal of spilled tool-result temp files at request end. */
57
+ async function disposeToolHandler(handler) {
58
+ try {
59
+ await handler.dispose?.();
60
+ }
61
+ catch (error) {
62
+ logger.warn("[Chat] Tool handler dispose failed", {
63
+ error: error instanceof Error ? error.message : String(error),
64
+ });
65
+ }
66
+ }
56
67
  async function ensureChatRuntime() {
57
68
  if (!chatRuntimePromise) {
58
69
  chatRuntimePromise = Promise.all([
@@ -220,7 +231,10 @@ chatRoutes.post("/send", chatRateLimiter, contentFilter(), zValidator("json", z.
220
231
  // Track token usage for cost dashboard
221
232
  if (response.usage?.totalTokens && response.model) {
222
233
  await ensureChatRuntime();
223
- trackChatUsage(response.model, response.usage.totalTokens, response.usage.promptTokens, response.usage.completionTokens);
234
+ trackChatUsage(response.model, response.usage.totalTokens, response.usage.promptTokens, response.usage.completionTokens, {
235
+ cacheReadTokens: response.usage.cacheReadTokens,
236
+ cacheWriteTokens: response.usage.cacheWriteTokens,
237
+ });
224
238
  }
225
239
  return c.json({
226
240
  id: response.id,
@@ -1297,6 +1311,7 @@ chatRoutes.post("/conversations/:id/messages/with-tools", chatRateLimiter, conte
1297
1311
  });
1298
1312
  // Get any pending approvals
1299
1313
  const pendingApprovals = toolHandler.getPendingApprovals();
1314
+ await disposeToolHandler(toolHandler);
1300
1315
  const inferToolCallStatus = (result) => {
1301
1316
  if (!result || typeof result !== "object") {
1302
1317
  return "success";
@@ -1519,6 +1534,7 @@ chatRoutes.post("/with-tools", zValidator("json", z.object({
1519
1534
  });
1520
1535
  // Check for pending approvals
1521
1536
  const pendingApprovals = toolHandler.getPendingApprovals();
1537
+ await disposeToolHandler(toolHandler);
1522
1538
  return c.json({
1523
1539
  id: response.id,
1524
1540
  conversationId,
@@ -1787,10 +1803,8 @@ chatRoutes.post("/conversations/:id/messages/agentic", chatRateLimiter, contentF
1787
1803
  : undefined,
1788
1804
  toolCalls: collectedToolCalls.length > 0 ? collectedToolCalls : undefined,
1789
1805
  });
1790
- // Track in-memory usage for cost dashboard
1791
- if (totalTokens > 0 && finalModel) {
1792
- trackChatUsage(finalModel, totalTokens, inputTokensTotal, outputTokensTotal);
1793
- }
1806
+ // Cost tracking for agentic runs happens per step inside AgentExecutor;
1807
+ // tracking totals here as well would double count.
1794
1808
  // Send final saved message event
1795
1809
  await stream.write(`data: ${JSON.stringify({
1796
1810
  type: "message_saved",
@@ -1812,6 +1826,9 @@ chatRoutes.post("/conversations/:id/messages/agentic", chatRateLimiter, contentF
1812
1826
  timestamp: Date.now(),
1813
1827
  })}\n\n`);
1814
1828
  }
1829
+ finally {
1830
+ await disposeToolHandler(toolHandler);
1831
+ }
1815
1832
  });
1816
1833
  }
1817
1834
  catch (error) {
package/dist/server.js CHANGED
@@ -805,6 +805,9 @@ async function main() {
805
805
  const { initTokenTracker } = await import('./costs/token-tracker.js');
806
806
  initTokenTracker();
807
807
  appLog.info('Token tracker initialized');
808
+ // Sweep stale tool-result temp dirs left by crashed runs (fire-and-forget)
809
+ const { ResultStore } = await import('./agents/result-store.js');
810
+ void ResultStore.sweepStale();
808
811
  // Initialize agents from config
809
812
  const { getAgentRegistry } = await import('./adapters/registry.js');
810
813
  const registry = getAgentRegistry();