@pi-unipi/utility 0.1.1 → 0.2.1

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 CHANGED
@@ -1,51 +1,151 @@
1
1
  # @pi-unipi/utility
2
2
 
3
- Utility commands and tools for the Pi coding agent — part of the Unipi suite.
3
+ Comprehensive utility suite for the Pi coding agent — part of the Unipi extension suite.
4
4
 
5
5
  ## Features
6
6
 
7
- ### `/unipi:continue` Command
7
+ ### Commands
8
+
9
+ | Command | Description |
10
+ |---------|-------------|
11
+ | `/unipi:continue` | Continue agent without polluting context |
12
+ | `/unipi:reload` | Explain how to reload extensions |
13
+ | `/unipi:status` | Show status of all unipi modules |
14
+ | `/unipi:cleanup` | Clean stale DBs, temp files, old sessions |
15
+ | `/unipi:env` | Show environment info (Node, Pi, OS, paths) |
16
+ | `/unipi:doctor` | Run diagnostics across all modules |
17
+
18
+ ### Tools
19
+
20
+ | Tool | Description |
21
+ |------|-------------|
22
+ | `ctx_batch` | Atomic batch execution with rollback support |
23
+ | `ctx_env` | Environment inspection for debugging |
24
+
25
+ ### Modules (Programmatic API)
26
+
27
+ | Module | Path | Description |
28
+ |--------|------|-------------|
29
+ | **ProcessLifecycle** | `lifecycle/process` | Parent PID polling, orphan detection, signal handlers, cleanup registry |
30
+ | **cleanupStale** | `lifecycle/cleanup` | Stale DB/temp/session/cache cleanup with dry-run support |
31
+ | **TTLCache** | `cache/ttl-cache` | Memory or SQLite-backed TTL cache with auto-expiration |
32
+ | **AnalyticsCollector** | `analytics/collector` | Privacy-respecting event collection with daily rollup |
33
+ | **runDiagnostics** | `diagnostics/engine` | Cross-module health checks with plugin architecture |
34
+ | **detectCapabilities** | `display/capabilities` | Terminal feature detection (color, Nerd Font, unicode) |
35
+ | **Width Utilities** | `display/width` | ANSI-aware clamp, wrap, collapse, pad, center |
36
+ | **SettingsInspector** | `tui/settings-inspector` | Reusable settings overlay data model |
8
37
 
9
- Continue the agent from where it left off **without adding a user message** to the conversation transcript.
38
+ ## Installation
39
+
40
+ ```bash
41
+ pi install npm:@pi-unipi/utility
42
+ ```
43
+
44
+ Or install the full Unipi suite:
10
45
 
46
+ ```bash
47
+ pi install npm:@pi-unipi/unipi
11
48
  ```
12
- /unipi:continue
49
+
50
+ ## Usage
51
+
52
+ ### Commands
53
+
54
+ ```
55
+ /unipi:continue # Resume agent cleanly
56
+ /unipi:cleanup # Clean stale files
57
+ /unipi:cleanup --dry-run # Preview what would be cleaned
58
+ /unipi:env # Show environment
59
+ /unipi:doctor # Run diagnostics
13
60
  ```
14
61
 
15
- This sends a "steer" message that tells the agent to proceed to the next step. Unlike typing "continue" yourself, this doesn't pollute the context with an extra user message.
62
+ ### Batch Execution (Code)
16
63
 
17
- ### `continue_task` Tool
64
+ ```typescript
65
+ import { BatchBuilder } from "@pi-unipi/utility/tools/batch";
18
66
 
19
- The agent can call this tool programmatically when it finishes a step and needs to proceed to the next without waiting for user input.
67
+ const report = await new BatchBuilder()
68
+ .addCommand("search", { query: "refactor" })
69
+ .addTool("memory_search", { query: "patterns" })
70
+ .withOptions({ failFast: true, commandTimeoutMs: 30000 })
71
+ .execute(myExecutor);
20
72
 
73
+ if (!report.success) {
74
+ console.log("Failed:", report.results.find(r => !r.success)?.error);
75
+ }
21
76
  ```
22
- continue_task()
77
+
78
+ ### TTL Cache (Code)
79
+
80
+ ```typescript
81
+ import { TTLCache } from "@pi-unipi/utility/cache/ttl-cache";
82
+
83
+ const cache = new TTLCache({ defaultTtlMs: 60000 });
84
+ await cache.set("key", { data: "value" });
85
+ const value = await cache.get("key");
23
86
  ```
24
87
 
25
- **When to use:** The agent has completed one step and should automatically proceed to the next.
88
+ ### Diagnostics (Code)
26
89
 
