syndes 0.1.0

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 (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -0
  3. package/adapters/claude-code.mjs +59 -0
  4. package/adapters/codex.mjs +256 -0
  5. package/adapters/index.mjs +92 -0
  6. package/analytics/index.mjs +189 -0
  7. package/analytics/metrics/context.mjs +95 -0
  8. package/analytics/metrics/cost.mjs +83 -0
  9. package/analytics/metrics/friction.mjs +86 -0
  10. package/analytics/metrics/prompts.mjs +93 -0
  11. package/analytics/metrics/rework.mjs +113 -0
  12. package/analytics/metrics/time.mjs +104 -0
  13. package/analytics/metrics/tokens.mjs +88 -0
  14. package/analytics/metrics/tools.mjs +118 -0
  15. package/analytics/metrics/volume.mjs +98 -0
  16. package/analytics/ranges.mjs +98 -0
  17. package/analytics/rollup.mjs +151 -0
  18. package/analytics/score.mjs +194 -0
  19. package/bin/cli.mjs +596 -0
  20. package/bin/postinstall.mjs +44 -0
  21. package/collect/classify.mjs +226 -0
  22. package/collect/git.mjs +78 -0
  23. package/collect/projects.mjs +82 -0
  24. package/collect/redact.mjs +85 -0
  25. package/collect/sessions.mjs +119 -0
  26. package/collect/tail.mjs +126 -0
  27. package/collect/tools.mjs +121 -0
  28. package/collect/transcript.mjs +128 -0
  29. package/dashboard/api/index.mjs +296 -0
  30. package/dashboard/auth.mjs +235 -0
  31. package/dashboard/router.mjs +55 -0
  32. package/dashboard/security.mjs +95 -0
  33. package/dashboard/server.mjs +156 -0
  34. package/dashboard/static.mjs +47 -0
  35. package/dashboard/web/SynDes.icns +0 -0
  36. package/dashboard/web/api.js +80 -0
  37. package/dashboard/web/app.css +532 -0
  38. package/dashboard/web/app.js +261 -0
  39. package/dashboard/web/charts.js +273 -0
  40. package/dashboard/web/index.html +23 -0
  41. package/dashboard/web/logo.png +0 -0
  42. package/dashboard/web/ui.js +434 -0
  43. package/dashboard/web/views/habits.js +166 -0
  44. package/dashboard/web/views/ledger.js +164 -0
  45. package/dashboard/web/views/overview.js +214 -0
  46. package/dashboard/web/views/sessions.js +133 -0
  47. package/dashboard/web/views/settings.js +180 -0
  48. package/ledger/append.mjs +126 -0
  49. package/ledger/chain.mjs +53 -0
  50. package/ledger/keys.mjs +72 -0
  51. package/ledger/read.mjs +77 -0
  52. package/ledger/retention.mjs +104 -0
  53. package/ledger/schema.mjs +96 -0
  54. package/ledger/segments.mjs +109 -0
  55. package/ledger/verify.mjs +174 -0
  56. package/notify/index.mjs +67 -0
  57. package/notify/linux.mjs +41 -0
  58. package/notify/mac.mjs +44 -0
  59. package/notify/terminal.mjs +15 -0
  60. package/notify/windows.mjs +61 -0
  61. package/package.json +66 -0
  62. package/practices/budget.mjs +97 -0
  63. package/practices/catalog.mjs +64 -0
  64. package/practices/deliver.mjs +101 -0
  65. package/practices/engine.mjs +107 -0
  66. package/practices/rules/batch-tool-calls.mjs +15 -0
  67. package/practices/rules/context-hygiene.mjs +17 -0
  68. package/practices/rules/delegate-wide-search.mjs +15 -0
  69. package/practices/rules/index.mjs +28 -0
  70. package/practices/rules/permission-friction.mjs +16 -0
  71. package/practices/rules/project-memory.mjs +27 -0
  72. package/practices/rules/prompt-specificity.mjs +15 -0
  73. package/practices/rules/read-before-edit.mjs +16 -0
  74. package/practices/rules/retry-storm.mjs +22 -0
  75. package/practices/rules/session-sprawl.mjs +15 -0
  76. package/practices/rules/verify-after-change.mjs +16 -0
  77. package/runtime/config.mjs +116 -0
  78. package/runtime/hook.mjs +154 -0
  79. package/runtime/jsonl.mjs +104 -0
  80. package/runtime/lock.mjs +98 -0
  81. package/runtime/log.mjs +37 -0
  82. package/runtime/paths.mjs +116 -0
  83. package/runtime/platform.mjs +74 -0
  84. package/runtime/spool.mjs +92 -0
  85. package/runtime/worker.mjs +275 -0
  86. package/src/briefing.mjs +94 -0
  87. package/src/doctor.mjs +153 -0
  88. package/src/export.mjs +68 -0
  89. package/src/install.mjs +95 -0
  90. package/src/open.mjs +23 -0
  91. package/src/report.mjs +120 -0
  92. package/src/settings.mjs +173 -0
  93. package/src/status.mjs +61 -0
  94. package/src/systemauth.mjs +179 -0
  95. package/src/term.mjs +272 -0
  96. package/src/uninstall.mjs +43 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 guruprasath005
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # SynDes
2
+
3
+ **A tamper-evident ledger of everything you do in Claude Code, an efficiency
4
+ score built from it, and a local dashboard that shows you how you actually work.**
5
+
6
+ macOS, Windows and Linux. Zero dependencies. Nothing leaves your machine.
7
+
8
+ ```bash
9
+ npm install -g syndes
10
+ ```
11
+
12
+ That is the whole setup. It wires itself into Claude Code, opens the ledger and
13
+ starts recording; your next `syndes` explains the rest.
14
+
15
+ ## What it does
16
+
17
+ **Works with more than one agent.** Claude Code through its hook API, Codex by
18
+ following its session files. Everything below that — the chain, the score, the
19
+ practice rules, the dashboard — is agent-agnostic, so adding another is one
20
+ adapter, not a rewrite. `syndes sources` shows what it can see.
21
+
22
+ **Records every move.** Every prompt, tool call, permission block, compaction,
23
+ interrupt, token and dollar — appended to a hash-chained log that cannot be
24
+ edited, re-ordered or back-dated without `syndes verify` naming the exact
25
+ record where the chain breaks.
26
+
27
+ **Tells you how to work better.** A dozen practice rules run over that ledger and
28
+ surface at most one nudge, at a moment that does not interrupt you, always with
29
+ the evidence attached: *"you edited `src/app.ts` six times in twelve minutes
30
+ without running tests."*
31
+
32
+ **Shows you the whole picture.** `syndes dashboard` opens a local
33
+ dashboard: your efficiency score and its five pillars, token and cost
34
+ breakdowns, cache hit rate, active time versus wall-clock, permission friction,
35
+ tool mix, per-project history — every number clickable through to the records
36
+ that produced it.
37
+
38
+ **No password to invent.** The dashboard opens straight from the command line;
39
+ the link it prints carries a one-time key. If you want a lock, use the one your
40
+ machine already has — `syndes lock system` asks Touch ID — or set a PIN.
41
+
42
+ ## Commands
43
+
44
+ | | |
45
+ | --- | --- |
46
+ | `syndes` | today at a glance |
47
+ | `syndes dashboard` | the full UI, on 127.0.0.1 |
48
+ | `syndes report week` | terminal digest |
49
+ | `syndes verify` | prove the chain is intact |
50
+ | `syndes sessions` | list and drill down |
51
+ | `syndes export --format=csv` | all of it, back out |
52
+ | `syndes lock system` | require Touch ID to open the dashboard |
53
+ | `syndes sources` | which coding agents SynDes can see |
54
+ | `syndes watch` | follow agents that have no hook API |
55
+ | `syndes doctor` | what is wired and what actually works |
56
+ | `syndes off` | stop tracking (recorded in the ledger) |
57
+
58
+ ## Privacy
59
+
60
+ No telemetry, no endpoint, no account. Secrets are scrubbed **before** anything
61
+ is written, because an append-only ledger has no take-backs. Three modes:
62
+ `redacted` (default), `full`, `metadata`.
63
+
64
+ What keeps the dashboard private is that it binds loopback and refuses any
65
+ request whose `Host` is not a literal loopback address — without that, any web
66
+ page you have open could read it. The lock is the second layer, for machines
67
+ other people also use.
68
+
69
+ ## Honest limits
70
+
71
+ The ledger is **tamper-evident, not tamper-proof**. Anything running as your
72
+ user can delete the directory. What it guarantees is that nothing can be quietly
73
+ *changed* — see `DESIGN.md` §2.
74
+
75
+ ## License
76
+
77
+ MIT
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Claude Code — the hook adapter.
3
+ *
4
+ * The highest-fidelity source we have: Claude Code invokes our hook on every
5
+ * event, so permission stops, tool boundaries and compactions are all observed
6
+ * as they happen rather than inferred from a log afterwards.
7
+ *
8
+ * The classification logic itself lives in collect/classify.mjs, which predates
9
+ * the adapter layer and is exercised by its own tests; this file is the seam
10
+ * that lets a second agent exist beside it.
11
+ */
12
+
13
+ import { existsSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { homedir } from 'node:os';
16
+ import { claudeDir, settingsFile } from '../runtime/paths.mjs';
17
+ import { classify as classifyPayload } from '../collect/classify.mjs';
18
+ import { familyOf as toolFamily } from '../collect/tools.mjs';
19
+ import { installHooks, uninstallHooks, hookCommand, installedEvents, HOOK_EVENTS } from '../src/settings.mjs';
20
+
21
+ export default {
22
+ id: 'claude-code',
23
+ name: 'Claude Code',
24
+ capture: 'hook',
25
+
26
+ detect() {
27
+ // The config directory is the honest signal. A `claude` binary may live
28
+ // anywhere or be a shell alias, but nothing creates ~/.claude except
29
+ // Claude Code having run.
30
+ const present = existsSync(claudeDir) || existsSync(join(homedir(), '.claude'));
31
+ return {
32
+ present,
33
+ detail: present ? `hooks in ${settingsFile.replace(homedir(), '~')}` : 'no ~/.claude directory found',
34
+ };
35
+ },
36
+
37
+ install() {
38
+ const { backup, events } = installHooks(hookCommand());
39
+ return { steps: [{ step: 'hooks', detail: `${events.length} events`, backup }] };
40
+ },
41
+
42
+ uninstall() {
43
+ const { removed, backup } = uninstallHooks();
44
+ return { steps: [{ step: 'hooks', detail: removed.length ? removed.join(', ') : 'none were wired', backup }] };
45
+ },
46
+
47
+ status() {
48
+ const { events } = installedEvents();
49
+ return { wired: events.length, expected: HOOK_EVENTS.length, events };
50
+ },
51
+
52
+ classify(raw, ctx) {
53
+ return classifyPayload(raw, ctx);
54
+ },
55
+
56
+ familyOf(tool) {
57
+ return toolFamily(tool);
58
+ },
59
+ };
@@ -0,0 +1,256 @@
1
+ /**
2
+ * OpenAI Codex CLI — the tail adapter.
3
+ *
4
+ * Codex has no hook API, so nothing invokes us. Instead the watcher follows the
5
+ * rollout files Codex writes under ~/.codex/sessions and turns appended lines
6
+ * into the same records the Claude Code hooks produce.
7
+ *
8
+ * What that costs: no permission-stop events and coarser timing, because the
9
+ * transcript records what happened rather than what was about to. What it buys:
10
+ * it needs no cooperation from Codex at all, and the same approach works for
11
+ * any agent that writes a session log — which is nearly all of them.
12
+ *
13
+ * The format is Codex's, not ours. Parse defensively; an unrecognised line is
14
+ * skipped, never fatal.
15
+ */
16
+
17
+ import { existsSync, readdirSync, statSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { homedir } from 'node:os';
20
+ import { draft, KIND } from '../ledger/schema.mjs';
21
+ import { redact, clamp } from '../collect/redact.mjs';
22
+
23
+ const HOME = homedir();
24
+ const CODEX_DIR = process.env.CODEX_HOME?.trim() || join(HOME, '.codex');
25
+ const SESSIONS = join(CODEX_DIR, 'sessions');
26
+
27
+ /** Follow files touched in the last week; older ones are finished history. */
28
+ const RECENT_MS = 7 * 24 * 60 * 60 * 1000;
29
+
30
+ const FAMILY = {
31
+ shell: 'execute',
32
+ local_shell: 'execute',
33
+ exec_command: 'execute',
34
+ apply_patch: 'edit',
35
+ read_file: 'read',
36
+ view_image: 'read',
37
+ update_plan: 'plan',
38
+ web_search: 'web',
39
+ mcp_tool_call: 'mcp',
40
+ };
41
+
42
+ export default {
43
+ id: 'codex',
44
+ name: 'Codex CLI',
45
+ capture: 'tail',
46
+
47
+ detect() {
48
+ if (!existsSync(CODEX_DIR)) return { present: false, detail: 'no ~/.codex directory found' };
49
+ const files = list();
50
+ return {
51
+ present: true,
52
+ detail: files.length
53
+ ? `${files.length} recent session file(s) in ~/.codex/sessions`
54
+ : 'installed, but no recent sessions to read',
55
+ };
56
+ },
57
+
58
+ /** Nothing to wire: tailing needs no cooperation from the agent. */
59
+ install() {
60
+ return { steps: [{ step: 'watch', detail: `following ${SESSIONS.replace(HOME, '~')}` }] };
61
+ },
62
+
63
+ uninstall() {
64
+ return { steps: [{ step: 'watch', detail: 'stopped following ~/.codex/sessions' }] };
65
+ },
66
+
67
+ sources() {
68
+ return list();
69
+ },
70
+
71
+ familyOf(tool) {
72
+ if (!tool) return 'other';
73
+ if (tool.startsWith('mcp')) return 'mcp';
74
+ return FAMILY[tool] ?? 'other';
75
+ },
76
+
77
+ /**
78
+ * One transcript line → drafts.
79
+ *
80
+ * @param {{line: object, file: string}} raw
81
+ */
82
+ classify(raw, ctx) {
83
+ const entry = raw?.line;
84
+ if (!entry || typeof entry !== 'object') return [];
85
+
86
+ const ts = Date.parse(entry.timestamp ?? '') || raw.at || Date.now();
87
+ const session = raw.session ?? null;
88
+ const project = ctx?.project?.id ?? null;
89
+ const base = { ts, session, project, source: 'codex' };
90
+ const mode = ctx?.config?.privacy ?? 'redacted';
91
+ const extra = ctx?.config?.redactPatterns ?? [];
92
+
93
+ const payload = entry.payload ?? entry;
94
+ const type = entry.type ?? payload?.type;
95
+
96
+ switch (type) {
97
+ case 'session_meta':
98
+ case 'session.created':
99
+ return [draft(KIND.SESSION_START, {
100
+ ...base,
101
+ data: { source: 'startup', cwd: mode === 'metadata' ? null : payload.cwd ?? null, agent: 'codex' },
102
+ })];
103
+
104
+ case 'user_message':
105
+ case 'message': {
106
+ if (payload.role && payload.role !== 'user') return [];
107
+ const text = textOf(payload);
108
+ if (!text) return [];
109
+ const scrubbed = redact(text, { mode, extra });
110
+ const stored = clamp(scrubbed.text);
111
+ return [draft(KIND.PROMPT, {
112
+ ...base,
113
+ data: {
114
+ chars: text.length,
115
+ words: text.trim() ? text.trim().split(/\s+/).length : 0,
116
+ lines: text.split('\n').length,
117
+ text: stored.text,
118
+ truncated: stored.truncated,
119
+ redactions: scrubbed.redactions,
120
+ correction: /^\s*(no|nope)\b|\bi (meant|said)\b|\bactually\b/i.test(text),
121
+ },
122
+ })];
123
+ }
124
+
125
+ case 'function_call':
126
+ case 'tool_call': {
127
+ const tool = payload.name ?? payload.tool ?? 'unknown';
128
+ return [draft(KIND.TOOL_PRE, {
129
+ ...base,
130
+ data: {
131
+ tool,
132
+ family: this.familyOf(tool),
133
+ target: mode === 'metadata' ? null : targetOf(tool, payload),
134
+ searchViaBash: this.familyOf(tool) === 'execute' && /\b(grep|rg|find|fd)\b/.test(argText(payload)),
135
+ verification: this.familyOf(tool) === 'execute' && /\b(test|lint|build|typecheck|pytest|cargo)\b/.test(argText(payload)),
136
+ },
137
+ })];
138
+ }
139
+
140
+ case 'function_call_output':
141
+ case 'tool_result': {
142
+ const tool = payload.name ?? payload.tool ?? 'unknown';
143
+ const failed = payload.success === false || payload.is_error === true ||
144
+ /^error\b/i.test(String(payload.output ?? payload.content ?? '').slice(0, 40));
145
+ const drafts = [draft(KIND.TOOL_POST, {
146
+ ...base,
147
+ data: { tool, family: this.familyOf(tool), target: mode === 'metadata' ? null : targetOf(tool, payload), durationMs: null, failed },
148
+ })];
149
+
150
+ if (this.familyOf(tool) === 'edit') {
151
+ const path = targetOf(tool, payload);
152
+ drafts.push(draft(KIND.FILE_TOUCH, {
153
+ ...base,
154
+ // Codex does not tell us whether the file was read first, and
155
+ // guessing would put a fabricated number into the score.
156
+ data: { path: mode === 'metadata' ? null : path, ext: extOf(path), op: 'edit', readFirst: null },
157
+ }));
158
+ }
159
+ return drafts;
160
+ }
161
+
162
+ case 'token_count':
163
+ case 'usage': {
164
+ const usage = payload.info?.total_token_usage ?? payload.usage ?? payload;
165
+ if (!usage || typeof usage !== 'object') return [];
166
+ return [draft(KIND.USAGE, {
167
+ ...base,
168
+ data: {
169
+ model: payload.model ?? entry.model ?? 'codex',
170
+ input: usage.input_tokens ?? 0,
171
+ output: usage.output_tokens ?? 0,
172
+ cacheRead: usage.cached_input_tokens ?? usage.cache_read_input_tokens ?? 0,
173
+ cacheWrite: 0,
174
+ thinking: usage.reasoning_output_tokens ?? 0,
175
+ toolUses: 0,
176
+ sidechain: false,
177
+ },
178
+ })];
179
+ }
180
+
181
+ case 'turn_complete':
182
+ case 'task_complete':
183
+ return [draft(KIND.STOP, { ...base, data: { chained: false } })];
184
+
185
+ default:
186
+ return [];
187
+ }
188
+ },
189
+ };
190
+
191
+ /** Session files touched recently, newest last so replay order is natural. */
192
+ function list() {
193
+ if (!existsSync(SESSIONS)) return [];
194
+ const out = [];
195
+ const cutoff = Date.now() - RECENT_MS;
196
+
197
+ const walk = (dir, depth = 0) => {
198
+ if (depth > 4) return;
199
+ let entries;
200
+ try {
201
+ entries = readdirSync(dir, { withFileTypes: true });
202
+ } catch {
203
+ return;
204
+ }
205
+ for (const entry of entries) {
206
+ const full = join(dir, entry.name);
207
+ if (entry.isDirectory()) walk(full, depth + 1);
208
+ else if (entry.name.endsWith('.jsonl')) {
209
+ try {
210
+ if (statSync(full).mtimeMs >= cutoff) out.push(full);
211
+ } catch { /* vanished between readdir and stat */ }
212
+ }
213
+ }
214
+ };
215
+
216
+ walk(SESSIONS);
217
+ return out.sort();
218
+ }
219
+
220
+ function textOf(payload) {
221
+ if (typeof payload.content === 'string') return payload.content;
222
+ if (typeof payload.message === 'string') return payload.message;
223
+ if (Array.isArray(payload.content)) {
224
+ return payload.content.map((part) => part?.text ?? '').filter(Boolean).join('\n');
225
+ }
226
+ return '';
227
+ }
228
+
229
+ function argText(payload) {
230
+ try {
231
+ return typeof payload.arguments === 'string' ? payload.arguments : JSON.stringify(payload.arguments ?? {});
232
+ } catch {
233
+ return '';
234
+ }
235
+ }
236
+
237
+ function targetOf(tool, payload) {
238
+ let args = payload.arguments;
239
+ if (typeof args === 'string') {
240
+ try { args = JSON.parse(args); } catch { args = {}; }
241
+ }
242
+ args ??= {};
243
+
244
+ if (Array.isArray(args.command)) return args.command[0]?.split('/').pop()?.slice(0, 40) ?? null;
245
+ if (typeof args.command === 'string') return args.command.trim().split(/\s+/)[0]?.split('/').pop()?.slice(0, 40) ?? null;
246
+ return args.path ?? args.file_path ?? args.filename ?? null;
247
+ }
248
+
249
+ function extOf(path) {
250
+ if (typeof path !== 'string') return null;
251
+ const name = path.split(/[\\/]/).pop() ?? '';
252
+ const dot = name.lastIndexOf('.');
253
+ return dot > 0 ? name.slice(dot + 1).toLowerCase().slice(0, 12) : null;
254
+ }
255
+
256
+ export { SESSIONS, CODEX_DIR };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Source adapters — how SynDes learns what an agent did.
3
+ *
4
+ * Everything below this layer is already agent-agnostic: the chain, the
5
+ * rollups, the score, the practice rules and the dashboard all operate on
6
+ * normalised records and never ask where one came from. So supporting a new
7
+ * coding agent means writing one adapter, not touching any of that.
8
+ *
9
+ * ── The contract ────────────────────────────────────────────────────────────
10
+ * An adapter is an object with:
11
+ *
12
+ * id stable string written into every record's `source`
13
+ * name what a human calls it
14
+ * capture 'hook' | 'tail' — how events arrive (see below)
15
+ * detect() → { present, detail } is this agent installed here?
16
+ * install() → { steps } wire it up (hook adapters only)
17
+ * uninstall() → { steps }
18
+ * classify(raw, ctx) → draft[] raw event → ledger records
19
+ * familyOf(tool) → family its tool names → our shared taxonomy
20
+ * sources() → string[] files to tail (tail adapters only)
21
+ *
22
+ * ── The two capture modes ───────────────────────────────────────────────────
23
+ * `hook` is the high-fidelity path: the agent invokes us on every event, so we
24
+ * see permission stops and tool boundaries as they happen. Only agents with a
25
+ * hook API can do this.
26
+ *
27
+ * `tail` needs no cooperation at all — a watcher follows the agent's own
28
+ * session files and turns appended lines into records. Lower fidelity (no
29
+ * permission events, coarser timing) but it works for anything that writes a
30
+ * transcript, which is most of them.
31
+ */
32
+
33
+ import claudeCode from './claude-code.mjs';
34
+ import codex from './codex.mjs';
35
+
36
+ export const ADAPTERS = [claudeCode, codex];
37
+
38
+ export const DEFAULT_SOURCE = claudeCode.id;
39
+
40
+ export function adapterFor(id) {
41
+ return ADAPTERS.find((adapter) => adapter.id === id) ?? null;
42
+ }
43
+
44
+ export function adapterIds() {
45
+ return ADAPTERS.map((adapter) => adapter.id);
46
+ }
47
+
48
+ /** Adapters whose agent is actually installed on this machine. */
49
+ export function detectAll() {
50
+ return ADAPTERS.map((adapter) => {
51
+ let found = { present: false, detail: 'detection failed' };
52
+ try {
53
+ found = adapter.detect();
54
+ } catch (error) {
55
+ found = { present: false, detail: error.message };
56
+ }
57
+ return { id: adapter.id, name: adapter.name, capture: adapter.capture, ...found };
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Map a tool name to our shared family taxonomy.
63
+ *
64
+ * Every agent names its tools differently — Read/Grep/Bash here,
65
+ * shell/apply_patch there. Normalising into one set of families is what makes
66
+ * "you search through the shell more than you should" a statement that holds
67
+ * across agents rather than one that only means something for Claude Code.
68
+ */
69
+ export function familyOf(source, tool) {
70
+ return adapterFor(source)?.familyOf?.(tool) ?? 'other';
71
+ }
72
+
73
+ /** Turn one raw captured event into ledger drafts. */
74
+ export function classify(source, raw, ctx) {
75
+ const adapter = adapterFor(source);
76
+ if (!adapter) return [];
77
+ return adapter.classify(raw, ctx).map((record) => ({ ...record, source }));
78
+ }
79
+
80
+ /** Every file the watcher should follow, across all tail adapters. */
81
+ export function tailTargets() {
82
+ const out = [];
83
+ for (const adapter of ADAPTERS) {
84
+ if (adapter.capture !== 'tail') continue;
85
+ try {
86
+ for (const file of adapter.sources() ?? []) out.push({ source: adapter.id, file });
87
+ } catch {
88
+ // An agent that is not installed simply contributes nothing to follow.
89
+ }
90
+ }
91
+ return out;
92
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * The one façade the CLI and the dashboard both call.
3
+ *
4
+ * No metric is computed in two places. The alternative is a terminal report and
5
+ * a UI that eventually disagree, with one of them wrong in a way nobody notices
6
+ * for months.
7
+ */
8
+
9
+ import { rangeFor, previousRange, localDay } from './ranges.mjs';
10
+ import { rollupsFor, mergeRollups } from './rollup.mjs';
11
+ import { score } from './score.mjs';
12
+ import { costOf, savingsFromCache } from './metrics/cost.mjs';
13
+ import { loadConfig } from '../runtime/config.mjs';
14
+ import { nameFor, loadProjects } from '../collect/projects.mjs';
15
+ import { readRange, height } from '../ledger/read.mjs';
16
+ import { KIND } from '../ledger/schema.mjs';
17
+
18
+ export { rangeFor, previousRange, localDay };
19
+
20
+ async function summarise(range) {
21
+ const rollups = await rollupsFor(range);
22
+ const merged = mergeRollups(rollups);
23
+ const prices = loadConfig().prices ?? {};
24
+ return {
25
+ range,
26
+ rollups,
27
+ merged,
28
+ cost: costOf(merged.tokens, prices),
29
+ cacheSavings: savingsFromCache(merged.tokens, prices),
30
+ };
31
+ }
32
+
33
+ /** Everything the overview screen needs, plus the same numbers one period back. */
34
+ export async function overview(spec = '7d') {
35
+ const range = rangeFor(spec);
36
+ const current = await summarise(range);
37
+ const previous = await summarise(previousRange(range));
38
+
39
+ const scored = score(current.merged);
40
+ const before = score(previous.merged);
41
+
42
+ return {
43
+ range: { ...range, days: range.days },
44
+ score: scored,
45
+ scoreDelta: scored.total !== null && before.total !== null ? scored.total - before.total : null,
46
+ headline: headlineOf(current),
47
+ previousHeadline: headlineOf(previous),
48
+ series: current.rollups.map((rollup) => ({
49
+ day: rollup.day,
50
+ records: rollup.records,
51
+ prompts: rollup.metrics?.prompts?.count ?? 0,
52
+ toolCalls: rollup.metrics?.tools?.calls ?? 0,
53
+ activeMs: rollup.metrics?.time?.activeMs ?? 0,
54
+ tokens: totalTokens(rollup.metrics?.tokens),
55
+ // Per-day so the timeline can mark the days the window ran out, which is
56
+ // the single most useful thing to spot across a week.
57
+ compacts: (rollup.metrics?.context?.autoCompacts ?? 0) + (rollup.metrics?.context?.manualCompacts ?? 0),
58
+ sessions: (rollup.metrics?.volume?.sessions ?? []).length,
59
+ cost: 0,
60
+ })),
61
+ hours: current.merged.time.hours,
62
+ weekdays: current.merged.time.weekdays,
63
+ metrics: current.merged,
64
+ cost: current.cost,
65
+ cacheSavings: current.cacheSavings,
66
+ ledger: height(),
67
+ };
68
+ }
69
+
70
+ function headlineOf({ merged, cost }) {
71
+ return {
72
+ sessions: merged.volume.sessionCount,
73
+ prompts: merged.prompts.count,
74
+ toolCalls: merged.tools.calls,
75
+ activeMs: merged.time.activeMs,
76
+ wallMs: merged.time.wallMs,
77
+ tokens: merged.tokens.total,
78
+ cacheHitRate: merged.tokens.cacheHitRate,
79
+ usd: cost.usd,
80
+ files: merged.volume.fileCount,
81
+ commits: merged.volume.commits,
82
+ compacts: merged.context.compacts,
83
+ blocks: merged.friction.blocks,
84
+ };
85
+ }
86
+
87
+ function totalTokens(summary) {
88
+ if (!summary?.byModel) return 0;
89
+ let total = 0;
90
+ for (const bucket of Object.values(summary.byModel)) {
91
+ total += bucket.input + bucket.output + bucket.cacheRead + bucket.cacheWrite;
92
+ }
93
+ return total;
94
+ }
95
+
96
+ /** Per-session detail, reconstructed from the chain rather than from state. */
97
+ export async function sessions(spec = '7d') {
98
+ const range = rangeFor(spec);
99
+ const found = new Map();
100
+
101
+ for await (const record of readRange(range.from, range.to)) {
102
+ const id = record.session;
103
+ if (!id) continue;
104
+
105
+ if (!found.has(id)) {
106
+ found.set(id, {
107
+ id, project: record.project, projectName: null,
108
+ startedAt: record.ts, endedAt: record.ts,
109
+ prompts: 0, toolCalls: 0, writes: 0, blocks: 0, compacts: 0,
110
+ errors: 0, tokens: 0, models: new Set(), files: new Set(),
111
+ ended: false, reason: null,
112
+ });
113
+ }
114
+ const session = found.get(id);
115
+ session.endedAt = Math.max(session.endedAt, record.ts);
116
+ session.project ??= record.project;
117
+
118
+ switch (record.kind) {
119
+ case KIND.PROMPT: session.prompts += 1; break;
120
+ case KIND.TOOL_POST:
121
+ session.toolCalls += 1;
122
+ if (record.data.failed) session.errors += 1;
123
+ break;
124
+ case KIND.FILE_TOUCH:
125
+ session.writes += 1;
126
+ if (record.data.path) session.files.add(record.data.path);
127
+ break;
128
+ case KIND.TOOL_BLOCKED: session.blocks += 1; break;
129
+ case KIND.COMPACT: session.compacts += 1; break;
130
+ case KIND.USAGE:
131
+ session.tokens += (record.data.input ?? 0) + (record.data.output ?? 0) +
132
+ (record.data.cacheRead ?? 0) + (record.data.cacheWrite ?? 0);
133
+ if (record.data.model) session.models.add(record.data.model);
134
+ break;
135
+ case KIND.SESSION_END:
136
+ session.ended = true;
137
+ session.reason = record.data.reason;
138
+ break;
139
+ default: break;
140
+ }
141
+ }
142
+
143
+ const names = loadProjects();
144
+ return [...found.values()]
145
+ .map((session) => ({
146
+ ...session,
147
+ projectName: names[session.project]?.name ?? session.project,
148
+ models: [...session.models],
149
+ files: [...session.files].length,
150
+ durationMs: session.endedAt - session.startedAt,
151
+ }))
152
+ .sort((a, b) => b.startedAt - a.startedAt);
153
+ }
154
+
155
+ /** One session's records, for the timeline view. */
156
+ export async function sessionDetail(sessionId) {
157
+ const events = [];
158
+ for await (const record of readRange(0, Date.now() + 86_400_000)) {
159
+ if (record.session === sessionId) events.push(record);
160
+ }
161
+ return events;
162
+ }
163
+
164
+ export async function byProject(spec = '30d') {
165
+ const range = rangeFor(spec);
166
+ const buckets = new Map();
167
+
168
+ for await (const record of readRange(range.from, range.to)) {
169
+ const id = record.project ?? 'unknown';
170
+ if (!buckets.has(id)) {
171
+ buckets.set(id, { id, name: nameFor(id), records: 0, prompts: 0, toolCalls: 0, tokens: 0, sessions: new Set() });
172
+ }
173
+ const bucket = buckets.get(id);
174
+ bucket.records += 1;
175
+ if (record.session) bucket.sessions.add(record.session);
176
+ if (record.kind === KIND.PROMPT) bucket.prompts += 1;
177
+ if (record.kind === KIND.TOOL_POST) bucket.toolCalls += 1;
178
+ if (record.kind === KIND.USAGE) {
179
+ bucket.tokens += (record.data.input ?? 0) + (record.data.output ?? 0) +
180
+ (record.data.cacheRead ?? 0) + (record.data.cacheWrite ?? 0);
181
+ }
182
+ }
183
+
184
+ return [...buckets.values()]
185
+ .map((bucket) => ({ ...bucket, sessions: bucket.sessions.size }))
186
+ .sort((a, b) => b.records - a.records);
187
+ }
188
+
189
+ export { summarise, score };