@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,510 @@
1
+ /**
2
+ * Observer module for the observational memory system.
3
+ *
4
+ * Handles extracting observations from raw conversation messages via a
5
+ * background LLM call. The observer watches the conversation and produces
6
+ * structured, timestamped observations that become the agent's sole
7
+ * memory of past interactions.
8
+ *
9
+ * References:
10
+ * - observational-memory-architecture.md (Observer System section)
11
+ * - compaction-strategy.md
12
+ */
13
+
14
+ import type { CompleteFn } from '../compaction.js';
15
+ import type { AgentMessage } from '../../context-manager.js';
16
+ import type { ObserverOutput } from './types.js';
17
+ import { OBSERVER_SYSTEM_PROMPT } from './constants.js';
18
+ import { estimateTokens } from '../../token-estimator.js';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Message Formatting
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Converts an array of AgentMessages into a formatted string for the
26
+ * observer LLM.
27
+ *
28
+ * Each message is formatted with role and positional timestamp. Messages
29
+ * with content arrays (tool calls/results) receive structured formatting
30
+ * so the observer can extract meaningful takeaways.
31
+ *
32
+ * Messages with timestamps are formatted with their actual date and time.
33
+ * Messages without timestamps fall back to sequential labels.
34
+ * Messages are grouped by date when timestamps are available.
35
+ */
36
+ export function formatMessagesForObserver(messages: AgentMessage[]): string {
37
+ if (messages.length === 0) return '';
38
+
39
+ const lines: string[] = [];
40
+ let currentDateHeader: string | null = null;
41
+
42
+ for (let i = 0; i < messages.length; i++) {
43
+ const msg = messages[i]!;
44
+ const roleLabel = msg.role;
45
+
46
+ // Build the timestamp label from message metadata
47
+ let label: string;
48
+ if (msg.timestamp !== 0) {
49
+ const date = new Date(msg.timestamp);
50
+ const dateHeader = formatDateHeader(date);
51
+
52
+ // Insert a date header when the date changes
53
+ if (dateHeader !== currentDateHeader) {
54
+ if (currentDateHeader !== null) {
55
+ lines.push(''); // blank line between date groups
56
+ }
57
+ lines.push(`Date: ${dateHeader}`);
58
+ currentDateHeader = dateHeader;
59
+ }
60
+
61
+ label = formatTime(date);
62
+ } else {
63
+ // No timestamp available; use a sequential fallback
64
+ if (currentDateHeader === null) {
65
+ lines.push('Date: (not specified)');
66
+ currentDateHeader = '(not specified)';
67
+ }
68
+ label = `Message ${i + 1}`;
69
+ }
70
+
71
+ if (typeof msg.content === 'string') {
72
+ lines.push(`**${roleLabel} (${label})**: ${msg.content}`);
73
+ } else if (Array.isArray(msg.content)) {
74
+ const parts = formatContentParts(msg.content);
75
+ if (parts) {
76
+ lines.push(`**${roleLabel} (${label})**:\n${parts}`);
77
+ } else {
78
+ lines.push(`**${roleLabel} (${label})**: [empty]`);
79
+ }
80
+ } else {
81
+ lines.push(`**${roleLabel} (${label})**: [empty]`);
82
+ }
83
+ }
84
+
85
+ return lines.join('\n\n');
86
+ }
87
+
88
+ /**
89
+ * Format a date for the observer date header.
90
+ * Example: "April 11, 2026"
91
+ */
92
+ function formatDateHeader(date: Date): string {
93
+ return date.toLocaleDateString('en-US', {
94
+ month: 'long',
95
+ day: 'numeric',
96
+ year: 'numeric',
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Format a time for a message label.
102
+ * Example: "2:30 PM"
103
+ */
104
+ function formatTime(date: Date): string {
105
+ return date.toLocaleTimeString('en-US', {
106
+ hour: 'numeric',
107
+ minute: '2-digit',
108
+ hour12: true,
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Format an array of content parts from a structured message.
114
+ *
115
+ * Handles text, tool_use, and tool_result part types. Other part types
116
+ * are rendered with their type label.
117
+ */
118
+ function formatContentParts(
119
+ parts: Array<{ type: string; text?: string; [key: string]: unknown }>,
120
+ ): string {
121
+ const formatted: string[] = [];
122
+
123
+ for (const part of parts) {
124
+ switch (part.type) {
125
+ case 'text': {
126
+ if (typeof part.text === 'string' && part.text.length > 0) {
127
+ formatted.push(part.text);
128
+ }
129
+ break;
130
+ }
131
+
132
+ case 'tool_use': {
133
+ const toolName = typeof part['name'] === 'string' ? part['name'] : 'unknown';
134
+ const argsSummary = summarizeToolArgs(part['input']);
135
+ formatted.push(`[Tool Call: ${toolName}] ${argsSummary}`);
136
+ break;
137
+ }
138
+
139
+ case 'tool_result': {
140
+ const toolUseId = typeof part['tool_use_id'] === 'string'
141
+ ? part['tool_use_id']
142
+ : undefined;
143
+ // Resolve tool name: check for a name field on the part, fall back to tool_use_id reference
144
+ const resultToolName = typeof part['name'] === 'string'
145
+ ? part['name']
146
+ : undefined;
147
+
148
+ const header = resultToolName
149
+ ? `[Tool Result: ${resultToolName}]`
150
+ : toolUseId
151
+ ? `[Tool Result (ref: ${toolUseId})]`
152
+ : '[Tool Result]';
153
+
154
+ const content = extractToolResultContent(part);
155
+ if (content) {
156
+ formatted.push(`${header}\n${content}`);
157
+ } else {
158
+ formatted.push(header);
159
+ }
160
+ break;
161
+ }
162
+
163
+ default: {
164
+ // Unknown part type: include type label and any text
165
+ if (typeof part.text === 'string' && part.text.length > 0) {
166
+ formatted.push(`[${part.type}]: ${part.text}`);
167
+ }
168
+ break;
169
+ }
170
+ }
171
+ }
172
+
173
+ return formatted.join('\n');
174
+ }
175
+
176
+ /**
177
+ * Summarize tool call arguments into a brief string.
178
+ *
179
+ * Produces a compact summary of the key arguments without reproducing
180
+ * large values verbatim.
181
+ */
182
+ function summarizeToolArgs(input: unknown): string {
183
+ if (input === null || input === undefined) return '';
184
+ if (typeof input === 'string') return `{${truncateValue(input, 100)}}`;
185
+
186
+ if (typeof input === 'object' && !Array.isArray(input)) {
187
+ const obj = input as Record<string, unknown>;
188
+ const entries = Object.entries(obj);
189
+ if (entries.length === 0) return '{}';
190
+
191
+ const parts = entries.slice(0, 5).map(([key, value]) => {
192
+ return `${key}: ${truncateValue(String(value), 80)}`;
193
+ });
194
+ const suffix = entries.length > 5 ? `, ... (+${entries.length - 5} more)` : '';
195
+ return `{${parts.join(', ')}${suffix}}`;
196
+ }
197
+
198
+ return String(input);
199
+ }
200
+
201
+ /**
202
+ * Extract text content from a tool_result part.
203
+ */
204
+ function extractToolResultContent(
205
+ part: { type: string; text?: string; [key: string]: unknown },
206
+ ): string {
207
+ // Direct text field
208
+ if (typeof part.text === 'string' && part.text.length > 0) {
209
+ return part.text;
210
+ }
211
+
212
+ // Content may be nested in a 'content' field (some tool result formats)
213
+ const nested = part['content'];
214
+ if (typeof nested === 'string' && nested.length > 0) {
215
+ return nested;
216
+ }
217
+
218
+ if (Array.isArray(nested)) {
219
+ const textParts = nested
220
+ .filter((p): p is { type: string; text: string } =>
221
+ typeof p === 'object' && p !== null && typeof p.text === 'string',
222
+ )
223
+ .map(p => p.text);
224
+ if (textParts.length > 0) return textParts.join('\n');
225
+ }
226
+
227
+ return '';
228
+ }
229
+
230
+ /**
231
+ * Truncate a string value for argument summaries.
232
+ */
233
+ function truncateValue(value: string, maxLen: number): string {
234
+ if (value.length <= maxLen) return value;
235
+ return value.slice(0, maxLen) + '...';
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Output Parsing
240
+ // ---------------------------------------------------------------------------
241
+
242
+ /**
243
+ * Parse the raw LLM output from the observer.
244
+ *
245
+ * Extracts content from `<observations>`, `<current-task>`, and
246
+ * `<suggested-response>` XML blocks. Uses simple string matching
247
+ * (not a DOM parser) and is lenient with malformed output.
248
+ *
249
+ * If no `<observations>` tag is found, the entire output is used as
250
+ * observations (graceful fallback).
251
+ */
252
+ export function parseObserverOutput(raw: string): ObserverOutput {
253
+ const observations = extractTagContent(raw, 'observations') ?? raw.trim();
254
+ const rawTask = extractTagContent(raw, 'current-task');
255
+ const rawSuggested = extractTagContent(raw, 'suggested-response');
256
+
257
+ const result: ObserverOutput = {
258
+ observations: observations.trim(),
259
+ };
260
+
261
+ const trimmedTask = rawTask?.trim();
262
+ if (trimmedTask && hasMeaningfulContent(trimmedTask)) {
263
+ result.currentTask = trimmedTask;
264
+ }
265
+
266
+ const trimmedSuggested = rawSuggested?.trim();
267
+ if (trimmedSuggested && hasMeaningfulContent(trimmedSuggested)) {
268
+ result.suggestedResponse = trimmedSuggested;
269
+ }
270
+
271
+ return result;
272
+ }
273
+
274
+ /**
275
+ * Check whether extracted tag content is meaningful (not just placeholder
276
+ * labels echoed from the prompt template).
277
+ *
278
+ * The observer prompt shows a structure like "- Primary:" and "- Secondary:"
279
+ * as a template. Some model outputs echo the template without filling in
280
+ * real content. This check rejects such outputs so the caller can fall back
281
+ * to prior hints or mark the field as missing.
282
+ */
283
+ function hasMeaningfulContent(value: string): boolean {
284
+ const contentOnly = value
285
+ .split('\n')
286
+ .map(line =>
287
+ line
288
+ .replace(/^\s*[-*]\s*/, '')
289
+ .replace(/^Primary\s*:\s*/i, '')
290
+ .replace(/^Secondary\s*:\s*/i, '')
291
+ .trim(),
292
+ )
293
+ .filter(line => line.length > 0)
294
+ .join(' ')
295
+ .trim();
296
+
297
+ return contentOnly.length >= 8;
298
+ }
299
+
300
+ /**
301
+ * Extract content between XML-style tags.
302
+ *
303
+ * Lenient: handles whitespace around tags, multiline content, and
304
+ * nested content. Returns null if the tag pair is not found.
305
+ */
306
+ function extractTagContent(text: string, tagName: string): string | null {
307
+ const openPattern = new RegExp(`<${tagName}>`, 'i');
308
+ const closePattern = new RegExp(`</${tagName}>`, 'i');
309
+
310
+ const openMatch = openPattern.exec(text);
311
+ if (!openMatch) return null;
312
+
313
+ const startIndex = openMatch.index + openMatch[0].length;
314
+ const closeMatch = closePattern.exec(text.slice(startIndex));
315
+
316
+ if (!closeMatch) {
317
+ // Opening tag found but no closing tag: return everything after the opening tag
318
+ return text.slice(startIndex);
319
+ }
320
+
321
+ return text.slice(startIndex, startIndex + closeMatch.index);
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Prompt Building
326
+ // ---------------------------------------------------------------------------
327
+
328
+ /**
329
+ * Build the full observer system prompt.
330
+ *
331
+ * Starts with `OBSERVER_SYSTEM_PROMPT` from constants and optionally
332
+ * appends consumer-provided custom instructions.
333
+ *
334
+ * @param previousObservations - Previous observations for context (unused in system prompt, kept for signature consistency)
335
+ * @param previousObserverTokens - Token budget for previous observations (unused in system prompt)
336
+ * @param customInstruction - Optional consumer-provided instruction to append
337
+ * @returns The complete system prompt string
338
+ */
339
+ export function buildObserverPrompt(
340
+ previousObservations: string | null,
341
+ previousObserverTokens: number,
342
+ customInstruction?: string,
343
+ ): string {
344
+ let prompt = OBSERVER_SYSTEM_PROMPT;
345
+
346
+ if (customInstruction) {
347
+ prompt += `\n\n## Additional Instructions\n\n${customInstruction}`;
348
+ }
349
+
350
+ return prompt;
351
+ }
352
+
353
+ /**
354
+ * Build the message array to send to the observer LLM.
355
+ *
356
+ * Constructs a sequence of user messages:
357
+ * 1. (Optional) Previous observations for deduplication context
358
+ * 2. The formatted message history
359
+ * 3. A task instruction to extract observations
360
+ *
361
+ * @param messages - The conversation messages to observe
362
+ * @param previousObservations - Previous observations to prevent duplication
363
+ * @param previousObserverTokens - Token budget for the previous observations context
364
+ * @returns Array of message objects for the LLM call
365
+ */
366
+ export function buildObserverMessages(
367
+ messages: AgentMessage[],
368
+ previousObservations: string | null,
369
+ previousObserverTokens: number,
370
+ ): unknown[] {
371
+ const result: Array<{ role: string; content: string }> = [];
372
+
373
+ // 1. Previous observations context (if available)
374
+ if (previousObservations && previousObservations.trim().length > 0) {
375
+ const preamble =
376
+ 'These are the observations that have already been captured. Do not repeat them. Only add NEW observations from the messages below.\n\n';
377
+ const truncated = tailTruncateToTokenBudget(previousObservations, previousObserverTokens);
378
+ result.push({ role: 'user', content: preamble + truncated });
379
+ }
380
+
381
+ // 2. Formatted message history (timestamps from message metadata)
382
+ const formatted = formatMessagesForObserver(messages);
383
+ result.push({ role: 'user', content: formatted });
384
+
385
+ // 3. Task instruction
386
+ result.push({
387
+ role: 'user',
388
+ content: 'Extract observations from the message history above. Follow the output format exactly.',
389
+ });
390
+
391
+ return result;
392
+ }
393
+
394
+ /**
395
+ * Tail-truncate a string to fit within a token budget.
396
+ *
397
+ * Keeps the end of the string (most recent observations) and trims
398
+ * from the beginning if the string exceeds the budget.
399
+ */
400
+ function tailTruncateToTokenBudget(text: string, tokenBudget: number): string {
401
+ const currentTokens = estimateTokens(text);
402
+ if (currentTokens <= tokenBudget) return text;
403
+
404
+ // Estimate chars to keep (tokens * 4 chars/token approximation)
405
+ const charsToKeep = tokenBudget * 4;
406
+ if (charsToKeep >= text.length) return text;
407
+
408
+ const truncated = text.slice(text.length - charsToKeep);
409
+
410
+ // Try to start at a line boundary for cleaner output
411
+ const firstNewline = truncated.indexOf('\n');
412
+ if (firstNewline > 0 && firstNewline < truncated.length * 0.2) {
413
+ return '[...truncated...]\n' + truncated.slice(firstNewline + 1);
414
+ }
415
+
416
+ return '[...truncated...]\n' + truncated;
417
+ }
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // Degenerate Repetition Detection
421
+ // ---------------------------------------------------------------------------
422
+
423
+ /**
424
+ * Check if the output contains degenerate repetition.
425
+ *
426
+ * Detects a line appearing 5+ times consecutively, which is a known
427
+ * failure mode of LLMs under certain conditions (e.g., high temperature,
428
+ * long context, repetitive input patterns).
429
+ *
430
+ * @param text - The text to check for repetition
431
+ * @returns true if degenerate repetition is detected
432
+ */
433
+ export function detectDegenerateRepetition(text: string): boolean {
434
+ const lines = text.split('\n');
435
+ if (lines.length < 5) return false;
436
+
437
+ let consecutiveCount = 1;
438
+ let previousLine = lines[0]!;
439
+
440
+ for (let i = 1; i < lines.length; i++) {
441
+ const currentLine = lines[i]!;
442
+ // Skip empty lines for comparison
443
+ if (currentLine.trim() === '' && previousLine.trim() === '') {
444
+ continue;
445
+ }
446
+
447
+ if (currentLine === previousLine && currentLine.trim().length > 0) {
448
+ consecutiveCount++;
449
+ if (consecutiveCount >= 5) return true;
450
+ } else {
451
+ consecutiveCount = 1;
452
+ previousLine = currentLine;
453
+ }
454
+ }
455
+
456
+ return false;
457
+ }
458
+
459
+ // ---------------------------------------------------------------------------
460
+ // Runner
461
+ // ---------------------------------------------------------------------------
462
+
463
+ /**
464
+ * Run the observer LLM call to extract observations from messages.
465
+ *
466
+ * Orchestrates the full observer pipeline:
467
+ * 1. Builds the system prompt (with optional custom instructions)
468
+ * 2. Builds the message array (with previous observations context)
469
+ * 3. Calls the LLM via `complete`
470
+ * 4. Parses the structured output
471
+ * 5. Detects and retries on degenerate repetition (once)
472
+ *
473
+ * @param complete - The LLM completion function
474
+ * @param messages - The conversation messages to observe
475
+ * @param previousObservations - Previous observations for deduplication
476
+ * @param config - Observer configuration
477
+ * @returns Parsed observer output with observations, current task, and suggested response
478
+ */
479
+ export async function runObserver(
480
+ complete: CompleteFn,
481
+ messages: AgentMessage[],
482
+ previousObservations: string | null,
483
+ config: {
484
+ previousObserverTokens: number;
485
+ observerInstruction?: string;
486
+ },
487
+ ): Promise<ObserverOutput> {
488
+ const systemPrompt = buildObserverPrompt(
489
+ previousObservations,
490
+ config.previousObserverTokens,
491
+ config.observerInstruction,
492
+ );
493
+
494
+ const observerMessages = buildObserverMessages(
495
+ messages,
496
+ previousObservations,
497
+ config.previousObserverTokens,
498
+ );
499
+
500
+ const raw = await complete({ systemPrompt, messages: observerMessages });
501
+ const output = parseObserverOutput(raw);
502
+
503
+ // Detect degenerate repetition and retry once
504
+ if (detectDegenerateRepetition(output.observations)) {
505
+ const retryRaw = await complete({ systemPrompt, messages: observerMessages });
506
+ return parseObserverOutput(retryRaw);
507
+ }
508
+
509
+ return output;
510
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Recall tool: search through past conversation history.
3
+ *
4
+ * The consumer provides a search function via RecallConfig. This module
5
+ * wraps it into a CortexTool that the agent can invoke to retrieve
6
+ * specific details from persisted messages. Observations include
7
+ * timestamps, enabling temporal anchoring for precise recall queries.
8
+ *
9
+ * Reference: docs/cortex/observational-memory-architecture.md (Recall Tool)
10
+ */
11
+
12
+ import { Type } from 'typebox';
13
+ import type { CortexTool } from '../../tool-contract.js';
14
+ import type { RecallConfig, RecallResult } from './types.js';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Schema
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const RecallParams = Type.Object({
21
+ query: Type.String({
22
+ description: 'What to search for in past conversation history',
23
+ }),
24
+ timeRange: Type.Optional(
25
+ Type.Object(
26
+ {
27
+ start: Type.Optional(
28
+ Type.String({ description: 'ISO date string for range start' }),
29
+ ),
30
+ end: Type.Optional(
31
+ Type.String({ description: 'ISO date string for range end' }),
32
+ ),
33
+ },
34
+ {
35
+ description:
36
+ 'Optional time range to narrow results. Use timestamps from your observations.',
37
+ },
38
+ ),
39
+ ),
40
+ });
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Constants
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /** Maximum number of results to include in the formatted output. */
47
+ const MAX_RESULTS = 10;
48
+
49
+ /** Maximum character length per individual result content. */
50
+ const MAX_RESULT_CHARS = 2_000;
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Formatting
54
+ // ---------------------------------------------------------------------------
55
+
56
+ function formatResult(result: RecallResult): string {
57
+ const role = result.role ?? result.type;
58
+ const timestamp = result.timestamp.toISOString();
59
+ const content =
60
+ result.content.length > MAX_RESULT_CHARS
61
+ ? result.content.slice(0, MAX_RESULT_CHARS) + '... (truncated)'
62
+ : result.content;
63
+
64
+ return `[${role}] (${timestamp}): ${content}`;
65
+ }
66
+
67
+ function formatResults(results: RecallResult[]): string {
68
+ if (results.length === 0) {
69
+ return 'No results found for your query.';
70
+ }
71
+
72
+ const limited = results.slice(0, MAX_RESULTS);
73
+ const lines = limited.map(formatResult);
74
+
75
+ if (results.length > MAX_RESULTS) {
76
+ lines.push(
77
+ `\n(${results.length - MAX_RESULTS} additional results omitted)`,
78
+ );
79
+ }
80
+
81
+ return lines.join('\n\n');
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Tool factory
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /**
89
+ * Create a CortexTool that wraps the consumer's recall search function.
90
+ *
91
+ * The returned tool lets the agent search through persisted conversation
92
+ * history using a free-text query and optional time range derived from
93
+ * observation timestamps.
94
+ */
95
+ export function createRecallTool(recallConfig: RecallConfig): CortexTool<{
96
+ query: string;
97
+ timeRange?: { start?: string; end?: string };
98
+ }, string> {
99
+ return {
100
+ name: 'recall',
101
+ description:
102
+ 'Search through past conversation history for specific details. ' +
103
+ 'Use when your observations mention something but lack the detail needed, ' +
104
+ 'or when you need exact content (code, errors, quotes, URLs). ' +
105
+ 'Include a timeRange from your observation timestamps for precision.',
106
+ parameters: RecallParams,
107
+
108
+ async execute(params): Promise<string> {
109
+ const { query, timeRange } = params;
110
+
111
+ // Build the time range object without assigning undefined to optional
112
+ // properties (exactOptionalPropertyTypes is enabled).
113
+ let options: { timeRange?: { start?: Date; end?: Date } } | undefined;
114
+ if (timeRange) {
115
+ const range: { start?: Date; end?: Date } = {};
116
+ if (timeRange.start) {
117
+ range.start = new Date(timeRange.start);
118
+ }
119
+ if (timeRange.end) {
120
+ range.end = new Date(timeRange.end);
121
+ }
122
+ options = { timeRange: range };
123
+ }
124
+
125
+ const results = await recallConfig.search(query, options);
126
+
127
+ return formatResults(results);
128
+ },
129
+ };
130
+ }