@metabob/minibob 0.1.2

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 (174) hide show
  1. package/ARCHITECTURE.md +255 -0
  2. package/CHANGELOG.md +112 -0
  3. package/README.md +380 -0
  4. package/bin/minibob.js +36 -0
  5. package/dist/acp-gossip.d.ts +72 -0
  6. package/dist/acp-gossip.d.ts.map +1 -0
  7. package/dist/acp-gossip.js +156 -0
  8. package/dist/acp-gossip.js.map +1 -0
  9. package/dist/acp.d.ts +62 -0
  10. package/dist/acp.d.ts.map +1 -0
  11. package/dist/acp.js +292 -0
  12. package/dist/acp.js.map +1 -0
  13. package/dist/activity.d.ts +157 -0
  14. package/dist/activity.d.ts.map +1 -0
  15. package/dist/activity.js +518 -0
  16. package/dist/activity.js.map +1 -0
  17. package/dist/agent-runtime.d.ts +104 -0
  18. package/dist/agent-runtime.d.ts.map +1 -0
  19. package/dist/boredom.d.ts +125 -0
  20. package/dist/boredom.d.ts.map +1 -0
  21. package/dist/boredom.js +244 -0
  22. package/dist/boredom.js.map +1 -0
  23. package/dist/cli/acp-server.d.ts +23 -0
  24. package/dist/cli/acp-server.d.ts.map +1 -0
  25. package/dist/cli/burrow.d.ts +26 -0
  26. package/dist/cli/burrow.d.ts.map +1 -0
  27. package/dist/cli/doctor.d.ts +22 -0
  28. package/dist/cli/doctor.d.ts.map +1 -0
  29. package/dist/cli/goal.d.ts +22 -0
  30. package/dist/cli/goal.d.ts.map +1 -0
  31. package/dist/cli/index.d.ts +47 -0
  32. package/dist/cli/index.d.ts.map +1 -0
  33. package/dist/cli/instance-registry.d.ts +78 -0
  34. package/dist/cli/instance-registry.d.ts.map +1 -0
  35. package/dist/cli/observe.d.ts +35 -0
  36. package/dist/cli/observe.d.ts.map +1 -0
  37. package/dist/cli/vessel.d.ts +14 -0
  38. package/dist/cli/vessel.d.ts.map +1 -0
  39. package/dist/composition-observer.d.ts +96 -0
  40. package/dist/composition-observer.d.ts.map +1 -0
  41. package/dist/config.d.ts +36 -0
  42. package/dist/config.d.ts.map +1 -0
  43. package/dist/config.js +128 -0
  44. package/dist/config.js.map +1 -0
  45. package/dist/docker/Dockerfile +35 -0
  46. package/dist/environment.d.ts +72 -0
  47. package/dist/environment.d.ts.map +1 -0
  48. package/dist/environment.js +142 -0
  49. package/dist/environment.js.map +1 -0
  50. package/dist/goal-processor.d.ts +165 -0
  51. package/dist/goal-processor.d.ts.map +1 -0
  52. package/dist/helm/minibob-cluster/Chart.yaml +13 -0
  53. package/dist/helm/minibob-cluster/templates/_helpers.tpl +60 -0
  54. package/dist/helm/minibob-cluster/templates/configmap.yaml +11 -0
  55. package/dist/helm/minibob-cluster/templates/deployment.yaml +108 -0
  56. package/dist/helm/minibob-cluster/templates/secret.yaml +10 -0
  57. package/dist/helm/minibob-cluster/templates/service.yaml +37 -0
  58. package/dist/helm/minibob-cluster/values-local.yaml +41 -0
  59. package/dist/helm/minibob-cluster/values-production.yaml +57 -0
  60. package/dist/helm/minibob-cluster/values-testing-cluster.yaml +43 -0
  61. package/dist/helm/minibob-cluster/values.yaml +127 -0
  62. package/dist/improviser.d.ts +74 -0
  63. package/dist/improviser.d.ts.map +1 -0
  64. package/dist/impulse-filter.d.ts +74 -0
  65. package/dist/impulse-filter.d.ts.map +1 -0
  66. package/dist/impulse.d.ts +92 -0
  67. package/dist/impulse.d.ts.map +1 -0
  68. package/dist/impulse.js +234 -0
  69. package/dist/impulse.js.map +1 -0
  70. package/dist/lib.d.ts +29 -0
  71. package/dist/lib.d.ts.map +1 -0
  72. package/dist/lib.js +18561 -0
  73. package/dist/lib.js.map +98 -0
  74. package/dist/lifecycle-hooks.d.ts +99 -0
  75. package/dist/lifecycle-hooks.d.ts.map +1 -0
  76. package/dist/lifecycle-hooks.js +135 -0
  77. package/dist/lifecycle-hooks.js.map +1 -0
  78. package/dist/llm.d.ts +31 -0
  79. package/dist/llm.d.ts.map +1 -0
  80. package/dist/llm.js +349 -0
  81. package/dist/llm.js.map +1 -0
  82. package/dist/mcp-activity-bridge.d.ts +66 -0
  83. package/dist/mcp-activity-bridge.d.ts.map +1 -0
  84. package/dist/mcp-activity-bridge.js +126 -0
  85. package/dist/mcp-activity-bridge.js.map +1 -0
  86. package/dist/mcp.d.ts +216 -0
  87. package/dist/mcp.d.ts.map +1 -0
  88. package/dist/mcp.js +292 -0
  89. package/dist/mcp.js.map +1 -0
  90. package/dist/memory-agent.d.ts +92 -0
  91. package/dist/memory-agent.d.ts.map +1 -0
  92. package/dist/memory-agent.js +277 -0
  93. package/dist/memory-agent.js.map +1 -0
  94. package/dist/runtime-mapping.d.ts +97 -0
  95. package/dist/runtime-mapping.d.ts.map +1 -0
  96. package/dist/search-first-executor.d.ts +113 -0
  97. package/dist/search-first-executor.d.ts.map +1 -0
  98. package/dist/session.d.ts +48 -0
  99. package/dist/session.d.ts.map +1 -0
  100. package/dist/template-extractor.d.ts +9 -0
  101. package/dist/template-extractor.d.ts.map +1 -0
  102. package/dist/template-generator.d.ts +12 -0
  103. package/dist/template-generator.d.ts.map +1 -0
  104. package/dist/tools.d.ts +58 -0
  105. package/dist/tools.d.ts.map +1 -0
  106. package/dist/tools.js +771 -0
  107. package/dist/tools.js.map +1 -0
  108. package/dist/types.d.ts +503 -0
  109. package/dist/types.d.ts.map +1 -0
  110. package/dist/types.js +8 -0
  111. package/dist/types.js.map +1 -0
  112. package/dist/understanding/analyzer.d.ts +55 -0
  113. package/dist/understanding/analyzer.d.ts.map +1 -0
  114. package/dist/understanding/explorer.d.ts +73 -0
  115. package/dist/understanding/explorer.d.ts.map +1 -0
  116. package/dist/understanding/index.d.ts +7 -0
  117. package/dist/understanding/index.d.ts.map +1 -0
  118. package/dist/understanding/types.d.ts +136 -0
  119. package/dist/understanding/types.d.ts.map +1 -0
  120. package/dist/validation.d.ts +29 -0
  121. package/dist/validation.d.ts.map +1 -0
  122. package/dist/validation.js +106 -0
  123. package/dist/validation.js.map +1 -0
  124. package/dist/vessel-bootstrap.d.ts +190 -0
  125. package/dist/vessel-bootstrap.d.ts.map +1 -0
  126. package/dist/vessel-registry.d.ts +229 -0
  127. package/dist/vessel-registry.d.ts.map +1 -0
  128. package/index.ts +1329 -0
  129. package/package.json +54 -0
  130. package/src/acp-gossip.ts +193 -0
  131. package/src/acp.ts +362 -0
  132. package/src/activity.ts +1464 -0
  133. package/src/agent-runtime.ts +365 -0
  134. package/src/boredom.ts +423 -0
  135. package/src/cli/acp-server.ts +377 -0
  136. package/src/cli/burrow.ts +896 -0
  137. package/src/cli/doctor.ts +526 -0
  138. package/src/cli/goal.ts +224 -0
  139. package/src/cli/index.ts +147 -0
  140. package/src/cli/instance-registry.ts +271 -0
  141. package/src/cli/observe.ts +682 -0
  142. package/src/cli/vessel.ts +287 -0
  143. package/src/components/SystemOverview.tsx +331 -0
  144. package/src/composition-observer.ts +449 -0
  145. package/src/config.ts +172 -0
  146. package/src/environment.ts +167 -0
  147. package/src/goal-processor.ts +654 -0
  148. package/src/improviser.ts +591 -0
  149. package/src/impulse-filter.ts +273 -0
  150. package/src/impulse.ts +311 -0
  151. package/src/lib.ts +147 -0
  152. package/src/lifecycle-hooks.ts +181 -0
  153. package/src/llm.ts +434 -0
  154. package/src/mcp-activity-bridge.ts +158 -0
  155. package/src/mcp.ts +747 -0
  156. package/src/memory-agent.ts +316 -0
  157. package/src/runtime-mapping.ts +527 -0
  158. package/src/search-first-executor.ts +666 -0
  159. package/src/session.ts +141 -0
  160. package/src/template-extractor.ts +256 -0
  161. package/src/template-generator.ts +130 -0
  162. package/src/tools.ts +924 -0
  163. package/src/types.ts +497 -0
  164. package/src/understanding/analyzer.ts +354 -0
  165. package/src/understanding/explorer.ts +488 -0
  166. package/src/understanding/index.ts +27 -0
  167. package/src/understanding/types.ts +153 -0
  168. package/src/validation.ts +125 -0
  169. package/src/vessel-bootstrap.ts +440 -0
  170. package/src/vessel-registry.ts +621 -0
  171. package/templates/core/edit-file.json +85 -0
  172. package/templates/understanding/diagnose-problem.json +32 -0
  173. package/templates/understanding/explore-codebase-v2.json +57 -0
  174. package/templates/understanding/explore-codebase.json +37 -0
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Impulse Filtering Logic (Phase 1.8)
3
+ *
4
+ * Filters impulses based on learned relevance scores to reduce token usage.
5
+ *
6
+ * Key concepts:
7
+ * - relevance_score: P(success | impulse loaded)
8
+ * - irrelevance_score: P(success | impulse NOT loaded)
9
+ *
10
+ * Decision rules:
11
+ * 1. Always load if relevance_score > 0.8 (strong positive signal)
12
+ * 2. Load if relevance_score > threshold (default: 0.5)
13
+ * 3. Skip if irrelevance_score > relevance_score (better without it)
14
+ * 4. Limit to maxImpulses (default: 10)
15
+ */
16
+
17
+ import type { ImpulseRelevanceMetric } from "./mcp"
18
+
19
+ // =============================================================================
20
+ // CONFIGURATION
21
+ // =============================================================================
22
+
23
+ export interface FilterConfig {
24
+ // Threshold for loading impulses (default: 0.5)
25
+ relevanceThreshold: number
26
+
27
+ // Always load impulses with score above this (default: 0.8)
28
+ alwaysLoadThreshold: number
29
+
30
+ // Maximum impulses to load (default: 10)
31
+ maxImpulses: number
32
+
33
+ // Fallback behavior when no metrics available
34
+ fallbackBehavior: 'load-all' | 'load-none' | 'load-top-n'
35
+ }
36
+
37
+ /**
38
+ * Get filter configuration from environment variables
39
+ */
40
+ export function getFilterConfig(): FilterConfig {
41
+ const relevanceThreshold = parseFloat(process.env.IMPULSE_RELEVANCE_THRESHOLD || '0.5')
42
+ const alwaysLoadThreshold = parseFloat(process.env.IMPULSE_ALWAYS_LOAD_THRESHOLD || '0.8')
43
+ const maxImpulses = parseInt(process.env.IMPULSE_MAX_LOAD || '10', 10)
44
+ const fallbackBehavior = (process.env.IMPULSE_FALLBACK_BEHAVIOR || 'load-all') as FilterConfig['fallbackBehavior']
45
+
46
+ return {
47
+ relevanceThreshold: isNaN(relevanceThreshold) ? 0.5 : relevanceThreshold,
48
+ alwaysLoadThreshold: isNaN(alwaysLoadThreshold) ? 0.8 : alwaysLoadThreshold,
49
+ maxImpulses: isNaN(maxImpulses) ? 10 : maxImpulses,
50
+ fallbackBehavior: ['load-all', 'load-none', 'load-top-n'].includes(fallbackBehavior)
51
+ ? fallbackBehavior
52
+ : 'load-all',
53
+ }
54
+ }
55
+
56
+ export const DEFAULT_FILTER_CONFIG: FilterConfig = {
57
+ relevanceThreshold: 0.5,
58
+ alwaysLoadThreshold: 0.8,
59
+ maxImpulses: 10,
60
+ fallbackBehavior: 'load-all', // Conservative: load all if no data
61
+ }
62
+
63
+ // =============================================================================
64
+ // FILTERING LOGIC
65
+ // =============================================================================
66
+
67
+ export interface FilterResult {
68
+ toLoad: string[]
69
+ toSkip: string[]
70
+ reasoning: Record<string, string>
71
+ }
72
+
73
+ /**
74
+ * Filter impulses based on relevance scores
75
+ */
76
+ export function filterImpulsesByRelevance(
77
+ impulseIds: string[],
78
+ metrics: ImpulseRelevanceMetric[],
79
+ config: Partial<FilterConfig> = {}
80
+ ): FilterResult {
81
+ // Merge environment config with provided config (provided config takes precedence)
82
+ const envConfig = getFilterConfig()
83
+ const cfg = { ...envConfig, ...config }
84
+
85
+ // Build metric map for fast lookup
86
+ const metricMap = new Map<string, ImpulseRelevanceMetric>()
87
+ for (const metric of metrics) {
88
+ metricMap.set(metric.impulse_id, metric)
89
+ }
90
+
91
+ const toLoad: string[] = []
92
+ const toSkip: string[] = []
93
+ const reasoning: Record<string, string> = {}
94
+
95
+ for (const impulseId of impulseIds) {
96
+ const metric = metricMap.get(impulseId)
97
+
98
+ if (!metric) {
99
+ // No metrics available - use fallback
100
+ if (cfg.fallbackBehavior === 'load-all') {
101
+ toLoad.push(impulseId)
102
+ reasoning[impulseId] = 'No metrics available (fallback: load)'
103
+ } else if (cfg.fallbackBehavior === 'load-none') {
104
+ toSkip.push(impulseId)
105
+ reasoning[impulseId] = 'No metrics available (fallback: skip)'
106
+ } else {
107
+ // load-top-n: will be handled after sorting
108
+ toLoad.push(impulseId)
109
+ reasoning[impulseId] = 'No metrics available (fallback: top-n)'
110
+ }
111
+ continue
112
+ }
113
+
114
+ // Decision logic based on learned scores
115
+ if (metric.relevance_score >= cfg.alwaysLoadThreshold) {
116
+ // Strong positive signal: always load
117
+ toLoad.push(impulseId)
118
+ reasoning[impulseId] = `High relevance (${metric.relevance_score.toFixed(2)})`
119
+ } else if (metric.irrelevance_score > metric.relevance_score) {
120
+ // Activity succeeds MORE often WITHOUT this impulse: skip (even if above threshold)
121
+ toSkip.push(impulseId)
122
+ reasoning[impulseId] = `More successful without it (irrelevance=${metric.irrelevance_score.toFixed(2)} vs relevance=${metric.relevance_score.toFixed(2)})`
123
+ } else if (metric.relevance_score >= cfg.relevanceThreshold) {
124
+ // Above threshold and relevance > irrelevance: load
125
+ toLoad.push(impulseId)
126
+ reasoning[impulseId] = `Relevant (${metric.relevance_score.toFixed(2)})`
127
+ } else {
128
+ // Below threshold: skip
129
+ toSkip.push(impulseId)
130
+ reasoning[impulseId] = `Low relevance (${metric.relevance_score.toFixed(2)})`
131
+ }
132
+ }
133
+
134
+ // Enforce max impulses limit
135
+ if (toLoad.length > cfg.maxImpulses) {
136
+ // Sort by relevance score descending
137
+ const loadWithScores = toLoad.map(id => ({
138
+ id,
139
+ score: metricMap.get(id)?.relevance_score || 0,
140
+ }))
141
+
142
+ loadWithScores.sort((a, b) => b.score - a.score)
143
+
144
+ const keptIds = loadWithScores.slice(0, cfg.maxImpulses).map(x => x.id)
145
+ const droppedIds = loadWithScores.slice(cfg.maxImpulses).map(x => x.id)
146
+
147
+ for (const id of droppedIds) {
148
+ toSkip.push(id)
149
+ reasoning[id] = `Dropped (exceeded max ${cfg.maxImpulses})`
150
+ }
151
+
152
+ return {
153
+ toLoad: keptIds,
154
+ toSkip,
155
+ reasoning,
156
+ }
157
+ }
158
+
159
+ return { toLoad, toSkip, reasoning }
160
+ }
161
+
162
+ // =============================================================================
163
+ // SAVINGS CALCULATION
164
+ // =============================================================================
165
+
166
+ export interface TokenSavings {
167
+ tokensSaved: number
168
+ costSaved: number // USD
169
+ percentSaved: number
170
+ totalTokens: number
171
+ loadedTokens: number
172
+ }
173
+
174
+ /**
175
+ * Calculate token savings from skipped impulses
176
+ */
177
+ export function calculateSavings(
178
+ skippedImpulses: string[],
179
+ impulseTokenSizes: Map<string, number>
180
+ ): TokenSavings {
181
+ const totalTokens = Array.from(impulseTokenSizes.values()).reduce((sum, t) => sum + t, 0)
182
+ const skippedTokens = skippedImpulses
183
+ .map(id => impulseTokenSizes.get(id) || 0)
184
+ .reduce((sum, t) => sum + t, 0)
185
+
186
+ const loadedTokens = totalTokens - skippedTokens
187
+ const percentSaved = totalTokens > 0 ? (skippedTokens / totalTokens) * 100 : 0
188
+
189
+ // Approximate cost: $3 per 1M input tokens (Claude pricing)
190
+ const costSaved = (skippedTokens / 1_000_000) * 3
191
+
192
+ return {
193
+ tokensSaved: skippedTokens,
194
+ costSaved,
195
+ percentSaved,
196
+ totalTokens,
197
+ loadedTokens,
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Estimate token size for an impulse based on pointer type
203
+ */
204
+ export function estimateImpulseTokens(impulse: {
205
+ id: string
206
+ pointer: {
207
+ type: string
208
+ [key: string]: any
209
+ }
210
+ }): number {
211
+ const pointerType = impulse.pointer.type
212
+
213
+ // Rough estimates based on typical sizes
214
+ switch (pointerType) {
215
+ case 'file':
216
+ return 2000 // Average file: ~2k tokens
217
+ case 'activityOutput':
218
+ return 1500 // Activity output: ~1.5k tokens
219
+ case 'memo':
220
+ return 500 // Memo: ~500 tokens
221
+ case 'templateDefinition':
222
+ return 1000 // Template def: ~1k tokens
223
+ case 'analysisResult':
224
+ return 1200 // Analysis: ~1.2k tokens
225
+ default:
226
+ return 1000 // Unknown: assume 1k tokens
227
+ }
228
+ }
229
+
230
+ // =============================================================================
231
+ // REPORTING
232
+ // =============================================================================
233
+
234
+ export interface FilteringSummary {
235
+ totalImpulses: number
236
+ loadedImpulses: number
237
+ skippedImpulses: number
238
+ tokensSaved: number
239
+ costSaved: number
240
+ percentSaved: number
241
+ topReasons: Array<{ reason: string; count: number }>
242
+ }
243
+
244
+ /**
245
+ * Generate filtering summary for logging/reporting
246
+ */
247
+ export function generateFilteringSummary(
248
+ filterResult: FilterResult,
249
+ savings: TokenSavings
250
+ ): FilteringSummary {
251
+ // Count reasons
252
+ const reasonCounts = new Map<string, number>()
253
+ for (const reason of Object.values(filterResult.reasoning)) {
254
+ const count = reasonCounts.get(reason) || 0
255
+ reasonCounts.set(reason, count + 1)
256
+ }
257
+
258
+ // Sort by count descending
259
+ const topReasons = Array.from(reasonCounts.entries())
260
+ .map(([reason, count]) => ({ reason, count }))
261
+ .sort((a, b) => b.count - a.count)
262
+ .slice(0, 5)
263
+
264
+ return {
265
+ totalImpulses: filterResult.toLoad.length + filterResult.toSkip.length,
266
+ loadedImpulses: filterResult.toLoad.length,
267
+ skippedImpulses: filterResult.toSkip.length,
268
+ tokensSaved: savings.tokensSaved,
269
+ costSaved: savings.costSaved,
270
+ percentSaved: savings.percentSaved,
271
+ topReasons,
272
+ }
273
+ }
package/src/impulse.ts ADDED
@@ -0,0 +1,311 @@
1
+ /**
2
+ * minibob Impulse System
3
+ *
4
+ * Minimal impulse implementation for context management.
5
+ * Supports: memo, file, activityOutput, custom pointer types.
6
+ */
7
+
8
+ import type { Impulse, ImpulsePointer } from "./types"
9
+ import { getMCPClient, isMCPEnabled } from "./mcp"
10
+
11
+ // =============================================================================
12
+ // IMPULSE STORAGE
13
+ // =============================================================================
14
+
15
+ /**
16
+ * In-memory impulse store (can be backed by MCP in production)
17
+ */
18
+ class ImpulseStore {
19
+ private impulses = new Map<string, Impulse>()
20
+ private customResolvers = new Map<string, (data: Record<string, unknown>) => Promise<string>>()
21
+ private activityOutputs = new Map<string, Map<string, string>>() // activityId -> taskId -> output
22
+
23
+ /**
24
+ * Create a new impulse
25
+ */
26
+ create(impulse: Omit<Impulse, "loaded" | "createdAt">): Impulse {
27
+ const fullImpulse: Impulse = {
28
+ ...impulse,
29
+ loaded: false,
30
+ createdAt: Date.now(),
31
+ }
32
+ this.impulses.set(impulse.id, fullImpulse)
33
+
34
+ // Store in backend if MCP enabled
35
+ // This enables cross-execution impulse tracking and learning
36
+ // Data flow: impulse.ts:create() → mcp.ts:storeImpulse() → POST /impulses → backend storage
37
+ if (isMCPEnabled()) {
38
+ const mcp = getMCPClient()
39
+ if (mcp) {
40
+ mcp.storeImpulse(fullImpulse).catch((err: Error) => {
41
+ console.warn(`[Impulse] Failed to store in backend: ${err.message}`)
42
+ })
43
+ }
44
+ }
45
+
46
+ return fullImpulse
47
+ }
48
+
49
+ /**
50
+ * Get an impulse by ID
51
+ */
52
+ get(id: string): Impulse | undefined {
53
+ return this.impulses.get(id)
54
+ }
55
+
56
+ /**
57
+ * Load an impulse (resolve its pointer and populate content)
58
+ */
59
+ async load(id: string): Promise<Impulse> {
60
+ const impulse = this.impulses.get(id)
61
+ if (!impulse) {
62
+ throw new Error(`Impulse not found: ${id}`)
63
+ }
64
+
65
+ if (impulse.loaded && impulse.content) {
66
+ return impulse
67
+ }
68
+
69
+ const content = await this.resolvePointer(impulse.pointer)
70
+ const tokenCount = this.estimateTokens(content)
71
+
72
+ // Truncate if over budget
73
+ let finalContent = content
74
+ if (tokenCount > impulse.budget) {
75
+ const ratio = impulse.budget / tokenCount
76
+ const targetChars = Math.floor(content.length * ratio * 0.9) // 10% safety margin
77
+ finalContent = content.substring(0, targetChars) + "\n... (truncated to fit budget)"
78
+ }
79
+
80
+ const loadedImpulse: Impulse = {
81
+ ...impulse,
82
+ loaded: true,
83
+ content: finalContent,
84
+ tokenCount: Math.min(tokenCount, impulse.budget),
85
+ }
86
+
87
+ this.impulses.set(id, loadedImpulse)
88
+ return loadedImpulse
89
+ }
90
+
91
+ /**
92
+ * Unload an impulse (free memory)
93
+ */
94
+ unload(id: string): void {
95
+ const impulse = this.impulses.get(id)
96
+ if (impulse) {
97
+ this.impulses.set(id, {
98
+ ...impulse,
99
+ loaded: false,
100
+ content: undefined,
101
+ tokenCount: undefined,
102
+ })
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Delete an impulse
108
+ */
109
+ delete(id: string): boolean {
110
+ return this.impulses.delete(id)
111
+ }
112
+
113
+ /**
114
+ * List all impulses
115
+ */
116
+ list(): Impulse[] {
117
+ return Array.from(this.impulses.values())
118
+ }
119
+
120
+ /**
121
+ * Register a custom resolver
122
+ */
123
+ registerResolver(name: string, resolver: (data: Record<string, unknown>) => Promise<string>): void {
124
+ this.customResolvers.set(name, resolver)
125
+ }
126
+
127
+ /**
128
+ * Store activity output for activityOutput pointer resolution
129
+ */
130
+ storeActivityOutput(activityId: string, taskId: string, output: string): void {
131
+ if (!this.activityOutputs.has(activityId)) {
132
+ this.activityOutputs.set(activityId, new Map())
133
+ }
134
+ this.activityOutputs.get(activityId)!.set(taskId, output)
135
+ }
136
+
137
+ /**
138
+ * Get activity output
139
+ */
140
+ getActivityOutput(activityId: string, taskId?: string): string | undefined {
141
+ const outputs = this.activityOutputs.get(activityId)
142
+ if (!outputs) return undefined
143
+
144
+ if (taskId) {
145
+ return outputs.get(taskId)
146
+ }
147
+
148
+ // Return all outputs concatenated
149
+ return Array.from(outputs.values()).join("\n\n---\n\n")
150
+ }
151
+
152
+ /**
153
+ * Resolve a pointer to content
154
+ *
155
+ * Architecture: MiniBob handles LOCAL types (memo, file) directly.
156
+ * All other types are delegated to backend via MCP.
157
+ */
158
+ private async resolvePointer(pointer: ImpulsePointer): Promise<string> {
159
+ // LOCAL TYPE: memo (content embedded in pointer)
160
+ if (pointer.type === "memo" && "content" in pointer) {
161
+ return pointer.content as string
162
+ }
163
+
164
+ // LOCAL TYPE: file (read from minibob's filesystem)
165
+ if (pointer.type === "file" && "path" in pointer) {
166
+ const filePath = pointer.path
167
+
168
+ // Validate path exists and is a string
169
+ if (!filePath || typeof filePath !== 'string') {
170
+ throw new Error(`Invalid file path in impulse pointer: expected string, got ${typeof filePath}. Pointer: ${JSON.stringify(pointer)}`)
171
+ }
172
+
173
+ let file
174
+ try {
175
+ file = Bun.file(filePath)
176
+ } catch (bunError) {
177
+ throw new Error(`Bun.file() failed for impulse path '${filePath}': ${bunError instanceof Error ? bunError.message : String(bunError)}`)
178
+ }
179
+
180
+ if (!(await file.exists())) {
181
+ throw new Error(`File not found: ${filePath}`)
182
+ }
183
+
184
+ const content = await file.text()
185
+ const lines = content.split("\n")
186
+ const offset = (pointer as any).offset ?? 0
187
+ const limit = (pointer as any).limit ?? lines.length
188
+ return lines.slice(offset, offset + limit).join("\n")
189
+ }
190
+
191
+ // BACKEND TYPES: Delegate to backend via MCP
192
+ // This includes: activityOutput, activityExecutionTrace, activityTemplate,
193
+ // activityMetrics, and ANY future types the backend introduces.
194
+ if (isMCPEnabled()) {
195
+ const mcp = getMCPClient()
196
+ if (mcp && "resolveImpulse" in mcp) {
197
+ try {
198
+ const content = await (mcp as any).resolveImpulse(pointer)
199
+ return content
200
+ } catch (error) {
201
+ throw new Error(
202
+ `Failed to resolve impulse type "${pointer.type}" from backend: ${
203
+ error instanceof Error ? error.message : String(error)
204
+ }`
205
+ )
206
+ }
207
+ }
208
+ }
209
+
210
+ // FALLBACK: No backend connection
211
+ // Try local activityOutput store (in-memory only)
212
+ if (pointer.type === "activityOutput" && "activityId" in pointer) {
213
+ const output = this.getActivityOutput(
214
+ pointer.activityId as string,
215
+ "taskId" in pointer ? (pointer.taskId as string) : undefined
216
+ )
217
+ if (output) {
218
+ console.warn(
219
+ `[Impulse] Resolved activityOutput from in-memory cache (no backend). ` +
220
+ `This will not work across executions.`
221
+ )
222
+ return output
223
+ }
224
+ }
225
+
226
+ // No backend and not a local type - fail
227
+ throw new Error(
228
+ `Impulse type "${pointer.type}" requires backend connection. ` +
229
+ `Only "memo" and "file" types work offline. ` +
230
+ `Please enable MCP connection to metabob-activity-api.`
231
+ )
232
+ }
233
+
234
+ /**
235
+ * Estimate token count (rough approximation: 4 chars per token)
236
+ */
237
+ private estimateTokens(content: string): number {
238
+ return Math.ceil(content.length / 4)
239
+ }
240
+ }
241
+
242
+ // =============================================================================
243
+ // IMPULSE MANAGER (Singleton)
244
+ // =============================================================================
245
+
246
+ let store: ImpulseStore | null = null
247
+
248
+ /**
249
+ * Get the impulse store (singleton)
250
+ */
251
+ export function getImpulseStore(): ImpulseStore {
252
+ if (!store) {
253
+ store = new ImpulseStore()
254
+ }
255
+ return store
256
+ }
257
+
258
+ /**
259
+ * Create a new impulse
260
+ */
261
+ export function createImpulse(impulse: Omit<Impulse, "loaded" | "createdAt">): Impulse {
262
+ return getImpulseStore().create(impulse)
263
+ }
264
+
265
+ /**
266
+ * Load an impulse
267
+ */
268
+ export async function loadImpulse(id: string): Promise<Impulse> {
269
+ return getImpulseStore().load(id)
270
+ }
271
+
272
+ /**
273
+ * Load multiple impulses
274
+ */
275
+ export async function loadImpulses(ids: string[]): Promise<Impulse[]> {
276
+ return Promise.all(ids.map((id) => loadImpulse(id)))
277
+ }
278
+
279
+ /**
280
+ * Format impulses for context injection
281
+ */
282
+ export function formatImpulsesForContext(impulses: Impulse[]): string {
283
+ if (impulses.length === 0) return ""
284
+
285
+ const formatted = impulses
286
+ .filter((imp) => imp.loaded && imp.content)
287
+ .map((imp) => {
288
+ return `<impulse id="${imp.id}" type="${imp.pointer.type}" tokens="${imp.tokenCount ?? 0}/${imp.budget}">
289
+ ${imp.content}
290
+ </impulse>`
291
+ })
292
+ .join("\n\n")
293
+
294
+ return `<impulse_context>
295
+ ${formatted}
296
+ </impulse_context>`
297
+ }
298
+
299
+ /**
300
+ * Register a custom resolver
301
+ */
302
+ export function registerResolver(name: string, resolver: (data: Record<string, unknown>) => Promise<string>): void {
303
+ getImpulseStore().registerResolver(name, resolver)
304
+ }
305
+
306
+ /**
307
+ * Store activity output
308
+ */
309
+ export function storeActivityOutput(activityId: string, taskId: string, output: string): void {
310
+ getImpulseStore().storeActivityOutput(activityId, taskId, output)
311
+ }