@sillybit/opencode-plugin-everynotify 0.2.0 → 0.3.4

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 CHANGED
@@ -27,6 +27,7 @@ In long-running development tasks or deep research sessions, it's common to swit
27
27
  - ✅ **Privacy & Control**: Completely opt-in; no notifications are sent until you enable and configure a service.
28
28
  - ✅ **Event Filtering**: Selectively enable or disable notifications for specific event types.
29
29
  - ✅ **Rich Content**: Notifications include the actual assistant response text for better context.
30
+ - ✅ **Truncation Direction**: Choose whether to keep the beginning or end of long messages when they exceed service limits.
30
31
 
31
32
  ## Installation
32
33
 
@@ -117,7 +118,8 @@ Create your `.everynotify.json` with the tokens for the services you want to use
117
118
  "error": true,
118
119
  "permission": false,
119
120
  "question": true
120
- }
121
+ },
122
+ "truncateFrom": "end"
121
123
  }
122
124
  ```
123
125
 
@@ -135,6 +137,41 @@ You can control which events trigger notifications by adding an `events` block t
135
137
  | `permission` | opencode is waiting for you to grant permission for a tool or file access. |
136
138
  | `question` | The `question` tool was used to ask you for clarification. |
137
139
 
140
+ ### Message Truncation
141
+
142
+ Each notification service has a maximum message length (e.g., Pushover: 1024 chars, Discord: 2000 chars). When a message exceeds the limit, EveryNotify truncates it and adds `...` as an indicator.
143
+
144
+ The `truncateFrom` option controls which part of the message is kept:
145
+
146
+ | Value | Behavior | Best For |
147
+ | --------- | ---------------------------------- | --------------------------------------------- |
148
+ | `"end"` | Keep beginning, trim end (default) | When the start of the message has the context |
149
+ | `"start"` | Keep end, trim beginning | When the conclusion/result at the end matters |
150
+
151
+ **Global setting** applies to all services:
152
+
153
+ ```json
154
+ {
155
+ "truncateFrom": "start"
156
+ }
157
+ ```
158
+
159
+ **Per-service override** — for example, keep message endings on Pushover but beginnings on Slack:
160
+
161
+ ```json
162
+ {
163
+ "truncateFrom": "end",
164
+ "pushover": {
165
+ "enabled": true,
166
+ "token": "...",
167
+ "userKey": "...",
168
+ "truncateFrom": "start"
169
+ }
170
+ }
171
+ ```
172
+
173
+ Service-level `truncateFrom` takes priority over the global setting.
174
+
138
175
  ### Rich Message Content
139
176
 
140
177
  EveryNotify provides rich context in its notifications by extracting the assistant's last response. Instead of generic "Task completed" messages, you receive the actual summary or answer provided by opencode.
@@ -227,6 +264,10 @@ For Pushover users, you can customize the `priority` level:
227
264
  - `1`: High priority (bypasses quiet hours)
228
265
  - `2`: Emergency priority (requires acknowledgment)
229
266
 
267
+ ### Message Truncation Direction
268
+
269
+ See [Message Truncation](#message-truncation) under Configuration Options for details on the `truncateFrom` setting. This is especially useful for services with tight message limits like Pushover (1024 chars), where the end of a message often contains the most important information (the result or conclusion).
270
+
230
271
  ### Session Enrichment
231
272
 
232
273
  EveryNotify automatically calculates the time elapsed since the first user message in a session. This duration is included in the notification text to give you context on how long the task took to complete.
package/dist/config.d.ts CHANGED
@@ -20,10 +20,14 @@ export declare const DEFAULT_CONFIG: EverynotifyConfig;
20
20
  * @returns full path to config file
21
21
  */
22
22
  export declare function getConfigPath(scope: "global" | "project", directory: string): string;
23
+ export interface LoadConfigResult {
24
+ config: EverynotifyConfig;
25
+ warnings: string[];
26
+ }
23
27
  /**
24
28
  * Load configuration from global and project scopes
25
29
  * Merge order: defaults ← global ← project (project wins)
26
30
  * @param directory project directory
27
- * @returns merged EverynotifyConfig
31
+ * @returns config and any warnings encountered during loading
28
32
  */
29
- export declare function loadConfig(directory: string): EverynotifyConfig;
33
+ export declare function loadConfig(directory: string): LoadConfigResult;
@@ -6,13 +6,9 @@
6
6
  * - Timeout: 5s per service call (via AbortController)
7
7
  * - Error isolation: One service failing doesn't block others (Promise.allSettled)
8
8
  */
9
- import type { EverynotifyConfig, NotificationPayload } from "./types";
9
+ import type { EverynotifyConfig, NotificationPayload, TruncationMode } from "./types";
10
10
  import type { Logger } from "./logger";
11
- /**
12
- * Truncate text to max length, appending "… [truncated]" if over limit
13
- * Shared utility function used by all services
14
- */
15
- export declare function truncate(text: string, maxLength: number): string;
11
+ export declare function truncate(text: string, maxLength: number, from?: TruncationMode): string;
16
12
  /**
17
13
  * Dispatcher interface returned by createDispatcher
18
14
  */
package/dist/index.js CHANGED
@@ -35,7 +35,8 @@ var DEFAULT_CONFIG = {
35
35
  error: true,
36
36
  permission: true,
37
37
  question: true
38
- }
38
+ },
39
+ truncateFrom: "end"
39
40
  };
40
41
  function getConfigPath(scope, directory) {
41
42
  if (scope === "global") {
@@ -63,9 +64,12 @@ function deepMerge(target, source) {
63
64
  if (source.events) {
64
65
  result.events = { ...result.events, ...source.events };
65
66
  }
67
+ if (source.truncateFrom !== undefined) {
68
+ result.truncateFrom = source.truncateFrom;
69
+ }
66
70
  return result;
67
71
  }
68
- function loadConfigFile(filePath) {
72
+ function loadConfigFile(filePath, warnings) {
69
73
  try {
70
74
  if (!fs.existsSync(filePath)) {
71
75
  return null;
@@ -75,208 +79,182 @@ function loadConfigFile(filePath) {
75
79
  return parsed;
76
80
  } catch (error) {
77
81
  const errorMsg = error instanceof Error ? error.message : String(error);
78
- console.error(`[EveryNotify] Failed to load config from ${filePath}: ${errorMsg}`);
82
+ warnings.push(`Failed to load config from ${filePath}: ${errorMsg}`);
79
83
  return null;
80
84
  }
81
85
  }
82
86
  function loadConfig(directory) {
87
+ const warnings = [];
83
88
  let config = { ...DEFAULT_CONFIG };
84
89
  const globalPath = getConfigPath("global", directory);
85
- const globalConfig = loadConfigFile(globalPath);
90
+ const globalConfig = loadConfigFile(globalPath, warnings);
86
91
  if (globalConfig) {
87
92
  config = deepMerge(config, globalConfig);
88
93
  }
89
94
  const projectPath = getConfigPath("project", directory);
90
- const projectConfig = loadConfigFile(projectPath);
95
+ const projectConfig = loadConfigFile(projectPath, warnings);
91
96
  if (projectConfig) {
92
97
  config = deepMerge(config, projectConfig);
93
98
  }
94
99
  const allDisabled = !config.pushover.enabled && !config.telegram.enabled && !config.slack.enabled && !config.discord.enabled;
95
100
  if (allDisabled) {
96
- console.error("[EveryNotify] No services configured. Enable services in .everynotify.json");
101
+ warnings.push("No services configured. Enable services in .everynotify.json");
97
102
  }
98
- return config;
103
+ return { config, warnings };
99
104
  }
100
105
 
101
106
  // src/services/pushover.ts
102
- function truncate(text, maxLength) {
103
- if (text.length <= maxLength) {
104
- return text;
105
- }
106
- const suffix = "… [truncated]";
107
- return text.slice(0, maxLength - suffix.length) + suffix;
108
- }
109
107
  async function send(config, payload, signal) {
110
- try {
111
- const body = new URLSearchParams({
112
- token: config.token,
113
- user: config.userKey,
114
- message: truncate(payload.message, 1024),
115
- title: truncate(payload.title, 250),
116
- priority: String(config.priority ?? 0)
117
- });
118
- const response = await fetch("https://api.pushover.net/1/messages.json", {
119
- method: "POST",
120
- headers: {
121
- "Content-Type": "application/x-www-form-urlencoded"
122
- },
123
- body: body.toString(),
124
- signal
125
- });
126
- if (!response.ok) {
127
- const errorText = await response.text();
128
- console.error(`[EveryNotify] Pushover error: ${response.status} ${errorText}`);
129
- return;
130
- }
131
- } catch (error) {
132
- const message = error instanceof Error ? error.message : String(error);
133
- console.error(`[EveryNotify] Pushover failed: ${message}`);
108
+ const body = new URLSearchParams({
109
+ token: config.token,
110
+ user: config.userKey,
111
+ message: truncate(payload.message, 1024, config.truncateFrom),
112
+ title: truncate(payload.title, 250, config.truncateFrom),
113
+ priority: String(config.priority ?? 0)
114
+ });
115
+ const response = await fetch("https://api.pushover.net/1/messages.json", {
116
+ method: "POST",
117
+ headers: {
118
+ "Content-Type": "application/x-www-form-urlencoded"
119
+ },
120
+ body: body.toString(),
121
+ signal
122
+ });
123
+ if (!response.ok) {
124
+ const errorText = await response.text();
125
+ throw new Error(`Pushover API error: ${response.status} ${errorText}`);
134
126
  }
135
127
  }
136
128
 
137
129
  // src/services/telegram.ts
138
- function truncate2(text, maxLength) {
139
- if (text.length <= maxLength) {
140
- return text;
141
- }
142
- const suffix = "… [truncated]";
143
- return text.slice(0, maxLength - suffix.length) + suffix;
144
- }
145
- function formatMessage(payload) {
146
- const truncatedTitle = truncate2(payload.title, 250);
147
- const truncatedMessage = truncate2(payload.message, 3840);
130
+ function formatMessage(payload, truncateFrom) {
131
+ const truncatedTitle = truncate(payload.title, 250, truncateFrom);
132
+ const truncatedMessage = truncate(payload.message, 3840, truncateFrom);
148
133
  return `<b>${truncatedTitle}</b>
