prism-mcp-server 20.21.22 → 20.21.23
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 +16 -0
- package/dist/config.js +3 -3
- package/dist/connect.js +7 -0
- package/dist/darkfactory/actionExecutor.js +327 -0
- package/dist/darkfactory/backendReadiness.js +12 -0
- package/dist/darkfactory/clawInvocation.js +18 -6
- package/dist/darkfactory/enablement.js +29 -0
- package/dist/darkfactory/executionAuthority.js +11 -0
- package/dist/darkfactory/hostPolicy.js +12 -0
- package/dist/darkfactory/promptRouting.js +59 -0
- package/dist/darkfactory/runner.js +179 -31
- package/dist/darkfactory/safetyController.js +10 -1
- package/dist/dashboard/server.js +5 -4
- package/dist/dashboard/settingsPolicy.js +2 -0
- package/dist/dashboard/ui.js +37 -1
- package/dist/onboarding/wizard.js +1 -1
- package/dist/server.js +12 -10
- package/dist/storage/configStorage.js +49 -0
- package/dist/storage/index.js +38 -7
- package/dist/storage/portalContracts.js +20 -0
- package/dist/storage/sqlite.js +13 -4
- package/dist/storage/supabase.js +27 -5
- package/dist/storage/synalux.js +63 -1
- package/dist/tools/ledgerHandlers.js +24 -2
- package/dist/tools/pipelineDefinitions.js +7 -1
- package/dist/tools/pipelineHandlers.js +18 -1
- package/dist/tools/sessionMemoryDefinitions.js +7 -1
- package/dist/utils/llm/adapters/anthropic.js +11 -3
- package/dist/utils/llm/adapters/gemini.js +3 -2
- package/dist/utils/llm/adapters/openai.js +3 -3
- package/dist/utils/llm/adapters/traced.js +2 -2
- package/dist/verification/runner.js +11 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -48,6 +48,12 @@ A paid subscription adds cloud sync, higher model tiers, and team features throu
|
|
|
48
48
|
Where a host offers hooks (Claude Code, Codex), `prism connect` adds two
|
|
49
49
|
small ones on top: mid-session prompt routing, and a post-compaction
|
|
50
50
|
re-injection of the protected-floor digest.
|
|
51
|
+
- **Prompt-triggered Factory** — enabled by default for suitable bounded
|
|
52
|
+
implementation requests with explicit files, an allowed write list, and a
|
|
53
|
+
verification command. The host starts and monitors the recommended pipeline;
|
|
54
|
+
BCBA requests stay with the host. A dashboard toggle or
|
|
55
|
+
`PRISM_DARK_FACTORY_ENABLED=false` disables Factory after an MCP restart.
|
|
56
|
+
Try the [three-test filename fix](examples/factory-filename-fix/README.md).
|
|
51
57
|
- **Safe escalation and observability** — inference outcomes are explicit,
|
|
52
58
|
reserved text remains fail-closed (clinical images are processed locally,
|
|
53
59
|
never sent to the cloud), and local/cloud usage is recorded for
|
|
@@ -158,6 +164,16 @@ or by re-enabling after each run.
|
|
|
158
164
|
<details>
|
|
159
165
|
<summary>Release history (optional)</summary>
|
|
160
166
|
|
|
167
|
+
## What's New in v20.21.23
|
|
168
|
+
|
|
169
|
+
- Factory routes suitable bounded implementation prompts and executes only the
|
|
170
|
+
allowed file actions, with independent verification and explicit lifecycle results.
|
|
171
|
+
- Paid pipeline creation, updates, aborts, and verification records use the
|
|
172
|
+
authenticated Portal API. Startup waits for paid storage initialization before
|
|
173
|
+
reporting Factory readiness.
|
|
174
|
+
- The default-enabled toggle distinguishes the saved setting from the active
|
|
175
|
+
MCP setting and shows when a restart is required.
|
|
176
|
+
|
|
161
177
|
## What's New in v20.21.22
|
|
162
178
|
|
|
163
179
|
- Factory pipeline lists for cloud accounts use the authenticated Portal API,
|
package/dist/config.js
CHANGED
|
@@ -315,9 +315,9 @@ export const PRISM_VERIFICATION_LAYERS = (process.env.PRISM_VERIFICATION_LAYERS
|
|
|
315
315
|
export const PRISM_VERIFICATION_DEFAULT_SEVERITY = (process.env.PRISM_VERIFICATION_DEFAULT_SEVERITY || "warn");
|
|
316
316
|
// ─── v7.3: Dark Factory Orchestration ─────────────────────────
|
|
317
317
|
// Autonomous pipeline runner: PLAN → EXECUTE → VERIFY → iterate.
|
|
318
|
-
//
|
|
319
|
-
/**
|
|
320
|
-
export const PRISM_DARK_FACTORY_ENABLED =
|
|
318
|
+
// Enabled by default; the persisted preference or an explicit env override can disable it.
|
|
319
|
+
/** Compatibility default; startup resolver applies env or persisted dashboard state. */
|
|
320
|
+
export const PRISM_DARK_FACTORY_ENABLED = true; // Startup resolver applies env override or persisted dashboard setting.
|
|
321
321
|
/** Poll interval for the runner loop (ms). Default: 30s. */
|
|
322
322
|
export const PRISM_DARK_FACTORY_POLL_MS = parseInt(process.env.PRISM_DARK_FACTORY_POLL_MS || "30000", 10);
|
|
323
323
|
/** Default max wall-clock time per pipeline (ms). Default: 15 minutes. */
|
package/dist/connect.js
CHANGED
|
@@ -4,6 +4,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32 as w
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { isDeepStrictEqual } from "node:util";
|
|
6
6
|
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
7
|
+
import { FACTORY_HOST_POLICY_LINES } from "./darkfactory/hostPolicy.js";
|
|
7
8
|
import { CONTEXT_RECOVERY_POLICY_LINES } from "./contextRecoveryPolicy.js";
|
|
8
9
|
import { EVIDENCE_WORKFLOW_POLICY_LINES } from "./evidenceWorkflowPolicy.js";
|
|
9
10
|
import { LOCAL_FIRST_POLICY_ID, LOCAL_FIRST_POLICY_LINES } from "./localFirstPolicy.js";
|
|
@@ -52,6 +53,8 @@ const CODEX_STARTUP_BODY = [
|
|
|
52
53
|
"",
|
|
53
54
|
...CONTEXT_RECOVERY_POLICY_LINES,
|
|
54
55
|
"",
|
|
56
|
+
...FACTORY_HOST_POLICY_LINES,
|
|
57
|
+
"",
|
|
55
58
|
...LOCAL_FIRST_POLICY_LINES,
|
|
56
59
|
"",
|
|
57
60
|
...EVIDENCE_WORKFLOW_POLICY_LINES,
|
|
@@ -588,6 +591,8 @@ function serializeClaudeStartupBlock(newline) {
|
|
|
588
591
|
"",
|
|
589
592
|
...CONTEXT_RECOVERY_POLICY_LINES,
|
|
590
593
|
"",
|
|
594
|
+
...FACTORY_HOST_POLICY_LINES,
|
|
595
|
+
"",
|
|
591
596
|
...LOCAL_FIRST_POLICY_LINES,
|
|
592
597
|
"",
|
|
593
598
|
...EVIDENCE_WORKFLOW_POLICY_LINES,
|
|
@@ -683,6 +688,8 @@ function serializeGeminiStartupBlock(newline) {
|
|
|
683
688
|
"",
|
|
684
689
|
...CONTEXT_RECOVERY_POLICY_LINES,
|
|
685
690
|
"",
|
|
691
|
+
...FACTORY_HOST_POLICY_LINES,
|
|
692
|
+
"",
|
|
686
693
|
...LOCAL_FIRST_POLICY_LINES,
|
|
687
694
|
"",
|
|
688
695
|
...EVIDENCE_WORKFLOW_POLICY_LINES,
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { promises as fs, constants as fsConstants } from "node:fs";
|
|
2
|
+
import { dirname, resolve, relative, isAbsolute } from "node:path";
|
|
3
|
+
import { execFile, spawn } from "node:child_process";
|
|
4
|
+
const MAX_OUTPUT = 16_000;
|
|
5
|
+
const MAX_FILE = 512_000;
|
|
6
|
+
const COMMAND_LIVENESS_POLL_MS = 100;
|
|
7
|
+
const COMMAND_STATUS_POLL_MS = 1_000;
|
|
8
|
+
const COMMAND_LIVENESS_TIMEOUT_MS = 5_000;
|
|
9
|
+
const COMMAND_STOPPED = "Pipeline stopped during command execution";
|
|
10
|
+
const COMMAND_LIVENESS_UNAVAILABLE = "Pipeline command liveness check timed out";
|
|
11
|
+
const COMMAND_TIMED_OUT = "Pipeline command timed out";
|
|
12
|
+
const COMMAND_OUTPUT_EXCEEDED = "Pipeline command output exceeded its limit";
|
|
13
|
+
const WRITE_STAGE_PREFIX = '.prism-write-';
|
|
14
|
+
const WRITE_STAGE_FILE = 'content';
|
|
15
|
+
async function assertCommandLive(stillRunning) {
|
|
16
|
+
let timer;
|
|
17
|
+
try {
|
|
18
|
+
const live = await Promise.race([
|
|
19
|
+
stillRunning(),
|
|
20
|
+
new Promise((_, reject) => {
|
|
21
|
+
timer = setTimeout(() => reject(new Error(COMMAND_LIVENESS_UNAVAILABLE)), COMMAND_LIVENESS_TIMEOUT_MS);
|
|
22
|
+
}),
|
|
23
|
+
]);
|
|
24
|
+
if (!live)
|
|
25
|
+
throw new Error(COMMAND_STOPPED);
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
if (timer)
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Execute only while the caller retains authority; reap inherited descendants too. */
|
|
33
|
+
async function runGuardedCommand(file, args, cwd, timeout, stillRunning, stillOwned = stillRunning) {
|
|
34
|
+
await assertCommandLive(stillRunning);
|
|
35
|
+
return new Promise((resolveCommand, rejectCommand) => {
|
|
36
|
+
let finished = false;
|
|
37
|
+
let checking = false;
|
|
38
|
+
let checkingStatus = false;
|
|
39
|
+
let stopReason;
|
|
40
|
+
let cleanup;
|
|
41
|
+
let stdout = '';
|
|
42
|
+
let stderr = '';
|
|
43
|
+
let outputBytes = 0;
|
|
44
|
+
const child = spawn(file, args, {
|
|
45
|
+
cwd, shell: false, detached: process.platform !== 'win32',
|
|
46
|
+
stdio: ['ignore', 'pipe', 'pipe'], env: { PATH: process.env.PATH },
|
|
47
|
+
});
|
|
48
|
+
child.once('error', error => { stopReason ??= error; });
|
|
49
|
+
child.once('close', (code, signal) => {
|
|
50
|
+
finished = true;
|
|
51
|
+
clearInterval(monitor);
|
|
52
|
+
clearInterval(statusMonitor);
|
|
53
|
+
clearTimeout(deadline);
|
|
54
|
+
void (async () => {
|
|
55
|
+
await terminateTree();
|
|
56
|
+
if (stopReason)
|
|
57
|
+
throw stopReason;
|
|
58
|
+
if (code !== 0)
|
|
59
|
+
throw Object.assign(new Error(`Command exited with ${signal || code}`), { stdout, stderr });
|
|
60
|
+
await assertCommandLive(stillRunning);
|
|
61
|
+
resolveCommand({ stdout, stderr });
|
|
62
|
+
})().catch(rejectCommand);
|
|
63
|
+
});
|
|
64
|
+
function terminateTree() {
|
|
65
|
+
if (cleanup)
|
|
66
|
+
return cleanup;
|
|
67
|
+
const pid = child.pid;
|
|
68
|
+
cleanup = !pid ? Promise.resolve() : new Promise((resolveCleanup, rejectCleanup) => {
|
|
69
|
+
if (process.platform === 'win32') {
|
|
70
|
+
execFile('taskkill', ['/PID', String(pid), '/T', '/F'], { shell: false, timeout: COMMAND_LIVENESS_TIMEOUT_MS }, error => {
|
|
71
|
+
if (error && child.exitCode === null && child.signalCode === null)
|
|
72
|
+
rejectCleanup(error);
|
|
73
|
+
else
|
|
74
|
+
resolveCleanup();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
try {
|
|
79
|
+
process.kill(-pid, 'SIGKILL');
|
|
80
|
+
resolveCleanup();
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (error.code === 'ESRCH')
|
|
84
|
+
resolveCleanup();
|
|
85
|
+
else
|
|
86
|
+
rejectCleanup(error);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
return cleanup;
|
|
91
|
+
}
|
|
92
|
+
function stop(error) {
|
|
93
|
+
stopReason ??= error;
|
|
94
|
+
void terminateTree().catch(cleanupError => { stopReason = new Error(`${error.message}; process cleanup failed: ${String(cleanupError)}`); });
|
|
95
|
+
}
|
|
96
|
+
const collect = (chunk, stream) => {
|
|
97
|
+
outputBytes += Buffer.byteLength(chunk);
|
|
98
|
+
if (outputBytes > MAX_OUTPUT * 2) {
|
|
99
|
+
stop(new Error(COMMAND_OUTPUT_EXCEEDED));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (stream === 'stdout')
|
|
103
|
+
stdout += chunk;
|
|
104
|
+
else
|
|
105
|
+
stderr += chunk;
|
|
106
|
+
};
|
|
107
|
+
child.stdout.setEncoding('utf8').on('data', chunk => collect(chunk, 'stdout'));
|
|
108
|
+
child.stderr.setEncoding('utf8').on('data', chunk => collect(chunk, 'stderr'));
|
|
109
|
+
// Reap inherited descendants on parent exit as well as on cancellation.
|
|
110
|
+
// Their output pipes must close before this command can settle.
|
|
111
|
+
child.once('exit', () => { void terminateTree().catch(error => { stopReason ??= error; }); });
|
|
112
|
+
const deadline = setTimeout(() => stop(new Error(COMMAND_TIMED_OUT)), timeout);
|
|
113
|
+
const monitor = setInterval(() => {
|
|
114
|
+
if (finished || checking)
|
|
115
|
+
return;
|
|
116
|
+
checking = true;
|
|
117
|
+
void assertCommandLive(stillOwned).catch(error => {
|
|
118
|
+
if (finished)
|
|
119
|
+
return;
|
|
120
|
+
stop(error instanceof Error ? error : new Error(String(error)));
|
|
121
|
+
}).finally(() => { checking = false; });
|
|
122
|
+
}, COMMAND_LIVENESS_POLL_MS);
|
|
123
|
+
// Local leases can be checked frequently without turning a repository
|
|
124
|
+
// command into hundreds of authenticated Portal reads per minute.
|
|
125
|
+
const statusMonitor = setInterval(() => {
|
|
126
|
+
if (finished || checkingStatus || stillOwned === stillRunning)
|
|
127
|
+
return;
|
|
128
|
+
checkingStatus = true;
|
|
129
|
+
void assertCommandLive(stillRunning).catch(error => {
|
|
130
|
+
if (!finished)
|
|
131
|
+
stop(error instanceof Error ? error : new Error(String(error)));
|
|
132
|
+
}).finally(() => { checkingStatus = false; });
|
|
133
|
+
}, COMMAND_STATUS_POLL_MS);
|
|
134
|
+
monitor.unref();
|
|
135
|
+
statusMonitor.unref();
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function contained(root, target) {
|
|
139
|
+
const rel = relative(root, target);
|
|
140
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(".." + "/") && !rel.startsWith(".." + "\\") && !isAbsolute(rel));
|
|
141
|
+
}
|
|
142
|
+
async function safePath(spec, targetPath, write) {
|
|
143
|
+
if (!spec.workingDirectory || !targetPath || isAbsolute(targetPath))
|
|
144
|
+
throw new Error("Action path must be relative to workingDirectory");
|
|
145
|
+
const root = await fs.realpath(spec.workingDirectory);
|
|
146
|
+
const candidate = resolve(root, targetPath);
|
|
147
|
+
const parent = await fs.realpath(dirname(candidate));
|
|
148
|
+
const target = resolve(parent, candidate.slice(candidate.lastIndexOf("/") + 1));
|
|
149
|
+
if (!contained(root, target))
|
|
150
|
+
throw new Error("Action path escapes workingDirectory");
|
|
151
|
+
try {
|
|
152
|
+
const final = await fs.realpath(target);
|
|
153
|
+
if (!contained(root, final))
|
|
154
|
+
throw new Error("Action path escapes workingDirectory");
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
if (error.code !== "ENOENT")
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
if (write && (!spec.allowedWritePaths || !spec.allowedWritePaths.some(p => resolve(root, p) === target))) {
|
|
161
|
+
throw new Error("Action path is outside allowedWritePaths");
|
|
162
|
+
}
|
|
163
|
+
return target;
|
|
164
|
+
}
|
|
165
|
+
async function commandArgs(command, spec) {
|
|
166
|
+
const parts = command.trim().split(/\s+/);
|
|
167
|
+
if (parts.length < 2 || parts.some(part => /[;&|`$<>]/.test(part)))
|
|
168
|
+
throw new Error("RUN_TEST command is not allowed");
|
|
169
|
+
const checkPath = async (value) => {
|
|
170
|
+
if (!value || value.startsWith('-') || isAbsolute(value) || value.split(/[\\/]/).includes('..'))
|
|
171
|
+
throw new Error("RUN_TEST path must stay within workingDirectory");
|
|
172
|
+
await safePath(spec, value, false);
|
|
173
|
+
};
|
|
174
|
+
if (parts[0] === "node" && parts[1] === "--test" && parts.length > 2) {
|
|
175
|
+
for (const value of parts.slice(2))
|
|
176
|
+
await checkPath(value);
|
|
177
|
+
return { file: "node", args: parts.slice(1) };
|
|
178
|
+
}
|
|
179
|
+
if (parts.length === 2 && parts[0] === "npm" && parts[1] === "test")
|
|
180
|
+
return { file: "npm", args: parts.slice(1) };
|
|
181
|
+
if (parts[0] === "pytest") {
|
|
182
|
+
for (const value of parts.slice(1))
|
|
183
|
+
await checkPath(value.split('::')[0]);
|
|
184
|
+
return { file: "pytest", args: parts.slice(1) };
|
|
185
|
+
}
|
|
186
|
+
if (parts[0] === "go" && parts[1] === "test" && parts.length > 2) {
|
|
187
|
+
for (const value of parts.slice(2)) {
|
|
188
|
+
if (!value.startsWith('./'))
|
|
189
|
+
throw new Error("RUN_TEST Go packages must be workspace relative");
|
|
190
|
+
await checkPath(value.endsWith('/...') ? value.slice(0, -4) || '.' : value);
|
|
191
|
+
}
|
|
192
|
+
return { file: "go", args: parts.slice(1) };
|
|
193
|
+
}
|
|
194
|
+
if (parts[0] === "cargo" && parts[1] === "test" && parts.slice(2).every(value => /^[A-Za-z0-9_]+$/.test(value)))
|
|
195
|
+
return { file: "cargo", args: parts.slice(1) };
|
|
196
|
+
throw new Error("RUN_TEST must use node --test, npm test, pytest, go test, or cargo test");
|
|
197
|
+
}
|
|
198
|
+
export async function executeActions(actions, spec, stillRunning, stillOwned = stillRunning) {
|
|
199
|
+
for (const action of actions) {
|
|
200
|
+
if (!action || !["READ_FILE", "WRITE_FILE", "PATCH_FILE", "RUN_TEST"].includes(action.type) || !action.targetPath)
|
|
201
|
+
throw new Error("Invalid action payload");
|
|
202
|
+
if (action.type === "WRITE_FILE" && typeof action.content !== "string")
|
|
203
|
+
throw new Error("WRITE_FILE content is required");
|
|
204
|
+
if (action.type === "PATCH_FILE" && typeof action.patch !== "string")
|
|
205
|
+
throw new Error("PATCH_FILE patch is required");
|
|
206
|
+
if (action.type === "RUN_TEST" && typeof action.command !== "string")
|
|
207
|
+
throw new Error("RUN_TEST command is required");
|
|
208
|
+
}
|
|
209
|
+
for (const action of actions) {
|
|
210
|
+
await safePath(spec, action.targetPath, action.type === "WRITE_FILE" || action.type === "PATCH_FILE");
|
|
211
|
+
if (action.type === "RUN_TEST") {
|
|
212
|
+
if (!spec.testCommand || action.command !== spec.testCommand)
|
|
213
|
+
throw new Error("RUN_TEST must exactly match spec.testCommand");
|
|
214
|
+
await commandArgs(action.command, spec);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const results = [];
|
|
218
|
+
for (const action of actions) {
|
|
219
|
+
if (!(await stillRunning()))
|
|
220
|
+
throw new Error("Pipeline stopped before action execution");
|
|
221
|
+
const write = action.type === "WRITE_FILE" || action.type === "PATCH_FILE";
|
|
222
|
+
const target = await safePath(spec, action.targetPath, write);
|
|
223
|
+
if (action.type === "READ_FILE") {
|
|
224
|
+
const content = await fs.readFile(target, "utf8");
|
|
225
|
+
results.push(`READ_FILE ${action.targetPath}: ${content.slice(0, MAX_OUTPUT)}`);
|
|
226
|
+
}
|
|
227
|
+
else if (action.type === "WRITE_FILE") {
|
|
228
|
+
if (typeof action.content !== "string" || action.content.length > MAX_FILE)
|
|
229
|
+
throw new Error("WRITE_FILE content is invalid or too large");
|
|
230
|
+
if (!(await stillRunning()))
|
|
231
|
+
throw new Error("Pipeline stopped before file mutation");
|
|
232
|
+
const flags = fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW;
|
|
233
|
+
let handle;
|
|
234
|
+
try {
|
|
235
|
+
handle = await fs.open(target, flags);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
if (error.code !== 'ENOENT')
|
|
239
|
+
throw error;
|
|
240
|
+
if (!(await stillRunning()))
|
|
241
|
+
throw new Error("Pipeline stopped before file mutation");
|
|
242
|
+
// Stage on the same filesystem, then publish a complete inode without
|
|
243
|
+
// replacing an existing pathname. Cancellation never unlinks target.
|
|
244
|
+
const stageDir = await fs.mkdtemp(resolve(dirname(target), WRITE_STAGE_PREFIX));
|
|
245
|
+
const staged = resolve(stageDir, WRITE_STAGE_FILE);
|
|
246
|
+
try {
|
|
247
|
+
const stagedHandle = await fs.open(staged, flags | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600);
|
|
248
|
+
try {
|
|
249
|
+
if (!(await stillRunning()))
|
|
250
|
+
throw new Error("Pipeline stopped before file mutation");
|
|
251
|
+
await stagedHandle.writeFile(action.content, "utf8");
|
|
252
|
+
if (!(await stillRunning()))
|
|
253
|
+
throw new Error("Pipeline stopped before file mutation");
|
|
254
|
+
await fs.link(staged, target);
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
await stagedHandle.close();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
await fs.unlink(staged).catch((cleanupError) => { if (cleanupError.code !== 'ENOENT')
|
|
262
|
+
throw cleanupError; });
|
|
263
|
+
await fs.rmdir(stageDir);
|
|
264
|
+
}
|
|
265
|
+
results.push(`WRITE_FILE ${action.targetPath}: ok`);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
if (!(await stillRunning()))
|
|
270
|
+
throw new Error("Pipeline stopped before file mutation");
|
|
271
|
+
await handle.truncate(0);
|
|
272
|
+
await handle.writeFile(action.content, "utf8");
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
await handle.close();
|
|
276
|
+
}
|
|
277
|
+
results.push(`WRITE_FILE ${action.targetPath}: ok`);
|
|
278
|
+
}
|
|
279
|
+
else if (action.type === "PATCH_FILE") {
|
|
280
|
+
const header = action.patch.match(/^diff --git a\/([^\n]+) b\/([^\n]+)\n(?:index [^\n]+\n)?--- a\/([^\n]+)\n\+\+\+ b\/([^\n]+)\n/);
|
|
281
|
+
if (!header || header[1] !== action.targetPath || header[2] !== action.targetPath || header[3] !== action.targetPath || header[4] !== action.targetPath || action.patch.includes("rename from") || action.patch.includes("new file mode") || action.patch.includes("old mode") || (action.patch.match(/^diff --git /gm) || []).length !== 1)
|
|
282
|
+
throw new Error("PATCH_FILE must be a single-file content patch");
|
|
283
|
+
const patchFile = resolve(spec.workingDirectory, `.prism-patch-${process.pid}-${Date.now()}.diff`);
|
|
284
|
+
if (!(await stillRunning()))
|
|
285
|
+
throw new Error("Pipeline stopped before patch mutation");
|
|
286
|
+
await fs.writeFile(patchFile, action.patch, { mode: 0o600 });
|
|
287
|
+
try {
|
|
288
|
+
await runGuardedCommand("git", ["apply", "--check", "--whitespace=nowarn", patchFile], spec.workingDirectory, 30_000, stillRunning, stillOwned);
|
|
289
|
+
if (!(await stillRunning()))
|
|
290
|
+
throw new Error("Pipeline stopped before patch mutation");
|
|
291
|
+
await runGuardedCommand("git", ["apply", "--whitespace=nowarn", patchFile], spec.workingDirectory, 30_000, stillRunning, stillOwned);
|
|
292
|
+
}
|
|
293
|
+
finally {
|
|
294
|
+
await fs.rm(patchFile, { force: true });
|
|
295
|
+
}
|
|
296
|
+
results.push(`PATCH_FILE ${action.targetPath}: ok`);
|
|
297
|
+
}
|
|
298
|
+
else if (action.type === "RUN_TEST") {
|
|
299
|
+
if (!spec.testCommand || action.command !== spec.testCommand)
|
|
300
|
+
throw new Error("RUN_TEST must exactly match spec.testCommand");
|
|
301
|
+
const parsed = await commandArgs(action.command, spec);
|
|
302
|
+
try {
|
|
303
|
+
const out = await runGuardedCommand(parsed.file, parsed.args, spec.workingDirectory, 120_000, stillRunning, stillOwned);
|
|
304
|
+
if (!(await stillRunning()))
|
|
305
|
+
throw new Error("Pipeline stopped during test execution");
|
|
306
|
+
results.push(`RUN_TEST ${action.command}: passed\n${String(out.stdout).slice(0, MAX_OUTPUT)}`);
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
throw new Error(`RUN_TEST failed: ${String(error?.stdout || error?.stderr || error).slice(0, MAX_OUTPUT)}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return results;
|
|
314
|
+
}
|
|
315
|
+
export async function readContextFiles(spec) {
|
|
316
|
+
if (!spec.contextFiles?.length)
|
|
317
|
+
return "";
|
|
318
|
+
if (spec.contextFiles.length > 20)
|
|
319
|
+
throw new Error("contextFiles exceeds limit");
|
|
320
|
+
const chunks = [];
|
|
321
|
+
for (const file of spec.contextFiles) {
|
|
322
|
+
const target = await safePath(spec, file, false);
|
|
323
|
+
const content = await fs.readFile(target, "utf8");
|
|
324
|
+
chunks.push(`=== ${file} ===\n${content.slice(0, 20_000)}`);
|
|
325
|
+
}
|
|
326
|
+
return chunks.join("\n");
|
|
327
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { getStorage, activeStorageBackend } from '../storage/index.js';
|
|
2
|
+
/** Resolve the instance before reading its capability: prewarm may still be pending. */
|
|
3
|
+
export async function factoryBackendReadiness() {
|
|
4
|
+
try {
|
|
5
|
+
const storage = await getStorage();
|
|
6
|
+
const paid = typeof storage.isFactoryReady === 'function';
|
|
7
|
+
return { storage_backend: paid ? 'synalux' : activeStorageBackend, factory_ready: paid ? storage.isFactoryReady() : true };
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return { storage_backend: 'unavailable', factory_ready: false };
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -2,6 +2,7 @@ import { getLLMProvider } from '../utils/llm/factory.js';
|
|
|
2
2
|
import { OpenAIAdapter } from '../utils/llm/adapters/openai.js';
|
|
3
3
|
import { SafetyController } from './safetyController.js';
|
|
4
4
|
import { debugLog } from '../utils/logger.js';
|
|
5
|
+
import { readContextFiles } from './actionExecutor.js';
|
|
5
6
|
/**
|
|
6
7
|
* JSON output schema instruction injected into EXECUTE step prompts.
|
|
7
8
|
* Forces the LLM to respond with machine-parseable structured output
|
|
@@ -13,23 +14,25 @@ You MUST respond with ONLY a valid JSON object matching this schema:
|
|
|
13
14
|
{
|
|
14
15
|
"actions": [
|
|
15
16
|
{
|
|
16
|
-
"type": "READ_FILE" | "WRITE_FILE" | "PATCH_FILE"
|
|
17
|
+
"type": "READ_FILE" | "WRITE_FILE" | "PATCH_FILE",
|
|
17
18
|
"targetPath": "<relative path within the workspace>",
|
|
18
19
|
"content": "<file content for WRITE_FILE>",
|
|
19
|
-
"patch": "<patch content for PATCH_FILE>"
|
|
20
|
-
"command": "<test command for RUN_TEST>"
|
|
20
|
+
"patch": "<patch content for PATCH_FILE>"
|
|
21
21
|
}
|
|
22
22
|
],
|
|
23
23
|
"notes": "<optional summary of what you did>"
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
RULES:
|
|
27
|
-
- type MUST be one of: READ_FILE, WRITE_FILE, PATCH_FILE
|
|
27
|
+
- type MUST be one of: READ_FILE, WRITE_FILE, PATCH_FILE
|
|
28
|
+
- Submit only file actions here. The host runs the verification command independently in VERIFY.
|
|
28
29
|
- targetPath MUST be a relative path within the workspace
|
|
29
30
|
- Do NOT include any text outside the JSON object
|
|
30
31
|
- Do NOT use markdown code fences
|
|
31
32
|
- If you cannot complete the task, return: {"actions": [], "notes": "reason"}
|
|
32
33
|
`.trim();
|
|
34
|
+
// Thinking models consume this budget before emitting the structured response.
|
|
35
|
+
const FACTORY_OUTPUT_TOKEN_BUDGET = 4096;
|
|
33
36
|
const PLAN_CONTRACT_SCHEMA = `
|
|
34
37
|
You MUST respond with ONLY a valid JSON object matching this schema:
|
|
35
38
|
{
|
|
@@ -80,6 +83,13 @@ export async function invokeClawAgent(spec, state, timeoutMs = 120000 // 2 min d
|
|
|
80
83
|
: getLLMProvider();
|
|
81
84
|
// Scope injection via SafetyController — single source of truth
|
|
82
85
|
const systemPrompt = SafetyController.generateBoundaryPrompt(spec, state);
|
|
86
|
+
let contextObservation = '';
|
|
87
|
+
try {
|
|
88
|
+
contextObservation = await readContextFiles(spec);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
return { success: false, resultText: `Context read failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
92
|
+
}
|
|
83
93
|
// Inject the appropriate JSON schema according to the step
|
|
84
94
|
let stepPrompt = `Based on the system instructions, execute the necessary task for the current step (${state.current_step}). Respond with your actions and observations.`;
|
|
85
95
|
let isJsonMode = false;
|
|
@@ -94,7 +104,7 @@ export async function invokeClawAgent(spec, state, timeoutMs = 120000 // 2 min d
|
|
|
94
104
|
isJsonMode = true;
|
|
95
105
|
}
|
|
96
106
|
else if (state.current_step === 'PLAN_CONTRACT') {
|
|
97
|
-
stepPrompt = `Based on the system instructions from the PLAN phase, formulate a strict, boolean-testable contract rubric.\n\n${PLAN_CONTRACT_SCHEMA}`;
|
|
107
|
+
stepPrompt = `Based on the system instructions from the PLAN phase, formulate a strict, boolean-testable contract rubric.\n\nActual PLAN output:\n${state.notes || '(no plan output)'}\n\n${PLAN_CONTRACT_SCHEMA}`;
|
|
98
108
|
isJsonMode = true;
|
|
99
109
|
}
|
|
100
110
|
else if (state.current_step === 'EVALUATE') {
|
|
@@ -109,11 +119,13 @@ ${state.contract_payload ? JSON.stringify(state.contract_payload.criteria, null,
|
|
|
109
119
|
${EVALUATE_SCHEMA}`;
|
|
110
120
|
isJsonMode = true;
|
|
111
121
|
}
|
|
122
|
+
if (contextObservation)
|
|
123
|
+
stepPrompt += `\n\n=== ACTUAL CONTEXT FILES ===\n${contextObservation}`;
|
|
112
124
|
debugLog(`[ClawInvocation] Launching agent on pipeline ${state.id} step=${state.current_step} iter=${state.iteration} with ${timeoutMs}ms limit.${isJsonMode ? ' (JSON mode)' : ''}`);
|
|
113
125
|
try {
|
|
114
126
|
// Timeout Promise to ensure the runner thread does not block indefinitely
|
|
115
127
|
const timeboundExecution = Promise.race([
|
|
116
|
-
llm.generateText(stepPrompt, systemPrompt),
|
|
128
|
+
llm.generateText(stepPrompt, systemPrompt, { json: isJsonMode, model: spec.modelOverride, maxOutputTokens: FACTORY_OUTPUT_TOKEN_BUDGET }),
|
|
117
129
|
new Promise((_, reject) => setTimeout(() => reject(new Error('LLM_EXECUTION_TIMEOUT')), timeoutMs))
|
|
118
130
|
]);
|
|
119
131
|
const result = await timeboundExecution;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getSettingSync } from "../storage/configStorage.js";
|
|
2
|
+
const SETTING_KEY = "PRISM_DARK_FACTORY_ENABLED";
|
|
3
|
+
const DEFAULT_ENABLED = true;
|
|
4
|
+
export const DARK_FACTORY_TOOL_NAMES = ["session_start_pipeline", "session_check_pipeline_status", "session_abort_pipeline"];
|
|
5
|
+
export const DARK_FACTORY_SETTINGS_UNAVAILABLE = "Factory startup settings unavailable. Retry when the server is reachable.";
|
|
6
|
+
let startupDecision;
|
|
7
|
+
export function resolveDarkFactoryEnabled(env, saved) {
|
|
8
|
+
if (env === "true" || env === "false")
|
|
9
|
+
return env === "true";
|
|
10
|
+
return saved === "true" || saved === "false" ? saved === "true" : DEFAULT_ENABLED;
|
|
11
|
+
}
|
|
12
|
+
/** Freeze one enablement decision for tool registration, dispatch, and runner startup. */
|
|
13
|
+
export function initializeDarkFactoryEnabled() {
|
|
14
|
+
const env = process.env.PRISM_DARK_FACTORY_ENABLED;
|
|
15
|
+
startupDecision = resolveDarkFactoryEnabled(env, getSettingSync(SETTING_KEY, String(DEFAULT_ENABLED)));
|
|
16
|
+
return startupDecision;
|
|
17
|
+
}
|
|
18
|
+
export function isDarkFactoryEnabled() {
|
|
19
|
+
return startupDecision ?? initializeDarkFactoryEnabled();
|
|
20
|
+
}
|
|
21
|
+
export function buildDarkFactoryEnablementState(saved, active, env) {
|
|
22
|
+
const envOverride = env === "true" || env === "false" ? env === "true" : null;
|
|
23
|
+
return { saved, active, envOverride, pendingRestart: envOverride === null && saved !== active };
|
|
24
|
+
}
|
|
25
|
+
export function darkFactoryEnablementState() {
|
|
26
|
+
const env = process.env.PRISM_DARK_FACTORY_ENABLED;
|
|
27
|
+
const saved = resolveDarkFactoryEnabled(undefined, getSettingSync(SETTING_KEY, String(DEFAULT_ENABLED)));
|
|
28
|
+
return buildDarkFactoryEnablementState(saved, isDarkFactoryEnabled(), env);
|
|
29
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { registerFactoryAuthority, isFactoryAuthorized, claimFactoryLease, DEFAULT_FACTORY_LEASE_MS, FACTORY_LEASE_RENEW_MS, } from "../storage/configStorage.js";
|
|
2
|
+
export { DEFAULT_FACTORY_LEASE_MS, FACTORY_LEASE_RENEW_MS };
|
|
3
|
+
export async function registerPipelineAuthority(pipeline) {
|
|
4
|
+
return registerFactoryAuthority(pipeline);
|
|
5
|
+
}
|
|
6
|
+
export async function isPipelineAuthorized(pipeline) {
|
|
7
|
+
return isFactoryAuthorized(pipeline);
|
|
8
|
+
}
|
|
9
|
+
export async function claimPipelineLease(pipeline) {
|
|
10
|
+
return claimFactoryLease(pipeline);
|
|
11
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Shared native-host instruction: routing is read-only; the host owns execution. */
|
|
2
|
+
export const FACTORY_HOST_POLICY_LINES = [
|
|
3
|
+
'## Prism Factory prompt routing',
|
|
4
|
+
'After the startup display, call session_route_prompt for a bounded implementation request on the',
|
|
5
|
+
'first task turn and when the task changes. Supply the verbatim prompt, current project, absolute',
|
|
6
|
+
'working_directory, concrete files, test_command and the source-only allowed_write_paths.',
|
|
7
|
+
'When prism_factory_route has target factory, automatically call recommended_tool with recommended_args',
|
|
8
|
+
'exactly once, report the pipeline ID, and monitor until a terminal status. Use that pipeline before',
|
|
9
|
+
'general local-worker delegation for the same request. Once a pipeline exists, monitor it instead of',
|
|
10
|
+
'starting a duplicate. Disabled Factory, host decisions and clinical/BCBA requests remain with the host.',
|
|
11
|
+
'The dashboard is optional. Do not change startup settings to force eligibility.',
|
|
12
|
+
];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
const MAX_FILES = 2;
|
|
3
|
+
const MAX_PROMPT_CHARS = 4096;
|
|
4
|
+
const IMPLEMENTATION_REQUEST = /^(?:(?:please|can you|could you|i want you to|i need you to)\s+)*(?:fix|implement|add|update|create|write|repair)\b/i;
|
|
5
|
+
const CLINICAL_REQUEST = /\b(?:bcba|bip|fba|aba|clinical|patient|client notes|behavio(?:u)?r (?:support|intervention|plan|assessment|analysis|reduction)|operational definition|fct|dra|dro|ncr|functional (?:analysis|assessment|communication training)|teaching procedure|reinforcement schedule|differential reinforcement|token economy|antecedent strateg(?:y|ies)|task analysis|preference assessment|sensory supports?|skill acquisition|prompting hierarchy|consequence (?:strateg(?:y|ies)|section)|crisis|restraint|seclusion|self[- ]injury|elopement|aggression)\b/i;
|
|
6
|
+
const RESERVED_REQUEST = /\b(?:deploy|publish|push|commit|production|credentials?|secrets?|payments?|billing|authentication|authorization|security|migration|delete|drop|truncate|architecture|redesign|entire codebase|entire repo)\b/i;
|
|
7
|
+
const CODE_FILES = /(?:^|[\s`"'(])([A-Za-z0-9_./-]+\.(?:cjs|mjs|js|ts|tsx|py|go|rs|swift|rb|java|kt|cs))\b/g;
|
|
8
|
+
const CODE_EXTENSION = /\.(?:cjs|mjs|js|ts|tsx|py|go|rs|swift|rb|java|kt|cs)$/;
|
|
9
|
+
const VERIFY_REQUEST = /\b(?:run|verify|test|check|validate)\b/i;
|
|
10
|
+
const NODE_TEST_COMMAND = /\b(node\s+--test\s+[A-Za-z0-9_./-]+\.(?:cjs|mjs|js|ts))\b/;
|
|
11
|
+
const MESSAGES = {
|
|
12
|
+
clinical: 'Clinical and non-coding BCBA requests stay with the clinical host/skill route; they do not start a coding pipeline.',
|
|
13
|
+
reserved: 'This request requires host judgment or external/destructive operations.',
|
|
14
|
+
ordinary: 'No bounded implementation request identified.',
|
|
15
|
+
files: 'Factory needs one or two concrete code files within the workspace.',
|
|
16
|
+
writes: 'Factory write paths must be a nonempty subset of the validated concrete code files.',
|
|
17
|
+
verify: 'Factory needs an explicit verification outcome.',
|
|
18
|
+
command: 'The host must supply the repository verification command before starting Factory.',
|
|
19
|
+
disabled: 'Factory is disabled for this MCP startup.',
|
|
20
|
+
context: 'The host must supply the project and absolute working_directory before starting Factory.',
|
|
21
|
+
paid: 'Paid Factory execution is unavailable until Portal pipeline and verification APIs and their schema are ready.',
|
|
22
|
+
candidate: 'Bounded implementation with concrete files and verification; the host should call the recommended pipeline tool automatically.',
|
|
23
|
+
};
|
|
24
|
+
/** Routing is read-only. Pipeline creation remains a separate, side-effecting MCP call. */
|
|
25
|
+
export function routeFactoryPrompt(prompt, context) {
|
|
26
|
+
const host = (reason, candidate = false) => ({ target: 'host', candidate, reason, recommended_tool: null });
|
|
27
|
+
if (CLINICAL_REQUEST.test(prompt))
|
|
28
|
+
return host(MESSAGES.clinical);
|
|
29
|
+
if (RESERVED_REQUEST.test(prompt))
|
|
30
|
+
return host(MESSAGES.reserved);
|
|
31
|
+
if (!IMPLEMENTATION_REQUEST.test(prompt.trim()) || prompt.length > MAX_PROMPT_CHARS)
|
|
32
|
+
return host(MESSAGES.ordinary);
|
|
33
|
+
const files = [...new Set(context.files_involved ?? [...prompt.matchAll(CODE_FILES)].map(match => match[1]))];
|
|
34
|
+
if (files.length === 0 || files.length > MAX_FILES || files.some(file => !CODE_EXTENSION.test(file) || path.isAbsolute(file) || file.split(/[\\/]/).includes('..')))
|
|
35
|
+
return host(MESSAGES.files);
|
|
36
|
+
const writePaths = [...new Set(context.allowed_write_paths ?? files)];
|
|
37
|
+
if (!writePaths.length || writePaths.some(file => path.isAbsolute(file) || file.split(/[\\/]/).includes('..') || !files.includes(file)))
|
|
38
|
+
return host(MESSAGES.writes);
|
|
39
|
+
if (!context.test_command && !VERIFY_REQUEST.test(prompt))
|
|
40
|
+
return host(MESSAGES.verify);
|
|
41
|
+
if (!context.enabled)
|
|
42
|
+
return host(MESSAGES.disabled, true);
|
|
43
|
+
if (context.factory_ready === false || context.storage_backend === 'unavailable' || (context.storage_backend === 'synalux' && context.factory_ready !== true))
|
|
44
|
+
return host(MESSAGES.paid, true);
|
|
45
|
+
if (!context.project?.trim() || !context.working_directory || !path.isAbsolute(context.working_directory))
|
|
46
|
+
return host(MESSAGES.context, true);
|
|
47
|
+
const testCommand = context.test_command || NODE_TEST_COMMAND.exec(prompt)?.[1];
|
|
48
|
+
if (!testCommand)
|
|
49
|
+
return host(MESSAGES.command, true);
|
|
50
|
+
const objective = `${prompt}\nVerification command: ${testCommand}`;
|
|
51
|
+
return {
|
|
52
|
+
target: 'factory', candidate: true, reason: MESSAGES.candidate,
|
|
53
|
+
recommended_tool: 'session_start_pipeline',
|
|
54
|
+
recommended_args: { project: context.project.trim(), objective, working_directory: context.working_directory, context_files: files, max_iterations: 3, test_command: testCommand, allowed_write_paths: writePaths },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function formatFactoryPromptRoute(route) {
|
|
58
|
+
return `<prism_factory_route>${JSON.stringify(route)}</prism_factory_route>`;
|
|
59
|
+
}
|