@pi-unipi/unipi 2.5.0 → 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.
Files changed (57) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +2 -0
  3. package/docs/prefix-cache-architecture.md +89 -0
  4. package/package.json +22 -21
  5. package/packages/ask-user/package.json +2 -2
  6. package/packages/autocomplete/package.json +1 -1
  7. package/packages/autocomplete/src/constants.ts +2 -0
  8. package/packages/btw/package.json +2 -2
  9. package/packages/cocoindex/README.md +2 -1
  10. package/packages/cocoindex/index.ts +6 -13
  11. package/packages/cocoindex/package.json +4 -3
  12. package/packages/cocoindex/tools.ts +45 -11
  13. package/packages/compactor/README.md +4 -2
  14. package/packages/compactor/package.json +3 -3
  15. package/packages/compactor/src/session/snapshot.ts +3 -2
  16. package/packages/compactor/src/tools/register.ts +6 -2
  17. package/packages/compactor/src/tools/vcc-recall.ts +18 -3
  18. package/packages/core/bounded-output.ts +106 -0
  19. package/packages/core/constants.ts +2 -0
  20. package/packages/core/index.ts +1 -0
  21. package/packages/core/package.json +1 -1
  22. package/packages/footer/package.json +2 -2
  23. package/packages/image/package.json +2 -2
  24. package/packages/info-screen/package.json +2 -2
  25. package/packages/input-shortcuts/package.json +2 -2
  26. package/packages/kanboard/package.json +2 -2
  27. package/packages/mcp/README.md +4 -0
  28. package/packages/mcp/package.json +2 -2
  29. package/packages/mcp/src/bridge/translator.ts +19 -12
  30. package/packages/memory/index.ts +56 -34
  31. package/packages/memory/package.json +3 -3
  32. package/packages/milestone/hooks.ts +1 -1
  33. package/packages/milestone/package.json +2 -2
  34. package/packages/notify/package.json +2 -2
  35. package/packages/ralph/index.ts +12 -18
  36. package/packages/ralph/package.json +6 -3
  37. package/packages/ralph/reminder.ts +40 -0
  38. package/packages/ralph/tools.ts +5 -1
  39. package/packages/subagents/README.md +2 -0
  40. package/packages/subagents/package.json +1 -1
  41. package/packages/subagents/src/agent-manager.ts +5 -1
  42. package/packages/subagents/src/agent-runner.ts +2 -2
  43. package/packages/subagents/src/core-compat.ts +73 -0
  44. package/packages/subagents/src/custom-agents.ts +10 -2
  45. package/packages/subagents/src/index.ts +14 -3
  46. package/packages/subagents/src/types.ts +2 -0
  47. package/packages/unipi/bundled.js +786 -312
  48. package/packages/updater/package.json +2 -2
  49. package/packages/utility/README.md +9 -0
  50. package/packages/utility/package.json +2 -2
  51. package/packages/utility/src/index.ts +48 -0
  52. package/packages/utility/src/lifecycle/cleanup.ts +29 -0
  53. package/packages/utility/src/prefix-cache.ts +263 -0
  54. package/packages/utility/src/types.ts +1 -1
  55. package/packages/web-api/package.json +2 -2
  56. package/packages/workflow/index.ts +2 -2
  57. package/packages/workflow/package.json +2 -2
