pi-file-change-reminder 2.0.0 → 2.0.1
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 +5 -10
- package/package.json +16 -10
- package/src/fileChangeReminderExtension.ts +446 -0
- package/fileChangeReminderExtension.ts +0 -460
package/README.md
CHANGED
|
@@ -91,18 +91,13 @@ Relative `rulesFile` values are resolved from that detected project directory. A
|
|
|
91
91
|
|
|
92
92
|
## Development
|
|
93
93
|
|
|
94
|
-
This package uses
|
|
94
|
+
This package uses Bun.
|
|
95
95
|
|
|
96
96
|
```bash
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
Or run the individual checks:
|
|
102
|
-
|
|
103
|
-
```bash
|
|
104
|
-
npm run typecheck
|
|
105
|
-
npm run verify:pi-load
|
|
97
|
+
bun install
|
|
98
|
+
bun run fix
|
|
99
|
+
bun run check
|
|
100
|
+
bun run test
|
|
106
101
|
```
|
|
107
102
|
|
|
108
103
|
## Current limitation
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-file-change-reminder",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "Pi extension that injects reminder messages when specific files are modified.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,28 +14,34 @@
|
|
|
14
14
|
"reminder"
|
|
15
15
|
],
|
|
16
16
|
"files": [
|
|
17
|
-
"fileChangeReminderExtension.ts",
|
|
17
|
+
"src/fileChangeReminderExtension.ts",
|
|
18
18
|
"README.md"
|
|
19
19
|
],
|
|
20
20
|
"pi": {
|
|
21
21
|
"extensions": [
|
|
22
|
-
"./fileChangeReminderExtension.ts"
|
|
22
|
+
"./src/fileChangeReminderExtension.ts"
|
|
23
23
|
]
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"picomatch": "^4.0.
|
|
26
|
+
"picomatch": "^4.0.5"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"@mariozechner/pi-coding-agent": "*"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@
|
|
33
|
-
"@
|
|
34
|
-
"
|
|
32
|
+
"@alexgorbatchev/typescript-ai-policy": "^12.0.0",
|
|
33
|
+
"@mariozechner/pi-coding-agent": "0.73.1",
|
|
34
|
+
"@types/picomatch": "^4.0.3",
|
|
35
|
+
"oxfmt": "^0.59.0",
|
|
36
|
+
"oxlint": "^1.74.0",
|
|
37
|
+
"typescript": "7.0.2",
|
|
38
|
+
"@types/node": "26.1.1",
|
|
39
|
+
"@typescript/native-preview": "7.0.0-dev.20260707.2"
|
|
35
40
|
},
|
|
36
41
|
"scripts": {
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
42
|
+
"fix": "bun --bun oxfmt --write .",
|
|
43
|
+
"check": "bun --bun oxfmt --check . && bun --bun oxlint . && tsgo --noEmit",
|
|
44
|
+
"test": "PI_OFFLINE=1 bun x pi --no-extensions -e ./src/fileChangeReminderExtension.ts --list-models > /dev/null 2>&1",
|
|
45
|
+
"test:local": "bun x pi -e ./src/fileChangeReminderExtension.ts"
|
|
40
46
|
}
|
|
41
47
|
}
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import picomatch from "picomatch";
|
|
4
|
+
import { SettingsManager, type ExtensionAPI, type 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
|
+
type PathMatcher = (candidatePath: string) => boolean;
|
|
16
|
+
|
|
17
|
+
const EXTENSION_ID = "file-change-reminder";
|
|
18
|
+
const SETTINGS_NAMESPACE = "pi-file-change-reminder";
|
|
19
|
+
const DEFAULT_RULES_FILE = ".pi/reminders.json";
|
|
20
|
+
const COMMAND_NAME = "pi-file-change-reminder";
|
|
21
|
+
const INJECTED_ENTRY_TYPE = `${EXTENSION_ID}.injected`;
|
|
22
|
+
|
|
23
|
+
export default function fileChangeReminderExtension(pi: ExtensionAPI): void {
|
|
24
|
+
let rulesCache: IReminderRule[] = [];
|
|
25
|
+
let rulesMtimeMs = -1;
|
|
26
|
+
let hasLoadedRules = false;
|
|
27
|
+
let lastLoadError = "";
|
|
28
|
+
let lastSettingsError = "";
|
|
29
|
+
let injectedReminderKeys = new Set<string>();
|
|
30
|
+
const globMatcherCache = new Map<string, PathMatcher>();
|
|
31
|
+
const projectDirectoryCache = new Map<string, string>();
|
|
32
|
+
|
|
33
|
+
const rebuildInjectedReminderKeys = (ctx: ExtensionContext): void => {
|
|
34
|
+
injectedReminderKeys = new Set<string>();
|
|
35
|
+
|
|
36
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
37
|
+
if (entry.type !== "custom" || entry.customType !== INJECTED_ENTRY_TYPE) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!isRecord(entry.data)) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const reminderKey = readNonEmptyString(entry.data.reminderKey);
|
|
46
|
+
if (reminderKey !== null) {
|
|
47
|
+
injectedReminderKeys.add(reminderKey);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const resolveProjectDirectory = async (cwd: string): Promise<string> => {
|
|
53
|
+
const resolvedCwd = path.resolve(cwd);
|
|
54
|
+
const cachedDirectory = projectDirectoryCache.get(resolvedCwd);
|
|
55
|
+
if (cachedDirectory !== undefined) {
|
|
56
|
+
return cachedDirectory;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let currentDirectory = resolvedCwd;
|
|
60
|
+
for (;;) {
|
|
61
|
+
const hasGitMarker = await pathExists(path.join(currentDirectory, ".git"));
|
|
62
|
+
const hasPiMarker = await pathExists(path.join(currentDirectory, ".pi"));
|
|
63
|
+
if (hasGitMarker || hasPiMarker) {
|
|
64
|
+
projectDirectoryCache.set(resolvedCwd, currentDirectory);
|
|
65
|
+
return currentDirectory;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const parentDirectory = path.dirname(currentDirectory);
|
|
69
|
+
if (parentDirectory === currentDirectory) {
|
|
70
|
+
projectDirectoryCache.set(resolvedCwd, resolvedCwd);
|
|
71
|
+
return resolvedCwd;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
currentDirectory = parentDirectory;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const reportSettingsErrors = (ctx: ExtensionContext, settingsManager: SettingsManager): void => {
|
|
79
|
+
const settingsErrors = settingsManager.drainErrors();
|
|
80
|
+
if (settingsErrors.length === 0) {
|
|
81
|
+
lastSettingsError = "";
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const settingsErrorMessage = settingsErrors
|
|
86
|
+
.map(({ scope, error }) => `${scope}: ${getErrorMessage(error)}`)
|
|
87
|
+
.join("; ");
|
|
88
|
+
if (settingsErrorMessage !== lastSettingsError && ctx.hasUI) {
|
|
89
|
+
ctx.ui.notify(`Failed to load reminder settings: ${settingsErrorMessage}`, "warning");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
lastSettingsError = settingsErrorMessage;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const getConfiguredRulesFilePath = (ctx: ExtensionContext): string | null => {
|
|
96
|
+
const settingsManager = SettingsManager.create(ctx.cwd);
|
|
97
|
+
reportSettingsErrors(ctx, settingsManager);
|
|
98
|
+
|
|
99
|
+
const projectConfiguredPath = readRulesFilePathFromSettings(settingsManager.getProjectSettings());
|
|
100
|
+
if (projectConfiguredPath !== null) {
|
|
101
|
+
return projectConfiguredPath;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const globalConfiguredPath = readRulesFilePathFromSettings(settingsManager.getGlobalSettings());
|
|
105
|
+
if (globalConfiguredPath !== null) {
|
|
106
|
+
return globalConfiguredPath;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return null;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const resolveRulesFilePathFromProjectDirectory = (projectDirectory: string, configuredPath: string): string => {
|
|
113
|
+
if (path.isAbsolute(configuredPath)) {
|
|
114
|
+
return configuredPath;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return path.resolve(projectDirectory, configuredPath);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const resolveRulesFilePath = async (ctx: ExtensionContext): Promise<string> => {
|
|
121
|
+
const projectDirectory = await resolveProjectDirectory(ctx.cwd);
|
|
122
|
+
const configuredPath = getConfiguredRulesFilePath(ctx) ?? DEFAULT_RULES_FILE;
|
|
123
|
+
return resolveRulesFilePathFromProjectDirectory(projectDirectory, configuredPath);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const loadRules = async (ctx: ExtensionContext, force: boolean): Promise<IReminderRule[]> => {
|
|
127
|
+
const rulesFilePath = await resolveRulesFilePath(ctx);
|
|
128
|
+
|
|
129
|
+
let mtimeMs = -1;
|
|
130
|
+
try {
|
|
131
|
+
const stats = await stat(rulesFilePath);
|
|
132
|
+
mtimeMs = stats.mtimeMs;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
rulesCache = [];
|
|
135
|
+
rulesMtimeMs = -1;
|
|
136
|
+
hasLoadedRules = true;
|
|
137
|
+
|
|
138
|
+
if (isRecord(error) && error.code === "ENOENT") {
|
|
139
|
+
lastLoadError = "";
|
|
140
|
+
return rulesCache;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const errorMessage = getErrorMessage(error);
|
|
144
|
+
if (errorMessage !== lastLoadError && ctx.hasUI) {
|
|
145
|
+
ctx.ui.notify(`Failed to stat reminder rules file: ${errorMessage}`, "warning");
|
|
146
|
+
}
|
|
147
|
+
lastLoadError = errorMessage;
|
|
148
|
+
return rulesCache;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!force && hasLoadedRules && mtimeMs === rulesMtimeMs) {
|
|
152
|
+
return rulesCache;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const raw = await readFile(rulesFilePath, "utf8");
|
|
157
|
+
const parsed: unknown = JSON.parse(raw);
|
|
158
|
+
rulesCache = parseRules(parsed);
|
|
159
|
+
rulesMtimeMs = mtimeMs;
|
|
160
|
+
hasLoadedRules = true;
|
|
161
|
+
lastLoadError = "";
|
|
162
|
+
globMatcherCache.clear();
|
|
163
|
+
return rulesCache;
|
|
164
|
+
} catch (error) {
|
|
165
|
+
rulesCache = [];
|
|
166
|
+
rulesMtimeMs = mtimeMs;
|
|
167
|
+
hasLoadedRules = true;
|
|
168
|
+
const errorMessage = getErrorMessage(error);
|
|
169
|
+
if (errorMessage !== lastLoadError && ctx.hasUI) {
|
|
170
|
+
ctx.ui.notify(`Failed to parse reminder rules file: ${errorMessage}`, "warning");
|
|
171
|
+
}
|
|
172
|
+
lastLoadError = errorMessage;
|
|
173
|
+
return rulesCache;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const maybeInjectReminder = (ctx: ExtensionContext, rule: IReminderRule, matchedPath: string): void => {
|
|
178
|
+
const reminderKey = buildReminderKey(rule.reminder);
|
|
179
|
+
if (injectedReminderKeys.has(reminderKey)) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
injectedReminderKeys.add(reminderKey);
|
|
184
|
+
pi.appendEntry(INJECTED_ENTRY_TYPE, {
|
|
185
|
+
reminderKey,
|
|
186
|
+
glob: rule.glob,
|
|
187
|
+
reminder: rule.reminder,
|
|
188
|
+
matchedPath,
|
|
189
|
+
injectedAt: new Date().toISOString(),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if (ctx.isIdle()) {
|
|
193
|
+
pi.sendUserMessage(rule.reminder);
|
|
194
|
+
} else {
|
|
195
|
+
pi.sendUserMessage(rule.reminder, { deliverAs: "steer" });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (ctx.hasUI) {
|
|
199
|
+
ctx.ui.notify(`Auto reminder injected for ${matchedPath} (rule: ${rule.glob})`, "info");
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const onSessionChanged = async (_ctxEvent: unknown, ctx: ExtensionContext): Promise<void> => {
|
|
204
|
+
rebuildInjectedReminderKeys(ctx);
|
|
205
|
+
await loadRules(ctx, true);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
pi.on("session_start", onSessionChanged);
|
|
209
|
+
pi.on("session_tree", onSessionChanged);
|
|
210
|
+
|
|
211
|
+
pi.registerCommand(COMMAND_NAME, {
|
|
212
|
+
description: "Inject a prompt that explains how to update reminder rules",
|
|
213
|
+
handler: async (args, ctx) => {
|
|
214
|
+
const projectDirectory = await resolveProjectDirectory(ctx.cwd);
|
|
215
|
+
const rulesFilePath = await resolveRulesFilePath(ctx);
|
|
216
|
+
const normalizedRulesFilePath = normalizePath(rulesFilePath);
|
|
217
|
+
const normalizedProjectDirectory = normalizePath(projectDirectory);
|
|
218
|
+
const prompt = buildReminderEditPrompt(normalizedRulesFilePath, normalizedProjectDirectory, args);
|
|
219
|
+
|
|
220
|
+
if (ctx.isIdle()) {
|
|
221
|
+
pi.sendUserMessage(prompt);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
pi.sendUserMessage(prompt, { deliverAs: "followUp" });
|
|
226
|
+
if (ctx.hasUI) {
|
|
227
|
+
ctx.ui.notify(`Queued /${COMMAND_NAME} prompt as a follow-up message.`, "info");
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
233
|
+
if (event.isError) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const changedPaths = getChangedPaths(event.toolName, event.input);
|
|
238
|
+
if (changedPaths.length === 0) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const rules = await loadRules(ctx, false);
|
|
243
|
+
if (rules.length === 0) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const projectDirectory = await resolveProjectDirectory(ctx.cwd);
|
|
248
|
+
for (const changedPath of changedPaths) {
|
|
249
|
+
const absolutePath = path.isAbsolute(changedPath)
|
|
250
|
+
? path.normalize(changedPath)
|
|
251
|
+
: path.resolve(ctx.cwd, changedPath);
|
|
252
|
+
const normalizedAbsolutePath = normalizePath(absolutePath);
|
|
253
|
+
const normalizedRelativePath = normalizePath(path.relative(projectDirectory, absolutePath));
|
|
254
|
+
|
|
255
|
+
for (const rule of rules) {
|
|
256
|
+
if (matchesRule(rule.glob, normalizedRelativePath, normalizedAbsolutePath, globMatcherCache)) {
|
|
257
|
+
maybeInjectReminder(ctx, rule, normalizedRelativePath);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function parseRules(parsed: unknown): IReminderRule[] {
|
|
265
|
+
if (!Array.isArray(parsed)) {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const rules: IReminderRule[] = [];
|
|
270
|
+
for (const item of parsed) {
|
|
271
|
+
if (!isRecord(item)) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const glob = readNonEmptyString(item.glob);
|
|
276
|
+
const reminder = readNonEmptyString(item.reminder);
|
|
277
|
+
if (glob === null || reminder === null) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
rules.push({
|
|
282
|
+
glob: normalizePath(glob),
|
|
283
|
+
reminder,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return rules;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function getChangedPaths(toolName: string, input: unknown): string[] {
|
|
291
|
+
if ((toolName === "write" || toolName === "edit") && isToolInputWithPath(input)) {
|
|
292
|
+
return [input.path];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (toolName !== "multi_tool_use.parallel") {
|
|
296
|
+
return [];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!isRecord(input) || !Array.isArray(input.tool_uses)) {
|
|
300
|
+
return [];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const changedPaths: string[] = [];
|
|
304
|
+
for (const toolUse of input.tool_uses) {
|
|
305
|
+
if (!isRecord(toolUse)) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const recipientName = readNonEmptyString(toolUse.recipient_name);
|
|
310
|
+
if (recipientName === null) {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const isWriteCall = recipientName.endsWith(".write");
|
|
315
|
+
const isEditCall = recipientName.endsWith(".edit");
|
|
316
|
+
if (!isWriteCall && !isEditCall) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (isToolInputWithPath(toolUse.parameters)) {
|
|
321
|
+
changedPaths.push(toolUse.parameters.path);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return changedPaths;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function isToolInputWithPath(input: unknown): input is IToolInputWithPath {
|
|
329
|
+
return isRecord(input) && typeof input.path === "string" && input.path.length > 0;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function matchesRule(
|
|
333
|
+
ruleGlob: string,
|
|
334
|
+
relativePath: string,
|
|
335
|
+
absolutePath: string,
|
|
336
|
+
globMatcherCache: Map<string, PathMatcher>,
|
|
337
|
+
): boolean {
|
|
338
|
+
const normalizedGlob = normalizePath(ruleGlob);
|
|
339
|
+
if (path.isAbsolute(normalizedGlob)) {
|
|
340
|
+
return matchesGlob(normalizedGlob, absolutePath, globMatcherCache);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return matchesGlob(normalizedGlob, relativePath, globMatcherCache);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function matchesGlob(globPattern: string, candidatePath: string, globMatcherCache: Map<string, PathMatcher>): boolean {
|
|
347
|
+
let matcher = globMatcherCache.get(globPattern);
|
|
348
|
+
if (matcher === undefined) {
|
|
349
|
+
const picomatchMatcher = picomatch(globPattern, { dot: true });
|
|
350
|
+
matcher = (value: string): boolean => picomatchMatcher(value);
|
|
351
|
+
globMatcherCache.set(globPattern, matcher);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return matcher(candidatePath);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function buildReminderEditPrompt(rulesFilePath: string, projectDirectory: string, args: string): string {
|
|
358
|
+
const requestedChange = readNonEmptyString(args);
|
|
359
|
+
const changeInstructions =
|
|
360
|
+
requestedChange === null
|
|
361
|
+
? "Explain what changes you recommend, then ask me what to add, edit, or remove."
|
|
362
|
+
: `Apply this requested change: ${requestedChange}`;
|
|
363
|
+
|
|
364
|
+
return [
|
|
365
|
+
"Help me update this project reminder configuration file:",
|
|
366
|
+
`- ${rulesFilePath}`,
|
|
367
|
+
"",
|
|
368
|
+
"Purpose of this file:",
|
|
369
|
+
"- It defines reminders that are auto-injected when matching files are changed.",
|
|
370
|
+
"- Each rule maps a file pattern (glob) to a reminder message sent to the agent.",
|
|
371
|
+
"",
|
|
372
|
+
"Glob usage:",
|
|
373
|
+
"- 'glob' uses picomatch syntax (for example *, **, ?, braces like {ts,tsx}, and extglobs).",
|
|
374
|
+
`- Relative globs are matched from the project marker directory: ${projectDirectory}`,
|
|
375
|
+
"- Example relative glob: src/**/*.ts",
|
|
376
|
+
"- The project marker directory is the nearest ancestor containing .git or .pi.",
|
|
377
|
+
"- Absolute globs are matched against normalized absolute file paths.",
|
|
378
|
+
"- Use specific patterns to avoid noisy reminders.",
|
|
379
|
+
"",
|
|
380
|
+
"Requirements:",
|
|
381
|
+
"- Keep valid JSON with a top-level array.",
|
|
382
|
+
"- Each rule must have non-empty string fields: glob, reminder.",
|
|
383
|
+
"- Preserve existing rules unless my request says otherwise.",
|
|
384
|
+
"- Use 2-space indentation.",
|
|
385
|
+
"",
|
|
386
|
+
changeInstructions,
|
|
387
|
+
].join("\n");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function readRulesFilePathFromSettings(settings: unknown): string | null {
|
|
391
|
+
if (!isRecord(settings)) {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const extensionSettings = settings[SETTINGS_NAMESPACE];
|
|
396
|
+
if (!isRecord(extensionSettings)) {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return readNonEmptyString(extensionSettings.rulesFile);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function pathExists(filePath: string): Promise<boolean> {
|
|
404
|
+
try {
|
|
405
|
+
await stat(filePath);
|
|
406
|
+
return true;
|
|
407
|
+
} catch {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function normalizePath(filePath: string): string {
|
|
413
|
+
const forwardSlashPath = filePath.replaceAll("\\", "/");
|
|
414
|
+
if (forwardSlashPath.startsWith("./")) {
|
|
415
|
+
return forwardSlashPath.slice(2);
|
|
416
|
+
}
|
|
417
|
+
return forwardSlashPath;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
421
|
+
return typeof value === "object" && value !== null;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function readNonEmptyString(value: unknown): string | null {
|
|
425
|
+
if (typeof value !== "string") {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const trimmed = value.trim();
|
|
430
|
+
if (trimmed.length === 0) {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return trimmed;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function buildReminderKey(reminder: string): string {
|
|
438
|
+
return reminder.trim();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function getErrorMessage(error: unknown): string {
|
|
442
|
+
if (error instanceof Error && error.message.length > 0) {
|
|
443
|
+
return error.message;
|
|
444
|
+
}
|
|
445
|
+
return String(error);
|
|
446
|
+
}
|
|
@@ -1,460 +0,0 @@
|
|
|
1
|
-
import { readFile, stat } from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import picomatch from "picomatch";
|
|
4
|
-
import { SettingsManager, type ExtensionAPI, type 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 SETTINGS_NAMESPACE = "pi-file-change-reminder";
|
|
17
|
-
const DEFAULT_RULES_FILE = ".pi/reminders.json";
|
|
18
|
-
const COMMAND_NAME = "pi-file-change-reminder";
|
|
19
|
-
const INJECTED_ENTRY_TYPE = `${EXTENSION_ID}.injected`;
|
|
20
|
-
|
|
21
|
-
export default function fileChangeReminderExtension(pi: ExtensionAPI): void {
|
|
22
|
-
let rulesCache: IReminderRule[] = [];
|
|
23
|
-
let rulesMtimeMs = -1;
|
|
24
|
-
let hasLoadedRules = false;
|
|
25
|
-
let lastLoadError = "";
|
|
26
|
-
let lastSettingsError = "";
|
|
27
|
-
let injectedReminderKeys = new Set<string>();
|
|
28
|
-
const globMatcherCache = new Map<string, (candidatePath: string) => boolean>();
|
|
29
|
-
const projectDirectoryCache = new Map<string, string>();
|
|
30
|
-
|
|
31
|
-
const rebuildInjectedReminderKeys = (ctx: ExtensionContext): void => {
|
|
32
|
-
injectedReminderKeys = new Set<string>();
|
|
33
|
-
|
|
34
|
-
for (const entry of ctx.sessionManager.getBranch()) {
|
|
35
|
-
if (entry.type !== "custom" || entry.customType !== INJECTED_ENTRY_TYPE) {
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (!isRecord(entry.data)) {
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const reminderKey = readNonEmptyString(entry.data.reminderKey);
|
|
44
|
-
if (reminderKey !== null) {
|
|
45
|
-
injectedReminderKeys.add(reminderKey);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const resolveProjectDirectory = async (cwd: string): Promise<string> => {
|
|
51
|
-
const resolvedCwd = path.resolve(cwd);
|
|
52
|
-
const cachedDirectory = projectDirectoryCache.get(resolvedCwd);
|
|
53
|
-
if (cachedDirectory !== undefined) {
|
|
54
|
-
return cachedDirectory;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
let currentDirectory = resolvedCwd;
|
|
58
|
-
for (;;) {
|
|
59
|
-
const hasGitMarker = await pathExists(path.join(currentDirectory, ".git"));
|
|
60
|
-
const hasPiMarker = await pathExists(path.join(currentDirectory, ".pi"));
|
|
61
|
-
if (hasGitMarker || hasPiMarker) {
|
|
62
|
-
projectDirectoryCache.set(resolvedCwd, currentDirectory);
|
|
63
|
-
return currentDirectory;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const parentDirectory = path.dirname(currentDirectory);
|
|
67
|
-
if (parentDirectory === currentDirectory) {
|
|
68
|
-
projectDirectoryCache.set(resolvedCwd, resolvedCwd);
|
|
69
|
-
return resolvedCwd;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
currentDirectory = parentDirectory;
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
const reportSettingsErrors = (ctx: ExtensionContext, settingsManager: SettingsManager): void => {
|
|
77
|
-
const settingsErrors = settingsManager.drainErrors();
|
|
78
|
-
if (settingsErrors.length === 0) {
|
|
79
|
-
lastSettingsError = "";
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const settingsErrorMessage = settingsErrors
|
|
84
|
-
.map(({ scope, error }) => `${scope}: ${getErrorMessage(error)}`)
|
|
85
|
-
.join("; ");
|
|
86
|
-
if (settingsErrorMessage !== lastSettingsError && ctx.hasUI) {
|
|
87
|
-
ctx.ui.notify(`Failed to load reminder settings: ${settingsErrorMessage}`, "warning");
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
lastSettingsError = settingsErrorMessage;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
const getConfiguredRulesFilePath = (ctx: ExtensionContext): string | null => {
|
|
94
|
-
const settingsManager = SettingsManager.create(ctx.cwd);
|
|
95
|
-
reportSettingsErrors(ctx, settingsManager);
|
|
96
|
-
|
|
97
|
-
const projectConfiguredPath = readRulesFilePathFromSettings(settingsManager.getProjectSettings());
|
|
98
|
-
if (projectConfiguredPath !== null) {
|
|
99
|
-
return projectConfiguredPath;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const globalConfiguredPath = readRulesFilePathFromSettings(settingsManager.getGlobalSettings());
|
|
103
|
-
if (globalConfiguredPath !== null) {
|
|
104
|
-
return globalConfiguredPath;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return null;
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
const resolveRulesFilePathFromProjectDirectory = (projectDirectory: string, configuredPath: string): string => {
|
|
111
|
-
if (path.isAbsolute(configuredPath)) {
|
|
112
|
-
return configuredPath;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return path.resolve(projectDirectory, configuredPath);
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
const resolveRulesFilePath = async (ctx: ExtensionContext): Promise<string> => {
|
|
119
|
-
const projectDirectory = await resolveProjectDirectory(ctx.cwd);
|
|
120
|
-
const configuredPath = getConfiguredRulesFilePath(ctx) ?? DEFAULT_RULES_FILE;
|
|
121
|
-
return resolveRulesFilePathFromProjectDirectory(projectDirectory, configuredPath);
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
const loadRules = async (ctx: ExtensionContext, force: boolean): Promise<IReminderRule[]> => {
|
|
125
|
-
const rulesFilePath = await resolveRulesFilePath(ctx);
|
|
126
|
-
|
|
127
|
-
let mtimeMs = -1;
|
|
128
|
-
try {
|
|
129
|
-
const stats = await stat(rulesFilePath);
|
|
130
|
-
mtimeMs = stats.mtimeMs;
|
|
131
|
-
} catch (error) {
|
|
132
|
-
rulesCache = [];
|
|
133
|
-
rulesMtimeMs = -1;
|
|
134
|
-
hasLoadedRules = true;
|
|
135
|
-
|
|
136
|
-
if (isRecord(error) && error.code === "ENOENT") {
|
|
137
|
-
lastLoadError = "";
|
|
138
|
-
return rulesCache;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const errorMessage = getErrorMessage(error);
|
|
142
|
-
if (errorMessage !== lastLoadError && ctx.hasUI) {
|
|
143
|
-
ctx.ui.notify(`Failed to stat reminder rules file: ${errorMessage}`, "warning");
|
|
144
|
-
}
|
|
145
|
-
lastLoadError = errorMessage;
|
|
146
|
-
return rulesCache;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (!force && hasLoadedRules && mtimeMs === rulesMtimeMs) {
|
|
150
|
-
return rulesCache;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
try {
|
|
154
|
-
const raw = await readFile(rulesFilePath, "utf8");
|
|
155
|
-
const parsed: unknown = JSON.parse(raw);
|
|
156
|
-
rulesCache = parseRules(parsed);
|
|
157
|
-
rulesMtimeMs = mtimeMs;
|
|
158
|
-
hasLoadedRules = true;
|
|
159
|
-
lastLoadError = "";
|
|
160
|
-
globMatcherCache.clear();
|
|
161
|
-
return rulesCache;
|
|
162
|
-
} catch (error) {
|
|
163
|
-
rulesCache = [];
|
|
164
|
-
rulesMtimeMs = mtimeMs;
|
|
165
|
-
hasLoadedRules = true;
|
|
166
|
-
const errorMessage = getErrorMessage(error);
|
|
167
|
-
if (errorMessage !== lastLoadError && ctx.hasUI) {
|
|
168
|
-
ctx.ui.notify(`Failed to parse reminder rules file: ${errorMessage}`, "warning");
|
|
169
|
-
}
|
|
170
|
-
lastLoadError = errorMessage;
|
|
171
|
-
return rulesCache;
|
|
172
|
-
}
|
|
173
|
-
};
|
|
174
|
-
|
|
175
|
-
const maybeInjectReminder = (
|
|
176
|
-
ctx: ExtensionContext,
|
|
177
|
-
rule: IReminderRule,
|
|
178
|
-
matchedPath: string,
|
|
179
|
-
): void => {
|
|
180
|
-
const reminderKey = buildReminderKey(rule.reminder);
|
|
181
|
-
if (injectedReminderKeys.has(reminderKey)) {
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
injectedReminderKeys.add(reminderKey);
|
|
186
|
-
pi.appendEntry(INJECTED_ENTRY_TYPE, {
|
|
187
|
-
reminderKey,
|
|
188
|
-
glob: rule.glob,
|
|
189
|
-
reminder: rule.reminder,
|
|
190
|
-
matchedPath,
|
|
191
|
-
injectedAt: new Date().toISOString(),
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
if (ctx.isIdle()) {
|
|
195
|
-
pi.sendUserMessage(rule.reminder);
|
|
196
|
-
} else {
|
|
197
|
-
pi.sendUserMessage(rule.reminder, { deliverAs: "steer" });
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (ctx.hasUI) {
|
|
201
|
-
ctx.ui.notify(`Auto reminder injected for ${matchedPath} (rule: ${rule.glob})`, "info");
|
|
202
|
-
}
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
const onSessionChanged = async (_ctxEvent: unknown, ctx: ExtensionContext): Promise<void> => {
|
|
206
|
-
rebuildInjectedReminderKeys(ctx);
|
|
207
|
-
await loadRules(ctx, true);
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
pi.on("session_start", onSessionChanged);
|
|
211
|
-
pi.on("session_switch", onSessionChanged);
|
|
212
|
-
pi.on("session_fork", onSessionChanged);
|
|
213
|
-
pi.on("session_tree", onSessionChanged);
|
|
214
|
-
|
|
215
|
-
pi.registerCommand(COMMAND_NAME, {
|
|
216
|
-
description: "Inject a prompt that explains how to update reminder rules",
|
|
217
|
-
handler: async (args, ctx) => {
|
|
218
|
-
const projectDirectory = await resolveProjectDirectory(ctx.cwd);
|
|
219
|
-
const rulesFilePath = await resolveRulesFilePath(ctx);
|
|
220
|
-
const normalizedRulesFilePath = normalizePath(rulesFilePath);
|
|
221
|
-
const normalizedProjectDirectory = normalizePath(projectDirectory);
|
|
222
|
-
const prompt = buildReminderEditPrompt(normalizedRulesFilePath, normalizedProjectDirectory, args);
|
|
223
|
-
|
|
224
|
-
if (ctx.isIdle()) {
|
|
225
|
-
pi.sendUserMessage(prompt);
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
pi.sendUserMessage(prompt, { deliverAs: "followUp" });
|
|
230
|
-
if (ctx.hasUI) {
|
|
231
|
-
ctx.ui.notify(`Queued /${COMMAND_NAME} prompt as a follow-up message.`, "info");
|
|
232
|
-
}
|
|
233
|
-
},
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
pi.on("tool_result", async (event, ctx) => {
|
|
237
|
-
if (event.isError) {
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const changedPaths = getChangedPaths(event.toolName, event.input);
|
|
242
|
-
if (changedPaths.length === 0) {
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const rules = await loadRules(ctx, false);
|
|
247
|
-
if (rules.length === 0) {
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const projectDirectory = await resolveProjectDirectory(ctx.cwd);
|
|
252
|
-
for (const changedPath of changedPaths) {
|
|
253
|
-
const absolutePath = path.isAbsolute(changedPath)
|
|
254
|
-
? path.normalize(changedPath)
|
|
255
|
-
: path.resolve(ctx.cwd, changedPath);
|
|
256
|
-
const normalizedAbsolutePath = normalizePath(absolutePath);
|
|
257
|
-
const normalizedRelativePath = normalizePath(path.relative(projectDirectory, absolutePath));
|
|
258
|
-
|
|
259
|
-
for (const rule of rules) {
|
|
260
|
-
if (
|
|
261
|
-
matchesRule(
|
|
262
|
-
rule.glob,
|
|
263
|
-
normalizedRelativePath,
|
|
264
|
-
normalizedAbsolutePath,
|
|
265
|
-
globMatcherCache,
|
|
266
|
-
)
|
|
267
|
-
) {
|
|
268
|
-
maybeInjectReminder(ctx, rule, normalizedRelativePath);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function parseRules(parsed: unknown): IReminderRule[] {
|
|
276
|
-
if (!Array.isArray(parsed)) {
|
|
277
|
-
return [];
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const rules: IReminderRule[] = [];
|
|
281
|
-
for (const item of parsed) {
|
|
282
|
-
if (!isRecord(item)) {
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
const glob = readNonEmptyString(item.glob);
|
|
287
|
-
const reminder = readNonEmptyString(item.reminder);
|
|
288
|
-
if (glob === null || reminder === null) {
|
|
289
|
-
continue;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
rules.push({
|
|
293
|
-
glob: normalizePath(glob),
|
|
294
|
-
reminder,
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
return rules;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function getChangedPaths(toolName: string, input: unknown): string[] {
|
|
302
|
-
if ((toolName === "write" || toolName === "edit") && isToolInputWithPath(input)) {
|
|
303
|
-
return [input.path];
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
if (toolName !== "multi_tool_use.parallel") {
|
|
307
|
-
return [];
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
if (!isRecord(input) || !Array.isArray(input.tool_uses)) {
|
|
311
|
-
return [];
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
const changedPaths: string[] = [];
|
|
315
|
-
for (const toolUse of input.tool_uses) {
|
|
316
|
-
if (!isRecord(toolUse)) {
|
|
317
|
-
continue;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
const recipientName = readNonEmptyString(toolUse.recipient_name);
|
|
321
|
-
if (recipientName === null) {
|
|
322
|
-
continue;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
const isWriteCall = recipientName.endsWith(".write");
|
|
326
|
-
const isEditCall = recipientName.endsWith(".edit");
|
|
327
|
-
if (!isWriteCall && !isEditCall) {
|
|
328
|
-
continue;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
if (isToolInputWithPath(toolUse.parameters)) {
|
|
332
|
-
changedPaths.push(toolUse.parameters.path);
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
return changedPaths;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
function isToolInputWithPath(input: unknown): input is IToolInputWithPath {
|
|
340
|
-
return isRecord(input) && typeof input.path === "string" && input.path.length > 0;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function matchesRule(
|
|
344
|
-
ruleGlob: string,
|
|
345
|
-
relativePath: string,
|
|
346
|
-
absolutePath: string,
|
|
347
|
-
globMatcherCache: Map<string, (candidatePath: string) => boolean>,
|
|
348
|
-
): boolean {
|
|
349
|
-
const normalizedGlob = normalizePath(ruleGlob);
|
|
350
|
-
if (path.isAbsolute(normalizedGlob)) {
|
|
351
|
-
return matchesGlob(normalizedGlob, absolutePath, globMatcherCache);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
return matchesGlob(normalizedGlob, relativePath, globMatcherCache);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function matchesGlob(
|
|
358
|
-
globPattern: string,
|
|
359
|
-
candidatePath: string,
|
|
360
|
-
globMatcherCache: Map<string, (candidatePath: string) => boolean>,
|
|
361
|
-
): boolean {
|
|
362
|
-
let matcher = globMatcherCache.get(globPattern);
|
|
363
|
-
if (matcher === undefined) {
|
|
364
|
-
const picomatchMatcher = picomatch(globPattern, { dot: true });
|
|
365
|
-
matcher = (value: string): boolean => picomatchMatcher(value);
|
|
366
|
-
globMatcherCache.set(globPattern, matcher);
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
return matcher(candidatePath);
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
function buildReminderEditPrompt(rulesFilePath: string, projectDirectory: string, args: string): string {
|
|
373
|
-
const requestedChange = readNonEmptyString(args);
|
|
374
|
-
const changeInstructions = requestedChange === null
|
|
375
|
-
? "Explain what changes you recommend, then ask me what to add, edit, or remove."
|
|
376
|
-
: `Apply this requested change: ${requestedChange}`;
|
|
377
|
-
|
|
378
|
-
return [
|
|
379
|
-
"Help me update this project reminder configuration file:",
|
|
380
|
-
`- ${rulesFilePath}`,
|
|
381
|
-
"",
|
|
382
|
-
"Purpose of this file:",
|
|
383
|
-
"- It defines reminders that are auto-injected when matching files are changed.",
|
|
384
|
-
"- Each rule maps a file pattern (glob) to a reminder message sent to the agent.",
|
|
385
|
-
"",
|
|
386
|
-
"Glob usage:",
|
|
387
|
-
"- 'glob' uses picomatch syntax (for example *, **, ?, braces like {ts,tsx}, and extglobs).",
|
|
388
|
-
`- Relative globs are matched from the project marker directory: ${projectDirectory}`,
|
|
389
|
-
"- Example relative glob: src/**/*.ts",
|
|
390
|
-
"- The project marker directory is the nearest ancestor containing .git or .pi.",
|
|
391
|
-
"- Absolute globs are matched against normalized absolute file paths.",
|
|
392
|
-
"- Use specific patterns to avoid noisy reminders.",
|
|
393
|
-
"",
|
|
394
|
-
"Requirements:",
|
|
395
|
-
"- Keep valid JSON with a top-level array.",
|
|
396
|
-
"- Each rule must have non-empty string fields: glob, reminder.",
|
|
397
|
-
"- Preserve existing rules unless my request says otherwise.",
|
|
398
|
-
"- Use 2-space indentation.",
|
|
399
|
-
"",
|
|
400
|
-
changeInstructions,
|
|
401
|
-
].join("\n");
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
function readRulesFilePathFromSettings(settings: unknown): string | null {
|
|
405
|
-
if (!isRecord(settings)) {
|
|
406
|
-
return null;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
const extensionSettings = settings[SETTINGS_NAMESPACE];
|
|
410
|
-
if (!isRecord(extensionSettings)) {
|
|
411
|
-
return null;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
return readNonEmptyString(extensionSettings.rulesFile);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
async function pathExists(filePath: string): Promise<boolean> {
|
|
418
|
-
try {
|
|
419
|
-
await stat(filePath);
|
|
420
|
-
return true;
|
|
421
|
-
} catch {
|
|
422
|
-
return false;
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
function normalizePath(filePath: string): string {
|
|
427
|
-
const forwardSlashPath = filePath.replaceAll("\\", "/");
|
|
428
|
-
if (forwardSlashPath.startsWith("./")) {
|
|
429
|
-
return forwardSlashPath.slice(2);
|
|
430
|
-
}
|
|
431
|
-
return forwardSlashPath;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
435
|
-
return typeof value === "object" && value !== null;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
function readNonEmptyString(value: unknown): string | null {
|
|
439
|
-
if (typeof value !== "string") {
|
|
440
|
-
return null;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
const trimmed = value.trim();
|
|
444
|
-
if (trimmed.length === 0) {
|
|
445
|
-
return null;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
return trimmed;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function buildReminderKey(reminder: string): string {
|
|
452
|
-
return reminder.trim();
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
function getErrorMessage(error: unknown): string {
|
|
456
|
-
if (error instanceof Error && error.message.length > 0) {
|
|
457
|
-
return error.message;
|
|
458
|
-
}
|
|
459
|
-
return String(error);
|
|
460
|
-
}
|