@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
@@ -118,8 +118,12 @@ server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
118
118
  };
119
119
  });
120
120
 
121
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
122
- tools: [
121
+ // The full construct-mcp tool catalog. Only a curated core is exposed flat in
122
+ // ListTools; the long tail is reachable through the construct_call meta-tool, so
123
+ // the serialized tool surface stays small on every host and model. The 71-tool
124
+ // flat surface alone (~10.6k tokens) overran a 32k local-model window.
125
+
126
+ const ALL_TOOL_DEFS = [
123
127
  {
124
128
  name: 'agent_health',
125
129
  description: 'Returns agent health summaries from the most recent performance review.',
@@ -1209,17 +1213,60 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1209
1213
  },
1210
1214
  },
1211
1215
  },
1212
- ],
1213
- }));
1216
+ ];
1214
1217
 
1215
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
1216
- const { name, arguments: args = {} } = request.params;
1218
+ // Curated flat core: high-frequency, low-arg tools the orchestrator and the
1219
+ // built-in Build/Plan agents actually reach for. Everything else collapses
1220
+ // behind construct_call so the schema stays small. Universal — applies on every
1221
+ // host/model; the long tail is reachable, just not front-loaded.
1222
+
1223
+ const CORE_TOOL_NAMES = new Set([
1224
+ 'orchestration_policy', 'get_skill', 'search_skills', 'knowledge_search',
1225
+ 'memory_search', 'project_context', 'summarize_diff',
1226
+ ]);
1227
+
1228
+ function firstSentence(text) {
1229
+ const s = String(text || '').replace(/\s+/g, ' ').trim();
1230
+ const end = s.search(/\.(\s|$)/);
1231
+ return (end > 0 ? s.slice(0, end) : s).slice(0, 140);
1232
+ }
1233
+
1234
+ const LONG_TAIL_DEFS = ALL_TOOL_DEFS.filter((t) => !CORE_TOOL_NAMES.has(t.name));
1235
+
1236
+ // One dispatcher for the long tail. `tool` is constrained to an enum of valid
1237
+ // names (≈1 token each — kills hallucinated names, the key small-model lever),
1238
+ // and the description carries a compact one-line catalog instead of ~10k of full
1239
+ // schemas. Dispatch reuses the same handlers via dispatchToolByName.
1240
+
1241
+ const CONSTRUCT_CALL_TOOL = {
1242
+ name: 'construct_call',
1243
+ description:
1244
+ 'Invoke any non-core Construct tool by name. Put the tool name in `tool` and its arguments in `args`. '
1245
+ + 'Available tools:\n'
1246
+ + LONG_TAIL_DEFS.map((t) => `- ${t.name} — ${firstSentence(t.description)}`).join('\n'),
1247
+ inputSchema: {
1248
+ type: 'object',
1249
+ properties: {
1250
+ tool: { type: 'string', enum: LONG_TAIL_DEFS.map((t) => t.name), description: 'The Construct tool to invoke.' },
1251
+ args: { type: 'object', additionalProperties: true, description: 'Arguments object for the tool.' },
1252
+ },
1253
+ required: ['tool'],
1254
+ },
1255
+ };
1256
+
1257
+ export function exposedTools() {
1258
+ return [...ALL_TOOL_DEFS.filter((t) => CORE_TOOL_NAMES.has(t.name)), CONSTRUCT_CALL_TOOL];
1259
+ }
1217
1260
 
1218
- // Extract W3C traceparent from params._meta (SEP-414 propagation).
1219
- const parentCtx = await extractTraceContext(request.params?._meta || {});
1261
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: exposedTools() }));
1220
1262
 
