@loreai/core 0.11.1 → 0.13.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/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 +55 -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 +799 -256
- 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/temporal.d.ts +15 -0
- package/dist/bun/temporal.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 +55 -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 +799 -256
- 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/temporal.d.ts +15 -0
- package/dist/node/temporal.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 +55 -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/temporal.d.ts +15 -0
- package/dist/types/temporal.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 +83 -4
- package/src/distillation.ts +270 -27
- 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 +142 -6
- package/src/search.ts +37 -1
- package/src/temporal.ts +39 -0
- 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
|
}
|
|
@@ -322,6 +360,24 @@ export async function runRecall(input: RecallInput): Promise<RecallResult> {
|
|
|
322
360
|
key: (r) => `t:${r.item.id}`,
|
|
323
361
|
},
|
|
324
362
|
);
|
|
363
|
+
|
|
364
|
+
// Recency-biased list for temporal results: same candidates re-ranked
|
|
365
|
+
// by created_at (newest first). RRF naturally boosts messages that
|
|
366
|
+
// appear in both the BM25 and recency lists — i.e. results that are
|
|
367
|
+
// both semantically relevant AND recent. Uses the same `t:` key prefix
|
|
368
|
+
// so RRF merges rather than duplicates.
|
|
369
|
+
if (temporalResults.length > 0) {
|
|
370
|
+
const recencySorted = [...temporalResults].sort(
|
|
371
|
+
(a, b) => b.created_at - a.created_at,
|
|
372
|
+
);
|
|
373
|
+
allRrfLists.push({
|
|
374
|
+
items: recencySorted.map((item) => ({
|
|
375
|
+
source: "temporal" as const,
|
|
376
|
+
item,
|
|
377
|
+
})),
|
|
378
|
+
key: (r) => `t:${r.item.id}`,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
325
381
|
}
|
|
326
382
|
|
|
327
383
|
// Vector search on the original query (not expansions — avoid redundant embeds).
|
|
@@ -358,7 +414,7 @@ export async function runRecall(input: RecallInput): Promise<RecallResult> {
|
|
|
358
414
|
.map((hit): TaggedResult | null => {
|
|
359
415
|
const row = db()
|
|
360
416
|
.query(
|
|
361
|
-
"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 = ?",
|
|
362
418
|
)
|
|
363
419
|
.get(hit.id) as Distillation | null;
|
|
364
420
|
if (!row) return null;
|
|
@@ -430,6 +486,86 @@ export async function runRecall(input: RecallInput): Promise<RecallResult> {
|
|
|
430
486
|
}
|
|
431
487
|
}
|
|
432
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
|
+
|
|
433
569
|
const fused = reciprocalRankFusion<TaggedResult>(allRrfLists);
|
|
434
570
|
return formatFusedResults(fused, 20);
|
|
435
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/temporal.ts
CHANGED
|
@@ -280,6 +280,45 @@ export function searchScored(input: {
|
|
|
280
280
|
}
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Normalized variance of relative-existence weights over message timestamps.
|
|
285
|
+
*
|
|
286
|
+
* Measures temporal attention imbalance: 0 means timestamps are evenly
|
|
287
|
+
* distributed (uniform attention), 1 means a single distant timestamp
|
|
288
|
+
* dominates (attention stuck in the past). Useful as a lightweight
|
|
289
|
+
* signal for distillation segmentation, recall time-biasing, and
|
|
290
|
+
* idle-resume awareness.
|
|
291
|
+
*
|
|
292
|
+
* Only meaningful for n ≥ 2. Returns 0 for 0 or 1 timestamps.
|
|
293
|
+
*
|
|
294
|
+
* Based on the "Temporal Clustering via Relative Existence" heuristic
|
|
295
|
+
* from D7x7z49/llm-context-idea.
|
|
296
|
+
*/
|
|
297
|
+
export function temporalCnorm(
|
|
298
|
+
timestamps: number[],
|
|
299
|
+
now: number = Date.now(),
|
|
300
|
+
): number {
|
|
301
|
+
const n = timestamps.length;
|
|
302
|
+
if (n < 2) return 0;
|
|
303
|
+
|
|
304
|
+
// Existence durations: how long each piece has existed
|
|
305
|
+
const durations = timestamps.map((t) => now - t);
|
|
306
|
+
const totalDuration = durations.reduce((a, b) => a + b, 0);
|
|
307
|
+
if (totalDuration <= 0) return 0;
|
|
308
|
+
|
|
309
|
+
// Relative existence weights (positive, sum to 1)
|
|
310
|
+
const weights = durations.map((d) => d / totalDuration);
|
|
311
|
+
|
|
312
|
+
// Normalized variance: Var(w) / Var_max
|
|
313
|
+
// Var(w) = (1/n) * Σ(w_i - 1/n)²
|
|
314
|
+
// Var_max = (n-1) / n² (when one weight = 1, rest = 0)
|
|
315
|
+
const uniform = 1 / n;
|
|
316
|
+
const variance =
|
|
317
|
+
weights.reduce((sum, w) => sum + (w - uniform) ** 2, 0) / n;
|
|
318
|
+
const maxVariance = (n - 1) / (n * n);
|
|
319
|
+
return maxVariance === 0 ? 0 : variance / maxVariance;
|
|
320
|
+
}
|
|
321
|
+
|
|
283
322
|
export function count(projectPath: string, sessionID?: string): number {
|
|
284
323
|
const pid = ensureProject(projectPath);
|
|
285
324
|
const query = sessionID
|
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
|
}
|