@dotdotgod/context 0.2.25 → 0.2.27

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/src/store.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdirSync, rmSync } from 'node:fs';
1
+ import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { DatabaseSync } from 'node:sqlite';
4
4
  import { extname } from 'node:path';
@@ -20,40 +20,174 @@ export function contextDbPath(root) {
20
20
  return join(safeRoot(root), '.dotdotgod', 'context', 'context.sqlite');
21
21
  }
22
22
 
23
+ const SCHEMA_VERSION = 3;
24
+ const MAX_PENDING_JOBS = 100;
25
+
26
+ function inspectSchema(db) {
27
+ const objects = new Map(db.prepare("SELECT name, type, sql FROM sqlite_master WHERE name IN ('sources', 'chunks')").all().map((row) => [row.name, row]));
28
+ const sourceRows = db.prepare('PRAGMA table_info(sources)').all();
29
+ const chunkRows = db.prepare('PRAGMA table_info(chunks)').all();
30
+ const requiredSources = ['id', 'scope', 'session_id', 'label', 'kind', 'metadata', 'created_at', 'expires_at'];
31
+ const requiredChunks = ['source_id', 'scope', 'session_id', 'ordinal', 'body'];
32
+ const missingSources = requiredSources.filter((name) => !sourceRows.some((row) => row.name === name));
33
+ const missingChunks = requiredChunks.filter((name) => !chunkRows.some((row) => row.name === name));
34
+ const sourceShape = sourceRows.length === requiredSources.length && sourceRows.every((row, index) => row.name === requiredSources[index])
35
+ && sourceRows[0].pk === 1
36
+ && sourceRows.every((row) => ['scope', 'label', 'kind', 'metadata', 'created_at'].includes(row.name) ? row.notnull === 1 : true);
37
+ const chunkShape = chunkRows.length === requiredChunks.length && chunkRows.every((row, index) => row.name === requiredChunks[index]);
38
+ const chunkSql = String(objects.get('chunks')?.sql ?? '').toLowerCase();
39
+ const compatible = objects.get('sources')?.type === 'table'
40
+ && objects.get('chunks')?.type === 'table'
41
+ && chunkSql.includes('using fts5')
42
+ && chunkSql.includes("tokenize='porter unicode61'")
43
+ && sourceShape
44
+ && chunkShape
45
+ && missingSources.length === 0
46
+ && missingChunks.length === 0;
47
+ let version = 0;
48
+ if (compatible) {
49
+ const ledger = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'").get();
50
+ if (ledger) version = Number(db.prepare('SELECT coalesce(max(version), 0) AS version FROM schema_migrations').get().version);
51
+ else version = 1;
52
+ }
53
+ return { compatible: compatible && version <= SCHEMA_VERSION, schemaProfile: compatible ? version : null, missing: [...missingSources, ...missingChunks] };
54
+ }
55
+
56
+ function migrate(db, fromVersion) {
57
+ if (fromVersion > SCHEMA_VERSION) throw new Error(`Incompatible context database schema version ${fromVersion}; runtime supports ${SCHEMA_VERSION}.`);
58
+ db.exec('BEGIN IMMEDIATE');
59
+ try {
60
+ db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL);');
61
+ if (fromVersion < 2) {
62
+ db.exec(`CREATE TABLE IF NOT EXISTS ingestion_jobs (
63
+ id TEXT PRIMARY KEY, state TEXT NOT NULL CHECK(state IN ('queued','running','completed','failed','cancelled')),
64
+ kind TEXT NOT NULL, input TEXT NOT NULL, result TEXT, error TEXT,
65
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
66
+ ); CREATE INDEX IF NOT EXISTS ingestion_jobs_state_created ON ingestion_jobs(state, created_at);`);
67
+ db.prepare('INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (1, ?)').run(Date.now());
68
+ db.prepare('INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (2, ?)').run(Date.now());
69
+ }
70
+ if (fromVersion < 3) {
71
+ const columns = db.prepare('PRAGMA table_info(ingestion_jobs)').all();
72
+ if (!columns.some((row) => row.name === 'session_id')) db.exec('ALTER TABLE ingestion_jobs ADD COLUMN session_id TEXT;');
73
+ db.prepare('INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (3, ?)').run(Date.now());
74
+ }
75
+ db.exec('COMMIT');
76
+ } catch (error) { db.exec('ROLLBACK'); throw error; }
77
+ }
78
+
79
+ function trigrams(value) {
80
+ const normalized = ` ${String(value).toLocaleLowerCase('en').replace(/[^\p{L}\p{N}]+/gu, ' ').trim()} `;
81
+ const out = new Set();
82
+ for (let index = 0; index + 3 <= normalized.length; index += 1) out.add(normalized.slice(index, index + 3));
83
+ return out;
84
+ }
85
+ function trigramScore(left, right) {
86
+ const a = trigrams(left); const b = trigrams(right);
87
+ if (!a.size || !b.size) return 0;
88
+ let overlap = 0; for (const item of a) if (b.has(item)) overlap += 1;
89
+ return (2 * overlap) / (a.size + b.size);
90
+ }
91
+ function editDistance(left, right, ceiling = 2) {
92
+ if (Math.abs(left.length - right.length) > ceiling) return ceiling + 1;
93
+ let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
94
+ for (let i = 1; i <= left.length; i += 1) {
95
+ const current = [i]; let rowMin = i;
96
+ for (let j = 1; j <= right.length; j += 1) { current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + (left[i - 1] === right[j - 1] ? 0 : 1)); rowMin = Math.min(rowMin, current[j]); }
97
+ if (rowMin > ceiling) return ceiling + 1; previous = current;
98
+ }
99
+ return previous[right.length];
100
+ }
101
+ function typoScore(query, value) {
102
+ const queryTerms = normalizeSearchTerms(query);
103
+ const valueTerms = normalizeSearchTerms(value);
104
+ let best = 0;
105
+ for (const needle of queryTerms) for (const token of valueTerms) {
106
+ if (needle.length >= 5 && editDistance(needle, token) <= 2) best = Math.max(best, trigramScore(needle, token));
107
+ }
108
+ return best;
109
+ }
110
+
111
+ export function healContextDatabase(root = process.cwd()) {
112
+ const path = contextDbPath(root);
113
+ if (!existsSync(path)) throw new Error('No context database exists to heal.');
114
+ const stamp = Date.now();
115
+ const backupPath = `${path}.backup-${stamp}`;
116
+ const rebuiltPath = `${path}.healed-${stamp}`;
117
+ let inspection;
118
+ let replaced = false;
119
+ const db = new DatabaseSync(path);
120
+ try {
121
+ db.exec('PRAGMA busy_timeout=1000; PRAGMA wal_checkpoint(FULL);');
122
+ inspection = inspectSchema(db);
123
+ if (!inspection.compatible || ![1, 2, 3].includes(inspection.schemaProfile)) {
124
+ throw new Error('Schema is not a recognized recoverable context database; no rebuild performed.');
125
+ }
126
+ copyFileSync(path, backupPath, 0);
127
+ migrate(db, inspection.schemaProfile);
128
+ db.exec('PRAGMA wal_checkpoint(TRUNCATE);');
129
+ db.exec(`VACUUM INTO '${rebuiltPath.replaceAll("'", "''")}';`);
130
+ } catch (error) {
131
+ rmSync(rebuiltPath, { force: true });
132
+ throw error;
133
+ } finally { db.close(); }
134
+ try {
135
+ renameSync(rebuiltPath, path);
136
+ replaced = true;
137
+ rmSync(`${path}-wal`, { force: true });
138
+ rmSync(`${path}-shm`, { force: true });
139
+ } finally {
140
+ if (!replaced) rmSync(rebuiltPath, { force: true });
141
+ }
142
+ return { ok: true, backupPath, fromVersion: inspection.schemaProfile, toVersion: SCHEMA_VERSION, rebuilt: true };
143
+ }
144
+
23
145
  export class ContextStore {
24
146
  constructor(root = process.cwd()) {
25
147
  this.root = safeRoot(root);
26
148
  this.path = contextDbPath(this.root);
149
+ const existing = existsSync(this.path);
150
+ let schemaVersion = 1;
151
+ if (existing) {
152
+ if (statSync(this.path).size === 0) throw new Error('Incompatible context database schema; empty database cannot be repaired automatically.');
153
+ const inspection = new DatabaseSync(this.path, { readOnly: true });
154
+ try {
155
+ const result = inspectSchema(inspection);
156
+ if (!result.compatible) throw new Error(`Incompatible context database schema; run doctor before explicit healing${result.missing.length ? ` (missing: ${result.missing.join(', ')})` : ''}.`);
157
+ schemaVersion = result.schemaProfile;
158
+ } finally {
159
+ inspection.close();
160
+ }
161
+ }
27
162
  mkdirSync(dirname(this.path), { recursive: true });
28
163
  this.db = new DatabaseSync(this.path);
29
- this.db.exec(`
30
- PRAGMA journal_mode=WAL;
31
- CREATE TABLE IF NOT EXISTS sources (
32
- id TEXT PRIMARY KEY,
33
- scope TEXT NOT NULL,
34
- session_id TEXT,
35
- label TEXT NOT NULL,
36
- kind TEXT NOT NULL,
37
- metadata TEXT NOT NULL,
38
- created_at INTEGER NOT NULL,
39
- expires_at INTEGER
40
- );
41
- CREATE VIRTUAL TABLE IF NOT EXISTS chunks USING fts5(
42
- source_id UNINDEXED,
43
- scope UNINDEXED,
44
- session_id UNINDEXED,
45
- ordinal UNINDEXED,
46
- body,
47
- tokenize='porter unicode61'
48
- );
49
- `);
50
- const sourceColumns = new Set(this.db.prepare('PRAGMA table_info(sources)').all().map((row) => row.name));
51
- const chunkColumns = new Set(this.db.prepare('PRAGMA table_info(chunks)').all().map((row) => row.name));
52
- const missingSources = ['id', 'scope', 'session_id', 'label', 'kind', 'metadata', 'created_at', 'expires_at'].filter((name) => !sourceColumns.has(name));
53
- const missingChunks = ['source_id', 'scope', 'session_id', 'ordinal', 'body'].filter((name) => !chunkColumns.has(name));
54
- if (missingSources.length || missingChunks.length) {
164
+ try {
165
+ this.db.exec('PRAGMA busy_timeout=1000; PRAGMA journal_mode=WAL;');
166
+ if (!existing) this.db.exec(`
167
+ CREATE TABLE sources (
168
+ id TEXT PRIMARY KEY,
169
+ scope TEXT NOT NULL,
170
+ session_id TEXT,
171
+ label TEXT NOT NULL,
172
+ kind TEXT NOT NULL,
173
+ metadata TEXT NOT NULL,
174
+ created_at INTEGER NOT NULL,
175
+ expires_at INTEGER
176
+ );
177
+ CREATE VIRTUAL TABLE chunks USING fts5(
178
+ source_id UNINDEXED,
179
+ scope UNINDEXED,
180
+ session_id UNINDEXED,
181
+ ordinal UNINDEXED,
182
+ body,
183
+ tokenize='porter unicode61'
184
+ );
185
+ `);
186
+ migrate(this.db, schemaVersion);
187
+ this.db.prepare("UPDATE ingestion_jobs SET state = 'queued', updated_at = ? WHERE state = 'running'").run(Date.now());
188
+ } catch (error) {
55
189
  this.db.close();
56
- throw new Error(`Incompatible context database schema; run doctor before repair (missing: ${[...missingSources, ...missingChunks].join(', ')}).`);
190
+ throw error;
57
191
  }
58
192
  }
59
193
 
@@ -67,7 +201,7 @@ export class ContextStore {
67
201
  const contentType = String(provenance.contentType ?? metadata.contentType ?? '').toLowerCase();
68
202
  const extension = extname(String(metadata.path ?? '')).toLowerCase();
69
203
  const format = provenance.format ?? (contentType.includes('json') || extension === '.json' ? 'json' : contentType.includes('markdown') || ['.md', '.mdx'].includes(extension) ? 'markdown' : 'text');
70
- const extractor = format === 'json' ? 'json-v1' : format === 'markdown' ? 'markdown-v1' : 'plain';
204
+ const extractor = provenance.extractor ?? (format === 'json' ? 'json-v1' : format === 'markdown' ? 'markdown-v1' : 'plain');
71
205
  const normalizedMetadata = createProvenanceMetadata(metadata, { sourceType, origin, content: text, extractor, indexedAt: new Date(createdAt).toISOString() });
72
206
  this.db.exec('BEGIN IMMEDIATE');
73
207
  try {
@@ -87,10 +221,18 @@ export class ContextStore {
87
221
 
88
222
  expire() {
89
223
  const ids = this.db.prepare('SELECT id FROM sources WHERE expires_at IS NOT NULL AND expires_at <= ?').all(Date.now()).map((row) => row.id);
224
+ if (ids.length === 0) return 0;
90
225
  const removeChunks = this.db.prepare('DELETE FROM chunks WHERE source_id = ?');
91
226
  const removeSource = this.db.prepare('DELETE FROM sources WHERE id = ?');
92
- for (const id of ids) { removeChunks.run(id); removeSource.run(id); }
93
- return ids.length;
227
+ this.db.exec('BEGIN IMMEDIATE');
228
+ try {
229
+ for (const id of ids) { removeChunks.run(id); removeSource.run(id); }
230
+ this.db.exec('COMMIT');
231
+ return ids.length;
232
+ } catch (error) {
233
+ this.db.exec('ROLLBACK');
234
+ throw error;
235
+ }
94
236
  }
95
237
 
96
238
  search({ query, scope, sessionId, source, limit = 5 }) {
@@ -121,6 +263,27 @@ export class ContextStore {
121
263
  AND (${labelClauses.join(' OR ')})${filterSql}
122
264
  ORDER BY lower(s.label), c.ordinal LIMIT ?
123
265
  `).all(...labelParams, ...filterParams, candidateLimit);
266
+ const typoWindow = Math.min(500, candidateLimit * 5);
267
+ const typoHalf = Math.max(1, Math.floor(typoWindow / 2));
268
+ const trigramRows = this.db.prepare(`
269
+ SELECT * FROM (
270
+ SELECT ${select.replace('SELECT ', '')}, 0 AS rank
271
+ FROM chunks c JOIN sources s ON s.id = c.source_id
272
+ WHERE 1 = 1${filterSql}
273
+ ORDER BY c.rowid ASC LIMIT ?
274
+ )
275
+ UNION ALL
276
+ SELECT * FROM (
277
+ SELECT ${select.replace('SELECT ', '')}, 0 AS rank
278
+ FROM chunks c JOIN sources s ON s.id = c.source_id
279
+ WHERE 1 = 1${filterSql}
280
+ ORDER BY c.rowid DESC LIMIT ?
281
+ )
282
+ `).all(...filterParams, typoHalf, ...filterParams, typoWindow - typoHalf)
283
+ .map((row) => ({ ...row, trigramScore: Math.max(typoScore(query, row.body), Number(row.ordinal) === 0 ? typoScore(query, row.label) : 0) }))
284
+ .filter((row) => row.trigramScore >= 0.5)
285
+ .sort((left, right) => right.trigramScore - left.trigramScore || String(left.source_id).localeCompare(String(right.source_id), 'en') || Number(left.ordinal) - Number(right.ordinal))
286
+ .slice(0, candidateLimit);
124
287
  const toCandidate = (row) => {
125
288
  let storedMetadata = {};
126
289
  try { storedMetadata = JSON.parse(row.metadata); } catch { /* legacy invalid metadata */ }
@@ -129,6 +292,7 @@ export class ContextStore {
129
292
  const fused = reciprocalRankFusion([
130
293
  { name: 'porter-bm25', candidates: porterRows.map(toCandidate) },
131
294
  { name: 'label-path', candidates: labelRows.map(toCandidate) },
295
+ { name: 'trigram-v1', candidates: trigramRows.map(toCandidate) },
132
296
  ], { limit: candidateLimit });
133
297
  return rerankCandidates(fused, query, { limit: resultLimit }).map((entry) => {
134
298
  const row = entry.candidate;
@@ -150,6 +314,53 @@ export class ContextStore {
150
314
  });
151
315
  }
152
316
 
317
+ createJob({ id = crypto.randomUUID(), kind, input, sessionId = null }) {
318
+ const now = Date.now();
319
+ const serializedInput = JSON.stringify(input);
320
+ if (Buffer.byteLength(serializedInput) > 64 * 1024) throw new Error('Background ingestion job input exceeds 64 KiB.');
321
+ this.db.exec('BEGIN IMMEDIATE');
322
+ try {
323
+ const pending = Number(this.db.prepare("SELECT count(*) AS count FROM ingestion_jobs WHERE state IN ('queued','running')").get().count);
324
+ if (pending >= MAX_PENDING_JOBS) throw new Error('Background ingestion queue is full.');
325
+ this.db.prepare('INSERT INTO ingestion_jobs(id, state, kind, input, session_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
326
+ .run(id, 'queued', kind, serializedInput, sessionId, now, now);
327
+ this.db.exec('COMMIT');
328
+ } catch (error) { this.db.exec('ROLLBACK'); throw error; }
329
+ return this.getJob(id);
330
+ }
331
+
332
+ getJob(id) {
333
+ const row = this.db.prepare('SELECT * FROM ingestion_jobs WHERE id = ?').get(id);
334
+ if (!row) return null;
335
+ return { id: row.id, state: row.state, kind: row.kind, input: JSON.parse(row.input), sessionId: row.session_id ?? null, result: row.result ? JSON.parse(row.result) : null, error: row.error, createdAt: row.created_at, updatedAt: row.updated_at };
336
+ }
337
+
338
+ claimNextJob() {
339
+ const row = this.db.prepare("SELECT id FROM ingestion_jobs WHERE state = 'queued' ORDER BY created_at, id LIMIT 1").get();
340
+ if (!row) return null;
341
+ const changed = this.db.prepare("UPDATE ingestion_jobs SET state = 'running', updated_at = ? WHERE id = ? AND state = 'queued'").run(Date.now(), row.id).changes;
342
+ return changed ? this.getJob(row.id) : null;
343
+ }
344
+
345
+ finishJob(id, state, value = null) {
346
+ if (!['completed', 'failed', 'cancelled'].includes(state)) throw new Error('Invalid terminal job state.');
347
+ let result = state === 'completed' ? JSON.stringify(value) : null;
348
+ if (result && Buffer.byteLength(result) > 256 * 1024) result = JSON.stringify({ truncated: true, message: 'Completed result exceeded the durable status limit.' });
349
+ const error = state === 'failed' ? String(value ?? 'Job failed').slice(0, 4096) : null;
350
+ this.db.prepare("UPDATE ingestion_jobs SET state = ?, result = ?, error = ?, updated_at = ? WHERE id = ? AND state IN ('queued','running')")
351
+ .run(state, result, error, Date.now(), id);
352
+ return this.getJob(id);
353
+ }
354
+
355
+ cancelJob(id) {
356
+ const changed = this.db.prepare("UPDATE ingestion_jobs SET state = 'cancelled', updated_at = ? WHERE id = ? AND state IN ('queued','running')").run(Date.now(), id).changes;
357
+ return { changed: Number(changed), job: this.getJob(id) };
358
+ }
359
+
360
+ listPendingJobs(limit = 20) {
361
+ return this.db.prepare("SELECT id FROM ingestion_jobs WHERE state IN ('queued','running') ORDER BY created_at, id LIMIT ?").all(Math.min(100, Math.max(1, limit))).map((row) => this.getJob(row.id));
362
+ }
363
+
153
364
  stats() {
154
365
  this.expire();
155
366
  const sources = this.db.prepare('SELECT count(*) AS count FROM sources').get().count;
@@ -168,11 +379,18 @@ export class ContextStore {
168
379
  const removeChunks = this.db.prepare('DELETE FROM chunks WHERE source_id = ?');
169
380
  const removeSource = this.db.prepare('DELETE FROM sources WHERE id = ?');
170
381
  let removed = 0;
171
- for (const id of ids) {
172
- removeChunks.run(id);
173
- removed += Number(removeSource.run(id).changes ?? 0);
382
+ this.db.exec('BEGIN IMMEDIATE');
383
+ try {
384
+ for (const id of ids) {
385
+ removeChunks.run(id);
386
+ removed += Number(removeSource.run(id).changes ?? 0);
387
+ }
388
+ this.db.exec('COMMIT');
389
+ return { removed };
390
+ } catch (error) {
391
+ this.db.exec('ROLLBACK');
392
+ throw error;
174
393
  }
175
- return { removed };
176
394
  }
177
395
 
178
396
  destroy() {
@@ -0,0 +1,13 @@
1
+ const object = (properties, required = []) => Object.freeze({ type: 'object', properties, required, additionalProperties: false, $schema: 'http://json-schema.org/draft-07/schema#' });
2
+ const string = (options = {}) => Object.freeze({ type: 'string', ...options });
3
+
4
+ export const PHASE3_TOOL_INPUT_SCHEMAS = Object.freeze({
5
+ session_resume: object({ sessionId: string({ minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$' }) }, ['sessionId']),
6
+ ingestion_job_start: object({
7
+ kind: Object.freeze({ type: 'string', enum: ['index', 'fetch'] }),
8
+ input: Object.freeze({ type: 'object', additionalProperties: Object.freeze({}) }),
9
+ }, ['kind', 'input']),
10
+ ingestion_job_status: object({ id: string({ format: 'uuid' }) }, ['id']),
11
+ ingestion_job_cancel: object({ id: string({ format: 'uuid' }) }, ['id']),
12
+ context_heal: object({ confirm: Object.freeze({ const: true, type: 'boolean' }) }, ['confirm']),
13
+ });