@loreai/core 0.12.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bun/agents-file.d.ts +29 -8
- package/dist/bun/agents-file.d.ts.map +1 -1
- package/dist/bun/config.d.ts +1 -0
- package/dist/bun/config.d.ts.map +1 -1
- package/dist/bun/db.d.ts.map +1 -1
- package/dist/bun/distillation.d.ts +29 -0
- package/dist/bun/distillation.d.ts.map +1 -1
- package/dist/bun/embedding.d.ts +15 -1
- package/dist/bun/embedding.d.ts.map +1 -1
- package/dist/bun/gradient.d.ts +53 -5
- package/dist/bun/gradient.d.ts.map +1 -1
- package/dist/bun/index.d.ts +4 -4
- package/dist/bun/index.d.ts.map +1 -1
- package/dist/bun/index.js +696 -243
- package/dist/bun/index.js.map +4 -4
- package/dist/bun/pattern-extract.d.ts +36 -0
- package/dist/bun/pattern-extract.d.ts.map +1 -0
- package/dist/bun/recall.d.ts +1 -0
- package/dist/bun/recall.d.ts.map +1 -1
- package/dist/bun/search.d.ts +13 -1
- package/dist/bun/search.d.ts.map +1 -1
- package/dist/bun/types.d.ts +41 -1
- package/dist/bun/types.d.ts.map +1 -1
- package/dist/bun/worker-model.d.ts +22 -0
- package/dist/bun/worker-model.d.ts.map +1 -1
- package/dist/node/agents-file.d.ts +29 -8
- package/dist/node/agents-file.d.ts.map +1 -1
- package/dist/node/config.d.ts +1 -0
- package/dist/node/config.d.ts.map +1 -1
- package/dist/node/db.d.ts.map +1 -1
- package/dist/node/distillation.d.ts +29 -0
- package/dist/node/distillation.d.ts.map +1 -1
- package/dist/node/embedding.d.ts +15 -1
- package/dist/node/embedding.d.ts.map +1 -1
- package/dist/node/gradient.d.ts +53 -5
- package/dist/node/gradient.d.ts.map +1 -1
- package/dist/node/index.d.ts +4 -4
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +696 -243
- package/dist/node/index.js.map +4 -4
- package/dist/node/pattern-extract.d.ts +36 -0
- package/dist/node/pattern-extract.d.ts.map +1 -0
- package/dist/node/recall.d.ts +1 -0
- package/dist/node/recall.d.ts.map +1 -1
- package/dist/node/search.d.ts +13 -1
- package/dist/node/search.d.ts.map +1 -1
- package/dist/node/types.d.ts +41 -1
- package/dist/node/types.d.ts.map +1 -1
- package/dist/node/worker-model.d.ts +22 -0
- package/dist/node/worker-model.d.ts.map +1 -1
- package/dist/types/agents-file.d.ts +29 -8
- package/dist/types/agents-file.d.ts.map +1 -1
- package/dist/types/config.d.ts +1 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/db.d.ts.map +1 -1
- package/dist/types/distillation.d.ts +29 -0
- package/dist/types/distillation.d.ts.map +1 -1
- package/dist/types/embedding.d.ts +15 -1
- package/dist/types/embedding.d.ts.map +1 -1
- package/dist/types/gradient.d.ts +53 -5
- package/dist/types/gradient.d.ts.map +1 -1
- package/dist/types/index.d.ts +4 -4
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/pattern-extract.d.ts +36 -0
- package/dist/types/pattern-extract.d.ts.map +1 -0
- package/dist/types/recall.d.ts +1 -0
- package/dist/types/recall.d.ts.map +1 -1
- package/dist/types/search.d.ts +13 -1
- package/dist/types/search.d.ts.map +1 -1
- package/dist/types/types.d.ts +41 -1
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/worker-model.d.ts +22 -0
- package/dist/types/worker-model.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/agents-file.ts +111 -28
- package/src/config.ts +25 -18
- package/src/curator.ts +2 -2
- package/src/db.ts +19 -2
- package/src/distillation.ts +152 -15
- package/src/embedding.ts +158 -14
- package/src/gradient.ts +398 -227
- package/src/index.ts +13 -5
- package/src/pattern-extract.ts +108 -0
- package/src/recall.ts +124 -6
- package/src/search.ts +37 -1
- package/src/types.ts +41 -1
- package/src/worker-model.ts +142 -5
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ export * as distillation from "./distillation";
|
|
|
15
15
|
export * as curator from "./curator";
|
|
16
16
|
export * as embedding from "./embedding";
|
|
17
17
|
export * as latReader from "./lat-reader";
|
|
18
|
+
export * as patternExtract from "./pattern-extract";
|
|
18
19
|
export * as log from "./log";
|
|
19
20
|
|
|
20
21
|
export {
|
|
@@ -72,6 +73,7 @@ export {
|
|
|
72
73
|
getLastTransformEstimate,
|
|
73
74
|
toolStripAnnotation,
|
|
74
75
|
onIdleResume,
|
|
76
|
+
getLastTurnAt,
|
|
75
77
|
consumeCameOutOfIdle,
|
|
76
78
|
// Test-only — exposed at the barrel so host-package tests can simulate idle
|
|
77
79
|
// gaps without sleeping. Not part of the public API.
|
|
@@ -93,13 +95,18 @@ export {
|
|
|
93
95
|
COMPACT_SUMMARY_TEMPLATE,
|
|
94
96
|
buildCompactPrompt,
|
|
95
97
|
} from "./prompt";
|
|
96
|
-
export {
|
|
98
|
+
export {
|
|
99
|
+
shouldImport,
|
|
100
|
+
importFromFile,
|
|
101
|
+
exportToFile,
|
|
102
|
+
exportLoreFile,
|
|
103
|
+
importLoreFile,
|
|
104
|
+
shouldImportLoreFile,
|
|
105
|
+
loreFileExists,
|
|
106
|
+
LORE_FILE,
|
|
107
|
+
} from "./agents-file";
|
|
97
108
|
export { workerSessionIDs, isWorkerSession } from "./worker";
|
|
98
109
|
export * as workerModel from "./worker-model";
|
|
99
|
-
export {
|
|
100
|
-
WORKER_JUDGE_SYSTEM,
|
|
101
|
-
workerJudgeUser,
|
|
102
|
-
} from "./worker-model";
|
|
103
110
|
export {
|
|
104
111
|
ftsQuery,
|
|
105
112
|
ftsQueryOr,
|
|
@@ -107,6 +114,7 @@ export {
|
|
|
107
114
|
reciprocalRankFusion,
|
|
108
115
|
expandQuery,
|
|
109
116
|
extractTopTerms,
|
|
117
|
+
exactTermMatchRank,
|
|
110
118
|
} from "./search";
|
|
111
119
|
export {
|
|
112
120
|
serialize,
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight regex-based pattern extraction from distillation observations.
|
|
3
|
+
*
|
|
4
|
+
* Scans for decision/preference/choice patterns and returns structured
|
|
5
|
+
* extractions that can be stored as knowledge entries. No LLM required.
|
|
6
|
+
*
|
|
7
|
+
* Patterns target how decisions and preferences are typically expressed
|
|
8
|
+
* in distilled engineering context:
|
|
9
|
+
* - "decided to use X"
|
|
10
|
+
* - "chose X over Y"
|
|
11
|
+
* - "switched from X to Y"
|
|
12
|
+
* - "prefers X for Y"
|
|
13
|
+
* - "going with X because Y"
|
|
14
|
+
*
|
|
15
|
+
* Extracted entries participate in the normal curator cycle — the curator
|
|
16
|
+
* can consolidate or remove them based on actual value. The extraction is
|
|
17
|
+
* a cheap seed, not a permanent fixture.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type ExtractedPattern = {
|
|
21
|
+
category: "decision" | "preference";
|
|
22
|
+
/** Short descriptive title, e.g. "Chose PostgreSQL over MySQL". */
|
|
23
|
+
title: string;
|
|
24
|
+
/** Full matched text for context. */
|
|
25
|
+
content: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type PatternDef = {
|
|
29
|
+
regex: RegExp;
|
|
30
|
+
category: "decision" | "preference";
|
|
31
|
+
titleFn: (match: RegExpMatchArray) => string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const PATTERNS: PatternDef[] = [
|
|
35
|
+
// Decision patterns
|
|
36
|
+
{
|
|
37
|
+
regex: /decided to (?:use |switch to |go with |adopt )(.+?)(?:\.|,|$)/gi,
|
|
38
|
+
category: "decision",
|
|
39
|
+
titleFn: (m) => `Decided to use ${m[1].trim()}`,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
regex: /chose (.+?) over (.+?)(?:\.|,|$)/gi,
|
|
43
|
+
category: "decision",
|
|
44
|
+
titleFn: (m) => `Chose ${m[1].trim()} over ${m[2].trim()}`,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
regex: /switched from (.+?) to (.+?)(?:\.|,|$)/gi,
|
|
48
|
+
category: "decision",
|
|
49
|
+
titleFn: (m) => `Switched from ${m[1].trim()} to ${m[2].trim()}`,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
regex: /going with (.+?) (?:because|for|due to)(.+?)(?:\.|,|$)/gi,
|
|
53
|
+
category: "decision",
|
|
54
|
+
titleFn: (m) => `Going with ${m[1].trim()}`,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
regex: /migrat(?:ed|ing) (?:from .+? )?to (.+?)(?:\.|,|$)/gi,
|
|
58
|
+
category: "decision",
|
|
59
|
+
titleFn: (m) => `Migrated to ${m[1].trim()}`,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
regex: /adopted (.+?) (?:for|as|instead)(.+?)(?:\.|,|$)/gi,
|
|
63
|
+
category: "decision",
|
|
64
|
+
titleFn: (m) => `Adopted ${m[1].trim()}`,
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
// Preference patterns
|
|
68
|
+
{
|
|
69
|
+
regex: /prefers? (.+?) (?:over|to|instead of|rather than) (.+?)(?:\.|,|$)/gi,
|
|
70
|
+
category: "preference",
|
|
71
|
+
titleFn: (m) => `Prefers ${m[1].trim()} over ${m[2].trim()}`,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
regex:
|
|
75
|
+
/(?:user |team |we )(?:always |usually |typically )(?:use|prefer|go with) (.+?)(?:\.|,|$)/gi,
|
|
76
|
+
category: "preference",
|
|
77
|
+
titleFn: (m) => `Typically uses ${m[1].trim()}`,
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Extract decision/preference patterns from distillation observations text.
|
|
83
|
+
*
|
|
84
|
+
* Returns structured entries suitable for `ltm.create()`. Deduplicates by
|
|
85
|
+
* lowercased title within a single call.
|
|
86
|
+
*
|
|
87
|
+
* @param observations The distilled observations text to scan.
|
|
88
|
+
* @returns Array of extracted patterns (may be empty).
|
|
89
|
+
*/
|
|
90
|
+
export function extractPatterns(observations: string): ExtractedPattern[] {
|
|
91
|
+
const results: ExtractedPattern[] = [];
|
|
92
|
+
const seen = new Set<string>();
|
|
93
|
+
|
|
94
|
+
for (const { regex, category, titleFn } of PATTERNS) {
|
|
95
|
+
// Reset lastIndex for global regexes reused across calls
|
|
96
|
+
regex.lastIndex = 0;
|
|
97
|
+
let match: RegExpMatchArray | null;
|
|
98
|
+
while ((match = regex.exec(observations)) !== null) {
|
|
99
|
+
const title = titleFn(match);
|
|
100
|
+
const key = title.toLowerCase();
|
|
101
|
+
if (seen.has(key)) continue;
|
|
102
|
+
seen.add(key);
|
|
103
|
+
results.push({ category, title, content: match[0].trim() });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return results;
|
|
108
|
+
}
|
package/src/recall.ts
CHANGED
|
@@ -19,7 +19,9 @@ import type { LoreConfig } from "./config";
|
|
|
19
19
|
import type { LLMClient } from "./types";
|
|
20
20
|
import {
|
|
21
21
|
EMPTY_QUERY,
|
|
22
|
+
exactTermMatchRank,
|
|
22
23
|
expandQuery,
|
|
24
|
+
filterTerms,
|
|
23
25
|
ftsQuery,
|
|
24
26
|
ftsQueryOr,
|
|
25
27
|
reciprocalRankFusion,
|
|
@@ -36,6 +38,7 @@ type Distillation = {
|
|
|
36
38
|
generation: number;
|
|
37
39
|
created_at: number;
|
|
38
40
|
session_id: string;
|
|
41
|
+
c_norm: number | null;
|
|
39
42
|
};
|
|
40
43
|
|
|
41
44
|
export type ScoredDistillation = Distillation & { rank: number };
|
|
@@ -72,6 +75,41 @@ type TaggedResult =
|
|
|
72
75
|
| { source: "temporal"; item: temporal.ScoredTemporalMessage }
|
|
73
76
|
| { source: "lat-section"; item: latReader.ScoredLatSection };
|
|
74
77
|
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Tagged result helpers (used by exact-match boost + formatting)
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/** Extract searchable text from any TaggedResult variant. */
|
|
83
|
+
function getTaggedText(tagged: TaggedResult): string {
|
|
84
|
+
switch (tagged.source) {
|
|
85
|
+
case "knowledge":
|
|
86
|
+
case "cross-knowledge":
|
|
87
|
+
return `${tagged.item.title} ${tagged.item.content}`;
|
|
88
|
+
case "distillation":
|
|
89
|
+
return tagged.item.observations;
|
|
90
|
+
case "temporal":
|
|
91
|
+
return tagged.item.content;
|
|
92
|
+
case "lat-section":
|
|
93
|
+
return `${tagged.item.heading} ${tagged.item.content}`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Unified key function for TaggedResult — source-prefixed ID for RRF dedup. */
|
|
98
|
+
function taggedResultKey(r: TaggedResult): string {
|
|
99
|
+
switch (r.source) {
|
|
100
|
+
case "knowledge":
|
|
101
|
+
return `k:${r.item.id}`;
|
|
102
|
+
case "cross-knowledge":
|
|
103
|
+
return `xk:${r.item.id}`;
|
|
104
|
+
case "distillation":
|
|
105
|
+
return `d:${r.item.id}`;
|
|
106
|
+
case "temporal":
|
|
107
|
+
return `t:${r.item.id}`;
|
|
108
|
+
case "lat-section":
|
|
109
|
+
return `lat:${r.item.id}`;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
75
113
|
// ---------------------------------------------------------------------------
|
|
76
114
|
// Distillation search
|
|
77
115
|
// ---------------------------------------------------------------------------
|
|
@@ -93,8 +131,8 @@ function searchDistillationsLike(input: {
|
|
|
93
131
|
.join(" AND ");
|
|
94
132
|
const likeParams = terms.map((term) => `%${term}%`);
|
|
95
133
|
const sql = input.sessionID
|
|
96
|
-
? `SELECT id, observations, generation, created_at, session_id FROM distillations WHERE project_id = ? AND session_id = ? AND ${conditions} ORDER BY created_at DESC LIMIT ?`
|
|
97
|
-
: `SELECT id, observations, generation, created_at, session_id FROM distillations WHERE project_id = ? AND ${conditions} ORDER BY created_at DESC LIMIT ?`;
|
|
134
|
+
? `SELECT id, observations, generation, created_at, session_id, c_norm FROM distillations WHERE project_id = ? AND session_id = ? AND ${conditions} ORDER BY created_at DESC LIMIT ?`
|
|
135
|
+
: `SELECT id, observations, generation, created_at, session_id, c_norm FROM distillations WHERE project_id = ? AND ${conditions} ORDER BY created_at DESC LIMIT ?`;
|
|
98
136
|
const allParams = input.sessionID
|
|
99
137
|
? [input.pid, input.sessionID, ...likeParams, input.limit]
|
|
100
138
|
: [input.pid, ...likeParams, input.limit];
|
|
@@ -115,13 +153,13 @@ function searchDistillationsScored(input: {
|
|
|
115
153
|
if (q === EMPTY_QUERY) return [];
|
|
116
154
|
|
|
117
155
|
const ftsSQL = input.sessionID
|
|
118
|
-
? `SELECT d.id, d.observations, d.generation, d.created_at, d.session_id, rank
|
|
156
|
+
? `SELECT d.id, d.observations, d.generation, d.created_at, d.session_id, d.c_norm, rank
|
|
119
157
|
FROM distillation_fts f
|
|
120
158
|
CROSS JOIN distillations d ON d.rowid = f.rowid
|
|
121
159
|
WHERE distillation_fts MATCH ?
|
|
122
160
|
AND d.project_id = ? AND d.session_id = ?
|
|
123
161
|
ORDER BY rank LIMIT ?`
|
|
124
|
-
: `SELECT d.id, d.observations, d.generation, d.created_at, d.session_id, rank
|
|
162
|
+
: `SELECT d.id, d.observations, d.generation, d.created_at, d.session_id, d.c_norm, rank
|
|
125
163
|
FROM distillation_fts f
|
|
126
164
|
CROSS JOIN distillations d ON d.rowid = f.rowid
|
|
127
165
|
WHERE distillation_fts MATCH ?
|
|
@@ -241,7 +279,7 @@ export async function runRecall(input: RecallInput): Promise<RecallResult> {
|
|
|
241
279
|
let queries = [query];
|
|
242
280
|
if (searchConfig?.queryExpansion && llm) {
|
|
243
281
|
try {
|
|
244
|
-
queries = await expandQuery(llm, query);
|
|
282
|
+
queries = await expandQuery(llm, query, undefined, sessionID);
|
|
245
283
|
} catch (err) {
|
|
246
284
|
log.info("recall: query expansion failed, using original:", err);
|
|
247
285
|
}
|
|
@@ -376,7 +414,7 @@ export async function runRecall(input: RecallInput): Promise<RecallResult> {
|
|
|
376
414
|
.map((hit): TaggedResult | null => {
|
|
377
415
|
const row = db()
|
|
378
416
|
.query(
|
|
379
|
-
"SELECT id, observations, generation, created_at, session_id FROM distillations WHERE id = ?",
|
|
417
|
+
"SELECT id, observations, generation, created_at, session_id, c_norm FROM distillations WHERE id = ?",
|
|
380
418
|
)
|
|
381
419
|
.get(hit.id) as Distillation | null;
|
|
382
420
|
if (!row) return null;
|
|
@@ -448,6 +486,86 @@ export async function runRecall(input: RecallInput): Promise<RecallResult> {
|
|
|
448
486
|
}
|
|
449
487
|
}
|
|
450
488
|
|
|
489
|
+
// Distillation quality list: rank distillation candidates by a quality score
|
|
490
|
+
// that combines temporal clustering (c_norm) and age. Segments with low c_norm
|
|
491
|
+
// (uniformly distributed timestamps) are considered higher quality than bursty
|
|
492
|
+
// segments (high c_norm). Among high-c_norm segments, recent ones are more
|
|
493
|
+
// likely relevant. This adds a mild signal — RRF naturally blends it with the
|
|
494
|
+
// BM25 and vector signals without overriding them.
|
|
495
|
+
{
|
|
496
|
+
const distillationCandidates: Array<{
|
|
497
|
+
tagged: TaggedResult;
|
|
498
|
+
key: string;
|
|
499
|
+
qualityScore: number;
|
|
500
|
+
}> = [];
|
|
501
|
+
|
|
502
|
+
for (const list of allRrfLists) {
|
|
503
|
+
for (const item of list.items) {
|
|
504
|
+
if (item.source !== "distillation") continue;
|
|
505
|
+
const key = `d:${item.item.id}`;
|
|
506
|
+
const d = item.item as ScoredDistillation;
|
|
507
|
+
const cNorm = d.c_norm ?? 0; // NULL → treat as uniform (best case)
|
|
508
|
+
// Quality score: lower c_norm is better. For high c_norm, recency
|
|
509
|
+
// partially compensates. Age is normalized to days (capped at 90).
|
|
510
|
+
const ageDays = Math.min(
|
|
511
|
+
(Date.now() - d.created_at) / 86_400_000,
|
|
512
|
+
90,
|
|
513
|
+
);
|
|
514
|
+
// score ∈ [0, ~1]: 0 = best quality (uniform + recent)
|
|
515
|
+
// c_norm dominates (0–1), age adds a mild 0–0.1 penalty
|
|
516
|
+
const score = cNorm + (ageDays / 90) * 0.1;
|
|
517
|
+
distillationCandidates.push({ tagged: item, key, qualityScore: score });
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (distillationCandidates.length > 1) {
|
|
522
|
+
// De-duplicate by key (same distillation may appear in BM25 + vector lists)
|
|
523
|
+
const seen = new Set<string>();
|
|
524
|
+
const unique = distillationCandidates.filter((c) => {
|
|
525
|
+
if (seen.has(c.key)) return false;
|
|
526
|
+
seen.add(c.key);
|
|
527
|
+
return true;
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// Sort by quality: lowest score first (best quality)
|
|
531
|
+
unique.sort((a, b) => a.qualityScore - b.qualityScore);
|
|
532
|
+
|
|
533
|
+
allRrfLists.push({
|
|
534
|
+
items: unique.map((c) => c.tagged),
|
|
535
|
+
key: (r) => `d:${r.item.id}`,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Exact-match boost: add an additional RRF list that ranks candidates by
|
|
541
|
+
// the number of exact query term matches. This boosts proper nouns, file
|
|
542
|
+
// names, and technical terms that BM25's prefix/stem matching may dilute.
|
|
543
|
+
// Only runs when there are meaningful terms and existing candidates.
|
|
544
|
+
if (filterTerms(query).length > 0 && allRrfLists.length > 0) {
|
|
545
|
+
// Collect unique candidates across all lists
|
|
546
|
+
const allCandidates = new Map<string, TaggedResult>();
|
|
547
|
+
for (const list of allRrfLists) {
|
|
548
|
+
for (const item of list.items) {
|
|
549
|
+
const key = list.key(item);
|
|
550
|
+
if (!allCandidates.has(key)) allCandidates.set(key, item);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const candidateEntries = [...allCandidates.entries()];
|
|
555
|
+
const exactRanked = exactTermMatchRank(
|
|
556
|
+
candidateEntries,
|
|
557
|
+
([, tagged]) => getTaggedText(tagged),
|
|
558
|
+
query,
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
if (exactRanked.length) {
|
|
562
|
+
allRrfLists.push({
|
|
563
|
+
items: exactRanked.map(([, item]) => item),
|
|
564
|
+
key: taggedResultKey,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
451
569
|
const fused = reciprocalRankFusion<TaggedResult>(allRrfLists);
|
|
452
570
|
return formatFusedResults(fused, 20);
|
|
453
571
|
}
|
package/src/search.ts
CHANGED
|
@@ -267,6 +267,41 @@ export function reciprocalRankFusion<T>(
|
|
|
267
267
|
return [...scores.values()].sort((a, b) => b.score - a.score);
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Exact term match ranking (Phase 5 — MemPalace-inspired keyword boost)
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Score candidates by exact query term overlap.
|
|
276
|
+
*
|
|
277
|
+
* Returns items sorted by number of exact term matches (descending).
|
|
278
|
+
* Used as an additional RRF list to boost results that contain query terms
|
|
279
|
+
* verbatim — important for proper nouns, file names, and technical terms
|
|
280
|
+
* that BM25's prefix matching + Porter stemming can miss or dilute.
|
|
281
|
+
*
|
|
282
|
+
* Terms are filtered through the standard stopword + single-char filter
|
|
283
|
+
* (same as `ftsQuery`), then matched case-insensitively via `includes()`.
|
|
284
|
+
*/
|
|
285
|
+
export function exactTermMatchRank<T>(
|
|
286
|
+
items: T[],
|
|
287
|
+
getText: (item: T) => string,
|
|
288
|
+
query: string,
|
|
289
|
+
): T[] {
|
|
290
|
+
const terms = filterTerms(query).map((t) => t.toLowerCase());
|
|
291
|
+
if (!terms.length) return [];
|
|
292
|
+
|
|
293
|
+
const scored = items
|
|
294
|
+
.map((item) => {
|
|
295
|
+
const text = getText(item).toLowerCase();
|
|
296
|
+
const matches = terms.filter((t) => text.includes(t)).length;
|
|
297
|
+
return { item, matches };
|
|
298
|
+
})
|
|
299
|
+
.filter((s) => s.matches > 0)
|
|
300
|
+
.sort((a, b) => b.matches - a.matches);
|
|
301
|
+
|
|
302
|
+
return scored.map((s) => s.item);
|
|
303
|
+
}
|
|
304
|
+
|
|
270
305
|
// ---------------------------------------------------------------------------
|
|
271
306
|
// LLM query expansion (Phase 4)
|
|
272
307
|
// ---------------------------------------------------------------------------
|
|
@@ -290,6 +325,7 @@ export async function expandQuery(
|
|
|
290
325
|
llm: LLMClient,
|
|
291
326
|
query: string,
|
|
292
327
|
model?: { providerID: string; modelID: string },
|
|
328
|
+
sessionID?: string,
|
|
293
329
|
): Promise<string[]> {
|
|
294
330
|
const TIMEOUT_MS = 3000;
|
|
295
331
|
|
|
@@ -299,7 +335,7 @@ export async function expandQuery(
|
|
|
299
335
|
llm.prompt(
|
|
300
336
|
QUERY_EXPANSION_SYSTEM,
|
|
301
337
|
`Input: "${query}"`,
|
|
302
|
-
{ model, workerID: "lore-query-expand" },
|
|
338
|
+
{ model, workerID: "lore-query-expand", thinking: false, urgent: true, sessionID },
|
|
303
339
|
),
|
|
304
340
|
new Promise<null>((resolve) => setTimeout(() => resolve(null), TIMEOUT_MS)),
|
|
305
341
|
]);
|
package/src/types.ts
CHANGED
|
@@ -189,7 +189,7 @@ export interface LLMClient {
|
|
|
189
189
|
*
|
|
190
190
|
* @param system System prompt text
|
|
191
191
|
* @param user User message text
|
|
192
|
-
* @param opts Optional model selection and
|
|
192
|
+
* @param opts Optional model selection, worker identification, and thinking control
|
|
193
193
|
* @returns The assistant's text response, or null on failure
|
|
194
194
|
*/
|
|
195
195
|
prompt(
|
|
@@ -203,6 +203,46 @@ export interface LLMClient {
|
|
|
203
203
|
* (e.g. OpenCode uses this as the session agent name).
|
|
204
204
|
*/
|
|
205
205
|
workerID?: string;
|
|
206
|
+
/**
|
|
207
|
+
* Disable extended thinking/reasoning for this call.
|
|
208
|
+
*
|
|
209
|
+
* Background workers discard thinking tokens — they only extract the
|
|
210
|
+
* text response. Setting `thinking: false` tells the adapter to avoid
|
|
211
|
+
* producing (and billing for) thinking tokens when possible.
|
|
212
|
+
*
|
|
213
|
+
* Adapter behavior:
|
|
214
|
+
* - Gateway: no-op (bare API call never triggers thinking)
|
|
215
|
+
* - Pi: passes `thinkingEnabled: false` to `complete()`
|
|
216
|
+
* - OpenCode: cannot honor — SDK has no thinking toggle on session.prompt();
|
|
217
|
+
* relies on Part A (non-reasoning model selection) instead
|
|
218
|
+
*/
|
|
219
|
+
thinking?: boolean;
|
|
220
|
+
/**
|
|
221
|
+
* When true, the request must be processed immediately and the result
|
|
222
|
+
* returned before the next user turn. When false or absent, the request
|
|
223
|
+
* may be deferred to a batch queue for cost savings (50% discount via
|
|
224
|
+
* Anthropic's Message Batches API).
|
|
225
|
+
*
|
|
226
|
+
* Callers that `await` the result for a blocking operation (compaction,
|
|
227
|
+
* overflow recovery, query expansion) should set `urgent: true`.
|
|
228
|
+
* Fire-and-forget background work (incremental distillation, idle
|
|
229
|
+
* curation) should leave it unset or set `false`.
|
|
230
|
+
*
|
|
231
|
+
* Only the gateway's BatchLLMClient honors this flag; other adapters
|
|
232
|
+
* (OpenCode, Pi) ignore it and always process immediately.
|
|
233
|
+
*/
|
|
234
|
+
urgent?: boolean;
|
|
235
|
+
/**
|
|
236
|
+
* Session identifier for per-session auth credential lookup.
|
|
237
|
+
*
|
|
238
|
+
* The gateway uses this to resolve the correct API key or OAuth
|
|
239
|
+
* token for the session that triggered the work, preventing
|
|
240
|
+
* cross-session key mixups when multiple clients are connected.
|
|
241
|
+
*
|
|
242
|
+
* Other adapters (OpenCode, Pi) ignore this field — they resolve
|
|
243
|
+
* auth through their own mechanisms.
|
|
244
|
+
*/
|
|
245
|
+
sessionID?: string;
|
|
206
246
|
},
|
|
207
247
|
): Promise<string | null>;
|
|
208
248
|
}
|
package/src/worker-model.ts
CHANGED
|
@@ -25,7 +25,11 @@ export type ModelInfo = {
|
|
|
25
25
|
providerID: string;
|
|
26
26
|
cost: { input: number }; // per-token cost
|
|
27
27
|
status: string;
|
|
28
|
-
capabilities: {
|
|
28
|
+
capabilities: {
|
|
29
|
+
input: { text: boolean };
|
|
30
|
+
/** Whether this model supports extended thinking/reasoning. */
|
|
31
|
+
reasoning?: boolean;
|
|
32
|
+
};
|
|
29
33
|
};
|
|
30
34
|
|
|
31
35
|
/** Result of a worker model validation stored in kv_meta. */
|
|
@@ -64,8 +68,17 @@ export function selectWorkerCandidates(
|
|
|
64
68
|
|
|
65
69
|
if (eligible.length === 0) return [];
|
|
66
70
|
|
|
67
|
-
// Sort by cost ascending
|
|
68
|
-
|
|
71
|
+
// Sort by cost ascending, then prefer non-reasoning models at equal cost.
|
|
72
|
+
// Non-reasoning models don't produce thinking tokens, avoiding wasted spend
|
|
73
|
+
// on tokens that background workers discard.
|
|
74
|
+
const sorted = [...eligible].sort((a, b) => {
|
|
75
|
+
const costDiff = a.cost.input - b.cost.input;
|
|
76
|
+
if (costDiff !== 0) return costDiff;
|
|
77
|
+
// At equal cost, non-reasoning (0) sorts before reasoning (1)
|
|
78
|
+
const aReasoning = a.capabilities.reasoning ? 1 : 0;
|
|
79
|
+
const bReasoning = b.capabilities.reasoning ? 1 : 0;
|
|
80
|
+
return aReasoning - bReasoning;
|
|
81
|
+
});
|
|
69
82
|
|
|
70
83
|
// Cheapest overall
|
|
71
84
|
const cheapest = sorted[0];
|
|
@@ -139,6 +152,11 @@ export function storeValidatedWorkerModel(result: WorkerModelResult): void {
|
|
|
139
152
|
.run(key, value, value);
|
|
140
153
|
}
|
|
141
154
|
|
|
155
|
+
/** Clear a stored worker model validation (e.g. when the model is deprecated). */
|
|
156
|
+
export function clearValidatedWorkerModel(providerID: string): void {
|
|
157
|
+
db().query("DELETE FROM kv_meta WHERE key = ?").run(`${KV_PREFIX}${providerID}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
142
160
|
/**
|
|
143
161
|
* Check whether the stored validation is stale (fingerprint mismatch).
|
|
144
162
|
*/
|
|
@@ -237,6 +255,122 @@ export function parseJudgeScore(response: string): number | null {
|
|
|
237
255
|
return parseInt(match[1], 10);
|
|
238
256
|
}
|
|
239
257
|
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Validation orchestration
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
import { DISTILLATION_SYSTEM, distillationUser } from "./prompt";
|
|
263
|
+
import type { LLMClient } from "./types";
|
|
264
|
+
|
|
265
|
+
export type ValidationInput = {
|
|
266
|
+
llm: LLMClient;
|
|
267
|
+
providerID: string;
|
|
268
|
+
sessionModelID: string;
|
|
269
|
+
candidates: ModelInfo[];
|
|
270
|
+
/** Recent gen-0 distillation to use as reference (observations text). */
|
|
271
|
+
referenceObservations: string;
|
|
272
|
+
/** Source messages text for re-running distillation with candidates. */
|
|
273
|
+
sourceMessagesText: string;
|
|
274
|
+
/** Date string for the distillation prompt. */
|
|
275
|
+
date: string;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Run the two-phase quality validation for worker model candidates.
|
|
280
|
+
* Returns the cheapest passing candidate, or null if none pass.
|
|
281
|
+
*/
|
|
282
|
+
export async function runValidation(
|
|
283
|
+
input: ValidationInput,
|
|
284
|
+
): Promise<WorkerModelResult | null> {
|
|
285
|
+
const { llm, candidates, referenceObservations, sourceMessagesText, date } = input;
|
|
286
|
+
|
|
287
|
+
const userPrompt = distillationUser({
|
|
288
|
+
messages: sourceMessagesText,
|
|
289
|
+
date,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
for (const candidate of candidates) {
|
|
293
|
+
// Skip the session model — it produced the reference, no need to test
|
|
294
|
+
if (candidate.id === input.sessionModelID) continue;
|
|
295
|
+
|
|
296
|
+
// Phase 1: run distillation with candidate model
|
|
297
|
+
let candidateObservations: string | null = null;
|
|
298
|
+
try {
|
|
299
|
+
const raw = await llm.prompt(DISTILLATION_SYSTEM, userPrompt, {
|
|
300
|
+
model: { providerID: candidate.providerID, modelID: candidate.id },
|
|
301
|
+
workerID: "lore-distill",
|
|
302
|
+
thinking: false,
|
|
303
|
+
});
|
|
304
|
+
if (raw) {
|
|
305
|
+
// Parse <observations>...</observations> block
|
|
306
|
+
const match = raw.match(/<observations>([\s\S]*?)<\/observations>/);
|
|
307
|
+
candidateObservations = match ? match[1].trim() : raw.trim();
|
|
308
|
+
}
|
|
309
|
+
} catch (e) {
|
|
310
|
+
log.warn(`worker model validation: candidate ${candidate.id} failed:`, e);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const structural = structuralCheck(candidateObservations, referenceObservations);
|
|
315
|
+
if (!structural.passed) {
|
|
316
|
+
log.info(
|
|
317
|
+
`worker model validation: ${candidate.id} failed structural check: ${structural.reason}`,
|
|
318
|
+
);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Phase 2: LLM judge (using session model)
|
|
323
|
+
let judgeScore: number | null = null;
|
|
324
|
+
try {
|
|
325
|
+
const judgeResponse = await llm.prompt(
|
|
326
|
+
WORKER_JUDGE_SYSTEM,
|
|
327
|
+
workerJudgeUser(referenceObservations, candidateObservations!),
|
|
328
|
+
{ workerID: "lore-distill", thinking: false }, // use session model (no model override)
|
|
329
|
+
);
|
|
330
|
+
if (judgeResponse) {
|
|
331
|
+
judgeScore = parseJudgeScore(judgeResponse);
|
|
332
|
+
}
|
|
333
|
+
} catch (e) {
|
|
334
|
+
log.warn(`worker model validation: judge call failed for ${candidate.id}:`, e);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (judgeScore !== null && judgeScore < 3) {
|
|
338
|
+
log.info(
|
|
339
|
+
`worker model validation: ${candidate.id} failed judge (score=${judgeScore})`,
|
|
340
|
+
);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Candidate passed both phases
|
|
345
|
+
const fingerprint = computeModelFingerprint(
|
|
346
|
+
input.providerID,
|
|
347
|
+
input.sessionModelID,
|
|
348
|
+
candidates.map((c) => c.id),
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const result: WorkerModelResult = {
|
|
352
|
+
modelID: candidate.id,
|
|
353
|
+
providerID: candidate.providerID,
|
|
354
|
+
fingerprint,
|
|
355
|
+
validatedAt: Date.now(),
|
|
356
|
+
judgeScore,
|
|
357
|
+
};
|
|
358
|
+
storeValidatedWorkerModel(result);
|
|
359
|
+
log.info(
|
|
360
|
+
`worker model validated: ${candidate.id} (judge=${judgeScore}) for provider ${input.providerID}`,
|
|
361
|
+
);
|
|
362
|
+
return result;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// No candidate passed — clear any stale stored result so we don't keep
|
|
366
|
+
// routing worker calls to a potentially-deprecated model.
|
|
367
|
+
clearValidatedWorkerModel(input.providerID);
|
|
368
|
+
log.info(
|
|
369
|
+
`worker model validation: no candidate passed for ${input.providerID} — cleared stale entry`,
|
|
370
|
+
);
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
|
|
240
374
|
// ---------------------------------------------------------------------------
|
|
241
375
|
// Effective worker model resolution
|
|
242
376
|
// ---------------------------------------------------------------------------
|
|
@@ -253,9 +387,12 @@ export function resolveWorkerModel(
|
|
|
253
387
|
// Explicit override wins
|
|
254
388
|
if (configWorkerModel) return configWorkerModel;
|
|
255
389
|
|
|
256
|
-
// Check for validated auto-selection
|
|
390
|
+
// Check for validated auto-selection.
|
|
391
|
+
// Don't trust entries older than 24h — model may have been deprecated.
|
|
392
|
+
// Validation will re-run on next idle cycle and either re-confirm or clear.
|
|
257
393
|
const validated = getValidatedWorkerModel(providerID);
|
|
258
|
-
|
|
394
|
+
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
395
|
+
if (validated && Date.now() - validated.validatedAt <= MAX_AGE_MS) {
|
|
259
396
|
return { providerID: validated.providerID, modelID: validated.modelID };
|
|
260
397
|
}
|
|
261
398
|
|