@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +2 -0
  3. package/docs/prefix-cache-architecture.md +89 -0
  4. package/package.json +24 -22
  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/core/sandbox.ts +7 -6
  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 +13 -0
  29. package/packages/mcp/package.json +5 -2
  30. package/packages/mcp/src/bridge/registry.ts +244 -137
  31. package/packages/mcp/src/bridge/translator.ts +96 -21
  32. package/packages/mcp/src/index.ts +43 -67
  33. package/packages/mcp/src/tui/settings-overlay.ts +3 -9
  34. package/packages/memory/README.md +15 -11
  35. package/packages/memory/bridge/mempalace_bridge.py +636 -0
  36. package/packages/memory/index.ts +56 -34
  37. package/packages/memory/mempalace.ts +169 -18
  38. package/packages/memory/package.json +3 -3
  39. package/packages/memory/storage.ts +27 -10
  40. package/packages/milestone/README.md +11 -3
  41. package/packages/milestone/hooks.ts +103 -29
  42. package/packages/milestone/index.ts +1 -1
  43. package/packages/milestone/package.json +5 -2
  44. package/packages/notify/package.json +2 -2
  45. package/packages/ralph/index.ts +12 -18
  46. package/packages/ralph/package.json +6 -3
  47. package/packages/ralph/reminder.ts +40 -0
  48. package/packages/ralph/tools.ts +5 -1
  49. package/packages/subagents/README.md +2 -0
  50. package/packages/subagents/package.json +1 -1
  51. package/packages/subagents/src/agent-manager.ts +5 -1
  52. package/packages/subagents/src/agent-runner.ts +2 -2
  53. package/packages/subagents/src/core-compat.ts +73 -0
  54. package/packages/subagents/src/custom-agents.ts +10 -2
  55. package/packages/subagents/src/index.ts +14 -3
  56. package/packages/subagents/src/types.ts +2 -0
  57. package/packages/unipi/bundled.js +1445 -686
  58. package/packages/updater/package.json +2 -2
  59. package/packages/utility/README.md +9 -0
  60. package/packages/utility/package.json +2 -2
  61. package/packages/utility/src/index.ts +48 -0
  62. package/packages/utility/src/lifecycle/cleanup.ts +29 -0
  63. package/packages/utility/src/prefix-cache.ts +263 -0
  64. package/packages/utility/src/types.ts +1 -1
  65. package/packages/web-api/package.json +2 -2
  66. package/packages/workflow/README.md +8 -0
  67. package/packages/workflow/commands.ts +16 -26
  68. package/packages/workflow/index.ts +165 -85
  69. package/packages/workflow/package.json +5 -2
@@ -5,15 +5,19 @@
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
 
12
- /** Pi-compatible tool parameter schema */
12
+ /** Client operation needed by translated tools. */
13
+ export type ToolCallClient = Pick<McpClient, "callTool">;
14
+
15
+ /** JSON object used as a Pi-compatible tool parameter schema. */
13
16
  interface ToolParameters {
17
+ [key: string]: unknown;
14
18
  type: "object";
15
19
  properties: Record<string, unknown>;
16
- required?: string[];
20
+ required: unknown;
17
21
  }
18
22
 
19
23
  /** Content block returned by a pi tool */
