@loreai/core 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +530 -67
  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 +530 -67
  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 +57 -1
  62. package/src/distillation.ts +225 -28
  63. package/src/embedding.ts +7 -0
  64. package/src/gradient.ts +262 -8
  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/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
+ }