@animus-labs/cortex 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (293) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -0
  3. package/dist/budget-guard.d.ts +75 -0
  4. package/dist/budget-guard.d.ts.map +1 -0
  5. package/dist/budget-guard.js +142 -0
  6. package/dist/budget-guard.js.map +1 -0
  7. package/dist/compaction/compaction.d.ts +99 -0
  8. package/dist/compaction/compaction.d.ts.map +1 -0
  9. package/dist/compaction/compaction.js +302 -0
  10. package/dist/compaction/compaction.js.map +1 -0
  11. package/dist/compaction/failsafe.d.ts +57 -0
  12. package/dist/compaction/failsafe.d.ts.map +1 -0
  13. package/dist/compaction/failsafe.js +135 -0
  14. package/dist/compaction/failsafe.js.map +1 -0
  15. package/dist/compaction/index.d.ts +381 -0
  16. package/dist/compaction/index.d.ts.map +1 -0
  17. package/dist/compaction/index.js +979 -0
  18. package/dist/compaction/index.js.map +1 -0
  19. package/dist/compaction/microcompaction.d.ts +219 -0
  20. package/dist/compaction/microcompaction.d.ts.map +1 -0
  21. package/dist/compaction/microcompaction.js +536 -0
  22. package/dist/compaction/microcompaction.js.map +1 -0
  23. package/dist/compaction/observational/buffering.d.ts +225 -0
  24. package/dist/compaction/observational/buffering.d.ts.map +1 -0
  25. package/dist/compaction/observational/buffering.js +354 -0
  26. package/dist/compaction/observational/buffering.js.map +1 -0
  27. package/dist/compaction/observational/constants.d.ts +70 -0
  28. package/dist/compaction/observational/constants.d.ts.map +1 -0
  29. package/dist/compaction/observational/constants.js +507 -0
  30. package/dist/compaction/observational/constants.js.map +1 -0
  31. package/dist/compaction/observational/index.d.ts +219 -0
  32. package/dist/compaction/observational/index.d.ts.map +1 -0
  33. package/dist/compaction/observational/index.js +641 -0
  34. package/dist/compaction/observational/index.js.map +1 -0
  35. package/dist/compaction/observational/observer.d.ts +97 -0
  36. package/dist/compaction/observational/observer.d.ts.map +1 -0
  37. package/dist/compaction/observational/observer.js +424 -0
  38. package/dist/compaction/observational/observer.js.map +1 -0
  39. package/dist/compaction/observational/recall-tool.d.ts +27 -0
  40. package/dist/compaction/observational/recall-tool.d.ts.map +1 -0
  41. package/dist/compaction/observational/recall-tool.js +93 -0
  42. package/dist/compaction/observational/recall-tool.js.map +1 -0
  43. package/dist/compaction/observational/reflector.d.ts +94 -0
  44. package/dist/compaction/observational/reflector.d.ts.map +1 -0
  45. package/dist/compaction/observational/reflector.js +167 -0
  46. package/dist/compaction/observational/reflector.js.map +1 -0
  47. package/dist/compaction/observational/types.d.ts +271 -0
  48. package/dist/compaction/observational/types.d.ts.map +1 -0
  49. package/dist/compaction/observational/types.js +15 -0
  50. package/dist/compaction/observational/types.js.map +1 -0
  51. package/dist/context-manager.d.ts +134 -0
  52. package/dist/context-manager.d.ts.map +1 -0
  53. package/dist/context-manager.js +170 -0
  54. package/dist/context-manager.js.map +1 -0
  55. package/dist/cortex-agent.d.ts +1020 -0
  56. package/dist/cortex-agent.d.ts.map +1 -0
  57. package/dist/cortex-agent.js +3589 -0
  58. package/dist/cortex-agent.js.map +1 -0
  59. package/dist/error-classifier.d.ts +48 -0
  60. package/dist/error-classifier.d.ts.map +1 -0
  61. package/dist/error-classifier.js +152 -0
  62. package/dist/error-classifier.js.map +1 -0
  63. package/dist/event-bridge.d.ts +166 -0
  64. package/dist/event-bridge.d.ts.map +1 -0
  65. package/dist/event-bridge.js +381 -0
  66. package/dist/event-bridge.js.map +1 -0
  67. package/dist/index.d.ts +55 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +57 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/mcp-client.d.ts +119 -0
  72. package/dist/mcp-client.d.ts.map +1 -0
  73. package/dist/mcp-client.js +474 -0
  74. package/dist/mcp-client.js.map +1 -0
  75. package/dist/model-wrapper.d.ts +58 -0
  76. package/dist/model-wrapper.d.ts.map +1 -0
  77. package/dist/model-wrapper.js +86 -0
  78. package/dist/model-wrapper.js.map +1 -0
  79. package/dist/noop-logger.d.ts +4 -0
  80. package/dist/noop-logger.d.ts.map +1 -0
  81. package/dist/noop-logger.js +8 -0
  82. package/dist/noop-logger.js.map +1 -0
  83. package/dist/prompt-diagnostics.d.ts +47 -0
  84. package/dist/prompt-diagnostics.d.ts.map +1 -0
  85. package/dist/prompt-diagnostics.js +230 -0
  86. package/dist/prompt-diagnostics.js.map +1 -0
  87. package/dist/provider-manager.d.ts +224 -0
  88. package/dist/provider-manager.d.ts.map +1 -0
  89. package/dist/provider-manager.js +563 -0
  90. package/dist/provider-manager.js.map +1 -0
  91. package/dist/provider-registry.d.ts +115 -0
  92. package/dist/provider-registry.d.ts.map +1 -0
  93. package/dist/provider-registry.js +305 -0
  94. package/dist/provider-registry.js.map +1 -0
  95. package/dist/schema-converter.d.ts +20 -0
  96. package/dist/schema-converter.d.ts.map +1 -0
  97. package/dist/schema-converter.js +48 -0
  98. package/dist/schema-converter.js.map +1 -0
  99. package/dist/skill-preprocessor.d.ts +46 -0
  100. package/dist/skill-preprocessor.d.ts.map +1 -0
  101. package/dist/skill-preprocessor.js +237 -0
  102. package/dist/skill-preprocessor.js.map +1 -0
  103. package/dist/skill-registry.d.ts +107 -0
  104. package/dist/skill-registry.d.ts.map +1 -0
  105. package/dist/skill-registry.js +330 -0
  106. package/dist/skill-registry.js.map +1 -0
  107. package/dist/skill-tool.d.ts +54 -0
  108. package/dist/skill-tool.d.ts.map +1 -0
  109. package/dist/skill-tool.js +88 -0
  110. package/dist/skill-tool.js.map +1 -0
  111. package/dist/sub-agent-manager.d.ts +90 -0
  112. package/dist/sub-agent-manager.d.ts.map +1 -0
  113. package/dist/sub-agent-manager.js +192 -0
  114. package/dist/sub-agent-manager.js.map +1 -0
  115. package/dist/token-estimator.d.ts +23 -0
  116. package/dist/token-estimator.d.ts.map +1 -0
  117. package/dist/token-estimator.js +27 -0
  118. package/dist/token-estimator.js.map +1 -0
  119. package/dist/tool-contract.d.ts +68 -0
  120. package/dist/tool-contract.d.ts.map +1 -0
  121. package/dist/tool-contract.js +35 -0
  122. package/dist/tool-contract.js.map +1 -0
  123. package/dist/tool-result-persistence.d.ts +89 -0
  124. package/dist/tool-result-persistence.d.ts.map +1 -0
  125. package/dist/tool-result-persistence.js +152 -0
  126. package/dist/tool-result-persistence.js.map +1 -0
  127. package/dist/tools/bash/index.d.ts +71 -0
  128. package/dist/tools/bash/index.d.ts.map +1 -0
  129. package/dist/tools/bash/index.js +485 -0
  130. package/dist/tools/bash/index.js.map +1 -0
  131. package/dist/tools/bash/interactive.d.ts +47 -0
  132. package/dist/tools/bash/interactive.d.ts.map +1 -0
  133. package/dist/tools/bash/interactive.js +262 -0
  134. package/dist/tools/bash/interactive.js.map +1 -0
  135. package/dist/tools/bash/safety.d.ts +149 -0
  136. package/dist/tools/bash/safety.d.ts.map +1 -0
  137. package/dist/tools/bash/safety.js +1116 -0
  138. package/dist/tools/bash/safety.js.map +1 -0
  139. package/dist/tools/edit.d.ts +57 -0
  140. package/dist/tools/edit.d.ts.map +1 -0
  141. package/dist/tools/edit.js +310 -0
  142. package/dist/tools/edit.js.map +1 -0
  143. package/dist/tools/glob.d.ts +34 -0
  144. package/dist/tools/glob.d.ts.map +1 -0
  145. package/dist/tools/glob.js +268 -0
  146. package/dist/tools/glob.js.map +1 -0
  147. package/dist/tools/grep.d.ts +53 -0
  148. package/dist/tools/grep.d.ts.map +1 -0
  149. package/dist/tools/grep.js +673 -0
  150. package/dist/tools/grep.js.map +1 -0
  151. package/dist/tools/index.d.ts +62 -0
  152. package/dist/tools/index.d.ts.map +1 -0
  153. package/dist/tools/index.js +52 -0
  154. package/dist/tools/index.js.map +1 -0
  155. package/dist/tools/read.d.ts +43 -0
  156. package/dist/tools/read.d.ts.map +1 -0
  157. package/dist/tools/read.js +459 -0
  158. package/dist/tools/read.js.map +1 -0
  159. package/dist/tools/runtime.d.ts +62 -0
  160. package/dist/tools/runtime.d.ts.map +1 -0
  161. package/dist/tools/runtime.js +116 -0
  162. package/dist/tools/runtime.js.map +1 -0
  163. package/dist/tools/shared/cwd-tracker.d.ts +32 -0
  164. package/dist/tools/shared/cwd-tracker.d.ts.map +1 -0
  165. package/dist/tools/shared/cwd-tracker.js +44 -0
  166. package/dist/tools/shared/cwd-tracker.js.map +1 -0
  167. package/dist/tools/shared/edit-history.d.ts +55 -0
  168. package/dist/tools/shared/edit-history.d.ts.map +1 -0
  169. package/dist/tools/shared/edit-history.js +72 -0
  170. package/dist/tools/shared/edit-history.js.map +1 -0
  171. package/dist/tools/shared/edit-matcher.d.ts +83 -0
  172. package/dist/tools/shared/edit-matcher.d.ts.map +1 -0
  173. package/dist/tools/shared/edit-matcher.js +359 -0
  174. package/dist/tools/shared/edit-matcher.js.map +1 -0
  175. package/dist/tools/shared/file-mutation-lock.d.ts +22 -0
  176. package/dist/tools/shared/file-mutation-lock.d.ts.map +1 -0
  177. package/dist/tools/shared/file-mutation-lock.js +35 -0
  178. package/dist/tools/shared/file-mutation-lock.js.map +1 -0
  179. package/dist/tools/shared/gitignore.d.ts +17 -0
  180. package/dist/tools/shared/gitignore.d.ts.map +1 -0
  181. package/dist/tools/shared/gitignore.js +59 -0
  182. package/dist/tools/shared/gitignore.js.map +1 -0
  183. package/dist/tools/shared/pdf-extractor.d.ts +96 -0
  184. package/dist/tools/shared/pdf-extractor.d.ts.map +1 -0
  185. package/dist/tools/shared/pdf-extractor.js +196 -0
  186. package/dist/tools/shared/pdf-extractor.js.map +1 -0
  187. package/dist/tools/shared/read-registry.d.ts +66 -0
  188. package/dist/tools/shared/read-registry.d.ts.map +1 -0
  189. package/dist/tools/shared/read-registry.js +65 -0
  190. package/dist/tools/shared/read-registry.js.map +1 -0
  191. package/dist/tools/shared/safe-env.d.ts +18 -0
  192. package/dist/tools/shared/safe-env.d.ts.map +1 -0
  193. package/dist/tools/shared/safe-env.js +70 -0
  194. package/dist/tools/shared/safe-env.js.map +1 -0
  195. package/dist/tools/sub-agent.d.ts +91 -0
  196. package/dist/tools/sub-agent.d.ts.map +1 -0
  197. package/dist/tools/sub-agent.js +89 -0
  198. package/dist/tools/sub-agent.js.map +1 -0
  199. package/dist/tools/task-output.d.ts +38 -0
  200. package/dist/tools/task-output.d.ts.map +1 -0
  201. package/dist/tools/task-output.js +186 -0
  202. package/dist/tools/task-output.js.map +1 -0
  203. package/dist/tools/tool-search/index.d.ts +40 -0
  204. package/dist/tools/tool-search/index.d.ts.map +1 -0
  205. package/dist/tools/tool-search/index.js +110 -0
  206. package/dist/tools/tool-search/index.js.map +1 -0
  207. package/dist/tools/tool-search/registry.d.ts +82 -0
  208. package/dist/tools/tool-search/registry.d.ts.map +1 -0
  209. package/dist/tools/tool-search/registry.js +238 -0
  210. package/dist/tools/tool-search/registry.js.map +1 -0
  211. package/dist/tools/undo-edit.d.ts +51 -0
  212. package/dist/tools/undo-edit.d.ts.map +1 -0
  213. package/dist/tools/undo-edit.js +231 -0
  214. package/dist/tools/undo-edit.js.map +1 -0
  215. package/dist/tools/web-fetch/cache.d.ts +49 -0
  216. package/dist/tools/web-fetch/cache.d.ts.map +1 -0
  217. package/dist/tools/web-fetch/cache.js +89 -0
  218. package/dist/tools/web-fetch/cache.js.map +1 -0
  219. package/dist/tools/web-fetch/index.d.ts +53 -0
  220. package/dist/tools/web-fetch/index.d.ts.map +1 -0
  221. package/dist/tools/web-fetch/index.js +513 -0
  222. package/dist/tools/web-fetch/index.js.map +1 -0
  223. package/dist/tools/write.d.ts +59 -0
  224. package/dist/tools/write.d.ts.map +1 -0
  225. package/dist/tools/write.js +316 -0
  226. package/dist/tools/write.js.map +1 -0
  227. package/dist/types.d.ts +881 -0
  228. package/dist/types.d.ts.map +1 -0
  229. package/dist/types.js +16 -0
  230. package/dist/types.js.map +1 -0
  231. package/dist/working-tags.d.ts +44 -0
  232. package/dist/working-tags.d.ts.map +1 -0
  233. package/dist/working-tags.js +103 -0
  234. package/dist/working-tags.js.map +1 -0
  235. package/package.json +87 -0
  236. package/src/budget-guard.ts +170 -0
  237. package/src/compaction/compaction.ts +386 -0
  238. package/src/compaction/failsafe.ts +185 -0
  239. package/src/compaction/index.ts +1199 -0
  240. package/src/compaction/microcompaction.ts +709 -0
  241. package/src/compaction/observational/buffering.ts +430 -0
  242. package/src/compaction/observational/constants.ts +532 -0
  243. package/src/compaction/observational/index.ts +837 -0
  244. package/src/compaction/observational/observer.ts +510 -0
  245. package/src/compaction/observational/recall-tool.ts +130 -0
  246. package/src/compaction/observational/reflector.ts +221 -0
  247. package/src/compaction/observational/types.ts +343 -0
  248. package/src/context-manager.ts +237 -0
  249. package/src/cortex-agent.ts +4297 -0
  250. package/src/error-classifier.ts +199 -0
  251. package/src/event-bridge.ts +508 -0
  252. package/src/index.ts +292 -0
  253. package/src/mcp-client.ts +582 -0
  254. package/src/model-wrapper.ts +128 -0
  255. package/src/noop-logger.ts +9 -0
  256. package/src/prompt-diagnostics.ts +296 -0
  257. package/src/provider-manager.ts +823 -0
  258. package/src/provider-registry.ts +386 -0
  259. package/src/schema-converter.ts +51 -0
  260. package/src/skill-preprocessor.ts +314 -0
  261. package/src/skill-registry.ts +378 -0
  262. package/src/skill-tool.ts +130 -0
  263. package/src/sub-agent-manager.ts +236 -0
  264. package/src/token-estimator.ts +26 -0
  265. package/src/tool-contract.ts +113 -0
  266. package/src/tool-result-persistence.ts +197 -0
  267. package/src/tools/bash/index.ts +633 -0
  268. package/src/tools/bash/interactive.ts +302 -0
  269. package/src/tools/bash/safety.ts +1297 -0
  270. package/src/tools/edit.ts +422 -0
  271. package/src/tools/glob.ts +330 -0
  272. package/src/tools/grep.ts +819 -0
  273. package/src/tools/index.ts +110 -0
  274. package/src/tools/read.ts +580 -0
  275. package/src/tools/runtime.ts +173 -0
  276. package/src/tools/shared/cwd-tracker.ts +50 -0
  277. package/src/tools/shared/edit-history.ts +96 -0
  278. package/src/tools/shared/edit-matcher.ts +457 -0
  279. package/src/tools/shared/file-mutation-lock.ts +40 -0
  280. package/src/tools/shared/gitignore.ts +61 -0
  281. package/src/tools/shared/pdf-extractor.ts +290 -0
  282. package/src/tools/shared/read-registry.ts +93 -0
  283. package/src/tools/shared/safe-env.ts +82 -0
  284. package/src/tools/sub-agent.ts +171 -0
  285. package/src/tools/task-output.ts +236 -0
  286. package/src/tools/tool-search/index.ts +167 -0
  287. package/src/tools/tool-search/registry.ts +278 -0
  288. package/src/tools/undo-edit.ts +314 -0
  289. package/src/tools/web-fetch/cache.ts +112 -0
  290. package/src/tools/web-fetch/index.ts +604 -0
  291. package/src/tools/write.ts +385 -0
  292. package/src/types.ts +1057 -0
  293. package/src/working-tags.ts +118 -0
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Tool barrel export and factory registration.
3
+ *
4
+ * All built-in tools are registered here. The CortexAgent maps tool names
5
+ * to factory functions, creates each tool with the appropriate config,
6
+ * and registers them on the pi-agent-core Agent.
7
+ */
8
+
9
+ // Shared infrastructure
10
+ export { ReadRegistry } from './shared/read-registry.js';
11
+ export type { ReadState } from './shared/read-registry.js';
12
+ export { EditHistory, MAX_STACK_DEPTH as EDIT_HISTORY_MAX_STACK_DEPTH } from './shared/edit-history.js';
13
+ export type { EditHistoryEntry } from './shared/edit-history.js';
14
+ export { CwdTracker } from './shared/cwd-tracker.js';
15
+ export {
16
+ CortexToolRuntime,
17
+ BackgroundTaskStore,
18
+ WebFetchRuntimeState,
19
+ globalBackgroundTaskStore,
20
+ attachRuntimeAwareTool,
21
+ getRuntimeAwareToolMetadata,
22
+ cloneRuntimeAwareTool,
23
+ } from './runtime.js';
24
+ export type {
25
+ BackgroundTask,
26
+ RuntimeAwareToolMetadata,
27
+ } from './runtime.js';
28
+
29
+ // Tool factories
30
+ export { createReadTool } from './read.js';
31
+ export type { ReadToolConfig, ReadDetails, ReadParamsType } from './read.js';
32
+ export { ReadParams } from './read.js';
33
+
34
+ export { createWriteTool } from './write.js';
35
+ export type { WriteToolConfig, WriteDetails, WriteParamsType, DiffHunk } from './write.js';
36
+ export { WriteParams } from './write.js';
37
+
38
+ export { createEditTool } from './edit.js';
39
+ export type { EditToolConfig, EditDetails, EditParamsType } from './edit.js';
40
+ export { EditParams } from './edit.js';
41
+
42
+ export { createUndoEditTool } from './undo-edit.js';
43
+ export type {
44
+ UndoEditToolConfig,
45
+ UndoEditDetails,
46
+ UndoEditParamsType,
47
+ } from './undo-edit.js';
48
+ export { UndoEditParams } from './undo-edit.js';
49
+
50
+ export { createGlobTool } from './glob.js';
51
+ export type { GlobToolConfig, GlobDetails, GlobParamsType } from './glob.js';
52
+ export { GlobParams } from './glob.js';
53
+
54
+ export { createGrepTool } from './grep.js';
55
+ export type { GrepToolConfig, GrepDetails, GrepParamsType } from './grep.js';
56
+ export { GrepParams } from './grep.js';
57
+
58
+ export { createBashTool, getBackgroundTask, getAllBackgroundTasks } from './bash/index.js';
59
+ export type { BashToolConfig, BashDetails, BashStreamUpdate, BashParamsType } from './bash/index.js';
60
+ export { BashParams } from './bash/index.js';
61
+
62
+ export { createTaskOutputTool } from './task-output.js';
63
+ export type { TaskOutputDetails, TaskOutputParamsType, TaskOutputToolConfig } from './task-output.js';
64
+ export { TaskOutputParams } from './task-output.js';
65
+
66
+ export { createWebFetchTool, isPrivateIp } from './web-fetch/index.js';
67
+ export type { WebFetchToolConfig, WebFetchDetails, WebFetchParamsType } from './web-fetch/index.js';
68
+ export { WebFetchParams } from './web-fetch/index.js';
69
+
70
+ export { WebFetchCache } from './web-fetch/cache.js';
71
+ export type { CacheEntry } from './web-fetch/cache.js';
72
+
73
+ export { createSubAgentTool, SUB_AGENT_TOOL_NAME } from './sub-agent.js';
74
+ export type { SubAgentToolConfig, SubAgentDetails, SubAgentParamsType } from './sub-agent.js';
75
+ export { SubAgentParams } from './sub-agent.js';
76
+
77
+ // Safety layers
78
+ export {
79
+ buildSafeEnv,
80
+ isCriticalPath,
81
+ classifyCommand,
82
+ splitOnShellOperators,
83
+ checkObfuscation,
84
+ stripInvisibleChars,
85
+ checkScriptPreflight,
86
+ checkAutoModeClassifier,
87
+ runSafetyChecks,
88
+ validateWritePaths,
89
+ extractWritePaths,
90
+ } from './bash/safety.js';
91
+ export type { CommandClassification, SafetyCheckResult } from './bash/safety.js';
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Tool name constants
95
+ // ---------------------------------------------------------------------------
96
+
97
+ export const TOOL_NAMES = {
98
+ Read: 'Read',
99
+ Write: 'Write',
100
+ Edit: 'Edit',
101
+ UndoEdit: 'UndoEdit',
102
+ Glob: 'Glob',
103
+ Grep: 'Grep',
104
+ Bash: 'Bash',
105
+ TaskOutput: 'TaskOutput',
106
+ WebFetch: 'WebFetch',
107
+ SubAgent: 'SubAgent',
108
+ } as const;
109
+
110
+ export type BuiltInToolName = keyof typeof TOOL_NAMES;
@@ -0,0 +1,580 @@
1
+ /**
2
+ * Read tool: read file contents from the local filesystem.
3
+ *
4
+ * Returns file content with line numbers in `cat -n` format.
5
+ * Handles text files, images (base64 ImageContent), and
6
+ * detects binary files.
7
+ *
8
+ * Reference: docs/cortex/tools/read.md
9
+ */
10
+
11
+ import * as crypto from 'node:crypto';
12
+ import * as fs from 'node:fs';
13
+ import * as path from 'node:path';
14
+ import { Type, type Static } from 'typebox';
15
+ import type { ReadRegistry } from './shared/read-registry.js';
16
+ import type { ToolContentDetails } from '../types.js';
17
+ import type { CortexToolRuntime } from './runtime.js';
18
+ import { attachRuntimeAwareTool } from './runtime.js';
19
+ import { estimateTokens } from '../token-estimator.js';
20
+ import { extractPdfText } from './shared/pdf-extractor.js';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Schema
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export const ReadParams = Type.Object({
27
+ file_path: Type.String({ description: 'Absolute path to the file to read' }),
28
+ offset: Type.Optional(
29
+ Type.Number({ description: 'Line number to start reading from (1-based). Only provide if the file is too large to read at once.' }),
30
+ ),
31
+ limit: Type.Optional(
32
+ Type.Number({ description: 'Maximum number of lines to read. Only provide if the file is too large to read at once.' }),
33
+ ),
34
+ pages: Type.Optional(
35
+ Type.String({ description: 'Page range for PDF files (e.g., "1-5", "3", "10-20"). Only applicable to PDF files. Max 20 pages per request.' }),
36
+ ),
37
+ });
38
+
39
+ export type ReadParamsType = Static<typeof ReadParams>;
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Constants
43
+ // ---------------------------------------------------------------------------
44
+
45
+ const DEFAULT_LIMIT = 2000;
46
+ const MAX_LINE_LENGTH = 2000;
47
+
48
+ /** Pre-read gate for full reads (no offset/limit provided). */
49
+ const MAX_FULL_READ_BYTES = 256 * 1024; // 256 KB
50
+
51
+ /** Hard ceiling even with offset/limit. Beyond this, use Bash. */
52
+ const MAX_READABLE_BYTES = 10 * 1024 * 1024; // 10 MB
53
+
54
+ /** Post-read token ceiling on formatted output. */
55
+ const MAX_OUTPUT_TOKENS = 25_000;
56
+
57
+ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']);
58
+ const IMAGE_MIME_TYPES: Record<string, string> = {
59
+ '.png': 'image/png',
60
+ '.jpg': 'image/jpeg',
61
+ '.jpeg': 'image/jpeg',
62
+ '.gif': 'image/gif',
63
+ '.webp': 'image/webp',
64
+ };
65
+
66
+ /**
67
+ * Device files that would hang the process: infinite output or blocking input.
68
+ * Checked by path only (no I/O).
69
+ */
70
+ const BLOCKED_DEVICE_PATHS = new Set([
71
+ // Infinite output
72
+ '/dev/zero',
73
+ '/dev/random',
74
+ '/dev/urandom',
75
+ '/dev/full',
76
+ // Blocks waiting for input
77
+ '/dev/stdin',
78
+ '/dev/tty',
79
+ '/dev/console',
80
+ // Nonsensical to read
81
+ '/dev/stdout',
82
+ '/dev/stderr',
83
+ // fd aliases for stdin/stdout/stderr
84
+ '/dev/fd/0',
85
+ '/dev/fd/1',
86
+ '/dev/fd/2',
87
+ ]);
88
+
89
+ function isBlockedDevicePath(filePath: string): boolean {
90
+ if (BLOCKED_DEVICE_PATHS.has(filePath)) return true;
91
+ // /proc/self/fd/0-2 and /proc/<pid>/fd/0-2 are Linux aliases for stdio
92
+ if (
93
+ filePath.startsWith('/proc/') &&
94
+ (filePath.endsWith('/fd/0') ||
95
+ filePath.endsWith('/fd/1') ||
96
+ filePath.endsWith('/fd/2'))
97
+ )
98
+ return true;
99
+ return false;
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Details type
104
+ // ---------------------------------------------------------------------------
105
+
106
+ export interface ReadDetails {
107
+ filePath: string;
108
+ totalLines: number;
109
+ byteSize: number;
110
+ truncated: boolean;
111
+ truncatedLines: boolean;
112
+ truncatedChars: boolean;
113
+ /** Starting line number (1-based) for the content returned. */
114
+ startLine: number;
115
+ /** True when the read was rejected by a size/token gate (content is an error message, not file data). */
116
+ rejected?: boolean;
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Config
121
+ // ---------------------------------------------------------------------------
122
+
123
+ export interface ReadToolConfig {
124
+ runtime?: CortexToolRuntime | undefined;
125
+ readRegistry?: ReadRegistry | undefined;
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Helpers
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * Detect if a buffer contains binary content.
134
+ * A file is considered binary if it contains null bytes in the first 8KB.
135
+ */
136
+ function isBinaryBuffer(buffer: Buffer): boolean {
137
+ const checkLength = Math.min(buffer.length, 8192);
138
+ for (let i = 0; i < checkLength; i++) {
139
+ if (buffer[i] === 0) {
140
+ return true;
141
+ }
142
+ }
143
+ return false;
144
+ }
145
+
146
+ /**
147
+ * Try to detect and decode file content with common encodings.
148
+ * Handles UTF-8, UTF-16 LE/BE (via BOM), and falls back to Latin-1.
149
+ */
150
+ function decodeFileContent(buffer: Buffer): string {
151
+ // Check for UTF-16 BOM
152
+ if (buffer.length >= 2) {
153
+ if (buffer[0] === 0xff && buffer[1] === 0xfe) {
154
+ return buffer.toString('utf16le');
155
+ }
156
+ if (buffer[0] === 0xfe && buffer[1] === 0xff) {
157
+ // UTF-16 BE: swap bytes and decode as UTF-16 LE
158
+ const swapped = Buffer.alloc(buffer.length);
159
+ for (let i = 0; i < buffer.length - 1; i += 2) {
160
+ swapped[i] = buffer[i + 1]!;
161
+ swapped[i + 1] = buffer[i]!;
162
+ }
163
+ return swapped.toString('utf16le');
164
+ }
165
+ }
166
+
167
+ // Check for UTF-8 BOM
168
+ if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) {
169
+ return buffer.toString('utf8').slice(1); // Skip the BOM character
170
+ }
171
+
172
+ // Try UTF-8 first (most common)
173
+ const utf8 = buffer.toString('utf8');
174
+
175
+ // Check for replacement characters that suggest bad UTF-8 decoding
176
+ // Only fall back to Latin-1 if there are many replacement chars
177
+ const replacementCount = (utf8.match(/\ufffd/g) ?? []).length;
178
+ if (replacementCount > 0 && replacementCount > buffer.length * 0.01) {
179
+ return buffer.toString('latin1');
180
+ }
181
+
182
+ return utf8;
183
+ }
184
+
185
+ /**
186
+ * Format lines with `cat -n` style line numbers.
187
+ * Format: spaces + line_number + tab + content
188
+ */
189
+ function formatWithLineNumbers(
190
+ lines: string[],
191
+ startLine: number,
192
+ ): string {
193
+ const maxLineNum = startLine + lines.length - 1;
194
+ const width = String(maxLineNum).length;
195
+
196
+ return lines
197
+ .map((line, i) => {
198
+ const lineNum = startLine + i;
199
+ const paddedNum = String(lineNum).padStart(width + 2);
200
+ // Truncate long lines
201
+ const truncatedLine =
202
+ line.length > MAX_LINE_LENGTH
203
+ ? line.slice(0, MAX_LINE_LENGTH) + '... [truncated]'
204
+ : line;
205
+ return `${paddedNum}\t${truncatedLine}`;
206
+ })
207
+ .join('\n');
208
+ }
209
+
210
+ /**
211
+ * Format byte count as a human-readable string (KB or MB).
212
+ */
213
+ function formatBytes(bytes: number): string {
214
+ if (bytes >= 1024 * 1024) {
215
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
216
+ }
217
+ return `${Math.round(bytes / 1024)} KB`;
218
+ }
219
+
220
+ /**
221
+ * Build a rejection result for size/token gate failures.
222
+ * Returns an error message as tool content with `rejected: true` in details.
223
+ */
224
+ function makeRejection(filePath: string, byteSize: number, message: string): ToolContentDetails<ReadDetails> {
225
+ return {
226
+ content: [{ type: 'text', text: message }],
227
+ details: {
228
+ filePath,
229
+ totalLines: 0,
230
+ byteSize,
231
+ truncated: false,
232
+ truncatedLines: false,
233
+ truncatedChars: false,
234
+ startLine: 1,
235
+ rejected: true,
236
+ },
237
+ };
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Tool factory
242
+ // ---------------------------------------------------------------------------
243
+
244
+ export function createReadTool(config: ReadToolConfig): {
245
+ name: string;
246
+ description: string;
247
+ parameters: typeof ReadParams;
248
+ execute: (params: ReadParamsType) => Promise<ToolContentDetails<ReadDetails>>;
249
+ } {
250
+ const readRegistry = config.runtime?.readRegistry ?? config.readRegistry;
251
+ if (!readRegistry) {
252
+ throw new Error('createReadTool requires either runtime or readRegistry');
253
+ }
254
+
255
+ const tool = {
256
+ name: 'Read',
257
+ description: [
258
+ 'Read file contents from the local filesystem.',
259
+ 'Returns content with line numbers in cat -n format.',
260
+ '',
261
+ 'Size limits:',
262
+ `- Files up to ${formatBytes(MAX_FULL_READ_BYTES)}: read in full (no offset/limit needed)`,
263
+ `- Files ${formatBytes(MAX_FULL_READ_BYTES)} to ${formatBytes(MAX_READABLE_BYTES)}: must provide offset and limit`,
264
+ `- Files over ${formatBytes(MAX_READABLE_BYTES)}: use Bash (head, tail, sed) instead`,
265
+ `- Output capped at ~${MAX_OUTPUT_TOKENS.toLocaleString()} tokens; reduce limit if exceeded`,
266
+ '',
267
+ 'For searching file contents, use Grep instead of reading the whole file.',
268
+ ].join('\n'),
269
+ parameters: ReadParams,
270
+
271
+ async execute(params: ReadParamsType): Promise<ToolContentDetails<ReadDetails>> {
272
+ const filePath = path.resolve(params.file_path);
273
+ const offset = params.offset ?? 1;
274
+ const limit = params.limit ?? DEFAULT_LIMIT;
275
+
276
+ // Block device paths that would hang (infinite output or blocking input)
277
+ if (isBlockedDevicePath(filePath)) {
278
+ return {
279
+ content: [{ type: 'text', text: `Cannot read '${params.file_path}': this device file would block or produce infinite output.` }],
280
+ details: {
281
+ filePath,
282
+ totalLines: 0,
283
+ byteSize: 0,
284
+ truncated: false,
285
+ truncatedLines: false,
286
+ truncatedChars: false,
287
+ startLine: 1,
288
+ },
289
+ };
290
+ }
291
+
292
+ // Check if path exists
293
+ let stat: fs.Stats;
294
+ try {
295
+ stat = await fs.promises.stat(filePath);
296
+ } catch (err: unknown) {
297
+ const code = (err as NodeJS.ErrnoException).code;
298
+ if (code === 'ENOENT') {
299
+ return {
300
+ content: [{ type: 'text', text: `File does not exist: ${filePath}` }],
301
+ details: {
302
+ filePath,
303
+ totalLines: 0,
304
+ byteSize: 0,
305
+ truncated: false,
306
+ truncatedLines: false,
307
+ truncatedChars: false,
308
+ startLine: 1,
309
+ },
310
+ };
311
+ }
312
+ if (code === 'EACCES') {
313
+ return {
314
+ content: [{ type: 'text', text: `Permission denied: ${filePath}` }],
315
+ details: {
316
+ filePath,
317
+ totalLines: 0,
318
+ byteSize: 0,
319
+ truncated: false,
320
+ truncatedLines: false,
321
+ truncatedChars: false,
322
+ startLine: 1,
323
+ },
324
+ };
325
+ }
326
+ throw err;
327
+ }
328
+
329
+ // Cannot read directories
330
+ if (stat.isDirectory()) {
331
+ return {
332
+ content: [{ type: 'text', text: 'Cannot read a directory. Use `ls` via Bash.' }],
333
+ details: {
334
+ filePath,
335
+ totalLines: 0,
336
+ byteSize: 0,
337
+ truncated: false,
338
+ truncatedLines: false,
339
+ truncatedChars: false,
340
+ startLine: 1,
341
+ },
342
+ };
343
+ }
344
+
345
+ // Gate 1: Absolute size ceiling - reject files > 10 MB entirely
346
+ if (stat.size > MAX_READABLE_BYTES) {
347
+ return makeRejection(
348
+ filePath,
349
+ stat.size,
350
+ `File is too large to read (${formatBytes(stat.size)}, limit ${formatBytes(MAX_READABLE_BYTES)}). Use Bash with head, tail, or sed to extract specific sections.`,
351
+ );
352
+ }
353
+
354
+ const ext = path.extname(filePath).toLowerCase();
355
+
356
+ // Handle image files
357
+ if (IMAGE_EXTENSIONS.has(ext)) {
358
+ const buffer = await fs.promises.readFile(filePath);
359
+ const mimeType = IMAGE_MIME_TYPES[ext] ?? 'application/octet-stream';
360
+ const base64 = buffer.toString('base64');
361
+
362
+ readRegistry.markRead(filePath, { timestamp: stat.mtimeMs });
363
+
364
+ return {
365
+ content: [{ type: 'image', data: base64, mimeType }],
366
+ details: {
367
+ filePath,
368
+ totalLines: 0,
369
+ byteSize: stat.size,
370
+ truncated: false,
371
+ truncatedLines: false,
372
+ truncatedChars: false,
373
+ startLine: 1,
374
+ },
375
+ };
376
+ }
377
+
378
+ // Handle PDF files. The extractor (shared/pdf-extractor.ts) wraps
379
+ // unpdf and returns a structured result: the Read tool's only
380
+ // responsibility is to decide how to surface each outcome.
381
+ if (ext === '.pdf') {
382
+ const pdfBuffer = await fs.promises.readFile(filePath);
383
+ const extraction = await extractPdfText({
384
+ data: pdfBuffer,
385
+ pagesSpec: params.pages,
386
+ });
387
+
388
+ if (
389
+ extraction.kind === 'error' ||
390
+ extraction.kind === 'invalid-range' ||
391
+ extraction.kind === 'empty'
392
+ ) {
393
+ // All three are read failures from the caller's perspective:
394
+ // there is no usable content to hand to the model. Flag as
395
+ // rejected so consumers can surface them uniformly and the
396
+ // model can retry (with a different pages spec, OCR, etc.).
397
+ return makeRejection(filePath, stat.size, extraction.message);
398
+ }
399
+
400
+ // Line-number the rendered output to match the cat -n style
401
+ // used for text files. Line numbers reset to 1 per call; PDFs
402
+ // don't map cleanly to the file-wide offset/limit model.
403
+ const renderedLines = extraction.rendered.split('\n');
404
+ const formatted = formatWithLineNumbers(renderedLines, 1);
405
+
406
+ // Gate 3: token ceiling on the formatted output.
407
+ const pdfTokenCount = estimateTokens(formatted);
408
+ if (pdfTokenCount > MAX_OUTPUT_TOKENS) {
409
+ const requestedPages = extraction.lastPage - extraction.firstPage + 1;
410
+ const suggestedPages = Math.max(
411
+ 1,
412
+ Math.floor(requestedPages * MAX_OUTPUT_TOKENS / pdfTokenCount),
413
+ );
414
+ return makeRejection(
415
+ filePath,
416
+ stat.size,
417
+ `PDF extraction too large (estimated ~${pdfTokenCount.toLocaleString()} tokens, limit ${MAX_OUTPUT_TOKENS.toLocaleString()}). ` +
418
+ `Narrow the \`pages\` range (try ~${suggestedPages} page${suggestedPages === 1 ? '' : 's'} per call).`,
419
+ );
420
+ }
421
+
422
+ readRegistry.markRead(filePath, { timestamp: stat.mtimeMs });
423
+
424
+ return {
425
+ content: [{ type: 'text', text: formatted }],
426
+ details: {
427
+ filePath,
428
+ totalLines: renderedLines.length,
429
+ byteSize: stat.size,
430
+ truncated: false,
431
+ truncatedLines: false,
432
+ truncatedChars: false,
433
+ startLine: 1,
434
+ },
435
+ };
436
+ }
437
+
438
+ // Gate 2: Full-read size gate - reject full reads of files > 256 KB
439
+ const hasExplicitRange = params.offset !== undefined || params.limit !== undefined;
440
+ if (!hasExplicitRange && stat.size > MAX_FULL_READ_BYTES) {
441
+ return makeRejection(
442
+ filePath,
443
+ stat.size,
444
+ `File is too large to read in full (${formatBytes(stat.size)}, limit ${formatBytes(MAX_FULL_READ_BYTES)}). Provide offset and limit to read a specific range, or use Grep to search for specific content.`,
445
+ );
446
+ }
447
+
448
+ // File-unchanged dedup: if we already read this exact range and the
449
+ // file hasn't changed on disk, return a stub. The earlier Read result
450
+ // is still in context, so re-sending wastes tokens.
451
+ const existingState = readRegistry.getState(filePath);
452
+ if (existingState && existingState.offset !== undefined) {
453
+ const rangeMatch =
454
+ existingState.offset === offset && existingState.limit === limit;
455
+ if (rangeMatch && stat.mtimeMs === existingState.timestamp) {
456
+ return {
457
+ content: [{ type: 'text', text: `[File unchanged since last read: ${filePath}]` }],
458
+ details: {
459
+ filePath,
460
+ totalLines: 0,
461
+ byteSize: stat.size,
462
+ truncated: false,
463
+ truncatedLines: false,
464
+ truncatedChars: false,
465
+ startLine: 1,
466
+ },
467
+ };
468
+ }
469
+ }
470
+
471
+ // Read the raw buffer
472
+ const buffer = await fs.promises.readFile(filePath);
473
+
474
+ // Binary detection (not image, not PDF)
475
+ if (isBinaryBuffer(buffer)) {
476
+ return {
477
+ content: [{ type: 'text', text: 'Binary file detected. Cannot display as text.' }],
478
+ details: {
479
+ filePath,
480
+ totalLines: 0,
481
+ byteSize: stat.size,
482
+ truncated: false,
483
+ truncatedLines: false,
484
+ truncatedChars: false,
485
+ startLine: 1,
486
+ },
487
+ };
488
+ }
489
+
490
+ // Decode and split into lines
491
+ const content = decodeFileContent(buffer);
492
+ const allLines = content.split('\n');
493
+ const totalLines = allLines.length;
494
+
495
+ // Handle empty file
496
+ if (totalLines === 0 || (totalLines === 1 && allLines[0] === '')) {
497
+ readRegistry.markRead(filePath, { timestamp: stat.mtimeMs, offset, limit });
498
+ return {
499
+ content: [{ type: 'text', text: `[File is empty: ${filePath}]` }],
500
+ details: {
501
+ filePath,
502
+ totalLines: 0,
503
+ byteSize: stat.size,
504
+ truncated: false,
505
+ truncatedLines: false,
506
+ truncatedChars: false,
507
+ startLine: 1,
508
+ },
509
+ };
510
+ }
511
+
512
+ // Apply offset and limit
513
+ const startIdx = Math.max(0, offset - 1); // Convert 1-based to 0-based
514
+ const endIdx = Math.min(totalLines, startIdx + limit);
515
+ const selectedLines = allLines.slice(startIdx, endIdx);
516
+
517
+ const truncatedLines = endIdx < totalLines;
518
+ const truncatedChars = selectedLines.some((line) => line.length > MAX_LINE_LENGTH);
519
+
520
+ // Format with line numbers
521
+ const formatted = formatWithLineNumbers(selectedLines, startIdx + 1);
522
+
523
+ // Gate 3: Post-read token estimation
524
+ const estimatedTokenCount = estimateTokens(formatted);
525
+ if (estimatedTokenCount > MAX_OUTPUT_TOKENS) {
526
+ const suggestedLimit = Math.floor(limit * MAX_OUTPUT_TOKENS / estimatedTokenCount);
527
+ return makeRejection(
528
+ filePath,
529
+ stat.size,
530
+ `Read result too large (estimated ~${estimatedTokenCount.toLocaleString()} tokens, limit ${MAX_OUTPUT_TOKENS.toLocaleString()}). ` +
531
+ `The file has ${totalLines} lines. Use a smaller limit (try limit: ${Math.max(1, suggestedLimit)}) ` +
532
+ `or use Grep to find the specific content you need.`,
533
+ );
534
+ }
535
+
536
+ // Only mark as read after passing all gates, so rejected reads
537
+ // can be retried without hitting the dedup stub.
538
+ // For full, non-truncated reads, record a content hash so the
539
+ // Edit/Write tools can distinguish real file modifications from
540
+ // mtime-only changes (formatters, cloud sync, antivirus, etc.).
541
+ const isFullRead = !hasExplicitRange && !truncatedLines;
542
+ const contentHash = isFullRead
543
+ ? crypto.createHash('sha256').update(buffer).digest('hex')
544
+ : undefined;
545
+ readRegistry.markRead(filePath, {
546
+ timestamp: stat.mtimeMs,
547
+ offset,
548
+ limit,
549
+ ...(contentHash !== undefined ? { contentHash } : {}),
550
+ });
551
+
552
+ let text = formatted;
553
+ if (truncatedLines) {
554
+ text += `\n\n[Showing lines ${startIdx + 1}-${endIdx} of ${totalLines} total. Use offset/limit to read more.]`;
555
+ }
556
+
557
+ return {
558
+ content: [{ type: 'text', text }],
559
+ details: {
560
+ filePath,
561
+ totalLines,
562
+ byteSize: stat.size,
563
+ truncated: truncatedLines || truncatedChars,
564
+ truncatedLines,
565
+ truncatedChars,
566
+ startLine: offset,
567
+ },
568
+ };
569
+ },
570
+ };
571
+
572
+ return attachRuntimeAwareTool(tool, {
573
+ toolKind: 'Read',
574
+ cloneForRuntime: (runtime) => createReadTool({
575
+ ...config,
576
+ runtime,
577
+ readRegistry: runtime.readRegistry,
578
+ }),
579
+ });
580
+ }