@@ -31,6 +35,7 @@ interface PiToolResult {
31
35
  /** Pi-compatible external tool */
32
36
  export interface PiExternalTool {
33
37
  name: string;
38
+ label: string;
34
39
  description: string;
35
40
  parameters: ToolParameters;
36
41
  execute: (
@@ -41,6 +46,66 @@ export interface PiExternalTool {
41
46
  ) => Promise<PiToolResult>;
42
47
  }
43
48
 
49
+ /**
50
+ * Compare strings by JavaScript/Unicode UTF-16 code units.
51
+ *
52
+ * Unlike localeCompare(), this ordering does not depend on the host locale.
53
+ */
54
+ export function compareCodeUnits(left: string, right: string): number {
55
+ return left < right ? -1 : left > right ? 1 : 0;
56
+ }
57
+
58
+ function isObject(value: unknown): value is Record<string, unknown> {
59
+ return typeof value === "object" && value !== null && !Array.isArray(value);
60
+ }
61
+
62
+ const LITERAL_VALUE_KEYWORDS = new Set(["const", "default", "enum", "examples"]);
63
+
64
+ function canonicalizeValue(
65
+ value: unknown,
66
+ key?: string,
67
+ normalizeSchemaKeywords = true,
68
+ ): unknown {
69
+ if (Array.isArray(value)) {
70
+ if (
71
+ normalizeSchemaKeywords &&
72
+ key === "required" &&
73
+ value.every((item) => typeof item === "string")
74
+ ) {
75
+ return [...new Set(value as string[])].sort(compareCodeUnits);
76
+ }
77
+ return value.map((item) => canonicalizeValue(item, undefined, normalizeSchemaKeywords));
78
+ }
79
+
80
+ if (!isObject(value)) return value;
81
+
82
+ const canonical: Record<string, unknown> = {};
83
+ for (const objectKey of Object.keys(value).sort(compareCodeUnits)) {
84
+ // Values under these JSON Schema keywords are literal application data,
85
+ // not nested schemas. A property named `required` inside that data must
86
+ // retain array order (for example under `const`).
87
+ const childNormalizesSchemaKeywords =
88
+ normalizeSchemaKeywords && !LITERAL_VALUE_KEYWORDS.has(objectKey);
89
+ canonical[objectKey] = canonicalizeValue(
90
+ value[objectKey],
91
+ objectKey,
92
+ childNormalizesSchemaKeywords,
93
+ );
94
+ }
95
+ return canonical;
96
+ }
97
+
98
+ /**
99
+ * Recursively clone and canonicalize a JSON Schema value.
100
+ *
101
+ * Object keys use locale-independent code-unit order. Arrays retain their
102
+ * original order, except valid `required` arrays (arrays containing only
103
+ * strings), which are sorted and deduplicated.
104
+ */
105
+ export function canonicalizeJsonSchema(schema: unknown): unknown {
106
+ return canonicalizeValue(schema);
107
+ }
108
+
44
109
  /**
45
110
  * Translate an MCP tool definition to a pi-compatible external tool.
46
111
  *
@@ -52,19 +117,21 @@ export interface PiExternalTool {
52
117
  export function translateMcpTool(
53
118
  mcpTool: McpTool,
54
119
  serverName: string,
55
- client: McpClient,
120
+ client: ToolCallClient,
56
121
  ): PiExternalTool {
57
122
  const separator = MCP_DEFAULTS.TOOL_NAME_SEPARATOR;
58
123
  const toolName = `${serverName}${separator}${mcpTool.name}`;
59
124
 
60
- // Ensure inputSchema is a valid JSON Schema object
61
- const inputSchema = mcpTool.inputSchema ?? {};
62
- const parameters: ToolParameters = {
125
+ // Preserve the existing Pi-facing top-level shape while cloning and
126
+ // canonicalizing all nested property schemas. Forwarding additional MCP
127
+ // top-level keywords is a separate provider-compatibility decision.
128
+ const inputSchema = isObject(mcpTool.inputSchema) ? mcpTool.inputSchema : {};
129
+ const normalizedSchema: Record<string, unknown> = {
63
130
  type: "object",
64
- properties:
65
- (inputSchema.properties as Record<string, unknown>) ?? {},
66
- required: inputSchema.required as string[] | undefined,
131
+ properties: isObject(inputSchema.properties) ? inputSchema.properties : {},
132
+ required: Array.isArray(inputSchema.required) ? inputSchema.required : [],
67
133
  };
134
+ const parameters = canonicalizeJsonSchema(normalizedSchema) as ToolParameters;
68
135
 
69
136
  const description = [
70
137
  mcpTool.description || `MCP tool: ${mcpTool.name}`,
@@ -103,21 +170,28 @@ export function translateMcpTool(
103
170
  }
104
171
  }
105
172
 
106
- if (result.isError) {
107
- const errorText = blocks.map((b) => b.text).join("\n") || "Unknown error";
108
- return {
109
- content: [{ type: "text", text: `MCP tool error from ${serverName}: ${errorText}` }],
110
- details: { error: true, server: serverName, tool: mcpTool.name },
111
- };
112
- }
113
-
114
173
  if (blocks.length === 0) {
115
- blocks.push({ type: "text", text: "(no output)" });
174
+ blocks.push({ type: "text", text: result.isError ? "Unknown error" : "(no output)" });
116
175
  }
117
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
+
118
185
  return {
119
- content: blocks,
120
- 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
+ },
121
195
  };
122
196
  } catch (err) {
123
197
  const message = err instanceof Error ? err.message : String(err);
@@ -137,6 +211,7 @@ export function translateMcpTool(
137
211
 
138
212
  return {
139
213
  name: toolName,
214
+ label: toolName,
140
215
  description,
141
216
  parameters,
142
217
  execute,
@@ -18,6 +18,7 @@ import type { ResolvedServer } from "./types.js";
18
18
  import { loadAndResolve, getGlobalConfigDir } from "./config/manager.js";
19
19
  import { syncCatalog, loadCatalog } from "./config/sync.js";
20
20
  import { ServerRegistry } from "./bridge/registry.js";
21
+ import { compareCodeUnits } from "./bridge/translator.js";
21
22
  import { renderMcpAddOverlay } from "./tui/add-overlay.js";
22
23
  import { renderMcpSettingsOverlay } from "./tui/settings-overlay.js";
23
24
 
@@ -42,24 +43,32 @@ export default function (pi: ExtensionAPI) {
42
43
  // Session start — load configs, start servers
43
44
  pi.on("session_start", async (_event, ctx) => {
44
45
  // Create registry with pi integration callbacks
46
+ const toolApi = pi as ExtensionAPI & {
47
+ registerExternalTool?: (tool: unknown) => void;
48
+ unregisterTool?: (toolName: string) => void;
49
+ unregisterExternalTool?: (toolName: string) => void;
50
+ };
51
+ const registerTool = typeof toolApi.registerTool === "function"
52
+ ? (tool: unknown) => toolApi.registerTool(tool as Parameters<typeof toolApi.registerTool>[0])
53
+ : typeof toolApi.registerExternalTool === "function"
54
+ ? (tool: unknown) => toolApi.registerExternalTool!(tool)
55
+ : () => {
56
+ throw new Error("Pi does not expose a supported MCP tool registration API");
57
+ };
58
+ const canUnregisterTools =
59
+ typeof toolApi.unregisterTool === "function" ||
60
+ typeof toolApi.unregisterExternalTool === "function";
61
+ const unregisterTool = typeof toolApi.unregisterTool === "function"
62
+ ? (toolName: string) => toolApi.unregisterTool!(toolName)
63
+ : typeof toolApi.unregisterExternalTool === "function"
64
+ ? (toolName: string) => toolApi.unregisterExternalTool!(toolName)
65
+ : () => {};
66
+
45
67
  registry = new ServerRegistry({
46
68
  emitEvent: (event, payload) => emitEvent(pi, event, payload),
47
- registerTool: (tool) => {
48
- try {
49
- (pi as any).registerTool?.(tool) ??
50
- (pi as any).registerExternalTool?.(tool);
51
- } catch {
52
- // Tool registration may not be available in all contexts
53
- }
54
- },
55
- unregisterTool: (toolName) => {
56
- try {
57
- (pi as any).unregisterTool?.(toolName) ??
58
- (pi as any).unregisterExternalTool?.(toolName);
59
- } catch {
60
- // Ignore
61
- }
62
- },
69
+ registerTool,
70
+ unregisterTool,
71
+ canUnregisterTools,
63
72
  });
64
73
 
65
74
  // Load and resolve server configs
@@ -73,21 +82,13 @@ export default function (pi: ExtensionAPI) {
73
82
  // Config load failure — servers will be empty, visible via /unipi:mcp-status.
74
83
  }
75
84
 
76
- // Start enabled servers (parallel, non-blocking errors)
77
- const startPromises = servers
78
- .filter((s) => s.enabled)
79
- .map(async (server) => {
80
- try {
81
- await registry!.startServer(server);
82
- // Removed console.log — startup logs cause layout shift in TUI.
83
- // Server status visible via /unipi:mcp-status or info screen.
84
- } catch (err) {
85
- // Removed console.error — errors surfaced via info-screen MCP group.
86
- // Server failure tracked in registry state.
87
- }
88
- });
89
-
90
- await Promise.allSettled(startPromises);
85
+ // Connect/discover in parallel, then register the successful combined set
86
+ // after a barrier so tool order is stable across runs.
87
+ try {
88
+ await registry.startServers(servers.filter((server) => server.enabled));
89
+ } catch (_err) {
90
+ // Errors are tracked in registry state and surfaced by the info screen.
91
+ }
91
92
 
92
93
  // Register info-screen group
93
94
  const infoRegistry = getInfoRegistry();
@@ -153,16 +154,17 @@ export default function (pi: ExtensionAPI) {
153
154
  `unipi:${MCP_COMMANDS.STATUS}`,
154
155
  `unipi:${MCP_COMMANDS.RELOAD}`,
155
156
  ],
156
- tools: activeServers.flatMap((s) =>
157
- registry?.getEntry(s.name)?.toolNames ?? [],
158
- ),
157
+ tools: activeServers
158
+ .flatMap((server) => registry?.getEntry(server.name)?.toolNames ?? [])
159
+ .sort(compareCodeUnits),
159
160
  });
160
161
  });
161
162
 
162
- // Session shutdown — stop all servers
163
+ // Session shutdown tears down clients. Pi tears down this extension's tool
164
+ // registry itself, so do not claim per-tool unregistration here.
163
165
  pi.on("session_shutdown", async (_event, _ctx) => {
164
166
  if (registry) {
165
- await registry.stopAll();
167
+ await registry.disconnectAll();
166
168
  registry = null;
167
169
  }
168
170
  });
@@ -305,38 +307,12 @@ export default function (pi: ExtensionAPI) {
305
307
 
306
308
  // /unipi:mcp-reload — restart all MCP servers
307
309
  pi.registerCommand(`unipi:${MCP_COMMANDS.RELOAD}`, {
308
- description: "Reload all MCP servers (restart with current config)",
310
+ description: "Explain how to reload MCP servers safely",
309
311
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
310
- const reg = getRegistry();
311
- if (!reg) {
312
- ctx.ui.notify("MCP extension not initialized", "warning");
313
- return;
314
- }
315
-
316
- const all = reg.getAll();
317
- if (all.length === 0) {
318
- ctx.ui.notify("No MCP servers configured. Use /unipi:mcp-add to add one.", "info");
319
- return;
320
- }
321
-
322
- ctx.ui.notify(`Reloading ${all.length} MCP server(s)...`, "info");
323
-
324
- let restarted = 0;
325
- let failed = 0;
326
- for (const state of all) {
327
- try {
328
- await reg.restartServer(state.name);
329
- restarted++;
330
- } catch (_err) {
331
- failed++;
332
- // Silently ignore — restart failure tracked in failed count.
333
- }
334
- }
335
-
336
- const msg = failed > 0
337
- ? `Reloaded: ${restarted} ok, ${failed} failed`
338
- : `Reloaded ${restarted} MCP server(s) successfully`;
339
- ctx.ui.notify(msg, failed > 0 ? "warning" : "info");
312
+ // Pi 0.80 does not expose dynamic tool removal. Restarting in place can
313
+ // leave stale schemas in the provider-visible tool list, so require a
314
+ // process/extension restart to establish a clean cache epoch.
315
+ ctx.ui.notify("Restart Pi to reload MCP servers and tool schemas safely.", "info");
340
316
  },
341
317
  });
342
318
  }
@@ -141,15 +141,9 @@ export function renderMcpSettingsOverlay(params?: {
141
141
  };
142
142
  saveMetadata(configDir, meta);
143
143
 
144
- // Try to stop if disabling
145
- if (!newEnabled && registry) {
146
- try {
147
- await registry.stopServer(server.name);
148
- } catch {
149
- // Ignore stop errors
150
- }
151
- }
152
-
144
+ // Pi 0.80 cannot remove a registered tool definition at runtime.
145
+ // Persist the setting now; the next Pi restart applies the new set as
146
+ // one deterministic cache epoch.
153
147
  refreshServers();
154
148
  refresh();
155
149
  } catch (err) {
@@ -2,7 +2,7 @@
2
2
 
3
3
  Persistent memory that survives across sessions. Stores facts, preferences, and decisions with semantic vector search, so the agent remembers what you told it last week.
4
4
 
5
- **Primary backend: [MemPalace](https://github.com/mempalace/mempalace)** — auto-installed via `uv` on first load, with one-way auto-migration of any existing legacy memories. If MemPalace or `uv` is unavailable, the package transparently falls back to the bundled SQLite + sqlite-vec store, so memory never hard-fails.
5
+ **Primary backend: [MemPalace](https://github.com/mempalace/mempalace)** — auto-installed via `uv` on first load, with verified, resumable migration of existing legacy memories. If MemPalace or `uv` is unavailable, the package transparently falls back to the bundled SQLite + sqlite-vec store, so memory never hard-fails.
6
6
 
7
7
  Two storage tiers: MemPalace (or SQLite) for vector similarity search, markdown files for a durable human-readable copy you can edit by hand. Project-scoped memories stay separate per codebase, global memories are accessible everywhere.
8
8
 
@@ -76,7 +76,7 @@ Memory has no configuration file. Storage paths are fixed:
76
76
  ```
77
77
  ~/.unipi/memory/ # UniPi memory root (legacy + markdown tier)
78
78
  ├── .mempalace-install # Cached MemPalace venv detection
79
- ├── .mempalace-migrated # One-way migration completion flag
79
+ ├── .mempalace-migrated # Versioned migration verification state
80
80
  ├── global/
81
81
  │ ├── memory.db # Global vector DB (SQLite fallback)
82
82
  │ └── *.md # Global memory files
@@ -94,14 +94,18 @@ On first load, the memory package:
94
94
  `uv tool install mempalace` once (caches the venv python path in
95
95
  `~/.unipi/memory/.mempalace-install`).
96
96
  2. Pings the bridge to confirm the palace is usable.
97
- 3. If `~/.unipi/memory/.mempalace-migrated` is absent, performs a one-way
98
- read-only migration of every legacy memory (SQLite rows + markdown files
99
- across all projects) into MemPalace drawers, then writes the flag.
100
- Migration is idempotent (deterministic drawer IDs) and never deletes or
101
- mutates legacy files.
102
-
103
- Each memory operation invokes a bundled Python bridge
104
- (`bridge/mempalace_bridge.py`) once via `spawnSync` (~0.5s per call). The
97
+ 3. Fingerprints the durable SQLite and markdown sources. If they differ from
98
+ the verified state in `~/.unipi/memory/.mempalace-migrated`, performs an
99
+ idempotent read-only migration into MemPalace drawers. Unchanged drawers are
100
+ skipped, new or changed memories are upserted, and the versioned state is
101
+ written only after every discovered record is verified in MemPalace.
102
+ Failed or partial migrations remain unmarked and retry on a later session.
103
+ Legacy files are never deleted or mutated.
104
+
105
+ Each memory operation invokes the packaged Python bridge
106
+ (`bridge/mempalace_bridge.py`) once via `spawnSync` (~0.5s per call). Both the
107
+ standalone memory package and the all-in-one umbrella tarball ship and resolve
108
+ this bridge. The
105
109
  first MemPalace use on a machine also downloads the default ONNX embedding
106
110
  model (~80MB, cached at `~/.cache/chroma/onnx_models/`).
107
111
 
@@ -109,7 +113,7 @@ model (~80MB, cached at `~/.cache/chroma/onnx_models/`).
109
113
 
110
114
  ```bash
111
115
  rm ~/.unipi/memory/.mempalace-install # re-detect MemPalace next session
112
- rm ~/.unipi/memory/.mempalace-migrated # re-run one-way migration next session
116
+ rm ~/.unipi/memory/.mempalace-migrated # force a full verified migration pass next session
113
117
  ```
114
118
 
115
119
  ### Backend override