onbuzz 4.9.13 → 4.10.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 (451) hide show
  1. package/node_modules/glob/README.md +31 -5
  2. package/node_modules/glob/dist/commonjs/glob.d.ts +8 -0
  3. package/node_modules/glob/dist/commonjs/glob.d.ts.map +1 -1
  4. package/node_modules/glob/dist/commonjs/glob.js +2 -1
  5. package/node_modules/glob/dist/commonjs/glob.js.map +1 -1
  6. package/node_modules/glob/dist/commonjs/index.min.js +3 -3
  7. package/node_modules/glob/dist/commonjs/index.min.js.map +4 -4
  8. package/node_modules/glob/dist/commonjs/pattern.d.ts +3 -0
  9. package/node_modules/glob/dist/commonjs/pattern.d.ts.map +1 -1
  10. package/node_modules/glob/dist/commonjs/pattern.js +4 -0
  11. package/node_modules/glob/dist/commonjs/pattern.js.map +1 -1
  12. package/node_modules/glob/dist/esm/glob.d.ts +8 -0
  13. package/node_modules/glob/dist/esm/glob.d.ts.map +1 -1
  14. package/node_modules/glob/dist/esm/glob.js +2 -1
  15. package/node_modules/glob/dist/esm/glob.js.map +1 -1
  16. package/node_modules/glob/dist/esm/index.min.js +3 -3
  17. package/node_modules/glob/dist/esm/index.min.js.map +4 -4
  18. package/node_modules/glob/dist/esm/pattern.d.ts +3 -0
  19. package/node_modules/glob/dist/esm/pattern.d.ts.map +1 -1
  20. package/node_modules/glob/dist/esm/pattern.js +4 -0
  21. package/node_modules/glob/dist/esm/pattern.js.map +1 -1
  22. package/node_modules/{@isaacs → glob/node_modules}/balanced-match/README.md +7 -10
  23. package/node_modules/{@isaacs → glob/node_modules}/balanced-match/package.json +7 -18
  24. package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/README.md +3 -6
  25. package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.js +6 -4
  26. package/node_modules/glob/node_modules/brace-expansion/dist/commonjs/index.js.map +1 -0
  27. package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.js +6 -4
  28. package/node_modules/glob/node_modules/brace-expansion/dist/esm/index.js.map +1 -0
  29. package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/package.json +11 -7
  30. package/node_modules/glob/node_modules/minimatch/README.md +76 -1
  31. package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.d.ts +1 -1
  32. package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.d.ts.map +1 -1
  33. package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.js.map +1 -1
  34. package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.d.ts +4 -2
  35. package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.d.ts.map +1 -1
  36. package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.js +309 -55
  37. package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.js.map +1 -1
  38. package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.d.ts.map +1 -1
  39. package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.js +2 -4
  40. package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.js.map +1 -1
  41. package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.d.ts +1 -1
  42. package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.d.ts.map +1 -1
  43. package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.js +4 -4
  44. package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.js.map +1 -1
  45. package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.d.ts +81 -1
  46. package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.d.ts.map +1 -1
  47. package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.js +232 -134
  48. package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.js.map +1 -1
  49. package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.d.ts +1 -1
  50. package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.d.ts.map +1 -1
  51. package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.js +8 -8
  52. package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.js.map +1 -1
  53. package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.d.ts +1 -1
  54. package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.d.ts.map +1 -1
  55. package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.js.map +1 -1
  56. package/node_modules/glob/node_modules/minimatch/dist/esm/ast.d.ts +4 -2
  57. package/node_modules/glob/node_modules/minimatch/dist/esm/ast.d.ts.map +1 -1
  58. package/node_modules/glob/node_modules/minimatch/dist/esm/ast.js +309 -55
  59. package/node_modules/glob/node_modules/minimatch/dist/esm/ast.js.map +1 -1
  60. package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.d.ts.map +1 -1
  61. package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.js +2 -4
  62. package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.js.map +1 -1
  63. package/node_modules/glob/node_modules/minimatch/dist/esm/escape.d.ts +1 -1
  64. package/node_modules/glob/node_modules/minimatch/dist/esm/escape.d.ts.map +1 -1
  65. package/node_modules/glob/node_modules/minimatch/dist/esm/escape.js +4 -4
  66. package/node_modules/glob/node_modules/minimatch/dist/esm/escape.js.map +1 -1
  67. package/node_modules/glob/node_modules/minimatch/dist/esm/index.d.ts +81 -1
  68. package/node_modules/glob/node_modules/minimatch/dist/esm/index.d.ts.map +1 -1
  69. package/node_modules/glob/node_modules/minimatch/dist/esm/index.js +232 -134
  70. package/node_modules/glob/node_modules/minimatch/dist/esm/index.js.map +1 -1
  71. package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.d.ts +1 -1
  72. package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.d.ts.map +1 -1
  73. package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.js +8 -8
  74. package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.js.map +1 -1
  75. package/node_modules/glob/node_modules/minimatch/package.json +17 -11
  76. package/node_modules/glob/package.json +10 -13
  77. package/node_modules/minipass/LICENSE.md +55 -0
  78. package/node_modules/minipass/dist/commonjs/index.d.ts +12 -16
  79. package/node_modules/minipass/dist/commonjs/index.d.ts.map +1 -1
  80. package/node_modules/minipass/dist/commonjs/index.js +13 -3
  81. package/node_modules/minipass/dist/commonjs/index.js.map +1 -1
  82. package/node_modules/minipass/dist/esm/index.d.ts +12 -16
  83. package/node_modules/minipass/dist/esm/index.d.ts.map +1 -1
  84. package/node_modules/minipass/dist/esm/index.js +3 -1
  85. package/node_modules/minipass/dist/esm/index.js.map +1 -1
  86. package/node_modules/minipass/package.json +9 -14
  87. package/node_modules/path-scurry/node_modules/lru-cache/README.md +96 -10
  88. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel-browser.d.ts.map +1 -0
  89. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel-browser.js.map +1 -0
  90. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel.d.ts +5 -0
  91. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel.js +7 -0
  92. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.d.ts +1400 -0
  93. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.d.ts.map +1 -0
  94. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.js +1726 -0
  95. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.js.map +1 -0
  96. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.min.js +2 -0
  97. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.min.js.map +7 -0
  98. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.d.ts +12 -0
  99. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.d.ts.map +1 -0
  100. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.js +10 -0
  101. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.js.map +1 -0
  102. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel-cjs.cjs.map +1 -0
  103. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel-cjs.d.cts.map +1 -0
  104. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel.d.ts +5 -0
  105. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel.js +7 -0
  106. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.d.ts +109 -32
  107. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.d.ts.map +1 -1
  108. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.js +334 -197
  109. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.js.map +1 -1
  110. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.min.js +1 -1
  111. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.min.js.map +4 -4
  112. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel-node.d.ts.map +1 -0
  113. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel-node.js.map +1 -0
  114. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel.d.ts +5 -0
  115. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel.js +9 -0
  116. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.d.ts +1400 -0
  117. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.d.ts.map +1 -0
  118. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.js +1726 -0
  119. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.js.map +1 -0
  120. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.min.js +2 -0
  121. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.min.js.map +7 -0
  122. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.d.ts +12 -0
  123. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.d.ts.map +1 -0
  124. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.js +10 -0
  125. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.js.map +1 -0
  126. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.d.ts +12 -0
  127. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.d.ts.map +1 -0
  128. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.js +10 -0
  129. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.js.map +1 -0
  130. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel-browser.d.ts.map +1 -0
  131. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel-browser.js.map +1 -0
  132. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel.d.ts +5 -0
  133. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel.js +4 -0
  134. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.d.ts +1400 -0
  135. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.d.ts.map +1 -0
  136. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.js +1722 -0
  137. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.js.map +1 -0
  138. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.min.js +2 -0
  139. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.min.js.map +7 -0
  140. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.d.ts +12 -0
  141. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.d.ts.map +1 -0
  142. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.js +7 -0
  143. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.js.map +1 -0
  144. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel-esm.d.mts.map +1 -0
  145. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel-esm.mjs.map +1 -0
  146. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel.d.ts +5 -0
  147. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel.js +19 -0
  148. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.d.ts +109 -32
  149. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.d.ts.map +1 -1
  150. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.js +333 -196
  151. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.js.map +1 -1
  152. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.min.js +1 -1
  153. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.min.js.map +4 -4
  154. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel-node.d.ts.map +1 -0
  155. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel-node.js.map +1 -0
  156. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel.d.ts +5 -0
  157. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel.js +6 -0
  158. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.d.ts +1400 -0
  159. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.d.ts.map +1 -0
  160. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.js +1722 -0
  161. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.js.map +1 -0
  162. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.min.js +2 -0
  163. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.min.js.map +7 -0
  164. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.d.ts +12 -0
  165. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.d.ts.map +1 -0
  166. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.js +7 -0
  167. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.js.map +1 -0
  168. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.d.ts +12 -0
  169. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.d.ts.map +1 -0
  170. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.js +7 -0
  171. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.js.map +1 -0
  172. package/node_modules/path-scurry/node_modules/lru-cache/package.json +71 -18
  173. package/node_modules/path-scurry/package.json +8 -24
  174. package/package.json +1 -1
  175. package/scripts/debug-balance-probe.mjs +35 -35
  176. package/scripts/push-image.sh +43 -43
  177. package/scripts/setup-acr.sh +65 -65
  178. package/scripts/verify-optional-deps.js +96 -1
  179. package/src/__tests__/composioCliFlags.test.js +239 -239
  180. package/src/analyzers/CSSAnalyzer.js +298 -297
  181. package/src/analyzers/ConfigValidator.js +691 -690
  182. package/src/analyzers/ESLintAnalyzer.js +320 -320
  183. package/src/analyzers/JavaScriptAnalyzer.js +260 -261
  184. package/src/analyzers/PrettierFormatter.js +246 -247
  185. package/src/analyzers/PythonAnalyzer.js +283 -283
  186. package/src/analyzers/SecurityAnalyzer.js +729 -729
  187. package/src/analyzers/SparrowAnalyzer.js +341 -341
  188. package/src/analyzers/TypeScriptAnalyzer.js +247 -247
  189. package/src/analyzers/__tests__/CSSAnalyzer.test.js +41 -41
  190. package/src/analyzers/__tests__/ConfigValidator.test.js +362 -362
  191. package/src/analyzers/__tests__/JavaScriptAnalyzer.test.js +40 -40
  192. package/src/analyzers/__tests__/PythonAnalyzer.test.js +205 -208
  193. package/src/analyzers/__tests__/SecurityAnalyzer.test.js +303 -303
  194. package/src/analyzers/__tests__/TypeScriptAnalyzer.test.js +187 -187
  195. package/src/analyzers/codeCloneDetector/analyzer.js +344 -344
  196. package/src/analyzers/codeCloneDetector/detector.js +250 -250
  197. package/src/analyzers/codeCloneDetector/index.js +194 -192
  198. package/src/analyzers/codeCloneDetector/parser.js +199 -199
  199. package/src/core/__tests__/agentPool.test.js +866 -866
  200. package/src/core/__tests__/agentPoolAutoResume.test.js +209 -209
  201. package/src/core/__tests__/agentPoolWakeOnMessage.test.js +315 -315
  202. package/src/core/__tests__/agentScheduler.emptyResponseChatStall.test.js +213 -213
  203. package/src/core/__tests__/agentScheduler.errorCategorisation.test.js +246 -246
  204. package/src/core/__tests__/agentScheduler.firstChunkTimeout.test.js +138 -138
  205. package/src/core/__tests__/agentScheduler.modeTransitions.test.js +233 -233
  206. package/src/core/__tests__/agentScheduler.nativePromptPick.test.js +319 -319
  207. package/src/core/__tests__/agentScheduler.taskLifecycleInstruction.test.js +78 -78
  208. package/src/core/__tests__/agentScheduler.visualizer.test.js +258 -258
  209. package/src/core/__tests__/flowCheckpointStore.test.js +140 -140
  210. package/src/core/__tests__/flowEndToEnd.test.js +565 -565
  211. package/src/core/__tests__/flowFieldMapping.test.js +188 -189
  212. package/src/core/__tests__/flowLintClientMirror.test.js +96 -98
  213. package/src/core/__tests__/flowSavePayload.test.js +170 -169
  214. package/src/core/__tests__/flowTemplates.test.js +311 -311
  215. package/src/core/__tests__/flowVersionStore.test.js +123 -123
  216. package/src/core/__tests__/messageProcessor.test.js +669 -669
  217. package/src/core/__tests__/stateManager.test.js +0 -1
  218. package/src/core/agentPool.js +2474 -2475
  219. package/src/core/agentScheduler.js +1 -4
  220. package/src/core/contextManager.js +708 -708
  221. package/src/core/flowExecutor.js +1510 -1510
  222. package/src/core/flowFieldMapping.js +136 -138
  223. package/src/core/messageProcessor.js +953 -954
  224. package/src/core/orchestrator.js +593 -595
  225. package/src/core/stateManager.js +1765 -1752
  226. package/src/index.js +1221 -1221
  227. package/src/interfaces/__tests__/archivedAgentDelete.test.js +207 -207
  228. package/src/interfaces/__tests__/bulkAgentRoute.test.js +361 -361
  229. package/src/interfaces/__tests__/imageServing.test.js +228 -228
  230. package/src/interfaces/__tests__/remoteSessionAuth.test.js +308 -308
  231. package/src/interfaces/__tests__/videoJobsRoutes.test.js +178 -179
  232. package/src/interfaces/__tests__/webServer.marketplace.test.js +629 -629
  233. package/src/interfaces/schedulerRoutes.js +50 -50
  234. package/src/interfaces/terminal/__tests__/smoke/connection.test.js +341 -350
  235. package/src/interfaces/terminal/__tests__/smoke/enhancements.test.js +156 -156
  236. package/src/interfaces/terminal/__tests__/smoke/imports.test.js +325 -330
  237. package/src/interfaces/terminal/__tests__/smoke/tools.test.js +385 -388
  238. package/src/interfaces/terminal/api/session.js +265 -266
  239. package/src/interfaces/terminal/api/websocket.js +496 -497
  240. package/src/interfaces/terminal/components/AgentCreator.js +691 -705
  241. package/src/interfaces/terminal/components/AgentEditor.js +676 -678
  242. package/src/interfaces/terminal/components/AgentSwitcher.js +331 -330
  243. package/src/interfaces/terminal/components/ErrorPanel.js +263 -264
  244. package/src/interfaces/terminal/components/Header.js +28 -28
  245. package/src/interfaces/terminal/components/Layout.js +598 -603
  246. package/src/interfaces/terminal/components/MessageList.js +280 -281
  247. package/src/interfaces/terminal/components/SettingsPanel.js +410 -415
  248. package/src/interfaces/terminal/components/StatusBar.js +2 -0
  249. package/src/interfaces/terminal/index.js +168 -168
  250. package/src/interfaces/terminal/state/useAgentControl.js +496 -496
  251. package/src/interfaces/terminal/state/useAgents.js +537 -537
  252. package/src/interfaces/terminal/state/useMessages.js +629 -630
  253. package/src/interfaces/terminal/state/useTools.js +554 -554
  254. package/src/interfaces/terminal/utils/debugLogger.js +44 -44
  255. package/src/interfaces/terminal/utils/settingsStorage.js +232 -232
  256. package/src/interfaces/webServer.js +7578 -7579
  257. package/src/interfaces/webServer.js.bak +7046 -7046
  258. package/src/modules/fileExplorer/__tests__/zipDownload.test.js +237 -237
  259. package/src/modules/fileExplorer/controller.js +470 -469
  260. package/src/modules/fileExplorer/routes.js +285 -286
  261. package/src/modules/widget/__tests__/isDisabled.test.js +41 -41
  262. package/src/modules/widget/__tests__/routes.test.js +677 -678
  263. package/src/modules/widget/__tests__/runtime.test.js +401 -401
  264. package/src/modules/widget/__tests__/versioning.test.js +309 -309
  265. package/src/modules/widget/__tests__/webComponentRuntime.test.js +565 -565
  266. package/src/modules/widget/__tests__/widgetTool.test.js +316 -316
  267. package/src/modules/widget/routes.js +435 -435
  268. package/src/modules/widget/runtime/bundle.js +640 -640
  269. package/src/modules/widget/runtime/webComponentBundle.js +470 -470
  270. package/src/modules/widget/schema.js +182 -181
  271. package/src/modules/widget/widgetTool.js +1389 -1389
  272. package/src/services/__tests__/agentActivityService.test.js +401 -402
  273. package/src/services/__tests__/benchmarkService.test.js +184 -184
  274. package/src/services/__tests__/contextInjectionService.test.js +246 -246
  275. package/src/services/__tests__/conversationQuery.test.js +721 -723
  276. package/src/services/__tests__/credentialVault.test.js +469 -469
  277. package/src/services/__tests__/discordService.integration.test.js +638 -639
  278. package/src/services/__tests__/flowContextService.test.js +590 -590
  279. package/src/services/__tests__/memoryService.test.js +1 -1
  280. package/src/services/__tests__/messageSource.test.js +380 -380
  281. package/src/services/__tests__/modelRouterNaming.test.js +111 -111
  282. package/src/services/__tests__/projectDetector.test.js +34 -34
  283. package/src/services/__tests__/promptService.test.js +242 -242
  284. package/src/services/__tests__/telegramService.test.js +941 -941
  285. package/src/services/__tests__/tokenCountingService.test.js +48 -48
  286. package/src/services/agentActivityService.js +419 -420
  287. package/src/services/aiService.js +2997 -3001
  288. package/src/services/apiKeyManager.js +359 -359
  289. package/src/services/benchmarkService.js +196 -196
  290. package/src/services/codebaseKnowledgeService.js +2 -2
  291. package/src/services/composioService.js +738 -738
  292. package/src/services/conversationCompactionService.js +1258 -1257
  293. package/src/services/credentialVault.js +685 -685
  294. package/src/services/discordService.js +792 -793
  295. package/src/services/embeddings/__tests__/azureCustomProvider.test.js +232 -232
  296. package/src/services/embeddings/__tests__/embeddingService.test.js +417 -417
  297. package/src/services/embeddings/__tests__/localProvider.test.js +263 -263
  298. package/src/services/embeddings/autoRecall.js +218 -219
  299. package/src/services/embeddings/indexers/__tests__/agentIndexer.test.js +232 -232
  300. package/src/services/embeddings/indexers/__tests__/memoryIndexer.test.js +418 -418
  301. package/src/services/embeddings/indexers/__tests__/reminisceIndexer.test.js +356 -357
  302. package/src/services/embeddings/indexers/__tests__/skillsIndexer.test.js +145 -145
  303. package/src/services/embeddings/indexers/__tests__/taskIndexer.test.js +146 -146
  304. package/src/services/embeddings/indexers/composioIndexer.js +279 -279
  305. package/src/services/embeddings/providerInterface.js +206 -206
  306. package/src/services/embeddings/providers/localProvider.js +11 -7
  307. package/src/services/embeddings/providers/openaiProvider.js +101 -101
  308. package/src/services/embeddings/vectorStore/inMemoryJsonStore.js +356 -356
  309. package/src/services/errorHandler.js +809 -809
  310. package/src/services/flowContextService.js +586 -586
  311. package/src/services/grounding/MockAdapter.js +125 -125
  312. package/src/services/modelRouterService.js +26 -31
  313. package/src/services/modelsService.js +322 -322
  314. package/src/services/ollamaService.js +452 -452
  315. package/src/services/projectDetector.js +403 -404
  316. package/src/services/promptService.js +418 -418
  317. package/src/services/qualityInspector.js +795 -795
  318. package/src/services/scheduleService.js +726 -726
  319. package/src/services/serviceRegistry.js +386 -386
  320. package/src/services/telegrafBot.js +174 -174
  321. package/src/services/telegramService.js +1972 -1972
  322. package/src/services/visualEditorBridge.js +1033 -1033
  323. package/src/services/visualEditorServer.js +1769 -1774
  324. package/src/services/whatsappService.js +667 -668
  325. package/src/tools/__tests__/agentCommunicationTool.findAgent.test.js +226 -226
  326. package/src/tools/__tests__/agentCommunicationTool.test.js +3 -3
  327. package/src/tools/__tests__/agentDelayTool.test.js +342 -342
  328. package/src/tools/__tests__/baseTool.test.js +3 -3
  329. package/src/tools/__tests__/codeMapTool.test.js +915 -915
  330. package/src/tools/__tests__/fileContentReplaceTool.test.js +309 -309
  331. package/src/tools/__tests__/fileTreeTool.test.js +274 -274
  332. package/src/tools/__tests__/filesystemTool.test.js +815 -815
  333. package/src/tools/__tests__/foundryWebSearchTool.test.js +252 -252
  334. package/src/tools/__tests__/imageTool.validator.test.js +194 -194
  335. package/src/tools/__tests__/jobDoneTool.test.js +580 -581
  336. package/src/tools/__tests__/memoryTool.forgetStale.test.js +272 -272
  337. package/src/tools/__tests__/memoryTool.reminisce.test.js +2 -2
  338. package/src/tools/__tests__/memoryTool.reminisceSemanticSearch.test.js +301 -301
  339. package/src/tools/__tests__/memoryTool.semanticSearch.test.js +405 -405
  340. package/src/tools/__tests__/memoryTool.teamPool.test.js +293 -293
  341. package/src/tools/__tests__/memoryTool.test.js +1 -1
  342. package/src/tools/__tests__/seekTool.test.js +282 -282
  343. package/src/tools/__tests__/skillsTool.search.test.js +164 -164
  344. package/src/tools/__tests__/skillsTool.test.js +226 -226
  345. package/src/tools/__tests__/staticAnalysisTool.test.js +509 -509
  346. package/src/tools/__tests__/taskManagerTool.discipline.test.js +137 -137
  347. package/src/tools/__tests__/taskManagerTool.search.test.js +143 -143
  348. package/src/tools/__tests__/taskManagerTool.test.js +866 -866
  349. package/src/tools/__tests__/terminalTool.test.js +448 -448
  350. package/src/tools/__tests__/toolShapeForgiveness.test.js +259 -260
  351. package/src/tools/__tests__/userPromptTool.test.js +297 -297
  352. package/src/tools/__tests__/videoTool.jobs.test.js +147 -147
  353. package/src/tools/__tests__/webTool.e2e.test.js +609 -603
  354. package/src/tools/__tests__/webTool.unit.test.js +195 -195
  355. package/src/tools/__tests__/webTool.visionModel.test.js +75 -75
  356. package/src/tools/agentCommunicationTool.js +8 -10
  357. package/src/tools/agentDelayTool.js +496 -497
  358. package/src/tools/asyncToolManager.js +602 -603
  359. package/src/tools/baseTool.js +12 -11
  360. package/src/tools/cloneDetectionTool.js +576 -581
  361. package/src/tools/codeMapTool.js +0 -6
  362. package/src/tools/composioTool.js +617 -617
  363. package/src/tools/dependencyResolverTool.js +1211 -1212
  364. package/src/tools/desktop/DesktopTool.js +629 -638
  365. package/src/tools/desktop/__tests__/DesktopTool.e2e.test.js +306 -306
  366. package/src/tools/desktop/__tests__/DesktopTool.test.js +507 -507
  367. package/src/tools/desktop/__tests__/osController.test.js +364 -364
  368. package/src/tools/desktop/osController.js +491 -491
  369. package/src/tools/docxTool.js +623 -623
  370. package/src/tools/excelTool.js +636 -636
  371. package/src/tools/fileContentReplaceTool.js +5 -7
  372. package/src/tools/fileSystemTool.js +12 -19
  373. package/src/tools/fileTreeTool.js +840 -840
  374. package/src/tools/foundryWebSearchTool.js +273 -273
  375. package/src/tools/helpTool.js +198 -198
  376. package/src/tools/imageTool.js +1397 -1397
  377. package/src/tools/importAnalyzerTool.js +1056 -1056
  378. package/src/tools/jobDoneTool.js +495 -495
  379. package/src/tools/memoryTool.js +1 -1
  380. package/src/tools/office/pres/__tests__/presSystem.test.js +365 -365
  381. package/src/tools/office/pres/archetypes/agenda.js +61 -61
  382. package/src/tools/office/pres/archetypes/bentoGrid.js +218 -219
  383. package/src/tools/office/pres/archetypes/bigStat.js +140 -142
  384. package/src/tools/office/pres/archetypes/closing.js +70 -70
  385. package/src/tools/office/pres/archetypes/hero.js +70 -70
  386. package/src/tools/office/pres/archetypes/productHero.js +93 -94
  387. package/src/tools/office/pres/archetypes/table.js +73 -74
  388. package/src/tools/office/pres/backgrounds/orb.js +66 -66
  389. package/src/tools/office/pres/components.js +422 -423
  390. package/src/tools/officeTool.js +441 -441
  391. package/src/tools/pdfTool.js +625 -627
  392. package/src/tools/platformControlTool.js +1081 -1081
  393. package/src/tools/seekTool.js +917 -918
  394. package/src/tools/skillsTool.js +1 -1
  395. package/src/tools/staticAnalysisTool.js +2143 -2146
  396. package/src/tools/taskManagerTool.js +3324 -3324
  397. package/src/tools/terminalTool.js +2615 -2618
  398. package/src/tools/videoTool.js +1303 -1303
  399. package/src/tools/visionTool.js +508 -508
  400. package/src/tools/visualEditorTool.js +1289 -1290
  401. package/src/tools/webTool.js +3368 -3368
  402. package/src/tools/whatsappTool.js +464 -464
  403. package/src/types/__tests__/agent.test.js +499 -499
  404. package/src/types/__tests__/contextReference.test.js +606 -606
  405. package/src/types/__tests__/conversation.test.js +555 -555
  406. package/src/types/__tests__/toolCommand.test.js +584 -584
  407. package/src/types/contextReference.js +974 -971
  408. package/src/types/conversation.js +729 -729
  409. package/src/types/toolCommand.js +746 -746
  410. package/src/utilities/__tests__/attachmentValidator.test.js +80 -80
  411. package/src/utilities/__tests__/auditReport.test.js +328 -328
  412. package/src/utilities/__tests__/directoryAccessManager.test.js +388 -388
  413. package/src/utilities/__tests__/jsonRepair.test.js +103 -104
  414. package/src/utilities/__tests__/modeTransitionReasons.test.js +105 -105
  415. package/src/utilities/__tests__/platformUtils.test.js +80 -87
  416. package/src/utilities/__tests__/structuredFileValidator.test.js +261 -263
  417. package/src/utilities/__tests__/toolConstants.test.js +92 -94
  418. package/src/utilities/__tests__/useIsTouchDevice.detect.test.js +114 -114
  419. package/src/utilities/__tests__/webUiUtilSync.test.js +117 -117
  420. package/src/utilities/attachmentValidator.js +284 -288
  421. package/src/utilities/authCache.js.backup-1779570472481 +121 -121
  422. package/src/utilities/browserStealth.js +631 -630
  423. package/src/utilities/configManager.js +616 -617
  424. package/src/utilities/directoryAccessManager.js +564 -565
  425. package/src/utilities/fileProcessor.js +308 -307
  426. package/src/utilities/humanBehavior.js +454 -453
  427. package/src/utilities/logger.js +479 -479
  428. package/src/utilities/structuredFileValidator.js +696 -699
  429. package/src/utilities/tagParser.js +5 -10
  430. package/src/utilities/userDataDir.js +308 -308
  431. package/node_modules/@isaacs/brace-expansion/dist/commonjs/index.js.map +0 -1
  432. package/node_modules/@isaacs/brace-expansion/dist/esm/index.js.map +0 -1
  433. package/node_modules/minipass/LICENSE +0 -15
  434. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/LICENSE.md +0 -0
  435. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.d.ts +0 -0
  436. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.d.ts.map +0 -0
  437. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.js +0 -0
  438. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.js.map +0 -0
  439. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/package.json +0 -0
  440. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.d.ts +0 -0
  441. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.d.ts.map +0 -0
  442. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.js +0 -0
  443. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.js.map +0 -0
  444. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/package.json +0 -0
  445. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/LICENSE +0 -0
  446. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.d.ts +0 -0
  447. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.d.ts.map +0 -0
  448. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/package.json +0 -0
  449. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.d.ts +0 -0
  450. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.d.ts.map +0 -0
  451. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/package.json +0 -0
