@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.
Files changed (92) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +216 -0
  4. package/bin/occasio-mcp.js +5 -0
  5. package/bin/occasio.js +2 -0
  6. package/bin/supervisor/README.md +90 -0
  7. package/bin/supervisor/com.occasio.proxy.plist.template +36 -0
  8. package/bin/supervisor/install-windows-task.ps1 +48 -0
  9. package/bin/supervisor/occasio.service +18 -0
  10. package/docs/AUDIT.md +120 -0
  11. package/docs/attest_verify.py +283 -0
  12. package/docs/audit_walker.py +65 -0
  13. package/docs/canonicalize.py +99 -0
  14. package/docs/compliance-mapping.md +93 -0
  15. package/docs/demos/mcp-block.md +148 -0
  16. package/docs/edr-calibration.md +73 -0
  17. package/docs/edr-demo.md +83 -0
  18. package/docs/python-verifier.md +74 -0
  19. package/docs/reference-pipeline.md +140 -0
  20. package/package.json +69 -0
  21. package/policy-templates/dev-default.yml +84 -0
  22. package/policy-templates/finance.yml +61 -0
  23. package/policy-templates/strict.yml +49 -0
  24. package/schemas/agent-attestation-v1.json +190 -0
  25. package/schemas/occasio-policy.schema.json +99 -0
  26. package/spec/agent-attestation/v1/README.md +137 -0
  27. package/src/adapters/claude-code.js +518 -0
  28. package/src/adapters/cline.js +161 -0
  29. package/src/adapters/computer-use-cli.js +198 -0
  30. package/src/adapters/computer-use.js +227 -0
  31. package/src/analyzer.js +170 -0
  32. package/src/anomaly/cli.js +143 -0
  33. package/src/anomaly/detectors/deny-rate.js +84 -0
  34. package/src/anomaly/detectors/file-read-volume.js +109 -0
  35. package/src/anomaly/detectors/secret-redact-rate.js +107 -0
  36. package/src/anomaly/detectors/unknown-tool-input.js +83 -0
  37. package/src/anomaly/index.js +169 -0
  38. package/src/attest/canonicalize.js +97 -0
  39. package/src/attest/index.js +355 -0
  40. package/src/attest/run-slice.js +57 -0
  41. package/src/attest/sign.js +186 -0
  42. package/src/attest/verify.js +192 -0
  43. package/src/audit/errors.js +21 -0
  44. package/src/audit/input-normalizer.js +121 -0
  45. package/src/audit/jsonl-auditor.js +178 -0
  46. package/src/audit/verifier.js +152 -0
  47. package/src/baseline.js +507 -0
  48. package/src/boundary.js +238 -0
  49. package/src/budget.js +42 -0
  50. package/src/classifier.js +115 -0
  51. package/src/context-budget.js +77 -0
  52. package/src/core/boundary-event.js +75 -0
  53. package/src/core/decision.js +61 -0
  54. package/src/core/pipeline.js +66 -0
  55. package/src/core/tool-names.js +105 -0
  56. package/src/dashboard.js +892 -0
  57. package/src/demo/README.md +31 -0
  58. package/src/demo/anomalies-demo.js +211 -0
  59. package/src/demo/attest-demo.js +198 -0
  60. package/src/distiller.js +155 -0
  61. package/src/embeddings.json +72 -0
  62. package/src/executor/dispatcher.js +230 -0
  63. package/src/harness.js +817 -0
  64. package/src/index.js +1711 -0
  65. package/src/inspect.js +329 -0
  66. package/src/interceptor.js +1198 -0
  67. package/src/lao.js +185 -0
  68. package/src/lao_prep.py +119 -0
  69. package/src/ledger.js +209 -0
  70. package/src/mcp-experiment.js +140 -0
  71. package/src/mcp-normalize.js +139 -0
  72. package/src/mcp-server.js +320 -0
  73. package/src/outbound-policy.js +433 -0
  74. package/src/policy/built-in-classifiers.js +78 -0
  75. package/src/policy/doctor.js +226 -0
  76. package/src/policy/engine.js +339 -0
  77. package/src/policy/init.js +153 -0
  78. package/src/policy/loader.js +448 -0
  79. package/src/policy/rules-default.js +36 -0
  80. package/src/policy/shell-path.js +135 -0
  81. package/src/policy/show.js +196 -0
  82. package/src/policy/validate.js +310 -0
  83. package/src/preflight/cli.js +164 -0
  84. package/src/preflight/miner.js +329 -0
  85. package/src/proxy/agent-router.js +93 -0
  86. package/src/redteam.js +428 -0
  87. package/src/replay.js +446 -0
  88. package/src/report/index.js +224 -0
  89. package/src/runtime.js +595 -0
  90. package/src/scanner/index.js +49 -0
  91. package/src/selftest.js +192 -0
  92. 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 };