@pi-unipi/unipi 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.
Files changed (62) hide show
  1. package/CHANGELOG.md +43 -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 +4 -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 +3 -0
  20. package/packages/core/index.ts +1 -0
  21. package/packages/core/model-cache.ts +18 -12
  22. package/packages/core/package.json +1 -1
  23. package/packages/footer/package.json +2 -2
  24. package/packages/image/package.json +2 -2
  25. package/packages/info-screen/package.json +2 -2
  26. package/packages/input-shortcuts/package.json +2 -2
  27. package/packages/kanboard/package.json +2 -2
  28. package/packages/mcp/README.md +4 -0
  29. package/packages/mcp/package.json +2 -2
  30. package/packages/mcp/src/bridge/translator.ts +19 -12
  31. package/packages/memory/index.ts +56 -34
  32. package/packages/memory/package.json +3 -3
  33. package/packages/milestone/hooks.ts +1 -1
  34. package/packages/milestone/package.json +2 -2
  35. package/packages/notify/README.md +1 -0
  36. package/packages/notify/commands.ts +61 -3
  37. package/packages/notify/package.json +2 -2
  38. package/packages/notify/tui/recap-model-selector.ts +72 -31
  39. package/packages/notify/tui/settings-overlay.ts +45 -32
  40. package/packages/ralph/index.ts +12 -18
  41. package/packages/ralph/package.json +6 -3
  42. package/packages/ralph/reminder.ts +40 -0
  43. package/packages/ralph/tools.ts +5 -1
  44. package/packages/subagents/README.md +2 -0
  45. package/packages/subagents/package.json +1 -1
  46. package/packages/subagents/src/agent-manager.ts +5 -1
  47. package/packages/subagents/src/agent-runner.ts +2 -2
  48. package/packages/subagents/src/core-compat.ts +73 -0
  49. package/packages/subagents/src/custom-agents.ts +10 -2
  50. package/packages/subagents/src/index.ts +14 -3
  51. package/packages/subagents/src/types.ts +2 -0
  52. package/packages/unipi/bundled.js +1002 -452
  53. package/packages/updater/package.json +2 -2
  54. package/packages/utility/README.md +9 -0
  55. package/packages/utility/package.json +2 -2
  56. package/packages/utility/src/index.ts +48 -0
  57. package/packages/utility/src/lifecycle/cleanup.ts +29 -0
  58. package/packages/utility/src/prefix-cache.ts +263 -0
  59. package/packages/utility/src/types.ts +1 -1
  60. package/packages/web-api/package.json +2 -2
  61. package/packages/workflow/index.ts +2 -2
  62. 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(),
@@ -295,6 +297,7 @@ export const NOTIFY_COMMANDS = {
295
297
  SET_NTFY: "notify-set-ntfy",
296
298
  TEST: "notify-test",
297
299
  RECAP_MODEL: "notify-recap-model",
300
+ NOTIFY_EVENT: "notify-event",
298
301
  } as const;
299
302
 
300
303
  /** Notify tool names */
@@ -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";
@@ -9,14 +9,18 @@
9
9
  import * as fs from "node:fs";
10
10
  import * as path from "node:path";
11
11
 
12
- /** Path to the model cache directory */
13
- const CACHE_DIR = path.join(
14
- process.env.HOME ?? process.env.USERPROFILE ?? "~",
15
- ".unipi/config",
16
- );
12
+ /** Resolve the model cache directory at call time (respects HOME changes). */
13
+ function cacheDir(): string {
14
+ return path.join(
15
+ process.env.HOME ?? process.env.USERPROFILE ?? "~",
16
+ ".unipi/config",
17
+ );
18
+ }
17
19
 
18
- /** Path to the model cache file */
19
- const CACHE_FILE = path.join(CACHE_DIR, "models-cache.json");
20
+ /** Resolve the model cache file path at call time. */
21
+ function cacheFile(): string {
22
+ return path.join(cacheDir(), "models-cache.json");
23
+ }
20
24
 