1263
+ // Single dispatch table shared by the direct CallTool path and the construct_call
1264
+ // meta-tool, so collapsing the surface costs no capability — every tool stays
1265
+ // reachable by name. construct_call re-enters here once (guarded against
1266
+ // recursing into itself).
1267
+
1268
+ export async function dispatchToolByName(name, args = {}) {
1221
1269
  let result;
1222
- try {
1223
1270
  if (name === 'agent_health') result = agentHealth(args);
1224
1271
  else if (name === 'summarize_diff') result = summarizeDiff(args);
1225
1272
  else if (name === 'scan_file') result = scanFile(args);
@@ -1301,13 +1348,65 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1301
1348
  else if (name === 'construct_execution_resolve') result = executionResolve(args);
1302
1349
  else if (name === 'orchestration_run') { const m = await import('./tools/orchestration-run.mjs'); result = await m.orchestrationRun(args); }
1303
1350
  else if (name === 'orchestration_status') { const m = await import('./tools/orchestration-run.mjs'); result = await m.orchestrationStatus(args); }
1351
+ else if (name === 'construct_call') {
1352
+ const inner = String(args?.tool || '');
1353
+ if (!inner || inner === 'construct_call') result = { error: "construct_call requires a 'tool' name (not 'construct_call')" };
1354
+ else result = await dispatchToolByName(inner, args?.args || {});
1355
+ }
1304
1356
  else result = { error: `Unknown tool: ${name}` };
1305
- } catch (err) {
1306
- result = { error: err.message ?? String(err) };
1357
+ return result;
1358
+ }
1359
+
1360
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1361
+ const { name, arguments: args = {} } = request.params;
1362
+
1363
+ // Extract W3C traceparent from params._meta (SEP-414 propagation). Tracing
1364
+ // must never break dispatch — a malformed _meta should not fail the call.
1365
+ let parentCtx = {};
1366
+ try { parentCtx = await extractTraceContext(request.params?._meta || {}); } catch { /* tracing optional */ }
1367
+ void parentCtx;
1368
+
1369
+ // Bound every tool call. A tool that stalls (a stuck external extractor, a slow
1370
+ // model load, a wedged subprocess) must surface a clean timeout error to the
1371
+ // client rather than block the request until the client gives up and reports an
1372
+ // opaque failure. Override with CONSTRUCT_MCP_TOOL_TIMEOUT_MS (0 disables).
1373
+ const TOOL_TIMEOUT_MS = (() => {
1374
+ const raw = Number(process.env.CONSTRUCT_MCP_TOOL_TIMEOUT_MS);
1375
+ return Number.isFinite(raw) && raw >= 0 ? raw : 120_000;
1376
+ })();
1377
+
1378
+ const dispatch = (async () => {
1379
+ let result;
1380
+ try {
1381
+ result = await dispatchToolByName(name, args);
1382
+ } catch (err) {
1383
+ result = { error: err.message ?? String(err) };
1384
+ }
1385
+ return result;
1386
+ })();
1387
+
1388
+ let toolResult;
1389
+ if (!TOOL_TIMEOUT_MS) {
1390
+ toolResult = await dispatch;
1391
+ } else {
1392
+ let timer;
1393
+ const timeout = new Promise((_, reject) => {
1394
+ timer = setTimeout(
1395
+ () => reject(new Error(`tool ${name} timed out after ${Math.round(TOOL_TIMEOUT_MS / 1000)}s`)),
1396
+ TOOL_TIMEOUT_MS,
1397
+ );
1398
+ });
1399
+ try {
1400
+ toolResult = await Promise.race([dispatch, timeout]);
1401
+ } catch (err) {
1402
+ toolResult = { error: err.message ?? String(err) };
1403
+ } finally {
1404
+ clearTimeout(timer);
1405
+ }
1307
1406
  }
1308
1407
 
1309
1408
  return {
1310
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
1409
+ content: [{ type: 'text', text: JSON.stringify(toolResult, null, 2) }],
1311
1410
  };
1312
1411
  });
1313
1412
 
