@animus-labs/cortex 0.2.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 (293) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -0
  3. package/dist/budget-guard.d.ts +75 -0
  4. package/dist/budget-guard.d.ts.map +1 -0
  5. package/dist/budget-guard.js +142 -0
  6. package/dist/budget-guard.js.map +1 -0
  7. package/dist/compaction/compaction.d.ts +99 -0
  8. package/dist/compaction/compaction.d.ts.map +1 -0
  9. package/dist/compaction/compaction.js +302 -0
  10. package/dist/compaction/compaction.js.map +1 -0
  11. package/dist/compaction/failsafe.d.ts +57 -0
  12. package/dist/compaction/failsafe.d.ts.map +1 -0
  13. package/dist/compaction/failsafe.js +135 -0
  14. package/dist/compaction/failsafe.js.map +1 -0
  15. package/dist/compaction/index.d.ts +381 -0
  16. package/dist/compaction/index.d.ts.map +1 -0
  17. package/dist/compaction/index.js +979 -0
  18. package/dist/compaction/index.js.map +1 -0
  19. package/dist/compaction/microcompaction.d.ts +219 -0
  20. package/dist/compaction/microcompaction.d.ts.map +1 -0
  21. package/dist/compaction/microcompaction.js +536 -0
  22. package/dist/compaction/microcompaction.js.map +1 -0
  23. package/dist/compaction/observational/buffering.d.ts +225 -0
  24. package/dist/compaction/observational/buffering.d.ts.map +1 -0
  25. package/dist/compaction/observational/buffering.js +354 -0
  26. package/dist/compaction/observational/buffering.js.map +1 -0
  27. package/dist/compaction/observational/constants.d.ts +70 -0
  28. package/dist/compaction/observational/constants.d.ts.map +1 -0
  29. package/dist/compaction/observational/constants.js +507 -0
  30. package/dist/compaction/observational/constants.js.map +1 -0
  31. package/dist/compaction/observational/index.d.ts +219 -0
  32. package/dist/compaction/observational/index.d.ts.map +1 -0
  33. package/dist/compaction/observational/index.js +641 -0
  34. package/dist/compaction/observational/index.js.map +1 -0
  35. package/dist/compaction/observational/observer.d.ts +97 -0
  36. package/dist/compaction/observational/observer.d.ts.map +1 -0
  37. package/dist/compaction/observational/observer.js +424 -0
  38. package/dist/compaction/observational/observer.js.map +1 -0
  39. package/dist/compaction/observational/recall-tool.d.ts +27 -0
  40. package/dist/compaction/observational/recall-tool.d.ts.map +1 -0
  41. package/dist/compaction/observational/recall-tool.js +93 -0
  42. package/dist/compaction/observational/recall-tool.js.map +1 -0
  43. package/dist/compaction/observational/reflector.d.ts +94 -0
  44. package/dist/compaction/observational/reflector.d.ts.map +1 -0
  45. package/dist/compaction/observational/reflector.js +167 -0
  46. package/dist/compaction/observational/reflector.js.map +1 -0
  47. package/dist/compaction/observational/types.d.ts +271 -0
  48. package/dist/compaction/observational/types.d.ts.map +1 -0
  49. package/dist/compaction/observational/types.js +15 -0
  50. package/dist/compaction/observational/types.js.map +1 -0
  51. package/dist/context-manager.d.ts +134 -0
  52. package/dist/context-manager.d.ts.map +1 -0
  53. package/dist/context-manager.js +170 -0
  54. package/dist/context-manager.js.map +1 -0
  55. package/dist/cortex-agent.d.ts +1020 -0
  56. package/dist/cortex-agent.d.ts.map +1 -0
  57. package/dist/cortex-agent.js +3589 -0
  58. package/dist/cortex-agent.js.map +1 -0
  59. package/dist/error-classifier.d.ts +48 -0
  60. package/dist/error-classifier.d.ts.map +1 -0
  61. package/dist/error-classifier.js +152 -0
  62. package/dist/error-classifier.js.map +1 -0
  63. package/dist/event-bridge.d.ts +166 -0
  64. package/dist/event-bridge.d.ts.map +1 -0
  65. package/dist/event-bridge.js +381 -0
  66. package/dist/event-bridge.js.map +1 -0
  67. package/dist/index.d.ts +55 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +57 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/mcp-client.d.ts +119 -0
  72. package/dist/mcp-client.d.ts.map +1 -0
  73. package/dist/mcp-client.js +474 -0
  74. package/dist/mcp-client.js.map +1 -0
  75. package/dist/model-wrapper.d.ts +58 -0
  76. package/dist/model-wrapper.d.ts.map +1 -0
  77. package/dist/model-wrapper.js +86 -0
  78. package/dist/model-wrapper.js.map +1 -0
  79. package/dist/noop-logger.d.ts +4 -0
  80. package/dist/noop-logger.d.ts.map +1 -0
  81. package/dist/noop-logger.js +8 -0
  82. package/dist/noop-logger.js.map +1 -0
  83. package/dist/prompt-diagnostics.d.ts +47 -0
  84. package/dist/prompt-diagnostics.d.ts.map +1 -0
  85. package/dist/prompt-diagnostics.js +230 -0
  86. package/dist/prompt-diagnostics.js.map +1 -0
  87. package/dist/provider-manager.d.ts +224 -0
  88. package/dist/provider-manager.d.ts.map +1 -0
  89. package/dist/provider-manager.js +563 -0
  90. package/dist/provider-manager.js.map +1 -0
  91. package/dist/provider-registry.d.ts +115 -0
  92. package/dist/provider-registry.d.ts.map +1 -0
  93. package/dist/provider-registry.js +305 -0
  94. package/dist/provider-registry.js.map +1 -0
  95. package/dist/schema-converter.d.ts +20 -0
  96. package/dist/schema-converter.d.ts.map +1 -0
  97. package/dist/schema-converter.js +48 -0
  98. package/dist/schema-converter.js.map +1 -0
  99. package/dist/skill-preprocessor.d.ts +46 -0
  100. package/dist/skill-preprocessor.d.ts.map +1 -0
  101. package/dist/skill-preprocessor.js +237 -0
  102. package/dist/skill-preprocessor.js.map +1 -0
  103. package/dist/skill-registry.d.ts +107 -0
  104. package/dist/skill-registry.d.ts.map +1 -0
  105. package/dist/skill-registry.js +330 -0
  106. package/dist/skill-registry.js.map +1 -0
  107. package/dist/skill-tool.d.ts +54 -0
  108. package/dist/skill-tool.d.ts.map +1 -0
  109. package/dist/skill-tool.js +88 -0
  110. package/dist/skill-tool.js.map +1 -0
  111. package/dist/sub-agent-manager.d.ts +90 -0
  112. package/dist/sub-agent-manager.d.ts.map +1 -0
  113. package/dist/sub-agent-manager.js +192 -0
  114. package/dist/sub-agent-manager.js.map +1 -0
  115. package/dist/token-estimator.d.ts +23 -0
  116. package/dist/token-estimator.d.ts.map +1 -0
  117. package/dist/token-estimator.js +27 -0
  118. package/dist/token-estimator.js.map +1 -0
  119. package/dist/tool-contract.d.ts +68 -0
  120. package/dist/tool-contract.d.ts.map +1 -0
  121. package/dist/tool-contract.js +35 -0
  122. package/dist/tool-contract.js.map +1 -0
  123. package/dist/tool-result-persistence.d.ts +89 -0
  124. package/dist/tool-result-persistence.d.ts.map +1 -0
  125. package/dist/tool-result-persistence.js +152 -0
  126. package/dist/tool-result-persistence.js.map +1 -0
  127. package/dist/tools/bash/index.d.ts +71 -0
  128. package/dist/tools/bash/index.d.ts.map +1 -0
  129. package/dist/tools/bash/index.js +485 -0
  130. package/dist/tools/bash/index.js.map +1 -0
  131. package/dist/tools/bash/interactive.d.ts +47 -0
  132. package/dist/tools/bash/interactive.d.ts.map +1 -0
  133. package/dist/tools/bash/interactive.js +262 -0
  134. package/dist/tools/bash/interactive.js.map +1 -0
  135. package/dist/tools/bash/safety.d.ts +149 -0
  136. package/dist/tools/bash/safety.d.ts.map +1 -0
  137. package/dist/tools/bash/safety.js +1116 -0
  138. package/dist/tools/bash/safety.js.map +1 -0
  139. package/dist/tools/edit.d.ts +57 -0
  140. package/dist/tools/edit.d.ts.map +1 -0
  141. package/dist/tools/edit.js +310 -0
  142. package/dist/tools/edit.js.map +1 -0
  143. package/dist/tools/glob.d.ts +34 -0
  144. package/dist/tools/glob.d.ts.map +1 -0
  145. package/dist/tools/glob.js +268 -0
  146. package/dist/tools/glob.js.map +1 -0
  147. package/dist/tools/grep.d.ts +53 -0
  148. package/dist/tools/grep.d.ts.map +1 -0
  149. package/dist/tools/grep.js +673 -0
  150. package/dist/tools/grep.js.map +1 -0
  151. package/dist/tools/index.d.ts +62 -0
  152. package/dist/tools/index.d.ts.map +1 -0
  153. package/dist/tools/index.js +52 -0
  154. package/dist/tools/index.js.map +1 -0
  155. package/dist/tools/read.d.ts +43 -0
  156. package/dist/tools/read.d.ts.map +1 -0
  157. package/dist/tools/read.js +459 -0
  158. package/dist/tools/read.js.map +1 -0
  159. package/dist/tools/runtime.d.ts +62 -0
  160. package/dist/tools/runtime.d.ts.map +1 -0
  161. package/dist/tools/runtime.js +116 -0
  162. package/dist/tools/runtime.js.map +1 -0
  163. package/dist/tools/shared/cwd-tracker.d.ts +32 -0
  164. package/dist/tools/shared/cwd-tracker.d.ts.map +1 -0
  165. package/dist/tools/shared/cwd-tracker.js +44 -0
  166. package/dist/tools/shared/cwd-tracker.js.map +1 -0
  167. package/dist/tools/shared/edit-history.d.ts +55 -0
  168. package/dist/tools/shared/edit-history.d.ts.map +1 -0
  169. package/dist/tools/shared/edit-history.js +72 -0
  170. package/dist/tools/shared/edit-history.js.map +1 -0
  171. package/dist/tools/shared/edit-matcher.d.ts +83 -0
  172. package/dist/tools/shared/edit-matcher.d.ts.map +1 -0
  173. package/dist/tools/shared/edit-matcher.js +359 -0
  174. package/dist/tools/shared/edit-matcher.js.map +1 -0
  175. package/dist/tools/shared/file-mutation-lock.d.ts +22 -0
  176. package/dist/tools/shared/file-mutation-lock.d.ts.map +1 -0
  177. package/dist/tools/shared/file-mutation-lock.js +35 -0
  178. package/dist/tools/shared/file-mutation-lock.js.map +1 -0
  179. package/dist/tools/shared/gitignore.d.ts +17 -0
  180. package/dist/tools/shared/gitignore.d.ts.map +1 -0
  181. package/dist/tools/shared/gitignore.js +59 -0
  182. package/dist/tools/shared/gitignore.js.map +1 -0
  183. package/dist/tools/shared/pdf-extractor.d.ts +96 -0
  184. package/dist/tools/shared/pdf-extractor.d.ts.map +1 -0
  185. package/dist/tools/shared/pdf-extractor.js +196 -0
  186. package/dist/tools/shared/pdf-extractor.js.map +1 -0
  187. package/dist/tools/shared/read-registry.d.ts +66 -0
  188. package/dist/tools/shared/read-registry.d.ts.map +1 -0
  189. package/dist/tools/shared/read-registry.js +65 -0
  190. package/dist/tools/shared/read-registry.js.map +1 -0
  191. package/dist/tools/shared/safe-env.d.ts +18 -0
  192. package/dist/tools/shared/safe-env.d.ts.map +1 -0
  193. package/dist/tools/shared/safe-env.js +70 -0
  194. package/dist/tools/shared/safe-env.js.map +1 -0
  195. package/dist/tools/sub-agent.d.ts +91 -0
  196. package/dist/tools/sub-agent.d.ts.map +1 -0
  197. package/dist/tools/sub-agent.js +89 -0
  198. package/dist/tools/sub-agent.js.map +1 -0
  199. package/dist/tools/task-output.d.ts +38 -0
  200. package/dist/tools/task-output.d.ts.map +1 -0
  201. package/dist/tools/task-output.js +186 -0
  202. package/dist/tools/task-output.js.map +1 -0
  203. package/dist/tools/tool-search/index.d.ts +40 -0
  204. package/dist/tools/tool-search/index.d.ts.map +1 -0
  205. package/dist/tools/tool-search/index.js +110 -0
  206. package/dist/tools/tool-search/index.js.map +1 -0
  207. package/dist/tools/tool-search/registry.d.ts +82 -0
  208. package/dist/tools/tool-search/registry.d.ts.map +1 -0
  209. package/dist/tools/tool-search/registry.js +238 -0
  210. package/dist/tools/tool-search/registry.js.map +1 -0
  211. package/dist/tools/undo-edit.d.ts +51 -0
  212. package/dist/tools/undo-edit.d.ts.map +1 -0
  213. package/dist/tools/undo-edit.js +231 -0
  214. package/dist/tools/undo-edit.js.map +1 -0
  215. package/dist/tools/web-fetch/cache.d.ts +49 -0
  216. package/dist/tools/web-fetch/cache.d.ts.map +1 -0
  217. package/dist/tools/web-fetch/cache.js +89 -0
  218. package/dist/tools/web-fetch/cache.js.map +1 -0
  219. package/dist/tools/web-fetch/index.d.ts +53 -0
  220. package/dist/tools/web-fetch/index.d.ts.map +1 -0
  221. package/dist/tools/web-fetch/index.js +513 -0
  222. package/dist/tools/web-fetch/index.js.map +1 -0
  223. package/dist/tools/write.d.ts +59 -0
  224. package/dist/tools/write.d.ts.map +1 -0
  225. package/dist/tools/write.js +316 -0
  226. package/dist/tools/write.js.map +1 -0
  227. package/dist/types.d.ts +881 -0
  228. package/dist/types.d.ts.map +1 -0
  229. package/dist/types.js +16 -0
  230. package/dist/types.js.map +1 -0
  231. package/dist/working-tags.d.ts +44 -0
  232. package/dist/working-tags.d.ts.map +1 -0
  233. package/dist/working-tags.js +103 -0
  234. package/dist/working-tags.js.map +1 -0
  235. package/package.json +87 -0
  236. package/src/budget-guard.ts +170 -0
  237. package/src/compaction/compaction.ts +386 -0
  238. package/src/compaction/failsafe.ts +185 -0
  239. package/src/compaction/index.ts +1199 -0
  240. package/src/compaction/microcompaction.ts +709 -0
  241. package/src/compaction/observational/buffering.ts +430 -0
  242. package/src/compaction/observational/constants.ts +532 -0
  243. package/src/compaction/observational/index.ts +837 -0
  244. package/src/compaction/observational/observer.ts +510 -0
  245. package/src/compaction/observational/recall-tool.ts +130 -0
  246. package/src/compaction/observational/reflector.ts +221 -0
  247. package/src/compaction/observational/types.ts +343 -0
  248. package/src/context-manager.ts +237 -0
  249. package/src/cortex-agent.ts +4297 -0
  250. package/src/error-classifier.ts +199 -0
  251. package/src/event-bridge.ts +508 -0
  252. package/src/index.ts +292 -0
  253. package/src/mcp-client.ts +582 -0
  254. package/src/model-wrapper.ts +128 -0
  255. package/src/noop-logger.ts +9 -0
  256. package/src/prompt-diagnostics.ts +296 -0
  257. package/src/provider-manager.ts +823 -0
  258. package/src/provider-registry.ts +386 -0
  259. package/src/schema-converter.ts +51 -0
  260. package/src/skill-preprocessor.ts +314 -0
  261. package/src/skill-registry.ts +378 -0
  262. package/src/skill-tool.ts +130 -0
  263. package/src/sub-agent-manager.ts +236 -0
  264. package/src/token-estimator.ts +26 -0
  265. package/src/tool-contract.ts +113 -0
  266. package/src/tool-result-persistence.ts +197 -0
  267. package/src/tools/bash/index.ts +633 -0
  268. package/src/tools/bash/interactive.ts +302 -0
  269. package/src/tools/bash/safety.ts +1297 -0
  270. package/src/tools/edit.ts +422 -0
  271. package/src/tools/glob.ts +330 -0
  272. package/src/tools/grep.ts +819 -0
  273. package/src/tools/index.ts +110 -0
  274. package/src/tools/read.ts +580 -0
  275. package/src/tools/runtime.ts +173 -0
  276. package/src/tools/shared/cwd-tracker.ts +50 -0
  277. package/src/tools/shared/edit-history.ts +96 -0
  278. package/src/tools/shared/edit-matcher.ts +457 -0
  279. package/src/tools/shared/file-mutation-lock.ts +40 -0
  280. package/src/tools/shared/gitignore.ts +61 -0
  281. package/src/tools/shared/pdf-extractor.ts +290 -0
  282. package/src/tools/shared/read-registry.ts +93 -0
  283. package/src/tools/shared/safe-env.ts +82 -0
  284. package/src/tools/sub-agent.ts +171 -0
  285. package/src/tools/task-output.ts +236 -0
  286. package/src/tools/tool-search/index.ts +167 -0
  287. package/src/tools/tool-search/registry.ts +278 -0
  288. package/src/tools/undo-edit.ts +314 -0
  289. package/src/tools/web-fetch/cache.ts +112 -0
  290. package/src/tools/web-fetch/index.ts +604 -0
  291. package/src/tools/write.ts +385 -0
  292. package/src/types.ts +1057 -0
  293. package/src/working-tags.ts +118 -0
