@lazco/pi-rules 0.0.0-stage → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +663 -0
- package/README.md +216 -2
- package/THIRD_PARTY_NOTICES.md +34 -0
- package/package.json +58 -4
- package/src/discovery.ts +341 -0
- package/src/index.ts +272 -0
- package/src/matching.ts +79 -0
- package/src/state.ts +69 -0
- package/src/types.ts +49 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
stripFrontmatter,
|
|
5
|
+
type ExtensionAPI,
|
|
6
|
+
type ExtensionContext,
|
|
7
|
+
type Skill,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
10
|
+
import { DEFAULT_RULE_SOURCES, discoverRules, loadRuleConfig, type RuleSource } from "./discovery.ts";
|
|
11
|
+
import { extractTarget, ruleAppliesToTarget, ruleMatchesToolCallEvent, targetLabel } from "./matching.ts";
|
|
12
|
+
import { addInjection, collectInjectedState, CUSTOM_MESSAGE_TYPE, type InjectedState } from "./state.ts";
|
|
13
|
+
import type { PendingRule, Rule, RuleInjectionDetails } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
export { CUSTOM_MESSAGE_TYPE };
|
|
16
|
+
|
|
17
|
+
export interface RuleInjection {
|
|
18
|
+
customType: typeof CUSTOM_MESSAGE_TYPE;
|
|
19
|
+
content: string;
|
|
20
|
+
display: true;
|
|
21
|
+
details: RuleInjectionDetails;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function makeExtension() {
|
|
25
|
+
return (pi: ExtensionAPI): void => {
|
|
26
|
+
let config: { enabled: boolean; sources: RuleSource[] } = { enabled: true, sources: DEFAULT_RULE_SOURCES };
|
|
27
|
+
let rules: Rule[] = [];
|
|
28
|
+
let reportWarning = (message: string): void => {
|
|
29
|
+
process.stderr.write(`${message}\n`);
|
|
30
|
+
};
|
|
31
|
+
const availableSkills = new Map<string, Skill>();
|
|
32
|
+
// Matches from the tool calls of the current turn. They load when the turn ends.
|
|
33
|
+
const pendingRules = new Map<string, PendingRule>();
|
|
34
|
+
// Injections sent with pi.sendMessage that pi has not written to the session yet.
|
|
35
|
+
const inFlight = new Map<string, RuleInjectionDetails>();
|
|
36
|
+
// Building the projection walks the whole branch, and parallel tool calls share one leaf.
|
|
37
|
+
let cachedBranch: { leafId: string | null; state: InjectedState } | undefined;
|
|
38
|
+
|
|
39
|
+
pi.registerMessageRenderer<RuleInjectionDetails>(CUSTOM_MESSAGE_TYPE, (message, { expanded }, theme) => {
|
|
40
|
+
const details = message.details;
|
|
41
|
+
const sourceLines = (details?.sources ?? ["(unknown)"]).map((source) => `↳ ${source}`).join("\n");
|
|
42
|
+
const skillLines = (details?.skills ?? []).map((skill) => `↳ ${skill}`).join("\n");
|
|
43
|
+
const summary = `Loaded rules:\n${sourceLines}${skillLines ? `\nLoaded skills:\n${skillLines}` : ""}`;
|
|
44
|
+
if (!expanded) {
|
|
45
|
+
return new Text(theme.fg("muted", summary), 0, 0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const targetLines = details?.sources
|
|
49
|
+
.map((source) => {
|
|
50
|
+
const targets = details.targets[source] ?? [];
|
|
51
|
+
return targets.length > 0 ? `${source} → ${targets.join(", ")}` : `${source} → unconditional`;
|
|
52
|
+
})
|
|
53
|
+
.join("\n");
|
|
54
|
+
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
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const reloadConfig = async (cwd: string): Promise<void> => {
|
|
63
|
+
const result = await loadRuleConfig(cwd);
|
|
64
|
+
config = { enabled: result.enabled, sources: result.sources };
|
|
65
|
+
if (result.diagnostic) {
|
|
66
|
+
reportWarning(`[pi-rules] invalid config ${result.diagnostic.sourceLabel}: ${result.diagnostic.reason}`);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const reloadRules = async (cwd: string): Promise<Rule[]> => {
|
|
71
|
+
const discovery = await discoverRules(cwd, { sources: config.sources });
|
|
72
|
+
for (const diagnostic of discovery.diagnostics) {
|
|
73
|
+
reportWarning(`[pi-rules] skipped ${diagnostic.sourceLabel}: ${diagnostic.reason}`);
|
|
74
|
+
}
|
|
75
|
+
return discovery.rules;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const withInFlight = (base: InjectedState): InjectedState => {
|
|
79
|
+
for (const injectionId of base.injectionIds) inFlight.delete(injectionId);
|
|
80
|
+
if (inFlight.size === 0) return base;
|
|
81
|
+
const merged: InjectedState = {
|
|
82
|
+
rules: new Map(base.rules),
|
|
83
|
+
skills: new Set(base.skills),
|
|
84
|
+
injectionIds: new Set(base.injectionIds),
|
|
85
|
+
};
|
|
86
|
+
for (const details of inFlight.values()) addInjection(merged, details);
|
|
87
|
+
return merged;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const branchState = (ctx: ExtensionContext): InjectedState => {
|
|
91
|
+
const leafId = ctx.sessionManager.getLeafId();
|
|
92
|
+
if (cachedBranch?.leafId !== leafId) {
|
|
93
|
+
cachedBranch = { leafId, state: collectInjectedState(ctx.sessionManager.buildSessionProjection().entries) };
|
|
94
|
+
}
|
|
95
|
+
return withInFlight(cachedBranch.state);
|
|
96
|
+
};
|
|
97
|
+
|
|
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));
|
|
101
|
+
pendingRules.clear();
|
|
102
|
+
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>() });
|
|
105
|
+
}
|
|
106
|
+
return due;
|
|
107
|
+
};
|
|
108
|
+
|
|
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);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
115
|
+
reportWarning = ctx.hasUI
|
|
116
|
+
? (message) => ctx.ui.notify(message, "warning")
|
|
117
|
+
: (message) => process.stderr.write(`${message}\n`);
|
|
118
|
+
await reloadConfig(ctx.cwd);
|
|
119
|
+
rules = config.enabled ? await reloadRules(ctx.cwd) : [];
|
|
120
|
+
availableSkills.clear();
|
|
121
|
+
pendingRules.clear();
|
|
122
|
+
inFlight.clear();
|
|
123
|
+
cachedBranch = undefined;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
pi.on("session_tree", () => {
|
|
127
|
+
pendingRules.clear();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
131
|
+
availableSkills.clear();
|
|
132
|
+
for (const skill of event.systemPromptOptions?.skills ?? []) availableSkills.set(skill.name, skill);
|
|
133
|
+
if (!config.enabled) return;
|
|
134
|
+
rules = await reloadRules(ctx.cwd);
|
|
135
|
+
const state = branchState(ctx);
|
|
136
|
+
const due = takeDueRules(state);
|
|
137
|
+
if (due.length === 0) return;
|
|
138
|
+
return { message: await buildInjection(due, state) };
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
142
|
+
if (!config.enabled) return;
|
|
143
|
+
const target = extractTarget(event, ctx.cwd);
|
|
144
|
+
if (!target) return;
|
|
145
|
+
rules = await reloadRules(ctx.cwd);
|
|
146
|
+
const state = branchState(ctx);
|
|
147
|
+
|
|
148
|
+
const label = targetLabel(target);
|
|
149
|
+
let shouldBlock = false;
|
|
150
|
+
for (const rule of rules) {
|
|
151
|
+
if (state.rules.has(rule.id)) continue;
|
|
152
|
+
if (!ruleAppliesToTarget(rule, target)) continue;
|
|
153
|
+
if (!ruleMatchesToolCallEvent(rule, event.toolName)) continue;
|
|
154
|
+
if (event.toolName !== "read") shouldBlock = true;
|
|
155
|
+
const pending = pendingRules.get(rule.id);
|
|
156
|
+
if (pending) {
|
|
157
|
+
if (isScoped(rule)) pending.targets.add(label);
|
|
158
|
+
} else {
|
|
159
|
+
pendingRules.set(rule.id, { rule, targets: new Set(isScoped(rule) ? [label] : []) });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (!shouldBlock) return;
|
|
163
|
+
|
|
164
|
+
// Reads can finish before the next model call. Edits and writes pause, because their arguments are already final.
|
|
165
|
+
return { block: true, reason: `[pi-rules] Tool \`${event.toolName}\` paused so Pi can load matching rules. Review them, adjust the change if needed, then retry.` };
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
169
|
+
if (!config.enabled) {
|
|
170
|
+
pendingRules.clear();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
rules = await reloadRules(ctx.cwd);
|
|
174
|
+
// The preview already holds the entries that earlier handlers proposed for this boundary.
|
|
175
|
+
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);
|
|
179
|
+
return { entries: [...event.entries, { type: "custom_message", ...injection }] };
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
pi.on("session_compact", async (_event, ctx) => {
|
|
183
|
+
pendingRules.clear();
|
|
184
|
+
await reloadConfig(ctx.cwd);
|
|
185
|
+
if (!config.enabled) {
|
|
186
|
+
rules = [];
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
rules = await reloadRules(ctx.cwd);
|
|
190
|
+
// A compaction that retries the turn makes a model call before any turn boundary, so restore the rules now.
|
|
191
|
+
const state = branchState(ctx);
|
|
192
|
+
const due = takeDueRules(state);
|
|
193
|
+
if (due.length === 0) return;
|
|
194
|
+
const injection = await buildInjection(due, state);
|
|
195
|
+
inFlight.set(injection.details.injectionId, injection.details);
|
|
196
|
+
pi.sendMessage(injection);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
pi.on("agent_settled", () => {
|
|
200
|
+
// By now pi has written every queued message or dropped it.
|
|
201
|
+
inFlight.clear();
|
|
202
|
+
});
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
interface SkillBlock {
|
|
207
|
+
name: string;
|
|
208
|
+
content: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function createInjection(entries: PendingRule[], skillBlocks: SkillBlock[] = []): RuleInjection {
|
|
212
|
+
const sorted = [...entries].sort(comparePendingRules);
|
|
213
|
+
const sources = sorted.map((entry) => entry.rule.sourceLabel);
|
|
214
|
+
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");
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
customType: CUSTOM_MESSAGE_TYPE,
|
|
222
|
+
content: skillBody ? `${ruleBody}\n\n---\n\n${skillBody}` : ruleBody,
|
|
223
|
+
display: true,
|
|
224
|
+
details: {
|
|
225
|
+
injectionId: randomUUID(),
|
|
226
|
+
rules: sorted.map((entry) => ({ id: entry.rule.id, hash: entry.rule.hash })),
|
|
227
|
+
sources,
|
|
228
|
+
targets,
|
|
229
|
+
skills: skillBlocks.map((skill) => skill.name),
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function loadSkillBlocks(
|
|
235
|
+
entries: PendingRule[],
|
|
236
|
+
availableSkills: Map<string, Skill>,
|
|
237
|
+
injectedSkills: ReadonlySet<string>,
|
|
238
|
+
reportWarning: (message: string) => void,
|
|
239
|
+
): Promise<SkillBlock[]> {
|
|
240
|
+
const names = [...new Set(entries.flatMap((entry) => entry.rule.skills ?? []))]
|
|
241
|
+
.filter((name) => !injectedSkills.has(name))
|
|
242
|
+
.sort();
|
|
243
|
+
const blocks: SkillBlock[] = [];
|
|
244
|
+
for (const name of names) {
|
|
245
|
+
const skill = availableSkills.get(name);
|
|
246
|
+
if (!skill) {
|
|
247
|
+
reportWarning(`[pi-rules] skill not found: ${name}`);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const body = stripFrontmatter(await readFile(skill.filePath, "utf8")).trim();
|
|
252
|
+
blocks.push({
|
|
253
|
+
name,
|
|
254
|
+
content: `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${skill.baseDir}.\n\n${body}\n</skill>`,
|
|
255
|
+
});
|
|
256
|
+
} catch (error) {
|
|
257
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
258
|
+
reportWarning(`[pi-rules] failed to load skill ${name}: ${reason}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return blocks;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function isScoped(rule: Rule): boolean {
|
|
265
|
+
return Boolean(rule.paths || rule.events);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function comparePendingRules(left: PendingRule, right: PendingRule): number {
|
|
269
|
+
return left.rule.sourceLabel.localeCompare(right.rule.sourceLabel);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export default makeExtension();
|
package/src/matching.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import picomatch from "picomatch";
|
|
4
|
+
import type { ToolCallEvent } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { Rule } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
const FILE_TOOLS = new Set(["read", "edit", "write"]);
|
|
8
|
+
|
|
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;
|
|
19
|
+
const rawPath = (event.input as { path?: unknown }).path;
|
|
20
|
+
if (typeof rawPath !== "string" || rawPath.trim().length === 0) return undefined;
|
|
21
|
+
const withoutAt = rawPath.startsWith("@") ? rawPath.slice(1) : rawPath;
|
|
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);
|
|
28
|
+
const relativePath = path.relative(cwd, absolutePath);
|
|
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) };
|
|
37
|
+
}
|
|
38
|
+
|
|
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));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function ruleMatchesToolCallEvent(rule: Rule, toolName: string): boolean {
|
|
56
|
+
return !rule.events || rule.events.tool_call.some((name) => name === toolName);
|
|
57
|
+
}
|
|
58
|
+
|
|
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 });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function toPosixPath(value: string): string {
|
|
78
|
+
return value.split(path.sep).join("/");
|
|
79
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ProjectedSessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { RuleInjectionDetails } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export const CUSTOM_MESSAGE_TYPE = "pi-rules-injection";
|
|
5
|
+
|
|
6
|
+
/** The rules and skills that the model can see now, read from the injection messages in its context. */
|
|
7
|
+
export interface InjectedState {
|
|
8
|
+
/** Rule id to the hash of the body that the model has. */
|
|
9
|
+
rules: Map<string, string>;
|
|
10
|
+
skills: Set<string>;
|
|
11
|
+
injectionIds: Set<string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function emptyInjectedState(): InjectedState {
|
|
15
|
+
return { rules: new Map(), skills: new Set(), injectionIds: new Set() };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Collect the injections that reach the model.
|
|
20
|
+
* `contextEntries` must be the compaction-aware projection of the current branch,
|
|
21
|
+
* so injections on other branches, before a compaction, or removed by a context edit do not count.
|
|
22
|
+
* `pendingMessages` are messages that pi queued for the next model call but has not written yet.
|
|
23
|
+
*/
|
|
24
|
+
export function collectInjectedState(
|
|
25
|
+
contextEntries: readonly ProjectedSessionEntry[],
|
|
26
|
+
pendingMessages: readonly unknown[] = [],
|
|
27
|
+
): InjectedState {
|
|
28
|
+
const state = emptyInjectedState();
|
|
29
|
+
for (const entry of contextEntries) {
|
|
30
|
+
if (entry.messages.length === 0) continue;
|
|
31
|
+
const source = entry.sourceEntry;
|
|
32
|
+
if (source.type !== "custom_message" || source.customType !== CUSTOM_MESSAGE_TYPE) continue;
|
|
33
|
+
addInjection(state, source.details);
|
|
34
|
+
}
|
|
35
|
+
for (const message of pendingMessages) {
|
|
36
|
+
if (!isInjectionMessage(message)) continue;
|
|
37
|
+
addInjection(state, message.details);
|
|
38
|
+
}
|
|
39
|
+
return state;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function addInjection(state: InjectedState, details: unknown): void {
|
|
43
|
+
if (!isInjectionDetails(details)) return;
|
|
44
|
+
state.injectionIds.add(details.injectionId);
|
|
45
|
+
for (const rule of details.rules) state.rules.set(rule.id, rule.hash);
|
|
46
|
+
for (const skill of details.skills) state.skills.add(skill);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isInjectionMessage(message: unknown): message is { details: unknown } {
|
|
50
|
+
return (
|
|
51
|
+
typeof message === "object" &&
|
|
52
|
+
message !== null &&
|
|
53
|
+
"customType" in message &&
|
|
54
|
+
message.customType === CUSTOM_MESSAGE_TYPE &&
|
|
55
|
+
"details" in message
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 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 {
|
|
61
|
+
if (typeof details !== "object" || details === null) return false;
|
|
62
|
+
const candidate = details as Partial<RuleInjectionDetails>;
|
|
63
|
+
return (
|
|
64
|
+
typeof candidate.injectionId === "string" &&
|
|
65
|
+
Array.isArray(candidate.rules) &&
|
|
66
|
+
candidate.rules.every((rule) => typeof rule?.id === "string" && typeof rule.hash === "string") &&
|
|
67
|
+
Array.isArray(candidate.skills)
|
|
68
|
+
);
|
|
69
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type RuleToolName = "read" | "edit" | "write";
|
|
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
|
+
|
|
6
|
+
export interface RuleEvents {
|
|
7
|
+
tool_call: RuleToolName[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Rule {
|
|
11
|
+
id: string;
|
|
12
|
+
sourcePath: string;
|
|
13
|
+
sourceLabel: string;
|
|
14
|
+
scope: RuleScope;
|
|
15
|
+
body: string;
|
|
16
|
+
/** Hash of the body, so a changed rule file can be told apart from the copy the model already has. */
|
|
17
|
+
hash: string;
|
|
18
|
+
paths?: string[];
|
|
19
|
+
events?: RuleEvents;
|
|
20
|
+
skills?: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RuleDiagnostic {
|
|
24
|
+
sourceLabel: string;
|
|
25
|
+
reason: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DiscoveryResult {
|
|
29
|
+
rules: Rule[];
|
|
30
|
+
diagnostics: RuleDiagnostic[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface InjectedRule {
|
|
34
|
+
id: string;
|
|
35
|
+
hash: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RuleInjectionDetails {
|
|
39
|
+
injectionId: string;
|
|
40
|
+
rules: InjectedRule[];
|
|
41
|
+
sources: string[];
|
|
42
|
+
targets: Record<string, string[]>;
|
|
43
|
+
skills: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PendingRule {
|
|
47
|
+
rule: Rule;
|
|
48
|
+
targets: Set<string>;
|
|
49
|
+
}
|