@oh-my-pi/pi-coding-agent 13.5.0 → 13.5.2

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,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.5.2] - 2026-03-01
6
+
7
+ ### Added
8
+
9
+ - Added `checkpoint` tool to create context checkpoints before exploratory work, allowing you to investigate with many intermediate tool calls and minimize context cost afterward
10
+ - Added `rewind` tool to end an active checkpoint and replace intermediate exploration messages with a concise investigation report
11
+ - Added `checkpoint.enabled` setting to control availability of the checkpoint and rewind tools
12
+ - Added `render_mermaid` tool to convert Mermaid graph source into ASCII diagram output
13
+ - Added `renderMermaid.enabled` setting to control availability of the render_mermaid tool
14
+
15
+ ### Changed
16
+
17
+ - Changed Mermaid rendering from PNG images to ASCII diagrams in theme rendering
18
+ - Changed `prerenderMermaid()` function to synchronously render ASCII instead of asynchronously rendering PNG
19
+
5
20
  ## [13.5.0] - 2026-03-01
6
21
 
7
22
  ### Added
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "13.5.0",
4
+ "version": "13.5.2",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -41,12 +41,12 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@mozilla/readability": "^0.6",
44
- "@oh-my-pi/omp-stats": "13.5.0",
45
- "@oh-my-pi/pi-agent-core": "13.5.0",
46
- "@oh-my-pi/pi-ai": "13.5.0",
47
- "@oh-my-pi/pi-natives": "13.5.0",
48
- "@oh-my-pi/pi-tui": "13.5.0",
49
- "@oh-my-pi/pi-utils": "13.5.0",
44
+ "@oh-my-pi/omp-stats": "13.5.2",
45
+ "@oh-my-pi/pi-agent-core": "13.5.2",
46
+ "@oh-my-pi/pi-ai": "13.5.2",
47
+ "@oh-my-pi/pi-natives": "13.5.2",
48
+ "@oh-my-pi/pi-tui": "13.5.2",
49
+ "@oh-my-pi/pi-utils": "13.5.2",
50
50
  "@sinclair/typebox": "^0.34",
51
51
  "@xterm/headless": "^6.0",
52
52
  "ajv": "^8.18",
@@ -460,11 +460,29 @@ export const SETTINGS_SCHEMA = {
460
460
  description: "Enable the ast_edit tool for structural AST rewrites",
461
461
  },
462
462
  },
463
+ "renderMermaid.enabled": {
464
+ type: "boolean",
465
+ default: false,
466
+ ui: {
467
+ tab: "tools",
468
+ label: "Enable Render Mermaid",
469
+ description: "Enable the render_mermaid tool for Mermaid-to-ASCII rendering",
470
+ },
471
+ },
463
472
  "notebook.enabled": {
464
473
  type: "boolean",
465
474
  default: true,
466
475
  ui: { tab: "tools", label: "Enable Notebook", description: "Enable the notebook tool for notebook editing" },
467
476
  },
