@pi-unipi/utility 2.5.0 → 2.6.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
@@ -16,6 +16,7 @@ The diff rendering is the standout feature — Shiki-powered syntax-highlighted
16
16
  | `/unipi:name-badge` | Toggle name badge overlay |
17
17
  | `/unipi:badge-gen` | Generate session name via LLM and enable badge |
18
18
  | `/unipi:util-settings` | Unified settings for badge and diff rendering |
19
+ | `/unipi:prefix-cache` | Show privacy-safe request-prefix transitions and provider cache token counters |
19
20
 
20
21
  ### Examples
21
22
 
@@ -32,6 +33,14 @@ The diff rendering is the standout feature — Shiki-powered syntax-highlighted
32
33
 
33
34
  Utility registers with the info-screen dashboard, showing module status and diagnostic results. The footer subscribes to utility events for its extension status segment.
34
35
 
36
+ ## Provider prefix-cache diagnostics
37
+
38
+ `/unipi:prefix-cache` observes Pi's provider-native request payloads without modifying them. It reports session-local cache epochs, exact structural prefix extensions, retries, request-envelope/history boundaries, and provider-reported `cacheRead`/`cacheWrite` token totals.
39
+
40
+ The fingerprints are keyed HMAC-SHA-256 values using a random in-memory key that is discarded on reload. Raw prompts, messages, tool arguments, tool schemas, and provider payloads are never retained or logged by this diagnostic. Fingerprints therefore cannot be compared across process lifetimes. A reported prefix extension means the request is structurally eligible for reuse; provider TTL, routing, and cache policy still decide whether a hit occurs.
41
+
42
+ `/unipi:cleanup` includes private `~/.unipi/tool-results/` artifacts in the normal temporary-file retention policy (7 days by default). Use dry-run mode to review candidates before deletion.
43
+
35
44
  The diff rendering feature wraps Pi's built-in `write` and `edit` tools. When enabled, these tools show syntax-highlighted diffs instead of plain output. This is a transparent replacement — the agent doesn't need to know about it.
36
45
 
37
46
  ## Agent Tools
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/utility",
3
- "version": "2.5.0",
3
+ "version": "2.6.1",
4
4
  "description": "Utility commands and tools for Pi coding agent — lifecycle, diagnostics, cache, analytics, display, batch execution",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -36,7 +36,7 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
- "@pi-unipi/core": "2.5.0",
39
+ "@pi-unipi/core": "2.6.1",
40
40
  "diff": "^7.0.0",
41
41
  "shiki": "^4.0.2"
42
42
  },
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ import { registerEnhancedWriteTool, registerEnhancedEditTool } from "./diff/wrap
32
32
  import { getLifecycle } from "./lifecycle/process.js";
33
33
  import { getAnalyticsCollector } from "./analytics/collector.js";
34
34
  import { registerInfoScreen } from "./info-screen.js";
35
+ import { PrefixCacheTracker, formatPrefixCacheStats } from "./prefix-cache.js";
35
36
 
36
37
  /** Re-export readBadgeSettings for cross-package use */
37
38
  export { readBadgeSettings } from "./tui/badge-settings.js";
@@ -61,6 +62,7 @@ const ALL_COMMANDS = [
61
62
  UTILITY_COMMANDS.BADGE_TOGGLE,
62
63
  UTILITY_COMMANDS.BADGE_SETTINGS,
63
64
  UTILITY_COMMANDS.UTIL_SETTINGS,
65
+ UTILITY_COMMANDS.PREFIX_CACHE,
64
66
  ].map((cmd) => `unipi:${cmd}`);
65
67
 
66
68
  /** All tools registered by this module */
