@prjct.app/pi-activity 0.1.3 → 0.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/src/format.ts ADDED
@@ -0,0 +1,272 @@
1
+ import { homedir } from "node:os";
2
+ import { stripVTControlCharacters } from "node:util";
3
+ import type {
4
+ ActivityCategory,
5
+ ActivityRecord,
6
+ ActivityRecordSnapshot,
7
+ ActivityStatus,
8
+ FileChange,
9
+ } from "./types.ts";
10
+
11
+ const INSPECTION_TOOLS = new Set(["read", "grep", "find", "ls"]);
12
+ const CHANGE_TOOLS = new Set(["edit", "write"]);
13
+ const VERIFY_COMMAND = /(?:^|[;&|]\s*|\b)(?:npm|pnpm|yarn|bun)\s+(?:(?:run|run-s|run-p)\s+)?(?:test|check|lint|build|typecheck|type-check)\b|(?:^|[;&|]\s*|\b)(?:pytest|vitest|jest|mocha|cargo\s+test|go\s+test|tsc\b)/i;
14
+
15
+ export const TOOL_VERBS: Record<string, string> = {
16
+ bash: "RUN",
17
+ read: "READ",
18
+ edit: "EDIT",
19
+ write: "WRITE",
20
+ find: "FIND",
21
+ grep: "SEARCH",
22
+ ls: "LIST",
23
+ };
24
+
25
+ export function cleanDisplayText(value: string): string {
26
+ return stripVTControlCharacters(value)
27
+ .replace(/\r?\n/g, " ↵ ")
28
+ .replace(/\t/g, " ")
29
+ .replace(/[\x00-\x1f\x7f]/g, "");
30
+ }
31
+
32
+ export function actionTarget(name: string, args: Record<string, unknown>): string {
33
+ const raw = name === "bash"
34
+ ? args.command
35
+ : name === "grep" || name === "find"
36
+ ? `${args.pattern ?? ""}${args.path ? ` · ${args.path}` : ""}`
37
+ : args.path;
38
+ if (typeof raw !== "string" || !raw) return ".";
39
+ const home = homedir();
40
+ const target = raw.startsWith(`${home}/`) ? `~/${raw.slice(home.length + 1)}` : raw;
41
+ return cleanDisplayText(target);
42
+ }
43
+
44
+ export function isVerificationCommand(command: unknown): boolean {
45
+ return typeof command === "string" && VERIFY_COMMAND.test(command);
46
+ }
47
+
48
+ export function activityCategory(name: string, args: Record<string, unknown>): ActivityCategory {
49
+ if (INSPECTION_TOOLS.has(name)) return "inspect";
50
+ if (CHANGE_TOOLS.has(name)) return "change";
51
+ if (name === "bash") return isVerificationCommand(args.command) ? "verify" : "execute";
52
+ return "other";
53
+ }
54
+
55
+ export function resultText(result: unknown): string {
56
+ if (!result || typeof result !== "object") return "";
57
+ const content = (result as { content?: unknown }).content;
58
+ if (!Array.isArray(content)) return "";
59
+ return content
60
+ .filter((item): item is { type: "text"; text: string } =>
61
+ Boolean(item) && typeof item === "object" && (item as { type?: unknown }).type === "text" && typeof (item as { text?: unknown }).text === "string",
62
+ )
63
+ .map((item) => item.text)
64
+ .join("\n")
65
+ .trim();
66
+ }
67
+
68
+ function detailsOf(result: unknown): Record<string, unknown> | undefined {
69
+ if (!result || typeof result !== "object") return undefined;
70
+ const details = (result as { details?: unknown }).details;
71
+ return details && typeof details === "object" ? details as Record<string, unknown> : undefined;
72
+ }
73
+
74
+ function countLines(text: string): number {
75
+ if (!text) return 0;
76
+ return text.replace(/\n$/, "").split("\n").length;
77
+ }
78
+
79
+ function plural(value: number, singular: string, pluralForm = `${singular}s`): string {
80
+ return `${value} ${value === 1 ? singular : pluralForm}`;
81
+ }
82
+
83
+ export function formatBytes(bytes: number): string {
84
+ if (bytes < 1024) return `${bytes} B`;
85
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
86
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
87
+ }
88
+
89
+ export function formatDuration(ms: number | undefined): string {
90
+ if (ms === undefined || !Number.isFinite(ms)) return "";
91
+ if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`;
92
+ if (ms < 10_000) return `${(ms / 1000).toFixed(1)}s`;
93
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
94
+ const minutes = Math.floor(ms / 60_000);
95
+ const seconds = Math.round((ms % 60_000) / 1000);
96
+ return `${minutes}m ${seconds}s`;
97
+ }
98
+
99
+ export function parseDiffStats(diff: string | undefined): { additions: number; deletions: number } {
100
+ if (!diff) return { additions: 0, deletions: 0 };
101
+ let additions = 0;
102
+ let deletions = 0;
103
+ for (const line of diff.split("\n")) {
104
+ if (line.startsWith("+") && !line.startsWith("+++")) additions++;
105
+ if (line.startsWith("-") && !line.startsWith("---")) deletions++;
106
+ }
107
+ return { additions, deletions };
108
+ }
109
+
110
+ function grepMatchCount(text: string): number {
111
+ const lines = text.split("\n").filter((line) => line.trim() && line.trim() !== "--");
112
+ const formattedMatches = lines.filter((line) => /(?:^|:|-)\d+(?::|-)/.test(line));
113
+ return formattedMatches.length || lines.length;
114
+ }
115
+
116
+ function listCount(text: string): number {
117
+ return text.split("\n").filter((line) => line.trim() && !line.trimStart().startsWith("[")).length;
118
+ }
119
+
120
+ function compactError(text: string): string | undefined {
121
+ const lines = text.split("\n").map((line) => cleanDisplayText(line.trim())).filter(Boolean);
122
+ if (!lines.length) return undefined;
123
+ const joined = lines.slice(-3).join(" · ");
124
+ return joined.length > 320 ? `${joined.slice(0, 317)}…` : joined;
125
+ }
126
+
127
+ function truncationState(result: unknown): boolean {
128
+ const details = detailsOf(result);
129
+ const truncation = details?.truncation;
130
+ return Boolean(
131
+ truncation && typeof truncation === "object" && (truncation as { truncated?: unknown }).truncated,
132
+ ) || Boolean(details?.matchLimitReached || details?.resultLimitReached || details?.entryLimitReached || details?.linesTruncated);
133
+ }
134
+
135
+ function statusFromResult(isError: boolean, text: string): Exclude<ActivityStatus, "running"> {
136
+ if (!isError) return "success";
137
+ return /\b(?:aborted|cancelled|canceled|interrupted)\b/i.test(text) ? "cancelled" : "error";
138
+ }
139
+
140
+ function verificationOutcome(command: unknown, output: string): string {
141
+ const passed = output.match(/\b(\d+)\s+(?:tests?\s+)?passed\b/i)?.[1]
142
+ ?? output.match(/^#\s*pass\s+(\d+)\s*$/im)?.[1];
143
+ if (passed) return `${passed} passed`;
144
+ if (typeof command === "string" && /\b(?:lint|eslint|biome)\b/i.test(command)) return "lint clean";
145
+ if (typeof command === "string" && /\b(?:typecheck|type-check|tsc)\b/i.test(command)) return "types clean";
146
+ if (typeof command === "string" && /\bbuild\b/i.test(command)) return "build passed";
147
+ return "verified";
148
+ }
149
+
150
+ function outcomeFor(
151
+ name: string,
152
+ args: Record<string, unknown>,
153
+ result: unknown,
154
+ status: Exclude<ActivityStatus, "running">,
155
+ ): { outcome: string; change?: FileChange } {
156
+ const text = resultText(result);
157
+ const details = detailsOf(result);
158
+ if (status === "cancelled") return { outcome: "cancelled" };
159
+ if (status === "error") {
160
+ const exitCode = text.match(/exited with code\s+(\d+)/i)?.[1];
161
+ if (exitCode) return { outcome: `exit ${exitCode}` };
162
+ if (/timed out/i.test(text)) return { outcome: "timed out" };
163
+ return { outcome: "failed" };
164
+ }
165
+
166
+ switch (name) {
167
+ case "read": {
168
+ const truncation = details?.truncation as { outputLines?: number } | undefined;
169
+ const lines = truncation?.outputLines ?? countLines(text);
170
+ return { outcome: plural(lines, "line") };
171
+ }
172
+ case "grep": {
173
+ if (/^no matches found$/i.test(text)) return { outcome: "no matches" };
174
+ const matches = grepMatchCount(text);
175
+ return { outcome: matches ? plural(matches, "match", "matches") : "no matches" };
176
+ }
177
+ case "find": {
178
+ if (/^no files found/i.test(text)) return { outcome: "no results" };
179
+ const results = listCount(text);
180
+ return { outcome: results ? plural(results, "result") : "no results" };
181
+ }
182
+ case "ls": {
183
+ if (/^\(?empty directory\)?$/i.test(text)) return { outcome: "empty" };
184
+ const entries = listCount(text);
185
+ return { outcome: entries ? plural(entries, "entry", "entries") : "empty" };
186
+ }
187
+ case "edit": {
188
+ const edits = Array.isArray(args.edits) ? args.edits.length : 1;
189
+ const diff = typeof details?.diff === "string" ? details.diff : undefined;
190
+ const stats = parseDiffStats(diff);
191
+ const path = typeof args.path === "string" ? args.path : ".";
192
+ const change: FileChange = {
193
+ path,
194
+ kind: "edit",
195
+ actions: 1,
196
+ additions: stats.additions,
197
+ deletions: stats.deletions,
198
+ };
199
+ const delta = stats.additions || stats.deletions ? ` · +${stats.additions} −${stats.deletions}` : "";
200
+ return { outcome: `${plural(edits, "block")}${delta}`, change };
201
+ }
202
+ case "write": {
203
+ const content = typeof args.content === "string" ? args.content : "";
204
+ const lines = countLines(content);
205
+ const bytes = Buffer.byteLength(content);
206
+ const path = typeof args.path === "string" ? args.path : ".";
207
+ return {
208
+ outcome: `${plural(lines, "line")} · ${formatBytes(bytes)}`,
209
+ change: { path, kind: "write", actions: 1, lines, bytes },
210
+ };
211
+ }
212
+ case "bash":
213
+ return { outcome: isVerificationCommand(args.command) ? verificationOutcome(args.command, text) : "exit 0" };
214
+ default:
215
+ return { outcome: "completed" };
216
+ }
217
+ }
218
+
219
+ export function applyToolResult(record: ActivityRecord, result: unknown, isError: boolean): void {
220
+ const text = resultText(result);
221
+ const status = statusFromResult(isError, text);
222
+ const derived = outcomeFor(record.name, record.args, result, status);
223
+ record.status = status;
224
+ record.outcome = derived.outcome;
225
+ record.change = derived.change;
226
+ record.truncated = truncationState(result);
227
+ record.outputPreview = text.length > 2_000 ? `${text.slice(0, 1_997)}…` : text;
228
+ if (status === "error" || status === "cancelled") record.errorMessage = compactError(text);
229
+ }
230
+
231
+ export function finishRecord(record: ActivityRecord, endedAt: number, result: unknown, isError: boolean): void {
232
+ record.endedAt = endedAt;
233
+ record.durationMs = Math.max(0, endedAt - record.startedAt);
234
+ applyToolResult(record, result, isError);
235
+ }
236
+
237
+ export function snapshotRecord(record: ActivityRecord): ActivityRecordSnapshot {
238
+ const status = record.status === "running" ? "cancelled" : record.status;
239
+ return {
240
+ id: record.id,
241
+ name: record.name,
242
+ target: record.target,
243
+ category: record.category,
244
+ status,
245
+ startedAt: record.startedAt,
246
+ durationMs: record.durationMs ?? Math.max(0, Date.now() - record.startedAt),
247
+ outcome: record.outcome,
248
+ truncated: record.truncated || undefined,
249
+ change: record.change,
250
+ errorMessage: record.errorMessage,
251
+ };
252
+ }
253
+
254
+ export function aggregateFileChanges(records: readonly ActivityRecordSnapshot[]): FileChange[] {
255
+ const files = new Map<string, FileChange>();
256
+ for (const record of records) {
257
+ if (record.status !== "success" || !record.change) continue;
258
+ const change = record.change;
259
+ const existing = files.get(change.path);
260
+ if (!existing) {
261
+ files.set(change.path, { ...change });
262
+ continue;
263
+ }
264
+ existing.actions += change.actions;
265
+ existing.kind = existing.kind === "write" || change.kind === "write" ? "write" : "edit";
266
+ if (change.additions !== undefined) existing.additions = (existing.additions ?? 0) + change.additions;
267
+ if (change.deletions !== undefined) existing.deletions = (existing.deletions ?? 0) + change.deletions;
268
+ if (change.lines !== undefined) existing.lines = change.lines;
269
+ if (change.bytes !== undefined) existing.bytes = change.bytes;
270
+ }
271
+ return [...files.values()];
272
+ }
package/src/types.ts ADDED
@@ -0,0 +1,67 @@
1
+ export type ActivityDensity = "minimal" | "balanced" | "forensic";
2
+ export type ActivityStatus = "running" | "success" | "error" | "cancelled";
3
+ export type ActivityCategory = "inspect" | "change" | "verify" | "execute" | "other";
4
+
5
+ export interface FileChange {
6
+ path: string;
7
+ kind: "edit" | "write";
8
+ actions: number;
9
+ additions?: number;
10
+ deletions?: number;
11
+ lines?: number;
12
+ bytes?: number;
13
+ }
14
+
15
+ export interface ActivityRecordSnapshot {
16
+ id: string;
17
+ name: string;
18
+ target: string;
19
+ category: ActivityCategory;
20
+ status: Exclude<ActivityStatus, "running">;
21
+ startedAt: number;
22
+ durationMs: number;
23
+ outcome?: string;
24
+ truncated?: boolean;
25
+ change?: FileChange;
26
+ errorMessage?: string;
27
+ }
28
+
29
+ export interface ActivityRecord {
30
+ id: string;
31
+ name: string;
32
+ args: Record<string, unknown>;
33
+ target: string;
34
+ category: ActivityCategory;
35
+ status: ActivityStatus;
36
+ startedAt: number;
37
+ endedAt?: number;
38
+ durationMs?: number;
39
+ outcome?: string;
40
+ truncated?: boolean;
41
+ change?: FileChange;
42
+ errorMessage?: string;
43
+ outputPreview?: string;
44
+ }
45
+
46
+ export interface ActivitySummaryData {
47
+ version: 2;
48
+ startedAt: number;
49
+ durationMs: number;
50
+ actionCount: number;
51
+ errorCount: number;
52
+ cancelledCount: number;
53
+ truncatedCount: number;
54
+ modifiedFiles: FileChange[];
55
+ completedActions: string[];
56
+ failedActions: string[];
57
+ categoryCounts: Partial<Record<ActivityCategory, number>>;
58
+ records: ActivityRecordSnapshot[];
59
+ }
60
+
61
+ export interface LegacyActivitySummaryData {
62
+ modifiedFiles?: string[];
63
+ failedActions?: string[];
64
+ completedActions?: string[];
65
+ actionCount?: number;
66
+ errorCount?: number;
67
+ }