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.
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/adapters/claude-code.mjs +59 -0
- package/adapters/codex.mjs +256 -0
- package/adapters/index.mjs +92 -0
- package/analytics/index.mjs +189 -0
- package/analytics/metrics/context.mjs +95 -0
- package/analytics/metrics/cost.mjs +83 -0
- package/analytics/metrics/friction.mjs +86 -0
- package/analytics/metrics/prompts.mjs +93 -0
- package/analytics/metrics/rework.mjs +113 -0
- package/analytics/metrics/time.mjs +104 -0
- package/analytics/metrics/tokens.mjs +88 -0
- package/analytics/metrics/tools.mjs +118 -0
- package/analytics/metrics/volume.mjs +98 -0
- package/analytics/ranges.mjs +98 -0
- package/analytics/rollup.mjs +151 -0
- package/analytics/score.mjs +194 -0
- package/bin/cli.mjs +596 -0
- package/bin/postinstall.mjs +44 -0
- package/collect/classify.mjs +226 -0
- package/collect/git.mjs +78 -0
- package/collect/projects.mjs +82 -0
- package/collect/redact.mjs +85 -0
- package/collect/sessions.mjs +119 -0
- package/collect/tail.mjs +126 -0
- package/collect/tools.mjs +121 -0
- package/collect/transcript.mjs +128 -0
- package/dashboard/api/index.mjs +296 -0
- package/dashboard/auth.mjs +235 -0
- package/dashboard/router.mjs +55 -0
- package/dashboard/security.mjs +95 -0
- package/dashboard/server.mjs +156 -0
- package/dashboard/static.mjs +47 -0
- package/dashboard/web/SynDes.icns +0 -0
- package/dashboard/web/api.js +80 -0
- package/dashboard/web/app.css +532 -0
- package/dashboard/web/app.js +261 -0
- package/dashboard/web/charts.js +273 -0
- package/dashboard/web/index.html +23 -0
- package/dashboard/web/logo.png +0 -0
- package/dashboard/web/ui.js +434 -0
- package/dashboard/web/views/habits.js +166 -0
- package/dashboard/web/views/ledger.js +164 -0
- package/dashboard/web/views/overview.js +214 -0
- package/dashboard/web/views/sessions.js +133 -0
- package/dashboard/web/views/settings.js +180 -0
- package/ledger/append.mjs +126 -0
- package/ledger/chain.mjs +53 -0
- package/ledger/keys.mjs +72 -0
- package/ledger/read.mjs +77 -0
- package/ledger/retention.mjs +104 -0
- package/ledger/schema.mjs +96 -0
- package/ledger/segments.mjs +109 -0
- package/ledger/verify.mjs +174 -0
- package/notify/index.mjs +67 -0
- package/notify/linux.mjs +41 -0
- package/notify/mac.mjs +44 -0
- package/notify/terminal.mjs +15 -0
- package/notify/windows.mjs +61 -0
- package/package.json +66 -0
- package/practices/budget.mjs +97 -0
- package/practices/catalog.mjs +64 -0
- package/practices/deliver.mjs +101 -0
- package/practices/engine.mjs +107 -0
- package/practices/rules/batch-tool-calls.mjs +15 -0
- package/practices/rules/context-hygiene.mjs +17 -0
- package/practices/rules/delegate-wide-search.mjs +15 -0
- package/practices/rules/index.mjs +28 -0
- package/practices/rules/permission-friction.mjs +16 -0
- package/practices/rules/project-memory.mjs +27 -0
- package/practices/rules/prompt-specificity.mjs +15 -0
- package/practices/rules/read-before-edit.mjs +16 -0
- package/practices/rules/retry-storm.mjs +22 -0
- package/practices/rules/session-sprawl.mjs +15 -0
- package/practices/rules/verify-after-change.mjs +16 -0
- package/runtime/config.mjs +116 -0
- package/runtime/hook.mjs +154 -0
- package/runtime/jsonl.mjs +104 -0
- package/runtime/lock.mjs +98 -0
- package/runtime/log.mjs +37 -0
- package/runtime/paths.mjs +116 -0
- package/runtime/platform.mjs +74 -0
- package/runtime/spool.mjs +92 -0
- package/runtime/worker.mjs +275 -0
- package/src/briefing.mjs +94 -0
- package/src/doctor.mjs +153 -0
- package/src/export.mjs +68 -0
- package/src/install.mjs +95 -0
- package/src/open.mjs +23 -0
- package/src/report.mjs +120 -0
- package/src/settings.mjs +173 -0
- package/src/status.mjs +61 -0
- package/src/systemauth.mjs +179 -0
- package/src/term.mjs +272 -0
- package/src/uninstall.mjs +43 -0
package/collect/tail.mjs
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Following other agents' session files.
|
|
3
|
+
*
|
|
4
|
+
* Agents without a hook API cannot tell us what they did, so we read what they
|
|
5
|
+
* write down. Each tail target keeps a byte cursor, and only the bytes appended
|
|
6
|
+
* since last time are parsed — reading a whole transcript on every poll would
|
|
7
|
+
* be quadratic over a long session.
|
|
8
|
+
*
|
|
9
|
+
* Two things this must never do: re-emit a line it has already recorded, and
|
|
10
|
+
* advance past a line that is only half written. Both are handled by never
|
|
11
|
+
* moving the cursor past the last complete newline.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { openSync, readSync, fstatSync, closeSync, existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
15
|
+
import { dirname, basename } from 'node:path';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { stateDir } from '../runtime/paths.mjs';
|
|
18
|
+
import { parseLine } from '../runtime/jsonl.mjs';
|
|
19
|
+
import { loadConfig } from '../runtime/config.mjs';
|
|
20
|
+
import { projectFor } from './projects.mjs';
|
|
21
|
+
import { tailTargets, classify } from '../adapters/index.mjs';
|
|
22
|
+
import { debug } from '../runtime/log.mjs';
|
|
23
|
+
|
|
24
|
+
const CURSORS = join(stateDir, 'tail-cursors.json');
|
|
25
|
+
/** Cap one poll so a huge backlog cannot stall the worker. */
|
|
26
|
+
const MAX_BYTES = 4 * 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
function loadCursors() {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(readFileSync(CURSORS, 'utf8'));
|
|
31
|
+
} catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function saveCursors(cursors) {
|
|
37
|
+
try {
|
|
38
|
+
mkdirSync(dirname(CURSORS), { recursive: true });
|
|
39
|
+
const staging = `${CURSORS}.tmp`;
|
|
40
|
+
writeFileSync(staging, `${JSON.stringify(cursors)}\n`);
|
|
41
|
+
renameSync(staging, CURSORS);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
debug('tail cursor save failed', error.message);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Poll every tail source once.
|
|
49
|
+
*
|
|
50
|
+
* @param {{firstRunSkipsHistory?: boolean}} options
|
|
51
|
+
* On the very first sighting of a file we jump to its end rather than
|
|
52
|
+
* importing the whole thing. Back-filling months of somebody's history the
|
|
53
|
+
* moment they install would make the first dashboard a wall of records they
|
|
54
|
+
* never asked us to keep.
|
|
55
|
+
* @returns {{drafts: object[], files: number, lines: number}}
|
|
56
|
+
*/
|
|
57
|
+
export function pollTails({ firstRunSkipsHistory = true } = {}) {
|
|
58
|
+
const cursors = loadCursors();
|
|
59
|
+
const config = loadConfig();
|
|
60
|
+
const drafts = [];
|
|
61
|
+
let files = 0;
|
|
62
|
+
let lines = 0;
|
|
63
|
+
|
|
64
|
+
for (const { source, file } of tailTargets()) {
|
|
65
|
+
if (!existsSync(file)) continue;
|
|
66
|
+
files += 1;
|
|
67
|
+
|
|
68
|
+
let handle;
|
|
69
|
+
try {
|
|
70
|
+
handle = openSync(file, 'r');
|
|
71
|
+
const size = fstatSync(handle).size;
|
|
72
|
+
const known = cursors[file];
|
|
73
|
+
|
|
74
|
+
if (known === undefined) {
|
|
75
|
+
cursors[file] = firstRunSkipsHistory ? size : 0;
|
|
76
|
+
if (firstRunSkipsHistory) continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Shorter than our cursor means the file was rotated or replaced.
|
|
80
|
+
let from = cursors[file] > size ? 0 : cursors[file];
|
|
81
|
+
if (from === size) continue;
|
|
82
|
+
|
|
83
|
+
const capped = Math.min(size - from, MAX_BYTES);
|
|
84
|
+
const buffer = Buffer.alloc(capped);
|
|
85
|
+
readSync(handle, buffer, 0, capped, from);
|
|
86
|
+
|
|
87
|
+
const text = buffer.toString('utf8');
|
|
88
|
+
const end = text.lastIndexOf('\n');
|
|
89
|
+
if (end === -1) continue; // nothing complete yet
|
|
90
|
+
|
|
91
|
+
const session = `${source}:${basename(file).replace(/\.jsonl$/, '')}`;
|
|
92
|
+
const ctx = { config, project: projectFor(process.cwd()), session };
|
|
93
|
+
|
|
94
|
+
for (const raw of text.slice(0, end).split('\n')) {
|
|
95
|
+
const line = parseLine(raw);
|
|
96
|
+
if (!line) continue;
|
|
97
|
+
lines += 1;
|
|
98
|
+
try {
|
|
99
|
+
drafts.push(...classify(source, { line, file, session, at: Date.now() }, ctx));
|
|
100
|
+
} catch (error) {
|
|
101
|
+
// One unrecognised line must never abort the whole poll.
|
|
102
|
+
debug('tail classify failed', source, error.message);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
cursors[file] = from + Buffer.byteLength(text.slice(0, end + 1));
|
|
107
|
+
} catch (error) {
|
|
108
|
+
debug('tail read failed', file, error.message);
|
|
109
|
+
} finally {
|
|
110
|
+
if (handle !== undefined) {
|
|
111
|
+
try { closeSync(handle); } catch { /* already closed */ }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
saveCursors(cursors);
|
|
117
|
+
drafts.sort((a, b) => a.ts - b.ts);
|
|
118
|
+
return { drafts, files, lines };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Forget every cursor, so the next poll re-imports from the top. */
|
|
122
|
+
export function resetCursors() {
|
|
123
|
+
saveCursors({});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export { CURSORS };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool taxonomy, and the normalisation the metrics depend on.
|
|
3
|
+
*
|
|
4
|
+
* Grouping into families is what makes "did you search with Grep or with `grep`
|
|
5
|
+
* through Bash" answerable — one of the strongest signals of whether someone has
|
|
6
|
+
* learned the tool they are using.
|
|
7
|
+
*
|
|
8
|
+
* Only one field per tool is kept: the path, the command's head, the pattern,
|
|
9
|
+
* the subagent type. Never the whole tool_input — that is the transcript's job
|
|
10
|
+
* and it is enormous.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const FAMILY = {
|
|
14
|
+
read: 'read',
|
|
15
|
+
search: 'search',
|
|
16
|
+
edit: 'edit',
|
|
17
|
+
execute: 'execute',
|
|
18
|
+
delegate: 'delegate',
|
|
19
|
+
web: 'web',
|
|
20
|
+
plan: 'plan',
|
|
21
|
+
mcp: 'mcp',
|
|
22
|
+
other: 'other',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const BY_NAME = {
|
|
26
|
+
Read: FAMILY.read, NotebookRead: FAMILY.read,
|
|
27
|
+
Glob: FAMILY.search, Grep: FAMILY.search, LS: FAMILY.search,
|
|
28
|
+
Edit: FAMILY.edit, Write: FAMILY.edit, MultiEdit: FAMILY.edit, NotebookEdit: FAMILY.edit,
|
|
29
|
+
Bash: FAMILY.execute, BashOutput: FAMILY.execute, KillShell: FAMILY.execute,
|
|
30
|
+
Task: FAMILY.delegate, Agent: FAMILY.delegate, Workflow: FAMILY.delegate,
|
|
31
|
+
WebFetch: FAMILY.web, WebSearch: FAMILY.web,
|
|
32
|
+
ExitPlanMode: FAMILY.plan, EnterPlanMode: FAMILY.plan, TodoWrite: FAMILY.plan, Skill: FAMILY.plan,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Tools whose use means a file changed on disk. */
|
|
36
|
+
const WRITERS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
37
|
+
|
|
38
|
+
export function familyOf(toolName) {
|
|
39
|
+
if (!toolName) return FAMILY.other;
|
|
40
|
+
if (toolName.startsWith('mcp__')) return FAMILY.mcp;
|
|
41
|
+
return BY_NAME[toolName] ?? FAMILY.other;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isWrite(toolName) {
|
|
45
|
+
return WRITERS.has(toolName);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isRead(toolName) {
|
|
49
|
+
return toolName === 'Read' || toolName === 'NotebookRead';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The one identifying detail worth keeping for a tool call.
|
|
54
|
+
*
|
|
55
|
+
* @returns {{target: string|null, detail: string|null}}
|
|
56
|
+
*/
|
|
57
|
+
export function summarise(toolName, toolInput) {
|
|
58
|
+
const input = toolInput ?? {};
|
|
59
|
+
|
|
60
|
+
if (isRead(toolName) || isWrite(toolName)) {
|
|
61
|
+
return { target: input.file_path ?? input.notebook_path ?? null, detail: null };
|
|
62
|
+
}
|
|
63
|
+
if (toolName === 'Bash') {
|
|
64
|
+
const command = typeof input.command === 'string' ? input.command : '';
|
|
65
|
+
return { target: headOf(command), detail: command.slice(0, 200) };
|
|
66
|
+
}
|
|
67
|
+
if (toolName === 'Grep' || toolName === 'Glob') {
|
|
68
|
+
return { target: input.pattern ?? null, detail: input.path ?? null };
|
|
69
|
+
}
|
|
70
|
+
if (toolName === 'Task' || toolName === 'Agent') {
|
|
71
|
+
return { target: input.subagent_type ?? 'general-purpose', detail: input.description ?? null };
|
|
72
|
+
}
|
|
73
|
+
if (toolName === 'WebFetch') {
|
|
74
|
+
return { target: hostOf(input.url), detail: null };
|
|
75
|
+
}
|
|
76
|
+
if (toolName === 'WebSearch') {
|
|
77
|
+
return { target: null, detail: typeof input.query === 'string' ? input.query.slice(0, 120) : null };
|
|
78
|
+
}
|
|
79
|
+
if (toolName === 'Skill') {
|
|
80
|
+
return { target: input.skill ?? null, detail: null };
|
|
81
|
+
}
|
|
82
|
+
return { target: null, detail: null };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The program a shell command actually runs, past env assignments and sudo. */
|
|
86
|
+
export function headOf(command) {
|
|
87
|
+
if (typeof command !== 'string') return null;
|
|
88
|
+
const words = command.trim().split(/\s+/);
|
|
89
|
+
let index = 0;
|
|
90
|
+
while (index < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index]) || words[index] === 'sudo')) index += 1;
|
|
91
|
+
const head = words[index] ?? null;
|
|
92
|
+
return head ? head.split('/').pop().slice(0, 40) : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function hostOf(url) {
|
|
96
|
+
try {
|
|
97
|
+
return new URL(url).host;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Whether a Bash command is doing a search tool's job.
|
|
105
|
+
*
|
|
106
|
+
* Not a style opinion: a `grep -r` through Bash returns raw bytes into context,
|
|
107
|
+
* where Grep returns a structured result. The cost difference is measurable and
|
|
108
|
+
* it is what the tool-discipline pillar scores.
|
|
109
|
+
*/
|
|
110
|
+
export function isSearchThroughBash(command) {
|
|
111
|
+
const head = headOf(command);
|
|
112
|
+
return ['grep', 'rg', 'ag', 'ack', 'find', 'fd'].includes(head ?? '');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Whether a Bash command is a verification step — the feedback-loop signal. */
|
|
116
|
+
export function isVerification(command) {
|
|
117
|
+
if (typeof command !== 'string') return false;
|
|
118
|
+
return /\b(npm|pnpm|yarn|bun)\s+(run\s+)?(test|lint|typecheck|build|check)\b/.test(command) ||
|
|
119
|
+
/\b(pytest|jest|vitest|mocha|go\s+test|cargo\s+(test|check|build)|tsc|mvn|gradle|make)\b/.test(command) ||
|
|
120
|
+
/\bnode\s+--test\b/.test(command);
|
|
121
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Incremental transcript tail → token usage, model, cache hits, turn shape.
|
|
3
|
+
*
|
|
4
|
+
* Tokens, cost and cache efficiency are not in the hook payload; they are in the
|
|
5
|
+
* session transcript JSONL that `transcript_path` points at. Reading it whole on
|
|
6
|
+
* every event would be quadratic over a long session, so a byte offset per
|
|
7
|
+
* session is kept and only the new tail is parsed.
|
|
8
|
+
*
|
|
9
|
+
* The format is Claude Code's, not ours. Parse defensively, tolerate unknown
|
|
10
|
+
* shapes, and never let one unexpected line abort the drain.
|
|
11
|
+
*
|
|
12
|
+
* Observed shape (Claude Code 2.1.x):
|
|
13
|
+
* {type:'assistant', timestamp, sessionId, cwd, gitBranch, requestId,
|
|
14
|
+
* message:{ id, model, usage:{ input_tokens, output_tokens,
|
|
15
|
+
* cache_creation_input_tokens, cache_read_input_tokens,
|
|
16
|
+
* output_tokens_details:{ thinking_tokens } }, content:[ {type:…} ] }}
|
|
17
|
+
* {type:'user', message:{ content: string | [{type:'tool_result', is_error}] }}
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { openSync, readSync, fstatSync, closeSync, existsSync } from 'node:fs';
|
|
21
|
+
import { parseLine } from '../runtime/jsonl.mjs';
|
|
22
|
+
import { debug } from '../runtime/log.mjs';
|
|
23
|
+
|
|
24
|
+
/** Cap one drain's read so a huge transcript cannot stall the worker. */
|
|
25
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read everything appended since the last call.
|
|
29
|
+
*
|
|
30
|
+
* @returns {{samples: object[], errors: number, results: number, cursor: number, model: string|null}}
|
|
31
|
+
*/
|
|
32
|
+
export function sync(path, cursor = 0) {
|
|
33
|
+
const empty = { samples: [], errors: 0, results: 0, cursor, model: null };
|
|
34
|
+
if (!path || !existsSync(path)) return empty;
|
|
35
|
+
|
|
36
|
+
let handle;
|
|
37
|
+
try {
|
|
38
|
+
handle = openSync(path, 'r');
|
|
39
|
+
const size = fstatSync(handle).size;
|
|
40
|
+
|
|
41
|
+
// A file shorter than our cursor was rotated or replaced. Start over rather
|
|
42
|
+
// than reading from a meaningless offset.
|
|
43
|
+
let from = cursor > size ? 0 : cursor;
|
|
44
|
+
if (size === from) return { ...empty, cursor: size };
|
|
45
|
+
|
|
46
|
+
const capped = Math.min(size - from, MAX_BYTES);
|
|
47
|
+
const buffer = Buffer.alloc(capped);
|
|
48
|
+
readSync(handle, buffer, 0, capped, from);
|
|
49
|
+
|
|
50
|
+
const text = buffer.toString('utf8');
|
|
51
|
+
// Stop at the last newline: a trailing partial line is re-read next time.
|
|
52
|
+
const end = text.lastIndexOf('\n');
|
|
53
|
+
if (end === -1) return { ...empty, cursor: from };
|
|
54
|
+
|
|
55
|
+
const parsed = scan(text.slice(0, end));
|
|
56
|
+
return { ...parsed, cursor: from + Buffer.byteLength(text.slice(0, end + 1)) };
|
|
57
|
+
} catch (error) {
|
|
58
|
+
debug('transcript sync failed', path, error.message);
|
|
59
|
+
return empty;
|
|
60
|
+
} finally {
|
|
61
|
+
if (handle !== undefined) {
|
|
62
|
+
try { closeSync(handle); } catch { /* already closed */ }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function scan(text) {
|
|
68
|
+
const samples = [];
|
|
69
|
+
let errors = 0;
|
|
70
|
+
let results = 0;
|
|
71
|
+
let model = null;
|
|
72
|
+
|
|
73
|
+
for (const line of text.split('\n')) {
|
|
74
|
+
const entry = parseLine(line);
|
|
75
|
+
if (!entry) continue;
|
|
76
|
+
|
|
77
|
+
if (entry.type === 'assistant') {
|
|
78
|
+
const sample = sampleFrom(entry);
|
|
79
|
+
if (sample) {
|
|
80
|
+
samples.push(sample);
|
|
81
|
+
model = sample.model ?? model;
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (entry.type === 'user') {
|
|
87
|
+
const content = entry.message?.content;
|
|
88
|
+
if (!Array.isArray(content)) continue;
|
|
89
|
+
for (const block of content) {
|
|
90
|
+
if (block?.type !== 'tool_result') continue;
|
|
91
|
+
results += 1;
|
|
92
|
+
// is_error is the authoritative failure signal. The PostToolUse payload
|
|
93
|
+
// does not carry one, so tool error rate is measured here, not there.
|
|
94
|
+
if (block.is_error === true) errors += 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { samples, errors, results, model };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sampleFrom(entry) {
|
|
103
|
+
const message = entry.message;
|
|
104
|
+
const usage = message?.usage;
|
|
105
|
+
if (!usage) return null;
|
|
106
|
+
|
|
107
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
108
|
+
const blocks = { text: 0, thinking: 0, tool_use: 0 };
|
|
109
|
+
for (const block of content) {
|
|
110
|
+
if (block?.type in blocks) blocks[block.type] += 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
id: message.id ?? entry.uuid ?? null,
|
|
115
|
+
ts: entry.timestamp ? Date.parse(entry.timestamp) : null,
|
|
116
|
+
model: message.model ?? null,
|
|
117
|
+
input: usage.input_tokens ?? 0,
|
|
118
|
+
output: usage.output_tokens ?? 0,
|
|
119
|
+
cacheRead: usage.cache_read_input_tokens ?? 0,
|
|
120
|
+
cacheWrite: usage.cache_creation_input_tokens ?? 0,
|
|
121
|
+
thinking: usage.output_tokens_details?.thinking_tokens ?? 0,
|
|
122
|
+
// Tool calls in ONE assistant message: the batching signal. Claude issuing
|
|
123
|
+
// three independent calls in one turn is one round-trip; three turns is three.
|
|
124
|
+
toolUses: blocks.tool_use,
|
|
125
|
+
sidechain: entry.isSidechain === true, // a subagent's turn, not the main thread
|
|
126
|
+
branch: entry.gitBranch ?? null,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The API surface.
|
|
3
|
+
*
|
|
4
|
+
* Every handler returns plain data from analytics/index.mjs and formats nothing.
|
|
5
|
+
* Formatting is the browser's job; duplicating it server-side is how two views
|
|
6
|
+
* of the same number drift apart.
|
|
7
|
+
*
|
|
8
|
+
* Handlers receive { req, res, url, body, port } and return
|
|
9
|
+
* { status?, data, headers? } — or write to res themselves for streams.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import * as analytics from '../../analytics/index.mjs';
|
|
15
|
+
import { verify } from '../../ledger/verify.mjs';
|
|
16
|
+
import { height, readRange, tail } from '../../ledger/read.mjs';
|
|
17
|
+
import { listDays, listSeals, readSeal } from '../../ledger/segments.mjs';
|
|
18
|
+
import { usage } from '../../ledger/retention.mjs';
|
|
19
|
+
import { evaluate } from '../../practices/engine.mjs';
|
|
20
|
+
import * as budget from '../../practices/budget.mjs';
|
|
21
|
+
import { loadConfig, saveConfig, rawConfig, setPath, resetCache } from '../../runtime/config.mjs';
|
|
22
|
+
import { packageRoot, displayPath, dataDir } from '../../runtime/paths.mjs';
|
|
23
|
+
import { probe } from '../../notify/index.mjs';
|
|
24
|
+
import {
|
|
25
|
+
issueToken, describe as describeAuth, mode as authMode,
|
|
26
|
+
verifyPin, verifySystem, setMode, systemAvailable, MODES,
|
|
27
|
+
} from '../auth.mjs';
|
|
28
|
+
import { sessionCookie } from '../security.mjs';
|
|
29
|
+
import { pendingCount } from '../../runtime/spool.mjs';
|
|
30
|
+
import { isInstalled, installedEvents } from '../../src/settings.mjs';
|
|
31
|
+
|
|
32
|
+
// ── Session ─────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/** What lock is on the door, so the unlock screen can render the right thing. */
|
|
35
|
+
export async function unlockOptions() {
|
|
36
|
+
return { data: describeAuth() };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Perform the unlock.
|
|
41
|
+
*
|
|
42
|
+
* In `open` mode there is nothing to check — reaching this port already required
|
|
43
|
+
* a loopback Host and, for the first load, the launch token from the CLI.
|
|
44
|
+
*/
|
|
45
|
+
export async function unlock({ body }) {
|
|
46
|
+
const current = authMode();
|
|
47
|
+
|
|
48
|
+
if (current === 'open') {
|
|
49
|
+
return { data: { ok: true, mode: current }, headers: { 'Set-Cookie': sessionCookie(issueToken()) } };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (current === 'system') {
|
|
53
|
+
const result = await verifySystem();
|
|
54
|
+
if (!result.ok) return { status: 401, data: { error: result.reason ?? 'not authenticated' } };
|
|
55
|
+
return { data: { ok: true, mode: current }, headers: { 'Set-Cookie': sessionCookie(issueToken()) } };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const result = verifyPin(body?.pin);
|
|
59
|
+
if (!result.ok) return { status: 401, data: { error: 'unlock failed', retryAfterMs: result.retryAfterMs } };
|
|
60
|
+
return { data: { ok: true, mode: current }, headers: { 'Set-Cookie': sessionCookie(issueToken()) } };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function logout() {
|
|
64
|
+
return { data: { ok: true }, headers: { 'Set-Cookie': sessionCookie('', { clear: true }) } };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Public: the login page needs to know whether there is a password at all. */
|
|
68
|
+
export async function status() {
|
|
69
|
+
return {
|
|
70
|
+
data: {
|
|
71
|
+
version: version(),
|
|
72
|
+
auth: describeAuth(),
|
|
73
|
+
installed: isInstalled(),
|
|
74
|
+
ledger: height(),
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Metrics ─────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
export async function overview({ url }) {
|
|
82
|
+
return { data: await analytics.overview(url.searchParams.get('range') ?? '7d') };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function sessions({ url }) {
|
|
86
|
+
return { data: await analytics.sessions(url.searchParams.get('range') ?? '7d') };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function sessionDetail({ url }) {
|
|
90
|
+
const id = url.searchParams.get('id');
|
|
91
|
+
if (!id) return { status: 400, data: { error: 'id is required' } };
|
|
92
|
+
return { data: { id, events: await analytics.sessionDetail(id) } };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function tools({ url }) {
|
|
96
|
+
const range = analytics.rangeFor(url.searchParams.get('range') ?? '7d');
|
|
97
|
+
const { merged } = await analytics.summarise(range);
|
|
98
|
+
return { data: { range, tools: merged.tools, tokens: merged.tokens, rework: merged.rework, friction: merged.friction } };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function projects({ url }) {
|
|
102
|
+
return { data: await analytics.byProject(url.searchParams.get('range') ?? '30d') };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function timeline({ url }) {
|
|
106
|
+
const range = analytics.rangeFor(url.searchParams.get('range') ?? '7d');
|
|
107
|
+
const limit = Math.min(Number(url.searchParams.get('limit') ?? 3000), 20_000);
|
|
108
|
+
|
|
109
|
+
// Downsampled server-side. The browser must never receive a million records
|
|
110
|
+
// to draw a chart that is 900 pixels wide.
|
|
111
|
+
const events = [];
|
|
112
|
+
for await (const record of readRange(range.from, range.to)) {
|
|
113
|
+
events.push({ ts: record.ts, kind: record.kind, session: record.session, project: record.project });
|
|
114
|
+
if (events.length >= limit) break;
|
|
115
|
+
}
|
|
116
|
+
return { data: { range, events, truncated: events.length >= limit } };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Practices ───────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
export async function practices() {
|
|
122
|
+
const findings = await evaluate();
|
|
123
|
+
const state = budget.load();
|
|
124
|
+
return { data: { findings, muted: state.muted ?? [], dismissed: state.dismissed ?? {}, snoozedUntil: state.snoozedUntil ?? 0 } };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The one place the UI writes anything, and it writes coach state only. */
|
|
128
|
+
export async function practiceAction({ body }) {
|
|
129
|
+
const { action, rule, ms } = body ?? {};
|
|
130
|
+
let state = budget.load();
|
|
131
|
+
|
|
132
|
+
if (action === 'mute') state = budget.mute(state, rule);
|
|
133
|
+
else if (action === 'unmute') state = budget.unmute(state, rule);
|
|
134
|
+
else if (action === 'dismiss') state = budget.dismiss(state, rule);
|
|
135
|
+
else if (action === 'snooze') state = budget.snooze(state, Math.min(Number(ms) || 3_600_000, 7 * 86_400_000));
|
|
136
|
+
else return { status: 400, data: { error: 'unknown action' } };
|
|
137
|
+
|
|
138
|
+
budget.save(state);
|
|
139
|
+
return { data: { ok: true, muted: state.muted ?? [], snoozedUntil: state.snoozedUntil ?? 0 } };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── The chain itself ────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Paged raw records.
|
|
146
|
+
*
|
|
147
|
+
* The dashboard must be able to show the chain, or "immutable" is a claim the
|
|
148
|
+
* user has to take on faith — and a claim taken on faith is a marketing line.
|
|
149
|
+
*/
|
|
150
|
+
export async function ledger({ url }) {
|
|
151
|
+
const limit = Math.min(Number(url.searchParams.get('limit') ?? 100), 1000);
|
|
152
|
+
const kind = url.searchParams.get('kind');
|
|
153
|
+
const session = url.searchParams.get('session');
|
|
154
|
+
const range = url.searchParams.get('range');
|
|
155
|
+
|
|
156
|
+
let records;
|
|
157
|
+
if (range || kind || session) {
|
|
158
|
+
const window = analytics.rangeFor(range ?? '30d');
|
|
159
|
+
records = [];
|
|
160
|
+
for await (const record of readRange(window.from, window.to)) {
|
|
161
|
+
if (kind && record.kind !== kind) continue;
|
|
162
|
+
if (session && record.session !== session) continue;
|
|
163
|
+
records.push(record);
|
|
164
|
+
}
|
|
165
|
+
records = records.slice(-limit);
|
|
166
|
+
} else {
|
|
167
|
+
records = await tail(limit);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
data: {
|
|
172
|
+
records,
|
|
173
|
+
height: height(),
|
|
174
|
+
days: listDays(),
|
|
175
|
+
seals: listSeals().map((day) => readSeal(day)).filter(Boolean),
|
|
176
|
+
usage: usage(),
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function verifyChain({ url }) {
|
|
182
|
+
return { data: await verify({ full: url.searchParams.get('full') === '1' }) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Settings ────────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
export async function readConfig() {
|
|
188
|
+
return {
|
|
189
|
+
data: {
|
|
190
|
+
config: loadConfig(),
|
|
191
|
+
paths: { data: displayPath(dataDir) },
|
|
192
|
+
notifications: await probe(),
|
|
193
|
+
hooks: { count: installedEvents().events.length },
|
|
194
|
+
spoolPending: pendingCount(),
|
|
195
|
+
auth: { mode: authMode(), modes: MODES, system: systemAvailable() },
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Change the lock. A PIN arrives here in the clear over loopback, which is the
|
|
201
|
+
* same trust boundary the whole dashboard already sits behind. */
|
|
202
|
+
export async function setAuthMode({ body }) {
|
|
203
|
+
try {
|
|
204
|
+
return { data: { ok: true, mode: setMode(body?.mode, { pin: body?.pin }) } };
|
|
205
|
+
} catch (error) {
|
|
206
|
+
return { status: 400, data: { error: error.message } };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Config writes are allowlisted by key.
|
|
212
|
+
*
|
|
213
|
+
* A generic setter would let the UI reach any path in the file, and this server
|
|
214
|
+
* is reachable from a browser. The allowlist is what keeps a bug in the client
|
|
215
|
+
* from becoming a way to repoint the tool at someone else's directory.
|
|
216
|
+
*/
|
|
217
|
+
const WRITABLE = new Set([
|
|
218
|
+
'privacy', 'coach.enabled', 'coach.maxPerDay', 'coach.cooldownHours',
|
|
219
|
+
'coach.channels.notification', 'coach.channels.terminal', 'coach.channels.digest',
|
|
220
|
+
'coach.injectContext', 'dashboard.port', 'dashboard.idleTimeoutMinutes',
|
|
221
|
+
'dashboard.openOnStart', 'retention.days', 'retention.gzipAfterDays',
|
|
222
|
+
'idleGapMinutes', 'track.tools', 'track.prompts', 'track.permissions',
|
|
223
|
+
'track.transcript', 'track.git',
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
export async function writeConfig({ body }) {
|
|
227
|
+
const updates = body?.updates;
|
|
228
|
+
if (!updates || typeof updates !== 'object') return { status: 400, data: { error: 'updates required' } };
|
|
229
|
+
|
|
230
|
+
const rejected = Object.keys(updates).filter((key) => !WRITABLE.has(key));
|
|
231
|
+
if (rejected.length) return { status: 400, data: { error: 'not writable', keys: rejected } };
|
|
232
|
+
|
|
233
|
+
const next = rawConfig();
|
|
234
|
+
for (const [key, value] of Object.entries(updates)) setPath(next, key, value);
|
|
235
|
+
|
|
236
|
+
saveConfig(next);
|
|
237
|
+
resetCache();
|
|
238
|
+
return { data: { ok: true, config: loadConfig() } };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Export ──────────────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The user's data, out, whole and streamed.
|
|
245
|
+
*
|
|
246
|
+
* A tracker that cannot hand back everything it collected does not deserve the
|
|
247
|
+
* collection. Streamed rather than buffered so a year of records does not have
|
|
248
|
+
* to fit in memory to leave the building.
|
|
249
|
+
*/
|
|
250
|
+
export async function exportData({ url, res }) {
|
|
251
|
+
const format = url.searchParams.get('format') ?? 'ndjson';
|
|
252
|
+
const range = analytics.rangeFor(url.searchParams.get('range') ?? 'all');
|
|
253
|
+
|
|
254
|
+
const types = { ndjson: 'application/x-ndjson', json: 'application/json', csv: 'text/csv' };
|
|
255
|
+
res.writeHead(200, {
|
|
256
|
+
'Content-Type': `${types[format] ?? types.ndjson}; charset=utf-8`,
|
|
257
|
+
'Content-Disposition': `attachment; filename="syndes-${range.days[0]}-to-${range.days.at(-1)}.${format}"`,
|
|
258
|
+
'Cache-Control': 'no-store',
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
if (format === 'csv') {
|
|
262
|
+
res.write('seq,ts,iso,kind,session,project,data\n');
|
|
263
|
+
for await (const record of readRange(range.from, range.to)) {
|
|
264
|
+
res.write([
|
|
265
|
+
record.seq, record.ts, new Date(record.ts).toISOString(), record.kind,
|
|
266
|
+
record.session ?? '', record.project ?? '', csv(JSON.stringify(record.data ?? {})),
|
|
267
|
+
].join(',') + '\n');
|
|
268
|
+
}
|
|
269
|
+
} else if (format === 'json') {
|
|
270
|
+
res.write('[');
|
|
271
|
+
let first = true;
|
|
272
|
+
for await (const record of readRange(range.from, range.to)) {
|
|
273
|
+
res.write((first ? '' : ',') + JSON.stringify(record));
|
|
274
|
+
first = false;
|
|
275
|
+
}
|
|
276
|
+
res.write(']');
|
|
277
|
+
} else {
|
|
278
|
+
for await (const record of readRange(range.from, range.to)) {
|
|
279
|
+
res.write(`${JSON.stringify(record)}\n`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
res.end();
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function csv(value) {
|
|
287
|
+
return `"${String(value).replace(/"/g, '""')}"`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function version() {
|
|
291
|
+
try {
|
|
292
|
+
return JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
|
|
293
|
+
} catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|