@@ -0,0 +1,106 @@
1
+ import { chmodSync, lstatSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+
6
+ export const DEFAULT_MODEL_OUTPUT_BYTES = 64 * 1024;
7
+ export const MAX_RAW_ARTIFACT_BYTES = 16 * 1024 * 1024;
8
+
9
+ export interface BoundedOutput {
10
+ text: string;
11
+ truncated: boolean;
12
+ originalBytes: number;
13
+ visibleBytes: number;
14
+ artifactPath?: string;
15
+ }
16
+
17
+ export interface BoundOutputOptions {
18
+ maxBytes?: number;
19
+ artifactPrefix?: string;
20
+ artifactDir?: string;
21
+ }
22
+
23
+ function byteSlice(text: string, start: number, end?: number): string {
24
+ return Buffer.from(text, "utf8").subarray(start, end).toString("utf8").replace(/\uFFFD+$/u, "");
25
+ }
26
+
27
+ function secureArtifactDir(customDir?: string): string {
28
+ const dir = customDir ?? join(homedir(), ".unipi", "tool-results");
29
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
30
+ let stat = lstatSync(dir);
31
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
32
+ throw new Error(`Refusing unsafe tool-result directory: ${dir}`);
33
+ }
34
+ if ((stat.mode & 0o077) !== 0) {
35
+ chmodSync(dir, 0o700);
36
+ stat = lstatSync(dir);
37
+ if ((stat.mode & 0o077) !== 0) {
38
+ throw new Error(`Refusing non-private tool-result directory: ${dir}`);
39
+ }
40
+ }
41
+ return dir;
42
+ }
43
+
44
+ function safePrefix(prefix: string): string {
45
+ const safe = prefix.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").slice(0, 48);
46
+ return safe || "tool-result";
47
+ }
48
+
49
+ /**
50
+ * Bound model-visible UTF-8 output and, up to the raw artifact safety cap,
51
+ * preserve the complete text in a private local artifact.
52
+ *
53
+ * Artifacts use random names and mode 0600 beneath a mode-0700 directory. The
54
+ * returned path is intentionally explicit so the agent can retrieve it with
55
+ * the ordinary read tool only when full output is actually needed.
56
+ */
57
+ export function boundModelOutput(text: string, options: BoundOutputOptions = {}): BoundedOutput {
58
+ const maxBytes = Math.max(1024, Math.floor(options.maxBytes ?? DEFAULT_MODEL_OUTPUT_BYTES));
59
+ const originalBytes = Buffer.byteLength(text, "utf8");
60
+ if (originalBytes <= maxBytes) {
61
+ return { text, truncated: false, originalBytes, visibleBytes: originalBytes };
62
+ }
63
+
64
+ let artifactPath: string | undefined;
65
+ let artifactWarning: string | undefined;
66
+ if (originalBytes <= MAX_RAW_ARTIFACT_BYTES) {
67
+ try {
68
+ const dir = secureArtifactDir(options.artifactDir);
69
+ artifactPath = join(dir, `${safePrefix(options.artifactPrefix ?? "tool-result")}-${randomUUID()}.txt`);
70
+ writeFileSync(artifactPath, text, { encoding: "utf8", mode: 0o600, flag: "wx" });
71
+ } catch (error) {
72
+ artifactWarning = `Full-output artifact unavailable: ${error instanceof Error ? error.message : String(error)}`;
73
+ artifactPath = undefined;
74
+ }
75
+ } else {
76
+ artifactWarning = `Full output exceeded the ${MAX_RAW_ARTIFACT_BYTES}-byte local artifact safety cap and was not retained.`;
77
+ }
78
+
79
+ const marker = [
80
+ "",
81
+ "--- output bounded by UniPi ---",
82
+ artifactPath ? `Full output: ${artifactPath}` : artifactWarning!,
83
+ `Original size: ${originalBytes} bytes; model-visible ceiling: ${maxBytes} bytes.`,
84
+ ...(artifactPath ? ["Use the read tool with offset/limit to inspect only the needed region."] : []),
85
+ ].join("\n");
86
+ const omissionReserve = 80;
87
+ const markerBytes = Buffer.byteLength(marker, "utf8");
88
+ const contentBudget = Math.max(1, maxBytes - markerBytes - omissionReserve);
89
+ const headBytes = Math.ceil(contentBudget * 0.75);
90
+ const tailBytes = Math.max(0, contentBudget - headBytes);
91
+ const head = byteSlice(text, 0, headBytes);
92
+ const tail = tailBytes > 0 ? byteSlice(text, originalBytes - tailBytes) : "";
93
+ const omission = `\n… ${Math.max(0, originalBytes - contentBudget)} bytes omitted …\n`;
94
+ let bounded = `${head}${omission}${tail}${marker}`;
95
+ if (Buffer.byteLength(bounded, "utf8") > maxBytes) {
96
+ bounded = byteSlice(bounded, 0, maxBytes);
97
+ }
98
+
99
+ return {
100
+ text: bounded,
101
+ truncated: true,
102
+ originalBytes,
103
+ visibleBytes: Buffer.byteLength(bounded, "utf8"),
104
+ artifactPath,
105
+ };
106
+ }
@@ -168,6 +168,7 @@ export const UTILITY_COMMANDS = {
168
168
  BADGE_TOGGLE: "badge-toggle",
169
169
  BADGE_SETTINGS: "badge-settings",
170
170
  UTIL_SETTINGS: "util-settings",
171
+ PREFIX_CACHE: "prefix-cache",
171
172
  } as const;
