@hecer/yoke 1.13.0 → 1.15.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 (45) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +16 -12
  3. package/canon/manifest.yaml +1 -1
  4. package/dist/cli.js +16 -2
  5. package/dist/code-intelligence/adapters/graft.js +11 -0
  6. package/dist/code-intelligence/adapters/graphify.js +8 -0
  7. package/dist/code-intelligence/adapters/index.js +5 -0
  8. package/dist/code-intelligence/adapters/mcp.js +37 -0
  9. package/dist/code-intelligence/adapters/serena.js +11 -0
  10. package/dist/code-intelligence/adapters/types.js +1 -0
  11. package/dist/code-intelligence/contracts.js +137 -0
  12. package/dist/code-intelligence/coordinator.js +370 -0
  13. package/dist/code-intelligence/edit-plans.js +53 -0
  14. package/dist/code-intelligence/evidence.js +77 -0
  15. package/dist/code-intelligence/index.js +5 -0
  16. package/dist/code-intelligence/internal-types.js +1 -0
  17. package/dist/code-intelligence/mcp-client.js +139 -0
  18. package/dist/code-intelligence/mcp-server.js +93 -0
  19. package/dist/code-intelligence/snapshots.js +117 -0
  20. package/dist/code-intelligence/transactions.js +60 -0
  21. package/dist/dashboard/analytics.js +193 -29
  22. package/dist/dashboard/contracts.js +23 -0
  23. package/dist/dashboard/page.js +39 -94
  24. package/dist/dashboard/panels.js +85 -31
  25. package/dist/dashboard/server.js +190 -15
  26. package/dist/observability/events.js +1 -1
  27. package/dist/observability/history.js +1 -1
  28. package/dist/retrofit/command.js +3 -1
  29. package/dist/retrofit/config.js +10 -0
  30. package/dist/retrofit/gitignore.js +1 -0
  31. package/dist/retrofit/plan.js +3 -3
  32. package/dist/retrofit/planners/claude.js +3 -3
  33. package/dist/retrofit/planners/codex.js +5 -5
  34. package/dist/retrofit/planners/gemini.js +2 -2
  35. package/dist/retrofit/planners/kilo.js +2 -2
  36. package/dist/retrofit/planners/opencode.js +2 -2
  37. package/dist/retrofit/planners/pi.js +1 -1
  38. package/dist/retrofit/planners/qwen.js +2 -2
  39. package/dist/retrofit/tools.js +9 -7
  40. package/dist/setup/command.js +3 -1
  41. package/docs/CODE-INTELLIGENCE.md +36 -0
  42. package/docs/DASHBOARD-EVOLUTION.md +16 -2
  43. package/docs/DASHBOARD-OVERHAUL.md +146 -0
  44. package/gemini-extension.json +1 -1
  45. package/package.json +1 -1
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ import { CodeIntelligenceCoordinator } from './coordinator.js';
3
+ import { TOOL_DEFINITIONS } from './contracts.js';
4
+ import { loadConfig } from '../retrofit/config.js';
5
+ const INPUT_SCHEMAS = {
6
+ code_context: { type: 'object', required: ['workspace_id', 'query'], properties: { workspace_id: { type: 'string' }, query: { type: 'string' }, snapshot_id: { type: 'string' }, mode: { type: 'string', enum: ['orient', 'locate', 'explain'] }, paths: { type: 'array', items: { type: 'string' } }, include_docs: { type: 'boolean' }, token_budget: { type: 'integer' }, timeout_ms: { type: 'integer' } } },
7
+ code_symbol: { type: 'object', required: ['workspace_id', 'snapshot_id'], properties: { workspace_id: { type: 'string' }, snapshot_id: { type: 'string' }, symbol_id: { type: 'string' }, query: { type: 'string' }, path: { type: 'string' }, include: { type: 'array' } } },
8
+ code_trace: { type: 'object', required: ['workspace_id', 'snapshot_id', 'symbol_id', 'direction', 'relations'], properties: { workspace_id: { type: 'string' }, snapshot_id: { type: 'string' }, symbol_id: { type: 'string' }, direction: { type: 'string', enum: ['in', 'out'] }, relations: { type: 'array' }, max_depth: { type: 'integer' }, max_nodes: { type: 'integer' } } },
9
+ code_impact: { type: 'object', required: ['workspace_id', 'snapshot_id'], properties: { workspace_id: { type: 'string' }, snapshot_id: { type: 'string' }, targets: { type: 'array' }, plan_id: { type: 'string' }, include_docs: { type: 'boolean' }, require_semantic: { type: 'boolean' } } },
10
+ code_edit_preview: { type: 'object', required: ['workspace_id', 'snapshot_id', 'operations', 'validation_profile'], properties: { workspace_id: { type: 'string' }, snapshot_id: { type: 'string' }, operations: { type: 'array' }, validation_profile: { type: 'string', enum: ['default', 'extended'] } } },
11
+ code_edit_apply: { type: 'object', required: ['workspace_id', 'plan_id', 'expected_snapshot_id', 'idempotency_key'], properties: { workspace_id: { type: 'string' }, plan_id: { type: 'string' }, expected_snapshot_id: { type: 'string' }, idempotency_key: { type: 'string' } } },
12
+ };
13
+ function arg(name, args) { return args.find(value => value.startsWith(`--${name}=`))?.slice(name.length + 3); }
14
+ function summary(response) { return `status=${response.status} snapshot=${response.snapshot_id} warnings=${response.warnings.length} (full result in structuredContent)`; }
15
+ export async function runCodeIntelligenceServer(argv = process.argv.slice(2)) {
16
+ const workspace = arg('workspace', argv) ?? process.cwd();
17
+ const configured = loadConfig(workspace)?.codeIntelligence;
18
+ const mode = arg('mode', argv) ?? configured?.mode ?? 'active';
19
+ const coordinator = new CodeIntelligenceCoordinator(workspace, {
20
+ mode,
21
+ policy: { excludePatterns: configured?.policy?.excludePatterns, maxFileBytes: configured?.policy?.maxFileBytes },
22
+ limits: { maxBytes: configured?.limits?.maxBytes, maxBackends: configured?.limits?.maxBackends },
23
+ graft: configured?.graft, graphify: configured?.graphify, serena: configured?.serena,
24
+ });
25
+ let framing = 'line';
26
+ let buffer = Buffer.alloc(0);
27
+ const send = (message) => {
28
+ const payload = JSON.stringify(message);
29
+ if (framing === 'line')
30
+ process.stdout.write(`${payload}\n`);
31
+ else
32
+ process.stdout.write(`Content-Length: ${Buffer.byteLength(payload, 'utf8')}\r\n\r\n${payload}`);
33
+ };
34
+ const handle = async (message) => {
35
+ if (message.method === 'notifications/initialized' || message.method?.startsWith('notifications/'))
36
+ return;
37
+ if (message.method === 'initialize') {
38
+ send({ jsonrpc: '2.0', id: message.id, result: { protocolVersion: '2025-11-25', capabilities: { tools: {} }, serverInfo: { name: 'yoke-code-intelligence', version: '0.1.0' } } });
39
+ return;
40
+ }
41
+ if (message.method === 'tools/list') {
42
+ send({ jsonrpc: '2.0', id: message.id, result: { tools: TOOL_DEFINITIONS.map(tool => ({ ...tool, inputSchema: INPUT_SCHEMAS[tool.name] })) } });
43
+ return;
44
+ }
45
+ if (message.method === 'tools/call') {
46
+ const name = message.params?.name;
47
+ const response = await coordinator.dispatch(name, message.params?.arguments ?? {});
48
+ send({ jsonrpc: '2.0', id: message.id, result: { content: [{ type: 'text', text: summary(response) }], structuredContent: response, isError: response.status === 'error' || response.status === 'blocked' } });
49
+ return;
50
+ }
51
+ if (message.id !== undefined)
52
+ send({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: `method not found: ${message.method}` } });
53
+ };
54
+ process.stdin.on('data', chunk => {
55
+ buffer = Buffer.concat([buffer, Buffer.from(chunk)]);
56
+ while (buffer.length) {
57
+ let raw;
58
+ if (/^Content-Length:/i.test(buffer.toString('ascii', 0, Math.min(buffer.length, 32)))) {
59
+ framing = 'content-length';
60
+ const end = buffer.indexOf('\r\n\r\n');
61
+ if (end < 0)
62
+ return;
63
+ const header = buffer.subarray(0, end).toString('ascii');
64
+ const match = /Content-Length:\s*(\d+)/i.exec(header);
65
+ if (!match) {
66
+ buffer = buffer.subarray(end + 4);
67
+ continue;
68
+ }
69
+ const length = Number(match[1]);
70
+ if (buffer.length < end + 4 + length)
71
+ return;
72
+ raw = buffer.subarray(end + 4, end + 4 + length).toString('utf8');
73
+ buffer = buffer.subarray(end + 4 + length);
74
+ }
75
+ else {
76
+ const end = buffer.indexOf(10);
77
+ if (end < 0)
78
+ return;
79
+ raw = buffer.subarray(0, end).toString('utf8').trim();
80
+ buffer = buffer.subarray(end + 1);
81
+ }
82
+ if (!raw)
83
+ continue;
84
+ try {
85
+ void handle(JSON.parse(raw));
86
+ }
87
+ catch { /* malformed protocol input is ignored */ }
88
+ }
89
+ });
90
+ await new Promise(resolve => { const stop = () => { process.off('SIGTERM', stop); process.off('SIGINT', stop); void coordinator.close().finally(resolve); }; process.once('SIGTERM', stop); process.once('SIGINT', stop); process.stdin.once('end', stop); });
91
+ }
92
+ if (process.argv[1]?.endsWith('code-intelligence-server.js'))
93
+ void runCodeIntelligenceServer();
@@ -0,0 +1,117 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, readdirSync, writeFileSync } from 'node:fs';
4
+ import { join, relative, resolve, sep } from 'node:path';
5
+ import { z } from 'zod';
6
+ export const SnapshotFileSchema = z.object({ path: z.string().min(1), hash: z.string().length(64), size: z.number().int().nonnegative() });
7
+ export const SnapshotSchema = z.object({
8
+ snapshot_id: z.string().length(64), workspace_id: z.string().min(1), root: z.string().min(1), head: z.string().nullable(),
9
+ files: z.array(SnapshotFileSchema), policy_hash: z.string().length(64), created_at: z.string(),
10
+ });
11
+ function sha256(value) {
12
+ return createHash('sha256').update(value).digest('hex');
13
+ }
14
+ function inside(root, candidate) {
15
+ const rel = relative(root, candidate);
16
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !rel.startsWith(sep));
17
+ }
18
+ function globToRegExp(pattern) {
19
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*').replace(/\?/g, '.');
20
+ return new RegExp(`^${escaped}$`);
21
+ }
22
+ function excluded(path, policy) {
23
+ const standard = [/^\.git(?:\/|$)/, /^\.yoke\/code-intelligence(?:\/|$)/, /(^|\/)\.env(?:\.|$)/, /\.(?:pem|key|p12|pfx)$/i];
24
+ if (standard.some(re => re.test(path)))
25
+ return true;
26
+ return (policy.excludePatterns ?? []).some(pattern => globToRegExp(pattern).test(path));
27
+ }
28
+ function safeFile(root, rel, policy) {
29
+ const normalized = rel.replaceAll('\\', '/');
30
+ if (!normalized || normalized.startsWith('/') || normalized.split('/').some(part => part === '..') || excluded(normalized, policy))
31
+ return null;
32
+ const absolute = resolve(root, normalized);
33
+ if (!inside(root, absolute) || !existsSync(absolute))
34
+ return null;
35
+ const stat = lstatSync(absolute);
36
+ if (!stat.isFile() || stat.isSymbolicLink() || (policy.maxFileBytes && stat.size > policy.maxFileBytes))
37
+ return null;
38
+ return { path: normalized, bytes: readFileSync(absolute) };
39
+ }
40
+ function fallbackFiles(root, dir = root, output = []) {
41
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
42
+ if (entry.name === '.git' || entry.name === '.yoke' || entry.name === 'node_modules')
43
+ continue;
44
+ const absolute = join(dir, entry.name);
45
+ if (entry.isDirectory() && !entry.isSymbolicLink())
46
+ fallbackFiles(root, absolute, output);
47
+ else if (entry.isFile())
48
+ output.push(relative(root, absolute).replaceAll('\\', '/'));
49
+ }
50
+ return output;
51
+ }
52
+ function trackedAndUntracked(root) {
53
+ try {
54
+ const result = execFileSync('git', ['-C', root, 'ls-files', '-co', '--exclude-standard', '-z'], { encoding: 'buffer', stdio: ['ignore', 'pipe', 'ignore'] });
55
+ return result.toString('utf8').split('\0').filter(Boolean);
56
+ }
57
+ catch {
58
+ return fallbackFiles(root);
59
+ }
60
+ }
61
+ function head(root) {
62
+ try {
63
+ return execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ export function workspaceId(workspaceRoot) {
70
+ return `workspace-${sha256(realpathSync(resolve(workspaceRoot))).slice(0, 24)}`;
71
+ }
72
+ export function assertSafePath(workspaceRoot, candidate, policy = {}) {
73
+ const root = realpathSync(resolve(workspaceRoot));
74
+ const absolute = resolve(root, candidate);
75
+ const rel = relative(root, absolute).replaceAll('\\', '/');
76
+ if (!inside(root, absolute) || rel === '' || excluded(rel, policy))
77
+ throw new Error(`path is outside the permitted workspace: ${candidate}`);
78
+ try {
79
+ if (lstatSync(absolute).isSymbolicLink())
80
+ throw new Error(`symbolic links are not allowed: ${candidate}`);
81
+ }
82
+ catch (error) {
83
+ if (error.code !== 'ENOENT')
84
+ throw error;
85
+ }
86
+ return rel;
87
+ }
88
+ export function createSnapshot(workspaceRoot, policy = {}, workspace = workspaceId(workspaceRoot)) {
89
+ const root = realpathSync(resolve(workspaceRoot));
90
+ const files = trackedAndUntracked(root).map(path => safeFile(root, path, policy)).filter((value) => value !== null)
91
+ .sort((a, b) => a.path.localeCompare(b.path)).map(file => ({ path: file.path, hash: sha256(file.bytes), size: file.bytes.byteLength }));
92
+ const policyHash = sha256(JSON.stringify({ excludePatterns: policy.excludePatterns ?? [], maxFileBytes: policy.maxFileBytes ?? null }));
93
+ const identity = JSON.stringify({ workspace, root, head: head(root), files, policyHash });
94
+ const snapshot = SnapshotSchema.parse({ snapshot_id: sha256(identity), workspace_id: workspace, root, head: head(root), files, policy_hash: policyHash, created_at: new Date().toISOString() });
95
+ const dir = join(root, '.yoke', 'code-intelligence', 'snapshots');
96
+ mkdirSync(dir, { recursive: true });
97
+ writeFileSync(join(dir, `${snapshot.snapshot_id}.json`), JSON.stringify(snapshot, null, 2) + '\n', { mode: 0o600 });
98
+ return snapshot;
99
+ }
100
+ export function loadSnapshot(workspaceRoot, snapshotId) {
101
+ const root = realpathSync(resolve(workspaceRoot));
102
+ const file = join(root, '.yoke', 'code-intelligence', 'snapshots', `${snapshotId}.json`);
103
+ if (!existsSync(file))
104
+ throw new Error(`unknown snapshot: ${snapshotId}`);
105
+ const snapshot = SnapshotSchema.parse(JSON.parse(readFileSync(file, 'utf8')));
106
+ if (snapshot.root !== root)
107
+ throw new Error('snapshot belongs to another workspace');
108
+ return snapshot;
109
+ }
110
+ export function currentSnapshotMatches(workspaceRoot, snapshot, policy = {}) {
111
+ const current = createSnapshot(workspaceRoot, policy, snapshot.workspace_id);
112
+ return current.snapshot_id === snapshot.snapshot_id;
113
+ }
114
+ export function assertSnapshotCurrent(workspaceRoot, snapshot, policy = {}) {
115
+ if (!currentSnapshotMatches(workspaceRoot, snapshot, policy))
116
+ throw new Error('snapshot is stale; create a new snapshot before continuing');
117
+ }
@@ -0,0 +1,60 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { copyFileSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { assertSnapshotCurrent } from './snapshots.js';
5
+ import { readPlanApproval } from './edit-plans.js';
6
+ function transactionDir(root) { return join(root, '.yoke', 'code-intelligence', 'transactions'); }
7
+ function hash(value) { return createHash('sha256').update(JSON.stringify(value)).digest('hex'); }
8
+ export function applyEditPlan(root, plan, snapshot, idempotencyKey) {
9
+ if (plan.expires_at <= new Date().toISOString())
10
+ throw new Error('edit plan has expired');
11
+ if (!readPlanApproval(root, plan))
12
+ throw new Error('edit plan requires Yoke control approval');
13
+ assertSnapshotCurrent(root, snapshot);
14
+ if (!existsSync(plan.diff_uri) || hash(readFileSync(plan.diff_uri, 'utf8')) !== plan.diff_hash)
15
+ throw new Error('edit plan diff has changed');
16
+ const sandboxHash = hash(plan.changed_paths.map(path => [path, existsSync(join(plan.sandbox_root, path)) ? readFileSync(join(plan.sandbox_root, path)).toString('base64') : null]));
17
+ if (sandboxHash !== plan.sandbox_hash)
18
+ throw new Error('edit plan sandbox has changed');
19
+ const dir = transactionDir(root);
20
+ mkdirSync(dir, { recursive: true });
21
+ const idem = join(dir, `${idempotencyKey}.json`);
22
+ if (existsSync(idem)) {
23
+ const prior = JSON.parse(readFileSync(idem, 'utf8'));
24
+ if (prior.plan_id !== plan.plan_id)
25
+ throw new Error('idempotency key is bound to another plan');
26
+ return prior;
27
+ }
28
+ const lock = join(dir, '.write.lock');
29
+ let handle;
30
+ try {
31
+ handle = openSync(lock, 'wx');
32
+ }
33
+ catch {
34
+ throw new Error('another code-intelligence transaction is active');
35
+ }
36
+ try {
37
+ const transactionId = `tx-${randomUUID()}`;
38
+ const worktree = join(root, '.yoke', 'code-intelligence', 'worktrees', transactionId);
39
+ mkdirSync(worktree, { recursive: true });
40
+ for (const path of plan.changed_paths) {
41
+ const source = join(plan.sandbox_root, path);
42
+ const target = join(worktree, path);
43
+ if (!existsSync(source))
44
+ continue;
45
+ mkdirSync(dirname(target), { recursive: true });
46
+ copyFileSync(source, target);
47
+ }
48
+ const result = { transaction_id: transactionId, plan_id: plan.plan_id, changed_paths: plan.changed_paths, worktree, new_snapshot_id: snapshot.snapshot_id, validation: plan.validation, committed: false, merged: false };
49
+ writeFileSync(idem, JSON.stringify(result, null, 2) + '\n', { mode: 0o600 });
50
+ return result;
51
+ }
52
+ finally {
53
+ if (handle !== undefined)
54
+ closeSync(handle);
55
+ try {
56
+ unlinkSync(lock);
57
+ }
58
+ catch { /* best effort */ }
59
+ }
60
+ }
@@ -1,5 +1,6 @@
1
1
  import { readMeasurements } from '../observability/history.js';
2
- import { readEvents } from '../observability/events.js';
2
+ import { EVENT_CAP, readEvents } from '../observability/events.js';
3
+ import { DASHBOARD_LIMITS } from './contracts.js';
3
4
  export function parsePeriod(params, now = Date.now()) {
4
5
  const to = params.has('to') ? Date.parse(params.get('to')) : now;
5
6
  const from = params.has('from') ? Date.parse(params.get('from')) : to - 30 * 86400000;
@@ -9,7 +10,6 @@ export function parsePeriod(params, now = Date.now()) {
9
10
  return { from, to, bucket: bucket };
10
11
  }
11
12
  const finite = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
12
- const numeric = (value) => finite(value) ? value : 0;
13
13
  const name = (value) => typeof value === 'string' ? value.slice(0, 200) : 'unknown';
14
14
  function bucketOf(timestamp, bucket) {
15
15
  const date = new Date(timestamp);
@@ -20,12 +20,17 @@ function bucketOf(timestamp, bucket) {
20
20
  return date.toISOString().slice(0, 10);
21
21
  }
22
22
  function empty() {
23
- return { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, cacheWriteInputTokens: 0, reportedCostUsd: 0,
24
- measuredCalls: 0, unknownCalls: 0, costReportedCalls: 0, incompleteCosts: 0, callDurationMs: 0, attemptDurationMs: 0,
25
- attempts: 0, successfulAttempts: 0, accepted: 0, repairs: 0, escalations: 0, unmeasuredAttempts: 0 };
23
+ return { inputTokens: null, outputTokens: null, cachedInputTokens: null, cacheWriteInputTokens: null, reasoningOutputTokens: null, reportedCostUsd: null,
24
+ measuredCalls: 0, unknownCalls: 0, costReportedCalls: 0, incompleteCosts: 0, callDurationMs: null, attemptDurationMs: null,
25
+ attempts: 0, successfulAttempts: 0, accepted: 0, repairs: 0, escalations: 0, unmeasuredAttempts: 0, activity: 0 };
26
+ }
27
+ function addMetric(target, key, value) {
28
+ if (finite(value))
29
+ target[key] = (target[key] ?? 0) + value;
26
30
  }
27
31
  export function aggregateMeasurements(events, period) {
28
- const total = empty(), buckets = new Map(), models = new Map(), modelBuckets = new Map(), tasks = new Map(), phases = new Map();
32
+ const total = empty(), buckets = new Map(), models = new Map(), modelBuckets = new Map(), tasks = new Map();
33
+ const dimensions = { agents: new Map(), providers: new Map(), variants: new Map(), roles: new Map(), phases: new Map() };
29
34
  const modelDetails = new Map();
30
35
  const seen = new Set(), accepted = new Set();
31
36
  let earliest, latest;
@@ -42,23 +47,40 @@ export function aggregateMeasurements(events, period) {
42
47
  latest = event.timestamp;
43
48
  const data = event.data ?? {}, bucket = get(buckets, bucketOf(event.timestamp, period.bucket)), task = get(tasks, event.storyId ?? 'unattributed');
44
49
  const targets = [total, bucket, task];
50
+ for (const target of targets)
51
+ target.activity++;
45
52
  if (event.type === 'tokens') {
46
53
  const calls = Array.isArray(data.calls) && data.calls.length ? data.calls : [{ ...data, actualModel: data.model, durationMs: event.durationMs }];
47
54
  for (const item of calls) {
48
55
  if (!item || typeof item !== 'object')
49
56
  continue;
50
57
  const call = item;
51
- const provider = name(call.provider), model = name(call.actualModel), role = name(call.role);
58
+ const provider = name(call.provider ?? data.provider), model = name(call.actualModel ?? call.model ?? data.actualModel ?? data.model), role = name(call.role ?? data.role);
52
59
  const key = JSON.stringify([provider, model, role]);
53
60
  modelDetails.set(key, { provider, model, role });
54
- for (const target of [...targets, get(models, key), get(modelBuckets, JSON.stringify([bucketOf(event.timestamp, period.bucket), provider, model, role]))]) {
55
- target.inputTokens += numeric(call.inputTokens);
56
- target.outputTokens += numeric(call.outputTokens);
57
- target.cachedInputTokens += numeric(call.cachedInputTokens);
58
- target.cacheWriteInputTokens += numeric(call.cacheWriteInputTokens);
59
- target.reportedCostUsd += numeric(call.totalCostUsd);
60
- target.callDurationMs += numeric(call.durationMs);
61
- const measured = finite(call.inputTokens) && finite(call.outputTokens) && call.usageAvailable !== false && call.measurementComplete !== false;
61
+ const dimensionTargets = [];
62
+ for (const [kind, value] of Object.entries({
63
+ agents: name(call.agent ?? data.agent ?? data.agentName ?? data.provider),
64
+ providers: provider,
65
+ variants: name(call.variant ?? data.variant),
66
+ roles: role,
67
+ })) {
68
+ const dimension = get(dimensions[kind], value);
69
+ dimension.activity++;
70
+ dimensionTargets.push(dimension);
71
+ }
72
+ for (const target of [...targets, get(models, key), get(modelBuckets, JSON.stringify([bucketOf(event.timestamp, period.bucket), provider, model, role])), ...dimensionTargets]) {
73
+ const usageAvailable = call.usageAvailable !== false && data.usageAvailable !== false && call.measurementComplete !== false && data.measurementComplete !== false;
74
+ if (usageAvailable) {
75
+ addMetric(target, 'inputTokens', call.inputTokens);
76
+ addMetric(target, 'outputTokens', call.outputTokens);
77
+ addMetric(target, 'cachedInputTokens', call.cachedInputTokens);
78
+ addMetric(target, 'cacheWriteInputTokens', call.cacheWriteInputTokens);
79
+ addMetric(target, 'reasoningOutputTokens', call.reasoningOutputTokens);
80
+ }
81
+ addMetric(target, 'reportedCostUsd', call.totalCostUsd);
82
+ addMetric(target, 'callDurationMs', call.durationMs);
83
+ const measured = finite(call.inputTokens) && finite(call.outputTokens) && usageAvailable;
62
84
  if (measured)
63
85
  target.measuredCalls++;
64
86
  else
@@ -75,7 +97,11 @@ export function aggregateMeasurements(events, period) {
75
97
  }
76
98
  else if (event.type === 'phase-ended') {
77
99
  const phase = name(event.phase);
78
- phases.set(phase, (phases.get(phase) ?? 0) + numeric(event.durationMs));
100
+ const phaseTarget = get(dimensions.phases, phase);
101
+ phaseTarget.activity++;
102
+ addMetric(phaseTarget, 'attemptDurationMs', event.durationMs);
103
+ if (phase === 'repairing')
104
+ phaseTarget.repairs++;
79
105
  if (phase === 'repairing')
80
106
  for (const target of targets)
81
107
  target.repairs++;
@@ -83,7 +109,7 @@ export function aggregateMeasurements(events, period) {
83
109
  else if (event.type === 'attempt-ended') {
84
110
  for (const target of targets) {
85
111
  target.attempts++;
86
- target.attemptDurationMs += numeric(event.durationMs);
112
+ addMetric(target, 'attemptDurationMs', event.durationMs);
87
113
  if (['completed', 'passed'].includes(event.outcome ?? ''))
88
114
  target.successfulAttempts++;
89
115
  if (data.usageAvailable !== true)
@@ -99,25 +125,163 @@ export function aggregateMeasurements(events, period) {
99
125
  }
100
126
  }
101
127
  }
102
- const decorate = (value) => ({ ...value,
103
- tokensPerCallMinute: value.callDurationMs > 0 ? (value.inputTokens + value.outputTokens) / (value.callDurationMs / 60000) : null,
104
- tokensPerAccepted: value.accepted ? (value.inputTokens + value.outputTokens) / value.accepted : null,
105
- timePerAcceptedMs: value.accepted ? value.attemptDurationMs / value.accepted : null,
106
- costState: value.costReportedCalls === 0 ? 'unknown' : value.costReportedCalls === value.measuredCalls && value.unknownCalls === 0 && value.unmeasuredAttempts === 0 && value.incompleteCosts === 0 ? 'measured' : 'partial',
107
- });
108
- return { total: { ...decorate(total), tokensPerElapsedMinute: (total.inputTokens + total.outputTokens) / ((period.to - period.from) / 60000) },
128
+ const decorate = (value) => {
129
+ const tokens = value.inputTokens !== null && value.outputTokens !== null ? value.inputTokens + value.outputTokens : null;
130
+ const costState = value.costReportedCalls === 0 ? 'unknown' : value.costReportedCalls === value.measuredCalls && value.unknownCalls === 0 && value.unmeasuredAttempts === 0 && value.incompleteCosts === 0 ? 'measured' : 'partial';
131
+ return { ...value,
132
+ tokensPerCallMinute: tokens !== null && value.callDurationMs !== null && value.callDurationMs > 0 ? tokens / (value.callDurationMs / 60000) : null,
133
+ tokensPerAccepted: tokens !== null && value.accepted ? tokens / value.accepted : null,
134
+ timePerAcceptedMs: value.accepted && value.attemptDurationMs !== null ? value.attemptDurationMs / value.accepted : null,
135
+ costState,
136
+ attention: value.unknownCalls + value.unmeasuredAttempts + value.incompleteCosts + Math.max(0, value.attempts - value.successfulAttempts) + value.repairs + value.escalations,
137
+ tokens: { input: value.inputTokens, output: value.outputTokens, cachedInput: value.cachedInputTokens, cacheWriteInput: value.cacheWriteInputTokens, reasoningOutput: value.reasoningOutputTokens, total: tokens },
138
+ calls: { measured: value.measuredCalls, unknown: value.unknownCalls, total: value.measuredCalls + value.unknownCalls },
139
+ outcomes: { attempts: value.attempts, successfulAttempts: value.successfulAttempts, accepted: value.accepted, repairs: value.repairs, escalations: value.escalations },
140
+ time: { callDurationMs: value.callDurationMs, attemptDurationMs: value.attemptDurationMs, elapsedMs: value.attemptDurationMs },
141
+ cost: { reportedUsd: value.reportedCostUsd, reportedCalls: value.costReportedCalls, incompleteCalls: value.incompleteCosts, state: costState, coverage: costState },
142
+ };
143
+ };
144
+ const dimensionRows = (kind) => [...dimensions[kind]].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([name, value]) => ({ name, ...decorate(value) }));
145
+ const phaseRows = dimensionRows('phases').map(row => ({ phase: row.name, durationMs: row.time.attemptDurationMs, ...row }));
146
+ const decoratedTotal = decorate(total);
147
+ return { total: { ...decoratedTotal, tokensPerElapsedMinute: decoratedTotal.tokensPerCallMinute === null && (total.inputTokens === null || total.outputTokens === null) ? null : (total.inputTokens + total.outputTokens) / ((period.to - period.from) / 60000) },
109
148
  buckets: [...buckets].sort(([a], [b]) => a.localeCompare(b)).map(([label, value]) => ({ label, ...decorate(value) })),
110
149
  models: [...models].map(([key, value]) => ({ ...modelDetails.get(key), ...decorate(value) })),
111
150
  modelBuckets: [...modelBuckets].sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => { const [label, provider, model, role] = JSON.parse(key); return { label, provider, model, role, ...decorate(value) }; }),
112
151
  tasks: [...tasks].map(([storyId, value]) => ({ storyId, ...decorate(value) })),
113
- phases: [...phases].map(([phase, durationMs]) => ({ phase, durationMs })), earliest, latest, };
152
+ phases: phaseRows, earliest, latest,
153
+ dimensions: { agents: dimensionRows('agents'), providers: dimensionRows('providers'), variants: dimensionRows('variants'), roles: dimensionRows('roles'), phases: phaseRows },
154
+ agents: dimensionRows('agents'), providers: dimensionRows('providers'), variants: dimensionRows('variants'), roles: dimensionRows('roles'), };
155
+ }
156
+ export function parseRanking(params) {
157
+ const value = params.get('sort') ?? 'attention';
158
+ if (!['attention', 'activity', 'tokens', 'cost', 'acceptance', 'name'].includes(value))
159
+ throw Error('Choose a valid ranking: attention, activity, tokens, cost, acceptance or name');
160
+ return value;
161
+ }
162
+ function compareNames(left, right) { return left < right ? -1 : left > right ? 1 : 0; }
163
+ function compareMetric(left, right) {
164
+ if (left === null && right === null)
165
+ return 0;
166
+ if (left === null)
167
+ return 1;
168
+ if (right === null)
169
+ return -1;
170
+ return right - left;
171
+ }
172
+ function rankingComparator(key) {
173
+ return (left, right) => {
174
+ if (key === 'name')
175
+ return compareNames(left.name, right.name) || compareNames(JSON.stringify(left), JSON.stringify(right));
176
+ return compareMetric(left[key], right[key]) || compareNames(left.name, right.name) || compareNames(JSON.stringify(left), JSON.stringify(right));
177
+ };
178
+ }
179
+ export function rankProjections(rows) {
180
+ const views = {};
181
+ for (const key of ['attention', 'activity', 'tokens', 'cost', 'acceptance', 'name'])
182
+ views[key] = [...rows].sort(rankingComparator(key));
183
+ return views;
184
+ }
185
+ function rankingRow(name, value) {
186
+ return { name, attention: value.attention, activity: value.activity, tokens: value.tokens.total, cost: value.cost.reportedUsd, acceptance: value.outcomes.accepted, costState: value.costState, total: value };
187
+ }
188
+ function dimensionRankings(aggregate, runs = []) {
189
+ return {
190
+ agents: rankProjections(aggregate.agents.map(row => rankingRow(row.name, row))),
191
+ providers: rankProjections(aggregate.providers.map(row => rankingRow(row.name, row))),
192
+ models: rankProjections(aggregate.models.map(row => rankingRow(row.model, row))),
193
+ variants: rankProjections(aggregate.variants.map(row => rankingRow(row.name, row))),
194
+ roles: rankProjections(aggregate.roles.map(row => rankingRow(row.name, row))),
195
+ phases: rankProjections(aggregate.dimensions.phases.map(row => rankingRow(row.name, row))),
196
+ runs: rankProjections(runs.map(run => rankingRow(run.runId, run.total))),
197
+ };
198
+ }
199
+ function projectEvents(root, period) {
200
+ const history = readMeasurements(root, period.from, period.to), seen = new Set();
201
+ const events = [...history.events, ...readEvents(root, EVENT_CAP)].filter(event => {
202
+ const time = Date.parse(event.timestamp);
203
+ if (seen.has(event.id) || !Number.isFinite(time) || time < period.from || time >= period.to)
204
+ return false;
205
+ seen.add(event.id);
206
+ return true;
207
+ }).sort((a, b) => a.timestamp.localeCompare(b.timestamp));
208
+ return { events, errors: history.errors };
209
+ }
210
+ function textValue(value) { return typeof value === 'string' && value.length > 0 ? value.slice(0, 200) : undefined; }
211
+ function eventMetadata(event) {
212
+ const direct = event, data = event.data ?? {};
213
+ const calls = Array.isArray(data.calls) ? data.calls.filter(item => item && typeof item === 'object') : [];
214
+ const sources = [direct, ...calls, data];
215
+ const first = (keys) => { for (const source of sources)
216
+ for (const key of keys) {
217
+ const value = textValue(source[key]);
218
+ if (value)
219
+ return value;
220
+ } return undefined; };
221
+ return { agent: first(['agent']), provider: first(['provider']), model: first(['actualModel', 'model']), variant: first(['variant']), role: first(['role']) };
222
+ }
223
+ function runProjections(events, period) {
224
+ const byRun = new Map();
225
+ for (const event of events)
226
+ byRun.set(event.runId, [...(byRun.get(event.runId) ?? []), event]);
227
+ return [...byRun].map(([runId, runEvents]) => {
228
+ const metadata = {}, phases = [], timestamps = runEvents.map(event => new Date(event.timestamp).toISOString());
229
+ for (const event of runEvents) {
230
+ const values = eventMetadata(event);
231
+ for (const key of ['agent', 'provider', 'model', 'variant', 'role'])
232
+ if (metadata[key] === undefined && values[key] !== undefined)
233
+ metadata[key] = values[key];
234
+ const phase = textValue(event.phase ?? event.data?.phase);
235
+ if (phase && !phases.includes(phase))
236
+ phases.push(phase);
237
+ }
238
+ const total = aggregateMeasurements(runEvents, period).total;
239
+ return { runId, ...metadata, ...(phases.length ? { phase: phases[phases.length - 1], phases } : {}), timestamps, startedAt: timestamps[0], endedAt: timestamps[timestamps.length - 1], events: runEvents, total, analytics: total };
240
+ }).sort((left, right) => compareNames(left.startedAt, right.startedAt) || compareNames(left.runId, right.runId));
241
+ }
242
+ export function projectHistory(root, period, limit = DASHBOARD_LIMITS.events) {
243
+ if (!Number.isInteger(limit) || limit < 1 || limit > DASHBOARD_LIMITS.events)
244
+ throw Error('Limit must be an integer from 1 to ' + DASHBOARD_LIMITS.events);
245
+ const history = projectEvents(root, period), events = history.events.slice(-limit);
246
+ return { from: new Date(period.from).toISOString(), to: new Date(period.to).toISOString(), bucket: period.bucket, timezone: 'UTC', events, runs: runProjections(events, period), errors: history.errors,
247
+ coverage: 'Recorded measurements only. Earlier unrecorded or expired activity cannot be reconstructed. Usage is assigned to its reporting time; durations to their end time.' };
248
+ }
249
+ export function workspaceAnalytics(projects, period, sort = 'attention') {
250
+ const source = [];
251
+ const allEvents = [];
252
+ for (const project of projects.slice(0, DASHBOARD_LIMITS.projects)) {
253
+ if (project.error || !project.root) {
254
+ source.push({ project, analytics: null, errors: [project.error ?? 'Project directory is unavailable'] });
255
+ continue;
256
+ }
257
+ try {
258
+ const history = projectEvents(project.root, period);
259
+ allEvents.push(...history.events);
260
+ const aggregate = aggregateMeasurements(history.events, period);
261
+ source.push({ project, analytics: { ...aggregate, from: new Date(period.from).toISOString(), to: new Date(period.to).toISOString(), bucket: period.bucket, timezone: 'UTC', errors: history.errors, coverage: 'Recorded measurements only. Earlier unrecorded or expired activity cannot be reconstructed. Usage is assigned to its reporting time; durations to their end time.' }, errors: history.errors });
262
+ }
263
+ catch (error) {
264
+ source.push({ project, analytics: null, errors: [error.message] });
265
+ }
266
+ }
267
+ const aggregate = aggregateMeasurements(allEvents, period);
268
+ const runs = runProjections(allEvents, period);
269
+ const projectsWithMetrics = source.map(({ project, analytics, errors }) => {
270
+ const total = analytics?.total;
271
+ return { id: project.id, name: project.name, analytics, errors, attention: project.attention ?? ((project.error ? 1 : 0) + (total?.attention ?? 0)), activity: total?.activity ?? null, tokens: total?.tokens.total ?? null, cost: total?.cost.reportedUsd ?? null, acceptance: total?.outcomes.accepted ?? null, costState: total?.costState ?? 'unknown' };
272
+ });
273
+ const projectRankings = rankProjections(projectsWithMetrics);
274
+ const rankings = { projects: projectRankings, ...dimensionRankings(aggregate, runs) };
275
+ return { ...aggregate, from: new Date(period.from).toISOString(), to: new Date(period.to).toISOString(), bucket: period.bucket, timezone: 'UTC',
276
+ projects: projectRankings[sort], runs, rankings, errors: source.flatMap(row => row.errors) };
114
277
  }
115
278
  export function projectAnalytics(root, period) {
116
- const history = readMeasurements(root, period.from, period.to);
117
- const recent = readEvents(root, 1000);
118
- return { ...aggregateMeasurements([...history.events, ...recent], period),
279
+ const history = projectEvents(root, period);
280
+ const aggregate = aggregateMeasurements(history.events, period);
281
+ const runs = runProjections(history.events, period);
282
+ return { ...aggregate,
119
283
  from: new Date(period.from).toISOString(), to: new Date(period.to).toISOString(), bucket: period.bucket, timezone: 'UTC',
120
- errors: history.errors,
284
+ runs, rankings: dimensionRankings(aggregate, runs), errors: history.errors,
121
285
  coverage: 'Recorded measurements only. Earlier unrecorded or expired activity cannot be reconstructed. Usage is assigned to its reporting time; durations to their end time.',
122
286
  };
123
287
  }
@@ -0,0 +1,23 @@
1
+ import { z } from 'zod';
2
+ export const DASHBOARD_LIMITS = {
3
+ projects: 500,
4
+ events: 100,
5
+ historyBytes: 32 * 1024 * 1024,
6
+ requestBytes: 16 * 1024,
7
+ fileBytes: 1_048_576,
8
+ noteChars: 4000,
9
+ changeChars: 8000,
10
+ };
11
+ export const DashboardControlPayloadSchema = z.object({ action: z.literal('pause') }).strict();
12
+ export const DashboardResumePayloadSchema = z.object({ action: z.literal('resume') }).strict();
13
+ export const DashboardNotePayloadSchema = z.object({ note: z.string().trim().min(1).max(DASHBOARD_LIMITS.noteChars) }).strict();
14
+ export const DashboardChangePayloadSchema = z.object({ request: z.string().trim().min(1).max(DASHBOARD_LIMITS.changeChars) }).strict();
15
+ export const DashboardControlResponseSchema = z.object({ status: z.literal('pause-requested') });
16
+ export function parseDashboardLimit(value) {
17
+ if (value === null)
18
+ return DASHBOARD_LIMITS.events;
19
+ const limit = Number(value);
20
+ if (!Number.isInteger(limit) || limit < 1 || limit > DASHBOARD_LIMITS.events)
21
+ throw Error(`Limit must be an integer from 1 to ${DASHBOARD_LIMITS.events}`);
22
+ return limit;
23
+ }