profclaw 2.3.0 → 2.4.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/CHANGELOG.md +15 -0
- package/README.md +5 -2
- package/config/pricing.yml +26 -2
- package/dist/agents/command-sandbox.d.ts +93 -0
- package/dist/agents/command-sandbox.js +235 -0
- package/dist/agents/executor-runner.d.ts +135 -0
- package/dist/agents/executor-runner.js +303 -0
- package/dist/agents/executor.d.ts +2 -0
- package/dist/agents/executor.js +35 -2
- package/dist/agents/output-truncator.d.ts +59 -0
- package/dist/agents/output-truncator.js +128 -0
- package/dist/agents/receipt/receipt-cli.d.ts +43 -0
- package/dist/agents/receipt/receipt-cli.js +82 -0
- package/dist/agents/receipt/receipt-types.d.ts +136 -0
- package/dist/agents/receipt/receipt-types.js +19 -0
- package/dist/agents/receipt/receipt-verify.d.ts +19 -0
- package/dist/agents/receipt/receipt-verify.js +176 -0
- package/dist/agents/receipt/receipt-view.d.ts +29 -0
- package/dist/agents/receipt/receipt-view.js +227 -0
- package/dist/agents/receipt/receipt-writer.d.ts +47 -0
- package/dist/agents/receipt/receipt-writer.js +186 -0
- package/dist/agents/result-store.d.ts +6 -0
- package/dist/agents/result-store.js +43 -2
- package/dist/agents/session-diff.d.ts +6 -0
- package/dist/agents/session-diff.js +10 -19
- package/dist/agents/tool-selector.d.ts +123 -0
- package/dist/agents/tool-selector.js +321 -0
- package/dist/agents/verified-run-git.d.ts +49 -0
- package/dist/agents/verified-run-git.js +82 -0
- package/dist/agents/verified-run.d.ts +149 -0
- package/dist/agents/verified-run.js +402 -0
- package/dist/agents/verifier.d.ts +64 -0
- package/dist/agents/verifier.js +186 -0
- package/dist/agents/worktree-tools.d.ts +47 -0
- package/dist/agents/worktree-tools.js +246 -0
- package/dist/chat/agentic-executor.js +14 -6
- package/dist/chat/execution/sandbox.d.ts +8 -0
- package/dist/chat/execution/sandbox.js +25 -1
- package/dist/chat/execution/tools/tts-speak.d.ts +2 -2
- package/dist/chat/meta-tool-handlers.d.ts +18 -0
- package/dist/chat/meta-tool-handlers.js +51 -0
- package/dist/chat/system-prompts.js +6 -1
- package/dist/chat/tool-handler.d.ts +2 -0
- package/dist/chat/tool-handler.js +47 -3
- package/dist/cli/commands/receipt.d.ts +7 -0
- package/dist/cli/commands/receipt.js +90 -0
- package/dist/cli/commands/run.d.ts +28 -0
- package/dist/cli/commands/run.js +204 -0
- package/dist/cli/index.js +4 -0
- package/dist/costs/token-tracker.d.ts +10 -1
- package/dist/costs/token-tracker.js +37 -5
- package/dist/providers/ai-sdk.js +33 -37
- package/dist/providers/core/models.js +59 -17
- package/dist/providers/core/types.d.ts +4 -0
- package/dist/providers/prompt-cache.d.ts +68 -0
- package/dist/providers/prompt-cache.js +165 -0
- package/dist/providers/smart-router.js +11 -3
- package/dist/routes/chat.js +22 -5
- package/dist/server.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meta-tool handlers: load_tools and fetch_result.
|
|
3
|
+
*
|
|
4
|
+
* Kept separate from tool-handler.ts so the handler stays small. These are pure
|
|
5
|
+
* functions over injected dependencies, which also makes them easy to test.
|
|
6
|
+
*/
|
|
7
|
+
import { readStoredRange, getTruncationConfig } from '../agents/output-truncator.js';
|
|
8
|
+
import { TOOL_GROUPS, describeTool, parseFetchResultArgs, parseLoadToolsArgs, toolsInGroup, } from '../agents/tool-selector.js';
|
|
9
|
+
export async function runLoadTools(args, deps) {
|
|
10
|
+
const parsed = parseLoadToolsArgs(args);
|
|
11
|
+
if (!parsed) {
|
|
12
|
+
return { success: false, error: 'load_tools expects { groups: string[] }' };
|
|
13
|
+
}
|
|
14
|
+
const loaded = deps.session.loadGroups(parsed.groups);
|
|
15
|
+
const unknown = parsed.groups.filter((g) => !loaded.includes(g.trim().toLowerCase()));
|
|
16
|
+
const tools = loaded.flatMap((group) => toolsInGroup(deps.allTools, group).map(describeTool));
|
|
17
|
+
const response = {
|
|
18
|
+
success: loaded.length > 0,
|
|
19
|
+
loaded,
|
|
20
|
+
tools,
|
|
21
|
+
note: 'These tools are callable from the next turn. Use invoke to call one now.',
|
|
22
|
+
};
|
|
23
|
+
if (unknown.length > 0) {
|
|
24
|
+
response.unknownGroups = unknown;
|
|
25
|
+
response.validGroups = TOOL_GROUPS.map((g) => g.name);
|
|
26
|
+
}
|
|
27
|
+
if (parsed.invoke) {
|
|
28
|
+
const target = parsed.invoke.tool;
|
|
29
|
+
const inLoaded = tools.some((t) => t.name === target);
|
|
30
|
+
if (!inLoaded) {
|
|
31
|
+
response.invokeError = `Tool "${target}" is not in the loaded groups`;
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
response.invokeResult = await deps.invoke(target, parsed.invoke.args ?? {});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return response;
|
|
38
|
+
}
|
|
39
|
+
export async function runFetchResult(args, store) {
|
|
40
|
+
const parsed = parseFetchResultArgs(args);
|
|
41
|
+
if (!parsed) {
|
|
42
|
+
return { success: false, error: 'fetch_result expects { result_id, offset?, length? }' };
|
|
43
|
+
}
|
|
44
|
+
const config = getTruncationConfig();
|
|
45
|
+
const range = await readStoredRange(store, parsed.result_id, parsed.offset ?? 0, parsed.length ?? config.fetchMaxChars, config);
|
|
46
|
+
if (!range) {
|
|
47
|
+
return { success: false, error: `No stored result with id "${parsed.result_id}"` };
|
|
48
|
+
}
|
|
49
|
+
return range;
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=meta-tool-handlers.js.map
|
|
@@ -350,7 +350,12 @@ ${context.user.role ? `- Role: ${context.user.role}` : ''}`);
|
|
|
350
350
|
// Only include top 15 most relevant skills, not all 55
|
|
351
351
|
const prioritySkills = entries
|
|
352
352
|
.filter((e) => e.enabled !== false)
|
|
353
|
-
.sort((a, b) =>
|
|
353
|
+
.sort((a, b) => {
|
|
354
|
+
const pa = a.metadata?.priority ?? 0;
|
|
355
|
+
const pb = b.metadata?.priority ?? 0;
|
|
356
|
+
// Name tie-break keeps the cached prompt prefix byte-stable
|
|
357
|
+
return pb - pa || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
358
|
+
})
|
|
354
359
|
.slice(0, 15);
|
|
355
360
|
const skillLines = prioritySkills
|
|
356
361
|
.map((e) => `- ${e.name}: ${e.description || 'No description'}`)
|
|
@@ -25,6 +25,8 @@ export interface ChatToolHandler {
|
|
|
25
25
|
}>;
|
|
26
26
|
/** Handle approval decision */
|
|
27
27
|
handleApproval(approvalId: string, decision: 'allow-once' | 'allow-always' | 'deny'): Promise<ToolExecutionResult | null>;
|
|
28
|
+
/** Delete spilled tool-result temp files. Call when the request/run ends. */
|
|
29
|
+
dispose(): Promise<void>;
|
|
28
30
|
}
|
|
29
31
|
export declare function createChatToolHandler(options: ChatToolHandlerOptions): Promise<ChatToolHandler>;
|
|
30
32
|
/**
|
|
@@ -9,6 +9,10 @@ import { randomUUID } from 'node:crypto';
|
|
|
9
9
|
import { getToolExecutor, getToolRegistry, initializeToolExecution, } from './execution/index.js';
|
|
10
10
|
import { logger } from '../utils/logger.js';
|
|
11
11
|
import { SAFE_BROWSER_TOOL_NAMES } from '../browser/index.js';
|
|
12
|
+
import { ResultStore } from '../agents/result-store.js';
|
|
13
|
+
import { truncateAndStore, isTruncationEnabled } from '../agents/output-truncator.js';
|
|
14
|
+
import { LOAD_TOOLS_NAME, FETCH_RESULT_NAME, getToolSelectorSession, } from '../agents/tool-selector.js';
|
|
15
|
+
import { runLoadTools, runFetchResult } from './meta-tool-handlers.js';
|
|
12
16
|
// In-Memory Session Manager for Chat
|
|
13
17
|
class ChatSessionManager {
|
|
14
18
|
sessions = new Map();
|
|
@@ -78,6 +82,23 @@ export async function createChatToolHandler(options) {
|
|
|
78
82
|
const registry = getToolRegistry();
|
|
79
83
|
const executor = getToolExecutor();
|
|
80
84
|
const sessionManager = new ChatSessionManager();
|
|
85
|
+
const resultStore = new ResultStore(options.conversationId.replace(/[^a-zA-Z0-9_-]/g, '_'));
|
|
86
|
+
/** Cap large results before they reach the model; the full result stays in the store. */
|
|
87
|
+
const capForModel = async (toolName, toolCallId, data) => {
|
|
88
|
+
if (!isTruncationEnabled() || toolName === FETCH_RESULT_NAME || toolName === LOAD_TOOLS_NAME) {
|
|
89
|
+
return data;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
return (await truncateAndStore(resultStore, toolCallId, data)).value;
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
logger.warn(`[ChatToolHandler] Output truncation failed for ${toolName}`, {
|
|
96
|
+
component: 'ChatToolHandler',
|
|
97
|
+
error: error instanceof Error ? error.message : String(error),
|
|
98
|
+
});
|
|
99
|
+
return data;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
81
102
|
// Build security policy
|
|
82
103
|
const securityPolicy = {
|
|
83
104
|
mode: options.securityMode || 'ask',
|
|
@@ -93,7 +114,7 @@ export async function createChatToolHandler(options) {
|
|
|
93
114
|
securityPolicy,
|
|
94
115
|
sessionManager,
|
|
95
116
|
});
|
|
96
|
-
|
|
117
|
+
const handler = {
|
|
97
118
|
getTools() {
|
|
98
119
|
const tools = registry.list();
|
|
99
120
|
return tools.map((tool) => ({
|
|
@@ -107,7 +128,26 @@ export async function createChatToolHandler(options) {
|
|
|
107
128
|
component: 'ChatToolHandler',
|
|
108
129
|
toolCallId,
|
|
109
130
|
});
|
|
131
|
+
// Meta-tools are served locally, not through the registry
|
|
132
|
+
if (toolName === LOAD_TOOLS_NAME) {
|
|
133
|
+
const allTools = registry.list().map((t) => ({
|
|
134
|
+
name: t.name,
|
|
135
|
+
description: t.description,
|
|
136
|
+
parameters: t.parameters,
|
|
137
|
+
}));
|
|
138
|
+
return {
|
|
139
|
+
result: await runLoadTools(args, {
|
|
140
|
+
session: getToolSelectorSession(options.conversationId),
|
|
141
|
+
allTools,
|
|
142
|
+
invoke: async (tool, toolArgs) => (await handler.executeTool(tool, toolArgs, randomUUID())).result,
|
|
143
|
+
}),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (toolName === FETCH_RESULT_NAME) {
|
|
147
|
+
return { result: await runFetchResult(args, resultStore) };
|
|
148
|
+
}
|
|
110
149
|
try {
|
|
150
|
+
getToolSelectorSession(options.conversationId).noteToolUse(toolName);
|
|
111
151
|
const context = createContext(toolCallId);
|
|
112
152
|
const result = await executor.execute({
|
|
113
153
|
id: toolCallId,
|
|
@@ -134,7 +174,7 @@ export async function createChatToolHandler(options) {
|
|
|
134
174
|
// The AI only sees `result` — `output` is preserved for UI rendering
|
|
135
175
|
if (result.result.success) {
|
|
136
176
|
// Extract clean data for the AI model
|
|
137
|
-
const aiResult = result.result.data ?? { success: true };
|
|
177
|
+
const aiResult = await capForModel(toolName, toolCallId, result.result.data ?? { success: true });
|
|
138
178
|
return {
|
|
139
179
|
result: aiResult,
|
|
140
180
|
output: result.result.output, // Sidecar for UI display
|
|
@@ -166,6 +206,9 @@ export async function createChatToolHandler(options) {
|
|
|
166
206
|
params: a.params,
|
|
167
207
|
}));
|
|
168
208
|
},
|
|
209
|
+
async dispose() {
|
|
210
|
+
await resultStore.cleanup();
|
|
211
|
+
},
|
|
169
212
|
async handleApproval(approvalId, decision) {
|
|
170
213
|
logger.info(`[ChatToolHandler] Handling approval: ${approvalId} -> ${decision}`, {
|
|
171
214
|
component: 'ChatToolHandler',
|
|
@@ -176,7 +219,7 @@ export async function createChatToolHandler(options) {
|
|
|
176
219
|
return null;
|
|
177
220
|
}
|
|
178
221
|
if (result.result.success) {
|
|
179
|
-
const aiResult = result.result.data ?? { success: true };
|
|
222
|
+
const aiResult = await capForModel(approvalId, randomUUID(), result.result.data ?? { success: true });
|
|
180
223
|
return {
|
|
181
224
|
result: aiResult,
|
|
182
225
|
output: result.result.output,
|
|
@@ -200,6 +243,7 @@ export async function createChatToolHandler(options) {
|
|
|
200
243
|
}
|
|
201
244
|
},
|
|
202
245
|
};
|
|
246
|
+
return handler;
|
|
203
247
|
}
|
|
204
248
|
// Tool Categories for Chat
|
|
205
249
|
/**
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* profclaw receipt: verify, view and key generation for run receipts.
|
|
3
|
+
* Spec: docs/specs/run-receipt-v0.md
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { Command } from 'commander';
|
|
7
|
+
import { verifyReceiptText, parseReceiptText } from '../../agents/receipt/receipt-verify.js';
|
|
8
|
+
import { renderHtml, renderTerminalSummary } from '../../agents/receipt/receipt-view.js';
|
|
9
|
+
import { generateKeyPairFiles } from '../../agents/receipt/receipt-cli.js';
|
|
10
|
+
import { error, info, success } from '../utils/output.js';
|
|
11
|
+
function readOrExit(file) {
|
|
12
|
+
try {
|
|
13
|
+
return readFileSync(file, 'utf-8');
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
error(`Cannot read ${file}: ${err instanceof Error ? err.message : 'unknown error'}`);
|
|
17
|
+
process.exitCode = 1;
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function receiptCommand() {
|
|
22
|
+
const cmd = new Command('receipt').description('Verify, view and sign tamper-evident run receipts');
|
|
23
|
+
cmd
|
|
24
|
+
.command('verify')
|
|
25
|
+
.description('Check a receipt: exit 0 if intact, 1 if tampered, truncated or invalid')
|
|
26
|
+
.argument('<file>', 'Path to receipt.jsonl')
|
|
27
|
+
.option('--pubkey <file>', 'Trusted Ed25519 public key (PEM); the receipt must be signed by it')
|
|
28
|
+
.option('--require-signature', 'Fail if the receipt is not signed')
|
|
29
|
+
.action((file, opts) => {
|
|
30
|
+
const text = readOrExit(file);
|
|
31
|
+
if (text === null)
|
|
32
|
+
return;
|
|
33
|
+
let expectedPublicKey;
|
|
34
|
+
if (opts.pubkey) {
|
|
35
|
+
const key = readOrExit(opts.pubkey);
|
|
36
|
+
if (key === null)
|
|
37
|
+
return;
|
|
38
|
+
expectedPublicKey = key;
|
|
39
|
+
}
|
|
40
|
+
const result = verifyReceiptText(text, { expectedPublicKey, requireSignature: opts.requireSignature });
|
|
41
|
+
if (result.ok) {
|
|
42
|
+
success(result.message);
|
|
43
|
+
info('This shows the receipt was not altered after it was written. It does not prove the run happened.');
|
|
44
|
+
process.exitCode = 0;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
error(`TAMPERED or invalid at seq ${result.firstBadSeq ?? 0} (${result.reason ?? 'unknown'}): ${result.message}`);
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
cmd
|
|
52
|
+
.command('view')
|
|
53
|
+
.description('Print a summary, or write a self-contained HTML viewer with --html')
|
|
54
|
+
.argument('<file>', 'Path to receipt.jsonl')
|
|
55
|
+
.option('--html <out>', 'Write a single-file HTML viewer to this path')
|
|
56
|
+
.action((file, opts) => {
|
|
57
|
+
const text = readOrExit(file);
|
|
58
|
+
if (text === null)
|
|
59
|
+
return;
|
|
60
|
+
const verify = verifyReceiptText(text);
|
|
61
|
+
const events = parseReceiptText(text);
|
|
62
|
+
if (opts.html) {
|
|
63
|
+
writeFileSync(opts.html, renderHtml(events, verify), 'utf-8');
|
|
64
|
+
info(`Wrote ${opts.html}`);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
process.stdout.write(renderTerminalSummary(events, verify));
|
|
68
|
+
}
|
|
69
|
+
if (!verify.ok)
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
});
|
|
72
|
+
cmd
|
|
73
|
+
.command('keygen')
|
|
74
|
+
.description('Write an Ed25519 keypair for signing receipts (private key mode 0600)')
|
|
75
|
+
.argument('<dir>', 'Directory to write receipt-key.pem and receipt-key.pub.pem into')
|
|
76
|
+
.action((dir) => {
|
|
77
|
+
try {
|
|
78
|
+
const files = generateKeyPairFiles(dir);
|
|
79
|
+
success(`Private key: ${files.privateKeyPath} (keep it off the machine that runs agents if you can)`);
|
|
80
|
+
info(`Public key: ${files.publicKeyPath}`);
|
|
81
|
+
info('Sign with --sign-key <private key> or env PROFCLAW_RECEIPT_KEY; check with: profclaw receipt verify <file> --pubkey <public key>');
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
error(err instanceof Error ? err.message : 'Key generation failed');
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
return cmd;
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=receipt.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verified Run CLI Command
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* profclaw run "<goal>" --verify "pnpm test" (built-in agent)
|
|
6
|
+
* profclaw run "<goal>" --verify "pnpm test" --agent-cmd "my-agent-cli"
|
|
7
|
+
*
|
|
8
|
+
* Done means verified: the run only succeeds when the verify command exits 0.
|
|
9
|
+
* Work happens in an isolated git worktree on a local branch. Nothing is
|
|
10
|
+
* pushed and no PR is opened.
|
|
11
|
+
*
|
|
12
|
+
* By default the built-in model-backed agent runs inside the worktree, starting
|
|
13
|
+
* on the smart router's cheap model and escalating a tier after repeated
|
|
14
|
+
* verifier failures. --agent-cmd overrides it with an external agent; the goal
|
|
15
|
+
* and feedback are written to that agent's stdin, never interpolated into a
|
|
16
|
+
* shell string.
|
|
17
|
+
*/
|
|
18
|
+
import { Command } from 'commander';
|
|
19
|
+
import type { AgentAttemptInput, AgentAttemptResult, AgentRunner } from '../../agents/verified-run.js';
|
|
20
|
+
/** Runs an external agent command, feeding the prompt on stdin. */
|
|
21
|
+
export declare class CommandAgent implements AgentRunner {
|
|
22
|
+
private readonly command;
|
|
23
|
+
constructor(command: string);
|
|
24
|
+
runAttempt(input: AgentAttemptInput): Promise<AgentAttemptResult>;
|
|
25
|
+
}
|
|
26
|
+
export declare function buildPrompt(input: AgentAttemptInput): string;
|
|
27
|
+
export declare function runCommand(): Command;
|
|
28
|
+
//# sourceMappingURL=run.d.ts.map
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verified Run CLI Command
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* profclaw run "<goal>" --verify "pnpm test" (built-in agent)
|
|
6
|
+
* profclaw run "<goal>" --verify "pnpm test" --agent-cmd "my-agent-cli"
|
|
7
|
+
*
|
|
8
|
+
* Done means verified: the run only succeeds when the verify command exits 0.
|
|
9
|
+
* Work happens in an isolated git worktree on a local branch. Nothing is
|
|
10
|
+
* pushed and no PR is opened.
|
|
11
|
+
*
|
|
12
|
+
* By default the built-in model-backed agent runs inside the worktree, starting
|
|
13
|
+
* on the smart router's cheap model and escalating a tier after repeated
|
|
14
|
+
* verifier failures. --agent-cmd overrides it with an external agent; the goal
|
|
15
|
+
* and feedback are written to that agent's stdin, never interpolated into a
|
|
16
|
+
* shell string.
|
|
17
|
+
*/
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { Command } from 'commander';
|
|
20
|
+
import { describeSandbox, resolveSandboxMode } from '../../agents/command-sandbox.js';
|
|
21
|
+
import { CommandVerifier } from '../../agents/verifier.js';
|
|
22
|
+
import { runVerifiedGoal, resolveLimits } from '../../agents/verified-run.js';
|
|
23
|
+
import { createDefaultExecutorRunner } from '../../agents/executor-runner.js';
|
|
24
|
+
import { setupRunReceipt, toolVersion } from '../../agents/receipt/receipt-cli.js';
|
|
25
|
+
import { error, info, success, warn, formatCost, formatTokens } from '../utils/output.js';
|
|
26
|
+
/** Runs an external agent command, feeding the prompt on stdin. */
|
|
27
|
+
export class CommandAgent {
|
|
28
|
+
command;
|
|
29
|
+
constructor(command) {
|
|
30
|
+
this.command = command;
|
|
31
|
+
}
|
|
32
|
+
runAttempt(input) {
|
|
33
|
+
const prompt = buildPrompt(input);
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const child = spawn('/bin/sh', ['-c', this.command], {
|
|
36
|
+
cwd: input.workdir,
|
|
37
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
38
|
+
});
|
|
39
|
+
let out = '';
|
|
40
|
+
child.stdout.on('data', (d) => {
|
|
41
|
+
out = (out + d.toString('utf-8')).slice(-4000);
|
|
42
|
+
});
|
|
43
|
+
child.stderr.on('data', (d) => {
|
|
44
|
+
out = (out + d.toString('utf-8')).slice(-4000);
|
|
45
|
+
});
|
|
46
|
+
input.signal?.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
|
|
47
|
+
child.on('error', reject);
|
|
48
|
+
child.on('close', (code) => {
|
|
49
|
+
if (code === 0)
|
|
50
|
+
resolve({ summary: out.trim().slice(-500), tokensUsed: 0, costUsd: 0 });
|
|
51
|
+
else
|
|
52
|
+
reject(new Error(`agent command exited with code ${code ?? 'null'}: ${out.trim().slice(-500)}`));
|
|
53
|
+
});
|
|
54
|
+
child.stdin.on('error', () => undefined);
|
|
55
|
+
child.stdin.end(prompt);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function buildPrompt(input) {
|
|
60
|
+
const parts = [
|
|
61
|
+
`Goal: ${input.goal}`,
|
|
62
|
+
`Work only inside: ${input.workdir}`,
|
|
63
|
+
`Your work is judged by running: ${input.verifyCommand}`,
|
|
64
|
+
'Do not push to any remote or open pull requests.',
|
|
65
|
+
];
|
|
66
|
+
if (input.feedback)
|
|
67
|
+
parts.push('', `Attempt ${input.attempt}. Previous attempt feedback:`, input.feedback);
|
|
68
|
+
return parts.join('\n') + '\n';
|
|
69
|
+
}
|
|
70
|
+
function parseNumber(raw, name) {
|
|
71
|
+
if (raw === undefined)
|
|
72
|
+
return undefined;
|
|
73
|
+
const n = Number(raw);
|
|
74
|
+
if (!Number.isFinite(n) || n < 0)
|
|
75
|
+
throw new Error(`${name} must be a non-negative number`);
|
|
76
|
+
return n;
|
|
77
|
+
}
|
|
78
|
+
function formatAttemptUsage(record) {
|
|
79
|
+
const parts = [`${formatTokens(record.tokensUsed)} tokens`];
|
|
80
|
+
if (record.cacheHitRate !== undefined)
|
|
81
|
+
parts.push(`cache hit ${(record.cacheHitRate * 100).toFixed(0)}%`);
|
|
82
|
+
parts.push(formatCost(record.costUsd));
|
|
83
|
+
if (record.model)
|
|
84
|
+
parts.push(record.model);
|
|
85
|
+
return parts.join(', ');
|
|
86
|
+
}
|
|
87
|
+
function printRunnerEvent(event) {
|
|
88
|
+
const { attempt, choice, escalated } = event;
|
|
89
|
+
const prefix = escalated ? 'Escalating to' : 'Using';
|
|
90
|
+
info(`Attempt ${attempt}: ${prefix} ${choice.provider}/${choice.model} (${choice.tier})`);
|
|
91
|
+
}
|
|
92
|
+
function printEvent(event) {
|
|
93
|
+
if (event.type === 'start')
|
|
94
|
+
info(`Run ${event.runId} on branch ${event.branch}`);
|
|
95
|
+
else if (event.type === 'attempt_start')
|
|
96
|
+
info(`Attempt ${event.attempt}...`);
|
|
97
|
+
else if (event.type === 'attempt_end') {
|
|
98
|
+
const { record } = event;
|
|
99
|
+
const line = `Attempt ${record.attempt}: ${record.outcome}${record.rolledBack ? ' (rolled back)' : ''} [${formatAttemptUsage(record)}]`;
|
|
100
|
+
if (record.outcome === 'verified')
|
|
101
|
+
success(line);
|
|
102
|
+
else
|
|
103
|
+
warn(line);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export function runCommand() {
|
|
107
|
+
return new Command('run')
|
|
108
|
+
.description('Run an agent toward a goal and only finish when a verify command passes')
|
|
109
|
+
.argument('<goal>', 'What the agent should accomplish')
|
|
110
|
+
.requiredOption('--verify <command>', 'Command that must exit 0 (tests, typecheck, lint, build)')
|
|
111
|
+
.option('--agent-cmd <command>', 'External agent command (overrides the built-in agent); receives the prompt on stdin')
|
|
112
|
+
.option('--model <ids>', 'Model, or comma separated ladder cheapest first (env PROFCLAW_RUN_MODEL); default: smart router')
|
|
113
|
+
.option('--escalate-after <n>', 'Verifier failures in a row before moving up a model tier, 0 disables (env PROFCLAW_RUN_ESCALATE_AFTER)')
|
|
114
|
+
.option('--max-attempts <n>', 'Maximum attempts (env PROFCLAW_RUN_MAX_ATTEMPTS)')
|
|
115
|
+
.option('--max-tokens <n>', 'Token budget, 0 disables (env PROFCLAW_RUN_MAX_TOKENS)')
|
|
116
|
+
.option('--max-cost <usd>', 'Dollar budget, 0 disables (env PROFCLAW_RUN_MAX_COST_USD)')
|
|
117
|
+
.option('--breaker <n>', 'Non-improving attempts before the circuit breaker trips (env PROFCLAW_RUN_BREAKER_THRESHOLD)')
|
|
118
|
+
.option('--verify-timeout <ms>', 'Verifier timeout (env PROFCLAW_RUN_VERIFY_TIMEOUT_MS)')
|
|
119
|
+
.option('--branch <name>', 'Branch name for the run (default profclaw/run-<id>)')
|
|
120
|
+
.option('--sandbox-verify', 'Also run the verify command under the OS sandbox (env PROFCLAW_RUN_SANDBOX_VERIFY=1); default off')
|
|
121
|
+
.option('--cleanup', 'Remove the worktree afterwards (the branch is kept)')
|
|
122
|
+
.option('--no-receipt', 'Do not write a tamper-evident receipt.jsonl for the run')
|
|
123
|
+
.option('--sign-key <path>', 'Ed25519 private key (PEM) to sign the receipt (env PROFCLAW_RECEIPT_KEY)')
|
|
124
|
+
.option('--json', 'Output the result as JSON')
|
|
125
|
+
.action(async (goal, options) => {
|
|
126
|
+
try {
|
|
127
|
+
const flags = {
|
|
128
|
+
maxAttempts: parseNumber(options.maxAttempts, '--max-attempts'),
|
|
129
|
+
maxTokens: parseNumber(options.maxTokens, '--max-tokens'),
|
|
130
|
+
maxCostUsd: parseNumber(options.maxCost, '--max-cost'),
|
|
131
|
+
breakerThreshold: parseNumber(options.breaker, '--breaker'),
|
|
132
|
+
verifyTimeoutMs: parseNumber(options.verifyTimeout, '--verify-timeout'),
|
|
133
|
+
};
|
|
134
|
+
for (const key of Object.keys(flags)) {
|
|
135
|
+
if (flags[key] === undefined)
|
|
136
|
+
delete flags[key];
|
|
137
|
+
}
|
|
138
|
+
const verifyCommand = options.verify;
|
|
139
|
+
const sandboxMode = resolveSandboxMode();
|
|
140
|
+
const sandboxVerify = options.sandboxVerify === true || process.env.PROFCLAW_RUN_SANDBOX_VERIFY === '1';
|
|
141
|
+
if (!options.json)
|
|
142
|
+
info(`${describeSandbox(sandboxMode)}${sandboxVerify ? '; verify command sandboxed' : ''}`);
|
|
143
|
+
const limits = resolveLimits(flags);
|
|
144
|
+
const rc = setupRunReceipt({ enabled: options.receipt !== false, projectRoot: process.cwd(), signKeyPath: options.signKey });
|
|
145
|
+
const agent = options.agentCmd
|
|
146
|
+
? new CommandAgent(options.agentCmd)
|
|
147
|
+
: await createDefaultExecutorRunner({
|
|
148
|
+
goal,
|
|
149
|
+
model: options.model,
|
|
150
|
+
config: { escalateAfter: parseNumber(options.escalateAfter, '--escalate-after') },
|
|
151
|
+
maxTokens: limits.maxTokens,
|
|
152
|
+
maxCostUsd: limits.maxCostUsd,
|
|
153
|
+
onEvent: options.json ? undefined : printRunnerEvent,
|
|
154
|
+
onToolCall: rc.onToolCall,
|
|
155
|
+
});
|
|
156
|
+
const result = await runVerifiedGoal({
|
|
157
|
+
goal,
|
|
158
|
+
verifyCommand,
|
|
159
|
+
agent,
|
|
160
|
+
projectRoot: process.cwd(),
|
|
161
|
+
runId: rc.runId,
|
|
162
|
+
receipt: rc.receipt,
|
|
163
|
+
toolInfo: { name: 'profclaw', version: toolVersion() },
|
|
164
|
+
limits: flags,
|
|
165
|
+
branchName: options.branch,
|
|
166
|
+
removeWorktree: options.cleanup,
|
|
167
|
+
onEvent: options.json ? undefined : printEvent,
|
|
168
|
+
createVerifier: (cwd) => new CommandVerifier(verifyCommand, {
|
|
169
|
+
cwd,
|
|
170
|
+
timeoutMs: flags.verifyTimeoutMs ?? Number(process.env.PROFCLAW_RUN_VERIFY_TIMEOUT_MS ?? 600_000),
|
|
171
|
+
maxOutputChars: Number(process.env.PROFCLAW_RUN_MAX_OUTPUT_CHARS ?? 4_000),
|
|
172
|
+
sandbox: sandboxVerify ? { mode: sandboxMode } : undefined,
|
|
173
|
+
}),
|
|
174
|
+
});
|
|
175
|
+
if (options.json) {
|
|
176
|
+
console.log(JSON.stringify({ ...result, diff: undefined }, null, 2));
|
|
177
|
+
}
|
|
178
|
+
else if (result.verified) {
|
|
179
|
+
success(`Verified after ${result.attempts.length} attempt(s), cost ${formatCost(result.totalCostUsd)}`);
|
|
180
|
+
info(`Branch ${result.branch} is ready for review (not pushed).`);
|
|
181
|
+
if (result.reportPath)
|
|
182
|
+
info(`Evidence: ${result.reportPath}`);
|
|
183
|
+
if (rc.receipt)
|
|
184
|
+
info(`Receipt: ${rc.receipt.path}`);
|
|
185
|
+
}
|
|
186
|
+
else if (result.stopReason === 'already_passing') {
|
|
187
|
+
warn('The verify command already passes before any change, so no attempt was made.');
|
|
188
|
+
if (result.reportPath)
|
|
189
|
+
info(`Report: ${result.reportPath}`);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
error(`Not verified (${result.stopReason}) after ${result.attempts.length} attempt(s)`);
|
|
193
|
+
if (result.reportPath)
|
|
194
|
+
info(`Report: ${result.reportPath}`);
|
|
195
|
+
}
|
|
196
|
+
process.exitCode = result.verified || result.stopReason === 'already_passing' ? 0 : 1;
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
error(err instanceof Error ? err.message : 'Verified run failed');
|
|
200
|
+
process.exitCode = 1;
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
//# sourceMappingURL=run.js.map
|
package/dist/cli/index.js
CHANGED
|
@@ -37,6 +37,8 @@ import { importCommands } from './commands/import.js';
|
|
|
37
37
|
import { historyCommands } from './commands/history.js';
|
|
38
38
|
import { planCommands } from './commands/plan.js';
|
|
39
39
|
import { initCommand } from './commands/init.js';
|
|
40
|
+
import { runCommand } from './commands/run.js';
|
|
41
|
+
import { receiptCommand } from './commands/receipt.js';
|
|
40
42
|
const VERSION = '2.0.0';
|
|
41
43
|
// ASCII Banner
|
|
42
44
|
function showBanner() {
|
|
@@ -92,6 +94,8 @@ program.addCommand(importCommands());
|
|
|
92
94
|
program.addCommand(historyCommands());
|
|
93
95
|
program.addCommand(planCommands());
|
|
94
96
|
program.addCommand(initCommand());
|
|
97
|
+
program.addCommand(runCommand());
|
|
98
|
+
program.addCommand(receiptCommand());
|
|
95
99
|
// Default action (no command) - show banner and help
|
|
96
100
|
program.action(() => {
|
|
97
101
|
showBanner();
|
|
@@ -2,6 +2,12 @@ export interface UsageSummary {
|
|
|
2
2
|
totalTokens: number;
|
|
3
3
|
inputTokens: number;
|
|
4
4
|
outputTokens: number;
|
|
5
|
+
/** Prompt tokens served from provider cache (subset of inputTokens) */
|
|
6
|
+
cacheReadTokens: number;
|
|
7
|
+
/** Prompt tokens written to provider cache (subset of inputTokens) */
|
|
8
|
+
cacheWriteTokens: number;
|
|
9
|
+
/** cacheReadTokens / inputTokens, 0 when there is no input */
|
|
10
|
+
cacheHitRate: number;
|
|
5
11
|
totalCost: number;
|
|
6
12
|
taskCount: number;
|
|
7
13
|
byModel: Record<string, {
|
|
@@ -22,7 +28,10 @@ export declare function initTokenTracker(): void;
|
|
|
22
28
|
/**
|
|
23
29
|
* Track usage from a chat/agentic session (not queue tasks)
|
|
24
30
|
*/
|
|
25
|
-
export declare function trackChatUsage(model: string, totalTokens: number, inputTokens?: number, outputTokens?: number
|
|
31
|
+
export declare function trackChatUsage(model: string, totalTokens: number, inputTokens?: number, outputTokens?: number, cache?: {
|
|
32
|
+
cacheReadTokens?: number;
|
|
33
|
+
cacheWriteTokens?: number;
|
|
34
|
+
}): void;
|
|
26
35
|
/**
|
|
27
36
|
* Get current usage summary
|
|
28
37
|
*/
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { onTaskEvent } from '../queue/index.js';
|
|
2
|
-
import { calculateCost } from './pricing.js';
|
|
2
|
+
import { calculateCost, getModelPricing } from './pricing.js';
|
|
3
|
+
import { cacheHitRate, calculateCachedInputCost, getCacheConfigForModel } from '../providers/prompt-cache.js';
|
|
3
4
|
import { logger } from '../utils/logger.js';
|
|
4
5
|
import { recordCost } from './persistence.js';
|
|
5
6
|
// In-memory usage store
|
|
@@ -7,6 +8,9 @@ let globalUsage = {
|
|
|
7
8
|
totalTokens: 0,
|
|
8
9
|
inputTokens: 0,
|
|
9
10
|
outputTokens: 0,
|
|
11
|
+
cacheReadTokens: 0,
|
|
12
|
+
cacheWriteTokens: 0,
|
|
13
|
+
cacheHitRate: 0,
|
|
10
14
|
totalCost: 0,
|
|
11
15
|
taskCount: 0,
|
|
12
16
|
byModel: {},
|
|
@@ -81,16 +85,32 @@ function trackTaskUsage(task, result) {
|
|
|
81
85
|
cost,
|
|
82
86
|
});
|
|
83
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Cost of a chat call. Uncached input bills at the model input rate; cache reads
|
|
90
|
+
* and writes bill at the configurable multipliers of that same rate. Output cost
|
|
91
|
+
* comes from the existing pricing table (calculateCost with zero input).
|
|
92
|
+
*/
|
|
93
|
+
function calculateChatCost(model, input, output, cacheRead, cacheWrite) {
|
|
94
|
+
if (cacheRead === 0 && cacheWrite === 0)
|
|
95
|
+
return calculateCost(model, input, output);
|
|
96
|
+
const inputCost = calculateCachedInputCost(input, cacheRead, cacheWrite, getModelPricing(model).inputRate, getCacheConfigForModel(model));
|
|
97
|
+
const outputCost = calculateCost(model, 0, output);
|
|
98
|
+
return Math.round((inputCost + outputCost) * 1_000_000) / 1_000_000;
|
|
99
|
+
}
|
|
84
100
|
/**
|
|
85
101
|
* Track usage from a chat/agentic session (not queue tasks)
|
|
86
102
|
*/
|
|
87
|
-
export function trackChatUsage(model, totalTokens, inputTokens, outputTokens) {
|
|
103
|
+
export function trackChatUsage(model, totalTokens, inputTokens, outputTokens, cache) {
|
|
88
104
|
const input = inputTokens ?? Math.floor(totalTokens * 0.7);
|
|
89
105
|
const output = outputTokens ?? totalTokens - input;
|
|
90
|
-
const
|
|
106
|
+
const cacheRead = cache?.cacheReadTokens ?? 0;
|
|
107
|
+
const cacheWrite = cache?.cacheWriteTokens ?? 0;
|
|
108
|
+
const cost = calculateChatCost(model, input, output, cacheRead, cacheWrite);
|
|
91
109
|
globalUsage.totalTokens += totalTokens;
|
|
92
110
|
globalUsage.inputTokens += input;
|
|
93
111
|
globalUsage.outputTokens += output;
|
|
112
|
+
globalUsage.cacheReadTokens += cacheRead;
|
|
113
|
+
globalUsage.cacheWriteTokens += cacheWrite;
|
|
94
114
|
globalUsage.totalCost += cost;
|
|
95
115
|
globalUsage.taskCount += 1;
|
|
96
116
|
const agentId = 'chat';
|
|
@@ -116,13 +136,22 @@ export function trackChatUsage(model, totalTokens, inputTokens, outputTokens) {
|
|
|
116
136
|
source: 'chat',
|
|
117
137
|
agentId: 'chat',
|
|
118
138
|
});
|
|
119
|
-
logger.debug('Chat usage tracked', {
|
|
139
|
+
logger.debug('Chat usage tracked', {
|
|
140
|
+
model,
|
|
141
|
+
tokens: totalTokens,
|
|
142
|
+
cost,
|
|
143
|
+
cacheReadTokens: cacheRead,
|
|
144
|
+
cacheWriteTokens: cacheWrite,
|
|
145
|
+
});
|
|
120
146
|
}
|
|
121
147
|
/**
|
|
122
148
|
* Get current usage summary
|
|
123
149
|
*/
|
|
124
150
|
export function getUsageSummary() {
|
|
125
|
-
return {
|
|
151
|
+
return {
|
|
152
|
+
...globalUsage,
|
|
153
|
+
cacheHitRate: cacheHitRate(globalUsage.cacheReadTokens, globalUsage.inputTokens),
|
|
154
|
+
};
|
|
126
155
|
}
|
|
127
156
|
/**
|
|
128
157
|
* Reset usage (for testing or billing cycles)
|
|
@@ -132,6 +161,9 @@ export function resetUsage() {
|
|
|
132
161
|
totalTokens: 0,
|
|
133
162
|
inputTokens: 0,
|
|
134
163
|
outputTokens: 0,
|
|
164
|
+
cacheReadTokens: 0,
|
|
165
|
+
cacheWriteTokens: 0,
|
|
166
|
+
cacheHitRate: 0,
|
|
135
167
|
totalCost: 0,
|
|
136
168
|
taskCount: 0,
|
|
137
169
|
byModel: {},
|