@occasiolabs/occasio 0.8.4 → 0.8.6
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 +4 -3
- package/docs/ADAPTER-STAGE-2-MIGRATION.md +59 -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 +8 -3
- 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/boundary.js +1 -1
- package/src/classifier.js +1 -1
- package/src/cli/clear.js +4 -4
- package/src/cli/conversation.js +121 -0
- package/src/cli/help.js +62 -38
- package/src/cli/recap.js +367 -0
- package/src/cli/status.js +1 -1
- package/src/dashboard.js +2 -3
- package/src/demo/audit-demo.js +330 -0
- 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 +118 -30
- 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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* conversation.js — read the last conversation snippet from Claude Code's
|
|
5
|
+
* own per-project session files at ~/.claude/projects/<cwd-encoded>/<id>.jsonl
|
|
6
|
+
*
|
|
7
|
+
* Why: pipeline-events.jsonl captures tool calls, but the actual prompts and
|
|
8
|
+
* model replies live in Claude Code's session files. Those already exist on
|
|
9
|
+
* disk — we don't need to add a new capture path in the proxy.
|
|
10
|
+
*
|
|
11
|
+
* Read-only, no I/O outside ~/.claude. Returns null on any miss (no claude
|
|
12
|
+
* dir, no project dir, no session file, malformed JSONL, etc.) — caller
|
|
13
|
+
* degrades gracefully.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
|
|
20
|
+
// Claude Code encodes the cwd by replacing every path separator (':', '\', '/')
|
|
21
|
+
// with a single '-'. So `C:\Users\leona\foo` → `C--Users-leona-foo` because
|
|
22
|
+
// `C` + `:` (-) + `\` (-) + `Users`… yields two dashes between C and Users.
|
|
23
|
+
function encodeCwd(cwd) {
|
|
24
|
+
return cwd.replace(/[:\\/]/g, '-');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function findProjectDir(cwd) {
|
|
28
|
+
const root = path.join(os.homedir(), '.claude', 'projects');
|
|
29
|
+
if (!fs.existsSync(root)) return null;
|
|
30
|
+
const encoded = encodeCwd(cwd);
|
|
31
|
+
const candidate = path.join(root, encoded);
|
|
32
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
33
|
+
// Case-insensitive fallback (Windows drive letters): scan once.
|
|
34
|
+
try {
|
|
35
|
+
const entries = fs.readdirSync(root);
|
|
36
|
+
const hit = entries.find(n => n.toLowerCase() === encoded.toLowerCase());
|
|
37
|
+
return hit ? path.join(root, hit) : null;
|
|
38
|
+
} catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function listSessionFiles(projectDir) {
|
|
42
|
+
if (!projectDir) return [];
|
|
43
|
+
try {
|
|
44
|
+
return fs.readdirSync(projectDir)
|
|
45
|
+
.filter(n => n.endsWith('.jsonl') && /^[0-9a-f-]{36}\.jsonl$/i.test(n))
|
|
46
|
+
.map(n => {
|
|
47
|
+
const full = path.join(projectDir, n);
|
|
48
|
+
let mtime = 0;
|
|
49
|
+
try { mtime = fs.statSync(full).mtimeMs; } catch { /* */ }
|
|
50
|
+
return { name: n, full, mtime };
|
|
51
|
+
})
|
|
52
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
53
|
+
} catch { return []; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Pull a string preview out of message.content, which Claude Code stores as
|
|
57
|
+
// either a raw string or an array of typed blocks.
|
|
58
|
+
function previewText(content, max = 240) {
|
|
59
|
+
if (!content) return '';
|
|
60
|
+
if (typeof content === 'string') return content.slice(0, max).trim();
|
|
61
|
+
if (!Array.isArray(content)) return '';
|
|
62
|
+
for (const block of content) {
|
|
63
|
+
if (!block) continue;
|
|
64
|
+
if (typeof block === 'string') return block.slice(0, max).trim();
|
|
65
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
66
|
+
return block.text.slice(0, max).trim();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function loadConversation(sessionFile) {
|
|
73
|
+
let text;
|
|
74
|
+
try { text = fs.readFileSync(sessionFile, 'utf8'); }
|
|
75
|
+
catch { return null; }
|
|
76
|
+
|
|
77
|
+
let firstUser = null, lastUser = null, lastAssistant = null;
|
|
78
|
+
let firstTs = null, lastTs = null;
|
|
79
|
+
|
|
80
|
+
for (const line of text.split('\n')) {
|
|
81
|
+
if (!line) continue;
|
|
82
|
+
let row;
|
|
83
|
+
try { row = JSON.parse(line); } catch { continue; }
|
|
84
|
+
if (row.type !== 'user' && row.type !== 'assistant') continue;
|
|
85
|
+
const msg = row.message;
|
|
86
|
+
if (!msg) continue;
|
|
87
|
+
const preview = previewText(msg.content);
|
|
88
|
+
if (!preview) continue;
|
|
89
|
+
const ts = row.timestamp || null;
|
|
90
|
+
if (ts && !firstTs) firstTs = ts;
|
|
91
|
+
if (ts) lastTs = ts;
|
|
92
|
+
if (row.type === 'user') {
|
|
93
|
+
if (!firstUser) firstUser = preview;
|
|
94
|
+
lastUser = preview;
|
|
95
|
+
} else if (row.type === 'assistant') {
|
|
96
|
+
lastAssistant = preview;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!firstUser && !lastUser && !lastAssistant) return null;
|
|
101
|
+
return { firstUser, lastUser, lastAssistant, firstTs, lastTs };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Best-effort: find and read the most recent Claude Code conversation for
|
|
106
|
+
* the given cwd. Returns null if none found.
|
|
107
|
+
*/
|
|
108
|
+
function readLastConversation(cwd) {
|
|
109
|
+
const dir = findProjectDir(cwd);
|
|
110
|
+
if (!dir) return null;
|
|
111
|
+
const sessions = listSessionFiles(dir);
|
|
112
|
+
if (!sessions.length) return null;
|
|
113
|
+
// Try the newest first; fall through to older if newest is empty.
|
|
114
|
+
for (const s of sessions.slice(0, 3)) {
|
|
115
|
+
const conv = loadConversation(s.full);
|
|
116
|
+
if (conv) return { ...conv, sessionFile: s.full };
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { readLastConversation, encodeCwd, findProjectDir, listSessionFiles, loadConversation };
|
package/src/cli/help.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// `occasio help` — top-level usage. Pure text; no side effects other
|
|
2
2
|
// than console.log. Each CLI command lives in its own file under
|
|
3
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
|
|
4
9
|
|
|
5
10
|
'use strict';
|
|
6
11
|
|
|
@@ -19,42 +24,60 @@ function run() {
|
|
|
19
24
|
console.log(`
|
|
20
25
|
${col.b(`⚡ Occasio v${VERSION}`)}
|
|
21
26
|
|
|
22
|
-
${col.b('
|
|
23
|
-
occasio
|
|
24
|
-
occasio
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
occasio
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
recap ${col.d('(stable)')} Markdown session summary for agent context (--last N, --run, --format)
|
|
50
|
+
boundary ${col.d('(stable)')} Per-request: produced / re-entered / prevented
|
|
51
|
+
inspect ${col.d('(stable)')} Cloud-boundary manifest (--last N, --entry N)
|
|
52
|
+
distill ${col.d('(stable)')} Inspect distilled outputs (--last N, --entry <N>)
|
|
53
|
+
report ${col.d('(stable)')} Governance export (--format csv for SIEM)
|
|
54
|
+
preflight ${col.d('(beta)')} Read-only miner over past logs
|
|
55
|
+
baseline ${col.d('(beta)')} Behavior baseline: [learn|show|compare|reset]
|
|
56
|
+
|
|
57
|
+
${col.b('Audit')} ${col.d('— tamper-evidence and attestation')}
|
|
58
|
+
audit verify ${col.d('(stable)')} Verify hash chain in pipeline-events.jsonl
|
|
59
|
+
audit repair ${col.d('(stable)')} Truncate crash-partial trailing line (--file --dry-run)
|
|
60
|
+
attest --run-id <uuid> ${col.d('(stable)')} Behavioral attestation: hash-chain + execution summary
|
|
61
|
+
${col.d('Add --sign in GitHub Actions for Sigstore keyless signing')}
|
|
62
|
+
attest verify <file> ${col.d('(stable)')} Re-verify signed attestation (bundle + DSSE + chain)
|
|
63
|
+
selftest ${col.d('(stable)')} Run governance self-checks on scratch chain
|
|
64
|
+
|
|
65
|
+
${col.b('Detect')} ${col.d('— anomalies, adversarial probes')}
|
|
66
|
+
anomalies ${col.d('(beta)')} Windowed EDR over the audit chain (--window 15m --json)
|
|
67
|
+
harness ${col.d('(alpha)')} Real Claude Code run vs. governance claims (API key required)
|
|
68
|
+
redteam ${col.d('(alpha)')} Autonomous adversarial probe (API key + SDK required)
|
|
69
|
+
|
|
70
|
+
${col.b('Policy & extras')}
|
|
71
|
+
policy [show] ${col.d('(stable)')} Show active policy: flags, routing, overrides
|
|
72
|
+
policy show --diff ${col.d('(stable)')} Only values that differ from defaults
|
|
73
|
+
policy validate ${col.d('(stable)')} Validate policy.yml and report errors/warnings
|
|
74
|
+
policy doctor ${col.d('(beta)')} Cross-reference logs with policy; suggest tightening
|
|
75
|
+
computer-use ${col.d('(alpha)')} Apply policy to a JSONL of tool_use blocks (--dry-run --example)
|
|
76
|
+
mcp-experiment ${col.d('(beta)')} MCP vs. built-in tool adoption stats
|
|
77
|
+
demo audit ${col.d('(stable)')} Auditor scenario: signed attestation + cross-verifier proof
|
|
78
|
+
demo attest ${col.d('(stable)')} End-to-end attestation pipeline against a synthetic chain
|
|
79
|
+
demo anomalies ${col.d('(stable)')} End-to-end EDR test: synthetic adversarial chain
|
|
80
|
+
demo ${col.d('(stable)')} 10-second proof: see Occasio block real secrets
|
|
58
81
|
|
|
59
82
|
${col.b('Presets:')}
|
|
60
83
|
--preset balanced (default) Intercept safe reads locally, log all requests
|
|
@@ -67,8 +90,9 @@ ${col.b('Flags:')}
|
|
|
67
90
|
--block-secrets Alias for --preset strict
|
|
68
91
|
--log-only Alias for --preset off
|
|
69
92
|
--dashboard Open live dashboard at http://localhost:3001
|
|
70
|
-
--port <N> Proxy port (default:
|
|
71
|
-
--
|
|
93
|
+
--port <N> Proxy port (default: auto-assigned by OS)
|
|
94
|
+
--recap Print a previous-session banner at startup
|
|
95
|
+
--verbose Print live per-request chatter (off by default)
|
|
72
96
|
|
|
73
97
|
${col.b('Multi-agent routing:')}
|
|
74
98
|
Default → Claude Code adapter
|
package/src/cli/recap.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* occasio recap — markdown summary of a past session, sized to paste into
|
|
5
|
+
* a new Claude prompt so the next agent picks up where the last one left off.
|
|
6
|
+
*
|
|
7
|
+
* Reads from ~/.occasio/pipeline-events.jsonl (read-only). Groups by run_id,
|
|
8
|
+
* picks the most recent session by default. No network, no signing, no policy
|
|
9
|
+
* decisions made — pure read view.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* occasio recap # last session, markdown
|
|
13
|
+
* occasio recap --last 3 # last 3 sessions
|
|
14
|
+
* occasio recap --run <id> # specific session
|
|
15
|
+
* occasio recap --format text|json # alt outputs
|
|
16
|
+
*
|
|
17
|
+
* The output is shaped to be useful as agent context — short, factual,
|
|
18
|
+
* decisions-first. Not an audit view (use `occasio replay --detail` for that).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const os = require('os');
|
|
24
|
+
|
|
25
|
+
const { readLastConversation } = require('./conversation');
|
|
26
|
+
|
|
27
|
+
const CHAIN_FILE = path.join(os.homedir(), '.occasio', 'pipeline-events.jsonl');
|
|
28
|
+
|
|
29
|
+
const col = {
|
|
30
|
+
r: s => `\x1b[31m${s}\x1b[0m`,
|
|
31
|
+
g: s => `\x1b[32m${s}\x1b[0m`,
|
|
32
|
+
y: s => `\x1b[33m${s}\x1b[0m`,
|
|
33
|
+
c: s => `\x1b[36m${s}\x1b[0m`,
|
|
34
|
+
d: s => `\x1b[2m${s}\x1b[0m`,
|
|
35
|
+
b: s => `\x1b[1m${s}\x1b[0m`,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Normalise tool names: chain has both PascalCase (Claude-Code-style) and
|
|
39
|
+
// snake_case (shell-tool aliases via interceptor). Render as the canonical
|
|
40
|
+
// display name so the recap doesn't double-count Read/read_file.
|
|
41
|
+
const TOOL_DISPLAY = {
|
|
42
|
+
read_file: 'Read',
|
|
43
|
+
read: 'Read',
|
|
44
|
+
Read: 'Read',
|
|
45
|
+
grep: 'Grep',
|
|
46
|
+
Grep: 'Grep',
|
|
47
|
+
glob: 'Glob',
|
|
48
|
+
Glob: 'Glob',
|
|
49
|
+
find_files: 'Glob',
|
|
50
|
+
edit_file: 'Edit',
|
|
51
|
+
Edit: 'Edit',
|
|
52
|
+
MultiEdit: 'Edit',
|
|
53
|
+
write_file: 'Write',
|
|
54
|
+
Write: 'Write',
|
|
55
|
+
bash: 'Bash',
|
|
56
|
+
Bash: 'Bash',
|
|
57
|
+
shell_bash: 'Bash',
|
|
58
|
+
shell_powershell: 'PowerShell',
|
|
59
|
+
TodoWrite: 'TodoWrite',
|
|
60
|
+
TodoRead: 'TodoRead',
|
|
61
|
+
WebFetch: 'WebFetch',
|
|
62
|
+
WebSearch: 'WebSearch',
|
|
63
|
+
};
|
|
64
|
+
const TOOLS_THAT_READ = new Set(['Read', 'Grep', 'Glob']);
|
|
65
|
+
const TOOLS_THAT_MODIFY = new Set(['Edit', 'Write']);
|
|
66
|
+
|
|
67
|
+
function normTool(name) { return TOOL_DISPLAY[name] || name || 'unknown'; }
|
|
68
|
+
|
|
69
|
+
function readChain(file) {
|
|
70
|
+
if (!fs.existsSync(file)) return [];
|
|
71
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const line of text.split('\n')) {
|
|
74
|
+
if (!line) continue;
|
|
75
|
+
try { out.push(JSON.parse(line)); } catch { /* skip malformed */ }
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function groupByRun(rows) {
|
|
81
|
+
const map = new Map();
|
|
82
|
+
for (const r of rows) {
|
|
83
|
+
const id = r.run_id || 'legacy';
|
|
84
|
+
if (!map.has(id)) map.set(id, []);
|
|
85
|
+
map.get(id).push(r);
|
|
86
|
+
}
|
|
87
|
+
for (const arr of map.values()) {
|
|
88
|
+
arr.sort((a, b) => (a.ts || '') < (b.ts || '') ? -1 : 1);
|
|
89
|
+
}
|
|
90
|
+
return map;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pickRecentRuns(rowsByRun, n) {
|
|
94
|
+
const entries = [...rowsByRun.entries()].map(([id, rows]) => ({
|
|
95
|
+
id,
|
|
96
|
+
rows,
|
|
97
|
+
end: rows.length ? rows[rows.length - 1].ts || '' : '',
|
|
98
|
+
}));
|
|
99
|
+
entries.sort((a, b) => a.end < b.end ? 1 : -1);
|
|
100
|
+
return entries.slice(0, n);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function pathOf(row) {
|
|
104
|
+
const t = row.tool_inputs || {};
|
|
105
|
+
return t.path || t.file_path || t.pattern || t.command || null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function aggregate(rows) {
|
|
109
|
+
const tools = {};
|
|
110
|
+
const readPaths = new Set();
|
|
111
|
+
const modifyPaths = new Set();
|
|
112
|
+
const blocked = [];
|
|
113
|
+
const transformed = [];
|
|
114
|
+
const toolCalls = [];
|
|
115
|
+
|
|
116
|
+
for (const r of rows) {
|
|
117
|
+
if (r.kind !== 'tool_call') continue;
|
|
118
|
+
const tool = normTool(r.tool_name);
|
|
119
|
+
tools[tool] = (tools[tool] || 0) + 1;
|
|
120
|
+
toolCalls.push(r);
|
|
121
|
+
|
|
122
|
+
const p = pathOf(r);
|
|
123
|
+
if (p && r.action !== 'BLOCK') {
|
|
124
|
+
if (TOOLS_THAT_READ.has(tool)) readPaths.add(p);
|
|
125
|
+
if (TOOLS_THAT_MODIFY.has(tool)) modifyPaths.add(p);
|
|
126
|
+
}
|
|
127
|
+
if (r.action === 'BLOCK') {
|
|
128
|
+
blocked.push({ tool, target: p, reason: r.reason || 'policy' });
|
|
129
|
+
}
|
|
130
|
+
if (r.action === 'TRANSFORM') {
|
|
131
|
+
transformed.push({ tool, target: p, redacted: r.secrets_redacted || 0 });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
tools, readPaths: [...readPaths], modifyPaths: [...modifyPaths],
|
|
137
|
+
blocked, transformed, toolCalls,
|
|
138
|
+
start: rows[0]?.ts || null,
|
|
139
|
+
end: rows[rows.length - 1]?.ts || null,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function shortPath(p, root) {
|
|
144
|
+
if (!p) return '(no path)';
|
|
145
|
+
if (root && p.startsWith(root)) {
|
|
146
|
+
const rel = p.slice(root.length).replace(/^[\\/]+/, '');
|
|
147
|
+
return rel || p;
|
|
148
|
+
}
|
|
149
|
+
return p;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function durationStr(start, end) {
|
|
153
|
+
if (!start || !end) return 'unknown';
|
|
154
|
+
const ms = new Date(end) - new Date(start);
|
|
155
|
+
if (!Number.isFinite(ms) || ms < 0) return 'unknown';
|
|
156
|
+
const min = Math.round(ms / 60000);
|
|
157
|
+
if (min < 1) return '<1 min';
|
|
158
|
+
if (min < 60) return min + ' min';
|
|
159
|
+
return Math.floor(min/60) + 'h ' + (min%60) + 'm';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function lastActionsLines(toolCalls, root, n = 5) {
|
|
163
|
+
const last = toolCalls.slice(-n);
|
|
164
|
+
return last.map((r, i) => {
|
|
165
|
+
const tool = normTool(r.tool_name);
|
|
166
|
+
const p = shortPath(pathOf(r), root);
|
|
167
|
+
const action = r.action || '';
|
|
168
|
+
const tag = action === 'BLOCK' ? '✗' : action === 'TRANSFORM' ? '⚙' : '·';
|
|
169
|
+
return `${i+1}. ${tag} ${tool} ${p}${action && action !== 'PASS' && action !== 'LOCAL' ? ' → ' + action : ''}`;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function renderMarkdown(run, root, conv) {
|
|
174
|
+
const a = aggregate(run.rows);
|
|
175
|
+
const dur = durationStr(a.start, a.end);
|
|
176
|
+
const date = a.start ? new Date(a.start).toISOString().replace('T', ' ').slice(0, 16) : 'unknown';
|
|
177
|
+
|
|
178
|
+
const lines = [];
|
|
179
|
+
lines.push(`# Previous session — ${date}, ${dur}`);
|
|
180
|
+
lines.push('');
|
|
181
|
+
|
|
182
|
+
if (conv && (conv.lastUser || conv.lastAssistant)) {
|
|
183
|
+
lines.push('## Last conversation');
|
|
184
|
+
if (conv.lastUser) {
|
|
185
|
+
lines.push(`- **You said:** ${conv.lastUser}`);
|
|
186
|
+
}
|
|
187
|
+
if (conv.lastAssistant) {
|
|
188
|
+
lines.push(`- **Agent replied:** ${conv.lastAssistant}`);
|
|
189
|
+
}
|
|
190
|
+
lines.push('');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (a.readPaths.length || a.modifyPaths.length) {
|
|
194
|
+
lines.push('## Files touched');
|
|
195
|
+
if (a.modifyPaths.length) {
|
|
196
|
+
const shown = a.modifyPaths.slice(0, 10).map(p => shortPath(p, root));
|
|
197
|
+
const more = a.modifyPaths.length > 10 ? ` (+${a.modifyPaths.length - 10} more)` : '';
|
|
198
|
+
lines.push(`- modified: ${shown.join(', ')}${more}`);
|
|
199
|
+
}
|
|
200
|
+
if (a.readPaths.length) {
|
|
201
|
+
const shown = a.readPaths.slice(0, 10).map(p => shortPath(p, root));
|
|
202
|
+
const more = a.readPaths.length > 10 ? ` (+${a.readPaths.length - 10} more)` : '';
|
|
203
|
+
lines.push(`- read: ${shown.join(', ')}${more}`);
|
|
204
|
+
}
|
|
205
|
+
lines.push('');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const toolEntries = Object.entries(a.tools).sort((x, y) => y[1] - x[1]);
|
|
209
|
+
if (toolEntries.length) {
|
|
210
|
+
lines.push('## Tools used');
|
|
211
|
+
lines.push('- ' + toolEntries.map(([k, v]) => `${v} ${k}`).join(' · '));
|
|
212
|
+
lines.push('');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (a.blocked.length || a.transformed.length) {
|
|
216
|
+
lines.push('## Decisions');
|
|
217
|
+
if (a.blocked.length) {
|
|
218
|
+
const byTarget = {};
|
|
219
|
+
for (const b of a.blocked) {
|
|
220
|
+
const key = `${b.tool} ${shortPath(b.target, root)}`;
|
|
221
|
+
byTarget[key] = (byTarget[key] || 0) + 1;
|
|
222
|
+
}
|
|
223
|
+
for (const [k, count] of Object.entries(byTarget)) {
|
|
224
|
+
lines.push(`- ${count}× BLOCK ${k}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (a.transformed.length) {
|
|
228
|
+
const total = a.transformed.reduce((s, t) => s + (t.redacted || 0), 0);
|
|
229
|
+
lines.push(`- ${a.transformed.length}× TRANSFORM (${total} secret${total === 1 ? '' : 's'} redacted)`);
|
|
230
|
+
}
|
|
231
|
+
lines.push('');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (a.toolCalls.length) {
|
|
235
|
+
lines.push('## Last actions');
|
|
236
|
+
for (const line of lastActionsLines(a.toolCalls, root)) lines.push(line);
|
|
237
|
+
lines.push('');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Open-thread heuristic: surface last Bash/Edit/Write so the next agent
|
|
241
|
+
// knows the most recent state-changing action.
|
|
242
|
+
const lastBash = [...a.toolCalls].reverse().find(r => normTool(r.tool_name) === 'Bash' || normTool(r.tool_name) === 'PowerShell');
|
|
243
|
+
const lastWrite = [...a.toolCalls].reverse().find(r => TOOLS_THAT_MODIFY.has(normTool(r.tool_name)));
|
|
244
|
+
if (lastBash || lastWrite) {
|
|
245
|
+
lines.push('## Open thread (best-guess from last actions)');
|
|
246
|
+
if (lastWrite) {
|
|
247
|
+
lines.push(`- last write: ${normTool(lastWrite.tool_name)} ${shortPath(pathOf(lastWrite), root)}`);
|
|
248
|
+
}
|
|
249
|
+
if (lastBash) {
|
|
250
|
+
const cmd = (lastBash.tool_inputs && (lastBash.tool_inputs.command || lastBash.tool_inputs.cmd)) || '(no cmd)';
|
|
251
|
+
const exit = (typeof lastBash.exit_code === 'number') ? ` (exit ${lastBash.exit_code})` : '';
|
|
252
|
+
lines.push(`- last shell: ${cmd.slice(0, 80)}${cmd.length > 80 ? '…' : ''}${exit}`);
|
|
253
|
+
}
|
|
254
|
+
lines.push('');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return lines.join('\n');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function renderText(run, root, conv) {
|
|
261
|
+
// Plain text variant, no markdown noise — convenient for CLI piping.
|
|
262
|
+
return renderMarkdown(run, root, conv).replace(/^#+\s*/gm, '').replace(/\n{3,}/g, '\n\n');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function renderJson(run, root, conv) {
|
|
266
|
+
const a = aggregate(run.rows);
|
|
267
|
+
return JSON.stringify({
|
|
268
|
+
run_id: run.id,
|
|
269
|
+
started_at: a.start,
|
|
270
|
+
ended_at: a.end,
|
|
271
|
+
duration: durationStr(a.start, a.end),
|
|
272
|
+
conversation: conv ? {
|
|
273
|
+
last_user: conv.lastUser || null,
|
|
274
|
+
last_assistant: conv.lastAssistant || null,
|
|
275
|
+
} : null,
|
|
276
|
+
files: {
|
|
277
|
+
read: a.readPaths.map(p => shortPath(p, root)),
|
|
278
|
+
modified: a.modifyPaths.map(p => shortPath(p, root)),
|
|
279
|
+
},
|
|
280
|
+
tools: a.tools,
|
|
281
|
+
decisions: { blocked: a.blocked, transformed: a.transformed },
|
|
282
|
+
last_actions: a.toolCalls.slice(-5).map(r => ({
|
|
283
|
+
tool: normTool(r.tool_name), action: r.action,
|
|
284
|
+
path: shortPath(pathOf(r), root), ts: r.ts,
|
|
285
|
+
})),
|
|
286
|
+
}, null, 2);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function parseArgs(argv) {
|
|
290
|
+
const out = { last: 1, run: null, format: 'md' };
|
|
291
|
+
for (let i = 0; i < argv.length; i++) {
|
|
292
|
+
const a = argv[i];
|
|
293
|
+
if (a === '--last') out.last = Math.max(1, parseInt(argv[++i], 10) || 1);
|
|
294
|
+
else if (a === '--run') out.run = argv[++i];
|
|
295
|
+
else if (a === '--format') out.format = argv[++i];
|
|
296
|
+
else if (a === '--help' || a === '-h') out.help = true;
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function printHelp() {
|
|
302
|
+
process.stdout.write(`
|
|
303
|
+
${col.b('occasio recap')} ${col.d('— session summary for agent context')}
|
|
304
|
+
|
|
305
|
+
Markdown summary of a past Occasio session: files touched, tools used,
|
|
306
|
+
decisions made, last actions. Designed to paste into a new Claude prompt
|
|
307
|
+
so the next session picks up where the last one left off.
|
|
308
|
+
|
|
309
|
+
${col.b('Usage')}
|
|
310
|
+
occasio recap last session (markdown)
|
|
311
|
+
occasio recap --last 3 last 3 sessions
|
|
312
|
+
occasio recap --run <id> specific session
|
|
313
|
+
occasio recap --format text|json alternative output
|
|
314
|
+
|
|
315
|
+
${col.b('Tip')}
|
|
316
|
+
occasio recap | clip ${col.d('(Windows)')}
|
|
317
|
+
occasio recap | pbcopy ${col.d('(macOS)')}
|
|
318
|
+
occasio recap | xclip -sel clip ${col.d('(Linux)')}
|
|
319
|
+
|
|
320
|
+
`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function run(argv) {
|
|
324
|
+
const opts = parseArgs(argv || []);
|
|
325
|
+
if (opts.help) { printHelp(); return 0; }
|
|
326
|
+
|
|
327
|
+
const rows = readChain(CHAIN_FILE);
|
|
328
|
+
if (!rows.length) {
|
|
329
|
+
process.stderr.write(col.d(`No chain at ${CHAIN_FILE} — run \`occasio claude\` first.\n`));
|
|
330
|
+
return 1;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const byRun = groupByRun(rows);
|
|
334
|
+
let runs;
|
|
335
|
+
if (opts.run) {
|
|
336
|
+
if (!byRun.has(opts.run)) {
|
|
337
|
+
process.stderr.write(col.r(`No session with run_id ${opts.run}\n`));
|
|
338
|
+
return 1;
|
|
339
|
+
}
|
|
340
|
+
runs = [{ id: opts.run, rows: byRun.get(opts.run) }];
|
|
341
|
+
} else {
|
|
342
|
+
runs = pickRecentRuns(byRun, opts.last);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const root = process.cwd();
|
|
346
|
+
// Conversation snippet from Claude Code's own session files for the cwd.
|
|
347
|
+
// Only attach to the most-recent run; older runs in --last N get none
|
|
348
|
+
// (we have no way to map run_id to a specific Claude session reliably).
|
|
349
|
+
const conv = readLastConversation(root);
|
|
350
|
+
const parts = [];
|
|
351
|
+
for (let i = 0; i < runs.length; i++) {
|
|
352
|
+
const r = runs[i];
|
|
353
|
+
const useConv = i === 0 ? conv : null;
|
|
354
|
+
if (opts.format === 'json') {
|
|
355
|
+
parts.push(renderJson(r, root, useConv));
|
|
356
|
+
} else if (opts.format === 'text') {
|
|
357
|
+
parts.push(renderText(r, root, useConv));
|
|
358
|
+
} else {
|
|
359
|
+
parts.push(renderMarkdown(r, root, useConv));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
module.exports = { run, aggregate, renderMarkdown, normTool };
|
package/src/cli/status.js
CHANGED
|
@@ -26,7 +26,7 @@ function todayStr() {
|
|
|
26
26
|
function getLogFile() { return path.join(LOG_DIR, 'logs', `${todayStr()}.jsonl`); }
|
|
27
27
|
|
|
28
28
|
function run() {
|
|
29
|
-
let s = null; try { s = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')); } catch {}
|
|
29
|
+
let s = null; try { s = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')); } catch { /* ignore */ }
|
|
30
30
|
console.log(col.b('\n⚡ Occasio\n'));
|
|
31
31
|
if (!s) { console.log(col.d(' No session data yet. Run: occasio claude\n')); return; }
|
|
32
32
|
|
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: [] });
|