@oh-my-pi/pi-coding-agent 3.4.1337 → 3.6.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [3.6.1337] - 2026-01-03
6
+
7
+ ## [3.5.1337] - 2026-01-03
8
+
9
+ ### Added
10
+
11
+ - Added session header and footer output in text mode showing version, model, provider, thinking level, and session ID
12
+ - Added Extension Control Center dashboard accessible via `/extensions` command for unified management of all providers and extensions
13
+ - Added ability to enable/disable individual extensions with persistent settings
14
+ - Added three-column dashboard layout with sidebar tree, extension list, and inspector panel
15
+ - Added fuzzy search filtering for extensions in the dashboard
16
+ - Added keyboard navigation with Tab to cycle panes, j/k for navigation, Space to toggle, Enter to expand/collapse
17
+
18
+ ### Changed
19
+
20
+ - Redesigned Extension Control Center from 3-column layout to tabbed interface with horizontal provider tabs and 2-column grid
21
+ - Replaced sidebar tree navigation with provider tabs using TAB/Shift+TAB cycling
22
+
23
+ ### Fixed
24
+
25
+ - Fixed title generation flag not resetting when starting a new session
26
+
5
27
  ## [3.4.1337] - 2026-01-03
6
28
 
7
29
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "3.4.1337",
3
+ "version": "3.6.1337",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -39,9 +39,9 @@
39
39
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-agent-core": "3.4.1337",
43
- "@oh-my-pi/pi-ai": "3.4.1337",
44
- "@oh-my-pi/pi-tui": "3.4.1337",
42
+ "@oh-my-pi/pi-agent-core": "3.6.1337",
43
+ "@oh-my-pi/pi-ai": "3.6.1337",
44
+ "@oh-my-pi/pi-tui": "3.6.1337",
45
45
  "@sinclair/typebox": "^0.34.46",
46
46
  "ajv": "^8.17.1",
47
47
  "chalk": "^5.5.0",
@@ -52,6 +52,7 @@
52
52
  "highlight.js": "^11.11.1",
53
53
  "marked": "^15.0.12",
54
54
  "minimatch": "^10.1.1",
55
+ "nanoid": "^5.1.6",
55
56
  "node-html-parser": "^6.1.13",
56
57
  "smol-toml": "^1.6.0",
57
58
  "strip-ansi": "^7.1.2",
package/src/core/sdk.ts CHANGED
@@ -79,8 +79,10 @@ import {
79
79
  createLsTool,
80
80
  createReadOnlyTools,
81
81
  createReadTool,
82
+ createRulebookTool,
82
83
  createWriteTool,
83
84
  editTool,
85
+ filterRulebookRules,
84
86
  findTool,
85
87
  grepTool,
86
88
  lsTool,
@@ -604,7 +606,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
604
606
  const skills = options.skills ?? discoverSkills(cwd, agentDir, settingsManager.getSkillsSettings());
605
607
  time("discoverSkills");
606
608
 
607
- // Discover TTSR rules
609
+ // Discover rules
608
610
  const ttsrManager = createTtsrManager(settingsManager.getTtsrSettings());
609
611
  const rulesResult = loadCapability<Rule>(ruleCapability.id, { cwd });
610
612
  for (const rule of rulesResult.items) {
@@ -614,6 +616,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
614
616
  }
615
617
  time("discoverTtsrRules");
616
618
 
619
+ // Filter rules for the rulebook (non-TTSR, non-alwaysApply, with descriptions)
620
+ const rulebookRules = filterRulebookRules(rulesResult.items);
621
+ time("filterRulebookRules");
622
+
617
623
  const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir);
618
624
  time("discoverContextFiles");
619
625
 
@@ -757,6 +763,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
757
763
 
758
764
  let allToolsArray: Tool[] = [...builtInTools, ...wrappedCustomTools];
759
765
 
766
+ // Add rulebook tool if there are rules with descriptions (always enabled, regardless of --tools)
767
+ if (rulebookRules.length > 0) {
768
+ allToolsArray.push(createRulebookTool(rulebookRules));
769
+ }
770
+
760
771
  // Filter out hidden tools unless explicitly requested
761
772
  if (options.explicitTools) {
762
773
  const explicitSet = new Set(options.explicitTools);
@@ -781,6 +792,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
781
792
  cwd,
782
793
  skills,
783
794
  contextFiles,
795
+ rulebookRules,
784
796
  });