@@ -0,0 +1,709 @@
1
+ /**
2
+ * Layer 1: Microcompaction (tool result trimming).
3
+ *
4
+ * Progressively reduces the footprint of old tool results in conversation
5
+ * history. Operates in-memory only via transformContext; never modifies
6
+ * the persisted conversation history (agent.state.messages).
7
+ *
8
+ * Two sub-mechanisms:
9
+ * 1. Insertion-time cap: Truncate large tool results at insertion time.
10
+ * 2. Cache-aware token-offset trimming: When the prompt cache has gone
11
+ * cold and context utilization is above the trim floor, walk history
12
+ * from newest to oldest accumulating token offsets. Tool results
13
+ * within the hot zone stay full; beyond it, bookend size shrinks
14
+ * linearly across the degradation span; beyond the span, results
15
+ * become placeholder or clear based on tool category.
16
+ *
17
+ * When a persistResult callback is configured, full content is persisted
18
+ * to disk before any destructive trim (bookend, placeholder, clear) for
19
+ * non-reproducible and computational tools, and the in-context replacement
20
+ * includes the disk path so the agent can Read the content back.
21
+ *
22
+ * References:
23
+ * - compaction-strategy.md (Layer 1: Tool Result Trimming)
24
+ * - observational-memory-architecture.md (cache-aware L1)
25
+ * - tool-result-persistence.md (proactive persistence at execution)
26
+ */
27
+
28
+ import type { AgentMessage } from '../context-manager.js';
29
+ import type { MicrocompactionConfig, ToolCategory } from '../types.js';
30
+ import { estimateTokens } from '../token-estimator.js';
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Defaults
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export const MICROCOMPACTION_DEFAULTS: MicrocompactionConfig = {
37
+ maxResultTokens: 50_000,
38
+ trimFloorRatio: 0.25,
39
+ hotZoneMinTokens: 16_000,
40
+ hotZoneRatio: 0.05,
41
+ bookendMaxChars: 2_000,
42
+ bookendMinChars: 256,
43
+ degradationSpanRatio: 0.40,
44
+ // extendedRetentionMultiplier intentionally undefined; resolved per-engine
45
+ // based on whether a persister is configured (1.0 with, 1.5 without).
46
+ };
47
+
48
+ /** Default extended retention multiplier when no persister is configured. */
49
+ const EXTENDED_RETENTION_NO_PERSISTER = 1.5;
50
+ /** Default extended retention multiplier when a persister is configured. */
51
+ const EXTENDED_RETENTION_WITH_PERSISTER = 1.0;
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Tool category defaults
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * Default tool categories for built-in tools.
59
+ * Consumers can override and extend via config.toolCategories.
60
+ */
61
+ const DEFAULT_TOOL_CATEGORIES: Record<string, ToolCategory> = {
62
+ Read: 'rereadable',
63
+ Glob: 'rereadable',
64
+ Grep: 'rereadable',
65
+ WebFetch: 'non-reproducible',
66
+ Bash: 'non-reproducible',
67
+ SubAgent: 'ephemeral',
68
+ TaskOutput: 'ephemeral',
69
+ };
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Trim state cache
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * Trim instruction for a single tool result message.
77
+ * Describes how to transform the message content for the LLM context.
78
+ */
79
+ export type TrimAction =
80
+ | { kind: 'full' }
81
+ | { kind: 'bookend'; headChars: number; tailChars: number; originalTokens: number }
82
+ | { kind: 'placeholder'; toolName: string; preview: string }
83
+ | { kind: 'clear' };
84
+
85
+ /**
86
+ * Cached trim state. Keyed on history length and a coarse utilization band
87
+ * so the engine can replay identical trim decisions across consecutive
88
+ * transformContext calls when the underlying conversation hasn't changed
89
+ * meaningfully.
90
+ */
91
+ export interface TrimState {
92
+ /** History length at the time the state was computed. */
93
+ historyLength: number;
94
+ /** Coarse utilization band: floor(usageRatio * 10). */
95
+ utilizationBand: number;
96
+ /** Map from conversation history index to trim action. */
97
+ actions: Map<number, TrimAction>;
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Insertion-time cap (Tier 1)
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /**
105
+ * Cap a tool result at insertion time. If the result exceeds maxResultTokens,
106
+ * truncate to head + tail bookend format using bookendMaxChars per side.
107
+ *
108
+ * This runs at insertion, NOT in transformContext. The truncated result is
109
+ * stored in conversation history.
110
+ */
111
+ export function capToolResult(
112
+ content: string,
113
+ config: Pick<MicrocompactionConfig, 'maxResultTokens' | 'bookendMaxChars'>,
114
+ ): string {
115
+ const tokens = estimateTokens(content);
116
+ if (tokens <= config.maxResultTokens) {
117
+ return content;
118
+ }
119
+
120
+ const headSize = config.bookendMaxChars;
121
+ const tailSize = config.bookendMaxChars;
122
+
123
+ // Ensure we don't exceed the content length
124
+ if (headSize + tailSize >= content.length) {
125
+ return content;
126
+ }
127
+
128
+ const head = content.slice(0, headSize);
129
+ const tail = content.slice(-tailSize);
130
+ const trimmedTokens = tokens - estimateTokens(head) - estimateTokens(tail);
131
+
132
+ return `${head}\n\n... [~${trimmedTokens} tokens trimmed at insertion] ...\n\n${tail}`;
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Content extraction helpers
137
+ // ---------------------------------------------------------------------------
138
+
139
+ /**
140
+ * Extract the text content from a message's content field.
141
+ * Handles both string content and content arrays.
142
+ * For content arrays, extracts text from 'text' parts and also from
143
+ * 'tool_result' parts that have a 'text' field (which is where
144
+ * pi-agent-core stores tool output).
145
+ */
146
+ export function extractTextContent(message: AgentMessage): string {
147
+ if (typeof message.content === 'string') {
148
+ return message.content;
149
+ }
150
+
151
+ // Guard: content may be undefined/null (corrupted toolResult) or empty
152
+ if (!Array.isArray(message.content) || message.content.length === 0) {
153
+ return '';
154
+ }
155
+
156
+ return message.content
157
+ .filter(part => typeof part.text === 'string')
158
+ .map(part => part.text as string)
159
+ .join('');
160
+ }
161
+
162
+ /**
163
+ * Check if a message is a tool result.
164
+ * Pi-agent-core stores tool results as messages with content arrays
165
+ * containing tool_result type parts.
166
+ */
167
+ export function isToolResultMessage(message: AgentMessage): boolean {
168
+ if (message.role === 'toolResult') {
169
+ return true;
170
+ }
171
+ if (!Array.isArray(message.content)) {
172
+ return false;
173
+ }
174
+ return message.content.some(part => part.type === 'tool_result');
175
+ }
176
+
177
+ /**
178
+ * Check if a message contains a tool use (tool call from the assistant).
179
+ */
180
+ export function isToolUseMessage(message: AgentMessage): boolean {
181
+ if (message.role !== 'assistant') {
182
+ return false;
183
+ }
184
+ if (!Array.isArray(message.content)) {
185
+ return false;
186
+ }
187
+ return message.content.some(part => part.type === 'tool_use');
188
+ }
189
+
190
+ /**
191
+ * Extract the tool name from a tool result or tool use message.
192
+ * Returns null if the message is not a tool-related message.
193
+ */
194
+ export function extractToolName(message: AgentMessage): string | null {
195
+ if (typeof message.toolName === 'string') {
196
+ return message.toolName;
197
+ }
198
+
199
+ if (!Array.isArray(message.content)) {
200
+ return null;
201
+ }
202
+
203
+ for (const part of message.content) {
204
+ if (part.type === 'tool_use' && typeof part['name'] === 'string') {
205
+ return part['name'];
206
+ }
207
+ if (part.type === 'tool_result' && typeof part['name'] === 'string') {
208
+ return part['name'];
209
+ }
210
+ }
211
+
212
+ return null;
213
+ }
214
+
215
+ /**
216
+ * Get the effective tool category for a tool name.
217
+ */
218
+ export function getToolCategory(
219
+ toolName: string,
220
+ customCategories?: Record<string, ToolCategory>,
221
+ ): ToolCategory | undefined {
222
+ // Custom categories take precedence
223
+ if (customCategories?.[toolName]) {
224
+ return customCategories[toolName];
225
+ }
226
+ return DEFAULT_TOOL_CATEGORIES[toolName];
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Token-offset trimming (cache-aware, progressive degradation)
231
+ // ---------------------------------------------------------------------------
232
+
233
+ /**
234
+ * Compute the effective hot zone size in tokens.
235
+ * `max(hotZoneMinTokens, contextWindow * hotZoneRatio)`.
236
+ */
237
+ export function computeHotZone(
238
+ contextWindow: number,
239
+ config: Pick<MicrocompactionConfig, 'hotZoneMinTokens' | 'hotZoneRatio'>,
240
+ ): number {
241
+ return Math.max(config.hotZoneMinTokens, contextWindow * config.hotZoneRatio);
242
+ }
243
+
244
+ /**
245
+ * Resolve the effective extended retention multiplier. When the config value
246
+ * is undefined, use 1.0 if a persister is configured (content recoverable
247
+ * from disk) or 1.5 if not (content is truly lost on trim).
248
+ */
249
+ export function resolveExtendedRetentionMultiplier(config: MicrocompactionConfig): number {
250
+ if (typeof config.extendedRetentionMultiplier === 'number') {
251
+ return config.extendedRetentionMultiplier;
252
+ }
253
+ return config.persistResult
254
+ ? EXTENDED_RETENTION_WITH_PERSISTER
255
+ : EXTENDED_RETENTION_NO_PERSISTER;
256
+ }
257
+
258
+ /**
259
+ * Determine the trim action for a tool result based on its token offset
260
+ * from the most recent message.
261
+ *
262
+ * @param message - The tool result message
263
+ * @param tokenOffset - Cumulative tokens between this message and the end of history
264
+ * @param hotZone - Hot zone size in tokens (preserved fully)
265
+ * @param degradationSpan - Degradation span in tokens (bookend size shrinks across this span)
266
+ * @param config - Microcompaction config
267
+ * @param extendedMultiplier - Resolved extended retention multiplier for non-reproducible tools
268
+ */
269
+ export function computeAction(
270
+ message: AgentMessage,
271
+ tokenOffset: number,
272
+ hotZone: number,
273
+ degradationSpan: number,
274
+ config: MicrocompactionConfig,
275
+ extendedMultiplier: number,
276
+ ): TrimAction {
277
+ const toolName = extractToolName(message);
278
+ const category = toolName ? getToolCategory(toolName, config.toolCategories) : undefined;
279
+
280
+ // Non-reproducible tools get an extended hot zone
281
+ const effectiveHotZone = category === 'non-reproducible'
282
+ ? hotZone * extendedMultiplier
283
+ : hotZone;
284
+
285
+ // Within hot zone: keep full
286
+ if (tokenOffset < effectiveHotZone) {
287
+ return { kind: 'full' };
288
+ }
289
+
290
+ const distanceBeyondHotZone = tokenOffset - effectiveHotZone;
291
+ const t = degradationSpan > 0
292
+ ? Math.min(distanceBeyondHotZone / degradationSpan, 1.0)
293
+ : 1.0;
294
+
295
+ // Within degradation span: interpolated bookend
296
+ if (t < 1.0) {
297
+ const bookendChars = Math.max(
298
+ config.bookendMinChars,
299
+ Math.round(config.bookendMaxChars * (1 - t) + config.bookendMinChars * t),
300
+ );
301
+ const content = extractTextContent(message);
302
+ const tokens = estimateTokens(content);
303
+ return {
304
+ kind: 'bookend',
305
+ headChars: bookendChars,
306
+ tailChars: bookendChars,
307
+ originalTokens: tokens,
308
+ };
309
+ }
310
+
311
+ // Beyond degradation span: placeholder or clear based on tool category
312
+ if (category === 'ephemeral') {
313
+ return { kind: 'clear' };
314
+ }
315
+
316
+ // Default for rereadable, non-reproducible, computational, and unknown:
317
+ // placeholder preserves the breadcrumb of what was tried.
318
+ const content = extractTextContent(message);
319
+ const preview = content.slice(0, 80).replace(/\n/g, ' ').trim();
320
+ return {
321
+ kind: 'placeholder',
322
+ toolName: toolName ?? 'unknown',
323
+ preview,
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Compute the full trim state by walking history from newest to oldest,
329
+ * accumulating token offsets.
330
+ */
331
+ export function computeTrimState(
332
+ history: AgentMessage[],
333
+ contextWindow: number,
334
+ usageRatio: number,
335
+ config: MicrocompactionConfig,
336
+ ): TrimState {
337
+ const actions = new Map<number, TrimAction>();
338
+ const hotZone = computeHotZone(contextWindow, config);
339
+ const degradationSpan = contextWindow * config.degradationSpanRatio;
340
+ const extendedMultiplier = resolveExtendedRetentionMultiplier(config);
341
+
342
+ let tokenOffset = 0;
343
+
344
+ for (let i = history.length - 1; i >= 0; i--) {
345
+ const message = history[i]!;
346
+
347
+ if (isToolResultMessage(message)) {
348
+ const action = computeAction(
349
+ message,
350
+ tokenOffset,
351
+ hotZone,
352
+ degradationSpan,
353
+ config,
354
+ extendedMultiplier,
355
+ );
356
+ if (action.kind !== 'full') {
357
+ actions.set(i, action);
358
+ }
359
+ }
360
+
361
+ // Accumulate tokens regardless of message type so non-tool-result
362
+ // messages (assistant text, user messages) push older results further
363
+ // down the timeline correctly.
364
+ tokenOffset += estimateTokens(extractTextContent(message));
365
+ }
366
+
367
+ return {
368
+ historyLength: history.length,
369
+ utilizationBand: Math.floor(usageRatio * 10),
370
+ actions,
371
+ };
372
+ }
373
+
374
+ /**
375
+ * Apply a bookend trim to content.
376
+ */
377
+ export function applyBookend(
378
+ content: string,
379
+ headChars: number,
380
+ tailChars: number,
381
+ originalTokens: number,
382
+ ): string {
383
+ if (headChars + tailChars >= content.length) {
384
+ return content;
385
+ }
386
+
387
+ const head = content.slice(0, headChars);
388
+ const tail = content.slice(-tailChars);
389
+ const trimmedTokens = originalTokens - estimateTokens(head) - estimateTokens(tail);
390
+
391
+ return `${head}\n\n... [~${trimmedTokens} tokens trimmed] ...\n\n${tail}`;
392
+ }
393
+
394
+ /**
395
+ * Apply a bookend trim with a persisted file reference. The agent can
396
+ * Read the path to recover the trimmed middle portion.
397
+ */
398
+ export function applyBookendWithPersistence(
399
+ content: string,
400
+ headChars: number,
401
+ tailChars: number,
402
+ originalTokens: number,
403
+ toolName: string,
404
+ path: string,
405
+ ): string {
406
+ const totalChars = content.length;
407
+ const header = `[Result persisted: ${path} (${totalChars} chars, ~${originalTokens} tokens) -- ${toolName}]`;
408
+
409
+ if (headChars + tailChars >= content.length) {
410
+ return `${header}\n\n${content}`;
411
+ }
412
+
413
+ const head = content.slice(0, headChars);
414
+ const tail = content.slice(-tailChars);
415
+ const trimmedTokens = originalTokens - estimateTokens(head) - estimateTokens(tail);
416
+
417
+ return `${header}\n\n${head}\n\n... [~${trimmedTokens} tokens trimmed; full content at ${path}] ...\n\n${tail}`;
418
+ }
419
+
420
+ /**
421
+ * Compute the replacement text for a trim action (no persistence).
422
+ */
423
+ function getTrimmedText(content: string, action: TrimAction): string {
424
+ if (action.kind === 'bookend') {
425
+ return applyBookend(content, action.headChars, action.tailChars, action.originalTokens);
426
+ }
427
+ if (action.kind === 'placeholder') {
428
+ return `[Tool result trimmed -- ${action.toolName}: "${action.preview}" -- see assistant response below for findings]`;
429
+ }
430
+ if (action.kind === 'clear') {
431
+ return '[Tool result cleared]';
432
+ }
433
+ return content;
434
+ }
435
+
436
+ /**
437
+ * Apply a trim action to a message, returning a new message.
438
+ *
439
+ * Preserves the content array structure for tool_result messages so that
440
+ * tool_use_id linkage is maintained. The Anthropic API requires every
441
+ * tool_use block to have a matching tool_result with the same tool_use_id;
442
+ * replacing the content with a plain string would break this contract.
443
+ */
444
+ export function applyTrimAction(message: AgentMessage, action: TrimAction): AgentMessage {
445
+ if (action.kind === 'full') {
446
+ return message;
447
+ }
448
+
449
+ // If the message has a structured content array (e.g., runtime toolResult
450
+ // messages with text parts, or legacy tool_result parts), preserve the array
451
+ // structure and only replace text within each part.
452
+ if (Array.isArray(message.content) && message.content.length > 0) {
453
+ const newContent = message.content.map(part => {
454
+ const shouldTrim = typeof part.text === 'string' && (
455
+ part.type === 'tool_result' ||
456
+ (part.type === 'text' && isToolResultMessage(message))
457
+ );
458
+ if (!shouldTrim) {
459
+ return part;
460
+ }
461
+ const trimmed = getTrimmedText(part.text as string, action);
462
+ return { ...part, text: trimmed };
463
+ });
464
+ return { ...message, content: newContent };
465
+ }
466
+
467
+ // Plain string content: replace directly (backward compat for non-tool messages)
468
+ const content = typeof message.content === 'string' ? message.content : '';
469
+ const trimmed = getTrimmedText(content, action);
470
+ return { ...message, content: trimmed };
471
+ }
472
+
473
+ // ---------------------------------------------------------------------------
474
+ // MicrocompactionEngine
475
+ // ---------------------------------------------------------------------------
476
+
477
+ /**
478
+ * Stateful engine for microcompaction. Caches trim state across consecutive
479
+ * calls with the same history length and utilization band so the same trim
480
+ * decisions are replayed when the conversation hasn't changed.
481
+ */
482
+ export class MicrocompactionEngine {
483
+ private readonly config: MicrocompactionConfig;
484
+ private cachedState: TrimState | null = null;
485
+
486
+ constructor(config: Partial<MicrocompactionConfig> = {}) {
487
+ this.config = { ...MICROCOMPACTION_DEFAULTS, ...config };
488
+ }
489
+
490
+ /**
491
+ * Whether a persist callback is configured.
492
+ */
493
+ get hasPersistCallback(): boolean {
494
+ return typeof this.config.persistResult === 'function';
495
+ }
496
+
497
+ /**
498
+ * Get the current config (for testing/inspection).
499
+ */
500
+ getConfig(): MicrocompactionConfig {
501
+ return this.config;
502
+ }
503
+
504
+ /**
505
+ * Cap a tool result at insertion time.
506
+ */
507
+ capAtInsertion(content: string): string {
508
+ return capToolResult(content, this.config);
509
+ }
510
+
511
+ /**
512
+ * Apply microcompaction to conversation history messages.
513
+ * Called inside transformContext. Returns a new array; never modifies the input.
514
+ *
515
+ * Cache-aware gating:
516
+ * - When `cacheCold` is false (cache warm), L1 is dormant and history
517
+ * is returned untouched. This preserves cache hits during active use.
518
+ * - When `cacheCold` is true, L1 may run if utilization is at or above
519
+ * `trimFloorRatio` (default 25%).
520
+ *
521
+ * Token-offset trimming:
522
+ * Walks history from newest to oldest. Tool results within the hot zone
523
+ * stay full. Beyond the hot zone, bookend size shrinks linearly across
524
+ * the degradation span. Beyond the span, results become placeholder or
525
+ * clear based on tool category.
526
+ *
527
+ * Persistence:
528
+ * When a persistResult callback is configured, full content is saved to
529
+ * disk before any destructive action (bookend, placeholder, clear) for
530
+ * non-reproducible and computational tools. The in-context replacement
531
+ * includes the disk path so the agent can Read the content back.
532
+ *
533
+ * @param history - Conversation history messages (post-slot region)
534
+ * @param contextWindow - Total context window size in tokens
535
+ * @param currentTokens - Current estimated token count
536
+ * @param options.cacheCold - Whether the prompt cache has expired (or is unused)
537
+ * @returns Potentially trimmed conversation history
538
+ */
539
+ async apply(
540
+ history: AgentMessage[],
541
+ contextWindow: number,
542
+ currentTokens: number,
543
+ options: { cacheCold: boolean } = { cacheCold: true },
544
+ ): Promise<AgentMessage[]> {
545
+ if (contextWindow <= 0 || history.length === 0) {
546
+ return history;
547
+ }
548
+
549
+ // Gate 1: Cache awareness. When the cache is warm, L1 is dormant.
550
+ if (!options.cacheCold) {
551
+ return history;
552
+ }
553
+
554
+ // Gate 2: Trim floor. Below this utilization, no trimming even if cold.
555
+ const usageRatio = currentTokens / contextWindow;
556
+ if (usageRatio < this.config.trimFloorRatio) {
557
+ return history;
558
+ }
559
+
560
+ const utilizationBand = Math.floor(usageRatio * 10);
561
+
562
+ // Replay cached state when nothing meaningful has changed.
563
+ const canReplay = this.cachedState
564
+ && this.cachedState.historyLength === history.length
565
+ && this.cachedState.utilizationBand === utilizationBand;
566
+
567
+ if (!canReplay) {
568
+ this.cachedState = computeTrimState(history, contextWindow, usageRatio, this.config);
569
+ }
570
+
571
+ if (!this.cachedState || this.cachedState.actions.size === 0) {
572
+ return history;
573
+ }
574
+
575
+ const result: AgentMessage[] = [];
576
+ for (let i = 0; i < history.length; i++) {
577
+ const action = this.cachedState.actions.get(i);
578
+ if (action) {
579
+ const persisted = await this.maybePersistBeforeTrim(history[i]!, action, i);
580
+ result.push(persisted);
581
+ } else {
582
+ result.push(history[i]!);
583
+ }
584
+ }
585
+
586
+ return result;
587
+ }
588
+
589
+ /**
590
+ * If a persistResult callback is configured and the action is destructive
591
+ * (bookend, placeholder, or clear), persist each non-reproducible or
592
+ * computational tool_result part's content to disk individually and
593
+ * replace each part's text with a path-referencing representation.
594
+ *
595
+ * Multi-part messages (from parallel tool calls) get per-part treatment so
596
+ * each part's disk path maps back to its own content.
597
+ *
598
+ * Parts that don't qualify for persistence (rereadable, ephemeral, no
599
+ * callback, or non-tool-result parts) still get the standard trim action
600
+ * applied to their own text.
601
+ */
602
+ private async maybePersistBeforeTrim(
603
+ message: AgentMessage,
604
+ action: TrimAction,
605
+ messageIndex: number,
606
+ ): Promise<AgentMessage> {
607
+ if (action.kind === 'full') {
608
+ return message;
609
+ }
610
+
611
+ // No persister: fall back entirely to the standard per-part trim.
612
+ if (!this.config.persistResult) {
613
+ return applyTrimAction(message, action);
614
+ }
615
+
616
+ // String content: treat as a single unit (non-tool-result message).
617
+ if (!Array.isArray(message.content) || message.content.length === 0) {
618
+ return applyTrimAction(message, action);
619
+ }
620
+
621
+ // Per-part processing: each tool result text part gets its own persistence
622
+ // decision and its own replacement text.
623
+ const messageToolName = typeof message.toolName === 'string' ? message.toolName : null;
624
+ const newParts = await Promise.all(message.content.map(async part => {
625
+ const isLegacyToolResult = part.type === 'tool_result' && typeof part.text === 'string';
626
+ const isRuntimeToolResultText = message.role === 'toolResult' && part.type === 'text' && typeof part.text === 'string';
627
+ const isToolResultText = isLegacyToolResult || isRuntimeToolResultText;
628
+ if (!isToolResultText) {
629
+ return part;
630
+ }
631
+
632
+ const partText = part.text as string;
633
+ const toolName = typeof part['name'] === 'string' ? part['name'] as string : messageToolName;
634
+ const category = toolName ? getToolCategory(toolName, this.config.toolCategories) : undefined;
635
+
636
+ // Only persist non-reproducible and computational parts.
637
+ const shouldPersist = category === 'non-reproducible' || category === 'computational';
638
+ if (!shouldPersist) {
639
+ return { ...part, text: getTrimmedText(partText, action) };
640
+ }
641
+
642
+ let path: string;
643
+ try {
644
+ path = await this.config.persistResult!(partText, {
645
+ toolName: toolName ?? 'unknown',
646
+ messageIndex,
647
+ category,
648
+ });
649
+ } catch {
650
+ // Persist failed; fall back to standard trim for this part.
651
+ return { ...part, text: getTrimmedText(partText, action) };
652
+ }
653
+
654
+ const persistedText = formatPersistedReplacement(
655
+ partText,
656
+ action,
657
+ toolName ?? 'unknown',
658
+ path,
659
+ );
660
+ return { ...part, text: persistedText };
661
+ }));
662
+
663
+ return { ...message, content: newParts };
664
+ }
665
+
666
+ /**
667
+ * Reset the cached trim state. Called after Layer 2 compaction
668
+ * replaces conversation history.
669
+ */
670
+ resetCache(): void {
671
+ this.cachedState = null;
672
+ }
673
+
674
+ /**
675
+ * Get the cached state (for testing/inspection).
676
+ */
677
+ getCachedState(): TrimState | null {
678
+ return this.cachedState;
679
+ }
680
+ }
681
+
682
+ // ---------------------------------------------------------------------------
683
+ // Persistence formatting helpers
684
+ // ---------------------------------------------------------------------------
685
+
686
+ /**
687
+ * Format the in-context replacement when content has been persisted to disk.
688
+ * - bookend: header + head + middle marker + tail
689
+ * - placeholder: one-line breadcrumb with disk path
690
+ * - clear: minimal marker with disk path
691
+ */
692
+ function formatPersistedReplacement(
693
+ content: string,
694
+ action: TrimAction,
695
+ toolName: string,
696
+ path: string,
697
+ ): string {
698
+ if (action.kind === 'bookend') {
699
+ return applyBookendWithPersistence(
700
+ content, action.headChars, action.tailChars, action.originalTokens, toolName, path,
701
+ );
702
+ }
703
+ if (action.kind === 'placeholder') {
704
+ return `[Tool result persisted -- ${toolName}: "${action.preview}" -- use Read on ${path} for full content]`;
705
+ }
706
+ // clear
707
+ return `[Tool result persisted -- ${toolName} -- use Read on ${path} for full content]`;
708
+ }
709
+