@pi-unipi/unipi 2.1.3 → 2.2.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 (90) hide show
  1. package/README.md +6 -2
  2. package/package.json +23 -22
  3. package/packages/ask-user/ask-ui.ts +45 -31
  4. package/packages/ask-user/launcher-ui.ts +26 -22
  5. package/packages/ask-user/package.json +2 -2
  6. package/packages/ask-user/settings-tui.ts +22 -9
  7. package/packages/autocomplete/package.json +1 -1
  8. package/packages/autocomplete/src/constants.ts +5 -0
  9. package/packages/btw/package.json +2 -2
  10. package/packages/cocoindex/package.json +2 -2
  11. package/packages/compactor/package.json +3 -3
  12. package/packages/compactor/src/tui/settings-overlay.ts +2 -1
  13. package/packages/core/constants.ts +18 -0
  14. package/packages/core/index.ts +1 -0
  15. package/packages/core/package.json +1 -1
  16. package/packages/core/tui-width.ts +127 -0
  17. package/packages/footer/package.json +2 -2
  18. package/packages/footer/src/tui/settings-tui.ts +2 -1
  19. package/packages/image/README.md +82 -0
  20. package/packages/image/index.ts +1 -0
  21. package/packages/image/package.json +57 -0
  22. package/packages/image/skills/image/SKILL.md +73 -0
  23. package/packages/image/src/commands.ts +17 -0
  24. package/packages/image/src/generate.ts +201 -0
  25. package/packages/image/src/image-source.ts +204 -0
  26. package/packages/image/src/index.ts +99 -0
  27. package/packages/image/src/models.ts +290 -0
  28. package/packages/image/src/recognize.ts +223 -0
  29. package/packages/image/src/settings.ts +149 -0
  30. package/packages/image/src/tools.ts +296 -0
  31. package/packages/image/src/tui/model-selector.ts +279 -0
  32. package/packages/image/src/tui/settings-dialog.ts +236 -0
  33. package/packages/info-screen/package.json +2 -2
  34. package/packages/info-screen/tui/info-overlay.ts +505 -0
  35. package/packages/input-shortcuts/package.json +2 -2
  36. package/packages/kanboard/package.json +2 -2
  37. package/packages/kanboard/tui/kanboard-overlay.ts +300 -0
  38. package/packages/mcp/package.json +2 -2
  39. package/packages/mcp/src/tui/add-overlay.ts +10 -7
  40. package/packages/mcp/src/tui/settings-overlay.ts +10 -7
  41. package/packages/memory/package.json +3 -3
  42. package/packages/memory/tui/settings-tui.ts +281 -0
  43. package/packages/milestone/package.json +2 -2
  44. package/packages/notify/README.md +2 -0
  45. package/packages/notify/events.ts +11 -0
  46. package/packages/notify/package.json +3 -11
  47. package/packages/notify/permission-prompt-message.ts +95 -0
  48. package/packages/notify/platforms/focus-win.ts +123 -0
  49. package/packages/notify/platforms/focus.ts +33 -0
  50. package/packages/notify/platforms/gotify.ts +36 -0
  51. package/packages/notify/platforms/native.ts +79 -0
  52. package/packages/notify/platforms/node-notifier.d.ts +20 -0
  53. package/packages/notify/platforms/ntfy.ts +48 -0
  54. package/packages/notify/platforms/telegram.ts +77 -0
  55. package/packages/notify/settings.ts +1 -0
  56. package/packages/notify/skills/configure-notify/SKILL.md +28 -1
  57. package/packages/notify/src/__tests__/permission-prompt-message.test.ts +172 -0
  58. package/packages/notify/tui/gotify-setup.ts +529 -0
  59. package/packages/notify/tui/ntfy-setup.ts +653 -0
  60. package/packages/notify/tui/recap-model-selector.ts +301 -0
  61. package/packages/notify/tui/settings-overlay.ts +353 -0
  62. package/packages/notify/tui/telegram-setup.ts +302 -0
  63. package/packages/ralph/package.json +3 -3
  64. package/packages/subagents/package.json +4 -4
  65. package/packages/subagents/src/__tests__/shutdown-stale-ctx.test.ts +185 -0
  66. package/packages/subagents/src/index.ts +52 -10
  67. package/packages/unipi/index.ts +2 -0
  68. package/packages/updater/package.json +2 -2
  69. package/packages/updater/src/tui/changelog-overlay.ts +2 -2
  70. package/packages/updater/src/tui/readme-overlay.ts +2 -1
  71. package/packages/updater/src/tui/update-overlay.ts +2 -1
  72. package/packages/utility/package.json +2 -2
  73. package/packages/utility/src/tui/badge-settings-tui.ts +2 -2
  74. package/packages/utility/src/tui/util-settings-tui.ts +2 -2
  75. package/packages/web-api/README.md +28 -11
  76. package/packages/web-api/package.json +8 -2
  77. package/packages/web-api/skills/web/SKILL.md +36 -9
  78. package/packages/web-api/src/index.ts +15 -0
  79. package/packages/web-api/src/providers/duckduckgo.ts +71 -27
  80. package/packages/web-api/src/providers/firecrawl.ts +1 -1
  81. package/packages/web-api/src/providers/jina-reader.ts +1 -1
  82. package/packages/web-api/src/providers/jina-search.ts +1 -1
  83. package/packages/web-api/src/providers/perplexity.ts +2 -2
  84. package/packages/web-api/src/providers/serpapi.ts +1 -1
  85. package/packages/web-api/src/providers/tavily.ts +1 -1
  86. package/packages/web-api/src/providers/wigolo-client.ts +196 -0
  87. package/packages/web-api/src/providers/wigolo.ts +117 -0
  88. package/packages/web-api/src/settings.ts +1 -0
  89. package/packages/web-api/src/tools.ts +93 -36
  90. package/packages/workflow/package.json +2 -2
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @pi-unipi/notify — Native OS notification platform
3
+ *
4
+ * Wraps node-notifier for cross-platform desktop notifications.
5
+ * Windows: SnoreToast (no admin required)
6
+ * macOS: terminal-notifier
7
+ * Linux: notify-send / libnotify
8
+ */
9
+
10
+ import notifier from "node-notifier";
11
+ import { isWindowFocused } from "./focus.js";
12
+
13
+ /** Options for native notification */
14
+ export interface NativeNotificationOptions {
15
+ /** Windows appID to show instead of "SnoreToast" */
16
+ windowsAppId?: string;
17
+ /**
18
+ * When true, suppresses the notification if the terminal window is
19
+ * the foreground (active) window. Only effective on platforms where
20
+ * `isWindowFocused` is implemented (currently Windows).
21
+ */
22
+ suppressWhenFocused?: boolean;
23
+ }
24
+
25
+ /**
26
+ * Thrown by sendNativeNotification when the notification was suppressed
27
+ * because suppressWhenFocused is set and the terminal window is focused.
28
+ *
29
+ * Callers should catch this and treat it as intentional suppression,
30
+ * NOT as a send failure.
31
+ */
32
+ export class SuppressedError extends Error {
33
+ constructor() {
34
+ super("Notification suppressed: terminal window is focused");
35
+ this.name = "SuppressedError";
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Send a native OS notification.
41
+ *
42
+ * When `suppressWhenFocused` is true and `isWindowFocused()` returns true
43
+ * (i.e. the terminal is the foreground window), the notification is
44
+ * suppressed and the promise rejects with SuppressedError.
45
+ *
46
+ * Resolves when notification is shown, rejects with SuppressedError on
47
+ * suppression or with a standard Error on failure.
48
+ */
49
+ export async function sendNativeNotification(
50
+ title: string,
51
+ message: string,
52
+ options?: NativeNotificationOptions
53
+ ): Promise<void> {
54
+ // Suppress if the terminal window is currently focused
55
+ if (options?.suppressWhenFocused && await isWindowFocused()) {
56
+ throw new SuppressedError();
57
+ }
58
+
59
+ return new Promise((resolve, reject) => {
60
+ notifier.notify(
61
+ {
62
+ title,
63
+ message,
64
+ appID: options?.windowsAppId,
65
+ },
66
+ (err: Error | null) => {
67
+ if (err) {
68
+ reject(
69
+ new Error(
70
+ `Native notification failed: ${err.message}`
71
+ )
72
+ );
73
+ } else {
74
+ resolve();
75
+ }
76
+ }
77
+ );
78
+ });
79
+ }
@@ -0,0 +1,20 @@
1
+ declare module "node-notifier" {
2
+ interface Notification {
3
+ title?: string;
4
+ message?: string;
5
+ appID?: string;
6
+ sound?: boolean | string;
7
+ icon?: string;
8
+ wait?: boolean;
9
+ }
10
+
11
+ interface Notifier {
12
+ notify(
13
+ notification: Notification,
14
+ callback: (err: Error | null, data: any) => void
15
+ ): void;
16
+ }
17
+
18
+ const notifier: Notifier;
19
+ export default notifier;
20
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @pi-unipi/notify — ntfy notification platform
3
+ *
4
+ * Sends push notifications to an ntfy server via HTTP POST.
5
+ * ntfy is a simple HTTP-based pub-sub notification service.
6
+ * Supports self-hosted instances and ntfy.sh (public).
7
+ */
8
+
9
+ /** Send a notification to an ntfy server */
10
+ export async function sendNtfyNotification(
11
+ serverUrl: string,
12
+ topic: string,
13
+ title: string,
14
+ message: string,
15
+ priority: number = 3,
16
+ token?: string
17
+ ): Promise<void> {
18
+ // ntfy supports POSTing to the server root with a JSON body that carries
19
+ // topic/title/message/priority. JSON bodies are UTF-8 safe, unlike HTTP
20
+ // headers which must be ByteString (Latin-1) and reject characters like
21
+ // em dash (U+2014). See https://docs.ntfy.sh/publish/#publish-as-json
22
+ const url = serverUrl.replace(/\/$/, "");
23
+ const headers: Record<string, string> = {
24
+ "Content-Type": "application/json",
25
+ };
26
+
27
+ if (token) {
28
+ headers["Authorization"] = `Bearer ${token}`;
29
+ }
30
+
31
+ const body = JSON.stringify({
32
+ topic,
33
+ title,
34
+ message,
35
+ priority: Math.max(1, Math.min(5, priority)),
36
+ });
37
+
38
+ const response = await fetch(url, {
39
+ method: "POST",
40
+ headers,
41
+ body,
42
+ });
43
+
44
+ if (!response.ok) {
45
+ const resBody = await response.text().catch(() => "<no body>");
46
+ throw new Error(`ntfy API error ${response.status}: ${resBody}`);
47
+ }
48
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @pi-unipi/notify — Telegram notification platform
3
+ *
4
+ * Sends notifications via Telegram Bot API.
5
+ * Supports auto-detection of chat ID via polling getUpdates.
6
+ */
7
+
8
+ /** Send a notification via Telegram Bot API */
9
+ export async function sendTelegramNotification(
10
+ botToken: string,
11
+ chatId: string,
12
+ title: string,
13
+ message: string
14
+ ): Promise<void> {
15
+ const text = `*${escapeMarkdown(title)}*\n${escapeMarkdown(message)}`;
16
+ const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
17
+
18
+ const response = await fetch(url, {
19
+ method: "POST",
20
+ headers: { "Content-Type": "application/json" },
21
+ body: JSON.stringify({
22
+ chat_id: chatId,
23
+ text,
24
+ parse_mode: "MarkdownV2",
25
+ }),
26
+ });
27
+
28
+ if (!response.ok) {
29
+ const body = await response.text().catch(() => "<no body>");
30
+ throw new Error(
31
+ `Telegram API error ${response.status}: ${body}`
32
+ );
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Poll Telegram getUpdates to detect the chat ID from a user message.
38
+ * Returns the chat ID string, or null if no message found.
39
+ */
40
+ export async function pollForChatId(
41
+ botToken: string,
42
+ signal?: AbortSignal
43
+ ): Promise<string | null> {
44
+ const url = `https://api.telegram.org/bot${botToken}/getUpdates?allowed_updates=["message"]`;
45
+
46
+ try {
47
+ const response = await fetch(url, { signal });
48
+ const data = (await response.json()) as {
49
+ ok: boolean;
50
+ result: Array<{
51
+ message?: { chat?: { id?: number } };
52
+ callback_query?: { message?: { chat?: { id?: number } } };
53
+ }>;
54
+ };
55
+
56
+ if (data.ok && data.result.length > 0) {
57
+ const lastUpdate = data.result[data.result.length - 1];
58
+ const chatId =
59
+ lastUpdate.message?.chat?.id ||
60
+ lastUpdate.callback_query?.message?.chat?.id;
61
+ if (chatId) {
62
+ return String(chatId);
63
+ }
64
+ }
65
+ } catch (err) {
66
+ if (signal?.aborted) throw err;
67
+ // Network error — return null to allow retry
68
+ return null;
69
+ }
70
+
71
+ return null;
72
+ }
73
+
74
+ /** Escape special MarkdownV2 characters */
75
+ function escapeMarkdown(text: string): string {
76
+ return text.replace(/[_*\[\]()~`>#+\-=|{}.!\\]/g, "\\$&");
77
+ }
@@ -28,6 +28,7 @@ export const DEFAULT_CONFIG: NotifyConfig = {
28
28
  memory_consolidated: { enabled: false, platforms: [] },
29
29
  session_shutdown: { enabled: false, platforms: [] },
30
30
  ask_user_prompt: { enabled: false, platforms: [] },
31
+ permission_request: { enabled: false, platforms: [] },
31
32
  },
32
33
  native: {
33
34
  enabled: true,
@@ -36,7 +36,9 @@ Help users configure the `@pi-unipi/notify` notification system.
36
36
  "agent_end": { "enabled": false, "platforms": [] },
37
37
  "agent_settled": { "enabled": false, "platforms": [] },
38
38
  "memory_consolidated": { "enabled": false, "platforms": [] },
39
- "session_shutdown": { "enabled": false, "platforms": [] }
39
+ "session_shutdown": { "enabled": false, "platforms": [] },
40
+ "ask_user_prompt": { "enabled": false, "platforms": [] },
41
+ "permission_request": { "enabled": false, "platforms": [] }
40
42
  },
41
43
  "native": {
42
44
  "enabled": true,
@@ -155,9 +157,34 @@ ntfy uses dedicated `ntfy.json` files at both global and project scope, with ful
155
157
  | `agent_settled` | Off | Agent fully settles after retries, compaction, and queued continuations |
156
158
  | `memory_consolidated` | Off | Memory auto-saved |
157
159
  | `session_shutdown` | Off | Session ends |
160
+ | `ask_user_prompt` | Off | Agent asked a question and is waiting for an answer |
161
+ | `permission_request` | Off | A permission prompt is about to be shown |
158
162
 
159
163
  Each event can override `platforms` — empty array means use `defaultPlatforms`.
160
164
 
165
+ ### `permission_request`
166
+
167
+ Fires on the `permissions:ui_prompt` broadcast from
168
+ [`@gotgenes/pi-permission-system`](https://www.npmjs.com/package/@gotgenes/pi-permission-system),
169
+ emitted immediately before the user-facing permission UI is invoked. Policy
170
+ auto-allow, policy deny, session approvals, and infrastructure auto-allowed
171
+ requests do **not** emit it, so this event does not produce notification spam.
172
+ Forwarded subagent prompts are handled too — the parent UI session emits the
173
+ event right before showing the forwarded dialog, and the notification is
174
+ suffixed with `(forwarded)`.
175
+
176
+ The notification is formatted defensively from the payload's `agentName`,
177
+ `surface`, `value` and `message` fields:
178
+
179
+ ```text
180
+ Pi — Permission Request
181
+ Current agent requested bash 'npm test'. Allow this command?
182
+ ```
183
+
184
+ Enable it when you run Pi in a background pane and don't want to miss a
185
+ permission prompt. If the permission system is not installed the event simply
186
+ never fires.
187
+
161
188
  ## Agent workflow
162
189
 
163
190
  ### Reading current config
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Tests for buildPermissionPromptMessage — the `permissions:ui_prompt`
3
+ * payload projection used by the `permission_request` notify event.
4
+ *
5
+ * The test fixtures use `satisfies PermissionPromptEventPayload` to
6
+ * enforce the payload contract at compile time.
7
+ */
8
+
9
+ import { describe, it } from "node:test";
10
+ import assert from "node:assert/strict";
11
+
12
+ import {
13
+ buildPermissionPromptMessage,
14
+ type PermissionPromptEventPayload,
15
+ } from "../../permission-prompt-message.ts";
16
+
17
+ describe("buildPermissionPromptMessage", () => {
18
+ it("formats a standard bash permission payload", () => {
19
+ const payload = {
20
+ requestId: "req-1",
21
+ source: "tool",
22
+ surface: "bash",
23
+ value: "npm test",
24
+ agentName: "Current agent",
25
+ message: "Allow this command?",
26
+ } satisfies PermissionPromptEventPayload;
27
+
28
+ assert.equal(
29
+ buildPermissionPromptMessage(payload),
30
+ "Current agent requested bash 'npm test'. Allow this command?",
31
+ );
32
+ });
33
+
34
+ it("falls back to \"Agent\" when agentName is absent", () => {
35
+ const payload = {
36
+ surface: "edit",
37
+ value: "src/index.ts",
38
+ } satisfies PermissionPromptEventPayload;
39
+
40
+ assert.equal(
41
+ buildPermissionPromptMessage(payload),
42
+ "Agent requested edit 'src/index.ts'.",
43
+ );
44
+ });
45
+
46
+ it("marks forwarded subagent prompts", () => {
47
+ const payload = {
48
+ surface: "mcp",
49
+ value: "github.create_issue",
50
+ agentName: "explore",
51
+ forwarding: { from: "subagent-1" },
52
+ } satisfies PermissionPromptEventPayload;
53
+
54
+ assert.equal(
55
+ buildPermissionPromptMessage(payload),
56
+ "explore requested mcp 'github.create_issue'. (forwarded)",
57
+ );
58
+ });
59
+
60
+ it("does not mark prompts when forwarding is explicitly falsy", () => {
61
+ for (const forwarding of [false, null, "", 0, {}, []]) {
62
+ const payload = {
63
+ surface: "read",
64
+ value: "/etc/hosts",
65
+ forwarding,
66
+ } satisfies PermissionPromptEventPayload;
67
+
68
+ const result = buildPermissionPromptMessage(payload);
69
+ assert.equal(
70
+ result.includes("(forwarded)"),
71
+ false,
72
+ `expected no forwarded marker for ${JSON.stringify(forwarding)}`,
73
+ );
74
+ }
75
+ });
76
+
77
+ it("uses surface alone when no value is provided", () => {
78
+ const payload = { surface: "bash" } satisfies PermissionPromptEventPayload;
79
+
80
+ assert.equal(
81
+ buildPermissionPromptMessage(payload),
82
+ "Agent requested bash access.",
83
+ );
84
+ });
85
+
86
+ it("uses value alone when no surface is provided", () => {
87
+ const payload = { value: "rm -rf build" } satisfies PermissionPromptEventPayload;
88
+
89
+ assert.equal(
90
+ buildPermissionPromptMessage(payload),
91
+ "Agent requested 'rm -rf build'.",
92
+ );
93
+ });
94
+
95
+ it("falls back to the message when surface and value are missing", () => {
96
+ const payload = {
97
+ requestId: "req-2",
98
+ message: "Allow access to the workspace?",
99
+ } satisfies PermissionPromptEventPayload;
100
+
101
+ assert.equal(
102
+ buildPermissionPromptMessage(payload),
103
+ "Allow access to the workspace?",
104
+ );
105
+ });
106
+
107
+ it("does not duplicate the message when it equals the request clause", () => {
108
+ const payload = {
109
+ surface: "bash",
110
+ value: "npm test",
111
+ agentName: "Current agent",
112
+ message: "Current agent requested bash 'npm test'.",
113
+ } satisfies PermissionPromptEventPayload;
114
+
115
+ assert.equal(
116
+ buildPermissionPromptMessage(payload),
117
+ "Current agent requested bash 'npm test'.",
118
+ );
119
+ });
120
+
121
+ it("returns a generic fallback for an empty payload", () => {
122
+ assert.equal(
123
+ buildPermissionPromptMessage({}),
124
+ "Pi is waiting for a permission decision.",
125
+ );
126
+ });
127
+
128
+ it("handles null, undefined and non-object payloads without throwing", () => {
129
+ for (const payload of [null, undefined, 42, "nope", true]) {
130
+ assert.equal(
131
+ buildPermissionPromptMessage(payload),
132
+ "Pi is waiting for a permission decision.",
133
+ );
134
+ }
135
+ });
136
+
137
+ it("treats empty and whitespace-only strings as absent", () => {
138
+ const payload = {
139
+ surface: " ",
140
+ value: "",
141
+ agentName: " ",
142
+ message: "\t\n",
143
+ } satisfies PermissionPromptEventPayload;
144
+
145
+ assert.equal(
146
+ buildPermissionPromptMessage(payload),
147
+ "Pi is waiting for a permission decision.",
148
+ );
149
+ });
150
+
151
+ it("ignores non-string surface/value/message fields", () => {
152
+ const payload = {
153
+ surface: 123,
154
+ value: { cmd: "ls" },
155
+ message: ["a"],
156
+ } as unknown as PermissionPromptEventPayload;
157
+
158
+ assert.equal(
159
+ buildPermissionPromptMessage(payload),
160
+ "Pi is waiting for a permission decision.",
161
+ );
162
+ });
163
+
164
+ it("still reports a forwarded prompt with no other detail", () => {
165
+ const payload = { forwarding: true } satisfies PermissionPromptEventPayload;
166
+
167
+ assert.equal(
168
+ buildPermissionPromptMessage(payload),
169
+ "Pi is waiting for a permission decision. (forwarded)",
170
+ );
171
+ });
172
+ });