@mnexium/core 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/.env.example +37 -0
- package/README.md +37 -0
- package/docs/API.md +299 -0
- package/docs/BEHAVIOR.md +224 -0
- package/docs/OPERATIONS.md +116 -0
- package/docs/SETUP.md +239 -0
- package/package.json +22 -0
- package/scripts/e2e.lib.mjs +604 -0
- package/scripts/e2e.routes.mjs +32 -0
- package/scripts/e2e.sh +76 -0
- package/scripts/e2e.webapp.client.js +408 -0
- package/scripts/e2e.webapp.mjs +1065 -0
- package/sql/postgres/schema.sql +275 -0
- package/src/adapters/postgres/PostgresCoreStore.ts +1017 -0
- package/src/ai/memoryExtractionService.ts +265 -0
- package/src/ai/recallService.ts +442 -0
- package/src/ai/types.ts +11 -0
- package/src/contracts/storage.ts +137 -0
- package/src/contracts/types.ts +138 -0
- package/src/dev.ts +144 -0
- package/src/index.ts +15 -0
- package/src/providers/cerebras.ts +101 -0
- package/src/providers/openaiChat.ts +116 -0
- package/src/providers/openaiEmbedding.ts +52 -0
- package/src/server/createCoreServer.ts +1154 -0
- package/src/server/memoryEventBus.ts +57 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import type { CerebrasClient } from "../providers/cerebras";
|
|
2
|
+
import type { JsonLlmClient } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface ExtractedClaim {
|
|
5
|
+
predicate: string;
|
|
6
|
+
object_value: string;
|
|
7
|
+
claim_type?: string;
|
|
8
|
+
confidence?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ExtractedMemory {
|
|
12
|
+
text: string;
|
|
13
|
+
kind: "fact" | "preference" | "context" | "note" | "event" | "trait";
|
|
14
|
+
importance: number;
|
|
15
|
+
confidence: number;
|
|
16
|
+
is_temporal: boolean;
|
|
17
|
+
visibility: "private" | "shared" | "public";
|
|
18
|
+
tags: string[];
|
|
19
|
+
claims: ExtractedClaim[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface MemoryExtractionResult {
|
|
23
|
+
memories: ExtractedMemory[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MemoryExtractionService {
|
|
27
|
+
name: string;
|
|
28
|
+
extract(args: {
|
|
29
|
+
subject_id: string;
|
|
30
|
+
text: string;
|
|
31
|
+
force?: boolean;
|
|
32
|
+
conversation_context?: string[];
|
|
33
|
+
}): Promise<MemoryExtractionResult>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CreateLLMMemoryExtractionServiceOptions {
|
|
37
|
+
llm: JsonLlmClient;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function asBool(value: unknown, fallback = false): boolean {
|
|
41
|
+
if (typeof value === "boolean") return value;
|
|
42
|
+
if (typeof value === "string") return value.toLowerCase() === "true";
|
|
43
|
+
return fallback;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function asNumber(value: unknown, fallback: number, min: number, max: number): number {
|
|
47
|
+
const n = Number(value);
|
|
48
|
+
if (!Number.isFinite(n)) return fallback;
|
|
49
|
+
if (n < min) return min;
|
|
50
|
+
if (n > max) return max;
|
|
51
|
+
return n;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function asStringArray(value: unknown): string[] {
|
|
55
|
+
if (!Array.isArray(value)) return [];
|
|
56
|
+
return value.map((v) => String(v || "").trim()).filter(Boolean);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeKind(value: unknown): ExtractedMemory["kind"] {
|
|
60
|
+
const v = String(value || "").toLowerCase().trim();
|
|
61
|
+
if (v === "fact" || v === "preference" || v === "context" || v === "note" || v === "event" || v === "trait") {
|
|
62
|
+
return v;
|
|
63
|
+
}
|
|
64
|
+
return "fact";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeVisibility(value: unknown): ExtractedMemory["visibility"] {
|
|
68
|
+
const v = String(value || "").toLowerCase().trim();
|
|
69
|
+
if (v === "private" || v === "shared" || v === "public") return v;
|
|
70
|
+
return "private";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeClaim(raw: unknown): ExtractedClaim | null {
|
|
74
|
+
if (!raw || typeof raw !== "object") return null;
|
|
75
|
+
const obj = raw as Record<string, unknown>;
|
|
76
|
+
const predicate = String(obj.predicate || "").trim();
|
|
77
|
+
const objectValue = String(obj.object_value || "").trim();
|
|
78
|
+
if (!predicate || !objectValue) return null;
|
|
79
|
+
const out: ExtractedClaim = {
|
|
80
|
+
predicate,
|
|
81
|
+
object_value: objectValue,
|
|
82
|
+
};
|
|
83
|
+
const claimType = String(obj.claim_type || "").trim();
|
|
84
|
+
if (claimType) out.claim_type = claimType;
|
|
85
|
+
const confidence = Number(obj.confidence);
|
|
86
|
+
if (Number.isFinite(confidence)) out.confidence = Math.max(0, Math.min(1, confidence));
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizeMemory(raw: unknown): ExtractedMemory | null {
|
|
91
|
+
if (!raw || typeof raw !== "object") return null;
|
|
92
|
+
const obj = raw as Record<string, unknown>;
|
|
93
|
+
const text = String(obj.text || "").trim();
|
|
94
|
+
if (!text) return null;
|
|
95
|
+
const claimsRaw = Array.isArray(obj.claims) ? obj.claims : [];
|
|
96
|
+
const claims = claimsRaw.map(normalizeClaim).filter((v): v is ExtractedClaim => !!v);
|
|
97
|
+
return {
|
|
98
|
+
text,
|
|
99
|
+
kind: normalizeKind(obj.kind),
|
|
100
|
+
importance: asNumber(obj.importance, 50, 0, 100),
|
|
101
|
+
confidence: asNumber(obj.confidence, 0.8, 0, 1),
|
|
102
|
+
is_temporal: asBool(obj.is_temporal, false),
|
|
103
|
+
visibility: normalizeVisibility(obj.visibility),
|
|
104
|
+
tags: asStringArray(obj.tags),
|
|
105
|
+
claims,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalizePredicate(raw: string): string {
|
|
110
|
+
return raw
|
|
111
|
+
.toLowerCase()
|
|
112
|
+
.replace(/[^a-z0-9_ ]/g, "")
|
|
113
|
+
.trim()
|
|
114
|
+
.replace(/\s+/g, "_");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildSimpleClaims(text: string): ExtractedClaim[] {
|
|
118
|
+
const claims: ExtractedClaim[] = [];
|
|
119
|
+
const push = (predicate: string, value: string, claimType = "fact", confidence = 0.65) => {
|
|
120
|
+
const p = normalizePredicate(predicate);
|
|
121
|
+
const v = String(value || "").trim();
|
|
122
|
+
if (!p || !v) return;
|
|
123
|
+
const key = `${p}::${v.toLowerCase()}`;
|
|
124
|
+
if (claims.some((c) => `${c.predicate}::${c.object_value.toLowerCase()}` === key)) return;
|
|
125
|
+
claims.push({
|
|
126
|
+
predicate: p,
|
|
127
|
+
object_value: v,
|
|
128
|
+
claim_type: claimType,
|
|
129
|
+
confidence,
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const patterns: Array<[RegExp, (m: RegExpMatchArray) => void]> = [
|
|
134
|
+
[/my name is\s+([^.,!?\n]+)/i, (m) => push("name", m[1], "fact", 0.9)],
|
|
135
|
+
[/i live in\s+([^.,!?\n]+)/i, (m) => push("lives_in", m[1], "fact", 0.85)],
|
|
136
|
+
[/i work at\s+([^.,!?\n]+)/i, (m) => push("works_at", m[1], "fact", 0.85)],
|
|
137
|
+
[/my favorite\s+([a-zA-Z ]+)\s+is\s+([^.,!?\n]+)/i, (m) => push(`favorite_${m[1]}`, m[2], "preference", 0.85)],
|
|
138
|
+
[/i like\s+([^.,!?\n]+)/i, (m) => push("likes", m[1], "preference", 0.7)],
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
for (const [pattern, handler] of patterns) {
|
|
142
|
+
const match = text.match(pattern);
|
|
143
|
+
if (match) handler(match);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return claims;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function simpleExtract(args: {
|
|
150
|
+
text: string;
|
|
151
|
+
force?: boolean;
|
|
152
|
+
}): MemoryExtractionResult {
|
|
153
|
+
const text = String(args.text || "").trim().replace(/\s+/g, " ");
|
|
154
|
+
if (!text) return { memories: [] };
|
|
155
|
+
|
|
156
|
+
const lower = text.toLowerCase();
|
|
157
|
+
const trivial =
|
|
158
|
+
/^(ok|thanks|thank you|cool|nice|yes|no|yep|nope|hi|hello|hey)\b/.test(lower) &&
|
|
159
|
+
text.length < 40;
|
|
160
|
+
|
|
161
|
+
if (trivial && !args.force) {
|
|
162
|
+
return { memories: [] };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const claims = buildSimpleClaims(text);
|
|
166
|
+
return {
|
|
167
|
+
memories: [
|
|
168
|
+
{
|
|
169
|
+
text: text.slice(0, 2000),
|
|
170
|
+
kind: claims.length > 0 ? "fact" : "note",
|
|
171
|
+
importance: args.force ? 70 : 50,
|
|
172
|
+
confidence: claims.length > 0 ? 0.75 : 0.6,
|
|
173
|
+
is_temporal: false,
|
|
174
|
+
visibility: "private",
|
|
175
|
+
tags: ["simple_mode"],
|
|
176
|
+
claims,
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function createLLMMemoryExtractionService(
|
|
183
|
+
options: CreateLLMMemoryExtractionServiceOptions,
|
|
184
|
+
): MemoryExtractionService {
|
|
185
|
+
const { llm } = options;
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
name: `${llm.provider}:${llm.model}`,
|
|
189
|
+
async extract(args) {
|
|
190
|
+
const text = String(args.text || "").trim();
|
|
191
|
+
if (!text) return { memories: [] };
|
|
192
|
+
|
|
193
|
+
const force = args.force === true;
|
|
194
|
+
const contextBlock = Array.isArray(args.conversation_context) && args.conversation_context.length > 0
|
|
195
|
+
? `\nRecent conversation:\n${args.conversation_context.map((m, i) => `${i + 1}. ${m}`).join("\n")}\n`
|
|
196
|
+
: "";
|
|
197
|
+
|
|
198
|
+
const systemPrompt = `You extract durable user memories from chat text.
|
|
199
|
+
Return strict JSON:
|
|
200
|
+
{
|
|
201
|
+
"memories": [
|
|
202
|
+
{
|
|
203
|
+
"text": "string",
|
|
204
|
+
"kind": "fact|preference|context|note|event|trait",
|
|
205
|
+
"importance": 0-100,
|
|
206
|
+
"confidence": 0-1,
|
|
207
|
+
"is_temporal": true|false,
|
|
208
|
+
"visibility": "private|shared|public",
|
|
209
|
+
"tags": ["string"],
|
|
210
|
+
"claims": [
|
|
211
|
+
{
|
|
212
|
+
"predicate": "string",
|
|
213
|
+
"object_value": "string",
|
|
214
|
+
"claim_type": "string",
|
|
215
|
+
"confidence": 0-1
|
|
216
|
+
}
|
|
217
|
+
]
|
|
218
|
+
}
|
|
219
|
+
]
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
Rules:
|
|
223
|
+
- Prefer durable, user-specific memories.
|
|
224
|
+
- Keep memory text concise and factual.
|
|
225
|
+
- Use empty list when no durable memory exists.
|
|
226
|
+
- If force=true, return at least one memory if possible.`;
|
|
227
|
+
|
|
228
|
+
const userPrompt = `${contextBlock}subject_id=${args.subject_id}\nforce=${force}\ntext:\n${text}`;
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const result = await llm.call({
|
|
232
|
+
systemPrompt,
|
|
233
|
+
userPrompt,
|
|
234
|
+
jsonMode: true,
|
|
235
|
+
timeoutMs: 4000,
|
|
236
|
+
});
|
|
237
|
+
const memoriesRaw = Array.isArray(result?.memories) ? result.memories : [];
|
|
238
|
+
const memories = memoriesRaw.map(normalizeMemory).filter((v): v is ExtractedMemory => !!v);
|
|
239
|
+
if (memories.length > 0) return { memories };
|
|
240
|
+
} catch {
|
|
241
|
+
// Fall through to simple extraction fallback.
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return simpleExtract({ text, force });
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function createSimpleMemoryExtractionService(): MemoryExtractionService {
|
|
250
|
+
return {
|
|
251
|
+
name: "simple",
|
|
252
|
+
async extract(args) {
|
|
253
|
+
return simpleExtract(args);
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Compatibility helper to preserve previous import path.
|
|
259
|
+
export function createCerebrasMemoryExtractionService(options: {
|
|
260
|
+
cerebras: CerebrasClient;
|
|
261
|
+
}): MemoryExtractionService {
|
|
262
|
+
return createLLMMemoryExtractionService({
|
|
263
|
+
llm: options.cerebras as unknown as JsonLlmClient,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import type { CoreStore } from "../contracts/storage";
|
|
2
|
+
import type { Memory } from "../contracts/types";
|
|
3
|
+
import type { CerebrasClient } from "../providers/cerebras";
|
|
4
|
+
import type { JsonLlmClient } from "./types";
|
|
5
|
+
|
|
6
|
+
const CLASSIFY_TIMEOUT_MS = 2000;
|
|
7
|
+
const RERANK_TIMEOUT_MS = 3000;
|
|
8
|
+
|
|
9
|
+
export type RecallMode = "broad" | "direct" | "indirect" | "simple";
|
|
10
|
+
|
|
11
|
+
type ScoredMemory = Memory & {
|
|
12
|
+
score: number;
|
|
13
|
+
effective_score: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export interface RecallService {
|
|
17
|
+
name: string;
|
|
18
|
+
search(args: {
|
|
19
|
+
project_id: string;
|
|
20
|
+
subject_id: string;
|
|
21
|
+
query: string;
|
|
22
|
+
limit: number;
|
|
23
|
+
min_score: number;
|
|
24
|
+
conversation_context?: string[];
|
|
25
|
+
}): Promise<{
|
|
26
|
+
memories: ScoredMemory[];
|
|
27
|
+
mode: RecallMode;
|
|
28
|
+
used_queries: string[];
|
|
29
|
+
predicates: string[];
|
|
30
|
+
}>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CreateLLMRecallServiceOptions {
|
|
34
|
+
store: CoreStore;
|
|
35
|
+
llm: JsonLlmClient;
|
|
36
|
+
embed: (text: string) => Promise<number[]>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CreateSimpleRecallServiceOptions {
|
|
40
|
+
store: CoreStore;
|
|
41
|
+
embed?: (text: string) => Promise<number[]>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function asStringArray(value: unknown): string[] {
|
|
45
|
+
if (!Array.isArray(value)) return [];
|
|
46
|
+
return value
|
|
47
|
+
.map((v) => String(v || "").trim())
|
|
48
|
+
.filter(Boolean);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function scoreNum(v: unknown, fallback = 0): number {
|
|
52
|
+
const n = Number(v);
|
|
53
|
+
if (!Number.isFinite(n)) return fallback;
|
|
54
|
+
return n;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function clampInt(v: number, min: number, max: number): number {
|
|
58
|
+
if (!Number.isFinite(v)) return min;
|
|
59
|
+
if (v < min) return min;
|
|
60
|
+
if (v > max) return max;
|
|
61
|
+
return Math.floor(v);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function dedupeQueries(items: string[]): string[] {
|
|
65
|
+
const seen = new Set<string>();
|
|
66
|
+
const out: string[] = [];
|
|
67
|
+
for (const item of items) {
|
|
68
|
+
const key = item.toLowerCase().trim();
|
|
69
|
+
if (!key || seen.has(key)) continue;
|
|
70
|
+
seen.add(key);
|
|
71
|
+
out.push(item.trim());
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function toIso(value: unknown): string {
|
|
77
|
+
const s = String(value || "");
|
|
78
|
+
const d = new Date(s);
|
|
79
|
+
if (Number.isNaN(d.getTime())) return new Date(0).toISOString();
|
|
80
|
+
return d.toISOString();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function classifyRecallQuery(params: {
|
|
84
|
+
llm: JsonLlmClient;
|
|
85
|
+
query: string;
|
|
86
|
+
conversationContext: string[];
|
|
87
|
+
}): Promise<{
|
|
88
|
+
mode: Exclude<RecallMode, "simple">;
|
|
89
|
+
predicates: string[];
|
|
90
|
+
searchHints: string[];
|
|
91
|
+
expandedQueries: string[];
|
|
92
|
+
}> {
|
|
93
|
+
const contextBlock = params.conversationContext.length
|
|
94
|
+
? `\nRecent conversation:\n${params.conversationContext
|
|
95
|
+
.map((m, i) => `${i + 1}. ${m}`)
|
|
96
|
+
.join("\n")}\n`
|
|
97
|
+
: "";
|
|
98
|
+
|
|
99
|
+
const systemPrompt = `You are a memory retrieval router.
|
|
100
|
+
Classify a user query into:
|
|
101
|
+
- broad: asks for overall summary/profile
|
|
102
|
+
- direct: asks for specific personal fact
|
|
103
|
+
- indirect: asks for advice where personal context helps
|
|
104
|
+
|
|
105
|
+
Also extract:
|
|
106
|
+
- predicates: structured fields likely needed (0-3)
|
|
107
|
+
- search_hints: short keyword phrases (1-3)
|
|
108
|
+
- expanded_queries: only for indirect mode (0-3)
|
|
109
|
+
|
|
110
|
+
Return strict JSON:
|
|
111
|
+
{
|
|
112
|
+
"mode":"broad|direct|indirect",
|
|
113
|
+
"predicates":["..."],
|
|
114
|
+
"search_hints":["..."],
|
|
115
|
+
"expanded_queries":["..."]
|
|
116
|
+
}`;
|
|
117
|
+
|
|
118
|
+
const userPrompt = `${contextBlock}User message: "${params.query}"`;
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const result = await params.llm.call({
|
|
122
|
+
systemPrompt,
|
|
123
|
+
userPrompt,
|
|
124
|
+
jsonMode: true,
|
|
125
|
+
timeoutMs: CLASSIFY_TIMEOUT_MS,
|
|
126
|
+
});
|
|
127
|
+
const mode: Exclude<RecallMode, "simple"> =
|
|
128
|
+
result?.mode === "broad" || result?.mode === "direct" || result?.mode === "indirect"
|
|
129
|
+
? result.mode
|
|
130
|
+
: "indirect";
|
|
131
|
+
return {
|
|
132
|
+
mode,
|
|
133
|
+
predicates: asStringArray(result?.predicates).slice(0, 3),
|
|
134
|
+
searchHints: asStringArray(result?.search_hints).slice(0, 3),
|
|
135
|
+
expandedQueries: asStringArray(result?.expanded_queries).slice(0, 3),
|
|
136
|
+
};
|
|
137
|
+
} catch {
|
|
138
|
+
return {
|
|
139
|
+
mode: "indirect",
|
|
140
|
+
predicates: [],
|
|
141
|
+
searchHints: [],
|
|
142
|
+
expandedQueries: [],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function rerankMemories(params: {
|
|
148
|
+
llm: JsonLlmClient;
|
|
149
|
+
query: string;
|
|
150
|
+
conversationContext: string[];
|
|
151
|
+
candidates: ScoredMemory[];
|
|
152
|
+
topK: number;
|
|
153
|
+
}): Promise<ScoredMemory[]> {
|
|
154
|
+
if (params.candidates.length === 0) return [];
|
|
155
|
+
const filtered = params.candidates.filter((m) => String(m.text || "").trim().length >= 10);
|
|
156
|
+
if (filtered.length === 0) return [];
|
|
157
|
+
if (filtered.length <= params.topK) return filtered;
|
|
158
|
+
const contextBlock = params.conversationContext.length
|
|
159
|
+
? `\nRecent conversation:\n${params.conversationContext
|
|
160
|
+
.map((m, i) => `${i + 1}. ${m}`)
|
|
161
|
+
.join("\n")}\n`
|
|
162
|
+
: "";
|
|
163
|
+
|
|
164
|
+
const memories = filtered.map((m, i) => `[${i}] ${m.text}`).join("\n");
|
|
165
|
+
const systemPrompt = `You are a memory relevance judge.
|
|
166
|
+
Given a user query and candidate memories, select relevant memories and rank them.
|
|
167
|
+
Return strict JSON:
|
|
168
|
+
{
|
|
169
|
+
"ranked": [
|
|
170
|
+
{ "index": 0, "relevant": true, "score": 0.92 }
|
|
171
|
+
]
|
|
172
|
+
}
|
|
173
|
+
Rules:
|
|
174
|
+
- Keep only relevant=true entries.
|
|
175
|
+
- score is 0..1.
|
|
176
|
+
- Return at most ${params.topK} entries.`;
|
|
177
|
+
const userPrompt = `${contextBlock}User query: "${params.query}"\n\nCandidates:\n${memories}`;
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const result = await params.llm.call({
|
|
181
|
+
systemPrompt,
|
|
182
|
+
userPrompt,
|
|
183
|
+
jsonMode: true,
|
|
184
|
+
timeoutMs: RERANK_TIMEOUT_MS,
|
|
185
|
+
});
|
|
186
|
+
const ranked = Array.isArray(result?.ranked) ? result.ranked : [];
|
|
187
|
+
const selected: Array<{ idx: number; score: number }> = [];
|
|
188
|
+
for (const item of ranked) {
|
|
189
|
+
const idx = Number(item?.index);
|
|
190
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= filtered.length) continue;
|
|
191
|
+
if (item?.relevant !== true) continue;
|
|
192
|
+
selected.push({ idx, score: scoreNum(item?.score, 0.5) });
|
|
193
|
+
}
|
|
194
|
+
if (selected.length === 0) return [];
|
|
195
|
+
selected.sort((a, b) => b.score - a.score);
|
|
196
|
+
return selected.slice(0, params.topK).map((row) => {
|
|
197
|
+
const base = filtered[row.idx];
|
|
198
|
+
const rerankScore = row.score * 100;
|
|
199
|
+
return {
|
|
200
|
+
...base,
|
|
201
|
+
effective_score: Math.max(base.effective_score, rerankScore),
|
|
202
|
+
score: Math.max(base.score, rerankScore),
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
} catch {
|
|
206
|
+
return filtered.slice(0, params.topK);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function createLLMRecallService(options: CreateLLMRecallServiceOptions): RecallService {
|
|
211
|
+
const { store, llm, embed } = options;
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
name: `${llm.provider}:${llm.model}`,
|
|
215
|
+
async search(args) {
|
|
216
|
+
const query = String(args.query || "").trim();
|
|
217
|
+
const limit = clampInt(args.limit || 25, 1, 200);
|
|
218
|
+
const minScore = Number.isFinite(Number(args.min_score)) ? Number(args.min_score) : 30;
|
|
219
|
+
const conversationContext = Array.isArray(args.conversation_context)
|
|
220
|
+
? args.conversation_context.map((v) => String(v || "")).filter(Boolean).slice(-5)
|
|
221
|
+
: [];
|
|
222
|
+
|
|
223
|
+
if (!query) {
|
|
224
|
+
return {
|
|
225
|
+
memories: [],
|
|
226
|
+
mode: "indirect",
|
|
227
|
+
used_queries: [],
|
|
228
|
+
predicates: [],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const classified = await classifyRecallQuery({
|
|
233
|
+
llm,
|
|
234
|
+
query,
|
|
235
|
+
conversationContext,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (classified.mode === "broad") {
|
|
239
|
+
const rows = await store.listMemories({
|
|
240
|
+
project_id: args.project_id,
|
|
241
|
+
subject_id: args.subject_id,
|
|
242
|
+
limit: Math.min(limit * 3, 200),
|
|
243
|
+
offset: 0,
|
|
244
|
+
include_deleted: false,
|
|
245
|
+
include_superseded: false,
|
|
246
|
+
});
|
|
247
|
+
const sorted = [...rows].sort((a, b) => {
|
|
248
|
+
const imp = Number(b.importance) - Number(a.importance);
|
|
249
|
+
if (imp !== 0) return imp;
|
|
250
|
+
return toIso(b.created_at).localeCompare(toIso(a.created_at));
|
|
251
|
+
});
|
|
252
|
+
const broadLimit = Math.max(limit, 20);
|
|
253
|
+
const memories: ScoredMemory[] = sorted.slice(0, broadLimit).map((m) => ({
|
|
254
|
+
...m,
|
|
255
|
+
score: 100,
|
|
256
|
+
effective_score: Number(m.importance || 0),
|
|
257
|
+
}));
|
|
258
|
+
return {
|
|
259
|
+
memories,
|
|
260
|
+
mode: "broad",
|
|
261
|
+
used_queries: [query],
|
|
262
|
+
predicates: classified.predicates,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const queries = dedupeQueries([
|
|
267
|
+
query,
|
|
268
|
+
...classified.searchHints,
|
|
269
|
+
...(classified.mode === "indirect" ? classified.expandedQueries : []),
|
|
270
|
+
]).slice(0, 6);
|
|
271
|
+
|
|
272
|
+
const allResults: Array<{
|
|
273
|
+
q: string;
|
|
274
|
+
rank: number;
|
|
275
|
+
rows: ScoredMemory[];
|
|
276
|
+
}> = [];
|
|
277
|
+
|
|
278
|
+
const searches = queries.map(async (q, rank) => {
|
|
279
|
+
let embedding: number[] | null = null;
|
|
280
|
+
try {
|
|
281
|
+
const emb = await embed(q);
|
|
282
|
+
embedding = Array.isArray(emb) && emb.length > 0 ? emb : null;
|
|
283
|
+
} catch {
|
|
284
|
+
embedding = null;
|
|
285
|
+
}
|
|
286
|
+
const rows = await store.searchMemories({
|
|
287
|
+
project_id: args.project_id,
|
|
288
|
+
subject_id: args.subject_id,
|
|
289
|
+
q,
|
|
290
|
+
query_embedding: embedding,
|
|
291
|
+
limit: Math.min(limit * 2, 200),
|
|
292
|
+
min_score: minScore,
|
|
293
|
+
});
|
|
294
|
+
allResults.push({
|
|
295
|
+
q,
|
|
296
|
+
rank,
|
|
297
|
+
rows: rows as ScoredMemory[],
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
await Promise.all(searches);
|
|
302
|
+
|
|
303
|
+
if (classified.mode === "direct" && classified.predicates.length > 0) {
|
|
304
|
+
const truth = await store.getCurrentTruth({
|
|
305
|
+
project_id: args.project_id,
|
|
306
|
+
subject_id: args.subject_id,
|
|
307
|
+
});
|
|
308
|
+
const sourceMemoryIds = truth
|
|
309
|
+
.filter((row) => classified.predicates.includes(String(row.predicate)))
|
|
310
|
+
.map((row) => row.source_memory_id)
|
|
311
|
+
.filter((id): id is string => !!id);
|
|
312
|
+
const uniqueIds = [...new Set(sourceMemoryIds)];
|
|
313
|
+
for (const memoryId of uniqueIds) {
|
|
314
|
+
const mem = await store.getMemory({
|
|
315
|
+
project_id: args.project_id,
|
|
316
|
+
id: memoryId,
|
|
317
|
+
});
|
|
318
|
+
if (!mem || mem.is_deleted || mem.status !== "active") continue;
|
|
319
|
+
allResults.push({
|
|
320
|
+
q: "__claims__",
|
|
321
|
+
rank: 0,
|
|
322
|
+
rows: [
|
|
323
|
+
{
|
|
324
|
+
...mem,
|
|
325
|
+
score: 100,
|
|
326
|
+
effective_score: 120,
|
|
327
|
+
},
|
|
328
|
+
],
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const byId = new Map<string, ScoredMemory>();
|
|
334
|
+
for (const group of allResults) {
|
|
335
|
+
for (const row of group.rows) {
|
|
336
|
+
const qBoost = group.rank === 0 ? 1 : 1 - group.rank * 0.03;
|
|
337
|
+
const boosted: ScoredMemory = {
|
|
338
|
+
...row,
|
|
339
|
+
score: scoreNum(row.score, 0) * qBoost,
|
|
340
|
+
effective_score: scoreNum(row.effective_score, 0) * qBoost,
|
|
341
|
+
};
|
|
342
|
+
const existing = byId.get(row.id);
|
|
343
|
+
if (!existing || boosted.effective_score > existing.effective_score) {
|
|
344
|
+
byId.set(row.id, boosted);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let candidates = [...byId.values()].sort((a, b) => b.effective_score - a.effective_score);
|
|
350
|
+
|
|
351
|
+
if (classified.mode === "direct") {
|
|
352
|
+
const hasClaimResults = allResults.some((group) => group.q === "__claims__" && group.rows.length > 0);
|
|
353
|
+
if (hasClaimResults) {
|
|
354
|
+
const directLimit = Math.min(limit, 5);
|
|
355
|
+
candidates = candidates
|
|
356
|
+
.sort((a, b) => b.effective_score - a.effective_score)
|
|
357
|
+
.slice(0, directLimit);
|
|
358
|
+
} else if (candidates.length > limit) {
|
|
359
|
+
candidates = await rerankMemories({
|
|
360
|
+
llm,
|
|
361
|
+
query,
|
|
362
|
+
conversationContext,
|
|
363
|
+
candidates,
|
|
364
|
+
topK: limit,
|
|
365
|
+
});
|
|
366
|
+
} else {
|
|
367
|
+
const directLimit = Math.min(limit, 5);
|
|
368
|
+
candidates = candidates
|
|
369
|
+
.sort((a, b) => b.effective_score - a.effective_score)
|
|
370
|
+
.slice(0, directLimit);
|
|
371
|
+
}
|
|
372
|
+
} else if (candidates.length > limit) {
|
|
373
|
+
candidates = await rerankMemories({
|
|
374
|
+
llm,
|
|
375
|
+
query,
|
|
376
|
+
conversationContext,
|
|
377
|
+
candidates,
|
|
378
|
+
topK: limit,
|
|
379
|
+
});
|
|
380
|
+
} else {
|
|
381
|
+
candidates = candidates.slice(0, limit);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
memories: candidates.slice(0, limit),
|
|
386
|
+
mode: classified.mode,
|
|
387
|
+
used_queries: queries,
|
|
388
|
+
predicates: classified.predicates,
|
|
389
|
+
};
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function createSimpleRecallService(options: CreateSimpleRecallServiceOptions): RecallService {
|
|
395
|
+
const { store, embed } = options;
|
|
396
|
+
return {
|
|
397
|
+
name: "simple",
|
|
398
|
+
async search(args) {
|
|
399
|
+
const query = String(args.query || "").trim();
|
|
400
|
+
const limit = clampInt(args.limit || 25, 1, 200);
|
|
401
|
+
const minScore = Number.isFinite(Number(args.min_score)) ? Number(args.min_score) : 30;
|
|
402
|
+
let embedding: number[] | null = null;
|
|
403
|
+
if (embed) {
|
|
404
|
+
try {
|
|
405
|
+
const emb = await embed(query);
|
|
406
|
+
embedding = Array.isArray(emb) && emb.length > 0 ? emb : null;
|
|
407
|
+
} catch {
|
|
408
|
+
embedding = null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const memories = (await store.searchMemories({
|
|
413
|
+
project_id: args.project_id,
|
|
414
|
+
subject_id: args.subject_id,
|
|
415
|
+
q: query,
|
|
416
|
+
query_embedding: embedding,
|
|
417
|
+
limit,
|
|
418
|
+
min_score: minScore,
|
|
419
|
+
})) as ScoredMemory[];
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
memories,
|
|
423
|
+
mode: "simple",
|
|
424
|
+
used_queries: [query],
|
|
425
|
+
predicates: [],
|
|
426
|
+
};
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Compatibility helper to preserve previous import path.
|
|
432
|
+
export function createCerebrasRecallService(options: {
|
|
433
|
+
store: CoreStore;
|
|
434
|
+
cerebras: CerebrasClient;
|
|
435
|
+
embed: (text: string) => Promise<number[]>;
|
|
436
|
+
}): RecallService {
|
|
437
|
+
return createLLMRecallService({
|
|
438
|
+
store: options.store,
|
|
439
|
+
llm: options.cerebras as unknown as JsonLlmClient,
|
|
440
|
+
embed: options.embed,
|
|
441
|
+
});
|
|
442
|
+
}
|
package/src/ai/types.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface JsonLlmClient {
|
|
2
|
+
provider: "cerebras" | "openai" | "unknown";
|
|
3
|
+
model: string;
|
|
4
|
+
call: (opts: {
|
|
5
|
+
systemPrompt: string;
|
|
6
|
+
userPrompt: string;
|
|
7
|
+
jsonMode?: boolean;
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
temperature?: number;
|
|
10
|
+
}) => Promise<any | null>;
|
|
11
|
+
}
|