@@ -1333,6 +1432,19 @@ export {
1333
1432
  const argv1Real = (() => { try { return realpathSync(process.argv[1]); } catch { return process.argv[1]; } })();
1334
1433
  if (fileURLToPath(import.meta.url) === argv1Real) {
1335
1434
  console.error('[construct-mcp] server started');
1435
+
1436
+ // A long-running stdio server must survive a single malformed request: a
1437
+ // background rejection from one ingest (e.g. a broken docling sidecar that
1438
+ // settles late) must not terminate the process and close the client
1439
+ // connection. Log loudly and keep serving instead of crashing. Scoped to the
1440
+ // server entry so importing this module never installs global handlers.
1441
+ process.on('unhandledRejection', (reason) => {
1442
+ console.error('[construct-mcp] unhandledRejection (kept alive):', reason instanceof Error ? reason.stack : reason);
1443
+ });
1444
+ process.on('uncaughtException', (err) => {
1445
+ console.error('[construct-mcp] uncaughtException (kept alive):', err?.stack || err);
1446
+ });
1447
+
1336
1448
  const transport = new StdioServerTransport();
1337
1449
  await server.connect(transport);
1338
1450
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * lib/mcp/tool-budget.mjs — tool-surface sizing for the OpenCode integration.
3
+ *
4
+ * OpenCode 1.15.4 exposes no per-session tool filter (chat.params carries only
5
+ * sampler params, and tool.definition cannot remove a tool or see the model), so
6
+ * the serialized tool surface is fixed by two config/server-time levers:
7
+ * 1. construct-mcp's ListTools — a lean core + the construct_call gateway.
8
+ * 2. which external MCP servers are `enabled` in opencode.json — sync disables
9
+ * the heavy ones for local-capable setups (they cannot be trimmed at runtime).
10
+ * Holds the shared token-sizing helper and the external-server id list both
11
+ * levers reason about.
12
+ */
13
+
14
+ const TOKENS_PER_CHAR = 0.25;
15
+
16
+ // Heavy external MCP servers Construct registers globally. On a local-capable
17
+ // setup these alone serialize ~12k tokens into every agent's window (including
18
+ // the built-in Build/Plan agents) and cannot be filtered per request, so sync
19
+ // disables them in opencode.json. construct-mcp's own knowledge_search /
20
+ // memory_search cover the search/memory cases.
21
+
22
+ export const HEAVY_EXTERNAL_MCP_IDS = ['context7', 'github', 'memory', 'sequential-thinking', 'playwright'];
23
+
24
+ export const LOCAL_SURFACE_MODES = ['auto', 'on', 'off'];
25
+
26
+ // A model benefits from trimming only when it is a small-context local runtime
27
+ // reached over Ollama/localhost. github-copilot is a hosted, full-context proxy,
28
+ // so it is deliberately not "local" here even though it is provider-local.
29
+
30
+ export function isLocalModel(model) {
31
+ const m = (model || "").toLowerCase();
32
+ return m.includes("ollama") || m.includes("localhost") || m.includes("127.0.0.1");
33
+ }
34
+
35
+ // Decide whether to disable the heavy external MCP servers for one opencode.json.
36
+ // Trimming is driven by INTENT — the default model this config actually selects —
37
+ // not by whether the machine happens to have Ollama installed. Machine-wide
38
+ // presence stripped context7/github from cloud sessions that merely shared a box
39
+ // with Ollama; keying off the config's own default model preserves cloud surfaces
40
+ // and still shrinks the window when the user has chosen a local model.
41
+ // surface: 'on' (always trim) | 'off' (never) | 'auto' (trim iff default is local)
42
+
43
+ export function decideTrim({ surface = 'auto', defaultModel = null } = {}) {
44
+ if (surface === 'off') return false;
45
+ if (surface === 'on') return true;
46
+ return isLocalModel(defaultModel);
47
+ }
48
+
49
+ export function estimateToolTokens(tools) {
50
+ if (!tools) return 0;
51
+ const arr = Array.isArray(tools) ? tools : Object.values(tools);
52
+ return Math.round(JSON.stringify(arr).length * TOKENS_PER_CHAR);
53
+ }
@@ -29,7 +29,7 @@
29
29
  {
30
30
  "id": "sequential-thinking",
31
31
  "name": "Sequential Thinking",
32
- "category": "core",
32
+ "category": "optional",
33
33
  "description": "Structured multi-step reasoning chains. Helps with complex planning and analysis.",
34
34
  "package": "@modelcontextprotocol/server-sequential-thinking",
35
35
  "command": "npx",
@@ -0,0 +1,78 @@
1
+ /**
2
+ * lib/ollama/capability-store.mjs — durable record of local-model agentic coherence.
3
+ *
4
+ * `construct doctor --probe-local` measures whether each Ollama model can drive
5
+ * the agentic loop or collapses into word salad, and writes the verdict here.
6
+ * sync and doctor READ this store (they never probe — probing loads each model and
7
+ * costs minutes) to skip Modelfile provisioning for collapsed models, inform the
8
+ * MCP-trim decision, and warn when a configured default model cannot do agentic
9
+ * work. Verdicts are keyed by the model's `ollama list` digest so a re-pulled tag
10
+ * goes stale and is re-probed rather than trusted blindly.
11
+ *
12
+ * Lives at ~/.cx/local-models.json because local-model capability is a property of
13
+ * the machine's Ollama install, not of any one project.
14
+ */
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { cxDir } from "../paths.mjs";
18
+
19
+ const STORE_VERSION = 1;
20
+
21
+ export function localModelsPath() {
22
+ return path.join(cxDir(), "local-models.json");
23
+ }
24
+
25
+ export function readCapabilityStore() {
26
+ try {
27
+ const raw = JSON.parse(fs.readFileSync(localModelsPath(), "utf8"));
28
+ if (raw && raw.version === STORE_VERSION && raw.models && typeof raw.models === "object") return raw;
29
+ } catch { /* missing or malformed — treat as empty */ }
30
+ return { version: STORE_VERSION, models: {} };
31
+ }
32
+
33
+ // Persist one probe result. probeResult is the object probeAgenticCoherence
34
+ // returns ({ ok, coherent, repeatRatio, uniqueRatio, ... }); a probe that errored
35
+ // (ok=false) is not recorded, so a transient failure never overwrites a real verdict.
36
+
37
+ export function recordProbeResult(model, probeResult, digest = null) {
38
+ if (!probeResult || probeResult.ok !== true) return readCapabilityStore();
39
+ const store = readCapabilityStore();
40
+ store.models[model] = {
41
+ verdict: probeResult.coherent ? "COHERENT" : "COLLAPSED",
42
+ coherent: probeResult.coherent === true,
43
+ calledTool: probeResult.calledTool === true,
44
+ repeatRatio: probeResult.repeatRatio ?? null,
45
+ uniqueRatio: probeResult.uniqueRatio ?? null,
46
+ digest: digest ?? null,
47
+ probedAt: new Date().toISOString(),
48
+ };
49
+ const dir = path.dirname(localModelsPath());
50
+ fs.mkdirSync(dir, { recursive: true });
51
+ fs.writeFileSync(localModelsPath(), `${JSON.stringify(store, null, 2)}\n`);
52
+ return store;
53
+ }
54
+
55
+ export function getModelVerdict(model) {
56
+ return readCapabilityStore().models[model] ?? null;
57
+ }
58
+
59
+ // A verdict is stale when no digest is recorded for it or the current digest
60
+ // differs from the one it was measured against — callers re-probe rather than
61
+ // trust a verdict about different model bytes.
62
+
63
+ export function isVerdictStale(model, currentDigest) {
64
+ const entry = getModelVerdict(model);
65
+ if (!entry) return true;
66
+ if (!currentDigest || !entry.digest) return true;
67
+ return entry.digest !== currentDigest;
68
+ }
69
+
70
+ // COLLAPSED only counts when the digest still matches: a stale or unknown verdict
71
+ // must not strand a model. Callers warn-and-allow-override rather than hide.
72
+
73
+ export function isKnownCollapsed(model, currentDigest = null) {
74
+ const entry = getModelVerdict(model);
75
+ if (!entry) return false;
76
+ if (currentDigest && isVerdictStale(model, currentDigest)) return false;
77
+ return entry.verdict === "COLLAPSED";
78
+ }
@@ -0,0 +1,344 @@
1
+ /**
2
+ * lib/ollama/provision-context.mjs — Provision context-extended Ollama model variants.
3
+ *
4
+ * OpenCode reaches Ollama through the OpenAI-compatible `/v1` endpoint, which has
5
+ * no field for the context window — so `num_ctx` set in opencode.json is silently
6
+ * dropped and Ollama serves the model at its 4096 default. A Construct session's
7
+ * system prompt plus MCP tool schemas overruns 4096, so a capable model loses the
8
+ * tail of its own prompt/conversation. The only surface that actually sets Ollama's
9
+ * runtime context is the Modelfile, so for any tool-capable model with no baked
10
+ * `num_ctx` (capability does not track parameter count, so size is not a gate) this
11
+ * module derives a `<model>-cx<N>k` variant via `ollama create` with the context
12
+ * window, a safe Qwen/Llama repeat_penalty, and ChatML stop tokens baked in.
13
+ *
14
+ * Idempotent: a variant that already exists is left untouched. Mirrors the
15
+ * user-provisioned qwen3-coder:32k pattern.
16
+ */
17
+ import { spawnSync } from "node:child_process";
18
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+
22
+ const DEFAULT_NUM_CTX = 32768;
23
+ const CHATML_STOPS = ["<|im_start|>", "<|im_end|>", "<|endoftext|>"];
24
+
25
+ function ollama(args, opts = {}) {
26
+ return spawnSync("ollama", args, { encoding: "utf8", ...opts });
27
+ }
28
+
29
+ export function ollamaAvailable() {
30
+ const r = ollama(["--version"]);
31
+ return r.status === 0;
32
+ }
33
+
34
+ export function listModels() {
35
+ const r = ollama(["list"]);
36
+ if (r.status !== 0) return [];
37
+ return r.stdout.trim().split("\n").slice(1)
38
+ .map((line) => line.split(/\s+/).filter(Boolean)[0])
39
+ .filter(Boolean);
40
+ }
41
+
42
+ // `ollama list` prints NAME, ID, SIZE, MODIFIED. The ID column is the digest
43
+ // prefix and changes when a tag is re-pulled, so it keys a probe verdict to the
44
+ // exact model bytes it was measured against — a re-pulled model goes stale.
45
+
46
+ export function modelDigest(model) {
47
+ const r = ollama(["list"]);
48
+ if (r.status !== 0) return null;
49
+ for (const line of r.stdout.trim().split("\n").slice(1)) {
50
+ const cols = line.split(/\s+/).filter(Boolean);
51
+ if (cols[0] === model) return cols[1] || null;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ // `ollama show` prints a "Parameters" block listing only params baked into the
57
+ // model. `context length` under "Model" is the trained maximum, not the runtime
58
+ // window — the distinction that makes a raw model look 32k-capable while Ollama
59
+ // actually serves it at 4096.
60
+
61
+ export function inspectModel(model) {
62
+ const r = ollama(["show", model]);
63
+ if (r.status !== 0) return null;
64
+ const text = r.stdout;
65
+ const paramMatch = text.match(/parameters\s+([\d.]+)\s*([BMK])/i);
66
+ const paramCountB = paramMatch
67
+ ? Number(paramMatch[1]) * (paramMatch[2].toUpperCase() === "M" ? 0.001 : paramMatch[2].toUpperCase() === "K" ? 0.000001 : 1)
68
+ : null;
69
+ const contextMatch = text.match(/context length\s+(\d+)/i);
70
+ const trainedContext = contextMatch ? Number(contextMatch[1]) : null;
71
+
72
+ // `num_ctx` appears only inside the Parameters block, so a whole-text match is
73
+ // safe (the trained-max line reads "context length", not num_ctx). `tools` is a
74
+ // standalone line under Capabilities — match it anchored to avoid the lowercase
75
+ // "parameters" count line elsewhere in the output.
76
+
77
+ const bakedNumCtxMatch = text.match(/\bnum_ctx\s+(\d+)/i);
78
+ const bakedNumCtx = bakedNumCtxMatch ? Number(bakedNumCtxMatch[1]) : null;
79
+ const toolCapable = /^\s*tools\s*$/im.test(text);
80
+
81
+ return { model, paramCountB, trainedContext, bakedNumCtx, toolCapable };
82
+ }
83
+
84
+ export function variantName(model, numCtx) {
85
+ const kSuffix = `cx${Math.round(numCtx / 1024)}k`;
86
+ const [base, tag] = model.split(":");
87
+ return tag ? `${base}:${tag}-${kSuffix}` : `${base}:${kSuffix}`;
88
+ }
89
+
90
+ export function buildModelfile(model, numCtx) {
91
+ const lines = [
92
+ `FROM ${model}`,
93
+ `PARAMETER num_ctx ${numCtx}`,
94
+ `PARAMETER repeat_penalty 1.05`,
95
+ `PARAMETER temperature 0.1`,
96
+ ...CHATML_STOPS.map((s) => `PARAMETER stop "${s}"`),
97
+ ];
98
+ return lines.join("\n") + "\n";
99
+ }
100
+
101
+ // `ollama create` reads the Modelfile from a path, not stdin, so stage it in a
102
+ // temp dir and pass the path. Status is unreliable on some Ollama builds (exits 0
103
+ // even on "no Modelfile found"), so success is confirmed by the variant appearing
104
+ // in the model list rather than the exit code alone.
105
+
106
+ export function createVariant(model, numCtx, { dryRun = false } = {}) {
107
+ const name = variantName(model, numCtx);
108
+ if (dryRun) return { name, ok: true };
109
+ const dir = mkdtempSync(join(tmpdir(), "cx-modelfile-"));
110
+ const file = join(dir, "Modelfile");
111
+ try {
112
+ writeFileSync(file, buildModelfile(model, numCtx));
113
+ const r = ollama(["create", name, "-f", file]);
114
+ const ok = r.status === 0 && listModels().includes(name);
115
+ return { name, ok, stderr: r.stderr };
116
+ } finally {
117
+ rmSync(dir, { recursive: true, force: true });
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Ensure every raw, tool-capable model whose runtime context falls back to the
123
+ * default has a context-extended variant. Returns a map of raw → variant for
124
+ * the caller (sync-config) to register, plus a per-model action log.
125
+ */
126
+ export function ensureLocalContextVariants({ numCtx = DEFAULT_NUM_CTX, dryRun = false } = {}) {
127
+ if (!ollamaAvailable()) return { available: false, mapping: {}, actions: [] };
128
+
129
+ const models = listModels();
130
+ const existing = new Set(models);
131
+ const mapping = {};
132
+ const actions = [];
133
+
134
+ for (const model of models) {
135
+ if (/:cx\d+k$/.test(model)) continue;
136
+ const info = inspectModel(model);
137
+ if (!info || !info.toolCapable) {
138
+ actions.push({ model, action: "skip", reason: info ? "not-tool-capable" : "inspect-failed" });
139
+ continue;
140
+ }
141
+ if (info.bakedNumCtx && info.bakedNumCtx >= numCtx) {
142
+ actions.push({ model, action: "skip", reason: `already-baked-${info.bakedNumCtx}` });
143
+ continue;
144
+ }
145
+
146
+ const name = variantName(model, numCtx);
147
+ if (existing.has(name)) {
148
+ mapping[model] = name;
149
+ actions.push({ model, action: "exists", variant: name });
150
+ continue;
151
+ }
152
+ const res = createVariant(model, numCtx, { dryRun });
153
+ if (res.ok) {
154
+ mapping[model] = res.name;
155
+ actions.push({ model, action: dryRun ? "would-create" : "created", variant: res.name });
156
+ } else {
157
+ actions.push({ model, action: "error", variant: res.name, stderr: res.stderr });
158
+ }
159
+ }
160
+
161
+ return { available: true, numCtx, mapping, actions };
162
+ }
163
+
164
+ // Collapse on an agentic prompt is model-specific and not predictable from
165
+ // parameter count (a 7B coder model collapses where a 30B does not). It must be
166
+ // probed with a payload heavy enough to match what a real host (OpenCode) sends:
167
+ // a light prompt + one tool is too easy — qwen2.5-coder:7b passes it yet still
168
+ // word-salads ("client client client") in OpenCode. So the stimulus below is a
169
+ // dense ~1.4k-token agentic system prompt plus a realistic ten-tool surface,
170
+ // empirically tuned (2026-06-09, validated through real OpenCode 1.15.4) to flip
171
+ // qwen2.5-coder:7b -> COLLAPSED while qwen3-coder:32k and devstral:24b stay
172
+ // COHERENT. An incapable model emits empty/no-tool output or degenerate
173
+ // repetition; a capable one calls a tool or answers coherently. Requires the
174
+ // model loaded in Ollama, so it is opt-in (doctor / on demand), never inline on sync.
175
+
176
+ const PROBE_TOOL = (name, description, properties, required) => ({
177
+ type: "function",
178
+ function: { name, description, parameters: { type: "object", properties, required } },
179
+ });
180
+
181
+ const PROBE_TOOLS = [
182
+ PROBE_TOOL("read", "Read a file from the project, optionally a line range", { path: { type: "string" }, offset: { type: "number" }, limit: { type: "number" } }, ["path"]),
183
+ PROBE_TOOL("write", "Write a file to disk, creating or overwriting it", { path: { type: "string" }, content: { type: "string" } }, ["path", "content"]),
184
+ PROBE_TOOL("edit", "Replace an exact unique string in a file with a new string", { path: { type: "string" }, old_string: { type: "string" }, new_string: { type: "string" }, replace_all: { type: "boolean" } }, ["path", "old_string", "new_string"]),
185
+ PROBE_TOOL("bash", "Execute a shell command and return its stdout and stderr", { command: { type: "string" }, timeout: { type: "number" }, description: { type: "string" } }, ["command"]),
186
+ PROBE_TOOL("grep", "Search file contents using a regular expression", { pattern: { type: "string" }, path: { type: "string" }, glob: { type: "string" }, output_mode: { type: "string" } }, ["pattern"]),
187
+ PROBE_TOOL("glob", "Find files whose paths match a glob pattern, sorted by mtime", { pattern: { type: "string" }, path: { type: "string" } }, ["pattern"]),
188
+ PROBE_TOOL("list", "List the entries of a directory", { path: { type: "string" }, ignore: { type: "array", items: { type: "string" } } }, ["path"]),
189
+ PROBE_TOOL("webfetch", "Fetch a URL and return its content as markdown text", { url: { type: "string" }, format: { type: "string" } }, ["url"]),
190
+ PROBE_TOOL("todowrite", "Create or update the structured task list for this session", { todos: { type: "array", items: { type: "object" } } }, ["todos"]),
191
+ PROBE_TOOL("task", "Launch a subagent to handle a complex multi-step subtask autonomously", { description: { type: "string" }, prompt: { type: "string" }, subagent_type: { type: "string" } }, ["description", "prompt"]),
192
+ ];
193
+
194
+ const PROBE_SYSTEM = [
195
+ "You are a highly capable autonomous software engineering agent embedded in a developer's terminal. You operate on a real codebase and complete tasks end to end. You are precise, methodical, and never fabricate.",
196
+ "# Operating principles\nWork from evidence, never assumption. Before editing any file, read it. Before claiming an API exists, grep for it. Before reporting a test passes, run it and read the output. Every load-bearing statement you make must trace to something you observed through a tool call. When a fact is unknown, write that it is unknown rather than guessing.",
197
+ "# Tool-use protocol\nYou have file, search, and shell tools. When you decide to act, emit exactly one well-formed tool call whose JSON arguments match the tool schema precisely. Do not wrap tool calls in prose, markdown fences, or explanations. Do not narrate at length what you are about to do — call the tool and let the result speak. After each tool call, read the result carefully before deciding the next step.",
198
+ "# Workflow\n1. Understand the request fully. Restate the goal to yourself. 2. Gather context: read the README, list the directory, grep for the relevant symbols, glob for related files. 3. Form the smallest plan that satisfies the request and matches existing conventions. 4. Implement with edit or write. 5. Verify: re-read the changed file; run the build or tests via bash where applicable. 6. Report the outcome plainly, including any failures with their output.",
199
+ "# Code conventions\nMatch the surrounding code exactly: indentation, quote style, import ordering, naming, and comment density. Do not introduce a new dependency unless asked. Do not reformat unrelated lines. Keep diffs minimal and focused on the request. Preserve the file's existing license header and structure.",
200
+ "# Safety\nNever run destructive shell commands without explicit instruction. Never commit, push, or delete without confirmation. Treat the user's working tree as precious. If a command could be irreversible, describe it and ask first.",
201
+ "# Communication\nBe concise in prose. Put detail into tool calls, not paragraphs. Use plain language. When the task is complete, stop and summarize what changed and how you verified it. If you could not complete the task, say exactly what blocked you.",
202
+ "# Reasoning\nThink step by step internally, but do not dump your entire chain of thought into the response. Decide, act via a tool, observe, and iterate. Prefer doing over explaining. If multiple approaches exist, pick the one most consistent with the codebase and note the tradeoff in one sentence.",
203
+ "# Error handling\nWhen a tool returns an error, read it, diagnose the cause, and adjust — do not repeat the same failing call. If a file is missing, search for the right path. If a command fails, inspect stderr before retrying. Bound your retries; if something cannot be done, report it honestly.",
204
+ "# Final answer\nYour final message to the user should be a short, accurate summary grounded in what you observed. Never claim work you did not verify. Never invent file paths, function names, or results.",
205
+ ].join("\n\n");
206
+
207
+ // Tokenize on word characters, not whitespace: collapse often comes out
208
+ // without spaces ("time.time.time", "clientclientclient"), which a whitespace
209
+ // split would see as a single token and miss. immediate-repeat ratio catches
210
+ // consecutive duplicates; unique-token ratio catches degenerate loops that
211
+ // aren't strictly adjacent. Either crossing its threshold means collapse.
212
+
213
+ function degeneracy(text) {
214
+ const tokens = (text || "").toLowerCase().match(/\w+/g) || [];
215
+ if (tokens.length < 8) return { repeatRatio: 0, uniqueRatio: 1, tokens: tokens.length };
216
+ let repeats = 0;
217
+ for (let i = 1; i < tokens.length; i++) if (tokens[i] === tokens[i - 1]) repeats++;
218
+ return {
219
+ repeatRatio: repeats / tokens.length,
220
+ uniqueRatio: new Set(tokens).size / tokens.length,
221
+ tokens: tokens.length,
222
+ };
223
+ }
224
+
225
+ export async function probeAgenticCoherence(model, { baseURL = "http://127.0.0.1:11434/v1", timeoutMs = 60000 } = {}) {
226
+ const controller = new AbortController();
227
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
228
+ try {
229
+ const res = await fetch(`${baseURL}/chat/completions`, {
230
+ method: "POST",
231
+ headers: { "content-type": "application/json" },
232
+ signal: controller.signal,
233
+ body: JSON.stringify({
234
+ model,
235
+ messages: [
236
+ { role: "system", content: PROBE_SYSTEM },
237
+ { role: "user", content: "What kind of project is in the current directory? Investigate with your tools, then answer in one sentence." },
238
+ ],
239
+ tools: PROBE_TOOLS,
240
+ stream: false,
241
+ temperature: 0.2,
242
+ }),
243
+ });
244
+ if (!res.ok) return { model, ok: false, reason: `http-${res.status}` };
245
+ const data = await res.json();
246
+ const msg = data?.choices?.[0]?.message || {};
247
+ const text = msg.content || "";
248
+ const calledTool = Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0;
249
+ const { repeatRatio, uniqueRatio, tokens } = degeneracy(text);
250
+ const degenerate = tokens >= 8 && (repeatRatio >= 0.25 || uniqueRatio <= 0.35);
251
+ const coherent = calledTool || (text.trim().length > 0 && !degenerate);
252
+ return { model, ok: true, coherent, calledTool, repeatRatio: Number(repeatRatio.toFixed(2)), uniqueRatio: Number(uniqueRatio.toFixed(2)), sample: text.slice(0, 120) };
253
+ } catch (err) {
254
+ return { model, ok: false, reason: err.name === "AbortError" ? "timeout" : err.message };
255
+ } finally {
256
+ clearTimeout(timer);
257
+ }
258
+ }
259
+
260
+ // A model that cannot emit native tool_calls leaks the call into the text channel
261
+ // as `<function=name>…`, `<tool_call>…`, or a `<|tool…|>` sentinel. The non-stream
262
+ // probe assembles the final message and never sees this mid-stream artifact, so the
263
+ // streaming probe scans the assembled delta text for these markers.
264
+
265
+ const TOOL_CALL_LEAK_RE = /<\s*\/?\s*(?:function(?:_calls?)?|tool_call|tools?_call)\b|<\|\s*tool/i;
266
+
267
+ export function detectToolCallLeak(text) {
268
+ const m = (text || "").match(TOOL_CALL_LEAK_RE);
269
+ return { leaked: Boolean(m), marker: m ? m[0].trim() : null };
270
+ }
271
+
272
+ // Parse the OpenAI-compatible SSE stream into the concatenated assistant text and
273
+ // whether any native tool_call delta arrived. `data: [DONE]` ends the stream;
274
+ // non-JSON keep-alive lines are skipped.
275
+ function assembleStream(raw) {
276
+ let text = "";
277
+ let calledTool = false;
278
+ for (const line of raw.split("\n")) {
279
+ const trimmed = line.trim();
280
+ if (!trimmed.startsWith("data:")) continue;
281
+ const payload = trimmed.slice("data:".length).trim();
282
+ if (payload === "[DONE]" || !payload) continue;
283
+ try {
284
+ const delta = JSON.parse(payload)?.choices?.[0]?.delta || {};
285
+ if (typeof delta.content === "string") text += delta.content;
286
+ if (Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0) calledTool = true;
287
+ } catch { /* keep-alive or partial frame */ }
288
+ }
289
+ return { text, calledTool };
290
+ }
291
+
292
+ // Streaming companion to probeAgenticCoherence: drives stream:true and flags a
293
+ // tool-call-as-text leak the buffered probe cannot reproduce. Defense-in-depth for
294
+ // the surface fix (the lean tool gateway already removed the leak empirically).
295
+
296
+ export async function probeStreamingToolCallLeak(model, { baseURL = "http://127.0.0.1:11434/v1", timeoutMs = 60000 } = {}) {
297
+ const controller = new AbortController();
298
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
299
+ try {
300
+ const res = await fetch(`${baseURL}/chat/completions`, {
301
+ method: "POST",
302
+ headers: { "content-type": "application/json" },
303
+ signal: controller.signal,
304
+ body: JSON.stringify({
305
+ model,
306
+ messages: [
307
+ { role: "system", content: PROBE_SYSTEM },
308
+ { role: "user", content: "What kind of project is in the current directory? Investigate with your tools, then answer in one sentence." },
309
+ ],
310
+ tools: PROBE_TOOLS,
311
+ stream: true,
312
+ temperature: 0.2,
313
+ }),
314
+ });
315
+ if (!res.ok) return { model, ok: false, reason: `http-${res.status}` };
316
+ const raw = await res.text();
317
+ const { text, calledTool } = assembleStream(raw);
318
+ const { leaked, marker } = detectToolCallLeak(text);
319
+ const { repeatRatio } = degeneracy(text);
320
+ return { model, ok: true, leaked, marker, calledTool, repeatRatio: Number(repeatRatio.toFixed(2)), sample: text.slice(0, 160) };
321
+ } catch (err) {
322
+ return { model, ok: false, reason: err.name === "AbortError" ? "timeout" : err.message };
323
+ } finally {
324
+ clearTimeout(timer);
325
+ }
326
+ }
327
+
328
+ if (import.meta.url === `file://${process.argv[1]}`) {
329
+ if (process.argv.includes("--probe")) {
330
+ const model = process.argv.find((a) => a.startsWith("--model="))?.split("=")[1];
331
+ const targets = model ? [model] : listModels();
332
+ for (const m of targets) {
333
+ const r = await probeAgenticCoherence(m);
334
+ const verdict = !r.ok ? `unavailable (${r.reason})` : r.coherent ? "COHERENT" : "COLLAPSED";
335
+ console.log(`${m}: ${verdict}${r.ok ? ` (repeat=${r.repeatRatio}, unique=${r.uniqueRatio}, tool=${r.calledTool})` : ""}${r.sample ? ` — ${JSON.stringify(r.sample)}` : ""}`);
336
+ }
337
+ process.exit(0);
338
+ }
339
+ const dryRun = process.argv.includes("--dry-run");
340
+ const numArg = process.argv.find((a) => a.startsWith("--num-ctx="));
341
+ const numCtx = numArg ? Number(numArg.split("=")[1]) : DEFAULT_NUM_CTX;
342
+ const result = ensureLocalContextVariants({ numCtx, dryRun });
343
+ console.log(JSON.stringify(result, null, 2));
344
+ }