engrm 0.1.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/.mcp.json +9 -0
- package/AUTH-DESIGN.md +436 -0
- package/BRIEF.md +197 -0
- package/CLAUDE.md +44 -0
- package/COMPETITIVE.md +174 -0
- package/CONTEXT-OPTIMIZATION.md +305 -0
- package/INFRASTRUCTURE.md +252 -0
- package/LICENSE +105 -0
- package/MARKET.md +230 -0
- package/PLAN.md +278 -0
- package/README.md +121 -0
- package/SENTINEL.md +293 -0
- package/SERVER-API-PLAN.md +553 -0
- package/SPEC.md +843 -0
- package/SWOT.md +148 -0
- package/SYNC-ARCHITECTURE.md +294 -0
- package/VIBE-CODER-STRATEGY.md +250 -0
- package/bun.lock +375 -0
- package/hooks/post-tool-use.ts +144 -0
- package/hooks/session-start.ts +64 -0
- package/hooks/stop.ts +131 -0
- package/mem-page.html +1305 -0
- package/package.json +30 -0
- package/src/capture/dedup.test.ts +103 -0
- package/src/capture/dedup.ts +76 -0
- package/src/capture/extractor.test.ts +245 -0
- package/src/capture/extractor.ts +330 -0
- package/src/capture/quality.test.ts +168 -0
- package/src/capture/quality.ts +104 -0
- package/src/capture/retrospective.test.ts +115 -0
- package/src/capture/retrospective.ts +121 -0
- package/src/capture/scanner.test.ts +131 -0
- package/src/capture/scanner.ts +100 -0
- package/src/capture/scrubber.test.ts +144 -0
- package/src/capture/scrubber.ts +181 -0
- package/src/cli.ts +517 -0
- package/src/config.ts +238 -0
- package/src/context/inject.test.ts +940 -0
- package/src/context/inject.ts +382 -0
- package/src/embeddings/backfill.ts +50 -0
- package/src/embeddings/embedder.test.ts +76 -0
- package/src/embeddings/embedder.ts +139 -0
- package/src/lifecycle/aging.test.ts +103 -0
- package/src/lifecycle/aging.ts +36 -0
- package/src/lifecycle/compaction.test.ts +264 -0
- package/src/lifecycle/compaction.ts +190 -0
- package/src/lifecycle/purge.test.ts +100 -0
- package/src/lifecycle/purge.ts +37 -0
- package/src/lifecycle/scheduler.test.ts +120 -0
- package/src/lifecycle/scheduler.ts +101 -0
- package/src/provisioning/browser-auth.ts +172 -0
- package/src/provisioning/provision.test.ts +198 -0
- package/src/provisioning/provision.ts +94 -0
- package/src/register.test.ts +167 -0
- package/src/register.ts +178 -0
- package/src/server.ts +436 -0
- package/src/storage/migrations.test.ts +244 -0
- package/src/storage/migrations.ts +261 -0
- package/src/storage/outbox.test.ts +229 -0
- package/src/storage/outbox.ts +131 -0
- package/src/storage/projects.test.ts +137 -0
- package/src/storage/projects.ts +184 -0
- package/src/storage/sqlite.test.ts +798 -0
- package/src/storage/sqlite.ts +934 -0
- package/src/storage/vec.test.ts +198 -0
- package/src/sync/auth.test.ts +76 -0
- package/src/sync/auth.ts +68 -0
- package/src/sync/client.ts +183 -0
- package/src/sync/engine.test.ts +94 -0
- package/src/sync/engine.ts +127 -0
- package/src/sync/pull.test.ts +279 -0
- package/src/sync/pull.ts +170 -0
- package/src/sync/push.test.ts +117 -0
- package/src/sync/push.ts +230 -0
- package/src/tools/get.ts +34 -0
- package/src/tools/pin.ts +47 -0
- package/src/tools/save.test.ts +301 -0
- package/src/tools/save.ts +231 -0
- package/src/tools/search.test.ts +69 -0
- package/src/tools/search.ts +181 -0
- package/src/tools/timeline.ts +64 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context injection for session start.
|
|
3
|
+
*
|
|
4
|
+
* When a Claude Code session begins, we search memory for relevant
|
|
5
|
+
* observations from the current project and inject them as context.
|
|
6
|
+
* This gives the agent prior knowledge without being asked.
|
|
7
|
+
*
|
|
8
|
+
* Optimizations:
|
|
9
|
+
* - Token budget (not count limit) prevents context blowup at scale
|
|
10
|
+
* - Facts-first: shows facts[] bullets instead of narrative prose (~50% denser)
|
|
11
|
+
* - Tiered: top 3 get detail, rest are title-only
|
|
12
|
+
* - Blended scoring: quality * 0.6 + recency * 0.4 (recent medium-quality beats old high-quality)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { detectProject } from "../storage/projects.js";
|
|
16
|
+
import type { MemDatabase, ObservationRow, SessionSummaryRow } from "../storage/sqlite.js";
|
|
17
|
+
|
|
18
|
+
export interface ContextOptions {
|
|
19
|
+
/** Max tokens for context injection (default: 800) */
|
|
20
|
+
tokenBudget?: number;
|
|
21
|
+
/** Max observations to return (legacy, overrides tokenBudget if set) */
|
|
22
|
+
maxCount?: number;
|
|
23
|
+
/** Number of observations to show with full detail (default: 3) */
|
|
24
|
+
detailedCount?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface InjectedContext {
|
|
28
|
+
project_name: string;
|
|
29
|
+
canonical_id: string;
|
|
30
|
+
observations: ContextObservation[];
|
|
31
|
+
/** Number of observations included in context */
|
|
32
|
+
session_count: number;
|
|
33
|
+
/** Total active observations in project (for footer) */
|
|
34
|
+
total_active: number;
|
|
35
|
+
/** Recent session summaries for lessons learned */
|
|
36
|
+
summaries?: SessionSummaryRow[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ContextObservation {
|
|
40
|
+
id: number;
|
|
41
|
+
type: string;
|
|
42
|
+
title: string;
|
|
43
|
+
narrative: string | null;
|
|
44
|
+
facts: string | null;
|
|
45
|
+
quality: number;
|
|
46
|
+
created_at: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Decay window for recency scoring (30 days in seconds). */
|
|
50
|
+
const RECENCY_WINDOW_SECONDS = 30 * 86400;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compute a blended relevance score combining quality and recency.
|
|
54
|
+
* Quality contributes 60%, recency 40%. Both are 0-1 normalised.
|
|
55
|
+
* Recency decays linearly over 30 days to 0.
|
|
56
|
+
*/
|
|
57
|
+
export function computeBlendedScore(
|
|
58
|
+
quality: number,
|
|
59
|
+
createdAtEpoch: number,
|
|
60
|
+
nowEpoch: number
|
|
61
|
+
): number {
|
|
62
|
+
const age = nowEpoch - createdAtEpoch;
|
|
63
|
+
const recencyNorm = Math.max(0, Math.min(1, 1 - age / RECENCY_WINDOW_SECONDS));
|
|
64
|
+
return quality * 0.6 + recencyNorm * 0.4;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Estimate token count from text.
|
|
69
|
+
* Uses ~4 chars per token heuristic (standard for English).
|
|
70
|
+
*/
|
|
71
|
+
export function estimateTokens(text: string): number {
|
|
72
|
+
if (!text) return 0;
|
|
73
|
+
return Math.ceil(text.length / 4);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build context for a new session.
|
|
78
|
+
*
|
|
79
|
+
* Strategy:
|
|
80
|
+
* 1. Get pinned observations (always relevant, always included)
|
|
81
|
+
* 2. Fetch candidates sorted by quality, apply token budget
|
|
82
|
+
* 3. Tier output: top N detailed, rest title-only
|
|
83
|
+
*/
|
|
84
|
+
export function buildSessionContext(
|
|
85
|
+
db: MemDatabase,
|
|
86
|
+
cwd: string,
|
|
87
|
+
options: ContextOptions | number = {}
|
|
88
|
+
): InjectedContext | null {
|
|
89
|
+
// Backwards compat: accept number as legacy maxCount
|
|
90
|
+
const opts: ContextOptions =
|
|
91
|
+
typeof options === "number" ? { maxCount: options } : options;
|
|
92
|
+
const tokenBudget = opts.tokenBudget ?? 800;
|
|
93
|
+
const maxCount = opts.maxCount;
|
|
94
|
+
|
|
95
|
+
const detected = detectProject(cwd);
|
|
96
|
+
const project = db.getProjectByCanonicalId(detected.canonical_id);
|
|
97
|
+
|
|
98
|
+
if (!project) {
|
|
99
|
+
return {
|
|
100
|
+
project_name: detected.name,
|
|
101
|
+
canonical_id: detected.canonical_id,
|
|
102
|
+
observations: [],
|
|
103
|
+
session_count: 0,
|
|
104
|
+
total_active: 0,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Count total active observations for footer (exclude superseded)
|
|
109
|
+
const totalActive = (
|
|
110
|
+
db.db
|
|
111
|
+
.query<{ c: number }, [number]>(
|
|
112
|
+
`SELECT COUNT(*) as c FROM observations
|
|
113
|
+
WHERE project_id = ? AND lifecycle IN ('active', 'aging', 'pinned')
|
|
114
|
+
AND superseded_by IS NULL`
|
|
115
|
+
)
|
|
116
|
+
.get(project.id) ?? { c: 0 }
|
|
117
|
+
).c;
|
|
118
|
+
|
|
119
|
+
// Get pinned observations (always included, capped to prevent budget exhaustion)
|
|
120
|
+
const MAX_PINNED = 5;
|
|
121
|
+
const pinned = db.db
|
|
122
|
+
.query<ObservationRow, [number, number]>(
|
|
123
|
+
`SELECT * FROM observations
|
|
124
|
+
WHERE project_id = ? AND lifecycle = 'pinned'
|
|
125
|
+
AND superseded_by IS NULL
|
|
126
|
+
ORDER BY quality DESC, created_at_epoch DESC
|
|
127
|
+
LIMIT ?`
|
|
128
|
+
)
|
|
129
|
+
.all(project.id, MAX_PINNED);
|
|
130
|
+
|
|
131
|
+
// Fetch candidates (more than we need, we'll trim by token budget)
|
|
132
|
+
// Exclude superseded observations — they've been replaced by newer ones
|
|
133
|
+
const candidateLimit = maxCount ?? 50;
|
|
134
|
+
const candidates = db.db
|
|
135
|
+
.query<ObservationRow, [number, number]>(
|
|
136
|
+
`SELECT * FROM observations
|
|
137
|
+
WHERE project_id = ? AND lifecycle IN ('active', 'aging')
|
|
138
|
+
AND quality >= 0.3
|
|
139
|
+
AND superseded_by IS NULL
|
|
140
|
+
ORDER BY quality DESC, created_at_epoch DESC
|
|
141
|
+
LIMIT ?`
|
|
142
|
+
)
|
|
143
|
+
.all(project.id, candidateLimit);
|
|
144
|
+
|
|
145
|
+
// Deduplicate (pinned might overlap with candidates)
|
|
146
|
+
const seenIds = new Set(pinned.map((o) => o.id));
|
|
147
|
+
const deduped = candidates.filter((o) => !seenIds.has(o.id));
|
|
148
|
+
|
|
149
|
+
// Re-sort candidates by blended score (quality * 0.6 + recency * 0.4)
|
|
150
|
+
const nowEpoch = Math.floor(Date.now() / 1000);
|
|
151
|
+
const sorted = [...deduped].sort((a, b) => {
|
|
152
|
+
const scoreA = computeBlendedScore(a.quality, a.created_at_epoch, nowEpoch);
|
|
153
|
+
const scoreB = computeBlendedScore(b.quality, b.created_at_epoch, nowEpoch);
|
|
154
|
+
return scoreB - scoreA; // descending
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// If using legacy maxCount mode, just slice
|
|
158
|
+
if (maxCount !== undefined) {
|
|
159
|
+
const remaining = Math.max(0, maxCount - pinned.length);
|
|
160
|
+
const all = [...pinned, ...sorted.slice(0, remaining)];
|
|
161
|
+
return {
|
|
162
|
+
project_name: project.name,
|
|
163
|
+
canonical_id: project.canonical_id,
|
|
164
|
+
observations: all.map(toContextObservation),
|
|
165
|
+
session_count: all.length,
|
|
166
|
+
total_active: totalActive,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Token budget mode: fill greedily
|
|
171
|
+
// Reserve ~30 tokens for header + footer
|
|
172
|
+
let remainingBudget = tokenBudget - 30;
|
|
173
|
+
const selected: ObservationRow[] = [];
|
|
174
|
+
|
|
175
|
+
// Pinned always included (deducted from budget)
|
|
176
|
+
for (const obs of pinned) {
|
|
177
|
+
const cost = estimateObservationTokens(obs, selected.length);
|
|
178
|
+
remainingBudget -= cost;
|
|
179
|
+
selected.push(obs);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Fill with candidates (sorted by blended score) until budget exhausted
|
|
183
|
+
for (const obs of sorted) {
|
|
184
|
+
const cost = estimateObservationTokens(obs, selected.length);
|
|
185
|
+
if (remainingBudget - cost < 0 && selected.length > 0) break;
|
|
186
|
+
remainingBudget -= cost;
|
|
187
|
+
selected.push(obs);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Fetch recent session summaries for lessons learned
|
|
191
|
+
const summaries = db.getRecentSummaries(project.id, 2);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
project_name: project.name,
|
|
195
|
+
canonical_id: project.canonical_id,
|
|
196
|
+
observations: selected.map(toContextObservation),
|
|
197
|
+
session_count: selected.length,
|
|
198
|
+
total_active: totalActive,
|
|
199
|
+
summaries: summaries.length > 0 ? summaries : undefined,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Estimate token cost of an observation in context.
|
|
205
|
+
* Detailed entries (index < 3) cost more than title-only entries.
|
|
206
|
+
*/
|
|
207
|
+
function estimateObservationTokens(
|
|
208
|
+
obs: ObservationRow,
|
|
209
|
+
index: number
|
|
210
|
+
): number {
|
|
211
|
+
const DETAILED_THRESHOLD = 3;
|
|
212
|
+
// Title line: "- **[type]** title (date, q=0.X)"
|
|
213
|
+
const titleCost = estimateTokens(
|
|
214
|
+
`- **[${obs.type}]** ${obs.title} (2026-01-01, q=0.5)`
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
if (index >= DETAILED_THRESHOLD) {
|
|
218
|
+
return titleCost;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Detailed: title + facts or narrative snippet
|
|
222
|
+
const detailText = formatObservationDetail(obs);
|
|
223
|
+
return titleCost + estimateTokens(detailText);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Format injected context as a readable string for Claude.
|
|
228
|
+
*
|
|
229
|
+
* Tiered approach:
|
|
230
|
+
* - First 3 observations: title + facts (or narrative snippet)
|
|
231
|
+
* - Remaining: title-only
|
|
232
|
+
* - Footer: "N more observations available via search"
|
|
233
|
+
*/
|
|
234
|
+
export function formatContextForInjection(
|
|
235
|
+
context: InjectedContext
|
|
236
|
+
): string {
|
|
237
|
+
if (context.observations.length === 0) {
|
|
238
|
+
return `Project: ${context.project_name} (no prior observations)`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const DETAILED_COUNT = 3;
|
|
242
|
+
|
|
243
|
+
const lines: string[] = [
|
|
244
|
+
`## Project Memory: ${context.project_name}`,
|
|
245
|
+
`${context.session_count} relevant observation(s) from prior sessions:`,
|
|
246
|
+
"",
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
for (let i = 0; i < context.observations.length; i++) {
|
|
250
|
+
const obs = context.observations[i]!;
|
|
251
|
+
const date = obs.created_at.split("T")[0];
|
|
252
|
+
lines.push(
|
|
253
|
+
`- **[${obs.type}]** ${obs.title} (${date}, q=${obs.quality.toFixed(1)})`
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// Detailed tier: show facts or narrative snippet
|
|
257
|
+
if (i < DETAILED_COUNT) {
|
|
258
|
+
const detail = formatObservationDetailFromContext(obs);
|
|
259
|
+
if (detail) {
|
|
260
|
+
lines.push(detail);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Session summaries (lessons from recent sessions)
|
|
266
|
+
if (context.summaries && context.summaries.length > 0) {
|
|
267
|
+
lines.push("");
|
|
268
|
+
lines.push("Lessons from recent sessions:");
|
|
269
|
+
for (const summary of context.summaries) {
|
|
270
|
+
if (summary.request) {
|
|
271
|
+
lines.push(`- Request: ${summary.request}`);
|
|
272
|
+
}
|
|
273
|
+
if (summary.learned) {
|
|
274
|
+
lines.push(` Learned: ${truncateText(summary.learned, 100)}`);
|
|
275
|
+
}
|
|
276
|
+
if (summary.next_steps) {
|
|
277
|
+
lines.push(` Next: ${truncateText(summary.next_steps, 80)}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Footer: how many more are available
|
|
283
|
+
const remaining = context.total_active - context.session_count;
|
|
284
|
+
if (remaining > 0) {
|
|
285
|
+
lines.push("");
|
|
286
|
+
lines.push(
|
|
287
|
+
`${remaining} more observation(s) available via search tool.`
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return lines.join("\n");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function truncateText(text: string, maxLen: number): string {
|
|
295
|
+
if (text.length <= maxLen) return text;
|
|
296
|
+
return text.slice(0, maxLen - 3) + "...";
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Format detail for a top-tier observation.
|
|
301
|
+
* Prefers facts (bullet points, denser) over narrative (prose, verbose).
|
|
302
|
+
*/
|
|
303
|
+
function formatObservationDetailFromContext(
|
|
304
|
+
obs: ContextObservation
|
|
305
|
+
): string | null {
|
|
306
|
+
// Try facts first (denser per token)
|
|
307
|
+
if (obs.facts) {
|
|
308
|
+
const bullets = parseFacts(obs.facts);
|
|
309
|
+
if (bullets.length > 0) {
|
|
310
|
+
return bullets
|
|
311
|
+
.slice(0, 4) // Cap at 4 facts
|
|
312
|
+
.map((f) => ` - ${f}`)
|
|
313
|
+
.join("\n");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Fall back to narrative snippet
|
|
318
|
+
if (obs.narrative) {
|
|
319
|
+
const snippet =
|
|
320
|
+
obs.narrative.length > 120
|
|
321
|
+
? obs.narrative.slice(0, 117) + "..."
|
|
322
|
+
: obs.narrative;
|
|
323
|
+
return ` ${snippet}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Format detail for token estimation (from ObservationRow).
|
|
331
|
+
*/
|
|
332
|
+
function formatObservationDetail(obs: ObservationRow): string {
|
|
333
|
+
if (obs.facts) {
|
|
334
|
+
const bullets = parseFacts(obs.facts);
|
|
335
|
+
if (bullets.length > 0) {
|
|
336
|
+
return bullets
|
|
337
|
+
.slice(0, 4)
|
|
338
|
+
.map((f) => ` - ${f}`)
|
|
339
|
+
.join("\n");
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (obs.narrative) {
|
|
343
|
+
const snippet =
|
|
344
|
+
obs.narrative.length > 120
|
|
345
|
+
? obs.narrative.slice(0, 117) + "..."
|
|
346
|
+
: obs.narrative;
|
|
347
|
+
return ` ${snippet}`;
|
|
348
|
+
}
|
|
349
|
+
return "";
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Parse facts from stored JSON string.
|
|
354
|
+
* Handles malformed JSON gracefully.
|
|
355
|
+
*/
|
|
356
|
+
export function parseFacts(facts: string): string[] {
|
|
357
|
+
if (!facts) return [];
|
|
358
|
+
try {
|
|
359
|
+
const parsed = JSON.parse(facts);
|
|
360
|
+
if (Array.isArray(parsed)) {
|
|
361
|
+
return parsed.filter((f) => typeof f === "string" && f.length > 0);
|
|
362
|
+
}
|
|
363
|
+
} catch {
|
|
364
|
+
// Not valid JSON — treat as a single fact
|
|
365
|
+
if (facts.trim().length > 0) {
|
|
366
|
+
return [facts.trim()];
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return [];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function toContextObservation(obs: ObservationRow): ContextObservation {
|
|
373
|
+
return {
|
|
374
|
+
id: obs.id,
|
|
375
|
+
type: obs.type,
|
|
376
|
+
title: obs.title,
|
|
377
|
+
narrative: obs.narrative,
|
|
378
|
+
facts: obs.facts,
|
|
379
|
+
quality: obs.quality,
|
|
380
|
+
created_at: obs.created_at,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backfill embeddings for observations that pre-date sqlite-vec.
|
|
3
|
+
*
|
|
4
|
+
* Runs on startup, processing a batch of unembedded observations.
|
|
5
|
+
* Non-blocking — if embedding is unavailable, silently returns.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { MemDatabase } from "../storage/sqlite.js";
|
|
9
|
+
import {
|
|
10
|
+
composeEmbeddingText,
|
|
11
|
+
embedText,
|
|
12
|
+
isEmbeddingAvailable,
|
|
13
|
+
} from "./embedder.js";
|
|
14
|
+
|
|
15
|
+
export interface BackfillResult {
|
|
16
|
+
processed: number;
|
|
17
|
+
failed: number;
|
|
18
|
+
remaining: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Embed observations that don't yet have vectors.
|
|
23
|
+
* Processes up to `batchSize` per call. Returns counts.
|
|
24
|
+
*/
|
|
25
|
+
export async function backfillEmbeddings(
|
|
26
|
+
db: MemDatabase,
|
|
27
|
+
batchSize: number = 50
|
|
28
|
+
): Promise<BackfillResult> {
|
|
29
|
+
if (!db.vecAvailable) return { processed: 0, failed: 0, remaining: 0 };
|
|
30
|
+
if (!(await isEmbeddingAvailable()))
|
|
31
|
+
return { processed: 0, failed: 0, remaining: 0 };
|
|
32
|
+
|
|
33
|
+
const observations = db.getUnembeddedObservations(batchSize);
|
|
34
|
+
let processed = 0;
|
|
35
|
+
let failed = 0;
|
|
36
|
+
|
|
37
|
+
for (const obs of observations) {
|
|
38
|
+
const text = composeEmbeddingText(obs);
|
|
39
|
+
const embedding = await embedText(text);
|
|
40
|
+
if (embedding) {
|
|
41
|
+
db.vecInsert(obs.id, embedding);
|
|
42
|
+
processed++;
|
|
43
|
+
} else {
|
|
44
|
+
failed++;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const remaining = db.getUnembeddedCount();
|
|
49
|
+
return { processed, failed, remaining };
|
|
50
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { composeEmbeddingText, EMBEDDING_DIMS } from "./embedder.js";
|
|
3
|
+
|
|
4
|
+
describe("composeEmbeddingText", () => {
|
|
5
|
+
test("title only", () => {
|
|
6
|
+
const text = composeEmbeddingText({
|
|
7
|
+
title: "Fix auth bug",
|
|
8
|
+
narrative: null,
|
|
9
|
+
facts: null,
|
|
10
|
+
concepts: null,
|
|
11
|
+
});
|
|
12
|
+
expect(text).toBe("Fix auth bug");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("title + narrative", () => {
|
|
16
|
+
const text = composeEmbeddingText({
|
|
17
|
+
title: "Fix auth bug",
|
|
18
|
+
narrative: "Token refresh was broken",
|
|
19
|
+
facts: null,
|
|
20
|
+
concepts: null,
|
|
21
|
+
});
|
|
22
|
+
expect(text).toContain("Fix auth bug");
|
|
23
|
+
expect(text).toContain("Token refresh was broken");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("title + facts as JSON array", () => {
|
|
27
|
+
const text = composeEmbeddingText({
|
|
28
|
+
title: "Choose PostgreSQL",
|
|
29
|
+
narrative: null,
|
|
30
|
+
facts: JSON.stringify(["Supports JSONB", "Better indexing"]),
|
|
31
|
+
concepts: null,
|
|
32
|
+
});
|
|
33
|
+
expect(text).toContain("Choose PostgreSQL");
|
|
34
|
+
expect(text).toContain("- Supports JSONB");
|
|
35
|
+
expect(text).toContain("- Better indexing");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("title + concepts", () => {
|
|
39
|
+
const text = composeEmbeddingText({
|
|
40
|
+
title: "Database decision",
|
|
41
|
+
narrative: null,
|
|
42
|
+
facts: null,
|
|
43
|
+
concepts: JSON.stringify(["postgres", "database"]),
|
|
44
|
+
});
|
|
45
|
+
expect(text).toContain("Database decision");
|
|
46
|
+
expect(text).toContain("postgres, database");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("all fields combined", () => {
|
|
50
|
+
const text = composeEmbeddingText({
|
|
51
|
+
title: "Fix auth",
|
|
52
|
+
narrative: "Token expired",
|
|
53
|
+
facts: JSON.stringify(["Fact 1"]),
|
|
54
|
+
concepts: JSON.stringify(["auth"]),
|
|
55
|
+
});
|
|
56
|
+
expect(text).toContain("Fix auth");
|
|
57
|
+
expect(text).toContain("Token expired");
|
|
58
|
+
expect(text).toContain("- Fact 1");
|
|
59
|
+
expect(text).toContain("auth");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("handles malformed facts JSON gracefully", () => {
|
|
63
|
+
const text = composeEmbeddingText({
|
|
64
|
+
title: "Test",
|
|
65
|
+
narrative: null,
|
|
66
|
+
facts: "not valid json",
|
|
67
|
+
concepts: null,
|
|
68
|
+
});
|
|
69
|
+
expect(text).toContain("Test");
|
|
70
|
+
expect(text).toContain("not valid json");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("EMBEDDING_DIMS is 384", () => {
|
|
74
|
+
expect(EMBEDDING_DIMS).toBe(384);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local embedding model for offline semantic search.
|
|
3
|
+
*
|
|
4
|
+
* Uses @xenova/transformers to run all-MiniLM-L6-v2 (384 dims)
|
|
5
|
+
* entirely in-process via ONNX/WASM. No server needed.
|
|
6
|
+
*
|
|
7
|
+
* Lazy-loaded on first use — model downloaded on first run (~23MB),
|
|
8
|
+
* cached in ~/.cache/huggingface/ thereafter.
|
|
9
|
+
*
|
|
10
|
+
* Graceful degradation: if model fails to load, all functions
|
|
11
|
+
* return null and search falls back to FTS5 only.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ObservationRow } from "../storage/sqlite.js";
|
|
15
|
+
|
|
16
|
+
// --- State ---
|
|
17
|
+
|
|
18
|
+
let _available: boolean | null = null; // null = not yet checked
|
|
19
|
+
let _pipeline: any = null;
|
|
20
|
+
|
|
21
|
+
export const EMBEDDING_DIMS = 384;
|
|
22
|
+
const MODEL_NAME = "Xenova/all-MiniLM-L6-v2";
|
|
23
|
+
|
|
24
|
+
// --- Public API ---
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check if local embedding is available.
|
|
28
|
+
* First call triggers model loading.
|
|
29
|
+
*/
|
|
30
|
+
export async function isEmbeddingAvailable(): Promise<boolean> {
|
|
31
|
+
if (_available !== null) return _available;
|
|
32
|
+
try {
|
|
33
|
+
await getPipeline();
|
|
34
|
+
return _available!;
|
|
35
|
+
} catch {
|
|
36
|
+
_available = false;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Embed a single text string. Returns Float32Array[384] or null if unavailable.
|
|
43
|
+
*/
|
|
44
|
+
export async function embedText(text: string): Promise<Float32Array | null> {
|
|
45
|
+
const pipe = await getPipeline();
|
|
46
|
+
if (!pipe) return null;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const output = await pipe(text, { pooling: "mean", normalize: true });
|
|
50
|
+
return new Float32Array(output.data);
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Batch embed multiple texts. More efficient than individual calls.
|
|
58
|
+
*/
|
|
59
|
+
export async function embedTexts(
|
|
60
|
+
texts: string[]
|
|
61
|
+
): Promise<(Float32Array | null)[]> {
|
|
62
|
+
if (texts.length === 0) return [];
|
|
63
|
+
|
|
64
|
+
const pipe = await getPipeline();
|
|
65
|
+
if (!pipe) return texts.map(() => null);
|
|
66
|
+
|
|
67
|
+
const results: (Float32Array | null)[] = [];
|
|
68
|
+
// Process one at a time — @xenova/transformers handles batching internally
|
|
69
|
+
// but individual calls are more resilient to failures
|
|
70
|
+
for (const text of texts) {
|
|
71
|
+
try {
|
|
72
|
+
const output = await pipe(text, { pooling: "mean", normalize: true });
|
|
73
|
+
results.push(new Float32Array(output.data));
|
|
74
|
+
} catch {
|
|
75
|
+
results.push(null);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return results;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Compose the text to embed from an observation's fields.
|
|
83
|
+
* Mirrors the content composition in push.ts buildVectorDocument.
|
|
84
|
+
*/
|
|
85
|
+
export function composeEmbeddingText(obs: {
|
|
86
|
+
title: string;
|
|
87
|
+
narrative: string | null;
|
|
88
|
+
facts: string | null;
|
|
89
|
+
concepts: string | null;
|
|
90
|
+
}): string {
|
|
91
|
+
const parts = [obs.title];
|
|
92
|
+
|
|
93
|
+
if (obs.narrative) parts.push(obs.narrative);
|
|
94
|
+
|
|
95
|
+
if (obs.facts) {
|
|
96
|
+
try {
|
|
97
|
+
const facts = JSON.parse(obs.facts) as string[];
|
|
98
|
+
if (Array.isArray(facts) && facts.length > 0) {
|
|
99
|
+
parts.push(facts.map((f) => `- ${f}`).join("\n"));
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
parts.push(obs.facts);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (obs.concepts) {
|
|
107
|
+
try {
|
|
108
|
+
const concepts = JSON.parse(obs.concepts) as string[];
|
|
109
|
+
if (Array.isArray(concepts) && concepts.length > 0) {
|
|
110
|
+
parts.push(concepts.join(", "));
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// ignore
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return parts.join("\n\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- Internal ---
|
|
121
|
+
|
|
122
|
+
async function getPipeline(): Promise<any> {
|
|
123
|
+
if (_pipeline) return _pipeline;
|
|
124
|
+
if (_available === false) return null;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const { pipeline } = await import("@xenova/transformers");
|
|
128
|
+
_pipeline = await pipeline("feature-extraction", MODEL_NAME);
|
|
129
|
+
_available = true;
|
|
130
|
+
return _pipeline;
|
|
131
|
+
} catch (err) {
|
|
132
|
+
_available = false;
|
|
133
|
+
// Log once, then silent
|
|
134
|
+
console.error(
|
|
135
|
+
`[engrm] Local embedding model unavailable: ${err instanceof Error ? err.message : String(err)}`
|
|
136
|
+
);
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|