@oh-my-pi/pi-coding-agent 12.9.0 → 12.10.1

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/src/main.ts CHANGED
@@ -477,6 +477,11 @@ async function buildSessionOptions(
477
477
  settings.override("skills.includeSkills", parsed.skills as string[]);
478
478
  }
479
479
 
480
+ // Rules
481
+ if (parsed.noRules) {
482
+ options.rules = [];
483
+ }
484
+
480
485
  // Additional extension paths from CLI
481
486
  const cliExtensionPaths = parsed.noExtensions ? [] : [...(parsed.extensions ?? []), ...(parsed.hooks ?? [])];
482
487
  if (cliExtensionPaths.length > 0) {
@@ -168,7 +168,6 @@ export async function buildMemoryToolDeveloperInstructions(
168
168
  if (!truncated.trim()) return undefined;
169
169
 
170
170
  return renderPromptTemplate(readPathTemplate, {
171
- base_path: memoryRoot,
172
171
  memory_summary: truncated,
173
172
  });
174
173
  }
@@ -575,8 +574,7 @@ async function runStage1Job(options: {
575
574
  const budgetTokens = Math.floor(modelMaxTokens * config.rolloutPayloadPercent);
576
575
  const truncatedItems = truncateByApproxTokens(serializedItems, budgetTokens);
577
576
  const inputPrompt = renderPromptTemplate(stageOneInputTemplate, {
578
- rollout_path: claim.rolloutPath,
579
- cwd: claim.cwd,
577
+ thread_id: claim.threadId,
580
578
  response_items_json: truncatedItems,
581
579
  });
582
580
 
@@ -635,13 +633,9 @@ async function syncPhase2Artifacts(memoryRoot: string, outputs: Stage1OutputRow[
635
633
  const stem = formatRolloutFilename(row.threadId, row.rolloutSlug);
636
634
  const filename = `${stem}.md`;
637
635
  keepFiles.add(filename);
638
- const body = [
639
- `thread_id: ${row.threadId}`,
640
- `updated_at: ${row.sourceUpdatedAt}`,
641
- `cwd: ${row.cwd}`,
642
- "",
643
- row.rolloutSummary,
644
- ].join("\n");
636
+ const body = [`thread_id: ${row.threadId}`, `updated_at: ${row.sourceUpdatedAt}`, "", row.rolloutSummary].join(
637
+ "\n",
638
+ );
645
639
  await Bun.write(path.join(summariesDir, filename), `${body.trim()}\n`);
646
640
  }
647
641
 
@@ -668,7 +662,7 @@ function buildRawMemoriesMarkdown(outputs: Stage1OutputRow[]): string {
668
662
  }
669
663
 
670
664
  const blocks = outputs.map(row => {
671
- const header = [`## ${row.threadId}`, `updated_at: ${row.sourceUpdatedAt}`, `cwd: ${row.cwd}`, ""].join("\n");
665
+ const header = [`## ${row.threadId}`, `updated_at: ${row.sourceUpdatedAt}`, ""].join("\n");
672
666
  return `${header}${row.rawMemory.trim()}\n`;
673
667
  });
674
668
  return `# Raw Memories\n\n${blocks.join("\n")}`;
@@ -707,7 +701,6 @@ async function runConsolidationModel(options: { memoryRoot: string; model: Model
707
701
  const rawMemories = await Bun.file(path.join(memoryRoot, "raw_memories.md")).text();
708
702
  const rolloutSummaries = await readRolloutSummaries(memoryRoot);
709
703
  const input = renderPromptTemplate(consolidationTemplate, {
710
- memory_root: memoryRoot,
711
704
  raw_memories: truncateByApproxTokens(rawMemories, 20_000),
712
705
  rollout_summaries: truncateByApproxTokens(rolloutSummaries, 12_000),
713
706
  });
@@ -1077,7 +1070,7 @@ function loadMemoryConfig(settings: Settings): MemoryRuntimeConfig {
1077
1070
  };
1078
1071
  }
1079
1072
 
1080
- function getMemoryRoot(agentDir: string, cwd: string): string {
1073
+ export function getMemoryRoot(agentDir: string, cwd: string): string {
1081
1074
  return path.join(agentDir, "memories", encodeProjectPath(cwd));
1082
1075
  }
1083
1076
 
@@ -139,6 +139,12 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
139
139
  { value: "20", label: "20 messages" },
140
140
  { value: "30", label: "30 messages" },
141
141
  ],
142
+ "ttsr.interruptMode": [
143
+ { value: "always", label: "always", description: "Interrupt on prose and tool streams" },
144
+ { value: "prose-only", label: "prose-only", description: "Interrupt only on reply/thinking matches" },
145
+ { value: "tool-only", label: "tool-only", description: "Interrupt only on tool-call argument matches" },
146
+ { value: "never", label: "never", description: "Never interrupt; inject warning after completion" },
147
+ ],
142
148
  // Virtual terminal
143
149
  "bash.virtualTerminal": [
144
150
  { value: "on", label: "On", description: "PTY-backed interactive execution" },
@@ -28,6 +28,8 @@ export interface RpcClientOptions {
28
28
  provider?: string;
29
29
  /** Model ID to use */
30
30
  model?: string;
31
+ /** Session directory for the agent */
32
+ sessionDir?: string;
31
33
  /** Additional CLI arguments */
32
34
  args?: string[];
33
35
  }
@@ -108,6 +110,9 @@ export class RpcClient {
108
110
  if (this.options.model) {
109
111
  args.push("--model", this.options.model);
110
112
  }
113
+ if (this.options.sessionDir) {
114
+ args.push("--session-dir", this.options.sessionDir);
115
+ }
111
116
  if (this.options.args) {
112
117
  args.push(...this.options.args);
113
118
  }
@@ -153,6 +158,17 @@ export class RpcClient {
153
158
  this.#pendingRequests.clear();
154
159
  }
155
160
 
161
+ /**
162
+ * Stop the RPC agent process and clean up resources.
163
+ */
164
+ [Symbol.dispose](): void {
165
+ try {
166
+ this.stop();
167
+ } catch {
168
+ // Ignore cleanup errors
169
+ }
170
+ }
171
+
156
172
  /**
157
173
  * Subscribe to agent events.
158
174
  */
@@ -1,5 +1,5 @@
1
1
  You are the memory consolidation agent.
2
- Memory root: {{memory_root}}
2
+ Memory root: memory://root
3
3
  Input corpus (raw memories):
4
4
  {{raw_memories}}
5
5
  Input corpus (rollout summaries):
@@ -1,10 +1,10 @@
1
1
  # Memory Guidance
2
- Memory root: {{base_path}}
2
+ Memory root: memory://root
3
3
  Operational rules:
4
- 1) Read `{{base_path}}/memory_summary.md` first.
5
- 2) If needed, inspect `{{base_path}}/MEMORY.md` and `{{base_path}}/skills/*/SKILL.md`.
4
+ 1) Read `memory://root/memory_summary.md` first.
5
+ 2) If needed, inspect `memory://root/MEMORY.md` and `memory://root/skills/<name>/SKILL.md`.
6
6
  3) Decision boundary: trust memory for heuristics/process context; trust current repo files, runtime output, and user instruction for factual state and final decisions.
7
- 4) Citation policy: when memory changes your plan, cite the memory artifact path you used (for example `memories/skills/<name>/SKILL.md`) and pair it with current-repo evidence before acting.
7
+ 4) Citation policy: when memory changes your plan, cite the memory artifact path you used (for example `memory://root/skills/<name>/SKILL.md`) and pair it with current-repo evidence before acting.
8
8
  5) Conflict workflow: if memory disagrees with repo state or user instruction, prefer repo/user, treat memory as stale, proceed with corrected behavior, then update/regenerate memory artifacts through normal execution.