21
25
  /** A single cached model entry */
22
26
  export interface CachedModel {
@@ -42,8 +46,9 @@ export interface ModelCache {
42
46
  */
43
47
  export function readModelCache(): CachedModel[] {
44
48
  try {
45
- if (!fs.existsSync(CACHE_FILE)) return [];
46
- const parsed = JSON.parse(fs.readFileSync(CACHE_FILE, "utf-8"));
49
+ const file = cacheFile();
50
+ if (!fs.existsSync(file)) return [];
51
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
47
52
  return Array.isArray(parsed.models) ? parsed.models : [];
48
53
  } catch {
49
54
  return [];
@@ -56,14 +61,15 @@ export function readModelCache(): CachedModel[] {
56
61
  */
57
62
  export function writeModelCache(models: CachedModel[]): void {
58
63
  try {
59
- if (!fs.existsSync(CACHE_DIR)) {
60
- fs.mkdirSync(CACHE_DIR, { recursive: true });
64
+ const dir = cacheDir();
65
+ if (!fs.existsSync(dir)) {
66
+ fs.mkdirSync(dir, { recursive: true });
61
67
  }
62
68
  const cache: ModelCache = {
63
69
  updatedAt: new Date().toISOString(),
64
70
  models,
65
71
  };
66
- fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2) + "\n", "utf-8");
72
+ fs.writeFileSync(cacheFile(), JSON.stringify(cache, null, 2) + "\n", "utf-8");
67
73
  } catch {
68
74
  // Best effort — cache is optional
69
75
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/core",
3
- "version": "2.5.0",
3
+ "version": "2.6.1",
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.1",
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.1"
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.1",
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.1"
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.1",
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.1"
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.1",
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.1"
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.1",
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.1"
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.1",
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.1"
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.1",
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.1",
47
+ "@pi-unipi/info-screen": "2.6.1"
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.1",
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.1"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -13,6 +13,7 @@ Configure once, get alerts everywhere. Per-event platform routing lets you send
13
13
  | `/unipi:notify-set-tg` | Interactive Telegram bot setup |
14
14
  | `/unipi:notify-set-ntfy` | Configure ntfy topic and server |
15
15
  | `/unipi:notify-recap-model` | Set model for notification recaps |
16
+ | `/unipi:notify-event` | Toggle a single event without the TUI (`<event> <on\|off>`) — reports the new value; run `/reload` to re-register listeners |
16
17
  | `/unipi:notify-test` | Send test notification to all enabled platforms |
17
18
 
18
19
  ## Special Triggers
@@ -12,13 +12,32 @@ import { GotifySetupOverlay } from "./tui/gotify-setup.js";
12
12
  import { TelegramSetupOverlay } from "./tui/telegram-setup.js";
13
13
  import { NtfySetupOverlay } from "./tui/ntfy-setup.js";
14
14
  import { RecapModelSelectorOverlay } from "./tui/recap-model-selector.js";
15
- import { loadConfig } from "./settings.js";
15
+ import type { CachedModel } from "@pi-unipi/core";
16
+ import { loadConfig, saveConfig } from "./settings.js";
16
17
  import { loadNtfyConfig } from "./ntfy-config.js";
17
18
  import { sendNativeNotification, SuppressedError } from "./platforms/native.js";
18
19
  import { sendGotifyNotification } from "./platforms/gotify.js";
19
20
  import { sendTelegramNotification } from "./platforms/telegram.js";
20
21
  import { sendNtfyNotification } from "./platforms/ntfy.js";
21
22
 
23
+ /**
24
+ * Collect models for the recap selector from Pi's live model registry.
25
+ * Returns undefined when the registry is unavailable so the overlay can fall
26
+ * back to the project-wide model cache (issue #27: selector was empty because
27
+ * it only read ~/.unipi/config/models-cache.json, which may not exist even
28
+ * when models are configured in ~/.pi/agent/models.json).
29
+ */
30
+ function registryModels(ctx: ExtensionContext): CachedModel[] | undefined {
31
+ const registry = ctx.modelRegistry;
32
+ if (!registry) return undefined;
33
+ try {
34
+ const models = registry.getAvailable?.() ?? registry.getAll() ?? [];
35
+ return models.map((m) => ({ provider: m.provider, id: m.id, name: m.name }));
36
+ } catch {
37
+ return undefined;
38
+ }
39
+ }
40
+
22
41
  /**
23
42
  * Register notify commands.
24
43
  */
@@ -44,7 +63,7 @@ export function registerNotifyCommands(pi: ExtensionAPI): void {
44
63
  // Open model selector as nested overlay
45
64
  ctx.ui.custom(
46
65
  (innerTui: any, innerTheme: any, _innerKb: any, innerDone: any) => {
47
- const selector = new RecapModelSelectorOverlay();
66
+ const selector = new RecapModelSelectorOverlay(registryModels(ctx));
48
67
  selector.setTheme(innerTheme);
49
68
  selector.onClose = () => innerDone(undefined);
50
69
  selector.requestRender = () => innerTui.requestRender();
@@ -102,9 +121,10 @@ export function registerNotifyCommands(pi: ExtensionAPI): void {
102
121
  return;
103
122
  }
104
123
 
124
+ const models = registryModels(ctx);
105
125
  ctx.ui.custom(
106
126
  (tui, theme, _keybindings, done) => {
107
- const overlay = new RecapModelSelectorOverlay();
127
+ const overlay = new RecapModelSelectorOverlay(models);
108
128
  overlay.setTheme(theme);
109
129
  overlay.onClose = () => done(undefined);
110
130
  overlay.requestRender = () => tui.requestRender();
@@ -251,6 +271,44 @@ export function registerNotifyCommands(pi: ExtensionAPI): void {
251
271
  }
252
272
  );
253
273
 
274
+ // /unipi:notify-event <event> <on|off> — Non-TUI event toggle (issue #27 escape
275
+ // hatch for terminals where overlay input is unusable)
276
+ pi.registerCommand(
277
+ `${UNIPI_PREFIX}${NOTIFY_COMMANDS.NOTIFY_EVENT}`,
278
+ {
279
+ description: "Toggle a notify event without the TUI: <event> <on|off>",
280
+ handler: async (args: string, ctx: ExtensionContext) => {
281
+ const parts = args.trim().split(/\s+/).filter(Boolean);
282
+ const [event, value] = parts;
283
+
284
+ if (parts.length !== 2 || (value !== "on" && value !== "off")) {
285
+ ctx.ui.notify(
286
+ "Usage: /unipi:notify-event <event> <on|off>",
287
+ "warning"
288
+ );
289
+ return;
290
+ }
291
+
292
+ const config = loadConfig();
293
+ if (!(event in config.events)) {
294
+ const known = Object.keys(config.events).join(", ");
295
+ ctx.ui.notify(
296
+ `Unknown event "${event}". Known events: ${known}`,
297
+ "error"
298
+ );
299
+ return;
300
+ }
301
+
302
+ config.events[event].enabled = value === "on";
303
+ saveConfig(config);
304
+ ctx.ui.notify(
305
+ `notify: ${event} is now ${value}. Run /reload to re-register listeners.`,
306
+ "info"
307
+ );
308
+ },
309
+ }
310
+ );
311
+
254
312
  // /unipi:notify-test — Send test notification to all enabled platforms
255
313
  pi.registerCommand(
256
314
  `${UNIPI_PREFIX}${NOTIFY_COMMANDS.TEST}`,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/notify",
3
- "version": "2.5.0",
3
+ "version": "2.6.1",
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.1",
38
38
  "node-notifier": "^10.0.1"
39
39
  },
40
40
  "peerDependencies": {