@geraldmaron/construct 1.0.23 → 1.0.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -2
- package/bin/construct +15 -215
- package/lib/embed/inbox.mjs +6 -3
- package/lib/embed/recommendation-store.mjs +7 -289
- package/lib/embed/reconcile.mjs +2 -2
- package/lib/hooks/config-protection.mjs +4 -4
- package/lib/hooks/session-reflect.mjs +5 -1
- package/lib/intake/git-queue.mjs +195 -0
- package/lib/intake/queue.mjs +9 -16
- package/lib/mcp/tools/storage.mjs +2 -3
- package/lib/mcp-catalog.json +3 -3
- package/lib/mcp-manager.mjs +59 -3
- package/lib/observation-store.mjs +38 -166
- package/lib/orchestration/runtime.mjs +3 -2
- package/lib/reconcile/index.mjs +0 -2
- package/lib/service-manager.mjs +38 -256
- package/lib/setup.mjs +26 -426
- package/lib/status.mjs +3 -6
- package/lib/storage/admin.mjs +48 -325
- package/lib/storage/backend.mjs +10 -57
- package/lib/storage/hybrid-query.mjs +15 -196
- package/lib/storage/sync.mjs +36 -177
- package/lib/storage/vector-client.mjs +256 -235
- package/lib/strategy-store.mjs +35 -286
- package/lib/worker/entrypoint.mjs +6 -14
- package/package.json +6 -5
- package/platforms/claude/settings.template.json +0 -7
- package/scripts/sync-specialists.mjs +46 -12
- package/specialists/prompts/cx-qa.md +1 -1
- package/specialists/registry.json +0 -8
- package/templates/docs/construct_guide.md +1 -1
- package/db/schema/001_init.sql +0 -40
- package/db/schema/002_pgvector.sql +0 -182
- package/db/schema/003_intake.sql +0 -47
- package/db/schema/003_observation_reconciliation.sql +0 -14
- package/db/schema/004_recommendations.sql +0 -46
- package/db/schema/005_strategy.sql +0 -21
- package/db/schema/006_graph.sql +0 -24
- package/db/schema/007_tags.sql +0 -30
- package/db/schema/008_skill_usage.sql +0 -24
- package/db/schema/009_scheduler.sql +0 -14
- package/db/schema/010_cx_scores.sql +0 -51
- package/lib/intake/postgres-queue.mjs +0 -240
- package/lib/reconcile/postgres-namespace.mjs +0 -102
- package/lib/services/local-postgres.mjs +0 -15
- package/lib/storage/backup.mjs +0 -347
- package/lib/storage/migrations.mjs +0 -187
- package/lib/storage/postgres-backup.mjs +0 -124
- package/lib/storage/sql-store.mjs +0 -45
- package/lib/storage/store-version.mjs +0 -115
- package/lib/storage/unified-storage.mjs +0 -550
- package/lib/storage/vector-store.mjs +0 -100
package/lib/intake/queue.mjs
CHANGED
|
@@ -36,11 +36,10 @@
|
|
|
36
36
|
import path from 'node:path';
|
|
37
37
|
|
|
38
38
|
import { FilesystemIntakeQueue, queueRoot, pendingDir, processedDir, skippedDir } from './filesystem-queue.mjs';
|
|
39
|
-
import {
|
|
39
|
+
import { GitIntakeQueue } from './git-queue.mjs';
|
|
40
40
|
import { getDeploymentMode } from '../deployment-mode.mjs';
|
|
41
|
-
import { createSqlClient } from '../storage/backend.mjs';
|
|
42
41
|
|
|
43
|
-
export { FilesystemIntakeQueue,
|
|
42
|
+
export { FilesystemIntakeQueue, GitIntakeQueue, queueRoot, pendingDir, processedDir, skippedDir };
|
|
44
43
|
|
|
45
44
|
export const INTAKE_QUEUE_BACKEND_ENV_KEY = 'CONSTRUCT_INTAKE_QUEUE_BACKEND';
|
|
46
45
|
export const INTAKE_PROJECT_ENV_KEY = 'CONSTRUCT_PROJECT_NAME';
|
|
@@ -48,9 +47,9 @@ export const INTAKE_TENANT_ENV_KEY = 'CONSTRUCT_TENANT_ID';
|
|
|
48
47
|
|
|
49
48
|
function resolveBackend(env) {
|
|
50
49
|
const override = env?.[INTAKE_QUEUE_BACKEND_ENV_KEY];
|
|
51
|
-
if (override === 'filesystem' || override === '
|
|
50
|
+
if (override === 'filesystem' || override === 'git') return override;
|
|
52
51
|
const mode = getDeploymentMode(env);
|
|
53
|
-
return mode === 'solo' ? 'filesystem' : '
|
|
52
|
+
return mode === 'solo' ? 'filesystem' : 'git';
|
|
54
53
|
}
|
|
55
54
|
|
|
56
55
|
function resolveProject(rootDir, env) {
|
|
@@ -62,22 +61,16 @@ function resolveProject(rootDir, env) {
|
|
|
62
61
|
/**
|
|
63
62
|
* Create an IntakeQueue instance for the given project root.
|
|
64
63
|
* Backend selection: CONSTRUCT_INTAKE_QUEUE_BACKEND override wins; otherwise
|
|
65
|
-
* solo mode → filesystem, team/enterprise →
|
|
66
|
-
* backend
|
|
67
|
-
* CONSTRUCT_PROJECT_NAME / CONSTRUCT_TENANT_ID to scope queries; an
|
|
68
|
-
* explicit `sql` and `project` in opts override both.
|
|
64
|
+
* solo mode → filesystem, team/enterprise → git. The git
|
|
65
|
+
* backend uses the filesystem and git for state synchronization.
|
|
69
66
|
*/
|
|
70
67
|
export function createIntakeQueue(rootDir, env = process.env, opts = {}) {
|
|
71
68
|
const backend = opts.backend || resolveBackend(env);
|
|
72
69
|
if (backend === 'filesystem') return new FilesystemIntakeQueue(rootDir);
|
|
73
|
-
if (backend === 'postgres') {
|
|
74
|
-
|
|
75
|
-
if (!sql) {
|
|
76
|
-
throw new Error('Postgres intake queue requires a configured DATABASE_URL (or sql client). Run `construct init` or set CONSTRUCT_INTAKE_QUEUE_BACKEND=filesystem.');
|
|
77
|
-
}
|
|
70
|
+
if (backend === 'git' || backend === 'postgres') {
|
|
71
|
+
// We treat 'postgres' as an alias for 'git' now to avoid breaking existing configs
|
|
78
72
|
const project = opts.project ?? resolveProject(rootDir, env);
|
|
79
|
-
|
|
80
|
-
return new PostgresIntakeQueue({ sql, project, tenantId });
|
|
73
|
+
return new GitIntakeQueue({ project, rootDir });
|
|
81
74
|
}
|
|
82
75
|
throw new Error(`Unknown intake queue backend: ${backend}`);
|
|
83
76
|
}
|
|
@@ -37,14 +37,13 @@ export async function storageReset(args) {
|
|
|
37
37
|
return resetStorage(cwd, {
|
|
38
38
|
env: process.env,
|
|
39
39
|
project,
|
|
40
|
-
resetSql: args.reset_sql !== false,
|
|
41
40
|
resetVector: args.reset_vector !== false,
|
|
42
41
|
resetIngested: args.reset_ingested === true,
|
|
43
42
|
confirm: true,
|
|
44
43
|
});
|
|
45
44
|
}
|
|
46
45
|
|
|
47
|
-
export function deleteIngestedArtifactsTool(args) {
|
|
46
|
+
export async function deleteIngestedArtifactsTool(args) {
|
|
48
47
|
const cwd = args.cwd ? resolve(String(args.cwd)) : process.cwd();
|
|
49
48
|
if (args.confirm !== true) {
|
|
50
49
|
return { error: 'delete_ingested_artifacts requires confirm=true' };
|
|
@@ -53,7 +52,7 @@ export function deleteIngestedArtifactsTool(args) {
|
|
|
53
52
|
? args.files.map((value) => String(value))
|
|
54
53
|
: [];
|
|
55
54
|
try {
|
|
56
|
-
return deleteIngestedArtifacts(cwd, { files, confirm: true });
|
|
55
|
+
return await deleteIngestedArtifacts(cwd, { files, confirm: true });
|
|
57
56
|
} catch (error) {
|
|
58
57
|
return { error: error.message ?? String(error) };
|
|
59
58
|
}
|
package/lib/mcp-catalog.json
CHANGED
|
@@ -85,12 +85,12 @@
|
|
|
85
85
|
"id": "playwright",
|
|
86
86
|
"name": "Playwright",
|
|
87
87
|
"category": "optional",
|
|
88
|
-
"description": "Browser automation for E2E testing and web research.",
|
|
89
|
-
"package": "@playwright/mcp@
|
|
88
|
+
"description": "Browser automation for E2E testing and web research. Opt-in via `construct mcp add playwright` — not synced by default (downloads browser binaries on first use).",
|
|
89
|
+
"package": "@playwright/mcp@0.0.75",
|
|
90
90
|
"command": "npx",
|
|
91
91
|
"args": [
|
|
92
92
|
"-y",
|
|
93
|
-
"@playwright/mcp@
|
|
93
|
+
"@playwright/mcp@0.0.75"
|
|
94
94
|
],
|
|
95
95
|
"env": {},
|
|
96
96
|
"requiredEnv": [],
|
package/lib/mcp-manager.mjs
CHANGED
|
@@ -33,6 +33,56 @@ function getClaudeSettingsPath() {
|
|
|
33
33
|
return join(homedir(), '.claude', 'settings.json');
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// VS Code's "MCP: Open User Configuration" edits a per-profile mcp.json keyed
|
|
37
|
+
// on top-level `servers`; Cursor uses ~/.cursor/mcp.json keyed on `mcpServers`.
|
|
38
|
+
// construct sync writes both surfaces, so remove must clean them too or a
|
|
39
|
+
// removed MCP lingers there.
|
|
40
|
+
|
|
41
|
+
function getVSCodeUserMcpPaths(home = homedir()) {
|
|
42
|
+
const plat = platform();
|
|
43
|
+
if (plat === 'darwin') {
|
|
44
|
+
return [
|
|
45
|
+
join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'),
|
|
46
|
+
join(home, 'Library', 'Application Support', 'Code - Insiders', 'User', 'mcp.json'),
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
if (plat === 'win32') {
|
|
50
|
+
const appData = process.env.APPDATA ?? join(home, 'AppData', 'Roaming');
|
|
51
|
+
return [join(appData, 'Code', 'User', 'mcp.json'), join(appData, 'Code - Insiders', 'User', 'mcp.json')];
|
|
52
|
+
}
|
|
53
|
+
return [
|
|
54
|
+
join(home, '.config', 'Code', 'User', 'mcp.json'),
|
|
55
|
+
join(home, '.config', 'Code - Insiders', 'User', 'mcp.json'),
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getCursorMcpPath(home = homedir()) {
|
|
60
|
+
return join(home, '.cursor', 'mcp.json');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function mcpFileHasEntry(file, key, id) {
|
|
64
|
+
if (!existsSync(file)) return false;
|
|
65
|
+
try {
|
|
66
|
+
return Boolean(JSON.parse(readFileSync(file, 'utf8'))?.[key]?.[id]);
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function removeMcpFromJsonFile(file, key, id) {
|
|
73
|
+
if (!existsSync(file)) return false;
|
|
74
|
+
let config;
|
|
75
|
+
try {
|
|
76
|
+
config = JSON.parse(readFileSync(file, 'utf8'));
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if (!config?.[key] || !(id in config[key])) return false;
|
|
81
|
+
delete config[key][id];
|
|
82
|
+
writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
36
86
|
function openUrl(url) {
|
|
37
87
|
try {
|
|
38
88
|
const opener = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'start' : 'xdg-open';
|
|
@@ -426,10 +476,14 @@ export function cmdMcpRemove(id) {
|
|
|
426
476
|
const openCodeState = readOpenCodeConfig();
|
|
427
477
|
const oc = openCodeState.config ?? {};
|
|
428
478
|
const openCodeId = getOpenCodeMcpId(id);
|
|
479
|
+
const vscodePaths = getVSCodeUserMcpPaths();
|
|
480
|
+
const cursorPath = getCursorMcpPath();
|
|
429
481
|
const hasClaudeEntry = Boolean(settings.mcpServers?.[id]);
|
|
430
482
|
const hasOpenCodeEntry = Boolean(oc.mcp?.[openCodeId] || oc.mcp?.[id] || (id === 'memory' && oc.mcp?.cass));
|
|
431
483
|
const hasCodexEntry = readCodexConfig().includes(`[mcp_servers.${id}]`) || readCodexConfig().includes(`[mcp_servers.${tomlString(id)}]`);
|
|
432
|
-
|
|
484
|
+
const hasVscodeEntry = vscodePaths.some((p) => mcpFileHasEntry(p, 'servers', id));
|
|
485
|
+
const hasCursorEntry = mcpFileHasEntry(cursorPath, 'mcpServers', id);
|
|
486
|
+
if (!hasClaudeEntry && !hasOpenCodeEntry && !hasCodexEntry && !hasVscodeEntry && !hasCursorEntry) {
|
|
433
487
|
console.log(`${id} is not installed. Nothing to remove.`);
|
|
434
488
|
return;
|
|
435
489
|
}
|
|
@@ -448,9 +502,11 @@ export function cmdMcpRemove(id) {
|
|
|
448
502
|
if (openCodeState.config || hasOpenCodeEntry) saveOpenCodeConfig(oc);
|
|
449
503
|
else if (openCodeState.file && existsSync(openCodeState.file)) rmSync(openCodeState.file, { force: true });
|
|
450
504
|
removeCodexMcp(id);
|
|
505
|
+
for (const p of vscodePaths) removeMcpFromJsonFile(p, 'servers', id);
|
|
506
|
+
removeMcpFromJsonFile(cursorPath, 'mcpServers', id);
|
|
451
507
|
|
|
452
|
-
console.log(`✓ ${name} removed from Claude Code, OpenCode, and
|
|
453
|
-
console.log('Restart
|
|
508
|
+
console.log(`✓ ${name} removed from Claude Code, OpenCode, Codex, VS Code, and Cursor config`);
|
|
509
|
+
console.log('Restart the editor to deactivate it.');
|
|
454
510
|
}
|
|
455
511
|
|
|
456
512
|
/**
|
|
@@ -4,31 +4,36 @@
|
|
|
4
4
|
* Stores distilled insights that specialists learn during work:
|
|
5
5
|
* - patterns, anti-patterns, dependency relationships, decisions, insights
|
|
6
6
|
* - each scoped to a role (cx-engineer, cx-architect, etc.)
|
|
7
|
-
* - vector-indexed for semantic search via
|
|
7
|
+
* - vector-indexed for semantic search via embedded LanceDB
|
|
8
8
|
*
|
|
9
9
|
* Storage layout:
|
|
10
10
|
* .cx/observations/index.json — lightweight listing for fast filtering
|
|
11
11
|
* .cx/observations/<id>.json — full observation record
|
|
12
|
-
* .cx/observations/vectors.json — local vector index for semantic search
|
|
13
12
|
*/
|
|
14
13
|
import crypto from 'node:crypto';
|
|
15
14
|
import fs from 'node:fs';
|
|
16
15
|
import path from 'node:path';
|
|
17
|
-
import { cosineSimilarity, rankByBm25 } from './storage/embeddings.mjs';
|
|
18
|
-
import { embedSync as embedText } from './storage/embeddings-legacy.mjs';
|
|
19
16
|
import { embedText as embedTextEngine } from './storage/embeddings-engine.mjs';
|
|
20
17
|
import { VectorClient } from './storage/vector-client.mjs';
|
|
21
|
-
import { withFileLockSync } from './storage/file-lock.mjs';
|
|
22
18
|
import { ensureCxDir } from './project-init-shared.mjs';
|
|
23
19
|
|
|
24
20
|
const OBS_DIR = '.cx/observations';
|
|
25
21
|
const INDEX_FILE = 'index.json';
|
|
26
|
-
const VECTORS_FILE = 'vectors.json';
|
|
27
22
|
const MAX_INDEX = 1000;
|
|
28
23
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
24
|
+
// VectorClient resolves its store from CONSTRUCT_LANCEDB_PATH and otherwise
|
|
25
|
+
// falls back to process.cwd(). Observations are scoped to rootDir, so when no
|
|
26
|
+
// explicit path is configured (tests, unmanaged checkouts) the vector store
|
|
27
|
+
// must follow rootDir — not the caller's cwd — or a reader in a different cwd
|
|
28
|
+
// than the writer sees an empty table. A configured path (managed installs)
|
|
29
|
+
// is left untouched so the home-global store keeps working.
|
|
30
|
+
|
|
31
|
+
function vectorClientFor(rootDir) {
|
|
32
|
+
const env = process.env.CONSTRUCT_LANCEDB_PATH
|
|
33
|
+
? process.env
|
|
34
|
+
: { ...process.env, CONSTRUCT_LANCEDB_PATH: path.join(rootDir, '.cx', 'lancedb') };
|
|
35
|
+
return new VectorClient({ env });
|
|
36
|
+
}
|
|
32
37
|
|
|
33
38
|
export function observationSearchText(obs) {
|
|
34
39
|
return [obs.summary, obs.content, ...(obs.tags || [])].filter(Boolean).join(' ');
|
|
@@ -40,9 +45,6 @@ export function observationContentHash(text) {
|
|
|
40
45
|
const MAX_SUMMARY = 500;
|
|
41
46
|
const MAX_CONTENT = 2000;
|
|
42
47
|
const MAX_TAGS = 10;
|
|
43
|
-
// Size guardrail: surfaces in `construct doctor` when the observation tree
|
|
44
|
-
// exceeds this byte cap. Doesn't drop data — the operator decides whether to
|
|
45
|
-
// run `construct memory consolidate` (which prunes archive) or raise the cap.
|
|
46
48
|
const MAX_BYTES = 50 * 1024 * 1024;
|
|
47
49
|
|
|
48
50
|
const VALID_CATEGORIES = new Set([
|
|
@@ -50,8 +52,6 @@ const VALID_CATEGORIES = new Set([
|
|
|
50
52
|
]);
|
|
51
53
|
|
|
52
54
|
function ensureDir(dir, rootDir = null) {
|
|
53
|
-
// Preserves the construct context invariant: any code that creates a .cx/
|
|
54
|
-
// subdirectory also initializes context.md from the project template.
|
|
55
55
|
if (rootDir) ensureCxDir(rootDir);
|
|
56
56
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
57
57
|
}
|
|
@@ -64,10 +64,6 @@ function indexPath(rootDir) {
|
|
|
64
64
|
return path.join(obsDir(rootDir), INDEX_FILE);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
function vectorsPath(rootDir) {
|
|
68
|
-
return path.join(obsDir(rootDir), VECTORS_FILE);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
67
|
function generateId() {
|
|
72
68
|
const ts = Date.now().toString(36);
|
|
73
69
|
const rand = crypto.randomBytes(4).toString('hex');
|
|
@@ -132,30 +128,6 @@ function writeIndex(rootDir, entries) {
|
|
|
132
128
|
return { dropped };
|
|
133
129
|
}
|
|
134
130
|
|
|
135
|
-
function readVectors(rootDir) {
|
|
136
|
-
const p = vectorsPath(rootDir);
|
|
137
|
-
if (!fs.existsSync(p)) return [];
|
|
138
|
-
try {
|
|
139
|
-
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
140
|
-
} catch {
|
|
141
|
-
return [];
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function writeVectors(rootDir, records) {
|
|
146
|
-
ensureDir(obsDir(rootDir), rootDir);
|
|
147
|
-
const dropped = Math.max(0, records.length - MAX_INDEX);
|
|
148
|
-
if (dropped > 0) {
|
|
149
|
-
logCapDrop(rootDir, 'observation-vectors', dropped, records.length);
|
|
150
|
-
}
|
|
151
|
-
fs.writeFileSync(vectorsPath(rootDir), JSON.stringify(records, null, 2) + '\n');
|
|
152
|
-
return { dropped };
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Add a new observation and vectorize it for semantic search.
|
|
157
|
-
* Uses SQL + neural embeddings when available, falls back to local JSON + hashing.
|
|
158
|
-
*/
|
|
159
131
|
export async function addObservation(rootDir, {
|
|
160
132
|
role = 'unknown',
|
|
161
133
|
category = 'insight',
|
|
@@ -175,10 +147,9 @@ export async function addObservation(rootDir, {
|
|
|
175
147
|
const clampedSummary = clamp(String(summary), MAX_SUMMARY);
|
|
176
148
|
const clampedContent = clamp(String(content), MAX_CONTENT);
|
|
177
149
|
const clampedTags = (Array.isArray(tags) ? tags : []).slice(0, MAX_TAGS).map(String);
|
|
178
|
-
// Structured metadata: opt-in. Capped at 2 KB stringified so a misuse can't
|
|
179
|
-
// bloat the per-id JSON. Plain object only; rejects arrays and functions.
|
|
180
150
|
const clampedExtras = sanitizeExtras(extras);
|
|
181
151
|
|
|
152
|
+
const effectiveProject = project ? String(project) : rootDir.split('/').pop();
|
|
182
153
|
const record = {
|
|
183
154
|
id,
|
|
184
155
|
role: String(role),
|
|
@@ -186,7 +157,7 @@ export async function addObservation(rootDir, {
|
|
|
186
157
|
summary: clampedSummary,
|
|
187
158
|
content: clampedContent,
|
|
188
159
|
tags: clampedTags,
|
|
189
|
-
project:
|
|
160
|
+
project: effectiveProject,
|
|
190
161
|
confidence: Math.max(0, Math.min(1, Number(confidence) || 0.8)),
|
|
191
162
|
source: source || null,
|
|
192
163
|
gitSha: gitSha ? String(gitSha).slice(0, 40) : null,
|
|
@@ -195,37 +166,30 @@ export async function addObservation(rootDir, {
|
|
|
195
166
|
updatedAt: now,
|
|
196
167
|
};
|
|
197
168
|
|
|
198
|
-
// Lock the per-store index so concurrent writers (CLI + hooks) cannot
|
|
199
|
-
// corrupt the index/vectors JSON pair. The observation JSON file is
|
|
200
|
-
// unique-per-id and doesn't need the lock; the shared structures do.
|
|
201
169
|
ensureDir(obsDir(rootDir), rootDir);
|
|
202
170
|
fs.writeFileSync(
|
|
203
171
|
path.join(obsDir(rootDir), `${id}.json`),
|
|
204
172
|
JSON.stringify(record, null, 2) + '\n',
|
|
205
173
|
);
|
|
206
174
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
project: record.project,
|
|
216
|
-
createdAt: now,
|
|
217
|
-
});
|
|
218
|
-
indexDropped = writeIndex(rootDir, index).dropped;
|
|
175
|
+
const index = readIndex(rootDir);
|
|
176
|
+
index.unshift({
|
|
177
|
+
id,
|
|
178
|
+
role: record.role,
|
|
179
|
+
category: record.category,
|
|
180
|
+
summary: record.summary,
|
|
181
|
+
project: record.project,
|
|
182
|
+
createdAt: now,
|
|
219
183
|
});
|
|
184
|
+
const indexDropped = writeIndex(rootDir, index).dropped;
|
|
220
185
|
if (indexDropped > 0) {
|
|
221
186
|
record.capDropped = indexDropped;
|
|
222
187
|
process.stderr.write(`[observation-store] observation cap reached: ${indexDropped} oldest entries evicted (cap=${MAX_INDEX})\n`);
|
|
223
188
|
}
|
|
224
189
|
|
|
225
|
-
// Try SQL storage with neural embeddings
|
|
226
190
|
try {
|
|
227
|
-
const client =
|
|
228
|
-
if (await client.isHealthy()
|
|
191
|
+
const client = vectorClientFor(rootDir);
|
|
192
|
+
if (await client.isHealthy()) {
|
|
229
193
|
const searchText = observationSearchText(record);
|
|
230
194
|
const { embedding, model } = await embedTextEngine(searchText);
|
|
231
195
|
await client.storeObservation({
|
|
@@ -234,34 +198,15 @@ export async function addObservation(rootDir, {
|
|
|
234
198
|
contentHash: observationContentHash(searchText),
|
|
235
199
|
model,
|
|
236
200
|
});
|
|
237
|
-
await client.close();
|
|
238
|
-
return record;
|
|
239
201
|
}
|
|
240
202
|
await client.close();
|
|
241
203
|
} catch (err) {
|
|
242
|
-
|
|
243
|
-
// network) must not look identical to "no backend configured" — surface it, then
|
|
244
|
-
// preserve the observation in the local index so the write never silently vanishes.
|
|
245
|
-
|
|
246
|
-
process.stderr.write(`[observation-store] SQL store failed; kept observation in local index: ${err?.message || err}\n`);
|
|
204
|
+
process.stderr.write(`[observation-store] vector store failed; kept observation in local index: ${err?.message || err}\n`);
|
|
247
205
|
}
|
|
248
206
|
|
|
249
|
-
// Fallback: local JSON vector index with hashing-bow-v1.
|
|
250
|
-
const searchText = [record.summary, record.content, ...record.tags].filter(Boolean).join(' ');
|
|
251
|
-
const embedding = embedText(searchText);
|
|
252
|
-
withFileLockSync(vectorsPath(rootDir), () => {
|
|
253
|
-
const vectors = readVectors(rootDir);
|
|
254
|
-
vectors.push({ id, embedding, role: record.role, category: record.category, project: record.project });
|
|
255
|
-
writeVectors(rootDir, vectors.slice(-MAX_INDEX));
|
|
256
|
-
});
|
|
257
|
-
|
|
258
207
|
return record;
|
|
259
208
|
}
|
|
260
209
|
|
|
261
|
-
/**
|
|
262
|
-
* Search observations using SQL + neural embeddings when available,
|
|
263
|
-
* falling back to hybrid cosine + BM25 on local JSON.
|
|
264
|
-
*/
|
|
265
210
|
export async function searchObservations(rootDir, query, {
|
|
266
211
|
role = null,
|
|
267
212
|
category = null,
|
|
@@ -270,16 +215,15 @@ export async function searchObservations(rootDir, query, {
|
|
|
270
215
|
} = {}) {
|
|
271
216
|
if (!query) return [];
|
|
272
217
|
|
|
273
|
-
// Try SQL + neural embeddings first
|
|
274
218
|
try {
|
|
275
|
-
const client =
|
|
276
|
-
if (await client.isHealthy()
|
|
219
|
+
const client = vectorClientFor(rootDir);
|
|
220
|
+
if (await client.isHealthy()) {
|
|
277
221
|
const { embedding } = await embedTextEngine(String(query));
|
|
278
222
|
const results = await client.searchObservations({
|
|
279
223
|
project: project || rootDir.split('/').pop(),
|
|
280
224
|
queryEmbedding: embedding,
|
|
281
225
|
limit,
|
|
282
|
-
minSimilarity: 0.
|
|
226
|
+
minSimilarity: 0.01,
|
|
283
227
|
role,
|
|
284
228
|
category,
|
|
285
229
|
});
|
|
@@ -288,6 +232,7 @@ export async function searchObservations(rootDir, query, {
|
|
|
288
232
|
id: r.id,
|
|
289
233
|
role: r.role,
|
|
290
234
|
category: r.category,
|
|
235
|
+
project: r.project,
|
|
291
236
|
summary: r.summary,
|
|
292
237
|
content: r.content,
|
|
293
238
|
tags: r.tags || [],
|
|
@@ -299,65 +244,12 @@ export async function searchObservations(rootDir, query, {
|
|
|
299
244
|
}
|
|
300
245
|
await client.close();
|
|
301
246
|
} catch {
|
|
302
|
-
//
|
|
247
|
+
// If Vector search fails, return empty since fallback is removed.
|
|
303
248
|
}
|
|
304
249
|
|
|
305
|
-
|
|
306
|
-
const queryEmbedding = embedText(String(query));
|
|
307
|
-
let vectors = readVectors(rootDir);
|
|
308
|
-
|
|
309
|
-
if (role) vectors = vectors.filter((v) => v.role === role);
|
|
310
|
-
if (category) vectors = vectors.filter((v) => v.category === category);
|
|
311
|
-
if (project) vectors = vectors.filter((v) => v.project === project);
|
|
312
|
-
|
|
313
|
-
// Cosine pass
|
|
314
|
-
const cosineScored = vectors
|
|
315
|
-
.map((v) => ({
|
|
316
|
-
id: v.id,
|
|
317
|
-
score: cosineSimilarity(queryEmbedding, v.embedding || []),
|
|
318
|
-
}))
|
|
319
|
-
.filter(({ score }) => score > 0.05);
|
|
320
|
-
|
|
321
|
-
// Load full records for BM25 pass
|
|
322
|
-
const candidateIds = new Set(cosineScored.map((v) => v.id));
|
|
323
|
-
const recentIndex = readIndex(rootDir);
|
|
324
|
-
const filtered = role ? recentIndex.filter((e) => e.role === role) : recentIndex;
|
|
325
|
-
const filteredCat = category ? filtered.filter((e) => e.category === category) : filtered;
|
|
326
|
-
const filteredProj = project ? filteredCat.filter((e) => e.project === project) : filteredCat;
|
|
327
|
-
for (const entry of filteredProj.slice(0, Math.max(limit * 3, 30))) candidateIds.add(entry.id);
|
|
328
|
-
|
|
329
|
-
const candidateRecords = [...candidateIds]
|
|
330
|
-
.map((id) => getObservation(rootDir, id))
|
|
331
|
-
.filter(Boolean)
|
|
332
|
-
.map((r) => ({ ...r, text: [r.summary, r.content, ...(r.tags || [])].filter(Boolean).join(' ') }));
|
|
333
|
-
|
|
334
|
-
const bm25Scored = rankByBm25(candidateRecords, query, { limit: limit * 2 });
|
|
335
|
-
|
|
336
|
-
// Merge: highest score wins per id
|
|
337
|
-
const scoreMap = new Map();
|
|
338
|
-
for (const { id, score } of cosineScored) {
|
|
339
|
-
scoreMap.set(id, score);
|
|
340
|
-
}
|
|
341
|
-
for (const item of bm25Scored) {
|
|
342
|
-
const prev = scoreMap.get(item.id) || 0;
|
|
343
|
-
const bm25Max = bm25Scored[0]?.score || 1;
|
|
344
|
-
const normalized = Math.min(item.score / bm25Max, 1);
|
|
345
|
-
scoreMap.set(item.id, Math.max(prev, normalized));
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
return [...scoreMap.entries()]
|
|
349
|
-
.sort((a, b) => b[1] - a[1])
|
|
350
|
-
.slice(0, limit)
|
|
351
|
-
.map(([id, score]) => {
|
|
352
|
-
const record = getObservation(rootDir, id);
|
|
353
|
-
return record ? { ...record, score } : null;
|
|
354
|
-
})
|
|
355
|
-
.filter(Boolean);
|
|
250
|
+
return [];
|
|
356
251
|
}
|
|
357
252
|
|
|
358
|
-
/**
|
|
359
|
-
* List observations from the index with optional filters.
|
|
360
|
-
*/
|
|
361
253
|
export function listObservations(rootDir, {
|
|
362
254
|
role = null,
|
|
363
255
|
category = null,
|
|
@@ -371,9 +263,6 @@ export function listObservations(rootDir, {
|
|
|
371
263
|
return entries.slice(0, limit);
|
|
372
264
|
}
|
|
373
265
|
|
|
374
|
-
/**
|
|
375
|
-
* Load a full observation record by ID.
|
|
376
|
-
*/
|
|
377
266
|
export function getObservation(rootDir, id) {
|
|
378
267
|
const filePath = path.join(obsDir(rootDir), `${id}.json`);
|
|
379
268
|
if (!fs.existsSync(filePath)) return null;
|
|
@@ -384,31 +273,17 @@ export function getObservation(rootDir, id) {
|
|
|
384
273
|
}
|
|
385
274
|
}
|
|
386
275
|
|
|
387
|
-
/**
|
|
388
|
-
* Delete an observation by id.
|
|
389
|
-
*/
|
|
390
276
|
export function deleteObservation(rootDir, id) {
|
|
391
277
|
const filePath = path.join(obsDir(rootDir), `${id}.json`);
|
|
392
278
|
if (fs.existsSync(filePath)) fs.rmSync(filePath, { force: true });
|
|
393
279
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
if (filtered.length !== index.length) writeIndex(rootDir, filtered);
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
withFileLockSync(vectorsPath(rootDir), () => {
|
|
401
|
-
const vectors = readVectors(rootDir);
|
|
402
|
-
const filteredVec = vectors.filter((v) => v.id !== id);
|
|
403
|
-
if (filteredVec.length !== vectors.length) writeVectors(rootDir, filteredVec);
|
|
404
|
-
});
|
|
280
|
+
const index = readIndex(rootDir);
|
|
281
|
+
const filtered = index.filter((e) => e.id !== id);
|
|
282
|
+
if (filtered.length !== index.length) writeIndex(rootDir, filtered);
|
|
405
283
|
|
|
406
284
|
return true;
|
|
407
285
|
}
|
|
408
286
|
|
|
409
|
-
/**
|
|
410
|
-
* Count observations, optionally filtered.
|
|
411
|
-
*/
|
|
412
287
|
export function countObservations(rootDir, { role = null, project = null } = {}) {
|
|
413
288
|
let entries = readIndex(rootDir);
|
|
414
289
|
if (role) entries = entries.filter((e) => e.role === role);
|
|
@@ -416,9 +291,6 @@ export function countObservations(rootDir, { role = null, project = null } = {})
|
|
|
416
291
|
return entries.length;
|
|
417
292
|
}
|
|
418
293
|
|
|
419
|
-
// On-disk byte size of the observation tree (live + archive) at rootDir.
|
|
420
|
-
// Backs the `Observation size` doctor surface so size pressure shows up
|
|
421
|
-
// independently of count-based caps.
|
|
422
294
|
export function getObservationsSize(rootDir) {
|
|
423
295
|
const root = obsDir(rootDir);
|
|
424
296
|
if (!fs.existsSync(root)) return 0;
|
|
@@ -439,4 +311,4 @@ export function getObservationsSize(rootDir) {
|
|
|
439
311
|
export function checkObservationsSize(rootDir, { cap = MAX_BYTES } = {}) {
|
|
440
312
|
const size = getObservationsSize(rootDir);
|
|
441
313
|
return { size, cap, ok: size <= cap };
|
|
442
|
-
}
|
|
314
|
+
}
|
|
@@ -147,6 +147,7 @@ export async function planRun(request = {}, { env = process.env, cwd = process.c
|
|
|
147
147
|
const {
|
|
148
148
|
request: text = '', workflowType = null, requestedStrategy = 'auto', useConstruct = true,
|
|
149
149
|
host = null, hostModel = null, hostProvider = null, fileCount = 0, moduleCount = 0,
|
|
150
|
+
workerBackend: explicitWorkerBackend = null,
|
|
150
151
|
} = request;
|
|
151
152
|
|
|
152
153
|
const config = loadConfig(cwd, env);
|
|
@@ -161,7 +162,7 @@ export async function planRun(request = {}, { env = process.env, cwd = process.c
|
|
|
161
162
|
const traceId = newTraceId();
|
|
162
163
|
const runId = newRunId();
|
|
163
164
|
const now = new Date().toISOString();
|
|
164
|
-
const workerBackend = resolveWorkerBackend({ config });
|
|
165
|
+
const workerBackend = resolveWorkerBackend({ explicit: explicitWorkerBackend, config });
|
|
165
166
|
const chainOfThought = resolveChainOfThought({ config });
|
|
166
167
|
|
|
167
168
|
// Construct owns a specialist task sequence only when the contract resolves to
|
|
@@ -358,7 +359,7 @@ export function hostAdapterMetadata(run) {
|
|
|
358
359
|
degradationReason: e.degradationReason,
|
|
359
360
|
selectedProvider: e.selectedProvider,
|
|
360
361
|
selectedModel: e.selectedModel,
|
|
361
|
-
tasks: (run.tasks || []).map((t) => ({ id: t.id, role: t.role, status: t.status, executor: t.executor, reasoning: t.reasoning ?? null })),
|
|
362
|
+
tasks: (run.tasks || []).map((t) => ({ id: t.id, role: t.role, status: t.status, executor: t.executor, reasoning: t.reasoning ?? null, output: t.output ?? null, error: t.error ?? null })),
|
|
362
363
|
warnings: run.warnings || [],
|
|
363
364
|
semantics: run.semantics,
|
|
364
365
|
executionSemantics: run.executionSemantics,
|
package/lib/reconcile/index.mjs
CHANGED
|
@@ -35,7 +35,6 @@ import legacyDoctrineStrip from './legacy-doctrine-strip.mjs';
|
|
|
35
35
|
import legacyGuideDecommit from './legacy-guide-decommit.mjs';
|
|
36
36
|
import mcpEntryReconcile from './mcp-entry-reconcile.mjs';
|
|
37
37
|
import adapterPrune from './adapter-prune.mjs';
|
|
38
|
-
import postgresNamespace from './postgres-namespace.mjs';
|
|
39
38
|
|
|
40
39
|
export const RECONCILIATIONS = [
|
|
41
40
|
legacySkillsCleanup,
|
|
@@ -45,7 +44,6 @@ export const RECONCILIATIONS = [
|
|
|
45
44
|
legacyGuideDecommit,
|
|
46
45
|
mcpEntryReconcile,
|
|
47
46
|
adapterPrune,
|
|
48
|
-
postgresNamespace,
|
|
49
47
|
];
|
|
50
48
|
|
|
51
49
|
const STAMP_FILE = 'reconcile.json';
|