477
+ "checkpoint.enabled": {
478
+ type: "boolean",
479
+ default: false,
480
+ ui: {
481
+ tab: "tools",
482
+ label: "Enable Checkpoint/Rewind",
483
+ description: "Enable the checkpoint and rewind tools for context checkpointing",
484
+ },
485
+ },
468
486
  "fetch.enabled": {
469
487
  type: "boolean",
470
488
  default: true,
@@ -27,6 +27,11 @@ const DEBUG_MENU_ITEMS: SelectItem[] = [
27
27
  { value: "memory", label: "Report: memory issue", description: "Heap snapshot + bundle" },
28
28
  { value: "logs", label: "View: recent logs", description: "Show last 50 log entries" },
29
29
  { value: "system", label: "View: system info", description: "Show environment details" },
30
+ {
31
+ value: "transcript",
32
+ label: "Export: TUI transcript",
33
+ description: "Write visible TUI conversation to a temp txt",
34
+ },
30
35
  { value: "clear-cache", label: "Clear: artifact cache", description: "Remove old session artifacts" },
31
36
  ];
32
37
 
@@ -95,6 +100,9 @@ export class DebugSelectorComponent extends Container {
95
100
  case "system":
96
101
  await this.#handleViewSystemInfo();
97
102
  break;
103
+ case "transcript":
104
+ await this.#handleTranscriptExport();
105
+ break;
98
106
  case "clear-cache":
99
107
  await this.#handleClearCache();
100
108
  break;
@@ -323,6 +331,9 @@ export class DebugSelectorComponent extends Container {
323
331
  this.ctx.ui.requestRender();
324
332
  }
325
333
 
334
+ async #handleTranscriptExport(): Promise<void> {
335
+ await this.ctx.handleDebugTranscriptCommand();
336
+ }
326
337
  async #handleOpenArtifacts(): Promise<void> {
327
338
  const sessionFile = this.ctx.sessionManager.getSessionFile();
328
339
  if (!sessionFile) {
@@ -28,6 +28,7 @@ import type { AuthStorage } from "../../session/auth-storage";
28
28
  import { createCompactionSummaryMessage } from "../../session/messages";
29
29
  import { outputMeta } from "../../tools/output-meta";
30
30
  import { resolveToCwd } from "../../tools/path-utils";
31
+ import { replaceTabs } from "../../tools/render-utils";
31
32
  import { getChangelogPath, parseChangelog } from "../../utils/changelog";
32
33
  import { openPath } from "../../utils/open";
33
34
 
@@ -70,6 +71,25 @@ export class CommandController {
70
71
  }
71
72
  }
72
73
 
74
+ async handleDebugTranscriptCommand(): Promise<void> {
75
+ try {
76
+ const width = Math.max(1, this.ctx.ui.terminal.columns);
77
+ const renderedLines = this.ctx.chatContainer.render(width).map(line => replaceTabs(Bun.stripANSI(line)));
78
+ const rendered = renderedLines.join("\n").trimEnd();
79
+ if (!rendered) {
80
+ this.ctx.showError("No messages to dump yet.");
81
+ return;
82
+ }
83
+ const tmpPath = path.join(os.tmpdir(), `${Snowflake.next()}-tmp.txt`);
84
+ await Bun.write(tmpPath, `${rendered}\n`);
85
+ this.ctx.showStatus(`Debug transcript written to:\n${tmpPath}`);
86
+ } catch (error: unknown) {
87
+ this.ctx.showError(
88
+ `Failed to write debug transcript: ${error instanceof Error ? error.message : "Unknown error"}`,
89
+ );
90
+ }
91
+ }
92
+
73
93
  async handleShareCommand(): Promise<void> {
74
94
  const tmpFile = path.join(os.tmpdir(), `${Snowflake.next()}.html`);
75
95
  const cleanupTempFile = async () => {
@@ -947,6 +947,10 @@ export class InteractiveMode implements InteractiveModeContext {
947
947
  return this.#commandController.handleDumpCommand();
948
948
  }
949
949
 
950
+ handleDebugTranscriptCommand(): Promise<void> {
951
+ return this.#commandController.handleDebugTranscriptCommand();
952
+ }
953
+
950
954
  handleShareCommand(): Promise<void> {
951
955
  return this.#commandController.handleShareCommand();
952
956
  }
@@ -1,88 +1,48 @@
1
- import {
2
- extractMermaidBlocks,
3
- type MermaidImage,
4
- type MermaidRenderOptions,
5
- renderMermaidToPng,
6
- } from "@oh-my-pi/pi-tui";
7
- import { logger } from "@oh-my-pi/pi-utils";
1
+ import { extractMermaidBlocks, logger, renderMermaidAsciiSafe } from "@oh-my-pi/pi-utils";
8
2
 
9
- const cache = new Map<bigint, MermaidImage>();
10
- const pending = new Map<bigint, Promise<MermaidImage | null>>();
3
+ const cache = new Map<bigint, string>();
11
4
  const failed = new Set<bigint>();
12
5
 
13
- const defaultOptions: MermaidRenderOptions = {
14
- theme: "dark",
15
- backgroundColor: "transparent",
16
- };
17
-
18
6
  let onRenderNeeded: (() => void) | null = null;
19
7
 
20
8
  /**
21
- * Set callback to trigger TUI re-render when mermaid images become available.
9
+ * Set callback to trigger TUI re-render when mermaid ASCII renders become available.
22
10
  */
23
11
  export function setMermaidRenderCallback(callback: (() => void) | null): void {
24
12
  onRenderNeeded = callback;
25
13
  }
26
14
 
27
15
  /**
28
- * Get a pre-rendered mermaid image by hash.
16
+ * Get a pre-rendered mermaid ASCII diagram by hash.
29
17
  * Returns null if not cached or rendering failed.
30
18
  */
31
- export function getMermaidImage(hash: bigint): MermaidImage | null {
19
+ export function getMermaidAscii(hash: bigint): string | null {
32
20
  return cache.get(hash) ?? null;
33
21
  }
34
22
 
35
23
  /**
36
- * Pre-render all mermaid blocks in markdown text.
37
- * Renders in parallel, deduplicates concurrent requests.
38
- * Calls render callback when new images are cached.
24
+ * Render all mermaid blocks in markdown text.
25
+ * Caches results and calls render callback when new diagrams are available.
39
26
  */
40
- export async function prerenderMermaid(
41
- markdown: string,
42
- options: MermaidRenderOptions = defaultOptions,
43
- ): Promise<void> {
27
+ export function prerenderMermaid(markdown: string): void {
44
28
  const blocks = extractMermaidBlocks(markdown);
45
29
  if (blocks.length === 0) return;
46
30
 
47
- const promises: Promise<boolean>[] = [];
31
+ let hasNew = false;
48
32
 
49
33
  for (const { source, hash } of blocks) {
50
34
  if (cache.has(hash) || failed.has(hash)) continue;
51
35
 
52
- let promise = pending.get(hash);
53
- if (!promise) {
54
- promise = renderMermaidToPng(source, options);
55
- pending.set(hash, promise);
36
+ const ascii = renderMermaidAsciiSafe(source);
37
+ if (ascii) {
38
+ cache.set(hash, ascii);
39
+ hasNew = true;
40
+ } else {
41
+ failed.add(hash);
56
42
  }
57
-
58
- promises.push(
59
- promise
60
- .then(image => {
61
- pending.delete(hash);
62
- if (image) {
63
- cache.set(hash, image);
64
- failed.delete(hash);
65
- return true;
66
- }
67
- failed.add(hash);
68
- return false;
69
- })
70
- .catch(error => {
71
- pending.delete(hash);
72
- failed.add(hash);
73
- logger.warn("Mermaid render failed", {
74
- hash,
75
- error: error instanceof Error ? error.message : String(error),
76
- });
77
- return false;
78
- }),
79
- );
80
43
  }
81
44
 
82
- const results = await Promise.all(promises);
83
- const newImages = results.some(added => added);
84
-
85
- if (newImages && onRenderNeeded) {
45
+ if (hasNew && onRenderNeeded) {
86
46
  try {
87
47
  onRenderNeeded();
88
48
  } catch (error) {
@@ -107,5 +67,4 @@ export function hasPendingMermaid(markdown: string): boolean {
107
67
  export function clearMermaidCache(): void {
108
68
  cache.clear();
109
69
  failed.clear();
110
- pending.clear();
111
70
  }
@@ -16,7 +16,7 @@ import chalk from "chalk";
16
16
  import darkThemeJson from "./dark.json" with { type: "json" };
17
17
  import { defaultThemes } from "./defaults";
18
18
  import lightThemeJson from "./light.json" with { type: "json" };
19
- import { getMermaidImage } from "./mermaid-cache";
19
+ import { getMermaidAscii } from "./mermaid-cache";
20
20
 
21
21
  // ============================================================================
22
22
  // Symbol Presets
@@ -2340,7 +2340,7 @@ export function getMarkdownTheme(): MarkdownTheme {
2340
2340
  underline: (text: string) => theme.underline(text),
2341
2341
  strikethrough: (text: string) => chalk.strikethrough(text),
2342
2342
  symbols: getSymbolTheme(),
2343
- getMermaidImage,
2343
+ getMermaidAscii,
2344
2344
  highlightCode: (code: string, lang?: string): string[] => {
2345
2345
  const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
2346
2346
  try {
@@ -154,6 +154,7 @@ export interface InteractiveModeContext {
154
154
  handleChangelogCommand(showFull?: boolean): Promise<void>;
155
155
  handleHotkeysCommand(): void;
156
156
  handleDumpCommand(): Promise<void>;
157
+ handleDebugTranscriptCommand(): Promise<void>;
157
158
  handleClearCommand(): Promise<void>;
158
159
  handleForkCommand(): Promise<void>;
159
160
  handleBashCommand(command: string, excludeFromContext?: boolean): Promise<void>;
@@ -0,0 +1,16 @@
1
+ Creates a context checkpoint before exploratory work so you can later rewind and keep only a concise report.
2
+
3
+ Use this when you need to investigate with many intermediate tool calls (read/grep/find/lsp/etc.) and want to minimize context cost afterward.
4
+
5
+ Rules:
6
+ - You **MUST** call `rewind` before yielding after starting a checkpoint.
7
+ - You **MUST** provide a clear `goal` explaining what you are investigating.
8
+ - You **MUST NOT** call `checkpoint` while another checkpoint is active.
9
+ - Not available in subagents.
10
+
11
+ Typical flow:
12
+ 1. `checkpoint(goal: …)`
13
+ 2. Perform exploratory work
14
+ 3. `rewind(report: …)` with concise findings
15
+
16
+ After rewind, intermediate checkpoint messages are removed from active context and replaced by the report.
@@ -0,0 +1,9 @@
1
+ Convert Mermaid graph source into ASCII diagram output.
2
+
3
+ Parameters:
4
+ - `mermaid` (required): Mermaid graph text to render.
5
+ - `config` (optional): JSON render configuration (spacing and layout options).
6
+ Behavior:
7
+ - Returns ASCII diagram text.
8
+ - Saves full ASCII output to an artifact URL (`artifact://<id>`) when artifact storage is available.
9
+ - Returns an error when the Mermaid input is invalid or rendering fails.
@@ -0,0 +1,13 @@
1
+ Ends an active checkpoint and rewinds context back to that checkpoint, replacing intermediate exploration with your report.
2
+
3
+ Use this immediately after investigative work started with `checkpoint`.
4
+
5
+ Requirements:
6
+ - `report` is **REQUIRED** and must be concise, factual, and actionable.
7
+ - Include key findings, decisions, and any unresolved risks.
8
+ - Do not include raw scratch logs unless essential.
9
+ - You **MUST** call this before yielding if a checkpoint is active.
10
+
11
+ Behavior:
12
+ - If no checkpoint is active, this tool errors.
13
+ - On success, the session rewinds and keeps your report as retained context.
package/src/sdk.ts CHANGED
@@ -806,6 +806,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
806
806
  getCompactContext: () => session.formatCompactContext(),
807
807
  getTodoPhases: () => session.getTodoPhases(),
808
808
  setTodoPhases: phases => session.setTodoPhases(phases),
809
+ getCheckpointState: () => session.getCheckpointState(),
810
+ setCheckpointState: state => session.setCheckpointState(state ?? undefined),
809
811
  allocateOutputArtifact: async toolType => {
810
812
  try {
811
813
  return await sessionManager.allocateArtifactPath(toolType);
@@ -84,6 +84,7 @@ import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with {
84
84
  import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
85
85
  import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
86
86
  import type { SecretObfuscator } from "../secrets/obfuscator";
87
+ import type { CheckpointState } from "../tools/checkpoint";
87
88
  import { outputMeta } from "../tools/output-meta";
88
89
  import { resolveToCwd } from "../tools/path-utils";
89
90
  import type { PendingActionStore } from "../tools/pending-action";
@@ -373,6 +374,8 @@ export class AgentSession {
373
374
  #promptInFlight = false;
374
375
  #obfuscator: SecretObfuscator | undefined;
375
376
  #pendingActionStore: PendingActionStore | undefined;
377
+ #checkpointState: CheckpointState | undefined = undefined;
378
+ #pendingRewindReport: string | undefined = undefined;
376
379
  #promptGeneration = 0;
377
380
  #providerSessionState = new Map<string, ProviderSessionState>();
378
381
 
@@ -513,6 +516,11 @@ export class AgentSession {
513
516
  if (event.type === "turn_end" && this.#ttsrManager) {
514
517
  this.#ttsrManager.incrementMessageCount();
515
518
  }
519
+ if (event.type === "turn_end" && this.#pendingRewindReport) {
520
+ const report = this.#pendingRewindReport;
521
+ this.#pendingRewindReport = undefined;
522
+ await this.#applyRewind(report);
523
+ }
516
524
 
517
525
  // TTSR: Check for pattern matches on assistant text/thinking and tool argument deltas
518
526
  if (event.type === "message_update" && this.#ttsrManager?.hasRules()) {
@@ -674,7 +682,7 @@ export class AgentSession {
674
682
  if (event.message.role === "toolResult") {
675
683
  const { toolName, details, isError, content } = event.message as {
676
684
  toolName?: string;
677
- details?: { path?: string; phases?: TodoPhase[] };
685
+ details?: { path?: string; phases?: TodoPhase[]; report?: string; startedAt?: string };
678
686
  isError?: boolean;
679
687
  content?: Array<TextContent | ImageContent>;
680
688
  };
@@ -704,6 +712,23 @@ export class AgentSession {
704
712
  { deliverAs: "nextTurn" },
705
713
  );
706
714
  }
715
+ if (toolName === "checkpoint" && !isError) {
716
+ const checkpointEntryId = this.sessionManager.getEntries().at(-1)?.id ?? null;
717
+ this.#checkpointState = {
718
+ checkpointMessageCount: this.agent.state.messages.length,
719
+ checkpointEntryId,
720
+ startedAt: details?.startedAt ?? new Date().toISOString(),
721
+ };
722
+ this.#pendingRewindReport = undefined;
723
+ }
724
+ if (toolName === "rewind" && !isError && this.#checkpointState) {
725
+ const detailReport = typeof details?.report === "string" ? details.report.trim() : "";
726
+ const textReport = content?.find(part => part.type === "text")?.text?.trim() ?? "";
727
+ const report = detailReport || textReport;
728
+ if (report.length > 0) {
729
+ this.#pendingRewindReport = report;
730
+ }
731
+ }
707
732
  }
708
733
  }
709
734
 
@@ -723,11 +748,18 @@ export class AgentSession {
723
748
  if (didRetry) return; // Retry was initiated, don't proceed to compaction
724
749
  }
725
750
 
751
+ if (msg.stopReason === "aborted" && this.#checkpointState) {
752
+ this.#checkpointState = undefined;
753
+ this.#pendingRewindReport = undefined;
754
+ }
726
755
  const compactionTask = this.#checkCompaction(msg);
727
756
  this.#trackPostPromptTask(compactionTask);
728
757
  await compactionTask;
729
758
  // Check for incomplete todos (unless there was an error or abort)
730
759
  if (msg.stopReason !== "error" && msg.stopReason !== "aborted") {
760
+ if (this.#enforceRewindBeforeYield()) {
761
+ return;
762
+ }
731
763
  await this.#checkTodoCompletion();
732
764
  }
733
765
  }
@@ -1678,6 +1710,17 @@ export class AgentSession {
1678
1710
  this.#planReferencePath = path;
1679
1711
  }
1680
1712
 
1713
+ getCheckpointState(): CheckpointState | undefined {
1714
+ return this.#checkpointState;
1715
+ }
1716
+
1717
+ setCheckpointState(state: CheckpointState | undefined): void {
1718
+ this.#checkpointState = state;
1719
+ if (!state) {
1720
+ this.#pendingRewindReport = undefined;
1721
+ }
1722
+ }
1723
+
1681
1724
  /**
1682
1725
  * Inject the plan mode context message into the conversation history.
1683
1726
  */
@@ -3238,6 +3281,54 @@ Be thorough - include exact file paths, function names, error messages, and tech
3238
3281
  }
3239
3282
  }
3240
3283
  }
3284
+ #enforceRewindBeforeYield(): boolean {
3285
+ if (!this.#checkpointState || this.#pendingRewindReport) {
3286
+ return false;
3287
+ }
3288
+ const reminder = [
3289
+ "<system-warning>",
3290
+ "You are in an active checkpoint. You MUST call rewind with your investigation findings before yielding. Do NOT yield without completing the checkpoint.",
3291
+ "</system-warning>",
3292
+ ].join("\n");
3293
+ this.agent.appendMessage({
3294
+ role: "developer",
3295
+ content: [{ type: "text", text: reminder }],
3296
+ timestamp: Date.now(),
3297
+ });
3298
+ this.#scheduleAgentContinue({ generation: this.#promptGeneration });
3299
+ return true;
3300
+ }
3301
+
3302
+ async #applyRewind(report: string): Promise<void> {
3303
+ const checkpointState = this.#checkpointState;
3304
+ if (!checkpointState) {
3305
+ return;
3306
+ }
3307
+ const safeCount = Math.max(0, Math.min(checkpointState.checkpointMessageCount, this.agent.state.messages.length));
3308
+ this.agent.replaceMessages(this.agent.state.messages.slice(0, safeCount));
3309
+ try {
3310
+ this.sessionManager.branchWithSummary(checkpointState.checkpointEntryId, report, {
3311
+ startedAt: checkpointState.startedAt,
3312
+ });
3313
+ } catch (error) {
3314
+ logger.warn("Rewind branch checkpoint missing, falling back to root", {
3315
+ error: error instanceof Error ? error.message : String(error),
3316
+ });
3317
+ this.sessionManager.branchWithSummary(null, report, { startedAt: checkpointState.startedAt });
3318
+ }
3319
+ const details = { startedAt: checkpointState.startedAt, rewoundAt: new Date().toISOString() };
3320
+ this.agent.appendMessage({
3321
+ role: "custom",
3322
+ customType: "rewind-report",
3323
+ content: report,
3324
+ display: false,
3325
+ details,
3326
+ timestamp: Date.now(),
3327
+ });
3328
+ this.sessionManager.appendCustomMessageEntry("rewind-report", report, false, details);
3329
+ this.#checkpointState = undefined;
3330
+ this.#pendingRewindReport = undefined;
3331
+ }
3241
3332
  /**
3242
3333
  * Check if agent stopped with incomplete todos and prompt to continue.
3243
3334
  */
@@ -394,7 +394,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
394
394
  },
395
395
  {
396
396
  name: "debug",
397
- description: "Write debug log (TUI state and messages)",
397
+ description: "Open debug tools selector",
398
398
  handle: (_command, runtime) => {
399
399
  runtime.ctx.showDebugSelector();
400
400
  runtime.ctx.editor.setText("");
@@ -0,0 +1,128 @@
1
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
+ import { type Static, Type } from "@sinclair/typebox";
3
+ import { renderPromptTemplate } from "../config/prompt-templates";
4
+ import checkpointDescription from "../prompts/tools/checkpoint.md" with { type: "text" };
5
+ import rewindDescription from "../prompts/tools/rewind.md" with { type: "text" };
6
+ import type { ToolSession } from ".";
7
+ import type { OutputMeta } from "./output-meta";
8
+ import { ToolError } from "./tool-errors";
9
+ import { toolResult } from "./tool-result";
10
+
11
+ export interface CheckpointState {
12
+ /** Number of in-memory messages at checkpoint (AFTER checkpoint tool result is appended) */
13
+ checkpointMessageCount: number;
14
+ /** Session entry ID at checkpoint (for session tree branching) */
15
+ checkpointEntryId: string | null;
16
+ /** Timestamp */
17
+ startedAt: string;
18
+ }
19
+
20
+ const checkpointSchema = Type.Object({
21
+ goal: Type.String({ description: "What you are investigating and why" }),
22
+ });
23
+
24
+ type CheckpointParams = Static<typeof checkpointSchema>;
25
+
26
+ const rewindSchema = Type.Object({
27
+ report: Type.String({ description: "Concise investigation findings to retain after rewind" }),
28
+ });
29
+
30
+ type RewindParams = Static<typeof rewindSchema>;
31
+
32
+ export interface CheckpointToolDetails {
33
+ goal: string;
34
+ startedAt: string;
35
+ meta?: OutputMeta;
36
+ }
37
+
38
+ export interface RewindToolDetails {
39
+ report: string;
40
+ rewound: boolean;
41
+ meta?: OutputMeta;
42
+ }
43
+
44
+ function isTopLevelSession(session: ToolSession): boolean {
45
+ const depth = session.taskDepth;
46
+ return depth === undefined || depth === 0;
47
+ }
48
+
49
+ export class CheckpointTool implements AgentTool<typeof checkpointSchema, CheckpointToolDetails> {
50
+ readonly name = "checkpoint";
51
+ readonly label = "Checkpoint";
52
+ readonly description: string;
53
+ readonly parameters = checkpointSchema;
54
+ readonly strict = true;
55
+
56
+ constructor(private readonly session: ToolSession) {
57
+ this.description = renderPromptTemplate(checkpointDescription);
58
+ }
59
+
60
+ static createIf(session: ToolSession): CheckpointTool | null {
61
+ if (!isTopLevelSession(session)) return null;
62
+ return new CheckpointTool(session);
63
+ }
64
+
65
+ async execute(
66
+ _toolCallId: string,
67
+ params: CheckpointParams,
68
+ _signal?: AbortSignal,
69
+ _onUpdate?: AgentToolUpdateCallback<CheckpointToolDetails>,
70
+ _context?: AgentToolContext,
71
+ ): Promise<AgentToolResult<CheckpointToolDetails>> {
72
+ if (!isTopLevelSession(this.session)) {
73
+ throw new ToolError("Checkpoint not available in subagents.");
74
+ }
75
+ if (this.session.getCheckpointState?.()) {
76
+ throw new ToolError("Checkpoint already active.");
77
+ }
78
+ const startedAt = new Date().toISOString();
79
+ return toolResult<CheckpointToolDetails>({ goal: params.goal, startedAt })
80
+ .text(
81
+ [
82
+ "Checkpoint created.",
83
+ `Goal: ${params.goal}`,
84
+ "Run your investigation, then call rewind with a concise report.",
85
+ ].join("\n"),
86
+ )
87
+ .done();
88
+ }
89
+ }
90
+
91
+ export class RewindTool implements AgentTool<typeof rewindSchema, RewindToolDetails> {
92
+ readonly name = "rewind";
93
+ readonly label = "Rewind";
94
+ readonly description: string;
95
+ readonly parameters = rewindSchema;
96
+ readonly strict = true;
97
+
98
+ constructor(private readonly session: ToolSession) {
99
+ this.description = renderPromptTemplate(rewindDescription);
100
+ }
101
+
102
+ static createIf(session: ToolSession): RewindTool | null {
103
+ if (!isTopLevelSession(session)) return null;
104
+ return new RewindTool(session);
105
+ }
106
+
107
+ async execute(
108
+ _toolCallId: string,
109
+ params: RewindParams,
110
+ _signal?: AbortSignal,
111
+ _onUpdate?: AgentToolUpdateCallback<RewindToolDetails>,
112
+ _context?: AgentToolContext,
113
+ ): Promise<AgentToolResult<RewindToolDetails>> {
114
+ if (!isTopLevelSession(this.session)) {
115
+ throw new ToolError("Checkpoint not available in subagents.");
116
+ }
117
+ if (!this.session.getCheckpointState?.()) {
118
+ throw new ToolError("No active checkpoint.");
119
+ }
120
+ const report = params.report.trim();
121
+ if (report.length === 0) {
122
+ throw new ToolError("Report cannot be empty.");
123
+ }
124
+ return toolResult<RewindToolDetails>({ report, rewound: true })
125
+ .text(["Rewind requested.", "Report captured for context replacement."].join("\n"))
126
+ .done();
127
+ }
128
+ }
@@ -22,6 +22,7 @@ import { BashTool } from "./bash";
22
22
  import { BrowserTool } from "./browser";
23
23
  import { CalculatorTool } from "./calculator";
24
24
  import { CancelJobTool } from "./cancel-job";
25
+ import { type CheckpointState, CheckpointTool, RewindTool } from "./checkpoint";
25
26
  import { ExitPlanModeTool } from "./exit-plan-mode";
26
27
  import { FetchTool } from "./fetch";
27
28
  import { FindTool } from "./find";
@@ -30,6 +31,7 @@ import { NotebookTool } from "./notebook";
30
31
  import { wrapToolWithMetaNotice } from "./output-meta";
31
32
  import { PythonTool } from "./python";
32
33
  import { ReadTool } from "./read";
34
+ import { RenderMermaidTool } from "./render-mermaid";
33
35
  import { ResolveTool } from "./resolve";
34
36
  import { reportFindingTool } from "./review";
35
37
  import { loadSshTool } from "./ssh";
@@ -54,6 +56,7 @@ export * from "./bash";
54
56
  export * from "./browser";
55
57
  export * from "./calculator";
56
58
  export * from "./cancel-job";
59
+ export * from "./checkpoint";
57
60
  export * from "./exit-plan-mode";
58
61
  export * from "./fetch";
59
62
  export * from "./find";
@@ -63,6 +66,7 @@ export * from "./notebook";
63
66
  export * from "./pending-action";
64
67
  export * from "./python";
65
68
  export * from "./read";
69
+ export * from "./render-mermaid";
66
70
  export * from "./resolve";
67
71
  export * from "./review";
68
72
  export * from "./ssh";
@@ -143,6 +147,10 @@ export interface ToolSession {
143
147
  setTodoPhases?: (phases: TodoPhase[]) => void;
144
148
  /** Pending action store for preview/apply workflows */
145
149
  pendingActionStore?: import("./pending-action").PendingActionStore;
150
+ /** Get active checkpoint state if any. */
151
+ getCheckpointState?: () => CheckpointState | undefined;
152
+ /** Set or clear active checkpoint state. */
153
+ setCheckpointState?: (state: CheckpointState | null) => void;
146
154
  }
147
155
 
148
156
  type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool | null>;
@@ -150,6 +158,7 @@ type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool | null>;
150
158
  export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
151
159
  ast_grep: s => new AstGrepTool(s),
152
160
  ast_edit: s => new AstEditTool(s),
161
+ render_mermaid: s => new RenderMermaidTool(s),
153
162
  ask: AskTool.createIf,
154
163
  bash: s => new BashTool(s),
155
164
  python: s => new PythonTool(s),
@@ -162,6 +171,8 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
162
171
  notebook: s => new NotebookTool(s),
163
172
  read: s => new ReadTool(s),
164
173
  browser: s => new BrowserTool(s),
174
+ checkpoint: CheckpointTool.createIf,
175
+ rewind: RewindTool.createIf,
165
176
  task: TaskTool.create,
166
177
  cancel_job: CancelJobTool.createIf,
167
178
  await: AwaitTool.createIf,
@@ -281,12 +292,14 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
281
292
  if (name === "grep") return session.settings.get("grep.enabled");
282
293
  if (name === "ast_grep") return session.settings.get("astGrep.enabled");
283
294
  if (name === "ast_edit") return session.settings.get("astEdit.enabled");
295
+ if (name === "render_mermaid") return session.settings.get("renderMermaid.enabled");
284
296
  if (name === "notebook") return session.settings.get("notebook.enabled");
285
297
  if (name === "fetch") return session.settings.get("fetch.enabled");
286
298
  if (name === "web_search") return session.settings.get("web_search.enabled");
287
299
  if (name === "lsp") return session.settings.get("lsp.enabled");
288
300
  if (name === "calc") return session.settings.get("calc.enabled");
289
301
  if (name === "browser") return session.settings.get("browser.enabled");
302
+ if (name === "checkpoint" || name === "rewind") return session.settings.get("checkpoint.enabled");
290
303
  if (name === "task") {
291
304
  const maxDepth = session.settings.get("task.maxRecursionDepth") ?? 2;
292
305
  const currentDepth = session.taskDepth ?? 0;
@@ -0,0 +1,67 @@
1
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
+ import { type MermaidAsciiRenderOptions, renderMermaidAscii } from "@oh-my-pi/pi-utils";
3
+ import { type Static, Type } from "@sinclair/typebox";
4
+ import { renderPromptTemplate } from "../config/prompt-templates";
5
+ import renderMermaidDescription from "../prompts/tools/render-mermaid.md" with { type: "text" };
6
+ import type { ToolSession } from "./index";
7
+
8
+ const renderMermaidSchema = Type.Object({
9
+ mermaid: Type.String({ description: "Mermaid graph source text" }),
10
+ config: Type.Optional(
11
+ Type.Object({
12
+ useAscii: Type.Optional(Type.Boolean()),
13
+ paddingX: Type.Optional(Type.Number()),
14
+ paddingY: Type.Optional(Type.Number()),
15
+ boxBorderPadding: Type.Optional(Type.Number()),
16
+ }),
17
+ ),
18
+ });
19
+
20
+ type RenderMermaidParams = Static<typeof renderMermaidSchema>;
21
+
22
+ function sanitizeRenderConfig(config: MermaidAsciiRenderOptions | undefined): MermaidAsciiRenderOptions | undefined {
23
+ if (!config) return undefined;
24
+ return {
25
+ useAscii: config.useAscii,
26
+ boxBorderPadding:
27
+ config.boxBorderPadding === undefined ? undefined : Math.max(0, Math.floor(config.boxBorderPadding)),
28
+ paddingX: config.paddingX === undefined ? undefined : Math.max(0, Math.floor(config.paddingX)),
29
+ paddingY: config.paddingY === undefined ? undefined : Math.max(0, Math.floor(config.paddingY)),
30
+ };
31
+ }
32
+ export interface RenderMermaidToolDetails {
33
+ artifactId?: string;
34
+ }
35
+
36
+ export class RenderMermaidTool implements AgentTool<typeof renderMermaidSchema, RenderMermaidToolDetails> {
37
+ readonly name = "render_mermaid";
38
+ readonly label = "RenderMermaid";
39
+ readonly description: string;
40
+ readonly parameters = renderMermaidSchema;
41
+ readonly strict = true;
42
+
43
+ constructor(private readonly session: ToolSession) {
44
+ this.description = renderPromptTemplate(renderMermaidDescription);
45
+ }
46
+
47
+ async execute(
48
+ _toolCallId: string,
49
+ params: RenderMermaidParams,
50
+ _signal?: AbortSignal,
51
+ _onUpdate?: AgentToolUpdateCallback<RenderMermaidToolDetails>,
52
+ _context?: AgentToolContext,
53
+ ): Promise<AgentToolResult<RenderMermaidToolDetails>> {
54
+ const ascii = renderMermaidAscii(params.mermaid, sanitizeRenderConfig(params.config));
55
+ const { path: artifactPath, id: artifactId } =
56
+ (await this.session.allocateOutputArtifact?.("render_mermaid")) ?? {};
57
+ if (artifactPath) {
58
+ await Bun.write(artifactPath, ascii);
59
+ }
60
+
61
+ const artifactLine = artifactId ? `\n\nSaved artifact: artifact://${artifactId}` : "";
62
+ return {
63
+ content: [{ type: "text", text: `${ascii}${artifactLine}` }],
64
+ details: { artifactId },
65
+ };
66
+ }
67
+ }