@piagent/platform 1.2.0 → 1.2.2

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 (64) hide show
  1. package/CHANGELOG.md +149 -1
  2. package/README.md +1 -1
  3. package/catalog/capabilities.json +4 -4
  4. package/docs/command-reference-vietnamese.md +15 -4
  5. package/docs/context-window-policy.md +1 -0
  6. package/docs/distribution-standard.md +7 -7
  7. package/docs/mcp-and-tools.md +49 -3
  8. package/docs/operator-manual-vietnamese.md +52 -11
  9. package/docs/publishing-for-teams.md +2 -0
  10. package/docs/quickstart-vietnamese.md +4 -2
  11. package/docs/release-install-policy.md +6 -6
  12. package/docs/security-threat-model.md +4 -3
  13. package/docs/team-onboarding.md +3 -3
  14. package/docs/usage-observability.md +49 -0
  15. package/docs-site/architecture.html +1 -1
  16. package/docs-site/commands.html +1 -1
  17. package/docs-site/content/mcp-approval.html +54 -2
  18. package/docs-site/content/quickstart.html +1 -1
  19. package/docs-site/index.html +1 -1
  20. package/docs-site/mcp-approval.html +55 -3
  21. package/docs-site/mcp-auth.html +1 -1
  22. package/docs-site/mcp-servers.html +1 -1
  23. package/docs-site/mcp.html +1 -1
  24. package/docs-site/permissions.html +1 -1
  25. package/docs-site/profiles.html +1 -1
  26. package/docs-site/quickstart.html +2 -2
  27. package/docs-site/runtime.html +1 -1
  28. package/docs-site/scope.html +1 -1
  29. package/docs-site/security.html +1 -1
  30. package/docs-site/team.html +1 -1
  31. package/docs-site/workflows.html +1 -1
  32. package/package.json +1 -1
  33. package/packages/piagent-core/README.md +2 -0
  34. package/packages/piagent-core/capabilities/capability-core.js +41 -4
  35. package/packages/piagent-core/capabilities/capability-sources.js +73 -12
  36. package/packages/piagent-core/extensions/piagent-guard.ts +539 -70
  37. package/packages/piagent-core/extensions/policy-core.js +1586 -98
  38. package/packages/piagent-core/mcp/mcp-approval-store.js +56 -15
  39. package/packages/piagent-core/mcp/mcp-auth-readiness.js +31 -3
  40. package/packages/piagent-core/mcp/mcp-command-actions.js +139 -30
  41. package/packages/piagent-core/mcp/mcp-config-layers.js +339 -6
  42. package/packages/piagent-core/mcp/mcp-import-kinds.js +240 -0
  43. package/packages/piagent-core/mcp/mcp-server-entry.js +51 -0
  44. package/packages/piagent-core/mcp/mcp-session-view.js +55 -4
  45. package/packages/piagent-core/mcp/mcp-tool-naming.js +90 -0
  46. package/packages/piagent-core/package.json +1 -1
  47. package/packages/piagent-core/prompts/be-to-fe.md +1 -0
  48. package/packages/piagent-core/prompts/piagent-commands.md +1 -0
  49. package/packages/piagent-core/prompts/platform-improve.md +1 -0
  50. package/packages/piagent-core/prompts/review.md +1 -0
  51. package/packages/piagent-core/prompts/scout.md +1 -0
  52. package/packages/piagent-core/prompts/task.md +1 -0
  53. package/scripts/configure-model-scope.sh +19 -3
  54. package/scripts/configure-subagents.sh +17 -4
  55. package/scripts/init-project.sh +15 -8
  56. package/scripts/mcp-manage.mjs +111 -22
  57. package/scripts/pi-model-catalog.sh +11 -1
  58. package/scripts/pi-session-stats.sh +27 -1
  59. package/scripts/pi-usage-history.mjs +567 -0
  60. package/scripts/quality-benchmark.sh +37 -9
  61. package/scripts/setup.sh +21 -13
  62. package/scripts/uninstall-global.sh +10 -4
  63. package/scripts/verify-local.sh +19 -0
  64. package/scripts/verify-release-identity.mjs +29 -0
