@occasiolabs/occasio 0.8.3 → 0.8.5
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 +1 -0
- package/docs/ADAPTER-STAGE-2-MIGRATION.md +59 -0
- package/docs/ARCHITECTURE.md +171 -0
- package/docs/STAGE-2-STEP-5-SHELL-PLAN.md +107 -0
- package/docs/THREAT-MODEL.md +195 -0
- package/docs/edr-calibration.md +29 -0
- package/package.json +12 -2
- package/src/adapters/claude-code.js +1 -2
- package/src/adapters/computer-use.js +1 -1
- package/src/anomaly/cli.js +4 -1
- package/src/anomaly/detectors/deny-rate.js +2 -1
- package/src/anomaly/detectors/file-read-volume.js +2 -1
- package/src/anomaly/index.js +5 -0
- package/src/attest/check-summary.js +1 -1
- package/src/attest/index.js +14 -1
- package/src/audit/jsonl-auditor.js +180 -14
- package/src/audit/repair.js +118 -0
- package/src/audit/verifier.js +36 -2
- package/src/boundary.js +1 -1
- package/src/classifier.js +1 -1
- package/src/cli/clear.js +55 -0
- package/src/cli/help.js +102 -0
- package/src/cli/register.js +90 -0
- package/src/cli/status.js +94 -0
- package/src/cost/prices.js +106 -0
- package/src/dashboard.js +2 -3
- package/src/distiller.js +1 -1
- package/src/executor/dispatcher.js +2 -2
- package/src/executor/native-handlers/glob.js +173 -0
- package/src/executor/native-handlers/grep.js +258 -0
- package/src/executor/native-handlers/read.js +99 -0
- package/src/executor/native-handlers/todo.js +56 -0
- package/src/harness.js +8 -10
- package/src/index.js +26 -283
- package/src/inspect.js +1 -1
- package/src/interceptor.js +9 -29
- package/src/ledger.js +2 -3
- package/src/mcp-experiment.js +4 -4
- package/src/mcp-server.js +3 -3
- package/src/policy/doctor.js +2 -2
- package/src/policy/engine.js +0 -1
- package/src/policy/init.js +1 -1
- package/src/policy/loader.js +3 -3
- package/src/policy/show.js +1 -2
- package/src/preflight/cli.js +0 -1
- package/src/preflight/miner.js +3 -6
- package/src/redteam.js +1 -2
- package/src/replay.js +1 -1
- package/src/report/index.js +0 -4
- package/src/runtime.js +42 -444
- package/src/selftest.js +1 -1
- package/src/session.js +1 -1
package/src/cli/help.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// `occasio help` — top-level usage. Pure text; no side effects other
|
|
2
|
+
// than console.log. Each CLI command lives in its own file under
|
|
3
|
+
// src/cli/ as part of the index.js decomposition (see CHANGELOG).
|
|
4
|
+
//
|
|
5
|
+
// Maturity tags follow the bewertung pillars:
|
|
6
|
+
// (stable) — load-bearing, has test coverage and field validation
|
|
7
|
+
// (beta) — works end-to-end but missing breadth (one detector, one preset)
|
|
8
|
+
// (alpha) — scaffold; needs operator calibration before relying on it
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const VERSION = (() => {
|
|
13
|
+
try { return require('../../package.json').version; }
|
|
14
|
+
catch { return '0.0.0-unknown'; }
|
|
15
|
+
})();
|
|
16
|
+
|
|
17
|
+
const col = {
|
|
18
|
+
r: s => `\x1b[31m${s}\x1b[0m`, g: s => `\x1b[32m${s}\x1b[0m`,
|
|
19
|
+
y: s => `\x1b[33m${s}\x1b[0m`, c: s => `\x1b[36m${s}\x1b[0m`,
|
|
20
|
+
d: s => `\x1b[2m${s}\x1b[0m`, b: s => `\x1b[1m${s}\x1b[0m`,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function run() {
|
|
24
|
+
console.log(`
|
|
25
|
+
${col.b(`⚡ Occasio v${VERSION}`)}
|
|
26
|
+
|
|
27
|
+
${col.b('60-Second Start:')}
|
|
28
|
+
${col.c('occasio init')} Create policy.yml from a template
|
|
29
|
+
${col.c('occasio register')} Install shell alias so 'claude' uses the proxy
|
|
30
|
+
${col.c('claude --version')} Confirm the wrapper resolves Claude Code
|
|
31
|
+
|
|
32
|
+
${col.b('Usage:')} occasio <command> [args...] (or oc <command>)
|
|
33
|
+
|
|
34
|
+
${col.b('Setup')} ${col.d('— one-time, per project')}
|
|
35
|
+
init ${col.d('(stable)')} Create starter policy.yml (--template strict|finance)
|
|
36
|
+
register ${col.d('(stable)')} Register shell alias (type 'claude' directly)
|
|
37
|
+
doctor ${col.d('(stable)')} Check setup: Node, claude CLI, port, Python, profile
|
|
38
|
+
|
|
39
|
+
${col.b('Run')} ${col.d('— start a session, observe live state')}
|
|
40
|
+
claude [args...] ${col.d('(stable)')} Start Claude with local proxy (intercept + log)
|
|
41
|
+
status ${col.d('(stable)')} Session stats, savings breakdown, coverage
|
|
42
|
+
clear ${col.d('(stable)')} Reset today's log and session data
|
|
43
|
+
clear --history ${col.d('(stable)')} Wipe all historical logs
|
|
44
|
+
ledger ${col.d('(stable)')} Inspect token ledger (--last N, --summary, --scope)
|
|
45
|
+
dashboard ${col.d('(beta)')} Open live dashboard at http://localhost:3001
|
|
46
|
+
|
|
47
|
+
${col.b('Inspect')} ${col.d('— forensics over what the agent did')}
|
|
48
|
+
replay ${col.d('(stable)')} Replay run audit (--last N, --detail, --run <id>)
|
|
49
|
+
boundary ${col.d('(stable)')} Per-request: produced / re-entered / prevented
|
|
50
|
+
inspect ${col.d('(stable)')} Cloud-boundary manifest (--last N, --entry N)
|
|
51
|
+
distill ${col.d('(stable)')} Inspect distilled outputs (--last N, --entry <N>)
|
|
52
|
+
report ${col.d('(stable)')} Governance export (--format csv for SIEM)
|
|
53
|
+
preflight ${col.d('(beta)')} Read-only miner over past logs
|
|
54
|
+
baseline ${col.d('(beta)')} Behavior baseline: [learn|show|compare|reset]
|
|
55
|
+
|
|
56
|
+
${col.b('Audit')} ${col.d('— tamper-evidence and attestation')}
|
|
57
|
+
audit verify ${col.d('(stable)')} Verify hash chain in pipeline-events.jsonl
|
|
58
|
+
audit repair ${col.d('(stable)')} Truncate crash-partial trailing line (--file --dry-run)
|
|
59
|
+
attest --run-id <uuid> ${col.d('(stable)')} Behavioral attestation: hash-chain + execution summary
|
|
60
|
+
${col.d('Add --sign in GitHub Actions for Sigstore keyless signing')}
|
|
61
|
+
attest verify <file> ${col.d('(stable)')} Re-verify signed attestation (bundle + DSSE + chain)
|
|
62
|
+
selftest ${col.d('(stable)')} Run governance self-checks on scratch chain
|
|
63
|
+
|
|
64
|
+
${col.b('Detect')} ${col.d('— anomalies, adversarial probes')}
|
|
65
|
+
anomalies ${col.d('(beta)')} Windowed EDR over the audit chain (--window 15m --json)
|
|
66
|
+
harness ${col.d('(alpha)')} Real Claude Code run vs. governance claims (API key required)
|
|
67
|
+
redteam ${col.d('(alpha)')} Autonomous adversarial probe (API key + SDK required)
|
|
68
|
+
|
|
69
|
+
${col.b('Policy & extras')}
|
|
70
|
+
policy [show] ${col.d('(stable)')} Show active policy: flags, routing, overrides
|
|
71
|
+
policy show --diff ${col.d('(stable)')} Only values that differ from defaults
|
|
72
|
+
policy validate ${col.d('(stable)')} Validate policy.yml and report errors/warnings
|
|
73
|
+
policy doctor ${col.d('(beta)')} Cross-reference logs with policy; suggest tightening
|
|
74
|
+
computer-use ${col.d('(alpha)')} Apply policy to a JSONL of tool_use blocks (--dry-run --example)
|
|
75
|
+
mcp-experiment ${col.d('(beta)')} MCP vs. built-in tool adoption stats
|
|
76
|
+
demo ${col.d('(stable)')} 10-second proof: see Occasio block real secrets
|
|
77
|
+
demo attest ${col.d('(stable)')} End-to-end attestation pipeline against a synthetic chain
|
|
78
|
+
demo anomalies ${col.d('(stable)')} End-to-end EDR test: synthetic adversarial chain
|
|
79
|
+
|
|
80
|
+
${col.b('Presets:')}
|
|
81
|
+
--preset balanced (default) Intercept safe reads locally, log all requests
|
|
82
|
+
--preset strict Block requests that contain detected secrets
|
|
83
|
+
--preset off Log only — no interception, no blocking
|
|
84
|
+
|
|
85
|
+
${col.b('Flags:')}
|
|
86
|
+
--budget <N> Block requests once session cost exceeds $N (e.g. --budget 1.00)
|
|
87
|
+
--hardened Route Read/Glob/Grep through unified runtime (distill + secret scan)
|
|
88
|
+
--block-secrets Alias for --preset strict
|
|
89
|
+
--log-only Alias for --preset off
|
|
90
|
+
--dashboard Open live dashboard at http://localhost:3001
|
|
91
|
+
--port <N> Proxy port (default: 8081)
|
|
92
|
+
--verbose Print live per-request chatter (off by default)
|
|
93
|
+
|
|
94
|
+
${col.b('Multi-agent routing:')}
|
|
95
|
+
Default → Claude Code adapter
|
|
96
|
+
Header x-occasio-agent: cline → Cline adapter (synthetic; live validation pending)
|
|
97
|
+
|
|
98
|
+
${col.b('Logs:')} ~/.occasio/logs/YYYY-MM-DD.jsonl
|
|
99
|
+
`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { run };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// `occasio register` — installs a `claude()` shell function so the user
|
|
2
|
+
// can keep typing `claude` and silently get routed through the proxy.
|
|
3
|
+
//
|
|
4
|
+
// Idempotent: detects the canonical marker and exits; auto-upgrades the
|
|
5
|
+
// legacy `--intercept` snippet to `claude` if found. Best-effort on
|
|
6
|
+
// failure — prints a manual instruction rather than crashing.
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
const col = {
|
|
15
|
+
r: s => `\x1b[31m${s}\x1b[0m`, g: s => `\x1b[32m${s}\x1b[0m`,
|
|
16
|
+
y: s => `\x1b[33m${s}\x1b[0m`, d: s => `\x1b[2m${s}\x1b[0m`,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function registerWindows() {
|
|
20
|
+
const profileDir = path.join(os.homedir(), 'Documents', 'PowerShell');
|
|
21
|
+
const profileFile = path.join(profileDir, 'Microsoft.PowerShell_profile.ps1');
|
|
22
|
+
const snippet = `\n# Occasio — intercept Claude Code traffic\nfunction claude { occasio claude @args }\n`;
|
|
23
|
+
const alreadyMarker = 'occasio claude @args';
|
|
24
|
+
const legacyMarker = 'occasio --intercept @args';
|
|
25
|
+
try {
|
|
26
|
+
if (!fs.existsSync(profileDir)) fs.mkdirSync(profileDir, { recursive: true });
|
|
27
|
+
const existing = fs.existsSync(profileFile) ? fs.readFileSync(profileFile, 'utf8') : '';
|
|
28
|
+
if (existing.includes(alreadyMarker)) {
|
|
29
|
+
console.log(col.g('✓ Already registered (PowerShell)'));
|
|
30
|
+
console.log(col.d(' Type: claude'));
|
|
31
|
+
} else if (existing.includes(legacyMarker)) {
|
|
32
|
+
const updated = existing.replace(
|
|
33
|
+
/function claude \{ occasio --intercept @args \}/g,
|
|
34
|
+
'function claude { occasio claude @args }'
|
|
35
|
+
);
|
|
36
|
+
fs.writeFileSync(profileFile, updated);
|
|
37
|
+
console.log(col.g('✓ Updated to canonical form (occasio claude)'));
|
|
38
|
+
console.log('');
|
|
39
|
+
console.log(col.y(` ⚠ Restart PowerShell — the 'claude' alias is not active yet.`));
|
|
40
|
+
console.log(col.d(` Open a new terminal, or run: . $PROFILE`));
|
|
41
|
+
console.log('');
|
|
42
|
+
} else {
|
|
43
|
+
fs.appendFileSync(profileFile, snippet);
|
|
44
|
+
console.log(col.g(`✓ Registered in ${profileFile}`));
|
|
45
|
+
console.log('');
|
|
46
|
+
console.log(col.y(` ⚠ Restart PowerShell — the 'claude' alias is not active yet.`));
|
|
47
|
+
console.log(col.d(` Open a new terminal, or run: . $PROFILE`));
|
|
48
|
+
console.log('');
|
|
49
|
+
}
|
|
50
|
+
} catch (e) {
|
|
51
|
+
console.log(col.r(`✗ Could not write profile: ${e.message}`));
|
|
52
|
+
console.log(col.d(` Add manually to your PowerShell profile:\n function claude { occasio claude @args }`));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function registerPosix() {
|
|
57
|
+
const rcFile = (process.env.SHELL || '').includes('zsh')
|
|
58
|
+
? path.join(os.homedir(), '.zshrc')
|
|
59
|
+
: path.join(os.homedir(), '.bashrc');
|
|
60
|
+
const snippet = `\n# Occasio — intercept Claude Code traffic\nclaude() { occasio claude "$@"; }\n`;
|
|
61
|
+
const alreadyMarker = 'occasio claude "$@"';
|
|
62
|
+
const legacyMarker = 'occasio --intercept "$@"';
|
|
63
|
+
try {
|
|
64
|
+
const existing = fs.existsSync(rcFile) ? fs.readFileSync(rcFile, 'utf8') : '';
|
|
65
|
+
if (existing.includes(alreadyMarker)) {
|
|
66
|
+
console.log(col.g(`✓ Already registered (${rcFile})`));
|
|
67
|
+
} else if (existing.includes(legacyMarker)) {
|
|
68
|
+
const updated = existing.replace(
|
|
69
|
+
/claude\(\) \{ occasio --intercept "\$@"; \}/g,
|
|
70
|
+
'claude() { occasio claude "$@"; }'
|
|
71
|
+
);
|
|
72
|
+
fs.writeFileSync(rcFile, updated);
|
|
73
|
+
console.log(col.g(`✓ Updated to canonical form in ${rcFile}`));
|
|
74
|
+
} else {
|
|
75
|
+
fs.appendFileSync(rcFile, snippet);
|
|
76
|
+
console.log(col.g(`✓ Registered in ${rcFile}`));
|
|
77
|
+
}
|
|
78
|
+
console.log(col.d(' Run: source ' + rcFile + ' — then type: claude'));
|
|
79
|
+
} catch (e) {
|
|
80
|
+
console.log(col.r(`✗ Could not write ${rcFile}: ${e.message}`));
|
|
81
|
+
console.log(col.d(` Add manually:\n claude() { occasio claude "$@"; }`));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function run() {
|
|
86
|
+
if (process.platform === 'win32') registerWindows();
|
|
87
|
+
else registerPosix();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { run };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// `occasio status` — session summary (cost, savings, coverage, budget).
|
|
2
|
+
// Read-only against ~/.occasio/session.json + today's JSONL log.
|
|
3
|
+
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const { calcCompoundingSavings } = require('../cost/prices');
|
|
11
|
+
const { fmtBudget } = require('../budget');
|
|
12
|
+
|
|
13
|
+
const LOG_DIR = path.join(os.homedir(), '.occasio');
|
|
14
|
+
const SESSION_FILE = path.join(LOG_DIR, 'session.json');
|
|
15
|
+
|
|
16
|
+
const col = {
|
|
17
|
+
r: s => `\x1b[31m${s}\x1b[0m`, g: s => `\x1b[32m${s}\x1b[0m`,
|
|
18
|
+
y: s => `\x1b[33m${s}\x1b[0m`, c: s => `\x1b[36m${s}\x1b[0m`,
|
|
19
|
+
d: s => `\x1b[2m${s}\x1b[0m`, b: s => `\x1b[1m${s}\x1b[0m`,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function todayStr() {
|
|
23
|
+
const d = new Date();
|
|
24
|
+
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
|
25
|
+
}
|
|
26
|
+
function getLogFile() { return path.join(LOG_DIR, 'logs', `${todayStr()}.jsonl`); }
|
|
27
|
+
|
|
28
|
+
function run() {
|
|
29
|
+
let s = null; try { s = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')); } catch { /* ignore */ }
|
|
30
|
+
console.log(col.b('\n⚡ Occasio\n'));
|
|
31
|
+
if (!s) { console.log(col.d(' No session data yet. Run: occasio claude\n')); return; }
|
|
32
|
+
|
|
33
|
+
const cacheSav = s.cache_savings || 0;
|
|
34
|
+
const laoSav = s.lao_cost_saved || 0;
|
|
35
|
+
const distSav = s.distill_cost_saved || 0;
|
|
36
|
+
const payload = laoSav + distSav;
|
|
37
|
+
const { savings: context } =
|
|
38
|
+
calcCompoundingSavings(s.run_id, s.log_file || getLogFile(), s.model || '');
|
|
39
|
+
const totalSav = payload + context + cacheSav;
|
|
40
|
+
const broaderCf = (s.cost || 0) + totalSav;
|
|
41
|
+
const savedPct = broaderCf > 0.00001 ? Math.round(totalSav / broaderCf * 100) : 0;
|
|
42
|
+
|
|
43
|
+
// Headline
|
|
44
|
+
if (totalSav > 0.00001) {
|
|
45
|
+
console.log(col.g(` Saved: $${totalSav.toFixed(4)}`) +
|
|
46
|
+
col.d(` (${savedPct}% off — would have cost $${broaderCf.toFixed(4)})`));
|
|
47
|
+
} else {
|
|
48
|
+
console.log(col.d(` Saved: $0.0000 (no interceptable tool calls in this session yet)`));
|
|
49
|
+
}
|
|
50
|
+
console.log(col.y(` Cost: $${s.cost.toFixed(4)}`));
|
|
51
|
+
|
|
52
|
+
// Plain-English coverage. Defensive: legacy sessions (pre-multi-round-fix)
|
|
53
|
+
// may have tools_attempted undercounted relative to tools_local_count.
|
|
54
|
+
// We clamp the denominator to at least the numerator so the displayed
|
|
55
|
+
// ratio is always 0–100% and never reads "X of Y < X (>100%)".
|
|
56
|
+
const localCnt = s.tools_local_count || 0;
|
|
57
|
+
const mcpCnt = s.tools_mcp_count || 0;
|
|
58
|
+
const attempted = s.tools_attempted || 0;
|
|
59
|
+
const totalLocal = localCnt + mcpCnt;
|
|
60
|
+
const denom = Math.max(attempted, totalLocal);
|
|
61
|
+
if (denom > 0) {
|
|
62
|
+
const cpct = Math.round(totalLocal / denom * 100);
|
|
63
|
+
const cColor = cpct >= 80 ? col.g : cpct >= 50 ? col.y : col.r;
|
|
64
|
+
console.log(cColor(` Ran locally: ${totalLocal} of ${denom} tool calls (${cpct}%)`));
|
|
65
|
+
}
|
|
66
|
+
if (s.blocked) console.log(col.r(` Blocked: ${s.blocked} secrets`));
|
|
67
|
+
if (s.secrets_redacted) console.log(col.c(` Redacted: ${s.secrets_redacted} secret${s.secrets_redacted !== 1 ? 's' : ''} in tool results`));
|
|
68
|
+
if (s.tools_transformed) console.log(col.c(` Transforms: ${s.tools_transformed} tool result${s.tools_transformed !== 1 ? 's' : ''} shaped`));
|
|
69
|
+
if (s.budget != null) {
|
|
70
|
+
const pct = Math.min(999, Math.round((s.cost || 0) / s.budget * 100));
|
|
71
|
+
const budgetStr = fmtBudget(s.cost || 0, s.budget);
|
|
72
|
+
const budgetColor = pct >= 100 ? col.r : pct >= 80 ? col.y : col.g;
|
|
73
|
+
console.log(budgetColor(` Budget: ${budgetStr}`));
|
|
74
|
+
if (s.budget_exceeded_count) console.log(col.r(` BudgetBlk: ${s.budget_exceeded_count} request(s) blocked`));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Detail
|
|
78
|
+
console.log(col.d(` ────`));
|
|
79
|
+
console.log(col.d(` Requests: ${s.requests} · ${(s.input_tokens/1000).toFixed(1)}k tokens in · ${(s.output_tokens/1000).toFixed(1)}k out`));
|
|
80
|
+
if (totalSav > 0.00001) {
|
|
81
|
+
const parts = [];
|
|
82
|
+
if (payload > 0.00001) parts.push(`$${payload.toFixed(4)} payload`);
|
|
83
|
+
if (context > 0.00001) parts.push(`$${context.toFixed(4)} context`);
|
|
84
|
+
if (cacheSav > 0.00001) parts.push(`$${cacheSav.toFixed(4)} cache`);
|
|
85
|
+
if (parts.length) console.log(col.d(` Breakdown: ${parts.join(' + ')}`));
|
|
86
|
+
}
|
|
87
|
+
const tail = [];
|
|
88
|
+
if (s.mode) tail.push(`Mode: ${s.mode}`);
|
|
89
|
+
if (s.start) tail.push(`Since: ${new Date(s.start).toLocaleString()}`);
|
|
90
|
+
if (tail.length) console.log(col.d(` ${tail.join(' · ')}`));
|
|
91
|
+
console.log('');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { run };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Token-cost arithmetic for Anthropic-priced models. Extracted from
|
|
2
|
+
// src/index.js so the proxy hot-path doesn't carry pricing data, and so
|
|
3
|
+
// MODEL_PRICES updates land in a file small enough to review at a glance.
|
|
4
|
+
//
|
|
5
|
+
// Prices are USD per 1M tokens. Cache-write is the one-time cost to
|
|
6
|
+
// populate a cache breakpoint; cache-read is the cheap subsequent hit.
|
|
7
|
+
//
|
|
8
|
+
// Substring matching is intentional — Anthropic's model_id strings often
|
|
9
|
+
// carry a dated suffix (claude-haiku-4-5-20251001) we want to absorb
|
|
10
|
+
// without a table update. The trade-off: a truly unknown model silently
|
|
11
|
+
// falls back to `default`. We warn once per unknown model so the failure
|
|
12
|
+
// is loud the first time and quiet thereafter.
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
|
|
18
|
+
const MODEL_PRICES = {
|
|
19
|
+
'claude-opus-4-6': { in: 15.00, out: 75.00, cache_write: 18.75, cache_read: 1.50 },
|
|
20
|
+
'claude-opus-4': { in: 15.00, out: 75.00, cache_write: 18.75, cache_read: 1.50 },
|
|
21
|
+
'claude-sonnet-4-6': { in: 3.00, out: 15.00, cache_write: 3.75, cache_read: 0.30 },
|
|
22
|
+
'claude-sonnet-4': { in: 3.00, out: 15.00, cache_write: 3.75, cache_read: 0.30 },
|
|
23
|
+
'claude-haiku-4-5': { in: 0.25, out: 1.25, cache_write: 0.30, cache_read: 0.03 },
|
|
24
|
+
'claude-haiku-4': { in: 0.25, out: 1.25, cache_write: 0.30, cache_read: 0.03 },
|
|
25
|
+
'default': { in: 3.00, out: 15.00, cache_write: 3.75, cache_read: 0.30 },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Track which unknown model names we've already complained about, so a
|
|
29
|
+
// long session with a new model surfaces the warning exactly once instead
|
|
30
|
+
// of on every request.
|
|
31
|
+
const _warnedUnknown = new Set();
|
|
32
|
+
|
|
33
|
+
function getPrice(model) {
|
|
34
|
+
if (!model) return MODEL_PRICES.default;
|
|
35
|
+
for (const [k, v] of Object.entries(MODEL_PRICES)) {
|
|
36
|
+
if (k !== 'default' && model.includes(k)) return v;
|
|
37
|
+
}
|
|
38
|
+
if (!_warnedUnknown.has(model)) {
|
|
39
|
+
_warnedUnknown.add(model);
|
|
40
|
+
// stderr so it doesn't pollute proxy stdout. Silenceable via env for
|
|
41
|
+
// CI runs that legitimately want to price unknown models as default.
|
|
42
|
+
if (!process.env.OCCASIO_QUIET_PRICING) {
|
|
43
|
+
process.stderr.write(
|
|
44
|
+
`[occasio] warning: unknown model "${model}" — falling back to default pricing ` +
|
|
45
|
+
`(in:$${MODEL_PRICES.default.in}/M, out:$${MODEL_PRICES.default.out}/M). ` +
|
|
46
|
+
`Add it to src/cost/prices.js to silence this.\n`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return MODEL_PRICES.default;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function calcCost(model, inp, out, cacheWrite = 0, cacheRead = 0) {
|
|
54
|
+
const p = getPrice(model);
|
|
55
|
+
return (inp / 1e6 * p.in) + (out / 1e6 * p.out)
|
|
56
|
+
+ (cacheWrite / 1e6 * p.cache_write) + (cacheRead / 1e6 * p.cache_read);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Savings from Anthropic prompt caching: cache reads are 10× cheaper than fresh input.
|
|
60
|
+
function calcCacheSavings(model, cacheReadTokens) {
|
|
61
|
+
if (!cacheReadTokens) return 0;
|
|
62
|
+
const p = getPrice(model);
|
|
63
|
+
return (cacheReadTokens / 1e6) * (p.in - p.cache_read);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Cross-request compounding savings: reads the run's JSONL entries in sequence order
|
|
67
|
+
// and weights each distilled batch by the exact number of subsequent API calls that
|
|
68
|
+
// carry the smaller result in their conversation history.
|
|
69
|
+
// Formula: Σ (distill_tokens_saved[i] / 1M × price_in × (N - i - 1))
|
|
70
|
+
// Returns { savings: float, carryInstances: int }
|
|
71
|
+
// carryInstances = total sum of subsequent-call counts across all distilled batches
|
|
72
|
+
// — the actual multiplier used — so the display is self-explanatory.
|
|
73
|
+
// Assumption: tool results accumulate in Claude Code's message history for all
|
|
74
|
+
// subsequent requests in the session (true for normal sessions; may not hold if
|
|
75
|
+
// Claude Code resets context mid-session).
|
|
76
|
+
function calcCompoundingSavings(runId, logFile, model) {
|
|
77
|
+
if (!runId) return { savings: 0, carryInstances: 0 };
|
|
78
|
+
let entries;
|
|
79
|
+
try {
|
|
80
|
+
const lines = fs.readFileSync(logFile, 'utf8').trim().split('\n').filter(Boolean);
|
|
81
|
+
entries = lines
|
|
82
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
83
|
+
.filter(e => e && e.run_id === runId);
|
|
84
|
+
} catch { return { savings: 0, carryInstances: 0 }; }
|
|
85
|
+
const N = entries.length;
|
|
86
|
+
if (N < 2) return { savings: 0, carryInstances: 0 };
|
|
87
|
+
const p = getPrice(model);
|
|
88
|
+
let savings = 0, carryInstances = 0;
|
|
89
|
+
for (let i = 0; i < N; i++) {
|
|
90
|
+
const dt = entries[i].distill_tokens_saved || 0;
|
|
91
|
+
if (dt > 0) {
|
|
92
|
+
const subsequent = N - i - 1;
|
|
93
|
+
savings += (dt / 1e6) * p.in * subsequent;
|
|
94
|
+
carryInstances += subsequent;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { savings, carryInstances };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = {
|
|
101
|
+
MODEL_PRICES,
|
|
102
|
+
getPrice,
|
|
103
|
+
calcCost,
|
|
104
|
+
calcCacheSavings,
|
|
105
|
+
calcCompoundingSavings,
|
|
106
|
+
};
|
package/src/dashboard.js
CHANGED
|
@@ -16,7 +16,6 @@ const path = require('path');
|
|
|
16
16
|
const os = require('os');
|
|
17
17
|
|
|
18
18
|
const DASHBOARD_PORT = 3001;
|
|
19
|
-
const PROXY_PORT = 8081;
|
|
20
19
|
const LOG_DIR = path.join(os.homedir(), '.occasio');
|
|
21
20
|
const SESSION_FILE = path.join(LOG_DIR, 'session.json');
|
|
22
21
|
|
|
@@ -97,8 +96,8 @@ const server = http.createServer((req, res) => {
|
|
|
97
96
|
}
|
|
98
97
|
|
|
99
98
|
if (req.url === '/api/clear' && req.method === 'POST') {
|
|
100
|
-
try { fs.writeFileSync(todayLogFile(), ''); } catch {}
|
|
101
|
-
try { fs.writeFileSync(SESSION_FILE, '{}'); } catch {}
|
|
99
|
+
try { fs.writeFileSync(todayLogFile(), ''); } catch { /* ignore */ }
|
|
100
|
+
try { fs.writeFileSync(SESSION_FILE, '{}'); } catch { /* ignore */ }
|
|
102
101
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
103
102
|
res.end('{"ok":true}');
|
|
104
103
|
broadcast({ type: 'update', session: {}, entries: [] });
|
package/src/distiller.js
CHANGED
|
@@ -120,7 +120,7 @@ const FAIL_RE = /\b(FAIL|FAILED|ERROR|error:|✗|×|AssertionError|not ok|ERRORE
|
|
|
120
120
|
* Keeps all failure-related lines (plus 1 line of context each side) and the
|
|
121
121
|
* last 15 lines (usually the summary). Clips total to TEST_MAX_LINES.
|
|
122
122
|
*/
|
|
123
|
-
function distillTestOutput(output, rawBytes,
|
|
123
|
+
function distillTestOutput(output, rawBytes, _cmd) {
|
|
124
124
|
const lines = output.split('\n');
|
|
125
125
|
const none = { content: output, distilled: false, savedTokens: 0, label: '', rawBytes, rawContent: null };
|
|
126
126
|
if (lines.length <= TEST_MAX_LINES) return none;
|
|
@@ -44,7 +44,7 @@ const NATIVE_HANDLERS = {
|
|
|
44
44
|
// but nativeHandle returned null, fall back to the exec subprocess. The
|
|
45
45
|
// returned `native` field tells the caller which path was taken.
|
|
46
46
|
[CANONICAL.SHELL_BASH]: async (input) => {
|
|
47
|
-
const cmd = (input?.command
|
|
47
|
+
const cmd = (typeof input?.command === 'string' ? input.command : '').trim();
|
|
48
48
|
if (!cmd) return null;
|
|
49
49
|
const nr = nativeHandle(cmd);
|
|
50
50
|
if (nr !== null) {
|
|
@@ -63,7 +63,7 @@ const NATIVE_HANDLERS = {
|
|
|
63
63
|
// then native-only execution. expandedCmd is returned so the caller can
|
|
64
64
|
// record the actually-executed command in toolsRun.
|
|
65
65
|
[CANONICAL.SHELL_POWERSHELL]: (input) => {
|
|
66
|
-
const rawCmd = (input?.command
|
|
66
|
+
const rawCmd = (typeof input?.command === 'string' ? input.command : '').trim();
|
|
67
67
|
if (!rawCmd) return null;
|
|
68
68
|
const cmd = expandPsEnvVars(rawCmd);
|
|
69
69
|
const nr = nativeHandle(cmd);
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Native handler for the Glob tool.
|
|
5
|
+
*
|
|
6
|
+
* Pure filesystem function: takes a glob pattern (+ optional base path) and
|
|
7
|
+
* returns a sorted list of matching paths. No dependency on the interceptor
|
|
8
|
+
* pipeline, Anthropic API, or shell execution. Safe to import in any process
|
|
9
|
+
* context.
|
|
10
|
+
*
|
|
11
|
+
* Extracted from src/runtime.js as Stage-2 Step 3 of the executor migration
|
|
12
|
+
* (see docs/ADAPTER-STAGE-2-MIGRATION.md). src/runtime.js re-exports these
|
|
13
|
+
* so existing consumers keep working unchanged.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
// ── Glob tool support ──────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
// Characters that indicate shell injection in a glob pattern.
|
|
22
|
+
// We reject patterns containing these so handleGlobTool stays read-only.
|
|
23
|
+
const GLOB_INJECTION_RE = /[;&|`$<>!]/;
|
|
24
|
+
|
|
25
|
+
// Directories skipped during recursive glob walks.
|
|
26
|
+
const GLOB_SKIP = new Set(['node_modules', '.git', '.hg', '.svn', 'dist', 'build', '__pycache__', '.venv', 'venv']);
|
|
27
|
+
|
|
28
|
+
// Maximum number of matches returned to avoid overwhelming the model context.
|
|
29
|
+
const GLOB_MAX = 500;
|
|
30
|
+
|
|
31
|
+
// Maximum recursion depth from baseDir. Hard cap on path-traversal DoS
|
|
32
|
+
// (a fuzz-discovered class — see THREAT-MODEL.md residual risk #5).
|
|
33
|
+
// Tunable via env for special-case repos.
|
|
34
|
+
const GLOB_MAX_DEPTH = Number(process.env.OCCASIO_GLOB_MAX_DEPTH) || 16;
|
|
35
|
+
|
|
36
|
+
// Soft wall-clock limit per walk in ms. Stops a walk that strayed onto a huge
|
|
37
|
+
// subtree (e.g. agent globbed up from /) before it burns seconds. Stop is
|
|
38
|
+
// best-effort — the caller still receives whatever was collected so far.
|
|
39
|
+
const GLOB_MAX_MS = Number(process.env.OCCASIO_GLOB_MAX_MS) || 2_000;
|
|
40
|
+
|
|
41
|
+
function isGlobHandleable(input) {
|
|
42
|
+
if (!input || typeof input !== 'object') return false;
|
|
43
|
+
const pattern = input.pattern;
|
|
44
|
+
if (!pattern || typeof pattern !== 'string' || !pattern.trim()) return false;
|
|
45
|
+
if (GLOB_INJECTION_RE.test(pattern)) return false;
|
|
46
|
+
if (input.path != null && typeof input.path !== 'string') return false;
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Escape regex metacharacters in a literal string segment.
|
|
51
|
+
function escapeRegexChars(s) {
|
|
52
|
+
return s.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Convert a glob pattern to a RegExp.
|
|
57
|
+
* Supports: ** (any path depth), * (single segment), ? (single char),
|
|
58
|
+
* {ts,tsx} (alternation), [abc] (character classes).
|
|
59
|
+
* Exported for unit testing.
|
|
60
|
+
*/
|
|
61
|
+
function globToRegex(pattern) {
|
|
62
|
+
// Normalise Windows separators in the pattern.
|
|
63
|
+
const p = pattern.replace(/\\/g, '/');
|
|
64
|
+
|
|
65
|
+
let re = '';
|
|
66
|
+
let i = 0;
|
|
67
|
+
while (i < p.length) {
|
|
68
|
+
// ** — match any path segments (including none), consuming the trailing /
|
|
69
|
+
if (p[i] === '*' && p[i + 1] === '*') {
|
|
70
|
+
re += '.*';
|
|
71
|
+
i += 2;
|
|
72
|
+
if (p[i] === '/') i++; // consume separator after **
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// * — match within a single path segment
|
|
76
|
+
if (p[i] === '*') { re += '[^/]*'; i++; continue; }
|
|
77
|
+
// ? — match a single character within a segment
|
|
78
|
+
if (p[i] === '?') { re += '[^/]'; i++; continue; }
|
|
79
|
+
// {a,b,c} — alternation
|
|
80
|
+
if (p[i] === '{') {
|
|
81
|
+
const end = p.indexOf('}', i);
|
|
82
|
+
if (end !== -1) {
|
|
83
|
+
const alts = p.slice(i + 1, end).split(',').map(escapeRegexChars);
|
|
84
|
+
re += `(?:${alts.join('|')})`;
|
|
85
|
+
i = end + 1;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// [abc] / [^abc] — pass character classes through verbatim
|
|
90
|
+
if (p[i] === '[') {
|
|
91
|
+
const end = p.indexOf(']', i);
|
|
92
|
+
if (end !== -1) { re += p.slice(i, end + 1); i = end + 1; continue; }
|
|
93
|
+
}
|
|
94
|
+
re += escapeRegexChars(p[i]);
|
|
95
|
+
i++;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// On Windows, matching is case-insensitive; on POSIX it's case-sensitive.
|
|
99
|
+
const flags = process.platform === 'win32' ? 'i' : '';
|
|
100
|
+
return new RegExp(`^${re}$`, flags);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Walk `dir` recursively, collecting paths that match `regex`.
|
|
105
|
+
* Results are relative to `baseDir`.
|
|
106
|
+
*/
|
|
107
|
+
function walkGlob(dir, baseDir, regex, results, depth = 0, deadline = Infinity) {
|
|
108
|
+
if (results.length >= GLOB_MAX) return;
|
|
109
|
+
if (depth >= GLOB_MAX_DEPTH) return;
|
|
110
|
+
if (Date.now() >= deadline) return;
|
|
111
|
+
let entries;
|
|
112
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
113
|
+
catch { return; }
|
|
114
|
+
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (results.length >= GLOB_MAX) break;
|
|
117
|
+
if (Date.now() >= deadline) break;
|
|
118
|
+
if (GLOB_SKIP.has(entry.name)) continue;
|
|
119
|
+
const abs = path.join(dir, entry.name);
|
|
120
|
+
// Normalise to forward slashes for matching (consistent on all platforms).
|
|
121
|
+
const rel = path.relative(baseDir, abs).replace(/\\/g, '/');
|
|
122
|
+
if (entry.isDirectory()) {
|
|
123
|
+
walkGlob(abs, baseDir, regex, results, depth + 1, deadline);
|
|
124
|
+
} else if (regex.test(rel)) {
|
|
125
|
+
results.push(rel);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resolve glob pattern + optional base path to a sorted list of matching paths,
|
|
132
|
+
* relative to CWD. Returns { output, exitCode, matchCount }.
|
|
133
|
+
*/
|
|
134
|
+
function handleGlobTool(input) {
|
|
135
|
+
const pattern = (typeof input?.pattern === 'string' ? input.pattern : '').trim();
|
|
136
|
+
if (!pattern) return { output: '(no pattern provided)', exitCode: 1, matchCount: 0 };
|
|
137
|
+
|
|
138
|
+
const baseDir = input?.path
|
|
139
|
+
? path.resolve(process.cwd(), input.path)
|
|
140
|
+
: process.cwd();
|
|
141
|
+
|
|
142
|
+
const cwd = process.cwd();
|
|
143
|
+
|
|
144
|
+
let regex;
|
|
145
|
+
try { regex = globToRegex(pattern); }
|
|
146
|
+
catch (e) { return { output: `Glob: invalid pattern: ${e.message}`, exitCode: 1, matchCount: 0 }; }
|
|
147
|
+
|
|
148
|
+
const results = [];
|
|
149
|
+
const deadline = Date.now() + GLOB_MAX_MS;
|
|
150
|
+
walkGlob(baseDir, baseDir, regex, results, 0, deadline);
|
|
151
|
+
const timedOut = Date.now() >= deadline;
|
|
152
|
+
results.sort();
|
|
153
|
+
|
|
154
|
+
const truncated = results.length >= GLOB_MAX;
|
|
155
|
+
const lines = results.map(r => path.join(baseDir !== cwd ? baseDir : '', r).replace(/\\/g, '/'));
|
|
156
|
+
const suffix = truncated ? `\n(truncated at ${GLOB_MAX} results)`
|
|
157
|
+
: timedOut ? `\n(truncated — walk exceeded ${GLOB_MAX_MS} ms)`
|
|
158
|
+
: '';
|
|
159
|
+
const output = lines.join('\n') + suffix;
|
|
160
|
+
return { output: output || '(no matches)', exitCode: 0, matchCount: results.length };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = {
|
|
164
|
+
GLOB_INJECTION_RE,
|
|
165
|
+
GLOB_SKIP,
|
|
166
|
+
GLOB_MAX,
|
|
167
|
+
GLOB_MAX_DEPTH,
|
|
168
|
+
GLOB_MAX_MS,
|
|
169
|
+
isGlobHandleable,
|
|
170
|
+
globToRegex,
|
|
171
|
+
walkGlob,
|
|
172
|
+
handleGlobTool,
|
|
173
|
+
};
|