@geraldmaron/construct 1.0.23 → 1.0.24

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 (52) hide show
  1. package/README.md +0 -2
  2. package/bin/construct +15 -215
  3. package/lib/embed/inbox.mjs +6 -3
  4. package/lib/embed/recommendation-store.mjs +7 -289
  5. package/lib/embed/reconcile.mjs +2 -2
  6. package/lib/hooks/config-protection.mjs +4 -4
  7. package/lib/hooks/session-reflect.mjs +5 -1
  8. package/lib/intake/git-queue.mjs +195 -0
  9. package/lib/intake/queue.mjs +9 -16
  10. package/lib/mcp/tools/storage.mjs +2 -3
  11. package/lib/mcp-catalog.json +3 -3
  12. package/lib/mcp-manager.mjs +59 -3
  13. package/lib/observation-store.mjs +38 -166
  14. package/lib/orchestration/runtime.mjs +3 -2
  15. package/lib/reconcile/index.mjs +0 -2
  16. package/lib/service-manager.mjs +38 -256
  17. package/lib/setup.mjs +26 -426
  18. package/lib/status.mjs +3 -6
  19. package/lib/storage/admin.mjs +48 -325
  20. package/lib/storage/backend.mjs +10 -57
  21. package/lib/storage/hybrid-query.mjs +15 -196
  22. package/lib/storage/sync.mjs +36 -177
  23. package/lib/storage/vector-client.mjs +256 -235
  24. package/lib/strategy-store.mjs +35 -286
  25. package/lib/worker/entrypoint.mjs +6 -14
  26. package/package.json +6 -5
  27. package/platforms/claude/settings.template.json +0 -7
  28. package/scripts/sync-specialists.mjs +46 -12
  29. package/specialists/prompts/cx-qa.md +1 -1
  30. package/specialists/registry.json +0 -8
  31. package/templates/docs/construct_guide.md +1 -1
  32. package/db/schema/001_init.sql +0 -40
  33. package/db/schema/002_pgvector.sql +0 -182
  34. package/db/schema/003_intake.sql +0 -47
  35. package/db/schema/003_observation_reconciliation.sql +0 -14
  36. package/db/schema/004_recommendations.sql +0 -46
  37. package/db/schema/005_strategy.sql +0 -21
  38. package/db/schema/006_graph.sql +0 -24
  39. package/db/schema/007_tags.sql +0 -30
  40. package/db/schema/008_skill_usage.sql +0 -24
  41. package/db/schema/009_scheduler.sql +0 -14
  42. package/db/schema/010_cx_scores.sql +0 -51
  43. package/lib/intake/postgres-queue.mjs +0 -240
  44. package/lib/reconcile/postgres-namespace.mjs +0 -102
  45. package/lib/services/local-postgres.mjs +0 -15
  46. package/lib/storage/backup.mjs +0 -347
  47. package/lib/storage/migrations.mjs +0 -187
  48. package/lib/storage/postgres-backup.mjs +0 -124
  49. package/lib/storage/sql-store.mjs +0 -45
  50. package/lib/storage/store-version.mjs +0 -115
  51. package/lib/storage/unified-storage.mjs +0 -550
  52. package/lib/storage/vector-store.mjs +0 -100
@@ -1,202 +1,21 @@
1
- #!/usr/bin/env node
2
1
  /**
3
- * lib/storage/hybrid-query.mjs — hybrid file + SQL + semantic retrieval.
2
+ * lib/storage/hybrid-query.mjs — unified search across knowledge and facts.
3
+ *
4
+ * This is a stub for the new LanceDB-backed search.
4
5
  */
5
- import { loadStateSnapshot, summarizeStateSnapshot } from './state-source.mjs';
6
- import { describeSqlStore } from './sql-store.mjs';
7
- import { describeVectorStore, searchLocalVectorIndex, vectorSearchLocal } from './vector-store.mjs';
8
- import { createSqlClient, closeSqlClient, readVectorConfig } from './backend.mjs';
9
- import { embedText, getEmbeddingModelInfo } from './embeddings-engine.mjs';
10
- import { floatArrayToPgVector } from './vector-client.mjs';
11
- import { reciprocalRankFusion } from './rrf.mjs';
6
+ import { VectorClient } from './vector-client.mjs';
12
7
 
