product-console-companion 0.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/LICENSE +21 -0
- package/README.md +96 -0
- package/dist/api-key-caller.js +371 -0
- package/dist/claude-cli-caller.js +181 -0
- package/dist/cli.js +207 -0
- package/dist/config.js +32 -0
- package/dist/design-context.js +54 -0
- package/dist/http-client.js +46 -0
- package/dist/local-scan.js +58 -0
- package/dist/sandbox.js +78 -0
- package/dist/shipment.js +111 -0
- package/dist/types.js +9 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fynch Consulting
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# product-console-companion
|
|
2
|
+
|
|
3
|
+
A local-first BYOS companion for the [Fynch Product Console](../README.md). Reads code from
|
|
4
|
+
your own machine, so:
|
|
5
|
+
|
|
6
|
+
- code never leaves your machine — only a small structured verdict (matches/differs + a short
|
|
7
|
+
explanation) is ever sent back;
|
|
8
|
+
- there's no server-side truncation on what gets read (`server/drift.ts`'s
|
|
9
|
+
`MAX_FILE_CHARS`/`MAX_TREE_PATHS` don't apply here at all);
|
|
10
|
+
- AI spend bills against your own key/subscription, not the workspace's shared/BYOK credential.
|
|
11
|
+
|
|
12
|
+
Covers every AI call the hosted Console makes that reads your code — six task kinds as of this
|
|
13
|
+
release (`verify_module` — "Check against code" on a module card; `generate_spec_batch` —
|
|
14
|
+
writing full specs during setup; `propose_skeleton` and `propose_new_capabilities` — the two
|
|
15
|
+
shallow whole-repo scans; `design_prompt` — the free-text export; `analyse_shipment` — the
|
|
16
|
+
passive per-commit drift check behind the GitHub webhook and the "Check GitHub" button), two
|
|
17
|
+
backends, chosen per connection at `connect` time:
|
|
18
|
+
|
|
19
|
+
- **`api-key`** (default) — no CLI dependency. Calls Anthropic directly with your own API key
|
|
20
|
+
(`api-key-caller.ts`), running its own two-pass file-select-then-judge, mirroring
|
|
21
|
+
`server/drift.ts`'s `verifyModuleAgainstCode` but reading local files unbounded.
|
|
22
|
+
- **`cli`** — shells out to an already-authenticated `claude` CLI (`claude-cli-caller.ts`) for
|
|
23
|
+
full agentic Read/Grep/Glob depth in one call — the model does its own exploring instead of a
|
|
24
|
+
separate file-select pass. Bills against whatever the CLI is authenticated with (a Claude
|
|
25
|
+
subscription via `claude setup-token`, or `ANTHROPIC_API_KEY` if the CLI has that set). Runs
|
|
26
|
+
against a disposable, read-only git worktree (`sandbox.ts`) as defense-in-depth on top of
|
|
27
|
+
`--restricted --tools "Read,Grep,Glob"`, which should already make a write/exec structurally
|
|
28
|
+
impossible on its own.
|
|
29
|
+
|
|
30
|
+
## Setup
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx product-console-companion connect \
|
|
34
|
+
--url https://console.fynchconsulting.com \
|
|
35
|
+
--token <publicId>.<secret> \
|
|
36
|
+
--connection <connectionId> \
|
|
37
|
+
--path /path/to/your/local/checkout \
|
|
38
|
+
--backend api-key # or: --backend cli
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Get `--token`/`--connection` from the Console's **Companion** button (generates a token and a
|
|
42
|
+
ready-to-paste version of this command) — see `src/CompanionModal.tsx` in the parent repo.
|
|
43
|
+
Running `connect` again for a different `--connection`/`--path` (same `--url`/`--token`) adds a
|
|
44
|
+
second connection to the same local config; one companion process can serve several repos, each
|
|
45
|
+
with its own backend choice.
|
|
46
|
+
|
|
47
|
+
For `--backend api-key`: `export ANTHROPIC_API_KEY=sk-ant-...` (get one at console.anthropic.com).
|
|
48
|
+
For `--backend cli`: install and authenticate the CLI first — `npm install -g
|
|
49
|
+
@anthropic-ai/claude-code`, then `claude` (interactive login) or set `ANTHROPIC_API_KEY` for it.
|
|
50
|
+
`connect` checks `claude --version` up front and fails fast if it's missing.
|
|
51
|
+
|
|
52
|
+
Then, in the Console's **Manage connected repos**, switch that repo's execution to "On your
|
|
53
|
+
machine, via companion". Finally, to actually serve tasks:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npx product-console-companion run
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Polls each configured connection every few seconds until Ctrl+C. Nothing runs while this isn't
|
|
60
|
+
running — see the parent plan's Section 7 for why that's deliberate (goal 4: no AI spend while
|
|
61
|
+
you're not around to notice).
|
|
62
|
+
|
|
63
|
+
## What's NOT yet implemented (be aware before relying on this)
|
|
64
|
+
|
|
65
|
+
- **Per-task budget is not enforced client-side for the `api-key` backend.** Every dispatched
|
|
66
|
+
task carries a `maxBudgetUsd` (visible in the `run` log line); `api-key-caller.ts` does not
|
|
67
|
+
track spend against it or abort a call that exceeds it. The `cli` backend passes it straight
|
|
68
|
+
through as `claude`'s own `--max-budget-usd`, which the CLI does enforce (confirmed
|
|
69
|
+
empirically: it stops and reports `terminal_reason: 'budget_exhausted'`).
|
|
70
|
+
- **No wall-clock timeout for the `api-key` backend** — a hung request blocks that connection's
|
|
71
|
+
polling loop. The `cli` backend has one (10 minutes, independent of the budget cap — a dollar
|
|
72
|
+
ceiling doesn't stop a network-stall hang).
|
|
73
|
+
- **Git-fetch-if-missing is `analyse_shipment`-only.** Every other task kind reads whatever HEAD
|
|
74
|
+
currently is in `--path` (or, for `cli`, a worktree pinned to that HEAD) — correct for those,
|
|
75
|
+
since they check the *current* state of your code, not a specific historical commit.
|
|
76
|
+
`analyse_shipment` is different: it's about one specific commit a webhook or the hosted poller
|
|
77
|
+
told the server about, which the server never fetches for you. It verifies that commit is
|
|
78
|
+
actually present locally first, attempts one `git fetch` (your own git credentials — never
|
|
79
|
+
anything from the hosted server) if it isn't, and reports back distinctly (`sha_not_local`)
|
|
80
|
+
rather than silently analysing the wrong tree state if it still can't find it.
|
|
81
|
+
- **`cli` backend's sandbox worktree isolates writes, not reads** — `--add-dir` points at the
|
|
82
|
+
disposable worktree, so a read sees exactly what's committed at HEAD, including anything
|
|
83
|
+
`.gitignore`d being invisible (same as `git ls-files`'s own behavior). Uncommitted local
|
|
84
|
+
changes in your real checkout are never visible to the CLI backend — worth knowing if you're
|
|
85
|
+
expecting a check to see work-in-progress edits. (`analyse_shipment`'s own diff read is the one
|
|
86
|
+
exception: it happens against your real checkout, via `git show`, before the sandbox is even
|
|
87
|
+
created — the model itself still only ever sees the sandboxed copy.)
|
|
88
|
+
|
|
89
|
+
## Local development (inside the parent repo)
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
cd companion
|
|
93
|
+
npm install
|
|
94
|
+
npm run dev -- connect --url http://localhost:3101 --token <publicId>.<secret> --connection <connectionId> --path <repo path> --backend api-key
|
|
95
|
+
npm run dev -- run
|
|
96
|
+
```
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { buildLocalDesignContext } from './design-context.js';
|
|
4
|
+
import { buildLocalRepoContext, listRepoFiles } from './local-scan.js';
|
|
5
|
+
import { appendDiffToVolatile, prepareShipmentAnalysis } from './shipment.js';
|
|
6
|
+
const MAX_CANDIDATE_FILES = 8;
|
|
7
|
+
/** A skip threshold, not a truncation — a file over this is left OUT of what's read entirely
|
|
8
|
+
* (with a note in the log), rather than read and cut off partway through. The whole point of
|
|
9
|
+
* this backend is that what IS read is never truncated; this only guards against an
|
|
10
|
+
* accidentally-selected generated/binary/data file blowing up the prompt. */
|
|
11
|
+
const MAX_FILE_BYTES = 512 * 1024;
|
|
12
|
+
const fileSelectSchema = {
|
|
13
|
+
type: 'object',
|
|
14
|
+
additionalProperties: false,
|
|
15
|
+
required: ['paths'],
|
|
16
|
+
properties: {
|
|
17
|
+
paths: {
|
|
18
|
+
type: 'array',
|
|
19
|
+
items: { type: 'string' },
|
|
20
|
+
description: `Up to ${MAX_CANDIDATE_FILES} repo file paths (verbatim from the tree) most likely to implement this module. Prefer source over tests/config. Empty if nothing in the tree plausibly implements it.`,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
async function readLocalFile(repoPath, relPath) {
|
|
25
|
+
try {
|
|
26
|
+
const buf = await readFile(join(repoPath, relPath));
|
|
27
|
+
if (buf.byteLength > MAX_FILE_BYTES) {
|
|
28
|
+
console.warn(` [skip] ${relPath} is ${Math.round(buf.byteLength / 1024)}KB — over the ${MAX_FILE_BYTES / 1024}KB safety threshold, leaving it out rather than truncating it`);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return buf.toString('utf8');
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Runs the whole verify_module task locally and returns exactly what
|
|
38
|
+
* POST /api/companion/tasks/:id/result expects — `result` validated server-side against the
|
|
39
|
+
* exact schema the task shipped with (task.payload.spec.schema), `filesRead` for the UI's
|
|
40
|
+
* provenance note. Throws on anything that should surface as a task error (caller reports it
|
|
41
|
+
* via POST .../error). */
|
|
42
|
+
export async function runVerifyModuleTask(client, task, repoPath) {
|
|
43
|
+
const payload = task.payload;
|
|
44
|
+
const spec = payload.spec;
|
|
45
|
+
const allFiles = await listRepoFiles(repoPath);
|
|
46
|
+
console.log(` scanning ${allFiles.length} files in ${repoPath}…`);
|
|
47
|
+
// Pass 1: which files implement this module? Reuses the module-spec text the server already
|
|
48
|
+
// wrote into spec.volatileText (buildVerifyModuleTaskSpec) as context — it already contains
|
|
49
|
+
// the full spec, so this doesn't need to re-derive it.
|
|
50
|
+
const selectPrompt = `${spec.volatileText}
|
|
51
|
+
|
|
52
|
+
# The repository file tree (${allFiles.length} files)
|
|
53
|
+
|
|
54
|
+
${allFiles.join('\n')}
|
|
55
|
+
|
|
56
|
+
# Before judging
|
|
57
|
+
|
|
58
|
+
First, list the file paths (verbatim from the tree above, up to ${MAX_CANDIDATE_FILES}) most
|
|
59
|
+
likely to implement this module's behaviour. Prefer application source over tests, config, and
|
|
60
|
+
generated files. Empty list if nothing plausibly implements it.`;
|
|
61
|
+
const selectStream = client.messages.stream({
|
|
62
|
+
model: spec.model,
|
|
63
|
+
max_tokens: 2000,
|
|
64
|
+
thinking: { type: 'adaptive' },
|
|
65
|
+
output_config: { effort: 'low', format: { type: 'json_schema', schema: fileSelectSchema } },
|
|
66
|
+
system: spec.system,
|
|
67
|
+
messages: [{ role: 'user', content: selectPrompt }],
|
|
68
|
+
});
|
|
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
|
+
const known = new Set(allFiles);
|
|
74
|
+
const chosen = JSON.parse(selectText.text).paths.filter((p) => known.has(p)).slice(0, MAX_CANDIDATE_FILES);
|
|
75
|
+
if (chosen.length === 0) {
|
|
76
|
+
return {
|
|
77
|
+
filesRead: [],
|
|
78
|
+
result: {
|
|
79
|
+
verdict: 'differs',
|
|
80
|
+
specSaid: 'See the module spec above.',
|
|
81
|
+
codeDid: 'No file in the local checkout appears to implement this module.',
|
|
82
|
+
differences: ['Nothing in the local checkout plausibly implements this spec — it may be unbuilt, or the code lives outside this checkout.'],
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const files = (await Promise.all(chosen.map(async (p) => {
|
|
87
|
+
const content = await readLocalFile(repoPath, p);
|
|
88
|
+
return content !== null ? { path: p, content } : null;
|
|
89
|
+
}))).filter((f) => f !== null);
|
|
90
|
+
console.log(` reading ${files.map((f) => f.path).join(', ') || '(nothing readable)'}`);
|
|
91
|
+
// Pass 2: does the code satisfy the spec? spec.system/spec.volatileText/spec.schema are sent
|
|
92
|
+
// by the server unchanged — this is the exact same prompt+schema the server-execution path
|
|
93
|
+
// would use, just pointed at unbounded local file contents instead of truncated GitHub reads.
|
|
94
|
+
const content = [];
|
|
95
|
+
if (spec.stableText)
|
|
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({
|
|
102
|
+
model: spec.model,
|
|
103
|
+
max_tokens: spec.maxTokens,
|
|
104
|
+
thinking: { type: 'adaptive' },
|
|
105
|
+
output_config: spec.schema
|
|
106
|
+
? { effort: spec.effort, format: { type: 'json_schema', schema: spec.schema } }
|
|
107
|
+
: { effort: spec.effort },
|
|
108
|
+
system: spec.system,
|
|
109
|
+
messages: [{ role: 'user', content }],
|
|
110
|
+
});
|
|
111
|
+
const verdictMsg = await verdictStream.finalMessage();
|
|
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) };
|
|
118
|
+
}
|
|
119
|
+
/* ------------------------------------------------------------------ generate_spec_batch */
|
|
120
|
+
/** Matches verify_module's own MAX_CANDIDATE_FILES — tried lower (4) first on the theory that a
|
|
121
|
+
* batch multiplies by however many modules it holds, but a real end-to-end test caught it
|
|
122
|
+
* under-reading: a module genuinely spread across companion/, server/, and src/ (this repo's
|
|
123
|
+
* own "Local Companion Agent" module, dogfooded during Phase 3 development) got 4 real files
|
|
124
|
+
* read and produced an accurate spec for what it saw, but never even considered
|
|
125
|
+
* server/companion.ts or src/CompanionModal.tsx — half the actual implementation. Comfortably
|
|
126
|
+
* beats the server path's own FILES_PER_MODULE=2 (bootstrap.ts) either way; this raises the
|
|
127
|
+
* ceiling for the genuinely multi-file case rather than assuming it wouldn't come up. */
|
|
128
|
+
const MAX_FILES_PER_MODULE_IN_BATCH = 8;
|
|
129
|
+
const batchFileSelectSchema = {
|
|
130
|
+
type: 'object',
|
|
131
|
+
additionalProperties: false,
|
|
132
|
+
required: ['selections'],
|
|
133
|
+
properties: {
|
|
134
|
+
selections: {
|
|
135
|
+
type: 'array',
|
|
136
|
+
description: 'One entry per module in the batch, in the same order.',
|
|
137
|
+
items: {
|
|
138
|
+
type: 'object',
|
|
139
|
+
additionalProperties: false,
|
|
140
|
+
required: ['moduleId', 'paths'],
|
|
141
|
+
properties: {
|
|
142
|
+
moduleId: { type: 'string' },
|
|
143
|
+
paths: {
|
|
144
|
+
type: 'array',
|
|
145
|
+
items: { type: 'string' },
|
|
146
|
+
description: `Up to ${MAX_FILES_PER_MODULE_IN_BATCH} repo file paths (verbatim from the tree) most likely to implement this module. Empty if nothing plausibly does.`,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
/** Runs a whole generate_spec_batch task locally — writes full specs for every module in the
|
|
154
|
+
* batch (up to server/bootstrap.ts's BATCH_SIZE, currently 5) in one task. Same two-pass shape
|
|
155
|
+
* as runVerifyModuleTask (pick files, then use them), just per-module in pass 1 and producing
|
|
156
|
+
* the whole specs array in pass 2. `spec.volatileText` already carries the per-module id/lane/
|
|
157
|
+
* purpose breakdown and the "find files yourself" instruction (buildSpecBatchVolatile,
|
|
158
|
+
* server/bootstrap.ts) — this only adds the file tree and, in pass 2, what was actually read. */
|
|
159
|
+
export async function runGenerateSpecBatchTask(client, task, repoPath) {
|
|
160
|
+
const payload = task.payload;
|
|
161
|
+
const spec = payload.spec;
|
|
162
|
+
const moduleIds = payload.moduleIds;
|
|
163
|
+
const allFiles = await listRepoFiles(repoPath);
|
|
164
|
+
console.log(` scanning ${allFiles.length} files in ${repoPath} for ${moduleIds.length} module(s)…`);
|
|
165
|
+
// Pass 1: which files relate to each module in this batch?
|
|
166
|
+
const selectPrompt = `${spec.volatileText}
|
|
167
|
+
|
|
168
|
+
# The repository file tree (${allFiles.length} files)
|
|
169
|
+
|
|
170
|
+
${allFiles.join('\n')}
|
|
171
|
+
|
|
172
|
+
# Before writing specs
|
|
173
|
+
|
|
174
|
+
For EACH module above (by id: ${moduleIds.join(', ')}), list up to ${MAX_FILES_PER_MODULE_IN_BATCH}
|
|
175
|
+
file paths (verbatim from the tree above) most likely to implement it. One entry per module id,
|
|
176
|
+
in the same order as given.`;
|
|
177
|
+
const selectStream = client.messages.stream({
|
|
178
|
+
model: spec.model,
|
|
179
|
+
max_tokens: 3000,
|
|
180
|
+
thinking: { type: 'adaptive' },
|
|
181
|
+
output_config: { effort: 'low', format: { type: 'json_schema', schema: batchFileSelectSchema } },
|
|
182
|
+
system: spec.system,
|
|
183
|
+
messages: [{ role: 'user', content: selectPrompt }],
|
|
184
|
+
});
|
|
185
|
+
const selectMsg = await selectStream.finalMessage();
|
|
186
|
+
const selectText = selectMsg.content.find((b) => b.type === 'text');
|
|
187
|
+
if (!selectText || selectText.type !== 'text')
|
|
188
|
+
throw new Error('Could not pick files for this batch (no text in the file-select response).');
|
|
189
|
+
const known = new Set(allFiles);
|
|
190
|
+
const selections = JSON.parse(selectText.text).selections;
|
|
191
|
+
const uniquePaths = [...new Set(selections.flatMap((s) => s.paths.filter((p) => known.has(p)).slice(0, MAX_FILES_PER_MODULE_IN_BATCH)))];
|
|
192
|
+
const fileMap = new Map();
|
|
193
|
+
await Promise.all(uniquePaths.map(async (p) => {
|
|
194
|
+
const content = await readLocalFile(repoPath, p);
|
|
195
|
+
if (content !== null)
|
|
196
|
+
fileMap.set(p, content);
|
|
197
|
+
}));
|
|
198
|
+
console.log(` reading ${[...fileMap.keys()].join(', ') || '(nothing readable)'}`);
|
|
199
|
+
// Pass 2: write the actual specs, grounded in what was read.
|
|
200
|
+
const filesSection = selections
|
|
201
|
+
.map((s) => {
|
|
202
|
+
const found = s.paths.filter((p) => fileMap.has(p));
|
|
203
|
+
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)'}`;
|
|
204
|
+
})
|
|
205
|
+
.join('\n\n');
|
|
206
|
+
const content = [];
|
|
207
|
+
if (spec.stableText)
|
|
208
|
+
content.push({ type: 'text', text: spec.stableText, cache_control: { type: 'ephemeral' } });
|
|
209
|
+
content.push({ type: 'text', text: `${spec.volatileText}\n\n# Related files, as found in the local checkout\n\n${filesSection}` });
|
|
210
|
+
const writeStream = client.messages.stream({
|
|
211
|
+
model: spec.model,
|
|
212
|
+
max_tokens: spec.maxTokens,
|
|
213
|
+
thinking: { type: 'adaptive' },
|
|
214
|
+
output_config: spec.schema ? { effort: spec.effort, format: { type: 'json_schema', schema: spec.schema } } : { effort: spec.effort },
|
|
215
|
+
system: spec.system,
|
|
216
|
+
messages: [{ role: 'user', content }],
|
|
217
|
+
});
|
|
218
|
+
const writeMsg = await writeStream.finalMessage();
|
|
219
|
+
if (writeMsg.stop_reason === 'refusal')
|
|
220
|
+
throw new Error('The model declined to write specs for this batch.');
|
|
221
|
+
const writeText = writeMsg.content.find((b) => b.type === 'text');
|
|
222
|
+
if (!writeText || writeText.type !== 'text')
|
|
223
|
+
throw new Error('No specs returned.');
|
|
224
|
+
return { result: JSON.parse(writeText.text), filesRead: [...fileMap.keys()] };
|
|
225
|
+
}
|
|
226
|
+
/* ---------------------------------------------------- propose_skeleton / propose_new_capabilities */
|
|
227
|
+
/**
|
|
228
|
+
* Both propose_skeleton and propose_new_capabilities are "shallow whole-repo" tasks — server
|
|
229
|
+
* sends stableText/volatileText built with NO repo content at all (see bootstrap.ts's
|
|
230
|
+
* stableTextForLocalDispatch / buildCapabilityScanVolatile's repoCtx-null branch), because
|
|
231
|
+
* there's nothing GitHub-sourced to send: the whole point of dispatching is that the companion
|
|
232
|
+
* reads the repo itself. Unlike verify_module/generate_spec_batch, there's no per-module drilling
|
|
233
|
+
* here — just one shallow local scan (file tree + README/CHANGELOG/package.json, mirroring
|
|
234
|
+
* server/bootstrap.ts's own RepoContext shape) appended after spec.volatileText, then one model
|
|
235
|
+
* call. filesRead is always empty for these two: neither result shape uses it (check
|
|
236
|
+
* server/index.ts's completeAiJob payloads for /api/setup/skeleton and
|
|
237
|
+
* /api/module-suggestions/scan — neither includes it), so there's nothing meaningful to report.
|
|
238
|
+
*/
|
|
239
|
+
async function runShallowRepoTask(client, spec, repoPath) {
|
|
240
|
+
const repoCtx = await buildLocalRepoContext(repoPath);
|
|
241
|
+
console.log(` scanning ${repoCtx.fileList.length} files, README ${repoCtx.readme ? 'found' : 'not found'}, CHANGELOG ${repoCtx.changelog ? 'found' : 'not found'}, package.json ${repoCtx.packageJson ? 'found' : 'not found'}`);
|
|
242
|
+
const repoSection = `
|
|
243
|
+
# Repository context (your local checkout)
|
|
244
|
+
|
|
245
|
+
## File tree (${repoCtx.fileList.length} files)
|
|
246
|
+
${repoCtx.fileList.join('\n')}
|
|
247
|
+
|
|
248
|
+
## README
|
|
249
|
+
${repoCtx.readme ?? '(none found)'}
|
|
250
|
+
|
|
251
|
+
## CHANGELOG
|
|
252
|
+
${repoCtx.changelog ?? '(none found)'}
|
|
253
|
+
|
|
254
|
+
## package.json
|
|
255
|
+
${repoCtx.packageJson ?? '(none found)'}
|
|
256
|
+
`.trim();
|
|
257
|
+
const content = [];
|
|
258
|
+
if (spec.stableDocumentBase64) {
|
|
259
|
+
content.push({ type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: spec.stableDocumentBase64 } });
|
|
260
|
+
}
|
|
261
|
+
if (spec.stableText)
|
|
262
|
+
content.push({ type: 'text', text: spec.stableText, cache_control: { type: 'ephemeral' } });
|
|
263
|
+
content.push({ type: 'text', text: `${spec.volatileText}\n\n${repoSection}` });
|
|
264
|
+
const stream = client.messages.stream({
|
|
265
|
+
model: spec.model,
|
|
266
|
+
max_tokens: spec.maxTokens,
|
|
267
|
+
thinking: { type: 'adaptive' },
|
|
268
|
+
output_config: spec.schema ? { effort: spec.effort, format: { type: 'json_schema', schema: spec.schema } } : { effort: spec.effort },
|
|
269
|
+
system: spec.system,
|
|
270
|
+
messages: [{ role: 'user', content }],
|
|
271
|
+
});
|
|
272
|
+
const message = await stream.finalMessage();
|
|
273
|
+
if (message.stop_reason === 'refusal')
|
|
274
|
+
throw new Error('The model declined this request.');
|
|
275
|
+
const text = message.content.find((b) => b.type === 'text');
|
|
276
|
+
if (!text || text.type !== 'text')
|
|
277
|
+
throw new Error('No result returned.');
|
|
278
|
+
return { result: JSON.parse(text.text), filesRead: [] };
|
|
279
|
+
}
|
|
280
|
+
export async function runProposeSkeletonTask(client, task, repoPath) {
|
|
281
|
+
const payload = task.payload;
|
|
282
|
+
return runShallowRepoTask(client, payload.spec, repoPath);
|
|
283
|
+
}
|
|
284
|
+
export async function runProposeNewCapabilitiesTask(client, task, repoPath) {
|
|
285
|
+
const payload = task.payload;
|
|
286
|
+
return runShallowRepoTask(client, payload.spec, repoPath);
|
|
287
|
+
}
|
|
288
|
+
/* --------------------------------------------------------------------- design_prompt */
|
|
289
|
+
/**
|
|
290
|
+
* The one task kind with no schema — spec.schema is undefined, so the model's raw text answer
|
|
291
|
+
* IS the result, never JSON.parse'd (unlike every other task kind here). Looks for local design
|
|
292
|
+
* signal (findDesignFiles, copied verbatim from server/design-context.ts — see design-context.ts's
|
|
293
|
+
* own comment) and appends it if found; spec.volatileText already carries the right instructions
|
|
294
|
+
* for both the "honor what you find" and "propose from scratch" cases (see export.ts's
|
|
295
|
+
* generateDesignPrompt — the local-dispatch branch decides which wording to send based on the
|
|
296
|
+
* same `direction` the server path already respects, before this ever reaches the companion).
|
|
297
|
+
*/
|
|
298
|
+
export async function runDesignPromptTask(client, task, repoPath) {
|
|
299
|
+
const payload = task.payload;
|
|
300
|
+
const spec = payload.spec;
|
|
301
|
+
const designContext = await buildLocalDesignContext(repoPath);
|
|
302
|
+
console.log(` design signal: ${designContext ? 'found' : 'none found'}`);
|
|
303
|
+
const content = [];
|
|
304
|
+
if (spec.stableText)
|
|
305
|
+
content.push({ type: 'text', text: spec.stableText, cache_control: { type: 'ephemeral' } });
|
|
306
|
+
content.push({
|
|
307
|
+
type: 'text',
|
|
308
|
+
text: designContext ? `${spec.volatileText}\n\n# Design signal found in your local checkout\n\n${designContext}` : spec.volatileText,
|
|
309
|
+
});
|
|
310
|
+
const stream = client.messages.stream({
|
|
311
|
+
model: spec.model,
|
|
312
|
+
max_tokens: spec.maxTokens,
|
|
313
|
+
thinking: { type: 'adaptive' },
|
|
314
|
+
output_config: spec.schema ? { effort: spec.effort, format: { type: 'json_schema', schema: spec.schema } } : { effort: spec.effort },
|
|
315
|
+
system: spec.system,
|
|
316
|
+
messages: [{ role: 'user', content }],
|
|
317
|
+
});
|
|
318
|
+
const message = await stream.finalMessage();
|
|
319
|
+
if (message.stop_reason === 'refusal')
|
|
320
|
+
throw new Error('The model declined to write a design prompt.');
|
|
321
|
+
const text = message.content.find((b) => b.type === 'text');
|
|
322
|
+
if (!text || text.type !== 'text')
|
|
323
|
+
throw new Error('No design prompt returned.');
|
|
324
|
+
return { result: text.text, filesRead: [] }; // filesRead unused for this kind — see propose_skeleton/propose_new_capabilities's own comment
|
|
325
|
+
}
|
|
326
|
+
/* --------------------------------------------------------------------- analyse_shipment */
|
|
327
|
+
/**
|
|
328
|
+
* Runs a whole analyse_shipment task locally. Unlike every other task kind above, this one's
|
|
329
|
+
* "find the relevant content" step is git-level, not file-level (prepareShipmentAnalysis, see
|
|
330
|
+
* shipment.ts), and can conclude the task WITHOUT ever calling the model at all — a commit the
|
|
331
|
+
* companion's own local looksTrivial filter rejects is reported straight back as an empty
|
|
332
|
+
* result ({touched: [], possibleNewModules: []}), exactly the shape driftSchema already treats
|
|
333
|
+
* as a valid "nothing plausible here" answer, so there's no separate result shape to teach the
|
|
334
|
+
* server about. sha_not_local throws — the caller (cli.ts's runOneTask) reports it as a task
|
|
335
|
+
* error via POST .../error, which surfaces to checkConnection as one caught, per-commit failure
|
|
336
|
+
* (see server/poller.ts's own comment), not a crash that would wedge the rest of that check.
|
|
337
|
+
*/
|
|
338
|
+
export async function runAnalyseShipmentTask(client, task, repoPath) {
|
|
339
|
+
const payload = task.payload;
|
|
340
|
+
const prep = await prepareShipmentAnalysis(repoPath, payload.sha);
|
|
341
|
+
if (prep.outcome === 'sha_not_local') {
|
|
342
|
+
throw new Error(`sha_not_local: commit ${payload.sha} was not found in the local checkout at ${repoPath}, even after a git fetch.`);
|
|
343
|
+
}
|
|
344
|
+
if (prep.outcome === 'trivial') {
|
|
345
|
+
console.log(` ${payload.sha.slice(0, 7)} looks trivial (lockfile/CI-config/build-output only) — skipping the AI call`);
|
|
346
|
+
return { result: { touched: [], possibleNewModules: [] }, filesRead: [] };
|
|
347
|
+
}
|
|
348
|
+
console.log(` read ${payload.sha.slice(0, 7)}'s diff locally (${prep.diffText.length} chars, ${prep.changedPaths.length} file(s) changed)`);
|
|
349
|
+
const spec = appendDiffToVolatile(payload.spec, prep.diffText, prep.changedPaths);
|
|
350
|
+
const content = [];
|
|
351
|
+
if (spec.stableText)
|
|
352
|
+
content.push({ type: 'text', text: spec.stableText, cache_control: { type: 'ephemeral' } });
|
|
353
|
+
content.push({ type: 'text', text: spec.volatileText });
|
|
354
|
+
const stream = client.messages.stream({
|
|
355
|
+
model: spec.model,
|
|
356
|
+
max_tokens: spec.maxTokens,
|
|
357
|
+
thinking: { type: 'adaptive' },
|
|
358
|
+
output_config: spec.schema ? { effort: spec.effort, format: { type: 'json_schema', schema: spec.schema } } : { effort: spec.effort },
|
|
359
|
+
system: spec.system,
|
|
360
|
+
messages: [{ role: 'user', content }],
|
|
361
|
+
});
|
|
362
|
+
const message = await stream.finalMessage();
|
|
363
|
+
if (message.stop_reason === 'refusal')
|
|
364
|
+
throw new Error('The model declined to analyse this shipment.');
|
|
365
|
+
const text = message.content.find((b) => b.type === 'text');
|
|
366
|
+
if (!text || text.type !== 'text')
|
|
367
|
+
throw new Error('No drift analysis returned.');
|
|
368
|
+
// filesRead unused for this kind, same as propose_skeleton/propose_new_capabilities above —
|
|
369
|
+
// dispatchAndAwaitLocalTask's caller (poller.ts) only consumes the parsed result, not filesRead.
|
|
370
|
+
return { result: JSON.parse(text.text), filesRead: [] };
|
|
371
|
+
}
|