@pi-unipi/notify 2.1.2 → 2.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/events.ts +119 -81
- package/package.json +2 -2
- package/settings.ts +1 -0
- package/skills/configure-notify/SKILL.md +3 -1
package/README.md
CHANGED
|
@@ -24,7 +24,8 @@ Notify subscribes to Pi lifecycle events and routes notifications based on your
|
|
|
24
24
|
| `workflow_end` | On | Workflow command completes |
|
|
25
25
|
| `ralph_loop_end` | On | Ralph loop completes |
|
|
26
26
|
| `mcp_server_error` | On | MCP server error |
|
|
27
|
-
| `agent_end` | Off |
|
|
27
|
+
| `agent_end` | Off | Low-level agent run ends (may fire again on retries) |
|
|
28
|
+
| `agent_settled` | Off | Agent fully settles after retries, compaction, and queued continuations |
|
|
28
29
|
| `memory_consolidated` | Off | Memory auto-saved |
|
|
29
30
|
| `session_shutdown` | Off | Session ends |
|
|
30
31
|
|
package/events.ts
CHANGED
|
@@ -50,7 +50,8 @@ export const BUILTIN_EVENTS: Record<
|
|
|
50
50
|
string,
|
|
51
51
|
{ hook: string; label: string }
|
|
52
52
|
> = {
|
|
53
|
-
agent_end: { hook: "agent_end", label: "Agent Complete" },
|
|
53
|
+
agent_end: { hook: "agent_end", label: "Agent Run Complete" },
|
|
54
|
+
agent_settled: { hook: "agent_settled", label: "Agent Complete" },
|
|
54
55
|
workflow_end: { hook: UNIPI_EVENTS.WORKFLOW_END, label: "Workflow Done" },
|
|
55
56
|
ralph_loop_end: { hook: UNIPI_EVENTS.RALPH_LOOP_END, label: "Ralph Complete" },
|
|
56
57
|
mcp_server_error: { hook: UNIPI_EVENTS.MCP_SERVER_ERROR, label: "MCP Error" },
|
|
@@ -63,7 +64,7 @@ export const BUILTIN_EVENTS: Record<
|
|
|
63
64
|
* Pi lifecycle event types (dispatched by ExtensionRunner).
|
|
64
65
|
* These must use pi.on() — not pi.events.on() — to receive events.
|
|
65
66
|
*/
|
|
66
|
-
const LIFECYCLE_EVENTS = new Set(["agent_end", "session_shutdown"]);
|
|
67
|
+
const LIFECYCLE_EVENTS = new Set(["agent_end", "agent_settled", "session_shutdown"]);
|
|
67
68
|
|
|
68
69
|
/**
|
|
69
70
|
* Register event listeners for all enabled notification events.
|
|
@@ -77,9 +78,9 @@ export function registerEventListeners(
|
|
|
77
78
|
// Remove all previously registered EventBus listeners to prevent accumulation
|
|
78
79
|
// across reloads (EventBus persists but module instances are replaced).
|
|
79
80
|
unregisterAll();
|
|
80
|
-
// Register built-in events (except
|
|
81
|
+
// Register built-in events (except agent lifecycle notifications which have custom logic)
|
|
81
82
|
for (const [eventKey, def] of Object.entries(BUILTIN_EVENTS)) {
|
|
82
|
-
if (eventKey
|
|
83
|
+
if (isAgentNotificationEvent(eventKey)) continue; // handled separately below
|
|
83
84
|
|
|
84
85
|
const eventConfig = config.events[eventKey];
|
|
85
86
|
if (!eventConfig?.enabled) continue;
|
|
@@ -95,8 +96,8 @@ export function registerEventListeners(
|
|
|
95
96
|
);
|
|
96
97
|
};
|
|
97
98
|
|
|
98
|
-
//
|
|
99
|
-
//
|
|
99
|
+
// Pi lifecycle events are dispatched via ExtensionRunner — must use
|
|
100
|
+
// pi.on(). These are stored in
|
|
100
101
|
// extension.handlers and automatically replaced on reload, so they
|
|
101
102
|
// do NOT accumulate like EventBus listeners.
|
|
102
103
|
if (LIFECYCLE_EVENTS.has(eventKey)) {
|
|
@@ -120,55 +121,8 @@ export function registerEventListeners(
|
|
|
120
121
|
}));
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (agentEndConfig?.enabled) {
|
|
126
|
-
const handler = (payload: unknown) => {
|
|
127
|
-
// Fire-and-forget: build message and dispatch in background,
|
|
128
|
-
// don't block agent_end from completing
|
|
129
|
-
const sessionName = pi.getSessionName?.();
|
|
130
|
-
const title = `Pi — ${BUILTIN_EVENTS.agent_end.label}`;
|
|
131
|
-
|
|
132
|
-
if (config.recap.enabled) {
|
|
133
|
-
// Recap mode: summarize asynchronously, then dispatch
|
|
134
|
-
const lastText = extractLastAssistantText(payload);
|
|
135
|
-
if (lastText && sessionCtx?.modelRegistry) {
|
|
136
|
-
const provider = extractProvider(config.recap.model);
|
|
137
|
-
const modelId = extractModelId(config.recap.model);
|
|
138
|
-
const model = sessionCtx.modelRegistry.find(provider, modelId);
|
|
139
|
-
if (model) {
|
|
140
|
-
sessionCtx.modelRegistry.getApiKeyAndHeaders(model)
|
|
141
|
-
.then((apiKeyResult) => {
|
|
142
|
-
const apiKey = apiKeyResult.ok ? (apiKeyResult as { apiKey?: string }).apiKey : undefined;
|
|
143
|
-
if (apiKey) {
|
|
144
|
-
return summarizeLastMessage(lastText, apiKey, model.baseUrl, model.api, modelId)
|
|
145
|
-
.then((recap) => sessionName ? `${sessionName}: ${recap}` : recap);
|
|
146
|
-
}
|
|
147
|
-
return buildAgentEndMessage(sessionName);
|
|
148
|
-
})
|
|
149
|
-
.catch(() => buildAgentEndMessage(sessionName))
|
|
150
|
-
.then((message) =>
|
|
151
|
-
dispatchNotification(pi, title, message, agentEndConfig.platforms, "agent_end", config, cwd)
|
|
152
|
-
)
|
|
153
|
-
.catch(() => {
|
|
154
|
-
// Silently ignore — background agent_end notification failure is non-blocking.
|
|
155
|
-
});
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// No recap or recap unavailable: dispatch immediately in background
|
|
162
|
-
const message = buildAgentEndMessage(sessionName);
|
|
163
|
-
dispatchNotification(pi, title, message, agentEndConfig.platforms, "agent_end", config, cwd).catch(
|
|
164
|
-
() => {
|
|
165
|
-
// Silently ignore — background agent_end notification failure is non-blocking.
|
|
166
|
-
}
|
|
167
|
-
);
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
(pi as any).on("agent_end", handler);
|
|
171
|
-
}
|
|
124
|
+
registerAgentNotification(pi, "agent_end", config, cwd);
|
|
125
|
+
registerAgentNotification(pi, "agent_settled", config, cwd);
|
|
172
126
|
|
|
173
127
|
// Listen for dynamic module events
|
|
174
128
|
const moduleHandler = async (payload: unknown) => {
|
|
@@ -335,7 +289,9 @@ function buildEventMessage(eventKey: string, payload: unknown): string {
|
|
|
335
289
|
case "mcp_server_error":
|
|
336
290
|
return `Server "${String(p.name || "unknown")}" error: ${String(p.error || "unknown error")}`;
|
|
337
291
|
case "agent_end":
|
|
338
|
-
return "Agent finished responding";
|
|
292
|
+
return "Agent run finished responding";
|
|
293
|
+
case "agent_settled":
|
|
294
|
+
return "Agent is complete";
|
|
339
295
|
case "memory_consolidated":
|
|
340
296
|
return `Memory consolidated (${p.count || 0} items)`;
|
|
341
297
|
case "session_shutdown":
|
|
@@ -347,13 +303,93 @@ function buildEventMessage(eventKey: string, payload: unknown): string {
|
|
|
347
303
|
}
|
|
348
304
|
}
|
|
349
305
|
|
|
350
|
-
/**
|
|
351
|
-
function
|
|
352
|
-
|
|
353
|
-
|
|
306
|
+
/** Register an agent lifecycle notification with session name and recap support. */
|
|
307
|
+
function registerAgentNotification(
|
|
308
|
+
pi: ExtensionAPI,
|
|
309
|
+
eventKey: "agent_end" | "agent_settled",
|
|
310
|
+
config: NotifyConfig,
|
|
311
|
+
cwd: string
|
|
312
|
+
): void {
|
|
313
|
+
const eventConfig = config.events[eventKey];
|
|
314
|
+
if (!eventConfig?.enabled) return;
|
|
315
|
+
|
|
316
|
+
const handler = (payload: unknown) => {
|
|
317
|
+
// Fire-and-forget: build message and dispatch in background,
|
|
318
|
+
// don't block agent lifecycle hooks from completing.
|
|
319
|
+
const sessionName = pi.getSessionName?.();
|
|
320
|
+
const title = `Pi — ${BUILTIN_EVENTS[eventKey].label}`;
|
|
321
|
+
|
|
322
|
+
if (config.recap.enabled) {
|
|
323
|
+
// Recap mode: summarize asynchronously, then dispatch.
|
|
324
|
+
// agent_settled does not currently include a messages payload, so fall
|
|
325
|
+
// back to the latest assistant message in the session.
|
|
326
|
+
const lastText = extractLastAssistantText(payload) ?? extractLastAssistantTextFromSession();
|
|
327
|
+
if (lastText && sessionCtx?.modelRegistry) {
|
|
328
|
+
const provider = extractProvider(config.recap.model);
|
|
329
|
+
const modelId = extractModelId(config.recap.model);
|
|
330
|
+
const model = sessionCtx.modelRegistry.find(provider, modelId);
|
|
331
|
+
if (model) {
|
|
332
|
+
sessionCtx.modelRegistry.getApiKeyAndHeaders(model)
|
|
333
|
+
.then((apiKeyResult) => {
|
|
334
|
+
const apiKey = apiKeyResult.ok ? (apiKeyResult as { apiKey?: string }).apiKey : undefined;
|
|
335
|
+
if (apiKey) {
|
|
336
|
+
return summarizeLastMessage(lastText, apiKey, model.baseUrl, model.api, modelId)
|
|
337
|
+
.then((recap) => sessionName ? `${sessionName}: ${recap}` : recap);
|
|
338
|
+
}
|
|
339
|
+
return buildAgentLifecycleMessage(eventKey, sessionName);
|
|
340
|
+
})
|
|
341
|
+
.catch(() => buildAgentLifecycleMessage(eventKey, sessionName))
|
|
342
|
+
.then((message) =>
|
|
343
|
+
dispatchNotification(pi, title, message, eventConfig.platforms, eventKey, config, cwd)
|
|
344
|
+
)
|
|
345
|
+
.catch(() => {
|
|
346
|
+
// Silently ignore — background agent notification failure is non-blocking.
|
|
347
|
+
});
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// No recap or recap unavailable: dispatch immediately in background.
|
|
354
|
+
const message = buildAgentLifecycleMessage(eventKey, sessionName);
|
|
355
|
+
dispatchNotification(pi, title, message, eventConfig.platforms, eventKey, config, cwd).catch(
|
|
356
|
+
() => {
|
|
357
|
+
// Silently ignore — background agent notification failure is non-blocking.
|
|
358
|
+
}
|
|
359
|
+
);
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
(pi as any).on(eventKey, handler);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Whether an event key is an agent lifecycle notification with custom handling. */
|
|
366
|
+
function isAgentNotificationEvent(eventKey: string): eventKey is "agent_end" | "agent_settled" {
|
|
367
|
+
return eventKey === "agent_end" || eventKey === "agent_settled";
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Build agent lifecycle message using session name. */
|
|
371
|
+
function buildAgentLifecycleMessage(
|
|
372
|
+
eventKey: "agent_end" | "agent_settled",
|
|
373
|
+
sessionName: string | undefined
|
|
374
|
+
): string {
|
|
375
|
+
const status = eventKey === "agent_end" ? "Agent run is complete" : "Agent is complete";
|
|
376
|
+
if (sessionName) return `${sessionName} - ${status}`;
|
|
377
|
+
return status;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Extract text from the latest assistant message in the current session. */
|
|
381
|
+
function extractLastAssistantTextFromSession(): string | null {
|
|
382
|
+
const entries = sessionCtx?.sessionManager.getEntries() ?? [];
|
|
383
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
384
|
+
const entry = entries[i];
|
|
385
|
+
if (entry?.type !== "message") continue;
|
|
386
|
+
const text = extractAssistantText(entry.message);
|
|
387
|
+
if (text) return text;
|
|
388
|
+
}
|
|
389
|
+
return null;
|
|
354
390
|
}
|
|
355
391
|
|
|
356
|
-
/** Extract text from the last assistant message in
|
|
392
|
+
/** Extract text from the last assistant message in an agent lifecycle payload. */
|
|
357
393
|
function extractLastAssistantText(payload: unknown): string | null {
|
|
358
394
|
const p = payload as { messages?: Array<{ role?: string; content?: unknown }> };
|
|
359
395
|
if (!p?.messages || !Array.isArray(p.messages)) return null;
|
|
@@ -363,21 +399,31 @@ function extractLastAssistantText(payload: unknown): string | null {
|
|
|
363
399
|
const msg = p.messages[i];
|
|
364
400
|
if (msg?.role !== "assistant") continue;
|
|
365
401
|
|
|
366
|
-
const
|
|
367
|
-
if (
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
402
|
+
const text = extractAssistantText(msg);
|
|
403
|
+
if (text) return text;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Extract text from an assistant message-like object. */
|
|
410
|
+
function extractAssistantText(message: { role?: string; content?: unknown }): string | null {
|
|
411
|
+
if (message.role !== "assistant") return null;
|
|
412
|
+
|
|
413
|
+
const content = message.content;
|
|
414
|
+
if (typeof content === "string") return content;
|
|
415
|
+
if (Array.isArray(content)) {
|
|
416
|
+
// Extract text blocks from content array.
|
|
417
|
+
const textParts: string[] = [];
|
|
418
|
+
for (const block of content) {
|
|
419
|
+
if (typeof block === "object" && block !== null) {
|
|
420
|
+
const b = block as { type?: string; text?: string };
|
|
421
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
422
|
+
textParts.push(b.text);
|
|
377
423
|
}
|
|
378
424
|
}
|
|
379
|
-
if (textParts.length > 0) return textParts.join("\n");
|
|
380
425
|
}
|
|
426
|
+
if (textParts.length > 0) return textParts.join("\n");
|
|
381
427
|
}
|
|
382
428
|
|
|
383
429
|
return null;
|
|
@@ -394,11 +440,3 @@ function extractModelId(modelRef: string): string {
|
|
|
394
440
|
const slashIdx = modelRef.indexOf("/");
|
|
395
441
|
return slashIdx > 0 ? modelRef.slice(slashIdx + 1) : modelRef;
|
|
396
442
|
}
|
|
397
|
-
|
|
398
|
-
/** Resolve API key for a provider from environment variables */
|
|
399
|
-
function resolveApiKey(modelRef: string): string | undefined {
|
|
400
|
-
const provider = extractProvider(modelRef);
|
|
401
|
-
// Try standard env var patterns
|
|
402
|
-
const envKey = `${provider.toUpperCase().replace(/-/g, "_")}_API_KEY`;
|
|
403
|
-
return process.env[envKey];
|
|
404
|
-
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/notify",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.3",
|
|
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",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"notifications"
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
|
-
"test": "tsc --noEmit &&
|
|
23
|
+
"test": "tsc --noEmit && npx tsx --test src/__tests__/*.test.ts",
|
|
24
24
|
"typecheck": "tsc --noEmit"
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
package/settings.ts
CHANGED
|
@@ -24,6 +24,7 @@ export const DEFAULT_CONFIG: NotifyConfig = {
|
|
|
24
24
|
ralph_loop_end: { enabled: true, platforms: [] },
|
|
25
25
|
mcp_server_error: { enabled: true, platforms: [] },
|
|
26
26
|
agent_end: { enabled: false, platforms: [] },
|
|
27
|
+
agent_settled: { enabled: false, platforms: [] },
|
|
27
28
|
memory_consolidated: { enabled: false, platforms: [] },
|
|
28
29
|
session_shutdown: { enabled: false, platforms: [] },
|
|
29
30
|
ask_user_prompt: { enabled: false, platforms: [] },
|
|
@@ -34,6 +34,7 @@ Help users configure the `@pi-unipi/notify` notification system.
|
|
|
34
34
|
"ralph_loop_end": { "enabled": true, "platforms": [] },
|
|
35
35
|
"mcp_server_error": { "enabled": true, "platforms": [] },
|
|
36
36
|
"agent_end": { "enabled": false, "platforms": [] },
|
|
37
|
+
"agent_settled": { "enabled": false, "platforms": [] },
|
|
37
38
|
"memory_consolidated": { "enabled": false, "platforms": [] },
|
|
38
39
|
"session_shutdown": { "enabled": false, "platforms": [] }
|
|
39
40
|
},
|
|
@@ -150,7 +151,8 @@ ntfy uses dedicated `ntfy.json` files at both global and project scope, with ful
|
|
|
150
151
|
| `workflow_end` | On | Workflow command completes |
|
|
151
152
|
| `ralph_loop_end` | On | Ralph loop completes |
|
|
152
153
|
| `mcp_server_error` | On | MCP server error |
|
|
153
|
-
| `agent_end` | Off |
|
|
154
|
+
| `agent_end` | Off | Low-level agent run ends (may fire again on retries) |
|
|
155
|
+
| `agent_settled` | Off | Agent fully settles after retries, compaction, and queued continuations |
|
|
154
156
|
| `memory_consolidated` | Off | Memory auto-saved |
|
|
155
157
|
| `session_shutdown` | Off | Session ends |
|
|
156
158
|
|