@oh-my-pi/pi-coding-agent 3.3.1337 → 3.5.1337

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.
@@ -24,6 +24,12 @@ export { createNotebookTool, type NotebookToolDetails, notebookTool } from "./no
24
24
  export { createOutputTool, type OutputToolDetails, outputTool } from "./output";
25
25
  export { createReadTool, type ReadToolDetails, readTool } from "./read";
26
26
  export { createReportFindingTool, createSubmitReviewTool, reportFindingTool, submitReviewTool } from "./review";
27
+ export {
28
+ createRulebookTool,
29
+ filterRulebookRules,
30
+ formatRulesForPrompt,
31
+ type RulebookToolDetails,
32
+ } from "./rulebook";
27
33
  export { BUNDLED_AGENTS, createTaskTool, taskTool } from "./task/index";
28
34
  export type { TruncationResult } from "./truncate";
29
35
  export { createWebFetchTool, type WebFetchToolDetails, webFetchCustomTool, webFetchTool } from "./web-fetch";
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Rulebook Tool
3
+ *
4
+ * Allows the agent to fetch full content of rules that have descriptions.
5
+ * Rules are listed in the system prompt with name + description; this tool
6
+ * retrieves the complete rule content on demand.
7
+ */
8
+
9
+ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
10
+ import { Type } from "@sinclair/typebox";
11
+ import type { Rule } from "../../capability/rule";
12
+
13
+ export interface RulebookToolDetails {
14
+ type: "rulebook";
15
+ ruleName: string;
16
+ found: boolean;
17
+ content?: string;
18
+ }
19
+
20
+ const rulebookSchema = Type.Object({
21
+ name: Type.String({ description: "The name of the rule to fetch" }),
22
+ });
23
+
24
+ /**
25
+ * Create a rulebook tool with access to discovered rules.
26
+ * @param rules - Array of discovered rules (non-TTSR rules with descriptions)
27
+ */
28
+ export function createRulebookTool(rules: Rule[]): AgentTool<typeof rulebookSchema> {
29
+ // Build lookup map for O(1) access
30
+ const ruleMap = new Map<string, Rule>();
31
+ for (const rule of rules) {
32
+ ruleMap.set(rule.name, rule);
33
+ }
34
+
35
+ const ruleNames = rules.map((r) => r.name);
36
+
37
+ return {
38
+ name: "rulebook",
39
+ label: "Rulebook",
40
+ description: `Fetch the full content of a project rule by name. Use this when a rule listed in <available_rules> is relevant to your current task. Available: ${ruleNames.join(", ") || "(none)"}`,
41
+ parameters: rulebookSchema,
42
+ execute: async (_toolCallId: string, { name }: { name: string }) => {
43
+ const rule = ruleMap.get(name);
44
+
45
+ if (!rule) {
46
+ const available = ruleNames.join(", ");
47
+ return {
48
+ content: [{ type: "text", text: `Rule "${name}" not found. Available rules: ${available || "(none)"}` }],
49
+ details: {
50
+ type: "rulebook",
51
+ ruleName: name,
52
+ found: false,
53
+ } satisfies RulebookToolDetails,
54
+ };
55
+ }
56
+
57
+ return {
58
+ content: [{ type: "text", text: `# Rule: ${rule.name}\n\n${rule.content}` }],
59
+ details: {
60
+ type: "rulebook",
61
+ ruleName: name,
62
+ found: true,
63
+ content: rule.content,
64
+ } satisfies RulebookToolDetails,
65
+ };
66
+ },
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Filter rules to only those suitable for the rulebook (have descriptions, no TTSR trigger).
72
+ */
73
+ export function filterRulebookRules(rules: Rule[]): Rule[] {
74
+ return rules.filter((rule) => {
75
+ // Exclude TTSR rules (handled separately by streaming)
76
+ if (rule.ttsrTrigger) return false;
77
+ // Exclude always-apply rules (already in context)
78
+ if (rule.alwaysApply) return false;
79
+ // Must have a description for agent to know when to fetch
80
+ if (!rule.description) return false;
81
+ return true;
82
+ });
83
+ }
84
+
85
+ /**
86
+ * Format rules for inclusion in the system prompt.
87
+ * Lists rule names and descriptions so the agent knows what's available.
88
+ */
89
+ export function formatRulesForPrompt(rules: Rule[]): string {
90
+ if (rules.length === 0) {
91
+ return "";
92
+ }
93
+
94
+ const lines = [
95
+ "\n\n## Available Rules",
96
+ "",
97
+ "The following project rules are available. Use the `rulebook` tool to fetch a rule's full content when it's relevant to your task.",
98
+ "",
99
+ "<available_rules>",
100
+ ];
101
+
102
+ for (const rule of rules) {
103
+ lines.push(" <rule>");
104
+ lines.push(` <name>${escapeXml(rule.name)}</name>`);
105
+ lines.push(` <description>${escapeXml(rule.description || "")}</description>`);
106
+ if (rule.globs && rule.globs.length > 0) {
107
+ lines.push(` <globs>${escapeXml(rule.globs.join(", "))}</globs>`);
108
+ }
109
+ lines.push(" </rule>");
110
+ }
111
+
112
+ lines.push("</available_rules>");
113
+
114
+ return lines.join("\n");
115
+ }
116
+
117
+ function escapeXml(str: string): string {
118
+ return str
119
+ .replace(/&/g, "&amp;")
120
+ .replace(/</g, "&lt;")
121
+ .replace(/>/g, "&gt;")
122
+ .replace(/"/g, "&quot;")
123
+ .replace(/'/g, "&apos;");
124
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Time Traveling Stream Rules (TTSR) Manager
3
+ *
4
+ * Manages rules that get injected mid-stream when their trigger pattern matches
5
+ * the agent's output. When a match occurs, the stream is aborted, the rule is
6
+ * injected as a system reminder, and the request is retried.
7
+ */
8
+
9
+ import type { Rule } from "../capability/rule";
10
+ import { logger } from "./logger";
11
+ import type { TtsrSettings } from "./settings-manager";
12
+
13
+ interface TtsrEntry {
14
+ rule: Rule;
15
+ regex: RegExp;
16
+ }
17
+
18
+ /** Tracks when a rule was last injected (for repeat-after-gap mode) */
19
+ interface InjectionRecord {
20
+ /** Message count when the rule was last injected */
21
+ lastInjectedAt: number;
22
+ }
23
+
24
+ export interface TtsrManager {
25
+ /** Add a TTSR rule to be monitored */
26
+ addRule(rule: Rule): void;
27
+
28
+ /** Check if any uninjected TTSR matches the stream buffer. Returns matching rules. */
29
+ check(streamBuffer: string): Rule[];
30
+
31
+ /** Mark rules as injected (won't trigger again until conditions allow) */
32
+ markInjected(rules: Rule[]): void;
33
+
34
+ /** Get names of all injected rules (for persistence) */
35
+ getInjectedRuleNames(): string[];
36
+
37
+ /** Restore injected state from a list of rule names */
38
+ restoreInjected(ruleNames: string[]): void;
39
+
40
+ /** Reset stream buffer (called on new turn) */
41
+ resetBuffer(): void;
42
+
43
+ /** Get current stream buffer */
44
+ getBuffer(): string;
45
+
46
+ /** Append to stream buffer */
47
+ appendToBuffer(text: string): void;
48
+
49
+ /** Check if any TTSRs are registered */
50
+ hasRules(): boolean;
51
+
52
+ /** Increment message counter (call after each turn) */
53
+ incrementMessageCount(): void;
54
+
55
+ /** Get current message count */
56
+ getMessageCount(): number;
57
+
58
+ /** Get settings */
59
+ getSettings(): Required<TtsrSettings>;
60
+ }
61
+
62
+ const DEFAULT_SETTINGS: Required<TtsrSettings> = {
63
+ enabled: true,
64
+ contextMode: "discard",
65
+ repeatMode: "once",
66
+ repeatGap: 10,
67
+ };
68
+
69
+ export function createTtsrManager(settings?: TtsrSettings): TtsrManager {
70
+ /** Resolved settings with defaults */
71
+ const resolvedSettings: Required<TtsrSettings> = {
72
+ ...DEFAULT_SETTINGS,
73
+ ...settings,
74
+ };
75
+
76
+ /** Map of rule name -> { rule, compiled regex } */
77
+ const rules = new Map<string, TtsrEntry>();
78
+
79
+ /** Map of rule name -> injection record */
80
+ const injectionRecords = new Map<string, InjectionRecord>();
81
+
82
+ /** Current stream buffer for pattern matching */
83
+ let buffer = "";
84
+
85
+ /** Message counter for tracking gap between injections */
86
+ let messageCount = 0;
87
+
88
+ /** Check if a rule can be triggered based on repeat settings */
89
+ function canTrigger(ruleName: string): boolean {
90
+ const record = injectionRecords.get(ruleName);
91
+ if (!record) {
92
+ // Never injected, can trigger
93
+ return true;
94
+ }
95
+
96
+ if (resolvedSettings.repeatMode === "once") {
97
+ // Once mode: never trigger again after first injection
98
+ return false;
99
+ }
100
+
101
+ // After-gap mode: check if enough messages have passed
102
+ const gap = messageCount - record.lastInjectedAt;
103
+ return gap >= resolvedSettings.repeatGap;
104
+ }
105
+
106
+ return {
107
+ addRule(rule: Rule): void {
108
+ // Only add rules that have a TTSR trigger pattern
109
+ if (!rule.ttsrTrigger) {
110
+ return;
111
+ }
112
+
113
+ // Skip if already registered
114
+ if (rules.has(rule.name)) {
115
+ return;
116
+ }
117
+
118
+ // Compile the regex pattern
119
+ try {
120
+ const regex = new RegExp(rule.ttsrTrigger);
121
+ rules.set(rule.name, { rule, regex });
122
+ logger.debug("TTSR rule registered", {
123
+ ruleName: rule.name,
124
+ pattern: rule.ttsrTrigger,
125
+ });
126
+ } catch (err) {
127
+ logger.warn("TTSR rule has invalid regex pattern, skipping", {
128
+ ruleName: rule.name,
129
+ pattern: rule.ttsrTrigger,
130
+ error: err instanceof Error ? err.message : String(err),
131
+ });
132
+ }
133
+ },
134
+
135
+ check(streamBuffer: string): Rule[] {
136
+ const matches: Rule[] = [];
137
+
138
+ for (const [name, entry] of rules) {
139
+ // Skip rules that can't trigger yet
140
+ if (!canTrigger(name)) {
141
+ continue;
142
+ }
143
+
144
+ // Test the buffer against the rule's pattern
145
+ if (entry.regex.test(streamBuffer)) {
146
+ matches.push(entry.rule);
147
+ logger.debug("TTSR pattern matched", {
148
+ ruleName: name,
149
+ pattern: entry.rule.ttsrTrigger,
150
+ });
151
+ }
152
+ }
153
+
154
+ return matches;
155
+ },
156
+
157
+ markInjected(rulesToMark: Rule[]): void {
158
+ for (const rule of rulesToMark) {
159
+ injectionRecords.set(rule.name, { lastInjectedAt: messageCount });
160
+ logger.debug("TTSR rule marked as injected", {
161
+ ruleName: rule.name,
162
+ messageCount,
163
+ repeatMode: resolvedSettings.repeatMode,
164
+ });
165
+ }
166
+ },
167
+
168
+ getInjectedRuleNames(): string[] {
169
+ return Array.from(injectionRecords.keys());
170
+ },
171
+
172
+ restoreInjected(ruleNames: string[]): void {
173
+ // When restoring, we don't know the original message count, so use 0
174
+ // This means in "after-gap" mode, rules can trigger again after the gap
175
+ for (const name of ruleNames) {
176
+ injectionRecords.set(name, { lastInjectedAt: 0 });
177
+ }
178
+ if (ruleNames.length > 0) {
179
+ logger.debug("TTSR injected state restored", { ruleNames });
180
+ }
181
+ },
182
+
183
+ resetBuffer(): void {
184
+ buffer = "";
185
+ },
186
+
187
+ getBuffer(): string {
188
+ return buffer;
189
+ },
190
+
191
+ appendToBuffer(text: string): void {
192
+ buffer += text;
193
+ },
194
+
195
+ hasRules(): boolean {
196
+ return rules.size > 0;
197
+ },
198
+
199
+ incrementMessageCount(): void {
200
+ messageCount++;
201
+ },
202
+
203
+ getMessageCount(): number {
204
+ return messageCount;
205
+ },
206
+
207
+ getSettings(): Required<TtsrSettings> {
208
+ return resolvedSettings;
209
+ },
210
+ };
211
+ }
@@ -310,6 +310,7 @@ function loadRules(ctx: LoadContext): LoadResult<Rule> {
310
310
  globs: frontmatter.globs as string[] | undefined,
311
311
  alwaysApply: frontmatter.alwaysApply as boolean | undefined,
312
312
  description: frontmatter.description as string | undefined,
313
+ ttsrTrigger: typeof frontmatter.ttsr_trigger === "string" ? frontmatter.ttsr_trigger : undefined,
313
314
  _source: source,
314
315
  };
315
316
  },
@@ -52,6 +52,7 @@ function loadRules(ctx: LoadContext): LoadResult<Rule> {
52
52
  globs,
53
53
  alwaysApply: typeof frontmatter.alwaysApply === "boolean" ? frontmatter.alwaysApply : undefined,
54
54
  description: typeof frontmatter.description === "string" ? frontmatter.description : undefined,
55
+ ttsrTrigger: typeof frontmatter.ttsr_trigger === "string" ? frontmatter.ttsr_trigger : undefined,
55
56
  _source: source,
56
57
  };
57
58
  },
@@ -85,6 +86,7 @@ function loadRules(ctx: LoadContext): LoadResult<Rule> {
85
86
  globs,
86
87
  alwaysApply: typeof frontmatter.alwaysApply === "boolean" ? frontmatter.alwaysApply : undefined,
87
88
  description: typeof frontmatter.description === "string" ? frontmatter.description : undefined,
89
+ ttsrTrigger: typeof frontmatter.ttsr_trigger === "string" ? frontmatter.ttsr_trigger : undefined,
88
90
  _source: source,
89
91
  });
90
92
  }
@@ -163,6 +163,7 @@ function transformMDCRule(
163
163
  // Extract frontmatter fields
164
164
  const description = typeof frontmatter.description === "string" ? frontmatter.description : undefined;
165
165
  const alwaysApply = frontmatter.alwaysApply === true;
166
+ const ttsrTrigger = typeof frontmatter.ttsr_trigger === "string" ? frontmatter.ttsr_trigger : undefined;
166
167
 
167
168
  // Parse globs (can be array or single string)
168
169
  let globs: string[] | undefined;
@@ -182,6 +183,7 @@ function transformMDCRule(
182
183
  description,
183
184
  alwaysApply,
184
185
  globs,
186
+ ttsrTrigger,
185
187
  _source: source,
186
188
  };
187
189
  }
@@ -128,6 +128,7 @@ function loadRules(ctx: LoadContext): LoadResult<Rule> {
128
128
  globs,
129
129
  alwaysApply: frontmatter.alwaysApply as boolean | undefined,
130
130
  description: frontmatter.description as string | undefined,
131
+ ttsrTrigger: typeof frontmatter.ttsr_trigger === "string" ? frontmatter.ttsr_trigger : undefined,
131
132
  _source: createSourceMeta(PROVIDER_ID, userPath, "user"),
132
133
  });
133
134
  }
@@ -157,6 +158,7 @@ function loadRules(ctx: LoadContext): LoadResult<Rule> {
157
158
  globs,
158
159
  alwaysApply: frontmatter.alwaysApply as boolean | undefined,
159
160
  description: frontmatter.description as string | undefined,
161
+ ttsrTrigger: typeof frontmatter.ttsr_trigger === "string" ? frontmatter.ttsr_trigger : undefined,
160
162
  _source: source,
161
163
  };
162
164
  },
@@ -187,6 +189,7 @@ function loadRules(ctx: LoadContext): LoadResult<Rule> {
187
189
  globs,
188
190
  alwaysApply: frontmatter.alwaysApply as boolean | undefined,
189
191
  description: frontmatter.description as string | undefined,
192
+ ttsrTrigger: typeof frontmatter.ttsr_trigger === "string" ? frontmatter.ttsr_trigger : undefined,
190
193
  _source: createSourceMeta(PROVIDER_ID, legacyPath, "project"),
191
194
  });
192
195
  }