@geraldmaron/construct 1.0.23 → 1.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 (106) hide show
  1. package/README.md +0 -2
  2. package/bin/construct +189 -207
  3. package/lib/agent-instructions/inject.mjs +25 -4
  4. package/lib/audit-rules.mjs +127 -0
  5. package/lib/audit-skills.mjs +43 -1
  6. package/lib/beads-client.mjs +9 -0
  7. package/lib/beads-optimistic.mjs +23 -71
  8. package/lib/bridges/copilot-proxy.mjs +116 -0
  9. package/lib/cli-commands.mjs +5 -1
  10. package/lib/comment-lint.mjs +1 -1
  11. package/lib/config/schema.mjs +1 -1
  12. package/lib/document-extract/docling-client.mjs +16 -6
  13. package/lib/document-extract/docling-sidecar.py +32 -2
  14. package/lib/document-extract.mjs +37 -10
  15. package/lib/document-ingest.mjs +90 -5
  16. package/lib/embed/inbox.mjs +6 -3
  17. package/lib/embed/recommendation-store.mjs +7 -289
  18. package/lib/embed/reconcile.mjs +2 -2
  19. package/lib/embed/roadmap.mjs +16 -1
  20. package/lib/engine/consolidate.mjs +160 -3
  21. package/lib/engine/contradiction-judge.mjs +71 -0
  22. package/lib/engine/contradiction.mjs +74 -0
  23. package/lib/hooks/config-protection.mjs +4 -4
  24. package/lib/hooks/session-reflect.mjs +5 -1
  25. package/lib/host-capabilities.mjs +30 -0
  26. package/lib/ingest/docling-remote.mjs +90 -0
  27. package/lib/ingest/strategy.mjs +1 -1
  28. package/lib/init-unified.mjs +9 -13
  29. package/lib/intake/git-queue.mjs +195 -0
  30. package/lib/intake/queue.mjs +9 -16
  31. package/lib/logging/rotate.mjs +18 -0
  32. package/lib/mcp/server.mjs +124 -12
  33. package/lib/mcp/tool-budget.mjs +53 -0
  34. package/lib/mcp/tools/storage.mjs +2 -3
  35. package/lib/mcp-catalog.json +4 -4
  36. package/lib/mcp-manager.mjs +59 -3
  37. package/lib/observation-store.mjs +38 -166
  38. package/lib/ollama/capability-store.mjs +78 -0
  39. package/lib/ollama/provision-context.mjs +344 -0
  40. package/lib/opencode-config.mjs +148 -0
  41. package/lib/opencode-telemetry.mjs +7 -0
  42. package/lib/orchestration/runtime.mjs +3 -2
  43. package/lib/orchestration-policy.mjs +41 -6
  44. package/lib/platforms/capabilities.mjs +100 -0
  45. package/lib/prompt-composer.js +12 -8
  46. package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
  47. package/lib/reconcile/index.mjs +0 -2
  48. package/lib/reflect/extractor.mjs +14 -1
  49. package/lib/reflect/salience.mjs +65 -0
  50. package/lib/rules-delivery.mjs +122 -0
  51. package/lib/runtime/uv-bootstrap.mjs +32 -17
  52. package/lib/service-manager.mjs +79 -259
  53. package/lib/setup.mjs +44 -425
  54. package/lib/specialists/prompt-schema.mjs +162 -0
  55. package/lib/specialists/scaffold.mjs +109 -0
  56. package/lib/status.mjs +3 -6
  57. package/lib/storage/admin.mjs +48 -325
  58. package/lib/storage/backend.mjs +10 -57
  59. package/lib/storage/embeddings-engine.mjs +19 -5
  60. package/lib/storage/hybrid-query.mjs +15 -196
  61. package/lib/storage/sync.mjs +36 -177
  62. package/lib/storage/vector-client.mjs +256 -235
  63. package/lib/strategy-store.mjs +35 -286
  64. package/lib/telemetry/beads-fallback.mjs +40 -0
  65. package/lib/telemetry/hook-calls.mjs +138 -0
  66. package/lib/worker/entrypoint.mjs +6 -14
  67. package/package.json +6 -5
  68. package/personas/construct.md +1 -1
  69. package/platforms/capabilities.json +76 -0
  70. package/platforms/claude/settings.template.json +0 -7
  71. package/platforms/opencode/sync-config.mjs +121 -25
  72. package/rules/common/neurodivergent-output.md +1 -1
  73. package/rules/web/coding-style.md +8 -0
  74. package/rules/web/design-quality.md +8 -0
  75. package/rules/web/hooks.md +8 -0
  76. package/rules/web/patterns.md +8 -0
  77. package/rules/web/performance.md +8 -0
  78. package/rules/web/security.md +8 -0
  79. package/rules/web/testing.md +8 -0
  80. package/scripts/sync-specialists.mjs +174 -40
  81. package/specialists/prompts/cx-architect.md +20 -0
  82. package/specialists/prompts/cx-qa.md +1 -1
  83. package/specialists/prompts/cx-test-automation.md +12 -0
  84. package/specialists/registry.json +0 -8
  85. package/templates/docs/construct_guide.md +1 -1
  86. package/db/schema/001_init.sql +0 -40
  87. package/db/schema/002_pgvector.sql +0 -182
  88. package/db/schema/003_intake.sql +0 -47
  89. package/db/schema/003_observation_reconciliation.sql +0 -14
  90. package/db/schema/004_recommendations.sql +0 -46
  91. package/db/schema/005_strategy.sql +0 -21
  92. package/db/schema/006_graph.sql +0 -24
  93. package/db/schema/007_tags.sql +0 -30
  94. package/db/schema/008_skill_usage.sql +0 -24
  95. package/db/schema/009_scheduler.sql +0 -14
  96. package/db/schema/010_cx_scores.sql +0 -51
  97. package/lib/intake/postgres-queue.mjs +0 -240
  98. package/lib/reconcile/postgres-namespace.mjs +0 -102
  99. package/lib/services/local-postgres.mjs +0 -15
  100. package/lib/storage/backup.mjs +0 -347
  101. package/lib/storage/migrations.mjs +0 -187
  102. package/lib/storage/postgres-backup.mjs +0 -124
  103. package/lib/storage/sql-store.mjs +0 -45
  104. package/lib/storage/store-version.mjs +0 -115
  105. package/lib/storage/unified-storage.mjs +0 -550
  106. package/lib/storage/vector-store.mjs +0 -100
