@fathippo/fathippo-context-engine 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/engine.js ADDED
@@ -0,0 +1,1092 @@
1
+ /**
2
+ * FatHippo Context Engine
3
+ *
4
+ * Implements OpenClaw's ContextEngine interface for hosted or local agent memory.
5
+ */
6
+ import { existsSync } from "node:fs";
7
+ import path from "node:path";
8
+ import { createLocalMemoryStore, invalidateAllLocalResultsForUser, localRetrieve, localStoreResult, } from "@fathippo/local";
9
+ import { FatHippoClient } from "./api/client.js";
10
+ import { buildStructuredTrace, getMessageText, shouldCaptureCodingTrace } from "./cognitive/trace-capture.js";
11
+ import { CONTEXT_ENGINE_ID, CONTEXT_ENGINE_VERSION } from "./version.js";
12
+ import { formatMemoriesForInjection, dedupeMemories, estimateTokens, } from "./utils/formatting.js";
13
+ import { detectPromptInjection, matchesCapturePatterns, sanitizeContent, } from "./utils/filtering.js";
14
+ /**
15
+ * FatHippo Context Engine implementation
16
+ */
17
+ export class FatHippoContextEngine {
18
+ info = {
19
+ id: CONTEXT_ENGINE_ID,
20
+ name: "FatHippo Context Engine",
21
+ version: CONTEXT_ENGINE_VERSION,
22
+ ownsCompaction: true, // We handle compaction via Dream Cycle
23
+ };
24
+ client;
25
+ config;
26
+ mode;
27
+ localStore;
28
+ cachedCritical = null;
29
+ // Cognitive engine state
30
+ sessionStartTimes = new Map();
31
+ sessionApplicationIds = new Map();
32
+ sessionLocalProfiles = new Map();
33
+ sessionHippoNodState = new Map();
34
+ cognitiveEnabled;
35
+ static TRIVIAL_ACKS = new Set([
36
+ "ok",
37
+ "thanks",
38
+ "yes",
39
+ "no",
40
+ "sure",
41
+ "cool",
42
+ "nice",
43
+ "got it",
44
+ "k",
45
+ "ty",
46
+ "thx",
47
+ ]);
48
+ static MIN_VECTOR_SIMILARITY = 0.75;
49
+ static MIN_CRITICAL_RELEVANCE = 0.7;
50
+ static HIPPO_NOD_COOLDOWN_MS = 15 * 60 * 1000;
51
+ static HIPPO_NOD_MIN_MESSAGE_GAP = 6;
52
+ constructor(config) {
53
+ this.config = config;
54
+ this.mode = config.mode === "local" || (!config.apiKey && config.mode !== "hosted") ? "local" : "hosted";
55
+ if (this.mode === "hosted" && !config.apiKey) {
56
+ throw new Error("FatHippo hosted mode requires an API key. Pass apiKey or switch mode to local/auto.");
57
+ }
58
+ this.client = this.mode === "hosted" ? new FatHippoClient(config) : null;
59
+ this.localStore =
60
+ this.mode === "local"
61
+ ? createLocalMemoryStore({
62
+ storagePath: config.localStoragePath,
63
+ })
64
+ : null;
65
+ // Enable cognitive features if configured (default: true)
66
+ this.cognitiveEnabled = this.mode === "hosted" && config.cognitiveEnabled !== false;
67
+ }
68
+ /**
69
+ * Initialize engine state for a session
70
+ */
71
+ async bootstrap(params) {
72
+ try {
73
+ this.sessionStartTimes.set(params.sessionId, Date.now());
74
+ if (this.mode === "local") {
75
+ this.sessionLocalProfiles.set(params.sessionId, this.deriveLocalProfileId(params.sessionId, params.sessionFile));
76
+ return {
77
+ bootstrapped: true,
78
+ importedMessages: 0,
79
+ };
80
+ }
81
+ // Prefetch critical memories for this session
82
+ const critical = await this.client?.getCriticalMemories({
83
+ limit: 30,
84
+ excludeAbsorbed: true,
85
+ });
86
+ if (!critical) {
87
+ return {
88
+ bootstrapped: true,
89
+ importedMessages: 0,
90
+ };
91
+ }
92
+ this.cachedCritical = {
93
+ memories: critical.memories,
94
+ syntheses: critical.syntheses,
95
+ fetchedAt: Date.now(),
96
+ };
97
+ return {
98
+ bootstrapped: true,
99
+ importedMessages: critical.memories.length + critical.syntheses.length,
100
+ };
101
+ }
102
+ catch (error) {
103
+ console.error("[FatHippo] Bootstrap error:", error);
104
+ return {
105
+ bootstrapped: false,
106
+ reason: error instanceof Error ? error.message : "Unknown error",
107
+ };
108
+ }
109
+ }
110
+ /**
111
+ * Ingest a single message into FatHippo
112
+ */
113
+ async ingest(params) {
114
+ // Skip heartbeat messages
115
+ if (params.isHeartbeat) {
116
+ return { ingested: false };
117
+ }
118
+ // Only capture user messages (configurable)
119
+ if (this.config.captureUserOnly !== false &&
120
+ (!this.isRoleMessage(params.message) || params.message.role !== "user")) {
121
+ return { ingested: false };
122
+ }
123
+ const content = this.extractContent(params.message);
124
+ if (!content) {
125
+ return { ingested: false };
126
+ }
127
+ // Auto-detect constraints from user messages
128
+ if (this.cognitiveEnabled && this.isRoleMessage(params.message) && params.message.role === "user") {
129
+ this.maybeStoreConstraint(content).catch(() => { }); // Fire and forget
130
+ }
131
+ // Filter prompt injection attempts
132
+ if (detectPromptInjection(content)) {
133
+ console.warn("[FatHippo] Blocked prompt injection attempt");
134
+ return { ingested: false };
135
+ }
136
+ // Check if content matches capture patterns
137
+ if (!matchesCapturePatterns(content)) {
138
+ return { ingested: false };
139
+ }
140
+ try {
141
+ if (this.mode === "local") {
142
+ const profileId = this.getLocalProfileId(params.sessionId);
143
+ await this.localStore?.remember({
144
+ profileId,
145
+ content: sanitizeContent(content),
146
+ title: this.buildLocalTitle(content),
147
+ });
148
+ invalidateAllLocalResultsForUser(profileId);
149
+ }
150
+ else {
151
+ await this.client?.remember({
152
+ content: sanitizeContent(content),
153
+ conversationId: this.config.conversationId || params.sessionId,
154
+ });
155
+ }
156
+ return { ingested: true };
157
+ }
158
+ catch (error) {
159
+ console.error("[FatHippo] Ingest error:", error);
160
+ return { ingested: false };
161
+ }
162
+ }
163
+ /**
164
+ * Ingest a batch of messages
165
+ */
166
+ async ingestBatch(params) {
167
+ if (params.isHeartbeat) {
168
+ return { ingestedCount: 0 };
169
+ }
170
+ let ingestedCount = 0;
171
+ for (const message of params.messages) {
172
+ const result = await this.ingest({
173
+ sessionId: params.sessionId,
174
+ message,
175
+ isHeartbeat: params.isHeartbeat,
176
+ });
177
+ if (result.ingested)
178
+ ingestedCount++;
179
+ }
180
+ return { ingestedCount };
181
+ }
182
+ /**
183
+ * Post-turn lifecycle processing
184
+ */
185
+ async afterTurn(params) {
186
+ if (params.isHeartbeat) {
187
+ if (this.cognitiveEnabled && this.config.cognitiveHeartbeatEnabled !== false) {
188
+ await this.runCognitiveHeartbeat();
189
+ }
190
+ return;
191
+ }
192
+ // Invalidate critical cache after turns (may have new memories)
193
+ const cacheAge = this.cachedCritical
194
+ ? Date.now() - this.cachedCritical.fetchedAt
195
+ : Infinity;
196
+ if (cacheAge > 5 * 60 * 1000) {
197
+ // 5 minute cache
198
+ this.cachedCritical = null;
199
+ }
200
+ // Capture cognitive trace for coding sessions
201
+ if (this.mode === "local") {
202
+ await this.captureLocalTrace({
203
+ sessionId: params.sessionId,
204
+ sessionFile: params.sessionFile,
205
+ messages: params.messages,
206
+ });
207
+ }
208
+ else if (this.cognitiveEnabled) {
209
+ await this.captureStructuredTrace({
210
+ sessionId: params.sessionId,
211
+ sessionFile: params.sessionFile,
212
+ messages: params.messages,
213
+ });
214
+ }
215
+ }
216
+ detectToolsUsed(messages) {
217
+ const tools = new Set();
218
+ for (const message of messages) {
219
+ // Check for tool result messages
220
+ const msg = message;
221
+ if (msg.role === 'toolResult' || msg.type === 'toolResult') {
222
+ if (typeof msg.toolName === 'string') {
223
+ tools.add(msg.toolName);
224
+ }
225
+ }
226
+ }
227
+ return [...tools];
228
+ }
229
+ /**
230
+ * Assemble context for the model
231
+ */
232
+ async assemble(params) {
233
+ const lastUserMessage = this.findLastUserMessage(params.messages)?.trim() ?? "";
234
+ if (this.mode === "local") {
235
+ return this.assembleLocalContext(params, lastUserMessage);
236
+ }
237
+ const client = this.client;
238
+ if (!client) {
239
+ return {
240
+ messages: params.messages,
241
+ estimatedTokens: this.estimateMessageTokens(params.messages),
242
+ };
243
+ }
244
+ // Always fetch indexed summaries (they're compact)
245
+ let indexedContext = "";
246
+ try {
247
+ const indexed = await client.getIndexedSummaries();
248
+ if (indexed.count > 0) {
249
+ indexedContext = `\n## Indexed Memory (use GET /indexed/:key for full content)\n${indexed.contextFormat}\n`;
250
+ }
251
+ }
252
+ catch {
253
+ // Indexed memories are optional, don't fail on error
254
+ }
255
+ if (!lastUserMessage || this.isTrivialQuery(lastUserMessage)) {
256
+ // Still include indexed summaries even for trivial queries
257
+ const baseTokens = this.estimateMessageTokens(params.messages);
258
+ return {
259
+ messages: params.messages,
260
+ estimatedTokens: baseTokens + estimateTokens(indexedContext),
261
+ systemPromptAddition: indexedContext || undefined,
262
+ };
263
+ }
264
+ // Fetch relevant memories based on last user message
265
+ let memories = [];
266
+ let syntheses = [];
267
+ let memoryHippoCue = null;
268
+ let hasRelevantCriticalMatch = false;
269
+ try {
270
+ // Search for relevant memories based on query
271
+ const results = await client.search({
272
+ query: lastUserMessage,
273
+ limit: this.config.injectLimit || 20,
274
+ excludeAbsorbed: true,
275
+ });
276
+ const qualifyingResults = results.filter((r) => r.score >= FatHippoContextEngine.MIN_VECTOR_SIMILARITY);
277
+ const searchedMemories = qualifyingResults.map((r) => r.memory);
278
+ memories = dedupeMemories(searchedMemories);
279
+ // Inject critical only for non-trivial queries with critical relevance.
280
+ hasRelevantCriticalMatch = results.some((r) => r.memory.importanceTier === "critical" &&
281
+ r.score > FatHippoContextEngine.MIN_CRITICAL_RELEVANCE);
282
+ if (this.config.injectCritical !== false && hasRelevantCriticalMatch) {
283
+ let criticalMemories;
284
+ let criticalSyntheses;
285
+ if (this.cachedCritical &&
286
+ Date.now() - this.cachedCritical.fetchedAt < 5 * 60 * 1000) {
287
+ criticalMemories = this.cachedCritical.memories;
288
+ criticalSyntheses = this.cachedCritical.syntheses;
289
+ }
290
+ else {
291
+ const critical = await client.getCriticalMemories({
292
+ limit: 15,
293
+ excludeAbsorbed: true,
294
+ });
295
+ criticalMemories = critical.memories;
296
+ criticalSyntheses = critical.syntheses;
297
+ this.cachedCritical = {
298
+ memories: criticalMemories,
299
+ syntheses: criticalSyntheses,
300
+ fetchedAt: Date.now(),
301
+ };
302
+ }
303
+ memories = dedupeMemories([...criticalMemories, ...memories]);
304
+ syntheses = criticalSyntheses;
305
+ }
306
+ if (hasRelevantCriticalMatch || syntheses.length > 0) {
307
+ memoryHippoCue = {
308
+ kind: "memory",
309
+ reason: "Fathippo recalled relevant memory for this reply.",
310
+ };
311
+ }
312
+ }
313
+ catch (error) {
314
+ console.error("[FatHippo] Assemble error:", error);
315
+ }
316
+ const baseMessageTokens = this.estimateMessageTokens(params.messages);
317
+ if (typeof params.tokenBudget === "number" && params.tokenBudget > 0) {
318
+ const contextBudget = Math.max(0, params.tokenBudget - baseMessageTokens);
319
+ const constrained = this.constrainContextToBudget(memories, syntheses, contextBudget);
320
+ memories = constrained.memories;
321
+ syntheses = constrained.syntheses;
322
+ }
323
+ // Format memories for injection, include indexed summaries
324
+ const memoryBlock = formatMemoriesForInjection(memories, syntheses);
325
+ // Fetch constraints (always inject - these are critical rules)
326
+ let constraintsContext = "";
327
+ if (this.cognitiveEnabled) {
328
+ try {
329
+ constraintsContext = await this.fetchConstraints();
330
+ }
331
+ catch (error) {
332
+ console.error("[FatHippo] Constraints fetch error:", error);
333
+ }
334
+ }
335
+ // Fetch cognitive context (traces + patterns) for coding sessions
336
+ let cognitiveContext = "";
337
+ let cognitiveHippoCue = null;
338
+ if (this.cognitiveEnabled && this.looksLikeCodingQuery(lastUserMessage)) {
339
+ try {
340
+ const cognitive = await this.fetchCognitiveContext(params.sessionId, lastUserMessage);
341
+ if (cognitive.context) {
342
+ cognitiveContext = cognitive.context;
343
+ cognitiveHippoCue = cognitive.hippoCue;
344
+ }
345
+ }
346
+ catch (error) {
347
+ console.error("[FatHippo] Cognitive context error:", error);
348
+ }
349
+ }
350
+ const hippoNodInstruction = this.buildHippoNodInstruction({
351
+ sessionId: params.sessionId,
352
+ messageCount: params.messages.length,
353
+ lastUserMessage,
354
+ cue: cognitiveHippoCue ?? memoryHippoCue,
355
+ });
356
+ const fullContext = typeof params.tokenBudget === "number" && params.tokenBudget > 0
357
+ ? this.fitContextToBudget({
358
+ sections: [
359
+ constraintsContext,
360
+ memoryBlock ? `${memoryBlock}\n` : "",
361
+ indexedContext,
362
+ cognitiveContext,
363
+ hippoNodInstruction,
364
+ ],
365
+ contextBudget: Math.max(0, params.tokenBudget - baseMessageTokens),
366
+ })
367
+ : constraintsContext + (memoryBlock ? memoryBlock + "\n" : "") + indexedContext + cognitiveContext + hippoNodInstruction;
368
+ const tokens = estimateTokens(fullContext) + baseMessageTokens;
369
+ return {
370
+ messages: params.messages,
371
+ estimatedTokens: tokens,
372
+ systemPromptAddition: fullContext.trim() || undefined,
373
+ };
374
+ }
375
+ async assembleLocalContext(params, lastUserMessage) {
376
+ const profileId = this.getLocalProfileId(params.sessionId);
377
+ const indexed = await this.localStore?.getIndexedSummaries({
378
+ profileId,
379
+ limit: 18,
380
+ });
381
+ const indexedContext = indexed && indexed.count > 0
382
+ ? `\n## Indexed Local Memory\n${indexed.contextFormat}\n`
383
+ : "";
384
+ const baseTokens = this.estimateMessageTokens(params.messages);
385
+ if (!lastUserMessage || this.isTrivialQuery(lastUserMessage)) {
386
+ return {
387
+ messages: params.messages,
388
+ estimatedTokens: baseTokens + estimateTokens(indexedContext),
389
+ systemPromptAddition: indexedContext || undefined,
390
+ };
391
+ }
392
+ let memories = [];
393
+ let localCognitiveContext = null;
394
+ try {
395
+ let localMemories = [];
396
+ const cached = await localRetrieve(lastUserMessage, profileId);
397
+ if (cached.hit) {
398
+ localMemories = await this.localStore?.getMemoriesByIds(profileId, cached.memoryIds) ?? [];
399
+ }
400
+ if (localMemories.length === 0) {
401
+ const searchResults = await this.localStore?.search({
402
+ profileId,
403
+ query: lastUserMessage,
404
+ limit: Math.max(3, Math.min(this.config.injectLimit || 12, 12)),
405
+ }) ?? [];
406
+ localMemories = searchResults.map((result) => result.memory);
407
+ if (searchResults.length > 0) {
408
+ const avgScore = searchResults.reduce((sum, result) => sum + result.score, 0) / searchResults.length;
409
+ localStoreResult(profileId, lastUserMessage, searchResults.map((result) => result.memory.id), avgScore);
410
+ }
411
+ }
412
+ const critical = this.config.injectCritical !== false
413
+ ? await this.localStore?.getCriticalMemories({ profileId, limit: 10 }) ?? []
414
+ : [];
415
+ localCognitiveContext = await this.localStore?.getCognitiveContext({
416
+ profileId,
417
+ problem: lastUserMessage,
418
+ limit: 3,
419
+ }) ?? null;
420
+ memories = dedupeMemories([
421
+ ...critical.map((memory) => this.mapLocalMemory(memory, profileId)),
422
+ ...localMemories.map((memory) => this.mapLocalMemory(memory, profileId)),
423
+ ]).slice(0, this.config.injectLimit || 20);
424
+ }
425
+ catch (error) {
426
+ console.error("[FatHippo] Local assemble error:", error);
427
+ }
428
+ const memoryBlock = formatMemoriesForInjection(memories, []);
429
+ const workflowBlock = localCognitiveContext?.workflow
430
+ ? `## Recommended Workflow\n${localCognitiveContext.workflow.steps.map((step) => `- ${step}`).join("\n")}\n\nStrategy: ${localCognitiveContext.workflow.title} (${localCognitiveContext.workflow.rationale})\n`
431
+ : "";
432
+ const patternBlock = localCognitiveContext && localCognitiveContext.patterns.length > 0
433
+ ? `## Local Learned Fixes\n${localCognitiveContext.patterns
434
+ .map((pattern) => `- ${pattern.title}: ${pattern.approach}`)
435
+ .join("\n")}\n`
436
+ : "";
437
+ const hippoNodInstruction = this.buildHippoNodInstruction({
438
+ sessionId: params.sessionId,
439
+ messageCount: params.messages.length,
440
+ lastUserMessage,
441
+ cue: localCognitiveContext?.workflow
442
+ ? {
443
+ kind: "workflow",
444
+ reason: "Fathippo reused a locally learned workflow for this reply.",
445
+ }
446
+ : (localCognitiveContext?.patterns.length ?? 0) > 0
447
+ ? {
448
+ kind: "learned_fix",
449
+ reason: "Fathippo reused a locally learned fix for this reply.",
450
+ }
451
+ : memories.length > 0
452
+ ? {
453
+ kind: "memory",
454
+ reason: "Fathippo recalled local memory for this reply.",
455
+ }
456
+ : null,
457
+ });
458
+ const fullContext = typeof params.tokenBudget === "number" && params.tokenBudget > 0
459
+ ? this.fitContextToBudget({
460
+ sections: [
461
+ workflowBlock,
462
+ patternBlock,
463
+ memoryBlock ? `${memoryBlock}\n` : "",
464
+ indexedContext,
465
+ hippoNodInstruction,
466
+ ],
467
+ contextBudget: Math.max(0, params.tokenBudget - baseTokens),
468
+ })
469
+ : workflowBlock + patternBlock + (memoryBlock ? memoryBlock + "\n" : "") + indexedContext + hippoNodInstruction;
470
+ return {
471
+ messages: params.messages,
472
+ estimatedTokens: baseTokens + estimateTokens(fullContext),
473
+ systemPromptAddition: fullContext.trim() || undefined,
474
+ };
475
+ }
476
+ /**
477
+ * Check if query looks like a coding task
478
+ */
479
+ looksLikeCodingQuery(query) {
480
+ const codingKeywords = [
481
+ 'bug', 'error', 'fix', 'debug', 'implement', 'build', 'create', 'refactor',
482
+ 'function', 'class', 'api', 'endpoint', 'database', 'query', 'test',
483
+ 'deploy', 'config', 'install', 'code', 'script', 'compile', 'run'
484
+ ];
485
+ const queryLower = query.toLowerCase();
486
+ return codingKeywords.some(kw => queryLower.includes(kw));
487
+ }
488
+ /**
489
+ * Fetch active constraints (always injected)
490
+ */
491
+ async fetchConstraints() {
492
+ const baseUrl = this.getApiBaseUrl();
493
+ const response = await fetch(`${baseUrl}/v1/cognitive/constraints`, {
494
+ method: 'GET',
495
+ headers: this.getHostedHeaders(false),
496
+ });
497
+ if (!response.ok)
498
+ return '';
499
+ const data = await response.json();
500
+ return data.contextFormat || '';
501
+ }
502
+ /**
503
+ * Auto-detect and store constraints from user message
504
+ */
505
+ async maybeStoreConstraint(message) {
506
+ const baseUrl = this.getApiBaseUrl();
507
+ try {
508
+ await fetch(`${baseUrl}/v1/cognitive/constraints`, {
509
+ method: 'POST',
510
+ headers: this.getHostedHeaders(),
511
+ body: JSON.stringify({ message }),
512
+ });
513
+ }
514
+ catch {
515
+ // Constraint detection is best-effort
516
+ }
517
+ }
518
+ /**
519
+ * Fetch relevant traces and patterns from cognitive API
520
+ */
521
+ async fetchCognitiveContext(sessionId, problem) {
522
+ const baseUrl = this.getApiBaseUrl();
523
+ const response = await fetch(`${baseUrl}/v1/cognitive/traces/relevant`, {
524
+ method: 'POST',
525
+ headers: this.getHostedHeaders(),
526
+ body: JSON.stringify({
527
+ sessionId,
528
+ endpoint: "context-engine.assemble",
529
+ problem,
530
+ limit: 3,
531
+ adaptivePolicy: this.config.adaptivePolicyEnabled !== false,
532
+ }),
533
+ });
534
+ if (!response.ok) {
535
+ return { context: null, hippoCue: null };
536
+ }
537
+ const data = await response.json();
538
+ if (data.applicationId) {
539
+ this.sessionApplicationIds.set(sessionId, data.applicationId);
540
+ }
541
+ const sections = new Map();
542
+ if (data.workflow && data.workflow.steps.length > 0) {
543
+ const stepLines = data.workflow.steps.map((step) => `- ${step}`).join("\n");
544
+ const explorationNote = data.workflow.exploration ? " exploratory" : "";
545
+ sections.set("workflow", `## Recommended Workflow\n${stepLines}\n\nStrategy: ${data.workflow.title} (${data.workflow.rationale}${explorationNote})`);
546
+ }
547
+ const localPatterns = (data.patterns ?? []).filter((pattern) => pattern.scope !== "global");
548
+ const globalPatterns = (data.patterns ?? []).filter((pattern) => pattern.scope === "global");
549
+ if (localPatterns.length > 0) {
550
+ const patternLines = localPatterns
551
+ .map((pattern) => {
552
+ const score = typeof pattern.score === "number" ? `, score ${pattern.score.toFixed(1)}` : "";
553
+ return `- [${pattern.domain}] ${pattern.approach.slice(0, 200)} (${Math.round(pattern.confidence * 100)}% confidence${score})`;
554
+ })
555
+ .join("\n");
556
+ sections.set("local_patterns", `## Learned Coding Patterns\n${patternLines}`);
557
+ }
558
+ if (globalPatterns.length > 0) {
559
+ const patternLines = globalPatterns
560
+ .map((pattern) => {
561
+ const score = typeof pattern.score === "number" ? `, score ${pattern.score.toFixed(1)}` : "";
562
+ return `- [${pattern.domain}] ${pattern.approach.slice(0, 200)} (${Math.round(pattern.confidence * 100)}% confidence${score})`;
563
+ })
564
+ .join("\n");
565
+ sections.set("global_patterns", `## Shared Global Patterns\n${patternLines}`);
566
+ }
567
+ if (data.traces && data.traces.length > 0) {
568
+ const traceLines = data.traces.map(t => {
569
+ const icon = t.outcome === 'success' ? '✓' : t.outcome === 'failed' ? '✗' : '~';
570
+ const solution = t.solution ? ` → ${t.solution.slice(0, 80)}...` : '';
571
+ return `- ${icon} ${t.problem.slice(0, 80)}${solution}`;
572
+ }).join('\n');
573
+ sections.set("traces", `## Past Similar Problems\n${traceLines}`);
574
+ }
575
+ if (data.skills && data.skills.length > 0) {
576
+ const skillLines = data.skills
577
+ .map((skill) => `- [${skill.scope}] ${skill.name}: ${skill.description} (${Math.round(skill.successRate * 100)}% success)`)
578
+ .join("\n");
579
+ sections.set("skills", `## Synthesized Skills\n${skillLines}`);
580
+ }
581
+ let hippoCue = null;
582
+ if (data.workflow && data.workflow.steps.length > 0) {
583
+ hippoCue = {
584
+ kind: "workflow",
585
+ reason: "Fathippo surfaced a learned workflow for this task.",
586
+ };
587
+ }
588
+ else if ((data.skills?.length ?? 0) > 0) {
589
+ hippoCue = {
590
+ kind: "learned_fix",
591
+ reason: "Fathippo surfaced a synthesized skill for this task.",
592
+ };
593
+ }
594
+ else if ((localPatterns.length + globalPatterns.length + (data.traces?.length ?? 0)) >= 2) {
595
+ hippoCue = {
596
+ kind: "learned_fix",
597
+ reason: "Fathippo surfaced learned fixes and similar past problems for this task.",
598
+ };
599
+ }
600
+ if (sections.size === 0) {
601
+ return { context: null, hippoCue: null };
602
+ }
603
+ const orderedSections = [
604
+ sections.get("workflow"),
605
+ ...(data.policy?.sectionOrder ?? ["local_patterns", "global_patterns", "traces", "skills"]).map((key) => sections.get(key)),
606
+ ]
607
+ .filter((section) => typeof section === "string" && section.length > 0);
608
+ if (orderedSections.length === 0) {
609
+ return { context: null, hippoCue: null };
610
+ }
611
+ return {
612
+ context: '\n' + orderedSections.join('\n\n') + '\n',
613
+ hippoCue,
614
+ };
615
+ }
616
+ async captureStructuredTrace(params) {
617
+ if (!shouldCaptureCodingTrace(params.messages)) {
618
+ return;
619
+ }
620
+ if (!this.sessionStartTimes.has(params.sessionId)) {
621
+ this.sessionStartTimes.set(params.sessionId, Date.now() - 60_000);
622
+ }
623
+ const payload = buildStructuredTrace({
624
+ sessionId: params.sessionId,
625
+ messages: params.messages,
626
+ toolsUsed: this.detectToolsUsed(params.messages),
627
+ filesModified: this.detectFilesModified(params.sessionFile, params.messages),
628
+ workspaceRoot: this.detectWorkspaceRoot(params.sessionFile),
629
+ startTime: this.sessionStartTimes.get(params.sessionId) ?? Date.now() - 60_000,
630
+ endTime: Math.min(Date.now(), (this.sessionStartTimes.get(params.sessionId) ?? Date.now()) + 30 * 60 * 1000),
631
+ });
632
+ if (!payload) {
633
+ this.sessionStartTimes.set(params.sessionId, Date.now());
634
+ return;
635
+ }
636
+ try {
637
+ const baseUrl = this.getApiBaseUrl();
638
+ const response = await fetch(`${baseUrl}/v1/cognitive/traces`, {
639
+ method: "POST",
640
+ headers: this.getHostedHeaders(),
641
+ body: JSON.stringify({
642
+ ...payload,
643
+ applicationId: this.sessionApplicationIds.get(params.sessionId) ?? null,
644
+ shareEligible: this.config.shareEligibleByDefault !== false && payload.shareEligible,
645
+ }),
646
+ });
647
+ if (!response.ok) {
648
+ throw new Error(`Trace capture failed with status ${response.status}`);
649
+ }
650
+ this.sessionStartTimes.delete(params.sessionId);
651
+ this.sessionApplicationIds.delete(params.sessionId);
652
+ }
653
+ catch (error) {
654
+ console.error("[FatHippo] Trace capture error:", error);
655
+ this.sessionStartTimes.set(params.sessionId, Date.now());
656
+ }
657
+ }
658
+ async runCognitiveHeartbeat() {
659
+ const baseUrl = this.getApiBaseUrl();
660
+ const headers = this.getHostedHeaders();
661
+ try {
662
+ const response = await fetch(`${baseUrl}/v1/cognitive/patterns/extract`, {
663
+ method: "POST",
664
+ headers,
665
+ body: JSON.stringify({}),
666
+ });
667
+ if (!response.ok) {
668
+ throw new Error(`Pattern extraction failed with status ${response.status}`);
669
+ }
670
+ }
671
+ catch (error) {
672
+ console.error("[FatHippo] Pattern extraction heartbeat error:", error);
673
+ }
674
+ try {
675
+ const response = await fetch(`${baseUrl}/v1/cognitive/skills/synthesize`, {
676
+ method: "POST",
677
+ headers,
678
+ body: JSON.stringify({}),
679
+ });
680
+ if (!response.ok) {
681
+ throw new Error(`Skill synthesis failed with status ${response.status}`);
682
+ }
683
+ }
684
+ catch (error) {
685
+ console.error("[FatHippo] Skill synthesis heartbeat error:", error);
686
+ }
687
+ }
688
+ detectFilesModified(sessionFile, messages) {
689
+ const files = new Set();
690
+ if (sessionFile) {
691
+ files.add(sessionFile);
692
+ }
693
+ const filePattern = /(?:^|[\s("'`])((?:[\w.-]+\/)*[\w.-]+\.(?:ts|tsx|js|jsx|json|md|sql|py|go|rs|java|rb|sh|yaml|yml))(?:$|[\s)"'`:,])/g;
694
+ for (const message of messages) {
695
+ const text = getMessageText(message);
696
+ for (const match of text.matchAll(filePattern)) {
697
+ if (match[1]) {
698
+ files.add(match[1]);
699
+ }
700
+ }
701
+ }
702
+ return [...files].slice(0, 25);
703
+ }
704
+ detectWorkspaceRoot(sessionFile) {
705
+ if (!sessionFile) {
706
+ return undefined;
707
+ }
708
+ const resolved = path.resolve(sessionFile);
709
+ let candidate = path.extname(resolved) ? path.dirname(resolved) : resolved;
710
+ const markers = [
711
+ ".git",
712
+ "package.json",
713
+ "pnpm-workspace.yaml",
714
+ "yarn.lock",
715
+ "package-lock.json",
716
+ "bun.lockb",
717
+ "turbo.json",
718
+ "nx.json",
719
+ "deno.json",
720
+ ];
721
+ while (candidate && candidate !== path.dirname(candidate)) {
722
+ if (markers.some((marker) => existsSync(path.join(candidate, marker)))) {
723
+ return candidate;
724
+ }
725
+ candidate = path.dirname(candidate);
726
+ }
727
+ return path.extname(resolved) ? path.dirname(resolved) : resolved;
728
+ }
729
+ deriveLocalProfileId(sessionId, sessionFile) {
730
+ if (this.config.localProfileId) {
731
+ return this.config.localProfileId;
732
+ }
733
+ if (this.config.conversationId) {
734
+ return this.config.conversationId;
735
+ }
736
+ const workspaceRoot = sessionFile ? this.detectWorkspaceRoot(sessionFile) : undefined;
737
+ return workspaceRoot || this.sessionLocalProfiles.get(sessionId) || "openclaw-local-default";
738
+ }
739
+ getLocalProfileId(sessionId) {
740
+ return this.sessionLocalProfiles.get(sessionId) || this.deriveLocalProfileId(sessionId);
741
+ }
742
+ buildLocalTitle(content) {
743
+ const normalized = content.trim().replace(/\s+/g, " ");
744
+ return normalized.length > 72 ? `${normalized.slice(0, 69)}...` : normalized;
745
+ }
746
+ mapLocalMemory(memory, profileId) {
747
+ return {
748
+ id: memory.id,
749
+ title: memory.title,
750
+ content: memory.content,
751
+ userId: profileId,
752
+ createdAt: memory.createdAt,
753
+ updatedAt: memory.updatedAt,
754
+ accessCount: memory.accessCount,
755
+ importanceTier: memory.importanceTier,
756
+ };
757
+ }
758
+ toLocalToolSignals(payload) {
759
+ return [...payload.toolCalls, ...payload.toolResults]
760
+ .filter((signal) => Boolean(signal) && typeof signal === "object" && !Array.isArray(signal))
761
+ .map((signal) => ({
762
+ category: typeof signal.category === "string" ? signal.category : undefined,
763
+ command: typeof signal.command === "string" ? signal.command : undefined,
764
+ success: typeof signal.success === "boolean" ? signal.success : undefined,
765
+ }));
766
+ }
767
+ async captureLocalTrace(params) {
768
+ if (!shouldCaptureCodingTrace(params.messages)) {
769
+ return;
770
+ }
771
+ if (!this.sessionStartTimes.has(params.sessionId)) {
772
+ this.sessionStartTimes.set(params.sessionId, Date.now() - 60_000);
773
+ }
774
+ const payload = buildStructuredTrace({
775
+ sessionId: params.sessionId,
776
+ messages: params.messages,
777
+ toolsUsed: this.detectToolsUsed(params.messages),
778
+ filesModified: this.detectFilesModified(params.sessionFile, params.messages),
779
+ workspaceRoot: this.detectWorkspaceRoot(params.sessionFile),
780
+ startTime: this.sessionStartTimes.get(params.sessionId) ?? Date.now() - 60_000,
781
+ endTime: Math.min(Date.now(), (this.sessionStartTimes.get(params.sessionId) ?? Date.now()) + 30 * 60 * 1000),
782
+ });
783
+ if (!payload) {
784
+ this.sessionStartTimes.set(params.sessionId, Date.now());
785
+ return;
786
+ }
787
+ const profileId = this.deriveLocalProfileId(params.sessionId, params.sessionFile);
788
+ this.sessionLocalProfiles.set(params.sessionId, profileId);
789
+ try {
790
+ await this.localStore?.learnTrace({
791
+ profileId,
792
+ type: payload.type,
793
+ problem: payload.problem,
794
+ reasoning: payload.reasoning,
795
+ solution: payload.solution,
796
+ outcome: payload.outcome,
797
+ technologies: payload.context.technologies,
798
+ errorMessages: payload.context.errorMessages,
799
+ verificationCommands: payload.verificationCommands,
800
+ filesModified: payload.filesModified,
801
+ durationMs: payload.durationMs,
802
+ toolSignals: this.toLocalToolSignals(payload),
803
+ });
804
+ this.sessionStartTimes.delete(params.sessionId);
805
+ }
806
+ catch (error) {
807
+ console.error("[FatHippo] Local trace capture error:", error);
808
+ this.sessionStartTimes.set(params.sessionId, Date.now());
809
+ }
810
+ }
811
+ getApiBaseUrl() {
812
+ const baseUrl = this.config.baseUrl || "https://fathippo.ai/api";
813
+ return baseUrl.replace(/\/v1$/, "");
814
+ }
815
+ getHostedHeaders(includeContentType = true) {
816
+ const headers = {
817
+ "X-Fathippo-Plugin-Id": this.config.pluginId ?? CONTEXT_ENGINE_ID,
818
+ "X-Fathippo-Plugin-Version": this.config.pluginVersion ?? CONTEXT_ENGINE_VERSION,
819
+ "X-Fathippo-Plugin-Mode": this.mode,
820
+ };
821
+ if (this.config.apiKey) {
822
+ headers.Authorization = `Bearer ${this.config.apiKey}`;
823
+ }
824
+ if (includeContentType) {
825
+ headers["Content-Type"] = "application/json";
826
+ }
827
+ return headers;
828
+ }
829
+ buildHippoNodInstruction(params) {
830
+ if (this.config.hippoNodsEnabled === false || !params.cue) {
831
+ return "";
832
+ }
833
+ if (this.isHighUrgencyOrFormalMoment(params.lastUserMessage)) {
834
+ return "";
835
+ }
836
+ const prior = this.sessionHippoNodState.get(params.sessionId);
837
+ if (prior) {
838
+ const withinCooldown = Date.now() - prior.lastOfferedAt < FatHippoContextEngine.HIPPO_NOD_COOLDOWN_MS;
839
+ const withinMessageGap = params.messageCount - prior.lastMessageCount < FatHippoContextEngine.HIPPO_NOD_MIN_MESSAGE_GAP;
840
+ if (withinCooldown || withinMessageGap) {
841
+ return "";
842
+ }
843
+ }
844
+ this.sessionHippoNodState.set(params.sessionId, {
845
+ lastOfferedAt: Date.now(),
846
+ lastMessageCount: params.messageCount,
847
+ });
848
+ return [
849
+ "## Optional Fathippo Cue",
850
+ params.cue.reason,
851
+ 'If it fits naturally, you may include one very brief acknowledgement such as "🦛 Noted." or end one short sentence with "🦛".',
852
+ "Rules:",
853
+ "- This is optional, not required.",
854
+ "- Use it at most once in this reply.",
855
+ "- Only use it if the tone is friendly, calm, or neutral.",
856
+ "- Skip it for urgent, frustrated, highly formal, or sensitive situations.",
857
+ "- Do not mention internal scoring, retrieval policies, or training.",
858
+ "- Keep the rest of the reply normal, direct, and useful.",
859
+ "",
860
+ ].join("\n");
861
+ }
862
+ isHighUrgencyOrFormalMoment(message) {
863
+ const normalized = message.toLowerCase();
864
+ return [
865
+ "urgent",
866
+ "asap",
867
+ "immediately",
868
+ "prod down",
869
+ "production down",
870
+ "sev1",
871
+ "sev 1",
872
+ "security incident",
873
+ "breach",
874
+ "privacy request",
875
+ "gdpr",
876
+ "legal",
877
+ "compliance",
878
+ ].some((token) => normalized.includes(token));
879
+ }
880
+ /**
881
+ * Handle compaction via Dream Cycle
882
+ */
883
+ async compact(params) {
884
+ void params;
885
+ if (this.mode === "local") {
886
+ return { ok: true, compacted: false, reason: "local mode has no hosted Dream Cycle" };
887
+ }
888
+ if (this.config.dreamCycleOnCompact === false) {
889
+ // Fall back to default compaction
890
+ return { ok: true, compacted: false, reason: "dreamCycleOnCompact disabled" };
891
+ }
892
+ try {
893
+ const result = await this.client?.runDreamCycle({
894
+ processCompleted: true,
895
+ processEphemeral: true,
896
+ synthesizeCritical: true,
897
+ applyDecay: true,
898
+ updateGraph: true,
899
+ });
900
+ if (!result) {
901
+ return { ok: false, compacted: false, reason: "hosted client unavailable" };
902
+ }
903
+ // Invalidate cache after dream cycle
904
+ this.cachedCritical = null;
905
+ return {
906
+ ok: result.ok,
907
+ compacted: true,
908
+ reason: `Dream Cycle: ${result.synthesized || 0} synthesized, ${result.decayed || 0} decayed`,
909
+ };
910
+ }
911
+ catch (error) {
912
+ console.error("[FatHippo] Dream Cycle error:", error);
913
+ return {
914
+ ok: false,
915
+ compacted: false,
916
+ reason: error instanceof Error ? error.message : "Unknown error",
917
+ };
918
+ }
919
+ }
920
+ /**
921
+ * Prepare context for subagent spawn
922
+ */
923
+ async prepareSubagentSpawn(_params) {
924
+ void _params;
925
+ // For now, subagents inherit parent's memory scope
926
+ // Future: could create isolated memory scope for subagent
927
+ return {
928
+ rollback: async () => {
929
+ // Nothing to rollback currently
930
+ },
931
+ };
932
+ }
933
+ /**
934
+ * Handle subagent completion
935
+ */
936
+ async onSubagentEnded(_params) {
937
+ void _params;
938
+ // Future: extract learnings from subagent session
939
+ // and store them in parent's memory
940
+ }
941
+ /**
942
+ * Cleanup resources
943
+ */
944
+ async dispose() {
945
+ this.cachedCritical = null;
946
+ this.sessionApplicationIds.clear();
947
+ this.sessionLocalProfiles.clear();
948
+ this.sessionStartTimes.clear();
949
+ this.sessionHippoNodState.clear();
950
+ }
951
+ // --- Helper methods ---
952
+ extractContent(message) {
953
+ const msg = message;
954
+ if (typeof msg.content === "string") {
955
+ return msg.content;
956
+ }
957
+ if (typeof msg.text === "string") {
958
+ return msg.text;
959
+ }
960
+ if (Array.isArray(msg.content)) {
961
+ // Handle multi-part content (text blocks)
962
+ const textParts = msg.content
963
+ .filter((p) => typeof p === "object" && p !== null && "type" in p && p.type === "text")
964
+ .map((p) => p.text);
965
+ return textParts.join("\n") || null;
966
+ }
967
+ return null;
968
+ }
969
+ findLastUserMessage(messages) {
970
+ for (let i = messages.length - 1; i >= 0; i--) {
971
+ const msg = messages[i];
972
+ if (this.isRoleMessage(msg) && msg.role === "user") {
973
+ return this.extractContent(msg);
974
+ }
975
+ }
976
+ return null;
977
+ }
978
+ isTrivialQuery(message) {
979
+ const trimmed = message.trim();
980
+ if (!trimmed) {
981
+ return true;
982
+ }
983
+ if (trimmed.length < 3) {
984
+ return true;
985
+ }
986
+ const normalized = trimmed.toLowerCase().replace(/\s+/g, " ");
987
+ if (FatHippoContextEngine.TRIVIAL_ACKS.has(normalized)) {
988
+ return true;
989
+ }
990
+ if (/^[\p{P}\s]+$/u.test(trimmed)) {
991
+ return true;
992
+ }
993
+ if (/^(?:\p{Extended_Pictographic}|\p{Emoji_Component}|\u200D|\uFE0F|\s)+$/u.test(trimmed)) {
994
+ return true;
995
+ }
996
+ return false;
997
+ }
998
+ isRoleMessage(message) {
999
+ return (typeof message === "object" &&
1000
+ message !== null &&
1001
+ "role" in message &&
1002
+ typeof message.role === "string");
1003
+ }
1004
+ estimateMessageTokens(messages) {
1005
+ const plainText = messages
1006
+ .map((message) => this.extractContent(message))
1007
+ .filter((content) => Boolean(content))
1008
+ .join("\n");
1009
+ return estimateTokens(plainText);
1010
+ }
1011
+ constrainContextToBudget(memories, syntheses, contextBudget) {
1012
+ if (contextBudget <= 0) {
1013
+ return { memories: [], syntheses: [] };
1014
+ }
1015
+ let remaining = contextBudget;
1016
+ const selectedSyntheses = [];
1017
+ const selectedMemories = [];
1018
+ const pushIfFits = (tokens) => {
1019
+ if (tokens > remaining) {
1020
+ return false;
1021
+ }
1022
+ remaining -= tokens;
1023
+ return true;
1024
+ };
1025
+ for (const synthesis of syntheses) {
1026
+ const tokens = estimateTokens(`${synthesis.title}\n${synthesis.content}`);
1027
+ if (!pushIfFits(tokens)) {
1028
+ continue;
1029
+ }
1030
+ selectedSyntheses.push(synthesis);
1031
+ }
1032
+ const critical = memories.filter((memory) => memory.importanceTier === "critical");
1033
+ const high = memories.filter((memory) => memory.importanceTier === "high");
1034
+ const normal = memories.filter((memory) => memory.importanceTier === "normal" || !memory.importanceTier);
1035
+ for (const group of [critical, high, normal]) {
1036
+ for (const memory of group) {
1037
+ const tokens = estimateTokens(`${memory.title}\n${memory.content}`);
1038
+ if (!pushIfFits(tokens)) {
1039
+ continue;
1040
+ }
1041
+ selectedMemories.push(memory);
1042
+ }
1043
+ }
1044
+ return {
1045
+ memories: selectedMemories,
1046
+ syntheses: selectedSyntheses,
1047
+ };
1048
+ }
1049
+ fitContextToBudget(params) {
1050
+ if (params.contextBudget <= 0) {
1051
+ return "";
1052
+ }
1053
+ let remaining = params.contextBudget;
1054
+ const selected = [];
1055
+ for (const section of params.sections.map((value) => value.trim()).filter(Boolean)) {
1056
+ const sectionTokens = estimateTokens(section);
1057
+ if (sectionTokens <= remaining) {
1058
+ selected.push(section);
1059
+ remaining -= sectionTokens;
1060
+ continue;
1061
+ }
1062
+ if (remaining <= 16) {
1063
+ break;
1064
+ }
1065
+ const truncated = this.truncateContextSection(section, remaining);
1066
+ if (truncated) {
1067
+ selected.push(truncated);
1068
+ }
1069
+ break;
1070
+ }
1071
+ return selected.join("\n\n");
1072
+ }
1073
+ truncateContextSection(section, tokenBudget) {
1074
+ let low = 0;
1075
+ let high = section.length;
1076
+ let best = "";
1077
+ while (low <= high) {
1078
+ const mid = Math.floor((low + high) / 2);
1079
+ const candidate = `${section.slice(0, mid).trimEnd()}\n...`;
1080
+ const tokens = estimateTokens(candidate);
1081
+ if (tokens <= tokenBudget) {
1082
+ best = candidate;
1083
+ low = mid + 1;
1084
+ }
1085
+ else {
1086
+ high = mid - 1;
1087
+ }
1088
+ }
1089
+ return best;
1090
+ }
1091
+ }
1092
+ //# sourceMappingURL=engine.js.map