@causyne/agent 0.0.0-stage → 1.10.1

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 (274) hide show
  1. package/LICENSE +102 -0
  2. package/README.md +31 -2
  3. package/SECURITY.md +24 -0
  4. package/bin/causyne.js +13 -0
  5. package/docs/ARCHITECTURE.md +63 -0
  6. package/docs/PROVENANCE.md +29 -0
  7. package/docs/SECURITY_REVIEW.md +89 -0
  8. package/docs/THIRD_PARTY_NOTICES.md +49 -0
  9. package/native-skills/ai-engineering/context-window-management/SKILL.md +43 -0
  10. package/native-skills/ai-engineering/llm-evaluation/SKILL.md +46 -0
  11. package/native-skills/ai-engineering/model-cost-and-latency/SKILL.md +46 -0
  12. package/native-skills/ai-engineering/prompt-construction/SKILL.md +42 -0
  13. package/native-skills/ai-engineering/retrieval-augmented-generation/SKILL.md +42 -0
  14. package/native-skills/ai-engineering/structured-output-and-schemas/SKILL.md +45 -0
  15. package/native-skills/ai-engineering/tool-calling-agents/SKILL.md +43 -0
  16. package/native-skills/android/app-performance-profiling/SKILL.md +42 -0
  17. package/native-skills/android/background-work-scheduling/SKILL.md +47 -0
  18. package/native-skills/android/compose-state-and-recomposition/SKILL.md +43 -0
  19. package/native-skills/android/gradle-build-configuration/SKILL.md +41 -0
  20. package/native-skills/android/lifecycle-and-leaks/SKILL.md +46 -0
  21. package/native-skills/android/play-store-release/SKILL.md +43 -0
  22. package/native-skills/android/room-and-persistence/SKILL.md +40 -0
  23. package/native-skills/backend/authentication-and-sessions/SKILL.md +45 -0
  24. package/native-skills/backend/background-jobs-and-queues/SKILL.md +42 -0
  25. package/native-skills/backend/caching-strategy/SKILL.md +44 -0
  26. package/native-skills/backend/graceful-shutdown/SKILL.md +42 -0
  27. package/native-skills/backend/rate-limiting-and-quotas/SKILL.md +40 -0
  28. package/native-skills/backend/rest-api-design/SKILL.md +40 -0
  29. package/native-skills/backend/webhook-delivery-and-receipt/SKILL.md +43 -0
  30. package/native-skills/cloud/cost-optimisation/SKILL.md +46 -0
  31. package/native-skills/cloud/iam-least-privilege/SKILL.md +43 -0
  32. package/native-skills/cloud/infrastructure-as-code/SKILL.md +43 -0
  33. package/native-skills/cloud/multi-region-and-failover/SKILL.md +43 -0
  34. package/native-skills/cloud/serverless-patterns/SKILL.md +47 -0
  35. package/native-skills/data/data-quality-monitoring/SKILL.md +51 -0
  36. package/native-skills/data/etl-pipeline-reliability/SKILL.md +43 -0
  37. package/native-skills/data/exploratory-analysis/SKILL.md +43 -0
  38. package/native-skills/data/metric-definition/SKILL.md +42 -0
  39. package/native-skills/data/notebook-to-production/SKILL.md +43 -0
  40. package/native-skills/database/backup-and-recovery/SKILL.md +43 -0
  41. package/native-skills/database/connection-pooling/SKILL.md +42 -0
  42. package/native-skills/database/data-integrity-and-constraints/SKILL.md +42 -0
  43. package/native-skills/database/query-optimisation/SKILL.md +38 -0
  44. package/native-skills/database/safe-migrations/SKILL.md +47 -0
  45. package/native-skills/database/schema-design/SKILL.md +38 -0
  46. package/native-skills/database/transactions-and-isolation/SKILL.md +41 -0
  47. package/native-skills/design/colour-system-construction/SKILL.md +38 -0
  48. package/native-skills/design/design-direction-brief/SKILL.md +41 -0
  49. package/native-skills/design/motion-and-transitions/SKILL.md +38 -0
  50. package/native-skills/design/responsive-strategy/SKILL.md +39 -0
  51. package/native-skills/design/spacing-and-rhythm/SKILL.md +38 -0
  52. package/native-skills/design/type-scale-and-hierarchy/SKILL.md +39 -0
  53. package/native-skills/design/visual-qa-loop/SKILL.md +42 -0
  54. package/native-skills/devops/docker-multi-stage-builds/SKILL.md +60 -0
  55. package/native-skills/devops/github-actions-ci/SKILL.md +60 -0
  56. package/native-skills/devops/incident-triage/SKILL.md +38 -0
  57. package/native-skills/devops/kubernetes-deployment-hardening/SKILL.md +64 -0
  58. package/native-skills/devops/observability-baseline/SKILL.md +38 -0
  59. package/native-skills/devops/secret-management/SKILL.md +40 -0
  60. package/native-skills/devops/terraform-state-and-modules/SKILL.md +36 -0
  61. package/native-skills/devops/zero-downtime-deploys/SKILL.md +40 -0
  62. package/native-skills/git/bisect-regression-hunt/SKILL.md +43 -0
  63. package/native-skills/git/branch-strategy/SKILL.md +39 -0
  64. package/native-skills/git/commit-hygiene/SKILL.md +41 -0
  65. package/native-skills/git/history-recovery/SKILL.md +44 -0
  66. package/native-skills/git/large-repository-surgery/SKILL.md +46 -0
  67. package/native-skills/git/merge-conflict-resolution/SKILL.md +42 -0
  68. package/native-skills/media/cinematic-3d-shot/SKILL.md +104 -0
  69. package/native-skills/mobile/app-startup-optimisation/SKILL.md +41 -0
  70. package/native-skills/mobile/cross-platform-tradeoffs/SKILL.md +42 -0
  71. package/native-skills/mobile/deep-linking/SKILL.md +39 -0
  72. package/native-skills/mobile/mobile-storage-security/SKILL.md +45 -0
  73. package/native-skills/mobile/offline-first-sync/SKILL.md +42 -0
  74. package/native-skills/mobile/push-notifications/SKILL.md +38 -0
  75. package/native-skills/performance/algorithmic-complexity/SKILL.md +43 -0
  76. package/native-skills/performance/concurrency-and-parallelism/SKILL.md +42 -0
  77. package/native-skills/performance/load-testing/SKILL.md +42 -0
  78. package/native-skills/performance/memory-and-leak-hunting/SKILL.md +41 -0
  79. package/native-skills/performance/network-and-payload-efficiency/SKILL.md +42 -0
  80. package/native-skills/performance/profiling-methodology/SKILL.md +46 -0
  81. package/native-skills/python/async-correctness/SKILL.md +40 -0
  82. package/native-skills/python/cli-tool-construction/SKILL.md +44 -0
  83. package/native-skills/python/data-processing-pipelines/SKILL.md +38 -0
  84. package/native-skills/python/environment-isolation/SKILL.md +41 -0
  85. package/native-skills/python/packaging-and-distribution/SKILL.md +56 -0
  86. package/native-skills/python/pytest-suite-design/SKILL.md +39 -0
  87. package/native-skills/python/type-hints-and-static-checking/SKILL.md +42 -0
  88. package/native-skills/security/authorization-model/SKILL.md +42 -0
  89. package/native-skills/security/crypto-usage/SKILL.md +48 -0
  90. package/native-skills/security/dependency-supply-chain/SKILL.md +42 -0
  91. package/native-skills/security/input-validation-and-injection/SKILL.md +44 -0
  92. package/native-skills/security/secure-file-uploads/SKILL.md +46 -0
  93. package/native-skills/security/security-headers-and-csp/SKILL.md +44 -0
  94. package/native-skills/security/threat-modelling/SKILL.md +46 -0
  95. package/native-skills/testing/end-to-end-test-stability/SKILL.md +42 -0
  96. package/native-skills/testing/property-based-testing/SKILL.md +46 -0
  97. package/native-skills/testing/regression-test-from-a-bug/SKILL.md +41 -0
  98. package/native-skills/testing/snapshot-and-visual-regression/SKILL.md +42 -0
  99. package/native-skills/testing/test-doubles-and-mocking/SKILL.md +43 -0
  100. package/native-skills/testing/test-strategy-and-pyramid/SKILL.md +39 -0
  101. package/native-skills/testing/writing-good-assertions/SKILL.md +42 -0
  102. package/native-skills/web/accessible-components/SKILL.md +45 -0
  103. package/native-skills/web/api-client-resilience/SKILL.md +39 -0
  104. package/native-skills/web/bundle-size-reduction/SKILL.md +38 -0
  105. package/native-skills/web/core-web-vitals/SKILL.md +40 -0
  106. package/native-skills/web/css-layout-systems/SKILL.md +41 -0
  107. package/native-skills/web/form-validation-ux/SKILL.md +39 -0
  108. package/native-skills/web/react-state-architecture/SKILL.md +41 -0
  109. package/native-skills/web/ssr-hydration-correctness/SKILL.md +45 -0
  110. package/package.json +68 -4
  111. package/src/agentLoop.js +2458 -0
  112. package/src/appChannel.js +109 -0
  113. package/src/approvalGrants.js +66 -0
  114. package/src/auditLog.js +52 -0
  115. package/src/autoUpdate.js +12 -0
  116. package/src/capabilities.js +36 -0
  117. package/src/checkpoints.js +1199 -0
  118. package/src/cliMode.js +638 -0
  119. package/src/commands.js +805 -0
  120. package/src/compiledSkills.js +540 -0
  121. package/src/config.js +130 -0
  122. package/src/contextManager.js +498 -0
  123. package/src/cortex/autoCondenser.js +192 -0
  124. package/src/cortex/provisioner.js +27 -0
  125. package/src/cortex/vectorDb.js +447 -0
  126. package/src/designPalettes.js +99 -0
  127. package/src/devMode.js +6 -0
  128. package/src/embodimentSession.js +180 -0
  129. package/src/entrypoint.js +194 -0
  130. package/src/execSafe.js +21 -0
  131. package/src/gitSnapshot.js +378 -0
  132. package/src/mcp/jsonRpc.js +165 -0
  133. package/src/mcp/mcpClient.js +136 -0
  134. package/src/mcp/mcpRegistry.js +207 -0
  135. package/src/memory.js +62 -0
  136. package/src/memoryManager.js +124 -0
  137. package/src/modelCatalogue.js +132 -0
  138. package/src/modelIds.js +42 -0
  139. package/src/modelRouter.js +145 -0
  140. package/src/outputStore.js +246 -0
  141. package/src/perception.js +124 -0
  142. package/src/planCache.js +437 -0
  143. package/src/procSignal.js +32 -0
  144. package/src/providers/anthropic.js +408 -0
  145. package/src/providers/base.js +328 -0
  146. package/src/providers/dashscope-byok.js +86 -0
  147. package/src/providers/deepseek.js +80 -0
  148. package/src/providers/factory.js +37 -0
  149. package/src/providers/gemini.js +346 -0
  150. package/src/providers/moonshot.js +73 -0
  151. package/src/providers/muse.js +169 -0
  152. package/src/providers/ollama.js +104 -0
  153. package/src/providers/openai-compatible.js +84 -0
  154. package/src/providers/openai.js +20 -0
  155. package/src/providers/qwen-byok.js +76 -0
  156. package/src/providers/xai.js +73 -0
  157. package/src/provisioning/capabilityConsent.js +24 -0
  158. package/src/provisioning/capabilityProvisioner.js +271 -0
  159. package/src/provisioning/capabilityTools.js +29 -0
  160. package/src/reporting.js +373 -0
  161. package/src/sandbox.js +529 -0
  162. package/src/sessionStore.js +279 -0
  163. package/src/skillsManager.js +288 -0
  164. package/src/slopDetector.js +307 -0
  165. package/src/taskBuffer.js +45 -0
  166. package/src/taskShape.js +241 -0
  167. package/src/textDiff.js +150 -0
  168. package/src/tokenTracker.js +560 -0
  169. package/src/tool-scripts/ax_snapshot.applescript +39 -0
  170. package/src/tool-scripts/ax_snapshot.ps1 +10 -0
  171. package/src/tool-scripts/desktop_act.applescript +58 -0
  172. package/src/tool-scripts/resolve_automator.py +119 -0
  173. package/src/toolPacks.js +524 -0
  174. package/src/tools/archive.js +63 -0
  175. package/src/tools/astryxOracle.js +78 -0
  176. package/src/tools/axSnapshot.js +165 -0
  177. package/src/tools/blenderAutomator.js +349 -0
  178. package/src/tools/browserAutomator.js +252 -0
  179. package/src/tools/checkSystemSpecs.js +64 -0
  180. package/src/tools/clipboard.js +36 -0
  181. package/src/tools/codeSearchAst.js +101 -0
  182. package/src/tools/codeSearcher.js +230 -0
  183. package/src/tools/codeTools.js +262 -0
  184. package/src/tools/codebaseSearch.js +75 -0
  185. package/src/tools/createSkill.js +129 -0
  186. package/src/tools/deferredMcpSearch.js +246 -0
  187. package/src/tools/desktopAct.js +208 -0
  188. package/src/tools/desktopOpen.js +34 -0
  189. package/src/tools/dynamicLoader.js +44 -0
  190. package/src/tools/executeProceduralMedia.js +93 -0
  191. package/src/tools/fsDelete.js +51 -0
  192. package/src/tools/fsEdit.js +105 -0
  193. package/src/tools/fsFind.js +128 -0
  194. package/src/tools/fsList.js +77 -0
  195. package/src/tools/fsMove.js +57 -0
  196. package/src/tools/fsRead.js +87 -0
  197. package/src/tools/fsSearch.js +198 -0
  198. package/src/tools/fsWrite.js +41 -0
  199. package/src/tools/gameAiBehaviorTree.js +208 -0
  200. package/src/tools/gameAnimationRig.js +194 -0
  201. package/src/tools/gameAssetFetcher.js +372 -0
  202. package/src/tools/gameAssetHarvester.js +210 -0
  203. package/src/tools/gameAudioSynth.js +267 -0
  204. package/src/tools/gameBuilder.js +886 -0
  205. package/src/tools/gameCanvasAudit.js +101 -0
  206. package/src/tools/gameCinematicSequencer.js +151 -0
  207. package/src/tools/gameDataForge.js +124 -0
  208. package/src/tools/gameDialogueTreeGen.js +166 -0
  209. package/src/tools/gameEconomyBalancer.js +161 -0
  210. package/src/tools/gameEntitySpawner.js +207 -0
  211. package/src/tools/gameLoreForge.js +153 -0
  212. package/src/tools/gameQuestArcBuilder.js +152 -0
  213. package/src/tools/gameSceneBuilder.js +158 -0
  214. package/src/tools/gameShaderForge.js +181 -0
  215. package/src/tools/gameTilemapGenerator.js +164 -0
  216. package/src/tools/gameUiHudBuilder.js +292 -0
  217. package/src/tools/gameVfxEmitterGen.js +108 -0
  218. package/src/tools/gameWebRuntimeInspector.js +91 -0
  219. package/src/tools/generateMediaViaApi.js +358 -0
  220. package/src/tools/gitExec.js +54 -0
  221. package/src/tools/godotAutomator.js +437 -0
  222. package/src/tools/manageLocalAiDependencies.js +220 -0
  223. package/src/tools/memoryWrite.js +99 -0
  224. package/src/tools/multiEdit.js +107 -0
  225. package/src/tools/palmierAutomator.js +104 -0
  226. package/src/tools/pdfReader.js +112 -0
  227. package/src/tools/persistentShell.js +148 -0
  228. package/src/tools/printer.js +316 -0
  229. package/src/tools/procTerm.js +91 -0
  230. package/src/tools/processList.js +52 -0
  231. package/src/tools/readNativeSkill.js +39 -0
  232. package/src/tools/registry.js +277 -0
  233. package/src/tools/resolveAutomator.js +102 -0
  234. package/src/tools/screenshot.js +75 -0
  235. package/src/tools/subagentOrchestrator.js +305 -0
  236. package/src/tools/sysRun.js +125 -0
  237. package/src/tools/todoWrite.js +75 -0
  238. package/src/tools/toolDispatch.js +127 -0
  239. package/src/tools/toolForge.js +124 -0
  240. package/src/tools/updatePlan.js +88 -0
  241. package/src/tools/viewImage.js +82 -0
  242. package/src/tools/visionInspector.js +154 -0
  243. package/src/tools/webFetch.js +74 -0
  244. package/src/tools/webSearch.js +82 -0
  245. package/src/tui/ask.js +164 -0
  246. package/src/tui/banner.js +158 -0
  247. package/src/tui/block.js +152 -0
  248. package/src/tui/chatRenderer.js +953 -0
  249. package/src/tui/completion.js +424 -0
  250. package/src/tui/dialogs.js +606 -0
  251. package/src/tui/diff.js +296 -0
  252. package/src/tui/history.js +135 -0
  253. package/src/tui/home.js +217 -0
  254. package/src/tui/invocation.js +105 -0
  255. package/src/tui/keys.js +443 -0
  256. package/src/tui/lineEditor.js +256 -0
  257. package/src/tui/overlay.js +256 -0
  258. package/src/tui/prompt.js +1258 -0
  259. package/src/tui/selectList.js +335 -0
  260. package/src/tui/stage.js +330 -0
  261. package/src/tui/stageSurface.js +609 -0
  262. package/src/tui/statusLine.js +254 -0
  263. package/src/tui/text.js +345 -0
  264. package/src/tui/theme.js +393 -0
  265. package/src/tui/toolDisplay.js +176 -0
  266. package/src/tui/transcript.js +235 -0
  267. package/src/tui/viewport.js +337 -0
  268. package/src/uncertainty.js +271 -0
  269. package/src/userSkills.js +265 -0
  270. package/src/utils/cli_ui.js +0 -0
  271. package/src/utils/inquirer.js +12 -0
  272. package/src/utils/paths.js +37 -0
  273. package/src/version.js +38 -0
  274. package/src/vfeTracker.js +220 -0
