@lazco/pi-rules 0.8.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 CHANGED
@@ -87,6 +87,9 @@ pi-rules reads the injected rules from the messages that the model sees, so it s
87
87
  - resume a session, run `/reload`, or fork a session,
88
88
  - compact the session. The rules that the summary replaced load again when they are needed.
89
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
+
90
93
  ## Rule files
91
94
 
92
95
  A rule is a Markdown file.
@@ -203,7 +206,6 @@ pi-rules reads `rules.json` when a session starts and after a compaction.
203
206
 
204
207
  - **Project rules of other projects.** A file in another project loads only user rules, not the rules in the directories of that project.
205
208
  - **Shell commands.** A file that a `bash` command changes does not load a rule.
206
- - **Changed rules.** A rule that already loaded does not load again after you change its file, until the next compaction.
207
209
 
208
210
  ## Contributing
209
211
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazco/pi-rules",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Pi extension: load Markdown rules into the agent context for the whole project or for matching files",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",
package/src/discovery.ts CHANGED
@@ -141,7 +141,7 @@ export async function parseRuleFile(sourcePath: string, sourceLabel: string, sco
141
141
  return { diagnostic: { sourceLabel, reason: "unclosed frontmatter" } };
142
142
  }
143
143
  const body = content.replace(/^\uFEFF/, "");
144
- return { rule: { id: sourcePath, sourcePath, sourceLabel, scope, body, hash: hashBody(body) } };
144
+ return { rule: { id: sourcePath, sourcePath, sourceLabel, scope, body, hash: hashContent(content) } };
145
145
  }
146
146
 
147
147
  let raw: unknown;
@@ -183,7 +183,7 @@ export async function parseRuleFile(sourcePath: string, sourceLabel: string, sco
183
183
  }
184
184
 
185
185
  const body = content.slice(frontmatter[0].length);
186
- const rule: Rule = { id: sourcePath, sourcePath, sourceLabel, scope, body, hash: hashBody(body) };
186
+ const rule: Rule = { id: sourcePath, sourcePath, sourceLabel, scope, body, hash: hashContent(content) };
187
187
  if (paths) rule.paths = paths;
188
188
  if (events) rule.events = events;
189
189
  if (skills) rule.skills = skills;
@@ -329,8 +329,8 @@ function labelDirectory(directory: string, home: string): string {
329
329
  return directory.split(path.sep).join("/");
330
330
  }
331
331
 
