@ryan_nookpi/pi-extension-delayed-action 0.1.0

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 (3) hide show
  1. package/README.md +14 -0
  2. package/index.ts +278 -0
  3. package/package.json +24 -0
package/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # @ryan_nookpi/pi-extension-delayed-action
2
+
3
+ Standalone pi package for the `delayed-action` extension.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install /Users/creatrip/Documents/pi-extension/packages/delayed-action
9
+ pi install npm:@ryan_nookpi/pi-extension-delayed-action
10
+ ```
11
+
12
+ ## Extension Entry
13
+
14
+ - `./index.ts`
package/index.ts ADDED
@@ -0,0 +1,278 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+
3
+ const CUSTOM_TYPE = "delayed-action";
4
+ const DEFAULT_SOON_DELAY_MS = 10 * 60 * 1000; // "좀 있다가" 기본값: 10분
5
+ const MAX_DELAY_MS = 7 * 24 * 60 * 60 * 1000; // 7일
6
+
7
+ type ParsedReminder = {
8
+ task: string;
9
+ delayMs: number;
10
+ delayLabel: string;
11
+ };
12
+
13
+ type Reminder = {
14
+ id: number;
15
+ task: string;
16
+ delayMs: number;
17
+ delayLabel: string;
18
+ createdAt: number;
19
+ dueAt: number;
20
+ timer: ReturnType<typeof setTimeout>;
21
+ };
22
+
23
+ const EXPLICIT_DELAY_RE = /^(\d+)\s*(초|분|시간)\s*(?:있다가|후(?:에)?|뒤(?:에)?)\s*[,,:]?\s*(.+)$/i;
24
+ const SOON_DELAY_RE = /^(?:좀|조금|잠깐|잠시)\s*(?:있다가|후(?:에)?|뒤(?:에)?)\s*[,,:]?\s*(.+)$/i;
25
+ const DELAY_ONLY_RE = /^(?:\d+\s*(?:초|분|시간)|(?:좀|조금|잠깐|잠시))\s*(?:있다가|후(?:에)?|뒤(?:에)?)\s*$/i;
26
+
27
+ function toDelayMs(amount: number, unit: "초" | "분" | "시간"): number {
28
+ if (unit === "초") return amount * 1000;
29
+ if (unit === "시간") return amount * 60 * 60 * 1000;
30
+ return amount * 60 * 1000;
31
+ }
32
+
33
+ function formatDuration(ms: number): string {
34
+ if (ms < 60_000) return `${Math.max(1, Math.round(ms / 1000))}초`;
35
+ if (ms < 3_600_000) return `${Math.max(1, Math.round(ms / 60_000))}분`;
36
+
37
+ const hours = Math.floor(ms / 3_600_000);
38
+ const minutes = Math.floor((ms % 3_600_000) / 60_000);
39
+ if (minutes === 0) return `${hours}시간`;
40
+ return `${hours}시간 ${minutes}분`;
41
+ }
42
+
43
+ function formatClock(ts: number): string {
44
+ return new Date(ts).toLocaleTimeString("ko-KR", {
45
+ hour: "2-digit",
46
+ minute: "2-digit",
47
+ second: "2-digit",
48
+ hour12: false,
49
+ });
50
+ }
51
+
52
+ function parseReminderRequest(text: string): ParsedReminder | null {
53
+ const trimmed = text.trim();
54
+ if (!trimmed) return null;
55
+
56
+ const explicit = trimmed.match(EXPLICIT_DELAY_RE);
57
+ if (explicit) {
58
+ const amount = Number(explicit[1]);
59
+ const unit = explicit[2] as "초" | "분" | "시간";
60
+ const task = explicit[3]?.trim() ?? "";
61
+ if (!Number.isFinite(amount) || amount <= 0 || !task) return null;
62
+
63
+ const delayMs = toDelayMs(amount, unit);
64
+ if (delayMs > MAX_DELAY_MS) return null;
65
+
66
+ return {
67
+ task,
68
+ delayMs,
69
+ delayLabel: `${amount}${unit}`,
70
+ };
71
+ }
72
+
73
+ const soon = trimmed.match(SOON_DELAY_RE);
74
+ if (soon) {
75
+ const task = soon[1]?.trim() ?? "";
76
+ if (!task) return null;
77
+ return {
78
+ task,
79
+ delayMs: DEFAULT_SOON_DELAY_MS,
80
+ delayLabel: formatDuration(DEFAULT_SOON_DELAY_MS),
81
+ };
82
+ }
83
+
84
+ return null;
85
+ }
86
+
87
+ export default function (pi: ExtensionAPI) {
88
+ const reminders = new Map<number, Reminder>();
89
+ let nextReminderId = 1;
90
+ let agentRunning = false;
91
+ let latestCtx: ExtensionContext | undefined;
92
+
93
+ const clearAllReminders = () => {
94
+ for (const reminder of reminders.values()) {
95
+ clearTimeout(reminder.timer);
96
+ }
97
+ reminders.clear();
98
+ };
99
+
100
+ const listReminderLines = (): string[] => {
101
+ const now = Date.now();
102
+ return Array.from(reminders.values())
103
+ .sort((a, b) => a.dueAt - b.dueAt)
104
+ .map((r) => {
105
+ const remainMs = Math.max(0, r.dueAt - now);
106
+ return `#${r.id} · ${formatDuration(remainMs)} 후 · ${r.task}`;
107
+ });
108
+ };
109
+
110
+ const fireReminder = (id: number) => {
111
+ const reminder = reminders.get(id);
112
+ if (!reminder) return;
113
+
114
+ reminders.delete(id);
115
+
116
+ pi.sendMessage({
117
+ customType: CUSTOM_TYPE,
118
+ content: `[reminder#${reminder.id}] 시간 도달 (${formatClock(Date.now())})\nTask: ${reminder.task}`,
119
+ display: true,
120
+ details: {
121
+ id: reminder.id,
122
+ task: reminder.task,
123
+ dueAt: reminder.dueAt,
124
+ createdAt: reminder.createdAt,
125
+ },
126
+ });
127
+
128
+ const prompt = `예약한 시간이 되었어. 지금 아래 작업을 수행해줘.\n\n${reminder.task}`;
129
+ if (agentRunning) {
130
+ pi.sendUserMessage(prompt, { deliverAs: "followUp" });
131
+ } else {
132
+ pi.sendUserMessage(prompt);
133
+ }
134
+
135
+ if (latestCtx?.hasUI) {
136
+ latestCtx.ui.notify(`⏰ reminder #${reminder.id} 실행됨`, "info");
137
+ }
138
+ };
139
+
140
+ const scheduleReminder = (parsed: ParsedReminder, ctx: ExtensionContext) => {
141
+ const id = nextReminderId++;
142
+ const createdAt = Date.now();
143
+ const dueAt = createdAt + parsed.delayMs;
144
+
145
+ const timer = setTimeout(() => fireReminder(id), parsed.delayMs);
146
+ const reminder: Reminder = {
147
+ id,
148
+ task: parsed.task,
149
+ delayMs: parsed.delayMs,
150
+ delayLabel: parsed.delayLabel,
151
+ createdAt,
152
+ dueAt,
153
+ timer,
154
+ };
155
+ reminders.set(id, reminder);
156
+
157
+ pi.sendMessage({
158
+ customType: CUSTOM_TYPE,
159
+ content: `[reminder#${id}] 예약됨: ${parsed.delayLabel} 후\nTask: ${parsed.task}\nETA: ${formatClock(dueAt)}`,
160
+ display: true,
161
+ details: {
162
+ id,
163
+ task: parsed.task,
164
+ delayMs: parsed.delayMs,
165
+ dueAt,
166
+ createdAt,
167
+ },
168
+ });
169
+
170
+ if (ctx.hasUI) {
171
+ ctx.ui.notify(`⏰ reminder #${id} 설정됨 (${parsed.delayLabel})`, "info");
172
+ }
173
+ };
174
+
175
+ pi.registerCommand("reminders", {
176
+ description: "List pending reminders",
177
+ handler: async (_args, ctx) => {
178
+ latestCtx = ctx;
179
+ if (reminders.size === 0) {
180
+ ctx.ui.notify("현재 예약된 reminder가 없어.", "info");
181
+ return;
182
+ }
183
+
184
+ pi.sendMessage({
185
+ customType: CUSTOM_TYPE,
186
+ content: `Pending reminders\n\n${listReminderLines().join("\n")}`,
187
+ display: true,
188
+ });
189
+ },
190
+ });
191
+
192
+ pi.registerCommand("reminder-cancel", {
193
+ description: "Cancel reminder by id or all (usage: /reminder-cancel <id|all>)",
194
+ handler: async (args, ctx) => {
195
+ latestCtx = ctx;
196
+ const raw = (args ?? "").trim().toLowerCase();
197
+ if (!raw) {
198
+ ctx.ui.notify("Usage: /reminder-cancel <id|all>", "info");
199
+ return;
200
+ }
201
+
202
+ if (raw === "all") {
203
+ const count = reminders.size;
204
+ clearAllReminders();
205
+ ctx.ui.notify(`reminder ${count}개 취소됨`, "info");
206
+ return;
207
+ }
208
+
209
+ const id = Number(raw);
210
+ if (!Number.isInteger(id)) {
211
+ ctx.ui.notify("id는 숫자여야 해. 예: /reminder-cancel 3", "warning");
212
+ return;
213
+ }
214
+
215
+ const target = reminders.get(id);
216
+ if (!target) {
217
+ ctx.ui.notify(`reminder #${id} 없음`, "warning");
218
+ return;
219
+ }
220
+
221
+ clearTimeout(target.timer);
222
+ reminders.delete(id);
223
+ ctx.ui.notify(`reminder #${id} 취소됨`, "info");
224
+ },
225
+ });
226
+
227
+ pi.on("input", async (event, ctx) => {
228
+ latestCtx = ctx;
229
+ if (event.source === "extension") return { action: "continue" as const };
230
+
231
+ const text = event.text ?? "";
232
+ if (DELAY_ONLY_RE.test(text.trim())) {
233
+ if (ctx.hasUI) {
234
+ ctx.ui.notify('예약할 작업도 같이 써줘. 예: "10분 있다가 배포 로그 확인해"', "warning");
235
+ }
236
+ return { action: "handled" as const };
237
+ }
238
+
239
+ const parsed = parseReminderRequest(text);
240
+ if (!parsed) return { action: "continue" as const };
241
+
242
+ scheduleReminder(parsed, ctx);
243
+ return { action: "handled" as const };
244
+ });
245
+
246
+ pi.on("agent_start", async (_event, ctx) => {
247
+ agentRunning = true;
248
+ latestCtx = ctx;
249
+ });
250
+
251
+ pi.on("agent_end", async (_event, ctx) => {
252
+ agentRunning = false;
253
+ latestCtx = ctx;
254
+ });
255
+
256
+ // Filter out delayed-action log messages before LLM sees them.
257
+ // CustomMessageEntry (created by sendMessage) has role="custom" and participates
258
+ // in LLM context by default. We strip them here so they remain visible in the TUI
259
+ // (display:true is handled by the UI layer independently) but never reach the model.
260
+ pi.on("context", async (event, _ctx) => {
261
+ const filtered = event.messages.filter(
262
+ (m) => !(m.role === "custom" && (m as { customType?: string }).customType === CUSTOM_TYPE),
263
+ );
264
+ if (filtered.length === event.messages.length) return;
265
+ return { messages: filtered };
266
+ });
267
+
268
+ pi.on("session_start", async (_event, ctx) => {
269
+ agentRunning = false;
270
+ latestCtx = ctx;
271
+ clearAllReminders();
272
+ });
273
+
274
+ pi.on("session_shutdown", async () => {
275
+ agentRunning = false;
276
+ clearAllReminders();
277
+ });
278
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@ryan_nookpi/pi-extension-delayed-action",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension for scheduling delayed follow-up actions.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "files": [
10
+ "index.ts",
11
+ "README.md"
12
+ ],
13
+ "pi": {
14
+ "extensions": [
15
+ "./index.ts"
16
+ ]
17
+ },
18
+ "peerDependencies": {
19
+ "@mariozechner/pi-coding-agent": "*"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ }
24
+ }