@linxiraos/pi-mnemopi 1.0.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.
Files changed (135) hide show
  1. package/CHANGELOG.md +248 -0
  2. package/README.md +108 -0
  3. package/dist/types/cli.d.ts +35 -0
  4. package/dist/types/config.d.ts +108 -0
  5. package/dist/types/core/aaak.d.ts +55 -0
  6. package/dist/types/core/annotations.d.ts +75 -0
  7. package/dist/types/core/banks.d.ts +33 -0
  8. package/dist/types/core/beam/consolidate.d.ts +38 -0
  9. package/dist/types/core/beam/helpers.d.ts +69 -0
  10. package/dist/types/core/beam/index.d.ts +59 -0
  11. package/dist/types/core/beam/recall.d.ts +52 -0
  12. package/dist/types/core/beam/schema.d.ts +2 -0
  13. package/dist/types/core/beam/store.d.ts +57 -0
  14. package/dist/types/core/beam/types.d.ts +268 -0
  15. package/dist/types/core/binary-vectors.d.ts +54 -0
  16. package/dist/types/core/chat-normalize.d.ts +13 -0
  17. package/dist/types/core/content-sanitizer.d.ts +18 -0
  18. package/dist/types/core/cost-log.d.ts +13 -0
  19. package/dist/types/core/embeddings.d.ts +93 -0
  20. package/dist/types/core/entities.d.ts +9 -0
  21. package/dist/types/core/episodic-graph.d.ts +89 -0
  22. package/dist/types/core/extraction/client.d.ts +35 -0
  23. package/dist/types/core/extraction/diagnostics.d.ts +51 -0
  24. package/dist/types/core/extraction/prompts.d.ts +2 -0
  25. package/dist/types/core/extraction.d.ts +34 -0
  26. package/dist/types/core/fastembed-model-cache.d.ts +2 -0
  27. package/dist/types/core/fastembed-runtime.d.ts +41 -0
  28. package/dist/types/core/index.d.ts +6 -0
  29. package/dist/types/core/llm-backends.d.ts +23 -0
  30. package/dist/types/core/local-llm.d.ts +20 -0
  31. package/dist/types/core/memory.d.ts +186 -0
  32. package/dist/types/core/migrations/e6-triplestore-split.d.ts +17 -0
  33. package/dist/types/core/migrations/index.d.ts +1 -0
  34. package/dist/types/core/mmr.d.ts +8 -0
  35. package/dist/types/core/orchestrator.d.ts +20 -0
  36. package/dist/types/core/patterns.d.ts +61 -0
  37. package/dist/types/core/plugins.d.ts +109 -0
  38. package/dist/types/core/polyphonic-recall.d.ts +66 -0
  39. package/dist/types/core/query-cache.d.ts +45 -0
  40. package/dist/types/core/query-intent.d.ts +20 -0
  41. package/dist/types/core/recall-diagnostics.d.ts +48 -0
  42. package/dist/types/core/runtime-options.d.ts +76 -0
  43. package/dist/types/core/shmr.d.ts +62 -0
  44. package/dist/types/core/streaming.d.ts +136 -0
  45. package/dist/types/core/synonyms.d.ts +46 -0
  46. package/dist/types/core/temporal-parser.d.ts +16 -0
  47. package/dist/types/core/token-counter.d.ts +8 -0
  48. package/dist/types/core/triples.d.ts +63 -0
  49. package/dist/types/core/typed-memory.d.ts +39 -0
  50. package/dist/types/core/vector-index.d.ts +16 -0
  51. package/dist/types/core/vector-math.d.ts +1 -0
  52. package/dist/types/core/veracity-consolidation.d.ts +60 -0
  53. package/dist/types/core/weibull.d.ts +96 -0
  54. package/dist/types/db.d.ts +18 -0
  55. package/dist/types/diagnose.d.ts +24 -0
  56. package/dist/types/dr/index.d.ts +1 -0
  57. package/dist/types/dr/recovery.d.ts +68 -0
  58. package/dist/types/index.d.ts +6 -0
  59. package/dist/types/mcp-server.d.ts +40 -0
  60. package/dist/types/mcp-tools.d.ts +484 -0
  61. package/dist/types/migrations/e6-triplestore-split.d.ts +1 -0
  62. package/dist/types/migrations/index.d.ts +1 -0
  63. package/dist/types/types.d.ts +145 -0
  64. package/dist/types/util/datetime.d.ts +8 -0
  65. package/dist/types/util/env.d.ts +10 -0
  66. package/dist/types/util/ids.d.ts +3 -0
  67. package/dist/types/util/lru.d.ts +12 -0
  68. package/dist/types/util/regex.d.ts +11 -0
  69. package/package.json +108 -0
  70. package/src/cli.ts +398 -0
  71. package/src/config.ts +373 -0
  72. package/src/core/aaak.ts +142 -0
  73. package/src/core/annotations.ts +456 -0
  74. package/src/core/banks.ts +133 -0
  75. package/src/core/beam/consolidate.ts +1132 -0
  76. package/src/core/beam/helpers.ts +831 -0
  77. package/src/core/beam/index.ts +358 -0
  78. package/src/core/beam/recall.ts +1225 -0
  79. package/src/core/beam/schema.ts +426 -0
  80. package/src/core/beam/store.ts +1083 -0
  81. package/src/core/beam/types.ts +303 -0
  82. package/src/core/binary-vectors.ts +317 -0
  83. package/src/core/chat-normalize.ts +160 -0
  84. package/src/core/content-sanitizer.ts +136 -0
  85. package/src/core/cost-log.ts +101 -0
  86. package/src/core/embeddings.ts +624 -0
  87. package/src/core/entities.ts +263 -0
  88. package/src/core/episodic-graph.ts +708 -0
  89. package/src/core/extraction/client.ts +187 -0
  90. package/src/core/extraction/diagnostics.ts +193 -0
  91. package/src/core/extraction/prompts.ts +31 -0
  92. package/src/core/extraction.ts +491 -0
  93. package/src/core/fastembed-model-cache.ts +39 -0
  94. package/src/core/fastembed-runtime.ts +172 -0
  95. package/src/core/index.ts +39 -0
  96. package/src/core/llm-backends.ts +54 -0
  97. package/src/core/local-llm.ts +475 -0
  98. package/src/core/memory.ts +680 -0
  99. package/src/core/migrations/e6-triplestore-split.ts +211 -0
  100. package/src/core/migrations/index.ts +1 -0
  101. package/src/core/mmr.ts +101 -0
  102. package/src/core/orchestrator.ts +62 -0
  103. package/src/core/patterns.ts +484 -0
  104. package/src/core/plugins.ts +375 -0
  105. package/src/core/polyphonic-recall.ts +563 -0
  106. package/src/core/query-cache.ts +351 -0
  107. package/src/core/query-intent.ts +139 -0
  108. package/src/core/recall-diagnostics.ts +157 -0
  109. package/src/core/runtime-options.ts +130 -0
  110. package/src/core/shmr.ts +567 -0
  111. package/src/core/streaming.ts +419 -0
  112. package/src/core/synonyms.ts +197 -0
  113. package/src/core/temporal-parser.ts +363 -0
  114. package/src/core/token-counter.ts +30 -0
  115. package/src/core/triples.ts +452 -0
  116. package/src/core/typed-memory.ts +407 -0
  117. package/src/core/vector-index.ts +83 -0
  118. package/src/core/vector-math.ts +23 -0
  119. package/src/core/veracity-consolidation.ts +477 -0
  120. package/src/core/weibull.ts +124 -0
  121. package/src/db.ts +186 -0
  122. package/src/diagnose.ts +174 -0
  123. package/src/dr/index.ts +1 -0
  124. package/src/dr/recovery.ts +405 -0
  125. package/src/index.ts +33 -0
  126. package/src/mcp-server.ts +155 -0
  127. package/src/mcp-tools.ts +970 -0
  128. package/src/migrations/e6-triplestore-split.ts +1 -0
  129. package/src/migrations/index.ts +1 -0
  130. package/src/types.ts +157 -0
  131. package/src/util/datetime.ts +69 -0
  132. package/src/util/env.ts +65 -0
  133. package/src/util/ids.ts +19 -0
  134. package/src/util/lru.ts +48 -0
  135. package/src/util/regex.ts +165 -0
