@geraldmaron/construct 1.0.24 → 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.
Files changed (60) hide show
  1. package/bin/construct +185 -3
  2. package/lib/agent-instructions/inject.mjs +25 -4
  3. package/lib/audit-rules.mjs +127 -0
  4. package/lib/audit-skills.mjs +43 -1
  5. package/lib/beads-client.mjs +9 -0
  6. package/lib/beads-optimistic.mjs +23 -71
  7. package/lib/bridges/copilot-proxy.mjs +116 -0
  8. package/lib/cli-commands.mjs +5 -1
  9. package/lib/comment-lint.mjs +1 -1
  10. package/lib/config/schema.mjs +1 -1
  11. package/lib/document-extract/docling-client.mjs +16 -6
  12. package/lib/document-extract/docling-sidecar.py +32 -2
  13. package/lib/document-extract.mjs +37 -10
  14. package/lib/document-ingest.mjs +90 -5
  15. package/lib/embed/roadmap.mjs +16 -1
  16. package/lib/engine/consolidate.mjs +160 -3
  17. package/lib/engine/contradiction-judge.mjs +71 -0
  18. package/lib/engine/contradiction.mjs +74 -0
  19. package/lib/host-capabilities.mjs +30 -0
  20. package/lib/ingest/docling-remote.mjs +90 -0
  21. package/lib/ingest/strategy.mjs +1 -1
  22. package/lib/init-unified.mjs +9 -13
  23. package/lib/logging/rotate.mjs +18 -0
  24. package/lib/mcp/server.mjs +124 -12
  25. package/lib/mcp/tool-budget.mjs +53 -0
  26. package/lib/mcp-catalog.json +1 -1
  27. package/lib/ollama/capability-store.mjs +78 -0
  28. package/lib/ollama/provision-context.mjs +344 -0
  29. package/lib/opencode-config.mjs +148 -0
  30. package/lib/opencode-telemetry.mjs +7 -0
  31. package/lib/orchestration-policy.mjs +41 -6
  32. package/lib/platforms/capabilities.mjs +100 -0
  33. package/lib/prompt-composer.js +12 -8
  34. package/lib/reconcile/agent-instructions-rewrap.mjs +8 -4
  35. package/lib/reflect/extractor.mjs +14 -1
  36. package/lib/reflect/salience.mjs +65 -0
  37. package/lib/rules-delivery.mjs +122 -0
  38. package/lib/runtime/uv-bootstrap.mjs +32 -17
  39. package/lib/service-manager.mjs +41 -3
  40. package/lib/setup.mjs +21 -2
  41. package/lib/specialists/prompt-schema.mjs +162 -0
  42. package/lib/specialists/scaffold.mjs +109 -0
  43. package/lib/storage/embeddings-engine.mjs +19 -5
  44. package/lib/telemetry/beads-fallback.mjs +40 -0
  45. package/lib/telemetry/hook-calls.mjs +138 -0
  46. package/package.json +1 -1
  47. package/personas/construct.md +1 -1
  48. package/platforms/capabilities.json +76 -0
  49. package/platforms/opencode/sync-config.mjs +121 -25
  50. package/rules/common/neurodivergent-output.md +1 -1
  51. package/rules/web/coding-style.md +8 -0
  52. package/rules/web/design-quality.md +8 -0
  53. package/rules/web/hooks.md +8 -0
  54. package/rules/web/patterns.md +8 -0
  55. package/rules/web/performance.md +8 -0
  56. package/rules/web/security.md +8 -0
  57. package/rules/web/testing.md +8 -0
  58. package/scripts/sync-specialists.mjs +139 -39
  59. package/specialists/prompts/cx-architect.md +20 -0
  60. package/specialists/prompts/cx-test-automation.md +12 -0
@@ -9,6 +9,8 @@
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
11
  import os from "node:os";
12
+ import { detectActiveSessions } from "./host-capabilities.mjs";
13
+ import { getUserEnvPath, parseEnvFile } from "./env-config.mjs";
12
14
 