149
134
  ${truncatedMessage}`;
150
135
  }
151
136
  async function send2(config, payload, signal) {
152
- try {
153
- const text = formatMessage(payload);
154
- const body = JSON.stringify({
155
- chat_id: config.chatId,
156
- text,
157
- parse_mode: "HTML"
158
- });
159
- const response = await fetch(`https://api.telegram.org/bot${config.botToken}/sendMessage`, {
160
- method: "POST",
161
- headers: {
162
- "Content-Type": "application/json"
163
- },
164
- body,
165
- signal
166
- });
167
- if (!response.ok) {
168
- const errorText = await response.text();
169
- console.error(`[EveryNotify] Telegram error: ${response.status} ${errorText}`);
170
- return;
171
- }
172
- } catch (error) {
173
- const message = error instanceof Error ? error.message : String(error);
174
- console.error(`[EveryNotify] Telegram failed: ${message}`);
137
+ const text = formatMessage(payload, config.truncateFrom);
138
+ const body = JSON.stringify({
139
+ chat_id: config.chatId,
140
+ text,
141
+ parse_mode: "HTML"
142
+ });
143
+ const response = await fetch(`https://api.telegram.org/bot${config.botToken}/sendMessage`, {
144
+ method: "POST",
145
+ headers: {
146
+ "Content-Type": "application/json"
147
+ },
148
+ body,
149
+ signal
150
+ });
151
+ if (!response.ok) {
152
+ const errorText = await response.text();
153
+ throw new Error(`Telegram API error: ${response.status} ${errorText}`);
175
154
  }
176
155
  }