9
9
  6) Escalate confidence only after repository verification; memory alone is never sufficient proof.
10
10
  Memory summary:
@@ -1,5 +1,4 @@
1
- rollout_path: {{rollout_path}}
2
- cwd: {{cwd}}
1
+ thread_id: {{thread_id}}
3
2
 
4
3
  Persistable response items (JSON):
5
4
  {{response_items_json}}
@@ -4,34 +4,21 @@ Executes bash command in shell session for terminal operations like git, bun, ca
4
4
 
5
5
  <instruction>
6
6
  - Use `cwd` parameter to set working directory instead of `cd dir && ...`
7
- - Paths with spaces must use double quotes: `cd "/path/with spaces"`
8
- - For sequential dependent operations, chain with `&&`: `mkdir foo && cd foo && touch bar`
9
- - For parallel independent operations, make multiple tool calls in one message
10
7
  - Use `;` only when later commands should run regardless of earlier failures
8
+ - `skill://` URIs are auto-resolved to filesystem paths before execution
9
+ - `python skill://my-skill/scripts/init.py` runs the script from the skill directory
10
+ - `skill://<name>/<relative-path>` resolves within the skill's base directory
11
+ - `agent://`, `artifact://`, `plan://`, `memory://`, and `rule://` URIs are also auto-resolved to filesystem paths before execution
11
12
  </instruction>