@@ -73,6 +75,52 @@ export default function (pi: ExtensionAPI) {
73
75
  // Initialize analytics collector
74
76
  const analytics = getAnalyticsCollector();
75
77
 
78
+ // Session-local provider prefix observability. The random HMAC key and all
79
+ // payload-derived fingerprints remain in memory and are discarded on reload.
80
+ const prefixCache = new PrefixCacheTracker();
81
+ pi.registerCommand(`unipi:${UTILITY_COMMANDS.PREFIX_CACHE}`, {
82
+ description: "Show privacy-safe provider prefix-cache diagnostics",
83
+ handler: async (_args, ctx) => {
84
+ const report = formatPrefixCacheStats(prefixCache.getSnapshot());
85
+ if (ctx.hasUI) {
86
+ ctx.ui.notify(report, "info");
87
+ } else {
88
+ pi.sendMessage({ customType: "unipi-response", content: report, display: true }, { deliverAs: "followUp" });
89
+ }
90
+ },
91
+ });
92
+
93
+ pi.on("session_start", () => {
94
+ // A replacement/reloaded session has a distinct provider prefix lineage.
95
+ // The extension factory is normally recreated, but reset explicitly so
96
+ // custom hosts that reuse one factory cannot leak counts across sessions.
97
+ prefixCache.reset();
98
+ });
99
+
100
+ pi.on("before_provider_request", (event, ctx) => {
101
+ prefixCache.observeRequest(event.payload, ctx.model);
102
+ // Observe only: never replace or mutate provider payloads.
103
+ });
104
+
105
+ pi.on("agent_end", (event) => {
106
+ prefixCache.observeMessages(event.messages);
107
+ });
108
+
109
+ pi.on("model_select", (event) => {
110
+ if (event.previousModel && (
111
+ event.previousModel.provider !== event.model.provider ||
112
+ event.previousModel.id !== event.model.id ||
113
+ event.previousModel.api !== event.model.api
114
+ )) {
115
+ prefixCache.markBoundary("envelope_changed");
116
+ }
117
+ });
118
+ pi.on("thinking_level_select", (event) => {
119
+ if (event.previousLevel !== event.level) prefixCache.markBoundary("envelope_changed");
120
+ });
121
+ pi.on("session_compact", () => prefixCache.markBoundary("history_rewritten"));
122
+ pi.on("session_tree", () => prefixCache.markBoundary("history_rewritten"));
123
+
76
124
  // Register cleanup on shutdown
77
125
  lifecycle.registerCleanup(async () => {
78
126
  analytics.disable();
@@ -121,6 +121,34 @@ function cleanDbs(options: Required<CleanupOptions>): CleanupResult {
121
121
  return result;
122
122
  }
123
123
 
124
+ /** Clean private tool-result artifacts after the configured temp retention. */
125
+ function cleanToolResults(options: Required<CleanupOptions>): CleanupResult {
126
+ const result: CleanupResult = {
127
+ category: "tool-results",
128
+ removed: 0,
129
+ bytesFreed: 0,
130
+ paths: [],
131
+ };
132
+ const dir = expandHome("~/.unipi/tool-results");
133
+ if (!existsSync(dir)) return result;
134
+
135
+ let entries: string[];
136
+ try { entries = readdirSync(dir); } catch { return result; }
137
+ for (const entry of entries) {
138
+ if (!/^(?:tool-result|mcp-[a-zA-Z0-9_-]+|helper)-[a-f0-9-]+\.txt$/.test(entry)) continue;
139
+ const path = join(dir, entry);
140
+ try {
141
+ const stats = statSync(path);
142
+ if (!stats.isFile() || !isStale(path, options.tempMaxAgeDays)) continue;
143
+ result.paths.push(path);
144
+ result.bytesFreed += stats.size;
145
+ if (!options.dryRun) unlinkSync(path);
146
+ result.removed++;
147
+ } catch { /* best effort */ }
148
+ }
149
+ return result;
150
+ }
151
+
124
152
  /** Clean temp files matching unipi patterns */
125
153
  function cleanTemps(options: Required<CleanupOptions>): CleanupResult {
126
154
  const result: CleanupResult = {
@@ -288,6 +316,7 @@ export function cleanupStale(options: CleanupOptions = {}): CleanupReport {
288
316
  const results: CleanupResult[] = [
289
317
  cleanDbs(opts),
290
318
  cleanTemps(opts),
319
+ cleanToolResults(opts),
291
320
  cleanSessions(opts),
292
321
  cleanCache(opts),
293
322
  ];
@@ -0,0 +1,263 @@
1
+ import { createHmac, randomBytes } from "node:crypto";
2
+
3
+ export type PrefixTransition =
4
+ | "first_request"
5
+ | "prefix_extended"
6
+ | "identical_retry"
7
+ | "envelope_changed"
8
+ | "history_rewritten"
9
+ | "payload_shape_changed";
10
+
11
+ export interface PrefixCacheSnapshot {
12
+ epoch: number;
13
+ requests: number;
14
+ prefixExtensions: number;
15
+ identicalRetries: number;
16
+ boundaries: number;
17
+ lastTransition: PrefixTransition | "none";
18
+ requestFingerprint: string;
19
+ envelopeFingerprint: string;
20
+ sequenceField: string;
21
+ sequenceItems: number;
22
+ route: string;
23
+ usage: {
24
+ input: number;
25
+ output: number;
26
+ cacheRead: number;
27
+ cacheWrite: number;
28
+ responses: number;
29
+ };
30
+ }
31
+
32
+ interface ObservedRequest {
33
+ envelopeFingerprint: string;
34
+ requestFingerprint: string;
35
+ sequenceField: string;
36
+ sequenceItemFingerprints: string[];
37
+ }
38
+
39
+ const EMPTY_SNAPSHOT: PrefixCacheSnapshot = {
40
+ epoch: 0,
41
+ requests: 0,
42
+ prefixExtensions: 0,
43
+ identicalRetries: 0,
44
+ boundaries: 0,
45
+ lastTransition: "none",
46
+ requestFingerprint: "none",
47
+ envelopeFingerprint: "none",
48
+ sequenceField: "none",
49
+ sequenceItems: 0,
50
+ route: "unknown",
51
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, responses: 0 },
52
+ };
53
+
54
+ function canonicalize(value: unknown, seen = new WeakSet<object>()): unknown {
55
+ if (value === undefined) return { $type: "undefined" };
56
+ if (typeof value === "bigint") return { $type: "bigint", value: value.toString() };
57
+ if (typeof value === "number" && !Number.isFinite(value)) {
58
+ return { $type: "number", value: String(value) };
59
+ }
60
+ if (typeof value !== "object" || value === null) return value;
61
+ if (seen.has(value)) return { $type: "cycle" };
62
+ seen.add(value);
63
+
64
+ if (Array.isArray(value)) {
65
+ const result = value.map((item) => canonicalize(item, seen));
66
+ seen.delete(value);
67
+ return result;
68
+ }
69
+
70
+ if (value instanceof Uint8Array) {
71
+ seen.delete(value);
72
+ return { $type: "bytes", length: value.byteLength };
73
+ }
74
+
75
+ const record = value as Record<string, unknown>;
76
+ const result: Record<string, unknown> = {};
77
+ for (const key of Object.keys(record).sort((a, b) => a < b ? -1 : a > b ? 1 : 0)) {
78
+ result[key] = canonicalize(record[key], seen);
79
+ }
80
+ seen.delete(value);
81
+ return result;
82
+ }
83
+
84
+ function canonicalJson(value: unknown): string {
85
+ return JSON.stringify(canonicalize(value));
86
+ }
87
+
88
+ function routeLabel(model: unknown): string {
89
+ if (!model || typeof model !== "object") return "unknown";
90
+ const value = model as Record<string, unknown>;
91
+ return [value.provider, value.id, value.api]
92
+ .filter((part): part is string => typeof part === "string" && part.length > 0)
93
+ .join("/") || "unknown";
94
+ }
95
+
96
+ function findSequence(payload: Record<string, unknown>): { field: string; items: unknown[] } {
97
+ for (const field of ["messages", "input", "contents"] as const) {
98
+ if (Array.isArray(payload[field])) return { field, items: payload[field] as unknown[] };
99
+ }
100
+ return { field: "none", items: [] };
101
+ }
102
+
103
+ function isPrefix(previous: string[], current: string[]): boolean {
104
+ return previous.length <= current.length && previous.every((item, index) => current[index] === item);
105
+ }
106
+
107
+ function usageNumber(value: unknown): number {
108
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
109
+ }
110
+
111
+ /**
112
+ * Session-local, privacy-safe prefix observability.
113
+ *
114
+ * Prompt/tool bytes are only canonicalized transiently. The tracker retains
115
+ * keyed HMACs and counters, never raw provider payloads. Its random key is not
116
+ * persisted, so fingerprints cannot be correlated across process lifetimes or
117
+ * used as a precomputed dictionary oracle.
118
+ */
119
+ export class PrefixCacheTracker {
120
+ private readonly key: Uint8Array;
121
+ private previous: ObservedRequest | null = null;
122
+ private seenResponses = new WeakSet<object>();
123
+ private snapshot: PrefixCacheSnapshot = structuredClone(EMPTY_SNAPSHOT);
124
+
125
+ constructor(key: Uint8Array = randomBytes(32)) {
126
+ this.key = key;
127
+ }
128
+
129
+ reset(): void {
130
+ this.previous = null;
131
+ this.seenResponses = new WeakSet<object>();
132
+ this.snapshot = structuredClone(EMPTY_SNAPSHOT);
133
+ }
134
+
135
+ private fingerprint(value: unknown): string {
136
+ return createHmac("sha256", this.key).update(canonicalJson(value)).digest("hex").slice(0, 16);
137
+ }
138
+
139
+ observeRequest(payload: unknown, model?: unknown): PrefixCacheSnapshot {
140
+ const object = payload && typeof payload === "object" && !Array.isArray(payload)
141
+ ? payload as Record<string, unknown>
142
+ : { payload };
143
+ const { field, items } = findSequence(object);
144
+ const route = routeLabel(model);
145
+ const envelope: Record<string, unknown> = { route };
146
+ for (const [key, value] of Object.entries(object)) {
147
+ if (key !== field) envelope[key] = value;
148
+ }
149
+
150
+ const current: ObservedRequest = {
151
+ envelopeFingerprint: this.fingerprint(envelope),
152
+ requestFingerprint: this.fingerprint(object),
153
+ sequenceField: field,
154
+ sequenceItemFingerprints: items.map((item) => this.fingerprint(item)),
155
+ };
156
+
157
+ let transition: PrefixTransition;
158
+ let epoch = this.snapshot.epoch;
159
+ if (!this.previous) {
160
+ transition = "first_request";
161
+ epoch = Math.max(1, epoch);
162
+ } else if (
163
+ current.envelopeFingerprint !== this.previous.envelopeFingerprint
164
+ ) {
165
+ transition = "envelope_changed";
166
+ epoch++;
167
+ } else if (current.sequenceField !== this.previous.sequenceField) {
168
+ transition = "payload_shape_changed";
169
+ epoch++;
170
+ } else if (current.requestFingerprint === this.previous.requestFingerprint) {
171
+ transition = "identical_retry";
172
+ } else if (isPrefix(this.previous.sequenceItemFingerprints, current.sequenceItemFingerprints)) {
173
+ transition = "prefix_extended";
174
+ } else {
175
+ transition = "history_rewritten";
176
+ epoch++;
177
+ }
178
+
179
+ this.snapshot = {
180
+ ...this.snapshot,
181
+ epoch,
182
+ requests: this.snapshot.requests + 1,
183
+ prefixExtensions: this.snapshot.prefixExtensions + (transition === "prefix_extended" ? 1 : 0),
184
+ identicalRetries: this.snapshot.identicalRetries + (transition === "identical_retry" ? 1 : 0),
185
+ boundaries: this.snapshot.boundaries + (
186
+ transition === "envelope_changed" || transition === "history_rewritten" || transition === "payload_shape_changed"
187
+ ? 1
188
+ : 0
189
+ ),
190
+ lastTransition: transition,
191
+ requestFingerprint: current.requestFingerprint,
192
+ envelopeFingerprint: current.envelopeFingerprint,
193
+ sequenceField: current.sequenceField,
194
+ sequenceItems: current.sequenceItemFingerprints.length,
195
+ route,
196
+ };
197
+ this.previous = current;
198
+ return this.getSnapshot();
199
+ }
200
+
201
+ observeMessages(messages: unknown[]): PrefixCacheSnapshot {
202
+ for (const message of messages) {
203
+ if (!message || typeof message !== "object") continue;
204
+ const value = message as Record<string, unknown>;
205
+ if (value.role !== "assistant" || !value.usage || typeof value.usage !== "object") continue;
206
+ if (value.stopReason === "error" || value.stopReason === "aborted") continue;
207
+
208
+ // agent_end can expose the whole active context repeatedly. Deduplicate
209
+ // exact message objects by identity; two genuinely distinct provider
210
+ // responses with identical content/usage must still both count.
211
+ if (this.seenResponses.has(message as object)) continue;
212
+ this.seenResponses.add(message as object);
213
+
214
+ const usage = value.usage as Record<string, unknown>;
215
+ this.snapshot.usage.input += usageNumber(usage.input);
216
+ this.snapshot.usage.output += usageNumber(usage.output);
217
+ this.snapshot.usage.cacheRead += usageNumber(usage.cacheRead);
218
+ this.snapshot.usage.cacheWrite += usageNumber(usage.cacheWrite);
219
+ this.snapshot.usage.responses++;
220
+ }
221
+ return this.getSnapshot();
222
+ }
223
+
224
+ markBoundary(transition: Extract<PrefixTransition, "history_rewritten" | "envelope_changed">): void {
225
+ // Lifecycle events can identify a boundary before the next payload arrives.
226
+ // Drop the comparison baseline so the next request starts a fresh observed
227
+ // epoch rather than being misclassified against pre-boundary history.
228
+ if (this.previous) {
229
+ this.snapshot.epoch++;
230
+ this.snapshot.boundaries++;
231
+ }
232
+ this.snapshot.lastTransition = transition;
233
+ this.previous = null;
234
+ }
235
+
236
+ getSnapshot(): PrefixCacheSnapshot {
237
+ return structuredClone(this.snapshot);
238
+ }
239
+ }
240
+
241
+ export function formatPrefixCacheStats(snapshot: PrefixCacheSnapshot): string {
242
+ const usage = snapshot.usage;
243
+ const totalPrompt = usage.input + usage.cacheRead + usage.cacheWrite;
244
+ const readRate = totalPrompt > 0 ? `${((usage.cacheRead / totalPrompt) * 100).toFixed(1)}%` : "n/a";
245
+ return [
246
+ "## Provider Prefix Cache",
247
+ "",
248
+ `- Epoch: ${snapshot.epoch || "not observed"}`,
249
+ `- Requests observed: ${snapshot.requests}`,
250
+ `- Prefix extensions: ${snapshot.prefixExtensions}`,
251
+ `- Explicit boundaries: ${snapshot.boundaries}`,
252
+ `- Last transition: ${snapshot.lastTransition}`,
253
+ `- Route: ${snapshot.route}`,
254
+ `- Request fingerprint: ${snapshot.requestFingerprint}`,
255
+ `- Envelope fingerprint: ${snapshot.envelopeFingerprint}`,
256
+ `- Provider cache read: ${usage.cacheRead} tokens`,
257
+ `- Provider cache write: ${usage.cacheWrite} tokens`,
258
+ `- Observed cache-read share: ${readRate}`,
259
+ "",
260
+ "Fingerprints are session-local keyed HMACs. Raw prompts, messages, tool arguments, and payloads are not retained or logged.",
261
+ "A prefix extension indicates structural eligibility only; provider cache retention and routing still determine actual hits.",
262
+ ].join("\n");
263
+ }
package/src/types.ts CHANGED
@@ -227,7 +227,7 @@ export interface EnvironmentInfo {
227
227
  /** Result of a cleanup operation */
228
228
  export interface CleanupResult {
229
229
  /** What was cleaned */
230
- category: "db" | "temp" | "session" | "cache" | "log";
230
+ category: "db" | "temp" | "tool-results" | "session" | "cache" | "log";
231
231
  /** Items removed */
232
232
  removed: number;
233
233
  /** Bytes freed (approximate) */