pi-file-change-reminder 1.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Gorbatchev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # pi-file-change-reminder
2
+
3
+ [pi](https://pi.dev) extension that injects reminder messages when matching files are modified via `write`, `edit`, or `multi_tool_use.parallel` tool calls.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:pi-file-change-reminder
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ Create a rules file at your project root:
14
+
15
+ `.pi/reminders.json`
16
+
17
+ ```json
18
+ [
19
+ {
20
+ "glob": "README.md",
21
+ "reminder": "Run docs checks after editing README.md"
22
+ },
23
+ {
24
+ "glob": "src/**/*.ts",
25
+ "reminder": "Run the TypeScript test suite before finishing"
26
+ }
27
+ ]
28
+ ```
29
+
30
+ ## Slash command
31
+
32
+ Use the built-in extension command:
33
+
34
+ ```text
35
+ /pi-file-change-reminder
36
+ ```
37
+
38
+ This injects a user message that asks Pi to help update the reminder config file with the required JSON shape, safety constraints, and picomatch glob guidance.
39
+
40
+ You can also include a specific change request:
41
+
42
+ ```text
43
+ /pi-file-change-reminder add a rule for docs/**/*.md reminding me to run vale
44
+ ```
45
+
46
+ ## Rules file resolution
47
+
48
+ Default rules path is `.pi/reminders.json` resolved from the **nearest ancestor directory** containing either:
49
+
50
+ - `.git`, or
51
+ - `.pi`
52
+
53
+ If no ancestor contains either marker, resolution falls back to Pi's current working directory.
54
+
55
+ You can override with `PI_REMINDERS_FILE`:
56
+
57
+ ```bash
58
+ export PI_REMINDERS_FILE=/absolute/path/to/reminders.json
59
+ ```
60
+
61
+ If `PI_REMINDERS_FILE` is relative, it is resolved from the detected project directory (same logic as above).
62
+
63
+ ## Glob behavior
64
+
65
+ - Matching engine: `picomatch`
66
+ - Relative rule globs match against paths relative to the project marker directory (nearest ancestor with `.git` or `.pi`).
67
+ - Absolute rule globs match against normalized absolute file paths.
68
+
69
+ ## Runtime behavior
70
+
71
+ - When a rule matches a modified file, the extension injects the reminder as a user message.
72
+ - In interactive mode, it also shows an info notification with the matched path and rule.
73
+
74
+ ## Development
75
+
76
+ ```bash
77
+ npm install
78
+ npm run typecheck
79
+ npm run verify:pi-load
80
+ ```
81
+
82
+ ## Current limitation
83
+
84
+ Reminder deduplication is currently keyed by reminder text. If two different rules use the same `reminder` string, only one will be injected per session branch.
@@ -0,0 +1,412 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import picomatch from "picomatch";
4
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
5
+
6
+ interface IReminderRule {
7
+ glob: string;
8
+ reminder: string;
9
+ }
10
+
11
+ interface IToolInputWithPath {
12
+ path: string;
13
+ }
14
+
15
+ const EXTENSION_ID = "file-change-reminder";
16
+ const DEFAULT_RULES_FILE = ".pi/reminders.json";
17
+ const COMMAND_NAME = "pi-file-change-reminder";
18
+ const INJECTED_ENTRY_TYPE = `${EXTENSION_ID}.injected`;
19
+
20
+ export default function fileChangeReminderExtension(pi: ExtensionAPI): void {
21
+ let rulesCache: IReminderRule[] = [];
22
+ let rulesMtimeMs = -1;
23
+ let hasLoadedRules = false;
24
+ let lastLoadError = "";
25
+ let injectedReminderKeys = new Set<string>();
26
+ const globMatcherCache = new Map<string, (candidatePath: string) => boolean>();
27
+ const projectDirectoryCache = new Map<string, string>();
28
+
29
+ const rebuildInjectedReminderKeys = (ctx: ExtensionContext): void => {
30
+ injectedReminderKeys = new Set<string>();
31
+
32
+ for (const entry of ctx.sessionManager.getBranch()) {
33
+ if (entry.type !== "custom" || entry.customType !== INJECTED_ENTRY_TYPE) {
34
+ continue;
35
+ }
36
+
37
+ if (!isRecord(entry.data)) {
38
+ continue;
39
+ }
40
+
41
+ const reminderKey = readNonEmptyString(entry.data.reminderKey);
42
+ if (reminderKey !== null) {
43
+ injectedReminderKeys.add(reminderKey);
44
+ }
45
+ }
46
+ };
47
+
48
+ const resolveProjectDirectory = async (cwd: string): Promise<string> => {
49
+ const resolvedCwd = path.resolve(cwd);
50
+ const cachedDirectory = projectDirectoryCache.get(resolvedCwd);
51
+ if (cachedDirectory !== undefined) {
52
+ return cachedDirectory;
53
+ }
54
+
55
+ let currentDirectory = resolvedCwd;
56
+ for (;;) {
57
+ const hasGitMarker = await pathExists(path.join(currentDirectory, ".git"));
58
+ const hasPiMarker = await pathExists(path.join(currentDirectory, ".pi"));
59
+ if (hasGitMarker || hasPiMarker) {
60
+ projectDirectoryCache.set(resolvedCwd, currentDirectory);
61
+ return currentDirectory;
62
+ }
63
+
64
+ const parentDirectory = path.dirname(currentDirectory);
65
+ if (parentDirectory === currentDirectory) {
66
+ projectDirectoryCache.set(resolvedCwd, resolvedCwd);
67
+ return resolvedCwd;
68
+ }
69
+
70
+ currentDirectory = parentDirectory;
71
+ }
72
+ };
73
+
74
+ const resolveRulesFilePathFromProjectDirectory = (projectDirectory: string): string => {
75
+ const envValue = process.env.PI_REMINDERS_FILE;
76
+ const configuredPath = readNonEmptyString(envValue) ?? DEFAULT_RULES_FILE;
77
+ if (path.isAbsolute(configuredPath)) {
78
+ return configuredPath;
79
+ }
80
+
81
+ return path.resolve(projectDirectory, configuredPath);
82
+ };
83
+
84
+ const resolveRulesFilePath = async (cwd: string): Promise<string> => {
85
+ const projectDirectory = await resolveProjectDirectory(cwd);
86
+ return resolveRulesFilePathFromProjectDirectory(projectDirectory);
87
+ };
88
+
89
+ const loadRules = async (ctx: ExtensionContext, force: boolean): Promise<IReminderRule[]> => {
90
+ const rulesFilePath = await resolveRulesFilePath(ctx.cwd);
91
+
92
+ let mtimeMs = -1;
93
+ try {
94
+ const stats = await stat(rulesFilePath);
95
+ mtimeMs = stats.mtimeMs;
96
+ } catch (error) {
97
+ rulesCache = [];
98
+ rulesMtimeMs = -1;
99
+ hasLoadedRules = true;
100
+
101
+ if (isRecord(error) && error.code === "ENOENT") {
102
+ lastLoadError = "";
103
+ return rulesCache;
104
+ }
105
+
106
+ const errorMessage = getErrorMessage(error);
107
+ if (errorMessage !== lastLoadError && ctx.hasUI) {
108
+ ctx.ui.notify(`Failed to stat reminder rules file: ${errorMessage}`, "warning");
109
+ }
110
+ lastLoadError = errorMessage;
111
+ return rulesCache;
112
+ }
113
+
114
+ if (!force && hasLoadedRules && mtimeMs === rulesMtimeMs) {
115
+ return rulesCache;
116
+ }
117
+
118
+ try {
119
+ const raw = await readFile(rulesFilePath, "utf8");
120
+ const parsed: unknown = JSON.parse(raw);
121
+ rulesCache = parseRules(parsed);
122
+ rulesMtimeMs = mtimeMs;
123
+ hasLoadedRules = true;
124
+ lastLoadError = "";
125
+ globMatcherCache.clear();
126
+ return rulesCache;
127
+ } catch (error) {
128
+ rulesCache = [];
129
+ rulesMtimeMs = mtimeMs;
130
+ hasLoadedRules = true;
131
+ const errorMessage = getErrorMessage(error);
132
+ if (errorMessage !== lastLoadError && ctx.hasUI) {
133
+ ctx.ui.notify(`Failed to parse reminder rules file: ${errorMessage}`, "warning");
134
+ }
135
+ lastLoadError = errorMessage;
136
+ return rulesCache;
137
+ }
138
+ };
139
+
140
+ const maybeInjectReminder = (
141
+ ctx: ExtensionContext,
142
+ rule: IReminderRule,
143
+ matchedPath: string,
144
+ ): void => {
145
+ const reminderKey = buildReminderKey(rule.reminder);
146
+ if (injectedReminderKeys.has(reminderKey)) {
147
+ return;
148
+ }
149
+
150
+ injectedReminderKeys.add(reminderKey);
151
+ pi.appendEntry(INJECTED_ENTRY_TYPE, {
152
+ reminderKey,
153
+ glob: rule.glob,
154
+ reminder: rule.reminder,
155
+ matchedPath,
156
+ injectedAt: new Date().toISOString(),
157
+ });
158
+
159
+ if (ctx.isIdle()) {
160
+ pi.sendUserMessage(rule.reminder);
161
+ } else {
162
+ pi.sendUserMessage(rule.reminder, { deliverAs: "steer" });
163
+ }
164
+
165
+ if (ctx.hasUI) {
166
+ ctx.ui.notify(`Auto reminder injected for ${matchedPath} (rule: ${rule.glob})`, "info");
167
+ }
168
+ };
169
+
170
+ const onSessionChanged = async (_ctxEvent: unknown, ctx: ExtensionContext): Promise<void> => {
171
+ rebuildInjectedReminderKeys(ctx);
172
+ await loadRules(ctx, true);
173
+ };
174
+
175
+ pi.on("session_start", onSessionChanged);
176
+ pi.on("session_switch", onSessionChanged);
177
+ pi.on("session_fork", onSessionChanged);
178
+ pi.on("session_tree", onSessionChanged);
179
+
180
+ pi.registerCommand(COMMAND_NAME, {
181
+ description: "Inject a prompt that explains how to update reminder rules",
182
+ handler: async (args, ctx) => {
183
+ const projectDirectory = await resolveProjectDirectory(ctx.cwd);
184
+ const rulesFilePath = resolveRulesFilePathFromProjectDirectory(projectDirectory);
185
+ const normalizedRulesFilePath = normalizePath(rulesFilePath);
186
+ const normalizedProjectDirectory = normalizePath(projectDirectory);
187
+ const prompt = buildReminderEditPrompt(normalizedRulesFilePath, normalizedProjectDirectory, args);
188
+
189
+ if (ctx.isIdle()) {
190
+ pi.sendUserMessage(prompt);
191
+ return;
192
+ }
193
+
194
+ pi.sendUserMessage(prompt, { deliverAs: "followUp" });
195
+ if (ctx.hasUI) {
196
+ ctx.ui.notify(`Queued /${COMMAND_NAME} prompt as a follow-up message.`, "info");
197
+ }
198
+ },
199
+ });
200
+
201
+ pi.on("tool_result", async (event, ctx) => {
202
+ if (event.isError) {
203
+ return;
204
+ }
205
+
206
+ const changedPaths = getChangedPaths(event.toolName, event.input);
207
+ if (changedPaths.length === 0) {
208
+ return;
209
+ }
210
+
211
+ const rules = await loadRules(ctx, false);
212
+ if (rules.length === 0) {
213
+ return;
214
+ }
215
+
216
+ const projectDirectory = await resolveProjectDirectory(ctx.cwd);
217
+ for (const changedPath of changedPaths) {
218
+ const absolutePath = path.isAbsolute(changedPath)
219
+ ? path.normalize(changedPath)
220
+ : path.resolve(ctx.cwd, changedPath);
221
+ const normalizedAbsolutePath = normalizePath(absolutePath);
222
+ const normalizedRelativePath = normalizePath(path.relative(projectDirectory, absolutePath));
223
+
224
+ for (const rule of rules) {
225
+ if (
226
+ matchesRule(
227
+ rule.glob,
228
+ normalizedRelativePath,
229
+ normalizedAbsolutePath,
230
+ globMatcherCache,
231
+ )
232
+ ) {
233
+ maybeInjectReminder(ctx, rule, normalizedRelativePath);
234
+ }
235
+ }
236
+ }
237
+ });
238
+ }
239
+
240
+ function parseRules(parsed: unknown): IReminderRule[] {
241
+ if (!Array.isArray(parsed)) {
242
+ return [];
243
+ }
244
+
245
+ const rules: IReminderRule[] = [];
246
+ for (const item of parsed) {
247
+ if (!isRecord(item)) {
248
+ continue;
249
+ }
250
+
251
+ const glob = readNonEmptyString(item.glob);
252
+ const reminder = readNonEmptyString(item.reminder);
253
+ if (glob === null || reminder === null) {
254
+ continue;
255
+ }
256
+
257
+ rules.push({
258
+ glob: normalizePath(glob),
259
+ reminder,
260
+ });
261
+ }
262
+
263
+ return rules;
264
+ }
265
+
266
+ function getChangedPaths(toolName: string, input: unknown): string[] {
267
+ if ((toolName === "write" || toolName === "edit") && isToolInputWithPath(input)) {
268
+ return [input.path];
269
+ }
270
+
271
+ if (toolName !== "multi_tool_use.parallel") {
272
+ return [];
273
+ }
274
+
275
+ if (!isRecord(input) || !Array.isArray(input.tool_uses)) {
276
+ return [];
277
+ }
278
+
279
+ const changedPaths: string[] = [];
280
+ for (const toolUse of input.tool_uses) {
281
+ if (!isRecord(toolUse)) {
282
+ continue;
283
+ }
284
+
285
+ const recipientName = readNonEmptyString(toolUse.recipient_name);
286
+ if (recipientName === null) {
287
+ continue;
288
+ }
289
+
290
+ const isWriteCall = recipientName.endsWith(".write");
291
+ const isEditCall = recipientName.endsWith(".edit");
292
+ if (!isWriteCall && !isEditCall) {
293
+ continue;
294
+ }
295
+
296
+ if (isToolInputWithPath(toolUse.parameters)) {
297
+ changedPaths.push(toolUse.parameters.path);
298
+ }
299
+ }
300
+
301
+ return changedPaths;
302
+ }
303
+
304
+ function isToolInputWithPath(input: unknown): input is IToolInputWithPath {
305
+ return isRecord(input) && typeof input.path === "string" && input.path.length > 0;
306
+ }
307
+
308
+ function matchesRule(
309
+ ruleGlob: string,
310
+ relativePath: string,
311
+ absolutePath: string,
312
+ globMatcherCache: Map<string, (candidatePath: string) => boolean>,
313
+ ): boolean {
314
+ const normalizedGlob = normalizePath(ruleGlob);
315
+ if (path.isAbsolute(normalizedGlob)) {
316
+ return matchesGlob(normalizedGlob, absolutePath, globMatcherCache);
317
+ }
318
+
319
+ return matchesGlob(normalizedGlob, relativePath, globMatcherCache);
320
+ }
321
+
322
+ function matchesGlob(
323
+ globPattern: string,
324
+ candidatePath: string,
325
+ globMatcherCache: Map<string, (candidatePath: string) => boolean>,
326
+ ): boolean {
327
+ let matcher = globMatcherCache.get(globPattern);
328
+ if (matcher === undefined) {
329
+ const picomatchMatcher = picomatch(globPattern, { dot: true });
330
+ matcher = (value: string): boolean => picomatchMatcher(value);
331
+ globMatcherCache.set(globPattern, matcher);
332
+ }
333
+
334
+ return matcher(candidatePath);
335
+ }
336
+
337
+ function buildReminderEditPrompt(rulesFilePath: string, projectDirectory: string, args: string): string {
338
+ const requestedChange = readNonEmptyString(args);
339
+ const changeInstructions = requestedChange === null
340
+ ? "Explain what changes you recommend, then ask me what to add, edit, or remove."
341
+ : `Apply this requested change: ${requestedChange}`;
342
+
343
+ return [
344
+ "Help me update this project reminder configuration file:",
345
+ `- ${rulesFilePath}`,
346
+ "",
347
+ "Purpose of this file:",
348
+ "- It defines reminders that are auto-injected when matching files are changed.",
349
+ "- Each rule maps a file pattern (glob) to a reminder message sent to the agent.",
350
+ "",
351
+ "Glob usage:",
352
+ "- 'glob' uses picomatch syntax (for example *, **, ?, braces like {ts,tsx}, and extglobs).",
353
+ `- Relative globs are matched from the project marker directory: ${projectDirectory}`,
354
+ "- Example relative glob: src/**/*.ts",
355
+ "- The project marker directory is the nearest ancestor containing .git or .pi.",
356
+ "- Absolute globs are matched against normalized absolute file paths.",
357
+ "- Use specific patterns to avoid noisy reminders.",
358
+ "",
359
+ "Requirements:",
360
+ "- Keep valid JSON with a top-level array.",
361
+ "- Each rule must have non-empty string fields: glob, reminder.",
362
+ "- Preserve existing rules unless my request says otherwise.",
363
+ "- Use 2-space indentation.",
364
+ "",
365
+ changeInstructions,
366
+ ].join("\n");
367
+ }
368
+
369
+ async function pathExists(filePath: string): Promise<boolean> {
370
+ try {
371
+ await stat(filePath);
372
+ return true;
373
+ } catch {
374
+ return false;
375
+ }
376
+ }
377
+
378
+ function normalizePath(filePath: string): string {
379
+ const forwardSlashPath = filePath.replaceAll("\\", "/");
380
+ if (forwardSlashPath.startsWith("./")) {
381
+ return forwardSlashPath.slice(2);
382
+ }
383
+ return forwardSlashPath;
384
+ }
385
+
386
+ function isRecord(value: unknown): value is Record<string, unknown> {
387
+ return typeof value === "object" && value !== null;
388
+ }
389
+
390
+ function readNonEmptyString(value: unknown): string | null {
391
+ if (typeof value !== "string") {
392
+ return null;
393
+ }
394
+
395
+ const trimmed = value.trim();
396
+ if (trimmed.length === 0) {
397
+ return null;
398
+ }
399
+
400
+ return trimmed;
401
+ }
402
+
403
+ function buildReminderKey(reminder: string): string {
404
+ return reminder.trim();
405
+ }
406
+
407
+ function getErrorMessage(error: unknown): string {
408
+ if (error instanceof Error && error.message.length > 0) {
409
+ return error.message;
410
+ }
411
+ return String(error);
412
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "pi-file-change-reminder",
3
+ "version": "1.0.0",
4
+ "description": "Pi extension that injects reminder messages when specific files are modified.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi-extension",
10
+ "reminder"
11
+ ],
12
+ "files": [
13
+ "fileChangeReminderExtension.ts",
14
+ "README.md"
15
+ ],
16
+ "pi": {
17
+ "extensions": [
18
+ "./fileChangeReminderExtension.ts"
19
+ ]
20
+ },
21
+ "dependencies": {
22
+ "picomatch": "^4.0.3"
23
+ },
24
+ "peerDependencies": {
25
+ "@mariozechner/pi-coding-agent": "*"
26
+ },
27
+ "devDependencies": {
28
+ "@mariozechner/pi-coding-agent": "^0.61.1",
29
+ "@types/picomatch": "^4.0.2",
30
+ "typescript": "^5.9.3"
31
+ },
32
+ "scripts": {
33
+ "typecheck": "tsc --noEmit -p tsconfig.json",
34
+ "verify:pi-load": "PI_OFFLINE=1 pi --no-extensions -e ./fileChangeReminderExtension.ts --list-models > /dev/null"
35
+ }
36
+ }