12
13
 
13
14
  <output>
14
- Returns stdout, stderr, exit code from command execution.
15
- - Output truncated after 50KB or 2000 lines (whichever first); use `head` parameter to limit output
15
+ Returns the output, and an exit code from command execution.
16
16
  - If output truncated, full output stored under $ARTIFACTS and referenced as `artifact://<id>` in metadata
17
- - Exit codes shown on non-zero exit; stderr captured
17
+ - Exit codes shown on non-zero exit
18
18
  </output>
19
19
 
20
20
  <critical>
21
- Do NOT use Bash for these operationsspecialized tools exist:
22
- - Reading file contents Read tool
23
- - Searching file contents Grep tool
24
- - Finding files by pattern → Find tool
25
- - Content-addressed edits → Edit tool
26
- - Writing new files → Write tool
27
- </critical>
28
-
29
- <avoid>
30
- Do NOT pipe through `head` or `tail`—use `head` and `tail` parameters instead:
31
- - `command | head -n 50` → use `head: 50` parameter
32
- - `command | tail -n 100` → use `tail: 100` parameter
33
-
34
- Pipe pattern breaks streaming output and prevents artifact storage.
35
-
36
- Do NOT use `2>&1`—stdout and stderr already merged.
37
- </avoid>
21
+ - Do NOT use Bash for these operations like read, grep, find, edit, write, where specialized tools exist.
22
+ - Do NOT use `2>&1` pattern, stdout and stderr are already merged.
23
+ - Do NOT use `| head -n 50` or `| tail -n 100` pattern, use `head` and `tail` parameters instead.
24
+ </critical>
@@ -21,6 +21,8 @@ Reads files from local filesystem or internal URLs.
21
21
  - `skill://<name>` - read SKILL.md for a skill
22
22
  - `skill://<name>/<path>` - read relative path within skill directory
23
23
  - `rule://<name>` - read rule content
24
+ - `memory://root` - read memory summary (`memory_summary.md`)
25
+ - `memory://root/<path>` - read relative path within project memory root
24
26
  - `agent://<id>` - read agent output artifact
25
27
  - `agent://<id>/<path>` or `agent://<id>?q=<query>` - extract JSON from agent output
26
28
  </instruction>
package/src/sdk.ts CHANGED
@@ -40,13 +40,14 @@ import {
40
40
  AgentProtocolHandler,
41
41
  ArtifactProtocolHandler,
42
42
  InternalUrlRouter,
43
+ MemoryProtocolHandler,
43
44
  PlanProtocolHandler,
44
45
  RuleProtocolHandler,
45
46
  SkillProtocolHandler,
46
47
  } from "./internal-urls";
