@geraldmaron/construct 1.0.18 → 1.0.20
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 +8 -4
- package/bin/construct +105 -3
- package/db/schema/003_observation_reconciliation.sql +14 -0
- package/lib/bootstrap/resources.mjs +0 -1
- package/lib/cli-commands.mjs +57 -6
- package/lib/comment-lint.mjs +44 -0
- package/lib/config/schema.mjs +31 -1
- package/lib/contracts/validate.mjs +106 -0
- package/lib/decisions/enforced-baseline.json +25 -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/document-ingest.mjs +71 -6
- 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/embedded-contract/capability.mjs +3 -3
- package/lib/embedded-contract/contract-version.mjs +1 -1
- package/lib/embedded-contract/execution.mjs +184 -0
- package/lib/embedded-contract/index.mjs +16 -0
- package/lib/embedded-contract/ingest.mjs +61 -7
- package/lib/gates-audit.mjs +2 -2
- package/lib/hooks/_lib/output-mode.mjs +101 -0
- package/lib/hooks/config-protection.mjs +22 -3
- package/lib/hooks/guard-bash.mjs +1 -1
- package/lib/hooks/session-start.mjs +13 -1
- package/lib/ingest/provider-extract.mjs +200 -0
- package/lib/ingest/strategy.mjs +95 -0
- package/lib/init-docs.mjs +1 -0
- package/lib/mcp/server.mjs +72 -4
- package/lib/mcp/tools/embedded-contract.mjs +17 -1
- package/lib/mode-commands.mjs +6 -8
- package/lib/observation-store.mjs +16 -2
- package/lib/opencode-telemetry.mjs +1 -1
- package/lib/orchestration/run-store.mjs +82 -0
- package/lib/orchestration/runtime.mjs +240 -0
- 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 +10 -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,198 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* lib/embed/jobs/vector-sync.mjs — Automatic embedding pipeline for observations.
|
|
3
|
-
*
|
|
4
|
-
* Runs on a schedule (default: every 5 minutes) to embed new/modified observations
|
|
5
|
-
* and store them in Postgres (if available) and/or the local vector index.
|
|
6
|
-
*
|
|
7
|
-
* Design:
|
|
8
|
-
* - Scans .cx/observations/ for items not yet embedded
|
|
9
|
-
* - Embeds each using the configured model (local ONNX, OpenAI, Ollama)
|
|
10
|
-
* - Stores in Postgres construct_observations table (if DATABASE_URL set)
|
|
11
|
-
* - Falls back to local vectors.json if Postgres unavailable
|
|
12
|
-
* - Batched processing to avoid overwhelming the embedding API
|
|
13
|
-
* - Deduplicated via content hash comparison
|
|
14
|
-
*/
|
|
15
|
-
import { existsSync, readFileSync, writeFileSync, statSync, mkdirSync } from 'node:fs';
|
|
16
|
-
import { join, dirname } from 'node:path';
|
|
17
|
-
import { homedir } from 'node:os';
|
|
18
|
-
|
|
19
|
-
const DEFAULT_BATCH_SIZE = 32;
|
|
20
|
-
const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Find observations that haven't been embedded yet.
|
|
24
|
-
* @param {string} rootDir
|
|
25
|
-
* @returns {Array<{ id: string, project: string, role: string, category: string, summary: string, content: string, tags: string[], confidence: number, source: string, filePath: string }>}
|
|
26
|
-
*/
|
|
27
|
-
function findUnembeddedObservations(rootDir) {
|
|
28
|
-
const obsDir = join(rootDir, '.cx', 'observations');
|
|
29
|
-
if (!existsSync(obsDir)) return [];
|
|
30
|
-
|
|
31
|
-
const indexPath = join(obsDir, 'index.json');
|
|
32
|
-
if (!existsSync(indexPath)) return [];
|
|
33
|
-
|
|
34
|
-
let index;
|
|
35
|
-
try {
|
|
36
|
-
index = JSON.parse(readFileSync(indexPath, 'utf8'));
|
|
37
|
-
} catch (err) {
|
|
38
|
-
process.stderr.write('[jobs/vector-sync.mjs] findUnembedded: ' + (err?.message ?? String(err)) + '\n');
|
|
39
|
-
return [];
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Check which observations have been embedded
|
|
43
|
-
const vectorsPath = join(obsDir, 'vectors.json');
|
|
44
|
-
const embeddedIds = new Set();
|
|
45
|
-
if (existsSync(vectorsPath)) {
|
|
46
|
-
try {
|
|
47
|
-
const vectors = JSON.parse(readFileSync(vectorsPath, 'utf8'));
|
|
48
|
-
for (const v of (vectors.records || vectors)) {
|
|
49
|
-
if (v.id) embeddedIds.add(v.id);
|
|
50
|
-
}
|
|
51
|
-
} catch { /* ignore */ }
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Also check Postgres if available
|
|
55
|
-
// (This is handled by the caller via VectorClient.isHealthy())
|
|
56
|
-
|
|
57
|
-
return (index || [])
|
|
58
|
-
.filter((obs) => obs.id && !embeddedIds.has(obs.id))
|
|
59
|
-
.map((obs) => ({
|
|
60
|
-
...obs,
|
|
61
|
-
filePath: join(obsDir, `${obs.id}.json`),
|
|
62
|
-
}))
|
|
63
|
-
.filter((obs) => existsSync(obs.filePath));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Run the vector sync job.
|
|
68
|
-
* @param {object} opts
|
|
69
|
-
* @param {string} opts.rootDir - Root directory for observation store
|
|
70
|
-
* @param {object} opts.env - Environment variables
|
|
71
|
-
* @param {object} opts.logger - Optional logger with debug/info/error methods
|
|
72
|
-
* @param {object} opts.vectorClient - Optional VectorClient instance for SQL storage
|
|
73
|
-
* @returns {Promise<{ synced: number, errors: number, durationMs: number }>}
|
|
74
|
-
*/
|
|
75
|
-
export async function runVectorSync({ rootDir, env = process.env, logger, vectorClient } = {}) {
|
|
76
|
-
const startTime = Date.now();
|
|
77
|
-
const batchSize = parseInt(env.CONSTRUCT_EMBEDDING_BATCH_SIZE, 10) || DEFAULT_BATCH_SIZE;
|
|
78
|
-
|
|
79
|
-
try {
|
|
80
|
-
const unembedded = findUnembeddedObservations(rootDir);
|
|
81
|
-
if (!unembedded.length) {
|
|
82
|
-
logger?.debug?.('Vector sync: no new observations to embed');
|
|
83
|
-
return { synced: 0, errors: 0, durationMs: Date.now() - startTime };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
let synced = 0;
|
|
87
|
-
let errors = 0;
|
|
88
|
-
|
|
89
|
-
// Dynamically import embedding engine
|
|
90
|
-
const { embedBatch } = await import('../../storage/embeddings-engine.mjs');
|
|
91
|
-
|
|
92
|
-
for (let i = 0; i < unembedded.length; i += batchSize) {
|
|
93
|
-
const batch = unembedded.slice(i, i + batchSize);
|
|
94
|
-
const texts = batch.map((o) => `${o.summary}\n${o.content}`);
|
|
95
|
-
|
|
96
|
-
try {
|
|
97
|
-
const embeddings = await embedBatch(texts, { env });
|
|
98
|
-
|
|
99
|
-
for (let j = 0; j < batch.length; j++) {
|
|
100
|
-
const obs = batch[j];
|
|
101
|
-
const embedding = embeddings[j].embedding;
|
|
102
|
-
|
|
103
|
-
// Store in Postgres if available
|
|
104
|
-
if (vectorClient && await vectorClient.isHealthy()) {
|
|
105
|
-
try {
|
|
106
|
-
await vectorClient.storeObservation({
|
|
107
|
-
id: obs.id,
|
|
108
|
-
project: obs.project || rootDir.split('/').pop(),
|
|
109
|
-
role: obs.role,
|
|
110
|
-
category: obs.category,
|
|
111
|
-
summary: obs.summary,
|
|
112
|
-
content: obs.content,
|
|
113
|
-
tags: obs.tags || [],
|
|
114
|
-
confidence: obs.confidence ?? 0.8,
|
|
115
|
-
source: obs.source,
|
|
116
|
-
embedding,
|
|
117
|
-
});
|
|
118
|
-
} catch (err) {
|
|
119
|
-
logger?.error?.(`Vector sync: failed to store observation ${obs.id} in SQL: ${err.message}`);
|
|
120
|
-
errors++;
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Always update local vectors.json
|
|
126
|
-
updateLocalVectorIndex(rootDir, obs, embedding);
|
|
127
|
-
synced++;
|
|
128
|
-
}
|
|
129
|
-
} catch (err) {
|
|
130
|
-
logger?.error?.(`Vector sync: batch embedding failed: ${err.message}`);
|
|
131
|
-
errors += batch.length;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const durationMs = Date.now() - startTime;
|
|
136
|
-
logger?.info?.(`Vector sync: embedded ${synced} observation(s) in ${durationMs}ms (${errors} errors)`);
|
|
137
|
-
return { synced, errors, durationMs };
|
|
138
|
-
} catch (err) {
|
|
139
|
-
const durationMs = Date.now() - startTime;
|
|
140
|
-
logger?.error?.(`Vector sync failed: ${err.message}`);
|
|
141
|
-
return { synced: 0, errors: 1, durationMs };
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Update the local vectors.json index with a new embedding.
|
|
147
|
-
* @param {string} rootDir
|
|
148
|
-
* @param {object} obs - Observation record
|
|
149
|
-
* @param {Float32Array} embedding
|
|
150
|
-
*/
|
|
151
|
-
function updateLocalVectorIndex(rootDir, obs, embedding) {
|
|
152
|
-
const obsDir = join(rootDir, '.cx', 'observations');
|
|
153
|
-
const vectorsPath = join(obsDir, 'vectors.json');
|
|
154
|
-
|
|
155
|
-
let vectors;
|
|
156
|
-
if (existsSync(vectorsPath)) {
|
|
157
|
-
try {
|
|
158
|
-
vectors = JSON.parse(readFileSync(vectorsPath, 'utf8'));
|
|
159
|
-
} catch (err) {
|
|
160
|
-
process.stderr.write('[jobs/vector-sync.mjs] updateLocalVectorIndex: ' + (err?.message ?? String(err)) + '\n');
|
|
161
|
-
vectors = { version: 1, model: 'hashing-bow-v1', updatedAt: null, records: [] };
|
|
162
|
-
}
|
|
163
|
-
} else {
|
|
164
|
-
vectors = { version: 1, model: 'hashing-bow-v1', updatedAt: null, records: [] };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Add or update the record
|
|
168
|
-
const existingIdx = vectors.records.findIndex((r) => r.id === obs.id);
|
|
169
|
-
const record = {
|
|
170
|
-
id: obs.id,
|
|
171
|
-
embedding: Array.from(embedding),
|
|
172
|
-
summary: obs.summary,
|
|
173
|
-
content: obs.content,
|
|
174
|
-
tags: obs.tags || [],
|
|
175
|
-
project: obs.project,
|
|
176
|
-
role: obs.role,
|
|
177
|
-
category: obs.category,
|
|
178
|
-
updatedAt: new Date().toISOString(),
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
if (existingIdx >= 0) {
|
|
182
|
-
vectors.records[existingIdx] = record;
|
|
183
|
-
} else {
|
|
184
|
-
vectors.records.push(record);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Cap at 1000 records (sliding window)
|
|
188
|
-
if (vectors.records.length > 1000) {
|
|
189
|
-
vectors.records = vectors.records.slice(-1000);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
vectors.updatedAt = new Date().toISOString();
|
|
193
|
-
|
|
194
|
-
mkdirSync(dirname(vectorsPath), { recursive: true });
|
|
195
|
-
writeFileSync(vectorsPath, JSON.stringify(vectors, null, 2), 'utf8');
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
export { DEFAULT_BATCH_SIZE, DEFAULT_INTERVAL_MS };
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* lib/knowledge/postgres-search.mjs — Tag-aware vector search via pgvector + GIN.
|
|
3
|
-
*
|
|
4
|
-
* Runs when DATABASE_URL is configured. Chooses between two execution paths
|
|
5
|
-
* based on tag selectivity:
|
|
6
|
-
*
|
|
7
|
-
* - High selectivity (tag matches <5% of corpus): GIN prefilter first, then
|
|
8
|
-
* HNSW ANN on the filtered set.
|
|
9
|
-
* - Low selectivity (tag matches ≥5% of corpus): HNSW ANN first with
|
|
10
|
-
* hnsw.iterative_scan=relaxed_order, postfilter by tag.
|
|
11
|
-
*
|
|
12
|
-
* Falls back gracefully when DATABASE_URL is absent or the pgvector extension
|
|
13
|
-
* is not installed; callers should use knowledgeSearch() from search.mjs in
|
|
14
|
-
* that case.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
// Selectivity threshold: if the tagged fraction exceeds this, use postfilter.
|
|
18
|
-
const HIGH_SELECTIVITY_THRESHOLD = 0.05;
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Execute a tag-filtered vector search against Postgres + pgvector.
|
|
22
|
-
*
|
|
23
|
-
* @param {object} opts
|
|
24
|
-
* @param {Float32Array|number[]} opts.queryEmbedding — query vector
|
|
25
|
-
* @param {string} opts.project — project id (construct_documents.project)
|
|
26
|
-
* @param {string[]} [opts.tags] — tag filter
|
|
27
|
-
* @param {'any'|'all'} [opts.tagMatch] — 'any' (default) or 'all'
|
|
28
|
-
* @param {number} [opts.topK] — result limit (default 20)
|
|
29
|
-
* @param {object} opts.db — postgres client (from lib/db.mjs or postgres pkg)
|
|
30
|
-
* @returns {Promise<Array<{id, title, score}>>}
|
|
31
|
-
*/
|
|
32
|
-
export async function postgresTagSearch({ queryEmbedding, project, tags = [], tagMatch = 'any', topK = 20, db }) {
|
|
33
|
-
if (!db) throw new Error('postgres-search: db client required');
|
|
34
|
-
if (!queryEmbedding?.length) throw new Error('postgres-search: queryEmbedding required');
|
|
35
|
-
|
|
36
|
-
const vecLiteral = `[${Array.from(queryEmbedding).join(',')}]`;
|
|
37
|
-
|
|
38
|
-
if (!tags.length) {
|
|
39
|
-
// No tag filter — plain ANN.
|
|
40
|
-
const rows = await db`
|
|
41
|
-
select d.id, d.title, e.embedding <=> ${vecLiteral}::vector as distance
|
|
42
|
-
from construct_embeddings e
|
|
43
|
-
join construct_documents d on d.id = e.document_id
|
|
44
|
-
where d.project = ${project}
|
|
45
|
-
order by e.embedding <=> ${vecLiteral}::vector
|
|
46
|
-
limit ${topK}
|
|
47
|
-
`;
|
|
48
|
-
return rows.map((r) => ({ id: r.id, title: r.title, score: 1 - parseFloat(r.distance) }));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Estimate selectivity for this tag set.
|
|
52
|
-
const tagJsonb = JSON.stringify(tags);
|
|
53
|
-
const [{ total }] = await db`select count(*) as total from construct_documents where project = ${project}`;
|
|
54
|
-
const totalCount = parseInt(total, 10) || 1;
|
|
55
|
-
|
|
56
|
-
let taggedCount;
|
|
57
|
-
if (tagMatch === 'all') {
|
|
58
|
-
const [{ cnt }] = await db`
|
|
59
|
-
select count(*) as cnt from construct_documents
|
|
60
|
-
where project = ${project} and tags @> ${tagJsonb}::jsonb
|
|
61
|
-
`;
|
|
62
|
-
taggedCount = parseInt(cnt, 10);
|
|
63
|
-
} else {
|
|
64
|
-
// 'any' — count docs with at least one matching tag.
|
|
65
|
-
const [{ cnt }] = await db`
|
|
66
|
-
select count(*) as cnt from construct_documents
|
|
67
|
-
where project = ${project}
|
|
68
|
-
and (${db.unsafe(tags.map((_, i) => `tags ? $${i + 2}`).join(' or '))})
|
|
69
|
-
`;
|
|
70
|
-
taggedCount = parseInt(cnt, 10);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const selectivity = taggedCount / totalCount;
|
|
74
|
-
|
|
75
|
-
if (selectivity < HIGH_SELECTIVITY_THRESHOLD) {
|
|
76
|
-
// High selectivity: GIN prefilter → ANN on filtered set.
|
|
77
|
-
if (tagMatch === 'all') {
|
|
78
|
-
const rows = await db`
|
|
79
|
-
with filtered as (
|
|
80
|
-
select id from construct_documents
|
|
81
|
-
where project = ${project} and tags @> ${tagJsonb}::jsonb
|
|
82
|
-
)
|
|
83
|
-
select d.id, d.title, e.embedding <=> ${vecLiteral}::vector as distance
|
|
84
|
-
from construct_embeddings e
|
|
85
|
-
join filtered f on f.id = e.document_id
|
|
86
|
-
join construct_documents d on d.id = e.document_id
|
|
87
|
-
order by e.embedding <=> ${vecLiteral}::vector
|
|
88
|
-
limit ${topK}
|
|
89
|
-
`;
|
|
90
|
-
return rows.map((r) => ({ id: r.id, title: r.title, score: 1 - parseFloat(r.distance) }));
|
|
91
|
-
}
|
|
92
|
-
// 'any' — OR-expand tag list.
|
|
93
|
-
const tagConditions = tags.map((t) => `tags ? '${t.replace(/'/g, "''")}'`).join(' or ');
|
|
94
|
-
const rows = await db.unsafe(`
|
|
95
|
-
with filtered as (
|
|
96
|
-
select id from construct_documents
|
|
97
|
-
where project = $1 and (${tagConditions})
|
|
98
|
-
)
|
|
99
|
-
select d.id, d.title, e.embedding <=> $2::vector as distance
|
|
100
|
-
from construct_embeddings e
|
|
101
|
-
join filtered f on f.id = e.document_id
|
|
102
|
-
join construct_documents d on d.id = e.document_id
|
|
103
|
-
order by e.embedding <=> $2::vector
|
|
104
|
-
limit $3
|
|
105
|
-
`, [project, vecLiteral, topK]);
|
|
106
|
-
return rows.map((r) => ({ id: r.id, title: r.title, score: 1 - parseFloat(r.distance) }));
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Low selectivity: HNSW iterative scan → postfilter.
|
|
110
|
-
await db`set hnsw.iterative_scan = relaxed_order`;
|
|
111
|
-
if (tagMatch === 'all') {
|
|
112
|
-
const rows = await db`
|
|
113
|
-
select d.id, d.title, e.embedding <=> ${vecLiteral}::vector as distance
|
|
114
|
-
from construct_embeddings e
|
|
115
|
-
join construct_documents d on d.id = e.document_id
|
|
116
|
-
where d.project = ${project} and d.tags @> ${tagJsonb}::jsonb
|
|
117
|
-
order by e.embedding <=> ${vecLiteral}::vector
|
|
118
|
-
limit ${topK}
|
|
119
|
-
`;
|
|
120
|
-
return rows.map((r) => ({ id: r.id, title: r.title, score: 1 - parseFloat(r.distance) }));
|
|
121
|
-
}
|
|
122
|
-
const tagConditions = tags.map((t) => `d.tags ? '${t.replace(/'/g, "''")}'`).join(' or ');
|
|
123
|
-
const rows = await db.unsafe(`
|
|
124
|
-
select d.id, d.title, e.embedding <=> $1::vector as distance
|
|
125
|
-
from construct_embeddings e
|
|
126
|
-
join construct_documents d on d.id = e.document_id
|
|
127
|
-
where d.project = $2 and (${tagConditions})
|
|
128
|
-
order by e.embedding <=> $1::vector
|
|
129
|
-
limit $3
|
|
130
|
-
`, [vecLiteral, project, topK]);
|
|
131
|
-
return rows.map((r) => ({ id: r.id, title: r.title, score: 1 - parseFloat(r.distance) }));
|
|
132
|
-
}
|
|
@@ -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
|
-
}
|