13
15
  export function getOpenCodeConfigDir() {
14
16
  return path.join(os.homedir(), ".config", "opencode");
@@ -46,6 +48,63 @@ export function sanitizeOpenCodeConfig(config) {
46
48
  return sanitized;
47
49
  }
48
50
 
51
+ /**
52
+ * Maps Construct internal tiers (fast, standard, reasoning) to OpenCode model configurations.
53
+ */
54
+ export function mapConstructModelsToOpenCode(models = {}) {
55
+ const opencodeModels = [];
56
+
57
+ const isLocal = (m) => m && (m.includes("localhost") || m.includes("127.0.0.1") || m.startsWith("github-copilot/") || m.includes("ollama"));
58
+
59
+ const getProvider = (m) => {
60
+ if (m && m.startsWith("github-copilot/")) return "github-copilot";
61
+ if (m && m.includes("ollama")) return "ollama";
62
+ return isLocal(m) ? "ollama" : "openrouter";
63
+ };
64
+
65
+ const getLimit = (m, tier) => {
66
+ if (!isLocal(m)) return { context: 32768, output: 8192 };
67
+
68
+ // Scale local context based on tier/size to prevent VRAM saturation
69
+ const isLarge = m.includes("30b") || m.includes("32b") || m.includes("70b") || tier === "reasoning";
70
+ if (tier === "fast") return { context: 8192, output: 4096 };
71
+ if (isLarge) return { context: 16384, output: 4096 }; // "Sweet spot" for large local models
72
+ return { context: 12288, output: 4096 };
73
+ };
74
+
75
+ if (models.fast) {
76
+ opencodeModels.push({
77
+ id: "fast",
78
+ name: models.fast,
79
+ provider: getProvider(models.fast),
80
+ default: false,
81
+ limit: getLimit(models.fast, "fast")
82
+ });
83
+ }
84
+
85
+ if (models.standard) {
86
+ opencodeModels.push({
87
+ id: "standard",
88
+ name: models.standard,
89
+ provider: getProvider(models.standard),
90
+ default: true,
91
+ limit: getLimit(models.standard, "standard")
92
+ });
93
+ }
94
+
95
+ if (models.reasoning) {
96
+ opencodeModels.push({
97
+ id: "reasoning",
98
+ name: models.reasoning,
99
+ provider: getProvider(models.reasoning),
100
+ default: false,
101
+ limit: getLimit(models.reasoning, "reasoning")
102
+ });
103
+ }
104
+
105
+ return opencodeModels;
106
+ }
107
+
49
108
  export function writeOpenCodeConfig(config, file = findOpenCodeConfigPath()) {
50
109
  // Treat the canonical home config as the default target; any explicit path
51
110
  // outside the canonical home dir (e.g. project-scoped .opencode/opencode.json)
@@ -55,7 +114,96 @@ export function writeOpenCodeConfig(config, file = findOpenCodeConfigPath()) {
55
114
  const target = file && path.resolve(file) !== path.resolve(findOpenCodeConfigPath())
56
115
  ? file
57
116
  : canonical;
117
+
118
+ // If the config contains a construct model mapping, apply it to the OpenCode format.
119
+ if (config.construct?.models) {
120
+ config.models = mapConstructModelsToOpenCode(config.construct.models);
121
+
122
+ // Ensure OpenRouter and Ollama providers are registered if needed.
123
+ config.provider = config.provider || {};
124
+ if (config.models.some(m => m.provider === 'openrouter')) {
125
+ config.provider.openrouter = config.provider.openrouter || {
126
+ baseUrl: "https://openrouter.ai/api/v1",
127
+ apiKey: "__OPENROUTER_API_KEY__"
128
+ };
129
+ }
130
+ if (config.models.some(m => m.provider === 'ollama')) {
131
+ config.provider.ollama = config.provider.ollama || {
132
+ baseUrl: "http://localhost:11434/v1"
133
+ };
134
+ }
135
+
136
+ // Handle bridge providers based on active sessions
137
+ const sessions = detectActiveSessions();
138
+ if (sessions.includes('github-copilot')) {
139
+ const envValues = parseEnvFile(getUserEnvPath());
140
+ const port = envValues.COPILOT_BRIDGE_PORT || '5174';
141
+ config.provider['github-copilot'] = config.provider['github-copilot'] || {
142
+ baseUrl: `http://localhost:${port}/v1`,
143
+ apiKey: "noop" // The bridge handles auth via gh CLI
144
+ };
145
+ }
146
+ }
147
+
58
148
  fs.mkdirSync(path.dirname(target), { recursive: true });
149
+
150
+ // Local-model tuning for the OpenCode → Ollama path (ADR-0002). The
151
+ // OpenAI-compatible `/v1` boundary OpenCode speaks drops Ollama-specific options
152
+ // (num_ctx, repeat_penalty) — setting them here is a silent no-op, so the real
153
+ // context window and repeat penalty are baked into Modelfile variants upstream
154
+ // (lib/ollama/provision-context.mjs) and registered by sync-config. Only
155
+ // OpenAI-standard params survive the boundary, so emit just those: temperature
156
+ // and ChatML stop sequences. limit.context is OpenCode's own compaction budget,
157
+ // set to the variant's real window. Never emit frequency/presence penalty — the
158
+ // wrong knob for Qwen and a source of repetition collapse.
159
+
160
+ const CHATML_STOPS = ["<|im_start|>", "<|im_end|>", "<|endoftext|>"];
161
+ const applyLocalOptimizations = (m, provider) => {
162
+ if (!m || typeof m !== "object") return;
163
+ const isLocal = provider === 'ollama' || provider === 'github-copilot' ||
164
+ m.provider === 'ollama' || m.provider === 'github-copilot';
165
+
166
+ if (isLocal) {
167
+ m.limit = m.limit || {};
168
+ m.limit.context = 32768;
169
+ m.limit.output = 4096;
170
+
171
+ m.options = m.options || {};
172
+ m.options.temperature = 0.1;
173
+ m.options.stop = CHATML_STOPS;
174
+ delete m.options.num_ctx;
175
+ delete m.options.repeat_penalty;
176
+ delete m.options.frequency_penalty;
177
+ delete m.options.presence_penalty;
178
+ delete m.options.frequencyPenalty;
179
+ delete m.options.presencePenalty;
180
+ }
181
+
182
+ // The temporary id/provider keys exist only to pass OpenCode schema validation
183
+ // during mapping; strip them before write.
184
+
185
+ delete m.id;
186
+ delete m.provider;
187
+ };
188
+
189
+ if (config.models && Array.isArray(config.models)) {
190
+ config.models.forEach(m => applyLocalOptimizations(m, m.provider));
191
+ }
192
+
193
+ if (config.provider) {
194
+ for (const [pId, p] of Object.entries(config.provider)) {
195
+ if (p.models) {
196
+ for (const m of Object.values(p.models)) {
197
+ applyLocalOptimizations(m, pId);
198
+ }
199
+ }
200
+ }
201
+ }
202
+
203
+ // Remove the root models array as it violates OpenCode's strict JSON schema.
204
+ // The relevant models should only be defined under provider.<name>.models
205
+ delete config.models;
206
+
59
207
  fs.writeFileSync(target, `${JSON.stringify(sanitizeOpenCodeConfig(config), null, 2)}\n`, "utf8");
60
208
  return target;
61
209
  }
@@ -177,6 +177,13 @@ export async function onChatMessage(input, output, { env = process.env } = {}) {
177
177
 
178
178
  // ── Hook: chat.params ───────────────────────────────────────────────────────
179
179
  export async function onChatParams(input, output, { env = process.env } = {}) {
180
+ // The tool surface is NOT mutable here. OpenCode 1.15.4's chat.params `output`
181
+ // carries only sampler params (temperature/topP/topK/maxOutputTokens/options)
182
+ // — there is no tool list to filter (verified empirically 2026-06-09). Surface
183
+ // reduction therefore happens at the two points that actually control it:
184
+ // construct-mcp's own ListTools (the lean core + construct_call gateway) and
185
+ // sync disabling the heavy external MCP servers in opencode.json for local
186
+ // setups. Do not re-add tool pruning to this hook — it is a no-op.
180
187
  const ingest = getIngestClient(env);
181
188
  if (!ingest.available) return;
182
189
  const sessionId = input?.sessionID;
@@ -123,10 +123,40 @@ export function extractNamedEntities(request = '') {
123
123
  });
124
124
  }
125
125
 
126
+ // Research-shaped intent is the vocabulary of external and landscape research —
127
+ // where the answer lives in papers, vendor docs, standards, or the market, not
128
+ // in the local code. Grouped by shape so the matched kind rides on the gate
129
+ // result and the decision is traceable. A false positive here is cheap (it only
130
+ // offers the cx-researcher specialist, with no routing latency and no heavy-flow
131
+ // coupling), so the set is tuned for recall; the precision floor is that a
132
+ // code-walkthrough of the user's own system ("explain how X works") carries none
133
+ // of these terms and stays answered from local context.
134
+ const RESEARCH_SHAPE_PATTERNS = [
135
+ ['comparative', [/\bcompare\b/, /\bcomparison\b/, /\bcompared to\b/, /\bversus\b/, /\bhead[\s-]?to[\s-]?head\b/, /\btrade[\s-]?offs?\b/, /\bpros and cons\b/, /\balternatives\b/, /\balternative to\b/]],
136
+ ['selection', [/\bbest (approach(es)?|option|tool|library|framework|pattern|way to|method)\b/, /\bwhich\b.{0,40}\b(should|to)\b.{0,15}\b(use|choose|pick|adopt|go with)\b/, /\brecommend(ed|ation)?\b.{0,30}\b(tool|library|framework|approach|stack|way|practice)\b/, /\boptions for\b/]],
137
+ ['landscape', [/\blandscape\b/, /\bstate[\s-]of[\s-]the[\s-]art\b/, /\bsurvey of\b/, /\boverview of\b/, /\becosystem\b/, /\bprior art\b/, /\bliterature\b/, /\bexisting (solutions|approaches|tools|work)\b/, /\bwhat'?s out there\b/]],
138
+ ['market', [/\bmarket (research|analysis|share|size|landscape|trends?)\b/, /\bcompetitive (analysis|landscape)\b/, /\bcompetitors?\b/, /\bindustry (standard|trends?|benchmarks?)\b/, /\bpricing (comparison|models?|tiers?)\b/, /\badoption (rate|trends?)\b/]],
139
+ ['benchmark', [/\bbenchmarks?\b/, /\bbenchmarking\b/, /\bevaluate (options|alternatives|tools|approaches)\b/, /\bperformance comparison\b/]],
140
+ ['standards', [/\bbest practices?\b/, /\bindustry standard\b/, /\bconventions? for\b/, /\brecommended (way|approach|practice)\b/, /\brfc\s?\d+\b/, /\bspecification for\b/]],
141
+ ];
142
+
143
+ /**
144
+ * Classify a request's research shape, or null when it carries none. The
145
+ * returned category names which kind of external research the prompt implies.
146
+ */
147
+ export function classifyResearchShape(request = '') {
148
+ const text = String(request).toLowerCase();
149
+ for (const [category, patterns] of RESEARCH_SHAPE_PATTERNS) {
150
+ if (includesAny(text, patterns)) return category;
151
+ }
152
+ return null;
153
+ }
154
+
126
155
  /**
127
156
  * Returns whether external research is required before scaffolding, with the
128
- * reason. Triggered by named entities not in the project glossary, or by
129
- * architecture / writing / research-driven work regardless of entities.
157
+ * reason. Triggered by named entities not in the project glossary, by
158
+ * architecture / writing / docs work, or by research-shaped intent regardless of
159
+ * entities.
130
160
  */
131
161
  export function requiresExternalResearch({ request = '', workCategory, riskFlags } = {}) {
132
162
  const entities = extractNamedEntities(request);
@@ -138,10 +168,15 @@ export function requiresExternalResearch({ request = '', workCategory, riskFlags
138
168
  if (category === WORK_CATEGORIES.writing || flags.architecture || flags.docs) {
139
169
  return { required: true, reason: 'writing-or-architecture' };
140
170
  }
141
- // Research intent alone doesn't force external-source research a simple
142
- // code walkthrough ("explain how X works") is research intent but doesn't
143
- // need primary-source citations. Only fire when combined with a broader
144
- // scope that implies the answer isn't in the immediate code context.
171
+ // Research-shaped intent (compare, landscape, market, standards, …) fires even
172
+ // without a named entity, since the answer is external. Bare research intent
173
+ // does not: a code walkthrough ("explain how X works", "understand the
174
+ // retrieval path") carries none of these terms and stays answered from local
175
+ // context — the distinction that keeps the gate from firing on every prompt.
176
+ const shape = classifyResearchShape(request);
177
+ if (shape) {
178
+ return { required: true, reason: 'research-shaped', shape };
179
+ }
145
180
  return { required: false };
146
181
  }
147
182
 
@@ -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
+ }
@@ -27,7 +27,7 @@ import {
27
27
  resolveModelOperatingProfile,
28
28
  selectModelTierForWorkCategory,
29
29
  } from './model-router.mjs';
30
- import { searchObservations } from './observation-store.mjs';
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
- const results = searchObservations(rootDir, query, {
122
- limit: MAX_OBSERVATIONS * 2,
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({ hasBeadsBlock: BEADS_BLOCK_RE.test(content) });
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
  }
@@ -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: 0.7,
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
+ }