@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
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* occasio preflight — CLI entry point (ARCH-26, read-only).
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* occasio preflight Print opening-move pattern table
|
|
8
|
+
* occasio preflight --show Same (alias for future flag compatibility)
|
|
9
|
+
* occasio preflight --days N Use last N days of logs (default: 30)
|
|
10
|
+
* occasio preflight --reset Clear pattern store (available after ARCH-27)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const {
|
|
16
|
+
mine,
|
|
17
|
+
getGitRoot,
|
|
18
|
+
MIN_SESSIONS_FOR_PATTERN,
|
|
19
|
+
} = require('./miner');
|
|
20
|
+
|
|
21
|
+
const col = {
|
|
22
|
+
r: s => `\x1b[31m${s}\x1b[0m`,
|
|
23
|
+
g: s => `\x1b[32m${s}\x1b[0m`,
|
|
24
|
+
y: s => `\x1b[33m${s}\x1b[0m`,
|
|
25
|
+
c: s => `\x1b[36m${s}\x1b[0m`,
|
|
26
|
+
d: s => `\x1b[2m${s}\x1b[0m`,
|
|
27
|
+
b: s => `\x1b[1m${s}\x1b[0m`,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Minimum display confidence threshold — patterns below this are not shown
|
|
31
|
+
const DISPLAY_THRESHOLD = 0.40;
|
|
32
|
+
|
|
33
|
+
// Maximum table rows
|
|
34
|
+
const MAX_ROWS = 10;
|
|
35
|
+
|
|
36
|
+
function ordinal(n) {
|
|
37
|
+
const r = Math.round(n);
|
|
38
|
+
if (r === 1) return '1st';
|
|
39
|
+
if (r === 2) return '2nd';
|
|
40
|
+
if (r === 3) return '3rd';
|
|
41
|
+
return `${r}th+`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function truncate(s, maxLen) {
|
|
45
|
+
if (s.length <= maxLen) return s;
|
|
46
|
+
return '…' + s.slice(-(maxLen - 1)); // right-side truncation preserves filename
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function runPreflightCli(args, opts) {
|
|
50
|
+
const { cwd: cwdOverride, logsDir } = opts || {};
|
|
51
|
+
|
|
52
|
+
const daysIdx = (args || []).indexOf('--days');
|
|
53
|
+
const days = daysIdx >= 0 ? (parseInt(args[daysIdx + 1], 10) || 30) : 30;
|
|
54
|
+
const reset = (args || []).includes('--reset');
|
|
55
|
+
|
|
56
|
+
const cwd = cwdOverride || process.cwd();
|
|
57
|
+
const gitRoot = getGitRoot(cwd);
|
|
58
|
+
const projectRoot = gitRoot || cwd;
|
|
59
|
+
const displayRoot = projectRoot.replace(os.homedir(), '~');
|
|
60
|
+
const rootLabel = gitRoot ? 'git root' : 'directory';
|
|
61
|
+
|
|
62
|
+
console.log(col.b('\n⚡ Occasio — Opening-move patterns\n'));
|
|
63
|
+
|
|
64
|
+
// --reset: placeholder until ARCH-27 adds persistent pattern store
|
|
65
|
+
if (reset) {
|
|
66
|
+
console.log(` ${col.d(`${displayRoot} (${rootLabel})`)}`);
|
|
67
|
+
console.log(col.d('\n Pattern store reset will be available after ARCH-27 (pattern persistence).\n'));
|
|
68
|
+
return { ok: true };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const allProjects = mine({ days, logsDir, filterCwd: cwd });
|
|
72
|
+
const proj = allProjects.find(p => p.projectRoot === projectRoot);
|
|
73
|
+
|
|
74
|
+
// ── Empty-state handling ─────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
if (!proj || proj.totalSessions === 0) {
|
|
77
|
+
console.log(` ${col.d(`${displayRoot} (${rootLabel})`)}`);
|
|
78
|
+
console.log(col.d(` 0 sessions with cwd tracking \xb7 last ${days} days\n`));
|
|
79
|
+
console.log(col.d(` No session history found for this project.`));
|
|
80
|
+
if (!proj || proj.totalSessions === 0) {
|
|
81
|
+
console.log(col.d(` Patterns emerge after ${MIN_SESSIONS_FOR_PATTERN}+ sessions.`));
|
|
82
|
+
console.log(col.d(` (cwd tracking requires Occasio ≥v0.6.3)\n`));
|
|
83
|
+
}
|
|
84
|
+
return { ok: true, totalSessions: 0 };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (proj.totalSessions < MIN_SESSIONS_FOR_PATTERN) {
|
|
88
|
+
console.log(` ${col.d(`${displayRoot} (${rootLabel})`)}`);
|
|
89
|
+
console.log(col.d(` ${proj.totalSessions} session${proj.totalSessions !== 1 ? 's' : ''} \xb7 last ${days} days\n`));
|
|
90
|
+
console.log(col.d(` Not enough history — patterns emerge after ${MIN_SESSIONS_FOR_PATTERN}+ sessions.\n`));
|
|
91
|
+
return { ok: true, totalSessions: proj.totalSessions };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Table ────────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
const candidates = proj.tools
|
|
97
|
+
.filter(t => t.confidence >= DISPLAY_THRESHOLD)
|
|
98
|
+
.slice(0, MAX_ROWS);
|
|
99
|
+
|
|
100
|
+
console.log(` ${col.d(`${displayRoot} (${rootLabel})`)}`);
|
|
101
|
+
console.log(col.d(` ${proj.totalSessions} sessions \xb7 last ${days} days \xb7 first 5 tool calls per session\n`));
|
|
102
|
+
|
|
103
|
+
if (!candidates.length) {
|
|
104
|
+
console.log(col.d(` No consistent patterns found. Sessions in this project vary too much.\n`));
|
|
105
|
+
return { ok: true, totalSessions: proj.totalSessions, candidates: 0 };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Column widths
|
|
109
|
+
const W_NUM = 2;
|
|
110
|
+
const W_TOOL = 6;
|
|
111
|
+
const W_TARGET = 44;
|
|
112
|
+
const W_SEEN = 9;
|
|
113
|
+
const W_CONF = 11;
|
|
114
|
+
const W_POS = 10;
|
|
115
|
+
|
|
116
|
+
const header = [
|
|
117
|
+
'#'.padEnd(W_NUM),
|
|
118
|
+
'Tool'.padEnd(W_TOOL),
|
|
119
|
+
'Target'.padEnd(W_TARGET),
|
|
120
|
+
'Seen'.padStart(W_SEEN),
|
|
121
|
+
'Confidence'.padStart(W_CONF),
|
|
122
|
+
'Position'.padStart(W_POS),
|
|
123
|
+
].join(' ');
|
|
124
|
+
|
|
125
|
+
const divider = [
|
|
126
|
+
'─'.repeat(W_NUM),
|
|
127
|
+
'─'.repeat(W_TOOL),
|
|
128
|
+
'─'.repeat(W_TARGET),
|
|
129
|
+
'─'.repeat(W_SEEN),
|
|
130
|
+
'─'.repeat(W_CONF),
|
|
131
|
+
'─'.repeat(W_POS),
|
|
132
|
+
].join(' ');
|
|
133
|
+
|
|
134
|
+
console.log(` ${col.d(header)}`);
|
|
135
|
+
console.log(` ${col.d(divider)}`);
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
138
|
+
const c = candidates[i];
|
|
139
|
+
const numStr = String(i + 1).padEnd(W_NUM);
|
|
140
|
+
const toolStr = c.tool.padEnd(W_TOOL);
|
|
141
|
+
const target = truncate(c.cmd, W_TARGET).padEnd(W_TARGET);
|
|
142
|
+
const seen = `${c.count} / ${c.totalSessions}`.padStart(W_SEEN);
|
|
143
|
+
const confPct = `${Math.round(c.confidence * 100)}%`.padStart(W_CONF);
|
|
144
|
+
const posStr = ordinal(c.medianPosition).padStart(W_POS);
|
|
145
|
+
|
|
146
|
+
const confColor = c.confidence >= 0.70 ? col.g
|
|
147
|
+
: c.confidence >= 0.50 ? col.y
|
|
148
|
+
: col.d;
|
|
149
|
+
|
|
150
|
+
console.log(
|
|
151
|
+
` ${col.d(numStr)} ${col.c(toolStr)} ${col.d(target)} ` +
|
|
152
|
+
`${col.d(seen)} ${confColor(confPct)} ${col.d(posStr)}`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
console.log('');
|
|
157
|
+
console.log(col.d(` Patterns learned locally from tool-usage history, not prompt content.`));
|
|
158
|
+
console.log(col.d(` Preflight execution is off. Run occasio with --preflight to enable (coming soon).`));
|
|
159
|
+
console.log(col.d(` To reset patterns for this project: occasio preflight --reset\n`));
|
|
160
|
+
|
|
161
|
+
return { ok: true, totalSessions: proj.totalSessions, candidates: candidates.length };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = { runPreflightCli };
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Preflight pattern miner (ARCH-26, read-only).
|
|
5
|
+
*
|
|
6
|
+
* Reads ~/.occasio/logs/*.jsonl, groups entries by run_id, and identifies
|
|
7
|
+
* which tool calls appear consistently at the start of sessions for a given
|
|
8
|
+
* project root. No execution, no caching, no side effects.
|
|
9
|
+
*
|
|
10
|
+
* Schema requirement: log entries must have a `cwd` field (added in v0.6.3).
|
|
11
|
+
* Entries without `cwd` are silently skipped; the mine() output will show
|
|
12
|
+
* how many cwd-tagged sessions were found.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const { execSync } = require('child_process');
|
|
19
|
+
|
|
20
|
+
const LOG_DIR = path.join(os.homedir(), '.occasio');
|
|
21
|
+
|
|
22
|
+
// Only mine these tool types. Shell commands are too variable to be useful
|
|
23
|
+
// as preflight candidates; todo tools carry no path information.
|
|
24
|
+
const MINEABLE_TOOLS = new Set([
|
|
25
|
+
'Read', 'Glob', 'Grep', // Claude Code agent-protocol names
|
|
26
|
+
'read_file', 'find_files', 'grep', // Cline / canonical names
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
// Number of tools to consider "early in session" (per ARCH-26 design)
|
|
30
|
+
const EARLY_TOOL_LIMIT = 5;
|
|
31
|
+
|
|
32
|
+
// Minimum sessions before a pattern is surfaced
|
|
33
|
+
const MIN_SESSIONS_FOR_PATTERN = 3;
|
|
34
|
+
|
|
35
|
+
// -------------------------------------------------------------------
|
|
36
|
+
// Normalization helpers
|
|
37
|
+
// -------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
function normalizeToolName(name) {
|
|
40
|
+
if (name === 'Read' || name === 'read_file') return 'read';
|
|
41
|
+
if (name === 'Glob' || name === 'find_files') return 'glob';
|
|
42
|
+
if (name === 'Grep' || name === 'grep') return 'grep';
|
|
43
|
+
return (name || 'unknown').toLowerCase();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Make a path relative to a root directory.
|
|
47
|
+
// Returns the basename if the path is outside the root (e.g. cross-drive on Windows).
|
|
48
|
+
function relativize(root, absPath) {
|
|
49
|
+
if (!root || !absPath) return absPath || '';
|
|
50
|
+
try {
|
|
51
|
+
const rel = path.relative(root, absPath);
|
|
52
|
+
// Starts with '..' means outside root — fall back to basename for readability
|
|
53
|
+
return rel.startsWith('..') ? path.basename(absPath) : rel;
|
|
54
|
+
} catch {
|
|
55
|
+
return absPath;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Build a stable, comparable key for a tool call within a project.
|
|
60
|
+
// Read: key on relative file path (the cmd field is the absolute path).
|
|
61
|
+
// Glob / Grep: key on the pattern string (cmd is the pattern).
|
|
62
|
+
// Shell / other: excluded by MINEABLE_TOOLS guard before this is called.
|
|
63
|
+
function buildToolKey(toolRecord, projectRoot) {
|
|
64
|
+
const name = normalizeToolName(toolRecord.tool);
|
|
65
|
+
const cmd = toolRecord.cmd || '';
|
|
66
|
+
|
|
67
|
+
if (name === 'read') {
|
|
68
|
+
const rel = relativize(projectRoot, cmd);
|
|
69
|
+
return `${name}:${rel}`;
|
|
70
|
+
}
|
|
71
|
+
// glob and grep: cmd is already the pattern string
|
|
72
|
+
return `${name}:${cmd}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// -------------------------------------------------------------------
|
|
76
|
+
// Git root detection (cached, non-fatal)
|
|
77
|
+
// -------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
const _gitRootCache = new Map();
|
|
80
|
+
|
|
81
|
+
function getGitRoot(dir) {
|
|
82
|
+
if (!dir) return null;
|
|
83
|
+
if (_gitRootCache.has(dir)) return _gitRootCache.get(dir);
|
|
84
|
+
try {
|
|
85
|
+
if (!fs.existsSync(dir)) {
|
|
86
|
+
_gitRootCache.set(dir, null);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const raw = execSync('git rev-parse --show-toplevel', {
|
|
90
|
+
cwd: dir,
|
|
91
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
92
|
+
timeout: 2000,
|
|
93
|
+
}).toString().trim();
|
|
94
|
+
// Normalize separators so Windows forward-slash git output matches path.resolve()
|
|
95
|
+
const normalized = raw.split('/').join(path.sep);
|
|
96
|
+
_gitRootCache.set(dir, normalized);
|
|
97
|
+
return normalized;
|
|
98
|
+
} catch {
|
|
99
|
+
_gitRootCache.set(dir, null);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// -------------------------------------------------------------------
|
|
105
|
+
// Log reading
|
|
106
|
+
// -------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
function readRecentEntries(days, logsDir) {
|
|
109
|
+
const dir = logsDir || path.join(LOG_DIR, 'logs');
|
|
110
|
+
const entries = [];
|
|
111
|
+
try {
|
|
112
|
+
const cutoff = new Date();
|
|
113
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
114
|
+
const files = fs.readdirSync(dir)
|
|
115
|
+
.filter(f => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f))
|
|
116
|
+
.filter(f => {
|
|
117
|
+
try { return new Date(f.slice(0, 10)) >= cutoff; } catch { return false; }
|
|
118
|
+
})
|
|
119
|
+
.sort(); // chronological; oldest first
|
|
120
|
+
for (const f of files) {
|
|
121
|
+
const raw = fs.readFileSync(path.join(dir, f), 'utf8');
|
|
122
|
+
for (const line of raw.split('\n')) {
|
|
123
|
+
const trimmed = line.trim();
|
|
124
|
+
if (!trimmed) continue;
|
|
125
|
+
try { entries.push(JSON.parse(trimmed)); } catch {}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch {}
|
|
129
|
+
return entries;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// -------------------------------------------------------------------
|
|
133
|
+
// Run grouping and early-tool extraction
|
|
134
|
+
// -------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
// Group entries by run_id (entries without run_id are skipped).
|
|
137
|
+
// Sort each group's entries by iso timestamp ascending.
|
|
138
|
+
function groupByRun(entries) {
|
|
139
|
+
const map = new Map();
|
|
140
|
+
for (const e of entries) {
|
|
141
|
+
if (!e.run_id) continue;
|
|
142
|
+
if (!map.has(e.run_id)) map.set(e.run_id, []);
|
|
143
|
+
map.get(e.run_id).push(e);
|
|
144
|
+
}
|
|
145
|
+
for (const arr of map.values()) {
|
|
146
|
+
arr.sort((a, b) => {
|
|
147
|
+
const ai = a.iso || a.ts || '';
|
|
148
|
+
const bi = b.iso || b.ts || '';
|
|
149
|
+
return ai < bi ? -1 : ai > bi ? 1 : 0;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return map;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Pick cwd from the first entry in a run that carries the field.
|
|
156
|
+
// Returns null for runs logged before cwd was added to the schema.
|
|
157
|
+
function runCwd(runEntries) {
|
|
158
|
+
for (const e of runEntries) {
|
|
159
|
+
if (typeof e.cwd === 'string' && e.cwd) return e.cwd;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Extract the first EARLY_TOOL_LIMIT mineable tool calls across all entries
|
|
165
|
+
// of a run, in chronological order of the entries they appear in.
|
|
166
|
+
function earlyTools(runEntries, limit) {
|
|
167
|
+
const result = [];
|
|
168
|
+
for (const entry of runEntries) {
|
|
169
|
+
if (!Array.isArray(entry.tools)) continue;
|
|
170
|
+
for (const t of entry.tools) {
|
|
171
|
+
if (!MINEABLE_TOOLS.has(t.tool)) continue;
|
|
172
|
+
result.push(t);
|
|
173
|
+
if (result.length >= limit) return result;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// -------------------------------------------------------------------
|
|
180
|
+
// Median helper
|
|
181
|
+
// -------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
function median(arr) {
|
|
184
|
+
if (!arr.length) return 1;
|
|
185
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
186
|
+
const mid = Math.floor(sorted.length / 2);
|
|
187
|
+
return sorted.length % 2 === 0
|
|
188
|
+
? (sorted[mid - 1] + sorted[mid]) / 2
|
|
189
|
+
: sorted[mid];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// -------------------------------------------------------------------
|
|
193
|
+
// Core miner
|
|
194
|
+
// -------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Mine opening-move patterns from logs.
|
|
198
|
+
*
|
|
199
|
+
* Options:
|
|
200
|
+
* days — how many days of logs to scan (default: 30)
|
|
201
|
+
* logsDir — override log directory (for tests)
|
|
202
|
+
* filterCwd — if provided, only return patterns for this working directory
|
|
203
|
+
*
|
|
204
|
+
* Returns an array of project records, each shaped:
|
|
205
|
+
* {
|
|
206
|
+
* projectRoot: string, // git root or raw cwd
|
|
207
|
+
* totalSessions: number, // distinct run_ids with cwd for this project
|
|
208
|
+
* legacySessions: number, // run_ids seen but lacking cwd field (info only)
|
|
209
|
+
* tools: Array<{
|
|
210
|
+
* key: string, // stable tool key
|
|
211
|
+
* tool: string, // 'read' | 'glob' | 'grep'
|
|
212
|
+
* cmd: string, // display-friendly target/pattern
|
|
213
|
+
* count: number, // sessions that included this tool early
|
|
214
|
+
* totalSessions: number, // same as parent.totalSessions
|
|
215
|
+
* confidence: number, // count / totalSessions (0–1)
|
|
216
|
+
* medianPosition: number, // 1-indexed median ordinal position in early window
|
|
217
|
+
* }>
|
|
218
|
+
* }
|
|
219
|
+
*/
|
|
220
|
+
function mine(opts) {
|
|
221
|
+
const { days = 30, logsDir, filterCwd } = opts || {};
|
|
222
|
+
|
|
223
|
+
const entries = readRecentEntries(days, logsDir);
|
|
224
|
+
const runsMap = groupByRun(entries);
|
|
225
|
+
|
|
226
|
+
// Determine what project root the caller wants (if filtering)
|
|
227
|
+
let filterRoot = null;
|
|
228
|
+
if (filterCwd) {
|
|
229
|
+
filterRoot = getGitRoot(filterCwd) || filterCwd;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// project_root → { projectRoot, sessions: Set, toolCounts: Map, legacySessions: number }
|
|
233
|
+
const projects = new Map();
|
|
234
|
+
let totalLegacy = 0;
|
|
235
|
+
|
|
236
|
+
for (const [, runEntries] of runsMap) {
|
|
237
|
+
const cwd = runCwd(runEntries);
|
|
238
|
+
if (!cwd) {
|
|
239
|
+
// Pre-schema run; count it if it falls under the filter project
|
|
240
|
+
// but we can't know which project it belongs to — just count globally.
|
|
241
|
+
totalLegacy++;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const gitRoot = getGitRoot(cwd);
|
|
246
|
+
const projectRoot = gitRoot || cwd;
|
|
247
|
+
|
|
248
|
+
if (filterRoot && projectRoot !== filterRoot) continue;
|
|
249
|
+
|
|
250
|
+
if (!projects.has(projectRoot)) {
|
|
251
|
+
projects.set(projectRoot, {
|
|
252
|
+
projectRoot,
|
|
253
|
+
sessions: new Set(),
|
|
254
|
+
toolCounts: new Map(), // toolKey → { count, positions[], tool, cmd }
|
|
255
|
+
legacySessions: 0,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const proj = projects.get(projectRoot);
|
|
260
|
+
proj.sessions.add(runEntries[0].run_id);
|
|
261
|
+
|
|
262
|
+
const tools = earlyTools(runEntries, EARLY_TOOL_LIMIT);
|
|
263
|
+
tools.forEach((t, idx) => {
|
|
264
|
+
const key = buildToolKey(t, projectRoot);
|
|
265
|
+
if (!proj.toolCounts.has(key)) {
|
|
266
|
+
proj.toolCounts.set(key, {
|
|
267
|
+
count: 0,
|
|
268
|
+
positions: [],
|
|
269
|
+
tool: normalizeToolName(t.tool),
|
|
270
|
+
cmd: t.cmd || '',
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
const rec = proj.toolCounts.get(key);
|
|
274
|
+
rec.count++;
|
|
275
|
+
rec.positions.push(idx + 1); // 1-indexed position in early window
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const result = [];
|
|
280
|
+
for (const [, proj] of projects) {
|
|
281
|
+
const totalSessions = proj.sessions.size;
|
|
282
|
+
if (totalSessions === 0) continue;
|
|
283
|
+
|
|
284
|
+
const toolsList = [];
|
|
285
|
+
for (const [key, rec] of proj.toolCounts) {
|
|
286
|
+
const confidence = rec.count / totalSessions;
|
|
287
|
+
const medianPosition = median(rec.positions);
|
|
288
|
+
toolsList.push({
|
|
289
|
+
key,
|
|
290
|
+
tool: rec.tool,
|
|
291
|
+
cmd: rec.cmd,
|
|
292
|
+
count: rec.count,
|
|
293
|
+
totalSessions,
|
|
294
|
+
confidence,
|
|
295
|
+
medianPosition,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Sort: confidence desc, then median position asc (earlier = better preflight candidate)
|
|
300
|
+
toolsList.sort((a, b) =>
|
|
301
|
+
b.confidence - a.confidence || a.medianPosition - b.medianPosition
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
result.push({
|
|
305
|
+
projectRoot: proj.projectRoot,
|
|
306
|
+
totalSessions,
|
|
307
|
+
legacySessions: proj.legacySessions,
|
|
308
|
+
tools: toolsList,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return result;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
module.exports = {
|
|
316
|
+
mine,
|
|
317
|
+
// Exported for testing
|
|
318
|
+
readRecentEntries,
|
|
319
|
+
groupByRun,
|
|
320
|
+
earlyTools,
|
|
321
|
+
buildToolKey,
|
|
322
|
+
normalizeToolName,
|
|
323
|
+
relativize,
|
|
324
|
+
getGitRoot,
|
|
325
|
+
median,
|
|
326
|
+
runCwd,
|
|
327
|
+
EARLY_TOOL_LIMIT,
|
|
328
|
+
MIN_SESSIONS_FOR_PATTERN,
|
|
329
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Agent router — picks an adapter for an incoming proxy request.
|
|
5
|
+
*
|
|
6
|
+
* Detection signal: the `x-occasio-agent` HTTP header.
|
|
7
|
+
* - Header value matches a registered agent → that adapter.
|
|
8
|
+
* - Header missing or unknown → claude-code (default).
|
|
9
|
+
*
|
|
10
|
+
* The header is Occasio-internal: callers (Cline, etc.) set it; the
|
|
11
|
+
* proxy strips it before forwarding to Anthropic.
|
|
12
|
+
*
|
|
13
|
+
* This module deliberately knows about no specific adapter. Adapters are
|
|
14
|
+
* passed in as a map by the proxy bootstrap. That keeps this file
|
|
15
|
+
* agent-agnostic and testable in isolation.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Content fingerprint — given an Anthropic SSE response buffer and the
|
|
20
|
+
* registry, find the agent whose tool-name map best matches the tool blocks
|
|
21
|
+
* in the response. Returns the agentId or null if no agent claims any name.
|
|
22
|
+
*
|
|
23
|
+
* Used as a fallback signal when the explicit header is absent (e.g., the
|
|
24
|
+
* user's tool doesn't expose a custom-headers UI). Content fingerprint is
|
|
25
|
+
* deterministic and cannot misroute Claude Code traffic — Claude Code's
|
|
26
|
+
* tool names (`Read`, `Bash`) only exist in the claude-code map.
|
|
27
|
+
*/
|
|
28
|
+
function detectAgentFromSse(sseBody, adapters, registry) {
|
|
29
|
+
if (!sseBody || !adapters || !registry) return null;
|
|
30
|
+
try {
|
|
31
|
+
const { parseSSE } = require('../interceptor');
|
|
32
|
+
const parsed = parseSSE(sseBody);
|
|
33
|
+
const blocks = parsed && parsed.blocks ? Object.values(parsed.blocks) : [];
|
|
34
|
+
const names = blocks.filter(b => b && b.type === 'tool_use').map(b => b.name);
|
|
35
|
+
if (!names.length) return null;
|
|
36
|
+
|
|
37
|
+
let best = null;
|
|
38
|
+
let bestScore = 0;
|
|
39
|
+
for (const agentId of Object.keys(adapters)) {
|
|
40
|
+
const score = names.reduce(
|
|
41
|
+
(n, name) => n + (registry.toCanonical(agentId, name) ? 1 : 0),
|
|
42
|
+
0,
|
|
43
|
+
);
|
|
44
|
+
if (score > bestScore) { best = agentId; bestScore = score; }
|
|
45
|
+
}
|
|
46
|
+
return bestScore > 0 ? best : null;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pick an adapter for an incoming proxy request.
|
|
54
|
+
*
|
|
55
|
+
* Resolution order:
|
|
56
|
+
* 1. Explicit header `x-occasio-agent` → that adapter
|
|
57
|
+
* 2. Content fingerprint (when sseBody + registry supplied)
|
|
58
|
+
* 3. defaultAgent
|
|
59
|
+
*
|
|
60
|
+
* @param {object} headers
|
|
61
|
+
* @param {object} adapters { [agentId]: adapterModule, ... }
|
|
62
|
+
* @param {string} defaultAgent agentId to pick when nothing else matches
|
|
63
|
+
* @param {object} [opts] { sseBody, registry } for content fingerprint
|
|
64
|
+
* @returns {{ adapter, agentId, source: 'header' | 'fingerprint' | 'default' }}
|
|
65
|
+
*/
|
|
66
|
+
function selectAdapter(headers, adapters, defaultAgent, opts = {}) {
|
|
67
|
+
const raw = headers && headers['x-occasio-agent'];
|
|
68
|
+
if (typeof raw === 'string') {
|
|
69
|
+
const id = raw.trim().toLowerCase();
|
|
70
|
+
if (adapters && adapters[id]) {
|
|
71
|
+
return { adapter: adapters[id], agentId: id, source: 'header' };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Content fingerprint — used when the header is missing or the tool's
|
|
76
|
+
// request configuration didn't forward it. Only fires when sseBody and
|
|
77
|
+
// registry are provided (proxy supplies them; unit tests can omit).
|
|
78
|
+
if (opts.sseBody && opts.registry) {
|
|
79
|
+
const detected = detectAgentFromSse(opts.sseBody, adapters, opts.registry);
|
|
80
|
+
if (detected && detected !== defaultAgent) {
|
|
81
|
+
return { adapter: adapters[detected], agentId: detected, source: 'fingerprint' };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!adapters || !adapters[defaultAgent]) {
|
|
86
|
+
throw new Error(`agent-router: defaultAgent "${defaultAgent}" not in adapters map`);
|
|
87
|
+
}
|
|
88
|
+
return { adapter: adapters[defaultAgent], agentId: defaultAgent, source: 'default' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const HEADER_NAME = 'x-occasio-agent';
|
|
92
|
+
|
|
93
|
+
module.exports = { selectAdapter, detectAgentFromSse, HEADER_NAME };
|