13
- // iterative_scan (pgvector >= 0.8.0) keeps a filtered ANN query from
14
- // under-returning; harmless for the current unfiltered query, and the correct
15
- // default once tag/metadata filters are added to the vector search.
8
+ export async function buildHybridSearchResultsAsync(rootDir, query, { limit = 10, env = process.env } = {}) {
9
+ // 1. Embed query
10
+ const { embedText } = await import('./embeddings-engine.mjs');
11
+ const { embedding } = await embedText(query, { env });
16
12
 
17
- async function supportsIterativeScan(client) {
18
- try {
19
- const [row] = await client`SELECT extversion AS v FROM pg_extension WHERE extname = 'vector'`;
20
- if (!row?.v) return false;
21
- const [major, minor] = String(row.v).split('.').map((n) => parseInt(n, 10));
22
- return major > 0 || (major === 0 && minor >= 8);
23
- } catch {
24
- return false;
25
- }
26
- }
27
-
28
- function collectFileCandidates(snapshot) {
29
- const docs = [];
30
-
31
- if (snapshot.context) {
32
- docs.push({
33
- id: '.cx/context.json',
34
- kind: 'context',
35
- title: 'Context state',
36
- summary: snapshot.context.contextSummary || snapshot.context.summary || null,
37
- body: JSON.stringify(snapshot.context, null, 2),
38
- tags: ['context', 'state', 'cx'],
39
- });
40
- }
41
-
42
- if (snapshot.architecture) {
43
- docs.push({
44
- id: 'docs/concepts/architecture.md',
45
- kind: 'architecture',
46
- title: 'Architecture docs',
47
- summary: snapshot.architecture.slice(0, 240),
48
- body: snapshot.architecture,
49
- tags: ['architecture', 'docs'],
50
- });
51
- }
52
-
53
- if (snapshot.docsReadme) {
54
- docs.push({
55
- id: 'docs/README.md',
56
- kind: 'docs',
57
- title: 'Docs index',
58
- summary: snapshot.docsReadme.slice(0, 240),
59
- body: snapshot.docsReadme,
60
- tags: ['docs', 'index'],
61
- });
62
- }
63
-
64
- for (const doc of snapshot.productIntelDocs ?? []) {
65
- const kind = doc.path.startsWith('docs/prd/')
66
- ? 'prd'
67
- : doc.path.startsWith('docs/meta-prd/')
68
- ? 'meta-prd'
69
- : 'knowledge';
70
- docs.push({
71
- id: doc.path,
72
- kind,
73
- title: doc.title,
74
- summary: doc.body.slice(0, 240),
75
- body: doc.body,
76
- tags: ['knowledge', kind],
77
- });
78
- }
79
-
80
- return docs;
81
- }
82
-
83
- export function buildHybridSearchResults(rootDir, query, { limit = 10, env = process.env } = {}) {
84
- const snapshot = loadStateSnapshot(rootDir);
85
- const fileCandidates = collectFileCandidates(snapshot);
86
- const sqlStore = describeSqlStore(env);
87
- const vectorStore = describeVectorStore(env);
88
- const fileHits = vectorSearchLocal(fileCandidates, query, { limit });
89
- const localVectorHits = vectorStore.mode === 'local' && vectorStore.indexPath
90
- ? searchLocalVectorIndex(vectorStore.indexPath, query, { limit })
91
- : [];
92
- const merged = [...fileHits];
93
- for (const hit of localVectorHits) {
94
- if (merged.some((entry) => entry.id === hit.id)) continue;
95
- merged.push({
96
- id: hit.id,
97
- kind: hit.kind,
98
- title: hit.title,
99
- summary: hit.summary,
100
- score: hit.score,
101
- source_path: hit.source_path,
102
- });
103
- }
104
-
105
- return {
106
- query,
107
- summary: summarizeStateSnapshot(snapshot),
108
- stores: {
109
- file: { configured: true, mode: 'canonical' },
110
- sql: sqlStore,
111
- vector: vectorStore,
112
- },
113
- results: merged.slice(0, limit).map((hit) => ({
114
- id: hit.id,
115
- kind: hit.kind,
116
- title: hit.title,
117
- summary: hit.summary,
118
- score: hit.score,
119
- })),
120
- };
121
- }
122
-
123
- export async function buildHybridSearchResultsAsync(rootDir, query, { limit = 10, env = process.env, embed = embedText, embeddingModel: modelOverride = null } = {}) {
124
- const base = buildHybridSearchResults(rootDir, query, { limit, env });
125
- const client = createSqlClient(env);
126
- if (!client) return base;
127
-
128
- // Resolve the active embedding model so the SQL filter and the query
129
- // embedding agree on dimensionality and identity.
130
- const embeddingModel = modelOverride || (await getEmbeddingModelInfo({ env })).model;
131
-
132
- try {
133
- const queryVec = floatArrayToPgVector((await embed(query, { env })).embedding);
134
- const iterative = await supportsIterativeScan(client);
135
-
136
- // Native pgvector ANN over the HNSW index (not a JS full scan). When the
137
- // extension supports it, relaxed_order iterative scan runs inside the txn.
138
- const runVector = (sql) => sql`
139
- select d.id, d.kind, d.title, d.summary, d.source_path
140
- from construct_documents d
141
- join construct_embeddings e on e.document_id = d.id
142
- where d.project = 'construct' and e.model = ${embeddingModel}
143
- order by e.embedding <=> ${queryVec}
144
- limit ${limit}
145
- `;
146
- const vectorHits = iterative
147
- ? await client.begin(async (sql) => {
148
- await sql`SET LOCAL hnsw.iterative_scan = relaxed_order`;
149
- return runVector(sql);
150
- })
151
- : await runVector(client);
152
-
153
- const keywordHits = await client`
154
- select id, kind, title, summary, source_path
155
- from construct_documents
156
- where project = 'construct'
157
- and (title ilike ${`%${query}%`} or coalesce(summary, '') ilike ${`%${query}%`} or body ilike ${`%${query}%`})
158
- order by updated_at desc
159
- limit ${limit}
160
- `;
161
-
162
- // One consolidated ranking: file BM25 (base) + neural ANN + keyword, fused
163
- // by Reciprocal Rank Fusion so three incompatible score scales merge by
164
- // rank rather than magnitude.
165
- const lists = [base.results, vectorHits, keywordHits];
166
- const byId = new Map();
167
- for (const list of lists) {
168
- for (const it of list) if (it && !byId.has(it.id)) byId.set(it.id, it);
169
- }
170
- const results = reciprocalRankFusion(lists, { idOf: (x) => x.id, limit }).map(({ id, score }) => {
171
- const it = byId.get(id);
172
- return { id, kind: it.kind, title: it.title, summary: it.summary, score, source_path: it.source_path ?? null };
173
- });
13
+ // 2. Search LanceDB
14
+ const client = new VectorClient({ env });
15
+ const docs = await client.searchDocuments({ project: 'construct', queryEmbedding: embedding, limit });
16
+ const obs = await client.searchObservations({ project: 'construct', queryEmbedding: embedding, limit });
174
17
 
175
- return {
176
- ...base,
177
- results,
178
- stores: {
179
- ...base.stores,
180
- vector: {
181
- ...base.stores.vector,
182
- ...readVectorConfig(env),
183
- model: embeddingModel,
184
- iterativeScan: iterative,
185
- },
186
- sql: {
187
- ...base.stores.sql,
188
- mode: 'postgres',
189
- configured: true,
190
- sharedReady: true,
191
- },
192
- },
193
- };
194
- } catch (error) {
195
- return {
196
- ...base,
197
- error: error?.message || 'hybrid search failed',
198
- };
199
- } finally {
200
- await closeSqlClient(client);
201
- }
18
+ // 3. Merge and rank (simple merge for now)
19
+ const all = [...docs.map(d => ({ ...d, type: 'document' })), ...obs.map(o => ({ ...o, type: 'observation' }))];
20
+ return all.sort((a, b) => b.similarity - a.similarity).slice(0, limit);
202
21
  }
@@ -1,15 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * lib/storage/sync.mjs — sync file-state artifacts into shared Postgres indices.
3
+ * lib/storage/sync.mjs — sync file-state artifacts into local LanceDB indices.
4
+ *
5
+ * This implementation replaces the Postgres-backed sync with a LanceDB-backed
6
+ * one, matching the new local-first, Git-backed architecture.
4
7
  */
5
8
  import crypto from 'node:crypto';
6
9
  import { loadStateSnapshot } from './state-source.mjs';
7
- import { createSqlClient, closeSqlClient, readVectorConfig } from './backend.mjs';
8
10
  import { embedBatch, getEmbeddingModelInfo } from './embeddings-engine.mjs';
9
- import { writeLocalVectorIndex } from './vector-store.mjs';
10
- import { runMigrations } from './migrations.mjs';
11
- import { isManagedConstructPostgresUrl } from '../service-manager.mjs';
12
- import { floatArrayToPgVector } from './vector-client.mjs';
11
+ import { VectorClient } from './vector-client.mjs';
13
12
 
14
13
  function hashContent(content) {
15
14
  return crypto.createHash('sha256').update(String(content || '')).digest('hex');
@@ -25,7 +24,7 @@ function toDocumentRows(rootDir, snapshot, project = 'construct') {
25
24
  project,
26
25
  kind: 'context',
27
26
  title: 'Context state',
28
- summary: snapshot.context.contextSummary || snapshot.context.summary || null,
27
+ summary: snapshot.context.contextSummary || snapshot.context.summary || '',
29
28
  body,
30
29
  source_path: '.cx/context.json',
31
30
  tags: ['context', 'state', 'cx'],
@@ -87,187 +86,47 @@ function toDocumentRows(rootDir, snapshot, project = 'construct') {
87
86
  return rows;
88
87
  }
89
88
 
90
- /**
91
- * Ensure the hybrid storage schema is current by running every pending
92
- * migration in order. Uses `lib/storage/migrations.mjs` which tracks
93
- * applied state in `construct_schema_migrations` and surfaces drift on
94
- * changed files.
95
- */
96
- export async function ensureHybridSchema(client) {
97
- await runMigrations(client);
98
- }
99
-
100
89
  export async function syncFileStateToSql(rootDir, { env = process.env, project = 'construct' } = {}) {
101
90
  const snapshot = loadStateSnapshot(rootDir);
102
91
  const rows = toDocumentRows(rootDir, snapshot, project);
103
- const vectorConfig = readVectorConfig(env);
104
-
105
- // Resolve the active embedding model once per sync so every record carries
106
- // a consistent model id and dimensionality.
92
+
107
93
  const modelInfo = await getEmbeddingModelInfo({ env });
108
94
  const embeddingModel = modelInfo.model;
109
95
 
110
- // Pre-flight check: verify database is reachable before attempting sync
111
- const probeClient = createSqlClient(env);
112
- if (probeClient) {
113
- try {
114
- await probeClient`SELECT 1`;
115
- } catch (error) {
116
- // Database unreachable - check if it's a managed Postgres that needs startup
117
- const databaseUrl = env.DATABASE_URL || '';
118
- await closeSqlClient(probeClient);
119
-
120
- if (isManagedConstructPostgresUrl(databaseUrl)) {
121
- return {
122
- status: 'degraded',
123
- error: 'Managed Postgres is not running. Run `construct up` to start services, then retry.',
124
- documentsSynced: 0,
125
- embeddingsSynced: 0,
126
- embeddingModel,
127
- vector: vectorConfig,
128
- localVector: { status: 'unavailable', note: 'Sync failed - database offline' },
129
- sql: { status: 'unavailable', error: 'ECONNREFUSED - Postgres not running' },
130
- hint: 'Run `construct up` to start managed Postgres and other runtime services',
131
- };
132
- }
133
-
134
- return {
135
- status: 'degraded',
136
- error: `Database connection failed: ${error.message}`,
137
- documentsSynced: 0,
138
- embeddingsSynced: 0,
139
- embeddingModel,
140
- vector: vectorConfig,
141
- localVector: { status: 'unavailable', note: 'Sync failed - database offline' },
142
- sql: { status: 'unavailable', error: error.message },
143
- };
144
- }
145
- await closeSqlClient(probeClient);
146
- }
147
-
148
- // Batch-embed all documents through the engine. The engine selects the
149
- // configured adapter (local ONNX, openai, ollama, hashing) and returns
150
- // Float32Array embeddings of `modelInfo.dimensions` length.
151
- const embedTexts = rows.map((row) =>
152
- [row.title, row.summary, row.body, row.source_path, row.kind].filter(Boolean).join('\n')
153
- );
154
- const embeddings = embedTexts.length > 0 ? await embedBatch(embedTexts, { env }) : [];
155
-
156
- const localVectorRecords = rows.map((row, i) => ({
157
- id: row.id,
158
- project: row.project,
159
- kind: row.kind,
160
- title: row.title,
161
- summary: row.summary,
162
- body: row.body,
163
- source_path: row.source_path,
164
- tags: row.tags,
165
- content_hash: row.content_hash,
166
- embedding: Array.from(embeddings[i].embedding).map((v) => Number(v)),
167
- updatedAt: new Date().toISOString(),
168
- }));
169
-
170
- let localVector = { status: 'unavailable', note: 'No local vector index configured', recordsSynced: 0 };
171
- if (vectorConfig.indexPath) {
172
- const payload = writeLocalVectorIndex(vectorConfig.indexPath, localVectorRecords, { model: embeddingModel });
173
- localVector = {
174
- status: 'ok',
175
- indexPath: vectorConfig.indexPath,
176
- model: payload.model,
177
- recordsSynced: payload.records.length,
178
- updatedAt: payload.updatedAt,
179
- };
180
- }
181
-
182
- if (!probeClient) {
96
+ const vectorClient = new VectorClient({ env });
97
+
98
+ if (rows.length === 0) {
183
99
  return {
184
- status: localVector.status === 'ok' ? 'ok' : 'unavailable',
185
- note: 'No DATABASE_URL configured',
186
- documentsSynced: rows.length,
187
- embeddingsSynced: localVector.recordsSynced,
100
+ status: 'ok',
101
+ documentsSynced: 0,
102
+ embeddingsSynced: 0,
188
103
  embeddingModel,
189
- vector: vectorConfig,
190
- localVector,
191
- sql: { status: 'unavailable', note: 'No DATABASE_URL configured' },
104
+ backend: 'lancedb'
192
105
  };
193
106
  }
194
107
 
195
- // Create a fresh client for the actual sync operation
196
- const syncClient = createSqlClient(env);
197
- if (!syncClient) {
198
- return {
199
- status: localVector.status === 'ok' ? 'ok' : 'unavailable',
200
- note: 'No DATABASE_URL configured',
201
- documentsSynced: rows.length,
202
- embeddingsSynced: localVector.recordsSynced,
203
- embeddingModel,
204
- vector: vectorConfig,
205
- localVector,
206
- sql: { status: 'unavailable', note: 'No DATABASE_URL configured' },
207
- };
108
+ // Batch-embed all documents
109
+ const embedTexts = rows.map((row) =>
110
+ [row.title, row.summary, row.body, row.source_path, row.kind].filter(Boolean).join('\n')
111
+ );
112
+ const embeddings = await embedBatch(embedTexts, { env });
113
+
114
+ let documentsSynced = 0;
115
+ for (let i = 0; i < rows.length; i++) {
116
+ const row = rows[i];
117
+ await vectorClient.storeDocument({
118
+ ...row,
119
+ embedding: embeddings[i].embedding,
120
+ model: embeddingModel
121
+ });
122
+ documentsSynced += 1;
208
123
  }
209
124
 
210
- try {
211
- await ensureHybridSchema(syncClient);
212
-
213
- let documentsSynced = 0;
214
- let embeddingsSynced = 0;
215
- for (const row of rows) {
216
- await syncClient`
217
- insert into construct_documents (id, project, kind, title, summary, body, source_path, tags, content_hash, updated_at)
218
- values (${row.id}, ${row.project}, ${row.kind}, ${row.title}, ${row.summary}, ${row.body}, ${row.source_path}, ${JSON.stringify(row.tags)}, ${row.content_hash}, now())
219
- on conflict (id) do update set
220
- project = excluded.project,
221
- kind = excluded.kind,
222
- title = excluded.title,
223
- summary = excluded.summary,
224
- body = excluded.body,
225
- source_path = excluded.source_path,
226
- tags = excluded.tags,
227
- content_hash = excluded.content_hash,
228
- updated_at = now()
229
- `;
230
- documentsSynced += 1;
231
-
232
- const embeddingVec = floatArrayToPgVector(localVectorRecords[documentsSynced - 1].embedding);
233
- await syncClient`
234
- insert into construct_embeddings (document_id, model, embedding, content_hash, updated_at)
235
- values (${row.id}, ${embeddingModel}, ${embeddingVec}, ${row.content_hash}, now())
236
- on conflict (document_id) do update set
237
- model = excluded.model,
238
- embedding = excluded.embedding,
239
- content_hash = excluded.content_hash,
240
- updated_at = now()
241
- `;
242
- embeddingsSynced += 1;
243
- }
244
-
245
- await syncClient`
246
- insert into construct_sync_runs (project, source, documents_synced, embeddings_synced, status, note)
247
- values (${project}, ${'file-state'}, ${documentsSynced}, ${0}, ${'ok'}, ${'synced file-state documents'})
248
- `;
249
-
250
- return {
251
- status: 'ok',
252
- documentsSynced,
253
- embeddingsSynced,
254
- embeddingModel,
255
- vector: vectorConfig,
256
- localVector,
257
- sql: { status: 'ok' },
258
- };
259
- } catch (error) {
260
- return {
261
- status: localVector.status === 'ok' ? 'degraded' : 'degraded',
262
- error: error?.message || 'sync failed',
263
- documentsSynced: rows.length,
264
- embeddingsSynced: localVector.recordsSynced,
265
- embeddingModel,
266
- vector: vectorConfig,
267
- localVector,
268
- sql: { status: 'degraded', error: error?.message || 'sync failed' },
269
- };
270
- } finally {
271
- await closeSqlClient(syncClient);
272
- }
125
+ return {
126
+ status: 'ok',
127
+ documentsSynced,
128
+ embeddingsSynced: documentsSynced,
129
+ embeddingModel,
130
+ backend: 'lancedb'
131
+ };
273
132
  }