47
48
  import { disposeAllKernelSessions } from "./ipy/executor";
48
49
  import { discoverAndLoadMCPTools, type MCPManager, type MCPToolsLoadResult } from "./mcp";
49
- import { buildMemoryToolDeveloperInstructions, startMemoryStartupTask } from "./memories";
50
+ import { buildMemoryToolDeveloperInstructions, getMemoryRoot, startMemoryStartupTask } from "./memories";
50
51
  import { collectEnvSecrets, loadSecrets, obfuscateMessages, SecretObfuscator } from "./secrets";
51
52
  import { AgentSession } from "./session/agent-session";
52
53
  import { AuthStorage } from "./session/auth-storage";
@@ -133,6 +134,8 @@ export interface CreateAgentSessionOptions {
133
134
  skills?: Skill[];
134
135
  /** Skills to inline into the system prompt instead of listing available skills. */
135
136
  preloadedSkills?: Skill[];
137
+ /** Rules. Default: discovered from multiple locations */
138
+ rules?: Rule[];
136
139
  /** Context files (AGENTS.md content). Default: discovered walking up from cwd */
137
140
  contextFiles?: Array<{ path: string; content: string }>;
138
141
  /** Prompt templates. Default: discovered from cwd/.omp/prompts/ + agentDir/prompts/ */
@@ -671,17 +674,26 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
671
674
  // Discover rules
672
675
  const ttsrSettings = settings.getGroup("ttsr");
673
676
  const ttsrManager = new TtsrManager(ttsrSettings);
674
- const rulesResult = await loadCapability<Rule>(ruleCapability.id, { cwd });
677
+ const rulesResult =
678
+ options.rules !== undefined
679
+ ? { items: options.rules, warnings: undefined }
680
+ : await loadCapability<Rule>(ruleCapability.id, { cwd });
681
+ const registeredTtsrRuleNames = new Set<string>();
675
682
  for (const rule of rulesResult.items) {
676
- if (rule.ttsrTrigger) {
677
- ttsrManager.addRule(rule);
683
+ if (rule.condition && rule.condition.length > 0) {
684
+ if (ttsrManager.addRule(rule)) {
685
+ registeredTtsrRuleNames.add(rule.name);
686
+ }
678
687
  }
679
688
  }
689
+ if (existingSession.injectedTtsrRules.length > 0) {
690
+ ttsrManager.restoreInjected(existingSession.injectedTtsrRules);
691
+ }
680
692
  time("discoverTtsrRules");
681
693
 
682
694
  // Filter rules for the rulebook (non-TTSR, non-alwaysApply, with descriptions)
683
695
  const rulebookRules = rulesResult.items.filter((rule: Rule) => {
684
- if (rule.ttsrTrigger) return false;
696
+ if (registeredTtsrRuleNames.has(rule.name)) return false;
685
697
  if (rule.alwaysApply) return false;
686
698
  if (!rule.description) return false;
687
699
  return true;
@@ -729,7 +741,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
729
741
  modelRegistry,
730
742
  };
731
743
 
732
- // Initialize internal URL router for agent:// and skill:// URLs
744
+ // Initialize internal URL router for internal protocols (agent://, artifact://, plan://, memory://, skill://, rule://)
733
745
  const internalRouter = new InternalUrlRouter();
734
746
  const getArtifactsDir = () => {
735
747
  const sessionFile = sessionManager.getSessionFile();
@@ -743,6 +755,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
743
755
  cwd,
744
756
  }),
745
757
  );
758
+ internalRouter.register(
759
+ new MemoryProtocolHandler({
760
+ getMemoryRoot: () => getMemoryRoot(agentDir, settings.getCwd()),
761
+ }),
762
+ );
746
763
  internalRouter.register(
747
764
  new SkillProtocolHandler({
748
765
  getSkills: () => skills,
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import * as fs from "node:fs";
17
+ import * as path from "node:path";
17
18
 
18
19
  import type { Agent, AgentEvent, AgentMessage, AgentState, AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
19
20
  import type {
@@ -43,7 +44,7 @@ import {
43
44
  import type { Settings, SkillsSettings } from "../config/settings";
44
45
  import { type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor";
45
46
  import { exportSessionToHtml } from "../export/html";
46
- import type { TtsrManager } from "../export/ttsr";
47
+ import type { TtsrManager, TtsrMatchContext } from "../export/ttsr";
47
48
  import type { LoadedCustomCommand } from "../extensibility/custom-commands";
48
49
  import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types";
49
50
  import { CustomToolAdapter } from "../extensibility/custom-tools/wrapper";
@@ -346,6 +347,7 @@ export class AgentSession {
346
347
  #ttsrManager: TtsrManager | undefined = undefined;
347
348
  #pendingTtsrInjections: Rule[] = [];
348
349
  #ttsrAbortPending = false;
350
+ #ttsrRetryToken = 0;
349
351
 
350
352
  #streamingEditAbortTriggered = false;
351
353
  #streamingEditCheckedLineCounts = new Map<string, number>();
@@ -456,46 +458,82 @@ export class AgentSession {
456
458
  this.#ttsrManager.incrementMessageCount();
457
459
  }
458
460
 
459
- // TTSR: Check for pattern matches on text deltas and tool call argument deltas
461
+ // TTSR: Check for pattern matches on assistant text/thinking and tool argument deltas
460
462
  if (event.type === "message_update" && this.#ttsrManager?.hasRules()) {
461
463
  const assistantEvent = event.assistantMessageEvent;
462
- // Monitor both assistant prose (text_delta) and tool call arguments (toolcall_delta)
463
- if (assistantEvent.type === "text_delta" || assistantEvent.type === "toolcall_delta") {
464
- this.#ttsrManager.appendToBuffer(assistantEvent.delta);
465
- const matches = this.#ttsrManager.check(this.#ttsrManager.getBuffer());
464
+ let matchContext: TtsrMatchContext | undefined;
465
+
466
+ if (assistantEvent.type === "text_delta") {
467
+ matchContext = { source: "text" };
468
+ } else if (assistantEvent.type === "thinking_delta") {
469
+ matchContext = { source: "thinking" };
470
+ } else if (assistantEvent.type === "toolcall_delta") {
471
+ matchContext = this.#getTtsrToolMatchContext(event.message, assistantEvent.contentIndex);
472
+ }
473
+
474
+ if (matchContext && "delta" in assistantEvent) {
475
+ const matches = this.#ttsrManager.checkDelta(assistantEvent.delta, matchContext);
466
476
  if (matches.length > 0) {
467
- // Mark rules as injected so they don't trigger again
468
- this.#ttsrManager.markInjected(matches);
469
- // Store for injection on retry
470
- this.#pendingTtsrInjections.push(...matches);
471
- // Abort the stream immediately — do not gate on extension callbacks
472
- this.#ttsrAbortPending = true;
473
- this.agent.abort();
474
- // Notify extensions (fire-and-forget, does not block abort)
475
- this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
476
- // Schedule retry after a short delay
477
- setTimeout(async () => {
478
- this.#ttsrAbortPending = false;
479
-
480
- // Handle context mode: discard partial output if configured
481
- const ttsrSettings = this.#ttsrManager?.getSettings();
482
- if (ttsrSettings?.contextMode === "discard") {
483
- // Remove the partial/aborted message from agent state
484
- this.agent.popMessage();
485
- }
477
+ // Queue rules for injection; mark as injected only after successful enqueue.
478
+
479
+ this.#addPendingTtsrInjections(matches);
480
+
481
+ if (this.#shouldInterruptForTtsrMatch(matchContext)) {
482
+ // Abort the stream immediately — do not gate on extension callbacks
483
+ this.#ttsrAbortPending = true;
484
+ this.agent.abort();
485
+ // Notify extensions (fire-and-forget, does not block abort)
486
+ this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
487
+ // Schedule retry after a short delay
488
+ const retryToken = ++this.#ttsrRetryToken;
489
+ const generation = this.#promptGeneration;
490
+ const targetMessageTimestamp =
491
+ event.message.role === "assistant" ? event.message.timestamp : undefined;
492
+ setTimeout(async () => {
493
+ if (this.#ttsrRetryToken !== retryToken) {
494
+ return;
495
+ }
486
496
 
487
- // Inject TTSR rules as system reminder before retry
488
- const injectionContent = this.#getTtsrInjectionContent();
489
- if (injectionContent) {
490
- this.agent.appendMessage({
491
- role: "user",
492
- content: [{ type: "text", text: injectionContent }],
493
- timestamp: Date.now(),
494
- });
495
- }
496
- this.agent.continue().catch(() => {});
497
- }, 50);
498
- return;
497
+ const targetAssistantIndex = this.#findTtsrAssistantIndex(targetMessageTimestamp);
498
+ if (
499
+ !this.#ttsrAbortPending ||
500
+ this.#promptGeneration !== generation ||
501
+ targetAssistantIndex === -1
502
+ ) {
503
+ this.#ttsrAbortPending = false;
504
+ this.#pendingTtsrInjections = [];
505
+ return;
506
+ }
507
+ this.#ttsrAbortPending = false;
508
+ const ttsrSettings = this.#ttsrManager?.getSettings();
509
+ if (ttsrSettings?.contextMode === "discard") {
510
+ // Remove the partial/aborted assistant turn from agent state
511
+ this.agent.replaceMessages(this.agent.state.messages.slice(0, targetAssistantIndex));
512
+ }
513
+ // Inject TTSR rules as system reminder before retry
514
+ const injection = this.#getTtsrInjectionContent();
515
+ if (injection) {
516
+ const details = { rules: injection.rules.map(rule => rule.name) };
517
+ this.agent.appendMessage({
518
+ role: "custom",
519
+ customType: "ttsr-injection",
520
+ content: injection.content,
521
+ display: false,
522
+ details,
523
+ timestamp: Date.now(),
524
+ });
525
+ this.sessionManager.appendCustomMessageEntry(
526
+ "ttsr-injection",
527
+ injection.content,
528
+ false,
529
+ details,
530
+ );
531
+ this.#markTtsrInjected(details.rules);
532
+ }
533
+ this.agent.continue().catch(() => {});
534
+ }, 50);
535
+ return;
536
+ }
499
537
  }
500
538
  }
501
539
  }
@@ -522,6 +560,9 @@ export class AgentSession {
522
560
  event.message.display,
523
561
  event.message.details,
524
562
  );
563
+ if (event.message.role === "custom" && event.message.customType === "ttsr-injection") {
564
+ this.#markTtsrInjected(this.#extractTtsrRuleNames(event.message.details));
565
+ }
525
566
  } else if (
526
567
  event.message.role === "user" ||
527
568
  event.message.role === "assistant" ||
@@ -536,10 +577,8 @@ export class AgentSession {
536
577
  // Track assistant message for auto-compaction (checked on agent_end)
537
578
  if (event.message.role === "assistant") {
538
579
  this.#lastAssistantMessage = event.message;
539
-
540
- // Reset retry counter immediately on successful assistant response
541
- // This prevents accumulation across multiple LLM calls within a turn
542
580
  const assistantMsg = event.message as AssistantMessage;
581
+ this.#queueDeferredTtsrInjectionIfNeeded(assistantMsg);
543
582
  if (
544
583
  assistantMsg.stopReason !== "error" &&
545
584
  assistantMsg.stopReason !== "aborted" &&
@@ -622,16 +661,185 @@ export class AgentSession {
622
661
  }
623
662
  }
624
663
 
625
- /** Get TTSR injection content and clear pending injections */
626
- #getTtsrInjectionContent(): string | undefined {
664
+ /** Get TTSR injection payload and clear pending injections. */
665
+ #getTtsrInjectionContent(): { content: string; rules: Rule[] } | undefined {
627
666
  if (this.#pendingTtsrInjections.length === 0) return undefined;
628
- const content = this.#pendingTtsrInjections
667
+ const rules = this.#pendingTtsrInjections;
668
+ const content = rules
629
669
  .map(r => renderPromptTemplate(ttsrInterruptTemplate, { name: r.name, path: r.path, content: r.content }))
630
670
  .join("\n\n");
631
671
  this.#pendingTtsrInjections = [];
632
- return content;
672
+ return { content, rules };
673
+ }
674
+
675
+ #addPendingTtsrInjections(rules: Rule[]): void {
676
+ const seen = new Set(this.#pendingTtsrInjections.map(rule => rule.name));
677
+ for (const rule of rules) {
678
+ if (seen.has(rule.name)) continue;
679
+ this.#pendingTtsrInjections.push(rule);
680
+ seen.add(rule.name);
681
+ }
633
682
  }
634
683
 
684
+ #extractTtsrRuleNames(details: unknown): string[] {
685
+ if (!details || typeof details !== "object" || Array.isArray(details)) {
686
+ return [];
687
+ }
688
+ const rules = (details as { rules?: unknown }).rules;
689
+ if (!Array.isArray(rules)) {
690
+ return [];
691
+ }
692
+ return rules.filter((ruleName): ruleName is string => typeof ruleName === "string");
693
+ }
694
+
695
+ #markTtsrInjected(ruleNames: string[]): void {
696
+ const uniqueRuleNames = Array.from(
697
+ new Set(ruleNames.map(ruleName => ruleName.trim()).filter(ruleName => ruleName.length > 0)),
698
+ );
699
+ if (uniqueRuleNames.length === 0) {
700
+ return;
701
+ }
702
+ this.#ttsrManager?.markInjectedByNames(uniqueRuleNames);
703
+ this.sessionManager.appendTtsrInjection(uniqueRuleNames);
704
+ }
705
+
706
+ #findTtsrAssistantIndex(targetTimestamp: number | undefined): number {
707
+ const messages = this.agent.state.messages;
708
+ for (let i = messages.length - 1; i >= 0; i--) {
709
+ const message = messages[i];
710
+ if (message.role !== "assistant") {
711
+ continue;
712
+ }
713
+ if (targetTimestamp === undefined || message.timestamp === targetTimestamp) {
714
+ return i;
715
+ }
716
+ }
717
+ return -1;
718
+ }
719
+
720
+ #shouldInterruptForTtsrMatch(matchContext: TtsrMatchContext): boolean {
721
+ const mode = this.#ttsrManager?.getSettings().interruptMode ?? "always";
722
+ if (mode === "never") {
723
+ return false;
724
+ }
725
+ if (mode === "prose-only") {
726
+ return matchContext.source === "text" || matchContext.source === "thinking";
727
+ }
728
+ if (mode === "tool-only") {
729
+ return matchContext.source === "tool";
730
+ }
731
+ return true;
732
+ }
733
+
734
+ #queueDeferredTtsrInjectionIfNeeded(assistantMsg: AssistantMessage): void {
735
+ if (this.#ttsrAbortPending || this.#pendingTtsrInjections.length === 0) {
736
+ return;
737
+ }
738
+ if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") {
739
+ this.#pendingTtsrInjections = [];
740
+ return;
741
+ }
742
+
743
+ const injection = this.#getTtsrInjectionContent();
744
+ if (!injection) {
745
+ return;
746
+ }
747
+ this.agent.followUp({
748
+ role: "custom",
749
+ customType: "ttsr-injection",
750
+ content: injection.content,
751
+ display: false,
752
+ details: { rules: injection.rules.map(rule => rule.name) },
753
+ timestamp: Date.now(),
754
+ });
755
+ // Mark as injected after this custom message is delivered and persisted (handled in message_end).
756
+ // followUp() only enqueues; resume on the next tick once streaming settles.
757
+ setTimeout(() => {
758
+ if (this.agent.state.isStreaming || !this.agent.hasQueuedMessages()) {
759
+ return;
760
+ }
761
+ this.agent.continue().catch(() => {});
762
+ }, 0);
763
+ }
764
+
765
+ /** Build TTSR match context for tool call argument deltas. */
766
+ #getTtsrToolMatchContext(message: AgentMessage, contentIndex: number): TtsrMatchContext {
767
+ const context: TtsrMatchContext = { source: "tool" };
768
+ if (message.role !== "assistant") {
769
+ return context;
770
+ }
771
+
772
+ const content = message.content;
773
+ if (!Array.isArray(content) || contentIndex < 0 || contentIndex >= content.length) {
774
+ return context;
775
+ }
776
+
777
+ const block = content[contentIndex];
778
+ if (!block || typeof block !== "object" || block.type !== "toolCall") {
779
+ return context;
780
+ }
781
+
782
+ const toolCall = block as ToolCall;
783
+ context.toolName = toolCall.name;
784
+ context.streamKey = toolCall.id ? `toolcall:${toolCall.id}` : `tool:${toolCall.name}:${contentIndex}`;
785
+ context.filePaths = this.#extractTtsrFilePathsFromArgs(toolCall.arguments);
786
+ return context;
787
+ }
788
+
789
+ /** Extract path-like arguments from tool call payload for TTSR glob matching. */
790
+ #extractTtsrFilePathsFromArgs(args: unknown): string[] | undefined {
791
+ if (!args || typeof args !== "object" || Array.isArray(args)) {
792
+ return undefined;
793
+ }
794
+
795
+ const rawPaths: string[] = [];
796
+ for (const [key, value] of Object.entries(args)) {
797
+ const normalizedKey = key.toLowerCase();
798
+ if (typeof value === "string" && (normalizedKey === "path" || normalizedKey.endsWith("path"))) {
799
+ rawPaths.push(value);
800
+ continue;
801
+ }
802
+ if (Array.isArray(value) && (normalizedKey === "paths" || normalizedKey.endsWith("paths"))) {
803
+ for (const candidate of value) {
804
+ if (typeof candidate === "string") {
805
+ rawPaths.push(candidate);
806
+ }
807
+ }
808
+ }
809
+ }
810
+
811
+ const normalizedPaths = rawPaths.flatMap(pathValue => this.#normalizeTtsrPathCandidates(pathValue));
812
+ if (normalizedPaths.length === 0) {
813
+ return undefined;
814
+ }
815
+
816
+ return Array.from(new Set(normalizedPaths));
817
+ }
818
+
819
+ /** Convert a path argument into stable relative/absolute candidates for glob checks. */
820
+ #normalizeTtsrPathCandidates(rawPath: string): string[] {
821
+ const trimmed = rawPath.trim();
822
+ if (trimmed.length === 0) {
823
+ return [];
824
+ }
825
+
826
+ const normalizedInput = trimmed.replaceAll("\\", "/");
827
+ const candidates = new Set<string>([normalizedInput]);
828
+ if (normalizedInput.startsWith("./")) {
829
+ candidates.add(normalizedInput.slice(2));
830
+ }
831
+
832
+ const cwd = this.sessionManager.getCwd();
833
+ const absolutePath = path.isAbsolute(trimmed) ? path.normalize(trimmed) : path.resolve(cwd, trimmed);
834
+ candidates.add(absolutePath.replaceAll("\\", "/"));
835
+
836
+ const relativePath = path.relative(cwd, absolutePath).replaceAll("\\", "/");
837
+ if (relativePath && relativePath !== "." && !relativePath.startsWith("../") && relativePath !== "..") {
838
+ candidates.add(relativePath);
839
+ }
840
+
841
+ return Array.from(candidates);
842
+ }
635
843
  /** Extract text content from a message */
636
844
  #getUserMessageText(message: Message): string {
637
845
  if (message.role !== "user") return "";