@@ -0,0 +1,491 @@
1
+ import { getDiagnostics, safeForLog } from "./extraction/diagnostics";
2
+ import { callHostLlm, getHostLlmBackend } from "./llm-backends";
3
+ import {
4
+ callConfiguredCompletion,
5
+ callLocalLlm,
6
+ callRemoteLlm,
7
+ cleanOutput,
8
+ configuredLlmWillHandleCall,
9
+ llmAvailable,
10
+ type RemoteLlmOptions,
11
+ } from "./local-llm";
12
+ import { getMnemopiRuntimeOptions } from "./runtime-options";
13
+
14
+ const TRUE_VALUES: Record<string, true> = { "1": true, true: true, yes: true, on: true };
15
+
16
+ function env(name: string): string {
17
+ return process.env[name] ?? "";
18
+ }
19
+
20
+ function envBool(name: string, defaultValue: boolean): boolean {
21
+ const value = env(name).trim().toLowerCase();
22
+ return value === "" ? defaultValue : TRUE_VALUES[value] === true;
23
+ }
24
+
25
+ function envInt(name: string, defaultValue: number): number {
26
+ const parsed = Number.parseInt(env(name), 10);
27
+ return Number.isFinite(parsed) ? parsed : defaultValue;
28
+ }
29
+
30
+ function llmEnabled(): boolean {
31
+ return envBool("MNEMOPI_LLM_ENABLED", true);
32
+ }
33
+
34
+ function hostLlmEnabled(): boolean {
35
+ return envBool("MNEMOPI_HOST_LLM_ENABLED", false);
36
+ }
37
+
38
+ function llmMaxTokens(): number {
39
+ return envInt("MNEMOPI_LLM_MAX_TOKENS", 2048);
40
+ }
41
+
42
+ export const EXTRACTION_PROMPT_TEMPLATE =
43
+ env("MNEMOPI_EXTRACTION_PROMPT") ||
44
+ `You are an expert structured memory extractor for Mnemopi v3.0+ MEMORIA tables.
45
+ The user message below may be in English, German, Russian, or another language.
46
+ First detect the language, then extract ONLY high-signal, long-term relevant items.
47
+ Categories to extract (return valid JSON only, no extra text):
48
+ - facts: persistent user metrics, states, knowledge, or personal data
49
+ (Examples: 'my name is X', 'I work at Y', 'server runs on port 8080')
50
+ - instructions: rules or commands directed at me the agent
51
+ (Examples: 'always use tabs', 'never delete logs', 'call me boss')
52
+ - preferences: likes, dislikes, and their evolution
53
+ (Examples: 'I like dark mode', 'I prefer Python over Go')
54
+ - timelines: real events with dates/times
55
+ (Examples: 'release on 2024-12-01', 'meeting next Tuesday')
56
+ - kg: knowledge-graph triples in subject-predicate-object form
57
+
58
+ Rules:
59
+ - Only extract persistent, non-transient content. Ignore weather, one-off chat, system text.
60
+ - Use semantic understanding — do NOT rely on English keywords.
61
+ - Preserve original casing and language.
62
+ - If nothing qualifies, return empty arrays.
63
+
64
+ Return JSON in this exact format:
65
+ {"facts": [], "instructions": [], "preferences": [], "timelines": [], "kg": []}
66
+
67
+ User message: {text}
68
+
69
+ Extraction:`;
70
+
71
+ export function buildExtractionPrompt(text: string, detectedLang = "en"): string {
72
+ const template = getMnemopiRuntimeOptions()?.llm?.extractionPrompt ?? EXTRACTION_PROMPT_TEMPLATE;
73
+ return template.split("{text}").join(text).split("{lang}").join(detectedLang);
74
+ }
75
+ function stripFence(raw: string): string {
76
+ let s = raw.trim();
77
+ if (!s.startsWith("```")) {
78
+ return s;
79
+ }
80
+ s = s.replace(/^```(?:json)?\s*/i, "");
81
+ s = s.replace(/\s*```$/i, "");
82
+ return s.trim();
83
+ }
84
+
85
+ const FLAT_FACT_LIMIT = 5;
86
+ const STRUCTURED_CATEGORY_LIMIT = 5;
87
+ const STRING_CATEGORY_KEYS = ["facts", "instructions", "preferences", "timelines"] as const;
88
+ const FACT_TEXT_FIELD_KEYS = ["fact", "text", "content", "value", "statement"] as const;
89
+ const INSTRUCTION_TEXT_FIELD_KEYS = ["instruction", "rule", ...FACT_TEXT_FIELD_KEYS] as const;
90
+ const PREFERENCE_TEXT_FIELD_KEYS = ["preference", ...FACT_TEXT_FIELD_KEYS] as const;
91
+ const TIMELINE_TEXT_FIELD_KEYS = ["description", "event", "timeline", "date", ...FACT_TEXT_FIELD_KEYS] as const;
92
+
93
+ /** Parsed knowledge-graph edge emitted by the extractor LLM. */
94
+ export interface ExtractedKgTriple {
95
+ subject: string;
96
+ predicate: string;
97
+ object: string;
98
+ }
99
+
100
+ /** Category-preserving extraction result used by background memory routing. */
101
+ export interface ExtractedFactCategories {
102
+ facts: string[];
103
+ instructions: string[];
104
+ preferences: string[];
105
+ timelines: string[];
106
+ kg: ExtractedKgTriple[];
107
+ }
108
+
109
+ function emptyFactCategories(): ExtractedFactCategories {
110
+ return { facts: [], instructions: [], preferences: [], timelines: [], kg: [] };
111
+ }
112
+
113
+ function normalizeFact(fact: string): string {
114
+ const trimmed = fact.trim();
115
+ // Remove trailing sentence punctuation (. ! ?) if present
116
+ return trimmed.replace(/[.!?]+$/, "");
117
+ }
118
+
119
+ interface FactArrayOptions {
120
+ fields: readonly string[];
121
+ joinFields?: boolean;
122
+ }
123
+
124
+ function normalizeFactArray(items: unknown, options: FactArrayOptions): string[] {
125
+ if (!Array.isArray(items)) {
126
+ return [];
127
+ }
128
+ const out: string[] = [];
129
+ for (const item of items) {
130
+ let text: string | null = null;
131
+ if (typeof item === "string") {
132
+ text = item.trim();
133
+ } else if (isRecord(item)) {
134
+ const parts: string[] = [];
135
+ for (const key of options.fields) {
136
+ const candidate = item[key];
137
+ if (typeof candidate === "string") {
138
+ const trimmed = candidate.trim();
139
+ if (trimmed !== "") {
140
+ parts.push(trimmed);
141
+ if (options.joinFields !== true) break;
142
+ }
143
+ }
144
+ }
145
+ text = parts.length > 0 ? parts.join(" ") : null;
146
+ }
147
+ if (text !== null && text !== "") {
148
+ const normalized = normalizeFact(text);
149
+ if (normalized !== "") {
150
+ out.push(normalized);
151
+ if (out.length >= STRUCTURED_CATEGORY_LIMIT) break;
152
+ }
153
+ }
154
+ }
155
+ return out;
156
+ }
157
+
158
+ function isRecord(value: unknown): value is Record<string, unknown> {
159
+ return value !== null && typeof value === "object" && !Array.isArray(value);
160
+ }
161
+
162
+ function triplePart(value: unknown): string {
163
+ return typeof value === "string" ? normalizeFact(value) : "";
164
+ }
165
+
166
+ function normalizeKgTriple(item: unknown): ExtractedKgTriple | null {
167
+ let subject = "";
168
+ let predicate = "";
169
+ let object = "";
170
+ if (isRecord(item)) {
171
+ subject = triplePart(item.subject);
172
+ predicate = triplePart(item.predicate);
173
+ object = triplePart(item.object);
174
+ } else if (Array.isArray(item)) {
175
+ subject = triplePart(item[0]);
176
+ predicate = triplePart(item[1]);
177
+ object = triplePart(item[2]);
178
+ }
179
+ return subject !== "" && predicate !== "" && object !== "" ? { subject, predicate, object } : null;
180
+ }
181
+
182
+ function normalizeKgArray(items: unknown): ExtractedKgTriple[] {
183
+ if (!Array.isArray(items)) {
184
+ return [];
185
+ }
186
+ const out: ExtractedKgTriple[] = [];
187
+ for (const item of items) {
188
+ const triple = normalizeKgTriple(item);
189
+ if (triple !== null) {
190
+ out.push(triple);
191
+ if (out.length >= STRUCTURED_CATEGORY_LIMIT) break;
192
+ }
193
+ }
194
+ return out;
195
+ }
196
+
197
+ /** Flatten extracted string categories for legacy fact callers. */
198
+ export function flattenExtractedFactCategories(extracted: ExtractedFactCategories): string[] {
199
+ const out: string[] = [];
200
+ for (const category of STRING_CATEGORY_KEYS) {
201
+ for (const item of extracted[category]) {
202
+ out.push(item);
203
+ }
204
+ }
205
+ return out;
206
+ }
207
+
208
+ /** Count string facts plus KG triples in a category-preserving extraction result. */
209
+ export function countExtractedFactCategories(extracted: ExtractedFactCategories): number {
210
+ return (
211
+ extracted.facts.length +
212
+ extracted.instructions.length +
213
+ extracted.preferences.length +
214
+ extracted.timelines.length +
215
+ extracted.kg.length
216
+ );
217
+ }
218
+
219
+ /** Parse extractor output without discarding MEMORIA categories or KG triples. */
220
+ export function parseExtractedFactCategories(rawOutput: string | null | undefined): ExtractedFactCategories {
221
+ if (rawOutput === null || rawOutput === undefined) {
222
+ return emptyFactCategories();
223
+ }
224
+ const raw = rawOutput.trim();
225
+ if (raw === "" || raw.toUpperCase() === "NO_FACTS") {
226
+ return emptyFactCategories();
227
+ }
228
+ const rawClean = stripFence(raw);
229
+ if (rawClean.startsWith("{")) {
230
+ try {
231
+ const parsed: unknown = JSON.parse(rawClean);
232
+ if (isRecord(parsed)) {
233
+ return {
234
+ facts: normalizeFactArray(parsed.facts, { fields: FACT_TEXT_FIELD_KEYS }),
235
+ instructions: normalizeFactArray(parsed.instructions, { fields: INSTRUCTION_TEXT_FIELD_KEYS }),
236
+ preferences: normalizeFactArray(parsed.preferences, { fields: PREFERENCE_TEXT_FIELD_KEYS }),
237
+ timelines: normalizeFactArray(parsed.timelines, { fields: TIMELINE_TEXT_FIELD_KEYS, joinFields: true }),
238
+ kg: normalizeKgArray(parsed.kg),
239
+ };
240
+ }
241
+ } catch {
242
+ const matches = [...raw.matchAll(/"([^"]{10,})"/g)].map(m => m[1]).filter((v): v is string => v !== undefined);
243
+ if (matches.length > 0) {
244
+ return {
245
+ ...emptyFactCategories(),
246
+ facts: matches
247
+ .map(normalizeFact)
248
+ .filter(f => f !== "")
249
+ .slice(0, FLAT_FACT_LIMIT),
250
+ };
251
+ }
252
+ }
253
+ }
254
+ const cleaned: string[] = [];
255
+ for (const line of raw.split("\n")) {
256
+ const fact = line.replace(/^[\s\d.\-*]+/, "").trim();
257
+ if (fact.length > 10) {
258
+ const normalized = normalizeFact(fact);
259
+ if (normalized !== "") {
260
+ cleaned.push(normalized);
261
+ }
262
+ }
263
+ if (cleaned.length >= FLAT_FACT_LIMIT) break;
264
+ }
265
+ return { ...emptyFactCategories(), facts: cleaned };
266
+ }
267
+
268
+ /** Parse extractor output into the legacy flat string fact list. */
269
+ export function parseFacts(rawOutput: string | null | undefined): string[] {
270
+ return flattenExtractedFactCategories(parseExtractedFactCategories(rawOutput)).slice(0, FLAT_FACT_LIMIT);
271
+ }
272
+ function sentenceCase(value: string): string {
273
+ const trimmed = value.trim().replace(/[.!?]+$/, "");
274
+ return trimmed === "" ? "" : `${trimmed[0]?.toUpperCase() ?? ""}${trimmed.slice(1)}`;
275
+ }
276
+
277
+ function addUnique(out: string[], value: string): void {
278
+ const fact = sentenceCase(value);
279
+ if (fact.length > 10 && !out.includes(fact)) {
280
+ out.push(fact);
281
+ }
282
+ }
283
+
284
+ export function heuristicExtractFacts(text: string): string[] {
285
+ const normalized = text.replace(/\s+/g, " ").trim();
286
+ if (normalized === "") {
287
+ return [];
288
+ }
289
+ const facts: string[] = [];
290
+ const clauses = normalized.split(/(?:[.!?;]+|\s+and\s+|\s+but\s+)/i);
291
+ for (const clause of clauses) {
292
+ const c = clause.trim();
293
+ let value = /\bmy name is\s+([^,.!?;]+)/i.exec(c)?.[1];
294
+ if (value !== undefined) addUnique(facts, `The user's name is ${value}`);
295
+ value = /\bi (?:am|work as)\s+(?:an?\s+)?([^,.!?;]+)/i.exec(c)?.[1];
296
+ if (value !== undefined) addUnique(facts, `The user is ${value}`);
297
+ value = /\bi work (?:at|for)\s+([^,.!?;]+)/i.exec(c)?.[1];
298
+ if (value !== undefined) addUnique(facts, `The user works at ${value}`);
299
+ value = /\bi (?:live in|am based in)\s+([^,.!?;]+)/i.exec(c)?.[1];
300
+ if (value !== undefined) addUnique(facts, `The user lives in ${value}`);
301
+ value = /\bi (?:use|uses|am using)\s+([^,.!?;]+)/i.exec(c)?.[1];
302
+ if (value !== undefined) addUnique(facts, `The user uses ${value}`);
303
+ value = /\bi (?:like|love|prefer|enjoy)\s+([^,.!?;]+)/i.exec(c)?.[1];
304
+ if (value !== undefined) addUnique(facts, `The user prefers ${value}`);
305
+ value = /\bi (?:hate|dislike|do not like|don't like)\s+([^,.!?;]+)/i.exec(c)?.[1];
306
+ if (value !== undefined) addUnique(facts, `The user dislikes ${value}`);
307
+ // Require an explicit `i` or `you` subject before `always|never`. The
308
+ // other heuristics in this block all need an `i` subject (`i live in …`,
309
+ // `i use …`) which keeps them from matching narrative prose; the
310
+ // `Instruction:` pattern used to match any `always|never` token, so
311
+ // assistant prose like "the panel never populates" became stored as a
312
+ // user `Instruction:` memory (coding-agent issue #3372). Subject
313
+ // constraint mirrors how the rest of the heuristics filter for first- /
314
+ // second-person assertions and keeps narrative third-person prose out.
315
+ const instruction = /\b(?:i|you)\s+(always|never)\s+([^,.!?;]+)/i.exec(c);
316
+ if (instruction?.[1] !== undefined && instruction[2] !== undefined) {
317
+ addUnique(facts, `Instruction: ${instruction[1].toLowerCase()} ${instruction[2]}`);
318
+ }
319
+ }
320
+ return facts.slice(0, 5);
321
+ }
322
+
323
+ async function tryHostExtraction(prompt: string): Promise<[boolean, string | null]> {
324
+ if (!llmEnabled() || !hostLlmEnabled() || getHostLlmBackend() === null) {
325
+ return [false, null];
326
+ }
327
+ const raw = await callHostLlm(prompt, {
328
+ maxTokens: llmMaxTokens(),
329
+ temperature: 0,
330
+ timeout: 15,
331
+ provider: env("MNEMOPI_HOST_LLM_PROVIDER").trim() || null,
332
+ model: env("MNEMOPI_HOST_LLM_MODEL").trim() || null,
333
+ });
334
+ const text = typeof raw === "string" ? raw.trim() : "";
335
+ return [true, text === "" ? null : text];
336
+ }
337
+
338
+ async function localFallback(
339
+ prompt: string,
340
+ sourceText: string,
341
+ diag = getDiagnostics(),
342
+ ): Promise<ExtractedFactCategories> {
343
+ diag.recordAttempt("local");
344
+ try {
345
+ const raw = await callLocalLlm(prompt);
346
+ if (raw !== null) {
347
+ const extracted = parseExtractedFactCategories(cleanOutput(raw));
348
+ const count = countExtractedFactCategories(extracted);
349
+ if (count > 0) {
350
+ diag.recordSuccess("local", count);
351
+ diag.recordCall({ succeeded: true });
352
+ return extracted;
353
+ }
354
+ diag.recordNoOutput("local");
355
+ }
356
+ } catch (exc) {
357
+ diag.recordFailure("local", exc, "local_llm_raised");
358
+ diag.recordCall({ succeeded: false });
359
+ return emptyFactCategories();
360
+ }
361
+ diag.recordFailure("local", undefined, "model_not_loaded");
362
+ const heuristic = heuristicExtractFacts(sourceText);
363
+ if (heuristic.length > 0) {
364
+ diag.recordSuccess("local", heuristic.length);
365
+ diag.recordCall({ succeeded: true });
366
+ return { ...emptyFactCategories(), facts: heuristic };
367
+ }
368
+ diag.recordCall({ succeeded: false, allEmpty: true });
369
+ return emptyFactCategories();
370
+ }
371
+
372
+ /** Extract fact categories from text using configured, host, local, or remote LLMs. */
373
+ export async function extractFactCategories(
374
+ text: string | null | undefined,
375
+ options: RemoteLlmOptions = {},
376
+ ): Promise<ExtractedFactCategories> {
377
+ const diag = getDiagnostics();
378
+ if (typeof text !== "string" || text.trim() === "") {
379
+ return emptyFactCategories();
380
+ }
381
+ const prompt = buildExtractionPrompt(text);
382
+
383
+ // Configured completion (host-injected runtime LLM, e.g. the coding-agent's smol
384
+ // or a local on-device model). Mirrors consolidation's precedence: when a
385
+ // complete() fn is wired, it is the chosen path. Extraction is deterministic
386
+ // (temperature 0) so re-ingesting the same content does not create near-dupes.
387
+ if (configuredLlmWillHandleCall()) {
388
+ diag.recordAttempt("host");
389
+ try {
390
+ const raw = await callConfiguredCompletion(prompt, 0, { maxTokens: llmMaxTokens() });
391
+ if (typeof raw === "string" && raw.trim() !== "") {
392
+ const extracted = parseExtractedFactCategories(raw);
393
+ const count = countExtractedFactCategories(extracted);
394
+ if (count > 0) {
395
+ diag.recordSuccess("host", count);
396
+ diag.recordCall({ succeeded: true });
397
+ return extracted;
398
+ }
399
+ }
400
+ diag.recordNoOutput("host");
401
+ } catch (exc) {
402
+ diag.recordFailure("host", exc, "configured_completion_raised");
403
+ diag.recordCall({ succeeded: false });
404
+ console.warn(`extractFacts: configured completion raised: ${safeForLog(exc)}`);
405
+ return emptyFactCategories();
406
+ }
407
+ return localFallback(prompt, text, diag);
408
+ }
409
+
410
+ try {
411
+ const [attempted, hostText] = await tryHostExtraction(prompt);
412
+ if (attempted) {
413
+ diag.recordAttempt("host");
414
+ if (hostText !== null) {
415
+ const extracted = parseExtractedFactCategories(hostText);
416
+ const count = countExtractedFactCategories(extracted);
417
+ if (count > 0) {
418
+ diag.recordSuccess("host", count);
419
+ diag.recordCall({ succeeded: true });
420
+ return extracted;
421
+ }
422
+ }
423
+ diag.recordNoOutput("host");
424
+ return localFallback(prompt, text, diag);
425
+ }
426
+ } catch (exc) {
427
+ diag.recordAttempt("host");
428
+ diag.recordFailure("host", exc, "host_adapter_raised");
429
+ diag.recordCall({ succeeded: false });
430
+ console.warn(`extractFacts: host LLM adapter raised: ${safeForLog(exc)}`);
431
+ return emptyFactCategories();
432
+ }
433
+
434
+ if (!llmAvailable()) {
435
+ diag.recordAttempt("local");
436
+ const heuristic = heuristicExtractFacts(text);
437
+ if (heuristic.length > 0) {
438
+ diag.recordSuccess("local", heuristic.length);
439
+ diag.recordCall({ succeeded: true });
440
+ return { ...emptyFactCategories(), facts: heuristic };
441
+ }
442
+ diag.recordFailure("local", undefined, "llm_unavailable_at_call_site");
443
+ diag.recordCall({ succeeded: false });
444
+ return emptyFactCategories();
445
+ }
446
+
447
+ diag.recordAttempt("remote");
448
+ try {
449
+ const raw = await callRemoteLlm(prompt, 0, options);
450
+ if (raw !== null) {
451
+ const extracted = parseExtractedFactCategories(cleanOutput(raw));
452
+ const count = countExtractedFactCategories(extracted);
453
+ if (count > 0) {
454
+ diag.recordSuccess("remote", count);
455
+ diag.recordCall({ succeeded: true });
456
+ return extracted;
457
+ }
458
+ }
459
+ diag.recordNoOutput("remote");
460
+ } catch (exc) {
461
+ diag.recordFailure("remote", exc, "remote_call_raised");
462
+ console.warn(`extractFacts: remote LLM raised: ${safeForLog(exc)}`);
463
+ }
464
+
465
+ return localFallback(prompt, text, diag);
466
+ }
467
+
468
+ /** Extract legacy flat fact strings from text. */
469
+ export async function extractFacts(text: string | null | undefined, options: RemoteLlmOptions = {}): Promise<string[]> {
470
+ const extracted = await extractFactCategories(text, options);
471
+ return flattenExtractedFactCategories(extracted).slice(0, FLAT_FACT_LIMIT);
472
+ }
473
+
474
+ /** Safely extract category-preserving facts, swallowing best-effort failures. */
475
+ export async function extractFactCategoriesSafe(text: string | null | undefined): Promise<ExtractedFactCategories> {
476
+ try {
477
+ return await extractFactCategories(text);
478
+ } catch (exc) {
479
+ const diag = getDiagnostics();
480
+ diag.recordFailure("wrapper", exc, "outer_wrapper_caught");
481
+ diag.recordCall({ succeeded: false });
482
+ console.warn(`extractFactsSafe: extractFacts() raised: ${safeForLog(exc)}`);
483
+ return emptyFactCategories();
484
+ }
485
+ }
486
+
487
+ /** Safely extract legacy flat fact strings, swallowing best-effort failures. */
488
+ export async function extractFactsSafe(text: string | null | undefined): Promise<string[]> {
489
+ const extracted = await extractFactCategoriesSafe(text);
490
+ return flattenExtractedFactCategories(extracted).slice(0, FLAT_FACT_LIMIT);
491
+ }
@@ -0,0 +1,39 @@
1
+ import * as path from "node:path";
2
+
3
+ const FASTEMBED_MODEL_SIDECARS = [
4
+ "config.json",
5
+ "tokenizer.json",
6
+ "tokenizer_config.json",
7
+ "special_tokens_map.json",
8
+ ] as const;
9
+
10
+ const FASTEMBED_HF_REPOS: Record<string, string> = {
11
+ "fast-all-MiniLM-L6-v2": "sentence-transformers/all-MiniLM-L6-v2",
12
+ "fast-bge-base-en": "BAAI/bge-base-en",
13
+ "fast-bge-base-en-v1.5": "BAAI/bge-base-en-v1.5",
14
+ "fast-bge-small-en": "BAAI/bge-small-en",
15
+ "fast-bge-small-en-v1.5": "BAAI/bge-small-en-v1.5",
16
+ "fast-bge-small-zh-v1.5": "BAAI/bge-small-zh-v1.5",
17
+ "fast-multilingual-e5-large": "intfloat/multilingual-e5-large",
18
+ };
19
+
20
+ /** Download missing config/tokenizer sidecars into a fastembed model cache directory. */
21
+ export async function ensureFastembedModelSidecars(model: string, cacheDir = "local_cache"): Promise<boolean> {
22
+ const repo = FASTEMBED_HF_REPOS[model];
23
+ if (repo === undefined) return false;
24
+
25
+ const modelDir = path.join(cacheDir, model);
26
+ for (const fileName of FASTEMBED_MODEL_SIDECARS) {
27
+ const target = path.join(modelDir, fileName);
28
+ if (await Bun.file(target).exists()) continue;
29
+
30
+ const response = await fetch(`https://huggingface.co/${repo}/resolve/main/${fileName}`);
31
+ if (!response.ok) {
32
+ throw new Error(
33
+ `Failed to download ${model} ${fileName} from ${repo}: ${response.status} ${response.statusText}`,
34
+ );
35
+ }
36
+ await Bun.write(target, await response.arrayBuffer());
37
+ }
38
+ return true;
39
+ }