172
173
 
173
174
  /** Utility tool names */
@@ -232,6 +233,7 @@ export const MCP_DEFAULTS = {
232
233
  STARTUP_TIMEOUT_MS: 10000,
233
234
  MAX_SERVERS: 20,
234
235
  TOOL_NAME_SEPARATOR: "__",
236
+ MAX_MODEL_OUTPUT_BYTES: 64 * 1024,
235
237
  } as const;
236
238
 
237
239
  /** Compactor sentinel — when passed as customInstructions to ctx.compact(),
@@ -10,3 +10,4 @@ export * from "./sandbox.js";
10
10
  export * from "./utils.js";
11
11
  export * from "./model-cache.js";
12
12
  export * from "./tui-width.js";
13
+ export * from "./bounded-output.js";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/core",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Shared utilities, event types, and constants for Unipi extension suite",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/footer",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Persistent status bar for Unipi — subscribes to UNIPI_EVENTS and renders key stats from all unipi packages",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -32,7 +32,7 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "2.5.0"
35
+ "@pi-unipi/core": "2.6.0"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/image",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Image generation and image recognition tools for the Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pi-unipi/core": "2.5.0"
37
+ "@pi-unipi/core": "2.6.0"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@earendil-works/pi-ai": "^0.80.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/info-screen",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Dashboard and module registry for Unipi — configurable info overlay with tabbed groups",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -33,7 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "2.5.0"
36
+ "@pi-unipi/core": "2.6.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/input-shortcuts",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Keyboard shortcuts for stash/restore, undo/redo, clipboard, and thinking toggle — chord-based overlay system",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -33,7 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "2.5.0"
36
+ "@pi-unipi/core": "2.6.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/kanboard",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Visualization layer for unipi workflow — HTTP server with htmx/Alpine.js UI, modular parsers, TUI overlay, and kanban board",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -39,7 +39,7 @@
39
39
  "access": "public"
40
40
  },
41
41
  "dependencies": {
42
- "@pi-unipi/core": "2.5.0"
42
+ "@pi-unipi/core": "2.6.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -39,6 +39,10 @@ MCP input properties are cloned and recursively canonicalized before registratio
39
39
 
40
40
  Pi 0.80 cannot remove dynamically registered tools. Enabling, disabling, deleting, or changing MCP servers is therefore applied on the next Pi restart rather than mutating the tool list mid-session. This prevents stale schemas and makes the restart an explicit cache-epoch boundary.
41
41
 
42
+ ### Bounded Results
43
+
44
+ MCP text results are model-visible up to a hard 64 KiB ceiling. A larger result keeps a bounded head/tail preview and, when the raw result is at most 16 MiB, writes the complete text to a private mode-0600 artifact under `~/.unipi/tool-results/`. Existing result directories are tightened to mode 0700. The returned result includes the path and directs the agent to use `read` with offset/limit. Results above the raw safety cap or filesystem write failures still return a bounded preview with an explicit non-retention warning. MCP image bytes are not written by this text bridge; image blocks remain represented by MIME metadata as before.
45
+
42
46
  Example tool calls:
43
47
  ```
44
48
  github__search_code({ query: "authentication middleware" })
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/mcp",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "MCP server management extension for Pi coding agent — browse, add, configure, and use MCP servers",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -30,7 +30,7 @@
30
30
  "test": "npx tsx --test tests/**/*.test.ts"
31
31
  },
