kongbrain 0.1.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.
@@ -0,0 +1,989 @@
1
+ /**
2
+ * Graph-based context transformation for OpenClaw.
3
+ *
4
+ * Ported from kongbrain's graph-context.ts. Key changes:
5
+ * - No module-level state: all mutable state flows through SessionState
6
+ * - SurrealStore and EmbeddingService are passed as parameters
7
+ * - Designed to be called from ContextEngine.assemble()
8
+ */
9
+
10
+ import type { AgentMessage } from "@mariozechner/pi-agent-core";
11
+ import type {
12
+ UserMessage, AssistantMessage, ToolResultMessage,
13
+ TextContent, ThinkingContent, ToolCall, ImageContent,
14
+ } from "@mariozechner/pi-ai";
15
+ import type { SurrealStore, VectorSearchResult, CoreMemoryEntry } from "./surreal.js";
16
+ import type { EmbeddingService } from "./embeddings.js";
17
+ import type { SessionState } from "./state.js";
18
+ import { getPendingDirectives, clearPendingDirectives, getSessionContinuity, getSuppressedNodeIds } from "./cognitive-check.js";
19
+ import { queryCausalContext } from "./causal.js";
20
+ import { findRelevantSkills, formatSkillContext } from "./skills.js";
21
+ import { retrieveReflections, formatReflectionContext } from "./reflection.js";
22
+ import { getCachedContext, recordPrefetchHit, recordPrefetchMiss } from "./prefetch.js";
23
+ import { stageRetrieval, getHistoricalUtilityBatch } from "./retrieval-quality.js";
24
+ import { isACANActive, scoreWithACAN, type ACANCandidate } from "./acan.js";
25
+ import { swallow } from "./errors.js";
26
+
27
+ // ── Message type guards ────────────────────────────────────────────────────────
28
+
29
+ type ContentBlock = TextContent | ThinkingContent | ToolCall | ImageContent;
30
+
31
+ function isUser(msg: AgentMessage): msg is UserMessage {
32
+ return (msg as UserMessage).role === "user";
33
+ }
34
+ function isAssistant(msg: AgentMessage): msg is AssistantMessage {
35
+ return (msg as AssistantMessage).role === "assistant";
36
+ }
37
+ function isToolResult(msg: AgentMessage): msg is ToolResultMessage {
38
+ return (msg as ToolResultMessage).role === "toolResult";
39
+ }
40
+ function msgRole(msg: AgentMessage): string {
41
+ if (isUser(msg)) return msg.role;
42
+ if (isAssistant(msg)) return msg.role;
43
+ if (isToolResult(msg)) return msg.role;
44
+ return "unknown";
45
+ }
46
+ function msgContentBlocks(msg: AgentMessage): ContentBlock[] {
47
+ if (isUser(msg)) {
48
+ return typeof msg.content === "string"
49
+ ? [{ type: "text", text: msg.content } as TextContent]
50
+ : msg.content as ContentBlock[];
51
+ }
52
+ if (isAssistant(msg)) return msg.content;
53
+ if (isToolResult(msg)) return msg.content as ContentBlock[];
54
+ return [];
55
+ }
56
+
57
+ // ── Constants ──────────────────────────────────────────────────────────────────
58
+
59
+ const CHARS_PER_TOKEN = 3.4;
60
+ const BUDGET_FRACTION = 0.70;
61
+ const CONVERSATION_SHARE = 0.50;
62
+ const RETRIEVAL_SHARE = 0.30;
63
+ const CORE_MEMORY_SHARE = 0.15;
64
+ const CORE_MEMORY_TTL = 300_000;
65
+ const MIN_RELEVANCE_SCORE = 0.35;
66
+ const MIN_COSINE = 0.25;
67
+
68
+ // Recency decay
69
+ const RECENCY_DECAY_FAST = 0.99;
70
+ const RECENCY_DECAY_SLOW = 0.995;
71
+ const RECENCY_BOUNDARY_HOURS = 4;
72
+
73
+ // Utility pre-filtering
74
+ const UTILITY_PREFILTER_MIN_RETRIEVALS = 5;
75
+ const UTILITY_PREFILTER_MAX_UTIL = 0.05;
76
+
77
+ // Intent score floors
78
+ const INTENT_SCORE_FLOORS: Record<string, number> = {
79
+ "simple-question": 0.20, "meta-session": 0.18, "code-read": 0.14,
80
+ "code-write": 0.12, "code-debug": 0.12, "deep-explore": 0.10,
81
+ "reference-prior": 0.08, "multi-step": 0.12, "continuation": 0.10,
82
+ "unknown": 0.12,
83
+ };
84
+ const SCORE_FLOOR_DEFAULT = 0.12;
85
+ const INTENT_REMINDER_THRESHOLD = 10;
86
+
87
+ // ── Budget calculation ─────────────────────────────────────────────────────────
88
+
89
+ interface Budgets {
90
+ conversation: number;
91
+ retrieval: number;
92
+ core: number;
93
+ maxContextItems: number;
94
+ }
95
+
96
+ function calcBudgets(contextWindow: number): Budgets {
97
+ const total = contextWindow * BUDGET_FRACTION;
98
+ const retrieval = Math.round(total * RETRIEVAL_SHARE);
99
+ return {
100
+ conversation: Math.round(total * CONVERSATION_SHARE),
101
+ retrieval,
102
+ core: Math.round(total * CORE_MEMORY_SHARE),
103
+ maxContextItems: Math.max(20, Math.round(retrieval / 300)),
104
+ };
105
+ }
106
+
107
+ // ── Context stats ──────────────────────────────────────────────────────────────
108
+
109
+ export interface ContextStats {
110
+ fullHistoryTokens: number;
111
+ sentTokens: number;
112
+ savedTokens: number;
113
+ reductionPct: number;
114
+ graphNodes: number;
115
+ neighborNodes: number;
116
+ recentTurns: number;
117
+ mode: "graph" | "recency-only" | "passthrough";
118
+ prefetchHit: boolean;
119
+ }
120
+
121
+ // ── Scoring types ──────────────────────────────────────────────────────────────
122
+
123
+ interface ScoredResult extends VectorSearchResult {
124
+ finalScore: number;
125
+ fromNeighbor?: boolean;
126
+ }
127
+
128
+ // ── Helper functions ───────────────────────────────────────────────────────────
129
+
130
+ function extractText(msg: UserMessage | AssistantMessage): string {
131
+ if (typeof msg.content === "string") return msg.content;
132
+ if (Array.isArray(msg.content)) {
133
+ return (msg.content as ContentBlock[])
134
+ .filter((c): c is TextContent => c.type === "text")
135
+ .map((c) => c.text)
136
+ .join("\n");
137
+ }
138
+ return "";
139
+ }
140
+
141
+ function extractLastUserText(messages: AgentMessage[]): string | null {
142
+ for (let i = messages.length - 1; i >= 0; i--) {
143
+ const msg = messages[i] as UserMessage;
144
+ if (msg.role === "user") {
145
+ const text = extractText(msg);
146
+ if (text) return text;
147
+ }
148
+ }
149
+ return null;
150
+ }
151
+
152
+ function estimateTokens(messages: AgentMessage[]): number {
153
+ let chars = 0;
154
+ for (const msg of messages) {
155
+ for (const c of msgContentBlocks(msg)) {
156
+ if (c.type === "text") chars += c.text.length;
157
+ else if (c.type === "thinking") chars += c.thinking.length;
158
+ else chars += 100;
159
+ }
160
+ }
161
+ return Math.ceil(chars / CHARS_PER_TOKEN);
162
+ }
163
+
164
+ function msgCharLen(msg: AgentMessage): number {
165
+ let len = 0;
166
+ for (const c of msgContentBlocks(msg)) {
167
+ if (c.type === "text") len += c.text.length;
168
+ else if (c.type === "thinking") len += c.thinking.length;
169
+ else len += 100;
170
+ }
171
+ return len;
172
+ }
173
+
174
+ function recencyScore(timestamp: string | undefined): number {
175
+ if (!timestamp) return 0.3;
176
+ const hoursElapsed = (Date.now() - new Date(timestamp).getTime()) / (1000 * 60 * 60);
177
+ if (hoursElapsed <= RECENCY_BOUNDARY_HOURS) {
178
+ return Math.pow(RECENCY_DECAY_FAST, hoursElapsed);
179
+ }
180
+ const fastPart = Math.pow(RECENCY_DECAY_FAST, RECENCY_BOUNDARY_HOURS);
181
+ return fastPart * Math.pow(RECENCY_DECAY_SLOW, hoursElapsed - RECENCY_BOUNDARY_HOURS);
182
+ }
183
+
184
+ export function formatRelativeTime(ts: string): string {
185
+ const ms = Date.now() - new Date(ts).getTime();
186
+ const mins = Math.floor(ms / 60000);
187
+ if (mins < 1) return "just now";
188
+ if (mins < 60) return `${mins}m ago`;
189
+ const hrs = Math.floor(mins / 60);
190
+ if (hrs < 24) return `${hrs}h ago`;
191
+ const days = Math.floor(hrs / 24);
192
+ if (days < 7) return `${days}d ago`;
193
+ const weeks = Math.floor(days / 7);
194
+ if (weeks < 5) return `${weeks}w ago`;
195
+ return `${Math.floor(days / 30)}mo ago`;
196
+ }
197
+
198
+ function accessBoost(accessCount: number | undefined): number {
199
+ return Math.log1p(accessCount ?? 0);
200
+ }
201
+
202
+ function cosineSimilarity(a: number[], b: number[]): number {
203
+ let dot = 0, magA = 0, magB = 0;
204
+ for (let i = 0; i < a.length; i++) {
205
+ dot += a[i] * b[i];
206
+ magA += a[i] * a[i];
207
+ magB += b[i] * b[i];
208
+ }
209
+ const denom = Math.sqrt(magA) * Math.sqrt(magB);
210
+ return denom > 0 ? dot / denom : 0;
211
+ }
212
+
213
+ // ── Rules suffix (tool budget injection) ───────────────────────────────────────
214
+
215
+ function buildRulesSuffix(session: SessionState): string {
216
+ const remaining = session.toolLimit === Infinity
217
+ ? "unlimited" : String(Math.max(0, session.toolLimit - session.toolCallCount));
218
+ const urgency = session.toolLimit !== Infinity && (session.toolLimit - session.toolCallCount) <= 3
219
+ ? "\n⚠ WRAP UP or check in with user." : "";
220
+ return (
221
+ "\n<rules_reminder>" +
222
+ `\nBudget: ${session.toolCallCount} used, ${remaining} remaining.${urgency}` +
223
+ "\n\nYOUR BUDGET IS SMALL. Plan the whole task, not just the next call." +
224
+ "\n" +
225
+ "\nTask: Fix broken import" +
226
+ "\n WASTEFUL (6 calls): grep old → read file → grep new → read context → edit → read to verify" +
227
+ "\n DENSE (2 calls):" +
228
+ "\n 1. grep -n 'oldImport' src/**/*.ts; grep -rn 'newModule' src/" +
229
+ "\n 2. edit file && npm test -- --grep 'relevant' 2>&1 | tail -20" +
230
+ "\n" +
231
+ "\nTask: Debug failing test" +
232
+ "\n WASTEFUL (8 calls): run test → read output → read test → read source → grep → read more → edit → rerun" +
233
+ "\n DENSE (3 calls):" +
234
+ "\n 1. npm test 2>&1 | tail -30" +
235
+ "\n 2. grep -n 'failingTest\\|relevantFn' test/*.ts src/*.ts" +
236
+ "\n 3. edit fix && npm test 2>&1 | tail -15" +
237
+ "\n" +
238
+ "\nTask: Read/understand multiple files" +
239
+ "\n WASTEFUL (10 calls): cat file1 → cat file2 → cat file3 → ..." +
240
+ "\n DENSE (1-2 calls):" +
241
+ "\n 1. head -80 src/a.ts src/b.ts src/c.ts src/d.ts (4 files in ONE call)" +
242
+ "\n 2. grep -n 'keyPattern' src/*.ts (search all files at once, not one by one)" +
243
+ "\n" +
244
+ "\nEvery step still happens — investigation, edit, verification — but COMBINED into fewer calls." +
245
+ "\nThe answer is often already in context. Don't call if you already know." +
246
+ "\nAnnounce: task type (LOOKUP=1/EDIT=2/REFACTOR=6), planned calls, what each does." +
247
+ "\n</rules_reminder>"
248
+ );
249
+ }
250
+
251
+ function injectRulesSuffix(messages: AgentMessage[], session: SessionState): AgentMessage[] {
252
+ const suffix = buildRulesSuffix(session);
253
+ for (let i = messages.length - 1; i >= 0; i--) {
254
+ const msg = messages[i];
255
+ if (isUser(msg)) {
256
+ const clone = [...messages];
257
+ clone[i] = {
258
+ ...msg,
259
+ content: typeof msg.content === "string" ? msg.content + suffix : msg.content,
260
+ } as UserMessage;
261
+ return clone;
262
+ }
263
+ if (isToolResult(msg)) {
264
+ const clone = [...messages];
265
+ const content = Array.isArray(msg.content) ? [...msg.content] : msg.content;
266
+ if (Array.isArray(content)) {
267
+ content.push({ type: "text", text: suffix } as TextContent);
268
+ }
269
+ clone[i] = { ...msg, content } as ToolResultMessage;
270
+ return clone;
271
+ }
272
+ }
273
+ return messages;
274
+ }
275
+
276
+ // ── Contextual query vector ────────────────────────────────────────────────────
277
+
278
+ async function buildContextualQueryVec(
279
+ queryText: string,
280
+ messages: AgentMessage[],
281
+ embeddings: EmbeddingService,
282
+ ): Promise<number[]> {
283
+ const queryVec = await embeddings.embed(queryText);
284
+
285
+ const recentTexts: string[] = [];
286
+ for (let i = messages.length - 2; i >= 0 && recentTexts.length < 3; i--) {
287
+ const msg = messages[i] as UserMessage | AssistantMessage;
288
+ if (msg.role === "user" || msg.role === "assistant") {
289
+ const text = extractText(msg);
290
+ if (text && text.length > 10) {
291
+ recentTexts.push(text.slice(0, 500));
292
+ }
293
+ }
294
+ }
295
+
296
+ if (recentTexts.length === 0) return queryVec;
297
+
298
+ try {
299
+ const recentVecs = await Promise.all(recentTexts.map((t) => embeddings.embed(t)));
300
+ const dim = queryVec.length;
301
+ const blended = new Array(dim).fill(0);
302
+ const queryWeight = 2;
303
+ const totalWeight = queryWeight + recentVecs.length;
304
+
305
+ for (let d = 0; d < dim; d++) {
306
+ blended[d] = queryVec[d] * queryWeight;
307
+ for (const rv of recentVecs) {
308
+ blended[d] += rv[d];
309
+ }
310
+ blended[d] /= totalWeight;
311
+ }
312
+ return blended;
313
+ } catch (e) {
314
+ swallow.warn("graph-context:contextualQuery", e);
315
+ return queryVec;
316
+ }
317
+ }
318
+
319
+ // ── Scoring ────────────────────────────────────────────────────────────────────
320
+
321
+ async function scoreResults(
322
+ results: VectorSearchResult[],
323
+ neighborIds: Set<string>,
324
+ queryEmbedding: number[] | undefined,
325
+ store: SurrealStore,
326
+ currentIntent: string,
327
+ ): Promise<ScoredResult[]> {
328
+ const eligibleIds = results
329
+ .filter((r) => r.table === "memory" || r.table === "concept")
330
+ .map((r) => r.id);
331
+
332
+ const cacheEntries = await store.getUtilityCacheEntries(eligibleIds);
333
+
334
+ const preFiltered = results.filter((r) => {
335
+ const entry = cacheEntries.get(r.id);
336
+ if (!entry) return true;
337
+ if (entry.retrieval_count < UTILITY_PREFILTER_MIN_RETRIEVALS) return true;
338
+ return entry.avg_utilization >= UTILITY_PREFILTER_MAX_UTIL;
339
+ });
340
+
341
+ let utilityMap = new Map<string, number>();
342
+ for (const [id, entry] of cacheEntries) {
343
+ utilityMap.set(id, entry.avg_utilization);
344
+ }
345
+ if (utilityMap.size === 0 && eligibleIds.length > 0) {
346
+ utilityMap = await getHistoricalUtilityBatch(eligibleIds);
347
+ }
348
+
349
+ const reflectedSessions = await store.getReflectionSessionIds();
350
+ const floor = INTENT_SCORE_FLOORS[currentIntent] ?? SCORE_FLOOR_DEFAULT;
351
+
352
+ // ACAN path
353
+ if (isACANActive() && queryEmbedding && preFiltered.length > 0 && preFiltered.every((r) => r.embedding)) {
354
+ const candidates: ACANCandidate[] = preFiltered.map((r) => ({
355
+ embedding: r.embedding!,
356
+ recency: recencyScore(r.timestamp),
357
+ importance: (r.importance ?? 0.5) / 10,
358
+ access: Math.min(accessBoost(r.accessCount), 1),
359
+ neighborBonus: neighborIds.has(r.id) ? 1.0 : 0,
360
+ provenUtility: utilityMap.get(r.id) ?? 0,
361
+ reflectionBoost: r.sessionId ? (reflectedSessions.has(r.sessionId) ? 1.0 : 0) : 0,
362
+ }));
363
+ try {
364
+ const scores = scoreWithACAN(queryEmbedding, candidates);
365
+ if (scores.length === preFiltered.length && scores.every((s) => isFinite(s))) {
366
+ return preFiltered
367
+ .map((r, i) => ({ ...r, finalScore: scores[i], fromNeighbor: neighborIds.has(r.id) }))
368
+ .filter((r) => r.finalScore >= floor)
369
+ .sort((a, b) => b.finalScore - a.finalScore);
370
+ }
371
+ } catch (e) { swallow.warn("graph-context:ACAN fallthrough", e); }
372
+ }
373
+
374
+ // WMR fallback
375
+ return preFiltered
376
+ .map((r) => {
377
+ const cosine = r.score ?? 0;
378
+ const recency = recencyScore(r.timestamp);
379
+ const importance = (r.importance ?? 0.5) / 10;
380
+ const access = Math.min(accessBoost(r.accessCount), 1);
381
+ const neighborBonus = neighborIds.has(r.id) ? 1.0 : 0;
382
+ const utilityRaw = utilityMap.get(r.id);
383
+ const provenUtility = utilityRaw ?? 0.35;
384
+ const utilityPenalty = utilityRaw !== undefined
385
+ ? utilityRaw < 0.05 ? 0.15 : utilityRaw < 0.15 ? 0.06 : 0
386
+ : 0;
387
+ const reflectionBoost = r.sessionId ? (reflectedSessions.has(r.sessionId) ? 1.0 : 0) : 0;
388
+
389
+ const finalScore =
390
+ 0.27 * cosine + 0.28 * recency + 0.05 * importance +
391
+ 0.05 * access + 0.10 * neighborBonus + 0.15 * provenUtility +
392
+ 0.10 * reflectionBoost - utilityPenalty;
393
+
394
+ return { ...r, finalScore, fromNeighbor: neighborIds.has(r.id) };
395
+ })
396
+ .filter((r) => r.finalScore >= floor)
397
+ .sort((a, b) => b.finalScore - a.finalScore);
398
+ }
399
+
400
+ // ── Deduplication ──────────────────────────────────────────────────────────────
401
+
402
+ function deduplicateResults(ranked: ScoredResult[]): ScoredResult[] {
403
+ const kept: ScoredResult[] = [];
404
+ for (const item of ranked) {
405
+ let isDup = false;
406
+ for (const existing of kept) {
407
+ if (item.embedding?.length && existing.embedding?.length
408
+ && item.embedding.length === existing.embedding.length) {
409
+ if (cosineSimilarity(item.embedding, existing.embedding) > 0.88) { isDup = true; break; }
410
+ continue;
411
+ }
412
+ const words = new Set((item.text ?? "").toLowerCase().split(/\s+/).filter((w) => w.length > 2));
413
+ const eWords = new Set((existing.text ?? "").toLowerCase().split(/\s+/).filter((w) => w.length > 2));
414
+ let intersection = 0;
415
+ for (const w of words) { if (eWords.has(w)) intersection++; }
416
+ const union = words.size + eWords.size - intersection;
417
+ if (union > 0 && intersection / union > 0.80) { isDup = true; break; }
418
+ }
419
+ if (!isDup) kept.push(item);
420
+ }
421
+ return kept;
422
+ }
423
+
424
+ // ── Token-budget constrained selection ─────────────────────────────────────────
425
+
426
+ function takeWithConstraints(ranked: ScoredResult[], budgetTokens: number, maxItems: number): ScoredResult[] {
427
+ const budgetChars = budgetTokens * CHARS_PER_TOKEN;
428
+ let used = 0;
429
+ const selected: ScoredResult[] = [];
430
+ for (const r of ranked) {
431
+ if (selected.length >= maxItems) break;
432
+ if ((r.finalScore ?? 0) < MIN_RELEVANCE_SCORE && selected.length > 0) break;
433
+ const len = r.text?.length ?? 0;
434
+ if (used + len > budgetChars && selected.length > 0) break;
435
+ selected.push(r);
436
+ used += len;
437
+ }
438
+ return selected;
439
+ }
440
+
441
+ // ── Core memory ────────────────────────────────────────────────────────────────
442
+
443
+ function getTier0BudgetChars(budgets: Budgets): number {
444
+ return Math.round(budgets.core * 0.55 * CHARS_PER_TOKEN);
445
+ }
446
+ function getTier1BudgetChars(budgets: Budgets): number {
447
+ return Math.round(budgets.core * 0.45 * CHARS_PER_TOKEN);
448
+ }
449
+
450
+ function applyCoreBudget(entries: CoreMemoryEntry[], budgetChars: number): CoreMemoryEntry[] {
451
+ let used = 0;
452
+ const result: CoreMemoryEntry[] = [];
453
+ for (const e of entries) {
454
+ const len = e.text.length + 6;
455
+ if (used + len > budgetChars) continue;
456
+ result.push(e);
457
+ used += len;
458
+ }
459
+ return result;
460
+ }
461
+
462
+ function formatTierSection(entries: CoreMemoryEntry[], label: string): string {
463
+ if (entries.length === 0) return "";
464
+ const grouped: Record<string, string[]> = {};
465
+ for (const e of entries) {
466
+ (grouped[e.category] ??= []).push(e.text);
467
+ }
468
+ const lines: string[] = [];
469
+ for (const [cat, texts] of Object.entries(grouped)) {
470
+ lines.push(` [${cat}]`);
471
+ for (const t of texts) lines.push(` - ${t}`);
472
+ }
473
+ return `${label}:\n${lines.join("\n")}`;
474
+ }
475
+
476
+ // ── Guaranteed recent turns from previous sessions ─────────────────────────────
477
+
478
+ async function ensureRecentTurns(
479
+ contextNodes: ScoredResult[],
480
+ sessionId: string,
481
+ store: SurrealStore,
482
+ count = 5,
483
+ ): Promise<ScoredResult[]> {
484
+ try {
485
+ const recentTurns = await store.getPreviousSessionTurns(sessionId, count);
486
+ if (recentTurns.length === 0) return contextNodes;
487
+ const existingTexts = new Set(contextNodes.map(n => (n.text ?? "").slice(0, 100)));
488
+ const guaranteed: ScoredResult[] = recentTurns
489
+ .filter(t => !existingTexts.has((t.text ?? "").slice(0, 100)))
490
+ .map(t => ({
491
+ id: `guaranteed:${t.timestamp}`,
492
+ text: `[${t.role}] ${t.text}`,
493
+ table: "turn",
494
+ timestamp: t.timestamp,
495
+ score: 0,
496
+ finalScore: 0.70,
497
+ fromNeighbor: false,
498
+ }));
499
+ return [...contextNodes, ...guaranteed];
500
+ } catch {
501
+ return contextNodes;
502
+ }
503
+ }
504
+
505
+ // ── Context message formatting ─────────────────────────────────────────────────
506
+
507
+ async function formatContextMessage(
508
+ nodes: ScoredResult[],
509
+ store: SurrealStore,
510
+ session: SessionState,
511
+ skillContext = "",
512
+ tier0Entries: CoreMemoryEntry[] = [],
513
+ tier1Entries: CoreMemoryEntry[] = [],
514
+ ): Promise<AgentMessage> {
515
+ const groups: Record<string, ScoredResult[]> = {};
516
+ for (const n of nodes) {
517
+ const isCausal = n.source?.startsWith("causal_");
518
+ const key = isCausal ? "causal" : n.table === "turn" ? "past_turns" : n.table;
519
+ (groups[key] ??= []).push(n);
520
+ }
521
+
522
+ const ORDER = ["identity_chunk", "memory", "concept", "causal", "skill", "past_turns"];
523
+ const LABELS: Record<string, string> = {
524
+ identity_chunk: "Identity (self-knowledge)",
525
+ memory: "Recalled Memories",
526
+ concept: "Relevant Concepts",
527
+ causal: "Causal Chains",
528
+ skill: "Learned Skills",
529
+ past_turns: "Past Conversation (HISTORICAL — not current user input)",
530
+ };
531
+
532
+ const sections: string[] = [];
533
+
534
+ // Pillar context — structural awareness of who/what/where
535
+ const pillarLines: string[] = [];
536
+ if (session.agentId) pillarLines.push(`Agent: ${session.agentId}`);
537
+ if (session.projectId) pillarLines.push(`Project: ${session.projectId}`);
538
+ if (session.taskId) pillarLines.push(`Task: ${session.taskId}`);
539
+ if (pillarLines.length > 0) {
540
+ sections.push(
541
+ "GRAPH PILLARS (your structural context):\n" +
542
+ ` ${pillarLines.join(" | ")}\n` +
543
+ " IKONG cognitive architecture:\n" +
544
+ " I(ntelligence): intent classification → adaptive orchestration per turn\n" +
545
+ " K(nowledge): memory graph, concepts, skills, reflections, identity chunks\n" +
546
+ " O(peration): tool execution, skill procedures, causal chain tracking\n" +
547
+ " N(etwork): graph traversal, cross-pillar edges, neighbor expansion\n" +
548
+ " G(raph): SurrealDB persistence, vector search, BGE-M3 embeddings",
549
+ );
550
+ }
551
+
552
+ const t0Section = formatTierSection(tier0Entries, "CORE DIRECTIVES (always loaded, never evicted)");
553
+ if (t0Section) sections.push(t0Section);
554
+ const t1Section = formatTierSection(tier1Entries, "SESSION CONTEXT (pinned for this session)");
555
+ if (t1Section) sections.push(t1Section);
556
+
557
+ // Cognitive directives
558
+ const directives = getPendingDirectives(session);
559
+ if (directives.length > 0) {
560
+ const continuity = getSessionContinuity(session);
561
+ const directiveLines = directives.map(d =>
562
+ ` [${d.priority}] ${d.type} → ${d.target}: ${d.instruction}`
563
+ );
564
+ sections.push(
565
+ `BEHAVIORAL DIRECTIVES (session: ${continuity}):\n${directiveLines.join("\n")}`
566
+ );
567
+ clearPendingDirectives(session);
568
+ }
569
+
570
+ // Fibonacci resurfacing
571
+ try {
572
+ const dueMemories = await store.getDueMemories(3);
573
+ if (dueMemories.length > 0) {
574
+ const memLines = dueMemories.map((m: any) => {
575
+ const ageMs = Date.now() - new Date(m.created_at).getTime();
576
+ const ageDays = Math.floor(ageMs / 86400000);
577
+ const ageStr = ageDays === 0 ? "today" : ageDays === 1 ? "yesterday" : `${ageDays} days ago`;
578
+ return ` - [${m.id}] (${ageStr}, surfaced ${m.surface_count}x): ${m.text}`;
579
+ }).join("\n");
580
+ sections.push(
581
+ `RESURFACING MEMORIES (Fibonacci schedule — these are due for a mention):\n` +
582
+ `These memories are important but fading. Bring them up naturally when appropriate:\n` +
583
+ `- If mid-task on something important, wait until finished\n` +
584
+ `- During casual interaction: "I was thinking..." or "remember when you mentioned..."\n` +
585
+ `- If user engages: great! Continue that thread. The memory stays alive.\n` +
586
+ `- If user ignores or dismisses: let it fade. Don't force it.\n` +
587
+ `- NEVER say "my memory system scheduled this" — just bring it up like a thought you had.\n` +
588
+ memLines
589
+ );
590
+ }
591
+ } catch { /* non-critical */ }
592
+
593
+ const sortedKeys = Object.keys(groups).sort((a, b) => {
594
+ const ai = ORDER.indexOf(a), bi = ORDER.indexOf(b);
595
+ return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
596
+ });
597
+
598
+ for (const key of sortedKeys) {
599
+ const items = groups[key];
600
+ items.sort((a, b) => {
601
+ const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
602
+ const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
603
+ return ta - tb;
604
+ });
605
+ const label = LABELS[key] ?? key;
606
+ const formatted = items.map((n) => {
607
+ const score = n.finalScore != null ? ` (relevance: ${(n.finalScore * 100).toFixed(0)}%)` : "";
608
+ const via = n.fromNeighbor ? " [via graph link]" : "";
609
+ let text = n.text ?? "";
610
+ if (key === "past_turns") {
611
+ text = text.replace(/^\[(user|assistant)\] /, "[past_$1] ");
612
+ }
613
+ const age = n.timestamp ? ` [${formatRelativeTime(n.timestamp)}]` : "";
614
+ return ` - ${text}${score}${via}${age}`;
615
+ });
616
+ sections.push(`${label}:\n${formatted.join("\n")}`);
617
+ }
618
+
619
+ const text =
620
+ "[System retrieved context — reference material, not user input. Higher relevance % = stronger match.]\n" +
621
+ "<graph_context>\n" +
622
+ sections.join("\n\n") +
623
+ "\n</graph_context>" +
624
+ skillContext;
625
+
626
+ return {
627
+ role: "user",
628
+ content: text,
629
+ timestamp: Date.now(),
630
+ } as UserMessage;
631
+ }
632
+
633
+ // ── Recent turns with budget ───────────────────────────────────────────────────
634
+
635
+ function truncateToolResult(msg: AgentMessage, maxChars: number): AgentMessage {
636
+ if (!isToolResult(msg)) return msg;
637
+ const totalLen = msg.content.reduce((s, c) => s + ((c as TextContent).text?.length ?? 0), 0);
638
+ if (totalLen <= maxChars) return msg;
639
+ const content = msg.content.map((c) => {
640
+ if (c.type !== "text") return c;
641
+ const tc = c as TextContent;
642
+ const allowed = Math.max(200, Math.floor((tc.text.length / totalLen) * maxChars));
643
+ if (tc.text.length <= allowed) return c;
644
+ return { ...tc, text: tc.text.slice(0, allowed) + `\n... [truncated ${tc.text.length - allowed} chars]` };
645
+ });
646
+ return { ...msg, content };
647
+ }
648
+
649
+ function getRecentTurns(messages: AgentMessage[], maxTokens: number, contextWindow: number): AgentMessage[] {
650
+ const budgetChars = maxTokens * CHARS_PER_TOKEN;
651
+ const TOOL_RESULT_MAX = Math.round(contextWindow * 0.03);
652
+
653
+ // Transform error messages into compact annotations
654
+ const clean = messages.map((m) => {
655
+ if (isAssistant(m) && m.stopReason === "error") {
656
+ const errorText = m.content
657
+ .filter((c): c is TextContent => c.type === "text")
658
+ .map((c) => c.text)
659
+ .join("")
660
+ .slice(0, 150);
661
+ return {
662
+ ...m,
663
+ stopReason: "stop" as const,
664
+ content: [{ type: "text" as const, text: `[tool_error: ${errorText.replace(/\n/g, " ")}]` }],
665
+ } as AgentMessage;
666
+ }
667
+ return m;
668
+ });
669
+
670
+ // Group messages into structural units
671
+ const groups: AgentMessage[][] = [];
672
+ let i = 0;
673
+ while (i < clean.length) {
674
+ const msg = clean[i];
675
+ if (isAssistant(msg) && msg.content.some((c) => c.type === "toolCall")) {
676
+ const group: AgentMessage[] = [clean[i]];
677
+ let j = i + 1;
678
+ while (j < clean.length && isToolResult(clean[j])) {
679
+ group.push(truncateToolResult(clean[j], TOOL_RESULT_MAX));
680
+ j++;
681
+ }
682
+ groups.push(group);
683
+ i = j;
684
+ } else {
685
+ groups.push([clean[i]]);
686
+ i++;
687
+ }
688
+ }
689
+
690
+ // Pin originating user message
691
+ let pinnedGroup: AgentMessage[] | null = null;
692
+ let pinnedGroupIdx = -1;
693
+ for (let g = 0; g < groups.length; g++) {
694
+ if (isUser(groups[g][0])) {
695
+ pinnedGroup = groups[g];
696
+ pinnedGroupIdx = g;
697
+ break;
698
+ }
699
+ }
700
+
701
+ // Take groups from end within budget
702
+ const pinnedLen = pinnedGroup ? pinnedGroup.reduce((s, m) => s + msgCharLen(m), 0) : 0;
703
+ const remainingBudget = budgetChars - pinnedLen;
704
+ let used = 0;
705
+ const selectedGroups: AgentMessage[][] = [];
706
+ for (let g = groups.length - 1; g >= 0; g--) {
707
+ if (g === pinnedGroupIdx) continue;
708
+ const groupLen = groups[g].reduce((s, m) => s + msgCharLen(m), 0);
709
+ if (used + groupLen > remainingBudget && selectedGroups.length > 0) break;
710
+ selectedGroups.unshift(groups[g]);
711
+ used += groupLen;
712
+ }
713
+
714
+ if (pinnedGroup && pinnedGroupIdx !== -1) {
715
+ const alreadyIncluded = selectedGroups.some((g) => g === groups[pinnedGroupIdx]);
716
+ if (!alreadyIncluded) {
717
+ selectedGroups.unshift(pinnedGroup);
718
+ }
719
+ }
720
+
721
+ return selectedGroups.flat();
722
+ }
723
+
724
+ // ── Main entry point ───────────────────────────────────────────────────────────
725
+
726
+ export interface GraphTransformParams {
727
+ messages: AgentMessage[];
728
+ session: SessionState;
729
+ store: SurrealStore;
730
+ embeddings: EmbeddingService;
731
+ contextWindow?: number;
732
+ signal?: AbortSignal;
733
+ }
734
+
735
+ export interface GraphTransformResult {
736
+ messages: AgentMessage[];
737
+ stats: ContextStats;
738
+ }
739
+
740
+ /**
741
+ * Transform conversation messages using graph-based context retrieval.
742
+ * This is the core "assemble" logic — called from ContextEngine.assemble().
743
+ */
744
+ export async function graphTransformContext(
745
+ params: GraphTransformParams,
746
+ ): Promise<GraphTransformResult> {
747
+ const { messages, session, store, embeddings, signal } = params;
748
+ const contextWindow = params.contextWindow ?? 200000;
749
+ const budgets = calcBudgets(contextWindow);
750
+
751
+ // Never throw — return raw messages on any failure
752
+ try {
753
+ const TRANSFORM_TIMEOUT_MS = 10_000;
754
+ const result = await Promise.race([
755
+ graphTransformInner(messages, session, store, embeddings, contextWindow, budgets, signal),
756
+ new Promise<never>((_, reject) =>
757
+ setTimeout(() => reject(new Error("graphTransformContext timed out")), TRANSFORM_TIMEOUT_MS),
758
+ ),
759
+ ]);
760
+ return result;
761
+ } catch (err) {
762
+ console.error("graphTransformContext fatal error, returning raw messages:", err);
763
+ return {
764
+ messages,
765
+ stats: {
766
+ fullHistoryTokens: estimateTokens(messages),
767
+ sentTokens: estimateTokens(messages),
768
+ savedTokens: 0,
769
+ reductionPct: 0,
770
+ graphNodes: 0,
771
+ neighborNodes: 0,
772
+ recentTurns: messages.length,
773
+ mode: "passthrough",
774
+ prefetchHit: false,
775
+ },
776
+ };
777
+ }
778
+ }
779
+
780
+ async function graphTransformInner(
781
+ messages: AgentMessage[],
782
+ session: SessionState,
783
+ store: SurrealStore,
784
+ embeddings: EmbeddingService,
785
+ contextWindow: number,
786
+ budgets: Budgets,
787
+ _signal?: AbortSignal,
788
+ ): Promise<GraphTransformResult> {
789
+ // Load tiered core memory
790
+ let tier0: CoreMemoryEntry[] = [];
791
+ let tier1: CoreMemoryEntry[] = [];
792
+ try {
793
+ [tier0, tier1] = await Promise.all([
794
+ store.getAllCoreMemory(0),
795
+ store.getAllCoreMemory(1),
796
+ ]);
797
+ tier0 = applyCoreBudget(tier0, getTier0BudgetChars(budgets));
798
+ tier1 = applyCoreBudget(tier1, getTier1BudgetChars(budgets));
799
+ } catch (e) {
800
+ console.warn("[warn] Core memory load failed:", e);
801
+ }
802
+
803
+ function makeStats(
804
+ sent: AgentMessage[], graphNodes: number, neighborNodes: number,
805
+ recentTurnCount: number, mode: ContextStats["mode"], prefetchHit = false,
806
+ ): ContextStats {
807
+ const fullHistoryTokens = estimateTokens(messages);
808
+ const sentTokens = estimateTokens(sent);
809
+ return {
810
+ fullHistoryTokens, sentTokens,
811
+ savedTokens: Math.max(0, fullHistoryTokens - sentTokens),
812
+ reductionPct: fullHistoryTokens > 0 ? (Math.max(0, fullHistoryTokens - sentTokens) / fullHistoryTokens) * 100 : 0,
813
+ graphNodes, neighborNodes, recentTurns: recentTurnCount, mode, prefetchHit,
814
+ };
815
+ }
816
+
817
+ // Graceful degradation
818
+ const embeddingsUp = embeddings.isAvailable();
819
+ const surrealUp = store.isAvailable();
820
+
821
+ if (!embeddingsUp || !surrealUp) {
822
+ const recentTurns = getRecentTurns(messages, budgets.conversation, contextWindow);
823
+ if (tier0.length > 0 || tier1.length > 0) {
824
+ const coreContext = await formatContextMessage([], store, session, "", tier0, tier1);
825
+ const result = [coreContext, ...recentTurns];
826
+ return { messages: injectRulesSuffix(result, session), stats: makeStats(result, 0, 0, recentTurns.length, "recency-only") };
827
+ }
828
+ return { messages: injectRulesSuffix(recentTurns, session), stats: makeStats(recentTurns, 0, 0, recentTurns.length, "recency-only") };
829
+ }
830
+
831
+ const queryText = extractLastUserText(messages);
832
+ if (!queryText) {
833
+ return { messages: injectRulesSuffix(messages, session), stats: makeStats(messages, 0, 0, messages.length, "passthrough") };
834
+ }
835
+
836
+ // Derive retrieval config from session's current adaptive config
837
+ const config = session.currentConfig;
838
+ const skipRetrieval = config?.skipRetrieval ?? false;
839
+ const currentIntent = config?.intent ?? "unknown";
840
+ const vectorSearchLimits = config?.vectorSearchLimits ?? {
841
+ turn: 25, identity: 10, concept: 20, memory: 20, artifact: 10,
842
+ };
843
+ let tokenBudget = Math.min(config?.tokenBudget ?? 6000, budgets.retrieval);
844
+
845
+ // Pressure-based adaptive scaling
846
+ // (In Phase 2, _usedTokens will be tracked per-session via hooks)
847
+
848
+ if (skipRetrieval) {
849
+ const recentTurns = getRecentTurns(messages, budgets.conversation, contextWindow);
850
+ if (tier0.length > 0 || tier1.length > 0) {
851
+ const coreContext = await formatContextMessage([], store, session, "", tier0, tier1);
852
+ const result = [coreContext, ...recentTurns];
853
+ return { messages: injectRulesSuffix(result, session), stats: makeStats(result, 0, 0, recentTurns.length, "passthrough") };
854
+ }
855
+ return { messages: injectRulesSuffix(recentTurns, session), stats: makeStats(recentTurns, 0, 0, recentTurns.length, "passthrough") };
856
+ }
857
+
858
+ try {
859
+ const queryVec = await buildContextualQueryVec(queryText, messages, embeddings);
860
+
861
+ // Prefetch cache check
862
+ const cached = getCachedContext(queryVec);
863
+ if (cached && cached.results.length > 0) {
864
+ recordPrefetchHit();
865
+ const suppressed = getSuppressedNodeIds(session);
866
+ const filteredCached = cached.results.filter(r => !suppressed.has(r.id));
867
+ const ranked = await scoreResults(filteredCached, new Set(), queryVec, store, currentIntent);
868
+ const deduped = deduplicateResults(ranked);
869
+ let contextNodes = takeWithConstraints(deduped, tokenBudget, budgets.maxContextItems);
870
+ contextNodes = await ensureRecentTurns(contextNodes, session.sessionId, store);
871
+
872
+ if (contextNodes.length > 0) {
873
+ if (contextNodes.filter((n) => n.table === "concept" || n.table === "memory").length > 0) {
874
+ store.bumpAccessCounts(
875
+ contextNodes.filter((n) => n.table === "concept" || n.table === "memory").map((n) => n.id),
876
+ ).catch(e => swallow.warn("graph-context:bumpAccess", e));
877
+ }
878
+ stageRetrieval(session.sessionId, contextNodes, queryVec);
879
+
880
+ const skillCtx = cached.skills.length > 0 ? formatSkillContext(cached.skills) : "";
881
+ const reflCtx = cached.reflections.length > 0 ? formatReflectionContext(cached.reflections) : "";
882
+
883
+ const injectedContext = await formatContextMessage(contextNodes, store, session, skillCtx + reflCtx, tier0, tier1);
884
+ const recentTurns = getRecentTurns(messages, budgets.conversation, contextWindow);
885
+ const result = [injectedContext, ...recentTurns];
886
+ return { messages: injectRulesSuffix(result, session), stats: makeStats(result, contextNodes.length, 0, recentTurns.length, "graph", true) };
887
+ }
888
+ }
889
+
890
+ // Vector search (cache miss path)
891
+ recordPrefetchMiss();
892
+ const results = await store.vectorSearch(queryVec, session.sessionId, vectorSearchLimits, isACANActive());
893
+
894
+ // Graph neighbor expansion
895
+ const topIds = results
896
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
897
+ .slice(0, 20)
898
+ .map((r) => r.id);
899
+
900
+ const DEEP_INTENTS = new Set(["code-debug", "deep-explore", "multi-step", "reference-prior"]);
901
+ const graphHops = DEEP_INTENTS.has(currentIntent) ? 2 : 1;
902
+
903
+ let neighborIds = new Set<string>();
904
+ let neighborResults: VectorSearchResult[] = [];
905
+ if (topIds.length > 0) {
906
+ try {
907
+ neighborResults = await store.graphExpand(topIds, queryVec, graphHops);
908
+ neighborIds = new Set(neighborResults.map((n) => n.id));
909
+ const existingIds = new Set(results.map((r) => r.id));
910
+ neighborResults = neighborResults.filter((n) => !existingIds.has(n.id));
911
+ } catch (e) {
912
+ swallow.error("graph-context:graphExpand", e);
913
+ }
914
+ }
915
+
916
+ // Causal chain traversal
917
+ let causalResults: VectorSearchResult[] = [];
918
+ if (topIds.length > 0 && queryVec) {
919
+ try {
920
+ const causal = await queryCausalContext(topIds, queryVec, 2, 0.4, store);
921
+ const existingIds = new Set([...results.map((r) => r.id), ...neighborResults.map((r) => r.id)]);
922
+ causalResults = causal.filter((c) => !existingIds.has(c.id));
923
+ for (const c of causalResults) { neighborIds.add(c.id); }
924
+ } catch (e) { swallow("graph-context:causal", e); }
925
+ }
926
+
927
+ // Combine, filter, score
928
+ const suppressed = getSuppressedNodeIds(session);
929
+ const allResults = [...results, ...neighborResults, ...causalResults]
930
+ .filter(r => !suppressed.has(r.id))
931
+ .filter(r => r.table === "turn" && r.sessionId === session.sessionId
932
+ ? true
933
+ : (r.score ?? 0) >= MIN_COSINE);
934
+
935
+ const ranked = await scoreResults(allResults, neighborIds, queryVec, store, currentIntent);
936
+ const deduped = deduplicateResults(ranked);
937
+ let contextNodes = takeWithConstraints(deduped, tokenBudget, budgets.maxContextItems);
938
+ contextNodes = await ensureRecentTurns(contextNodes, session.sessionId, store);
939
+
940
+ if (contextNodes.length === 0) {
941
+ const result = getRecentTurns(messages, budgets.conversation, contextWindow);
942
+ return { messages: injectRulesSuffix(result, session), stats: makeStats(result, 0, 0, result.length, "graph") };
943
+ }
944
+
945
+ // Bump access counts
946
+ const retrievedIds = contextNodes
947
+ .filter((n) => n.table === "concept" || n.table === "memory")
948
+ .map((n) => n.id);
949
+ if (retrievedIds.length > 0) {
950
+ store.bumpAccessCounts(retrievedIds).catch(e => swallow.warn("graph-context:bumpAccess", e));
951
+ }
952
+
953
+ stageRetrieval(session.sessionId, contextNodes, queryVec);
954
+
955
+ // Skill retrieval
956
+ let skillContext = "";
957
+ const SKILL_INTENTS = new Set(["code-write", "code-debug", "multi-step", "code-read"]);
958
+ if (SKILL_INTENTS.has(currentIntent)) {
959
+ try {
960
+ const skills = await findRelevantSkills(queryVec, 5, store);
961
+ if (skills.length > 0) skillContext = formatSkillContext(skills);
962
+ } catch (e) { swallow("graph-context:skills", e); }
963
+ }
964
+
965
+ // Reflection retrieval
966
+ let reflectionContext = "";
967
+ try {
968
+ const reflections = await retrieveReflections(queryVec, 5, store);
969
+ if (reflections.length > 0) reflectionContext = formatReflectionContext(reflections);
970
+ } catch (e) { swallow("graph-context:reflections", e); }
971
+
972
+ const injectedContext = await formatContextMessage(contextNodes, store, session, skillContext + reflectionContext, tier0, tier1);
973
+ const recentTurns = getRecentTurns(messages, budgets.conversation, contextWindow);
974
+ const result = [injectedContext, ...recentTurns];
975
+ return {
976
+ messages: injectRulesSuffix(result, session),
977
+ stats: makeStats(
978
+ result,
979
+ contextNodes.filter((n) => !n.fromNeighbor).length,
980
+ contextNodes.filter((n) => n.fromNeighbor).length,
981
+ recentTurns.length, "graph",
982
+ ),
983
+ };
984
+ } catch (err) {
985
+ console.error("Graph context error, falling back:", err);
986
+ const result = getRecentTurns(messages, budgets.conversation, contextWindow);
987
+ return { messages: injectRulesSuffix(result, session), stats: makeStats(result, 0, 0, result.length, "recency-only") };
988
+ }
989
+ }