332
- function hashBody(body: string): string {
333
- return createHash("sha256").update(body).digest("hex").slice(0, 16);
332
+ function hashContent(content: string): string {
333
+ return createHash("sha256").update(content).digest("hex").slice(0, 16);
334
334
  }
335
335
 
336
336
  function getErrorCode(error: unknown): string {
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ import { Text } from "@earendil-works/pi-tui";
10
10
  import { DEFAULT_RULE_SOURCES, discoverRules, loadRuleConfig, type RuleSource } from "./discovery.ts";
11
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 sourceLines = (details?.sources ?? ["(unknown)"]).map((source) => `↳ ${source}`).join("\n");
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 summary = `Loaded rules:\n${sourceLines}${skillLines ? `\nLoaded skills:\n${skillLines}` : ""}`;
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
- return targets.length > 0 ? `${source} → ${targets.join(", ")}` : `${source} → unconditional`;
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
- // Takes the matches of this turn and adds every unconditional rule that the model does not have.
99
- const takeDueRules = (state: InjectedState): PendingRule[] => {
100
- const due = [...pendingRules.values()].filter((entry) => !state.rules.has(entry.rule.id));
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 (isScoped(rule) || state.rules.has(rule.id)) continue;
104
- if (!due.some((entry) => entry.rule.id === rule.id)) due.push({ rule, targets: new Set<string>() });
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
- return due;
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 (entries: PendingRule[], state: InjectedState): Promise<RuleInjection> => {
110
- const skillBlocks = await loadSkillBlocks(entries, availableSkills, state.skills, reportWarning);
111
- return createInjection(entries, skillBlocks);
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 due = takeDueRules(state);
137
- if (due.length === 0) return;
138
- return { message: await buildInjection(due, state) };
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) => {
@@ -173,9 +202,8 @@ export function makeExtension() {
173
202
  rules = await reloadRules(ctx.cwd);
174
203
  // The preview already holds the entries that earlier handlers proposed for this boundary.
175
204
  const state = withInFlight(collectInjectedState(event.context.contextEntries, event.context.pendingMessages));
176
- const due = takeDueRules(state);
177
- if (due.length === 0) return;
178
- const injection = await buildInjection(due, state);
205
+ const injection = await buildInjection(takeDueRules(state), state);
206
+ if (!injection) return;
179
207
  return { entries: [...event.entries, { type: "custom_message", ...injection }] };
180
208
  });
181
209
 
@@ -189,9 +217,8 @@ export function makeExtension() {
189
217
  rules = await reloadRules(ctx.cwd);
190
218
  // A compaction that retries the turn makes a model call before any turn boundary, so restore the rules now.
191
219
  const state = branchState(ctx);
192
- const due = takeDueRules(state);
193
- if (due.length === 0) return;
194
- const injection = await buildInjection(due, state);
220
+ const injection = await buildInjection(takeDueRules(state), state);
221
+ if (!injection) return;
195
222
  inFlight.set(injection.details.injectionId, injection.details);
196
223
  pi.sendMessage(injection);
197
224
  });
@@ -208,23 +235,34 @@ interface SkillBlock {
208
235
  content: string;
209
236
  }
210
237
 
211
- export function createInjection(entries: PendingRule[], skillBlocks: SkillBlock[] = []): RuleInjection {
238
+ export function createInjection(
239
+ entries: Array<PendingRule & { updated?: boolean }>,
240
+ skillBlocks: SkillBlock[] = [],
241
+ removed: RemovedRule[] = [],
242
+ ): RuleInjection {
212
243
  const sorted = [...entries].sort(comparePendingRules);
213
244
  const sources = sorted.map((entry) => entry.rule.sourceLabel);
214
245
  const targets = Object.fromEntries(sorted.map((entry) => [entry.rule.sourceLabel, [...entry.targets].sort()]));
215
- const ruleBody = sorted
216
- .map((entry) => `## Rule: ${entry.rule.sourceLabel}\n\n${entry.rule.body}`)
217
- .join("\n\n---\n\n");
218
- const skillBody = skillBlocks.map((skill) => skill.content).join("\n\n");
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
+ ];
219
255
 
220
256
  return {
221
257
  customType: CUSTOM_MESSAGE_TYPE,
222
- content: skillBody ? `${ruleBody}\n\n---\n\n${skillBody}` : ruleBody,
258
+ content: blocks.join("\n\n---\n\n"),
223
259
  display: true,
224
260
  details: {
225
261
  injectionId: randomUUID(),
226
- 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,
227
264
  sources,
265
+ updated: sorted.filter((entry) => entry.updated).map((entry) => entry.rule.sourceLabel),
228
266
  targets,
229
267
  skills: skillBlocks.map((skill) => skill.name),
230
268
  },
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 hash of the body that the model has. */
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
- for (const rule of details.rules) state.rules.set(rule.id, rule.hash);
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
- function isInjectionDetails(details: unknown): details is RuleInjectionDetails {
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<RuleInjectionDetails>;
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
@@ -13,7 +13,7 @@ export interface Rule {
13
13
  sourceLabel: string;
14
14
  scope: RuleScope;
15
15
  body: string;
16
- /** Hash of the body, so a changed rule file can be told apart from the copy the model already has. */
16
+ /** Hash of the whole file, so a changed rule can be told apart from the version that the model has. */
17
17
  hash: string;
18
18
  paths?: string[];
19
19
  events?: RuleEvents;
@@ -33,16 +33,31 @@ export interface DiscoveryResult {
33
33
  export interface InjectedRule {
34
34
  id: string;
35
35
  hash: string;
36
+ label: string;
37
+ }
38
+
39
+ export interface RemovedRule {
40
+ id: string;
41
+ label: string;
36
42
  }
37
43
 
38
44
  export interface RuleInjectionDetails {
39
45
  injectionId: string;
46
+ /** Rules that this message loads, also new versions of rules that the model has. */
40
47
  rules: InjectedRule[];
48
+ /** Rules whose file is gone. The message tells the model to stop following them. */
49
+ removed: RemovedRule[];
41
50
  sources: string[];
51
+ /** The labels in `sources` that replace an earlier version. */
52
+ updated: string[];
42
53
  targets: Record<string, string[]>;
43
54
  skills: string[];
44
55
  }
45
56
 
57
+ export interface DueRule extends PendingRule {
58
+ updated: boolean;
59
+ }
60
+
46
61
  export interface PendingRule {
47
62
  rule: Rule;
48
63
  targets: Set<string>;