@getplumb/core 0.1.6 → 0.4.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 (84) hide show
  1. package/README.md +2 -2
  2. package/dist/context-builder.d.ts +1 -7
  3. package/dist/context-builder.d.ts.map +1 -1
  4. package/dist/context-builder.js +7 -44
  5. package/dist/context-builder.js.map +1 -1
  6. package/dist/embedder.d.ts +16 -2
  7. package/dist/embedder.d.ts.map +1 -1
  8. package/dist/embedder.js +23 -4
  9. package/dist/embedder.js.map +1 -1
  10. package/dist/extraction-queue.d.ts +13 -3
  11. package/dist/extraction-queue.d.ts.map +1 -1
  12. package/dist/extraction-queue.js +21 -4
  13. package/dist/extraction-queue.js.map +1 -1
  14. package/dist/extractor.d.ts +2 -1
  15. package/dist/extractor.d.ts.map +1 -1
  16. package/dist/extractor.js +106 -7
  17. package/dist/extractor.js.map +1 -1
  18. package/dist/extractor.test.d.ts +2 -0
  19. package/dist/extractor.test.d.ts.map +1 -0
  20. package/dist/extractor.test.js +158 -0
  21. package/dist/extractor.test.js.map +1 -0
  22. package/dist/fact-search.d.ts +9 -5
  23. package/dist/fact-search.d.ts.map +1 -1
  24. package/dist/fact-search.js +25 -16
  25. package/dist/fact-search.js.map +1 -1
  26. package/dist/fact-search.test.d.ts +12 -0
  27. package/dist/fact-search.test.d.ts.map +1 -0
  28. package/dist/fact-search.test.js +117 -0
  29. package/dist/fact-search.test.js.map +1 -0
  30. package/dist/index.d.ts +6 -10
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +2 -5
  33. package/dist/index.js.map +1 -1
  34. package/dist/llm-client.d.ts +11 -2
  35. package/dist/llm-client.d.ts.map +1 -1
  36. package/dist/llm-client.js +47 -3
  37. package/dist/llm-client.js.map +1 -1
  38. package/dist/local-store.d.ts +19 -63
  39. package/dist/local-store.d.ts.map +1 -1
  40. package/dist/local-store.js +353 -262
  41. package/dist/local-store.js.map +1 -1
  42. package/dist/local-store.test.d.ts +2 -0
  43. package/dist/local-store.test.d.ts.map +1 -0
  44. package/dist/local-store.test.js +146 -0
  45. package/dist/local-store.test.js.map +1 -0
  46. package/dist/raw-log-search.d.ts +9 -5
  47. package/dist/raw-log-search.d.ts.map +1 -1
  48. package/dist/raw-log-search.js +107 -29
  49. package/dist/raw-log-search.js.map +1 -1
  50. package/dist/raw-log-search.test.d.ts +12 -0
  51. package/dist/raw-log-search.test.d.ts.map +1 -0
  52. package/dist/raw-log-search.test.js +124 -0
  53. package/dist/raw-log-search.test.js.map +1 -0
  54. package/dist/read-path.d.ts +6 -23
  55. package/dist/read-path.d.ts.map +1 -1
  56. package/dist/read-path.js +9 -48
  57. package/dist/read-path.js.map +1 -1
  58. package/dist/read-path.test.d.ts +15 -0
  59. package/dist/read-path.test.d.ts.map +1 -0
  60. package/dist/read-path.test.js +393 -0
  61. package/dist/read-path.test.js.map +1 -0
  62. package/dist/schema.d.ts +4 -13
  63. package/dist/schema.d.ts.map +1 -1
  64. package/dist/schema.js +42 -52
  65. package/dist/schema.js.map +1 -1
  66. package/dist/scorer.d.ts +0 -9
  67. package/dist/scorer.d.ts.map +1 -1
  68. package/dist/scorer.js +1 -31
  69. package/dist/scorer.js.map +1 -1
  70. package/dist/scorer.test.d.ts +10 -0
  71. package/dist/scorer.test.d.ts.map +1 -0
  72. package/dist/scorer.test.js +169 -0
  73. package/dist/scorer.test.js.map +1 -0
  74. package/dist/store.d.ts +2 -14
  75. package/dist/store.d.ts.map +1 -1
  76. package/dist/types.d.ts +0 -25
  77. package/dist/types.d.ts.map +1 -1
  78. package/dist/types.js +1 -6
  79. package/dist/types.js.map +1 -1
  80. package/dist/wasm-db.d.ts +63 -8
  81. package/dist/wasm-db.d.ts.map +1 -1
  82. package/dist/wasm-db.js +124 -31
  83. package/dist/wasm-db.js.map +1 -1
  84. package/package.json +14 -2
@@ -4,19 +4,111 @@ import { mkdirSync } from 'node:fs';
4
4
  import { join, dirname } from 'node:path';
