@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.
- package/README.md +2 -2
- package/dist/context-builder.d.ts +1 -7
- package/dist/context-builder.d.ts.map +1 -1
- package/dist/context-builder.js +7 -44
- package/dist/context-builder.js.map +1 -1
- package/dist/embedder.d.ts +16 -2
- package/dist/embedder.d.ts.map +1 -1
- package/dist/embedder.js +23 -4
- package/dist/embedder.js.map +1 -1
- package/dist/extraction-queue.d.ts +13 -3
- package/dist/extraction-queue.d.ts.map +1 -1
- package/dist/extraction-queue.js +21 -4
- package/dist/extraction-queue.js.map +1 -1
- package/dist/extractor.d.ts +2 -1
- package/dist/extractor.d.ts.map +1 -1
- package/dist/extractor.js +106 -7
- package/dist/extractor.js.map +1 -1
- package/dist/extractor.test.d.ts +2 -0
- package/dist/extractor.test.d.ts.map +1 -0
- package/dist/extractor.test.js +158 -0
- package/dist/extractor.test.js.map +1 -0
- package/dist/fact-search.d.ts +9 -5
- package/dist/fact-search.d.ts.map +1 -1
- package/dist/fact-search.js +25 -16
- package/dist/fact-search.js.map +1 -1
- package/dist/fact-search.test.d.ts +12 -0
- package/dist/fact-search.test.d.ts.map +1 -0
- package/dist/fact-search.test.js +117 -0
- package/dist/fact-search.test.js.map +1 -0
- package/dist/index.d.ts +6 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -5
- package/dist/index.js.map +1 -1
- package/dist/llm-client.d.ts +11 -2
- package/dist/llm-client.d.ts.map +1 -1
- package/dist/llm-client.js +47 -3
- package/dist/llm-client.js.map +1 -1
- package/dist/local-store.d.ts +19 -63
- package/dist/local-store.d.ts.map +1 -1
- package/dist/local-store.js +353 -262
- package/dist/local-store.js.map +1 -1
- package/dist/local-store.test.d.ts +2 -0
- package/dist/local-store.test.d.ts.map +1 -0
- package/dist/local-store.test.js +146 -0
- package/dist/local-store.test.js.map +1 -0
- package/dist/raw-log-search.d.ts +9 -5
- package/dist/raw-log-search.d.ts.map +1 -1
- package/dist/raw-log-search.js +107 -29
- package/dist/raw-log-search.js.map +1 -1
- package/dist/raw-log-search.test.d.ts +12 -0
- package/dist/raw-log-search.test.d.ts.map +1 -0
- package/dist/raw-log-search.test.js +124 -0
- package/dist/raw-log-search.test.js.map +1 -0
- package/dist/read-path.d.ts +6 -23
- package/dist/read-path.d.ts.map +1 -1
- package/dist/read-path.js +9 -48
- package/dist/read-path.js.map +1 -1
- package/dist/read-path.test.d.ts +15 -0
- package/dist/read-path.test.d.ts.map +1 -0
- package/dist/read-path.test.js +393 -0
- package/dist/read-path.test.js.map +1 -0
- package/dist/schema.d.ts +4 -13
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +42 -52
- package/dist/schema.js.map +1 -1
- package/dist/scorer.d.ts +0 -9
- package/dist/scorer.d.ts.map +1 -1
- package/dist/scorer.js +1 -31
- package/dist/scorer.js.map +1 -1
- package/dist/scorer.test.d.ts +10 -0
- package/dist/scorer.test.d.ts.map +1 -0
- package/dist/scorer.test.js +169 -0
- package/dist/scorer.test.js.map +1 -0
- package/dist/store.d.ts +2 -14
- package/dist/store.d.ts.map +1 -1
- package/dist/types.d.ts +0 -25
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -6
- package/dist/types.js.map +1 -1
- package/dist/wasm-db.d.ts +63 -8
- package/dist/wasm-db.d.ts.map +1 -1
- package/dist/wasm-db.js +124 -31
- package/dist/wasm-db.js.map +1 -1
- package/package.json +14 -2
package/dist/local-store.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
19
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
this.#
|
|
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
|
-
//
|
|
53
|
-
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
255
|
-
//
|
|
256
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
const
|
|
276
|
-
while (
|
|
277
|
-
|
|
278
|
-
orphanRows.push(row);
|
|
336
|
+
rawLogStmt.bind([userId]);
|
|
337
|
+
const rawLog = [];
|
|
338
|
+
while (rawLogStmt.step()) {
|
|
339
|
+
rawLog.push(rawLogStmt.get({}));
|
|
279
340
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
*
|
|
320
|
-
*
|
|
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
|
-
|
|
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
|
|
325
|
-
|
|
326
|
-
|
|
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,
|
|
332
|
-
const
|
|
431
|
+
stmt.bind([this.#userId, BATCH_SIZE]);
|
|
432
|
+
const pendingRows = [];
|
|
333
433
|
while (stmt.step()) {
|
|
334
|
-
|
|
434
|
+
pendingRows.push(stmt.get({}));
|
|
335
435
|
}
|
|
336
436
|
stmt.finalize();
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
//
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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() {
|