@loreai/core 0.10.2 → 0.11.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/config.d.ts +8 -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 +74 -2
- package/dist/bun/distillation.d.ts.map +1 -1
- package/dist/bun/embedding.d.ts.map +1 -1
- package/dist/bun/gradient.d.ts +72 -0
- package/dist/bun/gradient.d.ts.map +1 -1
- package/dist/bun/index.d.ts +4 -2
- package/dist/bun/index.d.ts.map +1 -1
- package/dist/bun/index.js +554 -76
- package/dist/bun/index.js.map +4 -4
- package/dist/bun/prompt.d.ts +8 -2
- package/dist/bun/prompt.d.ts.map +1 -1
- package/dist/bun/temporal.d.ts +31 -0
- package/dist/bun/temporal.d.ts.map +1 -1
- package/dist/bun/types.d.ts +9 -0
- package/dist/bun/types.d.ts.map +1 -1
- package/dist/bun/worker-model.d.ts +90 -0
- package/dist/bun/worker-model.d.ts.map +1 -0
- package/dist/node/config.d.ts +8 -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 +74 -2
- package/dist/node/distillation.d.ts.map +1 -1
- package/dist/node/embedding.d.ts.map +1 -1
- package/dist/node/gradient.d.ts +72 -0
- package/dist/node/gradient.d.ts.map +1 -1
- package/dist/node/index.d.ts +4 -2
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +554 -76
- package/dist/node/index.js.map +4 -4
- package/dist/node/prompt.d.ts +8 -2
- package/dist/node/prompt.d.ts.map +1 -1
- package/dist/node/temporal.d.ts +31 -0
- package/dist/node/temporal.d.ts.map +1 -1
- package/dist/node/types.d.ts +9 -0
- package/dist/node/types.d.ts.map +1 -1
- package/dist/node/worker-model.d.ts +90 -0
- package/dist/node/worker-model.d.ts.map +1 -0
- package/dist/types/config.d.ts +8 -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 +74 -2
- package/dist/types/distillation.d.ts.map +1 -1
- package/dist/types/embedding.d.ts.map +1 -1
- package/dist/types/gradient.d.ts +72 -0
- package/dist/types/gradient.d.ts.map +1 -1
- package/dist/types/index.d.ts +4 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/prompt.d.ts +8 -2
- package/dist/types/prompt.d.ts.map +1 -1
- package/dist/types/temporal.d.ts +31 -0
- package/dist/types/temporal.d.ts.map +1 -1
- package/dist/types/types.d.ts +9 -0
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/worker-model.d.ts +90 -0
- package/dist/types/worker-model.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/config.ts +53 -6
- package/src/db.ts +68 -6
- package/src/distillation.ts +225 -28
- package/src/embedding.ts +7 -0
- package/src/gradient.ts +305 -17
- package/src/index.ts +16 -0
- package/src/lat-reader.ts +4 -4
- package/src/ltm.ts +17 -17
- package/src/prompt.ts +101 -0
- package/src/recall.ts +4 -4
- package/src/temporal.ts +41 -10
- package/src/types.ts +9 -0
- package/src/worker-model.ts +264 -0
package/src/prompt.ts
CHANGED
|
@@ -184,14 +184,30 @@ EXACT NUMBERS: When two segments report different numbers for what seems like th
|
|
|
184
184
|
|
|
185
185
|
EARLY-SESSION CONTENT: Bug fixes, code changes, and decisions from the start of a session are just as important as later work. Never drop them just because the segment is short or old. If the first segment contains a specific bug fix with file paths and root cause, it MUST survive into the reflection.
|
|
186
186
|
|
|
187
|
+
ANCHORED UPDATES: If the prompt includes a <previous-meta-summary> block, treat it as the current consolidated state. Update it using the NEW observation segments — preserve still-true details, remove stale details, and merge in new facts. Keep the same section headings. Do NOT re-derive unchanged sections verbatim unless the new segments contradict them.
|
|
188
|
+
|
|
187
189
|
Output ONLY an <observations> block with the consolidated observations.`;
|
|
188
190
|
|
|
189
191
|
export function recursiveUser(
|
|
190
192
|
distillations: Array<{ observations: string }>,
|
|
193
|
+
previousMeta?: string,
|
|
191
194
|
): string {
|
|
192
195
|
const entries = distillations.map(
|
|
193
196
|
(d, i) => `Segment ${i + 1}:\n${d.observations}`,
|
|
194
197
|
);
|
|
198
|
+
if (previousMeta) {
|
|
199
|
+
return `Update the anchored meta-summary below using the NEW observation segments. Preserve still-true details, remove stale details, and merge in new facts. Keep the same section headings.
|
|
200
|
+
|
|
201
|
+
<previous-meta-summary>
|
|
202
|
+
${previousMeta}
|
|
203
|
+
</previous-meta-summary>
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
New observation segments to merge (chronological order):
|
|
208
|
+
|
|
209
|
+
${entries.join("\n\n---\n\n")}`;
|
|
210
|
+
}
|
|
195
211
|
return `Observation segments to consolidate (chronological order):
|
|
196
212
|
|
|
197
213
|
${entries.join("\n\n---\n\n")}`;
|
|
@@ -388,6 +404,91 @@ export function formatDistillations(
|
|
|
388
404
|
return sections.join("\n\n");
|
|
389
405
|
}
|
|
390
406
|
|
|
407
|
+
// Strict Markdown skeleton for the /compact session summary. Task-oriented
|
|
408
|
+
// sections so the next agent starting from the compacted context has a clear
|
|
409
|
+
// "where am I, what's next, what's blocked" briefing. Derived from upstream
|
|
410
|
+
// OpenCode's SUMMARY_TEMPLATE (session/compaction.ts in #23870) with a "(none)"
|
|
411
|
+
// directive added for explicit empty sections and a closing "I'm ready to
|
|
412
|
+
// continue." sentinel to preserve Lore's post-compact UX.
|
|
413
|
+
export const COMPACT_SUMMARY_TEMPLATE = `Output exactly this Markdown structure. Keep every section in this order, even when empty (use "(none)").
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
## Goal
|
|
417
|
+
- [single-sentence task summary]
|
|
418
|
+
|
|
419
|
+
## Constraints & Preferences
|
|
420
|
+
- [user constraints, preferences, specs, or "(none)"]
|
|
421
|
+
|
|
422
|
+
## Progress
|
|
423
|
+
### Done
|
|
424
|
+
- [completed work or "(none)"]
|
|
425
|
+
|
|
426
|
+
### In Progress
|
|
427
|
+
- [current work or "(none)"]
|
|
428
|
+
|
|
429
|
+
### Blocked
|
|
430
|
+
- [blockers or "(none)"]
|
|
431
|
+
|
|
432
|
+
## Key Decisions
|
|
433
|
+
- [decision and why, or "(none)"]
|
|
434
|
+
|
|
435
|
+
## Next Steps
|
|
436
|
+
- [ordered next actions or "(none)"]
|
|
437
|
+
|
|
438
|
+
## Critical Context
|
|
439
|
+
- [important technical facts, errors, open questions, or "(none)"]
|
|
440
|
+
|
|
441
|
+
## Relevant Files
|
|
442
|
+
- [file or directory path: why it matters, or "(none)"]
|
|
443
|
+
---
|
|
444
|
+
|
|
445
|
+
Rules:
|
|
446
|
+
- Keep every section, even when empty.
|
|
447
|
+
- Use terse bullets, not prose paragraphs.
|
|
448
|
+
- Preserve exact file paths, commands, error strings, and identifiers when known.
|
|
449
|
+
- Do not mention the summary process or that context was compacted.
|
|
450
|
+
- End with "I'm ready to continue." on its own line after the closing "---".`;
|
|
451
|
+
|
|
452
|
+
// Build the user-facing prompt passed to the compaction agent during /compact.
|
|
453
|
+
// Lore injects pre-computed distillations as context separately; this prompt
|
|
454
|
+
// just tells the model how to render its summary.
|
|
455
|
+
//
|
|
456
|
+
// `hasDistillations` is a boolean rather than the full array because this
|
|
457
|
+
// function only cares about presence — the distillation bodies are pushed into
|
|
458
|
+
// `output.context` separately by the caller. Passing the array shape would be
|
|
459
|
+
// misleading dead weight.
|
|
460
|
+
//
|
|
461
|
+
// `previousSummary` is the prior `/compact` output text (typically from the
|
|
462
|
+
// most recent assistant message with `info.summary === true`). When present,
|
|
463
|
+
// the prompt asks the model to UPDATE the anchored summary in place rather
|
|
464
|
+
// than re-derive from scratch — matching upstream OpenCode's behavior at
|
|
465
|
+
// `compaction.ts:121-132` (`buildPrompt`). When absent, the prompt is
|
|
466
|
+
// byte-identical to today's non-anchored output.
|
|
467
|
+
//
|
|
468
|
+
// F1b (this parameter) is OpenCode-specific: the retrieval path uses
|
|
469
|
+
// `client.session.messages` to find the prior summary by `info.summary === true`.
|
|
470
|
+
// See `findPreviousCompactSummary` in `packages/opencode/src/index.ts`.
|
|
471
|
+
export function buildCompactPrompt(input: {
|
|
472
|
+
hasDistillations: boolean;
|
|
473
|
+
knowledge?: string;
|
|
474
|
+
previousSummary?: string;
|
|
475
|
+
}): string {
|
|
476
|
+
const distillSection = input.hasDistillations
|
|
477
|
+
? "Lore has pre-computed chunked summaries of the session history (injected above as context). Use them as the authoritative source — do NOT re-read raw conversation messages that conflict with them.\n\n"
|
|
478
|
+
: "";
|
|
479
|
+
|
|
480
|
+
const anchorBlock = input.previousSummary
|
|
481
|
+
? `A prior compacted summary exists for this session. Update it using the conversation history above: preserve still-true details, remove stale details, and merge in new facts. Keep every section in place.\n\n<previous-summary>\n${input.previousSummary}\n</previous-summary>\n\n`
|
|
482
|
+
: "";
|
|
483
|
+
|
|
484
|
+
const knowledgeBlock = input.knowledge ? `\n${input.knowledge}\n` : "";
|
|
485
|
+
|
|
486
|
+
return `You are producing a compacted session summary for an AI coding agent. This summary will be the ONLY context available in the next part of the conversation.
|
|
487
|
+
|
|
488
|
+
${distillSection}${anchorBlock}${COMPACT_SUMMARY_TEMPLATE}
|
|
489
|
+
${knowledgeBlock}`;
|
|
490
|
+
}
|
|
491
|
+
|
|
391
492
|
// ~3 chars per token — validated as best heuristic against real API data.
|
|
392
493
|
function estimateTokens(text: string): number {
|
|
393
494
|
return Math.ceil(text.length / 3);
|
package/src/recall.ts
CHANGED
|
@@ -116,14 +116,14 @@ function searchDistillationsScored(input: {
|
|
|
116
116
|
|
|
117
117
|
const ftsSQL = input.sessionID
|
|
118
118
|
? `SELECT d.id, d.observations, d.generation, d.created_at, d.session_id, rank
|
|
119
|
-
FROM
|
|
120
|
-
JOIN
|
|
119
|
+
FROM distillation_fts f
|
|
120
|
+
CROSS JOIN distillations d ON d.rowid = f.rowid
|
|
121
121
|
WHERE distillation_fts MATCH ?
|
|
122
122
|
AND d.project_id = ? AND d.session_id = ?
|
|
123
123
|
ORDER BY rank LIMIT ?`
|
|
124
124
|
: `SELECT d.id, d.observations, d.generation, d.created_at, d.session_id, rank
|
|
125
|
-
FROM
|
|
126
|
-
JOIN
|
|
125
|
+
FROM distillation_fts f
|
|
126
|
+
CROSS JOIN distillations d ON d.rowid = f.rowid
|
|
127
127
|
WHERE distillation_fts MATCH ?
|
|
128
128
|
AND d.project_id = ?
|
|
129
129
|
ORDER BY rank LIMIT ?`;
|
package/src/temporal.ts
CHANGED
|
@@ -9,7 +9,38 @@ function estimate(text: string): number {
|
|
|
9
9
|
return Math.ceil(text.length / 3);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Chunk-boundary terminator inserted between chunks by `partsToText`.
|
|
14
|
+
*
|
|
15
|
+
* `\x1f` is ASCII Unit Separator — a non-word control char that:
|
|
16
|
+
* - cannot legitimately appear in normal chat or tool content (control
|
|
17
|
+
* chars are vanishingly rare even in binary file dumps),
|
|
18
|
+
* - is treated as a token separator by FTS5's `unicode61` tokenizer, so
|
|
19
|
+
* it has zero effect on BM25 indexing or scoring,
|
|
20
|
+
* - survives `sanitizeSurrogates()` (which only touches lone UTF-16
|
|
21
|
+
* surrogates, never ASCII control chars).
|
|
22
|
+
*
|
|
23
|
+
* Placed AFTER the existing `\n` so display tools that split on `\n`
|
|
24
|
+
* still render correctly; the structural parser (in `distillation.ts`)
|
|
25
|
+
* splits on `"\n" + CHUNK_TERMINATOR` for unambiguous chunk recovery.
|
|
26
|
+
*
|
|
27
|
+
* Adopted in F3b. Pre-F3b rows are rewritten in-place by a SQL migration
|
|
28
|
+
* (see `db.ts`); after that migration runs, every `temporal_messages.content`
|
|
29
|
+
* value uses this format consistently.
|
|
30
|
+
*/
|
|
31
|
+
export const CHUNK_TERMINATOR = "\x1f";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Serialize a list of message parts into a single content string for the
|
|
35
|
+
* `temporal_messages.content` column. Chunks are separated by
|
|
36
|
+
* `"\n" + CHUNK_TERMINATOR` so the structural parser can recover chunk
|
|
37
|
+
* boundaries unambiguously regardless of payload contents (including
|
|
38
|
+
* payloads that contain literal `[tool:...]` substrings — e.g. when the
|
|
39
|
+
* agent reads a file that documents this very format).
|
|
40
|
+
*
|
|
41
|
+
* Exported so tests can pin producer/consumer round-trip behavior.
|
|
42
|
+
*/
|
|
43
|
+
export function partsToText(parts: LorePart[]): string {
|
|
13
44
|
const chunks: string[] = [];
|
|
14
45
|
for (const part of parts) {
|
|
15
46
|
if (isTextPart(part)) chunks.push(part.text);
|
|
@@ -21,7 +52,7 @@ function partsToText(parts: LorePart[]): string {
|
|
|
21
52
|
// Sanitize unpaired surrogates from tool outputs and other raw text.
|
|
22
53
|
// Without this, surrogates survive into the DB and later break JSON
|
|
23
54
|
// serialization when included in recall tool responses.
|
|
24
|
-
return sanitizeSurrogates(chunks.join("\n"));
|
|
55
|
+
return sanitizeSurrogates(chunks.join("\n" + CHUNK_TERMINATOR));
|
|
25
56
|
}
|
|
26
57
|
|
|
27
58
|
function messageMetadata(info: LoreMessage, parts: LorePart[]): string {
|
|
@@ -167,12 +198,12 @@ export function search(input: {
|
|
|
167
198
|
if (q === EMPTY_QUERY) return [];
|
|
168
199
|
|
|
169
200
|
const ftsSQL = input.sessionID
|
|
170
|
-
? `SELECT m.* FROM
|
|
171
|
-
JOIN
|
|
201
|
+
? `SELECT m.* FROM temporal_fts f
|
|
202
|
+
CROSS JOIN temporal_messages m ON m.rowid = f.rowid
|
|
172
203
|
WHERE f.content MATCH ? AND m.project_id = ? AND m.session_id = ?
|
|
173
204
|
ORDER BY rank LIMIT ?`
|
|
174
|
-
: `SELECT m.* FROM
|
|
175
|
-
JOIN
|
|
205
|
+
: `SELECT m.* FROM temporal_fts f
|
|
206
|
+
CROSS JOIN temporal_messages m ON m.rowid = f.rowid
|
|
176
207
|
WHERE f.content MATCH ? AND m.project_id = ?
|
|
177
208
|
ORDER BY rank LIMIT ?`;
|
|
178
209
|
const params = input.sessionID
|
|
@@ -222,12 +253,12 @@ export function searchScored(input: {
|
|
|
222
253
|
if (q === EMPTY_QUERY) return [];
|
|
223
254
|
|
|
224
255
|
const ftsSQL = input.sessionID
|
|
225
|
-
? `SELECT m.*, rank FROM
|
|
226
|
-
JOIN
|
|
256
|
+
? `SELECT m.*, rank FROM temporal_fts f
|
|
257
|
+
CROSS JOIN temporal_messages m ON m.rowid = f.rowid
|
|
227
258
|
WHERE f.content MATCH ? AND m.project_id = ? AND m.session_id = ?
|
|
228
259
|
ORDER BY rank LIMIT ?`
|
|
229
|
-
: `SELECT m.*, rank FROM
|
|
230
|
-
JOIN
|
|
260
|
+
: `SELECT m.*, rank FROM temporal_fts f
|
|
261
|
+
CROSS JOIN temporal_messages m ON m.rowid = f.rowid
|
|
231
262
|
WHERE f.content MATCH ? AND m.project_id = ?
|
|
232
263
|
ORDER BY rank LIMIT ?`;
|
|
233
264
|
const params = input.sessionID
|
package/src/types.ts
CHANGED
|
@@ -35,6 +35,15 @@ export type LoreAssistantMessage = {
|
|
|
35
35
|
modelID: string;
|
|
36
36
|
providerID: string;
|
|
37
37
|
mode: string;
|
|
38
|
+
/**
|
|
39
|
+
* Set to `true` by the OpenCode compaction agent on the assistant
|
|
40
|
+
* message that holds a `/compact` summary (see upstream
|
|
41
|
+
* `compaction.ts:435`). Lore reads this flag in F1b's
|
|
42
|
+
* `findPreviousCompactSummary` to anchor repeat `/compact`
|
|
43
|
+
* invocations to the prior summary. Always undefined for normal
|
|
44
|
+
* assistant turns.
|
|
45
|
+
*/
|
|
46
|
+
summary?: boolean;
|
|
38
47
|
path: { cwd: string; root: string };
|
|
39
48
|
cost: number;
|
|
40
49
|
tokens: {
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic worker model selection.
|
|
3
|
+
*
|
|
4
|
+
* Background workers (distillation, curation, query expansion) don't need
|
|
5
|
+
* frontier reasoning. This module discovers cheaper models from the same
|
|
6
|
+
* provider and validates their quality via a two-phase comparison:
|
|
7
|
+
* Phase 1: structural checks (parsability, observation count, token bounds)
|
|
8
|
+
* Phase 2: LLM judge (session model rates candidate output vs reference)
|
|
9
|
+
*
|
|
10
|
+
* Results are persisted in kv_meta and re-evaluated when the model landscape
|
|
11
|
+
* changes (new models, session model switch, model deprecation).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { db } from "./db";
|
|
15
|
+
import { sha256 } from "#db/driver";
|
|
16
|
+
import * as log from "./log";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Types
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/** Minimal model info needed for worker selection — provider-agnostic. */
|
|
23
|
+
export type ModelInfo = {
|
|
24
|
+
id: string;
|
|
25
|
+
providerID: string;
|
|
26
|
+
cost: { input: number }; // per-token cost
|
|
27
|
+
status: string;
|
|
28
|
+
capabilities: { input: { text: boolean } };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Result of a worker model validation stored in kv_meta. */
|
|
32
|
+
export type WorkerModelResult = {
|
|
33
|
+
modelID: string;
|
|
34
|
+
providerID: string;
|
|
35
|
+
fingerprint: string;
|
|
36
|
+
validatedAt: number;
|
|
37
|
+
judgeScore: number | null; // null = structural-only (no judge run yet)
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const KV_PREFIX = "lore:worker_model:";
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Candidate selection
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Select worker model candidates from the available models.
|
|
48
|
+
*
|
|
49
|
+
* Returns up to 2 candidates: cheapest overall + one tier below the session
|
|
50
|
+
* model. The session model itself is included (if it's the cheapest, the list
|
|
51
|
+
* has 1 entry and no comparison is needed).
|
|
52
|
+
*/
|
|
53
|
+
export function selectWorkerCandidates(
|
|
54
|
+
sessionModel: { id: string; providerID: string; cost: { input: number } },
|
|
55
|
+
providerModels: ModelInfo[],
|
|
56
|
+
): ModelInfo[] {
|
|
57
|
+
// Filter: same provider, active, text-capable
|
|
58
|
+
const eligible = providerModels.filter(
|
|
59
|
+
(m) =>
|
|
60
|
+
m.providerID === sessionModel.providerID &&
|
|
61
|
+
m.status === "active" &&
|
|
62
|
+
m.capabilities.input.text,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
if (eligible.length === 0) return [];
|
|
66
|
+
|
|
67
|
+
// Sort by cost ascending (cheapest first)
|
|
68
|
+
const sorted = [...eligible].sort((a, b) => a.cost.input - b.cost.input);
|
|
69
|
+
|
|
70
|
+
// Cheapest overall
|
|
71
|
+
const cheapest = sorted[0];
|
|
72
|
+
|
|
73
|
+
// One tier below session model: the most expensive model that's still
|
|
74
|
+
// cheaper than the session model. If session IS cheapest, this is undefined.
|
|
75
|
+
const belowSession = sorted
|
|
76
|
+
.filter((m) => m.cost.input < sessionModel.cost.input)
|
|
77
|
+
.pop(); // last = most expensive among cheaper ones
|
|
78
|
+
|
|
79
|
+
// Deduplicate
|
|
80
|
+
const candidates = new Map<string, ModelInfo>();
|
|
81
|
+
candidates.set(cheapest.id, cheapest);
|
|
82
|
+
if (belowSession && belowSession.id !== cheapest.id) {
|
|
83
|
+
candidates.set(belowSession.id, belowSession);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// If session model is the cheapest, return just it
|
|
87
|
+
if (cheapest.id === sessionModel.id || cheapest.cost.input >= sessionModel.cost.input) {
|
|
88
|
+
return [cheapest];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return [...candidates.values()];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Fingerprinting
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Compute a fingerprint from the model landscape. Changes when:
|
|
100
|
+
* - Models are added or removed from the provider
|
|
101
|
+
* - The session model changes
|
|
102
|
+
*/
|
|
103
|
+
export function computeModelFingerprint(
|
|
104
|
+
providerID: string,
|
|
105
|
+
sessionModelID: string,
|
|
106
|
+
activeModelIDs: string[],
|
|
107
|
+
): string {
|
|
108
|
+
const sorted = [...activeModelIDs].sort();
|
|
109
|
+
return sha256(
|
|
110
|
+
JSON.stringify({ providerID, sessionModelID, modelIDs: sorted }),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Persistence
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
export function getValidatedWorkerModel(
|
|
119
|
+
providerID: string,
|
|
120
|
+
): WorkerModelResult | null {
|
|
121
|
+
const row = db()
|
|
122
|
+
.query("SELECT value FROM kv_meta WHERE key = ?")
|
|
123
|
+
.get(`${KV_PREFIX}${providerID}`) as { value: string } | null;
|
|
124
|
+
if (!row) return null;
|
|
125
|
+
try {
|
|
126
|
+
return JSON.parse(row.value) as WorkerModelResult;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function storeValidatedWorkerModel(result: WorkerModelResult): void {
|
|
133
|
+
const key = `${KV_PREFIX}${result.providerID}`;
|
|
134
|
+
const value = JSON.stringify(result);
|
|
135
|
+
db()
|
|
136
|
+
.query(
|
|
137
|
+
"INSERT INTO kv_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?",
|
|
138
|
+
)
|
|
139
|
+
.run(key, value, value);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Check whether the stored validation is stale (fingerprint mismatch).
|
|
144
|
+
*/
|
|
145
|
+
export function isValidationStale(
|
|
146
|
+
stored: WorkerModelResult | null,
|
|
147
|
+
currentFingerprint: string,
|
|
148
|
+
): boolean {
|
|
149
|
+
if (!stored) return true;
|
|
150
|
+
return stored.fingerprint !== currentFingerprint;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Structural validation
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
export type StructuralCheckResult = {
|
|
158
|
+
passed: boolean;
|
|
159
|
+
observationCount: number;
|
|
160
|
+
tokenCount: number;
|
|
161
|
+
reason?: string;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Structural quality check: does the candidate distillation output meet
|
|
166
|
+
* minimum quality thresholds relative to the reference?
|
|
167
|
+
*/
|
|
168
|
+
export function structuralCheck(
|
|
169
|
+
candidateObservations: string | null,
|
|
170
|
+
referenceObservations: string,
|
|
171
|
+
): StructuralCheckResult {
|
|
172
|
+
if (candidateObservations == null || candidateObservations.length === 0) {
|
|
173
|
+
return { passed: false, observationCount: 0, tokenCount: 0, reason: candidateObservations === null ? "parse_failed" : "empty" };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Count observation lines (non-empty lines starting with common markers)
|
|
177
|
+
const countObs = (text: string) =>
|
|
178
|
+
text.split("\n").filter((l) => l.trim().length > 0).length;
|
|
179
|
+
|
|
180
|
+
const refCount = countObs(referenceObservations);
|
|
181
|
+
const candCount = countObs(candidateObservations);
|
|
182
|
+
const candTokens = Math.ceil(candidateObservations.length / 3);
|
|
183
|
+
|
|
184
|
+
// Observation count within ±50% of reference
|
|
185
|
+
if (refCount > 0 && (candCount < refCount * 0.5 || candCount > refCount * 1.5)) {
|
|
186
|
+
return {
|
|
187
|
+
passed: false,
|
|
188
|
+
observationCount: candCount,
|
|
189
|
+
tokenCount: candTokens,
|
|
190
|
+
reason: `observation_count_${candCount}_vs_ref_${refCount}`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Not degenerate: not empty, not >3x reference size
|
|
195
|
+
const refTokens = Math.ceil(referenceObservations.length / 3);
|
|
196
|
+
if (candTokens === 0) {
|
|
197
|
+
return { passed: false, observationCount: candCount, tokenCount: candTokens, reason: "empty" };
|
|
198
|
+
}
|
|
199
|
+
if (refTokens > 0 && candTokens > refTokens * 3) {
|
|
200
|
+
return {
|
|
201
|
+
passed: false,
|
|
202
|
+
observationCount: candCount,
|
|
203
|
+
tokenCount: candTokens,
|
|
204
|
+
reason: `token_count_${candTokens}_vs_ref_${refTokens}_3x`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { passed: true, observationCount: candCount, tokenCount: candTokens };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Judge prompt
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
export const WORKER_JUDGE_SYSTEM = `You are evaluating distillation quality. You will be given a REFERENCE distillation (produced by a capable model) and a CANDIDATE distillation (produced by a cheaper model) of the same conversation segment.
|
|
216
|
+
|
|
217
|
+
Rate the candidate on a scale of 1-5:
|
|
218
|
+
5 = Captures all key facts and decisions, equivalent to reference
|
|
219
|
+
4 = Captures most facts, minor omissions
|
|
220
|
+
3 = Captures the essential facts, some detail loss acceptable
|
|
221
|
+
2 = Missing important facts or technical details
|
|
222
|
+
1 = Significantly incomplete or inaccurate
|
|
223
|
+
|
|
224
|
+
Respond with ONLY a single digit (1-5).`;
|
|
225
|
+
|
|
226
|
+
export function workerJudgeUser(
|
|
227
|
+
reference: string,
|
|
228
|
+
candidate: string,
|
|
229
|
+
): string {
|
|
230
|
+
return `<reference>\n${reference}\n</reference>\n\n<candidate>\n${candidate}\n</candidate>`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Parse the judge's score from a response. Returns null on parse failure. */
|
|
234
|
+
export function parseJudgeScore(response: string): number | null {
|
|
235
|
+
const match = response.trim().match(/^([1-5])/);
|
|
236
|
+
if (!match) return null;
|
|
237
|
+
return parseInt(match[1], 10);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Effective worker model resolution
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Resolve the effective worker model for a given provider.
|
|
246
|
+
* Priority: explicit config > validated auto-selection > session model (fallback).
|
|
247
|
+
*/
|
|
248
|
+
export function resolveWorkerModel(
|
|
249
|
+
providerID: string,
|
|
250
|
+
configWorkerModel?: { providerID: string; modelID: string },
|
|
251
|
+
configModel?: { providerID: string; modelID: string },
|
|
252
|
+
): { providerID: string; modelID: string } | undefined {
|
|
253
|
+
// Explicit override wins
|
|
254
|
+
if (configWorkerModel) return configWorkerModel;
|
|
255
|
+
|
|
256
|
+
// Check for validated auto-selection
|
|
257
|
+
const validated = getValidatedWorkerModel(providerID);
|
|
258
|
+
if (validated) {
|
|
259
|
+
return { providerID: validated.providerID, modelID: validated.modelID };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Fall back to the session model config (or undefined = host default)
|
|
263
|
+
return configModel;
|
|
264
|
+
}
|