5
5
  import { openDb } from './wasm-db.js';
6
6
  import { applySchema } from './schema.js';
7
- import { extractFacts } from './extractor.js';
8
- import { callLLMWithConfig } from './llm-client.js';
9
- import { embed } from './embedder.js';
7
+ import { embed, warmEmbedder, warmReranker } from './embedder.js';
10
8
  import { formatExchange } from './chunker.js';
11
9
  import { searchRawLog } from './raw-log-search.js';
12
- import { searchFacts } from './fact-search.js';
13
- import { ExtractionQueue } from './extraction-queue.js';
14
- import { serializeEmbedding } from './vector-search.js';
10
+ import { serializeEmbedding, deserializeEmbedding } from './vector-search.js';
11
+ /**
12
+ * Split text into overlapping child chunks for parent-child chunking (T-108).
13
+ * Target: ~250 chars per chunk with ~50 char overlap.
14
+ * Prefers sentence boundaries, falls back to word boundaries, hard-cuts at 300 chars max.
15
+ *
16
+ * Uses a generator to avoid materializing the full chunk array in memory,
17
+ * which prevents OOM crashes on large inputs (fix for splitIntoChildren array limit bug).
18
+ */
19
+ function* splitIntoChildren(text) {
20
+ const TARGET_SIZE = 250;
21
+ const OVERLAP = 50;
22
+ const MAX_SIZE = 300;
23
+ const SENTENCE_ENDINGS = /[.!?]\s+/g;
24
+ if (text.length <= TARGET_SIZE) {
25
+ // Text is already small enough — yield as single child
26
+ if (text.trim().length > 0)
27
+ yield text;
28
+ return;
29
+ }
30
+ let pos = 0;
31
+ while (pos < text.length) {
32
+ let endPos = Math.min(pos + TARGET_SIZE, text.length);
33
+ // If we're at the end of the text, take the rest
34
+ if (endPos >= text.length) {
35
+ const last = text.slice(pos).trim();
36
+ if (last.length > 0)
37
+ yield last;
38
+ break;
39
+ }
40
+ // Try to find a sentence boundary within the target range
41
+ const segment = text.slice(pos, Math.min(pos + MAX_SIZE, text.length));
42
+ const sentenceMatches = Array.from(segment.matchAll(SENTENCE_ENDINGS));
43
+ if (sentenceMatches.length > 0) {
44
+ // Find the last sentence boundary before TARGET_SIZE
45
+ let bestMatch = sentenceMatches[0]; // Safe: array is non-empty
46
+ for (const match of sentenceMatches) {
47
+ if (match.index !== undefined && match.index <= TARGET_SIZE) {
48
+ bestMatch = match;
49
+ }
50
+ else {
51
+ break;
52
+ }
53
+ }
54
+ if (bestMatch.index !== undefined && bestMatch[0] !== undefined) {
55
+ endPos = pos + bestMatch.index + bestMatch[0].length;
56
+ }
57
+ else {
58
+ // Fall back to word boundary
59
+ endPos = findWordBoundary(text, pos, TARGET_SIZE, MAX_SIZE);
60
+ }
61
+ }
62
+ else {
63
+ // No sentence boundary found — fall back to word boundary
64
+ endPos = findWordBoundary(text, pos, TARGET_SIZE, MAX_SIZE);
65
+ }
66
+ const chunk = text.slice(pos, endPos).trim();
67
+ if (chunk.length > 0)
68
+ yield chunk;
69
+ // Move position forward, with overlap
70
+ pos = endPos - OVERLAP;
71
+ if (pos < 0)
72
+ pos = endPos; // Safety: don't go negative
73
+ }
74
+ }
75
+ /**
76
+ * Find a word boundary near the target position.
77
+ * Prefers breaking at TARGET_SIZE, but will extend up to MAX_SIZE if needed.
78
+ */
79
+ function findWordBoundary(text, start, targetSize, maxSize) {
80
+ const targetPos = start + targetSize;
81
+ const maxPos = Math.min(start + maxSize, text.length);
82
+ // Look for whitespace near the target position
83
+ let endPos = targetPos;
84
+ // First try: find whitespace after targetPos
85
+ for (let i = targetPos; i < maxPos; i++) {
86
+ if (/\s/.test(text[i] ?? '')) {
87
+ endPos = i + 1; // Include the whitespace
88
+ break;
89
+ }
90
+ }
91
+ // If we hit maxPos without finding whitespace, hard cut at maxPos
92
+ if (endPos === targetPos && targetPos < maxPos) {
93
+ endPos = maxPos;
94
+ }
95
+ return endPos;
96
+ }
15
97
  export class LocalStore {
16
98
  #db;
17
99
  #userId;
18
- #llmConfig;
19
- #extractionQueue;
100
+ // Backlog processor state (T-095: drain loop)
101
+ #embedDrainStopped = false;
102
+ #embedDrainPromise = null;
103
+ #embedIdleMs;
104
+ // T-103: In-memory embedding cache for vec_raw_log (eliminates ~3,700ms SQLite load on each query)
105
+ #rawLogEmbeddingCache = [];
106
+ // FIX 3: WAL checkpoint throttling to prevent unbounded WAL growth
107
+ #lastCheckpoint = Date.now();
108
+ #checkpointIntervalMs = 60000; // Checkpoint every minute
109
+ // FIX 4: Health check to detect stuck drain loops
110
+ #lastActivityTimestamp = Date.now();
111
+ #healthCheckInterval = null;
20
112
  /** Expose database for plugin use (e.g., NudgeManager) */
21
113
  get db() {
22
114
  return this.#db;
@@ -25,15 +117,11 @@ export class LocalStore {
25
117
  get userId() {
26
118
  return this.#userId;
27
119
  }
28
- /** Expose extraction queue for lifecycle management (start/stop) */
29
- get extractionQueue() {
30
- return this.#extractionQueue;
31
- }
32
- constructor(db, userId, llmConfig, extractionQueue) {
120
+ constructor(db, userId, backlog) {
33
121
  this.#db = db;
34
122
  this.#userId = userId;
35
- this.#llmConfig = llmConfig;
36
- this.#extractionQueue = extractionQueue;
123
+ // Initialize backlog processor config
124
+ this.#embedIdleMs = backlog?.embedIdleMs ?? 5000;
37
125
  }
38
126
  /**
39
127
  * Create a new LocalStore instance (async factory).
@@ -42,99 +130,38 @@ export class LocalStore {
42
130
  static async create(options = {}) {
43
131
  const dbPath = options.dbPath ?? join(homedir(), '.plumb', 'memory.db');
44
132
  const userId = options.userId ?? 'default';
45
- const llmConfig = options.llmConfig;
46
133
  mkdirSync(dirname(dbPath), { recursive: true });
47
134
  const db = await openDb(dbPath);
48
135
  // Enable WAL mode and foreign keys
49
136
  db.exec('PRAGMA journal_mode = WAL');
50
137
  db.exec('PRAGMA foreign_keys = ON');
51
138
  applySchema(db);
52
- // Use a mutable cell to hold the store reference (needed for circular dependency)
53
- let storeRef = null;
54
- // Initialize extraction queue with deferred store lookup
55
- const extractFn = (exchange, userId) => {
56
- if (!storeRef)
57
- throw new Error('Store not initialized');
58
- const llmFn = llmConfig
59
- ? (prompt) => callLLMWithConfig(prompt, llmConfig)
60
- : undefined;
61
- return extractFacts(exchange, userId, storeRef, llmFn);
62
- };
63
- const extractionQueue = options.extractionQueue ?? new ExtractionQueue(extractFn);
64
- // Create store and assign to ref
65
- const store = new LocalStore(db, userId, llmConfig, extractionQueue);
66
- storeRef = store;
67
- return store;
68
- }
69
- async store(fact) {
70
- const id = crypto.randomUUID();
71
- // Embed concatenated fact text for vector search.
72
- const text = `${fact.subject} ${fact.predicate} ${fact.object} ${fact.context ?? ''}`.trim();
73
- const embedding = await embed(text);
74
- const embeddingJson = serializeEmbedding(embedding);
75
- // Begin transaction
76
- this.#db.exec('BEGIN');
77
- try {
78
- // Insert fact
79
- const factStmt = this.#db.prepare(`
80
- INSERT INTO facts
81
- (id, user_id, subject, predicate, object,
82
- confidence, decay_rate, timestamp, source_session_id,
83
- source_session_label, context)
84
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
85
- `);
86
- factStmt.bind([
87
- id,
88
- this.#userId,
89
- fact.subject,
90
- fact.predicate,
91
- fact.object,
92
- fact.confidence,
93
- fact.decayRate,
94
- fact.timestamp.toISOString(),
95
- fact.sourceSessionId,
96
- fact.sourceSessionLabel ?? null,
97
- fact.context ?? null,
98
- ]);
99
- factStmt.step();
100
- factStmt.finalize();
101
- // Insert embedding into vec_facts (auto-assigned id).
102
- const vecStmt = this.#db.prepare(`INSERT INTO vec_facts(embedding) VALUES (?)`);
103
- vecStmt.bind([embeddingJson]);
104
- vecStmt.step();
105
- vecStmt.finalize();
106
- const vecRowid = this.#db.selectValue('SELECT last_insert_rowid()');
107
- // Back-fill vec_rowid so fact-search can join without a mapping table.
108
- const updateStmt = this.#db.prepare(`UPDATE facts SET vec_rowid = ? WHERE id = ?`);
109
- updateStmt.bind([vecRowid, id]);
110
- updateStmt.step();
111
- updateStmt.finalize();
112
- this.#db.exec('COMMIT');
113
- }
114
- catch (err) {
115
- this.#db.exec('ROLLBACK');
116
- throw err;
117
- }
118
- return id;
119
- }
120
- async search(query, limit = 20) {
121
- return searchFacts(this.#db, this.#userId, query, limit);
122
- }
123
- async delete(id) {
124
- // Soft delete only — never hard delete.
125
- const stmt = this.#db.prepare(`
126
- UPDATE facts SET deleted_at = ? WHERE id = ? AND user_id = ?
139
+ // Create store
140
+ const store = new LocalStore(db, userId, options.backlog);
141
+ // T-096: Warm embedder pipeline to eliminate 365ms cold-start on first query
142
+ await warmEmbedder();
143
+ // T-101: Warm reranker pipeline to eliminate ~200ms cold-start on first query
144
+ // (intentionally loads ~80MB model at init for consistent <250ms query performance)
145
+ await warmReranker();
146
+ // T-103/T-108: Load vec_raw_log embeddings for child rows only (eliminates ~3,700ms SQLite load per query)
147
+ // Child rows have parent_id IS NOT NULL. Parent rows are not embedded (embed_status='no_embed').
148
+ const rawLogVecStmt = db.prepare(`
149
+ SELECT v.rowid, v.embedding
150
+ FROM vec_raw_log v
151
+ JOIN raw_log r ON r.vec_rowid = v.rowid
152
+ WHERE r.parent_id IS NOT NULL
127
153
  `);
128
- stmt.bind([new Date().toISOString(), id, this.#userId]);
129
- stmt.step();
130
- stmt.finalize();
154
+ while (rawLogVecStmt.step()) {
155
+ const row = rawLogVecStmt.get({});
156
+ store.#rawLogEmbeddingCache.push({
157
+ rowid: row.rowid,
158
+ embedding: deserializeEmbedding(row.embedding),
159
+ });
160
+ }
161
+ rawLogVecStmt.finalize();
162
+ return store;
131
163
  }
132
164
  async status() {
133
- const factStmt = this.#db.prepare(`SELECT COUNT(*) AS c FROM facts WHERE user_id = ? AND deleted_at IS NULL`);
134
- factStmt.bind([this.#userId]);
135
- factStmt.step();
136
- const factCount = factStmt.get(0);
137
- factStmt.finalize();
138
165
  const rawLogStmt = this.#db.prepare(`SELECT COUNT(*) AS c FROM raw_log WHERE user_id = ?`);
139
166
  rawLogStmt.bind([this.#userId]);
140
167
  rawLogStmt.step();
@@ -148,7 +175,6 @@ export class LocalStore {
148
175
  const pageCount = this.#db.selectValue('PRAGMA page_count');
149
176
  const pageSize = this.#db.selectValue('PRAGMA page_size');
150
177
  return {
151
- factCount,
152
178
  rawLogCount,
153
179
  lastIngestion: lastIngestionTs !== null ? new Date(lastIngestionTs) : null,
154
180
  storageBytes: pageCount * pageSize,
@@ -159,18 +185,16 @@ export class LocalStore {
159
185
  const chunkText = formatExchange(exchange);
160
186
  // Compute content hash for deduplication (scoped per userId).
161
187
  const contentHash = createHash('sha256').update(chunkText).digest('hex');
162
- // Embed before opening the DB transaction.
163
- const embedding = await embed(chunkText);
164
- const embeddingJson = serializeEmbedding(embedding);
165
188
  // Attempt insert — catch UNIQUE constraint violations (duplicate content_hash).
166
189
  try {
167
190
  this.#db.exec('BEGIN');
168
- // Insert into raw_log
191
+ // T-108: Insert parent row (no embedding, no vec_rowid).
169
192
  const rawLogStmt = this.#db.prepare(`
170
193
  INSERT INTO raw_log
171
194
  (id, user_id, session_id, session_label,
172
- user_message, agent_response, timestamp, source, chunk_text, chunk_index, content_hash)
173
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
195
+ user_message, agent_response, timestamp, source, chunk_text, chunk_index, content_hash,
196
+ embed_status, embed_error, embed_model, parent_id)
197
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
174
198
  `);
175
199
  rawLogStmt.bind([
176
200
  rawLogId,
@@ -184,44 +208,95 @@ export class LocalStore {
184
208
  chunkText,
185
209
  0,
186
210
  contentHash,
211
+ 'no_embed', // Parent is not embedded (T-108)
212
+ null,
213
+ null,
214
+ null, // parent_id=NULL for parent rows
187
215
  ]);
188
216
  rawLogStmt.step();
189
217
  rawLogStmt.finalize();
190
- // Insert embedding into vec_raw_log (auto-assigned id).
191
- const vecStmt = this.#db.prepare(`INSERT INTO vec_raw_log(embedding) VALUES (?)`);
192
- vecStmt.bind([embeddingJson]);
193
- vecStmt.step();
194
- vecStmt.finalize();
195
- const vecRowid = this.#db.selectValue('SELECT last_insert_rowid()');
196
- // Back-fill vec_rowid so raw-log-search can join without a mapping table.
197
- const updateStmt = this.#db.prepare(`UPDATE raw_log SET vec_rowid = ? WHERE id = ?`);
198
- updateStmt.bind([vecRowid, rawLogId]);
199
- updateStmt.step();
200
- updateStmt.finalize();
218
+ // T-108: Split parent into child chunks and embed each child.
219
+ // splitIntoChildren is a generator iterate lazily to avoid OOM on large inputs.
220
+ let i = 0;
221
+ for (const childText of splitIntoChildren(chunkText)) {
222
+ const childId = crypto.randomUUID();
223
+ let childEmbedding = null;
224
+ let childEmbeddingJson = null;
225
+ let childEmbedStatus = 'pending';
226
+ let childEmbedError = null;
227
+ let childEmbedModel = null;
228
+ // Embed the child chunk
229
+ try {
230
+ childEmbedding = await embed(childText);
231
+ childEmbeddingJson = serializeEmbedding(childEmbedding);
232
+ childEmbedStatus = 'done';
233
+ childEmbedModel = 'Xenova/bge-small-en-v1.5';
234
+ }
235
+ catch (err) {
236
+ childEmbedStatus = 'failed';
237
+ childEmbedError = err instanceof Error ? err.message : String(err);
238
+ }
239
+ // Insert child row
240
+ const childStmt = this.#db.prepare(`
241
+ INSERT INTO raw_log
242
+ (id, user_id, session_id, session_label,
243
+ user_message, agent_response, timestamp, source, chunk_text, chunk_index, content_hash,
244
+ embed_status, embed_error, embed_model, parent_id)
245
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
246
+ `);
247
+ childStmt.bind([
248
+ childId,
249
+ this.#userId,
250
+ exchange.sessionId,
251
+ exchange.sessionLabel ?? null,
252
+ exchange.userMessage,
253
+ exchange.agentResponse,
254
+ exchange.timestamp.toISOString(),
255
+ exchange.source,
256
+ childText,
257
+ i, // chunk_index for ordering
258
+ null, // No content_hash for children (they don't participate in dedup)
259
+ childEmbedStatus,
260
+ childEmbedError,
261
+ childEmbedModel,
262
+ rawLogId, // parent_id points to parent
263
+ ]);
264
+ childStmt.step();
265
+ childStmt.finalize();
266
+ // Insert child embedding into vec_raw_log if embedding succeeded
267
+ if (childEmbeddingJson !== null) {
268
+ const vecStmt = this.#db.prepare(`INSERT INTO vec_raw_log(embedding) VALUES (?)`);
269
+ vecStmt.bind([childEmbeddingJson]);
270
+ vecStmt.step();
271
+ vecStmt.finalize();
272
+ const vecRowid = this.#db.selectValue('SELECT last_insert_rowid()');
273
+ // Back-fill vec_rowid on child row
274
+ const updateStmt = this.#db.prepare(`UPDATE raw_log SET vec_rowid = ? WHERE id = ?`);
275
+ updateStmt.bind([vecRowid, childId]);
276
+ updateStmt.step();
277
+ updateStmt.finalize();
278
+ // T-103: Append child embedding to in-memory cache
279
+ this.#rawLogEmbeddingCache.push({ rowid: vecRowid, embedding: childEmbedding });
280
+ }
281
+ i++;
282
+ }
201
283
  this.#db.exec('COMMIT');
202
284
  }
203
285
  catch (err) {
204
286
  this.#db.exec('ROLLBACK');
205
287
  // Check for SQLite UNIQUE constraint error on content_hash.
206
288
  if (err instanceof Error && err.message.includes('UNIQUE constraint')) {
207
- // Duplicate content — skip ingestion and fact extraction.
289
+ // Duplicate content — skip ingestion.
208
290
  return {
209
291
  rawLogId: '',
210
- factsExtracted: 0,
211
- factIds: [],
212
292
  skipped: true,
213
293
  };
214
294
  }
215
295
  // Re-throw other errors (e.g., real DB issues).
216
296
  throw err;
217
297
  }
218
- // Layer 2: enqueue exchange for batched fact extraction (T-071).
219
- // ExtractionQueue handles draining on interval or batch size threshold.
220
- this.#extractionQueue.enqueue(exchange, this.#userId);
221
298
  return {
222
299
  rawLogId,
223
- factsExtracted: 0,
224
- factIds: [],
225
300
  };
226
301
  }
227
302
  /**
@@ -229,31 +304,16 @@ export class LocalStore {
229
304
  * See raw-log-search.ts for the full pipeline description.
230
305
  */
231
306
  async searchRawLog(query, limit = 10) {
232
- return searchRawLog(this.#db, this.#userId, query, limit);
233
- }
234
- /**
235
- * Wait for all queued fact extractions to complete.
236
- * Call this before close() to ensure all async work is done.
237
- * Delegates to ExtractionQueue.flush().
238
- */
239
- async drain() {
240
- await this.#extractionQueue.flush();
307
+ // T-103: Pass in-memory embedding cache to searchRawLog (eliminates ~3,700ms SQLite load per query)
308
+ return searchRawLog(this.#db, this.#userId, query, limit, this.#rawLogEmbeddingCache);
241
309
  }
242
310
  /**
243
- * Re-extract facts for orphaned raw_log chunks (chunks with no corresponding facts).
244
- *
245
- * This is useful when fact extraction failed during initial ingest (e.g., missing API key,
246
- * rate limits, crashes). Re-running the normal seeder won't help because content-hash dedup
247
- * skips already-ingested chunks before reaching the extraction phase.
248
- *
249
- * This method directly calls extractFacts() for each orphaned chunk, bypassing the dedup gate.
250
- *
251
- * @param throttleMs - Delay between extractions (default 1000ms) to stay under rate limits
252
- * @returns Statistics: orphansFound, factsCreated
311
+ * Export all data for a user (for plumb export command).
312
+ * Returns raw database rows (no vector data).
253
313
  */
254
- async reextractOrphans(throttleMs = 1000) {
255
- // Query for raw_log entries with no corresponding facts.
256
- const stmt = this.#db.prepare(`
314
+ exportAll(userId) {
315
+ // Export all raw_log entries (no vector data).
316
+ const rawLogStmt = this.#db.prepare(`
257
317
  SELECT
258
318
  id,
259
319
  user_id AS userId,
@@ -262,140 +322,171 @@ export class LocalStore {
262
322
  user_message AS userMessage,
263
323
  agent_response AS agentResponse,
264
324
  timestamp,
265
- source
325
+ source,
326
+ chunk_text AS chunkText,
327
+ chunk_index AS chunkIndex,
328
+ content_hash AS contentHash,
329
+ embed_status AS embedStatus,
330
+ embed_error AS embedError,
331
+ embed_model AS embedModel
266
332
  FROM raw_log
267
333
  WHERE user_id = ?
268
- AND NOT EXISTS (
269
- SELECT 1 FROM facts
270
- WHERE facts.source_session_id = raw_log.session_id
271
- )
272
- ORDER BY timestamp ASC
334
+ ORDER BY timestamp DESC
273
335
  `);
274
- stmt.bind([this.#userId]);
275
- const orphanRows = [];
276
- while (stmt.step()) {
277
- const row = stmt.get({});
278
- orphanRows.push(row);
336
+ rawLogStmt.bind([userId]);
337
+ const rawLog = [];
338
+ while (rawLogStmt.step()) {
339
+ rawLog.push(rawLogStmt.get({}));
279
340
  }
280
- stmt.finalize();
281
- const orphansFound = orphanRows.length;
282
- if (orphansFound === 0) {
283
- return { orphansFound: 0, factsCreated: 0 };
341
+ rawLogStmt.finalize();
342
+ return { rawLog };
343
+ }
344
+ /**
345
+ * Start background backlog processor drain loop (T-095).
346
+ * Launches continuous async loop for embed backlog.
347
+ */
348
+ startBacklogProcessor() {
349
+ // Start embed drain loop
350
+ if (this.#embedDrainPromise === null) {
351
+ this.#embedDrainStopped = false;
352
+ this.#embedDrainPromise = this.#embedDrainLoop();
284
353
  }
285
- let factsCreated = 0;
286
- for (let i = 0; i < orphanRows.length; i++) {
287
- const row = orphanRows[i];
288
- if (!row)
289
- continue;
290
- // Reconstruct MessageExchange from raw_log data
291
- const exchange = {
292
- userMessage: row.userMessage,
293
- agentResponse: row.agentResponse,
294
- timestamp: new Date(row.timestamp),
295
- source: row.source,
296
- sessionId: row.sessionId,
297
- ...(row.sessionLabel !== null ? { sessionLabel: row.sessionLabel } : {}),
298
- };
299
- // Extract facts directly (bypasses ingest dedup gate)
300
- try {
301
- const llmFn = this.#llmConfig
302
- ? (prompt) => callLLMWithConfig(prompt, this.#llmConfig)
303
- : undefined;
304
- const facts = await extractFacts(exchange, this.#userId, this, llmFn);
305
- factsCreated += facts.length;
306
- console.log(` ✅ [${i + 1}/${orphansFound}] Re-extracted ${facts.length} fact(s) from session ${row.sessionId}`);
307
- }
308
- catch (err) {
309
- console.error(` ❌ [${i + 1}/${orphansFound}] Failed to re-extract facts from session ${row.sessionId}:`, err);
354
+ // FIX 4: Health check - detect runaway loop that isn't processing or stopping
355
+ if (this.#healthCheckInterval === null) {
356
+ this.#healthCheckInterval = setInterval(() => {
357
+ const idleTime = Date.now() - this.#lastActivityTimestamp;
358
+ const MAX_IDLE_TIME = 300000; // 5 minutes of no activity
359
+ // If loop is running but idle for too long, force stop
360
+ if (idleTime > MAX_IDLE_TIME && !this.#embedDrainStopped) {
361
+ console.warn(`[plumb] Drain loop idle for ${Math.round(idleTime / 1000)}s, forcing stop`);
362
+ void this.stopBacklogProcessor();
363
+ }
364
+ }, 60000); // Check every minute
365
+ }
366
+ }
367
+ /**
368
+ * Stop background backlog processor drain loop (T-095).
369
+ * Signals loop to stop and awaits in-flight work.
370
+ */
371
+ async stopBacklogProcessor() {
372
+ // FIX 4: Clear health check interval
373
+ if (this.#healthCheckInterval !== null) {
374
+ clearInterval(this.#healthCheckInterval);
375
+ this.#healthCheckInterval = null;
376
+ }
377
+ // Signal loop to stop
378
+ this.#embedDrainStopped = true;
379
+ // Await drain loop Promise (waits for in-flight work to complete)
380
+ if (this.#embedDrainPromise !== null) {
381
+ await this.#embedDrainPromise;
382
+ this.#embedDrainPromise = null;
383
+ }
384
+ }
385
+ /**
386
+ * Continuous drain loop for embed backlog (T-095).
387
+ * Runs as fast as the Worker thread allows, with no artificial throttling.
388
+ * Only sleeps when the queue is empty.
389
+ */
390
+ async #embedDrainLoop() {
391
+ // FIX 2: Safety counter to detect infinite loops
392
+ let consecutiveEmptyBatches = 0;
393
+ const MAX_EMPTY_BATCHES = 1000; // Safety limit: stop after many empty iterations
394
+ while (!this.#embedDrainStopped) {
395
+ const processed = await this.#processEmbedBatch();
396
+ if (processed === 0) {
397
+ consecutiveEmptyBatches++;
398
+ // FIX 2: Safety check - if idle too long, verify stop flag
399
+ if (consecutiveEmptyBatches >= MAX_EMPTY_BATCHES) {
400
+ console.warn('[plumb] Embed drain loop: hit safety limit, verifying stop flag');
401
+ if (this.#embedDrainStopped)
402
+ break;
403
+ consecutiveEmptyBatches = 0; // Reset and continue
404
+ }
405
+ // Queue is empty — sleep before checking again
406
+ await new Promise(resolve => setTimeout(resolve, this.#embedIdleMs));
310
407
  }
311
- // Throttle to stay under rate limits (skip delay after last item)
312
- if (i < orphanRows.length - 1) {
313
- await new Promise(resolve => setTimeout(resolve, throttleMs));
408
+ else {
409
+ consecutiveEmptyBatches = 0;
410
+ // FIX 4: Update activity timestamp
411
+ this.#lastActivityTimestamp = Date.now();
314
412
  }
413
+ // If processed > 0: immediately loop to grab the next batch
315
414
  }
316
- return { orphansFound, factsCreated };
317
415
  }
318
416
  /**
319
- * Get top subjects by fact count (for plumb status command).
320
- * Returns subjects ordered by number of facts (non-deleted only).
417
+ * Process one batch of embed backlog rows (T-095).
418
+ * Uses Promise.all for parallelism across the batch (embed runs in Worker, no API limits).
419
+ * Returns count of rows processed.
321
420
  */
322
- topSubjects(userId, limit = 5) {
421
+ async #processEmbedBatch() {
422
+ const BATCH_SIZE = 50; // Large batch — embed is CPU-bound, no rate limit
423
+ // T-108: Fetch pending child rows only (parent_id IS NOT NULL).
424
+ // Old parent rows (parent_id IS NULL, embed_status='pending') are left as-is for fallback search.
323
425
  const stmt = this.#db.prepare(`
324
- SELECT subject, COUNT(*) as count
325
- FROM facts
326
- WHERE user_id = ? AND deleted_at IS NULL
327
- GROUP BY subject
328
- ORDER BY count DESC
426
+ SELECT id, chunk_text FROM raw_log
427
+ WHERE user_id = ? AND embed_status = 'pending' AND parent_id IS NOT NULL
428
+ ORDER BY rowid ASC
329
429
  LIMIT ?
330
430
  `);
331
- stmt.bind([userId, limit]);
332
- const results = [];
431
+ stmt.bind([this.#userId, BATCH_SIZE]);
432
+ const pendingRows = [];
333
433
  while (stmt.step()) {
334
- results.push(stmt.get({}));
434
+ pendingRows.push(stmt.get({}));
335
435
  }
336
436
  stmt.finalize();
337
- return results;
338
- }
339
- /**
340
- * Export all data for a user (for plumb export command).
341
- * Returns raw database rows (no vector data).
342
- * Includes soft-deleted facts for transparency.
343
- */
344
- exportAll(userId) {
345
- // Export all non-deleted facts only (soft-deleted facts are excluded).
346
- const factStmt = this.#db.prepare(`
347
- SELECT
348
- id,
349
- user_id AS userId,
350
- subject,
351
- predicate,
352
- object,
353
- confidence,
354
- decay_rate AS decayRate,
355
- timestamp,
356
- source_session_id AS sourceSessionId,
357
- source_session_label AS sourceSessionLabel,
358
- context,
359
- deleted_at AS deletedAt
360
- FROM facts
361
- WHERE user_id = ? AND deleted_at IS NULL
362
- ORDER BY timestamp DESC
363
- `);
364
- factStmt.bind([userId]);
365
- const factRows = [];
366
- while (factStmt.step()) {
367
- factRows.push(factStmt.get({}));
368
- }
369
- factStmt.finalize();
370
- const facts = factRows.map((row) => ({
371
- ...row,
372
- deleted: false, // All exported facts are non-deleted
437
+ if (pendingRows.length === 0)
438
+ return 0;
439
+ // Process rows concurrently with Promise.all
440
+ await Promise.all(pendingRows.map(async (row) => {
441
+ try {
442
+ const embedding = await embed(row.chunk_text);
443
+ const embeddingJson = serializeEmbedding(embedding);
444
+ const embedModel = 'Xenova/bge-small-en-v1.5';
445
+ // Insert into vec_raw_log (transaction per row for isolation)
446
+ this.#db.exec('BEGIN');
447
+ const vecStmt = this.#db.prepare(`INSERT INTO vec_raw_log(embedding) VALUES (?)`);
448
+ vecStmt.bind([embeddingJson]);
449
+ vecStmt.step();
450
+ vecStmt.finalize();
451
+ const vecRowid = this.#db.selectValue('SELECT last_insert_rowid()');
452
+ // Update raw_log: embed_status='done', vec_rowid, embed_model
453
+ const updateStmt = this.#db.prepare(`
454
+ UPDATE raw_log
455
+ SET embed_status = 'done', embed_error = NULL, embed_model = ?, vec_rowid = ?
456
+ WHERE id = ?
457
+ `);
458
+ updateStmt.bind([embedModel, vecRowid, row.id]);
459
+ updateStmt.step();
460
+ updateStmt.finalize();
461
+ this.#db.exec('COMMIT');
462
+ // T-103: Append new embedding to in-memory cache
463
+ this.#rawLogEmbeddingCache.push({ rowid: vecRowid, embedding });
464
+ }
465
+ catch (err) {
466
+ // Embedding failed — update embed_status='failed' with error
467
+ const errorMsg = err instanceof Error ? err.message : String(err);
468
+ const updateStmt = this.#db.prepare(`
469
+ UPDATE raw_log
470
+ SET embed_status = 'failed', embed_error = ?
471
+ WHERE id = ?
472
+ `);
473
+ updateStmt.bind([errorMsg, row.id]);
474
+ updateStmt.step();
475
+ updateStmt.finalize();
476
+ }
373
477
  }));
374
- // Export all raw_log entries (no vector data).
375
- const rawLogStmt = this.#db.prepare(`
376
- SELECT
377
- id,
378
- user_id AS userId,
379
- session_id AS sessionId,
380
- session_label AS sessionLabel,
381
- user_message AS userMessage,
382
- agent_response AS agentResponse,
383
- timestamp,
384
- source,
385
- chunk_text AS chunkText,
386
- chunk_index AS chunkIndex,
387
- content_hash AS contentHash
388
- FROM raw_log
389
- WHERE user_id = ?
390
- ORDER BY timestamp DESC
391
- `);
392
- rawLogStmt.bind([userId]);
393
- const rawLog = [];
394
- while (rawLogStmt.step()) {
395
- rawLog.push(rawLogStmt.get({}));
478
+ // FIX 3: Periodic WAL checkpoint to prevent unbounded growth
479
+ const now = Date.now();
480
+ if (now - this.#lastCheckpoint > this.#checkpointIntervalMs) {
481
+ try {
482
+ this.#db.exec('PRAGMA wal_checkpoint(PASSIVE)');
483
+ this.#lastCheckpoint = now;
484
+ }
485
+ catch (e) {
486
+ console.warn('[plumb] WAL checkpoint failed:', e);
487
+ }
396
488
  }
397
- rawLogStmt.finalize();
398
- return { facts, rawLog };
489
+ return pendingRows.length;
399
490
  }
400
491
  /** Close the database connection. Call when done (e.g. in tests). */
401
492
  close() {