@@ -1,59 +1,90 @@
1
1
  /**
2
- * lib/storage/vector-client.mjs — Unified vector storage client for pgvector.
3
- *
4
- * Hides whether we're using local Postgres, managed Postgres, or (future) external vector DB.
5
- * All callers use: store(id, embedding, metadata) and search(queryEmbedding, filters).
6
- *
7
- * Works with any Postgres that has the pgvector extension installed.
8
- * Connection string (DATABASE_URL) determines the target.
9
- *
10
- * Dimensionality is resolved from the embedding engine at construction time
11
- * via `getEmbeddingModelInfo()`. The legacy `PGVECTOR_DIMENSIONS = 384` constant
12
- * is retained as a fallback only when the engine cannot be reached. When
13
- * `assertSchemaDimensions()` runs, the actual `embedding` column declared
14
- * dimension is read from `pg_attribute` and compared against the engine; a
15
- * mismatch surfaces a precise error so the operator can either rerun a
16
- * compatible migration or change `CONSTRUCT_EMBEDDING_MODEL`.
17
- *
18
- * NOTE: `postgres` package is imported lazily to avoid ERR_MODULE_NOT_FOUND
19
- * when this module is loaded in environments where `postgres` is not installed.
2
+ * lib/storage/vector-client.mjs — Unified vector storage client for LanceDB.
20
3
  */
21
4
 
5
+ import path from 'path';
6
+ import fs from 'fs';
22
7
  import { getEmbeddingModelInfo } from './embeddings-engine.mjs';
23
8
 
24
- const DEFAULT_POOL = { max: 10, idle_timeout: 20, connect_timeout: 10 };
25
9
  const FALLBACK_DIMENSIONS = 384;
