@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/replay.js
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
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
|
+
|
|
9
|
+
const col = {
|
|
10
|
+
r: s => `\x1b[31m${s}\x1b[0m`, g: s => `\x1b[32m${s}\x1b[0m`,
|
|
11
|
+
y: s => `\x1b[33m${s}\x1b[0m`, c: s => `\x1b[36m${s}\x1b[0m`,
|
|
12
|
+
d: s => `\x1b[2m${s}\x1b[0m`, b: s => `\x1b[1m${s}\x1b[0m`,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function todayStr() {
|
|
16
|
+
const d = new Date();
|
|
17
|
+
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Group entries by run_id, sorted by iso within each run.
|
|
21
|
+
// Entries without a run_id are placed under the key 'legacy'.
|
|
22
|
+
function groupByRun(entries) {
|
|
23
|
+
const map = new Map();
|
|
24
|
+
for (const e of entries) {
|
|
25
|
+
const key = e.run_id || 'legacy';
|
|
26
|
+
if (!map.has(key)) map.set(key, []);
|
|
27
|
+
map.get(key).push(e);
|
|
28
|
+
}
|
|
29
|
+
for (const [, arr] of map) {
|
|
30
|
+
arr.sort((a, b) => {
|
|
31
|
+
const ai = a.iso || a.ts || '';
|
|
32
|
+
const bi = b.iso || b.ts || '';
|
|
33
|
+
return ai < bi ? -1 : ai > bi ? 1 : 0;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return map;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Aggregate per-run stats from a sorted array of entries.
|
|
40
|
+
function buildRunStats(runEntries) {
|
|
41
|
+
const zero = {
|
|
42
|
+
count: 0, cloud_sent: 0, local_only: 0, blocked: 0, trimmed: 0, budget_exceeded: 0,
|
|
43
|
+
input_tokens: 0, output_tokens: 0, cost: 0,
|
|
44
|
+
cache_savings: 0, lao_cost_saved: 0, lao_tokens_saved: 0,
|
|
45
|
+
distill_cost_saved: 0, distill_tokens_saved: 0,
|
|
46
|
+
tools_local_count: 0,
|
|
47
|
+
start: null, end: null, durationMs: 0,
|
|
48
|
+
};
|
|
49
|
+
if (!runEntries || !runEntries.length) return zero;
|
|
50
|
+
|
|
51
|
+
const stats = { ...zero };
|
|
52
|
+
for (const e of runEntries) {
|
|
53
|
+
stats.count++;
|
|
54
|
+
const et = e.event_type || (e.intercepted ? 'local_only' : 'cloud_sent');
|
|
55
|
+
if (et === 'cloud_sent') stats.cloud_sent++;
|
|
56
|
+
else if (et === 'local_only') stats.local_only++;
|
|
57
|
+
else if (et === 'blocked') stats.blocked++;
|
|
58
|
+
else if (et === 'trimmed') stats.trimmed++;
|
|
59
|
+
else if (et === 'budget_exceeded') stats.budget_exceeded++;
|
|
60
|
+
|
|
61
|
+
stats.input_tokens += e.input_tokens || 0;
|
|
62
|
+
stats.output_tokens += e.output_tokens || 0;
|
|
63
|
+
stats.cost += e.cost || 0;
|
|
64
|
+
stats.cache_savings += e.cache_savings || 0;
|
|
65
|
+
stats.lao_cost_saved += e.lao_cost_saved || 0;
|
|
66
|
+
stats.lao_tokens_saved += e.lao_tokens_saved || 0;
|
|
67
|
+
stats.distill_cost_saved += e.distill_cost_saved || 0;
|
|
68
|
+
stats.distill_tokens_saved += e.distill_tokens_saved || 0;
|
|
69
|
+
stats.tools_local_count += e.tools_local_count || 0;
|
|
70
|
+
|
|
71
|
+
const ts = e.iso || null;
|
|
72
|
+
if (ts) {
|
|
73
|
+
if (!stats.start || ts < stats.start) stats.start = ts;
|
|
74
|
+
if (!stats.end || ts > stats.end) stats.end = ts;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (stats.start && stats.end) {
|
|
78
|
+
stats.durationMs = new Date(stats.end) - new Date(stats.start);
|
|
79
|
+
}
|
|
80
|
+
return stats;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function fmtN(n) {
|
|
84
|
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
|
|
85
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
|
|
86
|
+
return String(n || 0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function fmtDuration(ms) {
|
|
90
|
+
if (!ms || ms < 1000) return '<1s';
|
|
91
|
+
const totalSecs = Math.floor(ms / 1000);
|
|
92
|
+
const m = Math.floor(totalSecs / 60);
|
|
93
|
+
const h = Math.floor(m / 60);
|
|
94
|
+
if (h > 0) return `${h}h ${m % 60}m`;
|
|
95
|
+
if (m > 0) return `${m}m ${totalSecs % 60}s`;
|
|
96
|
+
return `${totalSecs}s`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function shortId(runId) {
|
|
100
|
+
if (!runId || runId === 'legacy') return runId || 'legacy';
|
|
101
|
+
return runId.slice(0, 8) + '…';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function eventTypeLabel(et) {
|
|
105
|
+
switch (et) {
|
|
106
|
+
case 'cloud_sent': return col.c('cloud_sent ');
|
|
107
|
+
case 'local_only': return col.g('local_only ');
|
|
108
|
+
case 'blocked': return col.r('blocked ');
|
|
109
|
+
case 'trimmed': return col.y('trimmed ');
|
|
110
|
+
case 'budget_exceeded': return col.r('budget_exc ');
|
|
111
|
+
default: return col.d(((et || 'unknown')).padEnd(11));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function resolveEventType(e) {
|
|
116
|
+
return e.event_type || (e.intercepted ? 'local_only' : 'cloud_sent');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function printRunHeader(runId, stats) {
|
|
120
|
+
const id = shortId(runId);
|
|
121
|
+
const start = stats.start ? new Date(stats.start).toTimeString().slice(0, 8) : '?';
|
|
122
|
+
const end = stats.end ? new Date(stats.end).toTimeString().slice(0, 8) : '?';
|
|
123
|
+
const dur = fmtDuration(stats.durationMs);
|
|
124
|
+
|
|
125
|
+
const evParts = [];
|
|
126
|
+
if (stats.cloud_sent) evParts.push(col.c(`${stats.cloud_sent} cloud`));
|
|
127
|
+
if (stats.local_only) evParts.push(col.g(`${stats.local_only} local`));
|
|
128
|
+
if (stats.trimmed) evParts.push(col.y(`${stats.trimmed} trimmed`));
|
|
129
|
+
if (stats.blocked) evParts.push(col.r(`${stats.blocked} blocked`));
|
|
130
|
+
if (stats.budget_exceeded) evParts.push(col.r(`${stats.budget_exceeded} budget_exc`));
|
|
131
|
+
|
|
132
|
+
const saved = (stats.cache_savings || 0) + (stats.lao_cost_saved || 0) + (stats.distill_cost_saved || 0);
|
|
133
|
+
const costStr = col.y(`$${stats.cost.toFixed(4)}`);
|
|
134
|
+
const savedStr = saved > 0.00001 ? col.g(` saved $${saved.toFixed(4)}`) : '';
|
|
135
|
+
const tokenStr = col.d(`${fmtN(stats.input_tokens)} in / ${fmtN(stats.output_tokens)} out`);
|
|
136
|
+
|
|
137
|
+
console.log(`\n ${col.b('──')} Run ${col.b(id)} ${col.d(`${start} → ${end} (${dur})`)} ${col.b('──')}`);
|
|
138
|
+
console.log(` ${stats.count} event${stats.count === 1 ? '' : 's'}: ${evParts.join(col.d(' \xb7 '))} ${col.d('\xb7')} ${tokenStr}`);
|
|
139
|
+
console.log(` ${costStr}${savedStr}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function printRunDetail(runId, entries) {
|
|
143
|
+
const stats = buildRunStats(entries);
|
|
144
|
+
const id = shortId(runId);
|
|
145
|
+
const start = stats.start ? new Date(stats.start).toISOString() : '?';
|
|
146
|
+
const end = stats.end ? new Date(stats.end).toISOString() : '?';
|
|
147
|
+
const dur = fmtDuration(stats.durationMs);
|
|
148
|
+
|
|
149
|
+
console.log(col.b(`\n⚡ Occasio Replay — Run ${id}`));
|
|
150
|
+
if (runId && runId !== 'legacy') console.log(col.d(` run_id: ${runId}`));
|
|
151
|
+
console.log(col.d(` ${start} → ${end} (${dur})\n`));
|
|
152
|
+
|
|
153
|
+
entries.forEach((e, i) => {
|
|
154
|
+
const ts = e.ts || (e.iso ? new Date(e.iso).toTimeString().slice(0, 8) : '??:??:??');
|
|
155
|
+
const et = resolveEventType(e);
|
|
156
|
+
const model = (e.model || '—').replace('claude-', '').replace(/-\d{8}$/, '');
|
|
157
|
+
const inp = fmtN(e.input_tokens || 0).padStart(7);
|
|
158
|
+
const out = fmtN(e.output_tokens || 0).padStart(5);
|
|
159
|
+
const cost = `$${(e.cost || 0).toFixed(4)}`;
|
|
160
|
+
const idxStr = col.d(`${String(i + 1).padStart(4)}. `);
|
|
161
|
+
|
|
162
|
+
let extra = '';
|
|
163
|
+
if ((e.tools_local_count || 0) > 0) {
|
|
164
|
+
const names = (e.tools || []).map(t => t.tool).filter(Boolean).slice(0, 3).join(', ');
|
|
165
|
+
extra += col.d(` \xb7 ${e.tools_local_count} tools local${names ? ` (${names})` : ''}`);
|
|
166
|
+
}
|
|
167
|
+
if ((e.distill_tokens_saved || 0) > 0) extra += col.y(` \xb7 ✂ ${fmtN(e.distill_tokens_saved)} distilled`);
|
|
168
|
+
if ((e.lao_tokens_saved || 0) > 0) extra += col.c(` \xb7 ✂ LAO ${fmtN(e.lao_tokens_saved)}`);
|
|
169
|
+
if (et === 'blocked' && (e.secrets || []).length > 0) extra += col.r(` \xb7 🛑 ${e.secrets[0].label || 'secret'}`);
|
|
170
|
+
if (et !== 'blocked' && (e.secrets || []).length > 0) extra += col.y(` \xb7 ⚠ ${e.secrets[0].label || 'secret'}`);
|
|
171
|
+
if ((e.blocked || []).length > 0) extra += col.r(` \xb7 rule-blocked`);
|
|
172
|
+
if (et === 'budget_exceeded') extra += col.r(` \xb7 🚫 $${(e.budget_spent || 0).toFixed(4)} of $${(e.budget_limit || 0).toFixed(4)}`);
|
|
173
|
+
|
|
174
|
+
console.log(`${idxStr}${col.d(ts)} ${eventTypeLabel(et)} ${col.d(model.padEnd(16))} ${col.y(inp)} in /${col.y(out)} out ${col.g(cost)}${extra}`);
|
|
175
|
+
|
|
176
|
+
// Boundary sub-line: one indented line clarifying what crossed the cloud boundary
|
|
177
|
+
const pad = ' ';
|
|
178
|
+
if (et === 'local_only' && (e.tools || []).length > 0) {
|
|
179
|
+
const cmds = e.tools.slice(0, 3).map(t => (t.cmd || '?').split(/\s+/)[0]).join(', ');
|
|
180
|
+
const more = e.tools.length > 3 ? ` +${e.tools.length - 3}` : '';
|
|
181
|
+
console.log(col.d(`${pad}⚡ local: ${cmds}${more} → results forwarded to Anthropic`));
|
|
182
|
+
} else if (et === 'trimmed' && (e.lao_dropped || []).length > 0) {
|
|
183
|
+
const dropped = e.lao_dropped.slice(0, 3).map(f => f.split(/[/\\]/).pop()).join(', ');
|
|
184
|
+
const more = e.lao_dropped.length > 3 ? ` +${e.lao_dropped.length - 3}` : '';
|
|
185
|
+
console.log(col.y(`${pad}✂ LAO removed: ${dropped}${more}`));
|
|
186
|
+
} else if ((et === 'cloud_sent' || et === 'trimmed') && (e.file_tokens || []).length > 0) {
|
|
187
|
+
const fnames = e.file_tokens.slice(0, 3).map(f => f.name.split(/[/\\]/).pop()).join(', ');
|
|
188
|
+
const moreF = e.file_tokens.length > 3 ? ` +${e.file_tokens.length - 3}` : '';
|
|
189
|
+
console.log(col.d(`${pad}→ files in context: ${fnames}${moreF}`));
|
|
190
|
+
} else if (et === 'blocked') {
|
|
191
|
+
const sec = (e.secrets || []).map(s => s.label || 'secret').slice(0, 2).join(', ');
|
|
192
|
+
if (sec) console.log(col.r(`${pad}🛑 blocked: ${sec}`));
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const saved = (stats.cache_savings || 0) + (stats.lao_cost_saved || 0) + (stats.distill_cost_saved || 0);
|
|
197
|
+
const savedStr = saved > 0.00001 ? col.g(` \xb7 saved $${saved.toFixed(4)}`) : '';
|
|
198
|
+
const localStr = stats.local_only ? col.g(` \xb7 ${stats.local_only} local`) : '';
|
|
199
|
+
const blockedStr = stats.blocked ? col.r(` \xb7 ${stats.blocked} blocked`) : '';
|
|
200
|
+
const trimmedStr = stats.trimmed ? col.y(` \xb7 ${stats.trimmed} trimmed`) : '';
|
|
201
|
+
console.log(col.d(`\n Total: `) + col.y(`$${stats.cost.toFixed(4)}`) + savedStr + localStr + trimmedStr + blockedStr + '\n');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Token attribution ─────────────────────────────────────────────────────────
|
|
205
|
+
//
|
|
206
|
+
// "Where did this run's context tokens come from?"
|
|
207
|
+
//
|
|
208
|
+
// Walks a run's JSONL entries and reconstructs a per-source breakdown of the
|
|
209
|
+
// total input_tokens reported by Anthropic. Three classes:
|
|
210
|
+
//
|
|
211
|
+
// tool_contributions — Σ kept_bytes / 4 per canonical tool category. This
|
|
212
|
+
// approximates how many tokens of each tool's output
|
|
213
|
+
// first entered the model's context. APPROXIMATE
|
|
214
|
+
// (chars/4) and marked with '~'.
|
|
215
|
+
// cache_reuse — Σ cache_read_tokens. EXACT (Anthropic-reported).
|
|
216
|
+
// residual — Σ input_tokens − Σ tool_kept_tokens. Includes
|
|
217
|
+
// system prompt, user messages, and cross-request
|
|
218
|
+
// tool_result carry-over. We do NOT inspect request
|
|
219
|
+
// bodies; carry-over is folded into residual rather
|
|
220
|
+
// than mis-attributed to tools.
|
|
221
|
+
//
|
|
222
|
+
// "Prevented from re-entering" is summed exactly from the per-request log
|
|
223
|
+
// fields (distill_tokens_saved, lao_tokens_saved, plus a derived
|
|
224
|
+
// context_budget_saved from tools[].kept_bytes vs bytes when the prevention
|
|
225
|
+
// reason is 'context_budget').
|
|
226
|
+
//
|
|
227
|
+
// The function returns a plain JS object; renderers and CLI live below.
|
|
228
|
+
|
|
229
|
+
const TOOL_CATEGORY_MAP = {
|
|
230
|
+
Read: 'read_file', read_file: 'read_file',
|
|
231
|
+
Glob: 'find_files', find_files: 'find_files',
|
|
232
|
+
Grep: 'grep', grep: 'grep',
|
|
233
|
+
Bash: 'shell_bash', shell_bash: 'shell_bash',
|
|
234
|
+
PowerShell: 'shell_powershell', shell_powershell: 'shell_powershell',
|
|
235
|
+
TodoWrite: 'todo_write', todo_write: 'todo_write',
|
|
236
|
+
TodoRead: 'todo_read', todo_read: 'todo_read',
|
|
237
|
+
};
|
|
238
|
+
function categoryOf(toolName) {
|
|
239
|
+
return TOOL_CATEGORY_MAP[toolName] || (toolName ? String(toolName).toLowerCase() : 'unknown');
|
|
240
|
+
}
|
|
241
|
+
function tokensFromBytes(b) {
|
|
242
|
+
return Math.ceil((b || 0) / 4);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function attributeRun(runEntries) {
|
|
246
|
+
const base = {
|
|
247
|
+
request_count: 0,
|
|
248
|
+
model: null,
|
|
249
|
+
input_tokens: 0,
|
|
250
|
+
output_tokens: 0,
|
|
251
|
+
cache_read_tokens: 0,
|
|
252
|
+
cache_write_tokens: 0,
|
|
253
|
+
tool_contributions: {}, // category → tokens
|
|
254
|
+
tool_kept_total: 0,
|
|
255
|
+
prevented: {
|
|
256
|
+
distill_clip: 0,
|
|
257
|
+
redact_secrets: 0,
|
|
258
|
+
context_budget: 0,
|
|
259
|
+
lao_drop: 0,
|
|
260
|
+
},
|
|
261
|
+
prevented_total: 0,
|
|
262
|
+
residual_tokens: 0,
|
|
263
|
+
counterfactual_input_tokens: 0,
|
|
264
|
+
approximations: ['tool_contributions (chars/4)', 'residual (input_tokens − tool_kept)'],
|
|
265
|
+
};
|
|
266
|
+
if (!Array.isArray(runEntries) || runEntries.length === 0) return base;
|
|
267
|
+
|
|
268
|
+
for (const e of runEntries) {
|
|
269
|
+
base.request_count++;
|
|
270
|
+
base.model = base.model || e.model || null;
|
|
271
|
+
base.input_tokens += (e.input_tokens || 0);
|
|
272
|
+
base.output_tokens += (e.output_tokens || 0);
|
|
273
|
+
base.cache_read_tokens += (e.cache_read_tokens || 0);
|
|
274
|
+
base.cache_write_tokens += (e.cache_write_tokens || 0);
|
|
275
|
+
base.prevented.distill_clip += (e.distill_tokens_saved || 0);
|
|
276
|
+
base.prevented.lao_drop += (e.lao_tokens_saved || 0);
|
|
277
|
+
|
|
278
|
+
if (!Array.isArray(e.tools)) continue;
|
|
279
|
+
for (const t of e.tools) {
|
|
280
|
+
const cat = categoryOf(t.tool);
|
|
281
|
+
const kept = typeof t.kept_bytes === 'number' ? t.kept_bytes : (t.bytes || 0);
|
|
282
|
+
const keptTokens = tokensFromBytes(kept);
|
|
283
|
+
base.tool_contributions[cat] = (base.tool_contributions[cat] || 0) + keptTokens;
|
|
284
|
+
base.tool_kept_total += keptTokens;
|
|
285
|
+
// Context-budget savings derived from raw - kept on tools where the
|
|
286
|
+
// reason recorded by interceptor.js was 'context_budget'. The distill
|
|
287
|
+
// and redact paths already populate distill_tokens_saved /
|
|
288
|
+
// secretsRedacted-bytes elsewhere; only the budget path lacks a
|
|
289
|
+
// dedicated saved-tokens field.
|
|
290
|
+
if (t.prevention_reason === 'context_budget' && typeof t.bytes === 'number' && typeof t.kept_bytes === 'number') {
|
|
291
|
+
const saved = tokensFromBytes(Math.max(0, t.bytes - t.kept_bytes));
|
|
292
|
+
base.prevented.context_budget += saved;
|
|
293
|
+
}
|
|
294
|
+
if (typeof t.secretsRedacted === 'number' && t.secretsRedacted > 0 &&
|
|
295
|
+
typeof t.bytes === 'number' && typeof t.kept_bytes === 'number') {
|
|
296
|
+
// Approximate: bytes redacted ≈ delta. For full-redaction this is the
|
|
297
|
+
// honest figure; for partial-redaction it slightly overstates.
|
|
298
|
+
const saved = tokensFromBytes(Math.max(0, t.bytes - t.kept_bytes));
|
|
299
|
+
base.prevented.redact_secrets += saved;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
base.prevented_total = base.prevented.distill_clip
|
|
305
|
+
+ base.prevented.redact_secrets
|
|
306
|
+
+ base.prevented.context_budget
|
|
307
|
+
+ base.prevented.lao_drop;
|
|
308
|
+
base.residual_tokens = Math.max(0, base.input_tokens - base.tool_kept_total);
|
|
309
|
+
base.counterfactual_input_tokens = base.input_tokens + base.prevented_total;
|
|
310
|
+
return base;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function fmtAttribution(attr, runId) {
|
|
314
|
+
if (!attr || attr.request_count === 0) {
|
|
315
|
+
return '\n (no entries in this run)\n';
|
|
316
|
+
}
|
|
317
|
+
const lines = [];
|
|
318
|
+
const C = col;
|
|
319
|
+
const bar = (n, max, width = 24) => {
|
|
320
|
+
if (max <= 0) return '';
|
|
321
|
+
const cells = Math.min(width, Math.round((n / max) * width));
|
|
322
|
+
return '█'.repeat(cells);
|
|
323
|
+
};
|
|
324
|
+
const maxComponent = Math.max(
|
|
325
|
+
attr.tool_kept_total,
|
|
326
|
+
attr.cache_read_tokens,
|
|
327
|
+
attr.residual_tokens,
|
|
328
|
+
1,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
lines.push('');
|
|
332
|
+
lines.push(`${C.b('Run')} ${C.d(shortId(runId || ''))}${attr.model ? ' ' + C.d(attr.model) : ''} ${C.d(`(${attr.request_count} requests)`)}`);
|
|
333
|
+
lines.push('');
|
|
334
|
+
lines.push(` ${C.b('Tokens reaching the model (Σ across run):')}`);
|
|
335
|
+
const toolsSorted = Object.entries(attr.tool_contributions).sort((a, b) => b[1] - a[1]);
|
|
336
|
+
if (toolsSorted.length === 0) {
|
|
337
|
+
lines.push(` ${C.d('(no tool calls in this run)')}`);
|
|
338
|
+
} else {
|
|
339
|
+
lines.push(` ${C.b('Tool contributions')} ${C.y(`~${fmtN(attr.tool_kept_total)}t`)} ${C.d('(kept_bytes / 4)')}`);
|
|
340
|
+
for (const [cat, tok] of toolsSorted) {
|
|
341
|
+
lines.push(` ${cat.padEnd(20)} ${('~' + fmtN(tok) + 't').padStart(8)} ${C.c(bar(tok, maxComponent))}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
lines.push(` ${C.b('Cache reuse')} ${C.g(fmtN(attr.cache_read_tokens) + 't')} ${C.c(bar(attr.cache_read_tokens, maxComponent))} ${C.d('(Anthropic prompt cache, exact)')}`);
|
|
345
|
+
lines.push(` ${C.b('Residual')} ${fmtN(attr.residual_tokens) + 't'.padEnd(7)} ${C.c(bar(attr.residual_tokens, maxComponent))} ${C.d('(system + user + carry-over)')}`);
|
|
346
|
+
lines.push(` ${C.d('─────')}`);
|
|
347
|
+
lines.push(` ${C.b('Σ input_tokens')} ${C.b(fmtN(attr.input_tokens) + 't')} ${C.d('(Anthropic-reported, exact)')}`);
|
|
348
|
+
lines.push('');
|
|
349
|
+
lines.push(` ${C.b('Prevented from re-entering:')}`);
|
|
350
|
+
const p = attr.prevented;
|
|
351
|
+
lines.push(` distill_clip ${('~' + fmtN(p.distill_clip) + 't').padStart(8)}`);
|
|
352
|
+
lines.push(` redact_secrets ${('~' + fmtN(p.redact_secrets) + 't').padStart(8)}`);
|
|
353
|
+
lines.push(` context_budget ${('~' + fmtN(p.context_budget) + 't').padStart(8)}`);
|
|
354
|
+
lines.push(` lao_drop ${('~' + fmtN(p.lao_drop) + 't').padStart(8)}`);
|
|
355
|
+
lines.push(` ${C.d('─────')}`);
|
|
356
|
+
lines.push(` ${C.b('Total prevented')} ${C.r('~' + fmtN(attr.prevented_total) + 't')}`);
|
|
357
|
+
lines.push('');
|
|
358
|
+
const pct = attr.counterfactual_input_tokens > 0
|
|
359
|
+
? Math.round((attr.prevented_total / attr.counterfactual_input_tokens) * 100)
|
|
360
|
+
: 0;
|
|
361
|
+
lines.push(` ${C.b('Counterfactual:')} would have been ~${fmtN(attr.counterfactual_input_tokens)}t ${C.d(`(~${pct}% prevented)`)}`);
|
|
362
|
+
lines.push('');
|
|
363
|
+
lines.push(C.d(' Token figures with ~ are approximate (chars/4). Σ input_tokens and cache values are exact.'));
|
|
364
|
+
return lines.join('\n');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function runReplayCli(args) {
|
|
368
|
+
let limit = 3;
|
|
369
|
+
let detail = false;
|
|
370
|
+
let runFilter = null;
|
|
371
|
+
const attribute = args.includes('--attribute');
|
|
372
|
+
|
|
373
|
+
const lastIdx = args.indexOf('--last');
|
|
374
|
+
if (lastIdx >= 0) limit = parseInt(args[lastIdx + 1], 10) || 3;
|
|
375
|
+
|
|
376
|
+
if (args.includes('--detail')) detail = true;
|
|
377
|
+
|
|
378
|
+
const runIdx = args.indexOf('--run');
|
|
379
|
+
if (runIdx >= 0) { runFilter = args[runIdx + 1] || null; detail = true; }
|
|
380
|
+
|
|
381
|
+
const today = todayStr();
|
|
382
|
+
const logFile = path.join(LOG_DIR, 'logs', `${today}.jsonl`);
|
|
383
|
+
const entries = [];
|
|
384
|
+
if (fs.existsSync(logFile)) {
|
|
385
|
+
const lines = fs.readFileSync(logFile, 'utf8').split('\n');
|
|
386
|
+
for (const raw of lines) {
|
|
387
|
+
const line = raw.trim();
|
|
388
|
+
if (!line) continue;
|
|
389
|
+
try { entries.push(JSON.parse(line)); } catch {}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const runsMap = groupByRun(entries);
|
|
394
|
+
|
|
395
|
+
if (runFilter) {
|
|
396
|
+
let matchedKey = null;
|
|
397
|
+
for (const key of runsMap.keys()) {
|
|
398
|
+
if (key.startsWith(runFilter) || key === runFilter) { matchedKey = key; break; }
|
|
399
|
+
}
|
|
400
|
+
if (!matchedKey) {
|
|
401
|
+
console.log(col.d(`\n No run found matching '${runFilter}' in today's log.\n`));
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (attribute) {
|
|
405
|
+
console.log(col.b(`\n⚡ Occasio Replay — Token Attribution`));
|
|
406
|
+
console.log(fmtAttribution(attributeRun(runsMap.get(matchedKey)), matchedKey));
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
printRunDetail(matchedKey, runsMap.get(matchedKey));
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Order runs by earliest entry iso, show last N
|
|
414
|
+
const runList = Array.from(runsMap.entries())
|
|
415
|
+
.map(([runId, runEntries]) => ({ runId, runEntries, start: runEntries[0]?.iso || runEntries[0]?.ts || '' }))
|
|
416
|
+
.sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : 0));
|
|
417
|
+
|
|
418
|
+
const sliced = runList.slice(-limit);
|
|
419
|
+
const total = runList.length;
|
|
420
|
+
|
|
421
|
+
console.log(col.b(`\n⚡ Occasio Replay`));
|
|
422
|
+
console.log(col.d(` ${today} \xb7 ${total} run${total === 1 ? '' : 's'} today\n`));
|
|
423
|
+
|
|
424
|
+
if (!sliced.length) {
|
|
425
|
+
console.log(col.d(' No runs yet. Run: occasio claude\n'));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
for (const { runId, runEntries } of sliced) {
|
|
430
|
+
if (attribute) {
|
|
431
|
+
console.log(fmtAttribution(attributeRun(runEntries), runId));
|
|
432
|
+
} else if (detail) {
|
|
433
|
+
printRunDetail(runId, runEntries);
|
|
434
|
+
} else {
|
|
435
|
+
printRunHeader(runId, buildRunStats(runEntries));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (total > limit) {
|
|
440
|
+
console.log(col.d(`\n Showing ${sliced.length} of ${total} runs \xb7 --last ${total} for all \xb7 --detail for event breakdown\n`));
|
|
441
|
+
} else {
|
|
442
|
+
console.log(col.d(`\n ${total} run${total === 1 ? '' : 's'} \xb7 --detail for event breakdown \xb7 --run <id> for a specific run\n`));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
module.exports = { groupByRun, buildRunStats, attributeRun, fmtAttribution, runReplayCli };
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* occasio report — structured governance export (ARCH-27).
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* occasio report [--format json|csv] [--days N]
|
|
8
|
+
*
|
|
9
|
+
* Reads:
|
|
10
|
+
* ~/.occasio/pipeline-events.jsonl — per-tool audit events (tool access, blocks)
|
|
11
|
+
* ~/.occasio/logs/YYYY-MM-DD.jsonl — per-request cost/token summary
|
|
12
|
+
*
|
|
13
|
+
* Outputs a structured document answering:
|
|
14
|
+
* "What data did the AI agent access, what was blocked, and did any secrets appear?"
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const { verifyFile } = require('../audit/verifier');
|
|
21
|
+
|
|
22
|
+
const LOG_DIR = path.join(os.homedir(), '.occasio');
|
|
23
|
+
const EVENTS_FILE = path.join(LOG_DIR, 'pipeline-events.jsonl');
|
|
24
|
+
const LOGS_DIR = path.join(LOG_DIR, 'logs');
|
|
25
|
+
|
|
26
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
function todayStr(d) {
|
|
29
|
+
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function cutoffMs(days) {
|
|
33
|
+
const d = new Date();
|
|
34
|
+
d.setDate(d.getDate() - days);
|
|
35
|
+
d.setHours(0, 0, 0, 0);
|
|
36
|
+
return d.getTime();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readJsonlFile(filePath) {
|
|
40
|
+
try {
|
|
41
|
+
return fs.readFileSync(filePath, 'utf8')
|
|
42
|
+
.split('\n')
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
.map(line => { try { return JSON.parse(line); } catch { return null; } })
|
|
45
|
+
.filter(Boolean);
|
|
46
|
+
} catch { return []; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Read all daily JSONL logs within the period. */
|
|
50
|
+
function readDailyLogs(days) {
|
|
51
|
+
const cutoff = cutoffMs(days);
|
|
52
|
+
const entries = [];
|
|
53
|
+
try {
|
|
54
|
+
const files = fs.readdirSync(LOGS_DIR)
|
|
55
|
+
.filter(f => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f))
|
|
56
|
+
.filter(f => {
|
|
57
|
+
const [y, m, d2] = f.replace('.jsonl', '').split('-').map(Number);
|
|
58
|
+
return new Date(y, m - 1, d2).getTime() >= cutoff - 86400000;
|
|
59
|
+
});
|
|
60
|
+
for (const f of files) {
|
|
61
|
+
entries.push(...readJsonlFile(path.join(LOGS_DIR, f)));
|
|
62
|
+
}
|
|
63
|
+
} catch { /* logs dir absent */ }
|
|
64
|
+
return entries;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Filter pipeline events to those within the period. */
|
|
68
|
+
function filterEvents(events, days) {
|
|
69
|
+
const cutoff = cutoffMs(days);
|
|
70
|
+
return events.filter(e => e.ts && new Date(e.ts).getTime() >= cutoff);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Report builder ────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
function buildReport(days) {
|
|
76
|
+
const allEvents = readJsonlFile(EVENTS_FILE);
|
|
77
|
+
const periodEvents = filterEvents(allEvents, days);
|
|
78
|
+
const dailyLogs = readDailyLogs(days);
|
|
79
|
+
|
|
80
|
+
// Unique sessions from daily logs
|
|
81
|
+
const sessionIds = new Set(dailyLogs.map(e => e.session_id || e.run_id).filter(Boolean));
|
|
82
|
+
const totalCost = dailyLogs.reduce((s, e) => s + (typeof e.cost === 'number' ? e.cost : 0), 0);
|
|
83
|
+
|
|
84
|
+
// Tool-call events with path metadata
|
|
85
|
+
const toolCallEvents = periodEvents.filter(e => e.kind === 'tool_call');
|
|
86
|
+
|
|
87
|
+
// Access log: tool calls that actually ran (LOCAL or TRANSFORM, not BLOCK)
|
|
88
|
+
const accessLog = toolCallEvents
|
|
89
|
+
.filter(e => e.action !== 'BLOCK' && e.tool_inputs && e.tool_inputs.path)
|
|
90
|
+
.map(e => ({
|
|
91
|
+
ts: e.ts,
|
|
92
|
+
session_id: e.session_id || null,
|
|
93
|
+
tool: e.tool_name,
|
|
94
|
+
path: e.tool_inputs.path,
|
|
95
|
+
action: e.action,
|
|
96
|
+
reason: e.reason || null,
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
// Blocked accesses: tool calls blocked by path policy
|
|
100
|
+
const blockedAccesses = toolCallEvents
|
|
101
|
+
.filter(e => e.action === 'BLOCK' && (e.reason === 'path-denied' || e.reason === 'path-not-allowed'))
|
|
102
|
+
.map(e => ({
|
|
103
|
+
ts: e.ts,
|
|
104
|
+
session_id: e.session_id || null,
|
|
105
|
+
tool: e.tool_name,
|
|
106
|
+
path: (e.tool_inputs && e.tool_inputs.path) || null,
|
|
107
|
+
action: e.action,
|
|
108
|
+
reason: e.reason,
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
// Secret events: rows where secrets_redacted > 0
|
|
112
|
+
const secretEvents = periodEvents
|
|
113
|
+
.filter(e => typeof e.secrets_redacted === 'number' && e.secrets_redacted > 0)
|
|
114
|
+
.map(e => ({
|
|
115
|
+
ts: e.ts,
|
|
116
|
+
session_id: e.session_id || null,
|
|
117
|
+
tool: e.tool_name,
|
|
118
|
+
secrets_redacted: e.secrets_redacted,
|
|
119
|
+
action: e.action,
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
// Audit integrity over the full file (chain is cumulative, not period-scoped)
|
|
123
|
+
const integrity = verifyFile(EVENTS_FILE);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
generated_at: new Date().toISOString(),
|
|
127
|
+
period_days: days,
|
|
128
|
+
summary: {
|
|
129
|
+
sessions: sessionIds.size || dailyLogs.length,
|
|
130
|
+
requests: dailyLogs.length,
|
|
131
|
+
cost_usd: Math.round(totalCost * 100000) / 100000,
|
|
132
|
+
files_accessed: accessLog.length,
|
|
133
|
+
paths_blocked: blockedAccesses.length,
|
|
134
|
+
secrets_detected: secretEvents.length,
|
|
135
|
+
requests_blocked: periodEvents.filter(e => e.action === 'BLOCK' && e.kind !== 'tool_call').length,
|
|
136
|
+
},
|
|
137
|
+
audit_integrity: {
|
|
138
|
+
verified: integrity.ok,
|
|
139
|
+
chain_length: integrity.chained,
|
|
140
|
+
first_event_ts: periodEvents.length ? periodEvents[0].ts : null,
|
|
141
|
+
last_event_ts: periodEvents.length ? periodEvents[periodEvents.length - 1].ts : null,
|
|
142
|
+
},
|
|
143
|
+
access_log: accessLog,
|
|
144
|
+
blocked_accesses: blockedAccesses,
|
|
145
|
+
secret_events: secretEvents,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── CSV serialiser ────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
function csvEscape(v) {
|
|
152
|
+
if (v === null || v === undefined) return '';
|
|
153
|
+
const s = String(v);
|
|
154
|
+
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
|
|
155
|
+
return '"' + s.replace(/"/g, '""') + '"';
|
|
156
|
+
}
|
|
157
|
+
return s;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function toCsv(report) {
|
|
161
|
+
const lines = [];
|
|
162
|
+
|
|
163
|
+
// Summary header
|
|
164
|
+
const s = report.summary;
|
|
165
|
+
lines.push(`# generated_at: ${report.generated_at}`);
|
|
166
|
+
lines.push(`# period_days: ${report.period_days}`);
|
|
167
|
+
lines.push(`# sessions: ${s.sessions}, requests: ${s.requests}, cost_usd: ${s.cost_usd}`);
|
|
168
|
+
lines.push(`# files_accessed: ${s.files_accessed}, paths_blocked: ${s.paths_blocked}, secrets_detected: ${s.secrets_detected}`);
|
|
169
|
+
const iv = report.audit_integrity;
|
|
170
|
+
lines.push(`# audit_chain_verified: ${iv.verified}, chain_length: ${iv.chain_length}`);
|
|
171
|
+
lines.push('');
|
|
172
|
+
|
|
173
|
+
// Access log
|
|
174
|
+
lines.push('# === Access Log ===');
|
|
175
|
+
lines.push('ts,session_id,tool,path,action,reason');
|
|
176
|
+
for (const r of report.access_log) {
|
|
177
|
+
lines.push([r.ts, r.session_id, r.tool, r.path, r.action, r.reason].map(csvEscape).join(','));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (report.blocked_accesses.length) {
|
|
181
|
+
lines.push('');
|
|
182
|
+
lines.push('# === Blocked Accesses ===');
|
|
183
|
+
lines.push('ts,session_id,tool,path,action,reason');
|
|
184
|
+
for (const r of report.blocked_accesses) {
|
|
185
|
+
lines.push([r.ts, r.session_id, r.tool, r.path, r.action, r.reason].map(csvEscape).join(','));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (report.secret_events.length) {
|
|
190
|
+
lines.push('');
|
|
191
|
+
lines.push('# === Secret Events ===');
|
|
192
|
+
// path intentionally excluded from CSV secret events (report export must not itself leak paths)
|
|
193
|
+
lines.push('ts,session_id,tool,secrets_redacted,action');
|
|
194
|
+
for (const r of report.secret_events) {
|
|
195
|
+
lines.push([r.ts, r.session_id, r.tool, r.secrets_redacted, r.action].map(csvEscape).join(','));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return lines.join('\n');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── CLI entry point ───────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
function runReportCli(args) {
|
|
205
|
+
const formatIdx = (args || []).indexOf('--format');
|
|
206
|
+
const format = formatIdx >= 0 ? (args[formatIdx + 1] || 'json') : 'json';
|
|
207
|
+
const daysIdx = (args || []).indexOf('--days');
|
|
208
|
+
const days = daysIdx >= 0 ? (parseInt(args[daysIdx + 1], 10) || 30) : 30;
|
|
209
|
+
|
|
210
|
+
if (format !== 'json' && format !== 'csv') {
|
|
211
|
+
process.stderr.write(`[Occasio] report: unknown format "${format}", use json or csv\n`);
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const report = buildReport(days);
|
|
216
|
+
|
|
217
|
+
if (format === 'csv') {
|
|
218
|
+
process.stdout.write(toCsv(report) + '\n');
|
|
219
|
+
} else {
|
|
220
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = { runReportCli, buildReport };
|