package/src/config.js ADDED
@@ -0,0 +1,130 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import crypto from 'crypto';
5
+ import { canonicalModelId } from './modelIds.js';
6
+ import { DEFAULT_MODEL_BY_PROVIDER } from './modelCatalogue.js';
7
+ import { causyneHome } from './utils/paths.js';
8
+
9
+ const CONFIG_DIR = causyneHome();
10
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
11
+ const KEY_FILE = path.join(CONFIG_DIR, '.vault_key');
12
+
13
+ function ensureConfigDir() {
14
+ if (!fs.existsSync(CONFIG_DIR)) {
15
+ // SECURITY: Directory must only be accessible by the owner
16
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
17
+ }
18
+ }
19
+
20
+ function getVaultKey() {
21
+ ensureConfigDir();
22
+ if (!fs.existsSync(KEY_FILE)) {
23
+ const key = crypto.randomBytes(32);
24
+ fs.writeFileSync(KEY_FILE, key, { mode: 0o600 });
25
+ return key;
26
+ }
27
+ return fs.readFileSync(KEY_FILE);
28
+ }
29
+
30
+ function encryptString(text) {
31
+ if (!text) return text;
32
+ if (text.startsWith('ENC:')) return text; // Already encrypted
33
+ const key = getVaultKey();
34
+ const iv = crypto.randomBytes(16);
35
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
36
+ let encrypted = cipher.update(text, 'utf8', 'hex');
37
+ encrypted += cipher.final('hex');
38
+ const authTag = cipher.getAuthTag().toString('hex');
39
+ return `ENC:${iv.toString('hex')}:${authTag}:${encrypted}`;
40
+ }
41
+
42
+ function decryptString(encryptedText) {
43
+ if (!encryptedText || !encryptedText.startsWith('ENC:')) return encryptedText;
44
+ try {
45
+ const parts = encryptedText.split(':');
46
+ if (parts.length !== 4) return '';
47
+ const iv = Buffer.from(parts[1], 'hex');
48
+ const authTag = Buffer.from(parts[2], 'hex');
49
+ const encrypted = parts[3];
50
+ const key = getVaultKey();
51
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
52
+ decipher.setAuthTag(authTag);
53
+ let decrypted = decipher.update(encrypted, 'hex', 'utf8');
54
+ decrypted += decipher.final('utf8');
55
+ return decrypted;
56
+ } catch (e) {
57
+ console.error('Failed to decrypt API key:', e);
58
+ return '';
59
+ }
60
+ }
61
+
62
+ export function loadConfig() {
63
+ ensureConfigDir();
64
+ if (fs.existsSync(CONFIG_FILE)) {
65
+ try {
66
+ const parsed = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
67
+
68
+ // Decrypt API key into memory
69
+ if (parsed.apiKey) {
70
+ if (!parsed.apiKey.startsWith('ENC:')) {
71
+ // Found legacy plaintext key. We must encrypt it and re-save immediately.
72
+ const plaintext = parsed.apiKey;
73
+ parsed.apiKey = encryptString(plaintext);
74
+ saveConfig(parsed); // Re-saves safely encrypted
75
+ parsed.apiKey = plaintext; // Keep plaintext in memory for this session
76
+ } else {
77
+ parsed.apiKey = decryptString(parsed.apiKey);
78
+ }
79
+ }
80
+
81
+ // Self-heal a model id written before src/modelIds.js existed. The
82
+ // provider normalises too, but tokenTracker.getPricing() and the /model
83
+ // display both read config.model directly —
84
+ // they must agree with what is actually sent, not with what a stale
85
+ // Android build once wrote here.
86
+ const canonicalModel = canonicalModelId(parsed.model);
87
+ if (canonicalModel !== parsed.model) {
88
+ parsed.model = canonicalModel;
89
+ saveConfig(parsed);
90
+ }
91
+
92
+ return parsed;
93
+ } catch (e) {
94
+ console.error('Error reading config.json:', e);
95
+ }
96
+ }
97
+
98
+ // Default config
99
+ const defaultConfig = {
100
+ provider: 'dashscope',
101
+ // From the catalogue, not repeated here — this had been left at the
102
+ // long-retired `qwen-plus` while the picker had moved to qwen3.7.
103
+ model: DEFAULT_MODEL_BY_PROVIDER.dashscope,
104
+ apiKey: '',
105
+ autonomyMode: 'B',
106
+ tokenBudget: {
107
+ enabled: false,
108
+ maxTokensPerTask: 500000,
109
+ maxTokensPerDay: 2000000,
110
+ maxCostUsd: 5.00, // Hard stop if cost exceeds this in BYOK mode
111
+ warningThreshold: 0.8,
112
+ hardStop: true
113
+ }
114
+ };
115
+ saveConfig(defaultConfig);
116
+ return defaultConfig;
117
+ }
118
+
119
+ export function saveConfig(config) {
120
+ ensureConfigDir();
121
+ // SECURITY: Deep copy to avoid mutating the in-memory config
122
+ const configToSave = JSON.parse(JSON.stringify(config));
123
+ if (configToSave.apiKey) {
124
+ configToSave.apiKey = encryptString(configToSave.apiKey);
125
+ }
126
+
127
+ // SECURITY: Config file contains encrypted keys, must be 0600 (owner read/write only)
128
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(configToSave, null, 2), { encoding: 'utf8', mode: 0o600 });
129
+ fs.chmodSync(CONFIG_FILE, 0o600); // Enforce on existing files
130
+ }
@@ -0,0 +1,498 @@
1
+ // Sovereign Context Manager
2
+ //
3
+ // Previously the agent loop computed a sliding window as a per-iteration *view*
4
+ // while `this.messages` grew forever: across a long task the array, the
5
+ // chat_history.json on disk, and the JSON.stringify cost of every save all grew
6
+ // without bound. This module owns the whole context lifecycle instead:
7
+ //
8
+ // 1. Tool-result decay — old tool outputs collapse into one-line stubs, the
9
+ // client-side equivalent of Anthropic's clear_tool_uses context editing.
10
+ // 2. Window construction — character-pool budget, newest first.
11
+ // 3. Real compaction — amputated messages are physically replaced by a single
12
+ // summary message, so the array stops growing.
13
+ // 4. Sequence sanitising — guarantees valid tool_call / tool_result pairing.
14
+
15
+ import { buildDecayPointer } from './outputStore.js';
16
+
17
+ /**
18
+ * Characters per token, for display only.
19
+ *
20
+ * The pools below are already annotated with their token equivalents at this
21
+ * ratio (120000 chars = "~30k tokens"), so anything that shows a token figure
22
+ * derived from a character budget must use the SAME divisor or the two numbers
23
+ * on screen will disagree with each other. Never used for billing — providers
24
+ * report real counts and those are what tokenTracker records.
25
+ */
26
+ export const CHARS_PER_TOKEN = 4;
27
+
28
+ /**
29
+ * How many characters of a tool's result may enter the context, per tool.
30
+ *
31
+ * Every tool shared ONE 32,000-character allowance, which treats a source file
32
+ * and a build log as equally worth reading. They are not: `fs_read` returns
33
+ * what the task asked for, while a failing compile can emit thirty thousand
34
+ * characters of the same warning. The dense tool and the chatty one paid the
35
+ * same, so the chatty one crowded out the window.
36
+ *
37
+ * NOTHING IS LOST BY LOWERING A CAP. `outputStore.spillToolResult()` writes the
38
+ * COMPLETE result to disk first and the notice that replaces it names the path,
39
+ * so a tighter cap moves bytes out of the window and behind a bounded read — it
40
+ * never destroys them. That is what makes these numbers safe to tune.
41
+ *
42
+ * @type {Record<string, number>}
43
+ */
44
+ export const RESULT_CAPS = {
45
+ // Content the task actually asked for. Unchanged.
46
+ fs_read: 32000,
47
+ code_search_ast: 32000,
48
+ MultiEdit: 32000,
49
+
50
+ // Command output. Low information density, high volume, and the conclusion is
51
+ // almost always in the first and last few lines.
52
+ sysRun: 12000,
53
+ sysRunBg: 12000,
54
+ terminal_persistent_shell: 12000,
55
+
56
+ // Fetched pages are mostly navigation, boilerplate and scripts.
57
+ web_fetch: 12000,
58
+ web_search: 12000,
59
+
60
+ // A listing this long is a signal to narrow the query, not to read further.
61
+ fs_list: 8000,
62
+ fs_find: 8000,
63
+ fs_search: 12000
64
+ };
65
+
66
+ /** The allowance for a tool with no entry of its own. */
67
+ export const DEFAULT_RESULT_CAP = 32000;
68
+
69
+ /**
70
+ * @param {string} tool
71
+ * @returns {number} characters of this tool's result allowed into the window
72
+ */
73
+ export function resultCapFor(tool) {
74
+ const cap = RESULT_CAPS[String(tool || '')];
75
+ return Number.isFinite(cap) && cap > 0 ? cap : DEFAULT_RESULT_CAP;
76
+ }
77
+
78
+
79
+ export const CHAR_POOLS = {
80
+ super_max: 400000, // ~100k tokens
81
+ max: 240000, // ~60k tokens
82
+ premium: 180000, // ~45k tokens — the middle rung now buys a real context
83
+ // bump over fast, not just the right to run (monetization
84
+ // Move 3 / Gap #2 in docs/AGENT_MONETIZATION.md).
85
+ default: 120000 // ~30k tokens (fast / starter / free)
86
+ };
87
+
88
+ export function resolveCharPool(tier, override) {
89
+ if (override && Number.isFinite(override) && override > 0) return override;
90
+ if (tier === 'super_max') return CHAR_POOLS.super_max;
91
+ if (tier === 'max') return CHAR_POOLS.max;
92
+ if (tier === 'premium' || tier === 'premium_annual') return CHAR_POOLS.premium;
93
+ return CHAR_POOLS.default;
94
+ }
95
+
96
+ function contentLength(content) {
97
+ if (typeof content === 'string') return content.length;
98
+ if (content == null) return 0;
99
+ return JSON.stringify(content).length;
100
+ }
101
+
102
+ export function measureMessages(messages) {
103
+ let total = 0;
104
+ for (const msg of messages) total += contentLength(msg.content);
105
+ return total;
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Tool-result decay
110
+ // ---------------------------------------------------------------------------
111
+
112
+ const DECAY_MARKER = '[decayed]';
113
+
114
+ function summariseToolResult(msg) {
115
+ const raw = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
116
+ const sizeKb = (raw.length / 1024).toFixed(1);
117
+
118
+ // Keep the success/error verdict — that is the part the model still reasons
119
+ // about ten steps later. The payload is what it never re-reads.
120
+ let verdict = '';
121
+ try {
122
+ const parsed = JSON.parse(raw);
123
+ if (parsed && typeof parsed === 'object') {
124
+ verdict = parsed.success === false
125
+ ? ` failed: ${String(parsed.error || '').slice(0, 160)}`
126
+ : ' succeeded';
127
+ }
128
+ } catch (e) {
129
+ verdict = '';
130
+ }
131
+
132
+ // When the agent loop spilled this result to disk, send the model to the file
133
+ // instead of telling it to re-run. A re-run costs a full tool execution and
134
+ // puts another full-size payload back in the window; a targeted read of the
135
+ // spill costs a few hundred tokens. See src/outputStore.js.
136
+ const pointer = buildDecayPointer({ path: msg.spillPath });
137
+ const fallback = ' Re-run the tool or read the specific file/lines again if you still need it.';
138
+
139
+ return `${DECAY_MARKER} ${msg.name || 'tool'}${verdict}. Output (${sizeKb} KB) was cleared to protect the context window.${pointer || fallback}`;
140
+ }
141
+
142
+
143
+ export function decayToolResults(messages, options = {}) {
144
+ const keepRecent = Number.isInteger(options.keepRecent) ? options.keepRecent : 8;
145
+ const minChars = Number.isInteger(options.minChars) ? options.minChars : 600;
146
+ const keepMediaRecent = Number.isInteger(options.keepMediaRecent) ? options.keepMediaRecent : 2;
147
+
148
+ let reclaimed = 0;
149
+ let toolsSeen = 0;
150
+ let mediaSeen = 0;
151
+
152
+ for (let i = messages.length - 1; i >= 0; i--) {
153
+ const msg = messages[i];
154
+
155
+ if (msg.role === 'tool') {
156
+ toolsSeen++;
157
+ if (toolsSeen <= keepRecent) continue;
158
+ const original = contentLength(msg.content);
159
+ if (original < minChars) continue;
160
+ if (typeof msg.content === 'string' && msg.content.startsWith(DECAY_MARKER)) continue;
161
+
162
+ msg.content = summariseToolResult(msg);
163
+ reclaimed += original - msg.content.length;
164
+ continue;
165
+ }
166
+
167
+
168
+ if (Array.isArray(msg.content)) {
169
+ const hasMedia = msg.content.some(
170
+ block => block && (block.type === 'image' || block.type === 'image_url' || block.type === 'input_audio')
171
+ );
172
+ if (!hasMedia) continue;
173
+
174
+ mediaSeen++;
175
+ if (mediaSeen <= keepMediaRecent) continue;
176
+
177
+ const original = contentLength(msg.content);
178
+ const captions = msg.content
179
+ .filter(block => block && block.type === 'text' && block.text)
180
+ .map(block => block.text)
181
+ .join(' ');
182
+
183
+ msg.content = `${DECAY_MARKER} media attachment cleared to protect the context window. ${captions}`.trim();
184
+ reclaimed += original - msg.content.length;
185
+ }
186
+ }
187
+
188
+ return reclaimed;
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Window construction
193
+ // ---------------------------------------------------------------------------
194
+
195
+ /**
196
+ * Walk backward from the newest message accumulating until the character pool
197
+ * is exhausted.
198
+ *
199
+ * @returns {{ window: Array, splitIndex: number }} splitIndex is the index of
200
+ * the newest message that did NOT fit, or -1 when everything fit.
201
+ */
202
+ export function buildWindow(messages, maxCharPool) {
203
+ const window = [];
204
+ let characterCount = 0;
205
+ let splitIndex = -1;
206
+
207
+ for (let i = messages.length - 1; i >= 0; i--) {
208
+ const length = contentLength(messages[i].content);
209
+ if (characterCount + length > maxCharPool) {
210
+ splitIndex = i;
211
+ break;
212
+ }
213
+ characterCount += length;
214
+ window.unshift(messages[i]);
215
+ }
216
+
217
+ // NEVER RETURN AN EMPTY WINDOW.
218
+ //
219
+ // The loop above breaks on the FIRST message that does not fit, so a single
220
+ // message larger than the whole pool produced a window of length zero. The
221
+ // agent loop then skipped its root anchor (it is guarded on
222
+ // `slidingWindow.length > 0`) and sent the provider nothing but system
223
+ // blocks — which is the 400 "`messages` must contain at least one message
224
+ // with role `user` or `tool`" that killed a 340,000-token Blender run at
225
+ // step 13. A long traceback in a big conversation is all it takes.
226
+ //
227
+ // Keeping the last message truncated is strictly better than sending none:
228
+ // the model loses detail, where before it lost the entire turn.
229
+ if (window.length === 0 && messages.length > 0) {
230
+ const last = messages[messages.length - 1];
231
+ const text = typeof last.content === 'string' ? last.content : JSON.stringify(last.content ?? '');
232
+ window.push({
233
+ ...last,
234
+ content: text.length > maxCharPool
235
+ ? text.slice(0, Math.max(0, maxCharPool - 80)) + '\n[truncated: this single message exceeded the whole context budget]'
236
+ : text
237
+ });
238
+ splitIndex = messages.length - 1;
239
+ }
240
+
241
+ // A window that opens on tool results is broken: the assistant message that
242
+ // called them was amputated.
243
+ while (window.length > 1 && window[0].role === 'tool') {
244
+ window.shift();
245
+ }
246
+
247
+ const last = window[window.length - 1];
248
+ if (last && last.role === 'assistant' && last.tool_calls && last.tool_calls.length > 0) {
249
+ delete last.tool_calls;
250
+ }
251
+
252
+ return { window, splitIndex };
253
+ }
254
+
255
+ /**
256
+ * Physically replace the amputated range with a single summary message so the
257
+ * live array stops growing. Index 0 (the root anchor / original user intent) is
258
+ * never compacted away.
259
+ *
260
+ * @returns {number} number of messages removed
261
+ */
262
+ export function compactInPlace(messages, splitIndex, summaryText) {
263
+ const start = 1;
264
+ const end = splitIndex;
265
+ if (end < start) return 0;
266
+
267
+ const removed = end - start + 1;
268
+ const summary = {
269
+ role: 'user',
270
+ content: `[COMPACTED CONTEXT SUMMARY — ${removed} earlier messages were condensed to protect the context window]\n${summaryText}`
271
+ };
272
+
273
+ messages.splice(start, removed, summary);
274
+ return removed - 1;
275
+ }
276
+
277
+ /** Cheap deterministic summary used when no LLM condenser is configured. */
278
+ export function describeDroppedMessages(dropped) {
279
+ const toolCalls = new Map();
280
+ let userTurns = 0;
281
+
282
+ for (const msg of dropped) {
283
+ if (msg.role === 'tool' && msg.name) {
284
+ toolCalls.set(msg.name, (toolCalls.get(msg.name) || 0) + 1);
285
+ } else if (msg.role === 'user') {
286
+ userTurns++;
287
+ }
288
+ }
289
+
290
+ const tools = [...toolCalls.entries()]
291
+ .sort((a, b) => b[1] - a[1])
292
+ .slice(0, 12)
293
+ .map(([name, count]) => `${name}x${count}`)
294
+ .join(', ');
295
+
296
+ const parts = [];
297
+ if (userTurns > 0) parts.push(`${userTurns} earlier user turn(s)`);
298
+ if (tools) parts.push(`tools used: ${tools}`);
299
+ return parts.length > 0
300
+ ? parts.join('. ') + '. Re-read files or re-run tools if you need those details again.'
301
+ : 'Earlier exchange omitted.';
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // Sequence sanitising
306
+ // ---------------------------------------------------------------------------
307
+
308
+ /**
309
+ * Guarantee 100% valid tool-call sequences: every assistant message carrying
310
+ * tool_calls must be immediately followed by matching tool results, and no
311
+ * orphan tool message may survive.
312
+ */
313
+ export function sanitizeSequences(messages) {
314
+ for (let i = 0; i < messages.length; i++) {
315
+ const msg = messages[i];
316
+ if (msg.role === 'assistant' && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
317
+ const foundToolIds = new Set();
318
+ let followingResults = 0;
319
+ let j = i + 1;
320
+ while (j < messages.length && messages[j].role === 'tool') {
321
+ if (messages[j].tool_call_id) foundToolIds.add(messages[j].tool_call_id);
322
+ followingResults++;
323
+ j++;
324
+ }
325
+
326
+ // Id matching is the correct pairing, but a provider that returns tool
327
+ // calls without ids would match nothing and have its entire turn erased.
328
+ // When no result carries an id and the counts line up, the results can
329
+ // only be for these calls — pair them positionally instead of discarding
330
+ // a turn the model genuinely took.
331
+ const canPairPositionally =
332
+ foundToolIds.size === 0 && followingResults === msg.tool_calls.length;
333
+
334
+ if (!canPairPositionally) {
335
+ msg.tool_calls = msg.tool_calls.filter(tc => foundToolIds.has(tc.id));
336
+ }
337
+
338
+ if (msg.tool_calls.length === 0) {
339
+ delete msg.tool_calls;
340
+ if (!msg.content || typeof msg.content !== 'string' || msg.content.trim() === '') {
341
+ msg.content = '[Action initiated and completed]';
342
+ }
343
+ }
344
+ }
345
+ }
346
+
347
+ let validToolBlock = false;
348
+ const clean = [];
349
+ for (const msg of messages) {
350
+ if (msg.role === 'assistant' && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
351
+ validToolBlock = true;
352
+ clean.push(msg);
353
+ } else if (msg.role === 'tool') {
354
+ if (validToolBlock) clean.push(msg);
355
+ // otherwise drop the orphan rather than let the provider 400
356
+ } else {
357
+ validToolBlock = false;
358
+ clean.push(msg);
359
+ }
360
+ }
361
+
362
+ return clean;
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // 5-Stage Progressive Compaction (Claude Code Architecture / Research Section E)
367
+ // ---------------------------------------------------------------------------
368
+
369
+ /** Stage 1: Budget Reduction triggered when raw context exceeds 70% capacity */
370
+ export const STAGE1_BUDGET_THRESHOLD = 0.70;
371
+
372
+ /** Stage 2: Snip triggered when any single tool response exceeds 15,000 tokens ≈ 60,000 chars */
373
+ export const STAGE2_SNIP_CHARS = 60000;
374
+ export const STAGE2_SNIP_HEAD = 20000;
375
+ export const STAGE2_SNIP_TAIL = 10000;
376
+
377
+ /** Stage 4: Context Collapse triggered when active context exceeds 85% capacity */
378
+ export const STAGE4_COLLAPSE_THRESHOLD = 0.85;
379
+
380
+ /**
381
+ * Stage 2: Snip oversized tool responses.
382
+ *
383
+ * Walks conversation history and truncates any single tool output exceeding
384
+ * 15,000 tokens (60,000 chars) down to head + tail with an informative marker.
385
+ * This prevents massive build logs, package listings, or grep dumps from
386
+ * crowding out the active context window.
387
+ *
388
+ * @param {Array} messages
389
+ * @param {Object} [options]
390
+ * @returns {{ snippedCount: number, reclaimedChars: number }}
391
+ */
392
+ export function snipOversizedToolOutputs(messages, options = {}) {
393
+ const threshold = options.threshold || STAGE2_SNIP_CHARS;
394
+ const headChars = options.headChars || STAGE2_SNIP_HEAD;
395
+ const tailChars = options.tailChars || STAGE2_SNIP_TAIL;
396
+
397
+ let snippedCount = 0;
398
+ let reclaimedChars = 0;
399
+
400
+ for (const msg of messages) {
401
+ if (msg.role !== 'tool' && !msg.tool_call_id) continue;
402
+ if (typeof msg.content !== 'string') continue;
403
+
404
+ const len = msg.content.length;
405
+ if (len > threshold) {
406
+ const removed = len - (headChars + tailChars);
407
+ const head = msg.content.slice(0, headChars);
408
+ const tail = msg.content.slice(-tailChars);
409
+ const pointer = msg.spillPath ? ` Full output preserved at: ${msg.spillPath}.` : '';
410
+
411
+ msg.content = `${head}\n\n[... ✂️ SNIPPED ${removed.toLocaleString()} CHARS (${Math.round(removed / CHARS_PER_TOKEN).toLocaleString()} TOKENS) TO PREVENT CONTEXT OVERFLOW.${pointer} Use targeted file reads to inspect specific sections ...] \n\n${tail}`;
412
+
413
+ snippedCount++;
414
+ reclaimedChars += (len - msg.content.length);
415
+ }
416
+ }
417
+
418
+ return { snippedCount, reclaimedChars };
419
+ }
420
+
421
+ /**
422
+ * Stage 3: Microcompact text.
423
+ * Strips redundant whitespace and collapses multiple blank lines to keep prompts
424
+ * tight without altering semantic content.
425
+ *
426
+ * @param {string} text
427
+ * @returns {string}
428
+ */
429
+ export function microcompactText(text) {
430
+ if (typeof text !== 'string') return text;
431
+ return text.replace(/[ \t]+$/gm, '').replace(/\n{3,}/g, '\n\n');
432
+ }
433
+
434
+ /**
435
+ * Progressive Compaction Pipeline (Stages 1 through 4).
436
+ * Evaluated before model calls to guarantee memory stability.
437
+ *
438
+ * @param {Array} messages
439
+ * @param {number} maxCharPool
440
+ * @param {Object} [options]
441
+ * @returns {{
442
+ * totalReclaimed: number,
443
+ * stagesFired: string[],
444
+ * finalChars: number,
445
+ * finalRatio: number
446
+ * }}
447
+ */
448
+ export function applyProgressiveCompaction(messages, maxCharPool, options = {}) {
449
+ let totalReclaimed = 0;
450
+ const stagesFired = [];
451
+
452
+ // Stage 2: Snip oversized tool outputs (>15K tokens / 60K chars)
453
+ const snipRes = snipOversizedToolOutputs(messages, options.snipOptions);
454
+ if (snipRes.snippedCount > 0) {
455
+ totalReclaimed += snipRes.reclaimedChars;
456
+ stagesFired.push(`snip:${snipRes.snippedCount}_tools`);
457
+ }
458
+
459
+ let currentChars = measureMessages(messages);
460
+ const ratio = maxCharPool > 0 ? currentChars / maxCharPool : 0;
461
+
462
+ // Stage 1: Budget Reduction (>70% of character pool)
463
+ if (ratio >= STAGE1_BUDGET_THRESHOLD) {
464
+ stagesFired.push(`budget_reduction:${(ratio * 100).toFixed(0)}%`);
465
+ for (const msg of messages) {
466
+ if (msg.role === 'tool' && typeof msg.content === 'string') {
467
+ const origLen = msg.content.length;
468
+ msg.content = microcompactText(msg.content);
469
+ totalReclaimed += (origLen - msg.content.length);
470
+ }
471
+ }
472
+ const decayReclaimed = decayToolResults(messages, {
473
+ keepRecent: options.budgetKeepRecent ?? 4,
474
+ minChars: 300
475
+ });
476
+ totalReclaimed += decayReclaimed;
477
+ }
478
+
479
+ // Stage 4: Context Collapse (>85% of character pool)
480
+ currentChars = measureMessages(messages);
481
+ const updatedRatio = maxCharPool > 0 ? currentChars / maxCharPool : 0;
482
+ if (updatedRatio >= STAGE4_COLLAPSE_THRESHOLD) {
483
+ stagesFired.push(`context_collapse:${(updatedRatio * 100).toFixed(0)}%`);
484
+ const collapseReclaimed = decayToolResults(messages, {
485
+ keepRecent: options.collapseKeepRecent ?? 2,
486
+ minChars: 200,
487
+ keepMediaRecent: 0
488
+ });
489
+ totalReclaimed += collapseReclaimed;
490
+ }
491
+
492
+ return {
493
+ totalReclaimed,
494
+ stagesFired,
495
+ finalChars: measureMessages(messages),
496
+ finalRatio: maxCharPool > 0 ? measureMessages(messages) / maxCharPool : 0
497
+ };
498
+ }