@geraldmaron/construct 1.0.23 → 1.1.0
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 +189 -207
- package/lib/agent-instructions/inject.mjs +25 -4
- package/lib/audit-rules.mjs +127 -0
- package/lib/audit-skills.mjs +43 -1
- package/lib/beads-client.mjs +9 -0
- package/lib/beads-optimistic.mjs +23 -71
- package/lib/bridges/copilot-proxy.mjs +116 -0
- package/lib/cli-commands.mjs +5 -1
- package/lib/comment-lint.mjs +1 -1
- package/lib/config/schema.mjs +1 -1
- package/lib/document-extract/docling-client.mjs +16 -6
- package/lib/document-extract/docling-sidecar.py +32 -2
- package/lib/document-extract.mjs +37 -10
- package/lib/document-ingest.mjs +90 -5
- package/lib/embed/inbox.mjs +6 -3
- package/lib/embed/recommendation-store.mjs +7 -289
- package/lib/embed/reconcile.mjs +2 -2
- package/lib/embed/roadmap.mjs +16 -1
- package/lib/engine/consolidate.mjs +160 -3
- package/lib/engine/contradiction-judge.mjs +71 -0
- package/lib/engine/contradiction.mjs +74 -0
- package/lib/hooks/config-protection.mjs +4 -4
- package/lib/hooks/session-reflect.mjs +5 -1
- package/lib/host-capabilities.mjs +30 -0
- package/lib/ingest/docling-remote.mjs +90 -0
- package/lib/ingest/strategy.mjs +1 -1
- package/lib/init-unified.mjs +9 -13
- package/lib/intake/git-queue.mjs +195 -0
- package/lib/intake/queue.mjs +9 -16
- package/lib/logging/rotate.mjs +18 -0
- package/lib/mcp/server.mjs +124 -12
- package/lib/mcp/tool-budget.mjs +53 -0
- package/lib/mcp/tools/storage.mjs +2 -3
- package/lib/mcp-catalog.json +4 -4
- package/lib/mcp-manager.mjs +59 -3
- package/lib/observation-store.mjs +38 -166
- package/lib/ollama/capability-store.mjs +78 -0
- package/lib/ollama/provision-context.mjs +344 -0
- package/lib/opencode-config.mjs +148 -0
- package/lib/opencode-telemetry.mjs +7 -0
- package/lib/orchestration/runtime.mjs +3 -2
- package/lib/orchestration-policy.mjs +41 -6
- package/lib/platforms/capabilities.mjs +100 -0
- package/lib/prompt-composer.js +12 -8
- package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
- package/lib/reconcile/index.mjs +0 -2
- package/lib/reflect/extractor.mjs +14 -1
- package/lib/reflect/salience.mjs +65 -0
- package/lib/rules-delivery.mjs +122 -0
- package/lib/runtime/uv-bootstrap.mjs +32 -17
- package/lib/service-manager.mjs +79 -259
- package/lib/setup.mjs +44 -425
- package/lib/specialists/prompt-schema.mjs +162 -0
- package/lib/specialists/scaffold.mjs +109 -0
- package/lib/status.mjs +3 -6
- package/lib/storage/admin.mjs +48 -325
- package/lib/storage/backend.mjs +10 -57
- package/lib/storage/embeddings-engine.mjs +19 -5
- 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/telemetry/beads-fallback.mjs +40 -0
- package/lib/telemetry/hook-calls.mjs +138 -0
- package/lib/worker/entrypoint.mjs +6 -14
- package/package.json +6 -5
- package/personas/construct.md +1 -1
- package/platforms/capabilities.json +76 -0
- package/platforms/claude/settings.template.json +0 -7
- package/platforms/opencode/sync-config.mjs +121 -25
- package/rules/common/neurodivergent-output.md +1 -1
- package/rules/web/coding-style.md +8 -0
- package/rules/web/design-quality.md +8 -0
- package/rules/web/hooks.md +8 -0
- package/rules/web/patterns.md +8 -0
- package/rules/web/performance.md +8 -0
- package/rules/web/security.md +8 -0
- package/rules/web/testing.md +8 -0
- package/scripts/sync-specialists.mjs +174 -40
- package/specialists/prompts/cx-architect.md +20 -0
- package/specialists/prompts/cx-qa.md +1 -1
- package/specialists/prompts/cx-test-automation.md +12 -0
- 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
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/platforms/capabilities.mjs — loader for the platform capability registry.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for what each host platform can do (native subagent
|
|
5
|
+
* routing, hooks + their global-scope allowlist, MCP support, config format,
|
|
6
|
+
* local-model provisioning, instructions-only). The host enumeration, the
|
|
7
|
+
* displayName-to-id map, the hasNativeSubagents matrix, and the global hook /
|
|
8
|
+
* MCP allowlists all derive from this one registry so sync and init read host
|
|
9
|
+
* capabilities as data. See docs/adr/0033-platform-capability-registry.md.
|
|
10
|
+
*
|
|
11
|
+
* Hand-rolled validation (no AJV/zod) so this loads dependency-free on the
|
|
12
|
+
* bootstrap paths that run before npm install — same constraint as
|
|
13
|
+
* lib/config/schema.mjs. Fails loud on a malformed or unknown-shaped registry:
|
|
14
|
+
* a silent fall-through to a default would reintroduce exactly the per-host
|
|
15
|
+
* guesswork this registry exists to remove.
|
|
16
|
+
*/
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
const REGISTRY_PATH = path.join(
|
|
22
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
23
|
+
"..",
|
|
24
|
+
"..",
|
|
25
|
+
"platforms",
|
|
26
|
+
"capabilities.json",
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const CONFIG_FORMATS = ["json", "toml", "markdown"];
|
|
30
|
+
const PROVISIONING = ["modelfile", "none"];
|
|
31
|
+
|
|
32
|
+
function fail(msg) {
|
|
33
|
+
throw new Error(`platform capability registry: ${msg}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function validateHost(id, host) {
|
|
37
|
+
if (!host || typeof host !== "object" || Array.isArray(host)) fail(`host '${id}' must be an object`);
|
|
38
|
+
const bools = ["hasNativeSubagents", "instructionsOnly", "supportsMcp"];
|
|
39
|
+
for (const k of bools) {
|
|
40
|
+
if (typeof host[k] !== "boolean") fail(`host '${id}.${k}' must be a boolean`);
|
|
41
|
+
}
|
|
42
|
+
if (typeof host.displayName !== "string" || !host.displayName) fail(`host '${id}.displayName' must be a non-empty string`);
|
|
43
|
+
if (!CONFIG_FORMATS.includes(host.configFormat)) fail(`host '${id}.configFormat' must be one of ${JSON.stringify(CONFIG_FORMATS)}`);
|
|
44
|
+
if (!PROVISIONING.includes(host.localModelProvisioning)) fail(`host '${id}.localModelProvisioning' must be one of ${JSON.stringify(PROVISIONING)}`);
|
|
45
|
+
if (!host.hooks || typeof host.hooks !== "object") fail(`host '${id}.hooks' must be an object`);
|
|
46
|
+
if (typeof host.hooks.supported !== "boolean") fail(`host '${id}.hooks.supported' must be a boolean`);
|
|
47
|
+
if (!Array.isArray(host.hooks.globalAllowlist)) fail(`host '${id}.hooks.globalAllowlist' must be an array`);
|
|
48
|
+
if (!Array.isArray(host.globalMcpAllowlist)) fail(`host '${id}.globalMcpAllowlist' must be an array`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let cached = null;
|
|
52
|
+
|
|
53
|
+
export function loadCapabilities() {
|
|
54
|
+
if (cached) return cached;
|
|
55
|
+
let raw;
|
|
56
|
+
try {
|
|
57
|
+
raw = JSON.parse(fs.readFileSync(REGISTRY_PATH, "utf8"));
|
|
58
|
+
} catch (err) {
|
|
59
|
+
fail(`could not read/parse ${REGISTRY_PATH}: ${err.message}`);
|
|
60
|
+
}
|
|
61
|
+
if (raw.version !== 1) fail(`unsupported version ${raw.version}`);
|
|
62
|
+
if (!raw.hosts || typeof raw.hosts !== "object") fail("missing hosts object");
|
|
63
|
+
for (const [id, host] of Object.entries(raw.hosts)) validateHost(id, host);
|
|
64
|
+
cached = raw;
|
|
65
|
+
return raw;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// HOST_KEYS — the canonical host enumeration, derived from the registry so it
|
|
69
|
+
// has exactly one definition. Order is the registry's declaration order.
|
|
70
|
+
|
|
71
|
+
export const HOST_KEYS = Object.keys(loadCapabilities().hosts);
|
|
72
|
+
|
|
73
|
+
export function getCapability(hostKey) {
|
|
74
|
+
const host = loadCapabilities().hosts[hostKey];
|
|
75
|
+
if (!host) fail(`unknown host '${hostKey}' (known: ${HOST_KEYS.join(", ")})`);
|
|
76
|
+
return host;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function hasNativeSubagents(hostKey) {
|
|
80
|
+
return getCapability(hostKey).hasNativeSubagents;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// displayName-to-key lookup, built from the registry — the one place sync and
|
|
84
|
+
// init resolve a detected host's display name to its canonical key.
|
|
85
|
+
|
|
86
|
+
export function displayNameToKey() {
|
|
87
|
+
const map = {};
|
|
88
|
+
for (const [key, host] of Object.entries(loadCapabilities().hosts)) {
|
|
89
|
+
map[host.displayName] = key;
|
|
90
|
+
}
|
|
91
|
+
return map;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function globalHookAllowlist(hostKey) {
|
|
95
|
+
return new Set(getCapability(hostKey).hooks.globalAllowlist);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function globalMcpAllowlist(hostKey) {
|
|
99
|
+
return new Set(getCapability(hostKey).globalMcpAllowlist);
|
|
100
|
+
}
|
package/lib/prompt-composer.js
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
resolveModelOperatingProfile,
|
|
28
28
|
selectModelTierForWorkCategory,
|
|
29
29
|
} from './model-router.mjs';
|
|
30
|
-
import {
|
|
30
|
+
import { listObservations } from './observation-store.mjs';
|
|
31
31
|
import { routeRequest } from './orchestration-policy.mjs';
|
|
32
32
|
import { resolvePromptEntry, resolvePromptMetadata } from './prompt-metadata.mjs';
|
|
33
33
|
import { readRoleFile } from './role-preload.mjs';
|
|
@@ -91,14 +91,14 @@ function compactTokens(text, tokenLimit = 300, { modelId = 'default' } = {}) {
|
|
|
91
91
|
return `${normalized.slice(0, cutIdx)}…`;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
function readPromptBody(promptFile, rootDir) {
|
|
94
|
+
export function readPromptBody(promptFile, rootDir) {
|
|
95
95
|
const filePath = path.join(rootDir, promptFile);
|
|
96
96
|
if (!fs.existsSync(filePath)) return '';
|
|
97
97
|
const raw = fs.readFileSync(filePath, 'utf8');
|
|
98
98
|
return stripLeadingYamlFrontmatter(raw).trim();
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
function stripLeadingYamlFrontmatter(content) {
|
|
101
|
+
export function stripLeadingYamlFrontmatter(content) {
|
|
102
102
|
if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) return content;
|
|
103
103
|
const closeIdx = content.indexOf('\n---', 4);
|
|
104
104
|
if (closeIdx === -1) return content;
|
|
@@ -108,18 +108,22 @@ function stripLeadingYamlFrontmatter(content) {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
function buildLearnedPatternsBlock(agentName, {
|
|
111
|
-
intent = null,
|
|
112
|
-
workCategory = null,
|
|
113
111
|
project = null,
|
|
114
112
|
modelId = 'default',
|
|
115
113
|
tokenLimit = MODEL_OPERATING_PROFILES.balanced.learnedPatternsTokens,
|
|
116
114
|
} = {}) {
|
|
117
115
|
try {
|
|
118
116
|
const rootDir = homedir();
|
|
119
|
-
const query = [agentName, intent, workCategory].filter(Boolean).join(' ');
|
|
120
117
|
const shortName = String(agentName).replace(/^cx-/, '');
|
|
121
|
-
|
|
122
|
-
|
|
118
|
+
|
|
119
|
+
// Prompt composition runs at sync time (config generation). A live semantic
|
|
120
|
+
// search here would load the 90MB embedding model and stall sync for ~80s on
|
|
121
|
+
// a cold cache (and searchObservations is async — its Promise can't be
|
|
122
|
+
// consumed from this synchronous function). Read the durable observation
|
|
123
|
+
// index synchronously and rank by confidence + role match instead.
|
|
124
|
+
|
|
125
|
+
const results = listObservations(rootDir, {
|
|
126
|
+
limit: MAX_OBSERVATIONS * 4,
|
|
123
127
|
project: project ?? null,
|
|
124
128
|
});
|
|
125
129
|
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
injectIntoAgentFile,
|
|
23
23
|
injectConstructBlock,
|
|
24
24
|
buildConstructIntegrationBody,
|
|
25
|
+
variantForFile,
|
|
25
26
|
CONSTRUCT_INTEGRATION_VERSION,
|
|
26
27
|
} from '../agent-instructions/inject.mjs';
|
|
27
28
|
|
|
@@ -37,8 +38,11 @@ function hasIntegrationBlock(content) {
|
|
|
37
38
|
// dedups against a sibling Beads block, so the comparison rebuilds the body
|
|
38
39
|
// the same way injectIntoAgentFile does before hashing.
|
|
39
40
|
|
|
40
|
-
function wouldChange(content) {
|
|
41
|
-
const body = buildConstructIntegrationBody({
|
|
41
|
+
function wouldChange(content, name) {
|
|
42
|
+
const body = buildConstructIntegrationBody({
|
|
43
|
+
hasBeadsBlock: BEADS_BLOCK_RE.test(content),
|
|
44
|
+
variant: variantForFile(name),
|
|
45
|
+
});
|
|
42
46
|
const { action } = injectConstructBlock(content, body, CONSTRUCT_INTEGRATION_VERSION);
|
|
43
47
|
return action !== 'unchanged';
|
|
44
48
|
}
|
|
@@ -66,7 +70,7 @@ async function detect() {
|
|
|
66
70
|
if (candidates.length === 0) {
|
|
67
71
|
return { needsRepair: false, summary: 'No agent file carries a CONSTRUCT INTEGRATION block.' };
|
|
68
72
|
}
|
|
69
|
-
const stale = candidates.filter((c) => wouldChange(c.content)).map((c) => c.name);
|
|
73
|
+
const stale = candidates.filter((c) => wouldChange(c.content, c.name)).map((c) => c.name);
|
|
70
74
|
if (stale.length === 0) {
|
|
71
75
|
return { needsRepair: false, summary: 'CONSTRUCT INTEGRATION blocks are current.' };
|
|
72
76
|
}
|
|
@@ -81,7 +85,7 @@ async function apply() {
|
|
|
81
85
|
const candidates = candidateFiles();
|
|
82
86
|
const rewrapped = [];
|
|
83
87
|
for (const c of candidates) {
|
|
84
|
-
if (!wouldChange(c.content)) continue;
|
|
88
|
+
if (!wouldChange(c.content, c.name)) continue;
|
|
85
89
|
const result = injectIntoAgentFile(c.full, { version: CONSTRUCT_INTEGRATION_VERSION });
|
|
86
90
|
if (result.changed) rewrapped.push(c.name);
|
|
87
91
|
}
|
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';
|
|
@@ -7,8 +7,15 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Caller responsibility: read the transcript file and pass parsed lines.
|
|
9
9
|
* Output is capped at MAX_BYTES to honor the A1 hook's per-session budget.
|
|
10
|
+
*
|
|
11
|
+
* Salience: the observation's confidence is the session's deterministic salience
|
|
12
|
+
* (see lib/reflect/salience.mjs) rather than a flat value, so low-value sessions are
|
|
13
|
+
* remembered weakly and age out of the live set first under consolidation; a session
|
|
14
|
+
* with no durable signal is not stored at all.
|
|
10
15
|
*/
|
|
11
16
|
|
|
17
|
+
import { scoreSalience, shouldStore } from './salience.mjs';
|
|
18
|
+
|
|
12
19
|
// Hard cap on the summary content so a runaway session can't blow up the
|
|
13
20
|
// observation store. 5 KB matches the budget set in the A1 plan.
|
|
14
21
|
const MAX_BYTES = 5 * 1024;
|
|
@@ -32,6 +39,10 @@ export function extractSessionObservation({ entries, cwd, sessionId, durationMs
|
|
|
32
39
|
const stats = collectStats(entries);
|
|
33
40
|
if (stats.assistantTurns === 0 && stats.userTurns === 0) return null;
|
|
34
41
|
|
|
42
|
+
// Extraction decision: a session with no durable signal is noise, not memory.
|
|
43
|
+
if (!shouldStore(stats)) return null;
|
|
44
|
+
const { salience, signals: salienceSignals } = scoreSalience(stats);
|
|
45
|
+
|
|
35
46
|
const finalAssistant = stats.lastAssistantText;
|
|
36
47
|
const headline = deriveHeadline(finalAssistant, stats);
|
|
37
48
|
|
|
@@ -64,7 +75,7 @@ export function extractSessionObservation({ entries, cwd, sessionId, durationMs
|
|
|
64
75
|
summary: headline,
|
|
65
76
|
content,
|
|
66
77
|
tags: buildTags(stats),
|
|
67
|
-
confidence:
|
|
78
|
+
confidence: salience,
|
|
68
79
|
source: 'auto-reflect',
|
|
69
80
|
extras: {
|
|
70
81
|
sessionId: sessionId || null,
|
|
@@ -74,6 +85,8 @@ export function extractSessionObservation({ entries, cwd, sessionId, durationMs
|
|
|
74
85
|
assistantTurns: stats.assistantTurns,
|
|
75
86
|
toolCallCount: stats.toolCallCount,
|
|
76
87
|
filesTouched: Array.from(stats.filesTouched).slice(0, MAX_FILES),
|
|
88
|
+
salience,
|
|
89
|
+
salienceSignals,
|
|
77
90
|
},
|
|
78
91
|
};
|
|
79
92
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/reflect/salience.mjs — deterministic "is this worth remembering" score.
|
|
3
|
+
*
|
|
4
|
+
* The transcript extractor produces one observation per session and stamped every
|
|
5
|
+
* one with a flat confidence, so a session that changed the codebase and a trivial
|
|
6
|
+
* read-only exchange were remembered with equal weight (the mem0/Letta "salience"
|
|
7
|
+
* gap). This scores a session's durable value from signals already collected — what
|
|
8
|
+
* tools ran, whether files were mutated, how substantive the exchange was — with no
|
|
9
|
+
* LLM, matching Construct's offline-first posture.
|
|
10
|
+
*
|
|
11
|
+
* The score feeds the observation's `confidence`, so the existing consolidation pass
|
|
12
|
+
* carries retention on its own — no second mechanism. Consolidation archives a session
|
|
13
|
+
* only once both older than `archiveAfterDays` and below `archiveBelowConfidence` (0.5),
|
|
14
|
+
* so a low-salience session ages out of the live set while a high-salience one survives.
|
|
15
|
+
* `shouldStore` is the extraction decision: a session with no durable signal at all is
|
|
16
|
+
* not worth an observation.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// Tools that change durable state are the strongest salience signal; a session
|
|
20
|
+
// that edits the tree is worth more than one that only reads it.
|
|
21
|
+
const MUTATING_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
|
|
22
|
+
const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LS']);
|
|
23
|
+
|
|
24
|
+
function countByPredicate(toolTypes, pred) {
|
|
25
|
+
let n = 0;
|
|
26
|
+
for (const [name, count] of toolTypes ?? new Map()) if (pred(name)) n += count;
|
|
27
|
+
return n;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Score a session's salience in [0.05, 0.95] from its collected stats.
|
|
32
|
+
* @param {{toolTypes?: Map<string,number>, toolCallCount?: number, assistantTurns?: number, filesTouched?: Set<any>}} stats
|
|
33
|
+
* @returns {{salience: number, signals: string[]}}
|
|
34
|
+
*/
|
|
35
|
+
export function scoreSalience(stats = {}) {
|
|
36
|
+
const toolTypes = stats.toolTypes ?? new Map();
|
|
37
|
+
const mutations = countByPredicate(toolTypes, (t) => MUTATING_TOOLS.has(t));
|
|
38
|
+
const reads = countByPredicate(toolTypes, (t) => READ_TOOLS.has(t));
|
|
39
|
+
const bash = countByPredicate(toolTypes, (t) => t === 'Bash');
|
|
40
|
+
const assistantTurns = stats.assistantTurns ?? 0;
|
|
41
|
+
const filesTouched = stats.filesTouched?.size ?? 0;
|
|
42
|
+
|
|
43
|
+
const signals = [];
|
|
44
|
+
let score = 0.25;
|
|
45
|
+
if (mutations > 0) { score += 0.45; signals.push(`mutated ${mutations}× (durable change)`); }
|
|
46
|
+
else if (bash > 0) { score += 0.15; signals.push(`ran ${bash} bash command(s)`); }
|
|
47
|
+
else if (reads > 0) { signals.push('read-only/exploratory'); }
|
|
48
|
+
if (assistantTurns >= 5) { score += 0.1; signals.push(`${assistantTurns} assistant turns (substantive)`); }
|
|
49
|
+
if (filesTouched >= 3) { score += 0.05; signals.push(`${filesTouched} files in scope`); }
|
|
50
|
+
|
|
51
|
+
const salience = Math.max(0.05, Math.min(0.95, Number(score.toFixed(2))));
|
|
52
|
+
return { salience, signals };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The extraction decision: a session carrying no durable signal — no mutations, no
|
|
57
|
+
* bash, and barely any exchange — is noise, not memory, and is not stored.
|
|
58
|
+
*/
|
|
59
|
+
export function shouldStore(stats = {}) {
|
|
60
|
+
const toolTypes = stats.toolTypes ?? new Map();
|
|
61
|
+
const mutations = countByPredicate(toolTypes, (t) => MUTATING_TOOLS.has(t));
|
|
62
|
+
const bash = countByPredicate(toolTypes, (t) => t === 'Bash');
|
|
63
|
+
const assistantTurns = stats.assistantTurns ?? 0;
|
|
64
|
+
return mutations > 0 || bash > 0 || assistantTurns >= 2;
|
|
65
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/rules-delivery.mjs — deliver glob-scoped rules to hosts with a native rule format.
|
|
3
|
+
*
|
|
4
|
+
* Cursor is the one supported host with a native per-rule glob convention
|
|
5
|
+
* (`.cursor/rules/*.mdc`, comma-separated `globs` frontmatter, auto-attached when a
|
|
6
|
+
* matching file enters context). Project sync emits one managed `.mdc` per
|
|
7
|
+
* glob-scoped rule (`rules/<dir>/<name>.md` with `paths:` frontmatter) whose globs
|
|
8
|
+
* match files actually present in the project — the project's own contents are the
|
|
9
|
+
* intent signal, so a Go rule lands only in a repo that contains Go. Emitted files
|
|
10
|
+
* carry the `construct-` prefix and are swept when their rule stops matching, so
|
|
11
|
+
* user-authored .mdc files are never touched (ADR-0027 ownership contract).
|
|
12
|
+
* Claude/Codex/OpenCode have no per-rule glob mechanism; for them rules remain
|
|
13
|
+
* reference-delivered (cited by path in prose) — see docs/concepts/rules-delivery.md.
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.cx', 'dist', 'build', '.next', 'vendor', '.beads']);
|
|
19
|
+
const MAX_FILES = 5000;
|
|
20
|
+
|
|
21
|
+
function parseRule(absPath) {
|
|
22
|
+
const raw = fs.readFileSync(absPath, 'utf8');
|
|
23
|
+
const fm = raw.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
24
|
+
if (!fm) return null;
|
|
25
|
+
const globs = [...fm[1].matchAll(/^\s*-\s+"([^"]+)"\s*$/gm)].map((m) => m[1]);
|
|
26
|
+
if (!/^paths:/m.test(fm[1]) || globs.length === 0) return null;
|
|
27
|
+
const desc = fm[1].match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? '';
|
|
28
|
+
return { globs, description: desc, body: raw.slice(fm[0].length).trim() };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function collectGlobScopedRules(rulesDir) {
|
|
32
|
+
const out = [];
|
|
33
|
+
const walk = (dir, rel = '') => {
|
|
34
|
+
let ents;
|
|
35
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
36
|
+
for (const e of ents) {
|
|
37
|
+
const full = path.join(dir, e.name);
|
|
38
|
+
const childRel = rel ? `${rel}/${e.name}` : e.name;
|
|
39
|
+
if (e.isDirectory()) walk(full, childRel);
|
|
40
|
+
else if (e.name.endsWith('.md')) {
|
|
41
|
+
const parsed = parseRule(full);
|
|
42
|
+
if (parsed) out.push({ rule: childRel.replace(/\.md$/, ''), ...parsed });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
walk(rulesDir);
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The rule corpus uses simple glob shapes (`**/*.go`, `**/go.mod`, `*.md`); this
|
|
51
|
+
// translates exactly those: `**/` spans directories, `*` stays within a segment.
|
|
52
|
+
|
|
53
|
+
export function globToRegExp(glob) {
|
|
54
|
+
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
55
|
+
.replace(/\*\*\//g, '\u0001')
|
|
56
|
+
.replace(/\*/g, '[^/]*')
|
|
57
|
+
.replace(/\u0001/g, '(?:.*/)?');
|
|
58
|
+
return new RegExp(`^${escaped}$`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function listProjectFiles(targetDir, { maxFiles = MAX_FILES } = {}) {
|
|
62
|
+
const files = [];
|
|
63
|
+
const walk = (dir, rel = '') => {
|
|
64
|
+
if (files.length >= maxFiles) return;
|
|
65
|
+
let ents;
|
|
66
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
67
|
+
for (const e of ents) {
|
|
68
|
+
if (files.length >= maxFiles) return;
|
|
69
|
+
if (e.isDirectory()) {
|
|
70
|
+
if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) walk(path.join(dir, e.name), rel ? `${rel}/${e.name}` : e.name);
|
|
71
|
+
} else {
|
|
72
|
+
files.push(rel ? `${rel}/${e.name}` : e.name);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
walk(targetDir);
|
|
77
|
+
return files;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Emit managed per-rule .mdc files into <targetDir>/.cursor/rules/ for every
|
|
82
|
+
* glob-scoped rule matching the project's own files, and sweep managed files
|
|
83
|
+
* whose rule stopped matching. Returns { emitted, swept }.
|
|
84
|
+
*/
|
|
85
|
+
export function emitCursorRules({ rulesDir, targetDir, dryRun = false } = {}) {
|
|
86
|
+
const rules = collectGlobScopedRules(rulesDir);
|
|
87
|
+
const files = listProjectFiles(targetDir);
|
|
88
|
+
const outDir = path.join(targetDir, '.cursor', 'rules');
|
|
89
|
+
|
|
90
|
+
const wanted = new Map();
|
|
91
|
+
for (const r of rules) {
|
|
92
|
+
const regs = r.globs.map(globToRegExp);
|
|
93
|
+
if (!files.some((f) => regs.some((re) => re.test(f)))) continue;
|
|
94
|
+
const name = `construct-${r.rule.replace(/\//g, '-')}.mdc`;
|
|
95
|
+
const mdc = `---\ndescription: ${r.description}\nglobs: ${r.globs.join(', ')}\nalwaysApply: false\n---\n\n${r.body}\n`;
|
|
96
|
+
wanted.set(name, mdc);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const emitted = [];
|
|
100
|
+
const swept = [];
|
|
101
|
+
if (!dryRun) {
|
|
102
|
+
if (wanted.size > 0) fs.mkdirSync(outDir, { recursive: true });
|
|
103
|
+
for (const [name, mdc] of wanted) {
|
|
104
|
+
const dest = path.join(outDir, name);
|
|
105
|
+
if (!fs.existsSync(dest) || fs.readFileSync(dest, 'utf8') !== mdc) {
|
|
106
|
+
fs.writeFileSync(dest, mdc);
|
|
107
|
+
emitted.push(name);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Sweep only construct-<rule>.mdc files this module owns; construct.mdc (the
|
|
111
|
+
// front-door pointer) and user-authored files are never touched.
|
|
112
|
+
let existing = [];
|
|
113
|
+
try { existing = fs.readdirSync(outDir); } catch { /* no rules dir */ }
|
|
114
|
+
for (const name of existing) {
|
|
115
|
+
if (name.startsWith('construct-') && name.endsWith('.mdc') && !wanted.has(name)) {
|
|
116
|
+
fs.rmSync(path.join(outDir, name), { force: true });
|
|
117
|
+
swept.push(name);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { emitted, swept, matched: [...wanted.keys()] };
|
|
122
|
+
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* sidecar without relying on PATH state.
|
|
18
18
|
*/
|
|
19
19
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
-
import { spawnSync } from 'node:child_process';
|
|
20
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
21
21
|
import path from 'node:path';
|
|
22
22
|
import os from 'node:os';
|
|
23
23
|
|
|
@@ -37,6 +37,31 @@ function which(bin) {
|
|
|
37
37
|
return result.stdout.trim().split('\n')[0] || null;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// Provisioning runs on the request path (the first docling ingest), including
|
|
41
|
+
// inside the long-lived MCP server. spawnSync would block the event loop for the
|
|
42
|
+
// whole multi-minute install — freezing every other request and defeating the
|
|
43
|
+
// per-tool dispatch timeout. Run each step via async spawn instead, shaped to
|
|
44
|
+
// mirror spawnSync's result so describeStepFailure reads it unchanged.
|
|
45
|
+
|
|
46
|
+
function runAsync(cmd, args, { timeoutMs, env } = {}) {
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
const child = spawn(cmd, args, { env });
|
|
49
|
+
let stdout = '';
|
|
50
|
+
let stderr = '';
|
|
51
|
+
let timedOut = false;
|
|
52
|
+
child.stdout?.setEncoding('utf8');
|
|
53
|
+
child.stderr?.setEncoding('utf8');
|
|
54
|
+
child.stdout?.on('data', (d) => { stdout += d; });
|
|
55
|
+
child.stderr?.on('data', (d) => { stderr += d; });
|
|
56
|
+
const timer = timeoutMs ? setTimeout(() => { timedOut = true; child.kill('SIGTERM'); }, timeoutMs) : null;
|
|
57
|
+
child.on('error', (error) => { if (timer) clearTimeout(timer); resolve({ status: null, stdout, stderr, error, signal: null }); });
|
|
58
|
+
child.on('close', (code, signal) => {
|
|
59
|
+
if (timer) clearTimeout(timer);
|
|
60
|
+
resolve({ status: code, stdout, stderr, signal: timedOut ? 'SIGTERM' : signal, error: timedOut ? { code: 'ETIMEDOUT' } : null });
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
40
65
|
// First-run docling provisioning blocks for minutes (uv install, venv, then a
|
|
41
66
|
// large `uv pip install docling` that pulls ML deps). Without a heartbeat it
|
|
42
67
|
// reads as a hang. Progress goes to stderr only, so JSON consumers on stdout are
|
|
@@ -56,7 +81,7 @@ function describeStepFailure(label, result, timeoutMs) {
|
|
|
56
81
|
return `${label} failed (${result.status}): ${result.stderr || result.stdout || result.error?.message || 'unknown error'}`;
|
|
57
82
|
}
|
|
58
83
|
|
|
59
|
-
function ensureUv(installDir) {
|
|
84
|
+
async function ensureUv(installDir) {
|
|
60
85
|
const fromPath = which('uv');
|
|
61
86
|
if (fromPath) return fromPath;
|
|
62
87
|
const cachedUv = path.join(installDir, 'bin', 'uv');
|
|
@@ -64,11 +89,7 @@ function ensureUv(installDir) {
|
|
|
64
89
|
mkdirSync(installDir, { recursive: true });
|
|
65
90
|
progress('Installing uv (Python toolchain) — first run only…');
|
|
66
91
|
const env = { ...process.env, UV_INSTALL_DIR: path.join(installDir, 'bin'), UV_NO_MODIFY_PATH: '1' };
|
|
67
|
-
const sh =
|
|
68
|
-
env,
|
|
69
|
-
timeout: UV_TIMEOUT_MS,
|
|
70
|
-
encoding: 'utf8',
|
|
71
|
-
});
|
|
92
|
+
const sh = await runAsync('sh', ['-c', `curl -LsSf ${UV_INSTALL_URL} | sh`], { env, timeoutMs: UV_TIMEOUT_MS });
|
|
72
93
|
if (sh.status !== 0) {
|
|
73
94
|
throw new Error(describeStepFailure('uv install', sh, UV_TIMEOUT_MS));
|
|
74
95
|
}
|
|
@@ -95,7 +116,7 @@ function writeMarker(markerPath, payload) {
|
|
|
95
116
|
writeFileSync(markerPath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
96
117
|
}
|
|
97
118
|
|
|
98
|
-
export function ensureDoclingVenv({ runtimeDir = defaultRuntimeDir(), force = false } = {}) {
|
|
119
|
+
export async function ensureDoclingVenv({ runtimeDir = defaultRuntimeDir(), force = false } = {}) {
|
|
99
120
|
mkdirSync(runtimeDir, { recursive: true });
|
|
100
121
|
const venvDir = path.join(runtimeDir, '.venv');
|
|
101
122
|
const markerPath = path.join(runtimeDir, '.install-marker.json');
|
|
@@ -107,22 +128,16 @@ export function ensureDoclingVenv({ runtimeDir = defaultRuntimeDir(), force = fa
|
|
|
107
128
|
}
|
|
108
129
|
|
|
109
130
|
progress('Provisioning the docling document extractor (first run). This downloads a Python runtime and ML dependencies and can take several minutes; later runs are instant.');
|
|
110
|
-
const uv = ensureUv(runtimeDir);
|
|
131
|
+
const uv = await ensureUv(runtimeDir);
|
|
111
132
|
|
|
112
133
|
progress('Creating Python 3.11 virtualenv…');
|
|
113
|
-
const venvResult =
|
|
114
|
-
timeout: VENV_TIMEOUT_MS,
|
|
115
|
-
encoding: 'utf8',
|
|
116
|
-
});
|
|
134
|
+
const venvResult = await runAsync(uv, ['venv', venvDir, '--python', '3.11'], { timeoutMs: VENV_TIMEOUT_MS });
|
|
117
135
|
if (venvResult.status !== 0) {
|
|
118
136
|
throw new Error(describeStepFailure('uv venv', venvResult, VENV_TIMEOUT_MS));
|
|
119
137
|
}
|
|
120
138
|
|
|
121
139
|
progress(`Installing docling ${DOCLING_PIN} and its dependencies — the slow step (large download)…`);
|
|
122
|
-
const installResult =
|
|
123
|
-
timeout: INSTALL_TIMEOUT_MS,
|
|
124
|
-
encoding: 'utf8',
|
|
125
|
-
});
|
|
140
|
+
const installResult = await runAsync(uv, ['pip', 'install', '--python', pythonBinFor(venvDir), `docling==${DOCLING_PIN}`], { timeoutMs: INSTALL_TIMEOUT_MS });
|
|
126
141
|
if (installResult.status !== 0) {
|
|
127
142
|
throw new Error(describeStepFailure('docling install', installResult, INSTALL_TIMEOUT_MS));
|
|
128
143
|
}
|