177
156
 
178
157
  // src/services/slack.ts
179
- function truncate3(text, maxLength) {
180
- if (text.length <= maxLength) {
181
- return text;
182
- }
183
- const suffix = "… [truncated]";
184
- return text.slice(0, maxLength - suffix.length) + suffix;
185
- }
186
158
  function formatSlackMessage(payload) {
187
159
  return `*${payload.title}*
188
160
  ${payload.message}`;
189
161
  }
190
162
  async function send3(config, payload, signal) {
191
- try {
192
- const text = truncate3(formatSlackMessage(payload), 40000);
193
- const response = await fetch(config.webhookUrl, {
194
- method: "POST",
195
- headers: {
196
- "Content-Type": "application/json"
197
- },
198
- body: JSON.stringify({ text }),
199
- signal
200
- });
201
- if (!response.ok) {
202
- const errorText = await response.text();
203
- console.error(`[EveryNotify] Slack error: ${response.status} ${errorText}`);
204
- return;
205
- }
206
- } catch (error) {
207
- const message = error instanceof Error ? error.message : String(error);
208
- console.error(`[EveryNotify] Slack failed: ${message}`);
163
+ const text = truncate(formatSlackMessage(payload), 40000, config.truncateFrom);
164
+ const response = await fetch(config.webhookUrl, {
165
+ method: "POST",
166
+ headers: {
167
+ "Content-Type": "application/json"
168
+ },
169
+ body: JSON.stringify({ text }),
170
+ signal
171
+ });
172
+ if (!response.ok) {
173
+ const errorText = await response.text();
174
+ throw new Error(`Slack API error: ${response.status} ${errorText}`);
209
175
  }
210
176
  }
211
177
 
212
178
  // src/services/discord.ts
213
- function truncate4(text, maxLength) {
214
- if (text.length <= maxLength) {
215
- return text;
216
- }
217
- const suffix = "… [truncated]";
218
- return text.slice(0, maxLength - suffix.length) + suffix;
219
- }
220
179
  function formatDiscordMessage(payload) {
221
180
  return `**${payload.title}**
222
181
  ${payload.message}`;
223
182
  }
224
183
  async function send4(config, payload, signal) {
225
- try {
226
- const content = truncate4(formatDiscordMessage(payload), 2000);
227
- const response = await fetch(config.webhookUrl, {
228
- method: "POST",
229
- headers: {
230
- "Content-Type": "application/json"
231
- },
232
- body: JSON.stringify({ content }),
233
- signal
234
- });
235
- if (response.status === 429) {
236
- const retryAfter = response.headers.get("Retry-After");
237
- console.error(`[EveryNotify] Discord rate limited. Retry-After: ${retryAfter}s`);
238
- return;
239
- }
240
- if (!response.ok) {
241
- const errorText = await response.text();
242
- console.error(`[EveryNotify] Discord error: ${response.status} ${errorText}`);
243
- return;
244
- }
245
- } catch (error) {
246
- const message = error instanceof Error ? error.message : String(error);
247
- console.error(`[EveryNotify] Discord failed: ${message}`);
184
+ const content = truncate(formatDiscordMessage(payload), 2000, config.truncateFrom);
185
+ const response = await fetch(config.webhookUrl, {
186
+ method: "POST",
187
+ headers: {
188
+ "Content-Type": "application/json"
189
+ },
190
+ body: JSON.stringify({ content }),
191
+ signal
192
+ });
193
+ if (response.status === 429) {
194
+ const retryAfter = response.headers.get("Retry-After");
195
+ throw new Error(`Discord rate limited. Retry-After: ${retryAfter}s`);
196
+ }
197
+ if (!response.ok) {
198
+ const errorText = await response.text();
199
+ throw new Error(`Discord API error: ${response.status} ${errorText}`);
248
200
  }
249
201
  }
250
202
 
251
203
  // src/dispatcher.ts
204
+ function truncate(text, maxLength, from = "end") {
205
+ if (text.length <= maxLength) {
206
+ return text;
207
+ }
208
+ const indicator = "...";
209
+ if (maxLength <= indicator.length) {
210
+ return indicator;
211
+ }
212
+ if (from === "start") {
213
+ return indicator + text.slice(text.length - (maxLength - indicator.length));
214
+ }
215
+ return text.slice(0, maxLength - indicator.length) + indicator;
216
+ }
252
217
  function createDispatcher(config, logger) {
253
218
  const services = [];
219
+ const globalTruncateFrom = config.truncateFrom ?? "end";
254
220
  if (config.pushover.enabled) {
255
221
  services.push({
256
222
  name: "Pushover",
257
223
  send,
258
- config: config.pushover
224
+ config: {
225
+ ...config.pushover,
226
+ truncateFrom: config.pushover.truncateFrom ?? globalTruncateFrom
227
+ }
259
228
  });
260
229
  }
261
230
  if (config.telegram.enabled) {
262
231
  services.push({
263
232
  name: "Telegram",
264
233
  send: send2,
265
- config: config.telegram
234
+ config: {
235
+ ...config.telegram,
236
+ truncateFrom: config.telegram.truncateFrom ?? globalTruncateFrom
237
+ }
266
238
  });
267
239
  }
268
240
  if (config.slack.enabled) {
269
241
  services.push({
270
242
  name: "Slack",
271
243
  send: send3,
272
- config: config.slack
244
+ config: {
245
+ ...config.slack,
246
+ truncateFrom: config.slack.truncateFrom ?? globalTruncateFrom
247
+ }
273
248
  });
274
249
  }
275
250
  if (config.discord.enabled) {
276
251
  services.push({
277
252
  name: "Discord",
278
253
  send: send4,
279
- config: config.discord
254
+ config: {
255
+ ...config.discord,
256
+ truncateFrom: config.discord.truncateFrom ?? globalTruncateFrom
257
+ }
280
258
  });
281
259
  }
282
260
  const lastDispatchTime = new Map;
@@ -303,7 +281,6 @@ function createDispatcher(config, logger) {
303
281
  results.forEach((result, index) => {
304
282
  if (result.status === "rejected") {
305
283
  const errorMsg = result.reason instanceof Error ? result.reason.message : String(result.reason);
306
- console.error(`[EveryNotify] ${services[index].name} failed:`, result.reason);
307
284
  logger.error(`${services[index].name} failed: ${errorMsg}`);
308
285
  }
309
286
  });
@@ -318,30 +295,70 @@ import * as os2 from "node:os";
318
295
  function getLogFilePath() {
319
296
  return path2.join(os2.homedir(), ".config", "opencode", ".everynotify.log");
320
297
  }
321
- function createLogger(config) {
298
+ function createLogger(config, fsDeps) {
322
299
  if (!config.log.enabled) {
323
300
  return { error: () => {}, warn: () => {} };
324
301
  }
302
+ const _fs = {
303
+ mkdirSync: fsDeps?.mkdirSync ?? fs2.mkdirSync,
304
+ appendFileSync: fsDeps?.appendFileSync ?? fs2.appendFileSync,
305
+ statSync: fsDeps?.statSync ?? fs2.statSync,
306
+ renameSync: fsDeps?.renameSync ?? fs2.renameSync,
307
+ readdirSync: fsDeps?.readdirSync ?? fs2.readdirSync,
308
+ unlinkSync: fsDeps?.unlinkSync ?? fs2.unlinkSync
309
+ };
325
310
  const logFilePath = getLogFilePath();
326
311
  const logDir = path2.dirname(logFilePath);
327
312
  const level = config.log.level || "warn";
328
313
  let disabled = false;
329
314
  try {
330
- fs2.mkdirSync(logDir, { recursive: true });
315
+ _fs.mkdirSync(logDir, { recursive: true });
331
316
  } catch (error) {
332
317
  const message = error instanceof Error ? error.message : String(error);
333
318
  console.error(`[EveryNotify] Failed to create log directory: ${message}`);
334
319
  disabled = true;
335
320
  }
321
+ function rotateIfNeeded() {
322
+ try {
323
+ const stat = _fs.statSync(logFilePath);
324
+ const ageMs = Date.now() - stat.mtime.getTime();
325
+ const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
326
+ if (ageMs > sevenDaysMs) {
327
+ const mtimeDate = stat.mtime.toISOString().split("T")[0];
328
+ const rotatedPath = `${logFilePath}.${mtimeDate}`;
329
+ _fs.renameSync(logFilePath, rotatedPath);
330
+ cleanupRotatedFiles();
331
+ }
332
+ } catch (error) {
333
+ if (error.code !== "ENOENT") {
334
+ const message = error instanceof Error ? error.message : String(error);
335
+ console.error(`[EveryNotify] Rotation check failed: ${message}`);
336
+ }
337
+ }
338
+ }
339
+ function cleanupRotatedFiles() {
340
+ try {
341
+ const dir = path2.dirname(logFilePath);
342
+ const baseName = path2.basename(logFilePath);
343
+ const files = _fs.readdirSync(dir).filter((f) => f.startsWith(`${baseName}.`) && /\d{4}-\d{2}-\d{2}$/.test(f)).sort();
344
+ while (files.length > 4) {
345
+ const oldest = files.shift();
346
+ _fs.unlinkSync(path2.join(dir, oldest));
347
+ }
348
+ } catch (error) {
349
+ const message = error instanceof Error ? error.message : String(error);
350
+ console.error(`[EveryNotify] Cleanup failed: ${message}`);
351
+ }
352
+ }
336
353
  function writeLog(levelStr, msg) {
337
354
  if (disabled)
338
355
  return;
339
356
  try {
340
- rotateIfNeeded(logFilePath);
357
+ rotateIfNeeded();
341
358
  const timestamp = new Date().toISOString();
342
359
  const line = `[${timestamp}] [${levelStr}] [EveryNotify] ${msg}
343
360
  `;
344
- fs2.appendFileSync(logFilePath, line, "utf-8");
361
+ _fs.appendFileSync(logFilePath, line, "utf-8");
345
362
  } catch (error) {
346
363
  const message = error instanceof Error ? error.message : String(error);
347
364
  console.error(`[EveryNotify] Log write failed: ${message}`);
@@ -358,44 +375,15 @@ function createLogger(config) {
358
375
  }
359
376
  };
360
377
  }
361
- function rotateIfNeeded(logFilePath) {
362
- try {
363
- const stat = fs2.statSync(logFilePath);
364
- const ageMs = Date.now() - stat.mtime.getTime();
365
- const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
366
- if (ageMs > sevenDaysMs) {
367
- const mtimeDate = stat.mtime.toISOString().split("T")[0];
368
- const rotatedPath = `${logFilePath}.${mtimeDate}`;
369
- fs2.renameSync(logFilePath, rotatedPath);
370
- cleanupRotatedFiles(logFilePath);
371
- }
372
- } catch (error) {
373
- if (error.code !== "ENOENT") {
374
- const message = error instanceof Error ? error.message : String(error);
375
- console.error(`[EveryNotify] Rotation check failed: ${message}`);
376
- }
377
- }
378
- }
379
- function cleanupRotatedFiles(logFilePath) {
380
- try {
381
- const dir = path2.dirname(logFilePath);
382
- const baseName = path2.basename(logFilePath);
383
- const files = fs2.readdirSync(dir).filter((f) => f.startsWith(`${baseName}.`) && /\d{4}-\d{2}-\d{2}$/.test(f)).sort();
384
- while (files.length > 4) {
385
- const oldest = files.shift();
386
- fs2.unlinkSync(path2.join(dir, oldest));
387
- }
388
- } catch (error) {
389
- const message = error instanceof Error ? error.message : String(error);
390
- console.error(`[EveryNotify] Cleanup failed: ${message}`);
391
- }
392
- }
393
378
 
394
379
  // src/index.ts
395
380
  var EverynotifyPlugin = async (input) => {
396
381
  const { client, directory } = input;
397
- const config = loadConfig(directory);
382
+ const { config, warnings } = loadConfig(directory);
398
383
  const logger = createLogger(config);
384
+ for (const warning of warnings) {
385
+ logger.warn(warning);
386
+ }
399
387
  const { dispatch } = createDispatcher(config, logger);
400
388
  function isEventEnabled(eventType) {
401
389
  return config.events[eventType] !== false;
@@ -488,7 +476,6 @@ var EverynotifyPlugin = async (input) => {
488
476
  }
489
477
  } catch (error) {
490
478
  const errorMsg = error instanceof Error ? error.message : String(error);
491
- console.error(`[EveryNotify] Event hook error: ${errorMsg}`);
492
479
  logger.error(`Event hook error: ${errorMsg}`);
493
480
  }
494
481
  }
@@ -502,7 +489,6 @@ var EverynotifyPlugin = async (input) => {
502
489
  await dispatch(payload);
503
490
  } catch (error) {
504
491
  const errorMsg = error instanceof Error ? error.message : String(error);
505
- console.error(`[EveryNotify] Permission.ask hook error: ${errorMsg}`);
506
492
  logger.error(`Permission.ask hook error: ${errorMsg}`);
507
493
  }
508
494
  }
@@ -518,7 +504,6 @@ var EverynotifyPlugin = async (input) => {
518
504
  }
519
505
  } catch (error) {
520
506
  const errorMsg = error instanceof Error ? error.message : String(error);
521
- console.error(`[EveryNotify] Tool.execute.before hook error: ${errorMsg}`);
522
507
  logger.error(`Tool.execute.before hook error: ${errorMsg}`);
523
508
  }
524
509
  }
package/dist/logger.d.ts CHANGED
@@ -4,7 +4,16 @@
4
4
  * Provides a simple file-based logger with 7-day rotation.
5
5
  * Writes to ~/.config/opencode/.everynotify.log with automatic cleanup.
6
6
  */
7
+ import * as fs from "node:fs";
7
8
  import type { EverynotifyConfig } from "./types";
9
+ export interface FsDeps {
10
+ mkdirSync: typeof fs.mkdirSync;
11
+ appendFileSync: typeof fs.appendFileSync;
12
+ statSync: typeof fs.statSync;
13
+ renameSync: typeof fs.renameSync;
14
+ readdirSync: typeof fs.readdirSync;
15
+ unlinkSync: typeof fs.unlinkSync;
16
+ }
8
17
  /**
9
18
  * Logger interface with error and warn methods
10
19
  */
@@ -20,6 +29,7 @@ export declare function getLogFilePath(): string;
20
29
  /**
21
30
  * Creates a logger instance based on configuration
22
31
  * @param config - EveryNotify configuration object
32
+ * @param fsDeps - Optional fs operations override for testing
23
33
  * @returns Logger instance (no-op if logging disabled)
24
34
  */
25
- export declare function createLogger(config: EverynotifyConfig): Logger;
35
+ export declare function createLogger(config: EverynotifyConfig, fsDeps?: Partial<FsDeps>): Logger;
package/dist/types.d.ts CHANGED
@@ -4,6 +4,12 @@
4
4
  * All types for notification payloads, service configurations, and dispatch functions.
5
5
  * No validation logic here — types only.
6
6
  */
7
+ /**
8
+ * Truncation direction for messages exceeding service limits
9
+ * - "end": Keep beginning, truncate end (default)
10
+ * - "start": Keep end, truncate beginning
11
+ */
12
+ export type TruncationMode = "start" | "end";
7
13
  /**
8
14
  * Event types that trigger notifications
9
15
  */
@@ -32,6 +38,7 @@ export interface PushoverConfig {
32
38
  token: string;
33
39
  userKey: string;
34
40
  priority?: number;
41
+ truncateFrom?: TruncationMode;
35
42
  }
36
43
  /**
37
44
  * Telegram service configuration
@@ -43,6 +50,7 @@ export interface TelegramConfig {
43
50
  enabled: boolean;
44
51
  botToken: string;
45
52
  chatId: string;
53
+ truncateFrom?: TruncationMode;
46
54
  }
47
55
  /**
48
56
  * Slack service configuration
@@ -52,6 +60,7 @@ export interface TelegramConfig {
52
60
  export interface SlackConfig {
53
61
  enabled: boolean;
54
62
  webhookUrl: string;
63
+ truncateFrom?: TruncationMode;
55
64
  }
56
65
  /**
57
66
  * Discord service configuration
@@ -61,6 +70,7 @@ export interface SlackConfig {
61
70
  export interface DiscordConfig {
62
71
  enabled: boolean;
63
72
  webhookUrl: string;
73
+ truncateFrom?: TruncationMode;
64
74
  }
65
75
  /**
66
76
  * Logging configuration
@@ -98,6 +108,7 @@ export interface EverynotifyConfig {
98
108
  discord: DiscordConfig;
99
109
  log: LogConfig;
100
110
  events: EventsConfig;
111
+ truncateFrom?: TruncationMode;
101
112
  }
102
113
  /**
103
114
  * Function signature for service send functions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sillybit/opencode-plugin-everynotify",
3
- "version": "0.2.0",
3
+ "version": "0.3.4",
4
4
  "description": "Multi-service notification plugin for opencode — Pushover, Telegram, Slack, Discord",
5
5
  "author": "Sillybit <https://sillybit.io>",
6
6
  "repository": {
@@ -25,6 +25,7 @@
25
25
  },
26
26
  "devDependencies": {
27
27
  "@opencode-ai/plugin": "^1.1.53",
28
+ "@types/bun": "^1.3.8",
28
29
  "@types/node": "^22.0.0",
29
30
  "typescript": "^5.9.3"
30
31
  },