product-console-companion 0.5.2 → 0.6.1
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 +58 -4
- package/dist/api-key-caller.js +124 -140
- package/dist/claude-cli-caller.js +141 -35
- package/dist/cli-arguments.js +11 -0
- package/dist/cli.js +648 -93
- package/dist/codex-cli-caller.js +534 -0
- package/dist/config.js +2 -0
- package/dist/git-range.js +267 -0
- package/dist/http-client.js +133 -15
- package/dist/import-resolver.js +632 -0
- package/dist/investigation-reader.js +1238 -0
- package/dist/local-scan.js +191 -6
- package/dist/observed-sources.js +85 -0
- package/dist/reader-result-contract.js +388 -0
- package/dist/sandbox.js +174 -47
- package/dist/strict-schema.js +187 -0
- package/dist/surface-mapper.js +269 -0
- package/dist/text-model.js +93 -0
- package/dist/types.js +4 -2
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
A local-first BYOS companion for the [Fynch Product Console](../README.md). Reads code from
|
|
4
4
|
your own machine, so:
|
|
5
5
|
|
|
6
|
-
- code never
|
|
7
|
-
explanation) is
|
|
6
|
+
- code never goes to Fynch's servers — only a small structured verdict (matches/differs + a short
|
|
7
|
+
explanation) is sent back; the locally chosen AI provider receives the text needed to judge it;
|
|
8
8
|
- there's no server-side truncation on what gets read (`server/drift.ts`'s
|
|
9
9
|
`MAX_FILE_CHARS`/`MAX_TREE_PATHS` don't apply here at all);
|
|
10
10
|
- AI spend bills against your own key/subscription, not the workspace's shared/BYOK credential.
|
|
@@ -13,7 +13,7 @@ Runs every AI task the hosted Console dispatches. That includes the repo-aware s
|
|
|
13
13
|
checks as well as graph reasoning such as impact analysis, reviews, coupling maps, graph Q&A,
|
|
14
14
|
edge descriptions, and exports. Graph-reasoning tasks receive all of their context from the
|
|
15
15
|
Console and run connectionless with zero filesystem access. New server-side graph-reasoning task
|
|
16
|
-
kinds use the same safe fallback without requiring another companion release.
|
|
16
|
+
kinds use the same safe fallback without requiring another companion release. Three backends are
|
|
17
17
|
available, chosen per connection at `connect` time:
|
|
18
18
|
|
|
19
19
|
- **`api-key`** (default) — no CLI dependency. Calls Anthropic directly with your own API key
|
|
@@ -26,6 +26,9 @@ available, chosen per connection at `connect` time:
|
|
|
26
26
|
against a disposable, read-only git worktree (`sandbox.ts`) as defense-in-depth on top of
|
|
27
27
|
`--restricted --tools "Read,Grep,Glob"`, which should already make a write/exec structurally
|
|
28
28
|
impossible on its own.
|
|
29
|
+
- **`codex`** — uses a ChatGPT subscription through the Codex CLI as a model-only backend. The
|
|
30
|
+
companion still selects and reads files itself; Codex is given only their text and cannot
|
|
31
|
+
explore the checkout.
|
|
29
32
|
|
|
30
33
|
## Setup
|
|
31
34
|
|
|
@@ -63,7 +66,8 @@ npx product-console-companion connect --url https://console.fynchconsulting.com
|
|
|
63
66
|
|
|
64
67
|
This runs your workspace's PRD-only setup AI calls when there is no repo yet. `run` then polls
|
|
65
68
|
for workspace-level tasks in addition to any per-repo connections configured on this machine.
|
|
66
|
-
Pass `--backend cli` to use your Claude subscription
|
|
69
|
+
Pass `--backend cli` to use your Claude subscription, or `--backend codex` to use a ChatGPT
|
|
70
|
+
subscription, for these repo-less tasks too. This mode
|
|
67
71
|
runs with zero filesystem access (stricter than the repo-scoped `cli` backend, because there is
|
|
68
72
|
nothing to read). PDF-format PRDs cannot be delivered to the `cli` backend yet; use `api-key`,
|
|
69
73
|
or re-upload the PRD as `.md`, `.txt`, or `.docx`.
|
|
@@ -79,6 +83,56 @@ into it first — `npm install -g @anthropic-ai/claude-code`, then run `claude`
|
|
|
79
83
|
prompt. `connect` checks `claude --version` up front and fails fast if it's missing. Bills against
|
|
80
84
|
your Claude subscription, not a separate API key.
|
|
81
85
|
|
|
86
|
+
## ChatGPT subscription through Codex CLI
|
|
87
|
+
|
|
88
|
+
**Requirements.** Install the Codex CLI with `npm install -g @openai/codex`, then log in once to
|
|
89
|
+
the companion's own Codex home. This is separate from your normal Codex login:
|
|
90
|
+
|
|
91
|
+
```powershell
|
|
92
|
+
New-Item -ItemType Directory -Force "$HOME\.product-console-companion\codex-home" | Out-Null; $env:CODEX_HOME="$HOME\.product-console-companion\codex-home"; codex login; Remove-Item Env:CODEX_HOME
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
mkdir -p "$HOME/.product-console-companion/codex-home" && CODEX_HOME="$HOME/.product-console-companion/codex-home" codex login
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Use the PowerShell command on Windows and the shell command on macOS/Linux. Codex loads the global
|
|
100
|
+
`AGENTS.md` from its home into every call, so the separate login ensures your own instructions do
|
|
101
|
+
not shape Product Console assessments. The folder must exist before `codex login`, and the Windows
|
|
102
|
+
line clears `CODEX_HOME` afterwards so the terminal goes back to your own Codex login.
|
|
103
|
+
`connect --backend codex` checks this login before it saves
|
|
104
|
+
anything, and `run` checks it again before it polls or claims work. A logged-out `codex exec` can
|
|
105
|
+
retry a 401 ten times, so the companion never uses a failed task as a login check.
|
|
106
|
+
|
|
107
|
+
**How it works.** Codex never runs commands and is never given a path to your files. The companion
|
|
108
|
+
picks and reads the relevant files itself, exactly like the API-key backend, and gives Codex their
|
|
109
|
+
text to judge. Codex runs in an empty temporary folder. Like the API-key backend, it sees only the
|
|
110
|
+
files selected for it, up to eight per module; the Claude Code backend can explore further.
|
|
111
|
+
|
|
112
|
+
**What stops it acting.** Every Codex feature that can be switched off is switched off, no Codex
|
|
113
|
+
sandbox is used, and on the supported models every local tool routes through one mechanism that
|
|
114
|
+
refuses when switched off. Codex still lists some tools it cannot use. If it ever tries one, the
|
|
115
|
+
companion stops the call and throws the answer away. This was verified on Windows; the
|
|
116
|
+
companion-side stop applies on every platform.
|
|
117
|
+
|
|
118
|
+
Some prompt material cannot be removed by any tested setting: a skills list and a multi-agent
|
|
119
|
+
role message remain. That role message's own instruction forbids starting sub-agents unless the
|
|
120
|
+
user asks. Your environment variables are kept away from Codex: variables whose names look like
|
|
121
|
+
credentials are removed before Codex starts, and Codex's command environment is restricted too.
|
|
122
|
+
|
|
123
|
+
**No dollar budget enforcement.** Codex reports tokens, not cost, and exposes no budget flag or
|
|
124
|
+
output-token cap. The 10-minute timeout is the only hard bound. The companion logs the returned
|
|
125
|
+
token usage and explicitly marks the task's dollar budget as unenforced.
|
|
126
|
+
|
|
127
|
+
One model is used for every task type because there is no evidence that ChatGPT-plan models map to
|
|
128
|
+
the Claude tiers sent by the server. The default is `gpt-6-astra`. To override it, pass
|
|
129
|
+
`--codex-model gpt-5.6-sol`, `--codex-model gpt-5.6-terra`, or `--codex-model gpt-5.6-luna` to
|
|
130
|
+
`connect`. Other Codex models are refused because they were not verified to refuse tool use when
|
|
131
|
+
their tools are switched off. The server's requested reasoning effort still passes through.
|
|
132
|
+
|
|
133
|
+
PDF PRDs are not supported on this backend. Re-upload one as `.md`, `.txt`, or `.docx`, or use the
|
|
134
|
+
API-key backend.
|
|
135
|
+
|
|
82
136
|
Once it's running:
|
|
83
137
|
|
|
84
138
|
```bash
|
package/dist/api-key-caller.js
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The 3b backend (see the local-companion plan's Section 3) — no CLI dependency, calls the
|
|
3
|
+
* model directly with a locally-configured API key. Structurally mirrors server/drift.ts's
|
|
4
|
+
* verifyModuleAgainstCode almost exactly (same two-pass shape: pick candidate files, then judge
|
|
5
|
+
* against their contents) — the two things that actually change are which machine runs it and
|
|
6
|
+
* that file reads go through node:fs against the local checkout instead of the GitHub API, so
|
|
7
|
+
* nothing here truncates a file's contents the way MAX_FILE_CHARS does server-side.
|
|
8
|
+
*
|
|
9
|
+
* fileSelectSchema below is a deliberate duplicate of server/drift.ts's own (not imported —
|
|
10
|
+
* this package is meant to be independently publishable, see types.ts's own comment). Keep the
|
|
11
|
+
* two in sync by hand if either changes.
|
|
12
|
+
*/
|
|
13
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
1
14
|
import { readFile } from 'node:fs/promises';
|
|
2
15
|
import { join } from 'node:path';
|
|
3
16
|
import { buildLocalDesignContext } from './design-context.js';
|
|
@@ -9,7 +22,40 @@ const MAX_CANDIDATE_FILES = 8;
|
|
|
9
22
|
* this backend is that what IS read is never truncated; this only guards against an
|
|
10
23
|
* accidentally-selected generated/binary/data file blowing up the prompt. */
|
|
11
24
|
const MAX_FILE_BYTES = 512 * 1024;
|
|
12
|
-
|
|
25
|
+
/** Structured model-only call used by the investigation reader. The reader, not this call and
|
|
26
|
+
* not the model, owns every repository read and evidence id. */
|
|
27
|
+
export async function runStructuredModelCall(client, request) {
|
|
28
|
+
const stream = client.messages.stream({
|
|
29
|
+
model: request.model,
|
|
30
|
+
max_tokens: request.maxTokens,
|
|
31
|
+
thinking: { type: 'adaptive' },
|
|
32
|
+
output_config: {
|
|
33
|
+
effort: request.effort,
|
|
34
|
+
format: { type: 'json_schema', schema: request.schema },
|
|
35
|
+
},
|
|
36
|
+
system: request.system,
|
|
37
|
+
messages: [{ role: 'user', content: request.prompt }],
|
|
38
|
+
});
|
|
39
|
+
let message;
|
|
40
|
+
try {
|
|
41
|
+
message = await stream.finalMessage();
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error instanceof Anthropic.RateLimitError) {
|
|
45
|
+
throw new Error('budget_exhausted: Anthropic API rate limit (HTTP 429).', { cause: error });
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
if (message.stop_reason === 'refusal')
|
|
50
|
+
throw new Error('The model declined the structured investigation call.');
|
|
51
|
+
if (message.stop_reason === 'max_tokens')
|
|
52
|
+
throw new Error('budget_exhausted: the structured investigation call reached its output-token limit.');
|
|
53
|
+
const text = message.content.find((block) => block.type === 'text');
|
|
54
|
+
if (!text || text.type !== 'text')
|
|
55
|
+
throw new Error('The structured investigation call returned no text.');
|
|
56
|
+
return JSON.parse(text.text);
|
|
57
|
+
}
|
|
58
|
+
export const fileSelectSchema = {
|
|
13
59
|
type: 'object',
|
|
14
60
|
additionalProperties: false,
|
|
15
61
|
required: ['paths'],
|
|
@@ -39,7 +85,7 @@ async function readLocalFile(repoPath, relPath) {
|
|
|
39
85
|
* exact schema the task shipped with (task.payload.spec.schema), `filesRead` for the UI's
|
|
40
86
|
* provenance note. Throws on anything that should surface as a task error (caller reports it
|
|
41
87
|
* via POST .../error). */
|
|
42
|
-
export async function runVerifyModuleTask(
|
|
88
|
+
export async function runVerifyModuleTask(callModel, task, repoPath) {
|
|
43
89
|
const payload = task.payload;
|
|
44
90
|
const spec = payload.spec;
|
|
45
91
|
const allFiles = await listRepoFiles(repoPath);
|
|
@@ -58,20 +104,18 @@ ${allFiles.join('\n')}
|
|
|
58
104
|
First, list the file paths (verbatim from the tree above, up to ${MAX_CANDIDATE_FILES}) most
|
|
59
105
|
likely to implement this module's behaviour. Prefer application source over tests, config, and
|
|
60
106
|
generated files. Empty list if nothing plausibly implements it.`;
|
|
61
|
-
const
|
|
107
|
+
const selectMsg = await callModel({
|
|
108
|
+
purpose: 'verify_module:file_select',
|
|
62
109
|
model: spec.model,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
output_config: { effort: 'low', format: { type: 'json_schema', schema: fileSelectSchema } },
|
|
110
|
+
maxTokens: 2000,
|
|
111
|
+
effort: 'low',
|
|
66
112
|
system: spec.system,
|
|
67
|
-
|
|
113
|
+
stableText: '',
|
|
114
|
+
text: selectPrompt,
|
|
115
|
+
schema: fileSelectSchema,
|
|
68
116
|
});
|
|
69
|
-
const selectMsg = await selectStream.finalMessage();
|
|
70
|
-
const selectText = selectMsg.content.find((b) => b.type === 'text');
|
|
71
|
-
if (!selectText || selectText.type !== 'text')
|
|
72
|
-
throw new Error('Could not pick files to check (no text in the file-select response).');
|
|
73
117
|
const known = new Set(allFiles);
|
|
74
|
-
const chosen = JSON.parse(
|
|
118
|
+
const chosen = JSON.parse(selectMsg.text).paths.filter((p) => known.has(p)).slice(0, MAX_CANDIDATE_FILES);
|
|
75
119
|
if (chosen.length === 0) {
|
|
76
120
|
return {
|
|
77
121
|
filesRead: [],
|
|
@@ -91,30 +135,17 @@ generated files. Empty list if nothing plausibly implements it.`;
|
|
|
91
135
|
// Pass 2: does the code satisfy the spec? spec.system/spec.volatileText/spec.schema are sent
|
|
92
136
|
// by the server unchanged — this is the exact same prompt+schema the server-execution path
|
|
93
137
|
// would use, just pointed at unbounded local file contents instead of truncated GitHub reads.
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
content.push({ type: 'text', text: spec.stableText, cache_control: { type: 'ephemeral' } });
|
|
97
|
-
content.push({
|
|
98
|
-
type: 'text',
|
|
99
|
-
text: `${spec.volatileText}\n\n# The actual code, as it currently stands locally\n\n${files.map((f) => `## ${f.path}\n\n\`\`\`\n${f.content}\n\`\`\``).join('\n\n')}`,
|
|
100
|
-
});
|
|
101
|
-
const verdictStream = client.messages.stream({
|
|
138
|
+
const verdictMsg = await callModel({
|
|
139
|
+
purpose: 'verify_module:judge',
|
|
102
140
|
model: spec.model,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
output_config: spec.schema
|
|
106
|
-
? { effort: spec.effort, format: { type: 'json_schema', schema: spec.schema } }
|
|
107
|
-
: { effort: spec.effort },
|
|
141
|
+
maxTokens: spec.maxTokens,
|
|
142
|
+
effort: spec.effort,
|
|
108
143
|
system: spec.system,
|
|
109
|
-
|
|
144
|
+
stableText: spec.stableText,
|
|
145
|
+
text: `${spec.volatileText}\n\n# The actual code, as it currently stands locally\n\n${files.map((f) => `## ${f.path}\n\n\`\`\`\n${f.content}\n\`\`\``).join('\n\n')}`,
|
|
146
|
+
schema: spec.schema,
|
|
110
147
|
});
|
|
111
|
-
|
|
112
|
-
if (verdictMsg.stop_reason === 'refusal')
|
|
113
|
-
throw new Error('The model declined to check this module.');
|
|
114
|
-
const verdictText = verdictMsg.content.find((b) => b.type === 'text');
|
|
115
|
-
if (!verdictText || verdictText.type !== 'text')
|
|
116
|
-
throw new Error('No verdict returned.');
|
|
117
|
-
return { result: JSON.parse(verdictText.text), filesRead: files.map((f) => f.path) };
|
|
148
|
+
return { result: JSON.parse(verdictMsg.text), filesRead: files.map((f) => f.path) };
|
|
118
149
|
}
|
|
119
150
|
/* ------------------------------------------------------------------ generate_spec_batch */
|
|
120
151
|
/** Matches verify_module's own MAX_CANDIDATE_FILES — tried lower (4) first on the theory that a
|
|
@@ -126,7 +157,7 @@ generated files. Empty list if nothing plausibly implements it.`;
|
|
|
126
157
|
* beats the server path's own FILES_PER_MODULE=2 (bootstrap.ts) either way; this raises the
|
|
127
158
|
* ceiling for the genuinely multi-file case rather than assuming it wouldn't come up. */
|
|
128
159
|
const MAX_FILES_PER_MODULE_IN_BATCH = 8;
|
|
129
|
-
const batchFileSelectSchema = {
|
|
160
|
+
export const batchFileSelectSchema = {
|
|
130
161
|
type: 'object',
|
|
131
162
|
additionalProperties: false,
|
|
132
163
|
required: ['selections'],
|
|
@@ -156,31 +187,23 @@ const batchFileSelectSchema = {
|
|
|
156
187
|
* the whole specs array in pass 2. `spec.volatileText` already carries the per-module id/lane/
|
|
157
188
|
* purpose breakdown and the "find files yourself" instruction (buildSpecBatchVolatile,
|
|
158
189
|
* server/bootstrap.ts) — this only adds the file tree and, in pass 2, what was actually read. */
|
|
159
|
-
export async function runGenerateSpecBatchTask(
|
|
190
|
+
export async function runGenerateSpecBatchTask(callModel, task, repoPath) {
|
|
160
191
|
const payload = task.payload;
|
|
161
192
|
const spec = payload.spec;
|
|
162
193
|
const moduleIds = payload.moduleIds;
|
|
163
194
|
if (payload.connectionless) {
|
|
164
195
|
// The PRD is already in spec.stableText (stableTextForLocalDispatch); there is nothing local to read.
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
content.push({ type: 'text', text: spec.stableText, cache_control: { type: 'ephemeral' } });
|
|
168
|
-
content.push({ type: 'text', text: spec.volatileText });
|
|
169
|
-
const writeStream = client.messages.stream({
|
|
196
|
+
const writeMsg = await callModel({
|
|
197
|
+
purpose: 'generate_spec_batch:write',
|
|
170
198
|
model: spec.model,
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
output_config: spec.schema ? { effort: spec.effort, format: { type: 'json_schema', schema: spec.schema } } : { effort: spec.effort },
|
|
199
|
+
maxTokens: spec.maxTokens,
|
|
200
|
+
effort: spec.effort,
|
|
174
201
|
system: spec.system,
|
|
175
|
-
|
|
202
|
+
stableText: spec.stableText,
|
|
203
|
+
text: spec.volatileText,
|
|
204
|
+
schema: spec.schema,
|
|
176
205
|
});
|
|
177
|
-
|
|
178
|
-
if (writeMsg.stop_reason === 'refusal')
|
|
179
|
-
throw new Error('The model declined to write specs for this batch.');
|
|
180
|
-
const writeText = writeMsg.content.find((b) => b.type === 'text');
|
|
181
|
-
if (!writeText || writeText.type !== 'text')
|
|
182
|
-
throw new Error('No specs returned.');
|
|
183
|
-
return { result: JSON.parse(writeText.text), filesRead: [] };
|
|
206
|
+
return { result: JSON.parse(writeMsg.text), filesRead: [] };
|
|
184
207
|
}
|
|
185
208
|
const allFiles = await listRepoFiles(repoPath);
|
|
186
209
|
console.log(` scanning ${allFiles.length} files in ${repoPath} for ${moduleIds.length} module(s)…`);
|
|
@@ -196,20 +219,18 @@ ${allFiles.join('\n')}
|
|
|
196
219
|
For EACH module above (by id: ${moduleIds.join(', ')}), list up to ${MAX_FILES_PER_MODULE_IN_BATCH}
|
|
197
220
|
file paths (verbatim from the tree above) most likely to implement it. One entry per module id,
|
|
198
221
|
in the same order as given.`;
|
|
199
|
-
const
|
|
222
|
+
const selectMsg = await callModel({
|
|
223
|
+
purpose: 'generate_spec_batch:file_select',
|
|
200
224
|
model: spec.model,
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
output_config: { effort: 'low', format: { type: 'json_schema', schema: batchFileSelectSchema } },
|
|
225
|
+
maxTokens: 3000,
|
|
226
|
+
effort: 'low',
|
|
204
227
|
system: spec.system,
|
|
205
|
-
|
|
228
|
+
stableText: '',
|
|
229
|
+
text: selectPrompt,
|
|
230
|
+
schema: batchFileSelectSchema,
|
|
206
231
|
});
|
|
207
|
-
const selectMsg = await selectStream.finalMessage();
|
|
208
|
-
const selectText = selectMsg.content.find((b) => b.type === 'text');
|
|
209
|
-
if (!selectText || selectText.type !== 'text')
|
|
210
|
-
throw new Error('Could not pick files for this batch (no text in the file-select response).');
|
|
211
232
|
const known = new Set(allFiles);
|
|
212
|
-
const selections = JSON.parse(
|
|
233
|
+
const selections = JSON.parse(selectMsg.text).selections;
|
|
213
234
|
const uniquePaths = [...new Set(selections.flatMap((s) => s.paths.filter((p) => known.has(p)).slice(0, MAX_FILES_PER_MODULE_IN_BATCH)))];
|
|
214
235
|
const fileMap = new Map();
|
|
215
236
|
await Promise.all(uniquePaths.map(async (p) => {
|
|
@@ -225,25 +246,17 @@ in the same order as given.`;
|
|
|
225
246
|
return `## Files read for ${s.moduleId}\n\n${found.length ? found.map((p) => `### ${p}\n\`\`\`\n${fileMap.get(p)}\n\`\`\``).join('\n\n') : '(nothing readable found for this module)'}`;
|
|
226
247
|
})
|
|
227
248
|
.join('\n\n');
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
content.push({ type: 'text', text: spec.stableText, cache_control: { type: 'ephemeral' } });
|
|
231
|
-
content.push({ type: 'text', text: `${spec.volatileText}\n\n# Related files, as found in the local checkout\n\n${filesSection}` });
|
|
232
|
-
const writeStream = client.messages.stream({
|
|
249
|
+
const writeMsg = await callModel({
|
|
250
|
+
purpose: 'generate_spec_batch:write',
|
|
233
251
|
model: spec.model,
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
output_config: spec.schema ? { effort: spec.effort, format: { type: 'json_schema', schema: spec.schema } } : { effort: spec.effort },
|
|
252
|
+
maxTokens: spec.maxTokens,
|
|
253
|
+
effort: spec.effort,
|
|
237
254
|
system: spec.system,
|
|
238
|
-
|
|
255
|
+
stableText: spec.stableText,
|
|
256
|
+
text: `${spec.volatileText}\n\n# Related files, as found in the local checkout\n\n${filesSection}`,
|
|
257
|
+
schema: spec.schema,
|
|
239
258
|
});
|
|
240
|
-
|
|
241
|
-
if (writeMsg.stop_reason === 'refusal')
|
|
242
|
-
throw new Error('The model declined to write specs for this batch.');
|
|
243
|
-
const writeText = writeMsg.content.find((b) => b.type === 'text');
|
|
244
|
-
if (!writeText || writeText.type !== 'text')
|
|
245
|
-
throw new Error('No specs returned.');
|
|
246
|
-
return { result: JSON.parse(writeText.text), filesRead: [...fileMap.keys()] };
|
|
259
|
+
return { result: JSON.parse(writeMsg.text), filesRead: [...fileMap.keys()] };
|
|
247
260
|
}
|
|
248
261
|
/* ---------------------------------------------------- propose_skeleton / propose_new_capabilities */
|
|
249
262
|
/**
|
|
@@ -258,7 +271,7 @@ in the same order as given.`;
|
|
|
258
271
|
* server/index.ts's completeAiJob payloads for /api/setup/skeleton and
|
|
259
272
|
* /api/module-suggestions/scan — neither includes it), so there's nothing meaningful to report.
|
|
260
273
|
*/
|
|
261
|
-
async function runShallowRepoTask(
|
|
274
|
+
async function runShallowRepoTask(callModel, purpose, spec, repoPath, connectionless = false) {
|
|
262
275
|
let repoSection = '';
|
|
263
276
|
if (!connectionless) {
|
|
264
277
|
const repoCtx = await buildLocalRepoContext(repoPath);
|
|
@@ -279,45 +292,35 @@ ${repoCtx.changelog ?? '(none found)'}
|
|
|
279
292
|
${repoCtx.packageJson ?? '(none found)'}
|
|
280
293
|
`.trim();
|
|
281
294
|
}
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
content.push({ type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: spec.stableDocumentBase64 } });
|
|
285
|
-
}
|
|
286
|
-
if (spec.stableText)
|
|
287
|
-
content.push({ type: 'text', text: spec.stableText, cache_control: { type: 'ephemeral' } });
|
|
288
|
-
content.push({ type: 'text', text: connectionless ? spec.volatileText : `${spec.volatileText}\n\n${repoSection}` });
|
|
289
|
-
const stream = client.messages.stream({
|
|
295
|
+
const message = await callModel({
|
|
296
|
+
purpose,
|
|
290
297
|
model: spec.model,
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
output_config: spec.schema ? { effort: spec.effort, format: { type: 'json_schema', schema: spec.schema } } : { effort: spec.effort },
|
|
298
|
+
maxTokens: spec.maxTokens,
|
|
299
|
+
effort: spec.effort,
|
|
294
300
|
system: spec.system,
|
|
295
|
-
|
|
301
|
+
stableText: spec.stableText,
|
|
302
|
+
text: connectionless ? spec.volatileText : `${spec.volatileText}\n\n${repoSection}`,
|
|
303
|
+
schema: spec.schema,
|
|
304
|
+
documentBase64: spec.stableDocumentBase64,
|
|
296
305
|
});
|
|
297
|
-
|
|
298
|
-
if (message.stop_reason === 'refusal')
|
|
299
|
-
throw new Error('The model declined this request.');
|
|
300
|
-
const text = message.content.find((b) => b.type === 'text');
|
|
301
|
-
if (!text || text.type !== 'text')
|
|
302
|
-
throw new Error('No result returned.');
|
|
303
|
-
return { result: spec.schema ? JSON.parse(text.text) : text.text, filesRead: [] };
|
|
306
|
+
return { result: spec.schema ? JSON.parse(message.text) : message.text, filesRead: [] };
|
|
304
307
|
}
|
|
305
308
|
/** Forward-compatible fallback for server-created graph/spec reasoning tasks. The server sends
|
|
306
309
|
* the complete self-describing spec, so these calls never need or receive filesystem context. */
|
|
307
|
-
export async function runGenericTask(
|
|
310
|
+
export async function runGenericTask(callModel, task, repoPath) {
|
|
308
311
|
const spec = task.payload?.spec;
|
|
309
312
|
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
310
313
|
throw new Error(`Cannot run task kind "${task.kind}": payload.spec is missing or not an object.`);
|
|
311
314
|
}
|
|
312
|
-
return runShallowRepoTask(
|
|
315
|
+
return runShallowRepoTask(callModel, 'generic', spec, repoPath, true);
|
|
313
316
|
}
|
|
314
|
-
export async function runProposeSkeletonTask(
|
|
317
|
+
export async function runProposeSkeletonTask(callModel, task, repoPath) {
|
|
315
318
|
const payload = task.payload;
|
|
316
|
-
return runShallowRepoTask(
|
|
319
|
+
return runShallowRepoTask(callModel, 'propose_skeleton', payload.spec, repoPath, payload.connectionless);
|
|
317
320
|
}
|
|
318
|
-
export async function runProposeNewCapabilitiesTask(
|
|
321
|
+
export async function runProposeNewCapabilitiesTask(callModel, task, repoPath) {
|
|
319
322
|
const payload = task.payload;
|
|
320
|
-
return runShallowRepoTask(
|
|
323
|
+
return runShallowRepoTask(callModel, 'propose_new_capabilities', payload.spec, repoPath);
|
|
321
324
|
}
|
|
322
325
|
/* --------------------------------------------------------------------- design_prompt */
|
|
323
326
|
/**
|
|
@@ -329,33 +332,22 @@ export async function runProposeNewCapabilitiesTask(client, task, repoPath) {
|
|
|
329
332
|
* generateDesignPrompt — the local-dispatch branch decides which wording to send based on the
|
|
330
333
|
* same `direction` the server path already respects, before this ever reaches the companion).
|
|
331
334
|
*/
|
|
332
|
-
export async function runDesignPromptTask(
|
|
335
|
+
export async function runDesignPromptTask(callModel, task, repoPath) {
|
|
333
336
|
const payload = task.payload;
|
|
334
337
|
const spec = payload.spec;
|
|
335
338
|
const designContext = await buildLocalDesignContext(repoPath);
|
|
336
339
|
console.log(` design signal: ${designContext ? 'found' : 'none found'}`);
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
content.push({ type: 'text', text: spec.stableText, cache_control: { type: 'ephemeral' } });
|
|
340
|
-
content.push({
|
|
341
|
-
type: 'text',
|
|
342
|
-
text: designContext ? `${spec.volatileText}\n\n# Design signal found in your local checkout\n\n${designContext}` : spec.volatileText,
|
|
343
|
-
});
|
|
344
|
-
const stream = client.messages.stream({
|
|
340
|
+
const message = await callModel({
|
|
341
|
+
purpose: 'design_prompt',
|
|
345
342
|
model: spec.model,
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
output_config: spec.schema ? { effort: spec.effort, format: { type: 'json_schema', schema: spec.schema } } : { effort: spec.effort },
|
|
343
|
+
maxTokens: spec.maxTokens,
|
|
344
|
+
effort: spec.effort,
|
|
349
345
|
system: spec.system,
|
|
350
|
-
|
|
346
|
+
stableText: spec.stableText,
|
|
347
|
+
text: designContext ? `${spec.volatileText}\n\n# Design signal found in your local checkout\n\n${designContext}` : spec.volatileText,
|
|
348
|
+
schema: spec.schema,
|
|
351
349
|
});
|
|
352
|
-
|
|
353
|
-
if (message.stop_reason === 'refusal')
|
|
354
|
-
throw new Error('The model declined to write a design prompt.');
|
|
355
|
-
const text = message.content.find((b) => b.type === 'text');
|
|
356
|
-
if (!text || text.type !== 'text')
|
|
357
|
-
throw new Error('No design prompt returned.');
|
|
358
|
-
return { result: text.text, filesRead: [] }; // filesRead unused for this kind — see propose_skeleton/propose_new_capabilities's own comment
|
|
350
|
+
return { result: message.text, filesRead: [] }; // filesRead unused for this kind — see propose_skeleton/propose_new_capabilities's own comment
|
|
359
351
|
}
|
|
360
352
|
/* --------------------------------------------------------------------- analyse_shipment */
|
|
361
353
|
/**
|
|
@@ -369,7 +361,7 @@ export async function runDesignPromptTask(client, task, repoPath) {
|
|
|
369
361
|
* error via POST .../error, which surfaces to checkConnection as one caught, per-commit failure
|
|
370
362
|
* (see server/poller.ts's own comment), not a crash that would wedge the rest of that check.
|
|
371
363
|
*/
|
|
372
|
-
export async function runAnalyseShipmentTask(
|
|
364
|
+
export async function runAnalyseShipmentTask(callModel, task, repoPath) {
|
|
373
365
|
const payload = task.payload;
|
|
374
366
|
const prep = await prepareShipmentAnalysis(repoPath, payload.sha);
|
|
375
367
|
if (prep.outcome === 'sha_not_local') {
|
|
@@ -381,25 +373,17 @@ export async function runAnalyseShipmentTask(client, task, repoPath) {
|
|
|
381
373
|
}
|
|
382
374
|
console.log(` read ${payload.sha.slice(0, 7)}'s diff locally (${prep.diffText.length} chars, ${prep.changedPaths.length} file(s) changed)`);
|
|
383
375
|
const spec = appendDiffToVolatile(payload.spec, prep.diffText, prep.changedPaths);
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
content.push({ type: 'text', text: spec.stableText, cache_control: { type: 'ephemeral' } });
|
|
387
|
-
content.push({ type: 'text', text: spec.volatileText });
|
|
388
|
-
const stream = client.messages.stream({
|
|
376
|
+
const message = await callModel({
|
|
377
|
+
purpose: 'analyse_shipment',
|
|
389
378
|
model: spec.model,
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
output_config: spec.schema ? { effort: spec.effort, format: { type: 'json_schema', schema: spec.schema } } : { effort: spec.effort },
|
|
379
|
+
maxTokens: spec.maxTokens,
|
|
380
|
+
effort: spec.effort,
|
|
393
381
|
system: spec.system,
|
|
394
|
-
|
|
382
|
+
stableText: spec.stableText,
|
|
383
|
+
text: spec.volatileText,
|
|
384
|
+
schema: spec.schema,
|
|
395
385
|
});
|
|
396
|
-
const message = await stream.finalMessage();
|
|
397
|
-
if (message.stop_reason === 'refusal')
|
|
398
|
-
throw new Error('The model declined to analyse this shipment.');
|
|
399
|
-
const text = message.content.find((b) => b.type === 'text');
|
|
400
|
-
if (!text || text.type !== 'text')
|
|
401
|
-
throw new Error('No drift analysis returned.');
|
|
402
386
|
// filesRead unused for this kind, same as propose_skeleton/propose_new_capabilities above —
|
|
403
387
|
// dispatchAndAwaitLocalTask's caller (poller.ts) only consumes the parsed result, not filesRead.
|
|
404
|
-
return { result: JSON.parse(
|
|
388
|
+
return { result: JSON.parse(message.text), filesRead: [] };
|
|
405
389
|
}
|