@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.
Files changed (72) hide show
  1. package/dist/bun/config.d.ts +8 -0
  2. package/dist/bun/config.d.ts.map +1 -1
  3. package/dist/bun/db.d.ts.map +1 -1
  4. package/dist/bun/distillation.d.ts +74 -2
  5. package/dist/bun/distillation.d.ts.map +1 -1
  6. package/dist/bun/embedding.d.ts.map +1 -1
  7. package/dist/bun/gradient.d.ts +72 -0
  8. package/dist/bun/gradient.d.ts.map +1 -1
  9. package/dist/bun/index.d.ts +4 -2
  10. package/dist/bun/index.d.ts.map +1 -1
  11. package/dist/bun/index.js +554 -76
  12. package/dist/bun/index.js.map +4 -4
  13. package/dist/bun/prompt.d.ts +8 -2
  14. package/dist/bun/prompt.d.ts.map +1 -1
  15. package/dist/bun/temporal.d.ts +31 -0
  16. package/dist/bun/temporal.d.ts.map +1 -1
  17. package/dist/bun/types.d.ts +9 -0
  18. package/dist/bun/types.d.ts.map +1 -1
  19. package/dist/bun/worker-model.d.ts +90 -0
  20. package/dist/bun/worker-model.d.ts.map +1 -0
  21. package/dist/node/config.d.ts +8 -0
  22. package/dist/node/config.d.ts.map +1 -1
  23. package/dist/node/db.d.ts.map +1 -1
  24. package/dist/node/distillation.d.ts +74 -2
  25. package/dist/node/distillation.d.ts.map +1 -1
  26. package/dist/node/embedding.d.ts.map +1 -1
  27. package/dist/node/gradient.d.ts +72 -0
  28. package/dist/node/gradient.d.ts.map +1 -1
  29. package/dist/node/index.d.ts +4 -2
  30. package/dist/node/index.d.ts.map +1 -1
  31. package/dist/node/index.js +554 -76
  32. package/dist/node/index.js.map +4 -4
  33. package/dist/node/prompt.d.ts +8 -2
  34. package/dist/node/prompt.d.ts.map +1 -1
  35. package/dist/node/temporal.d.ts +31 -0
  36. package/dist/node/temporal.d.ts.map +1 -1
  37. package/dist/node/types.d.ts +9 -0
  38. package/dist/node/types.d.ts.map +1 -1
  39. package/dist/node/worker-model.d.ts +90 -0
  40. package/dist/node/worker-model.d.ts.map +1 -0
  41. package/dist/types/config.d.ts +8 -0
  42. package/dist/types/config.d.ts.map +1 -1
  43. package/dist/types/db.d.ts.map +1 -1
  44. package/dist/types/distillation.d.ts +74 -2
  45. package/dist/types/distillation.d.ts.map +1 -1
  46. package/dist/types/embedding.d.ts.map +1 -1
  47. package/dist/types/gradient.d.ts +72 -0
  48. package/dist/types/gradient.d.ts.map +1 -1
  49. package/dist/types/index.d.ts +4 -2
  50. package/dist/types/index.d.ts.map +1 -1
  51. package/dist/types/prompt.d.ts +8 -2
  52. package/dist/types/prompt.d.ts.map +1 -1
  53. package/dist/types/temporal.d.ts +31 -0
  54. package/dist/types/temporal.d.ts.map +1 -1
  55. package/dist/types/types.d.ts +9 -0
  56. package/dist/types/types.d.ts.map +1 -1
  57. package/dist/types/worker-model.d.ts +90 -0
  58. package/dist/types/worker-model.d.ts.map +1 -0
  59. package/package.json +1 -1
  60. package/src/config.ts +53 -6
  61. package/src/db.ts +68 -6
  62. package/src/distillation.ts +225 -28
  63. package/src/embedding.ts +7 -0
  64. package/src/gradient.ts +305 -17
  65. package/src/index.ts +16 -0
  66. package/src/lat-reader.ts +4 -4
  67. package/src/ltm.ts +17 -17
  68. package/src/prompt.ts +101 -0
  69. package/src/recall.ts +4 -4
  70. package/src/temporal.ts +41 -10
  71. package/src/types.ts +9 -0
  72. 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 distillations d
120
- JOIN distillation_fts f ON d.rowid = f.rowid
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 distillations d
126
- JOIN distillation_fts f ON d.rowid = f.rowid
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
- function partsToText(parts: LorePart[]): string {
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 temporal_messages m
171
- JOIN temporal_fts f ON m.rowid = f.rowid
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 temporal_messages m
175
- JOIN temporal_fts f ON m.rowid = f.rowid
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 temporal_messages m
226
- JOIN temporal_fts f ON m.rowid = f.rowid
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 temporal_messages m
230
- JOIN temporal_fts f ON m.rowid = f.rowid
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
+ }