@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
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/deployment/parity-contract.mjs — capability parity across deployment modes.
|
|
3
|
+
*
|
|
4
|
+
* Declares, for every dimension of lib/deployment-mode.mjs RESOURCE_TOPOLOGY,
|
|
5
|
+
* whether the capability is `parity` (present in every mode, backend may differ)
|
|
6
|
+
* or `mode-specific` (present only in some modes, by design). validateParityContract
|
|
7
|
+
* cross-checks the declaration against the live topology so the two cannot drift:
|
|
8
|
+
* a new topology dimension with no declaration, or a `parity` capability absent in
|
|
9
|
+
* a mode, fails the check. See ADR-0016.
|
|
10
|
+
*
|
|
11
|
+
* Scope is the static guard only. A live dual-run harness (run a capability in
|
|
12
|
+
* solo and team, compare) is the stronger complement, tracked separately — it
|
|
13
|
+
* needs a provisioned database and so cannot gate every change.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { DEPLOYMENT_MODES, resolveResourceMode } from '../deployment-mode.mjs';
|
|
17
|
+
|
|
18
|
+
export const PARITY_CLASSES = ['parity', 'mode-specific'];
|
|
19
|
+
|
|
20
|
+
// Backend value 'optional' means the capability is present but may run on a
|
|
21
|
+
// degraded local backend (e.g. file BM25 in place of pgvector); it still counts
|
|
22
|
+
// as present for parity purposes.
|
|
23
|
+
|
|
24
|
+
export const PARITY_CONTRACT = {
|
|
25
|
+
queue: {
|
|
26
|
+
parityClass: 'parity',
|
|
27
|
+
capability: 'Work intake queue (enqueue, list pending, mark processed)',
|
|
28
|
+
rationale: 'Filesystem queue (solo) and Postgres queue (team) implement one interface via lib/intake/queue.mjs.',
|
|
29
|
+
evidence: 'tests/intake-queue-factory.test.mjs',
|
|
30
|
+
},
|
|
31
|
+
memory: {
|
|
32
|
+
parityClass: 'parity',
|
|
33
|
+
capability: 'Observation/memory store',
|
|
34
|
+
rationale: 'Local index (solo) and shared Postgres (team) both satisfy the observation schema; Postgres is a secondary store.',
|
|
35
|
+
evidence: 'lib/observation-store.mjs',
|
|
36
|
+
},
|
|
37
|
+
database: {
|
|
38
|
+
parityClass: 'parity',
|
|
39
|
+
capability: 'Knowledge retrieval and durable state',
|
|
40
|
+
rationale: 'Present in every mode; solo degrades to file BM25 + local vectors when Postgres is absent.',
|
|
41
|
+
evidence: 'lib/storage/hybrid-query.mjs',
|
|
42
|
+
},
|
|
43
|
+
telemetry: {
|
|
44
|
+
parityClass: 'parity',
|
|
45
|
+
capability: 'Trace capture',
|
|
46
|
+
rationale: 'Local JSONL is the default in every mode; remote export (OTel/Langfuse) is opt-in, not required for the capability.',
|
|
47
|
+
evidence: 'lib/telemetry/client.mjs',
|
|
48
|
+
},
|
|
49
|
+
workers: {
|
|
50
|
+
parityClass: 'mode-specific',
|
|
51
|
+
capability: 'Background worker pool that claims queued work',
|
|
52
|
+
rationale: 'Solo runs work in-process in the user session; the async Docker/isolated worker pool exists only in team and enterprise.',
|
|
53
|
+
evidence: 'lib/deployment-mode.mjs',
|
|
54
|
+
},
|
|
55
|
+
policy: {
|
|
56
|
+
parityClass: 'mode-specific',
|
|
57
|
+
capability: 'Server-side policy enforcement',
|
|
58
|
+
rationale: 'Solo enforces via local hooks (lightweight); server-side/enforceable policy is a team and enterprise capability.',
|
|
59
|
+
evidence: 'lib/deployment-mode.mjs',
|
|
60
|
+
},
|
|
61
|
+
mcp: {
|
|
62
|
+
parityClass: 'mode-specific',
|
|
63
|
+
capability: 'Brokered MCP with policy, audit, and rate limiting',
|
|
64
|
+
rationale: 'Solo calls MCP tools directly; the broker (and its audit/rate-limit/policy) exists only in team and enterprise.',
|
|
65
|
+
evidence: 'lib/mcp/broker.mjs',
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function topologyByDimension() {
|
|
70
|
+
const byMode = {};
|
|
71
|
+
for (const mode of DEPLOYMENT_MODES) byMode[mode] = resolveResourceMode(mode);
|
|
72
|
+
const dimensions = new Set();
|
|
73
|
+
for (const mode of DEPLOYMENT_MODES) for (const dim of Object.keys(byMode[mode])) dimensions.add(dim);
|
|
74
|
+
return { byMode, dimensions: [...dimensions] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Cross-check the declared parity contract against the live resource topology.
|
|
79
|
+
* Returns { ok, errors }. Failures: an undeclared topology dimension, a declared
|
|
80
|
+
* capability with no topology dimension, an invalid parityClass, a `parity`
|
|
81
|
+
* capability missing from some mode, or a `mode-specific` capability whose backend
|
|
82
|
+
* is identical across all modes (then it is not actually mode-specific).
|
|
83
|
+
*/
|
|
84
|
+
export function validateParityContract() {
|
|
85
|
+
const { byMode, dimensions } = topologyByDimension();
|
|
86
|
+
const errors = [];
|
|
87
|
+
|
|
88
|
+
for (const dim of dimensions) {
|
|
89
|
+
if (!PARITY_CONTRACT[dim]) {
|
|
90
|
+
errors.push(`topology dimension "${dim}" has no parity declaration (declare it parity or mode-specific in PARITY_CONTRACT)`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const [dim, decl] of Object.entries(PARITY_CONTRACT)) {
|
|
95
|
+
if (!dimensions.includes(dim)) {
|
|
96
|
+
errors.push(`parity declaration "${dim}" has no matching topology dimension`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (!PARITY_CLASSES.includes(decl.parityClass)) {
|
|
100
|
+
errors.push(`"${dim}": invalid parityClass "${decl.parityClass}"`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const values = DEPLOYMENT_MODES.map((m) => byMode[m][dim]);
|
|
104
|
+
if (decl.parityClass === 'parity') {
|
|
105
|
+
const missing = DEPLOYMENT_MODES.filter((m) => !byMode[m][dim]);
|
|
106
|
+
if (missing.length > 0) {
|
|
107
|
+
errors.push(`"${dim}" is declared parity but absent in mode(s): ${missing.join(', ')}`);
|
|
108
|
+
}
|
|
109
|
+
} else if (new Set(values).size === 1) {
|
|
110
|
+
errors.push(`"${dim}" is declared mode-specific but its backend is identical across all modes (${values[0]})`);
|
|
111
|
+
}
|
|
112
|
+
if (!decl.rationale) errors.push(`"${dim}": mode-specific and parity declarations both require a rationale`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { ok: errors.length === 0, errors };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function describeParityContract() {
|
|
119
|
+
const { byMode } = topologyByDimension();
|
|
120
|
+
const rows = Object.entries(PARITY_CONTRACT).map(([dim, decl]) => {
|
|
121
|
+
const backends = DEPLOYMENT_MODES.map((m) => `${m}:${byMode[m][dim]}`).join(' ');
|
|
122
|
+
return { dimension: dim, parityClass: decl.parityClass, backends, capability: decl.capability, rationale: decl.rationale };
|
|
123
|
+
});
|
|
124
|
+
return rows;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function runDeploymentParityCli(args = []) {
|
|
128
|
+
if (args.includes('--json')) {
|
|
129
|
+
const { ok, errors } = validateParityContract();
|
|
130
|
+
process.stdout.write(JSON.stringify({ ok, errors, contract: describeParityContract() }, null, 2) + '\n');
|
|
131
|
+
if (!ok) process.exit(1);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const { ok, errors } = validateParityContract();
|
|
136
|
+
for (const row of describeParityContract()) {
|
|
137
|
+
const mark = row.parityClass === 'parity' ? '=' : '≠';
|
|
138
|
+
process.stdout.write(`${mark} ${row.dimension.padEnd(10)} [${row.parityClass}] ${row.backends}\n ${row.capability}\n`);
|
|
139
|
+
}
|
|
140
|
+
process.stdout.write('\n');
|
|
141
|
+
if (ok) {
|
|
142
|
+
process.stdout.write('✓ parity contract reconciled with deployment topology\n');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
process.stderr.write(`✗ parity contract has ${errors.length} error(s):\n`);
|
|
146
|
+
for (const e of errors) process.stderr.write(` - ${e}\n`);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
package/lib/document-ingest.mjs
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Reuses the shared extraction backends from lib/document-extract.mjs, writes
|
|
5
5
|
* markdown outputs into retrieval-friendly project paths, and can optionally
|
|
6
|
-
* trigger storage sync for SQL/vector indexing.
|
|
6
|
+
* trigger storage sync for SQL/vector indexing. The extraction step honors a
|
|
7
|
+
* resolved ingest strategy (adapter | provider) with an explicit fallback policy
|
|
8
|
+
* and records the selected strategy/model in the returned `ingestion` block.
|
|
7
9
|
*/
|
|
8
10
|
import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
9
11
|
import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve } from 'node:path';
|
|
@@ -11,6 +13,9 @@ import { extractDocumentText, extractDocumentTextAsync, extractDocumentMetadata,
|
|
|
11
13
|
import { syncFileStateToSql } from './storage/sync.mjs';
|
|
12
14
|
import { stampFrontmatter } from './doc-stamp.mjs';
|
|
13
15
|
import { KNOWLEDGE_ROOT, KNOWLEDGE_SUBDIRS } from './knowledge/layout.mjs';
|
|
16
|
+
import { loadProjectConfig } from './config/project-config.mjs';
|
|
17
|
+
import { resolveIngestStrategy, INGEST_STRATEGIES } from './ingest/strategy.mjs';
|
|
18
|
+
import { extractViaProvider } from './ingest/provider-extract.mjs';
|
|
14
19
|
|
|
15
20
|
const DEFAULT_TARGET_DIR = '.cx/knowledge/internal';
|
|
16
21
|
|
|
@@ -121,6 +126,38 @@ function resolveOutputPath(sourcePath, { cwd, outputPath, outputDir, target }) {
|
|
|
121
126
|
return join(resolvedDir, `${basename(sourcePath)}.md`);
|
|
122
127
|
}
|
|
123
128
|
|
|
129
|
+
function extractViaAdapter(sourcePath, { highFidelity, maxChars }) {
|
|
130
|
+
return highFidelity
|
|
131
|
+
? extractDocumentTextAsync(sourcePath, { maxChars })
|
|
132
|
+
: extractDocumentText(sourcePath, { maxChars });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function extractWithStrategy(sourcePath, { strategy, fallback, model, provider, highFidelity, maxChars, env }) {
|
|
136
|
+
const primary = strategy === 'provider' ? 'provider' : 'adapter';
|
|
137
|
+
const run = (mode) => (mode === 'provider'
|
|
138
|
+
? extractViaProvider({ filePath: sourcePath, model, provider, maxChars, env })
|
|
139
|
+
: extractViaAdapter(sourcePath, { highFidelity, maxChars }));
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const extracted = await run(primary);
|
|
143
|
+
return { extracted, strategyUsed: primary, fallbackApplied: null, error: null };
|
|
144
|
+
} catch (primaryErr) {
|
|
145
|
+
if (fallback === 'none' || fallback === primary) {
|
|
146
|
+
const err = new Error(`ingest ${primary} strategy failed and fallback is "${fallback}": ${primaryErr.message}`);
|
|
147
|
+
err.code = primaryErr.code || 'INGEST_STRATEGY_FAILED';
|
|
148
|
+
err.cause = primaryErr;
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
const extracted = await run(fallback);
|
|
152
|
+
return {
|
|
153
|
+
extracted,
|
|
154
|
+
strategyUsed: fallback,
|
|
155
|
+
fallbackApplied: { from: primary, to: fallback, reason: primaryErr.message, code: primaryErr.code || 'INGEST_STRATEGY_FAILED' },
|
|
156
|
+
error: null,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
124
161
|
export async function ingestDocuments(inputPaths, {
|
|
125
162
|
cwd = process.cwd(),
|
|
126
163
|
outputPath = null,
|
|
@@ -129,6 +166,7 @@ export async function ingestDocuments(inputPaths, {
|
|
|
129
166
|
sync = false,
|
|
130
167
|
strict = false,
|
|
131
168
|
highFidelity = true,
|
|
169
|
+
strategy = null,
|
|
132
170
|
env = process.env,
|
|
133
171
|
} = {}) {
|
|
134
172
|
if (!Array.isArray(inputPaths) || inputPaths.length === 0) {
|
|
@@ -143,12 +181,24 @@ export async function ingestDocuments(inputPaths, {
|
|
|
143
181
|
throw new Error('No supported document files found');
|
|
144
182
|
}
|
|
145
183
|
|
|
184
|
+
const { config } = loadProjectConfig(cwd, env);
|
|
185
|
+
const resolvedStrategy = resolveIngestStrategy({ config, env, override: strategy });
|
|
186
|
+
|
|
146
187
|
const results = [];
|
|
147
188
|
let totalDrops = 0;
|
|
189
|
+
let fallbackApplied = null;
|
|
148
190
|
for (const sourcePath of files) {
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
:
|
|
191
|
+
const routed = await extractWithStrategy(sourcePath, {
|
|
192
|
+
strategy: resolvedStrategy.strategy,
|
|
193
|
+
fallback: resolvedStrategy.fallback,
|
|
194
|
+
model: resolvedStrategy.model,
|
|
195
|
+
provider: resolvedStrategy.provider,
|
|
196
|
+
highFidelity,
|
|
197
|
+
maxChars: 200_000,
|
|
198
|
+
env,
|
|
199
|
+
});
|
|
200
|
+
const extracted = routed.extracted;
|
|
201
|
+
if (routed.fallbackApplied) fallbackApplied = routed.fallbackApplied;
|
|
152
202
|
totalDrops += (extracted.droppedInfo ?? []).reduce((a, d) => a + (Number(d.count) || 1), 0);
|
|
153
203
|
const metadata = extractDocumentMetadata(sourcePath);
|
|
154
204
|
const targetPath = resolveOutputPath(sourcePath, { cwd, outputPath, outputDir, target });
|
|
@@ -213,6 +263,13 @@ export async function ingestDocuments(inputPaths, {
|
|
|
213
263
|
droppedInfo: results.flatMap((r) => r.droppedInfo.map((d) => ({ ...d, sourcePath: r.sourcePath }))),
|
|
214
264
|
droppedCount: totalDrops,
|
|
215
265
|
highFidelity,
|
|
266
|
+
ingestion: {
|
|
267
|
+
strategy: resolvedStrategy.strategy,
|
|
268
|
+
fallback: resolvedStrategy.fallback,
|
|
269
|
+
model: resolvedStrategy.model,
|
|
270
|
+
provider: resolvedStrategy.provider,
|
|
271
|
+
fallbackApplied,
|
|
272
|
+
},
|
|
216
273
|
};
|
|
217
274
|
}
|
|
218
275
|
|
|
@@ -224,26 +281,34 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
|
|
|
224
281
|
let sync = false;
|
|
225
282
|
let strict = false;
|
|
226
283
|
let highFidelity = true;
|
|
284
|
+
let strategy = null;
|
|
227
285
|
|
|
228
286
|
for (const arg of argv) {
|
|
229
287
|
if (arg.startsWith('--out=')) outputPath = arg.split('=').slice(1).join('=');
|
|
230
288
|
else if (arg.startsWith('--out-dir=')) outputDir = arg.split('=').slice(1).join('=');
|
|
231
289
|
else if (arg.startsWith('--target=')) target = arg.split('=').slice(1).join('=');
|
|
290
|
+
else if (arg.startsWith('--strategy=')) strategy = arg.split('=').slice(1).join('=');
|
|
291
|
+
else if (arg === '--strategy') strategy = '';
|
|
232
292
|
else if (arg === '--sync') sync = true;
|
|
233
293
|
else if (arg === '--strict') strict = true;
|
|
234
294
|
else if (arg === '--legacy-extractor') highFidelity = false;
|
|
295
|
+
else if (strategy === '') strategy = arg;
|
|
235
296
|
else inputs.push(arg);
|
|
236
297
|
}
|
|
237
298
|
|
|
238
299
|
if (inputs.length === 0) {
|
|
239
300
|
throw new Error(
|
|
240
301
|
`Usage: construct ingest <file-or-dir> [more paths] [--out=FILE] [--out-dir=DIR] ` +
|
|
241
|
-
`[--target=sibling|knowledge/<subdir>] [--sync] [--strict] [--legacy-extractor]\n` +
|
|
302
|
+
`[--target=sibling|knowledge/<subdir>] [--strategy=adapter|provider] [--sync] [--strict] [--legacy-extractor]\n` +
|
|
303
|
+
` --strategy: extraction strategy override (adapter = local extractors; provider = configured provider/model)\n` +
|
|
242
304
|
` --strict: exit non-zero if any extraction drops occur\n` +
|
|
243
305
|
` --legacy-extractor: use the pre-docling regex extractor (lower fidelity)\n` +
|
|
244
306
|
` knowledge subdirs: ${KNOWLEDGE_SUBDIRS.join(', ')}`,
|
|
245
307
|
);
|
|
246
308
|
}
|
|
309
|
+
if (strategy !== null && strategy !== '' && !INGEST_STRATEGIES.includes(strategy)) {
|
|
310
|
+
throw new Error(`Unsupported strategy: ${strategy}. Valid strategies: ${INGEST_STRATEGIES.join(', ')}`);
|
|
311
|
+
}
|
|
247
312
|
const knowledgeTargets = KNOWLEDGE_SUBDIRS.map((s) => `knowledge/${s}`);
|
|
248
313
|
if (!['sibling', ...knowledgeTargets].includes(target)) {
|
|
249
314
|
throw new Error(
|
|
@@ -251,5 +316,5 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
|
|
|
251
316
|
);
|
|
252
317
|
}
|
|
253
318
|
|
|
254
|
-
return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, sync, strict, highFidelity, env });
|
|
319
|
+
return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, sync, strict, highFidelity, strategy: strategy || null, env });
|
|
255
320
|
}
|
package/lib/embed/cli.mjs
CHANGED
|
@@ -533,6 +533,17 @@ async function cmdEmbedMigrateModel(args) {
|
|
|
533
533
|
process.stdout.write(
|
|
534
534
|
`Done. embeddingsSynced=${result.embeddingsSynced} model=${result.embeddingModel}\n`
|
|
535
535
|
);
|
|
536
|
+
|
|
537
|
+
// Observations share the embedding model/dimension. Re-dimension their
|
|
538
|
+
// column too, then reconcile re-embeds them from the local source so both
|
|
539
|
+
// corpora live in one vector space after a model change.
|
|
540
|
+
await client.unsafe(
|
|
541
|
+
`BEGIN; TRUNCATE TABLE construct_observations; ` +
|
|
542
|
+
`ALTER TABLE construct_observations ALTER COLUMN embedding TYPE vector(${targetDim}); COMMIT;`
|
|
543
|
+
);
|
|
544
|
+
const { reconcileObservationEmbeddings } = await import('./reconcile.mjs');
|
|
545
|
+
const recon = await reconcileObservationEmbeddings(process.cwd());
|
|
546
|
+
process.stdout.write(`Observations reconciled: re-embedded ${recon.reembedded}.\n`);
|
|
536
547
|
} catch (err) {
|
|
537
548
|
process.stderr.write(`migrate-model failed: ${err.message}\n`);
|
|
538
549
|
process.exit(1);
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* Rebuilt on first use or when artifact file count changes.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, renameSync } from 'node:fs';
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, renameSync, unlinkSync } from 'node:fs';
|
|
19
19
|
import { join } from 'node:path';
|
|
20
20
|
import { embed, cosineSimilarity, extractTextFromPacket } from './semantic.mjs';
|
|
21
21
|
import { listArtifacts } from './artifact.mjs';
|
|
@@ -253,9 +253,9 @@ export async function detectInterItemConflicts(pendingPackets, { threshold = 0.7
|
|
|
253
253
|
*/
|
|
254
254
|
export function resetArtifactIndex() {
|
|
255
255
|
_artifactIndex = null;
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
try { unlinkSync(
|
|
256
|
+
const { indexFile } = indexPaths();
|
|
257
|
+
if (existsSync(indexFile)) {
|
|
258
|
+
try { unlinkSync(indexFile); } catch { /* ignore */ }
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
261
|
|
|
@@ -273,7 +273,7 @@ export function searchCustomerProfiles(query) {
|
|
|
273
273
|
id: entry.id,
|
|
274
274
|
name: entry.name,
|
|
275
275
|
status: entry.status,
|
|
276
|
-
path: join(
|
|
276
|
+
path: join(profilesDir, `${slugify(entry.name)}-${entry.id}.md`),
|
|
277
277
|
});
|
|
278
278
|
}
|
|
279
279
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/embed/reconcile.mjs — bring the Postgres observation index back in sync
|
|
3
|
+
* with the local source of truth.
|
|
4
|
+
*
|
|
5
|
+
* Observations are embedded inline on write (lib/observation-store.mjs), but
|
|
6
|
+
* the inline write is skipped whenever Postgres is unreachable at creation time
|
|
7
|
+
* (the local hashing-bow fallback is used instead), and a model change leaves
|
|
8
|
+
* every prior row embedded in a stale vector space. This pass is the safety
|
|
9
|
+
* net: for each local observation it compares the (content_hash, model) the
|
|
10
|
+
* pg row was embedded with against the live content + current model, and
|
|
11
|
+
* re-embeds ONLY the rows that are missing or stale. Idempotent — a second run
|
|
12
|
+
* with no changes re-embeds nothing.
|
|
13
|
+
*
|
|
14
|
+
* Reconciliation-on-sync is preferred over a fixed-interval polling cron (see
|
|
15
|
+
* docs/research/vector-search-best-practices.md): the pass runs where Construct
|
|
16
|
+
* already touches storage (the model-change re-embed, the storage CLI), keys
|
|
17
|
+
* idempotency on a content hash, and re-embeds on model change.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { VectorClient } from '../storage/vector-client.mjs';
|
|
21
|
+
import { embedText as embedTextEngine, getEmbeddingModelInfo } from '../storage/embeddings-engine.mjs';
|
|
22
|
+
import { listObservations, getObservation, observationSearchText, observationContentHash } from '../observation-store.mjs';
|
|
23
|
+
|
|
24
|
+
export async function reconcileObservationEmbeddings(rootDir, { env = process.env, limit = Infinity, embed = embedTextEngine, modelId = null } = {}) {
|
|
25
|
+
const client = new VectorClient({ env });
|
|
26
|
+
const result = { checked: 0, reembedded: 0, model: null, skipped: null };
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
if (!(await client.isHealthy()) || !(await client.isPgvectorEnabled())) {
|
|
30
|
+
result.skipped = 'no-pg';
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const currentModel = modelId || (await getEmbeddingModelInfo({ env })).model;
|
|
35
|
+
result.model = currentModel;
|
|
36
|
+
|
|
37
|
+
const ids = (listObservations(rootDir, { limit: Number.MAX_SAFE_INTEGER }) || []).map((e) => e.id);
|
|
38
|
+
const fingerprints = await client.getObservationFingerprints(ids);
|
|
39
|
+
|
|
40
|
+
for (const id of ids) {
|
|
41
|
+
if (result.reembedded >= limit) break;
|
|
42
|
+
const obs = getObservation(rootDir, id);
|
|
43
|
+
if (!obs) continue;
|
|
44
|
+
result.checked += 1;
|
|
45
|
+
|
|
46
|
+
const searchText = observationSearchText(obs);
|
|
47
|
+
const hash = observationContentHash(searchText);
|
|
48
|
+
const fp = fingerprints.get(id);
|
|
49
|
+
if (fp && fp.contentHash === hash && fp.model === currentModel) continue;
|
|
50
|
+
|
|
51
|
+
const { embedding, model } = await embed(searchText, { env });
|
|
52
|
+
await client.storeObservation({ ...obs, embedding, contentHash: hash, model: model || currentModel });
|
|
53
|
+
result.reembedded += 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return result;
|
|
57
|
+
} finally {
|
|
58
|
+
await client.close();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -32,9 +32,9 @@ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
|
32
32
|
|
|
33
33
|
function buildInterfaces() {
|
|
34
34
|
return [
|
|
35
|
-
{ surface: 'cli', contractVersion: CONTRACT_VERSION, entrypoints: ['construct capability describe --json', 'construct models resolve --json', 'construct intake classify --json', 'construct graph recommend --json', 'construct workflow invoke --json'] },
|
|
36
|
-
{ surface: 'mcp', contractVersion: CONTRACT_VERSION, tools: ['capability_describe', 'model_resolve', 'triage_recommend', 'workflow_invoke'] },
|
|
37
|
-
{ surface: 'sdk', contractVersion: CONTRACT_VERSION, module: 'construct/embedded-contract', exports: ['describeCapabilities', 'resolveEmbeddedModel', 'recommendPlan', 'invokeWorkflow'] },
|
|
35
|
+
{ surface: 'cli', contractVersion: CONTRACT_VERSION, entrypoints: ['construct capability describe --json', 'construct models resolve --json', 'construct execution resolve --json', 'construct intake classify --json', 'construct graph recommend --json', 'construct workflow invoke --json'] },
|
|
36
|
+
{ surface: 'mcp', contractVersion: CONTRACT_VERSION, tools: ['capability_describe', 'model_resolve', 'construct_execution_resolve', 'triage_recommend', 'workflow_invoke'] },
|
|
37
|
+
{ surface: 'sdk', contractVersion: CONTRACT_VERSION, module: 'construct/embedded-contract', exports: ['describeCapabilities', 'resolveEmbeddedModel', 'resolveExecution', 'recommendPlan', 'invokeWorkflow'] },
|
|
38
38
|
];
|
|
39
39
|
}
|
|
40
40
|
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { parseSemver, compareSemver } from '../version.mjs';
|
|
17
17
|
|
|
18
|
-
export const CONTRACT_VERSION = '1.
|
|
18
|
+
export const CONTRACT_VERSION = '1.1.0';
|
|
19
19
|
|
|
20
20
|
// A client built against an older minor of the same major still works, because
|
|
21
21
|
// minor bumps are additive-only; a different major is incompatible either way.
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/embedded-contract/execution.mjs — embedded execution-capability contract.
|
|
3
|
+
*
|
|
4
|
+
* An embedded host can request Construct orchestration but cannot otherwise tell
|
|
5
|
+
* whether a run actually engaged personas/skills/routing or degraded to a
|
|
6
|
+
* prompt-only envelope — and if it degraded, why. This contract answers that
|
|
7
|
+
* before or at workflow start: given a requested strategy and the host's model
|
|
8
|
+
* context, it derives an `executionMode`, the Construct capabilities that mode
|
|
9
|
+
* contributes, and an explicit degradation reason.
|
|
10
|
+
*
|
|
11
|
+
* Honesty boundary (ADR-0019): orchestration in the embedded layer is
|
|
12
|
+
* DESCRIPTIVE, not enforced. Construct returns a plan; the host runtime performs
|
|
13
|
+
* the reasoning. So this contract reports what Construct PLANNED and can RESOLVE
|
|
14
|
+
* a model for — never an observation that the host executed personas. Every
|
|
15
|
+
* response carries a `semantics` disclaimer to that effect, and
|
|
16
|
+
* `constructCapabilitiesActive` describes Construct's contribution, not proof of
|
|
17
|
+
* host execution. Claiming observed orchestration would violate the
|
|
18
|
+
* no-fabrication rule. A `same-family-fallback` (host model unavailable) and a
|
|
19
|
+
* `config-error` (no model) both surface as `degraded: true`.
|
|
20
|
+
*
|
|
21
|
+
* Pure and synchronous; reuses resolveEmbeddedModel (no second model surface)
|
|
22
|
+
* and returns a result object carrying `warnings`, lifted into the envelope by
|
|
23
|
+
* the calling surface.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { getDeploymentMode } from '../deployment-mode.mjs';
|
|
27
|
+
import { resolveEmbeddedModel } from './model-resolve.mjs';
|
|
28
|
+
import { getWorkflowDef } from './workflow-defs.mjs';
|
|
29
|
+
|
|
30
|
+
export const EXECUTION_MODES = ['construct-orchestrated', 'construct-prompt-only', 'host-direct', 'same-family-fallback'];
|
|
31
|
+
export const CONSTRUCT_STRATEGIES = ['orchestrated', 'prompt-only', 'auto'];
|
|
32
|
+
export const CONSTRUCT_CAPABILITIES = ['personas', 'skills', 'workflow-routing', 'prompt-envelope'];
|
|
33
|
+
|
|
34
|
+
export const EXECUTION_SEMANTICS = 'Reports Construct-planned capability and model-resolvability before/at workflow start; does not observe host execution. constructCapabilitiesActive describes what Construct contributes, not proof the host ran personas/skills.';
|
|
35
|
+
|
|
36
|
+
const ORCHESTRATED_CAPS = ['personas', 'skills', 'workflow-routing', 'prompt-envelope'];
|
|
37
|
+
const PROMPT_ONLY_CAPS = ['prompt-envelope'];
|
|
38
|
+
|
|
39
|
+
function normalizeStrategy(value) {
|
|
40
|
+
const lowered = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
41
|
+
return CONSTRUCT_STRATEGIES.includes(lowered) ? lowered : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the execution-capability contract for an embedded workflow.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} request
|
|
48
|
+
* @param {string} [request.workflowType] workflow whose orchestration plan is being weighed
|
|
49
|
+
* @param {string} [request.requestedStrategy] orchestrated | prompt-only | auto (default auto)
|
|
50
|
+
* @param {boolean} [request.useConstruct=true] false ⇒ host-direct (host runs without Construct)
|
|
51
|
+
* @param {string} [request.host]
|
|
52
|
+
* @param {string} [request.hostModel]
|
|
53
|
+
* @param {string} [request.hostProvider]
|
|
54
|
+
* @param {string} [request.requestedTier]
|
|
55
|
+
* @param {string[]} [request.capabilities]
|
|
56
|
+
* @param {boolean} [request.allowCrossProviderFallback=false]
|
|
57
|
+
* @param {object} [opts]
|
|
58
|
+
* @param {Record<string,string>} [opts.env]
|
|
59
|
+
* @param {string} [opts.cwd]
|
|
60
|
+
* @param {string} [opts.registryPath]
|
|
61
|
+
* @returns {object}
|
|
62
|
+
*/
|
|
63
|
+
export function resolveExecution(request = {}, { env = process.env, cwd = process.cwd(), registryPath = null } = {}) {
|
|
64
|
+
const {
|
|
65
|
+
workflowType = null,
|
|
66
|
+
requestedStrategy: rawStrategy,
|
|
67
|
+
useConstruct = true,
|
|
68
|
+
host, hostModel, hostProvider, requestedTier, capabilities, allowCrossProviderFallback = false,
|
|
69
|
+
} = request;
|
|
70
|
+
|
|
71
|
+
const warnings = [];
|
|
72
|
+
const requestedStrategy = normalizeStrategy(rawStrategy) || 'auto';
|
|
73
|
+
if (rawStrategy != null && !normalizeStrategy(rawStrategy)) {
|
|
74
|
+
warnings.push(`Unknown requestedStrategy "${rawStrategy}"; defaulted to "auto".`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const deploymentMode = getDeploymentMode(env, { cwd });
|
|
78
|
+
|
|
79
|
+
// Model resolution is the single source of provider/model truth; its warnings
|
|
80
|
+
// are namespaced so an embedder can tell model concerns from execution ones.
|
|
81
|
+
const modelResolution = resolveEmbeddedModel(
|
|
82
|
+
{ workflowType, requestedTier, host, hostModel, hostProvider, capabilities, allowCrossProviderFallback },
|
|
83
|
+
{ env, registryPath },
|
|
84
|
+
);
|
|
85
|
+
for (const w of modelResolution.warnings || []) warnings.push(`model-resolution: ${w}`);
|
|
86
|
+
|
|
87
|
+
const { resolutionSource, selectedModel, selectedProvider } = modelResolution;
|
|
88
|
+
const orchestrationAvailable = resolutionSource !== 'config-error';
|
|
89
|
+
|
|
90
|
+
// An explicit-but-unknown workflowType has no plan; absent a workflowType,
|
|
91
|
+
// Construct's generic router can still orchestrate, so a plan is available.
|
|
92
|
+
const knownWorkflow = workflowType ? Boolean(getWorkflowDef(workflowType)) : true;
|
|
93
|
+
if (workflowType && !knownWorkflow) {
|
|
94
|
+
warnings.push(`Unknown workflowType "${workflowType}"; no orchestration plan is available for it.`);
|
|
95
|
+
}
|
|
96
|
+
const orchestrationPlanned = knownWorkflow;
|
|
97
|
+
|
|
98
|
+
const base = {
|
|
99
|
+
requestedStrategy,
|
|
100
|
+
selectedProvider,
|
|
101
|
+
selectedModel,
|
|
102
|
+
resolutionSource,
|
|
103
|
+
orchestrationPlanned,
|
|
104
|
+
orchestrationAvailable,
|
|
105
|
+
deploymentMode,
|
|
106
|
+
modelResolution: stripWarnings(modelResolution),
|
|
107
|
+
semantics: EXECUTION_SEMANTICS,
|
|
108
|
+
warnings,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
if (useConstruct === false) {
|
|
112
|
+
return {
|
|
113
|
+
...base,
|
|
114
|
+
executionMode: 'host-direct',
|
|
115
|
+
effectiveStrategy: 'host-direct',
|
|
116
|
+
constructCapabilitiesActive: [],
|
|
117
|
+
degraded: false,
|
|
118
|
+
degradationReason: null,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (requestedStrategy === 'prompt-only') {
|
|
123
|
+
return {
|
|
124
|
+
...base,
|
|
125
|
+
executionMode: 'construct-prompt-only',
|
|
126
|
+
effectiveStrategy: 'prompt-only',
|
|
127
|
+
constructCapabilitiesActive: [...PROMPT_ONLY_CAPS],
|
|
128
|
+
degraded: false,
|
|
129
|
+
degradationReason: null,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// orchestrated or auto: deliver orchestration only when a plan exists AND a
|
|
134
|
+
// model resolves. A same-family fallback runs but did not honor the host's
|
|
135
|
+
// exact model, so it is reported as its own mode and as degraded.
|
|
136
|
+
const canOrchestrate = orchestrationPlanned && orchestrationAvailable;
|
|
137
|
+
if (canOrchestrate) {
|
|
138
|
+
if (resolutionSource === 'same-family-fallback') {
|
|
139
|
+
return {
|
|
140
|
+
...base,
|
|
141
|
+
executionMode: 'same-family-fallback',
|
|
142
|
+
effectiveStrategy: 'orchestrated',
|
|
143
|
+
constructCapabilitiesActive: [...ORCHESTRATED_CAPS],
|
|
144
|
+
degraded: true,
|
|
145
|
+
degradationReason: `Host model unavailable; resolved a same-family tier model — ${modelResolution.fallbackReason || 'same-family fallback applied'}.`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
...base,
|
|
150
|
+
executionMode: 'construct-orchestrated',
|
|
151
|
+
effectiveStrategy: 'orchestrated',
|
|
152
|
+
constructCapabilitiesActive: [...ORCHESTRATED_CAPS],
|
|
153
|
+
degraded: false,
|
|
154
|
+
degradationReason: null,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Could not orchestrate. Degradation is asserted only when orchestration was
|
|
159
|
+
// explicitly requested, or when no model resolved at all (auto still needs a
|
|
160
|
+
// model to do anything useful).
|
|
161
|
+
let degraded = false;
|
|
162
|
+
let degradationReason = null;
|
|
163
|
+
if (!orchestrationAvailable) {
|
|
164
|
+
degraded = true;
|
|
165
|
+
degradationReason = `Model could not be resolved; orchestration requires a runnable model — ${modelResolution.error?.reason || 'no model available'}.`;
|
|
166
|
+
} else if (requestedStrategy === 'orchestrated') {
|
|
167
|
+
degraded = true;
|
|
168
|
+
degradationReason = `Orchestration was requested but no orchestration plan is available${workflowType ? ` for workflowType "${workflowType}"` : ''}.`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
...base,
|
|
173
|
+
executionMode: 'construct-prompt-only',
|
|
174
|
+
effectiveStrategy: 'prompt-only',
|
|
175
|
+
constructCapabilitiesActive: [...PROMPT_ONLY_CAPS],
|
|
176
|
+
degraded,
|
|
177
|
+
degradationReason,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function stripWarnings(result) {
|
|
182
|
+
const { warnings, ...rest } = result || {};
|
|
183
|
+
return rest;
|
|
184
|
+
}
|
|
@@ -12,9 +12,11 @@ import { resolveEmbeddedModel as resolveModelCore } from './model-resolve.mjs';
|
|
|
12
12
|
import { recommendPlan as recommendPlanCore } from './triage.mjs';
|
|
13
13
|
import { invokeWorkflow as invokeWorkflowCore } from './workflow-invoke.mjs';
|
|
14
14
|
import { buildCapabilityContract } from './capability.mjs';
|
|
15
|
+
import { resolveExecution as resolveExecutionCore } from './execution.mjs';
|
|
15
16
|
|
|
16
17
|
export { CONTRACT_VERSION, MIN_CLIENT_CONTRACT_VERSION, isClientCompatible } from './contract-version.mjs';
|
|
17
18
|
export { APPROVAL_MODES } from './audit.mjs';
|
|
19
|
+
export { EXECUTION_MODES, CONSTRUCT_STRATEGIES, CONSTRUCT_CAPABILITIES } from './execution.mjs';
|
|
18
20
|
|
|
19
21
|
// File→text resolution for SDK callers: extract a path through the real
|
|
20
22
|
// pipeline (docling/whisper/transcript), then pass { text, ingestion } to
|
|
@@ -69,3 +71,17 @@ export async function invokeWorkflow(request = {}, { env, cwd } = {}) {
|
|
|
69
71
|
export function describeCapabilities({ env, cwd, rootDir } = {}) {
|
|
70
72
|
return wrapContractResult(buildCapabilityContract({ env, rootDir }), { surface: 'sdk', env, cwd });
|
|
71
73
|
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve the execution-capability contract for an embedded workflow: the
|
|
77
|
+
* executionMode, the contributed Construct capabilities, and any degradation
|
|
78
|
+
* with a reason. Returns a versioned envelope; the resolution is under `.data`.
|
|
79
|
+
* Read-only; performs no model call beyond model resolution.
|
|
80
|
+
*
|
|
81
|
+
* @param {object} request See execution.mjs resolveExecution.
|
|
82
|
+
* @param {object} [opts] { env, cwd, registryPath }
|
|
83
|
+
* @returns {object}
|
|
84
|
+
*/
|
|
85
|
+
export function resolveExecution(request = {}, { env, cwd, registryPath } = {}) {
|
|
86
|
+
return wrapContractResult(resolveExecutionCore(request, { env, cwd, registryPath }), { surface: 'sdk', env, cwd });
|
|
87
|
+
}
|