@pi-unipi/unipi 2.4.2 → 2.6.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/CHANGELOG.md +49 -0
- package/README.md +2 -0
- package/docs/prefix-cache-architecture.md +89 -0
- package/package.json +24 -22
- package/packages/ask-user/package.json +2 -2
- package/packages/autocomplete/package.json +1 -1
- package/packages/autocomplete/src/constants.ts +2 -0
- package/packages/btw/package.json +2 -2
- package/packages/cocoindex/README.md +2 -1
- package/packages/cocoindex/index.ts +6 -13
- package/packages/cocoindex/package.json +4 -3
- package/packages/cocoindex/tools.ts +45 -11
- package/packages/compactor/README.md +4 -2
- package/packages/compactor/package.json +3 -3
- package/packages/compactor/src/session/snapshot.ts +3 -2
- package/packages/compactor/src/tools/register.ts +6 -2
- package/packages/compactor/src/tools/vcc-recall.ts +18 -3
- package/packages/core/bounded-output.ts +106 -0
- package/packages/core/constants.ts +2 -0
- package/packages/core/index.ts +1 -0
- package/packages/core/package.json +1 -1
- package/packages/core/sandbox.ts +7 -6
- package/packages/footer/package.json +2 -2
- package/packages/image/package.json +2 -2
- package/packages/info-screen/package.json +2 -2
- package/packages/input-shortcuts/package.json +2 -2
- package/packages/kanboard/package.json +2 -2
- package/packages/mcp/README.md +13 -0
- package/packages/mcp/package.json +5 -2
- package/packages/mcp/src/bridge/registry.ts +244 -137
- package/packages/mcp/src/bridge/translator.ts +96 -21
- package/packages/mcp/src/index.ts +43 -67
- package/packages/mcp/src/tui/settings-overlay.ts +3 -9
- package/packages/memory/README.md +15 -11
- package/packages/memory/bridge/mempalace_bridge.py +636 -0
- package/packages/memory/index.ts +56 -34
- package/packages/memory/mempalace.ts +169 -18
- package/packages/memory/package.json +3 -3
- package/packages/memory/storage.ts +27 -10
- package/packages/milestone/README.md +11 -3
- package/packages/milestone/hooks.ts +103 -29
- package/packages/milestone/index.ts +1 -1
- package/packages/milestone/package.json +5 -2
- package/packages/notify/package.json +2 -2
- package/packages/ralph/index.ts +12 -18
- package/packages/ralph/package.json +6 -3
- package/packages/ralph/reminder.ts +40 -0
- package/packages/ralph/tools.ts +5 -1
- package/packages/subagents/README.md +2 -0
- package/packages/subagents/package.json +1 -1
- package/packages/subagents/src/agent-manager.ts +5 -1
- package/packages/subagents/src/agent-runner.ts +2 -2
- package/packages/subagents/src/core-compat.ts +73 -0
- package/packages/subagents/src/custom-agents.ts +10 -2
- package/packages/subagents/src/index.ts +14 -3
- package/packages/subagents/src/types.ts +2 -0
- package/packages/unipi/bundled.js +1445 -686
- package/packages/updater/package.json +2 -2
- package/packages/utility/README.md +9 -0
- package/packages/utility/package.json +2 -2
- package/packages/utility/src/index.ts +48 -0
- package/packages/utility/src/lifecycle/cleanup.ts +29 -0
- package/packages/utility/src/prefix-cache.ts +263 -0
- package/packages/utility/src/types.ts +1 -1
- package/packages/web-api/package.json +2 -2
- package/packages/workflow/README.md +8 -0
- package/packages/workflow/commands.ts +16 -26
- package/packages/workflow/index.ts +165 -85
- package/packages/workflow/package.json +5 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/updater",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "Auto-updater, changelog browser, and readme browser for Unipi — checks npm registry, renders CHANGELOG.md and README.md files in TUI overlays",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"access": "public"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@pi-unipi/core": "2.
|
|
37
|
+
"@pi-unipi/core": "2.6.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"@earendil-works/pi-coding-agent": "^0.80.0",
|
|
@@ -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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/utility",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
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.
|
|
39
|
+
"@pi-unipi/core": "2.6.0",
|
|
40
40
|
"diff": "^7.0.0",
|
|
41
41
|
"shiki": "^4.0.2"
|
|
42
42
|
},
|
|
@@ -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
|
+
}
|
|
@@ -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) */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/web-api",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "Web search, read, and summarize tools with provider-based backend selection for Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"README.md"
|
|
32
32
|
],
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@pi-unipi/core": "2.
|
|
34
|
+
"@pi-unipi/core": "2.6.0",
|
|
35
35
|
"defuddle": "^0.18.1",
|
|
36
36
|
"linkedom": "^0.18.12",
|
|
37
37
|
"lodash": "^4.17.21",
|
|
@@ -128,6 +128,14 @@ Skills define:
|
|
|
128
128
|
|
|
129
129
|
The agent reads the skill, executes the steps, and produces artifacts in the `.unipi/` directory.
|
|
130
130
|
|
|
131
|
+
## Workflow Sandbox
|
|
132
|
+
|
|
133
|
+
Each workflow activates a command-specific sandbox for the duration of its agent run. The sandbox blocks disallowed tool calls by name (for example, `write`, `edit`, or `bash` in read-only workflows) without changing Pi's active tool list. Keeping the provider tool schemas and their order unchanged makes the workflow prefix cache-stable.
|
|
134
|
+
|
|
135
|
+
Sandbox instructions are stored as hidden, append-only `unipi-workflow-sandbox-snapshot` messages rather than being injected into the system prompt. An active snapshot explicitly supersedes older snapshots. After the workflow completes at `agent_end`, the next agent start appends an inactive snapshot when needed so stale restrictions no longer apply. Sessions that have never activated a workflow do not receive a marker.
|
|
136
|
+
|
|
137
|
+
The sandbox preserves the existing command-level semantics; skill instructions still define narrower write locations and setup-only shell usage where applicable.
|
|
138
|
+
|
|
131
139
|
## Directory Structure
|
|
132
140
|
|
|
133
141
|
```
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { readFileSync, readdirSync, existsSync, statSync } from "fs";
|
|
10
10
|
import { join, basename } from "path";
|
|
11
|
-
import { UNIPI_PREFIX, WORKFLOW_COMMANDS,
|
|
11
|
+
import { UNIPI_PREFIX, WORKFLOW_COMMANDS, type UnipiWorkflowEvent } from "@pi-unipi/core";
|
|
12
12
|
|
|
13
13
|
type CompletionItem = { value: string; label: string; description: string };
|
|
14
14
|
|
|
@@ -16,14 +16,10 @@ type CompletionItem = { value: string; label: string; description: string };
|
|
|
16
16
|
export interface WorkflowCommandOptions {
|
|
17
17
|
/** Check if ralph module is detected */
|
|
18
18
|
isRalphDetected: () => boolean;
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
/** Save tools for later restore */
|
|
24
|
-
saveTools: (tools: string[]) => void;
|
|
25
|
-
/** Begin one workflow lifecycle; false means another workflow is active. */
|
|
26
|
-
startWorkflow: (event: UnipiWorkflowEvent) => boolean;
|
|
19
|
+
/** Begin one workflow lifecycle and activate its sandbox. */
|
|
20
|
+
activateSandbox: (event: UnipiWorkflowEvent) => boolean;
|
|
21
|
+
/** Roll back lifecycle and sandbox state after dispatch failure. */
|
|
22
|
+
abortWorkflow: () => void;
|
|
27
23
|
}
|
|
28
24
|
|
|
29
25
|
/** Command definition */
|
|
@@ -409,19 +405,12 @@ export function registerWorkflowCommands(
|
|
|
409
405
|
fullCommand: `/${fullCommand}`,
|
|
410
406
|
args: args?.trim() ?? "",
|
|
411
407
|
};
|
|
412
|
-
if (!options.
|
|
408
|
+
if (!options.activateSandbox(workflowEvent)) {
|
|
413
409
|
if (ctx.hasUI) ctx.ui.notify("Another UniPi workflow is still active", "warning");
|
|
414
410
|
return;
|
|
415
411
|
}
|
|
416
412
|
|
|
417
|
-
//
|
|
418
|
-
const currentTools = options.getActiveTools();
|
|
419
|
-
options.saveTools(currentTools);
|
|
420
|
-
const sandboxTools = getToolsForCommand(cmd.name, currentTools);
|
|
421
|
-
const sandboxLevel = getSandboxLevel(cmd.name);
|
|
422
|
-
options.setActiveTools([...sandboxTools], sandboxLevel);
|
|
423
|
-
|
|
424
|
-
// Load skill content from SKILL.md
|
|
413
|
+
// Load skill content from SKILL.md.
|
|
425
414
|
let skillContent = "";
|
|
426
415
|
try {
|
|
427
416
|
const skillPath = join(
|
|
@@ -431,33 +420,34 @@ export function registerWorkflowCommands(
|
|
|
431
420
|
);
|
|
432
421
|
skillContent = readFileSync(skillPath, "utf-8");
|
|
433
422
|
} catch {
|
|
434
|
-
// Skill file not found — continue without it
|
|
423
|
+
// Skill file not found — continue without it.
|
|
435
424
|
}
|
|
436
425
|
|
|
437
|
-
// Build skill invocation message
|
|
438
426
|
let message = `Execute the ${cmd.skillName} workflow.`;
|
|
439
427
|
|
|
440
|
-
// Add args if provided
|
|
441
428
|
if (args?.trim()) {
|
|
442
429
|
message += `\n\nArguments: ${args.trim()}`;
|
|
443
430
|
}
|
|
444
431
|
|
|
445
|
-
// Add ralph hint if applicable
|
|
446
432
|
if (cmd.ralphHint && options.isRalphDetected()) {
|
|
447
433
|
message += `\n\n💡 ${cmd.ralphHint}`;
|
|
448
434
|
}
|
|
449
435
|
|
|
450
|
-
// Inject skill content as context
|
|
451
436
|
if (skillContent) {
|
|
452
437
|
message += `\n\n<skill_content>\n${skillContent}\n</skill_content>`;
|
|
453
438
|
}
|
|
454
439
|
|
|
455
|
-
//
|
|
456
|
-
|
|
440
|
+
// Any synchronous dispatch failure must not leave a phantom workflow
|
|
441
|
+
// lifecycle or sandbox behind.
|
|
442
|
+
try {
|
|
443
|
+
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
444
|
+
} catch (error) {
|
|
445
|
+
options.abortWorkflow();
|
|
446
|
+
throw error;
|
|
447
|
+
}
|
|
457
448
|
|
|
458
449
|
if (ctx.hasUI) {
|
|
459
450
|
ctx.ui.notify(`Running /${fullCommand}`, "info");
|
|
460
|
-
// Update extension status with active command name
|
|
461
451
|
const ralphStatus = options.isRalphDetected() ? "✓ rl" : "○ rl";
|
|
462
452
|
ctx.ui.setStatus("unipi-workflow", `⚡ wf:${cmd.name} ${ralphStatus}`);
|
|
463
453
|
}
|