@geraldmaron/construct 1.0.18 → 1.0.19
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 +6 -4
- package/bin/construct +26 -3
- package/db/schema/003_observation_reconciliation.sql +14 -0
- package/lib/bootstrap/resources.mjs +0 -1
- package/lib/cli-commands.mjs +33 -5
- package/lib/comment-lint.mjs +44 -0
- package/lib/contracts/validate.mjs +106 -0
- package/lib/decisions/enforced-baseline.json +23 -0
- package/lib/decisions/golden.mjs +87 -0
- package/lib/decisions/precedence.mjs +46 -0
- package/lib/decisions/registry.mjs +469 -0
- package/lib/deployment/parity-contract.mjs +148 -0
- package/lib/embed/cli.mjs +11 -0
- package/lib/embed/conflict-detection.mjs +4 -4
- package/lib/embed/customer-profiles.mjs +1 -1
- package/lib/embed/reconcile.mjs +60 -0
- package/lib/gates-audit.mjs +2 -2
- package/lib/hooks/config-protection.mjs +22 -3
- package/lib/hooks/guard-bash.mjs +1 -1
- package/lib/init-docs.mjs +1 -0
- package/lib/mode-commands.mjs +6 -8
- package/lib/observation-store.mjs +16 -2
- package/lib/opencode-telemetry.mjs +1 -1
- package/lib/roles/cli.mjs +10 -2
- package/lib/roles/gateway.mjs +50 -1
- package/lib/scheduler/index.mjs +31 -0
- package/lib/server/index.mjs +13 -3
- package/lib/server/static/index.html +1 -1
- package/lib/setup.mjs +6 -0
- package/lib/storage/hybrid-query.mjs +49 -38
- package/lib/storage/rrf.mjs +42 -0
- package/lib/storage/vector-client.mjs +18 -3
- package/lib/telemetry/backends/local.mjs +1 -1
- package/lib/telemetry/skill-calls.mjs +1 -1
- package/lib/templates/visual-requirements.mjs +84 -0
- package/package.json +9 -1
- package/rules/common/comments.md +3 -0
- package/rules/common/no-fabrication.md +3 -0
- package/rules/common/precedence.md +19 -0
- package/rules/common/research-sources.md +32 -0
- package/rules/common/research.md +59 -2
- package/skills/roles/data-engineer.pipeline.md +13 -1
- package/skills/roles/debugger.md +9 -0
- package/skills/roles/designer.accessibility.md +13 -3
- package/skills/roles/designer.md +10 -0
- package/skills/roles/engineer.platform.md +8 -0
- package/skills/roles/operator.md +10 -1
- package/skills/roles/operator.release.md +8 -0
- package/skills/roles/operator.sre.md +10 -1
- package/skills/roles/orchestrator.md +9 -2
- package/skills/roles/product-manager.business-strategy.md +10 -1
- package/skills/roles/researcher.explorer.md +12 -1
- package/skills/roles/researcher.ux.md +13 -1
- package/skills/roles/reviewer.devil-advocate.md +14 -2
- package/skills/roles/reviewer.evaluator.md +17 -4
- package/skills/roles/reviewer.trace.md +12 -1
- package/skills/roles/security.legal-compliance.md +8 -0
- package/skills/roles/security.md +11 -0
- package/specialists/contracts.json +18 -0
- package/specialists/prompts/cx-researcher.md +4 -2
- package/templates/docs/backlog-proposal.md +1 -1
- package/templates/docs/customer-profile.md +1 -1
- package/templates/docs/evidence-brief.md +5 -1
- package/templates/docs/incident-report.md +37 -21
- package/templates/docs/prfaq.md +2 -2
- package/templates/docs/product-intelligence-report.md +1 -1
- package/templates/docs/research-brief.md +8 -6
- package/templates/docs/research-finding.md +32 -7
- package/templates/docs/rfc.md +13 -1
- package/templates/docs/runbook.md +20 -1
- package/templates/docs/signal-brief.md +4 -1
- package/templates/docs/skill-artifact.md +27 -7
- package/templates/docs/strategy.md +23 -2
- package/lib/bootstrap/lazy-install.mjs +0 -161
- package/lib/embed/jobs/vector-sync.mjs +0 -198
- package/lib/knowledge/postgres-search.mjs +0 -132
- package/lib/services/pattern-promotion-service.mjs +0 -167
- package/lib/storage/unified-storage.mjs +0 -550
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* lib/services/pattern-promotion-service.mjs — Bounded pattern replication engine.
|
|
4
|
-
*
|
|
5
|
-
* Analyzes observations, scores candidate patterns, promotes high-quality ones to permanent
|
|
6
|
-
* learned patterns in role skill files. Prevents unbounded growth with hard limits.
|
|
7
|
-
*
|
|
8
|
-
* Limits:
|
|
9
|
-
* - Max 15 learned patterns per role
|
|
10
|
-
* - Min confidence 0.85, usage 3, score 0.8
|
|
11
|
-
* - Semantic + exact deduplication
|
|
12
|
-
* - Pruning of low-effectiveness patterns
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import fs from 'node:fs';
|
|
16
|
-
import path from 'node:path';
|
|
17
|
-
|
|
18
|
-
const ROOT_DIR = process.cwd();
|
|
19
|
-
const SKILLS_DIR = path.join(ROOT_DIR, 'skills', 'roles');
|
|
20
|
-
const OBS_DIR = path.join(process.cwd(), '.cx', 'observations', 'agent-outcomes');
|
|
21
|
-
|
|
22
|
-
const MAX_PATTERNS = 15;
|
|
23
|
-
const MIN_CONFIDENCE = 0.85;
|
|
24
|
-
const MIN_USAGE = 3;
|
|
25
|
-
const MIN_SCORE = 0.8;
|
|
26
|
-
const MAX_TOKENS_FILE = 2500; // Approx tokens
|
|
27
|
-
|
|
28
|
-
const LEARNED_SECTION_MARKER = '## Learned Patterns';
|
|
29
|
-
|
|
30
|
-
function scorePattern(obs) {
|
|
31
|
-
const confidenceWeight = 0.3;
|
|
32
|
-
const usageWeight = 0.2;
|
|
33
|
-
const successWeight = 0.4;
|
|
34
|
-
const crossProjectWeight = 0.1;
|
|
35
|
-
|
|
36
|
-
const normalizedUsage = Math.min((obs.usage_count || 0) / 10, 1.0);
|
|
37
|
-
const crossProject = (obs.projects && obs.projects.length >= 2) ? 1.0 : 0.5;
|
|
38
|
-
const successRate = obs.success_rate || 0.5;
|
|
39
|
-
|
|
40
|
-
return (
|
|
41
|
-
(obs.confidence || 0) * confidenceWeight +
|
|
42
|
-
normalizedUsage * usageWeight +
|
|
43
|
-
successRate * successWeight +
|
|
44
|
-
crossProject * crossProjectWeight
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function normalizeSummary(summary) {
|
|
49
|
-
return summary.toLowerCase().replace(/[^\w\s]/g, '').trim();
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function isDuplicate(newObs, existingPatterns) {
|
|
53
|
-
const newNorm = normalizeSummary(newObs.summary);
|
|
54
|
-
for (const pat of existingPatterns) {
|
|
55
|
-
const patNorm = normalizeSummary(pat.summary);
|
|
56
|
-
if (newNorm.includes(patNorm.slice(0, Math.min(50, patNorm.length))) ||
|
|
57
|
-
patNorm.includes(newNorm.slice(0, Math.min(50, newNorm.length)))) {
|
|
58
|
-
return true;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return false;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function parseLearnedPatterns(content) {
|
|
65
|
-
const match = content.match(new RegExp(`${LEARNED_SECTION_MARKER}[\\s\\S]*?(?=## |$)`));
|
|
66
|
-
if (!match) return [];
|
|
67
|
-
|
|
68
|
-
const section = match[0];
|
|
69
|
-
return section.split('\n\n').slice(1).filter(Boolean).map(block => {
|
|
70
|
-
const lines = block.split('\n');
|
|
71
|
-
return {
|
|
72
|
-
summary: lines[0].replace(/^###\s*L\d+\.\s*/, ''),
|
|
73
|
-
content: block
|
|
74
|
-
};
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function generatePatternEntry(obs, index) {
|
|
79
|
-
return `### L${index}. ${obs.summary}
|
|
80
|
-
**Context**: ${obs.context || 'General'}
|
|
81
|
-
**Effective Action**: ${obs.content.slice(0, 120)}...
|
|
82
|
-
**Evidence**: Score ${(obs.score || 0).toFixed(2)}, used ${(obs.usage_count || 0)} times, ${(obs.projects || []).length} projects
|
|
83
|
-
*Last reinforced: ${new Date().toISOString().split('T')[0]}*
|
|
84
|
-
|
|
85
|
-
`;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function updateSkillFile(roleName, candidate) {
|
|
89
|
-
const skillFile = path.join(SKILLS_DIR, `${roleName}.md`);
|
|
90
|
-
if (!fs.existsSync(skillFile)) {
|
|
91
|
-
console.warn(`Skill file not found: ${skillFile}`);
|
|
92
|
-
return false;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
let content = fs.readFileSync(skillFile, 'utf8');
|
|
96
|
-
|
|
97
|
-
// Check token limit (rough char/4)
|
|
98
|
-
if (content.length > MAX_TOKENS_FILE * 4) {
|
|
99
|
-
console.warn(`Skill file too large: ${skillFile}`);
|
|
100
|
-
return false;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const existingPatterns = parseLearnedPatterns(content);
|
|
104
|
-
if (existingPatterns.length >= MAX_PATTERNS || isDuplicate(candidate, existingPatterns)) {
|
|
105
|
-
console.log(`Skipped ${candidate.summary} (dupe or limit reached)`);
|
|
106
|
-
return false;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const entry = generatePatternEntry(candidate, existingPatterns.length + 1);
|
|
110
|
-
let newContent;
|
|
111
|
-
|
|
112
|
-
const sectionExists = content.includes(LEARNED_SECTION_MARKER);
|
|
113
|
-
if (sectionExists) {
|
|
114
|
-
const markerIndex = content.indexOf(LEARNED_SECTION_MARKER);
|
|
115
|
-
const afterMarker = content.slice(markerIndex);
|
|
116
|
-
const endMatch = afterMarker.match(/\n\n## /) || afterMarker.match(/$/);
|
|
117
|
-
const insertPos = markerIndex + afterMarker.indexOf(endMatch[0]);
|
|
118
|
-
newContent = content.slice(0, insertPos) + '\n\n' + entry + content.slice(insertPos);
|
|
119
|
-
} else {
|
|
120
|
-
newContent = content + '\n\n' + LEARNED_SECTION_MARKER + '\n\n' + entry;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
fs.writeFileSync(skillFile, newContent);
|
|
124
|
-
console.log(`Added pattern to ${roleName}.md: ${candidate.summary}`);
|
|
125
|
-
return true;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function getCandidates() {
|
|
129
|
-
if (!fs.existsSync(OBS_DIR)) return [];
|
|
130
|
-
|
|
131
|
-
const files = fs.readdirSync(OBS_DIR).filter(f => f.endsWith('.json'));
|
|
132
|
-
const candidates = [];
|
|
133
|
-
|
|
134
|
-
for (const file of files) {
|
|
135
|
-
try {
|
|
136
|
-
const obs = JSON.parse(fs.readFileSync(path.join(OBS_DIR, file), 'utf8'));
|
|
137
|
-
if (obs.category === 'pattern' &&
|
|
138
|
-
(obs.confidence || 0) >= MIN_CONFIDENCE &&
|
|
139
|
-
(obs.usage_count || 0) >= MIN_USAGE) {
|
|
140
|
-
obs.score = scorePattern(obs);
|
|
141
|
-
if (obs.score >= MIN_SCORE) {
|
|
142
|
-
candidates.push(obs);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
} catch (e) {
|
|
146
|
-
// Skip invalid
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
return candidates.sort((a, b) => b.score - a.score).slice(0, 5); // Top 5
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export function promotePatterns() {
|
|
154
|
-
const candidates = getCandidates();
|
|
155
|
-
let promoted = 0;
|
|
156
|
-
|
|
157
|
-
for (const candidate of candidates) {
|
|
158
|
-
const roleName = candidate.role.replace(/^cx-/, '') || 'engineer'; // Fallback
|
|
159
|
-
if (updateSkillFile(roleName, candidate)) promoted++;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
console.log(`Pattern promotion complete: ${promoted}/${candidates.length} promoted`);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
166
|
-
promotePatterns();
|
|
167
|
-
}
|
|
@@ -1,550 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* lib/storage/unified-storage.mjs — Single storage abstraction with strong consistency.
|
|
3
|
-
*
|
|
4
|
-
* Addresses storage sync fragility by:
|
|
5
|
-
* 1. Single unified interface for all storage operations
|
|
6
|
-
* 2. Transaction-based writes (atomic, rollback on failure)
|
|
7
|
-
* 3. Automatic backend selection (file/SQL/vector) based on capabilities
|
|
8
|
-
* 4. Consistent error handling and retry logic
|
|
9
|
-
* 5. No manual sync between backends - single source of truth
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import fs from 'node:fs';
|
|
13
|
-
import path from 'node:path';
|
|
14
|
-
import { homedir } from 'node:os';
|
|
15
|
-
import { createSqlClient, closeSqlClient } from './backend.mjs';
|
|
16
|
-
import { loadStateSnapshot } from './state-source.mjs';
|
|
17
|
-
import { embedBatch, getEmbeddingModelInfo } from './embeddings-engine.mjs';
|
|
18
|
-
import { writeLocalVectorIndex, readLocalVectorIndex } from './vector-store.mjs';
|
|
19
|
-
import { runMigrations } from './migrations.mjs';
|
|
20
|
-
|
|
21
|
-
const CX_DIR = path.join(homedir(), '.cx');
|
|
22
|
-
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
// Storage backend abstraction
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
|
|
27
|
-
class StorageBackend {
|
|
28
|
-
constructor(name, capabilities) {
|
|
29
|
-
this.name = name;
|
|
30
|
-
this.capabilities = capabilities; // 'read', 'write', 'query', 'vector'
|
|
31
|
-
this.healthy = false;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
async healthCheck() { return { healthy: false, error: 'Not implemented' }; }
|
|
35
|
-
async read(id) { throw new Error('Not implemented'); }
|
|
36
|
-
async write(id, data) { throw new Error('Not implemented'); }
|
|
37
|
-
async query(criteria) { throw new Error('Not implemented'); }
|
|
38
|
-
async vectorSearch(embedding, options) { throw new Error('Not implemented'); }
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
class FileBackend extends StorageBackend {
|
|
42
|
-
constructor(basePath) {
|
|
43
|
-
super('file', ['read', 'write']);
|
|
44
|
-
this.basePath = basePath;
|
|
45
|
-
fs.mkdirSync(basePath, { recursive: true });
|
|
46
|
-
this.healthy = true;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async healthCheck() {
|
|
50
|
-
try {
|
|
51
|
-
fs.accessSync(this.basePath, fs.constants.W_OK);
|
|
52
|
-
return { healthy: true };
|
|
53
|
-
} catch (error) {
|
|
54
|
-
return { healthy: false, error: error.message };
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
async read(id) {
|
|
59
|
-
const filePath = path.join(this.basePath, `${id}.json`);
|
|
60
|
-
if (!fs.existsSync(filePath)) return null;
|
|
61
|
-
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async write(id, data) {
|
|
65
|
-
const filePath = path.join(this.basePath, `${id}.json`);
|
|
66
|
-
const tempPath = `${filePath}.tmp`;
|
|
67
|
-
|
|
68
|
-
// Atomic write: write to temp, then rename
|
|
69
|
-
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2));
|
|
70
|
-
fs.renameSync(tempPath, filePath);
|
|
71
|
-
|
|
72
|
-
return { written: true, path: filePath };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async query({ project, kind }) {
|
|
76
|
-
const results = [];
|
|
77
|
-
const files = fs.readdirSync(this.basePath).filter(f => f.endsWith('.json'));
|
|
78
|
-
|
|
79
|
-
for (const file of files) {
|
|
80
|
-
try {
|
|
81
|
-
const data = JSON.parse(fs.readFileSync(path.join(this.basePath, file), 'utf8'));
|
|
82
|
-
if ((!project || data.project === project) && (!kind || data.kind === kind)) {
|
|
83
|
-
results.push(data);
|
|
84
|
-
}
|
|
85
|
-
} catch { /* skip invalid files */ }
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
return results;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
class SqlBackend extends StorageBackend {
|
|
93
|
-
constructor(env) {
|
|
94
|
-
super('sql', ['read', 'write', 'query', 'vector']);
|
|
95
|
-
this.env = env;
|
|
96
|
-
this.client = null;
|
|
97
|
-
this.healthy = false;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async connect() {
|
|
101
|
-
if (this.client) return this.client;
|
|
102
|
-
this.client = createSqlClient(this.env);
|
|
103
|
-
if (this.client) {
|
|
104
|
-
await runMigrations(this.client);
|
|
105
|
-
this.healthy = true;
|
|
106
|
-
}
|
|
107
|
-
return this.client;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
async healthCheck() {
|
|
111
|
-
try {
|
|
112
|
-
const client = await this.connect();
|
|
113
|
-
if (!client) return { healthy: false, error: 'No DATABASE_URL configured' };
|
|
114
|
-
|
|
115
|
-
await client`SELECT 1`;
|
|
116
|
-
return { healthy: true };
|
|
117
|
-
} catch (error) {
|
|
118
|
-
this.healthy = false;
|
|
119
|
-
return { healthy: false, error: error.message };
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
async read(id) {
|
|
124
|
-
const client = await this.connect();
|
|
125
|
-
if (!client) return null;
|
|
126
|
-
|
|
127
|
-
const rows = await client`
|
|
128
|
-
SELECT * FROM construct_documents
|
|
129
|
-
WHERE id = ${id}
|
|
130
|
-
LIMIT 1
|
|
131
|
-
`;
|
|
132
|
-
|
|
133
|
-
return rows[0] || null;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async write(id, data) {
|
|
137
|
-
const client = await this.connect();
|
|
138
|
-
if (!client) throw new Error('SQL backend not available');
|
|
139
|
-
|
|
140
|
-
// Use upsert for atomic write
|
|
141
|
-
await client`
|
|
142
|
-
INSERT INTO construct_documents (
|
|
143
|
-
id, project, kind, title, summary, body,
|
|
144
|
-
source_path, tags, content_hash, embedding, updated_at
|
|
145
|
-
) VALUES (
|
|
146
|
-
${id}, ${data.project}, ${data.kind}, ${data.title},
|
|
147
|
-
${data.summary}, ${data.body}, ${data.source_path},
|
|
148
|
-
${data.tags}, ${data.content_hash}, ${data.embedding},
|
|
149
|
-
NOW()
|
|
150
|
-
)
|
|
151
|
-
ON CONFLICT (id) DO UPDATE SET
|
|
152
|
-
project = EXCLUDED.project,
|
|
153
|
-
kind = EXCLUDED.kind,
|
|
154
|
-
title = EXCLUDED.title,
|
|
155
|
-
summary = EXCLUDED.summary,
|
|
156
|
-
body = EXCLUDED.body,
|
|
157
|
-
source_path = EXCLUDED.source_path,
|
|
158
|
-
tags = EXCLUDED.tags,
|
|
159
|
-
content_hash = EXCLUDED.content_hash,
|
|
160
|
-
embedding = EXCLUDED.embedding,
|
|
161
|
-
updated_at = NOW()
|
|
162
|
-
`;
|
|
163
|
-
|
|
164
|
-
return { written: true, id };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
async query({ project, kind, limit = 100 }) {
|
|
168
|
-
const client = await this.connect();
|
|
169
|
-
if (!client) return [];
|
|
170
|
-
|
|
171
|
-
let query = client`SELECT * FROM construct_documents WHERE 1=1`;
|
|
172
|
-
|
|
173
|
-
if (project) {
|
|
174
|
-
query = client`${query} AND project = ${project}`;
|
|
175
|
-
}
|
|
176
|
-
if (kind) {
|
|
177
|
-
query = client`${query} AND kind = ${kind}`;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
query = client`${query} ORDER BY updated_at DESC LIMIT ${limit}`;
|
|
181
|
-
|
|
182
|
-
return await query;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
async vectorSearch(embedding, { project, limit = 10 }) {
|
|
186
|
-
const client = await this.connect();
|
|
187
|
-
if (!client) return [];
|
|
188
|
-
|
|
189
|
-
const embeddingStr = `[${embedding.join(',')}]`;
|
|
190
|
-
|
|
191
|
-
return await client`
|
|
192
|
-
SELECT *, embedding <-> ${embeddingStr}::vector as distance
|
|
193
|
-
FROM construct_documents
|
|
194
|
-
WHERE project = ${project}
|
|
195
|
-
ORDER BY embedding <-> ${embeddingStr}::vector
|
|
196
|
-
LIMIT ${limit}
|
|
197
|
-
`;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
class VectorBackend extends StorageBackend {
|
|
202
|
-
constructor(indexPath) {
|
|
203
|
-
super('vector', ['read', 'query', 'vector']);
|
|
204
|
-
this.indexPath = indexPath;
|
|
205
|
-
this.healthy = !!indexPath;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
async healthCheck() {
|
|
209
|
-
if (!this.indexPath) {
|
|
210
|
-
return { healthy: false, error: 'No index path configured' };
|
|
211
|
-
}
|
|
212
|
-
try {
|
|
213
|
-
fs.accessSync(path.dirname(this.indexPath), fs.constants.W_OK);
|
|
214
|
-
return { healthy: true };
|
|
215
|
-
} catch (error) {
|
|
216
|
-
return { healthy: false, error: error.message };
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
async read(id) {
|
|
221
|
-
const index = readLocalVectorIndex(this.indexPath);
|
|
222
|
-
return index.find(item => item.id === id) || null;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
async write(id, data) {
|
|
226
|
-
// Vector backend is read-only - writes go through SQL backend
|
|
227
|
-
throw new Error('Vector backend is read-only. Use SQL backend for writes.');
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
async vectorSearch(embedding, options) {
|
|
231
|
-
const index = readLocalVectorIndex(this.indexPath);
|
|
232
|
-
const { limit = 10 } = options;
|
|
233
|
-
|
|
234
|
-
// Simple cosine similarity
|
|
235
|
-
const results = index.map(item => ({
|
|
236
|
-
...item,
|
|
237
|
-
similarity: cosineSimilarity(embedding, item.embedding),
|
|
238
|
-
}));
|
|
239
|
-
|
|
240
|
-
results.sort((a, b) => b.similarity - a.similarity);
|
|
241
|
-
return results.slice(0, limit);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function cosineSimilarity(a, b) {
|
|
246
|
-
let dotProduct = 0;
|
|
247
|
-
let normA = 0;
|
|
248
|
-
let normB = 0;
|
|
249
|
-
|
|
250
|
-
for (let i = 0; i < a.length; i++) {
|
|
251
|
-
dotProduct += a[i] * b[i];
|
|
252
|
-
normA += a[i] * a[i];
|
|
253
|
-
normB += b[i] * b[i];
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// ---------------------------------------------------------------------------
|
|
260
|
-
// Unified storage manager
|
|
261
|
-
// ---------------------------------------------------------------------------
|
|
262
|
-
|
|
263
|
-
export class UnifiedStorage {
|
|
264
|
-
constructor(options = {}) {
|
|
265
|
-
this.env = options.env || process.env;
|
|
266
|
-
this.project = options.project || 'construct';
|
|
267
|
-
this.backends = new Map();
|
|
268
|
-
this.primaryBackend = null;
|
|
269
|
-
this.cache = new Map();
|
|
270
|
-
|
|
271
|
-
this.initializeBackends();
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
initializeBackends() {
|
|
275
|
-
const cxDir = path.join(homedir(), '.cx');
|
|
276
|
-
|
|
277
|
-
// Always have file backend as fallback
|
|
278
|
-
const fileBackend = new FileBackend(path.join(cxDir, 'storage', 'documents'));
|
|
279
|
-
this.backends.set('file', fileBackend);
|
|
280
|
-
|
|
281
|
-
// SQL backend if configured
|
|
282
|
-
if (this.env.DATABASE_URL) {
|
|
283
|
-
const sqlBackend = new SqlBackend(this.env);
|
|
284
|
-
this.backends.set('sql', sqlBackend);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// Vector backend if configured
|
|
288
|
-
const vectorPath = this.env.CONSTRUCT_VECTOR_INDEX_PATH || path.join(cxDir, 'vector-index.json');
|
|
289
|
-
if (vectorPath) {
|
|
290
|
-
const vectorBackend = new VectorBackend(vectorPath);
|
|
291
|
-
this.backends.set('vector', vectorBackend);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Determine primary backend (prefer SQL, fallback to file)
|
|
295
|
-
this.primaryBackend = this.backends.get('sql') || this.backends.get('file');
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
async healthCheck() {
|
|
299
|
-
const results = {};
|
|
300
|
-
|
|
301
|
-
for (const [name, backend] of this.backends) {
|
|
302
|
-
results[name] = await backend.healthCheck();
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const healthy = Object.values(results).some(r => r.healthy);
|
|
306
|
-
const primaryHealthy = this.primaryBackend ? (await this.primaryBackend.healthCheck()).healthy : false;
|
|
307
|
-
|
|
308
|
-
return {
|
|
309
|
-
overall: healthy ? (primaryHealthy ? 'healthy' : 'degraded') : 'unavailable',
|
|
310
|
-
primary: this.primaryBackend?.name,
|
|
311
|
-
backends: results,
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Store a document with atomic consistency.
|
|
317
|
-
* Writes to primary backend only - no sync needed.
|
|
318
|
-
*/
|
|
319
|
-
async storeDocument(id, document) {
|
|
320
|
-
if (!this.primaryBackend) {
|
|
321
|
-
throw new Error('No storage backend available');
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const enriched = {
|
|
325
|
-
...document,
|
|
326
|
-
project: this.project,
|
|
327
|
-
storedAt: new Date().toISOString(),
|
|
328
|
-
};
|
|
329
|
-
|
|
330
|
-
try {
|
|
331
|
-
const result = await this.primaryBackend.write(id, enriched);
|
|
332
|
-
|
|
333
|
-
// Invalidate cache
|
|
334
|
-
this.cache.delete(id);
|
|
335
|
-
|
|
336
|
-
return {
|
|
337
|
-
success: true,
|
|
338
|
-
id,
|
|
339
|
-
backend: this.primaryBackend.name,
|
|
340
|
-
...result,
|
|
341
|
-
};
|
|
342
|
-
} catch (error) {
|
|
343
|
-
return {
|
|
344
|
-
success: false,
|
|
345
|
-
id,
|
|
346
|
-
error: error.message,
|
|
347
|
-
};
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/**
|
|
352
|
-
* Retrieve a document by ID.
|
|
353
|
-
* Checks cache first, then primary backend.
|
|
354
|
-
*/
|
|
355
|
-
async retrieveDocument(id) {
|
|
356
|
-
// Check cache
|
|
357
|
-
if (this.cache.has(id)) {
|
|
358
|
-
return this.cache.get(id);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
if (!this.primaryBackend) {
|
|
362
|
-
return null;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
const doc = await this.primaryBackend.read(id);
|
|
366
|
-
|
|
367
|
-
if (doc) {
|
|
368
|
-
this.cache.set(id, doc);
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
return doc;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Query documents with filters.
|
|
376
|
-
*/
|
|
377
|
-
async queryDocuments(criteria) {
|
|
378
|
-
if (!this.primaryBackend) {
|
|
379
|
-
return [];
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
return await this.primaryBackend.query({
|
|
383
|
-
...criteria,
|
|
384
|
-
project: this.project,
|
|
385
|
-
});
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/**
|
|
389
|
-
* Semantic search using vector similarity.
|
|
390
|
-
* Tries SQL backend first (most accurate), falls back to local vector index.
|
|
391
|
-
*/
|
|
392
|
-
async semanticSearch(query, options = {}) {
|
|
393
|
-
const { limit = 10, threshold = 0.7 } = options;
|
|
394
|
-
|
|
395
|
-
// Generate embedding for query
|
|
396
|
-
const modelInfo = await getEmbeddingModelInfo({ env: this.env });
|
|
397
|
-
const embeddings = await embedBatch([query], { env: this.env });
|
|
398
|
-
const queryEmbedding = embeddings[0]?.embedding;
|
|
399
|
-
|
|
400
|
-
if (!queryEmbedding) {
|
|
401
|
-
throw new Error('Failed to generate query embedding');
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// Try SQL backend first
|
|
405
|
-
const sqlBackend = this.backends.get('sql');
|
|
406
|
-
if (sqlBackend && (await sqlBackend.healthCheck()).healthy) {
|
|
407
|
-
const results = await sqlBackend.vectorSearch(queryEmbedding, {
|
|
408
|
-
project: this.project,
|
|
409
|
-
limit
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
return results
|
|
413
|
-
.filter(r => 1 - r.distance >= threshold)
|
|
414
|
-
.map(r => ({
|
|
415
|
-
...r,
|
|
416
|
-
similarity: 1 - r.distance,
|
|
417
|
-
}));
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// Fall back to vector backend
|
|
421
|
-
const vectorBackend = this.backends.get('vector');
|
|
422
|
-
if (vectorBackend && (await vectorBackend.healthCheck()).healthy) {
|
|
423
|
-
return await vectorBackend.vectorSearch(queryEmbedding, { limit });
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// Last resort: no vector search available
|
|
427
|
-
return [];
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
/**
|
|
431
|
-
* Sync file state to storage with embedding.
|
|
432
|
-
* Single operation - no separate sync step needed.
|
|
433
|
-
*/
|
|
434
|
-
async syncFromFileState(rootDir, options = {}) {
|
|
435
|
-
const snapshot = loadStateSnapshot(rootDir);
|
|
436
|
-
const modelInfo = await getEmbeddingModelInfo({ env: this.env });
|
|
437
|
-
|
|
438
|
-
const results = {
|
|
439
|
-
processed: 0,
|
|
440
|
-
failed: 0,
|
|
441
|
-
errors: [],
|
|
442
|
-
};
|
|
443
|
-
|
|
444
|
-
// Process each document in the snapshot
|
|
445
|
-
const documents = this.snapshotToDocuments(snapshot, rootDir);
|
|
446
|
-
|
|
447
|
-
// Generate embeddings in batch
|
|
448
|
-
const texts = documents.map(d =>
|
|
449
|
-
[d.title, d.summary, d.body, d.source_path].filter(Boolean).join('\n')
|
|
450
|
-
);
|
|
451
|
-
|
|
452
|
-
const embeddings = texts.length > 0
|
|
453
|
-
? await embedBatch(texts, { env: this.env })
|
|
454
|
-
: [];
|
|
455
|
-
|
|
456
|
-
// Store each document
|
|
457
|
-
for (let i = 0; i < documents.length; i++) {
|
|
458
|
-
const doc = documents[i];
|
|
459
|
-
const embedding = embeddings[i]?.embedding;
|
|
460
|
-
|
|
461
|
-
try {
|
|
462
|
-
await this.storeDocument(doc.id, {
|
|
463
|
-
...doc,
|
|
464
|
-
embedding: embedding ? Array.from(embedding) : null,
|
|
465
|
-
embeddingModel: modelInfo.model,
|
|
466
|
-
});
|
|
467
|
-
results.processed++;
|
|
468
|
-
} catch (error) {
|
|
469
|
-
results.failed++;
|
|
470
|
-
results.errors.push({ id: doc.id, error: error.message });
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
return results;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
snapshotToDocuments(snapshot, rootDir) {
|
|
478
|
-
const docs = [];
|
|
479
|
-
const project = this.project;
|
|
480
|
-
|
|
481
|
-
if (snapshot.context) {
|
|
482
|
-
docs.push({
|
|
483
|
-
id: `${project}:context`,
|
|
484
|
-
kind: 'context',
|
|
485
|
-
title: 'Context state',
|
|
486
|
-
summary: snapshot.context.contextSummary || '',
|
|
487
|
-
body: JSON.stringify(snapshot.context, null, 2),
|
|
488
|
-
source_path: '.cx/context.json',
|
|
489
|
-
tags: ['context', 'state'],
|
|
490
|
-
});
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
if (snapshot.architecture) {
|
|
494
|
-
docs.push({
|
|
495
|
-
id: `${project}:architecture`,
|
|
496
|
-
kind: 'architecture',
|
|
497
|
-
title: 'Architecture docs',
|
|
498
|
-
summary: snapshot.architecture.slice(0, 240),
|
|
499
|
-
body: snapshot.architecture,
|
|
500
|
-
source_path: 'docs/concepts/architecture.md',
|
|
501
|
-
tags: ['architecture', 'docs'],
|
|
502
|
-
});
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// Add product intel docs
|
|
506
|
-
for (const doc of snapshot.productIntelDocs || []) {
|
|
507
|
-
const kind = doc.path.startsWith('docs/prd/') ? 'prd'
|
|
508
|
-
: doc.path.startsWith('docs/meta-prd/') ? 'meta-prd'
|
|
509
|
-
: 'knowledge';
|
|
510
|
-
|
|
511
|
-
docs.push({
|
|
512
|
-
id: `${project}:${doc.path}`,
|
|
513
|
-
kind,
|
|
514
|
-
title: doc.title,
|
|
515
|
-
summary: doc.body.slice(0, 240),
|
|
516
|
-
body: doc.body,
|
|
517
|
-
source_path: doc.path,
|
|
518
|
-
tags: ['knowledge', kind],
|
|
519
|
-
});
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
return docs;
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
// ---------------------------------------------------------------------------
|
|
527
|
-
// Convenience exports
|
|
528
|
-
// ---------------------------------------------------------------------------
|
|
529
|
-
|
|
530
|
-
let globalStorage = null;
|
|
531
|
-
|
|
532
|
-
export function getUnifiedStorage(options = {}) {
|
|
533
|
-
if (!globalStorage) {
|
|
534
|
-
globalStorage = new UnifiedStorage(options);
|
|
535
|
-
}
|
|
536
|
-
return globalStorage;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
export function resetUnifiedStorage() {
|
|
540
|
-
globalStorage = null;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
export async function withStorage(fn, options = {}) {
|
|
544
|
-
const storage = getUnifiedStorage(options);
|
|
545
|
-
try {
|
|
546
|
-
return await fn(storage);
|
|
547
|
-
} finally {
|
|
548
|
-
// Cleanup if needed
|
|
549
|
-
}
|
|
550
|
-
}
|