26
10
 
27
- export const PGVECTOR_DIMENSIONS = FALLBACK_DIMENSIONS;
11
+ function getDbPath(env = process.env) {
12
+ if (env.CONSTRUCT_LANCEDB_PATH) return env.CONSTRUCT_LANCEDB_PATH;
13
+ return path.join(process.cwd(), '.cx', 'lancedb');
14
+ }
15
+
16
+ // LanceDB uses optimistic concurrency: two writes racing the same table fail
17
+ // with "Incompatible transaction ... Overwrite at version N" and two creates
18
+ // race "Table already exists". Call sites build a fresh VectorClient per write
19
+ // (inbox ingest fans out across files), so writes are serialized per database
20
+ // directory at module scope — keyed by path, not by instance — and the queue
21
+ // key is dropped once its chain drains.
28
22
 
29
- export function floatArrayToPgVector(arr) {
30
- if (!arr) return null;
31
- const values = arr instanceof Float32Array ? Array.from(arr) : arr;
32
- return `[${values.join(',')}]`;
23
+ const writeQueues = new Map();
24
+
25
+ function serializeWrite(dbPath, task) {
26
+ const prev = writeQueues.get(dbPath) || Promise.resolve();
27
+ const run = prev.then(task, task);
28
+ const chain = run.then(() => {}, () => {});
29
+ writeQueues.set(dbPath, chain);
30
+ chain.then(() => {
31
+ if (writeQueues.get(dbPath) === chain) writeQueues.delete(dbPath);
32
+ });
33
+ return run;
33
34
  }
34
35
 
35
- export function assertPgvectorDimensions(embedding, { expected = FALLBACK_DIMENSIONS, label = 'embedding' } = {}) {
36
- const length = embedding?.length;
37
- if (length !== expected) {
38
- throw new Error(`${label} has ${length ?? 0} dimensions; pgvector schema expects ${expected}. Configure a ${expected}d embedding model or run a compatible migration before indexing.`);
36
+ // Serialization covers this process; a retry absorbs the residual conflict from
37
+ // a concurrent writer in another process (separate test files) or a create race.
38
+
39
+ async function withWriteRetry(task, { attempts = 6, baseMs = 25 } = {}) {
40
+ let lastErr;
41
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
42
+ try {
43
+ return await task();
44
+ } catch (err) {
45
+ lastErr = err;
46
+ const msg = String(err?.message || err);
47
+ const retriable = /Incompatible transaction|already exists|Commit conflict|conflict_resolver|failed to persist|No such file or directory|LanceError\(IO\)/i.test(msg);
48
+ if (!retriable || attempt === attempts - 1) throw err;
49
+ await new Promise((resolve) => setTimeout(resolve, baseMs * (attempt + 1)));
50
+ }
39
51
  }
52
+ throw lastErr;
40
53
  }
41
54
 
42
55
  export class VectorClient {
43
56
  constructor({ databaseUrl, env } = {}) {
44
- this.url = databaseUrl || process.env.DATABASE_URL || null;
45
57
  this.env = env || process.env;
46
- this._sql = null;
47
- this._pgModule = null;
58
+ this._db = null;
59
+ this._lancedbModule = null;
60
+ this._arrowModule = null;
48
61
  this._engineDim = null;
49
62
  }
50
63
 
51
- /**
52
- * Lazily resolve the active embedding engine's dimensionality. Cached for
53
- * the life of the client. If engine resolution fails, falls back to
54
- * FALLBACK_DIMENSIONS (384) and the caller surfaces the dim-mismatch error
55
- * at insert/search time.
56
- */
64
+ async _initModules() {
65
+ if (this._lancedbModule && this._arrowModule) return;
66
+ try {
67
+ this._lancedbModule = await import('@lancedb/lancedb');
68
+ this._arrowModule = await import('apache-arrow');
69
+ } catch (err) {
70
+ throw new Error(
71
+ `Failed to load LanceDB or Apache Arrow: ${err.message}. ` +
72
+ `Ensure they are installed (npm install @lancedb/lancedb apache-arrow).`
73
+ );
74
+ }
75
+ }
76
+
77
+ async _getDb() {
78
+ if (this._db) return this._db;
79
+ await this._initModules();
80
+ const dbPath = getDbPath(this.env);
81
+ if (!fs.existsSync(dbPath)) {
82
+ fs.mkdirSync(dbPath, { recursive: true });
83
+ }
84
+ this._db = await this._lancedbModule.connect(dbPath);
85
+ return this._db;
86
+ }
87
+
57
88
  async getEngineDimensions() {
58
89
  if (this._engineDim != null) return this._engineDim;
59
90
  try {
@@ -65,237 +96,227 @@ export class VectorClient {
65
96
  return this._engineDim;
66
97
  }
67
98
 
68
- /**
69
- * Read the actual declared dimensionality of `construct_embeddings.embedding`
70
- * from `pg_attribute` and compare against the engine. Throws a precise
71
- * error on mismatch. Returns the matched dimension on success.
72
- */
73
- async assertSchemaDimensions() {
74
- const sql = await this._getSql();
75
- if (!sql) throw new Error('No DATABASE_URL configured; cannot verify pgvector schema');
76
- const engineDim = await this.getEngineDimensions();
77
-
78
- const rows = await sql`
79
- SELECT format_type(atttypid, atttypmod) AS pg_type
80
- FROM pg_attribute
81
- WHERE attrelid = 'construct_embeddings'::regclass
82
- AND attname = 'embedding'
83
- AND NOT attisdropped
84
- `;
85
- const declared = rows?.[0]?.pg_type || '';
86
- const match = declared.match(/vector\((\d+)\)/);
87
- if (!match) {
88
- throw new Error(
89
- `construct_embeddings.embedding column has unexpected type '${declared}'. ` +
90
- `Recreate the column with 'ALTER TABLE construct_embeddings ALTER COLUMN embedding TYPE vector(${engineDim})' ` +
91
- `(truncate first; vectors do not survive a dim change).`
92
- );
93
- }
94
- const schemaDim = Number(match[1]);
95
- if (schemaDim !== engineDim) {
96
- throw new Error(
97
- `pgvector dimension mismatch: schema declares vector(${schemaDim}) but engine model produces ${engineDim}d. ` +
98
- `Either set CONSTRUCT_EMBEDDING_MODEL to a ${schemaDim}d model, or recreate the column with ` +
99
- `'ALTER TABLE construct_embeddings ALTER COLUMN embedding TYPE vector(${engineDim})' (truncate first).`
100
- );
99
+ async _getObservationsTable() {
100
+ const db = await this._getDb();
101
+ const tableName = 'observations_v1';
102
+ const dim = await this.getEngineDimensions();
103
+
104
+ try {
105
+ return await db.openTable(tableName);
106
+ } catch {
107
+ const schema = new this._arrowModule.Schema([
108
+ new this._arrowModule.Field('id', new this._arrowModule.Utf8()),
109
+ new this._arrowModule.Field('project', new this._arrowModule.Utf8(), true),
110
+ new this._arrowModule.Field('role', new this._arrowModule.Utf8(), true),
111
+ new this._arrowModule.Field('category', new this._arrowModule.Utf8(), true),
112
+ new this._arrowModule.Field('summary', new this._arrowModule.Utf8(), true),
113
+ new this._arrowModule.Field('content', new this._arrowModule.Utf8(), true),
114
+ new this._arrowModule.Field('tags', new this._arrowModule.Utf8(), true),
115
+ new this._arrowModule.Field('confidence', new this._arrowModule.Float32(), true),
116
+ new this._arrowModule.Field('source', new this._arrowModule.Utf8(), true),
117
+ new this._arrowModule.Field('git_sha', new this._arrowModule.Utf8(), true),
118
+ new this._arrowModule.Field('embedding', new this._arrowModule.FixedSizeList(dim, new this._arrowModule.Field('item', new this._arrowModule.Float32()))),
119
+ new this._arrowModule.Field('content_hash', new this._arrowModule.Utf8(), true),
120
+ new this._arrowModule.Field('model', new this._arrowModule.Utf8(), true),
121
+ new this._arrowModule.Field('created_at', new this._arrowModule.Utf8(), true),
122
+ new this._arrowModule.Field('updated_at', new this._arrowModule.Utf8(), true),
123
+ ]);
124
+ try {
125
+ return await db.createTable(tableName, [], { schema });
126
+ } catch (err) {
127
+ if (/already exists/i.test(String(err?.message))) return await db.openTable(tableName);
128
+ throw err;
129
+ }
101
130
  }
102
- return schemaDim;
103
131
  }
104
132
 
105
- /**
106
- * Lazily load the `postgres` module and create the connection.
107
- */
108
- async _getSql() {
109
- if (!this.url) return null;
110
- if (this._sql) return this._sql;
133
+ async _getDocumentsTable() {
134
+ const db = await this._getDb();
135
+ const tableName = 'documents_v1';
136
+ const dim = await this.getEngineDimensions();
111
137
 
112
- if (!this._pgModule) {
138
+ try {
139
+ return await db.openTable(tableName);
140
+ } catch {
141
+ const schema = new this._arrowModule.Schema([
142
+ new this._arrowModule.Field('id', new this._arrowModule.Utf8()),
143
+ new this._arrowModule.Field('project', new this._arrowModule.Utf8(), true),
144
+ new this._arrowModule.Field('kind', new this._arrowModule.Utf8(), true),
145
+ new this._arrowModule.Field('title', new this._arrowModule.Utf8(), true),
146
+ new this._arrowModule.Field('summary', new this._arrowModule.Utf8(), true),
147
+ new this._arrowModule.Field('body', new this._arrowModule.Utf8(), true),
148
+ new this._arrowModule.Field('source_path', new this._arrowModule.Utf8(), true),
149
+ new this._arrowModule.Field('tags', new this._arrowModule.Utf8(), true),
150
+ new this._arrowModule.Field('content_hash', new this._arrowModule.Utf8(), true),
151
+ new this._arrowModule.Field('embedding', new this._arrowModule.FixedSizeList(dim, new this._arrowModule.Field('item', new this._arrowModule.Float32()))),
152
+ new this._arrowModule.Field('model', new this._arrowModule.Utf8(), true),
153
+ new this._arrowModule.Field('created_at', new this._arrowModule.Utf8(), true),
154
+ new this._arrowModule.Field('updated_at', new this._arrowModule.Utf8(), true),
155
+ ]);
113
156
  try {
114
- const m = await import('postgres');
115
- this._pgModule = m.default || m;
157
+ return await db.createTable(tableName, [], { schema });
116
158
  } catch (err) {
117
- // Re-throw with a clearer message so callers understand why
118
- throw new Error(
119
- `Failed to load 'postgres' module: ${err.message}. ` +
120
- `Ensure 'postgres' is installed (npm install postgres).`
121
- );
159
+ if (/already exists/i.test(String(err?.message))) return await db.openTable(tableName);
160
+ throw err;
122
161
  }
123
162
  }
124
-
125
- this._sql = this._pgModule(this.url, DEFAULT_POOL);
126
- return this._sql;
127
163
  }
128
164
 
129
- /**
130
- * Check if the SQL backend is available and healthy.
131
- */
132
165
  async isHealthy() {
133
- const sql = await this._getSql();
134
- if (!sql) return false;
135
166
  try {
136
- const result = await sql`SELECT 1 AS health`;
137
- return result?.[0]?.health === 1;
167
+ await this._getDb();
168
+ return true;
138
169
  } catch {
139
170
  return false;
140
171
  }
141
172
  }
142
173
 
143
- /**
144
- * Check if pgvector extension is enabled.
145
- */
146
174
  async isPgvectorEnabled() {
147
- const sql = await this._getSql();
148
- if (!sql) return false;
149
- try {
150
- const result = await sql`
151
- SELECT EXISTS (
152
- SELECT 1 FROM pg_extension WHERE extname = 'vector'
153
- ) AS enabled
154
- `;
155
- return result?.[0]?.enabled === true;
156
- } catch {
157
- return false;
158
- }
175
+ return this.isHealthy();
159
176
  }
160
177
 
161
- /**
162
- * Store an observation with its embedding.
163
- */
164
- async storeObservation({ id, project, role, category, summary, content, tags, confidence, source, embedding, gitSha, contentHash = null, model = null }) {
165
- const sql = await this._getSql();
166
- if (!sql) return { mode: 'file', reason: 'no_sql' };
167
- const expected = await this.getEngineDimensions();
168
- assertPgvectorDimensions(embedding, { expected, label: 'observation embedding' });
169
-
170
- const embeddingVec = floatArrayToPgVector(embedding);
171
- await sql`
172
- INSERT INTO construct_observations (id, project, role, category, summary, content, tags, confidence, source, git_sha, embedding, content_hash, model)
173
- VALUES (${id}, ${project}, ${role}, ${category}, ${summary}, ${content}, ${JSON.stringify(tags || [])}, ${confidence || 0.8}, ${source || null}, ${gitSha || null}, ${embeddingVec}, ${contentHash}, ${model})
174
- ON CONFLICT (id) DO UPDATE SET
175
- summary = EXCLUDED.summary,
176
- content = EXCLUDED.content,
177
- tags = EXCLUDED.tags,
178
- confidence = EXCLUDED.confidence,
179
- source = EXCLUDED.source,
180
- git_sha = EXCLUDED.git_sha,
181
- embedding = EXCLUDED.embedding,
182
- content_hash = EXCLUDED.content_hash,
183
- model = EXCLUDED.model,
184
- updated_at = now()
185
- `;
186
- return { mode: 'sql', id };
178
+ async storeObservation(record) {
179
+ return serializeWrite(getDbPath(this.env), () => withWriteRetry(() => this._storeObservation(record)));
187
180
  }
188
181
 
189
- // Fingerprint = the (content_hash, model) a stored observation was embedded
190
- // with. Reconciliation compares this against the live content + current model
191
- // to find rows needing re-embedding without re-reading every vector.
182
+ async _storeObservation(record) {
183
+ const table = await this._getObservationsTable();
184
+ const now = new Date().toISOString();
185
+
186
+ const data = {
187
+ id: record.id,
188
+ project: record.project || '',
189
+ role: record.role || 'unknown',
190
+ category: record.category || 'insight',
191
+ summary: record.summary || '',
192
+ content: record.content || '',
193
+ tags: JSON.stringify(record.tags || []),
194
+ confidence: Number(record.confidence || 0.8),
195
+ source: typeof record.source === 'object' ? JSON.stringify(record.source) : (record.source || ''),
196
+ git_sha: record.gitSha || '',
197
+ embedding: Array.from(record.embedding),
198
+ content_hash: record.contentHash || '',
199
+ model: record.model || '',
200
+ created_at: record.createdAt || now,
201
+ updated_at: now
202
+ };
203
+
204
+ await table.mergeInsert('id')
205
+ .whenMatchedUpdateAll()
206
+ .whenNotMatchedInsertAll()
207
+ .execute([data]);
208
+
209
+ return { mode: 'lancedb', id: record.id };
210
+ }
192
211
 
193
212
  async getObservationFingerprints(ids = []) {
194
- const sql = await this._getSql();
195
- if (!sql || !Array.isArray(ids) || ids.length === 0) return new Map();
196
- const rows = await sql`
197
- SELECT id, content_hash, model FROM construct_observations WHERE id = ANY(${ids})
198
- `;
199
- return new Map(rows.map((r) => [r.id, { contentHash: r.content_hash, model: r.model }]));
213
+ const table = await this._getObservationsTable();
214
+ if (!Array.isArray(ids) || ids.length === 0) return new Map();
215
+
216
+ const results = await table.query()
217
+ .where(`id IN (${ids.map(id => `'${id}'`).join(',')})`)
218
+ .select(['id', 'content_hash', 'model'])
219
+ .toArray();
220
+
221
+ return new Map(results.map((r) => [r.id, { contentHash: r.content_hash, model: r.model }]));
222
+ }
223
+
224
+ async searchObservations({ project, queryEmbedding, limit = 10, minSimilarity = 0.01, role, category }) {
225
+ const table = await this._getObservationsTable();
226
+
227
+ const query = table.query()
228
+ .nearestTo(Array.from(queryEmbedding))
229
+ .distanceType('cosine');
230
+
231
+ const rawResults = await query.limit(100).toArray();
232
+
233
+ return rawResults.map(r => {
234
+ const distance = r._distance !== undefined ? r._distance : 0;
235
+ let tags = [];
236
+ try {
237
+ tags = (typeof r.tags === 'string' && r.tags.startsWith('[')) ? JSON.parse(r.tags) : (Array.isArray(r.tags) ? r.tags : []);
238
+ } catch {}
239
+
240
+ return {
241
+ ...r,
242
+ similarity: 1 - distance,
243
+ tags
244
+ };
245
+ })
246
+ .filter(r => {
247
+ // Use fuzzy similarity and robust project matching
248
+ if (r.similarity < minSimilarity) return false;
249
+ if (project && r.project !== project) return false;
250
+ if (role && r.role && r.role !== role) return false;
251
+ if (category && r.category && r.category !== category) return false;
252
+ return true;
253
+ })
254
+ .slice(0, limit);
200
255
  }
201
256
 
202
- /**
203
- * Search observations by vector similarity.
204
- */
205
- async searchObservations({ project, queryEmbedding, limit = 10, minSimilarity = 0.3, role, category }) {
206
- const sql = await this._getSql();
207
- if (!sql) return [];
208
- const expected = await this.getEngineDimensions();
209
- assertPgvectorDimensions(queryEmbedding, { expected, label: 'observation query embedding' });
210
-
211
- const queryVec = floatArrayToPgVector(queryEmbedding);
212
- const conditions = [sql`project = ${project}`];
213
- if (role) conditions.push(sql`role = ${role}`);
214
- if (category) conditions.push(sql`category = ${category}`);
215
- conditions.push(sql`1 - (embedding <=> ${queryVec}) > ${minSimilarity}`);
216
-
217
- const results = await sql`
218
- SELECT
219
- id, role, category, summary, content, tags, confidence, source, git_sha, created_at,
220
- 1 - (embedding <=> ${queryVec}) AS similarity
221
- FROM construct_observations
222
- WHERE ${sql.join(conditions, ' AND ')}
223
- ORDER BY embedding <=> ${queryVec}
224
- LIMIT ${limit}
225
- `;
226
- return results;
257
+ async storeDocument(record) {
258
+ return serializeWrite(getDbPath(this.env), () => withWriteRetry(() => this._storeDocument(record)));
227
259
  }
228
260
 
229
- /**
230
- * Store a document with its embedding.
231
- */
232
- async storeDocument({ id, project, kind, title, summary, body, sourcePath, tags, contentHash, embedding, model }) {
233
- const sql = await this._getSql();
234
- if (!sql) return { mode: 'file', reason: 'no_sql' };
235
- const expected = await this.getEngineDimensions();
236
- assertPgvectorDimensions(embedding, { expected, label: 'document embedding' });
237
- if (!model) {
238
- const info = await getEmbeddingModelInfo({ env: this.env });
239
- model = info.model;
240
- }
261
+ async _storeDocument(record) {
262
+ const table = await this._getDocumentsTable();
263
+ const now = new Date().toISOString();
264
+
265
+ const data = {
266
+ id: record.id,
267
+ project: record.project || '',
268
+ kind: record.kind || '',
269
+ title: record.title || '',
270
+ summary: record.summary || '',
271
+ body: record.body || '',
272
+ source_path: record.sourcePath || '',
273
+ tags: JSON.stringify(record.tags || []),
274
+ content_hash: record.contentHash || '',
275
+ embedding: Array.from(record.embedding),
276
+ model: record.model || '',
277
+ created_at: record.createdAt || now,
278
+ updated_at: now
279
+ };
241
280
 
242
- const embeddingVec = floatArrayToPgVector(embedding);
243
- await sql`
244
- INSERT INTO construct_documents (id, project, kind, title, summary, body, source_path, tags, content_hash)
245
- VALUES (${id}, ${project}, ${kind}, ${title}, ${summary || ''}, ${body}, ${sourcePath || ''}, ${JSON.stringify(tags || [])}, ${contentHash || ''})
246
- ON CONFLICT (id) DO UPDATE SET
247
- title = EXCLUDED.title,
248
- summary = EXCLUDED.summary,
249
- body = EXCLUDED.body,
250
- source_path = EXCLUDED.source_path,
251
- tags = EXCLUDED.tags,
252
- content_hash = EXCLUDED.content_hash,
253
- updated_at = now()
254
- `;
255
-
256
- await sql`
257
- INSERT INTO construct_embeddings (document_id, model, embedding, content_hash)
258
- VALUES (${id}, ${model}, ${embeddingVec}, ${contentHash || ''})
259
- ON CONFLICT (document_id) DO UPDATE SET
260
- model = EXCLUDED.model,
261
- embedding = EXCLUDED.embedding,
262
- content_hash = EXCLUDED.content_hash,
263
- updated_at = now()
264
- `;
265
- return { mode: 'sql', id };
281
+ await table.mergeInsert('id')
282
+ .whenMatchedUpdateAll()
283
+ .whenNotMatchedInsertAll()
284
+ .execute([data]);
285
+
286
+ return { mode: 'lancedb', id: record.id };
266
287
  }
267
288
 
268
- /**
269
- * Search documents by vector similarity.
270
- */
271
- async searchDocuments({ project, queryEmbedding, limit = 10, minSimilarity = 0.3 }) {
272
- const sql = await this._getSql();
273
- if (!sql) return [];
274
- const expected = await this.getEngineDimensions();
275
- assertPgvectorDimensions(queryEmbedding, { expected, label: 'document query embedding' });
276
-
277
- const queryVec = floatArrayToPgVector(queryEmbedding);
278
- const results = await sql`
279
- SELECT
280
- d.id, d.title, d.summary, d.body, d.source_path, d.tags, d.kind,
281
- 1 - (e.embedding <=> ${queryVec}) AS similarity
282
- FROM construct_documents d
283
- JOIN construct_embeddings e ON d.id = e.document_id
284
- WHERE d.project = ${project}
285
- AND 1 - (e.embedding <=> ${queryVec}) > ${minSimilarity}
286
- ORDER BY e.embedding <=> ${queryVec}
287
- LIMIT ${limit}
288
- `;
289
- return results;
289
+ async searchDocuments({ project, queryEmbedding, limit = 10, minSimilarity = 0.01 }) {
290
+ const table = await this._getDocumentsTable();
291
+
292
+ const query = table.query()
293
+ .nearestTo(Array.from(queryEmbedding))
294
+ .distanceType('cosine');
295
+
296
+ const rawResults = await query.limit(100).toArray();
297
+
298
+ return rawResults.map(r => {
299
+ const distance = r._distance !== undefined ? r._distance : 0;
300
+ let tags = [];
301
+ try {
302
+ tags = (typeof r.tags === 'string' && r.tags.startsWith('[')) ? JSON.parse(r.tags) : (Array.isArray(r.tags) ? r.tags : []);
303
+ } catch {}
304
+
305
+ return {
306
+ ...r,
307
+ similarity: 1 - distance,
308
+ tags
309
+ };
310
+ })
311
+ .filter(r => {
312
+ if (r.similarity < minSimilarity) return false;
313
+ if (project && r.project !== project) return false;
314
+ return true;
315
+ })
316
+ .slice(0, limit);
290
317
  }
291
318
 
292
- /**
293
- * Close the connection pool.
294
- */
295
319
  async close() {
296
- if (this._sql) {
297
- await this._sql.end();
298
- this._sql = null;
299
- }
320
+ this._db = null;
300
321
  }
301
322
  }