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.
Files changed (82) hide show
  1. package/.mcp.json +9 -0
  2. package/AUTH-DESIGN.md +436 -0
  3. package/BRIEF.md +197 -0
  4. package/CLAUDE.md +44 -0
  5. package/COMPETITIVE.md +174 -0
  6. package/CONTEXT-OPTIMIZATION.md +305 -0
  7. package/INFRASTRUCTURE.md +252 -0
  8. package/LICENSE +105 -0
  9. package/MARKET.md +230 -0
  10. package/PLAN.md +278 -0
  11. package/README.md +121 -0
  12. package/SENTINEL.md +293 -0
  13. package/SERVER-API-PLAN.md +553 -0
  14. package/SPEC.md +843 -0
  15. package/SWOT.md +148 -0
  16. package/SYNC-ARCHITECTURE.md +294 -0
  17. package/VIBE-CODER-STRATEGY.md +250 -0
  18. package/bun.lock +375 -0
  19. package/hooks/post-tool-use.ts +144 -0
  20. package/hooks/session-start.ts +64 -0
  21. package/hooks/stop.ts +131 -0
  22. package/mem-page.html +1305 -0
  23. package/package.json +30 -0
  24. package/src/capture/dedup.test.ts +103 -0
  25. package/src/capture/dedup.ts +76 -0
  26. package/src/capture/extractor.test.ts +245 -0
  27. package/src/capture/extractor.ts +330 -0
  28. package/src/capture/quality.test.ts +168 -0
  29. package/src/capture/quality.ts +104 -0
  30. package/src/capture/retrospective.test.ts +115 -0
  31. package/src/capture/retrospective.ts +121 -0
  32. package/src/capture/scanner.test.ts +131 -0
  33. package/src/capture/scanner.ts +100 -0
  34. package/src/capture/scrubber.test.ts +144 -0
  35. package/src/capture/scrubber.ts +181 -0
  36. package/src/cli.ts +517 -0
  37. package/src/config.ts +238 -0
  38. package/src/context/inject.test.ts +940 -0
  39. package/src/context/inject.ts +382 -0
  40. package/src/embeddings/backfill.ts +50 -0
  41. package/src/embeddings/embedder.test.ts +76 -0
  42. package/src/embeddings/embedder.ts +139 -0
  43. package/src/lifecycle/aging.test.ts +103 -0
  44. package/src/lifecycle/aging.ts +36 -0
  45. package/src/lifecycle/compaction.test.ts +264 -0
  46. package/src/lifecycle/compaction.ts +190 -0
  47. package/src/lifecycle/purge.test.ts +100 -0
  48. package/src/lifecycle/purge.ts +37 -0
  49. package/src/lifecycle/scheduler.test.ts +120 -0
  50. package/src/lifecycle/scheduler.ts +101 -0
  51. package/src/provisioning/browser-auth.ts +172 -0
  52. package/src/provisioning/provision.test.ts +198 -0
  53. package/src/provisioning/provision.ts +94 -0
  54. package/src/register.test.ts +167 -0
  55. package/src/register.ts +178 -0
  56. package/src/server.ts +436 -0
  57. package/src/storage/migrations.test.ts +244 -0
  58. package/src/storage/migrations.ts +261 -0
  59. package/src/storage/outbox.test.ts +229 -0
  60. package/src/storage/outbox.ts +131 -0
  61. package/src/storage/projects.test.ts +137 -0
  62. package/src/storage/projects.ts +184 -0
  63. package/src/storage/sqlite.test.ts +798 -0
  64. package/src/storage/sqlite.ts +934 -0
  65. package/src/storage/vec.test.ts +198 -0
  66. package/src/sync/auth.test.ts +76 -0
  67. package/src/sync/auth.ts +68 -0
  68. package/src/sync/client.ts +183 -0
  69. package/src/sync/engine.test.ts +94 -0
  70. package/src/sync/engine.ts +127 -0
  71. package/src/sync/pull.test.ts +279 -0
  72. package/src/sync/pull.ts +170 -0
  73. package/src/sync/push.test.ts +117 -0
  74. package/src/sync/push.ts +230 -0
  75. package/src/tools/get.ts +34 -0
  76. package/src/tools/pin.ts +47 -0
  77. package/src/tools/save.test.ts +301 -0
  78. package/src/tools/save.ts +231 -0
  79. package/src/tools/search.test.ts +69 -0
  80. package/src/tools/search.ts +181 -0
  81. package/src/tools/timeline.ts +64 -0
  82. 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
+ }