@occasiolabs/occasio 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +216 -0
- package/bin/occasio-mcp.js +5 -0
- package/bin/occasio.js +2 -0
- package/bin/supervisor/README.md +90 -0
- package/bin/supervisor/com.occasio.proxy.plist.template +36 -0
- package/bin/supervisor/install-windows-task.ps1 +48 -0
- package/bin/supervisor/occasio.service +18 -0
- package/docs/AUDIT.md +120 -0
- package/docs/attest_verify.py +283 -0
- package/docs/audit_walker.py +65 -0
- package/docs/canonicalize.py +99 -0
- package/docs/compliance-mapping.md +93 -0
- package/docs/demos/mcp-block.md +148 -0
- package/docs/edr-calibration.md +73 -0
- package/docs/edr-demo.md +83 -0
- package/docs/python-verifier.md +74 -0
- package/docs/reference-pipeline.md +140 -0
- package/package.json +69 -0
- package/policy-templates/dev-default.yml +84 -0
- package/policy-templates/finance.yml +61 -0
- package/policy-templates/strict.yml +49 -0
- package/schemas/agent-attestation-v1.json +190 -0
- package/schemas/occasio-policy.schema.json +99 -0
- package/spec/agent-attestation/v1/README.md +137 -0
- package/src/adapters/claude-code.js +518 -0
- package/src/adapters/cline.js +161 -0
- package/src/adapters/computer-use-cli.js +198 -0
- package/src/adapters/computer-use.js +227 -0
- package/src/analyzer.js +170 -0
- package/src/anomaly/cli.js +143 -0
- package/src/anomaly/detectors/deny-rate.js +84 -0
- package/src/anomaly/detectors/file-read-volume.js +109 -0
- package/src/anomaly/detectors/secret-redact-rate.js +107 -0
- package/src/anomaly/detectors/unknown-tool-input.js +83 -0
- package/src/anomaly/index.js +169 -0
- package/src/attest/canonicalize.js +97 -0
- package/src/attest/index.js +355 -0
- package/src/attest/run-slice.js +57 -0
- package/src/attest/sign.js +186 -0
- package/src/attest/verify.js +192 -0
- package/src/audit/errors.js +21 -0
- package/src/audit/input-normalizer.js +121 -0
- package/src/audit/jsonl-auditor.js +178 -0
- package/src/audit/verifier.js +152 -0
- package/src/baseline.js +507 -0
- package/src/boundary.js +238 -0
- package/src/budget.js +42 -0
- package/src/classifier.js +115 -0
- package/src/context-budget.js +77 -0
- package/src/core/boundary-event.js +75 -0
- package/src/core/decision.js +61 -0
- package/src/core/pipeline.js +66 -0
- package/src/core/tool-names.js +105 -0
- package/src/dashboard.js +892 -0
- package/src/demo/README.md +31 -0
- package/src/demo/anomalies-demo.js +211 -0
- package/src/demo/attest-demo.js +198 -0
- package/src/distiller.js +155 -0
- package/src/embeddings.json +72 -0
- package/src/executor/dispatcher.js +230 -0
- package/src/harness.js +817 -0
- package/src/index.js +1711 -0
- package/src/inspect.js +329 -0
- package/src/interceptor.js +1198 -0
- package/src/lao.js +185 -0
- package/src/lao_prep.py +119 -0
- package/src/ledger.js +209 -0
- package/src/mcp-experiment.js +140 -0
- package/src/mcp-normalize.js +139 -0
- package/src/mcp-server.js +320 -0
- package/src/outbound-policy.js +433 -0
- package/src/policy/built-in-classifiers.js +78 -0
- package/src/policy/doctor.js +226 -0
- package/src/policy/engine.js +339 -0
- package/src/policy/init.js +153 -0
- package/src/policy/loader.js +448 -0
- package/src/policy/rules-default.js +36 -0
- package/src/policy/shell-path.js +135 -0
- package/src/policy/show.js +196 -0
- package/src/policy/validate.js +310 -0
- package/src/preflight/cli.js +164 -0
- package/src/preflight/miner.js +329 -0
- package/src/proxy/agent-router.js +93 -0
- package/src/redteam.js +428 -0
- package/src/replay.js +446 -0
- package/src/report/index.js +224 -0
- package/src/runtime.js +595 -0
- package/src/scanner/index.js +49 -0
- package/src/selftest.js +192 -0
- package/src/session.js +36 -0
package/src/lao.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* lao.js — LAO context optimization for the Occasio proxy.
|
|
5
|
+
*
|
|
6
|
+
* Before a request goes to Anthropic, optimizeContext() scores every file
|
|
7
|
+
* already loaded into the conversation (via Read / cat tool_results) against
|
|
8
|
+
* the current task using a Python TF-IDF scorer (lao_prep.py).
|
|
9
|
+
* Tool-results for low-relevance files are replaced with a one-line
|
|
10
|
+
* placeholder, keeping conversation structure intact while saving tokens.
|
|
11
|
+
*
|
|
12
|
+
* Only activates when total context exceeds MIN_TOK (default 20 000).
|
|
13
|
+
* Scorer output is cached for 30 s to avoid repeated subprocess spawning.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { spawn } = require('child_process');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const { estimateTokens } = require('./analyzer');
|
|
19
|
+
|
|
20
|
+
const LAO_PY = path.join(__dirname, 'lao_prep.py');
|
|
21
|
+
const MIN_TOK = 20_000;
|
|
22
|
+
const TRIM_FRAC = 0.10; // drop files scoring < 10 % of top score
|
|
23
|
+
const CACHE_TTL = 30_000;
|
|
24
|
+
const PLACEHOLDER = '[content trimmed by Occasio LAO — low relevance to current task]';
|
|
25
|
+
|
|
26
|
+
let _cache = { key: '', result: [], ts: 0 };
|
|
27
|
+
|
|
28
|
+
// ── Python subprocess ──────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function scorePaths(task, repoPath) {
|
|
31
|
+
const key = `${task.slice(0, 120)}|${repoPath}`;
|
|
32
|
+
if (key === _cache.key && Date.now() - _cache.ts < CACHE_TTL) {
|
|
33
|
+
return Promise.resolve(_cache.result);
|
|
34
|
+
}
|
|
35
|
+
return new Promise(resolve => {
|
|
36
|
+
const py = spawn('python', [LAO_PY, task.slice(0, 500), repoPath], {
|
|
37
|
+
windowsHide: true,
|
|
38
|
+
});
|
|
39
|
+
let out = '';
|
|
40
|
+
const timer = setTimeout(() => { py.kill(); resolve([]); }, 8_000);
|
|
41
|
+
py.stdout.on('data', d => { out += d.toString(); });
|
|
42
|
+
py.on('close', () => {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
try {
|
|
45
|
+
const result = JSON.parse(out);
|
|
46
|
+
_cache = { key, result, ts: Date.now() };
|
|
47
|
+
resolve(result);
|
|
48
|
+
} catch { resolve([]); }
|
|
49
|
+
});
|
|
50
|
+
py.on('error', () => { clearTimeout(timer); resolve([]); });
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
function contextTokens(messages) {
|
|
57
|
+
let total = 0;
|
|
58
|
+
for (const m of messages) {
|
|
59
|
+
if (typeof m.content === 'string') { total += estimateTokens(m.content); continue; }
|
|
60
|
+
if (!Array.isArray(m.content)) continue;
|
|
61
|
+
for (const b of m.content) {
|
|
62
|
+
const t = b.text ?? (typeof b.content === 'string' ? b.content : '') ?? '';
|
|
63
|
+
total += estimateTokens(t);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return total;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function extractTask(messages) {
|
|
70
|
+
return [...messages]
|
|
71
|
+
.reverse()
|
|
72
|
+
.filter(m => m.role === 'user')
|
|
73
|
+
.slice(0, 3)
|
|
74
|
+
.map(m =>
|
|
75
|
+
typeof m.content === 'string' ? m.content :
|
|
76
|
+
Array.isArray(m.content)
|
|
77
|
+
? m.content.filter(b => b.type === 'text').map(b => b.text || '').join(' ')
|
|
78
|
+
: ''
|
|
79
|
+
)
|
|
80
|
+
.join(' ');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build a map of tool_use_id → normalised file path from all assistant messages.
|
|
85
|
+
* Handles: Claude Code Read tool (file_path / path input), MCP read_file (path),
|
|
86
|
+
* and Bash cat/head/tail calls (command input).
|
|
87
|
+
*/
|
|
88
|
+
function buildToolIdPathMap(messages) {
|
|
89
|
+
const map = new Map();
|
|
90
|
+
for (const msg of messages) {
|
|
91
|
+
if (msg.role !== 'assistant') continue;
|
|
92
|
+
const content = Array.isArray(msg.content) ? msg.content : [];
|
|
93
|
+
for (const block of content) {
|
|
94
|
+
if (block.type !== 'tool_use' || !block.id) continue;
|
|
95
|
+
const inp = block.input || {};
|
|
96
|
+
// Direct file path fields
|
|
97
|
+
const fp = inp.file_path || inp.path || null;
|
|
98
|
+
if (fp) { map.set(block.id, fp.replace(/\\/g, '/')); continue; }
|
|
99
|
+
// Bash: cat/head/tail/bat/type <file>
|
|
100
|
+
if (block.name === 'Bash' && inp.command) {
|
|
101
|
+
const m = inp.command.match(
|
|
102
|
+
/^(?:cat|bat|head|tail|type|Get-Content)\s+(?:-\S+\s+)*(['"]?)(.+?)\1\s*$/i
|
|
103
|
+
);
|
|
104
|
+
if (m) map.set(block.id, m[2].trim().replace(/\\/g, '/'));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return map;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function toolResultText(block) {
|
|
112
|
+
if (typeof block.content === 'string') return block.content;
|
|
113
|
+
if (Array.isArray(block.content)) {
|
|
114
|
+
return block.content.map(b => (typeof b === 'string' ? b : b.text || '')).join('\n');
|
|
115
|
+
}
|
|
116
|
+
return '';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Main export ────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Trim low-relevance file tool_results from an Anthropic request body.
|
|
123
|
+
*
|
|
124
|
+
* Skips (returns unchanged) if:
|
|
125
|
+
* - total context < MIN_TOK tokens
|
|
126
|
+
* - Python scorer returns no results or scores all zero
|
|
127
|
+
* - no file path could be matched for a tool_result
|
|
128
|
+
*
|
|
129
|
+
* @param {object} reqBody Parsed request body (has .messages array)
|
|
130
|
+
* @param {string} cwd Repo root for the scorer
|
|
131
|
+
* @returns {Promise<{ messages: Array, tokensSaved: number, filesDropped: string[] }>}
|
|
132
|
+
*/
|
|
133
|
+
async function optimizeContext(reqBody, cwd) {
|
|
134
|
+
const messages = reqBody.messages || [];
|
|
135
|
+
const noChange = { messages, tokensSaved: 0, filesDropped: [] };
|
|
136
|
+
|
|
137
|
+
if (contextTokens(messages) < MIN_TOK) return noChange;
|
|
138
|
+
|
|
139
|
+
const task = extractTask(messages).trim();
|
|
140
|
+
if (!task) return noChange;
|
|
141
|
+
|
|
142
|
+
const scored = await scorePaths(task, cwd);
|
|
143
|
+
if (!scored.length) return noChange;
|
|
144
|
+
|
|
145
|
+
const maxScore = scored[0].score;
|
|
146
|
+
if (maxScore <= 0) return noChange;
|
|
147
|
+
|
|
148
|
+
const threshold = maxScore * TRIM_FRAC;
|
|
149
|
+
const relevance = new Map(scored.map(s => [s.path, s.score]));
|
|
150
|
+
const toolIdPath = buildToolIdPathMap(messages);
|
|
151
|
+
|
|
152
|
+
let tokensSaved = 0;
|
|
153
|
+
const filesDropped = [];
|
|
154
|
+
|
|
155
|
+
const trimmed = messages.map(msg => {
|
|
156
|
+
if (msg.role !== 'user' || !Array.isArray(msg.content)) return msg;
|
|
157
|
+
|
|
158
|
+
let changed = false;
|
|
159
|
+
const newContent = msg.content.map(block => {
|
|
160
|
+
if (block.type !== 'tool_result') return block;
|
|
161
|
+
|
|
162
|
+
const filePath = toolIdPath.get(block.tool_use_id);
|
|
163
|
+
if (!filePath) return block;
|
|
164
|
+
|
|
165
|
+
const rel = filePath.replace(/\\/g, '/');
|
|
166
|
+
const score = relevance.get(rel)
|
|
167
|
+
?? relevance.get(path.posix.basename(rel))
|
|
168
|
+
?? maxScore; // unknown → keep
|
|
169
|
+
|
|
170
|
+
if (score >= threshold) return block;
|
|
171
|
+
|
|
172
|
+
const text = toolResultText(block);
|
|
173
|
+
tokensSaved += estimateTokens(text);
|
|
174
|
+
filesDropped.push(path.basename(filePath));
|
|
175
|
+
changed = true;
|
|
176
|
+
return { ...block, content: PLACEHOLDER };
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return changed ? { ...msg, content: newContent } : msg;
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
return { messages: trimmed, tokensSaved, filesDropped };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = { optimizeContext, contextTokens, buildToolIdPathMap, extractTask };
|
package/src/lao_prep.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Occasio LAO context scorer.
|
|
4
|
+
Usage: python lao_prep.py <task> <repo_path>
|
|
5
|
+
Output: JSON array [{path, score, est_tokens}] sorted by score desc.
|
|
6
|
+
No external dependencies — stdlib only.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
IGNORE_DIRS: set[str] = {
|
|
17
|
+
".git", "__pycache__", "node_modules", ".venv", "venv",
|
|
18
|
+
"dist", "build", ".next", ".nuxt", "coverage", ".cache",
|
|
19
|
+
".mypy_cache", ".pytest_cache", ".ruff_cache", ".tox",
|
|
20
|
+
}
|
|
21
|
+
IGNORE_EXT: set[str] = {
|
|
22
|
+
".lock", ".log", ".map", ".min.js", ".min.css",
|
|
23
|
+
".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico",
|
|
24
|
+
".woff", ".woff2", ".ttf", ".eot",
|
|
25
|
+
".pdf", ".zip", ".tar", ".gz", ".bin", ".exe", ".dll",
|
|
26
|
+
".pyc", ".pyo", ".so", ".dylib",
|
|
27
|
+
}
|
|
28
|
+
MAX_FILE_BYTES = 100_000
|
|
29
|
+
MAX_FILES = 300
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def tokenize(text: str) -> list[str]:
|
|
33
|
+
# Identifiers >= 3 chars only: reduces noise from short tokens
|
|
34
|
+
return re.findall(r"[a-zA-Z_]\w{2,}", text.lower())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def scan_repo(repo_path: str) -> list[dict]:
|
|
38
|
+
root = Path(repo_path).resolve()
|
|
39
|
+
files: list[dict] = []
|
|
40
|
+
try:
|
|
41
|
+
for p in root.rglob("*"):
|
|
42
|
+
if not p.is_file():
|
|
43
|
+
continue
|
|
44
|
+
if any(part in IGNORE_DIRS for part in p.parts):
|
|
45
|
+
continue
|
|
46
|
+
if p.suffix.lower() in IGNORE_EXT:
|
|
47
|
+
continue
|
|
48
|
+
try:
|
|
49
|
+
size = p.stat().st_size
|
|
50
|
+
except OSError:
|
|
51
|
+
continue
|
|
52
|
+
if size > MAX_FILE_BYTES:
|
|
53
|
+
continue
|
|
54
|
+
try:
|
|
55
|
+
content = p.read_text(encoding="utf-8", errors="ignore")
|
|
56
|
+
except OSError:
|
|
57
|
+
continue
|
|
58
|
+
rel = str(p.relative_to(root)).replace("\\", "/")
|
|
59
|
+
path_tokens = set(tokenize(rel))
|
|
60
|
+
content_tokens = set(tokenize(content))
|
|
61
|
+
files.append({
|
|
62
|
+
"path": rel,
|
|
63
|
+
"token_set": content_tokens | path_tokens,
|
|
64
|
+
"path_tokens": path_tokens,
|
|
65
|
+
"est_tokens": max(1, size // 4),
|
|
66
|
+
})
|
|
67
|
+
if len(files) >= MAX_FILES:
|
|
68
|
+
break
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
return files
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def score_files(task: str, files: list[dict]) -> list[dict]:
|
|
75
|
+
task_tokens = tokenize(task)
|
|
76
|
+
if not task_tokens or not files:
|
|
77
|
+
return []
|
|
78
|
+
|
|
79
|
+
N = len(files)
|
|
80
|
+
idf: dict[str, float] = {}
|
|
81
|
+
for tok in set(task_tokens):
|
|
82
|
+
df = sum(1 for f in files if tok in f["token_set"])
|
|
83
|
+
idf[tok] = math.log((N + 1) / (df + 1)) + 1.0
|
|
84
|
+
|
|
85
|
+
results: list[dict] = []
|
|
86
|
+
for f in files:
|
|
87
|
+
ts = f["token_set"]
|
|
88
|
+
pt = f["path_tokens"]
|
|
89
|
+
score = 0.0
|
|
90
|
+
for tok in task_tokens:
|
|
91
|
+
w = idf.get(tok, 0.0)
|
|
92
|
+
if tok in pt:
|
|
93
|
+
score += w * 3.0 # path match: strong signal
|
|
94
|
+
elif tok in ts:
|
|
95
|
+
score += w * 1.0 # content match
|
|
96
|
+
score /= math.sqrt(max(len(ts), 1))
|
|
97
|
+
results.append({
|
|
98
|
+
"path": f["path"],
|
|
99
|
+
"score": round(score, 5),
|
|
100
|
+
"est_tokens": f["est_tokens"],
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
results.sort(key=lambda x: -x["score"])
|
|
104
|
+
return results
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main() -> None:
|
|
108
|
+
if len(sys.argv) < 3:
|
|
109
|
+
print("[]")
|
|
110
|
+
return
|
|
111
|
+
task = sys.argv[1]
|
|
112
|
+
repo_path = sys.argv[2]
|
|
113
|
+
files = scan_repo(repo_path)
|
|
114
|
+
results = score_files(task, files)
|
|
115
|
+
print(json.dumps(results))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
main()
|
package/src/ledger.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
const LOG_DIR = path.join(os.homedir(), '.occasio');
|
|
8
|
+
const SESSION_FILE = path.join(LOG_DIR, 'session.json');
|
|
9
|
+
|
|
10
|
+
const col = {
|
|
11
|
+
r: s => `\x1b[31m${s}\x1b[0m`, g: s => `\x1b[32m${s}\x1b[0m`,
|
|
12
|
+
y: s => `\x1b[33m${s}\x1b[0m`, c: s => `\x1b[36m${s}\x1b[0m`,
|
|
13
|
+
d: s => `\x1b[2m${s}\x1b[0m`, b: s => `\x1b[1m${s}\x1b[0m`,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function todayStr() {
|
|
17
|
+
const d = new Date();
|
|
18
|
+
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readDayLog(dateStr) {
|
|
22
|
+
const logFile = path.join(LOG_DIR, 'logs', `${dateStr}.jsonl`);
|
|
23
|
+
if (!fs.existsSync(logFile)) return [];
|
|
24
|
+
const lines = fs.readFileSync(logFile, 'utf8').split('\n');
|
|
25
|
+
const result = [];
|
|
26
|
+
for (const raw of lines) {
|
|
27
|
+
const line = raw.trim();
|
|
28
|
+
if (!line) continue;
|
|
29
|
+
try { result.push(JSON.parse(line)); } catch {}
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Filter entries to those at or after sessionStart (ISO string).
|
|
35
|
+
// Uses entry.iso when available; falls back to HH:MM:SS string comparison.
|
|
36
|
+
function readSessionEntries(entries, sessionStart) {
|
|
37
|
+
if (!sessionStart || !entries.length) return entries;
|
|
38
|
+
const startHms = new Date(sessionStart).toTimeString().slice(0, 8);
|
|
39
|
+
const filtered = entries.filter(e => {
|
|
40
|
+
if (e.iso) return e.iso >= sessionStart;
|
|
41
|
+
return (e.ts || '') >= startHms;
|
|
42
|
+
});
|
|
43
|
+
return filtered.length > 0 ? filtered : entries;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Aggregate totals from a list of log entries.
|
|
47
|
+
// Handles both new entries (event_type field) and old entries (intercepted flag).
|
|
48
|
+
function summarize(entries) {
|
|
49
|
+
const t = {
|
|
50
|
+
requests: 0, cloud_sent: 0, local_only: 0, blocked: 0, trimmed: 0,
|
|
51
|
+
budget_exceeded: 0,
|
|
52
|
+
input_tokens: 0, output_tokens: 0,
|
|
53
|
+
cache_read_tokens: 0, cache_write_tokens: 0,
|
|
54
|
+
cost: 0, cache_savings: 0, lao_cost_saved: 0, lao_tokens_saved: 0,
|
|
55
|
+
distill_cost_saved: 0, distill_tokens_saved: 0,
|
|
56
|
+
tools_local_count: 0,
|
|
57
|
+
};
|
|
58
|
+
for (const e of entries) {
|
|
59
|
+
t.requests++;
|
|
60
|
+
const et = e.event_type || (e.intercepted ? 'local_only' : 'cloud_sent');
|
|
61
|
+
if (et === 'cloud_sent') t.cloud_sent++;
|
|
62
|
+
else if (et === 'local_only') t.local_only++;
|
|
63
|
+
else if (et === 'blocked') t.blocked++;
|
|
64
|
+
else if (et === 'trimmed') t.trimmed++;
|
|
65
|
+
else if (et === 'budget_exceeded') t.budget_exceeded++;
|
|
66
|
+
t.input_tokens += e.input_tokens || 0;
|
|
67
|
+
t.output_tokens += e.output_tokens || 0;
|
|
68
|
+
t.cache_read_tokens += e.cache_read_tokens || 0;
|
|
69
|
+
t.cache_write_tokens += e.cache_write_tokens || 0;
|
|
70
|
+
t.cost += e.cost || 0;
|
|
71
|
+
t.cache_savings += e.cache_savings || 0;
|
|
72
|
+
t.lao_cost_saved += e.lao_cost_saved || 0;
|
|
73
|
+
t.lao_tokens_saved += e.lao_tokens_saved || 0;
|
|
74
|
+
t.distill_cost_saved += e.distill_cost_saved || 0;
|
|
75
|
+
t.distill_tokens_saved += e.distill_tokens_saved || 0;
|
|
76
|
+
t.tools_local_count += e.tools_local_count || 0;
|
|
77
|
+
}
|
|
78
|
+
return t;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function fmtN(n) {
|
|
82
|
+
if (n >= 1e6) return (n/1e6).toFixed(1)+'M';
|
|
83
|
+
if (n >= 1e3) return (n/1e3).toFixed(1)+'k';
|
|
84
|
+
return String(n || 0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function eventCol(et) {
|
|
88
|
+
switch (et) {
|
|
89
|
+
case 'cloud_sent': return col.c('cloud_sent ');
|
|
90
|
+
case 'local_only': return col.g('local_only ');
|
|
91
|
+
case 'blocked': return col.r('blocked ');
|
|
92
|
+
case 'trimmed': return col.y('trimmed ');
|
|
93
|
+
case 'budget_exceeded': return col.r('budget_exc ');
|
|
94
|
+
default: return col.d((et || 'unknown').padEnd(11));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveEventType(e) {
|
|
99
|
+
return e.event_type || (e.intercepted ? 'local_only' : 'cloud_sent');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function printEntry(e, idx) {
|
|
103
|
+
const ts = e.ts || '??:??:??';
|
|
104
|
+
const model = (e.model || '—').replace('claude-', '').replace(/-\d{8}$/, '');
|
|
105
|
+
const inp = fmtN(e.input_tokens || 0).padStart(7);
|
|
106
|
+
const out = fmtN(e.output_tokens || 0).padStart(5);
|
|
107
|
+
const cost = `$${(e.cost || 0).toFixed(4)}`;
|
|
108
|
+
const et = resolveEventType(e);
|
|
109
|
+
|
|
110
|
+
let extra = '';
|
|
111
|
+
if ((e.tools_local_count || 0) > 0) extra += col.d(` · ${e.tools_local_count} tools local`);
|
|
112
|
+
if ((e.lao_tokens_saved || 0) > 0) extra += col.d(` · ${fmtN(e.lao_tokens_saved)} trimmed`);
|
|
113
|
+
if (et === 'blocked' && e.secrets?.length > 0) extra += col.r(` · 🛑 ${e.secrets[0].label || 'secret'}`);
|
|
114
|
+
if (et !== 'blocked' && e.secrets?.length > 0) extra += col.y(` · ⚠ ${e.secrets[0].label || 'secret'}`);
|
|
115
|
+
|
|
116
|
+
const idxStr = idx !== undefined ? col.d(`${String(idx + 1).padStart(4)}. `) : ' ';
|
|
117
|
+
console.log(`${idxStr}${col.d(ts)} ${eventCol(et)} ${col.d(model.padEnd(16))} ${col.y(inp)} in /${col.y(out)} out ${col.g(cost)}${extra}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function printSummary(totals, scope, runId) {
|
|
121
|
+
const { requests, cloud_sent = 0, local_only = 0, blocked = 0, trimmed = 0,
|
|
122
|
+
budget_exceeded = 0,
|
|
123
|
+
input_tokens, output_tokens, cost,
|
|
124
|
+
cache_savings, lao_cost_saved, distill_cost_saved = 0,
|
|
125
|
+
distill_tokens_saved = 0, tools_local_count } = totals;
|
|
126
|
+
const saved = (cache_savings || 0) + (lao_cost_saved || 0) + distill_cost_saved;
|
|
127
|
+
|
|
128
|
+
console.log(col.b(`\n⚡ Occasio Ledger — Summary`));
|
|
129
|
+
if (runId) console.log(col.d(` run: ${runId}`));
|
|
130
|
+
console.log(col.d(` scope: ${scope}\n`));
|
|
131
|
+
|
|
132
|
+
console.log(` ${col.b('Requests'.padEnd(16))} ${requests}`);
|
|
133
|
+
console.log(` ${col.d(' cloud_sent'.padEnd(16))} ${cloud_sent}`);
|
|
134
|
+
if (trimmed) console.log(` ${col.d(' trimmed'.padEnd(16))} ${trimmed}`);
|
|
135
|
+
console.log(` ${col.d(' local_only'.padEnd(16))} ${local_only}`);
|
|
136
|
+
if (blocked) console.log(` ${col.d(' blocked'.padEnd(16))} ${blocked}`);
|
|
137
|
+
if (totals.budget_exceeded) console.log(` ${col.r(' budget_exc'.padEnd(16))} ${totals.budget_exceeded}`);
|
|
138
|
+
|
|
139
|
+
console.log(`\n ${col.b('Tokens in'.padEnd(16))} ${fmtN(input_tokens || 0)}`);
|
|
140
|
+
console.log(` ${col.b('Tokens out'.padEnd(16))} ${fmtN(output_tokens || 0)}`);
|
|
141
|
+
console.log(col.y(` ${'Cost'.padEnd(18)} $${(cost || 0).toFixed(4)}`));
|
|
142
|
+
|
|
143
|
+
if (saved > 0.00001) {
|
|
144
|
+
const parts = [];
|
|
145
|
+
if ((cache_savings || 0) > 0.00001) parts.push(`cache $${cache_savings.toFixed(4)}`);
|
|
146
|
+
if ((lao_cost_saved || 0) > 0.00001) parts.push(`LAO $${lao_cost_saved.toFixed(4)}`);
|
|
147
|
+
if (distill_cost_saved > 0.00001) parts.push(`distill $${distill_cost_saved.toFixed(4)}`);
|
|
148
|
+
console.log(col.g(` ${'Saved'.padEnd(18)} $${saved.toFixed(4)}`) + col.d(` (${parts.join(' + ')})`));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if ((tools_local_count || 0) > 0) console.log(col.g(` ${'Tools local'.padEnd(18)} ${tools_local_count}`));
|
|
152
|
+
if (distill_tokens_saved > 0) console.log(col.c(` ${'Distilled'.padEnd(18)} ${fmtN(distill_tokens_saved)} tokens saved across tool outputs`));
|
|
153
|
+
console.log('');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function runLedgerCli(args) {
|
|
157
|
+
let scope = 'session';
|
|
158
|
+
let limit = 10;
|
|
159
|
+
let showSummary = false;
|
|
160
|
+
|
|
161
|
+
const scopeIdx = args.indexOf('--scope');
|
|
162
|
+
if (scopeIdx >= 0) scope = args[scopeIdx + 1] || 'session';
|
|
163
|
+
|
|
164
|
+
const lastIdx = args.indexOf('--last');
|
|
165
|
+
if (lastIdx >= 0) limit = parseInt(args[lastIdx + 1], 10) || 10;
|
|
166
|
+
|
|
167
|
+
if (args.includes('--summary')) showSummary = true;
|
|
168
|
+
|
|
169
|
+
let session = null;
|
|
170
|
+
try { session = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')); } catch {}
|
|
171
|
+
|
|
172
|
+
const todayEntries = readDayLog(todayStr());
|
|
173
|
+
const entries = scope === 'session'
|
|
174
|
+
? readSessionEntries(todayEntries, session?.start)
|
|
175
|
+
: todayEntries;
|
|
176
|
+
|
|
177
|
+
const runId = session?.run_id || null;
|
|
178
|
+
|
|
179
|
+
if (showSummary) {
|
|
180
|
+
printSummary(summarize(entries), scope, runId);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// List view
|
|
185
|
+
console.log(col.b(`\n⚡ Occasio Ledger`));
|
|
186
|
+
let headerLine = '';
|
|
187
|
+
if (runId) headerLine += col.d(`run: ${runId}`);
|
|
188
|
+
if (session?.start) headerLine += col.d(`${runId ? ' · ' : ''}started ${new Date(session.start).toTimeString().slice(0, 8)}`);
|
|
189
|
+
if (headerLine) console.log(` ${headerLine}`);
|
|
190
|
+
console.log(col.d(` scope: ${scope} · ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} total\n`));
|
|
191
|
+
|
|
192
|
+
if (!entries.length) {
|
|
193
|
+
console.log(col.d(' No entries yet. Run: occasio claude\n'));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const slice = entries.slice(-limit);
|
|
198
|
+
const offset = entries.length - slice.length;
|
|
199
|
+
slice.forEach((e, i) => printEntry(e, offset + i));
|
|
200
|
+
console.log('');
|
|
201
|
+
|
|
202
|
+
if (entries.length > limit) {
|
|
203
|
+
console.log(col.d(` Showing last ${limit} of ${entries.length} · --last ${entries.length} for all · --summary for totals\n`));
|
|
204
|
+
} else {
|
|
205
|
+
console.log(col.d(` ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} · --summary for totals · --scope today for full day\n`));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = { readDayLog, readSessionEntries, summarize, runLedgerCli };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* mcp-experiment.js — Reads both log streams and reports MCP vs. built-in adoption.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node src/mcp-experiment.js — stats for today's session
|
|
8
|
+
* node src/mcp-experiment.js --clear — wipe mcp-experiment.jsonl and restart count
|
|
9
|
+
* node src/mcp-experiment.js --raw — dump raw MCP log entries
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
|
|
16
|
+
const col = {
|
|
17
|
+
r: s => `\x1b[31m${s}\x1b[0m`,
|
|
18
|
+
g: s => `\x1b[32m${s}\x1b[0m`,
|
|
19
|
+
y: s => `\x1b[33m${s}\x1b[0m`,
|
|
20
|
+
c: s => `\x1b[36m${s}\x1b[0m`,
|
|
21
|
+
d: s => `\x1b[2m${s}\x1b[0m`,
|
|
22
|
+
b: s => `\x1b[1m${s}\x1b[0m`,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const LOG_DIR = path.join(os.homedir(), '.occasio');
|
|
26
|
+
const MCP_LOG = path.join(LOG_DIR, 'mcp-experiment.jsonl');
|
|
27
|
+
const TODAY = new Date().toISOString().slice(0, 10);
|
|
28
|
+
const SESSION_LOG = path.join(LOG_DIR, 'logs', `${TODAY}.jsonl`);
|
|
29
|
+
|
|
30
|
+
function runStats() {
|
|
31
|
+
// ── MCP path: read mcp-experiment.jsonl ──────────────────────────────────────
|
|
32
|
+
let mcpEntries = [];
|
|
33
|
+
try {
|
|
34
|
+
mcpEntries = fs.readFileSync(MCP_LOG, 'utf8').trim().split('\n')
|
|
35
|
+
.filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
36
|
+
} catch {}
|
|
37
|
+
|
|
38
|
+
// ── Built-in path: read today's session log, count intercepted Read/Glob/Grep ─
|
|
39
|
+
let builtinTools = [];
|
|
40
|
+
try {
|
|
41
|
+
const lines = fs.readFileSync(SESSION_LOG, 'utf8').trim().split('\n').filter(Boolean);
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
try {
|
|
44
|
+
const entry = JSON.parse(line);
|
|
45
|
+
const tools = (entry.tools || []).filter(t => ['Read', 'Glob', 'Grep'].includes(t.tool));
|
|
46
|
+
builtinTools.push(...tools);
|
|
47
|
+
} catch {}
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
|
|
51
|
+
const mcpTotal = mcpEntries.length;
|
|
52
|
+
const builtinTotal = builtinTools.length;
|
|
53
|
+
const total = mcpTotal + builtinTotal;
|
|
54
|
+
const mcpPct = total > 0 ? (mcpTotal / total * 100).toFixed(1) : '—';
|
|
55
|
+
|
|
56
|
+
// Aggregate hardening metrics (added in Slice 2)
|
|
57
|
+
const mcpDistilled = mcpEntries.filter(e => e.distillSaved > 0).length;
|
|
58
|
+
const mcpDistillSaved = mcpEntries.reduce((s, e) => s + (e.distillSaved || 0), 0);
|
|
59
|
+
const mcpSecretsFound = mcpEntries.reduce((s, e) => s + (e.secretsFound || 0), 0);
|
|
60
|
+
|
|
61
|
+
console.log(col.b('\n⚡ Occasio MCP Experiment\n'));
|
|
62
|
+
|
|
63
|
+
if (total === 0) {
|
|
64
|
+
console.log(col.d(' No tool calls recorded yet. Run a Claude session first.\n'));
|
|
65
|
+
console.log(col.d(' Tip: ask Claude to read a file, search code, or list files.\n'));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
console.log(` MCP path (mcp__lf__*) ${col.g(String(mcpTotal).padStart(4))} calls`);
|
|
70
|
+
console.log(` Built-in (Read / Glob / Grep) ${col.c(String(builtinTotal).padStart(4))} calls`);
|
|
71
|
+
console.log(` Total ${String(total).padStart(4)} calls`);
|
|
72
|
+
console.log('');
|
|
73
|
+
console.log(` MCP adoption rate: ${col.b(mcpPct + '%')}`);
|
|
74
|
+
|
|
75
|
+
// MCP hardening metrics (distillation + secret scanning)
|
|
76
|
+
if (mcpTotal > 0) {
|
|
77
|
+
if (mcpDistilled > 0) {
|
|
78
|
+
console.log(col.d(` MCP distilled: ${mcpDistilled} calls, ${mcpDistillSaved} tokens saved`));
|
|
79
|
+
}
|
|
80
|
+
if (mcpSecretsFound > 0) {
|
|
81
|
+
console.log(col.r(` MCP secrets: ${mcpSecretsFound} credential pattern(s) detected`));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Breakdown by tool
|
|
86
|
+
if (mcpTotal > 0) {
|
|
87
|
+
console.log(col.d('\n MCP breakdown:'));
|
|
88
|
+
const byTool = {};
|
|
89
|
+
for (const e of mcpEntries) byTool[e.tool] = (byTool[e.tool] || 0) + 1;
|
|
90
|
+
for (const [t, n] of Object.entries(byTool)) {
|
|
91
|
+
console.log(col.d(` ${t.padEnd(14)} ${n}`));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (builtinTotal > 0) {
|
|
95
|
+
console.log(col.d('\n Built-in breakdown:'));
|
|
96
|
+
const byTool = {};
|
|
97
|
+
for (const e of builtinTools) byTool[e.tool] = (byTool[e.tool] || 0) + 1;
|
|
98
|
+
for (const [t, n] of Object.entries(byTool)) {
|
|
99
|
+
console.log(col.d(` ${t.padEnd(14)} ${n}`));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Verdict
|
|
104
|
+
console.log('');
|
|
105
|
+
const pct = parseFloat(mcpPct);
|
|
106
|
+
if (isNaN(pct)) {
|
|
107
|
+
console.log(col.d(' Not enough data.'));
|
|
108
|
+
} else if (pct >= 70) {
|
|
109
|
+
console.log(col.g(' ✓ MCP adoption is strong — MCP-primary architecture is viable.'));
|
|
110
|
+
} else if (pct >= 30) {
|
|
111
|
+
console.log(col.y(' ~ Mixed adoption — MCP usable as supplement, interceptor still needed.'));
|
|
112
|
+
} else {
|
|
113
|
+
console.log(col.r(' ✗ MCP adoption too low — interceptor must remain primary path.'));
|
|
114
|
+
}
|
|
115
|
+
console.log('');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function runClear() {
|
|
119
|
+
try { fs.unlinkSync(MCP_LOG); console.log(col.g('✓ mcp-experiment.jsonl cleared')); } catch {}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function runRaw() {
|
|
123
|
+
try {
|
|
124
|
+
const entries = fs.readFileSync(MCP_LOG, 'utf8').trim().split('\n').filter(Boolean);
|
|
125
|
+
if (entries.length === 0) { console.log('No MCP calls recorded.'); return; }
|
|
126
|
+
console.log(col.b(`\n${entries.length} MCP calls:\n`));
|
|
127
|
+
entries.forEach((l, i) => {
|
|
128
|
+
try {
|
|
129
|
+
const e = JSON.parse(l);
|
|
130
|
+
console.log(` [${i}] ${e.ts} ${col.c(e.tool)} ${col.d(JSON.stringify(e).slice(0, 120))}`);
|
|
131
|
+
} catch { console.log(` [${i}] ${l.slice(0, 120)}`); }
|
|
132
|
+
});
|
|
133
|
+
console.log('');
|
|
134
|
+
} catch { console.log('No MCP log found.'); }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const args = process.argv.slice(2);
|
|
138
|
+
if (args.includes('--clear')) runClear();
|
|
139
|
+
else if (args.includes('--raw')) runRaw();
|
|
140
|
+
else runStats();
|