785
797
  time("buildSystemPrompt");
786
798
 
@@ -791,6 +803,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
791
803
  cwd,
792
804
  skills,
793
805
  contextFiles,
806
+ rulebookRules,
794
807
  customPrompt: options.systemPrompt,
795
808
  });
796
809
  } else {
@@ -13,6 +13,7 @@ import {
13
13
  import { join, resolve } from "node:path";
14
14
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
15
15
  import type { ImageContent, Message, TextContent } from "@oh-my-pi/pi-ai";
16
+ import { nanoid } from "nanoid";
16
17
  import { getAgentDir as getDefaultAgentDir } from "../config";
17
18
  import {
18
19
  type BashExecutionMessage,
@@ -196,11 +197,10 @@ export type ReadonlySessionManager = Pick<
196
197
  /** Generate a unique short ID (8 hex chars, collision-checked) */
197
198
  function generateId(byId: { has(id: string): boolean }): string {
198
199
  for (let i = 0; i < 100; i++) {
199
- const id = crypto.randomUUID().slice(0, 8);
200
+ const id = nanoid(8);
200
201
  if (!byId.has(id)) return id;
201
202
  }
202
- // Fallback to full UUID if somehow we have collisions
203
- return crypto.randomUUID();
203
+ return nanoid(); // fallback to full nanoid
204
204
  }
205
205
 
206
206
  /** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */
@@ -451,42 +451,105 @@ export function loadEntriesFromFile(filePath: string): FileEntry[] {
451
451
  return entries;
452
452
  }
453
453
 
454
- function isValidSessionFile(filePath: string): boolean {
455
- try {
456
- const fd = openSync(filePath, "r");
457
- const buffer = Buffer.alloc(512);
458
- const bytesRead = readSync(fd, buffer, 0, 512, 0);
459
- closeSync(fd);
460
- const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n")[0];
461
- if (!firstLine) return false;
462
- const header = JSON.parse(firstLine);
463
- return header.type === "session" && typeof header.id === "string";
464
- } catch {
465
- return false;
454
+ /**
455
+ * Lightweight metadata for a session file, used in session picker UI.
456
+ * Uses lazy getters to defer string formatting until actually displayed.
457
+ */
458
+ class RecentSessionInfo {
459
+ readonly path: string;
460
+ readonly mtime: number;
461
+
462
+ #fullName: string | undefined;
463
+ #name: string | undefined;
464
+ #timeAgo: string | undefined;
465
+
466
+ constructor(path: string, mtime: number, header: Record<string, unknown>) {
467
+ this.path = path;
468
+ this.mtime = mtime;
469
+
470
+ // Extract title from session header, falling back to id if title is missing
471
+ const trystr = (v: unknown) => (typeof v === "string" ? v : undefined);
472
+ this.#fullName = trystr(header.title) ?? trystr(header.id);
473
+ }
474
+
475
+ /** Full session name from header, or filename without extension as fallback */
476
+ get fullName(): string {
477
+ if (this.#fullName) return this.#fullName;
478
+ this.#fullName = this.path.split("/").pop()?.replace(".jsonl", "") ?? "Unknown";
479
+ return this.#fullName;
480
+ }
481
+
482
+ /** Truncated name for display (max 40 chars) */
483
+ get name(): string {
484
+ if (this.#name) return this.#name;
485
+ const fullName = this.fullName;
486
+ this.#name = fullName.length <= 40 ? fullName : `${fullName.slice(0, 37)}...`;
487
+ return this.#name;
488
+ }
489
+
490
+ /** Human-readable relative time (e.g., "2 hours ago") */
491
+ get timeAgo(): string {
492
+ if (this.#timeAgo) return this.#timeAgo;
493
+ this.#timeAgo = formatTimeAgo(new Date(this.mtime));
494
+ return this.#timeAgo;
466
495
  }
467
496
  }
468
497
 
469
- /** Exported for testing */
470
- export function findMostRecentSession(sessionDir: string): string | null {
498
+ /**
499
+ * Reads all session files from the directory and returns them sorted by mtime (newest first).
500
+ * Uses low-level file I/O to efficiently read only the first 512 bytes of each file
501
+ * to extract the JSON header without loading entire session logs into memory.
502
+ */
503
+ function getSortedSessions(sessionDir: string): RecentSessionInfo[] {
471
504
  try {
472
- const files = readdirSync(sessionDir)
473
- .filter((f) => f.endsWith(".jsonl"))
474
- .map((f) => join(sessionDir, f))
475
- .filter(isValidSessionFile)
476
- .map((path) => ({ path, mtime: statSync(path).mtime }))
477
- .sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
478
-
479
- return files[0]?.path || null;
505
+ // Reusable buffer for reading file headers
506
+ const buf = Buffer.allocUnsafe(512);
507
+
508
+ /**
509
+ * Reads the first line (JSON header) from an open file descriptor.
510
+ * Returns null if the file is empty or doesn't start with valid JSON.
511
+ */
512
+ const readHeader = (fd: number) => {
513
+ const bytesRead = readSync(fd, buf, 0, 512, 0);
514
+ if (bytesRead === 0) return null;
515
+ const sub = buf.subarray(0, bytesRead);
516
+ // Quick check: first char must be '{' for valid JSON object
517
+ if (sub.at(0) !== "{".charCodeAt(0)) return null;
518
+ // Find end of first JSON line
519
+ const eol = sub.indexOf("}\n");
520
+ if (eol <= 0) return null;
521
+ return JSON.parse(sub.toString("utf8", 0, eol + 1));
522
+ };
523
+
524
+ return readdirSync(sessionDir)
525
+ .map((f) => {
526
+ try {
527
+ if (!f.endsWith(".jsonl")) return null;
528
+ const path = join(sessionDir, f);
529
+ const fd = openSync(path, "r");
530
+ try {
531
+ const header = readHeader(fd);
532
+ if (!header) return null;
533
+ const mtime = statSync(path).mtimeMs;
534
+ return new RecentSessionInfo(path, mtime, header);
535
+ } finally {
536
+ closeSync(fd);
537
+ }
538
+ } catch {
539
+ return null;
540
+ }
541
+ })
542
+ .filter((x) => x !== null)
543
+ .sort((a, b) => b.mtime - a.mtime); // Sort newest first
480
544
  } catch {
481
- return null;
545
+ return [];
482
546
  }
483
547
  }
484
548
 
485
- /** Recent session info for display */
486
- export interface RecentSessionInfo {
487
- name: string;
488
- path: string;
489
- timeAgo: string;
549
+ /** Exported for testing */
550
+ export function findMostRecentSession(sessionDir: string): string | null {
551
+ const sessions = getSortedSessions(sessionDir);
552
+ return sessions[0]?.path || null;
490
553
  }
491
554
 
492
555
  /** Format a time difference as a human-readable string */
@@ -506,41 +569,7 @@ function formatTimeAgo(date: Date): string {
506
569
 
507
570
  /** Get recent sessions for display in welcome screen */
508
571
  export function getRecentSessions(sessionDir: string, limit = 3): RecentSessionInfo[] {
509
- try {
510
- const files = readdirSync(sessionDir)
511
- .filter((f) => f.endsWith(".jsonl"))
512
- .map((f) => join(sessionDir, f))
513
- .filter(isValidSessionFile)
514
- .map((path) => {
515
- const stat = statSync(path);
516
- // Try to get session title or id from first line
517
- let name = path.split("/").pop()?.replace(".jsonl", "") ?? "Unknown";
518
- try {
519
- const content = readFileSync(path, "utf-8");
520
- const firstLine = content.split("\n")[0];
521
- if (firstLine) {
522
- const header = JSON.parse(firstLine) as SessionHeader;
523
- if (header.type === "session") {
524
- // Prefer title over id
525
- name = header.title ?? header.id ?? name;
526
- }
527
- }
528
- } catch {
529
- // Use filename as fallback
530
- }
531
- return { path, name, mtime: stat.mtime };
532
- })
533
- .sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
534
- .slice(0, limit);
535
-
536
- return files.map((f) => ({
537
- name: f.name.length > 40 ? `${f.name.slice(0, 37)}...` : f.name,
538
- path: f.path,
539
- timeAgo: formatTimeAgo(f.mtime),
540
- }));
541
- } catch {
542
- return [];
543
- }
572
+ return getSortedSessions(sessionDir).slice(0, limit);
544
573
  }
545
574
 
546
575
  /**
@@ -588,7 +617,7 @@ export class SessionManager {
588
617
  if (existsSync(this.sessionFile)) {
589
618
  this.fileEntries = loadEntriesFromFile(this.sessionFile);
590
619
  const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
591
- this.sessionId = header?.id ?? crypto.randomUUID();
620
+ this.sessionId = header?.id ?? nanoid();
592
621
  this.sessionTitle = header?.title;
593
622
 
594
623
  if (migrateToCurrentVersion(this.fileEntries)) {
@@ -603,7 +632,7 @@ export class SessionManager {
603
632
  }
604
633
 
605
634
  newSession(options?: NewSessionOptions): string | undefined {
606
- this.sessionId = crypto.randomUUID();
635
+ this.sessionId = nanoid();
607
636
  const timestamp = new Date().toISOString();
608
637
  const header: SessionHeader = {
609
638
  type: "session",
@@ -1081,7 +1110,7 @@ export class SessionManager {
1081
1110
  // Filter out LabelEntry from path - we'll recreate them from the resolved map
1082
1111
  const pathWithoutLabels = path.filter((e) => e.type !== "label");
1083
1112
 
1084
- const newSessionId = crypto.randomUUID();
1113
+ const newSessionId = nanoid();
1085
1114
  const timestamp = new Date().toISOString();
1086
1115
  const fileTimestamp = timestamp.replace(/[:.]/g, "-");
1087
1116
  const newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);
@@ -105,6 +105,7 @@ export interface Settings {
105
105
  edit?: EditSettings;
106
106
  ttsr?: TtsrSettings;
107
107
  disabledProviders?: string[]; // Discovery provider IDs that are disabled
108
+ disabledExtensions?: string[]; // Individual extension IDs that are disabled (e.g., "skill:commit")
108
109
  }
109
110
 
110
111
  /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
@@ -594,6 +595,38 @@ export class SettingsManager {
594
595
  this.save();
595
596
  }
596
597
 
598
+ getDisabledExtensions(): string[] {
599
+ return [...(this.settings.disabledExtensions ?? [])];
600
+ }
601
+
602
+ setDisabledExtensions(extensionIds: string[]): void {
603
+ this.globalSettings.disabledExtensions = extensionIds;
604
+ this.save();
605
+ }
606
+
607
+ isExtensionEnabled(extensionId: string): boolean {
608
+ return !(this.settings.disabledExtensions ?? []).includes(extensionId);
609
+ }
610
+
611
+ enableExtension(extensionId: string): void {
612
+ const disabled = this.globalSettings.disabledExtensions ?? [];
613
+ const index = disabled.indexOf(extensionId);
614
+ if (index !== -1) {
615
+ disabled.splice(index, 1);
616
+ this.globalSettings.disabledExtensions = disabled;
617
+ this.save();
618
+ }
619
+ }
620
+
621
+ disableExtension(extensionId: string): void {
622
+ const disabled = this.globalSettings.disabledExtensions ?? [];
623
+ if (!disabled.includes(extensionId)) {
624
+ disabled.push(extensionId);
625
+ this.globalSettings.disabledExtensions = disabled;
626
+ this.save();
627
+ }
628
+ }
629
+
597
630
  getTtsrSettings(): TtsrSettings {
598
631
  return this.settings.ttsr ?? {};
599
632
  }
@@ -5,12 +5,14 @@
5
5
  import { existsSync, readFileSync } from "node:fs";
6
6
  import chalk from "chalk";
7
7
  import { contextFileCapability } from "../capability/context-file";
8
+ import type { Rule } from "../capability/rule";
8
9
  import { systemPromptCapability } from "../capability/system-prompt";
9
10
  import { getDocsPath, getExamplesPath, getReadmePath } from "../config";
10
11
  import { type ContextFile, loadSync, type SystemPrompt as SystemPromptFile } from "../discovery/index";
11
12
  import type { SkillsSettings } from "./settings-manager";
12
13
  import { formatSkillsForPrompt, loadSkills, type Skill } from "./skills";
13
14
  import type { ToolName } from "./tools/index";
15
+ import { formatRulesForPrompt } from "./tools/rulebook";
14
16
 
15
17
  /**
16
18
  * Execute a git command synchronously and return stdout or null on failure.
@@ -238,6 +240,8 @@ export interface BuildSystemPromptOptions {
238
240
  contextFiles?: Array<{ path: string; content: string; depth?: number }>;
239
241
  /** Pre-loaded skills (skips discovery if provided). */
240
242
  skills?: Skill[];
243
+ /** Pre-loaded rulebook rules (rules with descriptions, excluding TTSR and always-apply). */
244
+ rulebookRules?: Rule[];
241
245
  }
242
246
 
243
247
  /** Build the system prompt with tools, guidelines, and context */
@@ -250,6 +254,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
250
254
  cwd,
251
255
  contextFiles: providedContextFiles,
252
256
  skills: providedSkills,
257
+ rulebookRules,
253
258
  } = options;
254
259
  const resolvedCwd = cwd ?? process.cwd();
255
260
  const resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt");
@@ -310,6 +315,11 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
310
315
  prompt += formatSkillsForPrompt(skills);
311
316
  }
312
317
 
318
+ // Append rules section (always enabled when rules exist)
319
+ if (rulebookRules && rulebookRules.length > 0) {
320
+ prompt += formatRulesForPrompt(rulebookRules);
321
+ }
322
+
313
323
  // Add date/time and working directory last
314
324
  prompt += `\nCurrent date and time: ${dateTime}`;
315
325
  prompt += `\nCurrent working directory: ${resolvedCwd}`;
@@ -419,6 +429,11 @@ Documentation:
419
429
  prompt += formatSkillsForPrompt(skills);
420
430
  }
421
431
 
432
+ // Append rules section (always enabled when rules exist)
433
+ if (rulebookRules && rulebookRules.length > 0) {
434
+ prompt += formatRulesForPrompt(rulebookRules);
435
+ }
436
+
422
437
  // Add date/time and working directory last
423
438
  prompt += `\nCurrent date and time: ${dateTime}`;
424
439
  prompt += `\nCurrent working directory: ${resolvedCwd}`;
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { Model } from "@oh-my-pi/pi-ai";
6
6
  import { completeSimple } from "@oh-my-pi/pi-ai";
7
+ import { logger } from "./logger";
7
8
  import type { ModelRegistry } from "./model-registry";
8
9
  import { findSmolModel } from "./model-resolver";
9
10
 
@@ -43,21 +44,35 @@ export async function generateSessionTitle(
43
44
  savedSmolModel?: string,
44
45
  ): Promise<string | null> {
45
46
  const model = await findTitleModel(registry, savedSmolModel);
46
- if (!model) return null;
47
+ if (!model) {
48
+ logger.debug("title-generator: no smol model found");
49
+ return null;
50
+ }
47
51
 
48
52
  const apiKey = await registry.getApiKey(model);
49
- if (!apiKey) return null;
53
+ if (!apiKey) {
54
+ logger.debug("title-generator: no API key for model", { provider: model.provider, id: model.id });
55
+ return null;
56
+ }
50
57
 
51
58
  // Truncate message if too long
52
59
  const truncatedMessage =
53
60
  firstMessage.length > MAX_INPUT_CHARS ? `${firstMessage.slice(0, MAX_INPUT_CHARS)}...` : firstMessage;
54
61
 
62
+ const request = {
63
+ model: `${model.provider}/${model.id}`,
64
+ systemPrompt: TITLE_SYSTEM_PROMPT,
65
+ userMessage: `<user-message>\n${truncatedMessage}\n</user-message>`,
66
+ maxTokens: 30,
67
+ };
68
+ logger.debug("title-generator: request", request);
69
+
55
70
  try {
56
71
  const response = await completeSimple(
57
72
  model,
58
73
  {
59
- systemPrompt: TITLE_SYSTEM_PROMPT,
60
- messages: [{ role: "user", content: truncatedMessage, timestamp: Date.now() }],
74
+ systemPrompt: request.systemPrompt,
75
+ messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }],
61
76
  },
62
77
  {
63
78
  apiKey,
@@ -74,13 +89,20 @@ export async function generateSessionTitle(
74
89
  }
75
90
  title = title.trim();
76
91
 
77
- if (!title || title.length > 60) {
92
+ logger.debug("title-generator: response", {
93
+ title,
94
+ usage: response.usage,
95
+ stopReason: response.stopReason,
96
+ });
97
+
98
+ if (!title) {
78
99
  return null;
79
100
  }
80
101
 
81
102
  // Clean up: remove quotes, trailing punctuation
82
103
  return title.replace(/^["']|["']$/g, "").replace(/[.!?]$/, "");
83
- } catch {
104
+ } catch (err) {
105
+ logger.debug("title-generator: error", { error: err instanceof Error ? err.message : String(err) });
84
106
  return null;
85
107
  }
86
108
  }
@@ -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
+ }