@@ -0,0 +1,567 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+
7
+ const USAGE = `Usage:
8
+ scripts/pi-usage-history.mjs [project-path] [options]
9
+ scripts/pi-usage-history.mjs --all-projects [options]
10
+
11
+ Options:
12
+ --history Accepted for piagent-usage wrapper compatibility.
13
+ --all-projects Include every Pi session under the session directory.
14
+ --days N Include activity from the last N days.
15
+ --since YYYY-MM-DD Include activity on or after this local date.
16
+ --until YYYY-MM-DD Include activity on or before this local date.
17
+ --format table|json|csv|markdown
18
+ --json Alias for --format json.
19
+ --csv Alias for --format csv.
20
+ --markdown Alias for --format markdown.
21
+ --include-subagents Include subagent session files. Default.
22
+ --no-subagents Exclude files under sessions/subagent/.
23
+ --limit N Max session rows in table/markdown output. Default: 20.
24
+ --sessions-dir PATH Override Pi session root for tests or custom installs.
25
+ -h, --help Show this help.
26
+
27
+ Examples:
28
+ piagent-usage --history .
29
+ piagent-usage --history --days 7 --all-projects
30
+ piagent-usage --history /path/to/project --json
31
+ `;
32
+
33
+ const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
34
+
35
+ function fail(message) {
36
+ console.error(`FAIL: ${message}`);
37
+ process.exit(2);
38
+ }
39
+
40
+ function parsePositiveInteger(raw, label) {
41
+ if (!/^[1-9]\d*$/.test(String(raw ?? ""))) fail(`${label} must be a positive integer`);
42
+ return Number(raw);
43
+ }
44
+
45
+ function parseLocalDateStart(raw, label) {
46
+ const match = String(raw ?? "").match(DATE_ONLY);
47
+ if (!match) fail(`${label} must use YYYY-MM-DD`);
48
+ const [, y, m, d] = match;
49
+ const date = new Date(Number(y), Number(m) - 1, Number(d), 0, 0, 0, 0);
50
+ if (Number.isNaN(date.getTime())) fail(`${label} is not a valid date`);
51
+ return date;
52
+ }
53
+
54
+ function parseLocalDateEnd(raw, label) {
55
+ const start = parseLocalDateStart(raw, label);
56
+ return new Date(start.getFullYear(), start.getMonth(), start.getDate(), 23, 59, 59, 999);
57
+ }
58
+
59
+ function parseArgs(argv) {
60
+ const options = {
61
+ allProjects: false,
62
+ format: "table",
63
+ includeSubagents: true,
64
+ limit: 20,
65
+ sessionsDir: process.env.PI_CODING_AGENT_SESSION_DIR
66
+ || path.join(process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent"), "sessions"),
67
+ projectPath: undefined,
68
+ since: undefined,
69
+ until: undefined
70
+ };
71
+ const positionals = [];
72
+
73
+ for (let index = 0; index < argv.length; index += 1) {
74
+ const arg = argv[index];
75
+ const readValue = (name) => {
76
+ const value = argv[index + 1];
77
+ if (value === undefined || value.startsWith("-")) fail(`Missing value for ${name}`);
78
+ index += 1;
79
+ return value;
80
+ };
81
+
82
+ switch (arg) {
83
+ case "-h":
84
+ case "--help":
85
+ options.help = true;
86
+ break;
87
+ case "--history":
88
+ break;
89
+ case "--all-projects":
90
+ options.allProjects = true;
91
+ break;
92
+ case "--days": {
93
+ const days = parsePositiveInteger(readValue("--days"), "--days");
94
+ options.since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
95
+ break;
96
+ }
97
+ case "--since":
98
+ options.since = parseLocalDateStart(readValue("--since"), "--since");
99
+ break;
100
+ case "--until":
101
+ options.until = parseLocalDateEnd(readValue("--until"), "--until");
102
+ break;
103
+ case "--format": {
104
+ const format = readValue("--format");
105
+ if (!["table", "json", "csv", "markdown"].includes(format)) {
106
+ fail("--format must be one of table, json, csv, markdown");
107
+ }
108
+ options.format = format;
109
+ break;
110
+ }
111
+ case "--json":
112
+ options.format = "json";
113
+ break;
114
+ case "--csv":
115
+ options.format = "csv";
116
+ break;
117
+ case "--markdown":
118
+ options.format = "markdown";
119
+ break;
120
+ case "--include-subagents":
121
+ options.includeSubagents = true;
122
+ break;
123
+ case "--no-subagents":
124
+ options.includeSubagents = false;
125
+ break;
126
+ case "--limit":
127
+ options.limit = parsePositiveInteger(readValue("--limit"), "--limit");
128
+ break;
129
+ case "--sessions-dir":
130
+ options.sessionsDir = path.resolve(readValue("--sessions-dir"));
131
+ break;
132
+ default:
133
+ if (arg.startsWith("-")) fail(`unknown option: ${arg}`);
134
+ positionals.push(arg);
135
+ break;
136
+ }
137
+ }
138
+
139
+ if (positionals.length > 1) fail(`expected at most one project path, received ${positionals.length}`);
140
+ if (positionals[0]) {
141
+ const projectPath = path.resolve(positionals[0]);
142
+ if (!fs.existsSync(projectPath) || !fs.statSync(projectPath).isDirectory()) {
143
+ fail(`project path does not exist: ${projectPath}`);
144
+ }
145
+ options.projectPath = fs.realpathSync(projectPath);
146
+ } else if (!options.allProjects) {
147
+ options.projectPath = fs.realpathSync(process.cwd());
148
+ }
149
+
150
+ if (options.since && options.until && options.since > options.until) {
151
+ fail("--since must be before or equal to --until");
152
+ }
153
+ return options;
154
+ }
155
+
156
+ function walkJsonl(root) {
157
+ const out = [];
158
+ if (!fs.existsSync(root)) return out;
159
+ const stack = [root];
160
+ while (stack.length) {
161
+ const dir = stack.pop();
162
+ let entries = [];
163
+ try {
164
+ entries = fs.readdirSync(dir, { withFileTypes: true });
165
+ } catch {
166
+ continue;
167
+ }
168
+ for (const entry of entries) {
169
+ const target = path.join(dir, entry.name);
170
+ if (entry.isDirectory()) {
171
+ stack.push(target);
172
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
173
+ out.push(target);
174
+ }
175
+ }
176
+ }
177
+ return out;
178
+ }
179
+
180
+ function parseIso(raw) {
181
+ if (!raw) return undefined;
182
+ const date = new Date(raw);
183
+ return Number.isNaN(date.getTime()) ? undefined : date;
184
+ }
185
+
186
+ function inRange(date, options) {
187
+ if (!date) return true;
188
+ if (options.since && date < options.since) return false;
189
+ if (options.until && date > options.until) return false;
190
+ return true;
191
+ }
192
+
193
+ function emptyTotals() {
194
+ return {
195
+ input: 0,
196
+ output: 0,
197
+ cacheRead: 0,
198
+ cacheWrite: 0,
199
+ reasoning: 0,
200
+ total: 0,
201
+ cost: 0
202
+ };
203
+ }
204
+
205
+ function addTotals(target, usage) {
206
+ target.input += numberValue(usage.input);
207
+ target.output += numberValue(usage.output);
208
+ target.cacheRead += numberValue(usage.cacheRead);
209
+ target.cacheWrite += numberValue(usage.cacheWrite);
210
+ target.reasoning += numberValue(usage.reasoning);
211
+ target.total += numberValue(usage.totalTokens)
212
+ || numberValue(usage.input) + numberValue(usage.output) + numberValue(usage.cacheRead) + numberValue(usage.cacheWrite);
213
+ const cost = usage.cost;
214
+ target.cost += typeof cost === "object" && cost !== null
215
+ ? numberValue(cost.total)
216
+ : numberValue(cost);
217
+ }
218
+
219
+ function numberValue(raw) {
220
+ const n = Number(raw ?? 0);
221
+ return Number.isFinite(n) ? n : 0;
222
+ }
223
+
224
+ function extractTextLength(content) {
225
+ if (typeof content === "string") return content.length;
226
+ if (!Array.isArray(content)) return 0;
227
+ return content.reduce((sum, block) => {
228
+ if (block?.type === "text" && typeof block.text === "string") return sum + block.text.length;
229
+ return sum;
230
+ }, 0);
231
+ }
232
+
233
+ function summarizeSession(file, options) {
234
+ let stat;
235
+ try {
236
+ stat = fs.statSync(file);
237
+ } catch {
238
+ return undefined;
239
+ }
240
+
241
+ const summary = {
242
+ id: path.basename(file, ".jsonl"),
243
+ name: "",
244
+ cwd: "",
245
+ file,
246
+ isSubagent: file.split(path.sep).includes("subagent"),
247
+ provider: "",
248
+ modelId: "",
249
+ thinkingLevel: "",
250
+ firstTimestamp: undefined,
251
+ lastTimestamp: undefined,
252
+ countedFirstTimestamp: undefined,
253
+ countedLastTimestamp: undefined,
254
+ messages: { user: 0, assistant: 0, toolCalls: 0, toolResults: 0, total: 0 },
255
+ promptChars: 0,
256
+ tokens: emptyTotals(),
257
+ toolNames: {},
258
+ sizeBytes: stat.size,
259
+ mtime: stat.mtime
260
+ };
261
+
262
+ let hasCountedActivity = false;
263
+ const lines = fs.readFileSync(file, "utf8").split(/\n/);
264
+ for (const line of lines) {
265
+ if (!line) continue;
266
+ let entry;
267
+ try {
268
+ entry = JSON.parse(line);
269
+ } catch {
270
+ continue;
271
+ }
272
+
273
+ const ts = parseIso(entry.timestamp ?? entry.message?.timestamp);
274
+ if (ts) {
275
+ if (!summary.firstTimestamp || ts < summary.firstTimestamp) summary.firstTimestamp = ts;
276
+ if (!summary.lastTimestamp || ts > summary.lastTimestamp) summary.lastTimestamp = ts;
277
+ }
278
+
279
+ if (entry.type === "session") {
280
+ if (entry.id) summary.id = String(entry.id);
281
+ if (entry.cwd) summary.cwd = String(entry.cwd);
282
+ continue;
283
+ }
284
+ if (entry.type === "model_change") {
285
+ summary.provider = String(entry.provider ?? summary.provider);
286
+ summary.modelId = String(entry.modelId ?? summary.modelId);
287
+ continue;
288
+ }
289
+ if (entry.type === "thinking_level_change") {
290
+ summary.thinkingLevel = String(entry.thinkingLevel ?? summary.thinkingLevel);
291
+ continue;
292
+ }
293
+ if (entry.type === "session_info" && entry.name) {
294
+ summary.name = String(entry.name);
295
+ continue;
296
+ }
297
+ if (entry.type !== "message" || !entry.message) continue;
298
+
299
+ const counted = inRange(ts ?? stat.mtime, options);
300
+ if (!counted) continue;
301
+ hasCountedActivity = true;
302
+ const countedTs = ts ?? stat.mtime;
303
+ if (!summary.countedFirstTimestamp || countedTs < summary.countedFirstTimestamp) summary.countedFirstTimestamp = countedTs;
304
+ if (!summary.countedLastTimestamp || countedTs > summary.countedLastTimestamp) summary.countedLastTimestamp = countedTs;
305
+
306
+ const message = entry.message;
307
+ const role = message.role;
308
+ summary.messages.total += 1;
309
+ if (role === "user") {
310
+ summary.messages.user += 1;
311
+ summary.promptChars += extractTextLength(message.content);
312
+ } else if (role === "assistant") {
313
+ summary.messages.assistant += 1;
314
+ if (message.provider && !summary.provider) summary.provider = String(message.provider);
315
+ if (message.model && !summary.modelId) summary.modelId = String(message.model);
316
+ if (message.usage) addTotals(summary.tokens, message.usage);
317
+ if (Array.isArray(message.content)) {
318
+ for (const block of message.content) {
319
+ if (block?.type !== "toolCall") continue;
320
+ summary.messages.toolCalls += 1;
321
+ const name = String(block.name ?? "tool");
322
+ summary.toolNames[name] = (summary.toolNames[name] ?? 0) + 1;
323
+ }
324
+ }
325
+ } else if (role === "toolResult") {
326
+ summary.messages.toolResults += 1;
327
+ const name = String(message.toolName ?? "tool");
328
+ summary.toolNames[name] = summary.toolNames[name] ?? 0;
329
+ }
330
+ }
331
+
332
+ if (!summary.cwd) summary.cwd = "(unknown)";
333
+ if (!summary.firstTimestamp) summary.firstTimestamp = stat.mtime;
334
+ if (!summary.lastTimestamp) summary.lastTimestamp = stat.mtime;
335
+ if (!summary.countedFirstTimestamp) summary.countedFirstTimestamp = summary.firstTimestamp;
336
+ if (!summary.countedLastTimestamp) summary.countedLastTimestamp = summary.lastTimestamp;
337
+
338
+ if (!hasCountedActivity && (options.since || options.until)) return undefined;
339
+ return summary;
340
+ }
341
+
342
+ function loadReport(options) {
343
+ const files = walkJsonl(options.sessionsDir);
344
+ const sessions = [];
345
+ for (const file of files) {
346
+ const isSubagent = file.split(path.sep).includes("subagent");
347
+ if (isSubagent && !options.includeSubagents) continue;
348
+ const session = summarizeSession(file, options);
349
+ if (!session) continue;
350
+ if (!options.allProjects && options.projectPath && session.cwd !== options.projectPath) continue;
351
+ sessions.push(session);
352
+ }
353
+ sessions.sort((a, b) => b.countedLastTimestamp - a.countedLastTimestamp);
354
+ return buildReport(options, sessions);
355
+ }
356
+
357
+ function buildReport(options, sessions) {
358
+ const totals = {
359
+ sessions: sessions.length,
360
+ mainSessions: sessions.filter((s) => !s.isSubagent).length,
361
+ subagentSessions: sessions.filter((s) => s.isSubagent).length,
362
+ messages: { user: 0, assistant: 0, toolCalls: 0, toolResults: 0, total: 0 },
363
+ promptChars: 0,
364
+ tokens: emptyTotals()
365
+ };
366
+ const projects = new Map();
367
+ const tools = {};
368
+ for (const session of sessions) {
369
+ for (const key of Object.keys(totals.messages)) totals.messages[key] += session.messages[key];
370
+ totals.promptChars += session.promptChars;
371
+ for (const key of Object.keys(totals.tokens)) totals.tokens[key] += session.tokens[key];
372
+ const project = projects.get(session.cwd) ?? {
373
+ cwd: session.cwd,
374
+ sessions: 0,
375
+ mainSessions: 0,
376
+ subagentSessions: 0,
377
+ tokens: emptyTotals(),
378
+ cost: 0
379
+ };
380
+ project.sessions += 1;
381
+ if (session.isSubagent) project.subagentSessions += 1;
382
+ else project.mainSessions += 1;
383
+ for (const key of Object.keys(project.tokens)) project.tokens[key] += session.tokens[key];
384
+ project.cost = project.tokens.cost;
385
+ projects.set(session.cwd, project);
386
+ for (const [name, count] of Object.entries(session.toolNames)) {
387
+ tools[name] = (tools[name] ?? 0) + count;
388
+ }
389
+ }
390
+
391
+ const projectRows = [...projects.values()].sort((a, b) => b.tokens.total - a.tokens.total);
392
+ const toolRows = Object.entries(tools)
393
+ .map(([name, count]) => ({ name, count }))
394
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
395
+
396
+ return {
397
+ generatedAt: new Date().toISOString(),
398
+ scope: {
399
+ projectPath: options.allProjects ? null : options.projectPath,
400
+ allProjects: options.allProjects,
401
+ includeSubagents: options.includeSubagents,
402
+ sessionsDir: options.sessionsDir,
403
+ since: options.since?.toISOString() ?? null,
404
+ until: options.until?.toISOString() ?? null
405
+ },
406
+ totals,
407
+ projects: projectRows,
408
+ tools: toolRows,
409
+ sessions
410
+ };
411
+ }
412
+
413
+ function compact(n) {
414
+ const abs = Math.abs(n);
415
+ if (abs >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
416
+ if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
417
+ if (abs >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
418
+ return String(Math.round(n));
419
+ }
420
+
421
+ function money(n) {
422
+ return `$${n.toFixed(6)}`;
423
+ }
424
+
425
+ function iso(date) {
426
+ return date ? date.toISOString() : "";
427
+ }
428
+
429
+ function localLabel(date) {
430
+ if (!date) return "?";
431
+ const pad = (n) => String(n).padStart(2, "0");
432
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
433
+ }
434
+
435
+ function rangeLabel(report) {
436
+ const { since, until } = report.scope;
437
+ if (!since && !until) return "all time";
438
+ return `${since ? since.slice(0, 10) : "beginning"} -> ${until ? until.slice(0, 10) : "now"}`;
439
+ }
440
+
441
+ function renderTable(report, limit) {
442
+ const lines = [];
443
+ lines.push("PiAgent usage history");
444
+ lines.push(`Scope: ${report.scope.allProjects ? "all projects" : report.scope.projectPath}`);
445
+ lines.push(`Range: ${rangeLabel(report)}`);
446
+ lines.push(`Sessions: ${report.totals.sessions} (${report.totals.mainSessions} main, ${report.totals.subagentSessions} subagent)`);
447
+ lines.push(`Tokens: ${compact(report.totals.tokens.total)} total (${compact(report.totals.tokens.input)} input, ${compact(report.totals.tokens.output)} output, ${compact(report.totals.tokens.cacheRead)} cache read, ${compact(report.totals.tokens.cacheWrite)} cache write, ${compact(report.totals.tokens.reasoning)} reasoning)`);
448
+ lines.push(`Cost: ${money(report.totals.tokens.cost)}`);
449
+ lines.push(`Messages: ${report.totals.messages.user} user, ${report.totals.messages.assistant} assistant, ${report.totals.messages.toolCalls} tool calls, ${report.totals.messages.toolResults} tool results`);
450
+ if (report.projects.length) {
451
+ lines.push("");
452
+ lines.push("Projects:");
453
+ for (const project of report.projects.slice(0, 10)) {
454
+ lines.push(` ${compact(project.tokens.total).padStart(8)} tok ${money(project.tokens.cost).padStart(11)} ${String(project.sessions).padStart(3)} sessions ${project.cwd}`);
455
+ }
456
+ }
457
+ if (report.sessions.length) {
458
+ lines.push("");
459
+ lines.push(`Top sessions (${Math.min(limit, report.sessions.length)} of ${report.sessions.length}):`);
460
+ for (const session of report.sessions.slice(0, limit)) {
461
+ const kind = session.isSubagent ? "sub" : "main";
462
+ const model = session.modelId || session.provider || "?";
463
+ lines.push(` ${localLabel(session.countedLastTimestamp)} ${kind.padEnd(4)} ${compact(session.tokens.total).padStart(8)} tok ${money(session.tokens.cost).padStart(11)} ${String(session.messages.user).padStart(2)}p/${String(session.messages.toolCalls).padStart(2)}t ${model} ${session.name || session.id}`);
464
+ }
465
+ }
466
+ return `${lines.join("\n")}\n`;
467
+ }
468
+
469
+ function renderMarkdown(report, limit) {
470
+ const lines = [];
471
+ lines.push("# PiAgent Usage History");
472
+ lines.push("");
473
+ lines.push(`- Scope: ${report.scope.allProjects ? "all projects" : report.scope.projectPath}`);
474
+ lines.push(`- Range: ${rangeLabel(report)}`);
475
+ lines.push(`- Sessions: ${report.totals.sessions} (${report.totals.mainSessions} main, ${report.totals.subagentSessions} subagent)`);
476
+ lines.push(`- Tokens: ${report.totals.tokens.total.toLocaleString("en-US")}`);
477
+ lines.push(`- Cost: ${money(report.totals.tokens.cost)}`);
478
+ lines.push("");
479
+ lines.push("## Projects");
480
+ lines.push("");
481
+ lines.push("| Project | Sessions | Tokens | Cost |");
482
+ lines.push("|---|---:|---:|---:|");
483
+ for (const project of report.projects.slice(0, 20)) {
484
+ lines.push(`| ${escapeMarkdown(project.cwd)} | ${project.sessions} | ${project.tokens.total} | ${money(project.tokens.cost)} |`);
485
+ }
486
+ lines.push("");
487
+ lines.push("## Sessions");
488
+ lines.push("");
489
+ lines.push("| Last activity | Kind | Session | Model | Prompts | Tools | Tokens | Cost |");
490
+ lines.push("|---|---|---|---|---:|---:|---:|---:|");
491
+ for (const session of report.sessions.slice(0, limit)) {
492
+ lines.push(`| ${localLabel(session.countedLastTimestamp)} | ${session.isSubagent ? "subagent" : "main"} | ${escapeMarkdown(session.name || session.id)} | ${escapeMarkdown(session.modelId || session.provider || "?")} | ${session.messages.user} | ${session.messages.toolCalls} | ${session.tokens.total} | ${money(session.tokens.cost)} |`);
493
+ }
494
+ return `${lines.join("\n")}\n`;
495
+ }
496
+
497
+ function escapeMarkdown(raw) {
498
+ return String(raw).replaceAll("|", "\\|");
499
+ }
500
+
501
+ function csvEscape(raw) {
502
+ const s = String(raw ?? "");
503
+ if (!/[",\n]/.test(s)) return s;
504
+ return `"${s.replaceAll("\"", "\"\"")}"`;
505
+ }
506
+
507
+ function renderCsv(report) {
508
+ const columns = [
509
+ "session_id", "name", "cwd", "kind", "first_timestamp", "last_timestamp",
510
+ "provider", "model", "thinking_level", "user_messages", "assistant_messages",
511
+ "tool_calls", "tool_results", "input_tokens", "output_tokens", "cache_read_tokens",
512
+ "cache_write_tokens", "reasoning_tokens", "total_tokens", "cost_usd", "file"
513
+ ];
514
+ const rows = [columns.join(",")];
515
+ for (const s of report.sessions) {
516
+ rows.push([
517
+ s.id, s.name, s.cwd, s.isSubagent ? "subagent" : "main", iso(s.firstTimestamp), iso(s.lastTimestamp),
518
+ s.provider, s.modelId, s.thinkingLevel, s.messages.user, s.messages.assistant,
519
+ s.messages.toolCalls, s.messages.toolResults, s.tokens.input, s.tokens.output, s.tokens.cacheRead,
520
+ s.tokens.cacheWrite, s.tokens.reasoning, s.tokens.total, s.tokens.cost.toFixed(6), s.file
521
+ ].map(csvEscape).join(","));
522
+ }
523
+ return `${rows.join("\n")}\n`;
524
+ }
525
+
526
+ function toJson(report) {
527
+ return JSON.stringify(report, (_key, value) => value instanceof Date ? value.toISOString() : value, 2) + "\n";
528
+ }
529
+
530
+ function render(report, options) {
531
+ switch (options.format) {
532
+ case "json":
533
+ return toJson(report);
534
+ case "csv":
535
+ return renderCsv(report);
536
+ case "markdown":
537
+ return renderMarkdown(report, options.limit);
538
+ default:
539
+ return renderTable(report, options.limit);
540
+ }
541
+ }
542
+
543
+ export {
544
+ buildReport,
545
+ loadReport,
546
+ parseArgs,
547
+ render,
548
+ summarizeSession,
549
+ walkJsonl
550
+ };
551
+
552
+ async function main() {
553
+ const options = parseArgs(process.argv.slice(2));
554
+ if (options.help) {
555
+ process.stdout.write(USAGE);
556
+ return;
557
+ }
558
+ const report = loadReport(options);
559
+ process.stdout.write(render(report, options));
560
+ }
561
+
562
+ if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
563
+ main().catch((error) => {
564
+ console.error(`FAIL: ${error?.message ?? error}`);
565
+ process.exit(1);
566
+ });
567
+ }
@@ -31,6 +31,14 @@ if [[ -z "$PROJECT_PATH" ]]; then
31
31
  usage
32
32
  exit 2
33
33
  fi
34
+ # The project path is positional and comes first, so omitting it hands the first
35
+ # flag to `cd` and the run dies somewhere further down complaining about an
36
+ # option. Say what is actually wrong instead.
37
+ if [[ "$PROJECT_PATH" == -* ]]; then
38
+ echo "FAIL: the project path comes first, before any flag; received: $PROJECT_PATH" >&2
39
+ usage >&2
40
+ exit 2
41
+ fi
34
42
  shift || true
35
43
 
36
44
  PROJECT_PATH="$(cd "$PROJECT_PATH" && pwd)"
@@ -45,6 +53,17 @@ COST=""
45
53
  DURATION=""
46
54
  NOTES=""
47
55
 
56
+ # A flag left without a value otherwise consumes the next flag as its value, and
57
+ # the run continues on a record that says something nobody typed.
58
+ require_value() {
59
+ local option="$1"
60
+ local value="${2:-}"
61
+ if [[ -z "$value" || "$value" == --* ]]; then
62
+ echo "Missing value for $option" >&2
63
+ exit 2
64
+ fi
65
+ }
66
+
48
67
  while [[ $# -gt 0 ]]; do
49
68
  case "$1" in
50
69
  --init)
@@ -56,39 +75,48 @@ while [[ $# -gt 0 ]]; do
56
75
  shift
57
76
  ;;
58
77
  --scenario)
59
- SCENARIO="${2:-}"
78
+ require_value "$1" "${2:-}"
79
+ SCENARIO="$2"
60
80
  shift 2
61
81
  ;;
62
82
  --surface|--agent)
63
- SURFACE="${2:-}"
83
+ require_value "$1" "${2:-}"
84
+ SURFACE="$2"
64
85
  shift 2
65
86
  ;;
66
87
  --result)
67
- RESULT="${2:-}"
88
+ require_value "$1" "${2:-}"
89
+ RESULT="$2"
68
90
  shift 2
69
91
  ;;
70
92
  --task-file)
71
- TASK_FILE="${2:-}"
93
+ require_value "$1" "${2:-}"
94
+ TASK_FILE="$2"
72
95
  shift 2
73
96
  ;;
74
97
  --verify)
75
- VERIFY_CMD="${2:-}"
98
+ require_value "$1" "${2:-}"
99
+ VERIFY_CMD="$2"
76
100
  shift 2
77
101
  ;;
78
102
  --tokens)
79
- TOKENS="${2:-}"
103
+ require_value "$1" "${2:-}"
104
+ TOKENS="$2"
80
105
  shift 2
81
106
  ;;
82
107
  --cost)
83
- COST="${2:-}"
108
+ require_value "$1" "${2:-}"
109
+ COST="$2"
84
110
  shift 2
85
111
  ;;
86
112
  --duration)
87
- DURATION="${2:-}"
113
+ require_value "$1" "${2:-}"
114
+ DURATION="$2"
88
115
  shift 2
89
116
  ;;
90
117
  --notes)
91
- NOTES="${2:-}"
118
+ require_value "$1" "${2:-}"
119
+ NOTES="$2"
92
120
  shift 2
93
121
  ;;
94
122
  -h|--help)