@lazco/pi-rules 0.7.0 → 0.9.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/README.md +18 -3
- package/package.json +1 -1
- package/src/discovery.ts +35 -12
- package/src/index.ts +77 -38
- package/src/matching.ts +56 -20
- package/src/state.ts +20 -6
- package/src/types.ts +20 -1
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ It reads the same rule files, so you can switch without a change to your rules.
|
|
|
17
17
|
|
|
18
18
|
- **Claude Code compatible.** Reads `.claude/rules/` and `.agents/rules/` next to `.pi/rules/`, so one set of files serves both tools.
|
|
19
19
|
- **Rules for matching files.** A rule with `paths` loads only when the agent reads, edits, or writes a matching file.
|
|
20
|
+
- **User rules for any file.** A rule in your home directory also loads for files outside the project, such as `/tmp/build.sh` or `~/.zshrc`.
|
|
20
21
|
- **Rule before change.** An edit or a write to a matching file waits until the agent has the rule, then the agent tries again.
|
|
21
22
|
- **Linked skills.** A rule can load full pi [skills](#skills) when it loads.
|
|
22
23
|
- **Six sources in one list.** Project and user directories merge by file name, and the first source wins. See [Rule files](#rule-files).
|
|
@@ -86,6 +87,9 @@ pi-rules reads the injected rules from the messages that the model sees, so it s
|
|
|
86
87
|
- resume a session, run `/reload`, or fork a session,
|
|
87
88
|
- compact the session. The rules that the summary replaced load again when they are needed.
|
|
88
89
|
|
|
90
|
+
When you change a rule file that already loaded, also only its frontmatter, the new version loads before the next model call, marked `(updated)`.
|
|
91
|
+
When you delete it, the model gets a short notice that the rule no longer applies.
|
|
92
|
+
|
|
89
93
|
## Rule files
|
|
90
94
|
|
|
91
95
|
A rule is a Markdown file.
|
|
@@ -119,7 +123,19 @@ A file without frontmatter is a rule for every session.
|
|
|
119
123
|
| `events` | `tool_call: <tool>` or a list of tools | The rule loads only for these tools: `read`, `edit`, `write`. |
|
|
120
124
|
| `skills` | a skill name or a list of names | These skills load with the rule. |
|
|
121
125
|
|
|
122
|
-
Globs
|
|
126
|
+
Globs use `/` and match dotfiles.
|
|
127
|
+
How a glob matches depends on the directory of the rule:
|
|
128
|
+
|
|
129
|
+
| Rule in | Applies to | Glob |
|
|
130
|
+
| --- | --- | --- |
|
|
131
|
+
| a project directory (1 to 3) | files inside `<project>` | relative to `<project>` |
|
|
132
|
+
| a user directory (4 to 6) | any file | `/…` is an absolute path. `~/…` starts at your home directory. Other globs match the path inside `<project>` or the absolute path. |
|
|
133
|
+
|
|
134
|
+
For example, a user rule with `paths: "**/*.sh"` loads for `/tmp/build.sh` and for `scripts/build.sh` in any project.
|
|
135
|
+
A user rule with `paths: "~/Projects/shop/**"` loads only for files of that project.
|
|
136
|
+
|
|
137
|
+
When you start pi in your home directory, `.agents/rules/` and `~/.agents/rules/` are the same directory.
|
|
138
|
+
Its rules then count as user rules.
|
|
123
139
|
|
|
124
140
|
### Tools
|
|
125
141
|
|
|
@@ -188,9 +204,8 @@ pi-rules reads `rules.json` when a session starts and after a compaction.
|
|
|
188
204
|
|
|
189
205
|
## Limitations
|
|
190
206
|
|
|
191
|
-
- **
|
|
207
|
+
- **Project rules of other projects.** A file in another project loads only user rules, not the rules in the directories of that project.
|
|
192
208
|
- **Shell commands.** A file that a `bash` command changes does not load a rule.
|
|
193
|
-
- **Changed rules.** A rule that already loaded does not load again after you change its file, until the next compaction.
|
|
194
209
|
|
|
195
210
|
## Contributing
|
|
196
211
|
|
package/package.json
CHANGED
package/src/discovery.ts
CHANGED
|
@@ -5,7 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import Type from "typebox";
|
|
6
6
|
import { Compile } from "typebox/compile";
|
|
7
7
|
import { parseDocument } from "yaml";
|
|
8
|
-
import type { DiscoveryResult, Rule, RuleDiagnostic, RuleEvents, RuleToolName } from "./types.ts";
|
|
8
|
+
import type { DiscoveryResult, Rule, RuleDiagnostic, RuleEvents, RuleScope, RuleToolName } from "./types.ts";
|
|
9
9
|
|
|
10
10
|
const FRONTMATTER_RE = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
|
11
11
|
const RULE_TOOL_NAMES = new Set<RuleToolName>(["read", "edit", "write"]);
|
|
@@ -36,6 +36,7 @@ export const DEFAULT_RULE_SOURCES: RuleSource[] = [
|
|
|
36
36
|
interface RuleRoot {
|
|
37
37
|
directory: string;
|
|
38
38
|
label: string;
|
|
39
|
+
scope: RuleScope;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
interface DiscoveryOptions {
|
|
@@ -96,7 +97,9 @@ export async function loadRuleConfig(cwd: string, options: DiscoveryOptions = {}
|
|
|
96
97
|
export async function discoverRules(cwd: string, options: DiscoveryOptions = {}): Promise<DiscoveryResult> {
|
|
97
98
|
const home = options.home ?? os.homedir();
|
|
98
99
|
const env = options.env ?? process.env;
|
|
99
|
-
const roots =
|
|
100
|
+
const roots = await withoutUserAliases(
|
|
101
|
+
(options.sources ?? DEFAULT_RULE_SOURCES).map((source) => resolveRuleRoot(source, cwd, env, home)),
|
|
102
|
+
);
|
|
100
103
|
|
|
101
104
|
const rules: Rule[] = [];
|
|
102
105
|
const diagnostics: RuleDiagnostic[] = [];
|
|
@@ -111,7 +114,7 @@ export async function discoverRules(cwd: string, options: DiscoveryOptions = {})
|
|
|
111
114
|
|
|
112
115
|
const sourcePath = path.join(root.directory, ...relativePath.split("/"));
|
|
113
116
|
const sourceLabel = `${root.label}/${relativePath}`;
|
|
114
|
-
const parsed = await parseRuleFile(sourcePath, sourceLabel);
|
|
117
|
+
const parsed = await parseRuleFile(sourcePath, sourceLabel, root.scope);
|
|
115
118
|
if ("rule" in parsed) {
|
|
116
119
|
rules.push(parsed.rule);
|
|
117
120
|
} else {
|
|
@@ -123,7 +126,7 @@ export async function discoverRules(cwd: string, options: DiscoveryOptions = {})
|
|
|
123
126
|
return { rules, diagnostics };
|
|
124
127
|
}
|
|
125
128
|
|
|
126
|
-
export async function parseRuleFile(sourcePath: string, sourceLabel: string): Promise<ParseResult> {
|
|
129
|
+
export async function parseRuleFile(sourcePath: string, sourceLabel: string, scope: RuleScope): Promise<ParseResult> {
|
|
127
130
|
let content: string;
|
|
128
131
|
try {
|
|
129
132
|
content = await readFile(sourcePath, "utf8");
|
|
@@ -138,7 +141,7 @@ export async function parseRuleFile(sourcePath: string, sourceLabel: string): Pr
|
|
|
138
141
|
return { diagnostic: { sourceLabel, reason: "unclosed frontmatter" } };
|
|
139
142
|
}
|
|
140
143
|
const body = content.replace(/^\uFEFF/, "");
|
|
141
|
-
return { rule: { id: sourcePath, sourcePath, sourceLabel, body, hash:
|
|
144
|
+
return { rule: { id: sourcePath, sourcePath, sourceLabel, scope, body, hash: hashContent(content) } };
|
|
142
145
|
}
|
|
143
146
|
|
|
144
147
|
let raw: unknown;
|
|
@@ -180,7 +183,7 @@ export async function parseRuleFile(sourcePath: string, sourceLabel: string): Pr
|
|
|
180
183
|
}
|
|
181
184
|
|
|
182
185
|
const body = content.slice(frontmatter[0].length);
|
|
183
|
-
const rule: Rule = { id: sourcePath, sourcePath, sourceLabel, body, hash:
|
|
186
|
+
const rule: Rule = { id: sourcePath, sourcePath, sourceLabel, scope, body, hash: hashContent(content) };
|
|
184
187
|
if (paths) rule.paths = paths;
|
|
185
188
|
if (events) rule.events = events;
|
|
186
189
|
if (skills) rule.skills = skills;
|
|
@@ -234,19 +237,39 @@ function resolvePiCodingAgentDirectory(env: NodeJS.ProcessEnv, home: string): st
|
|
|
234
237
|
|
|
235
238
|
function resolveRuleRoot(source: RuleSource, cwd: string, env: NodeJS.ProcessEnv, home: string): RuleRoot {
|
|
236
239
|
if (source.scope === "repo") {
|
|
237
|
-
return { directory: path.join(cwd, `.${source.kind}`, "rules"), label: `.${source.kind}/rules
|
|
240
|
+
return { directory: path.join(cwd, `.${source.kind}`, "rules"), label: `.${source.kind}/rules`, scope: "repo" };
|
|
238
241
|
}
|
|
239
242
|
|
|
240
243
|
if (source.kind === "pi") {
|
|
241
244
|
const directory = path.join(resolvePiCodingAgentDirectory(env, home), "rules");
|
|
242
|
-
return { directory, label: labelDirectory(directory, home) };
|
|
245
|
+
return { directory, label: labelDirectory(directory, home), scope: "user" };
|
|
243
246
|
}
|
|
244
247
|
if (source.kind === "agents") {
|
|
245
|
-
return { directory: path.join(home, ".agents", "rules"), label: "~/.agents/rules" };
|
|
248
|
+
return { directory: path.join(home, ".agents", "rules"), label: "~/.agents/rules", scope: "user" };
|
|
246
249
|
}
|
|
247
250
|
|
|
248
251
|
const directory = path.join(env.CLAUDE_CONFIG_DIR || path.join(home, ".claude"), "rules");
|
|
249
|
-
return { directory, label: labelDirectory(directory, home) };
|
|
252
|
+
return { directory, label: labelDirectory(directory, home), scope: "user" };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Drop a project root that is a user root, such as `.agents/rules` when pi starts in the home directory.
|
|
257
|
+
* Its rules then load once, from the user root, with user scope.
|
|
258
|
+
*/
|
|
259
|
+
async function withoutUserAliases(roots: RuleRoot[]): Promise<RuleRoot[]> {
|
|
260
|
+
const realDirectories = await Promise.all(roots.map((root) => realDirectory(root.directory)));
|
|
261
|
+
const userDirectories = new Set(
|
|
262
|
+
roots.flatMap((root, index) => (root.scope === "user" ? [realDirectories[index]] : [])),
|
|
263
|
+
);
|
|
264
|
+
return roots.filter((root, index) => root.scope === "user" || !userDirectories.has(realDirectories[index]));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function realDirectory(directory: string): Promise<string> {
|
|
268
|
+
try {
|
|
269
|
+
return await realpath(directory);
|
|
270
|
+
} catch {
|
|
271
|
+
return path.resolve(directory);
|
|
272
|
+
}
|
|
250
273
|
}
|
|
251
274
|
|
|
252
275
|
function invalidConfig(configPath: string, sourceLabel: string, reason: string): RuleConfigResult {
|
|
@@ -306,8 +329,8 @@ function labelDirectory(directory: string, home: string): string {
|
|
|
306
329
|
return directory.split(path.sep).join("/");
|
|
307
330
|
}
|
|
308
331
|
|
|
309
|
-
function
|
|
310
|
-
return createHash("sha256").update(
|
|
332
|
+
function hashContent(content: string): string {
|
|
333
|
+
return createHash("sha256").update(content).digest("hex").slice(0, 16);
|
|
311
334
|
}
|
|
312
335
|
|
|
313
336
|
function getErrorCode(error: unknown): string {
|
package/src/index.ts
CHANGED
|
@@ -8,9 +8,9 @@ import {
|
|
|
8
8
|
} from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { Text } from "@earendil-works/pi-tui";
|
|
10
10
|
import { DEFAULT_RULE_SOURCES, discoverRules, loadRuleConfig, type RuleSource } from "./discovery.ts";
|
|
11
|
-
import { extractTarget,
|
|
11
|
+
import { extractTarget, ruleAppliesToTarget, ruleMatchesToolCallEvent, targetLabel } from "./matching.ts";
|
|
12
12
|
import { addInjection, collectInjectedState, CUSTOM_MESSAGE_TYPE, type InjectedState } from "./state.ts";
|
|
13
|
-
import type { PendingRule, Rule, RuleInjectionDetails } from "./types.ts";
|
|
13
|
+
import type { DueRule, PendingRule, RemovedRule, Rule, RuleInjectionDetails } from "./types.ts";
|
|
14
14
|
|
|
15
15
|
export { CUSTOM_MESSAGE_TYPE };
|
|
16
16
|
|
|
@@ -38,9 +38,19 @@ export function makeExtension() {
|
|
|
38
38
|
|
|
39
39
|
pi.registerMessageRenderer<RuleInjectionDetails>(CUSTOM_MESSAGE_TYPE, (message, { expanded }, theme) => {
|
|
40
40
|
const details = message.details;
|
|
41
|
-
const
|
|
41
|
+
const updated = new Set(details?.updated ?? []);
|
|
42
|
+
const sourceLines = (details?.sources ?? ["(unknown)"])
|
|
43
|
+
.map((source) => `↳ ${source}${updated.has(source) ? " (updated)" : ""}`)
|
|
44
|
+
.join("\n");
|
|
42
45
|
const skillLines = (details?.skills ?? []).map((skill) => `↳ ${skill}`).join("\n");
|
|
43
|
-
const
|
|
46
|
+
const removedLines = (details?.removed ?? []).map((rule) => `↳ ${rule.label}`).join("\n");
|
|
47
|
+
const summary = [
|
|
48
|
+
sourceLines ? `Loaded rules:\n${sourceLines}` : "",
|
|
49
|
+
skillLines ? `Loaded skills:\n${skillLines}` : "",
|
|
50
|
+
removedLines ? `Removed rules:\n${removedLines}` : "",
|
|
51
|
+
]
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.join("\n");
|
|
44
54
|
if (!expanded) {
|
|
45
55
|
return new Text(theme.fg("muted", summary), 0, 0);
|
|
46
56
|
}
|
|
@@ -48,15 +58,12 @@ export function makeExtension() {
|
|
|
48
58
|
const targetLines = details?.sources
|
|
49
59
|
.map((source) => {
|
|
50
60
|
const targets = details.targets[source] ?? [];
|
|
51
|
-
|
|
61
|
+
if (targets.length > 0) return `${source} → ${targets.join(", ")}`;
|
|
62
|
+
return `${source} → ${updated.has(source) ? "updated" : "unconditional"}`;
|
|
52
63
|
})
|
|
53
64
|
.join("\n");
|
|
54
65
|
const content = typeof message.content === "string" ? message.content : JSON.stringify(message.content, null, 2);
|
|
55
|
-
return new Text(
|
|
56
|
-
`${theme.fg("accent", "Loaded rules:")}\n${theme.fg("muted", sourceLines)}\n\n${theme.fg("dim", targetLines ?? "")}\n\n${content}`,
|
|
57
|
-
0,
|
|
58
|
-
0,
|
|
59
|
-
);
|
|
66
|
+
return new Text(`${theme.fg("accent", summary)}\n\n${theme.fg("dim", targetLines ?? "")}\n\n${content}`, 0, 0);
|
|
60
67
|
});
|
|
61
68
|
|
|
62
69
|
const reloadConfig = async (cwd: string): Promise<void> => {
|
|
@@ -95,20 +102,42 @@ export function makeExtension() {
|
|
|
95
102
|
return withInFlight(cachedBranch.state);
|
|
96
103
|
};
|
|
97
104
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Takes the matches of this turn, and adds every unconditional rule that the model does not have,
|
|
107
|
+
* every rule whose file changed after the model got it, and every rule whose file is gone.
|
|
108
|
+
*/
|
|
109
|
+
const takeDueRules = (state: InjectedState): { due: DueRule[]; removed: RemovedRule[] } => {
|
|
110
|
+
// A rule file can change between the tool call and the end of the turn, so take the newest version.
|
|
111
|
+
const current = new Map(rules.map((rule) => [rule.id, rule]));
|
|
112
|
+
const due: DueRule[] = [];
|
|
113
|
+
for (const entry of pendingRules.values()) {
|
|
114
|
+
const rule = current.get(entry.rule.id);
|
|
115
|
+
if (rule && !state.rules.has(rule.id)) due.push({ rule, targets: entry.targets, updated: false });
|
|
116
|
+
}
|
|
101
117
|
pendingRules.clear();
|
|
102
118
|
for (const rule of rules) {
|
|
103
|
-
if (
|
|
104
|
-
|
|
119
|
+
if (due.some((entry) => entry.rule.id === rule.id)) continue;
|
|
120
|
+
const known = state.rules.get(rule.id);
|
|
121
|
+
if (known) {
|
|
122
|
+
if (known.hash !== rule.hash) due.push({ rule, targets: new Set<string>(), updated: true });
|
|
123
|
+
} else if (!isScoped(rule)) {
|
|
124
|
+
due.push({ rule, targets: new Set<string>(), updated: false });
|
|
125
|
+
}
|
|
105
126
|
}
|
|
106
|
-
|
|
127
|
+
const discovered = new Set(rules.map((rule) => rule.id));
|
|
128
|
+
const removed = [...state.rules]
|
|
129
|
+
.filter(([id]) => !discovered.has(id))
|
|
130
|
+
.map(([id, known]) => ({ id, label: known.label }));
|
|
131
|
+
return { due, removed };
|
|
107
132
|
};
|
|
108
133
|
|
|
109
|
-
const buildInjection = async (
|
|
110
|
-
|
|
111
|
-
|
|
134
|
+
const buildInjection = async (
|
|
135
|
+
{ due, removed }: { due: DueRule[]; removed: RemovedRule[] },
|
|
136
|
+
state: InjectedState,
|
|
137
|
+
): Promise<RuleInjection | undefined> => {
|
|
138
|
+
if (due.length === 0 && removed.length === 0) return undefined;
|
|
139
|
+
const skillBlocks = await loadSkillBlocks(due, availableSkills, state.skills, reportWarning);
|
|
140
|
+
return createInjection(due, skillBlocks, removed);
|
|
112
141
|
};
|
|
113
142
|
|
|
114
143
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -133,9 +162,9 @@ export function makeExtension() {
|
|
|
133
162
|
if (!config.enabled) return;
|
|
134
163
|
rules = await reloadRules(ctx.cwd);
|
|
135
164
|
const state = branchState(ctx);
|
|
136
|
-
const
|
|
137
|
-
if (
|
|
138
|
-
return { message:
|
|
165
|
+
const injection = await buildInjection(takeDueRules(state), state);
|
|
166
|
+
if (!injection) return;
|
|
167
|
+
return { message: injection };
|
|
139
168
|
});
|
|
140
169
|
|
|
141
170
|
pi.on("tool_call", async (event, ctx) => {
|
|
@@ -145,17 +174,18 @@ export function makeExtension() {
|
|
|
145
174
|
rules = await reloadRules(ctx.cwd);
|
|
146
175
|
const state = branchState(ctx);
|
|
147
176
|
|
|
177
|
+
const label = targetLabel(target);
|
|
148
178
|
let shouldBlock = false;
|
|
149
179
|
for (const rule of rules) {
|
|
150
180
|
if (state.rules.has(rule.id)) continue;
|
|
151
|
-
if (
|
|
181
|
+
if (!ruleAppliesToTarget(rule, target)) continue;
|
|
152
182
|
if (!ruleMatchesToolCallEvent(rule, event.toolName)) continue;
|
|
153
183
|
if (event.toolName !== "read") shouldBlock = true;
|
|
154
184
|
const pending = pendingRules.get(rule.id);
|
|
155
185
|
if (pending) {
|
|
156
|
-
if (isScoped(rule)) pending.targets.add(
|
|
186
|
+
if (isScoped(rule)) pending.targets.add(label);
|
|
157
187
|
} else {
|
|
158
|
-
pendingRules.set(rule.id, { rule, targets: new Set(isScoped(rule) ? [
|
|
188
|
+
pendingRules.set(rule.id, { rule, targets: new Set(isScoped(rule) ? [label] : []) });
|
|
159
189
|
}
|
|
160
190
|
}
|
|
161
191
|
if (!shouldBlock) return;
|
|
@@ -172,9 +202,8 @@ export function makeExtension() {
|
|
|
172
202
|
rules = await reloadRules(ctx.cwd);
|
|
173
203
|
// The preview already holds the entries that earlier handlers proposed for this boundary.
|
|
174
204
|
const state = withInFlight(collectInjectedState(event.context.contextEntries, event.context.pendingMessages));
|
|
175
|
-
const
|
|
176
|
-
if (
|
|
177
|
-
const injection = await buildInjection(due, state);
|
|
205
|
+
const injection = await buildInjection(takeDueRules(state), state);
|
|
206
|
+
if (!injection) return;
|
|
178
207
|
return { entries: [...event.entries, { type: "custom_message", ...injection }] };
|
|
179
208
|
});
|
|
180
209
|
|
|
@@ -188,9 +217,8 @@ export function makeExtension() {
|
|
|
188
217
|
rules = await reloadRules(ctx.cwd);
|
|
189
218
|
// A compaction that retries the turn makes a model call before any turn boundary, so restore the rules now.
|
|
190
219
|
const state = branchState(ctx);
|
|
191
|
-
const
|
|
192
|
-
if (
|
|
193
|
-
const injection = await buildInjection(due, state);
|
|
220
|
+
const injection = await buildInjection(takeDueRules(state), state);
|
|
221
|
+
if (!injection) return;
|
|
194
222
|
inFlight.set(injection.details.injectionId, injection.details);
|
|
195
223
|
pi.sendMessage(injection);
|
|
196
224
|
});
|
|
@@ -207,23 +235,34 @@ interface SkillBlock {
|
|
|
207
235
|
content: string;
|
|
208
236
|
}
|
|
209
237
|
|
|
210
|
-
export function createInjection(
|
|
238
|
+
export function createInjection(
|
|
239
|
+
entries: Array<PendingRule & { updated?: boolean }>,
|
|
240
|
+
skillBlocks: SkillBlock[] = [],
|
|
241
|
+
removed: RemovedRule[] = [],
|
|
242
|
+
): RuleInjection {
|
|
211
243
|
const sorted = [...entries].sort(comparePendingRules);
|
|
212
244
|
const sources = sorted.map((entry) => entry.rule.sourceLabel);
|
|
213
245
|
const targets = Object.fromEntries(sorted.map((entry) => [entry.rule.sourceLabel, [...entry.targets].sort()]));
|
|
214
|
-
const
|
|
215
|
-
.map((entry) =>
|
|
216
|
-
|
|
217
|
-
|
|
246
|
+
const blocks = [
|
|
247
|
+
...sorted.map((entry) =>
|
|
248
|
+
entry.updated
|
|
249
|
+
? `## Rule: ${entry.rule.sourceLabel} (updated)\n\nThis version replaces the earlier version of this rule.\n\n${entry.rule.body}`
|
|
250
|
+
: `## Rule: ${entry.rule.sourceLabel}\n\n${entry.rule.body}`,
|
|
251
|
+
),
|
|
252
|
+
...removed.map((rule) => `## Rule removed: ${rule.label}\n\nThis rule no longer applies. Stop following its earlier version.`),
|
|
253
|
+
...skillBlocks.map((skill) => skill.content),
|
|
254
|
+
];
|
|
218
255
|
|
|
219
256
|
return {
|
|
220
257
|
customType: CUSTOM_MESSAGE_TYPE,
|
|
221
|
-
content:
|
|
258
|
+
content: blocks.join("\n\n---\n\n"),
|
|
222
259
|
display: true,
|
|
223
260
|
details: {
|
|
224
261
|
injectionId: randomUUID(),
|
|
225
|
-
rules: sorted.map((entry) => ({ id: entry.rule.id, hash: entry.rule.hash })),
|
|
262
|
+
rules: sorted.map((entry) => ({ id: entry.rule.id, hash: entry.rule.hash, label: entry.rule.sourceLabel })),
|
|
263
|
+
removed,
|
|
226
264
|
sources,
|
|
265
|
+
updated: sorted.filter((entry) => entry.updated).map((entry) => entry.rule.sourceLabel),
|
|
227
266
|
targets,
|
|
228
267
|
skills: skillBlocks.map((skill) => skill.name),
|
|
229
268
|
},
|
package/src/matching.ts
CHANGED
|
@@ -1,41 +1,77 @@
|
|
|
1
|
+
import os from "node:os";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import picomatch from "picomatch";
|
|
3
|
-
import type { ToolCallEvent
|
|
4
|
+
import type { ToolCallEvent } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import type { Rule } from "./types.ts";
|
|
5
6
|
|
|
6
7
|
const FILE_TOOLS = new Set(["read", "edit", "write"]);
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
/** A file that a tool call reads or changes. */
|
|
10
|
+
export interface ToolTarget {
|
|
11
|
+
/** Absolute path with `/` separators. */
|
|
12
|
+
absolutePath: string;
|
|
13
|
+
/** Path relative to the working directory, only when the file is inside it. */
|
|
14
|
+
relativePath?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function extractTarget(event: ToolCallEvent, cwd: string, home = os.homedir()): ToolTarget | undefined {
|
|
18
|
+
if (!FILE_TOOLS.has(event.toolName)) return undefined;
|
|
10
19
|
const rawPath = (event.input as { path?: unknown }).path;
|
|
11
20
|
if (typeof rawPath !== "string" || rawPath.trim().length === 0) return undefined;
|
|
12
|
-
|
|
13
21
|
const withoutAt = rawPath.startsWith("@") ? rawPath.slice(1) : rawPath;
|
|
14
|
-
|
|
22
|
+
return resolveTarget(withoutAt, cwd, home);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveTarget(rawPath: string, cwd: string, home = os.homedir()): ToolTarget {
|
|
26
|
+
const expanded = rawPath === "~" || rawPath.startsWith("~/") ? path.join(home, rawPath.slice(1)) : rawPath;
|
|
27
|
+
const absolutePath = path.resolve(cwd, expanded);
|
|
15
28
|
const relativePath = path.relative(cwd, absolutePath);
|
|
16
|
-
|
|
17
|
-
relativePath.length
|
|
18
|
-
relativePath
|
|
19
|
-
relativePath.startsWith(`..${path.sep}`)
|
|
20
|
-
path.isAbsolute(relativePath)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return toPosixPath(relativePath);
|
|
29
|
+
const inside =
|
|
30
|
+
relativePath.length > 0 &&
|
|
31
|
+
relativePath !== ".." &&
|
|
32
|
+
!relativePath.startsWith(`..${path.sep}`) &&
|
|
33
|
+
!path.isAbsolute(relativePath);
|
|
34
|
+
return inside
|
|
35
|
+
? { absolutePath: toPosixPath(absolutePath), relativePath: toPosixPath(relativePath) }
|
|
36
|
+
: { absolutePath: toPosixPath(absolutePath) };
|
|
25
37
|
}
|
|
26
38
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
39
|
+
/** The text that the injection message shows for a target. */
|
|
40
|
+
export function targetLabel(target: ToolTarget): string {
|
|
41
|
+
return target.relativePath ?? target.absolutePath;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A project rule applies only inside the working directory, and its globs are relative to it.
|
|
46
|
+
* A user rule applies to any file. Its globs can be absolute (`/…`), start at the home directory (`~/…`),
|
|
47
|
+
* or be relative: a relative glob matches the path inside the working directory or the absolute path.
|
|
48
|
+
*/
|
|
49
|
+
export function ruleAppliesToTarget(rule: Rule, target: ToolTarget, home = os.homedir()): boolean {
|
|
50
|
+
if (rule.scope === "repo" && target.relativePath === undefined) return false;
|
|
51
|
+
if (!rule.paths) return true;
|
|
52
|
+
return rule.paths.some((pattern) => patternMatches(pattern, rule, target, home));
|
|
31
53
|
}
|
|
32
54
|
|
|
33
55
|
export function ruleMatchesToolCallEvent(rule: Rule, toolName: string): boolean {
|
|
34
56
|
return !rule.events || rule.events.tool_call.some((name) => name === toolName);
|
|
35
57
|
}
|
|
36
58
|
|
|
37
|
-
function
|
|
38
|
-
|
|
59
|
+
function patternMatches(pattern: string, rule: Rule, target: ToolTarget, home: string): boolean {
|
|
60
|
+
const normalized = toPosixPath(pattern).replace(/^\.\//, "");
|
|
61
|
+
if (rule.scope === "repo") return isMatch(target.relativePath ?? "", normalized);
|
|
62
|
+
if (normalized.startsWith("/")) return isMatch(target.absolutePath, normalized);
|
|
63
|
+
if (normalized.startsWith("~/")) {
|
|
64
|
+
// Compare in `~/` form, so glob characters in the home path cannot change the pattern.
|
|
65
|
+
const homePrefix = `${toPosixPath(home)}/`;
|
|
66
|
+
if (!target.absolutePath.startsWith(homePrefix)) return false;
|
|
67
|
+
return isMatch(`~/${target.absolutePath.slice(homePrefix.length)}`, normalized);
|
|
68
|
+
}
|
|
69
|
+
if (target.relativePath !== undefined && isMatch(target.relativePath, normalized)) return true;
|
|
70
|
+
return isMatch(target.absolutePath.replace(/^\//, ""), normalized);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isMatch(candidate: string, pattern: string): boolean {
|
|
74
|
+
return picomatch.isMatch(candidate, pattern, { dot: true });
|
|
39
75
|
}
|
|
40
76
|
|
|
41
77
|
function toPosixPath(value: string): string {
|
package/src/state.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import type { ProjectedSessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { RuleInjectionDetails } from "./types.ts";
|
|
3
2
|
|
|
4
3
|
export const CUSTOM_MESSAGE_TYPE = "pi-rules-injection";
|
|
5
4
|
|
|
6
5
|
/** The rules and skills that the model can see now, read from the injection messages in its context. */
|
|
7
6
|
export interface InjectedState {
|
|
8
|
-
/** Rule id to the
|
|
9
|
-
rules: Map<string, string>;
|
|
7
|
+
/** Rule id to the version of the rule that the model has. */
|
|
8
|
+
rules: Map<string, { hash: string; label: string }>;
|
|
10
9
|
skills: Set<string>;
|
|
11
10
|
injectionIds: Set<string>;
|
|
12
11
|
}
|
|
@@ -42,7 +41,11 @@ export function collectInjectedState(
|
|
|
42
41
|
export function addInjection(state: InjectedState, details: unknown): void {
|
|
43
42
|
if (!isInjectionDetails(details)) return;
|
|
44
43
|
state.injectionIds.add(details.injectionId);
|
|
45
|
-
|
|
44
|
+
details.rules.forEach((rule, index) => {
|
|
45
|
+
// 0.7 and 0.8 stored the label only in `sources`, in the same order as `rules`.
|
|
46
|
+
state.rules.set(rule.id, { hash: rule.hash, label: rule.label ?? details.sources[index] ?? rule.id });
|
|
47
|
+
});
|
|
48
|
+
for (const rule of details.removed ?? []) state.rules.delete(rule.id);
|
|
46
49
|
for (const skill of details.skills) state.skills.add(skill);
|
|
47
50
|
}
|
|
48
51
|
|
|
@@ -57,13 +60,24 @@ function isInjectionMessage(message: unknown): message is { details: unknown } {
|
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
// Injections written by older versions have no rule list. They are ignored, so those rules load once more.
|
|
60
|
-
|
|
63
|
+
interface StoredInjectionDetails {
|
|
64
|
+
injectionId: string;
|
|
65
|
+
rules: Array<{ id: string; hash: string; label?: string }>;
|
|
66
|
+
removed?: Array<{ id: string }>;
|
|
67
|
+
sources: string[];
|
|
68
|
+
skills: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isInjectionDetails(details: unknown): details is StoredInjectionDetails {
|
|
61
72
|
if (typeof details !== "object" || details === null) return false;
|
|
62
|
-
const candidate = details as Partial<
|
|
73
|
+
const candidate = details as Partial<StoredInjectionDetails>;
|
|
63
74
|
return (
|
|
64
75
|
typeof candidate.injectionId === "string" &&
|
|
65
76
|
Array.isArray(candidate.rules) &&
|
|
66
77
|
candidate.rules.every((rule) => typeof rule?.id === "string" && typeof rule.hash === "string") &&
|
|
78
|
+
(candidate.removed === undefined ||
|
|
79
|
+
(Array.isArray(candidate.removed) && candidate.removed.every((rule) => typeof rule?.id === "string"))) &&
|
|
80
|
+
Array.isArray(candidate.sources) &&
|
|
67
81
|
Array.isArray(candidate.skills)
|
|
68
82
|
);
|
|
69
83
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export type RuleToolName = "read" | "edit" | "write";
|
|
2
2
|
|
|
3
|
+
/** `repo` rules come from the project and apply inside it. `user` rules come from the home directory and apply to any file. */
|
|
4
|
+
export type RuleScope = "repo" | "user";
|
|
5
|
+
|
|
3
6
|
export interface RuleEvents {
|
|
4
7
|
tool_call: RuleToolName[];
|
|
5
8
|
}
|
|
@@ -8,8 +11,9 @@ export interface Rule {
|
|
|
8
11
|
id: string;
|
|
9
12
|
sourcePath: string;
|
|
10
13
|
sourceLabel: string;
|
|
14
|
+
scope: RuleScope;
|
|
11
15
|
body: string;
|
|
12
|
-
/** Hash of the
|
|
16
|
+
/** Hash of the whole file, so a changed rule can be told apart from the version that the model has. */
|
|
13
17
|
hash: string;
|
|
14
18
|
paths?: string[];
|
|
15
19
|
events?: RuleEvents;
|
|
@@ -29,16 +33,31 @@ export interface DiscoveryResult {
|
|
|
29
33
|
export interface InjectedRule {
|
|
30
34
|
id: string;
|
|
31
35
|
hash: string;
|
|
36
|
+
label: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RemovedRule {
|
|
40
|
+
id: string;
|
|
41
|
+
label: string;
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
export interface RuleInjectionDetails {
|
|
35
45
|
injectionId: string;
|
|
46
|
+
/** Rules that this message loads, also new versions of rules that the model has. */
|
|
36
47
|
rules: InjectedRule[];
|
|
48
|
+
/** Rules whose file is gone. The message tells the model to stop following them. */
|
|
49
|
+
removed: RemovedRule[];
|
|
37
50
|
sources: string[];
|
|
51
|
+
/** The labels in `sources` that replace an earlier version. */
|
|
52
|
+
updated: string[];
|
|
38
53
|
targets: Record<string, string[]>;
|
|
39
54
|
skills: string[];
|
|
40
55
|
}
|
|
41
56
|
|
|
57
|
+
export interface DueRule extends PendingRule {
|
|
58
|
+
updated: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
42
61
|
export interface PendingRule {
|
|
43
62
|
rule: Rule;
|
|
44
63
|
targets: Set<string>;
|