@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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [3.5.1337] - 2026-01-03
6
+
7
+ ### Added
8
+
9
+ - Added session header and footer output in text mode showing version, model, provider, thinking level, and session ID
10
+ - Added Extension Control Center dashboard accessible via `/extensions` command for unified management of all providers and extensions
11
+ - Added ability to enable/disable individual extensions with persistent settings
12
+ - Added three-column dashboard layout with sidebar tree, extension list, and inspector panel
13
+ - Added fuzzy search filtering for extensions in the dashboard
14
+ - Added keyboard navigation with Tab to cycle panes, j/k for navigation, Space to toggle, Enter to expand/collapse
15
+
16
+ ### Changed
17
+
18
+ - Redesigned Extension Control Center from 3-column layout to tabbed interface with horizontal provider tabs and 2-column grid
19
+ - Replaced sidebar tree navigation with provider tabs using TAB/Shift+TAB cycling
20
+
21
+ ### Fixed
22
+
23
+ - Fixed title generation flag not resetting when starting a new session
24
+
25
+ ## [3.4.1337] - 2026-01-03
26
+
27
+ ### Added
28
+
29
+ - Added Time Traveling Stream Rules (TTSR) feature that monitors agent output for pattern matches and injects rule reminders mid-stream
30
+ - Added `ttsr_trigger` frontmatter field for rules to define regex patterns that trigger mid-stream injection
31
+ - Added TTSR settings for enabled state, context mode (keep/discard partial output), and repeat mode (once/after-gap)
32
+
33
+ ### Fixed
34
+
35
+ - Fixed excessive subprocess spawns by caching git status for 1 second in the footer component
36
+
5
37
  ## [3.3.1337] - 2026-01-03
6
38
 
7
39
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "3.3.1337",
3
+ "version": "3.5.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.3.1337",
43
- "@oh-my-pi/pi-ai": "3.3.1337",
44
- "@oh-my-pi/pi-tui": "3.3.1337",
42
+ "@oh-my-pi/pi-agent-core": "3.5.1337",
43
+ "@oh-my-pi/pi-ai": "3.5.1337",
44
+ "@oh-my-pi/pi-tui": "3.5.1337",
45
45
  "@sinclair/typebox": "^0.34.46",
46
46
  "ajv": "^8.17.1",
47
47
  "chalk": "^5.5.0",
@@ -15,6 +15,8 @@ export interface RuleFrontmatter {
15
15
  description?: string;
16
16
  globs?: string[];
17
17
  alwaysApply?: boolean;
18
+ /** Regex pattern that triggers time-traveling rule injection */
19
+ ttsr_trigger?: string;
18
20
  [key: string]: unknown;
19
21
  }
20
22
 
@@ -34,6 +36,8 @@ export interface Rule {
34
36
  alwaysApply?: boolean;
35
37
  /** Description (for agent-requested rules) */
36
38
  description?: string;
39
+ /** Regex pattern that triggers time-traveling rule injection */
40
+ ttsrTrigger?: string;
37
41
  /** Source metadata */
38
42
  _source: SourceMeta;
39
43
  }