@@ -1,1257 +1,1258 @@
1
- /**
2
- * ConversationCompactionService - Intelligent conversation compactization
3
- *
4
- * Purpose:
5
- * - Compress long conversations while preserving critical information
6
- * - AI-based summarization with sandwich approach (beginning + summary + end)
7
- * - Multi-pass compaction (up to 3 passes) when a single pass isn't enough
8
- * - Model switching support via best-existing-conversation selection
9
- * - Compaction model validation against live model catalog
10
- *
11
- * Strategy:
12
- * - Summarization only (sandwich approach):
13
- * Keep beginning messages + AI summary of middle + end messages
14
- * Middle segment always >= 50% of total messages
15
- * Multi-pass: if result is still too large, re-summarize up to MAX_COMPACTION_PASSES times
16
- *
17
- * Model Switch Behavior:
18
- * - Instead of truncation, find the best existing compacted conversation
19
- * from another model and summarize that for the target model
20
- */
21
-
22
- import {
23
- COMPACTION_CONFIG,
24
- COMPACTION_STRATEGIES,
25
- } from '../utilities/constants.js';
26
-
27
- class ConversationCompactionService {
28
- constructor(tokenCountingService, aiService, logger) {
29
- this.tokenCountingService = tokenCountingService;
30
- this.aiService = aiService;
31
- this.logger = logger;
32
-
33
- // Models service for runtime validation (injected after construction)
34
- this.modelsService = null;
35
-
36
- // Round-robin index for compaction model selection
37
- this.compactionModelIndex = 0;
38
-
39
- // Summary generation prompt template
40
- this.summaryPromptTemplate = this._createSummaryPromptTemplate();
41
- }
42
-
43
- /**
44
- * Inject models service for runtime model validation
45
- * @param {ModelsService} modelsService - Models service instance
46
- */
47
- setModelsService(modelsService) {
48
- this.modelsService = modelsService;
49
- this.logger.info('ModelsService injected into compaction service');
50
- }
51
-
52
- /**
53
- * Get validated compaction models — filters COMPACTION_MODELS against live catalog
54
- * @returns {string[]} Array of model names that are both recommended and available
55
- * @private
56
- */
57
- _getValidatedCompactionModels() {
58
- const recommendedModels = COMPACTION_CONFIG.COMPACTION_MODELS || [];
59
-
60
- if (!this.modelsService) {
61
- this.logger.debug('No modelsService available, using all recommended compaction models');
62
- return recommendedModels;
63
- }
64
-
65
- try {
66
- const availableNames = this.modelsService.getAvailableModelNames();
67
- if (!availableNames || availableNames.length === 0) {
68
- this.logger.warn('ModelsService returned no models, using all recommended compaction models');
69
- return recommendedModels;
70
- }
71
-
72
- const availableSet = new Set(availableNames);
73
- const validated = recommendedModels.filter(m => availableSet.has(m));
74
-
75
- if (validated.length > 0) {
76
- this.logger.debug('Compaction models validated against live catalog', {
77
- recommended: recommendedModels.length,
78
- available: validated.length,
79
- validated
80
- });
81
- return validated;
82
- }
83
-
84
- // No recommended models match — pick the available model with the largest context window
85
- this.logger.warn('No recommended compaction models found in catalog, selecting largest-context available model');
86
- const models = this.modelsService.getModels();
87
- const chatModels = models.filter(m => m.type === 'chat' || !m.type);
88
-
89
- if (chatModels.length === 0) {
90
- this.logger.error('No chat models available at all, falling back to recommended list');
91
- return recommendedModels;
92
- }
93
-
94
- // Sort by context window descending
95
- chatModels.sort((a, b) => (b.contextWindow || 0) - (a.contextWindow || 0));
96
- const fallbackModel = chatModels[0].name;
97
-
98
- this.logger.info('Using fallback compaction model from catalog', {
99
- model: fallbackModel,
100
- contextWindow: chatModels[0].contextWindow
101
- });
102
- return [fallbackModel];
103
-
104
- } catch (error) {
105
- this.logger.warn('Failed to validate compaction models against catalog', {
106
- error: error.message
107
- });
108
- return recommendedModels;
109
- }
110
- }
111
-
112
- /**
113
- * Get next compaction model using round-robin from validated models
114
- * @param {string[]} models - Validated model list
115
- * @param {number} offset - Offset from current index
116
- * @returns {string} Model name
117
- * @private
118
- */
119
- _getNextCompactionModel(models, offset = 0) {
120
- if (!models || models.length === 0) {
121
- throw new Error('No compaction models available');
122
- }
123
- const index = (this.compactionModelIndex + offset) % models.length;
124
- return models[index];
125
- }
126
-
127
- /**
128
- * Advance the round-robin index
129
- * @private
130
- */
131
- _advanceCompactionModelIndex() {
132
- const models = COMPACTION_CONFIG.COMPACTION_MODELS || [];
133
- if (models.length > 0) {
134
- this.compactionModelIndex = (this.compactionModelIndex + 1) % models.length;
135
- }
136
- }
137
-
138
- /**
139
- * Main compaction entry point
140
- * @param {Array} messages - Original messages array
141
- * @param {string} currentModel - Current model being used
142
- * @param {string} targetModel - Target model (may differ if switching)
143
- * @param {Object} options - Compaction options
144
- * @param {Map} [options.compactedConversations] - Map of modelId → compactedMessages (for model switch)
145
- * @returns {Promise<Object>} Compaction result with messages and metadata
146
- */
147
- async compactConversation(messages, currentModel, targetModel, options = {}) {
148
- const startTime = Date.now();
149
-
150
- try {
151
- // Validate inputs
152
- if (!Array.isArray(messages) || messages.length === 0) {
153
- throw new Error('Messages array is required and cannot be empty');
154
- }
155
-
156
- // Split oversized messages into smaller chunks before compaction.
157
- // This increases message count so the sandwich strategy can push
158
- // oversized content into the summarizable middle segment.
159
- const splitResult = this._splitOversizedMessages(messages);
160
- let wasSplit = splitResult.wasSplit;
161
-
162
- const minMessages = options.emergency ? 4 : COMPACTION_CONFIG.MIN_MESSAGES_FOR_COMPACTION;
163
-
164
- if (splitResult.messages.length < minMessages) {
165
- this.logger.warn('Too few messages for compaction', {
166
- messageCount: splitResult.messages.length,
167
- originalCount: messages.length,
168
- minimum: minMessages,
169
- emergency: !!options.emergency
170
- });
171
-
172
- return {
173
- compactedMessages: messages,
174
- strategy: 'none',
175
- originalTokenCount: 0,
176
- compactedTokenCount: 0,
177
- reductionPercent: 0,
178
- skipped: true,
179
- reason: 'Too few messages'
180
- };
181
- }
182
-
183
- // Determine if model switch and pick best starting conversation
184
- const isModelSwitch = currentModel !== targetModel;
185
- let messagesToCompact = splitResult.messages;
186
-
187
- if (isModelSwitch && options.compactedConversations) {
188
- const bestConversation = this._findBestConversationForModelSwitch(
189
- options.compactedConversations,
190
- targetModel
191
- );
192
- if (bestConversation) {
193
- // Split the best conversation too (it may contain oversized messages)
194
- const bestSplit = this._splitOversizedMessages(bestConversation);
195
- messagesToCompact = bestSplit.messages;
196
- wasSplit = wasSplit || bestSplit.wasSplit;
197
- this.logger.info('Using best existing conversation for model switch', {
198
- originalMessages: messages.length,
199
- bestConversationMessages: bestConversation.length,
200
- currentModel,
201
- targetModel
202
- });
203
- }
204
- }
205
-
206
- this.logger.info('Starting conversation compaction', {
207
- messageCount: messagesToCompact.length,
208
- currentModel,
209
- targetModel,
210
- strategy: COMPACTION_STRATEGIES.SUMMARIZATION,
211
- isModelSwitch
212
- });
213
-
214
- // Execute multi-pass summarization
215
- const result = await this._compactWithMultiPassSummarization(
216
- messagesToCompact,
217
- targetModel,
218
- { ...options, wasSplit }
219
- );
220
-
221
- // Add execution metadata
222
- const executionTime = Date.now() - startTime;
223
- result.executionTime = executionTime;
224
- result.timestamp = new Date().toISOString();
225
-
226
- this.logger.info('Compaction completed successfully', {
227
- strategy: result.strategy,
228
- originalMessages: messagesToCompact.length,
229
- compactedMessages: result.compactedMessages.length,
230
- originalTokens: result.originalTokenCount,
231
- compactedTokens: result.compactedTokenCount,
232
- reductionPercent: result.reductionPercent.toFixed(2),
233
- passes: result.passes,
234
- executionTime: `${executionTime}ms`
235
- });
236
-
237
- return result;
238
-
239
- } catch (error) {
240
- const executionTime = Date.now() - startTime;
241
-
242
- this.logger.error('Compaction failed', {
243
- error: error.message,
244
- messageCount: messages.length,
245
- currentModel,
246
- targetModel,
247
- executionTime: `${executionTime}ms`
248
- });
249
-
250
- throw error;
251
- }
252
- }
253
-
254
- /**
255
- * Find the best existing compacted conversation for model switching.
256
- * Prefers the conversation from a model whose context window is the largest
257
- * C where C < targetModel's context window.
258
- * Falls back to the shortest compacted conversation.
259
- *
260
- * @param {Map} compactedConversations - Map of modelId → compactedMessages
261
- * @param {string} targetModel - Target model name
262
- * @returns {Array|null} Best conversation messages, or null
263
- * @private
264
- */
265
- _findBestConversationForModelSwitch(compactedConversations, targetModel) {
266
- if (!compactedConversations || compactedConversations.size === 0) {
267
- return null;
268
- }
269
-
270
- const targetContextWindow = this.tokenCountingService.getModelContextWindow(targetModel);
271
-
272
- // Collect candidates: conversations that have messages
273
- const candidates = [];
274
- for (const [modelId, msgs] of compactedConversations) {
275
- if (Array.isArray(msgs) && msgs.length > 0) {
276
- const contextWindow = this.tokenCountingService.getModelContextWindow(modelId);
277
- candidates.push({ modelId, messages: msgs, contextWindow });
278
- }
279
- }
280
-
281
- if (candidates.length === 0) {
282
- return null;
283
- }
284
-
285
- // Prefer: largest context window that is < targetModel's context window
286
- // (these conversations were already compacted to fit a smaller window, so they'll fit the target)
287
- const smallerCandidates = candidates
288
- .filter(c => c.contextWindow < targetContextWindow)
289
- .sort((a, b) => b.contextWindow - a.contextWindow);
290
-
291
- if (smallerCandidates.length > 0) {
292
- const best = smallerCandidates[0];
293
- this.logger.debug('Best conversation for model switch: largest smaller context', {
294
- selectedModel: best.modelId,
295
- selectedContextWindow: best.contextWindow,
296
- targetContextWindow,
297
- messageCount: best.messages.length
298
- });
299
- return best.messages;
300
- }
301
-
302
- // Fallback: shortest conversation (fewest messages)
303
- candidates.sort((a, b) => a.messages.length - b.messages.length);
304
- const shortest = candidates[0];
305
- this.logger.debug('Best conversation for model switch: shortest', {
306
- selectedModel: shortest.modelId,
307
- messageCount: shortest.messages.length
308
- });
309
- return shortest.messages;
310
- }
311
-
312
- /**
313
- * Multi-pass summarization: runs up to MAX_COMPACTION_PASSES passes.
314
- * After each pass, checks if the result fits within the compaction threshold.
315
- * If it fits, returns immediately; otherwise re-compacts the compacted result.
316
- *
317
- * @param {Array} messages - Messages to compact
318
- * @param {string} model - Target model name
319
- * @param {Object} options - Compaction options
320
- * @returns {Promise<Object>} Compaction result
321
- * @private
322
- */
323
- async _compactWithMultiPassSummarization(messages, model, options) {
324
- const maxPasses = COMPACTION_CONFIG.MAX_COMPACTION_PASSES;
325
- const contextWindow = this.tokenCountingService.getModelContextWindow(model);
326
- const maxOutputTokens = this.tokenCountingService.getModelMaxOutputTokens(model);
327
- const threshold = COMPACTION_CONFIG.DEFAULT_THRESHOLD;
328
- const wasSplit = options.wasSplit || false;
329
-
330
- let currentMessages = messages;
331
- let result = null;
332
-
333
- for (let pass = 1; pass <= maxPasses; pass++) {
334
- this.logger.info(`Compaction pass ${pass}/${maxPasses}`, {
335
- inputMessages: currentMessages.length,
336
- model,
337
- contextWindow,
338
- wasSplit
339
- });
340
-
341
- result = await this._executeSingleSummarizationPass(currentMessages, model, options, pass);
342
-
343
- // Check if result fits within threshold
344
- const compactedTokens = this.tokenCountingService.getConversationTokenCount(
345
- result.compactedMessages,
346
- model
347
- );
348
- result.compactedTokenCount = compactedTokens;
349
-
350
- const fitsWithinThreshold = !this.tokenCountingService.shouldTriggerCompaction(
351
- compactedTokens,
352
- maxOutputTokens,
353
- contextWindow,
354
- threshold
355
- );
356
-
357
- this.logger.info(`Compaction pass ${pass} result`, {
358
- compactedMessages: result.compactedMessages.length,
359
- compactedTokens,
360
- fitsWithinThreshold,
361
- contextWindow,
362
- threshold
363
- });
364
-
365
- if (fitsWithinThreshold) {
366
- result.passes = pass;
367
- return result;
368
- }
369
-
370
- // If not the last pass, use compacted result as input for next pass
371
- // Split any oversized messages that survived compaction
372
- if (pass < maxPasses) {
373
- const reSplit = this._splitOversizedMessages(result.compactedMessages);
374
- currentMessages = reSplit.messages;
375
- }
376
- }
377
-
378
- // Best effort after all passes
379
- this.logger.warn('Compaction did not fit within threshold after all passes', {
380
- passes: maxPasses,
381
- finalTokens: result.compactedTokenCount,
382
- contextWindow,
383
- threshold
384
- });
385
- result.passes = maxPasses;
386
- return result;
387
- }
388
-
389
- /**
390
- * Execute a single summarization pass (sandwich approach).
391
- * Preserves beginning + AI summary of middle + end.
392
- *
393
- * @param {Array} messages - Messages to compact
394
- * @param {string} model - Target model name
395
- * @param {Object} options - Compaction options
396
- * @param {number} passNumber - Current pass number (1-based)
397
- * @returns {Promise<Object>} Compaction result
398
- * @private
399
- */
400
- async _executeSingleSummarizationPass(messages, model, options, passNumber) {
401
- const strategy = COMPACTION_STRATEGIES.SUMMARIZATION;
402
-
403
- // Estimate original token count
404
- const originalTokenCount = this.tokenCountingService.getConversationTokenCount(
405
- messages,
406
- model
407
- );
408
-
409
- // Identify segments (message-count-based)
410
- // When oversized messages were split, use a small end segment so
411
- // most chunks land in the middle for summarization
412
- const segments = this._identifySegments(messages, { wasSplit: options.wasSplit });
413
-
414
- this.logger.info(`Pass ${passNumber}: segments identified`, {
415
- summarizedMessages: segments.middle.length,
416
- keptMessages: segments.end.length,
417
- totalMessages: messages.length
418
- });
419
-
420
- // Generate summary of middle segment
421
- let summary;
422
- try {
423
- summary = await this._generateSummary(
424
- segments.middle,
425
- model,
426
- {
427
- ...options,
428
- middleStartIndex: segments.middleStartIndex,
429
- middleEndIndex: segments.middleEndIndex,
430
- passNumber
431
- }
432
- );
433
- } catch (error) {
434
- if (error.code === 'ALL_MODELS_EXHAUSTED') {
435
- // All AI models failed — use structural fallback
436
- this.logger.warn('All summarization models exhausted, using structural fallback compaction');
437
- const fallbackResult = this._performFallbackCompaction(messages);
438
-
439
- const compactedTokenCount = this.tokenCountingService.getConversationTokenCount(
440
- fallbackResult.compactedMessages,
441
- model
442
- );
443
-
444
- const reductionPercent = originalTokenCount > 0
445
- ? ((originalTokenCount - compactedTokenCount) / originalTokenCount) * 100
446
- : 0;
447
-
448
- return {
449
- compactedMessages: fallbackResult.compactedMessages,
450
- strategy: 'structural_fallback',
451
- originalTokenCount,
452
- compactedTokenCount,
453
- reductionPercent,
454
- segments: {
455
- beginningCount: segments.beginning.length,
456
- middleCount: segments.middle.length,
457
- endCount: segments.end.length,
458
- summaryInserted: true,
459
- fallback: true
460
- }
461
- };
462
- }
463
- throw error;
464
- }
465
-
466
- // Construct compacted conversation
467
- const compactedMessages = [
468
- ...segments.beginning,
469
- summary,
470
- ...segments.end
471
- ];
472
-
473
- // Count tokens in compacted conversation
474
- const compactedTokenCount = this.tokenCountingService.getConversationTokenCount(
475
- compactedMessages,
476
- model
477
- );
478
-
479
- // Calculate reduction
480
- const reductionPercent = originalTokenCount > 0
481
- ? ((originalTokenCount - compactedTokenCount) / originalTokenCount) * 100
482
- : 0;
483
-
484
- return {
485
- compactedMessages,
486
- strategy,
487
- originalTokenCount,
488
- compactedTokenCount,
489
- reductionPercent,
490
- segments: {
491
- beginningCount: segments.beginning.length,
492
- middleCount: segments.middle.length,
493
- endCount: segments.end.length,
494
- summaryInserted: true
495
- }
496
- };
497
- }
498
-
499
- /**
500
- * Calculate the maximum characters the summarizer can handle in a single call.
501
- * Uses the largest available compaction model's context window minus overhead.
502
- *
503
- * @returns {number} Maximum characters per summarization call
504
- * @private
505
- */
506
- _calculateSummarizerCapacity() {
507
- const models = this._getValidatedCompactionModels();
508
- const contextWindows = { ...(COMPACTION_CONFIG.MODEL_CONTEXT_WINDOWS || {}) };
509
-
510
- // Augment with live data from modelsService if available
511
- if (this.modelsService) {
512
- try {
513
- const allModels = this.modelsService.getModels();
514
- for (const m of allModels) {
515
- if (m.contextWindow) {
516
- contextWindows[m.name] = m.contextWindow;
517
- }
518
- }
519
- } catch (e) { /* use static fallback */ }
520
- }
521
-
522
- const largestContext = Math.max(...models.map(m => contextWindows[m] || 128000));
523
-
524
- const usableTokens = largestContext
525
- - (COMPACTION_CONFIG.SUMMARIZER_SYSTEM_PROMPT_OVERHEAD || 500)
526
- - (COMPACTION_CONFIG.SUMMARIZER_TEMPLATE_OVERHEAD || 800)
527
- - (COMPACTION_CONFIG.MAX_SUMMARY_TOKENS || 8000)
528
- - (COMPACTION_CONFIG.SUMMARIZER_SAFETY_MARGIN || 5000);
529
-
530
- const effectiveTokens = Math.max(10000, usableTokens);
531
- return effectiveTokens * (COMPACTION_CONFIG.CHARS_PER_TOKEN_ESTIMATE || 3);
532
- }
533
-
534
- /**
535
- * Identify conversation segments using token-budget sizing.
536
- * The middle segment starts at the 15% char mark and extends forward
537
- * until hitting either the summarizer's capacity or 35% of total chars.
538
- * This guarantees the middle always fits within the summarizer's context window.
539
- *
540
- * @param {Array} messages - Messages array
541
- * @param {Object} [options] - Segmentation options
542
- * @param {boolean} [options.wasSplit] - Whether oversized messages were split (unused in new logic)
543
- * @returns {Object} { beginning, middle, end, middleStartIndex, middleEndIndex }
544
- * @private
545
- */
546
- _identifySegments(messages, options = {}) {
547
- const totalMessages = messages.length;
548
-
549
- // Edge case: very small conversations — summarize all but last message
550
- if (totalMessages <= 4) {
551
- return {
552
- beginning: [],
553
- middle: messages.slice(0, Math.max(1, totalMessages - 1)),
554
- end: messages.slice(-1),
555
- middleStartIndex: 0,
556
- middleEndIndex: Math.max(0, totalMessages - 2)
557
- };
558
- }
559
-
560
- // Calculate char length of each message
561
- const msgChars = messages.map(m => {
562
- const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content || '');
563
- return content.length;
564
- });
565
- const totalChars = msgChars.reduce((sum, c) => sum + c, 0);
566
-
567
- // Get summarizer capacity (max chars it can handle in one call)
568
- const summarizerCapacity = this._calculateSummarizerCapacity();
569
-
570
- // Walk BACKWARD from end to determine tail (kept verbatim)
571
- // Recent messages are more relevant for continuation than old ones
572
- const tailBudget = totalChars * COMPACTION_CONFIG.TAIL_PRESERVE_PERCENTAGE;
573
- let tailChars = 0;
574
- let keepStartIdx = totalMessages;
575
- for (let i = totalMessages - 1; i >= 0; i--) {
576
- if (tailChars + msgChars[i] > tailBudget && i < totalMessages - 1) {
577
- break;
578
- }
579
- tailChars += msgChars[i];
580
- keepStartIdx = i;
581
- }
582
- // Ensure at least 1 message in the summarize segment
583
- keepStartIdx = Math.max(1, keepStartIdx);
584
-
585
- // Old segment: M[0..keepStartIdx-1] — to be summarized
586
- // Cap by summarizer capacity (if too large, only summarize what fits this pass)
587
- let oldChars = 0;
588
- let summarizeEndIdx = keepStartIdx;
589
- for (let i = 0; i < keepStartIdx; i++) {
590
- if (oldChars + msgChars[i] > summarizerCapacity && i > 0) {
591
- summarizeEndIdx = i;
592
- break;
593
- }
594
- oldChars += msgChars[i];
595
- }
596
-
597
- const middle = messages.slice(0, summarizeEndIdx); // summarized
598
- const end = messages.slice(summarizeEndIdx); // kept verbatim
599
-
600
- this.logger.info('Segment identification (tail-preserving)', {
601
- totalMessages,
602
- totalChars,
603
- summarizerCapacity,
604
- middleCount: middle.length,
605
- middleChars: oldChars,
606
- endCount: end.length,
607
- tailBudget,
608
- keepStartIdx,
609
- summarizeEndIdx
610
- });
611
-
612
- return {
613
- beginning: [],
614
- middle: middle.length > 0 ? middle : [messages[0]],
615
- end: end.length > 0 ? end : [messages[messages.length - 1]],
616
- middleStartIndex: 0,
617
- middleEndIndex: summarizeEndIdx - 1
618
- };
619
- }
620
-
621
- /**
622
- * Generate AI summary of middle segment using validated compaction models
623
- * @private
624
- */
625
- async _generateSummary(middleMessages, model, options = {}) {
626
- if (middleMessages.length === 0) {
627
- return {
628
- role: 'system',
629
- content: `${COMPACTION_CONFIG.COMPACTION_SUMMARY_PREFIX} No messages to summarize.`,
630
- type: 'summary',
631
- timestamp: new Date().toISOString()
632
- };
633
- }
634
-
635
- // Format middle messages for summarization — PRE-TAG each message
636
- // with a category the summarizer can trust without inference.
637
- //
638
- // Why pre-tag instead of letting the summarizer figure it out:
639
- // tool-result wrappers carry `role: user` (they come back as
640
- // user-role messages by convention in this codebase). A summarizer
641
- // staring at raw `user:` prefixes can't reliably tell a literal
642
- // user typing from a tool-result blob and in our experiments
643
- // both gpt-4.1-mini and gpt-4.1-nano routinely quoted tool blobs
644
- // as if they were user messages, wasting budget and corrupting
645
- // the user-voice section. Categorizing here eliminates that whole
646
- // failure class. See _categorizeMessage for the rules.
647
- let middleContent = middleMessages
648
- .map(msg => {
649
- const cat = this._categorizeMessage(msg);
650
- const body = typeof msg.content === 'string'
651
- ? msg.content
652
- : JSON.stringify(msg.content);
653
- return `[${cat}] ${body}`;
654
- })
655
- .join('\n\n────────\n\n');
656
-
657
- // Estimate input tokens
658
- const estimatedInputTokens = Math.ceil(middleContent.length / COMPACTION_CONFIG.CHARS_PER_TOKEN_ESTIMATE);
659
-
660
- // Get validated compaction models
661
- const models = this._getValidatedCompactionModels();
662
-
663
- // Get context windows for smart selection
664
- const contextWindows = COMPACTION_CONFIG.MODEL_CONTEXT_WINDOWS || {};
665
-
666
- // If modelsService available, augment context windows with live data
667
- if (this.modelsService) {
668
- try {
669
- const allModels = this.modelsService.getModels();
670
- for (const m of allModels) {
671
- if (m.contextWindow) {
672
- contextWindows[m.name] = m.contextWindow;
673
- }
674
- }
675
- } catch (e) {
676
- // Ignore — use static fallback
677
- }
678
- }
679
-
680
- // Find the largest context window available among compaction models
681
- const largestContextWindow = Math.max(
682
- ...models.map(m => contextWindows[m] || 128000)
683
- );
684
- const maxInputTokens = largestContextWindow - 10000;
685
-
686
- // Middle is now sized by _identifySegments to fit within the summarizer's capacity.
687
- // No truncation needed the segment is guaranteed to be within budget.
688
- if (estimatedInputTokens > maxInputTokens) {
689
- this.logger.info('Middle segment exceeds single model but sized to fit summarizer capacity', {
690
- estimatedInputTokens,
691
- maxInputTokens,
692
- largestContextWindow,
693
- middleChars: middleContent.length
694
- });
695
- }
696
-
697
- // Build summary prompt
698
- const summaryPrompt = this.summaryPromptTemplate
699
- .replace('{middle_segment}', middleContent);
700
-
701
- // Estimate tokens for model selection
702
- const finalEstimatedTokens = Math.ceil(middleContent.length / COMPACTION_CONFIG.CHARS_PER_TOKEN_ESTIMATE);
703
-
704
- // Filter to models with sufficient context
705
- const requiredContext = finalEstimatedTokens + 10000;
706
- const capableModels = models.filter(m => {
707
- const modelContext = contextWindows[m] || 128000;
708
- return modelContext >= requiredContext;
709
- });
710
-
711
- const selectedModels = capableModels.length > 0 ? capableModels : models;
712
-
713
- this.logger.info('Compaction model selection', {
714
- finalEstimatedTokens,
715
- requiredContext,
716
- validatedModelsCount: models.length,
717
- capableModelsCount: capableModels.length,
718
- selectedModels
719
- });
720
-
721
- const modelsAttempted = [];
722
- let lastError = null;
723
-
724
- for (let attempt = 0; attempt < selectedModels.length; attempt++) {
725
- const compactionModel = selectedModels[attempt];
726
- modelsAttempted.push(compactionModel);
727
-
728
- try {
729
- this.logger.info('Generating summary', {
730
- compactionModel,
731
- attempt: attempt + 1,
732
- totalModels: selectedModels.length,
733
- middleMessageCount: middleMessages.length,
734
- passNumber: options.passNumber || 1
735
- });
736
-
737
- // Call AI service (skipCircuitBreaker prevents compaction failures from blocking the agent)
738
- const response = await this.aiService.sendMessage(
739
- compactionModel,
740
- summaryPrompt,
741
- {
742
- systemPrompt: 'You are a conversation summarization expert. Your goal is to compress conversations while preserving critical information for continued interaction.',
743
- maxTokens: COMPACTION_CONFIG.MAX_SUMMARY_TOKENS,
744
- temperature: 0.3,
745
- sessionId: options.sessionId,
746
- platformProvided: true,
747
- skipCircuitBreaker: true
748
- }
749
- );
750
-
751
- const summaryContent = response.content.trim();
752
-
753
- // Build index range string
754
- const indexRange = (options.middleStartIndex !== undefined && options.middleEndIndex !== undefined)
755
- ? `original messages ${options.middleStartIndex}-${options.middleEndIndex}`
756
- : `${middleMessages.length} messages`;
757
-
758
- this.logger.info('Summary generated successfully', {
759
- compactionModel,
760
- attempt: attempt + 1,
761
- originalLength: middleContent.length,
762
- summaryLength: summaryContent.length,
763
- compressionRatio: (summaryContent.length / middleContent.length * 100).toFixed(2) + '%',
764
- indexRange,
765
- passNumber: options.passNumber || 1
766
- });
767
-
768
- // Advance round-robin index
769
- this._advanceCompactionModelIndex();
770
-
771
- return {
772
- role: 'system',
773
- content: `${COMPACTION_CONFIG.COMPACTION_SUMMARY_PREFIX} - ${indexRange}]\n\n${summaryContent}\n\n${COMPACTION_CONFIG.COMPACTION_SUMMARY_SUFFIX}`,
774
- type: 'summary',
775
- timestamp: new Date().toISOString(),
776
- metadata: {
777
- originalMessageCount: middleMessages.length,
778
- originalStartIndex: options.middleStartIndex,
779
- originalEndIndex: options.middleEndIndex,
780
- compactionModel,
781
- passNumber: options.passNumber || 1
782
- }
783
- };
784
-
785
- } catch (error) {
786
- lastError = error;
787
- const isRateLimit = error.message?.includes('429') || error.message?.includes('rate limit');
788
-
789
- this.logger.warn('Summary generation failed, trying next model', {
790
- compactionModel,
791
- attempt: attempt + 1,
792
- remainingModels: selectedModels.length - attempt - 1,
793
- isRateLimit,
794
- error: error.message
795
- });
796
-
797
- // Notify user that compaction is taking longer (only if more models to try)
798
- if (attempt < selectedModels.length - 1 && options.onRetryAttempt) {
799
- options.onRetryAttempt({
800
- type: 'compaction_retry',
801
- message: 'Compaction is taking longer than usual, hold on we\'ll be right there',
802
- failedModel: compactionModel,
803
- nextModel: selectedModels[attempt + 1],
804
- attempt: attempt + 1,
805
- totalModels: selectedModels.length
806
- });
807
- }
808
- }
809
- }
810
-
811
- // All recommended models failed — try one last-resort random model with sufficient context
812
- if (this.modelsService) {
813
- try {
814
- const allModels = this.modelsService.getModels();
815
- const suitableModels = allModels
816
- .filter(m => (m.type === 'chat' || !m.type) && !modelsAttempted.includes(m.name))
817
- .filter(m => (m.contextWindow || 128000) >= requiredContext)
818
- .sort(() => Math.random() - 0.5);
819
-
820
- if (suitableModels.length > 0) {
821
- const lastResort = suitableModels[0].name;
822
- modelsAttempted.push(lastResort);
823
-
824
- this.logger.info('Trying last-resort random model for compaction', { lastResort, requiredContext });
825
-
826
- if (options.onRetryAttempt) {
827
- options.onRetryAttempt({
828
- type: 'compaction_retry',
829
- message: 'Compaction is taking longer than usual, hold on we\'ll be right there',
830
- failedModel: modelsAttempted[modelsAttempted.length - 2],
831
- nextModel: lastResort,
832
- attempt: modelsAttempted.length,
833
- totalModels: modelsAttempted.length
834
- });
835
- }
836
-
837
- const response = await this.aiService.sendMessage(
838
- lastResort,
839
- summaryPrompt,
840
- {
841
- systemPrompt: 'You are a conversation summarization expert. Your goal is to compress conversations while preserving critical information for continued interaction.',
842
- maxTokens: COMPACTION_CONFIG.MAX_SUMMARY_TOKENS,
843
- temperature: 0.3,
844
- sessionId: options.sessionId,
845
- platformProvided: true,
846
- skipCircuitBreaker: true
847
- }
848
- );
849
-
850
- const summaryContent = response.content.trim();
851
- const indexRange = (options.middleStartIndex !== undefined && options.middleEndIndex !== undefined)
852
- ? `original messages ${options.middleStartIndex}-${options.middleEndIndex}`
853
- : `${middleMessages.length} messages`;
854
-
855
- this.logger.info('Last-resort model succeeded for compaction', { lastResort, summaryLength: summaryContent.length });
856
- this._advanceCompactionModelIndex();
857
-
858
- return {
859
- role: 'system',
860
- content: `${COMPACTION_CONFIG.COMPACTION_SUMMARY_PREFIX} - ${indexRange}]\n\n${summaryContent}\n\n${COMPACTION_CONFIG.COMPACTION_SUMMARY_SUFFIX}`,
861
- type: 'summary',
862
- timestamp: new Date().toISOString(),
863
- metadata: {
864
- originalMessageCount: middleMessages.length,
865
- originalStartIndex: options.middleStartIndex,
866
- originalEndIndex: options.middleEndIndex,
867
- compactionModel: lastResort,
868
- passNumber: options.passNumber || 1,
869
- lastResort: true
870
- }
871
- };
872
- }
873
- } catch (lastResortError) {
874
- this.logger.warn('Last-resort model also failed', { error: lastResortError.message });
875
- lastError = lastResortError;
876
- }
877
- }
878
-
879
- // ALL models exhausted (including last-resort) — now show error to user
880
- const errorDetails = {
881
- modelsAttempted,
882
- lastError: lastError?.message,
883
- middleMessageCount: middleMessages.length,
884
- isRateLimitIssue: lastError?.message?.includes('429') || lastError?.message?.includes('rate limit')
885
- };
886
-
887
- this.logger.error('All compaction models exhausted (including last-resort)', errorDetails);
888
-
889
- if (options.onAllModelsExhausted) {
890
- options.onAllModelsExhausted({
891
- type: 'compaction_models_exhausted',
892
- message: `Conversation compaction failed: All ${modelsAttempted.length} models are currently unavailable. ${errorDetails.isRateLimitIssue ? 'Rate limits may be in effect.' : ''} Using structural fallback compaction.`,
893
- models: modelsAttempted,
894
- error: lastError?.message
895
- });
896
- }
897
-
898
- this._advanceCompactionModelIndex();
899
-
900
- const exhaustedError = new Error('ALL_MODELS_EXHAUSTED');
901
- exhaustedError.code = 'ALL_MODELS_EXHAUSTED';
902
- exhaustedError.details = errorDetails;
903
- throw exhaustedError;
904
- }
905
-
906
- /**
907
- * Perform structural fallback compaction when all AI models are unavailable.
908
- * No AI call required - pure structural transformation:
909
- * 1. Remove all system messages (except the first/main one)
910
- * 2. Remove tool results
911
- * 3. Keep beginning + end of remaining messages
912
- * 4. Replace middle with a short paragraph
913
- * @param {Array} allMessages - The full original messages array
914
- * @returns {Object} { compactedMessages, metadata }
915
- * @private
916
- */
917
- _performFallbackCompaction(allMessages) {
918
- // 1. Identify the main system message
919
- const mainSystemMsg = allMessages.find(m => m.role === 'system' && m.type !== 'summary');
920
-
921
- // 2. Filter out system messages (except main) and tool results
922
- const filteredMessages = allMessages.filter(m => {
923
- if (m === mainSystemMsg) return true;
924
- if (m.role === 'system') return false;
925
- if (m.type === 'tool_result' || m.type === 'tool-result' || m.role === 'tool') return false;
926
- return true;
927
- });
928
-
929
- const removedSystemCount = allMessages.filter(m => m.role === 'system' && m !== mainSystemMsg).length;
930
- const removedToolCount = allMessages.filter(m => m.type === 'tool_result' || m.type === 'tool-result' || m.role === 'tool').length;
931
-
932
- // 3. Apply sandwich using segment identification
933
- const segments = this._identifySegments(filteredMessages);
934
-
935
- // 4. Build replacement paragraph for middle section
936
- const middleSummary = this._buildFallbackMiddleParagraph(segments.middle, {
937
- removedSystemCount,
938
- removedToolCount,
939
- totalOriginalMessages: allMessages.length
940
- });
941
-
942
- // 5. Create the summary message
943
- const summaryMessage = {
944
- role: 'system',
945
- content: `${COMPACTION_CONFIG.COMPACTION_SUMMARY_PREFIX} - structural fallback]\n\n${middleSummary}\n\n${COMPACTION_CONFIG.COMPACTION_SUMMARY_SUFFIX}`,
946
- type: 'summary',
947
- timestamp: new Date().toISOString(),
948
- metadata: {
949
- fallback: true,
950
- structural: true,
951
- removedSystemMessages: removedSystemCount,
952
- removedToolResults: removedToolCount,
953
- middleMessagesCompacted: segments.middle.length
954
- }
955
- };
956
-
957
- // 6. Reconstruct conversation
958
- const compactedMessages = [
959
- ...segments.beginning,
960
- summaryMessage,
961
- ...segments.end
962
- ];
963
-
964
- this.logger.info('Structural fallback compaction performed', {
965
- originalMessages: allMessages.length,
966
- afterFiltering: filteredMessages.length,
967
- removedSystemMessages: removedSystemCount,
968
- removedToolResults: removedToolCount,
969
- beginningKept: segments.beginning.length,
970
- middleCompacted: segments.middle.length,
971
- endKept: segments.end.length,
972
- finalMessages: compactedMessages.length
973
- });
974
-
975
- return {
976
- compactedMessages,
977
- metadata: {
978
- strategy: 'structural_fallback',
979
- removedSystemMessages: removedSystemCount,
980
- removedToolResults: removedToolCount,
981
- middleMessagesCompacted: segments.middle.length
982
- }
983
- };
984
- }
985
-
986
- /**
987
- * Build a short paragraph summarizing the middle section for fallback compaction
988
- * @private
989
- */
990
- _buildFallbackMiddleParagraph(middleMessages, stats) {
991
- const userMsgs = middleMessages.filter(m => m.role === 'user');
992
- const assistantMsgs = middleMessages.filter(m => m.role === 'assistant');
993
-
994
- // Extract file paths mentioned in messages
995
- const filePathRegex = /(?:\/[\w.-]+)+\.\w+|(?:[A-Za-z]:)?(?:\\[\w.-]+)+\.\w+|(?:src|lib|test|config|public|dist|build|node_modules)\/[\w./\-]+/g;
996
- const filePaths = new Set();
997
- for (const msg of middleMessages) {
998
- const content = typeof msg.content === 'string' ? msg.content : '';
999
- const matches = content.match(filePathRegex);
1000
- if (matches) {
1001
- matches.forEach(p => filePaths.add(p));
1002
- }
1003
- }
1004
-
1005
- const parts = [];
1006
- parts.push(`[${middleMessages.length} messages compacted (${userMsgs.length} user, ${assistantMsgs.length} assistant).`);
1007
-
1008
- if (stats.removedSystemCount > 0 || stats.removedToolCount > 0) {
1009
- parts.push(`Additionally removed: ${stats.removedSystemCount} system messages, ${stats.removedToolCount} tool results.`);
1010
- }
1011
-
1012
- if (filePaths.size > 0) {
1013
- const fileList = Array.from(filePaths).slice(0, 20).join(', ');
1014
- parts.push(`Files referenced: ${fileList}${filePaths.size > 20 ? ` and ${filePaths.size - 20} more` : ''}.`);
1015
- }
1016
-
1017
- parts.push('Summary generation was unavailable - content structurally compacted for context management.]');
1018
-
1019
- return parts.join(' ');
1020
- }
1021
-
1022
- /**
1023
- * Split oversized messages into smaller chunks for effective compaction.
1024
- * When a single message exceeds OVERSIZED_MESSAGE_THRESHOLD, it gets split
1025
- * into chunks of MAX_CHUNK_SIZE, increasing message count so the sandwich
1026
- * strategy can push the content into the summarizable middle segment.
1027
- *
1028
- * @param {Array} messages - Messages array
1029
- * @returns {Array} Messages with oversized ones split into chunks
1030
- * @private
1031
- */
1032
- _splitOversizedMessages(messages) {
1033
- const threshold = COMPACTION_CONFIG.OVERSIZED_MESSAGE_THRESHOLD;
1034
- const maxChunk = COMPACTION_CONFIG.MAX_CHUNK_SIZE;
1035
-
1036
- let splitCount = 0;
1037
- const result = [];
1038
-
1039
- for (const msg of messages) {
1040
- const content = typeof msg.content === 'string' ? msg.content : '';
1041
-
1042
- if (content.length <= threshold) {
1043
- result.push(msg);
1044
- continue;
1045
- }
1046
-
1047
- // Split this message
1048
- splitCount++;
1049
- const chunks = this._splitContentIntoChunks(content, maxChunk);
1050
-
1051
- this.logger.info('Splitting oversized message for compaction', {
1052
- role: msg.role,
1053
- type: msg.type || 'none',
1054
- originalChars: content.length,
1055
- chunks: chunks.length
1056
- });
1057
-
1058
- for (let i = 0; i < chunks.length; i++) {
1059
- const chunkMsg = {
1060
- ...msg,
1061
- content: `[Part ${i + 1}/${chunks.length}${i === 0 ? ' — oversized message split for compaction' : ' — continued'}]\n${chunks[i]}`,
1062
- _splitMetadata: {
1063
- originalLength: content.length,
1064
- chunkIndex: i,
1065
- totalChunks: chunks.length
1066
- }
1067
- };
1068
- // Give each chunk a unique ID to avoid conflicts
1069
- if (msg.id) {
1070
- chunkMsg.id = `${msg.id}-chunk-${i + 1}`;
1071
- }
1072
- result.push(chunkMsg);
1073
- }
1074
- }
1075
-
1076
- if (splitCount > 0) {
1077
- this.logger.info('Oversized message splitting complete', {
1078
- originalCount: messages.length,
1079
- newCount: result.length,
1080
- added: result.length - messages.length,
1081
- messagesSplit: splitCount
1082
- });
1083
- }
1084
-
1085
- return { messages: result, wasSplit: splitCount > 0 };
1086
- }
1087
-
1088
- /**
1089
- * Split content into chunks, respecting natural boundaries.
1090
- * Priority: double newlines > single newlines > hard cut at maxChunk.
1091
- *
1092
- * @param {string} content - Content to split
1093
- * @param {number} maxChunk - Maximum chunk size in chars
1094
- * @returns {string[]} Array of content chunks
1095
- * @private
1096
- */
1097
- _splitContentIntoChunks(content, maxChunk) {
1098
- if (content.length <= maxChunk) return [content];
1099
-
1100
- const chunks = [];
1101
- let remaining = content;
1102
-
1103
- while (remaining.length > maxChunk) {
1104
- let splitAt = -1;
1105
-
1106
- // Try double newline within the chunk range
1107
- const searchRange = remaining.substring(0, maxChunk);
1108
- const lastDoubleNL = searchRange.lastIndexOf('\n\n');
1109
- if (lastDoubleNL > maxChunk * 0.3) {
1110
- splitAt = lastDoubleNL + 2;
1111
- }
1112
-
1113
- // Fallback: single newline
1114
- if (splitAt === -1) {
1115
- const lastNL = searchRange.lastIndexOf('\n');
1116
- if (lastNL > maxChunk * 0.3) {
1117
- splitAt = lastNL + 1;
1118
- }
1119
- }
1120
-
1121
- // Fallback: sentence boundary (". " — common in prose, rare in code)
1122
- if (splitAt === -1) {
1123
- const lastSentence = searchRange.lastIndexOf('. ');
1124
- if (lastSentence > maxChunk * 0.3) {
1125
- splitAt = lastSentence + 2;
1126
- }
1127
- }
1128
-
1129
- // Fallback: hard cut (e.g. minified code with no newlines)
1130
- // TODO: Consider splitting at last space or semicolon before maxChunk
1131
- // to avoid breaking mid-token/mid-word in minified code
1132
- if (splitAt === -1) {
1133
- splitAt = maxChunk;
1134
- }
1135
-
1136
- chunks.push(remaining.substring(0, splitAt));
1137
- remaining = remaining.substring(splitAt);
1138
- }
1139
-
1140
- if (remaining.length > 0) {
1141
- chunks.push(remaining);
1142
- }
1143
-
1144
- return chunks;
1145
- }
1146
-
1147
- /**
1148
- * Categorize one conversation message for compaction tagging.
1149
- *
1150
- * Returns one of:
1151
- * REAL_USER — a literal user typing turn
1152
- * TOOL_RESULT — a `[Tool Results …]` wrapper (carries role:user)
1153
- * PREVIOUS_TASK — a `[Previous Task — Final Tool Results]` boundary
1154
- * AGENT assistant turn
1155
- * SYSTEM system message
1156
- *
1157
- * The categorization is deterministic — text-prefix sniffing on the
1158
- * content, not heuristic. Matches the convention used everywhere
1159
- * else in the CLI for marking tool-result envelopes.
1160
- *
1161
- * @param {object} msg - { role, content }
1162
- * @returns {string} one of the categories above
1163
- * @private
1164
- */
1165
- _categorizeMessage(msg) {
1166
- if (msg.role === 'assistant') return 'AGENT';
1167
- if (msg.role === 'system') return 'SYSTEM';
1168
- // role === 'user' — could be a real user message OR a tool-result wrapper.
1169
- const c = typeof msg.content === 'string' ? msg.content.trimStart() : '';
1170
- if (c.startsWith('[Tool Results')) return 'TOOL_RESULT';
1171
- if (c.startsWith('[Previous Task')) return 'PREVIOUS_TASK';
1172
- return 'REAL_USER';
1173
- }
1174
-
1175
- /**
1176
- * Create the compaction-summary prompt template.
1177
- *
1178
- * Why this prompt is shaped this way:
1179
- * The previous "paraphrase-everything" template was found to drop
1180
- * the user's literal asks during compaction (see the Talisman
1181
- * case study: the agent paraphrased the user's 3-point UI request
1182
- * into "redesign UI" and then went off and built a Settings
1183
- * screen). Re-tested across 3 models × 5 prompt variants, this
1184
- * two-pass shape was the highest-fidelity option that worked
1185
- * uniformly well across gpt-4.1-mini, gpt-4.1-nano, and
1186
- * FW-Kimi-K2.5. See tmp-compaction-experiment/ for the harness.
1187
- *
1188
- * PASS 1 is transcription. The summarizer is NOT allowed to filter
1189
- * user messages by "I think the agent already handled this." That
1190
- * determination belongs to the consumer agent reading the summary,
1191
- * not to the summarizer itself making the summarizer choose was
1192
- * how completed-vs-open misjudgments crept in. The blockquote
1193
- * format gives the consumer agent a strong visual signal to
1194
- * anchor on those literal asks.
1195
- *
1196
- * PASS 2 is the narrative summary of the agent's work — files,
1197
- * tools, decisions, state. Heavy compression OK here; only the
1198
- * user-voice section is sacred.
1199
- *
1200
- * @private
1201
- */
1202
- _createSummaryPromptTemplate() {
1203
- return `You are compacting an earlier portion of an agent-user conversation. The input has been PRE-TAGGED — every message starts with one of:
1204
-
1205
- [REAL_USER] — a literal user message; TRANSCRIBE VERBATIM in PASS 1
1206
- [AGENT] assistant turn (tool calls + reasoning)
1207
- [TOOL_RESULT] a tool's output; the consumer agent does NOT need these verbatim
1208
- [PREVIOUS_TASK] final tool-result block from a previous task boundary
1209
- [SYSTEM] system note
1210
-
1211
- You DO NOT need to detect categories yourself. Trust the tags. The pre-tagging is deterministic.
1212
-
1213
- Write the summary in THREE passes, in this exact order.
1214
-
1215
- ──────────────────────────────────────────────
1216
- PASS 1 — USER VOICE (transcription only, no judgment)
1217
- ──────────────────────────────────────────────
1218
-
1219
- For EVERY [REAL_USER] message — and ONLY [REAL_USER] messages — emit a blockquote:
1220
-
1221
- > **User said (orig idx N):** "<exact text, word for word, all of it>"
1222
-
1223
- Absolute rules:
1224
- - Do NOT quote any [TOOL_RESULT], [AGENT], [PREVIOUS_TASK], or [SYSTEM] message here.
1225
- - Do NOT condense, paraphrase, or omit any [REAL_USER] message.
1226
- - Do NOT skip a [REAL_USER] message on the assumption "the agent already addressed it." That determination belongs to the consumer agent, not to you. Your job here is transcription.
1227
- - Reproduce every [REAL_USER] message, in original order, including punctuation and typos.
1228
- - If the input has no [REAL_USER] messages, write "(no user messages in this segment)" and proceed.
1229
-
1230
- ──────────────────────────────────────────────
1231
- PASS 2 — EVENT LOG (chronological bullets, concrete details)
1232
- ──────────────────────────────────────────────
1233
-
1234
- A bulleted list of every notable event between/after the user messages. ONE bullet per event:
1235
-
1236
- - [orig idx N] <one-line description — include full file paths, tool names, line numbers, status, and outcome>
1237
-
1238
- Cover: file writes, successful tool calls that changed state, decisions made by the agent, errors that affected outcome, task-list changes (especially destructive ones like 'removed: N tasks'). Skip: pure-read tool calls that didn't change state, repeated reads, pleasantries, verbose tool output dumps.
1239
-
1240
- A consumer agent should be able to read this log and reconstruct the cause-and-effect chain — what happened to each [REAL_USER] request.
1241
-
1242
- ──────────────────────────────────────────────
1243
- PASS 3 — STATE NARRATIVE (2–4 sentences)
1244
- ──────────────────────────────────────────────
1245
-
1246
- Plain prose describing the situation at the end of this segment: what is done, what is mid-flight, what is open — and where possible, map back to which [REAL_USER] request each piece corresponds to. If [REAL_USER] requests are still open with no work toward them, say so explicitly. This is the place where lossy paraphrase is most dangerous — name the gaps clearly.
1247
-
1248
- ──────────────────────────────────────────────
1249
-
1250
- CONVERSATION SEGMENT TO COMPACT:
1251
- {middle_segment}
1252
-
1253
- OUTPUT: PASS 1, PASS 2, PASS 3 in that order. Use exactly the section headers above. No preamble, no meta-commentary.`;
1254
- }
1255
- }
1256
-
1257
- export default ConversationCompactionService;
1
+ /**
2
+ * ConversationCompactionService - Intelligent conversation compactization
3
+ *
4
+ * Purpose:
5
+ * - Compress long conversations while preserving critical information
6
+ * - AI-based summarization with sandwich approach (beginning + summary + end)
7
+ * - Multi-pass compaction (up to 3 passes) when a single pass isn't enough
8
+ * - Model switching support via best-existing-conversation selection
9
+ * - Compaction model validation against live model catalog
10
+ *
11
+ * Strategy:
12
+ * - Summarization only (sandwich approach):
13
+ * Keep beginning messages + AI summary of middle + end messages
14
+ * Middle segment always >= 50% of total messages
15
+ * Multi-pass: if result is still too large, re-summarize up to MAX_COMPACTION_PASSES times
16
+ *
17
+ * Model Switch Behavior:
18
+ * - Instead of truncation, find the best existing compacted conversation
19
+ * from another model and summarize that for the target model
20
+ */
21
+
22
+ import {
23
+ COMPACTION_CONFIG,
24
+ COMPACTION_STRATEGIES,
25
+ } from '../utilities/constants.js';
26
+
27
+ class ConversationCompactionService {
28
+ constructor(tokenCountingService, aiService, logger) {
29
+ this.tokenCountingService = tokenCountingService;
30
+ this.aiService = aiService;
31
+ this.logger = logger;
32
+
33
+ // Models service for runtime validation (injected after construction)
34
+ this.modelsService = null;
35
+
36
+ // Round-robin index for compaction model selection
37
+ this.compactionModelIndex = 0;
38
+
39
+ // Summary generation prompt template
40
+ this.summaryPromptTemplate = this._createSummaryPromptTemplate();
41
+ }
42
+
43
+ /**
44
+ * Inject models service for runtime model validation
45
+ * @param {ModelsService} modelsService - Models service instance
46
+ */
47
+ setModelsService(modelsService) {
48
+ this.modelsService = modelsService;
49
+ this.logger.info('ModelsService injected into compaction service');
50
+ }
51
+
52
+ /**
53
+ * Get validated compaction models — filters COMPACTION_MODELS against live catalog
54
+ * @returns {string[]} Array of model names that are both recommended and available
55
+ * @private
56
+ */
57
+ _getValidatedCompactionModels() {
58
+ const recommendedModels = COMPACTION_CONFIG.COMPACTION_MODELS || [];
59
+
60
+ if (!this.modelsService) {
61
+ this.logger.debug('No modelsService available, using all recommended compaction models');
62
+ return recommendedModels;
63
+ }
64
+
65
+ try {
66
+ const availableNames = this.modelsService.getAvailableModelNames();
67
+ if (!availableNames || availableNames.length === 0) {
68
+ this.logger.warn('ModelsService returned no models, using all recommended compaction models');
69
+ return recommendedModels;
70
+ }
71
+
72
+ const availableSet = new Set(availableNames);
73
+ const validated = recommendedModels.filter(m => availableSet.has(m));
74
+
75
+ if (validated.length > 0) {
76
+ this.logger.debug('Compaction models validated against live catalog', {
77
+ recommended: recommendedModels.length,
78
+ available: validated.length,
79
+ validated
80
+ });
81
+ return validated;
82
+ }
83
+
84
+ // No recommended models match — pick the available model with the largest context window
85
+ this.logger.warn('No recommended compaction models found in catalog, selecting largest-context available model');
86
+ const models = this.modelsService.getModels();
87
+ const chatModels = models.filter(m => m.type === 'chat' || !m.type);
88
+
89
+ if (chatModels.length === 0) {
90
+ this.logger.error('No chat models available at all, falling back to recommended list');
91
+ return recommendedModels;
92
+ }
93
+
94
+ // Sort by context window descending
95
+ chatModels.sort((a, b) => (b.contextWindow || 0) - (a.contextWindow || 0));
96
+ const fallbackModel = chatModels[0].name;
97
+
98
+ this.logger.info('Using fallback compaction model from catalog', {
99
+ model: fallbackModel,
100
+ contextWindow: chatModels[0].contextWindow
101
+ });
102
+ return [fallbackModel];
103
+
104
+ } catch (error) {
105
+ this.logger.warn('Failed to validate compaction models against catalog', {
106
+ error: error.message
107
+ });
108
+ return recommendedModels;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Get next compaction model using round-robin from validated models
114
+ * @param {string[]} models - Validated model list
115
+ * @param {number} offset - Offset from current index
116
+ * @returns {string} Model name
117
+ * @private
118
+ */
119
+ _getNextCompactionModel(models, offset = 0) {
120
+ if (!models || models.length === 0) {
121
+ throw new Error('No compaction models available');
122
+ }
123
+ const index = (this.compactionModelIndex + offset) % models.length;
124
+ return models[index];
125
+ }
126
+
127
+ /**
128
+ * Advance the round-robin index
129
+ * @private
130
+ */
131
+ _advanceCompactionModelIndex() {
132
+ const models = COMPACTION_CONFIG.COMPACTION_MODELS || [];
133
+ if (models.length > 0) {
134
+ this.compactionModelIndex = (this.compactionModelIndex + 1) % models.length;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Main compaction entry point
140
+ * @param {Array} messages - Original messages array
141
+ * @param {string} currentModel - Current model being used
142
+ * @param {string} targetModel - Target model (may differ if switching)
143
+ * @param {Object} options - Compaction options
144
+ * @param {Map} [options.compactedConversations] - Map of modelId → compactedMessages (for model switch)
145
+ * @returns {Promise<Object>} Compaction result with messages and metadata
146
+ */
147
+ async compactConversation(messages, currentModel, targetModel, options = {}) {
148
+ const startTime = Date.now();
149
+
150
+ try {
151
+ // Validate inputs
152
+ if (!Array.isArray(messages) || messages.length === 0) {
153
+ throw new Error('Messages array is required and cannot be empty');
154
+ }
155
+
156
+ // Split oversized messages into smaller chunks before compaction.
157
+ // This increases message count so the sandwich strategy can push
158
+ // oversized content into the summarizable middle segment.
159
+ const splitResult = this._splitOversizedMessages(messages);
160
+ let wasSplit = splitResult.wasSplit;
161
+
162
+ const minMessages = options.emergency ? 4 : COMPACTION_CONFIG.MIN_MESSAGES_FOR_COMPACTION;
163
+
164
+ if (splitResult.messages.length < minMessages) {
165
+ this.logger.warn('Too few messages for compaction', {
166
+ messageCount: splitResult.messages.length,
167
+ originalCount: messages.length,
168
+ minimum: minMessages,
169
+ emergency: !!options.emergency
170
+ });
171
+
172
+ return {
173
+ compactedMessages: messages,
174
+ strategy: 'none',
175
+ originalTokenCount: 0,
176
+ compactedTokenCount: 0,
177
+ reductionPercent: 0,
178
+ skipped: true,
179
+ reason: 'Too few messages'
180
+ };
181
+ }
182
+
183
+ // Determine if model switch and pick best starting conversation
184
+ const isModelSwitch = currentModel !== targetModel;
185
+ let messagesToCompact = splitResult.messages;
186
+
187
+ if (isModelSwitch && options.compactedConversations) {
188
+ const bestConversation = this._findBestConversationForModelSwitch(
189
+ options.compactedConversations,
190
+ targetModel
191
+ );
192
+ if (bestConversation) {
193
+ // Split the best conversation too (it may contain oversized messages)
194
+ const bestSplit = this._splitOversizedMessages(bestConversation);
195
+ messagesToCompact = bestSplit.messages;
196
+ wasSplit = wasSplit || bestSplit.wasSplit;
197
+ this.logger.info('Using best existing conversation for model switch', {
198
+ originalMessages: messages.length,
199
+ bestConversationMessages: bestConversation.length,
200
+ currentModel,
201
+ targetModel
202
+ });
203
+ }
204
+ }
205
+
206
+ this.logger.info('Starting conversation compaction', {
207
+ messageCount: messagesToCompact.length,
208
+ currentModel,
209
+ targetModel,
210
+ strategy: COMPACTION_STRATEGIES.SUMMARIZATION,
211
+ isModelSwitch
212
+ });
213
+
214
+ // Execute multi-pass summarization
215
+ const result = await this._compactWithMultiPassSummarization(
216
+ messagesToCompact,
217
+ targetModel,
218
+ { ...options, wasSplit }
219
+ );
220
+
221
+ // Add execution metadata
222
+ const executionTime = Date.now() - startTime;
223
+ result.executionTime = executionTime;
224
+ result.timestamp = new Date().toISOString();
225
+
226
+ this.logger.info('Compaction completed successfully', {
227
+ strategy: result.strategy,
228
+ originalMessages: messagesToCompact.length,
229
+ compactedMessages: result.compactedMessages.length,
230
+ originalTokens: result.originalTokenCount,
231
+ compactedTokens: result.compactedTokenCount,
232
+ reductionPercent: result.reductionPercent.toFixed(2),
233
+ passes: result.passes,
234
+ executionTime: `${executionTime}ms`
235
+ });
236
+
237
+ return result;
238
+
239
+ } catch (error) {
240
+ const executionTime = Date.now() - startTime;
241
+
242
+ this.logger.error('Compaction failed', {
243
+ error: error.message,
244
+ messageCount: messages.length,
245
+ currentModel,
246
+ targetModel,
247
+ executionTime: `${executionTime}ms`
248
+ });
249
+
250
+ throw error;
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Find the best existing compacted conversation for model switching.
256
+ * Prefers the conversation from a model whose context window is the largest
257
+ * C where C < targetModel's context window.
258
+ * Falls back to the shortest compacted conversation.
259
+ *
260
+ * @param {Map} compactedConversations - Map of modelId → compactedMessages
261
+ * @param {string} targetModel - Target model name
262
+ * @returns {Array|null} Best conversation messages, or null
263
+ * @private
264
+ */
265
+ _findBestConversationForModelSwitch(compactedConversations, targetModel) {
266
+ if (!compactedConversations || compactedConversations.size === 0) {
267
+ return null;
268
+ }
269
+
270
+ const targetContextWindow = this.tokenCountingService.getModelContextWindow(targetModel);
271
+
272
+ // Collect candidates: conversations that have messages
273
+ const candidates = [];
274
+ for (const [modelId, msgs] of compactedConversations) {
275
+ if (Array.isArray(msgs) && msgs.length > 0) {
276
+ const contextWindow = this.tokenCountingService.getModelContextWindow(modelId);
277
+ candidates.push({ modelId, messages: msgs, contextWindow });
278
+ }
279
+ }
280
+
281
+ if (candidates.length === 0) {
282
+ return null;
283
+ }
284
+
285
+ // Prefer: largest context window that is < targetModel's context window
286
+ // (these conversations were already compacted to fit a smaller window, so they'll fit the target)
287
+ const smallerCandidates = candidates
288
+ .filter(c => c.contextWindow < targetContextWindow)
289
+ .sort((a, b) => b.contextWindow - a.contextWindow);
290
+
291
+ if (smallerCandidates.length > 0) {
292
+ const best = smallerCandidates[0];
293
+ this.logger.debug('Best conversation for model switch: largest smaller context', {
294
+ selectedModel: best.modelId,
295
+ selectedContextWindow: best.contextWindow,
296
+ targetContextWindow,
297
+ messageCount: best.messages.length
298
+ });
299
+ return best.messages;
300
+ }
301
+
302
+ // Fallback: shortest conversation (fewest messages)
303
+ candidates.sort((a, b) => a.messages.length - b.messages.length);
304
+ const shortest = candidates[0];
305
+ this.logger.debug('Best conversation for model switch: shortest', {
306
+ selectedModel: shortest.modelId,
307
+ messageCount: shortest.messages.length
308
+ });
309
+ return shortest.messages;
310
+ }
311
+
312
+ /**
313
+ * Multi-pass summarization: runs up to MAX_COMPACTION_PASSES passes.
314
+ * After each pass, checks if the result fits within the compaction threshold.
315
+ * If it fits, returns immediately; otherwise re-compacts the compacted result.
316
+ *
317
+ * @param {Array} messages - Messages to compact
318
+ * @param {string} model - Target model name
319
+ * @param {Object} options - Compaction options
320
+ * @returns {Promise<Object>} Compaction result
321
+ * @private
322
+ */
323
+ async _compactWithMultiPassSummarization(messages, model, options) {
324
+ const maxPasses = COMPACTION_CONFIG.MAX_COMPACTION_PASSES;
325
+ const contextWindow = this.tokenCountingService.getModelContextWindow(model);
326
+ const maxOutputTokens = this.tokenCountingService.getModelMaxOutputTokens(model);
327
+ const threshold = COMPACTION_CONFIG.DEFAULT_THRESHOLD;
328
+ const wasSplit = options.wasSplit || false;
329
+
330
+ let currentMessages = messages;
331
+ let result = null;
332
+
333
+ for (let pass = 1; pass <= maxPasses; pass++) {
334
+ this.logger.info(`Compaction pass ${pass}/${maxPasses}`, {
335
+ inputMessages: currentMessages.length,
336
+ model,
337
+ contextWindow,
338
+ wasSplit
339
+ });
340
+
341
+ result = await this._executeSingleSummarizationPass(currentMessages, model, options, pass);
342
+
343
+ // Check if result fits within threshold
344
+ const compactedTokens = this.tokenCountingService.getConversationTokenCount(
345
+ result.compactedMessages,
346
+ model
347
+ );
348
+ result.compactedTokenCount = compactedTokens;
349
+
350
+ const fitsWithinThreshold = !this.tokenCountingService.shouldTriggerCompaction(
351
+ compactedTokens,
352
+ maxOutputTokens,
353
+ contextWindow,
354
+ threshold
355
+ );
356
+
357
+ this.logger.info(`Compaction pass ${pass} result`, {
358
+ compactedMessages: result.compactedMessages.length,
359
+ compactedTokens,
360
+ fitsWithinThreshold,
361
+ contextWindow,
362
+ threshold
363
+ });
364
+
365
+ if (fitsWithinThreshold) {
366
+ result.passes = pass;
367
+ return result;
368
+ }
369
+
370
+ // If not the last pass, use compacted result as input for next pass
371
+ // Split any oversized messages that survived compaction
372
+ if (pass < maxPasses) {
373
+ const reSplit = this._splitOversizedMessages(result.compactedMessages);
374
+ currentMessages = reSplit.messages;
375
+ }
376
+ }
377
+
378
+ // Best effort after all passes
379
+ this.logger.warn('Compaction did not fit within threshold after all passes', {
380
+ passes: maxPasses,
381
+ finalTokens: result.compactedTokenCount,
382
+ contextWindow,
383
+ threshold
384
+ });
385
+ result.passes = maxPasses;
386
+ return result;
387
+ }
388
+
389
+ /**
390
+ * Execute a single summarization pass (sandwich approach).
391
+ * Preserves beginning + AI summary of middle + end.
392
+ *
393
+ * @param {Array} messages - Messages to compact
394
+ * @param {string} model - Target model name
395
+ * @param {Object} options - Compaction options
396
+ * @param {number} passNumber - Current pass number (1-based)
397
+ * @returns {Promise<Object>} Compaction result
398
+ * @private
399
+ */
400
+ async _executeSingleSummarizationPass(messages, model, options, passNumber) {
401
+ const strategy = COMPACTION_STRATEGIES.SUMMARIZATION;
402
+
403
+ // Estimate original token count
404
+ const originalTokenCount = this.tokenCountingService.getConversationTokenCount(
405
+ messages,
406
+ model
407
+ );
408
+
409
+ // Identify segments (message-count-based)
410
+ // When oversized messages were split, use a small end segment so
411
+ // most chunks land in the middle for summarization
412
+ const segments = this._identifySegments(messages, { wasSplit: options.wasSplit });
413
+
414
+ this.logger.info(`Pass ${passNumber}: segments identified`, {
415
+ summarizedMessages: segments.middle.length,
416
+ keptMessages: segments.end.length,
417
+ totalMessages: messages.length
418
+ });
419
+
420
+ // Generate summary of middle segment
421
+ let summary;
422
+ try {
423
+ summary = await this._generateSummary(
424
+ segments.middle,
425
+ model,
426
+ {
427
+ ...options,
428
+ middleStartIndex: segments.middleStartIndex,
429
+ middleEndIndex: segments.middleEndIndex,
430
+ passNumber
431
+ }
432
+ );
433
+ } catch (error) {
434
+ if (error.code === 'ALL_MODELS_EXHAUSTED') {
435
+ // All AI models failed — use structural fallback
436
+ this.logger.warn('All summarization models exhausted, using structural fallback compaction');
437
+ const fallbackResult = this._performFallbackCompaction(messages);
438
+
439
+ const compactedTokenCount = this.tokenCountingService.getConversationTokenCount(
440
+ fallbackResult.compactedMessages,
441
+ model
442
+ );
443
+
444
+ const reductionPercent = originalTokenCount > 0
445
+ ? ((originalTokenCount - compactedTokenCount) / originalTokenCount) * 100
446
+ : 0;
447
+
448
+ return {
449
+ compactedMessages: fallbackResult.compactedMessages,
450
+ strategy: 'structural_fallback',
451
+ originalTokenCount,
452
+ compactedTokenCount,
453
+ reductionPercent,
454
+ segments: {
455
+ beginningCount: segments.beginning.length,
456
+ middleCount: segments.middle.length,
457
+ endCount: segments.end.length,
458
+ summaryInserted: true,
459
+ fallback: true
460
+ }
461
+ };
462
+ }
463
+ throw error;
464
+ }
465
+
466
+ // Construct compacted conversation
467
+ const compactedMessages = [
468
+ ...segments.beginning,
469
+ summary,
470
+ ...segments.end
471
+ ];
472
+
473
+ // Count tokens in compacted conversation
474
+ const compactedTokenCount = this.tokenCountingService.getConversationTokenCount(
475
+ compactedMessages,
476
+ model
477
+ );
478
+
479
+ // Calculate reduction
480
+ const reductionPercent = originalTokenCount > 0
481
+ ? ((originalTokenCount - compactedTokenCount) / originalTokenCount) * 100
482
+ : 0;
483
+
484
+ return {
485
+ compactedMessages,
486
+ strategy,
487
+ originalTokenCount,
488
+ compactedTokenCount,
489
+ reductionPercent,
490
+ segments: {
491
+ beginningCount: segments.beginning.length,
492
+ middleCount: segments.middle.length,
493
+ endCount: segments.end.length,
494
+ summaryInserted: true
495
+ }
496
+ };
497
+ }
498
+
499
+ /**
500
+ * Calculate the maximum characters the summarizer can handle in a single call.
501
+ * Uses the largest available compaction model's context window minus overhead.
502
+ *
503
+ * @returns {number} Maximum characters per summarization call
504
+ * @private
505
+ */
506
+ _calculateSummarizerCapacity() {
507
+ const models = this._getValidatedCompactionModels();
508
+ const contextWindows = { ...(COMPACTION_CONFIG.MODEL_CONTEXT_WINDOWS || {}) };
509
+
510
+ // Augment with live data from modelsService if available
511
+ if (this.modelsService) {
512
+ try {
513
+ const allModels = this.modelsService.getModels();
514
+ for (const m of allModels) {
515
+ if (m.contextWindow) {
516
+ contextWindows[m.name] = m.contextWindow;
517
+ }
518
+ }
519
+ } catch { /* use static fallback */ }
520
+ }
521
+
522
+ const largestContext = Math.max(...models.map(m => contextWindows[m] || 128000));
523
+
524
+ const usableTokens = largestContext
525
+ - (COMPACTION_CONFIG.SUMMARIZER_SYSTEM_PROMPT_OVERHEAD || 500)
526
+ - (COMPACTION_CONFIG.SUMMARIZER_TEMPLATE_OVERHEAD || 800)
527
+ - (COMPACTION_CONFIG.MAX_SUMMARY_TOKENS || 8000)
528
+ - (COMPACTION_CONFIG.SUMMARIZER_SAFETY_MARGIN || 5000);
529
+
530
+ const effectiveTokens = Math.max(10000, usableTokens);
531
+ return effectiveTokens * (COMPACTION_CONFIG.CHARS_PER_TOKEN_ESTIMATE || 3);
532
+ }
533
+
534
+ /**
535
+ * Identify conversation segments using token-budget sizing.
536
+ * The middle segment starts at the 15% char mark and extends forward
537
+ * until hitting either the summarizer's capacity or 35% of total chars.
538
+ * This guarantees the middle always fits within the summarizer's context window.
539
+ *
540
+ * @param {Array} messages - Messages array
541
+ * @param {Object} [options] - Segmentation options
542
+ * @param {boolean} [options.wasSplit] - Whether oversized messages were split (unused in new logic)
543
+ * @returns {Object} { beginning, middle, end, middleStartIndex, middleEndIndex }
544
+ * @private
545
+ */
546
+ // eslint-disable-next-line no-unused-vars
547
+ _identifySegments(messages, options = {}) {
548
+ const totalMessages = messages.length;
549
+
550
+ // Edge case: very small conversations — summarize all but last message
551
+ if (totalMessages <= 4) {
552
+ return {
553
+ beginning: [],
554
+ middle: messages.slice(0, Math.max(1, totalMessages - 1)),
555
+ end: messages.slice(-1),
556
+ middleStartIndex: 0,
557
+ middleEndIndex: Math.max(0, totalMessages - 2)
558
+ };
559
+ }
560
+
561
+ // Calculate char length of each message
562
+ const msgChars = messages.map(m => {
563
+ const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content || '');
564
+ return content.length;
565
+ });
566
+ const totalChars = msgChars.reduce((sum, c) => sum + c, 0);
567
+
568
+ // Get summarizer capacity (max chars it can handle in one call)
569
+ const summarizerCapacity = this._calculateSummarizerCapacity();
570
+
571
+ // Walk BACKWARD from end to determine tail (kept verbatim)
572
+ // Recent messages are more relevant for continuation than old ones
573
+ const tailBudget = totalChars * COMPACTION_CONFIG.TAIL_PRESERVE_PERCENTAGE;
574
+ let tailChars = 0;
575
+ let keepStartIdx = totalMessages;
576
+ for (let i = totalMessages - 1; i >= 0; i--) {
577
+ if (tailChars + msgChars[i] > tailBudget && i < totalMessages - 1) {
578
+ break;
579
+ }
580
+ tailChars += msgChars[i];
581
+ keepStartIdx = i;
582
+ }
583
+ // Ensure at least 1 message in the summarize segment
584
+ keepStartIdx = Math.max(1, keepStartIdx);
585
+
586
+ // Old segment: M[0..keepStartIdx-1] to be summarized
587
+ // Cap by summarizer capacity (if too large, only summarize what fits this pass)
588
+ let oldChars = 0;
589
+ let summarizeEndIdx = keepStartIdx;
590
+ for (let i = 0; i < keepStartIdx; i++) {
591
+ if (oldChars + msgChars[i] > summarizerCapacity && i > 0) {
592
+ summarizeEndIdx = i;
593
+ break;
594
+ }
595
+ oldChars += msgChars[i];
596
+ }
597
+
598
+ const middle = messages.slice(0, summarizeEndIdx); // summarized
599
+ const end = messages.slice(summarizeEndIdx); // kept verbatim
600
+
601
+ this.logger.info('Segment identification (tail-preserving)', {
602
+ totalMessages,
603
+ totalChars,
604
+ summarizerCapacity,
605
+ middleCount: middle.length,
606
+ middleChars: oldChars,
607
+ endCount: end.length,
608
+ tailBudget,
609
+ keepStartIdx,
610
+ summarizeEndIdx
611
+ });
612
+
613
+ return {
614
+ beginning: [],
615
+ middle: middle.length > 0 ? middle : [messages[0]],
616
+ end: end.length > 0 ? end : [messages[messages.length - 1]],
617
+ middleStartIndex: 0,
618
+ middleEndIndex: summarizeEndIdx - 1
619
+ };
620
+ }
621
+
622
+ /**
623
+ * Generate AI summary of middle segment using validated compaction models
624
+ * @private
625
+ */
626
+ async _generateSummary(middleMessages, model, options = {}) {
627
+ if (middleMessages.length === 0) {
628
+ return {
629
+ role: 'system',
630
+ content: `${COMPACTION_CONFIG.COMPACTION_SUMMARY_PREFIX} No messages to summarize.`,
631
+ type: 'summary',
632
+ timestamp: new Date().toISOString()
633
+ };
634
+ }
635
+
636
+ // Format middle messages for summarization PRE-TAG each message
637
+ // with a category the summarizer can trust without inference.
638
+ //
639
+ // Why pre-tag instead of letting the summarizer figure it out:
640
+ // tool-result wrappers carry `role: user` (they come back as
641
+ // user-role messages by convention in this codebase). A summarizer
642
+ // staring at raw `user:` prefixes can't reliably tell a literal
643
+ // user typing from a tool-result blob and in our experiments
644
+ // both gpt-4.1-mini and gpt-4.1-nano routinely quoted tool blobs
645
+ // as if they were user messages, wasting budget and corrupting
646
+ // the user-voice section. Categorizing here eliminates that whole
647
+ // failure class. See _categorizeMessage for the rules.
648
+ let middleContent = middleMessages
649
+ .map(msg => {
650
+ const cat = this._categorizeMessage(msg);
651
+ const body = typeof msg.content === 'string'
652
+ ? msg.content
653
+ : JSON.stringify(msg.content);
654
+ return `[${cat}] ${body}`;
655
+ })
656
+ .join('\n\n────────\n\n');
657
+
658
+ // Estimate input tokens
659
+ const estimatedInputTokens = Math.ceil(middleContent.length / COMPACTION_CONFIG.CHARS_PER_TOKEN_ESTIMATE);
660
+
661
+ // Get validated compaction models
662
+ const models = this._getValidatedCompactionModels();
663
+
664
+ // Get context windows for smart selection
665
+ const contextWindows = COMPACTION_CONFIG.MODEL_CONTEXT_WINDOWS || {};
666
+
667
+ // If modelsService available, augment context windows with live data
668
+ if (this.modelsService) {
669
+ try {
670
+ const allModels = this.modelsService.getModels();
671
+ for (const m of allModels) {
672
+ if (m.contextWindow) {
673
+ contextWindows[m.name] = m.contextWindow;
674
+ }
675
+ }
676
+ } catch {
677
+ // Ignore — use static fallback
678
+ }
679
+ }
680
+
681
+ // Find the largest context window available among compaction models
682
+ const largestContextWindow = Math.max(
683
+ ...models.map(m => contextWindows[m] || 128000)
684
+ );
685
+ const maxInputTokens = largestContextWindow - 10000;
686
+
687
+ // Middle is now sized by _identifySegments to fit within the summarizer's capacity.
688
+ // No truncation needed — the segment is guaranteed to be within budget.
689
+ if (estimatedInputTokens > maxInputTokens) {
690
+ this.logger.info('Middle segment exceeds single model but sized to fit summarizer capacity', {
691
+ estimatedInputTokens,
692
+ maxInputTokens,
693
+ largestContextWindow,
694
+ middleChars: middleContent.length
695
+ });
696
+ }
697
+
698
+ // Build summary prompt
699
+ const summaryPrompt = this.summaryPromptTemplate
700
+ .replace('{middle_segment}', middleContent);
701
+
702
+ // Estimate tokens for model selection
703
+ const finalEstimatedTokens = Math.ceil(middleContent.length / COMPACTION_CONFIG.CHARS_PER_TOKEN_ESTIMATE);
704
+
705
+ // Filter to models with sufficient context
706
+ const requiredContext = finalEstimatedTokens + 10000;
707
+ const capableModels = models.filter(m => {
708
+ const modelContext = contextWindows[m] || 128000;
709
+ return modelContext >= requiredContext;
710
+ });
711
+
712
+ const selectedModels = capableModels.length > 0 ? capableModels : models;
713
+
714
+ this.logger.info('Compaction model selection', {
715
+ finalEstimatedTokens,
716
+ requiredContext,
717
+ validatedModelsCount: models.length,
718
+ capableModelsCount: capableModels.length,
719
+ selectedModels
720
+ });
721
+
722
+ const modelsAttempted = [];
723
+ let lastError = null;
724
+
725
+ for (let attempt = 0; attempt < selectedModels.length; attempt++) {
726
+ const compactionModel = selectedModels[attempt];
727
+ modelsAttempted.push(compactionModel);
728
+
729
+ try {
730
+ this.logger.info('Generating summary', {
731
+ compactionModel,
732
+ attempt: attempt + 1,
733
+ totalModels: selectedModels.length,
734
+ middleMessageCount: middleMessages.length,
735
+ passNumber: options.passNumber || 1
736
+ });
737
+
738
+ // Call AI service (skipCircuitBreaker prevents compaction failures from blocking the agent)
739
+ const response = await this.aiService.sendMessage(
740
+ compactionModel,
741
+ summaryPrompt,
742
+ {
743
+ systemPrompt: 'You are a conversation summarization expert. Your goal is to compress conversations while preserving critical information for continued interaction.',
744
+ maxTokens: COMPACTION_CONFIG.MAX_SUMMARY_TOKENS,
745
+ temperature: 0.3,
746
+ sessionId: options.sessionId,
747
+ platformProvided: true,
748
+ skipCircuitBreaker: true
749
+ }
750
+ );
751
+
752
+ const summaryContent = response.content.trim();
753
+
754
+ // Build index range string
755
+ const indexRange = (options.middleStartIndex !== undefined && options.middleEndIndex !== undefined)
756
+ ? `original messages ${options.middleStartIndex}-${options.middleEndIndex}`
757
+ : `${middleMessages.length} messages`;
758
+
759
+ this.logger.info('Summary generated successfully', {
760
+ compactionModel,
761
+ attempt: attempt + 1,
762
+ originalLength: middleContent.length,
763
+ summaryLength: summaryContent.length,
764
+ compressionRatio: (summaryContent.length / middleContent.length * 100).toFixed(2) + '%',
765
+ indexRange,
766
+ passNumber: options.passNumber || 1
767
+ });
768
+
769
+ // Advance round-robin index
770
+ this._advanceCompactionModelIndex();
771
+
772
+ return {
773
+ role: 'system',
774
+ content: `${COMPACTION_CONFIG.COMPACTION_SUMMARY_PREFIX} - ${indexRange}]\n\n${summaryContent}\n\n${COMPACTION_CONFIG.COMPACTION_SUMMARY_SUFFIX}`,
775
+ type: 'summary',
776
+ timestamp: new Date().toISOString(),
777
+ metadata: {
778
+ originalMessageCount: middleMessages.length,
779
+ originalStartIndex: options.middleStartIndex,
780
+ originalEndIndex: options.middleEndIndex,
781
+ compactionModel,
782
+ passNumber: options.passNumber || 1
783
+ }
784
+ };
785
+
786
+ } catch (error) {
787
+ lastError = error;
788
+ const isRateLimit = error.message?.includes('429') || error.message?.includes('rate limit');
789
+
790
+ this.logger.warn('Summary generation failed, trying next model', {
791
+ compactionModel,
792
+ attempt: attempt + 1,
793
+ remainingModels: selectedModels.length - attempt - 1,
794
+ isRateLimit,
795
+ error: error.message
796
+ });
797
+
798
+ // Notify user that compaction is taking longer (only if more models to try)
799
+ if (attempt < selectedModels.length - 1 && options.onRetryAttempt) {
800
+ options.onRetryAttempt({
801
+ type: 'compaction_retry',
802
+ message: 'Compaction is taking longer than usual, hold on we\'ll be right there',
803
+ failedModel: compactionModel,
804
+ nextModel: selectedModels[attempt + 1],
805
+ attempt: attempt + 1,
806
+ totalModels: selectedModels.length
807
+ });
808
+ }
809
+ }
810
+ }
811
+
812
+ // All recommended models failed — try one last-resort random model with sufficient context
813
+ if (this.modelsService) {
814
+ try {
815
+ const allModels = this.modelsService.getModels();
816
+ const suitableModels = allModels
817
+ .filter(m => (m.type === 'chat' || !m.type) && !modelsAttempted.includes(m.name))
818
+ .filter(m => (m.contextWindow || 128000) >= requiredContext)
819
+ .sort(() => Math.random() - 0.5);
820
+
821
+ if (suitableModels.length > 0) {
822
+ const lastResort = suitableModels[0].name;
823
+ modelsAttempted.push(lastResort);
824
+
825
+ this.logger.info('Trying last-resort random model for compaction', { lastResort, requiredContext });
826
+
827
+ if (options.onRetryAttempt) {
828
+ options.onRetryAttempt({
829
+ type: 'compaction_retry',
830
+ message: 'Compaction is taking longer than usual, hold on we\'ll be right there',
831
+ failedModel: modelsAttempted[modelsAttempted.length - 2],
832
+ nextModel: lastResort,
833
+ attempt: modelsAttempted.length,
834
+ totalModels: modelsAttempted.length
835
+ });
836
+ }
837
+
838
+ const response = await this.aiService.sendMessage(
839
+ lastResort,
840
+ summaryPrompt,
841
+ {
842
+ systemPrompt: 'You are a conversation summarization expert. Your goal is to compress conversations while preserving critical information for continued interaction.',
843
+ maxTokens: COMPACTION_CONFIG.MAX_SUMMARY_TOKENS,
844
+ temperature: 0.3,
845
+ sessionId: options.sessionId,
846
+ platformProvided: true,
847
+ skipCircuitBreaker: true
848
+ }
849
+ );
850
+
851
+ const summaryContent = response.content.trim();
852
+ const indexRange = (options.middleStartIndex !== undefined && options.middleEndIndex !== undefined)
853
+ ? `original messages ${options.middleStartIndex}-${options.middleEndIndex}`
854
+ : `${middleMessages.length} messages`;
855
+
856
+ this.logger.info('Last-resort model succeeded for compaction', { lastResort, summaryLength: summaryContent.length });
857
+ this._advanceCompactionModelIndex();
858
+
859
+ return {
860
+ role: 'system',
861
+ content: `${COMPACTION_CONFIG.COMPACTION_SUMMARY_PREFIX} - ${indexRange}]\n\n${summaryContent}\n\n${COMPACTION_CONFIG.COMPACTION_SUMMARY_SUFFIX}`,
862
+ type: 'summary',
863
+ timestamp: new Date().toISOString(),
864
+ metadata: {
865
+ originalMessageCount: middleMessages.length,
866
+ originalStartIndex: options.middleStartIndex,
867
+ originalEndIndex: options.middleEndIndex,
868
+ compactionModel: lastResort,
869
+ passNumber: options.passNumber || 1,
870
+ lastResort: true
871
+ }
872
+ };
873
+ }
874
+ } catch (lastResortError) {
875
+ this.logger.warn('Last-resort model also failed', { error: lastResortError.message });
876
+ lastError = lastResortError;
877
+ }
878
+ }
879
+
880
+ // ALL models exhausted (including last-resort) — now show error to user
881
+ const errorDetails = {
882
+ modelsAttempted,
883
+ lastError: lastError?.message,
884
+ middleMessageCount: middleMessages.length,
885
+ isRateLimitIssue: lastError?.message?.includes('429') || lastError?.message?.includes('rate limit')
886
+ };
887
+
888
+ this.logger.error('All compaction models exhausted (including last-resort)', errorDetails);
889
+
890
+ if (options.onAllModelsExhausted) {
891
+ options.onAllModelsExhausted({
892
+ type: 'compaction_models_exhausted',
893
+ message: `Conversation compaction failed: All ${modelsAttempted.length} models are currently unavailable. ${errorDetails.isRateLimitIssue ? 'Rate limits may be in effect.' : ''} Using structural fallback compaction.`,
894
+ models: modelsAttempted,
895
+ error: lastError?.message
896
+ });
897
+ }
898
+
899
+ this._advanceCompactionModelIndex();
900
+
901
+ const exhaustedError = new Error('ALL_MODELS_EXHAUSTED');
902
+ exhaustedError.code = 'ALL_MODELS_EXHAUSTED';
903
+ exhaustedError.details = errorDetails;
904
+ throw exhaustedError;
905
+ }
906
+
907
+ /**
908
+ * Perform structural fallback compaction when all AI models are unavailable.
909
+ * No AI call required - pure structural transformation:
910
+ * 1. Remove all system messages (except the first/main one)
911
+ * 2. Remove tool results
912
+ * 3. Keep beginning + end of remaining messages
913
+ * 4. Replace middle with a short paragraph
914
+ * @param {Array} allMessages - The full original messages array
915
+ * @returns {Object} { compactedMessages, metadata }
916
+ * @private
917
+ */
918
+ _performFallbackCompaction(allMessages) {
919
+ // 1. Identify the main system message
920
+ const mainSystemMsg = allMessages.find(m => m.role === 'system' && m.type !== 'summary');
921
+
922
+ // 2. Filter out system messages (except main) and tool results
923
+ const filteredMessages = allMessages.filter(m => {
924
+ if (m === mainSystemMsg) return true;
925
+ if (m.role === 'system') return false;
926
+ if (m.type === 'tool_result' || m.type === 'tool-result' || m.role === 'tool') return false;
927
+ return true;
928
+ });
929
+
930
+ const removedSystemCount = allMessages.filter(m => m.role === 'system' && m !== mainSystemMsg).length;
931
+ const removedToolCount = allMessages.filter(m => m.type === 'tool_result' || m.type === 'tool-result' || m.role === 'tool').length;
932
+
933
+ // 3. Apply sandwich using segment identification
934
+ const segments = this._identifySegments(filteredMessages);
935
+
936
+ // 4. Build replacement paragraph for middle section
937
+ const middleSummary = this._buildFallbackMiddleParagraph(segments.middle, {
938
+ removedSystemCount,
939
+ removedToolCount,
940
+ totalOriginalMessages: allMessages.length
941
+ });
942
+
943
+ // 5. Create the summary message
944
+ const summaryMessage = {
945
+ role: 'system',
946
+ content: `${COMPACTION_CONFIG.COMPACTION_SUMMARY_PREFIX} - structural fallback]\n\n${middleSummary}\n\n${COMPACTION_CONFIG.COMPACTION_SUMMARY_SUFFIX}`,
947
+ type: 'summary',
948
+ timestamp: new Date().toISOString(),
949
+ metadata: {
950
+ fallback: true,
951
+ structural: true,
952
+ removedSystemMessages: removedSystemCount,
953
+ removedToolResults: removedToolCount,
954
+ middleMessagesCompacted: segments.middle.length
955
+ }
956
+ };
957
+
958
+ // 6. Reconstruct conversation
959
+ const compactedMessages = [
960
+ ...segments.beginning,
961
+ summaryMessage,
962
+ ...segments.end
963
+ ];
964
+
965
+ this.logger.info('Structural fallback compaction performed', {
966
+ originalMessages: allMessages.length,
967
+ afterFiltering: filteredMessages.length,
968
+ removedSystemMessages: removedSystemCount,
969
+ removedToolResults: removedToolCount,
970
+ beginningKept: segments.beginning.length,
971
+ middleCompacted: segments.middle.length,
972
+ endKept: segments.end.length,
973
+ finalMessages: compactedMessages.length
974
+ });
975
+
976
+ return {
977
+ compactedMessages,
978
+ metadata: {
979
+ strategy: 'structural_fallback',
980
+ removedSystemMessages: removedSystemCount,
981
+ removedToolResults: removedToolCount,
982
+ middleMessagesCompacted: segments.middle.length
983
+ }
984
+ };
985
+ }
986
+
987
+ /**
988
+ * Build a short paragraph summarizing the middle section for fallback compaction
989
+ * @private
990
+ */
991
+ _buildFallbackMiddleParagraph(middleMessages, stats) {
992
+ const userMsgs = middleMessages.filter(m => m.role === 'user');
993
+ const assistantMsgs = middleMessages.filter(m => m.role === 'assistant');
994
+
995
+ // Extract file paths mentioned in messages
996
+ const filePathRegex = /(?:\/[\w.-]+)+\.\w+|(?:[A-Za-z]:)?(?:\\[\w.-]+)+\.\w+|(?:src|lib|test|config|public|dist|build|node_modules)\/[\w./-]+/g;
997
+ const filePaths = new Set();
998
+ for (const msg of middleMessages) {
999
+ const content = typeof msg.content === 'string' ? msg.content : '';
1000
+ const matches = content.match(filePathRegex);
1001
+ if (matches) {
1002
+ matches.forEach(p => filePaths.add(p));
1003
+ }
1004
+ }
1005
+
1006
+ const parts = [];
1007
+ parts.push(`[${middleMessages.length} messages compacted (${userMsgs.length} user, ${assistantMsgs.length} assistant).`);
1008
+
1009
+ if (stats.removedSystemCount > 0 || stats.removedToolCount > 0) {
1010
+ parts.push(`Additionally removed: ${stats.removedSystemCount} system messages, ${stats.removedToolCount} tool results.`);
1011
+ }
1012
+
1013
+ if (filePaths.size > 0) {
1014
+ const fileList = Array.from(filePaths).slice(0, 20).join(', ');
1015
+ parts.push(`Files referenced: ${fileList}${filePaths.size > 20 ? ` and ${filePaths.size - 20} more` : ''}.`);
1016
+ }
1017
+
1018
+ parts.push('Summary generation was unavailable - content structurally compacted for context management.]');
1019
+
1020
+ return parts.join(' ');
1021
+ }
1022
+
1023
+ /**
1024
+ * Split oversized messages into smaller chunks for effective compaction.
1025
+ * When a single message exceeds OVERSIZED_MESSAGE_THRESHOLD, it gets split
1026
+ * into chunks of MAX_CHUNK_SIZE, increasing message count so the sandwich
1027
+ * strategy can push the content into the summarizable middle segment.
1028
+ *
1029
+ * @param {Array} messages - Messages array
1030
+ * @returns {Array} Messages with oversized ones split into chunks
1031
+ * @private
1032
+ */
1033
+ _splitOversizedMessages(messages) {
1034
+ const threshold = COMPACTION_CONFIG.OVERSIZED_MESSAGE_THRESHOLD;
1035
+ const maxChunk = COMPACTION_CONFIG.MAX_CHUNK_SIZE;
1036
+
1037
+ let splitCount = 0;
1038
+ const result = [];
1039
+
1040
+ for (const msg of messages) {
1041
+ const content = typeof msg.content === 'string' ? msg.content : '';
1042
+
1043
+ if (content.length <= threshold) {
1044
+ result.push(msg);
1045
+ continue;
1046
+ }
1047
+
1048
+ // Split this message
1049
+ splitCount++;
1050
+ const chunks = this._splitContentIntoChunks(content, maxChunk);
1051
+
1052
+ this.logger.info('Splitting oversized message for compaction', {
1053
+ role: msg.role,
1054
+ type: msg.type || 'none',
1055
+ originalChars: content.length,
1056
+ chunks: chunks.length
1057
+ });
1058
+
1059
+ for (let i = 0; i < chunks.length; i++) {
1060
+ const chunkMsg = {
1061
+ ...msg,
1062
+ content: `[Part ${i + 1}/${chunks.length}${i === 0 ? ' — oversized message split for compaction' : ' — continued'}]\n${chunks[i]}`,
1063
+ _splitMetadata: {
1064
+ originalLength: content.length,
1065
+ chunkIndex: i,
1066
+ totalChunks: chunks.length
1067
+ }
1068
+ };
1069
+ // Give each chunk a unique ID to avoid conflicts
1070
+ if (msg.id) {
1071
+ chunkMsg.id = `${msg.id}-chunk-${i + 1}`;
1072
+ }
1073
+ result.push(chunkMsg);
1074
+ }
1075
+ }
1076
+
1077
+ if (splitCount > 0) {
1078
+ this.logger.info('Oversized message splitting complete', {
1079
+ originalCount: messages.length,
1080
+ newCount: result.length,
1081
+ added: result.length - messages.length,
1082
+ messagesSplit: splitCount
1083
+ });
1084
+ }
1085
+
1086
+ return { messages: result, wasSplit: splitCount > 0 };
1087
+ }
1088
+
1089
+ /**
1090
+ * Split content into chunks, respecting natural boundaries.
1091
+ * Priority: double newlines > single newlines > hard cut at maxChunk.
1092
+ *
1093
+ * @param {string} content - Content to split
1094
+ * @param {number} maxChunk - Maximum chunk size in chars
1095
+ * @returns {string[]} Array of content chunks
1096
+ * @private
1097
+ */
1098
+ _splitContentIntoChunks(content, maxChunk) {
1099
+ if (content.length <= maxChunk) return [content];
1100
+
1101
+ const chunks = [];
1102
+ let remaining = content;
1103
+
1104
+ while (remaining.length > maxChunk) {
1105
+ let splitAt = -1;
1106
+
1107
+ // Try double newline within the chunk range
1108
+ const searchRange = remaining.substring(0, maxChunk);
1109
+ const lastDoubleNL = searchRange.lastIndexOf('\n\n');
1110
+ if (lastDoubleNL > maxChunk * 0.3) {
1111
+ splitAt = lastDoubleNL + 2;
1112
+ }
1113
+
1114
+ // Fallback: single newline
1115
+ if (splitAt === -1) {
1116
+ const lastNL = searchRange.lastIndexOf('\n');
1117
+ if (lastNL > maxChunk * 0.3) {
1118
+ splitAt = lastNL + 1;
1119
+ }
1120
+ }
1121
+
1122
+ // Fallback: sentence boundary (". " — common in prose, rare in code)
1123
+ if (splitAt === -1) {
1124
+ const lastSentence = searchRange.lastIndexOf('. ');
1125
+ if (lastSentence > maxChunk * 0.3) {
1126
+ splitAt = lastSentence + 2;
1127
+ }
1128
+ }
1129
+
1130
+ // Fallback: hard cut (e.g. minified code with no newlines)
1131
+ // TODO: Consider splitting at last space or semicolon before maxChunk
1132
+ // to avoid breaking mid-token/mid-word in minified code
1133
+ if (splitAt === -1) {
1134
+ splitAt = maxChunk;
1135
+ }
1136
+
1137
+ chunks.push(remaining.substring(0, splitAt));
1138
+ remaining = remaining.substring(splitAt);
1139
+ }
1140
+
1141
+ if (remaining.length > 0) {
1142
+ chunks.push(remaining);
1143
+ }
1144
+
1145
+ return chunks;
1146
+ }
1147
+
1148
+ /**
1149
+ * Categorize one conversation message for compaction tagging.
1150
+ *
1151
+ * Returns one of:
1152
+ * REAL_USER — a literal user typing turn
1153
+ * TOOL_RESULT — a `[Tool Results]` wrapper (carries role:user)
1154
+ * PREVIOUS_TASK a `[Previous Task — Final Tool Results]` boundary
1155
+ * AGENT assistant turn
1156
+ * SYSTEM — system message
1157
+ *
1158
+ * The categorization is deterministic text-prefix sniffing on the
1159
+ * content, not heuristic. Matches the convention used everywhere
1160
+ * else in the CLI for marking tool-result envelopes.
1161
+ *
1162
+ * @param {object} msg - { role, content }
1163
+ * @returns {string} one of the categories above
1164
+ * @private
1165
+ */
1166
+ _categorizeMessage(msg) {
1167
+ if (msg.role === 'assistant') return 'AGENT';
1168
+ if (msg.role === 'system') return 'SYSTEM';
1169
+ // role === 'user' could be a real user message OR a tool-result wrapper.
1170
+ const c = typeof msg.content === 'string' ? msg.content.trimStart() : '';
1171
+ if (c.startsWith('[Tool Results')) return 'TOOL_RESULT';
1172
+ if (c.startsWith('[Previous Task')) return 'PREVIOUS_TASK';
1173
+ return 'REAL_USER';
1174
+ }
1175
+
1176
+ /**
1177
+ * Create the compaction-summary prompt template.
1178
+ *
1179
+ * Why this prompt is shaped this way:
1180
+ * The previous "paraphrase-everything" template was found to drop
1181
+ * the user's literal asks during compaction (see the Talisman
1182
+ * case study: the agent paraphrased the user's 3-point UI request
1183
+ * into "redesign UI" and then went off and built a Settings
1184
+ * screen). Re-tested across 3 models × 5 prompt variants, this
1185
+ * two-pass shape was the highest-fidelity option that worked
1186
+ * uniformly well across gpt-4.1-mini, gpt-4.1-nano, and
1187
+ * FW-Kimi-K2.5. See tmp-compaction-experiment/ for the harness.
1188
+ *
1189
+ * PASS 1 is transcription. The summarizer is NOT allowed to filter
1190
+ * user messages by "I think the agent already handled this." That
1191
+ * determination belongs to the consumer agent reading the summary,
1192
+ * not to the summarizer itself making the summarizer choose was
1193
+ * how completed-vs-open misjudgments crept in. The blockquote
1194
+ * format gives the consumer agent a strong visual signal to
1195
+ * anchor on those literal asks.
1196
+ *
1197
+ * PASS 2 is the narrative summary of the agent's work — files,
1198
+ * tools, decisions, state. Heavy compression OK here; only the
1199
+ * user-voice section is sacred.
1200
+ *
1201
+ * @private
1202
+ */
1203
+ _createSummaryPromptTemplate() {
1204
+ return `You are compacting an earlier portion of an agent-user conversation. The input has been PRE-TAGGED — every message starts with one of:
1205
+
1206
+ [REAL_USER] a literal user message; TRANSCRIBE VERBATIM in PASS 1
1207
+ [AGENT] assistant turn (tool calls + reasoning)
1208
+ [TOOL_RESULT] a tool's output; the consumer agent does NOT need these verbatim
1209
+ [PREVIOUS_TASK] final tool-result block from a previous task boundary
1210
+ [SYSTEM] — system note
1211
+
1212
+ You DO NOT need to detect categories yourself. Trust the tags. The pre-tagging is deterministic.
1213
+
1214
+ Write the summary in THREE passes, in this exact order.
1215
+
1216
+ ──────────────────────────────────────────────
1217
+ PASS 1 — USER VOICE (transcription only, no judgment)
1218
+ ──────────────────────────────────────────────
1219
+
1220
+ For EVERY [REAL_USER] message — and ONLY [REAL_USER] messages — emit a blockquote:
1221
+
1222
+ > **User said (orig idx N):** "<exact text, word for word, all of it>"
1223
+
1224
+ Absolute rules:
1225
+ - Do NOT quote any [TOOL_RESULT], [AGENT], [PREVIOUS_TASK], or [SYSTEM] message here.
1226
+ - Do NOT condense, paraphrase, or omit any [REAL_USER] message.
1227
+ - Do NOT skip a [REAL_USER] message on the assumption "the agent already addressed it." That determination belongs to the consumer agent, not to you. Your job here is transcription.
1228
+ - Reproduce every [REAL_USER] message, in original order, including punctuation and typos.
1229
+ - If the input has no [REAL_USER] messages, write "(no user messages in this segment)" and proceed.
1230
+
1231
+ ──────────────────────────────────────────────
1232
+ PASS 2 — EVENT LOG (chronological bullets, concrete details)
1233
+ ──────────────────────────────────────────────
1234
+
1235
+ A bulleted list of every notable event between/after the user messages. ONE bullet per event:
1236
+
1237
+ - [orig idx N] <one-line description — include full file paths, tool names, line numbers, status, and outcome>
1238
+
1239
+ Cover: file writes, successful tool calls that changed state, decisions made by the agent, errors that affected outcome, task-list changes (especially destructive ones like 'removed: N tasks'). Skip: pure-read tool calls that didn't change state, repeated reads, pleasantries, verbose tool output dumps.
1240
+
1241
+ A consumer agent should be able to read this log and reconstruct the cause-and-effect chain — what happened to each [REAL_USER] request.
1242
+
1243
+ ──────────────────────────────────────────────
1244
+ PASS 3 — STATE NARRATIVE (2–4 sentences)
1245
+ ──────────────────────────────────────────────
1246
+
1247
+ Plain prose describing the situation at the end of this segment: what is done, what is mid-flight, what is open — and where possible, map back to which [REAL_USER] request each piece corresponds to. If [REAL_USER] requests are still open with no work toward them, say so explicitly. This is the place where lossy paraphrase is most dangerous — name the gaps clearly.
1248
+
1249
+ ──────────────────────────────────────────────
1250
+
1251
+ CONVERSATION SEGMENT TO COMPACT:
1252
+ {middle_segment}
1253
+
1254
+ OUTPUT: PASS 1, PASS 2, PASS 3 in that order. Use exactly the section headers above. No preamble, no meta-commentary.`;
1255
+ }
1256
+ }
1257
+
1258
+ export default ConversationCompactionService;