32
32
  "dependencies": {
33
- "@pi-unipi/core": "2.5.0"
33
+ "@pi-unipi/core": "2.6.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -5,7 +5,7 @@
5
5
  * Naming convention: {serverName}__{toolName}
6
6
  */
7
7
 
8
- import { MCP_DEFAULTS } from "@pi-unipi/core";
8
+ import { MCP_DEFAULTS, boundModelOutput } from "@pi-unipi/core";
9
9
  import type { McpTool, McpToolResult } from "../types.js";
10
10
  import type { McpClient } from "./client.js";
11
11
 
@@ -170,21 +170,28 @@ export function translateMcpTool(
170
170
  }
171
171
  }
172
172
 
173
- if (result.isError) {
174
- const errorText = blocks.map((b) => b.text).join("\n") || "Unknown error";
175
- return {
176
- content: [{ type: "text", text: `MCP tool error from ${serverName}: ${errorText}` }],
177
- details: { error: true, server: serverName, tool: mcpTool.name },
178
- };
179
- }
180
-
181
173
  if (blocks.length === 0) {
182
- blocks.push({ type: "text", text: "(no output)" });
174
+ blocks.push({ type: "text", text: result.isError ? "Unknown error" : "(no output)" });
183
175
  }
184
176
 
177
+ const rawText = blocks.map((block) => block.text).join("\n");
178
+ const wrapper = result.isError ? `MCP tool error from ${serverName}: ` : "";
179
+ const output = boundModelOutput(rawText, {
180
+ maxBytes: Math.max(1024, MCP_DEFAULTS.MAX_MODEL_OUTPUT_BYTES - Buffer.byteLength(wrapper, "utf8")),
181
+ artifactPrefix: `mcp-${serverName}-${mcpTool.name}`,
182
+ });
183
+ const visibleText = `${wrapper}${output.text}`;
184
+
185
185
  return {
186
- content: blocks,
187
- details: { server: serverName, tool: mcpTool.name },
186
+ content: [{ type: "text", text: visibleText }],
187
+ details: {
188
+ error: result.isError || undefined,
189
+ server: serverName,
190
+ tool: mcpTool.name,
191
+ truncated: output.truncated,
192
+ originalBytes: output.originalBytes,
193
+ artifactPath: output.artifactPath,
194
+ },
188
195
  };
