@xingular/harness 1.1.1 → 1.2.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/CHANGELOG.md +13 -0
- package/README.md +28 -25
- package/dist/cli.js +6675 -2501
- package/dist/sdk/accounts/accounts.d.ts +27 -3
- package/dist/sdk/accounts/accounts.js +78 -24
- package/dist/sdk/accounts/secrets.d.ts +12 -0
- package/dist/sdk/accounts/secrets.js +114 -7
- package/dist/sdk/accounts/store.d.ts +26 -1
- package/dist/sdk/accounts/store.js +1 -1
- package/dist/sdk/agents/builtin.js +15 -1
- package/dist/sdk/agents/planResearch.d.ts +5 -0
- package/dist/sdk/agents/planResearch.js +19 -0
- package/dist/sdk/agents/profiles.d.ts +2 -1
- package/dist/sdk/agents/profiles.js +9 -4
- package/dist/sdk/annotations/pageScript.js +349 -48
- package/dist/sdk/annotations/part.js +34 -23
- package/dist/sdk/annotations/store.d.ts +7 -0
- package/dist/sdk/annotations/store.js +13 -5
- package/dist/sdk/browser/backend.d.ts +16 -0
- package/dist/sdk/browser/backend.js +17 -0
- package/dist/sdk/browser/contracts.d.ts +19 -0
- package/dist/sdk/browser/contracts.js +116 -0
- package/dist/sdk/browser/tools.d.ts +1 -4
- package/dist/sdk/browser/tools.js +14 -91
- package/dist/sdk/cli.js +5 -4
- package/dist/sdk/commands/list.d.ts +3 -0
- package/dist/sdk/commands/list.js +75 -0
- package/dist/sdk/commands/registry.d.ts +250 -0
- package/dist/sdk/commands/registry.js +47 -0
- package/dist/sdk/commands/routes.d.ts +16 -0
- package/dist/sdk/commands/routes.js +33 -0
- package/dist/sdk/core/commandBudget.d.ts +23 -0
- package/dist/sdk/core/commandBudget.js +57 -0
- package/dist/sdk/core/index.js +17 -1
- package/dist/sdk/core/interactiveProfile.d.ts +46 -0
- package/dist/sdk/core/interactiveProfile.js +143 -0
- package/dist/sdk/core/workspace.d.ts +13 -0
- package/dist/sdk/core/workspace.js +88 -0
- package/dist/sdk/core/workspaceBashTool.d.ts +4 -0
- package/dist/sdk/core/workspaceBashTool.js +36 -0
- package/dist/sdk/core/workspaceSandbox.d.ts +31 -0
- package/dist/sdk/core/workspaceSandbox.js +320 -0
- package/dist/sdk/core/workspaceWriteOperations.d.ts +22 -0
- package/dist/sdk/core/workspaceWriteOperations.js +206 -0
- package/dist/sdk/doctor/types.d.ts +2 -2
- package/dist/sdk/harness.d.ts +7 -0
- package/dist/sdk/harness.js +42 -2
- package/dist/sdk/index.d.ts +6 -1
- package/dist/sdk/index.js +3 -0
- package/dist/sdk/pi/runtime.d.ts +2 -1
- package/dist/sdk/pi/runtime.js +2 -2
- package/dist/sdk/plugins/builtin.js +29 -12
- package/dist/sdk/plugins/builtinRegistry.d.ts +10 -3
- package/dist/sdk/plugins/builtinRegistry.js +30 -8
- package/dist/sdk/plugins/catalogue.js +2 -1
- package/dist/sdk/plugins/commandPrefix.d.ts +8 -0
- package/dist/sdk/plugins/commandPrefix.js +21 -0
- package/dist/sdk/plugins/firstParty.d.ts +1 -1
- package/dist/sdk/plugins/firstParty.js +8 -2
- package/dist/sdk/plugins/inventory.js +4 -3
- package/dist/sdk/plugins/manifest.d.ts +2 -0
- package/dist/sdk/plugins/manifest.js +2 -1
- package/dist/sdk/plugins/surfaces.d.ts +5 -0
- package/dist/sdk/plugins/surfaces.js +11 -0
- package/dist/sdk/plugins/types.d.ts +15 -0
- package/dist/sdk/prompt/compile.d.ts +14 -0
- package/dist/sdk/prompt/compile.js +65 -29
- package/dist/sdk/prompt/standard.d.ts +17 -0
- package/dist/sdk/prompt/standard.js +21 -0
- package/dist/sdk/server/methods.js +18 -0
- package/dist/sdk/server/remote.js +36 -11
- package/dist/sdk/sessions/compactionCheckpoint.d.ts +11 -0
- package/dist/sdk/sessions/compactionCheckpoint.js +79 -0
- package/dist/sdk/sessions/continuation.d.ts +3 -0
- package/dist/sdk/sessions/continuation.js +5 -1
- package/dist/sdk/sessions/contracts.d.ts +168 -0
- package/dist/sdk/sessions/contracts.js +59 -1
- package/dist/sdk/sessions/control.d.ts +72 -0
- package/dist/sdk/sessions/control.js +158 -0
- package/dist/sdk/sessions/initialBash.d.ts +7 -0
- package/dist/sdk/sessions/initialBash.js +7 -0
- package/dist/sdk/sessions/planMode.d.ts +14 -3
- package/dist/sdk/sessions/planMode.js +73 -16
- package/dist/sdk/sessions/registry.d.ts +1 -1
- package/dist/sdk/sessions/registry.js +2 -2
- package/dist/sdk/sessions/runStore.d.ts +14 -0
- package/dist/sdk/sessions/runStore.js +35 -0
- package/dist/sdk/sessions/runner.d.ts +40 -2
- package/dist/sdk/sessions/runner.js +161 -13
- package/dist/sdk/sessions/sessions.d.ts +23 -3
- package/dist/sdk/sessions/sessions.js +202 -17
- package/dist/sdk/sessions/subagentService.d.ts +9 -0
- package/dist/sdk/sessions/subagentService.js +63 -4
- package/dist/sdk/sessions/workSlice.d.ts +103 -0
- package/dist/sdk/sessions/workSlice.js +297 -0
- package/dist/sdk/sessions/workspaceBoundary.d.ts +15 -0
- package/dist/sdk/sessions/workspaceBoundary.js +46 -0
- package/dist/sdk/terminal/browserControl.js +2 -1
- package/dist/sdk/terminal/controller.d.ts +11 -3
- package/dist/sdk/terminal/controller.js +40 -13
- package/dist/sdk/terminal/opentui/theme.d.ts +43 -0
- package/dist/sdk/terminal/opentui/theme.js +87 -0
- package/dist/sdk/terminal/piExtension.d.ts +6 -1
- package/dist/sdk/terminal/piExtension.js +154 -54
- package/dist/sdk/terminal/standalone.d.ts +7 -0
- package/dist/sdk/terminal/standalone.js +72 -13
- package/dist/sdk/terminal/subagentMonitor.d.ts +11 -2
- package/dist/sdk/terminal/subagentMonitor.js +32 -1
- package/dist/sdk/terminal/terminals.d.ts +19 -2
- package/dist/sdk/terminal/terminals.js +245 -35
- package/dist/sdk/terminal/tui/annotateCommand.d.ts +1 -1
- package/dist/sdk/terminal/tui/annotateCommand.js +3 -3
- package/dist/sdk/terminal/tui/commandPages.d.ts +3 -1
- package/dist/sdk/terminal/tui/commandPages.js +3 -0
- package/dist/sdk/terminal/tui/commands.d.ts +2 -0
- package/dist/sdk/terminal/tui/commands.js +205 -124
- package/dist/sdk/terminal/tui/list.d.ts +14 -1
- package/dist/sdk/terminal/tui/list.js +96 -14
- package/dist/sdk/terminal/tui/pageOverlay.js +13 -4
- package/dist/sdk/terminal/tui/pages/annotatePage.d.ts +1 -1
- package/dist/sdk/terminal/tui/pages/notesPage.d.ts +5 -5
- package/dist/sdk/terminal/tui/pages/notesPage.js +18 -18
- package/dist/sdk/terminal/tui/pages/pluginsPage.d.ts +0 -5
- package/dist/sdk/terminal/tui/pages/pluginsPage.js +9 -3
- package/dist/sdk/terminal/tui/pages/subagentsPage.d.ts +1 -0
- package/dist/sdk/terminal/tui/pages/subagentsPage.js +98 -16
- package/dist/sdk/terminal/tui/pages/usagePage.d.ts +43 -0
- package/dist/sdk/terminal/tui/pages/usagePage.js +370 -0
- package/dist/sdk/terminal/tui/piAdapter.d.ts +1 -2
- package/dist/sdk/terminal/tui/piAdapter.js +3 -5
- package/dist/sdk/terminal/tui/pluginCommands.d.ts +2 -0
- package/dist/sdk/terminal/tui/pluginCommands.js +13 -9
- package/dist/sdk/terminal/tui/runtime.js +23 -3
- package/dist/sdk/terminal/tui/sessionPicker.d.ts +40 -0
- package/dist/sdk/terminal/tui/sessionPicker.js +141 -0
- package/dist/sdk/tools/checklist.js +1 -0
- package/dist/sdk/tools/checkpoint.d.ts +27 -0
- package/dist/sdk/tools/checkpoint.js +154 -0
- package/dist/sdk/tools/getCapabilities.d.ts +31 -0
- package/dist/sdk/tools/getCapabilities.js +34 -0
- package/dist/sdk/tools/spawnSubagent.js +4 -3
- package/dist/sdk/usage/analytics.d.ts +20 -0
- package/dist/sdk/usage/analytics.js +151 -0
- package/dist/sdk/usage/ledger.d.ts +38 -0
- package/dist/sdk/usage/ledger.js +142 -0
- package/dist/sdk/usage/limits/adapters.d.ts +11 -0
- package/dist/sdk/usage/limits/adapters.js +349 -0
- package/dist/sdk/usage/limits/capture.d.ts +8 -0
- package/dist/sdk/usage/limits/capture.js +47 -0
- package/dist/sdk/usage/limits/http.d.ts +12 -0
- package/dist/sdk/usage/limits/http.js +51 -0
- package/dist/sdk/usage/limits/normalize.d.ts +15 -0
- package/dist/sdk/usage/limits/normalize.js +211 -0
- package/dist/sdk/usage/limits/service.d.ts +30 -0
- package/dist/sdk/usage/limits/service.js +176 -0
- package/dist/sdk/usage/limits/types.d.ts +65 -0
- package/dist/sdk/usage/limits/types.js +1 -0
- package/dist/sdk/usage/serverMethods.d.ts +3 -0
- package/dist/sdk/usage/serverMethods.js +22 -0
- package/dist/sdk/usage/service.d.ts +32 -0
- package/dist/sdk/usage/service.js +66 -0
- package/dist/sdk/usage/settings.d.ts +7 -0
- package/dist/sdk/usage/settings.js +28 -0
- package/dist/sdk/usage/types.d.ts +60 -0
- package/dist/sdk/usage/types.js +4 -0
- package/dist/terminal-opentui.js +5207 -1883
- package/dist/terminal-pi.js +1658 -271
- package/docs/architecture/02-sessions-and-runs.md +128 -26
- package/docs/architecture/04-prompt.md +50 -12
- package/docs/architecture/06-plugins-and-capabilities.md +24 -7
- package/docs/architecture/07-tools-and-gate.md +104 -24
- package/docs/architecture/08-memory-and-history.md +4 -2
- package/docs/architecture/09-accounts-and-connectors.md +31 -1
- package/docs/architecture/10-server-protocol.md +58 -3
- package/docs/architecture/11-terminal.md +105 -42
- package/docs/architecture/12-storage.md +30 -2
- package/docs/architecture/13-world-tools.md +22 -1
- package/docs/architecture/14-surfaces-and-parity.md +54 -7
- package/docs/architecture/README.md +3 -1
- package/docs/architecture/proposals/PROP-0017-page-annotations.md +6 -2
- package/docs/architecture/proposals/PROP-0018-annotation-queue-and-app-targets.md +1 -1
- package/docs/architecture/proposals/PROP-0031-plugin-command-owner-namespaces.md +54 -0
- package/docs/architecture/proposals/PROP-0032-planning-quality.md +196 -0
- package/docs/architecture/proposals/PROP-0033-compaction-plan-checkpoints.md +75 -0
- package/docs/architecture/proposals/PROP-0034-conversation-fast-mode.md +120 -0
- package/docs/architecture/proposals/PROP-0035-retain-subagent-status.md +47 -0
- package/docs/architecture/proposals/PROP-0036-first-class-harness-commands.md +59 -0
- package/docs/architecture/proposals/PROP-0037-workspace-write-boundary.md +63 -0
- package/docs/architecture/proposals/PROP-0038-annotate-open-shadow-roots.md +73 -0
- package/docs/architecture/proposals/PROP-0039-workspace-boundary-hardening.md +139 -0
- package/docs/architecture/proposals/PROP-0040-one-launch-path.md +87 -0
- package/docs/architecture/proposals/PROP-0041-bounded-work-slices.md +247 -0
- package/docs/architecture/proposals/PROP-0042-usage-and-provider-limits.md +127 -0
- package/docs/architecture/proposals/PROP-0043-read-only-plan-bash.md +74 -0
- package/docs/architecture/proposals/README.md +14 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +1 -1
- package/docs/decisions/ADR-0021-required-command-namespaces.md +1 -1
- package/docs/decisions/ADR-0031-plugin-command-owner-namespaces.md +37 -0
- package/docs/decisions/ADR-0032-compaction-run-checkpoints.md +33 -0
- package/docs/decisions/ADR-0033-plan-mode-research-delegation.md +47 -0
- package/docs/decisions/ADR-0034-first-class-harness-terminal-commands.md +25 -0
- package/docs/decisions/ADR-0035-one-launch-path.md +40 -0
- package/docs/decisions/ADR-0036-workspace-write-operation-boundary.md +54 -0
- package/docs/decisions/ADR-0037-usage-ledger-and-provider-limits.md +44 -0
- package/docs/decisions/ADR-0038-plan-bash-os-read-only.md +29 -0
- package/docs/experiments/EXP-0003-performance-audit/data/session-runtime-replication.json +420 -0
- package/docs/experiments/EXP-0003-performance-audit/data/session-runtime.json +420 -0
- package/docs/experiments/EXP-0003-performance-audit/data/terminal-compact.json +376 -0
- package/docs/experiments/EXP-0003-performance-audit/data/terminal-history.json +422 -0
- package/docs/experiments/EXP-0003-performance-audit/data/terminal-question.json +95 -0
- package/docs/experiments/EXP-0003-performance-audit/data/terminal-wide.json +1036 -0
- package/docs/experiments/EXP-0003-performance-audit/plan.md +109 -0
- package/docs/experiments/EXP-0003-performance-audit/results.md +151 -0
- package/docs/handoff-2026-10-25-app-parity-and-solo-tabs.md +202 -0
- package/docs/parity.md +80 -24
- package/docs/releases/1.1.1.md +28 -0
- package/docs/releases/1.2.0.md +49 -0
- package/docs/releases/4.0.0.md +10 -9
- package/docs/releases/README.md +35 -30
- package/docs/study/00-index.md +3 -0
- package/docs/study/STUDY-0003-long-horizon-runs.md +210 -0
- package/docs/terminal-tui.md +100 -59
- package/package.json +4 -3
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
2
|
+
import { stripTerminalSequences } from '@earendil-works/pi-tui';
|
|
3
|
+
import { displayProjectName, singleLine } from "./status.js";
|
|
4
|
+
import { FilterList } from "./list.js";
|
|
5
|
+
const RELATIVE_TIME = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
|
|
6
|
+
const TIME_UNITS = [
|
|
7
|
+
[365 * 24 * 60 * 60 * 1000, 'year'],
|
|
8
|
+
[30 * 24 * 60 * 60 * 1000, 'month'],
|
|
9
|
+
[7 * 24 * 60 * 60 * 1000, 'week'],
|
|
10
|
+
[24 * 60 * 60 * 1000, 'day'],
|
|
11
|
+
[60 * 60 * 1000, 'hour'],
|
|
12
|
+
[60 * 1000, 'minute'],
|
|
13
|
+
[1000, 'second']
|
|
14
|
+
];
|
|
15
|
+
export function formatSessionRelativeTime(value, now = Date.now()) {
|
|
16
|
+
const delta = value.getTime() - now;
|
|
17
|
+
if (!Number.isFinite(delta))
|
|
18
|
+
return 'unknown time';
|
|
19
|
+
const absolute = Math.abs(delta);
|
|
20
|
+
const unit = TIME_UNITS.find(([size]) => absolute >= size);
|
|
21
|
+
return unit ? RELATIVE_TIME.format(Math.round(delta / unit[0]), unit[1]) : RELATIVE_TIME.format(0, 'second');
|
|
22
|
+
}
|
|
23
|
+
export function formatSessionSize(bytes) {
|
|
24
|
+
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes < 0)
|
|
25
|
+
return '—';
|
|
26
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
27
|
+
let size = bytes;
|
|
28
|
+
let unit = 0;
|
|
29
|
+
while (size >= 1024 && unit < units.length - 1) {
|
|
30
|
+
size /= 1024;
|
|
31
|
+
unit += 1;
|
|
32
|
+
}
|
|
33
|
+
return `${unit === 0 ? Math.round(size) : size.toFixed(1)}${units[unit]}`;
|
|
34
|
+
}
|
|
35
|
+
function safe(value) {
|
|
36
|
+
return stripTerminalSequences(value).replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ' ');
|
|
37
|
+
}
|
|
38
|
+
export class ResumeSessionsPage {
|
|
39
|
+
options;
|
|
40
|
+
presentation = 'fullscreen';
|
|
41
|
+
list;
|
|
42
|
+
loading = false;
|
|
43
|
+
loaded = false;
|
|
44
|
+
disposed = false;
|
|
45
|
+
error;
|
|
46
|
+
abort;
|
|
47
|
+
constructor(options) {
|
|
48
|
+
this.options = options;
|
|
49
|
+
this.list = new FilterList({
|
|
50
|
+
theme: options.theme,
|
|
51
|
+
search: 'always',
|
|
52
|
+
rowGap: 1,
|
|
53
|
+
noun: ['session', 'sessions'],
|
|
54
|
+
empty: 'No saved sessions in this project',
|
|
55
|
+
onCancel: options.cancel,
|
|
56
|
+
onActivate: row => { if (row.data)
|
|
57
|
+
options.select(row.data); }
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
get focused() { return this.list.focused; }
|
|
61
|
+
set focused(value) { this.list.focused = value; }
|
|
62
|
+
tabs() { return [{ id: 'sessions', title: 'Resume session' }]; }
|
|
63
|
+
activeTab() { return 'sessions'; }
|
|
64
|
+
hint() { return '↑/↓ select · type to search · Enter resume'; }
|
|
65
|
+
activate() {
|
|
66
|
+
if (this.loaded || this.loading || this.disposed)
|
|
67
|
+
return;
|
|
68
|
+
this.abort = new AbortController();
|
|
69
|
+
void this.load(this.abort.signal);
|
|
70
|
+
}
|
|
71
|
+
dispose() {
|
|
72
|
+
this.disposed = true;
|
|
73
|
+
this.abort?.abort();
|
|
74
|
+
}
|
|
75
|
+
async load(signal) {
|
|
76
|
+
this.loading = true;
|
|
77
|
+
this.options.changed();
|
|
78
|
+
try {
|
|
79
|
+
const sessions = await this.options.listSessions(signal);
|
|
80
|
+
const branch = singleLine(this.options.branch() ?? '') || '—';
|
|
81
|
+
const rows = await Promise.all(sessions.map(async (session) => {
|
|
82
|
+
let bytes;
|
|
83
|
+
try {
|
|
84
|
+
bytes = (await stat(session.path)).size;
|
|
85
|
+
}
|
|
86
|
+
catch { /* A removed or unreadable file remains selectable and shows an unknown size. */ }
|
|
87
|
+
const title = session.name?.trim() || session.firstMessage.trim() || session.id;
|
|
88
|
+
const project = displayProjectName(session.cwd || this.options.cwd());
|
|
89
|
+
const modified = formatSessionRelativeTime(session.modified);
|
|
90
|
+
const subtitle = `${modified} · ${project}:${branch} · ${formatSessionSize(bytes)}`;
|
|
91
|
+
return {
|
|
92
|
+
id: session.path,
|
|
93
|
+
label: singleLine(title),
|
|
94
|
+
subtitle,
|
|
95
|
+
searchText: `${title} ${session.firstMessage} ${session.id} ${subtitle}`,
|
|
96
|
+
data: session.path
|
|
97
|
+
};
|
|
98
|
+
}));
|
|
99
|
+
if (this.disposed || signal.aborted)
|
|
100
|
+
return;
|
|
101
|
+
this.list.setRows(rows);
|
|
102
|
+
this.error = undefined;
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (!this.disposed && !signal.aborted)
|
|
106
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
this.loaded = true;
|
|
110
|
+
this.loading = false;
|
|
111
|
+
if (!this.disposed)
|
|
112
|
+
this.options.changed();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
handleInput(data) {
|
|
116
|
+
if (!this.list.handleInput(data))
|
|
117
|
+
return false;
|
|
118
|
+
this.options.changed();
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
handleMouse(event) {
|
|
122
|
+
if (event.type === 'move')
|
|
123
|
+
return { handled: true, render: false };
|
|
124
|
+
if (event.type === 'wheel')
|
|
125
|
+
return undefined;
|
|
126
|
+
const result = this.list.handleMouse(event);
|
|
127
|
+
if (result?.render)
|
|
128
|
+
this.options.changed();
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
render(width, rows) {
|
|
132
|
+
if (width < 1 || rows < 1)
|
|
133
|
+
return [];
|
|
134
|
+
const theme = this.options.theme();
|
|
135
|
+
if (this.error)
|
|
136
|
+
return [theme.fg('error', `Could not read saved sessions: ${safe(this.error)}`)];
|
|
137
|
+
if (this.loading || !this.loaded)
|
|
138
|
+
return [theme.fg('dim', 'Loading saved sessions…')];
|
|
139
|
+
return this.list.render(width, rows);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -25,6 +25,7 @@ export function checklistTool(run) {
|
|
|
25
25
|
description: 'Keep a checklist of the steps of the current work. The user watches this list to follow your progress, so write it early and keep it current. Send the whole list every time; it replaces the previous one. ' +
|
|
26
26
|
'Use it whenever the work has several distinct steps or will take several tool calls, when the user gives several things to do, and when a subagent brief asks for more than one thing. Do not use it for a single, simple step or for conversation. ' +
|
|
27
27
|
'Statuses: pending, in_progress, completed. Keep exactly one step in_progress while you work, mark a step completed as soon as it is done (not in batches), and add steps you discover. ' +
|
|
28
|
+
'Before your final response, write one final checklist update: mark every fully completed step completed, and leave unfinished or blocked steps in their accurate pending or in_progress status. Never mark unfinished work completed. ' +
|
|
28
29
|
'Only mark a step completed when it is fully done; if something blocks it, keep it in_progress and add a step for the blocker.',
|
|
29
30
|
parameters: (Type) => Type.Object({
|
|
30
31
|
items: Type.Array(Type.Object({
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Checkpoint } from "../sessions/contracts.js";
|
|
2
|
+
import type { ActiveRun } from "../sessions/registry.js";
|
|
3
|
+
import type { RunEvent } from "../sessions/contracts.js";
|
|
4
|
+
import type { ToolSpec } from "./types.js";
|
|
5
|
+
export declare const CHECKPOINT_WRITE = "checkpoint_write";
|
|
6
|
+
/** What the model may send: the record without the numbers the harness fills in. */
|
|
7
|
+
export type CheckpointInput = Omit<Checkpoint, 'slice' | 'reason'> & {
|
|
8
|
+
reason?: Checkpoint['reason'];
|
|
9
|
+
};
|
|
10
|
+
export type CheckpointParse = {
|
|
11
|
+
ok: true;
|
|
12
|
+
value: CheckpointInput;
|
|
13
|
+
} | {
|
|
14
|
+
ok: false;
|
|
15
|
+
error: string;
|
|
16
|
+
};
|
|
17
|
+
/** The checkpoint as the model sent it, or one line saying what to fix. */
|
|
18
|
+
export declare function parseCheckpoint(value: unknown): CheckpointParse;
|
|
19
|
+
/** The model's own checkpoint, filled in with this slice and why it was written. */
|
|
20
|
+
export declare function checkpointRecord(input: CheckpointInput, slice: number, fallbackReason: Checkpoint['reason']): Checkpoint;
|
|
21
|
+
/** The latest checkpoint a run recorded, if any. */
|
|
22
|
+
export declare function checkpointOf(events: readonly RunEvent[]): Checkpoint | null;
|
|
23
|
+
/** The checkpoint as the next slice reads it. Bounded by its parts; the caller clips the whole. */
|
|
24
|
+
export declare function describeCheckpoint(checkpoint: Checkpoint): string;
|
|
25
|
+
/** The checkpoint as the model reads it back after writing it. */
|
|
26
|
+
export declare function ackCheckpoint(checkpoint: Checkpoint): string;
|
|
27
|
+
export declare function checkpointTool(run: Pick<ActiveRun, 'emit'>, slice: number | (() => number)): ToolSpec;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// The reply's handoff when it reaches a work-slice budget (PROP-0041). The model writes it once,
|
|
2
|
+
// the run records it as a `checkpoint` event, and the next slice is told where this one stopped.
|
|
3
|
+
// It only changes run state, so it stays callable in plan mode.
|
|
4
|
+
import { checkpointCheckSchema, checkpointDecisionSchema, checkpointFileSchema, checkpointItemSchema, checkpointReasonSchema, MAX_CHECKPOINT_CHECKS, MAX_CHECKPOINT_DECISIONS, MAX_CHECKPOINT_DONE, MAX_CHECKPOINT_FILES, MAX_CHECKPOINT_SUMMARY, runEventPayloadSchemas } from "../sessions/contracts.js";
|
|
5
|
+
export const CHECKPOINT_WRITE = 'checkpoint_write';
|
|
6
|
+
const text = (value) => (typeof value === 'string' ? value.trim() : '');
|
|
7
|
+
/** Trims and bounds one entry; an unusable entry is dropped rather than failing the whole record. */
|
|
8
|
+
function collect(value, parse, limit) {
|
|
9
|
+
if (!Array.isArray(value))
|
|
10
|
+
return [];
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const entry of value.slice(0, limit)) {
|
|
13
|
+
const parsed = parse(entry);
|
|
14
|
+
if (parsed)
|
|
15
|
+
out.push(parsed);
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
/** The checkpoint as the model sent it, or one line saying what to fix. */
|
|
20
|
+
export function parseCheckpoint(value) {
|
|
21
|
+
if (!value || typeof value !== 'object')
|
|
22
|
+
return { ok: false, error: 'Send checkpoint_write an object with summary and next.' };
|
|
23
|
+
const input = value;
|
|
24
|
+
const summary = text(input.summary);
|
|
25
|
+
if (summary.length > MAX_CHECKPOINT_SUMMARY)
|
|
26
|
+
return { ok: false, error: `Keep summary to ${MAX_CHECKPOINT_SUMMARY} characters.` };
|
|
27
|
+
if (text(input.next).length > 500)
|
|
28
|
+
return { ok: false, error: 'Keep next to 500 characters.' };
|
|
29
|
+
if (text(input.blocked).length > 500)
|
|
30
|
+
return { ok: false, error: 'Keep blocked to 500 characters.' };
|
|
31
|
+
const done = collect(input.done, (entry) => {
|
|
32
|
+
const parsed = checkpointItemSchema.safeParse(entry);
|
|
33
|
+
return parsed.success ? parsed.data : null;
|
|
34
|
+
}, MAX_CHECKPOINT_DONE);
|
|
35
|
+
const files = collect(input.files, (entry) => {
|
|
36
|
+
const parsed = checkpointFileSchema.safeParse(entry);
|
|
37
|
+
return parsed.success ? parsed.data : null;
|
|
38
|
+
}, MAX_CHECKPOINT_FILES);
|
|
39
|
+
const checks = collect(input.checks, (entry) => {
|
|
40
|
+
const parsed = checkpointCheckSchema.safeParse(entry);
|
|
41
|
+
return parsed.success ? parsed.data : null;
|
|
42
|
+
}, MAX_CHECKPOINT_CHECKS);
|
|
43
|
+
const decisions = collect(input.decisions, (entry) => {
|
|
44
|
+
const parsed = checkpointDecisionSchema.safeParse(entry);
|
|
45
|
+
return parsed.success ? parsed.data : null;
|
|
46
|
+
}, MAX_CHECKPOINT_DECISIONS);
|
|
47
|
+
if (!summary && !done.length && !text(input.next) && !files.length) {
|
|
48
|
+
return { ok: false, error: 'A checkpoint needs a summary, a next step, or what is done; write the handoff the next slice will read.' };
|
|
49
|
+
}
|
|
50
|
+
const reason = input.reason === undefined ? undefined : checkpointReasonSchema.safeParse(input.reason);
|
|
51
|
+
if (reason && !reason.success)
|
|
52
|
+
return { ok: false, error: 'reason is budget, stuck, compaction-thrash or requested.' };
|
|
53
|
+
return {
|
|
54
|
+
ok: true,
|
|
55
|
+
value: {
|
|
56
|
+
summary,
|
|
57
|
+
done,
|
|
58
|
+
next: text(input.next),
|
|
59
|
+
...(text(input.blocked) ? { blocked: text(input.blocked) } : {}),
|
|
60
|
+
files,
|
|
61
|
+
checks,
|
|
62
|
+
decisions,
|
|
63
|
+
...(reason && reason.success ? { reason: reason.data } : {})
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** The model's own checkpoint, filled in with this slice and why it was written. */
|
|
68
|
+
export function checkpointRecord(input, slice, fallbackReason) {
|
|
69
|
+
return { ...input, slice, reason: input.reason ?? fallbackReason };
|
|
70
|
+
}
|
|
71
|
+
/** The latest checkpoint a run recorded, if any. */
|
|
72
|
+
export function checkpointOf(events) {
|
|
73
|
+
const last = events.filter((event) => event.type === 'checkpoint').at(-1);
|
|
74
|
+
if (!last)
|
|
75
|
+
return null;
|
|
76
|
+
const parsed = runEventPayloadSchemas.checkpoint.safeParse(last.payload);
|
|
77
|
+
return parsed.success ? parsed.data : null;
|
|
78
|
+
}
|
|
79
|
+
const line = (value) => value.replace(/\s+/gu, ' ').trim();
|
|
80
|
+
/** The checkpoint as the next slice reads it. Bounded by its parts; the caller clips the whole. */
|
|
81
|
+
export function describeCheckpoint(checkpoint) {
|
|
82
|
+
const parts = [];
|
|
83
|
+
if (checkpoint.summary)
|
|
84
|
+
parts.push(`Where the last slice stopped: ${line(checkpoint.summary)}`);
|
|
85
|
+
if (checkpoint.done.length)
|
|
86
|
+
parts.push(`Done:\n${checkpoint.done.map((entry) => `- ${line(entry.item)}${entry.evidence.length ? ` (${entry.evidence.map(line).join('; ')})` : ''}`).join('\n')}`);
|
|
87
|
+
if (checkpoint.files.length)
|
|
88
|
+
parts.push(`Files touched:\n${checkpoint.files.map((entry) => `- ${line(entry.path)}${entry.why ? `: ${line(entry.why)}` : ''}`).join('\n')}`);
|
|
89
|
+
if (checkpoint.checks.length) {
|
|
90
|
+
const failing = checkpoint.checks.filter((entry) => !entry.ok);
|
|
91
|
+
const passing = checkpoint.checks.filter((entry) => entry.ok);
|
|
92
|
+
if (passing.length)
|
|
93
|
+
parts.push(`Checks that passed: ${passing.map((entry) => line(entry.command)).join('; ')}`);
|
|
94
|
+
if (failing.length)
|
|
95
|
+
parts.push(`Checks that failed:\n${failing.map((entry) => `- ${line(entry.command)}${entry.detail ? `: ${line(entry.detail)}` : ''}`).join('\n')}`);
|
|
96
|
+
}
|
|
97
|
+
if (checkpoint.decisions.length)
|
|
98
|
+
parts.push(`Decisions:\n${checkpoint.decisions.map((entry) => `- ${line(entry.what)}${entry.why ? `: ${line(entry.why)}` : ''}`).join('\n')}`);
|
|
99
|
+
if (checkpoint.blocked)
|
|
100
|
+
parts.push(`Blocked: ${line(checkpoint.blocked)}`);
|
|
101
|
+
if (checkpoint.next)
|
|
102
|
+
parts.push(`Next step: ${line(checkpoint.next)}`);
|
|
103
|
+
return parts.join('\n\n');
|
|
104
|
+
}
|
|
105
|
+
/** The checkpoint as the model reads it back after writing it. */
|
|
106
|
+
export function ackCheckpoint(checkpoint) {
|
|
107
|
+
const counts = [
|
|
108
|
+
checkpoint.done.length ? `${checkpoint.done.length} done` : '',
|
|
109
|
+
checkpoint.files.length ? `${checkpoint.files.length} files` : '',
|
|
110
|
+
checkpoint.checks.length ? `${checkpoint.checks.length} checks` : ''
|
|
111
|
+
].filter(Boolean).join(', ');
|
|
112
|
+
return `Checkpoint recorded for slice ${checkpoint.slice}${counts ? ` (${counts})` : ''}.${checkpoint.next ? ` Next: ${line(checkpoint.next)}` : ''}`;
|
|
113
|
+
}
|
|
114
|
+
export function checkpointTool(run, slice) {
|
|
115
|
+
return {
|
|
116
|
+
name: CHECKPOINT_WRITE,
|
|
117
|
+
label: 'Write the handoff checkpoint',
|
|
118
|
+
description: 'Record where this reply stopped and what the next slice must know. Call it when the harness asks for a handoff at the end of a work slice, or before you stop on purpose. ' +
|
|
119
|
+
'Include what is done (with the file or check that proves it), the single next step, anything blocking, the files you touched, the checks you ran with their outcome, and decisions the next slice must not revisit. ' +
|
|
120
|
+
'It records run state for the next slice; it does not change files and does not replace your reply to the user.',
|
|
121
|
+
parameters: (Type) => Type.Object({
|
|
122
|
+
summary: Type.Optional(Type.String({ description: 'Where this slice stopped, in 1 to 3 sentences (max 1200 characters)' })),
|
|
123
|
+
done: Type.Optional(Type.Array(Type.Object({
|
|
124
|
+
item: Type.String({ description: 'One finished step' }),
|
|
125
|
+
evidence: Type.Optional(Type.Array(Type.String({ description: 'A path, a check command, or a decision that proves it' }), { maxItems: 5 }))
|
|
126
|
+
}), { maxItems: MAX_CHECKPOINT_DONE })),
|
|
127
|
+
next: Type.Optional(Type.String({ description: 'The single next step (max 500 characters)' })),
|
|
128
|
+
blocked: Type.Optional(Type.String({ description: 'What stops progress, if anything (max 500 characters)' })),
|
|
129
|
+
files: Type.Optional(Type.Array(Type.Object({
|
|
130
|
+
path: Type.String(),
|
|
131
|
+
why: Type.Optional(Type.String({ description: 'Why this file matters to the next slice' }))
|
|
132
|
+
}), { maxItems: MAX_CHECKPOINT_FILES })),
|
|
133
|
+
checks: Type.Optional(Type.Array(Type.Object({
|
|
134
|
+
command: Type.String(),
|
|
135
|
+
ok: Type.Boolean(),
|
|
136
|
+
detail: Type.Optional(Type.String({ description: 'The failing line, when it failed' }))
|
|
137
|
+
}), { maxItems: MAX_CHECKPOINT_CHECKS })),
|
|
138
|
+
decisions: Type.Optional(Type.Array(Type.Object({
|
|
139
|
+
what: Type.String(),
|
|
140
|
+
why: Type.Optional(Type.String())
|
|
141
|
+
}), { maxItems: MAX_CHECKPOINT_DECISIONS }))
|
|
142
|
+
}),
|
|
143
|
+
readOnly: true,
|
|
144
|
+
planModeSafe: () => true,
|
|
145
|
+
execute: async (input) => {
|
|
146
|
+
const parsed = parseCheckpoint(input);
|
|
147
|
+
if (!parsed.ok)
|
|
148
|
+
return parsed.error;
|
|
149
|
+
const checkpoint = checkpointRecord(parsed.value, typeof slice === 'function' ? slice() : slice, 'requested');
|
|
150
|
+
run.emit('checkpoint', checkpoint);
|
|
151
|
+
return ackCheckpoint(checkpoint);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { CapabilitySet } from "../capabilities/types.js";
|
|
2
|
+
import type { ToolSpec } from "./types.js";
|
|
3
|
+
export declare const GET_CAPABILITIES = "get_capabilities";
|
|
4
|
+
export declare const CAPABILITY_DISCOVERY: {
|
|
5
|
+
readonly name: "get_capabilities";
|
|
6
|
+
readonly label: "Inspect current capabilities";
|
|
7
|
+
readonly description: "List the tools callable in this run, or inspect one tool and its input schema. Availability is not permission: calls still follow the gate. Disabled, unavailable and unregistered tools are not advertised as callable.";
|
|
8
|
+
readonly parameters: {
|
|
9
|
+
readonly type: "object";
|
|
10
|
+
readonly additionalProperties: false;
|
|
11
|
+
readonly properties: {
|
|
12
|
+
readonly tool: {
|
|
13
|
+
readonly type: "string";
|
|
14
|
+
readonly minLength: 1;
|
|
15
|
+
readonly maxLength: 200;
|
|
16
|
+
readonly description: "An exact tool name to inspect. Omit to list callable tools.";
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
export interface RegisteredCapabilityTool {
|
|
22
|
+
name: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
parameters?: unknown;
|
|
25
|
+
}
|
|
26
|
+
export declare function describeCapabilities(input: {
|
|
27
|
+
capabilities: CapabilitySet;
|
|
28
|
+
active: readonly string[];
|
|
29
|
+
registered: readonly RegisteredCapabilityTool[];
|
|
30
|
+
}, name?: string): Record<string, unknown>;
|
|
31
|
+
export declare function getCapabilitiesTool(snapshot: () => Parameters<typeof describeCapabilities>[0]): ToolSpec;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const GET_CAPABILITIES = 'get_capabilities';
|
|
2
|
+
export const CAPABILITY_DISCOVERY = {
|
|
3
|
+
name: GET_CAPABILITIES,
|
|
4
|
+
label: 'Inspect current capabilities',
|
|
5
|
+
description: 'List the tools callable in this run, or inspect one tool and its input schema. Availability is not permission: calls still follow the gate. Disabled, unavailable and unregistered tools are not advertised as callable.',
|
|
6
|
+
parameters: {
|
|
7
|
+
type: 'object', additionalProperties: false,
|
|
8
|
+
properties: { tool: { type: 'string', minLength: 1, maxLength: 200, description: 'An exact tool name to inspect. Omit to list callable tools.' } }
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
export function describeCapabilities(input, name) {
|
|
12
|
+
const allowed = new Set(input.capabilities.tools);
|
|
13
|
+
const active = new Set(input.active);
|
|
14
|
+
const tools = input.registered.filter(tool => allowed.has(tool.name) && active.has(tool.name));
|
|
15
|
+
if (name) {
|
|
16
|
+
const tool = tools.find(tool => tool.name === name);
|
|
17
|
+
return tool
|
|
18
|
+
? { tool: { name: tool.name, description: tool.description ?? '', ...(tool.parameters !== undefined ? { inputSchema: tool.parameters } : {}), state: 'callable', permission: 'checked per call' } }
|
|
19
|
+
: { tool: { name, state: 'unavailable', reason: 'This tool is not callable in the current run.' } };
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
tools: tools.map(tool => ({ name: tool.name, description: tool.description ?? '', state: 'callable' })),
|
|
23
|
+
connectors: [...input.capabilities.connectors],
|
|
24
|
+
permission: 'Availability does not grant permission. Every call still passes the gate.'
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function getCapabilitiesTool(snapshot) {
|
|
28
|
+
return {
|
|
29
|
+
...CAPABILITY_DISCOVERY,
|
|
30
|
+
readOnly: true,
|
|
31
|
+
parameters: Type => Type.Unsafe(CAPABILITY_DISCOVERY.parameters),
|
|
32
|
+
execute: async (input) => JSON.stringify(describeCapabilities(snapshot(), typeof input.tool === 'string' ? input.tool : undefined))
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -27,11 +27,12 @@ export function subagentTools(service, parentRunId) {
|
|
|
27
27
|
return [{
|
|
28
28
|
name: SPAWN_SUBAGENT,
|
|
29
29
|
label: 'Start a subagent',
|
|
30
|
-
description: 'Start a fresh child run for independent work
|
|
30
|
+
description: 'Start a fresh child run for independent work. Delegate substantial, bounded tasks that benefit from a separate context, not simple lookups, tiny edits or tightly coupled steps. Prefer a named specialist profile; omit agent only when no profile fits. Plan mode permits only eligible read-only research profiles. Give a short title and a self-contained brief: the child has no conversation history and no more capabilities than you. Returns its run ID immediately by default so you can continue other useful work. Results arrive automatically before you finish. Use wait_subagents when you need results before proceeding, or wait: true for an inline result. Avoid overlapping write assignments. Stopping you stops your children.',
|
|
31
|
+
planModeSafe: input => service.planSpawnRefusal(parentRunId, input) === null,
|
|
31
32
|
parameters: (Type) => Type.Object({
|
|
32
33
|
title: Type.String({ description: `A short imperative title for the roster, at most ${MAX_SUBAGENT_TITLE_WORDS} words, such as "Map the auth flow"` }),
|
|
33
|
-
brief: Type.String(),
|
|
34
|
-
agent: Type.Optional(Type.String({ description: 'The name of
|
|
34
|
+
brief: Type.String({ description: 'A self-contained task: goal, relevant context or paths, non-goals, expected result, and acceptance or verification. The child cannot see this conversation.' }),
|
|
35
|
+
agent: Type.Optional(Type.String({ description: 'The name of a specialist profile from your Subagents list; omit only when no listed profile fits (omitting starts an unprofiled copy)' })),
|
|
35
36
|
tools: Type.Optional(Type.Array(Type.String())),
|
|
36
37
|
wait: Type.Optional(Type.Boolean({ description: 'Wait for the child and return its result; default false' }))
|
|
37
38
|
}),
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type PriceRates } from "../sessions/pricing.js";
|
|
2
|
+
import type { UsageFilter, UsagePeriod, UsageSummary } from "./types.js";
|
|
3
|
+
/** A price lookup, injected so tests never load Pi's catalogue. */
|
|
4
|
+
export type RatesLookup = (provider: string, model: string) => Promise<PriceRates | null>;
|
|
5
|
+
/** The local-day boundaries `period` covers, expressed as UTC instants. */
|
|
6
|
+
export declare function periodRange(period: UsagePeriod, offsetMinutes: number, now?: Date): {
|
|
7
|
+
from: string | null;
|
|
8
|
+
to: string;
|
|
9
|
+
};
|
|
10
|
+
export interface SummaryInput {
|
|
11
|
+
root: string;
|
|
12
|
+
filter: UsageFilter;
|
|
13
|
+
period: UsagePeriod;
|
|
14
|
+
timezoneOffsetMinutes: number;
|
|
15
|
+
/** Model labels are `provider/model`; only accounts need a lookup. */
|
|
16
|
+
accountLabel?: (accountId: string) => string;
|
|
17
|
+
rates?: RatesLookup;
|
|
18
|
+
now?: Date;
|
|
19
|
+
}
|
|
20
|
+
export declare function summarize(input: SummaryInput): Promise<UsageSummary>;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { all, withIndex } from "../core/index.js";
|
|
2
|
+
import { costAt } from "../sessions/pricing.js";
|
|
3
|
+
import { lastIndexedAt } from "./ledger.js";
|
|
4
|
+
const ROW_LIMIT = 10;
|
|
5
|
+
const DAY_LIMIT = 400;
|
|
6
|
+
const UNPRICED_LIMIT = 40;
|
|
7
|
+
const zero = (value) => value ?? 0;
|
|
8
|
+
const round = (value, places = 6) => Math.round(value * 10 ** places) / 10 ** places;
|
|
9
|
+
function totals(row, estimated = 0) {
|
|
10
|
+
const input = zero(row?.input);
|
|
11
|
+
const cacheRead = zero(row?.cacheRead);
|
|
12
|
+
const cacheWrite = zero(row?.cacheWrite);
|
|
13
|
+
const cost = row?.cost == null && !estimated ? null : round(zero(row?.cost) + estimated);
|
|
14
|
+
const cached = cacheRead + input + cacheWrite;
|
|
15
|
+
return {
|
|
16
|
+
requests: zero(row?.requests),
|
|
17
|
+
inputTokens: input,
|
|
18
|
+
cacheReadTokens: cacheRead,
|
|
19
|
+
cacheWriteTokens: cacheWrite,
|
|
20
|
+
outputTokens: zero(row?.output),
|
|
21
|
+
reasoningTokens: zero(row?.reasoning),
|
|
22
|
+
costUsd: cost,
|
|
23
|
+
estimatedCostUsd: estimated ? round(estimated) : 0,
|
|
24
|
+
unpricedRequests: zero(row?.unpriced),
|
|
25
|
+
avgTtftMs: row?.ttft == null ? null : Math.round(row.ttft),
|
|
26
|
+
avgDurationMs: row?.duration == null ? null : Math.round(row.duration),
|
|
27
|
+
cacheHitPercent: cached ? Math.round((cacheRead / cached) * 1000) / 10 : null
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** The local-day boundaries `period` covers, expressed as UTC instants. */
|
|
31
|
+
export function periodRange(period, offsetMinutes, now = new Date()) {
|
|
32
|
+
const to = now.toISOString();
|
|
33
|
+
if (period === 'all')
|
|
34
|
+
return { from: null, to };
|
|
35
|
+
const shifted = new Date(now.getTime() + offsetMinutes * 60_000);
|
|
36
|
+
const midnight = Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate());
|
|
37
|
+
const days = period === 'today' ? 0 : period === '7d' ? 6 : 29;
|
|
38
|
+
return { from: new Date(midnight - days * 86_400_000 - offsetMinutes * 60_000).toISOString(), to };
|
|
39
|
+
}
|
|
40
|
+
function where(filter, from, to) {
|
|
41
|
+
const clauses = ['at < ?'];
|
|
42
|
+
const params = [to];
|
|
43
|
+
if (from) {
|
|
44
|
+
clauses.push('at >= ?');
|
|
45
|
+
params.push(from);
|
|
46
|
+
}
|
|
47
|
+
if (filter.session) {
|
|
48
|
+
clauses.push('session_id = ?');
|
|
49
|
+
params.push(filter.session);
|
|
50
|
+
}
|
|
51
|
+
if (filter.account) {
|
|
52
|
+
clauses.push('account_id = ?');
|
|
53
|
+
params.push(filter.account);
|
|
54
|
+
}
|
|
55
|
+
if (filter.project) {
|
|
56
|
+
clauses.push('project_id = ?');
|
|
57
|
+
params.push(filter.project);
|
|
58
|
+
}
|
|
59
|
+
return { sql: clauses.join(' AND '), params };
|
|
60
|
+
}
|
|
61
|
+
const SUM = `COUNT(*) AS requests, SUM(input_tokens) AS input, SUM(cache_read_tokens) AS cacheRead,
|
|
62
|
+
SUM(cache_write_tokens) AS cacheWrite, SUM(output_tokens) AS output, SUM(reasoning_tokens) AS reasoning,
|
|
63
|
+
SUM(cost_usd) AS cost, SUM(CASE WHEN cost_usd IS NULL THEN 1 ELSE 0 END) AS unpriced,
|
|
64
|
+
AVG(ttft_ms) AS ttft, AVG(duration_ms) AS duration`;
|
|
65
|
+
function grouped(database, column, clause, params, limit) {
|
|
66
|
+
return all(database, `SELECT ${column} AS key, ${SUM} FROM usage_requests WHERE ${clause}
|
|
67
|
+
GROUP BY ${column} ORDER BY SUM(COALESCE(input_tokens,0)+COALESCE(cache_read_tokens,0)+COALESCE(cache_write_tokens,0)+COALESCE(output_tokens,0)) DESC
|
|
68
|
+
LIMIT ${limit}`, ...params).map((row) => ({ key: row.key ?? '', values: row }));
|
|
69
|
+
}
|
|
70
|
+
function modelKey(provider, model) {
|
|
71
|
+
return `${provider ?? 'unknown'}/${model ?? 'unknown'}`;
|
|
72
|
+
}
|
|
73
|
+
/** Estimates the cost of the requests a provider did not price, grouped by model. */
|
|
74
|
+
async function estimate(database, clause, params, rates) {
|
|
75
|
+
const rows = all(database, `SELECT provider, model, ${SUM} FROM usage_requests
|
|
76
|
+
WHERE ${clause} AND cost_usd IS NULL GROUP BY provider, model LIMIT ${UNPRICED_LIMIT}`, ...params);
|
|
77
|
+
const estimates = new Map();
|
|
78
|
+
for (const row of rows) {
|
|
79
|
+
if (!row.model)
|
|
80
|
+
continue;
|
|
81
|
+
const table = await rates(row.provider ?? '', row.model).catch(() => null);
|
|
82
|
+
if (!table)
|
|
83
|
+
continue;
|
|
84
|
+
const cost = costAt(table, {
|
|
85
|
+
inputTokens: zero(row.input),
|
|
86
|
+
outputTokens: zero(row.output),
|
|
87
|
+
cacheReadTokens: zero(row.cacheRead),
|
|
88
|
+
cacheWriteTokens: zero(row.cacheWrite)
|
|
89
|
+
});
|
|
90
|
+
const key = modelKey(row.provider, row.model);
|
|
91
|
+
const previous = estimates.get(key) ?? { cost: 0, requests: 0 };
|
|
92
|
+
estimates.set(key, { cost: previous.cost + cost, requests: previous.requests + zero(row.requests) });
|
|
93
|
+
}
|
|
94
|
+
return estimates;
|
|
95
|
+
}
|
|
96
|
+
export async function summarize(input) {
|
|
97
|
+
const offset = Number.isFinite(input.timezoneOffsetMinutes) ? Math.max(-840, Math.min(840, Math.trunc(input.timezoneOffsetMinutes))) : 0;
|
|
98
|
+
const { from, to } = periodRange(input.period, offset, input.now ?? new Date());
|
|
99
|
+
const { sql: clause, params } = where(input.filter, from, to);
|
|
100
|
+
const shift = `${offset >= 0 ? '+' : ''}${offset} minutes`;
|
|
101
|
+
const label = input.accountLabel ?? ((id) => id.slice(0, 8));
|
|
102
|
+
const summary = withIndex(input.root, (database) => {
|
|
103
|
+
const total = all(database, `SELECT ${SUM} FROM usage_requests WHERE ${clause}`, ...params)[0];
|
|
104
|
+
const days = all(database, `SELECT date(at, ?) AS day, ${SUM} FROM usage_requests WHERE ${clause}
|
|
105
|
+
GROUP BY day ORDER BY day DESC LIMIT ${DAY_LIMIT}`, shift, ...params);
|
|
106
|
+
return {
|
|
107
|
+
total,
|
|
108
|
+
days: days.reverse(),
|
|
109
|
+
byModel: grouped(database, "COALESCE(provider,'unknown') || '/' || COALESCE(model,'unknown')", clause, params, ROW_LIMIT + 1),
|
|
110
|
+
byProvider: grouped(database, "COALESCE(provider,'unknown')", clause, params, ROW_LIMIT + 1),
|
|
111
|
+
byAccount: grouped(database, "COALESCE(account_id,'')", clause, params, ROW_LIMIT + 1),
|
|
112
|
+
bySession: grouped(database, 'session_id', clause, params, ROW_LIMIT + 1)
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
const estimates = input.rates && zero(summary.total?.unpriced)
|
|
116
|
+
? await withIndex(input.root, (database) => estimate(database, clause, params, input.rates))
|
|
117
|
+
: new Map();
|
|
118
|
+
const estimated = [...estimates.values()].reduce((sum, entry) => sum + entry.cost, 0);
|
|
119
|
+
const row = (key, values, labelText, detail) => {
|
|
120
|
+
const estimateEntry = estimates.get(key);
|
|
121
|
+
return { key, label: labelText, detail, ...totals(values, estimateEntry?.cost ?? 0) };
|
|
122
|
+
};
|
|
123
|
+
const modelRows = summary.byModel.map((entry) => row(entry.key, entry.values, entry.key, null));
|
|
124
|
+
const truncate = (entries) => entries.length > ROW_LIMIT;
|
|
125
|
+
const accountRows = summary.byAccount.slice(0, ROW_LIMIT).map((entry) => row(entry.key, entry.values, entry.key ? label(entry.key) : 'No account', null));
|
|
126
|
+
const sessionRows = summary.bySession.slice(0, ROW_LIMIT).map((entry) => row(entry.key, entry.values, entry.key.slice(0, 12), null));
|
|
127
|
+
const days = summary.days.map((entry) => ({
|
|
128
|
+
day: entry.day,
|
|
129
|
+
requests: zero(entry.requests),
|
|
130
|
+
inputTokens: zero(entry.input),
|
|
131
|
+
cacheReadTokens: zero(entry.cacheRead),
|
|
132
|
+
outputTokens: zero(entry.output),
|
|
133
|
+
costUsd: entry.cost == null ? null : round(entry.cost)
|
|
134
|
+
}));
|
|
135
|
+
return {
|
|
136
|
+
scope: input.filter.session ? 'session' : input.filter.project ? 'project' : input.filter.account ? 'account' : 'all',
|
|
137
|
+
period: input.period,
|
|
138
|
+
from,
|
|
139
|
+
to,
|
|
140
|
+
timezoneOffsetMinutes: offset,
|
|
141
|
+
generatedAt: new Date().toISOString(),
|
|
142
|
+
totals: totals(summary.total, estimated),
|
|
143
|
+
byDay: days,
|
|
144
|
+
byModel: modelRows.slice(0, ROW_LIMIT),
|
|
145
|
+
byProvider: summary.byProvider.slice(0, ROW_LIMIT).map((entry) => row(entry.key, entry.values, entry.key, null)),
|
|
146
|
+
byAccount: accountRows,
|
|
147
|
+
bySession: sessionRows,
|
|
148
|
+
truncated: truncate(summary.byModel) || truncate(summary.byProvider) || truncate(summary.byAccount) || truncate(summary.bySession),
|
|
149
|
+
lastIndexedAt: lastIndexedAt(input.root)
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type RunEvent, type RunRecord } from "../sessions/contracts.js";
|
|
2
|
+
export interface UsageRow {
|
|
3
|
+
runId: string;
|
|
4
|
+
sequence: number;
|
|
5
|
+
requestIndex: number;
|
|
6
|
+
at: string;
|
|
7
|
+
day: string;
|
|
8
|
+
accountId: string | null;
|
|
9
|
+
projectId: string | null;
|
|
10
|
+
sessionId: string;
|
|
11
|
+
agentId: string | null;
|
|
12
|
+
mode: string;
|
|
13
|
+
trigger: string | null;
|
|
14
|
+
provider: string | null;
|
|
15
|
+
model: string | null;
|
|
16
|
+
inputTokens: number;
|
|
17
|
+
cacheReadTokens: number;
|
|
18
|
+
cacheWriteTokens: number;
|
|
19
|
+
outputTokens: number;
|
|
20
|
+
reasoningTokens: number;
|
|
21
|
+
costUsd: number | null;
|
|
22
|
+
costSource: string | null;
|
|
23
|
+
contextTokens: number | null;
|
|
24
|
+
contextWindow: number | null;
|
|
25
|
+
durationMs: number | null;
|
|
26
|
+
ttftMs: number | null;
|
|
27
|
+
requests: number;
|
|
28
|
+
}
|
|
29
|
+
/** One row per request: the event's own `request`, or the run's totals when a provider reported only those. */
|
|
30
|
+
export declare function usageRowsFor(run: RunRecord, events: readonly RunEvent[]): UsageRow[];
|
|
31
|
+
/** Indexes one run's usage. Idempotent, and safe to call when the index is unavailable. */
|
|
32
|
+
export declare function indexRunUsage(root: string, runId: string): boolean;
|
|
33
|
+
/** Indexes every run the ledger has not seen yet. Returns how many runs it added. */
|
|
34
|
+
export declare function syncUsage(root: string): number;
|
|
35
|
+
/** The ledger's last write, or null when it holds nothing. */
|
|
36
|
+
export declare function lastIndexedAt(root: string): string | null;
|
|
37
|
+
/** Run ids the ledger knows, for diagnostics and tests. */
|
|
38
|
+
export declare function indexedRuns(root: string): string[];
|