@@ -16,6 +16,7 @@
16
16
  import type { Agent, AgentEvent, AgentMessage, AgentState, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
17
17
  import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai";
18
18
  import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-ai";
19
+ import type { Rule } from "../capability/rule";
19
20
  import { getAuthPath } from "../config";
20
21
  import { type BashResult, executeBash as executeBashCommand } from "./bash-executor";
21
22
  import {
@@ -47,6 +48,7 @@ import type { ModelRegistry } from "./model-registry";
47
48
  import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager";
48
49
  import type { SettingsManager, SkillsSettings } from "./settings-manager";
49
50
  import { expandSlashCommand, type FileSlashCommand, parseCommandArgs } from "./slash-commands";
51
+ import type { TtsrManager } from "./ttsr";
50
52
 
51
53
  /** Session-specific events that extend the core AgentEvent */
52
54
  export type AgentSessionEvent =
@@ -54,7 +56,8 @@ export type AgentSessionEvent =
54
56
  | { type: "auto_compaction_start"; reason: "threshold" | "overflow" }
55
57
  | { type: "auto_compaction_end"; result: CompactionResult | undefined; aborted: boolean; willRetry: boolean }
56
58
  | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
57
- | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string };
59
+ | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
60
+ | { type: "ttsr_triggered"; rules: Rule[] };
58
61
 
59
62
  /** Listener function for agent session events */
60
63
  export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
@@ -80,6 +83,8 @@ export interface AgentSessionConfig {
80
83
  skillsSettings?: Required<SkillsSettings>;
81
84
  /** Model registry for API key resolution and model discovery */
82
85
  modelRegistry: ModelRegistry;
86
+ /** TTSR manager for time-traveling stream rules */
87
+ ttsrManager?: TtsrManager;
83
88
  }
84
89
 
85
90
  /** Options for AgentSession.prompt() */
@@ -179,6 +184,11 @@ export class AgentSession {
179
184
  // Model registry for API key resolution
180
185
  private _modelRegistry: ModelRegistry;
181
186
 
187
+ // TTSR manager for time-traveling stream rules
188
+ private _ttsrManager: TtsrManager | undefined = undefined;
189
+ private _pendingTtsrInjections: Rule[] = [];
190
+ private _ttsrAbortPending = false;
191
+
182
192
  constructor(config: AgentSessionConfig) {
183
193
  this.agent = config.agent;
184
194
  this.sessionManager = config.sessionManager;
@@ -190,6 +200,7 @@ export class AgentSession {
190
200
  this._customCommands = config.customCommands ?? [];
191
201
  this._skillsSettings = config.skillsSettings;
192
202
  this._modelRegistry = config.modelRegistry;
203
+ this._ttsrManager = config.ttsrManager;
193
204
 
194
205
  // Always subscribe to agent events for internal handling
195
206
  // (session persistence, hooks, auto-compaction, retry logic)
@@ -201,6 +212,16 @@ export class AgentSession {
201
212
  return this._modelRegistry;
202
213
  }
203
214
 
215
+ /** TTSR manager for time-traveling stream rules */
216
+ get ttsrManager(): TtsrManager | undefined {
217
+ return this._ttsrManager;
218
+ }
219
+
220
+ /** Whether a TTSR abort is pending (stream was aborted to inject rules) */
221
+ get isTtsrAbortPending(): boolean {
222
+ return this._ttsrAbortPending;
223
+ }
224
+
204
225
  // =========================================================================
205
226
  // Event Subscription
206
227
  // =========================================================================
@@ -239,6 +260,60 @@ export class AgentSession {
239
260
  // Notify all listeners
240
261
  this._emit(event);
241
262
 
263
+ // TTSR: Reset buffer on turn start
264
+ if (event.type === "turn_start" && this._ttsrManager) {
265
+ this._ttsrManager.resetBuffer();
266
+ }
267
+
268
+ // TTSR: Increment message count on turn end (for repeat-after-gap tracking)
269
+ if (event.type === "turn_end" && this._ttsrManager) {
270
+ this._ttsrManager.incrementMessageCount();
271
+ }
272
+
273
+ // TTSR: Check for pattern matches on text deltas and tool call argument deltas
274
+ if (event.type === "message_update" && this._ttsrManager?.hasRules()) {
275
+ const assistantEvent = event.assistantMessageEvent;
276
+ // Monitor both assistant prose (text_delta) and tool call arguments (toolcall_delta)
277
+ if (assistantEvent.type === "text_delta" || assistantEvent.type === "toolcall_delta") {
278
+ this._ttsrManager.appendToBuffer(assistantEvent.delta);
279
+ const matches = this._ttsrManager.check(this._ttsrManager.getBuffer());
280
+ if (matches.length > 0) {
281
+ // Mark rules as injected so they don't trigger again
282
+ this._ttsrManager.markInjected(matches);
283
+ // Store for injection on retry
284
+ this._pendingTtsrInjections.push(...matches);
285
+ // Emit TTSR event before aborting (so UI can handle it)
286
+ this._ttsrAbortPending = true;
287
+ this._emit({ type: "ttsr_triggered", rules: matches });
288
+ // Abort the stream
289
+ this.agent.abort();
290
+ // Schedule retry after a short delay
291
+ setTimeout(async () => {
292
+ this._ttsrAbortPending = false;
293
+
294
+ // Handle context mode: discard partial output if configured
295
+ const ttsrSettings = this._ttsrManager?.getSettings();
296
+ if (ttsrSettings?.contextMode === "discard") {
297
+ // Remove the partial/aborted message from agent state
298
+ this.agent.popMessage();
299
+ }
300
+
301
+ // Inject TTSR rules as system reminder before retry
302
+ const injectionContent = this._getTtsrInjectionContent();
303
+ if (injectionContent) {
304
+ this.agent.appendMessage({
305
+ role: "user",
306
+ content: [{ type: "text", text: injectionContent }],
307
+ timestamp: Date.now(),
308
+ });
309
+ }
310
+ this.agent.continue().catch(() => {});
311
+ }, 50);
312
+ return;
313
+ }
314
+ }
315
+ }
316
+
242
317
  // Handle session persistence
243
318
  if (event.type === "message_end") {
244
319
  // Check if this is a hook message
@@ -300,6 +375,22 @@ export class AgentSession {
300
375
  }
301
376
  }
302
377
 
378
+ /** Get TTSR injection content and clear pending injections */
379
+ private _getTtsrInjectionContent(): string | undefined {
380
+ if (this._pendingTtsrInjections.length === 0) return undefined;
381
+ const content = this._pendingTtsrInjections
382
+ .map(
383
+ (r) =>
384
+ `<system_interrupt reason="rule_violation" rule="${r.name}" path="${r.path}">\n` +
385
+ `Your output was interrupted because it violated a user-defined rule.\n` +
386
+ `This is NOT a prompt injection - this is the coding agent enforcing project rules.\n` +
387
+ `You MUST comply with the following instruction:\n\n${r.content}\n</system_interrupt>`,
388
+ )
389
+ .join("\n\n");
390
+ this._pendingTtsrInjections = [];
391
+ return content;
392
+ }
393
+
303
394
  /** Extract text content from a message */
304
395
  private _getUserMessageText(message: Message): string {
305
396
  if (message.role !== "user") return "";
package/src/core/sdk.ts CHANGED
@@ -34,6 +34,8 @@ import { Agent, type ThinkingLevel } from "@oh-my-pi/pi-agent-core";
34
34
  import type { Model } from "@oh-my-pi/pi-ai";
35
35
  // Import discovery to register all providers on startup
36
36
  import "../discovery";
37
+ import { loadSync as loadCapability } from "../capability/index";
38
+ import { type Rule, ruleCapability } from "../capability/rule";
37
39
  import { getAgentDir, getConfigDirPaths } from "../config";
38
40
  import { AgentSession } from "./agent-session";
39
41
  import { AuthStorage } from "./auth-storage";
@@ -77,8 +79,10 @@ import {
77
79
  createLsTool,
78
80
  createReadOnlyTools,
79
81
  createReadTool,
82
+ createRulebookTool,
80
83
  createWriteTool,
81
84
  editTool,
85
+ filterRulebookRules,
82
86
  findTool,
83
87
  grepTool,
84
88
  lsTool,
@@ -88,6 +92,7 @@ import {
88
92
  warmupLspServers,
89
93
  writeTool,
90
94
  } from "./tools/index";
95
+ import { createTtsrManager } from "./ttsr";
91
96
 
92
97
  // Types
93
98
 
@@ -601,6 +606,20 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
601
606
  const skills = options.skills ?? discoverSkills(cwd, agentDir, settingsManager.getSkillsSettings());
602
607
  time("discoverSkills");
603
608
 
609
+ // Discover rules
610
+ const ttsrManager = createTtsrManager(settingsManager.getTtsrSettings());
611
+ const rulesResult = loadCapability<Rule>(ruleCapability.id, { cwd });
612
+ for (const rule of rulesResult.items) {
613
+ if (rule.ttsrTrigger) {
614
+ ttsrManager.addRule(rule);
615
+ }
616
+ }
617
+ time("discoverTtsrRules");
618
+
619
+ // Filter rules for the rulebook (non-TTSR, non-alwaysApply, with descriptions)
620
+ const rulebookRules = filterRulebookRules(rulesResult.items);
621
+ time("filterRulebookRules");
622
+
604
623
  const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir);
605
624
  time("discoverContextFiles");
606
625
 
@@ -744,6 +763,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
744
763
 
745
764
  let allToolsArray: Tool[] = [...builtInTools, ...wrappedCustomTools];
746
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
+
747
771
  // Filter out hidden tools unless explicitly requested
748
772
  if (options.explicitTools) {
749
773
  const explicitSet = new Set(options.explicitTools);
@@ -768,6 +792,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
768
792
  cwd,
769
793
  skills,
770
794
  contextFiles,
795
+ rulebookRules,
771
796
  });
772
797
  time("buildSystemPrompt");
773
798
 
@@ -778,6 +803,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
778
803
  cwd,
779
804
  skills,
780
805
  contextFiles,
806
+ rulebookRules,
781
807
  customPrompt: options.systemPrompt,
782
808
  });
783
809
  } else {
@@ -847,6 +873,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
847
873
  customCommands: customCommandsResult.commands,
848
874
  skillsSettings: settingsManager.getSkillsSettings(),
849
875
  modelRegistry,
876
+ ttsrManager,
850
877
  });
851
878
  time("createAgentSession");
852
879
 
@@ -107,6 +107,13 @@ export interface LabelEntry extends SessionEntryBase {
107
107
  label: string | undefined;
108
108
  }
109
109
 
110
+ /** TTSR injection entry - tracks which time-traveling rules have been injected this session. */
111
+ export interface TtsrInjectionEntry extends SessionEntryBase {
112
+ type: "ttsr_injection";
113
+ /** Names of rules that were injected */
114
+ injectedRules: string[];
115
+ }
116
+
110
117
  /**
111
118
  * Custom message entry for hooks to inject messages into LLM context.
112
119
  * Use customType to identify your hook's entries.
@@ -136,7 +143,8 @@ export type SessionEntry =
136
143
  | BranchSummaryEntry
137
144
  | CustomEntry
138
145
  | CustomMessageEntry
139
- | LabelEntry;
146
+ | LabelEntry
147
+ | TtsrInjectionEntry;
140
148
 
141
149
  /** Raw file entry (includes header) */
142
150
  export type FileEntry = SessionHeader | SessionEntry;
@@ -154,6 +162,8 @@ export interface SessionContext {
154
162
  thinkingLevel: string;
155
163
  /** Model roles: { default: "provider/modelId", small: "provider/modelId", ... } */
156
164
  models: Record<string, string>;
165
+ /** Names of TTSR rules that have been injected this session */
166
+ injectedTtsrRules: string[];
157
167
  }
158
168
 
159
169
  export interface SessionInfo {
@@ -295,7 +305,7 @@ export function buildSessionContext(
295
305
  let leaf: SessionEntry | undefined;
296
306
  if (leafId === null) {
297
307
  // Explicitly null - return no messages (navigated to before first entry)
298
- return { messages: [], thinkingLevel: "off", models: {} };
308
+ return { messages: [], thinkingLevel: "off", models: {}, injectedTtsrRules: [] };
299
309
  }
300
310
  if (leafId) {
301
311
  leaf = byId.get(leafId);
@@ -306,7 +316,7 @@ export function buildSessionContext(
306
316
  }
307
317
 
308
318
  if (!leaf) {
309
- return { messages: [], thinkingLevel: "off", models: {} };
319
+ return { messages: [], thinkingLevel: "off", models: {}, injectedTtsrRules: [] };
310
320
  }
311
321
 
312
322
  // Walk from leaf to root, collecting path
@@ -321,6 +331,7 @@ export function buildSessionContext(
321
331
  let thinkingLevel = "off";
322
332
  const models: Record<string, string> = {};
323
333
  let compaction: CompactionEntry | null = null;
334
+ const injectedTtsrRulesSet = new Set<string>();
324
335
 
325
336
  for (const entry of path) {
326
337
  if (entry.type === "thinking_level_change") {
@@ -336,9 +347,16 @@ export function buildSessionContext(
336
347
  models.default = `${entry.message.provider}/${entry.message.model}`;
337
348
  } else if (entry.type === "compaction") {
338
349
  compaction = entry;
350
+ } else if (entry.type === "ttsr_injection") {
351
+ // Collect injected TTSR rule names
352
+ for (const ruleName of entry.injectedRules) {
353
+ injectedTtsrRulesSet.add(ruleName);
354
+ }
339
355
  }
340
356
  }
341
357
 
358
+ const injectedTtsrRules = Array.from(injectedTtsrRulesSet);
359
+
342
360
  // Build messages and collect corresponding entries
343
361
  // When there's a compaction, we need to:
344
362
  // 1. Emit summary first (entry = compaction)
@@ -389,7 +407,7 @@ export function buildSessionContext(
389
407
  }
390
408
  }
391
409
 
392
- return { messages, thinkingLevel, models };
410
+ return { messages, thinkingLevel, models, injectedTtsrRules };
393
411
  }
394
412
 
395
413
  /**
@@ -814,6 +832,44 @@ export class SessionManager {
814
832
  return entry.id;
815
833
  }
816
834
 
835
+ // =========================================================================
836
+ // TTSR (Time Traveling Stream Rules)
837
+ // =========================================================================
838
+
839
+ /**
840
+ * Append a TTSR injection entry recording which rules were injected.
841
+ * @param ruleNames Names of rules that were injected
842
+ * @returns Entry id
843
+ */
844
+ appendTtsrInjection(ruleNames: string[]): string {
845
+ const entry: TtsrInjectionEntry = {
846
+ type: "ttsr_injection",
847
+ id: generateId(this.byId),
848
+ parentId: this.leafId,
849
+ timestamp: new Date().toISOString(),
850
+ injectedRules: ruleNames,
851
+ };
852
+ this._appendEntry(entry);
853
+ return entry.id;
854
+ }
855
+
856
+ /**
857
+ * Get all unique TTSR rule names that have been injected in the current branch.
858
+ * Scans from root to current leaf for ttsr_injection entries.
859
+ */
860
+ getInjectedTtsrRules(): string[] {
861
+ const path = this.getBranch();
862
+ const ruleNames = new Set<string>();
863
+ for (const entry of path) {
864
+ if (entry.type === "ttsr_injection") {
865
+ for (const name of entry.injectedRules) {
866
+ ruleNames.add(name);
867
+ }
868
+ }
869
+ }
870
+ return Array.from(ruleNames);
871
+ }
872
+
817
873
  // =========================================================================
818
874
  // Tree Traversal
819
875
  // =========================================================================
@@ -68,6 +68,16 @@ export interface EditSettings {
68
68
  fuzzyMatch?: boolean; // default: true (accept high-confidence fuzzy matches for whitespace/indentation)
69
69
  }
70
70
 
71
+ export interface TtsrSettings {
72
+ enabled?: boolean; // default: true
73
+ /** What to do with partial output when TTSR triggers: "keep" shows interrupted attempt, "discard" removes it */
74
+ contextMode?: "keep" | "discard"; // default: "discard"
75
+ /** How TTSR rules repeat: "once" = only trigger once per session, "after-gap" = can repeat after N messages */
76
+ repeatMode?: "once" | "after-gap"; // default: "once"
77
+ /** Number of messages before a rule can trigger again (only used when repeatMode is "after-gap") */
78
+ repeatGap?: number; // default: 10
79
+ }
80
+
71
81
  export interface Settings {
72
82
  lastChangelogVersion?: string;
73
83
  /** Model roles map: { default: "provider/modelId", small: "provider/modelId", ... } */
@@ -93,7 +103,9 @@ export interface Settings {
93
103
  mcp?: MCPSettings;
94
104
  lsp?: LspSettings;
95
105
  edit?: EditSettings;
106
+ ttsr?: TtsrSettings;
96
107
  disabledProviders?: string[]; // Discovery provider IDs that are disabled
108
+ disabledExtensions?: string[]; // Individual extension IDs that are disabled (e.g., "skill:commit")
97
109
  }
98
110
 
99
111
  /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
@@ -582,4 +594,93 @@ export class SettingsManager {
582
594
  this.globalSettings.disabledProviders = providerIds;
583
595
  this.save();
584
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
+
630
+ getTtsrSettings(): TtsrSettings {
631
+ return this.settings.ttsr ?? {};
632
+ }
633
+
634
+ setTtsrSettings(settings: TtsrSettings): void {
635
+ this.globalSettings.ttsr = { ...this.globalSettings.ttsr, ...settings };
636
+ this.save();
637
+ }
638
+
639
+ getTtsrEnabled(): boolean {
640
+ return this.settings.ttsr?.enabled ?? true;
641
+ }
642
+
643
+ setTtsrEnabled(enabled: boolean): void {
644
+ if (!this.globalSettings.ttsr) {
645
+ this.globalSettings.ttsr = {};
646
+ }
647
+ this.globalSettings.ttsr.enabled = enabled;
648
+ this.save();
649
+ }
650
+
651
+ getTtsrContextMode(): "keep" | "discard" {
652
+ return this.settings.ttsr?.contextMode ?? "discard";
653
+ }
654
+
655
+ setTtsrContextMode(mode: "keep" | "discard"): void {
656
+ if (!this.globalSettings.ttsr) {
657
+ this.globalSettings.ttsr = {};
658
+ }
659
+ this.globalSettings.ttsr.contextMode = mode;
660
+ this.save();
661
+ }
662
+
663
+ getTtsrRepeatMode(): "once" | "after-gap" {
664
+ return this.settings.ttsr?.repeatMode ?? "once";
665
+ }
666
+
667
+ setTtsrRepeatMode(mode: "once" | "after-gap"): void {
668
+ if (!this.globalSettings.ttsr) {
669
+ this.globalSettings.ttsr = {};
670
+ }
671
+ this.globalSettings.ttsr.repeatMode = mode;
672
+ this.save();
673
+ }
674
+
675
+ getTtsrRepeatGap(): number {
676
+ return this.settings.ttsr?.repeatGap ?? 10;
677
+ }
678
+
679
+ setTtsrRepeatGap(gap: number): void {
680
+ if (!this.globalSettings.ttsr) {
681
+ this.globalSettings.ttsr = {};
682
+ }
683
+ this.globalSettings.ttsr.repeatGap = gap;
684
+ this.save();
685
+ }
585
686
  }
@@ -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
  }