27
- ## Installation
90
+ ```typescript
91
+ import { runDiagnostics, formatDiagnosticsReport } from "@pi-unipi/utility/diagnostics/engine";
28
92
 
29
- ```bash
30
- pi install npm:@pi-unipi/utility
93
+ const report = await runDiagnostics();
94
+ console.log(formatDiagnosticsReport(report));
31
95
  ```
32
96
 
33
- Or install the full Unipi suite:
97
+ ### Terminal Capabilities (Code)
34
98
 
35
- ```bash
36
- pi install npm:@pi-unipi/unipi
99
+ ```typescript
100
+ import { detectCapabilities, getIcon } from "@pi-unipi/utility/display/capabilities";
101
+
102
+ const caps = detectCapabilities();
103
+ console.log("Nerd Font:", caps.nerdFont);
104
+ console.log(getIcon("󰘳", "[OK]")); // Uses Nerd Font if available
37
105
  ```
38
106
 
39
- ## How It Works
107
+ ## Architecture
40
108
 
41
- Both the command and tool use Pi's `sendUserMessage` API with `deliverAs: "steer"` to inject a continuation prompt without creating a user message in the transcript. This keeps the conversation clean while allowing the agent to continue working.
109
+ ```
110
+ packages/utility/src/
111
+ ├── index.ts # Extension entry point
112
+ ├── commands.ts # Command registration
113
+ ├── types.ts # Shared types
114
+ ├── info-screen.ts # Info-screen integration
115
+ ├── lifecycle/
116
+ │ ├── process.ts # Process lifecycle manager
117
+ │ └── cleanup.ts # Stale cleanup utility
118
+ ├── cache/
119
+ │ └── ttl-cache.ts # TTL cache (memory + SQLite)
120
+ ├── analytics/
121
+ │ └── collector.ts # Event collection + rollup
122
+ ├── diagnostics/
123
+ │ └── engine.ts # Health check engine
124
+ ├── display/
125
+ │ ├── capabilities.ts # Terminal detection
126
+ │ └── width.ts # Width utilities
127
+ ├── tui/
128
+ │ └── settings-inspector.ts # Settings overlay model
129
+ └── tools/
130
+ ├── batch.ts # Batch execution
131
+ └── env.ts # Environment info
132
+ ```
42
133
 
43
- The default continue prompt is:
134
+ ## Privacy
44
135
 
45
- > Continue from where you left off. Proceed with the next step.
136
+ The analytics collector is **privacy-respecting** by design:
137
+ - No file contents are recorded
138
+ - No sensitive data (API keys, tokens, passwords) — redacted automatically
139
+ - Strings truncated to 500 characters
140
+ - All data stays local (in-memory by default, optional SQLite)
46
141
 
47
142
  ## Dependencies
48
143
 
49
- - `@pi-unipi/core` — Shared constants and utilities
144
+ - `@pi-unipi/core` — Shared constants, events, utilities
50
145
  - `@mariozechner/pi-coding-agent` — Pi extension API
51
- - `@sinclair/typebox` — Schema validation
146
+ - `@sinclair/typebox` — Schema validation (peer dependency)
147
+ - `sqlite3` — Optional, for persistent cache/analytics
148
+
149
+ ## License
150
+
151
+ MIT
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pi-unipi/utility",
3
- "version": "0.1.1",
4
- "description": "Utility commands and tools for Pi coding agent — continue, status helpers",
3
+ "version": "0.2.1",
4
+ "description": "Utility commands and tools for Pi coding agent — lifecycle, diagnostics, cache, analytics, display, batch execution",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Neuron Mr White",
@@ -16,16 +16,22 @@
16
16
  "pi-coding-agent",
17
17
  "unipi",
18
18
  "utility",
19
- "continue"
19
+ "continue",
20
+ "diagnostics",
21
+ "cache",
22
+ "analytics"
20
23
  ],
21
24
  "files": [
22
- "index.ts",
23
- "commands.ts",
24
- "README.md"
25
+ "src/index.ts",
26
+ "src/commands.ts",
27
+ "src/types.ts",
28
+ "src/**/*.ts",
29
+ "README.md",
30
+ "skills/**/*.md"
25
31
  ],
26
32
  "pi": {
27
33
  "extensions": [
28
- "index.ts"
34
+ "src/index.ts"
29
35
  ]
30
36
  },
