forked from zelenkovsky/opencode-reminders
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.ts
More file actions
154 lines (133 loc) · 5.53 KB
/
Copy pathindex.ts
File metadata and controls
154 lines (133 loc) · 5.53 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
import { Plugin } from "@opencode-ai/plugin"
import { logger } from "./logger"
import type { State, PluginConfig } from "./types"
import { ReminderSchema } from "./types"
import { getStorageDir, listReminders } from "./storage"
import {
beginSchedulerGeneration,
isSchedulerGenerationCurrent,
waitForSchedulerMutations,
scheduleTimer,
cancelReminder,
cleanupReminderSnapshot,
} from "./scheduler"
import { createReminderAddTool } from "./tools/reminderadd"
import { createReminderListTool } from "./tools/reminderlist"
import { createReminderRemoveTool } from "./tools/reminderremove"
const RemindersPlugin: Plugin = async (ctx) => {
const { project } = ctx
const generation = beginSchedulerGeneration(ctx)
logger.info(`[RemindersPlugin] Initializing for project ${project.id}`)
await getStorageDir(ctx)
const state: State = {
reminders: new Map(),
timers: new Map(),
projectID: project.id,
generation,
}
// Configuration with defaults
let config: PluginConfig = {
enabled: true,
max_reminders_per_project: 50,
min_interval_seconds: 30,
notifications: {
enabled: true,
},
}
const gracePeriod = 60 * 60 * 1000
const now = Date.now()
let restoredCount = 0
let expiredCount = 0
let invalidCount = 0
let healthyCount = 0
if (!(await waitForSchedulerMutations(ctx, generation))) return {}
const storedReminders = await listReminders(ctx)
if (!isSchedulerGenerationCurrent(ctx, generation)) return {}
for (const snapshot of storedReminders) {
if (!isSchedulerGenerationCurrent(ctx, generation)) return {}
const parsed = ReminderSchema.safeParse(snapshot)
// Persisted filenames are derived from IDs. Only generated UUID snapshots with safe
// timestamps may be re-opened or cleaned; malformed files are left for manual repair.
if (!parsed.success || !isSafeRestoredReminder(parsed.data)) {
logger.error(`[RemindersPlugin] Invalid restored reminder left untouched`)
invalidCount++
continue
}
const reminder = parsed.data
try {
// Skip session validation during startup - it may not be ready yet
// Session cleanup will happen via event hook when session is actually deleted
if (reminder.time.nextExecution + gracePeriod < now) {
if (await cleanupReminderSnapshot(reminder, ctx, state, config)) {
logger.info(`[RemindersPlugin] Reminder ${reminder.id} expired, removing`)
expiredCount++
}
if (!isSchedulerGenerationCurrent(ctx, generation)) return {}
continue
}
state.reminders.set(reminder.id, reminder)
const scheduled = await scheduleTimer(reminder, ctx, state, config, {
persist: false,
skipOverdue: true,
})
if (!isSchedulerGenerationCurrent(ctx, generation)) return {}
// Validate timer was actually created (timer health validation)
const isHealthy = scheduled && state.timers.has(reminder.id)
if (isHealthy) {
restoredCount++
healthyCount++
logger.info(`[RemindersPlugin] Restored and validated reminder ${reminder.id}`)
} else {
await cleanupReminderSnapshot(reminder, ctx, state, config)
state.reminders.delete(reminder.id)
invalidCount++
logger.error(`[RemindersPlugin] Timer restoration failed for ${reminder.id}, cancelled reminder`)
}
} catch (error) {
logger.error(`[RemindersPlugin] Failed to restore reminder:`, error)
await cleanupReminderSnapshot(reminder, ctx, state, config)
invalidCount++
}
}
logger.info(
`[RemindersPlugin] Timer persistence validation completed: ${storedReminders.length} total, ${restoredCount} restored, ${expiredCount} expired, ${invalidCount} invalid, ${healthyCount} healthy`,
)
// Cleanup considerations:
// - timer.unref() allows clean exit without blocking the process
// - Plugin API currently has no cleanup hook for graceful shutdown
// - A replacement generation clears timers and aborts active executions from the old instance
// - Reminder state persists to storage and is restored on next startup
// - Max reminders per project (50) bounds memory usage
return {
async config(cfg) {
const cfgAny = cfg as any
if (cfgAny.reminders) {
config = { ...config, ...cfgAny.reminders }
logger.info(`[RemindersPlugin] Configuration updated:`, config)
}
},
async event({ event }) {
if (event.type === "session.deleted") {
const sessionID = event.properties.info.id
logger.info(`[RemindersPlugin] Session ${sessionID} deleted, cleaning up reminders`)
const remindersToCancel = Array.from(state.reminders.values()).filter((r) => r.sessionID === sessionID)
let cancelledCount = 0
for (const reminder of remindersToCancel) {
if (await cancelReminder(reminder.id, ctx, state, config)) cancelledCount++
}
logger.info(`[RemindersPlugin] Cancelled ${cancelledCount} reminders for session ${sessionID}`)
}
},
tool: {
reminderadd: createReminderAddTool(ctx, state, () => config),
reminderlist: createReminderListTool(state),
reminderremove: createReminderRemoveTool(ctx, state, () => config),
},
}
}
function isSafeRestoredReminder(reminder: { id: string; time: { nextExecution: number; created: number } }): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(reminder.id)
&& Number.isSafeInteger(reminder.time.created)
&& Number.isSafeInteger(reminder.time.nextExecution)
}
export default RemindersPlugin