@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,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
|
+
}
|
package/lib/opencode-config.mjs
CHANGED
|
@@ -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;
|
|
@@ -147,6 +147,7 @@ export async function planRun(request = {}, { env = process.env, cwd = process.c
|
|
|
147
147
|
const {
|
|
148
148
|
request: text = '', workflowType = null, requestedStrategy = 'auto', useConstruct = true,
|
|
149
149
|
host = null, hostModel = null, hostProvider = null, fileCount = 0, moduleCount = 0,
|
|
150
|
+
workerBackend: explicitWorkerBackend = null,
|
|
150
151
|
} = request;
|
|
151
152
|
|
|
152
153
|
const config = loadConfig(cwd, env);
|
|
@@ -161,7 +162,7 @@ export async function planRun(request = {}, { env = process.env, cwd = process.c
|
|
|
161
162
|
const traceId = newTraceId();
|
|
162
163
|
const runId = newRunId();
|
|
163
164
|
const now = new Date().toISOString();
|
|
164
|
-
const workerBackend = resolveWorkerBackend({ config });
|
|
165
|
+
const workerBackend = resolveWorkerBackend({ explicit: explicitWorkerBackend, config });
|
|
165
166
|
const chainOfThought = resolveChainOfThought({ config });
|
|
166
167
|
|
|
167
168
|
// Construct owns a specialist task sequence only when the contract resolves to
|
|
@@ -358,7 +359,7 @@ export function hostAdapterMetadata(run) {
|
|
|
358
359
|
degradationReason: e.degradationReason,
|
|
359
360
|
selectedProvider: e.selectedProvider,
|
|
360
361
|
selectedModel: e.selectedModel,
|
|
361
|
-
tasks: (run.tasks || []).map((t) => ({ id: t.id, role: t.role, status: t.status, executor: t.executor, reasoning: t.reasoning ?? null })),
|
|
362
|
+
tasks: (run.tasks || []).map((t) => ({ id: t.id, role: t.role, status: t.status, executor: t.executor, reasoning: t.reasoning ?? null, output: t.output ?? null, error: t.error ?? null })),
|
|
362
363
|
warnings: run.warnings || [],
|
|
363
364
|
semantics: run.semantics,
|
|
364
365
|
executionSemantics: run.executionSemantics,
|
|
@@ -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,
|
|
129
|
-
* architecture / writing / research-
|
|
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
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
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
|
|