189
196
  } catch (err) {
190
197
  const message = err instanceof Error ? err.message : String(err);
@@ -35,6 +35,54 @@ import { isEmbeddingReady, hasModelChanged } from "./settings.js";
35
35
  /** Package version */
36
36
  const VERSION = getPackageVersion(dirname(fileURLToPath(import.meta.url)));
37
37
 
38
+ interface MemoryReminderInput {
39
+ projectName: string;
40
+ memories: Array<{ title: string }>;
41
+ canSearch: boolean;
42
+ canStore: boolean;
43
+ }
44
+
45
+ /**
46
+ * Build the deterministic, model-visible first-turn memory reminder.
47
+ * Exported so provider-payload regressions can exercise the exact production
48
+ * content without initializing a storage backend.
49
+ */
50
+ export function buildMemoryRecallReminder(input: MemoryReminderInput): string {
51
+ const lines = [
52
+ "## 🧠 Memory System Active",
53
+ "",
54
+ `You have ${input.memories.length} memories stored for project "${input.projectName}".`,
55
+ ];
56
+
57
+ if (input.canSearch && input.memories.length > 0) {
58
+ const titleList = input.memories.slice(0, 20).map((memory) => `- ${memory.title}`).join("\n");
59
+ const extra = input.memories.length > 20
60
+ ? `\n... and ${input.memories.length - 20} more`
61
+ : "";
62
+ lines.push(
63
+ "**BEFORE starting work**, call `memory_search` with relevant keywords to check for existing context.",
64
+ "",
65
+ "Available memories:",
66
+ titleList + extra,
67
+ );
68
+ }
69
+
70
+ if (input.canStore) {
71
+ lines.push(
72
+ "",
73
+ "**AFTER completing the task**, if you learned something non-obvious,",
74
+ "call `memory_store` to save it for future sessions.",
75
+ );
76
+ }
77
+
78
+ lines.push(
79
+ "",
80
+ "Guardrails: read max 10 memory results per search. Update existing memories instead of creating duplicates.",
81
+ );
82
+
83
+ return lines.join("\n");
84
+ }
85
+
38
86
  /** Storage instance for current project */
39
87
  let projectStorage: MemoryStorage | null = null;
40
88
 
@@ -251,44 +299,18 @@ export default function (pi: ExtensionAPI) {
251
299
  return;
252
300
  }
253
301
 
254
- const lines = [
255
- "## 🧠 Memory System Active",
256
- "",
257
- `You have ${projectMemories.length} memories stored for project "${projectName}".`,
258
- ];
259
-
260
- if (canSearch && projectMemories.length > 0) {
261
- const titleList = projectMemories.slice(0, 20).map(m => `- ${m.title}`).join("\n");
262
- const extra = projectMemories.length > 20 ? `\n... and ${projectMemories.length - 20} more` : "";
263
- lines.push(
264
- "**BEFORE starting work**, call `memory_search` with relevant keywords to check for existing context.",
265
- "",
266
- "Available memories:",
267
- titleList + extra,
268
- );
269
- } else {
270
- recallDone = true;
271
- }
272
-
273
- if (canStore) {
274
- lines.push(
275
- "",
276
- "**AFTER completing the task**, if you learned something non-obvious,",
277
- "call `memory_store` to save it for future sessions.",
278
- );
279
- } else {
280
- storeDone = true;
281
- }
282
-
283
- lines.push(
284
- "",
285
- "Guardrails: read max 10 memory results per search. Update existing memories instead of creating duplicates.",
286
- );
302
+ if (!canSearch || projectMemories.length === 0) recallDone = true;
303
+ if (!canStore) storeDone = true;
287
304
 
288
305
  return {
289
306
  message: {
290
307
  customType: "unipi-memory-recall-reminder",
291
- content: lines.join("\n"),
308
+ content: buildMemoryRecallReminder({
309
+ projectName,
310
+ memories: projectMemories,
311
+ canSearch,
312
+ canStore,
313
+ }),
292
314
  display: false,
293
315
  },
294
316
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/memory",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Persistent cross-session memory with MemPalace backend (auto-installed) and SQLite fallback for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -43,8 +43,8 @@
43
43
  "better-sqlite3": "^12.9.0",
44
44
  "sqlite-vec": "^0.1.9",
45
45
  "js-yaml": "^4.1.0",
46
- "@pi-unipi/core": "2.5.0",
47
- "@pi-unipi/info-screen": "2.5.0"
46
+ "@pi-unipi/core": "2.6.0",
47
+ "@pi-unipi/info-screen": "2.6.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -51,7 +51,7 @@ function formatMilestoneContext(filePath: string): string | null {
51
51
  }
52
52
 
53
53
  /** Build an append-only snapshot that explicitly invalidates earlier snapshots. */
54
- function formatMilestoneSnapshot(workspace: string, context: string | null): string {
54
+ export function formatMilestoneSnapshot(workspace: string, context: string | null): string {
55
55
  return [
56
56
  "# UniPi Milestone Snapshot",
57
57
  "This snapshot supersedes all prior UniPi milestone snapshots; use only this snapshot for milestone status.",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/milestone",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Lifecycle layer for project-level goals — MILESTONES.md tracking, session hooks, auto-sync",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -32,7 +32,7 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "2.5.0"
35
+ "@pi-unipi/core": "2.6.0"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/notify",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Cross-platform notification extension for Pi — native OS, Gotify, and Telegram notifications for agent lifecycle events",
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.5.0",
37
+ "@pi-unipi/core": "2.6.0",
38
38
  "node-notifier": "^10.0.1"
39
39
  },
40
40
  "peerDependencies": {
@@ -30,6 +30,7 @@ const VERSION = getPackageVersion(dirname(fileURLToPath(import.meta.url)));
30
30
  /** Current loop manager instance (recreated on session reload) */
31
31
  let manager: RalphLoopManager | null = null;
32
32
 
33
+
33
34
  /**
34
35
  * Get or create the loop manager for the current context.
35
36
  */
@@ -42,9 +43,14 @@ function getManager(ctx: ExtensionContext, pi: ExtensionAPI): RalphLoopManager {
42
43
  return manager;
43
44
  }
44
45
 
46
+ export { buildRalphLoopReminder } from "./reminder.js";
47
+ import { buildRalphLoopReminder, latestRalphReminder, RALPH_REMINDER_TYPE } from "./reminder.js";
48
+
45
49
  export default function (pi: ExtensionAPI) {
46
- // Register tools
47
- // (Manager will be created lazily on first use)
50
+ // Register static tool definitions at extension load. Their executors resolve
51
+ // the session-scoped manager lazily, so schemas never arrive late or reorder
52
+ // the provider tool array during session_start.
53
+ registerRalphTools(pi, (ctx) => getManager(ctx, pi));
48
54
 
49
55
  // Register commands
50
56
  registerCommands(pi);
@@ -157,20 +163,13 @@ export default function (pi: ExtensionAPI) {
157
163
  const state = mgr.loadState(currentLoop);
158
164
  if (!state || state.status !== "active") return;
159
165
 
160
- const iterStr = `${state.iteration}${state.maxIterations > 0 ? `/${state.maxIterations}` : ""}`;
161
-
162
- let instructions = `You are in a Ralph loop working on: ${state.taskFile}\n`;
163
- if (state.itemsPerIteration > 0) {
164
- instructions += `- Work on ~${state.itemsPerIteration} items this iteration\n`;
165
- }
166
- instructions += `- Update the task file as you progress\n`;
167
- instructions += `- When FULLY COMPLETE: ${RALPH_COMPLETE_MARKER}\n`;
168
- instructions += `- Otherwise, call ralph_done tool to proceed to next iteration`;
166
+ const content = buildRalphLoopReminder(state);
167
+ if (latestRalphReminder(ctx) === content) return;
169
168
 
170
169
  return {
171
170
  message: {
172
- customType: "unipi-ralph-loop-reminder",
173
- content: `[RALPH LOOP - ${state.name} - Iteration ${iterStr}]\n\n${instructions}`,
171
+ customType: RALPH_REMINDER_TYPE,
172
+ content,
174
173
  display: false,
175
174
  },
176
175
  };
@@ -187,11 +186,6 @@ export default function (pi: ExtensionAPI) {
187
186
  manager = null;
188
187
  });
189
188
 
190
- // Register tools after manager setup
191
- pi.on("session_start", async (_event, ctx) => {
192
- const mgr = getManager(ctx, pi);
193
- registerRalphTools(pi, mgr);
194
- });
195
189
  }
196
190
 
197
191
  /**
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ralph",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Long-running iterative development loops for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -26,9 +26,12 @@
26
26
  "publishConfig": {
27
27
  "access": "public"
28
28
  },
29
+ "scripts": {
30
+ "test": "npx tsx --test reminder.test.ts"
31
+ },
29
32
  "dependencies": {
30
- "@pi-unipi/core": "2.5.0",
31
- "@pi-unipi/info-screen": "2.5.0"
33
+ "@pi-unipi/core": "2.6.0",
34
+ "@pi-unipi/info-screen": "2.6.0"
32
35
  },
33
36
  "peerDependencies": {
34
37
  "@earendil-works/pi-ai": "^0.80.0",
@@ -0,0 +1,40 @@
1
+ import type { ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
2
+ import { RALPH_COMPLETE_MARKER } from "@pi-unipi/core";
3
+
4
+ export const RALPH_REMINDER_TYPE = "unipi-ralph-loop-reminder";
5
+
6
+ export interface RalphReminderInput {
7
+ name: string;
8
+ iteration: number;
9
+ maxIterations: number;
10
+ taskFile: string;
11
+ itemsPerIteration: number;
12
+ }
13
+
14
+ /** Build the exact deterministic hidden reminder used by the live hook. */
15
+ export function buildRalphLoopReminder(state: RalphReminderInput): string {
16
+ const iterStr = `${state.iteration}${state.maxIterations > 0 ? `/${state.maxIterations}` : ""}`;
17
+ let instructions = "This snapshot supersedes all earlier Ralph loop reminders.\n";
18
+ instructions += `You are in a Ralph loop working on: ${state.taskFile}\n`;
19
+ if (state.itemsPerIteration > 0) {
20
+ instructions += `- Work on ~${state.itemsPerIteration} items this iteration\n`;
21
+ }
22
+ instructions += "- Update the task file as you progress\n";
23
+ instructions += `- When FULLY COMPLETE: ${RALPH_COMPLETE_MARKER}\n`;
24
+ instructions += "- Otherwise, call ralph_done tool to proceed to next iteration";
25
+ return `[RALPH LOOP - ${state.name} - Iteration ${iterStr}]\n\n${instructions}`;
26
+ }
27
+
28
+ export function latestRalphReminder(ctx: Pick<ExtensionContext, "sessionManager">): string | null {
29
+ const branch = ctx.sessionManager.getBranch() as SessionEntry[];
30
+ for (let index = branch.length - 1; index >= 0; index--) {
31
+ const entry = branch[index];
32
+ if (entry.type === "custom_message" && entry.customType === RALPH_REMINDER_TYPE) {
33
+ return typeof entry.content === "string" ? entry.content : null;
34
+ }
35
+ // A compacted summary may contain the old reminder but no longer retains a
36
+ // dedicated custom entry. Inject the current snapshot once in the new epoch.
37
+ if (entry.type === "compaction") break;
38
+ }
39
+ return null;
40
+ }
@@ -9,10 +9,12 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
9
9
  import { RALPH_COMPLETE_MARKER, RALPH_DEFAULTS, RALPH_TOOLS } from "@pi-unipi/core";
10
10
  import { RalphLoopManager, DEFAULT_REFLECT_INSTRUCTIONS } from "./ralph-loop.js";
11
11
 
12
+ type ManagerProvider = (ctx: ExtensionContext) => RalphLoopManager;
13
+
12
14
  /**
13
15
  * Register ralph_start and ralph_done tools.
14
16
  */
15
- export function registerRalphTools(pi: ExtensionAPI, manager: RalphLoopManager): void {
17
+ export function registerRalphTools(pi: ExtensionAPI, getManager: ManagerProvider): void {
16
18
  // --- ralph_start tool ---
17
19
  pi.registerTool({
18
20
  name: RALPH_TOOLS.START,
@@ -44,6 +46,7 @@ export function registerRalphTools(pi: ExtensionAPI, manager: RalphLoopManager):
44
46
  ),
45
47
  }),
46
48
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
49
+ const manager = getManager(ctx);
47
50
  const taskFile = `.unipi/ralph/${params.name.replace(/[^a-zA-Z0-9_-]/g, "_")}.md`;
48
51
 
49
52
  if (manager.loadState(params.name)?.status === "active") {
@@ -92,6 +95,7 @@ export function registerRalphTools(pi: ExtensionAPI, manager: RalphLoopManager):
92
95
  ],
93
96
  parameters: Type.Object({}),
94
97
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
98
+ const manager = getManager(ctx);
95
99
  if (!manager.getCurrentLoop()) {
96
100
  return {
97
101
  content: [{ type: "text", text: "No active Ralph loop." }],
@@ -69,6 +69,8 @@ spawn_helper(
69
69
  get_helper_result(agent_id: "helper_abc123")
70
70
  ```
71
71
 
72
+ Foreground and retrieved background results have a hard 64 KiB model-visible ceiling. For raw results up to 16 MiB, larger output includes a bounded head/tail preview and a path to the complete private mode-0600 artifact under a mode-0700 `~/.unipi/tool-results/` directory. Use `read` with offset/limit to inspect only the needed region. Repeated retrieval reuses the same artifact. Results above the safety cap or artifact-write failures still return a preview with an explicit non-retention warning.
73
+
72
74
  ## Custom Agent Types
73
75
 
74
76
  Create markdown files defining agent behavior:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/subagents",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Subagents for UniPi — parallel execution, file locking, workflow integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",