@pugi/cli 0.1.0-alpha.3 → 0.1.0-alpha.5
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/README.md +20 -0
- package/dist/commands/jobs.js +245 -0
- package/dist/core/agents/registry.js +69 -0
- package/dist/core/bash-classifier.js +1001 -0
- package/dist/core/context/builder.js +114 -0
- package/dist/core/context/compaction-events.js +99 -0
- package/dist/core/context/compaction.js +602 -0
- package/dist/core/context/invariants.js +250 -0
- package/dist/core/context/markdown-loader.js +270 -0
- package/dist/core/engine/compaction-hook.js +154 -0
- package/dist/core/engine/index.js +5 -0
- package/dist/core/engine/prompts.js +42 -0
- package/dist/core/engine/tool-bridge.js +159 -61
- package/dist/core/hooks.js +415 -0
- package/dist/core/jobs/registry.js +462 -0
- package/dist/core/mcp/client.js +316 -0
- package/dist/core/mcp/registry.js +171 -0
- package/dist/core/mcp/trust.js +91 -0
- package/dist/core/permission.js +221 -116
- package/dist/core/repl/cap-warning.js +91 -0
- package/dist/core/repl/session.js +399 -0
- package/dist/core/repl/slash-commands.js +116 -0
- package/dist/core/session.js +168 -0
- package/dist/core/subagents/dispatcher.js +258 -0
- package/dist/core/subagents/index.js +26 -0
- package/dist/core/subagents/spawn.js +86 -0
- package/dist/core/trust.js +109 -0
- package/dist/runtime/cli.js +157 -45
- package/dist/runtime/commands/budget.js +192 -0
- package/dist/runtime/commands/config.js +231 -0
- package/dist/runtime/commands/privacy.js +107 -0
- package/dist/runtime/commands/undo.js +329 -0
- package/dist/tools/bash.js +660 -0
- package/dist/tui/agent-tree.js +66 -0
- package/dist/tui/conversation-pane.js +45 -0
- package/dist/tui/input-box.js +91 -0
- package/dist/tui/login-picker.js +69 -0
- package/dist/tui/render.js +68 -0
- package/dist/tui/repl-render.js +218 -0
- package/dist/tui/repl.js +152 -0
- package/dist/tui/splash-data.js +61 -0
- package/dist/tui/splash.js +31 -0
- package/dist/tui/status-bar.js +58 -0
- package/package.json +11 -5
package/dist/runtime/cli.js
CHANGED
|
@@ -16,7 +16,13 @@ import { globTool, grepTool, readTool } from '../tools/file-tools.js';
|
|
|
16
16
|
import { toolRegistry, toolSchemaBundleHashInput } from '../tools/registry.js';
|
|
17
17
|
import { emptyIndex, rebuildIndex, readIndex, upsertArtifact, writeIndex, } from '../core/index-store.js';
|
|
18
18
|
import { buildRuntimeConfig, loadRuntimeConfig, pollDeviceFlow, pugiHandoffBundleSchema, pugiSyncDryRunPlanSchema, pugiSyncPrivacyModeSchema, pugiSyncRequestSchema, pugiSyncUploadPlanSchema, pugiTripleReviewRequestSchema, startDeviceFlow, submitSync, submitTripleReview, } from '@pugi/sdk';
|
|
19
|
+
import { PUGI_TAGLINE } from '@pugi/personas';
|
|
19
20
|
import { clearApiKey, DEFAULT_API_URL, listStoredCredentials, maskApiKey, normalizeApiUrl, purgeAllCredentials, readCredentialsFile, resolveActiveCredential, storeApiKey, switchActiveAccount, } from '../core/credentials.js';
|
|
21
|
+
import { runJobsCommand } from '../commands/jobs.js';
|
|
22
|
+
import { runConfigCommand } from './commands/config.js';
|
|
23
|
+
import { runPrivacyCommand } from './commands/privacy.js';
|
|
24
|
+
import { runUndoCommand } from './commands/undo.js';
|
|
25
|
+
import { runBudgetCommand } from './commands/budget.js';
|
|
20
26
|
/**
|
|
21
27
|
* CLI version shown by `pugi version` and embedded in `pugi doctor --json`.
|
|
22
28
|
*
|
|
@@ -32,8 +38,9 @@ const PUGI_CLI_VERSION = '0.1.0-alpha.1';
|
|
|
32
38
|
const handlers = {
|
|
33
39
|
accounts,
|
|
34
40
|
build: runEngineTask('build_task'),
|
|
41
|
+
budget: dispatchBudget,
|
|
35
42
|
code: runEngineTask('code'),
|
|
36
|
-
config:
|
|
43
|
+
config: dispatchConfig,
|
|
37
44
|
doctor,
|
|
38
45
|
explain: runEngineTask('explain'),
|
|
39
46
|
fix: runEngineTask('fix'),
|
|
@@ -41,20 +48,70 @@ const handlers = {
|
|
|
41
48
|
help,
|
|
42
49
|
idea,
|
|
43
50
|
init,
|
|
51
|
+
jobs,
|
|
44
52
|
login,
|
|
45
53
|
logout,
|
|
46
54
|
plan: runEngineTask('plan'),
|
|
47
|
-
privacy:
|
|
55
|
+
privacy: dispatchPrivacy,
|
|
48
56
|
review,
|
|
49
57
|
resume,
|
|
50
58
|
sessions,
|
|
51
59
|
sync,
|
|
52
|
-
undo:
|
|
60
|
+
undo: dispatchUndo,
|
|
53
61
|
version,
|
|
54
62
|
whoami,
|
|
55
63
|
};
|
|
64
|
+
async function dispatchConfig(args, flags, _session) {
|
|
65
|
+
await runConfigCommand(args, {
|
|
66
|
+
workspaceRoot: process.cwd(),
|
|
67
|
+
json: flags.json,
|
|
68
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
async function dispatchPrivacy(args, flags, _session) {
|
|
72
|
+
await runPrivacyCommand(args, {
|
|
73
|
+
json: flags.json,
|
|
74
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
async function dispatchUndo(args, flags, session) {
|
|
78
|
+
await runUndoCommand(args, {
|
|
79
|
+
workspaceRoot: process.cwd(),
|
|
80
|
+
session,
|
|
81
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async function dispatchBudget(args, flags, _session) {
|
|
85
|
+
await runBudgetCommand(args, {
|
|
86
|
+
workspaceRoot: process.cwd(),
|
|
87
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
56
90
|
export async function runCli(argv) {
|
|
57
|
-
const { command, args, flags } = parseArgs(argv);
|
|
91
|
+
const { command, args, flags, isBareInvocation } = parseArgs(argv);
|
|
92
|
+
// Bare `pugi` on a TTY enters the REPL-by-default agentic session
|
|
93
|
+
// (Sprint α5.7, ADR-0056). The REPL is the customer-facing surface
|
|
94
|
+
// that brings Pugi to parity with Claude Code / Codex CLI. When the
|
|
95
|
+
// operator has no credentials yet, we fall back to the α5.0 splash
|
|
96
|
+
// so the install-time `pugi` surface still shows the wordmark +
|
|
97
|
+
// quick-start hints. Non-TTY (CI, pipes, `--no-tty`) also falls
|
|
98
|
+
// through to the splash because the REPL needs raw input and SSE.
|
|
99
|
+
if (isBareInvocation && isInteractive(flags)) {
|
|
100
|
+
const runtimeConfig = resolveRuntimeConfig();
|
|
101
|
+
if (runtimeConfig) {
|
|
102
|
+
const { renderRepl } = await import('../tui/repl-render.js');
|
|
103
|
+
await renderRepl({
|
|
104
|
+
apiUrl: runtimeConfig.apiUrl,
|
|
105
|
+
apiKey: runtimeConfig.apiKey,
|
|
106
|
+
workspaceLabel: workspaceLabel(process.cwd()),
|
|
107
|
+
cliVersion: PUGI_CLI_VERSION,
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const { renderSplash } = await import('../tui/render.js');
|
|
112
|
+
await renderSplash(PUGI_CLI_VERSION);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
58
115
|
const handler = handlers[command] ?? help;
|
|
59
116
|
const session = openSession(process.cwd());
|
|
60
117
|
recordCommandStarted(session, command);
|
|
@@ -80,6 +137,7 @@ function parseArgs(argv) {
|
|
|
80
137
|
dryRun: false,
|
|
81
138
|
triple: false,
|
|
82
139
|
offline: false,
|
|
140
|
+
noTty: false,
|
|
83
141
|
};
|
|
84
142
|
const args = [];
|
|
85
143
|
// Sprint 2E: `pugi --version` / `-v` are universal install-test conventions
|
|
@@ -87,10 +145,10 @@ function parseArgs(argv) {
|
|
|
87
145
|
// the test block). Normalize them to the `version` command so users can
|
|
88
146
|
// discover the CLI works without knowing our subcommand grammar.
|
|
89
147
|
if (argv[0] === '--version' || argv[0] === '-v') {
|
|
90
|
-
return { command: 'version', args: [], flags };
|
|
148
|
+
return { command: 'version', args: [], flags, isBareInvocation: false };
|
|
91
149
|
}
|
|
92
150
|
if (argv[0] === '--help' || argv[0] === '-h') {
|
|
93
|
-
return { command: 'help', args: [], flags };
|
|
151
|
+
return { command: 'help', args: [], flags, isBareInvocation: false };
|
|
94
152
|
}
|
|
95
153
|
for (let index = 0; index < argv.length; index += 1) {
|
|
96
154
|
const arg = argv[index] ?? '';
|
|
@@ -112,6 +170,9 @@ function parseArgs(argv) {
|
|
|
112
170
|
else if (arg === '--offline') {
|
|
113
171
|
flags.offline = true;
|
|
114
172
|
}
|
|
173
|
+
else if (arg === '--no-tty') {
|
|
174
|
+
flags.noTty = true;
|
|
175
|
+
}
|
|
115
176
|
else if (arg.startsWith('--privacy=')) {
|
|
116
177
|
flags.privacy = parsePrivacyMode(arg.slice('--privacy='.length));
|
|
117
178
|
}
|
|
@@ -126,10 +187,12 @@ function parseArgs(argv) {
|
|
|
126
187
|
args.push(arg);
|
|
127
188
|
}
|
|
128
189
|
}
|
|
190
|
+
const isBareInvocation = args.length === 0;
|
|
129
191
|
return {
|
|
130
192
|
command: args.shift() ?? 'help',
|
|
131
193
|
args,
|
|
132
194
|
flags,
|
|
195
|
+
isBareInvocation,
|
|
133
196
|
};
|
|
134
197
|
}
|
|
135
198
|
async function version(_args, flags, _session) {
|
|
@@ -144,7 +207,7 @@ async function help(_args, flags, _session) {
|
|
|
144
207
|
writeOutput(flags, { commands }, [
|
|
145
208
|
'Pugi CLI',
|
|
146
209
|
'',
|
|
147
|
-
'Usage: pugi <command> [--json] [--web] [--remote]',
|
|
210
|
+
'Usage: pugi <command> [--json] [--web] [--remote] [--no-tty]',
|
|
148
211
|
'',
|
|
149
212
|
'Commands:',
|
|
150
213
|
...commands.map((command) => ` ${command}`),
|
|
@@ -163,6 +226,11 @@ async function help(_args, flags, _session) {
|
|
|
163
226
|
'Sync safety:',
|
|
164
227
|
' pugi sync --dry-run --privacy metadata',
|
|
165
228
|
'',
|
|
229
|
+
'Interactivity:',
|
|
230
|
+
' --no-tty Force the line-buffered output path (CI, pipes,',
|
|
231
|
+
' recording flows, dumb terminals).',
|
|
232
|
+
'',
|
|
233
|
+
PUGI_TAGLINE,
|
|
166
234
|
'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
|
|
167
235
|
].join('\n'));
|
|
168
236
|
}
|
|
@@ -1711,17 +1779,30 @@ function parseProviderFlag(args) {
|
|
|
1711
1779
|
return lower;
|
|
1712
1780
|
}
|
|
1713
1781
|
/**
|
|
1714
|
-
* Returns true when stdin
|
|
1715
|
-
*
|
|
1716
|
-
*
|
|
1717
|
-
*
|
|
1782
|
+
* Returns true when BOTH stdin and stdout are attached to a TTY AND
|
|
1783
|
+
* `--json` / `--no-tty` / CI markers were not supplied. We only
|
|
1784
|
+
* prompt or render Ink surfaces when a human is plausibly watching
|
|
1785
|
+
* the screen. The multi-condition gate matches Claude Code, gh CLI,
|
|
1786
|
+
* Codex CLI, and the npm CLI conventions.
|
|
1718
1787
|
*/
|
|
1719
1788
|
function isInteractive(flags) {
|
|
1720
1789
|
if (flags.json)
|
|
1721
1790
|
return false;
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1791
|
+
if (flags.noTty)
|
|
1792
|
+
return false;
|
|
1793
|
+
// Common CI / scripted-context markers. CI is set by every major
|
|
1794
|
+
// provider (GitHub Actions, GitLab, CircleCI, Travis, Buildkite).
|
|
1795
|
+
if (process.env.CI)
|
|
1796
|
+
return false;
|
|
1797
|
+
if (process.env.PUGI_NO_TTY)
|
|
1798
|
+
return false;
|
|
1799
|
+
// `process.stdin.isTTY` / `process.stdout.isTTY` are `undefined`
|
|
1800
|
+
// when the stream is a pipe and `true` when attached to a real
|
|
1801
|
+
// terminal. Require both so a `pugi | tee` invocation falls back
|
|
1802
|
+
// to the line-buffered output path.
|
|
1803
|
+
const stdinTty = Boolean(process.stdin.isTTY);
|
|
1804
|
+
const stdoutTty = Boolean(process.stdout.isTTY);
|
|
1805
|
+
return stdinTty && stdoutTty;
|
|
1725
1806
|
}
|
|
1726
1807
|
async function login(args, flags, _session) {
|
|
1727
1808
|
if (args.includes('--help') || args.includes('-h')) {
|
|
@@ -1742,7 +1823,7 @@ async function login(args, flags, _session) {
|
|
|
1742
1823
|
' --provider env Promote PUGI_API_KEY from the environment into the store.',
|
|
1743
1824
|
' --token <PAT> Inline API key (visible in `ps`).',
|
|
1744
1825
|
' --token-stdin Read API key from stdin (gh-CLI style).',
|
|
1745
|
-
' --label <name>
|
|
1826
|
+
' --label <name> Short label surfaced in `pugi accounts list`.',
|
|
1746
1827
|
' --api-url <url> Override the Anvil endpoint (self-hosted).',
|
|
1747
1828
|
' --no-device-flow Refuse the device flow; fail fast in CI without a token.',
|
|
1748
1829
|
'',
|
|
@@ -1818,6 +1899,14 @@ async function login(args, flags, _session) {
|
|
|
1818
1899
|
// Path 4: interactive menu (TTY, not --json, no token args).
|
|
1819
1900
|
if (isInteractive(flags)) {
|
|
1820
1901
|
const choice = await promptLoginVariant(apiUrl);
|
|
1902
|
+
if (choice === null) {
|
|
1903
|
+
// User dismissed the picker via Esc / q. Use exit 130, the
|
|
1904
|
+
// standard "terminated by user signal" exit code (gh CLI,
|
|
1905
|
+
// codex, ssh, vim all use this).
|
|
1906
|
+
writeOutput(flags, { status: 'cancelled' }, 'Login cancelled.');
|
|
1907
|
+
process.exitCode = 130;
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1821
1910
|
await dispatchLoginProvider(choice, {
|
|
1822
1911
|
apiUrl,
|
|
1823
1912
|
flags,
|
|
@@ -1826,39 +1915,31 @@ async function login(args, flags, _session) {
|
|
|
1826
1915
|
});
|
|
1827
1916
|
return;
|
|
1828
1917
|
}
|
|
1829
|
-
// Path 5: no token, no TTY →
|
|
1830
|
-
//
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
await performDeviceFlowLogin(apiUrl, flags, labelFlag);
|
|
1918
|
+
// Path 5: no token, no TTY → previously fell through to a silent
|
|
1919
|
+
// device flow that nobody could answer. The Ink-TUI work refuses
|
|
1920
|
+
// that branch and raises a deterministic error so CI surfaces a
|
|
1921
|
+
// failed login immediately. The message lists every escape hatch.
|
|
1922
|
+
throw new Error('pugi login requires a token in non-interactive mode. Pass `--provider device|token|env`, `--token <PAT>`, pipe via `--token-stdin`, set PUGI_LOGIN_TOKEN, or use `--provider env` with PUGI_API_KEY exported.');
|
|
1835
1923
|
}
|
|
1836
1924
|
/**
|
|
1837
|
-
* Render the interactive picker shown when `pugi login` runs on
|
|
1838
|
-
* with no token args.
|
|
1839
|
-
*
|
|
1925
|
+
* Render the interactive Ink picker shown when `pugi login` runs on
|
|
1926
|
+
* a TTY with no token args. Returns the chosen provider, or `null`
|
|
1927
|
+
* when the user dismisses the picker via Esc / q. Mirrors the
|
|
1928
|
+
* Claude Code / Codex CLI auth picker UX.
|
|
1929
|
+
*
|
|
1930
|
+
* The Ink import is dynamic so a non-interactive `pugi <anything>`
|
|
1931
|
+
* never pays the React+Ink module-load cost. ESM dynamic-import is
|
|
1932
|
+
* cached after first call (same as require).
|
|
1840
1933
|
*/
|
|
1841
1934
|
async function promptLoginVariant(apiUrl) {
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
].join('\n'));
|
|
1851
|
-
const answer = await readSingleChoice('Enter choice [1-3] (default 1): ');
|
|
1852
|
-
switch (answer) {
|
|
1853
|
-
case '':
|
|
1854
|
-
case '1':
|
|
1855
|
-
return 'device';
|
|
1856
|
-
case '2':
|
|
1857
|
-
return 'token';
|
|
1858
|
-
case '3':
|
|
1859
|
-
return 'env';
|
|
1860
|
-
default:
|
|
1861
|
-
throw new Error(`Invalid login choice "${answer}". Expected 1, 2, or 3.`);
|
|
1935
|
+
const { renderLoginPicker, LoginCancelledError } = await import('../tui/render.js');
|
|
1936
|
+
try {
|
|
1937
|
+
return await renderLoginPicker(apiUrl);
|
|
1938
|
+
}
|
|
1939
|
+
catch (error) {
|
|
1940
|
+
if (error instanceof LoginCancelledError)
|
|
1941
|
+
return null;
|
|
1942
|
+
throw error;
|
|
1862
1943
|
}
|
|
1863
1944
|
}
|
|
1864
1945
|
/**
|
|
@@ -2441,7 +2522,7 @@ function formatResetSuffix(resetAtIso) {
|
|
|
2441
2522
|
* Render the login-method label shown in `pugi whoami` and emitted in
|
|
2442
2523
|
* the JSON envelope. Aliases the resolver's `source` discriminator (env
|
|
2443
2524
|
* vs file) plus the stored `fileSource` (token vs device-flow vs env
|
|
2444
|
-
* promotion) into a single
|
|
2525
|
+
* promotion) into a single short word.
|
|
2445
2526
|
*/
|
|
2446
2527
|
function describeLoginMethod(credential) {
|
|
2447
2528
|
if (credential.source === 'env')
|
|
@@ -2604,6 +2685,22 @@ function extractApiUrlFlag(args) {
|
|
|
2604
2685
|
function extractLabelFlag(args) {
|
|
2605
2686
|
return extractNamedFlagValue(args, 'label');
|
|
2606
2687
|
}
|
|
2688
|
+
/**
|
|
2689
|
+
* `pugi jobs` — surface the persistent JobRegistry on the CLI.
|
|
2690
|
+
* Sprint α5.9 (ADR-0056 PR-PUGI-CLI-M1-GAP-J). Subcommand parsing
|
|
2691
|
+
* (list/status/tail/kill) lives in `src/commands/jobs.ts`; this
|
|
2692
|
+
* handler is a thin shim so the existing command map dispatch
|
|
2693
|
+
* remains the single entry point.
|
|
2694
|
+
*/
|
|
2695
|
+
async function jobs(args, flags, session) {
|
|
2696
|
+
const exitCode = await runJobsCommand(args, { json: flags.json }, {
|
|
2697
|
+
write: (text) => process.stdout.write(text),
|
|
2698
|
+
writeError: (text) => process.stderr.write(text.endsWith('\n') ? text : `${text}\n`),
|
|
2699
|
+
}, session.id);
|
|
2700
|
+
if (exitCode !== 0) {
|
|
2701
|
+
process.exitCode = exitCode;
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2607
2704
|
function notImplemented(command) {
|
|
2608
2705
|
return async (_args, flags) => {
|
|
2609
2706
|
const payload = {
|
|
@@ -2632,6 +2729,21 @@ function ensurePugiGitIgnore(cwd, created, skipped) {
|
|
|
2632
2729
|
writeFileSync(gitignorePath, next, { encoding: 'utf8' });
|
|
2633
2730
|
created.push(`${gitignorePath} (+${marker})`);
|
|
2634
2731
|
}
|
|
2732
|
+
/**
|
|
2733
|
+
* Compute the workspace label surfaced in the REPL header bar
|
|
2734
|
+
* (Sprint α5.7). We prefer the basename of the workspace root because
|
|
2735
|
+
* that is what the operator sees in their shell prompt — keeping the
|
|
2736
|
+
* REPL header in sync with `pwd` lets the operator orient at a glance.
|
|
2737
|
+
* Empty / pathological cwd values (a worktree resolved to `/`) fall
|
|
2738
|
+
* back to `workspace` so the header never collapses.
|
|
2739
|
+
*/
|
|
2740
|
+
function workspaceLabel(cwd) {
|
|
2741
|
+
const segments = cwd.split('/').filter((s) => s.length > 0);
|
|
2742
|
+
const last = segments[segments.length - 1];
|
|
2743
|
+
if (!last || last.length === 0)
|
|
2744
|
+
return 'workspace';
|
|
2745
|
+
return last;
|
|
2746
|
+
}
|
|
2635
2747
|
function ensureDir(path, created, skipped) {
|
|
2636
2748
|
if (existsSync(path)) {
|
|
2637
2749
|
skipped.push(path);
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
const RATE_INPUT_USD_PER_TOKEN = 0.000003;
|
|
4
|
+
const RATE_OUTPUT_USD_PER_TOKEN = 0.000015;
|
|
5
|
+
export async function runBudgetCommand(args, ctx) {
|
|
6
|
+
const flags = parseFlags(args);
|
|
7
|
+
const eventsPath = resolve(ctx.workspaceRoot, '.pugi/events.jsonl');
|
|
8
|
+
if (!existsSync(eventsPath)) {
|
|
9
|
+
ctx.writeOutput({
|
|
10
|
+
command: 'budget',
|
|
11
|
+
status: 'noop',
|
|
12
|
+
reason: 'no_session',
|
|
13
|
+
tokens: 0,
|
|
14
|
+
dollars: 0,
|
|
15
|
+
}, 'No session events found. Run a Pugi command first.');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const events = readEvents(eventsPath);
|
|
19
|
+
const cutoff = flags.sinceMs === null ? 0 : Date.now() - flags.sinceMs;
|
|
20
|
+
const summary = summarise(events, cutoff);
|
|
21
|
+
const dollars = estimateDollars(summary.tokens);
|
|
22
|
+
const payload = {
|
|
23
|
+
command: 'budget',
|
|
24
|
+
status: 'ok',
|
|
25
|
+
workspaceRoot: ctx.workspaceRoot,
|
|
26
|
+
window: flags.sinceMs === null ? 'session' : `last ${args.find((a) => a.startsWith('--since='))?.slice('--since='.length) ?? ''}`,
|
|
27
|
+
tokens: summary.tokens,
|
|
28
|
+
dollars,
|
|
29
|
+
perCommand: summary.perCommand,
|
|
30
|
+
perPersona: summary.perPersona,
|
|
31
|
+
rate: {
|
|
32
|
+
inputUsdPerToken: RATE_INPUT_USD_PER_TOKEN,
|
|
33
|
+
outputUsdPerToken: RATE_OUTPUT_USD_PER_TOKEN,
|
|
34
|
+
assumedSplit: 'event log does not break tokens into in/out; rate uses output-token assumption',
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
const text = [
|
|
38
|
+
'Pugi budget',
|
|
39
|
+
`Window: ${payload.window}`,
|
|
40
|
+
`Tokens: ${summary.tokens}`,
|
|
41
|
+
`Estimated cost: $${dollars.toFixed(4)} (output-token rate)`,
|
|
42
|
+
summary.perCommand.length > 0
|
|
43
|
+
? `Per command:\n${summary.perCommand
|
|
44
|
+
.map((entry) => ` ${entry.command.padEnd(12)} ${entry.tokens} tokens`)
|
|
45
|
+
.join('\n')}`
|
|
46
|
+
: 'Per command: (no entries)',
|
|
47
|
+
summary.perPersona.length > 0
|
|
48
|
+
? `Per persona:\n${summary.perPersona
|
|
49
|
+
.map((entry) => ` ${entry.persona.padEnd(20)} ${entry.tokens} tokens`)
|
|
50
|
+
.join('\n')}`
|
|
51
|
+
: 'Per persona: (no entries)',
|
|
52
|
+
].join('\n');
|
|
53
|
+
ctx.writeOutput(payload, text);
|
|
54
|
+
}
|
|
55
|
+
function summarise(events, cutoffMs) {
|
|
56
|
+
let tokens = 0;
|
|
57
|
+
const perCommand = new Map();
|
|
58
|
+
const perPersona = new Map();
|
|
59
|
+
// Track the active command via command_started / command_completed
|
|
60
|
+
// bookends so we can attribute tool_result tokens to the surrounding
|
|
61
|
+
// command. Multiple commands per session are normal.
|
|
62
|
+
let activeCommand = null;
|
|
63
|
+
for (const event of events) {
|
|
64
|
+
if (!matchesWindow(event, cutoffMs))
|
|
65
|
+
continue;
|
|
66
|
+
if (event.type === 'session' && event.name === 'command_started' && typeof event.command === 'string') {
|
|
67
|
+
activeCommand = event.command;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (event.type === 'session' && event.name === 'command_completed') {
|
|
71
|
+
activeCommand = null;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const eventTokens = extractTokens(event);
|
|
75
|
+
if (eventTokens > 0) {
|
|
76
|
+
tokens += eventTokens;
|
|
77
|
+
if (activeCommand) {
|
|
78
|
+
perCommand.set(activeCommand, (perCommand.get(activeCommand) ?? 0) + eventTokens);
|
|
79
|
+
}
|
|
80
|
+
const persona = extractPersona(event);
|
|
81
|
+
if (persona) {
|
|
82
|
+
perPersona.set(persona, (perPersona.get(persona) ?? 0) + eventTokens);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
tokens,
|
|
88
|
+
perCommand: Array.from(perCommand.entries())
|
|
89
|
+
.map(([command, value]) => ({ command, tokens: value }))
|
|
90
|
+
.sort((a, b) => b.tokens - a.tokens),
|
|
91
|
+
perPersona: Array.from(perPersona.entries())
|
|
92
|
+
.map(([persona, value]) => ({ persona, tokens: value }))
|
|
93
|
+
.sort((a, b) => b.tokens - a.tokens),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function matchesWindow(event, cutoffMs) {
|
|
97
|
+
if (cutoffMs === 0)
|
|
98
|
+
return true;
|
|
99
|
+
const ts = typeof event.timestamp === 'string' ? Date.parse(event.timestamp) : NaN;
|
|
100
|
+
if (!Number.isFinite(ts))
|
|
101
|
+
return false;
|
|
102
|
+
return ts >= cutoffMs;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Extract a token count from any event shape we know about.
|
|
106
|
+
*
|
|
107
|
+
* - `tool_result.tokensUsed` — engine adapter emits this.
|
|
108
|
+
* - `subagent.completed.tokensUsed` — α5.4 subagent runner.
|
|
109
|
+
* - `engine.turn.tokensIn/tokensOut` — Anvil F1 metric mirror, future.
|
|
110
|
+
*
|
|
111
|
+
* Unknown events return 0 so adding new event types upstream never
|
|
112
|
+
* breaks `pugi budget`.
|
|
113
|
+
*/
|
|
114
|
+
function extractTokens(event) {
|
|
115
|
+
const direct = numericField(event, 'tokensUsed');
|
|
116
|
+
if (direct > 0)
|
|
117
|
+
return direct;
|
|
118
|
+
const tIn = numericField(event, 'tokensIn');
|
|
119
|
+
const tOut = numericField(event, 'tokensOut');
|
|
120
|
+
if (tIn + tOut > 0)
|
|
121
|
+
return tIn + tOut;
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
function numericField(event, key) {
|
|
125
|
+
const raw = event[key];
|
|
126
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0)
|
|
127
|
+
return 0;
|
|
128
|
+
return Math.floor(raw);
|
|
129
|
+
}
|
|
130
|
+
function extractPersona(event) {
|
|
131
|
+
const candidate = event['persona'] ?? event['personaSlug'] ?? event['subagent'];
|
|
132
|
+
if (typeof candidate === 'string' && candidate.length > 0)
|
|
133
|
+
return candidate;
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
function estimateDollars(totalTokens) {
|
|
137
|
+
// Use output-token rate as the worst case so a user planning to upgrade
|
|
138
|
+
// a tier is not surprised by an under-estimate.
|
|
139
|
+
return Number((totalTokens * RATE_OUTPUT_USD_PER_TOKEN).toFixed(6));
|
|
140
|
+
}
|
|
141
|
+
function parseFlags(args) {
|
|
142
|
+
const flags = { json: false, sinceMs: null };
|
|
143
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
144
|
+
const arg = args[i] ?? '';
|
|
145
|
+
if (arg === '--json')
|
|
146
|
+
flags.json = true;
|
|
147
|
+
else if (arg.startsWith('--since='))
|
|
148
|
+
flags.sinceMs = parseDuration(arg.slice('--since='.length));
|
|
149
|
+
else if (arg === '--since') {
|
|
150
|
+
const value = args[i + 1];
|
|
151
|
+
if (!value)
|
|
152
|
+
throw new Error('--since requires a duration like 24h, 30m, or 7d.');
|
|
153
|
+
flags.sinceMs = parseDuration(value);
|
|
154
|
+
i += 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return flags;
|
|
158
|
+
}
|
|
159
|
+
function parseDuration(raw) {
|
|
160
|
+
const match = /^(\d+)(h|m|d|s)?$/.exec(raw.trim());
|
|
161
|
+
if (!match) {
|
|
162
|
+
throw new Error(`Invalid --since duration "${raw}". Expected forms: 24h, 30m, 7d, 90s.`);
|
|
163
|
+
}
|
|
164
|
+
const amount = Number(match[1]);
|
|
165
|
+
const unit = match[2] ?? 'h';
|
|
166
|
+
const multiplier = unit === 'h'
|
|
167
|
+
? 60 * 60 * 1000
|
|
168
|
+
: unit === 'm'
|
|
169
|
+
? 60 * 1000
|
|
170
|
+
: unit === 'd'
|
|
171
|
+
? 24 * 60 * 60 * 1000
|
|
172
|
+
: 1000;
|
|
173
|
+
return amount * multiplier;
|
|
174
|
+
}
|
|
175
|
+
function readEvents(path) {
|
|
176
|
+
const raw = readFileSync(path, 'utf8');
|
|
177
|
+
const lines = raw.split('\n').filter((line) => line.trim().length > 0);
|
|
178
|
+
const out = [];
|
|
179
|
+
for (const line of lines) {
|
|
180
|
+
try {
|
|
181
|
+
const parsed = JSON.parse(line);
|
|
182
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
183
|
+
out.push(parsed);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// partial-write lines are ignored
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
//# sourceMappingURL=budget.js.map
|