31
37
  "publishConfig": {
@@ -37,5 +43,8 @@
37
43
  "peerDependencies": {
38
44
  "@mariozechner/pi-coding-agent": "*",
39
45
  "@sinclair/typebox": "*"
46
+ },
47
+ "scripts": {
48
+ "test": "npx tsx --test tests/**/*.test.ts"
40
49
  }
41
50
  }
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: utility
3
+ scope: agent
4
+ description: |
5
+ Guidance for using the @pi-unipi/utility extension commands and tools.
6
+ Provides lifecycle management, diagnostics, cleanup, batch execution,
7
+ environment inspection, and terminal utilities.
8
+ ---
9
+
10
+ # @pi-unipi/utility — Agent Guidance
11
+
12
+ ## Overview
13
+
14
+ The `@pi-unipi/utility` extension provides general-purpose utilities for the Pi coding agent. It is **not** related to compaction — it provides helpers that any module or agent can use.
15
+
16
+ ## Commands
17
+
18
+ | Command | Purpose | When to Use |
19
+ |---------|---------|-------------|
20
+ | `/unipi:continue` | Continue agent without polluting context | After interruption, to resume cleanly |
21
+ | `/unipi:reload` | Explain how to reload extensions | When user asks about hot-reloading |
22
+ | `/unipi:status` | Request status from all modules | To check which modules are loaded |
23
+ | `/unipi:cleanup` | Clean stale files, DBs, sessions | When disk space is low or after crashes |
24
+ | `/unipi:env` | Show environment info | For debugging version/path issues |
25
+ | `/unipi:doctor` | Run diagnostics | When something seems broken |
26
+
27
+ ### Cleanup Options
28
+
29
+ - `--dry-run` — Report what would be cleaned without removing anything
30
+ - Default max ages: DBs 14 days, temp 7 days, sessions 30 days
31
+
32
+ ## Tools
33
+
34
+ | Tool | Purpose |
35
+ |------|---------|
36
+ | `ctx_batch` | Atomic batch execution of commands with rollback |
37
+ | `ctx_env` | Environment inspection |
38
+
39
+ ### Batch Execution Pattern
40
+
41
+ ```typescript
42
+ import { BatchBuilder } from "@pi-unipi/utility/tools/batch";
43
+
44
+ const report = await new BatchBuilder()
45
+ .addCommand("search", { query: "foo" })
46
+ .addTool("memory_search", { query: "bar" })
47
+ .withOptions({ failFast: true, commandTimeoutMs: 30000 })
48
+ .execute(myExecutor);
49
+ ```
50
+
51
+ ## Modules (for code use)
52
+
53
+ | Module | Import Path | Purpose |
54
+ |--------|-------------|---------|
55
+ | ProcessLifecycle | `@pi-unipi/utility/lifecycle/process` | Parent PID polling, orphan detection, cleanup registry |
56
+ | cleanupStale | `@pi-unipi/utility/lifecycle/cleanup` | Stale file/DB/session cleanup |
57
+ | TTLCache | `@pi-unipi/utility/cache/ttl-cache` | In-memory or SQLite-backed TTL cache |
58
+ | AnalyticsCollector | `@pi-unipi/utility/analytics/collector` | Privacy-respecting event collection |
59
+ | runDiagnostics | `@pi-unipi/utility/diagnostics/engine` | Cross-module health checks |
60
+ | detectCapabilities | `@pi-unipi/utility/display/capabilities` | Terminal feature detection |
61
+ | clampWidth, wrapLines | `@pi-unipi/utility/display/width` | ANSI-aware text formatting |
62
+ | SettingsInspector | `@pi-unipi/utility/tui/settings-inspector` | Reusable settings overlay model |
63
+
64
+ ## Integration Notes
65
+
66
+ - Lifecycle manager is a singleton — use `getLifecycle()` to access
67
+ - Analytics collector is a singleton — use `getAnalyticsCollector()`
68
+ - Diagnostics engine supports custom plugins via `registerDiagnosticPlugin()`
69
+ - TTLCache defaults to memory backend; set `persistent: true` for SQLite
70
+ - All modules emit unipi events for cross-module integration
@@ -0,0 +1,293 @@
1
+ /**
2
+ * @pi-unipi/utility — Analytics Collector
3
+ *
4
+ * Lightweight metrics collection for all unipi modules.
5
+ * Privacy-respecting: no file contents, no sensitive data.
6
+ */
7
+
8
+ import { randomUUID } from "node:crypto";
9
+ import type {
10
+ AnalyticsEvent,
11
+ AnalyticsEventType,
12
+ AnalyticsRollup,
13
+ AnalyticsOptions,
14
+ } from "../types.js";
15
+
16
+ /** Default options */
17
+ const DEFAULTS: Required<AnalyticsOptions> = {
18
+ dbPath: "~/.unipi/analytics/events.db",
19
+ maxEvents: 10000,
20
+ rollupEnabled: true,
21
+ };
22
+
23
+ /** In-memory event buffer */
24
+ const eventBuffer: AnalyticsEvent[] = [];
25
+ let bufferFlushTimer: ReturnType<typeof setInterval> | null = null;
26
+
27
+ /** Generate a unique event ID */
28
+ function generateId(): string {
29
+ return randomUUID();
30
+ }
31
+
32
+ /** Get today's date as YYYY-MM-DD */
33
+ function today(): string {
34
+ return new Date().toISOString().slice(0, 10);
35
+ }
36
+
37
+ /**
38
+ * AnalyticsCollector records events and provides rollup aggregation.
39
+ * Uses in-memory buffering with periodic flush to avoid I/O overhead.
40
+ */
41
+ export class AnalyticsCollector {
42
+ private opts: Required<AnalyticsOptions>;
43
+ private enabled: boolean;
44
+
45
+ constructor(options: AnalyticsOptions = {}) {
46
+ this.opts = { ...DEFAULTS, ...options };
47
+ this.enabled = true;
48
+ this.startFlushTimer();
49
+ }
50
+
51
+ /** Disable collection */
52
+ disable(): void {
53
+ this.enabled = false;
54
+ this.stopFlushTimer();
55
+ }
56
+
57
+ /** Enable collection */
58
+ enable(): void {
59
+ this.enabled = true;
60
+ this.startFlushTimer();
61
+ }
62
+
63
+ /** Record an analytics event */
64
+ record(
65
+ type: AnalyticsEventType,
66
+ metadata?: Record<string, unknown>,
67
+ ): AnalyticsEvent {
68
+ if (!this.enabled) {
69
+ return {
70
+ id: generateId(),
71
+ type,
72
+ timestamp: Date.now(),
73
+ metadata,
74
+ };
75
+ }
76
+
77
+ const event: AnalyticsEvent = {
78
+ id: generateId(),
79
+ type,
80
+ timestamp: Date.now(),
81
+ metadata: this.sanitizeMetadata(metadata),
82
+ };
83
+
84
+ eventBuffer.push(event);
85
+
86
+ // Auto-flush if buffer is full
87
+ if (eventBuffer.length >= this.opts.maxEvents) {
88
+ this.flush().catch(() => {
89
+ // Best-effort flush
90
+ });
91
+ }
92
+
93
+ return event;
94
+ }
95
+
96
+ /** Record a command execution */
97
+ recordCommand(
98
+ command: string,
99
+ module: string,
100
+ durationMs: number,
101
+ success: boolean,
102
+ ): AnalyticsEvent {
103
+ return this.record("command_run", {
104
+ command,
105
+ module,
106
+ durationMs,
107
+ success,
108
+ });
109
+ }
110
+
111
+ /** Record a tool call */
112
+ recordTool(
113
+ tool: string,
114
+ module: string,
115
+ durationMs: number,
116
+ success: boolean,
117
+ ): AnalyticsEvent {
118
+ return this.record("tool_call", {
119
+ tool,
120
+ module,
121
+ durationMs,
122
+ success,
123
+ });
124
+ }
125
+
126
+ /** Record an error */
127
+ recordError(
128
+ module: string,
129
+ errorType: string,
130
+ message?: string,
131
+ ): AnalyticsEvent {
132
+ return this.record("error", {
133
+ module,
134
+ errorType,
135
+ message: message ? this.sanitizeString(message) : undefined,
136
+ });
137
+ }
138
+
139
+ /** Record a module load */
140
+ recordModuleLoad(module: string, version: string): AnalyticsEvent {
141
+ return this.record("module_load", { module, version });
142
+ }
143
+
144
+ /** Record a search operation */
145
+ recordSearch(
146
+ module: string,
147
+ query: string,
148
+ resultCount: number,
149
+ ): AnalyticsEvent {
150
+ return this.record("search", {
151
+ module,
152
+ query: this.sanitizeString(query),
153
+ resultCount,
154
+ });
155
+ }
156
+
157
+ /** Get all buffered events */
158
+ getEvents(): readonly AnalyticsEvent[] {
159
+ return eventBuffer;
160
+ }
161
+
162
+ /** Get events filtered by type */
163
+ getEventsByType(type: AnalyticsEventType): AnalyticsEvent[] {
164
+ return eventBuffer.filter((e) => e.type === type);
165
+ }
166
+
167
+ /** Compute daily rollup from buffered events */
168
+ getRollup(date?: string): AnalyticsRollup {
169
+ const targetDate = date ?? today();
170
+ const dayStart = new Date(targetDate).getTime();
171
+ const dayEnd = dayStart + 24 * 60 * 60 * 1000;
172
+
173
+ const dayEvents = eventBuffer.filter(
174
+ (e) => e.timestamp >= dayStart && e.timestamp < dayEnd,
175
+ );
176
+
177
+ const events: Record<AnalyticsEventType, number> = {
178
+ module_load: 0,
179
+ command_run: 0,
180
+ tool_call: 0,
181
+ error: 0,
182
+ compaction: 0,
183
+ search: 0,
184
+ };
185
+
186
+ let totalDurationMs = 0;
187
+ let errorCount = 0;
188
+
189
+ for (const e of dayEvents) {
190
+ events[e.type]++;
191
+ if (e.metadata?.durationMs) {
192
+ totalDurationMs += Number(e.metadata.durationMs);
193
+ }
194
+ if (e.type === "error" || e.metadata?.success === false) {
195
+ errorCount++;
196
+ }
197
+ }
198
+
199
+ return {
200
+ date: targetDate,
201
+ events,
202
+ totalDurationMs,
203
+ errorCount,
204
+ };
205
+ }
206
+
207
+ /** Export all events to JSON */
208
+ exportToJSON(): string {
209
+ return JSON.stringify(eventBuffer, null, 2);
210
+ }
211
+
212
+ /** Flush buffered events to storage (placeholder for future SQLite persistence) */
213
+ async flush(): Promise<number> {
214
+ const count = eventBuffer.length;
215
+ // TODO: persist to SQLite when sqlite3 is available
216
+ // For now, keep in memory but trim if over max
217
+ if (count > this.opts.maxEvents) {
218
+ eventBuffer.splice(0, count - this.opts.maxEvents);
219
+ }
220
+ return count;
221
+ }
222
+
223
+ /** Clear all buffered events */
224
+ clear(): void {
225
+ eventBuffer.length = 0;
226
+ }
227
+
228
+ // ─── Private ───────────────────────────────────────────────────────────────
229
+
230
+ private startFlushTimer(): void {
231
+ if (bufferFlushTimer) return;
232
+ bufferFlushTimer = setInterval(() => {
233
+ this.flush().catch(() => {
234
+ // Best-effort
235
+ });
236
+ }, 60000); // Flush every minute
237
+ if (bufferFlushTimer.unref) {
238
+ bufferFlushTimer.unref();
239
+ }
240
+ }
241
+
242
+ private stopFlushTimer(): void {
243
+ if (bufferFlushTimer) {
244
+ clearInterval(bufferFlushTimer);
245
+ bufferFlushTimer = null;
246
+ }
247
+ }
248
+
249
+ /** Sensitive key patterns to redact */
250
+ private static SENSITIVE_KEYS =
251
+ /api[_-]?key|apiKey|token|password|secret|auth|credential|private[_-]?key/i;
252
+
253
+ /** Remove sensitive data from metadata */
254
+ private sanitizeMetadata(
255
+ metadata?: Record<string, unknown>,
256
+ ): Record<string, unknown> | undefined {
257
+ if (!metadata) return undefined;
258
+ const sanitized: Record<string, unknown> = {};
259
+ for (const [key, value] of Object.entries(metadata)) {
260
+ if (AnalyticsCollector.SENSITIVE_KEYS.test(key)) {
261
+ sanitized[key] = "[REDACTED]";
262
+ } else if (typeof value === "string") {
263
+ sanitized[key] = this.sanitizeString(value);
264
+ } else {
265
+ sanitized[key] = value;
266
+ }
267
+ }
268
+ return sanitized;
269
+ }
270
+
271
+ /** Truncate long strings, remove potential secrets */
272
+ private sanitizeString(str: string): string {
273
+ // Truncate to 500 chars
274
+ let result = str.length > 500 ? str.slice(0, 500) + "…" : str;
275
+ // Redact common secret patterns in strings
276
+ result = result.replace(
277
+ /(api[_-]?key|apiKey|token|password|secret|auth)\s*[:=]\s*\S+/gi,
278
+ "$1: [REDACTED]",
279
+ );
280
+ return result;
281
+ }
282
+ }
283
+
284
+ /** Global singleton */
285
+ let globalCollector: AnalyticsCollector | null = null;
286
+
287
+ /** Get or create the global analytics collector */
288
+ export function getAnalyticsCollector(options?: AnalyticsOptions): AnalyticsCollector {
289
+ if (!globalCollector) {
290
+ globalCollector = new AnalyticsCollector(options);
291
+ }
292
+ return globalCollector;
293
+ }