@pi-unipi/notify 0.1.6 → 0.1.8

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 (4) hide show
  1. package/events.ts +29 -25
  2. package/package.json +2 -1
  3. package/summarize.ts +149 -0
  4. package/tools.ts +7 -14
package/events.ts CHANGED
@@ -56,10 +56,13 @@ export function registerEventListeners(
56
56
  const eventConfig = config.events[eventKey];
57
57
  if (!eventConfig?.enabled) continue;
58
58
 
59
- const handler = async (payload: unknown) => {
59
+ const handler = (payload: unknown) => {
60
60
  const title = `Pi — ${def.label}`;
61
61
  const message = buildEventMessage(eventKey, payload);
62
- await dispatchNotification(pi, title, message, eventConfig.platforms, eventKey, config);
62
+ // Fire-and-forget: don't block the event emitter
63
+ dispatchNotification(pi, title, message, eventConfig.platforms, eventKey, config).catch(
64
+ (err) => console.error(`[notify] Background notification failed for ${eventKey}:`, err)
65
+ );
63
66
  };
64
67
 
65
68
  (pi as any).on(def.hook, handler);
@@ -68,43 +71,44 @@ export function registerEventListeners(
68
71
  // agent_end — custom handler with session name and recap support
69
72
  const agentEndConfig = config.events["agent_end"];
70
73
  if (agentEndConfig?.enabled) {
71
- const handler = async (payload: unknown) => {
74
+ const handler = (payload: unknown) => {
75
+ // Fire-and-forget: build message and dispatch in background,
76
+ // don't block agent_end from completing
72
77
  const sessionName = pi.getSessionName?.();
73
78
  const title = `Pi — ${BUILTIN_EVENTS.agent_end.label}`;
74
- let message: string;
75
79
 
76
80
  if (config.recap.enabled) {
77
- // Recap mode: summarize last assistant message
81
+ // Recap mode: summarize asynchronously, then dispatch
78
82
  const lastText = extractLastAssistantText(payload);
79
83
  if (lastText && sessionCtx?.modelRegistry) {
80
84
  const provider = extractProvider(config.recap.model);
81
85
  const modelId = extractModelId(config.recap.model);
82
86
  const model = sessionCtx.modelRegistry.find(provider, modelId);
83
87
  if (model) {
84
- try {
85
- const apiKeyResult = await sessionCtx.modelRegistry.getApiKeyAndHeaders(model);
86
- const apiKey = apiKeyResult.ok ? (apiKeyResult as { apiKey?: string }).apiKey : undefined;
87
- if (apiKey) {
88
- const recap = await summarizeLastMessage(lastText, apiKey, model.baseUrl, model.api, modelId);
89
- message = sessionName ? `${sessionName}: ${recap}` : recap;
90
- } else {
91
- message = buildAgentEndMessage(sessionName);
92
- }
93
- } catch {
94
- message = buildAgentEndMessage(sessionName);
95
- }
96
- } else {
97
- message = buildAgentEndMessage(sessionName);
88
+ sessionCtx.modelRegistry.getApiKeyAndHeaders(model)
89
+ .then((apiKeyResult) => {
90
+ const apiKey = apiKeyResult.ok ? (apiKeyResult as { apiKey?: string }).apiKey : undefined;
91
+ if (apiKey) {
92
+ return summarizeLastMessage(lastText, apiKey, model.baseUrl, model.api, modelId)
93
+ .then((recap) => sessionName ? `${sessionName}: ${recap}` : recap);
94
+ }
95
+ return buildAgentEndMessage(sessionName);
96
+ })
97
+ .catch(() => buildAgentEndMessage(sessionName))
98
+ .then((message) =>
99
+ dispatchNotification(pi, title, message, agentEndConfig.platforms, "agent_end", config)
100
+ )
101
+ .catch((err) => console.error("[notify] Background agent_end notification failed:", err));
102
+ return;
98
103
  }
99
- } else {
100
- message = buildAgentEndMessage(sessionName);
101
104
  }
102
- } else {
103
- // No recap: use session name based message
104
- message = buildAgentEndMessage(sessionName);
105
105
  }
106
106
 
107
- await dispatchNotification(pi, title, message, agentEndConfig.platforms, "agent_end", config);
107
+ // No recap or recap unavailable: dispatch immediately in background
108
+ const message = buildAgentEndMessage(sessionName);
109
+ dispatchNotification(pi, title, message, agentEndConfig.platforms, "agent_end", config).catch(
110
+ (err) => console.error("[notify] Background agent_end notification failed:", err)
111
+ );
108
112
  };
109
113
 
110
114
  (pi as any).on("agent_end", handler);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/notify",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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
  "license": "MIT",
@@ -24,6 +24,7 @@
24
24
  "commands.ts",
25
25
  "settings.ts",
26
26
  "events.ts",
27
+ "summarize.ts",
27
28
  "types.ts",
28
29
  "platforms/*",
29
30
  "tui/*",
package/summarize.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @pi-unipi/notify — Recap summarization
3
+ *
4
+ * Calls an LLM to summarize the last assistant message for push notifications.
5
+ * Supports multiple API formats based on the model's api type.
6
+ */
7
+
8
+ const SYSTEM_PROMPT =
9
+ "Summarize this in one concise sentence for a push notification. Reply with ONLY the summary.";
10
+ const MAX_INPUT_CHARS = 2000;
11
+ const MAX_TOKENS = 100;
12
+ const TIMEOUT_MS = 10_000;
13
+ const FALLBACK_TRUNCATE_CHARS = 100;
14
+
15
+ /**
16
+ * Summarize a message using an LLM.
17
+ *
18
+ * @param messageText - The assistant message text to summarize
19
+ * @param apiKey - API key for the provider
20
+ * @param baseUrl - Provider base URL (from Model.baseUrl)
21
+ * @param api - API type (from Model.api, e.g. "openai-completions")
22
+ * @param modelId - Model ID to use
23
+ * @returns Summarized text, or truncated original on failure
24
+ */
25
+ export async function summarizeLastMessage(
26
+ messageText: string,
27
+ apiKey: string,
28
+ baseUrl: string,
29
+ api: string,
30
+ modelId: string,
31
+ ): Promise<string> {
32
+ // Truncate input if too long
33
+ const input =
34
+ messageText.length > MAX_INPUT_CHARS
35
+ ? messageText.slice(0, MAX_INPUT_CHARS) + "..."
36
+ : messageText;
37
+
38
+ try {
39
+ // Route to the correct API format
40
+ if (api === "anthropic-messages") {
41
+ return await callAnthropic(baseUrl, apiKey, modelId, input);
42
+ }
43
+ // Default: OpenAI-compatible (covers openai-completions, openai-responses, etc.)
44
+ return await callOpenAICompatible(baseUrl, apiKey, modelId, input);
45
+ } catch {
46
+ return fallbackSummary(messageText);
47
+ }
48
+ }
49
+
50
+ /** Call an OpenAI-compatible API (most providers) */
51
+ async function callOpenAICompatible(
52
+ baseUrl: string,
53
+ apiKey: string,
54
+ modelId: string,
55
+ input: string,
56
+ ): Promise<string> {
57
+ const url = `${baseUrl.replace(/\/$/, "")}/chat/completions`;
58
+ const controller = new AbortController();
59
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
60
+
61
+ try {
62
+ const response = await fetch(url, {
63
+ method: "POST",
64
+ headers: {
65
+ Authorization: `Bearer ${apiKey}`,
66
+ "Content-Type": "application/json",
67
+ },
68
+ body: JSON.stringify({
69
+ model: modelId,
70
+ max_tokens: MAX_TOKENS,
71
+ messages: [
72
+ { role: "system", content: SYSTEM_PROMPT },
73
+ { role: "user", content: input },
74
+ ],
75
+ }),
76
+ signal: controller.signal,
77
+ });
78
+
79
+ clearTimeout(timeout);
80
+
81
+ if (!response.ok) {
82
+ return fallbackSummary(input);
83
+ }
84
+
85
+ const data = (await response.json()) as {
86
+ choices?: Array<{ message?: { content?: string } }>;
87
+ };
88
+
89
+ const summary = data.choices?.[0]?.message?.content?.trim();
90
+ return summary && summary.length > 0 ? summary : fallbackSummary(input);
91
+ } catch {
92
+ clearTimeout(timeout);
93
+ return fallbackSummary(input);
94
+ }
95
+ }
96
+
97
+ /** Call the Anthropic Messages API */
98
+ async function callAnthropic(
99
+ baseUrl: string,
100
+ apiKey: string,
101
+ modelId: string,
102
+ input: string,
103
+ ): Promise<string> {
104
+ const url = `${baseUrl.replace(/\/$/, "")}/messages`;
105
+ const controller = new AbortController();
106
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
107
+
108
+ try {
109
+ const response = await fetch(url, {
110
+ method: "POST",
111
+ headers: {
112
+ "x-api-key": apiKey,
113
+ "anthropic-version": "2023-06-01",
114
+ "Content-Type": "application/json",
115
+ },
116
+ body: JSON.stringify({
117
+ model: modelId,
118
+ max_tokens: MAX_TOKENS,
119
+ system: SYSTEM_PROMPT,
120
+ messages: [{ role: "user", content: input }],
121
+ }),
122
+ signal: controller.signal,
123
+ });
124
+
125
+ clearTimeout(timeout);
126
+
127
+ if (!response.ok) {
128
+ return fallbackSummary(input);
129
+ }
130
+
131
+ const data = (await response.json()) as {
132
+ content?: Array<{ type?: string; text?: string }>;
133
+ };
134
+
135
+ const textBlock = data.content?.find((b) => b.type === "text");
136
+ const summary = textBlock?.text?.trim();
137
+ return summary && summary.length > 0 ? summary : fallbackSummary(input);
138
+ } catch {
139
+ clearTimeout(timeout);
140
+ return fallbackSummary(input);
141
+ }
142
+ }
143
+
144
+ /** Truncate message as fallback when summarization fails */
145
+ function fallbackSummary(messageText: string): string {
146
+ const trimmed = messageText.trim();
147
+ if (trimmed.length <= FALLBACK_TRUNCATE_CHARS) return trimmed;
148
+ return trimmed.slice(0, FALLBACK_TRUNCATE_CHARS) + "...";
149
+ }
package/tools.ts CHANGED
@@ -9,7 +9,6 @@ import { Type } from "@sinclair/typebox";
9
9
  import { NOTIFY_TOOLS } from "@pi-unipi/core";
10
10
  import { loadConfig } from "./settings.js";
11
11
  import { dispatchNotification } from "./events.js";
12
- import type { NotifyDispatchResult } from "./types.js";
13
12
 
14
13
  /** Schema for notify_user tool parameters */
15
14
  const NotifyUserSchema = Type.Object({
@@ -43,7 +42,7 @@ export function registerNotifyTools(pi: ExtensionAPI): void {
43
42
  "Send a notification to the user's configured platforms (native OS, Gotify, Telegram, ntfy). " +
44
43
  "Use for critical errors, completion of long-running tasks, or when the user explicitly asked to be notified.",
45
44
  parameters: NotifyUserSchema,
46
- async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
45
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx: ExtensionContext) {
47
46
  const {
48
47
  message,
49
48
  title,
@@ -64,32 +63,26 @@ export function registerNotifyTools(pi: ExtensionAPI): void {
64
63
  // Resolve platforms — use params.platforms or global defaults
65
64
  const notifPlatforms = platforms || config.defaultPlatforms;
66
65
 
67
- // Dispatch notification
68
- const result: NotifyDispatchResult = await dispatchNotification(
66
+ // Fire-and-forget: dispatch in background so the tool doesn't block the agent
67
+ dispatchNotification(
69
68
  pi,
70
69
  notifTitle,
71
70
  message,
72
71
  notifPlatforms,
73
72
  "agent_tool",
74
73
  config
75
- );
76
-
77
- // Format result
78
- const platformResults = result.results.map(
79
- (r) => `${r.platform}: ${r.success ? "✓ sent" : `✗ ${r.error || "failed"}`}`
74
+ ).catch((err) =>
75
+ console.error("[notify] Background notify_user dispatch failed:", err)
80
76
  );
81
77
 
82
78
  return {
83
79
  content: [
84
80
  {
85
81
  type: "text" as const,
86
- text: `Notification sent to ${result.results.length} platform(s):\n${platformResults.join("\n")}`,
82
+ text: `Notification sending to ${notifPlatforms.length} platform(s): ${notifPlatforms.join(", ")}`,
87
83
  },
88
84
  ],
89
- details: {
90
- platforms: result.results.map((r) => r.platform),
91
- allSuccess: result.allSuccess,
92
- },
85
+ details: { platforms: notifPlatforms },
93
86
  };
94
87
  },
95
88
  });