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.
- package/LICENSE +21 -0
- package/README.md +385 -0
- package/openclaw.plugin.json +66 -0
- package/package.json +65 -0
- package/src/acan.ts +309 -0
- package/src/causal.ts +237 -0
- package/src/cognitive-check.ts +330 -0
- package/src/config.ts +64 -0
- package/src/context-engine.ts +487 -0
- package/src/daemon-manager.ts +148 -0
- package/src/daemon-types.ts +65 -0
- package/src/embeddings.ts +77 -0
- package/src/errors.ts +43 -0
- package/src/graph-context.ts +989 -0
- package/src/hooks/after-tool-call.ts +99 -0
- package/src/hooks/before-prompt-build.ts +44 -0
- package/src/hooks/before-tool-call.ts +86 -0
- package/src/hooks/llm-output.ts +173 -0
- package/src/identity.ts +218 -0
- package/src/index.ts +435 -0
- package/src/intent.ts +190 -0
- package/src/memory-daemon.ts +495 -0
- package/src/orchestrator.ts +348 -0
- package/src/prefetch.ts +200 -0
- package/src/reflection.ts +280 -0
- package/src/retrieval-quality.ts +266 -0
- package/src/schema.surql +387 -0
- package/src/skills.ts +343 -0
- package/src/soul.ts +936 -0
- package/src/state.ts +119 -0
- package/src/surreal.ts +1371 -0
- package/src/tools/core-memory.ts +120 -0
- package/src/tools/introspect.ts +329 -0
- package/src/tools/recall.ts +102 -0
- package/src/wakeup.ts +318 -0
- package/src/workspace-migrate.ts +752 -0
|
@@ -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
|
+
}
|