@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 +3 -1
- package/package.json +1 -1
- package/src/discovery.ts +4 -4
- package/src/index.ts +72 -34
- package/src/state.ts +20 -6
- package/src/types.ts +16 -1
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
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:
|
|
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:
|
|
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
|
|
333
|
-
return createHash("sha256").update(
|
|
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
|
|
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) => {
|
|
@@ -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
|
|
177
|
-
if (
|
|
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
|
|
193
|
-
if (
|
|
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(
|
|
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
|
|
216
|
-
.map((entry) =>
|
|
217
|
-
|
|
218
|
-
|
|
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:
|
|
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
|
|
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
|
@@ -13,7 +13,7 @@ export interface Rule {
|
|
|
13
13
|
sourceLabel: string;
|
|
14
14
|
scope: RuleScope;
|
|
15
15
|
body: string;
|
|
16
|
-
/** Hash of the
|
|
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>;
|