pi-ui-extend 1.0.35 → 1.0.37

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/README.md CHANGED
@@ -401,6 +401,27 @@ Configurable areas include:
401
401
 
402
402
  Use `/settings` to inspect the effective settings summary and `/reload` after changing resources. Some command-driven settings update the relevant config file directly.
403
403
 
404
+ ### Ignoring legacy context files
405
+
406
+ If a legacy project already contains an `AGENTS.md` or `CLAUDE.md` that should not be loaded, the simplest project-local workaround for both Pi and Pix is an empty override file in the same directory:
407
+
408
+ ```bash
409
+ touch AGENTS.override.md
410
+ ```
411
+
412
+ Pi and Pix load `AGENTS.override.md` instead of `AGENTS.md`/`CLAUDE.md` from that directory. To keep this local without changing the repository:
413
+
414
+ ```bash
415
+ echo AGENTS.override.md >> .git/info/exclude
416
+ ```
417
+
418
+ To disable discovery of all context files, including files in parent directories:
419
+
420
+ - start Pi with `pi --no-context-files` (or `pi -nc`);
421
+ - in Pix, run `/no-context-files on`. Pix saves `"ignoreContextFiles": true` to `<workspace>/.pi/pix.jsonc`.
422
+
423
+ Start a new session or restart Pix after changing this setting. Use `/no-context-files off` to enable context-file loading again.
424
+
404
425
  ## Updates
405
426
 
406
427
  ```bash
@@ -28,10 +28,15 @@ export function renderConversationEntry(entry, width, options) {
28
28
  const lines = renderMarkdownTextLines(userEntry.text, userContentWidth, userContentLeft).map((line) => ({
29
29
  ...userLine(line.text, userEntry.id, line.syntaxHighlight, [
30
30
  ...(line.segments ?? []),
31
+ ...diagramStyledSegments(line.diagramSegments, options.colors),
31
32
  ...(line.links?.map((link) => ({ ...link, foreground: options.colors.info, underline: true })) ?? []),
32
33
  ], line.links),
33
34
  ...(line.copyText === undefined ? {} : { copyText: line.copyText }),
34
35
  ...(line.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
36
+ ...(line.codeBlock ? {
37
+ colorOverride: options.colors.codeBlockForeground,
38
+ backgroundOverride: options.colors.codeBlockBackground,
39
+ } : {}),
35
40
  }));
36
41
  return attachImageClickTargets(lines, userEntry.id, userEntry.images, { foreground: options.colors.info, underline: true });
37
42
  };
@@ -94,13 +99,16 @@ function renderAssistantLines(text, width, options) {
94
99
  const existingSegments = line.segments?.map((segment) => ({ ...segment, start: segment.start + contentLeft, end: segment.end + contentLeft })) ?? [];
95
100
  const links = line.links?.map((link) => ({ ...link, start: link.start + contentLeft, end: link.end + contentLeft })) ?? [];
96
101
  const linkSegments = links.map((link) => ({ start: link.start, end: link.end, foreground: options.colors.info, underline: true }));
97
- const allSegments = headingSegment ? [headingSegment, ...existingSegments, ...linkSegments] : [...existingSegments, ...linkSegments];
102
+ const diagramSegments = diagramStyledSegments(line.diagramSegments, options.colors, contentLeft);
103
+ const allSegments = headingSegment
104
+ ? [headingSegment, ...existingSegments, ...diagramSegments, ...linkSegments]
105
+ : [...existingSegments, ...diagramSegments, ...linkSegments];
98
106
  lines.push({
99
107
  text: padHorizontalText(line.text, width),
100
108
  ...(line.copyText === undefined ? {} : { copyText: line.copyText }),
101
109
  ...(line.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
102
- colorOverride: options.colors.assistantForeground,
103
- backgroundOverride: options.colors.assistantMessageBackground,
110
+ colorOverride: line.codeBlock ? options.colors.codeBlockForeground : options.colors.assistantForeground,
111
+ backgroundOverride: line.codeBlock ? options.colors.codeBlockBackground : options.colors.assistantMessageBackground,
104
112
  ...(allSegments.length > 0 ? { segments: allSegments } : {}),
105
113
  ...(links.length > 0 ? { links } : {}),
106
114
  ...(line.syntaxHighlight ? { syntaxHighlight: line.syntaxHighlight } : {}),
@@ -108,3 +116,17 @@ function renderAssistantLines(text, width, options) {
108
116
  }
109
117
  return lines;
110
118
  }
119
+ function diagramStyledSegments(segments, colors, offset = 0) {
120
+ return (segments ?? []).map((segment) => {
121
+ const range = { start: segment.start + offset, end: segment.end + offset };
122
+ switch (segment.class) {
123
+ case "border":
124
+ case "edgeLabel":
125
+ return { ...range, foreground: colors.muted };
126
+ case "edge":
127
+ return { ...range, foreground: colors.accent };
128
+ case "title":
129
+ return { ...range, foreground: colors.accent, bold: true };
130
+ }
131
+ });
132
+ }
@@ -1,3 +1,4 @@
1
+ import { type Cls as MermaidSpanClass } from "grok-mermaid";
1
2
  import { type SyntaxLineHighlight, type ToolBodySyntaxHighlights } from "./syntax-highlight.js";
2
3
  export type RenderedMarkdownLine = {
3
4
  text: string;
@@ -22,6 +23,7 @@ export type RenderedMarkdownTextLine = {
22
23
  text: string;
23
24
  copyText?: string;
24
25
  continuesOnNextLine?: boolean;
26
+ codeBlock?: true;
25
27
  segments?: readonly {
26
28
  start: number;
27
29
  end: number;
@@ -29,8 +31,14 @@ export type RenderedMarkdownTextLine = {
29
31
  }[] | undefined;
30
32
  links?: readonly RenderedMarkdownLink[] | undefined;
31
33
  syntaxHighlight?: SyntaxLineHighlight | undefined;
34
+ diagramSegments?: readonly RenderedMarkdownDiagramSegment[] | undefined;
32
35
  heading?: boolean;
33
36
  };
37
+ export type RenderedMarkdownDiagramSegment = {
38
+ start: number;
39
+ end: number;
40
+ class: Exclude<MermaidSpanClass, "none" | "text">;
41
+ };
34
42
  export type RenderMarkdownTextLinesOptions = {
35
43
  preserveWrappedWordSeparator?: boolean;
36
44
  };
@@ -1,6 +1,12 @@
1
+ import { render as renderMermaid } from "grok-mermaid";
1
2
  import { displayGraphemes, expandTabs, stringDisplayWidth } from "./terminal-width.js";
2
3
  import { syntaxHighlightLanguageForMarkdownFence, } from "./syntax-highlight.js";
3
4
  const MIN_TRAILING_WORD_WIDTH_TO_REBALANCE = 5;
5
+ const MERMAID_CACHE_LIMIT = 100;
6
+ const MERMAID_MAX_PANELS = 4;
7
+ const MERMAID_MIN_PANEL_WIDTH = 40;
8
+ const MERMAID_SEQUENCE_MESSAGE_OPERATORS = ["-->>", "->>", "--x", "-x", "--)", "-)", "-->", "->"];
9
+ const mermaidRenderCache = new Map();
4
10
  export function formatMarkdownTables(text, maxWidth) {
5
11
  const lines = text.split("\n");
6
12
  const formatted = [];
@@ -85,14 +91,40 @@ export function renderMarkdownLine(text, start = 0) {
85
91
  export function renderMarkdownTextLines(text, width, start = 0, options = {}) {
86
92
  const lines = [];
87
93
  let fence;
94
+ let fenceHasBodyLine = false;
88
95
  const formattedText = formatMarkdownTables(sanitizeMarkdownText(text), width);
89
96
  if (formattedText.length === 0)
90
97
  return [];
91
- for (const rawLine of formattedText.split("\n")) {
98
+ const rawLines = formattedText.split("\n");
99
+ for (let lineIndex = 0; lineIndex < rawLines.length;) {
100
+ const rawLine = rawLines[lineIndex] ?? "";
92
101
  const nextFence = markdownFence(rawLine);
102
+ if (!fence && nextFence && isMermaidFence(nextFence)) {
103
+ const diagram = renderMermaidBlock(rawLines, lineIndex, nextFence, width);
104
+ if (diagram) {
105
+ lines.push(...diagram.lines);
106
+ lineIndex = diagram.nextLineIndex;
107
+ continue;
108
+ }
109
+ }
93
110
  const closesFence = Boolean(fence && nextFence && fence.marker === nextFence.marker && nextFence.length >= fence.length);
94
111
  const opensFence = !fence && nextFence !== undefined;
95
- const syntaxHighlight = markdownLineSyntaxHighlight(fence, Boolean(opensFence || closesFence), start);
112
+ if (opensFence && nextFence) {
113
+ fence = { ...nextFence, language: syntaxHighlightLanguageForMarkdownFence(nextFence.info) };
114
+ fenceHasBodyLine = false;
115
+ lineIndex += 1;
116
+ continue;
117
+ }
118
+ if (closesFence) {
119
+ if (!fenceHasBodyLine)
120
+ lines.push({ text: "", codeBlock: true });
121
+ fence = undefined;
122
+ fenceHasBodyLine = false;
123
+ lineIndex += 1;
124
+ continue;
125
+ }
126
+ const syntaxHighlight = markdownLineSyntaxHighlight(fence, false, start);
127
+ const codeBlock = fence !== undefined;
96
128
  const isHeadingLine = !fence && /^\s{0,3}#{1,6}\s/.test(rawLine);
97
129
  const markdownLine = syntaxHighlight?.language === "markdown" || isHeadingLine ? renderMarkdownLine(rawLine) : undefined;
98
130
  const logicalLine = markdownLine ?? { text: rawLine, segments: [], links: [] };
@@ -113,21 +145,245 @@ export function renderMarkdownTextLines(text, width, start = 0, options = {}) {
113
145
  text: wrapped.text,
114
146
  ...(wrapped.copyText === undefined ? {} : { copyText: wrapped.copyText }),
115
147
  ...(wrapped.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
148
+ ...(codeBlock ? { codeBlock: true } : {}),
116
149
  ...(wrapped.segments.length > 0 ? { segments: wrapped.segments } : {}),
117
150
  ...(wrapped.links && wrapped.links.length > 0 ? { links: wrapped.links } : {}),
118
151
  ...(wrappedSyntaxHighlight ? { syntaxHighlight: wrappedSyntaxHighlight } : {}),
119
152
  ...(isHeadingLine ? { heading: true } : {}),
120
153
  });
121
154
  }
122
- if (opensFence && nextFence) {
123
- fence = { ...nextFence, language: syntaxHighlightLanguageForMarkdownFence(nextFence.info) };
155
+ if (codeBlock)
156
+ fenceHasBodyLine = true;
157
+ lineIndex += 1;
158
+ }
159
+ if (fence && !fenceHasBodyLine)
160
+ lines.push({ text: "", codeBlock: true });
161
+ return lines;
162
+ }
163
+ function renderMermaidBlock(rawLines, openingLineIndex, openingFence, width) {
164
+ let closingLineIndex;
165
+ for (let index = openingLineIndex + 1; index < rawLines.length; index += 1) {
166
+ const candidate = markdownFence(rawLines[index] ?? "");
167
+ if (candidate && candidate.marker === openingFence.marker && candidate.length >= openingFence.length) {
168
+ closingLineIndex = index;
169
+ break;
124
170
  }
125
- else if (closesFence) {
126
- fence = undefined;
171
+ }
172
+ const sourceEnd = closingLineIndex ?? rawLines.length;
173
+ const source = rawLines.slice(openingLineIndex + 1, sourceEnd).join("\n");
174
+ let art = cachedMermaidRender(source);
175
+ if (!art)
176
+ return undefined;
177
+ const availableWidth = Math.max(1, width);
178
+ let participantLegend = [];
179
+ let messageLegend = [];
180
+ if (art.width > availableWidth) {
181
+ const compactSequence = compactMermaidSequence(source);
182
+ if (compactSequence) {
183
+ const compactArt = cachedMermaidRender(compactSequence.source);
184
+ if (compactArt && compactArt.width < art.width) {
185
+ art = compactArt;
186
+ participantLegend = compactSequence.participantLegend;
187
+ messageLegend = compactSequence.messageLegend;
188
+ }
189
+ }
190
+ }
191
+ const panelStarts = mermaidPanelStarts(art.width, availableWidth);
192
+ if (panelStarts.length > 1 && (availableWidth < MERMAID_MIN_PANEL_WIDTH || panelStarts.length > MERMAID_MAX_PANELS)) {
193
+ return undefined;
194
+ }
195
+ return {
196
+ lines: [
197
+ ...mermaidArtLines(art, availableWidth, panelStarts),
198
+ ...mermaidLegendLines("Participants", participantLegend, availableWidth),
199
+ ...mermaidLegendLines("Messages", messageLegend, availableWidth),
200
+ ],
201
+ nextLineIndex: closingLineIndex === undefined ? rawLines.length : closingLineIndex + 1,
202
+ };
203
+ }
204
+ function compactMermaidSequence(source) {
205
+ const lines = source.split("\n");
206
+ const header = lines.find((line) => line.trim().length > 0 && !line.trimStart().startsWith("%%"));
207
+ if (!header || !/^sequenceDiagram\s*$/iu.test(header.trim()))
208
+ return undefined;
209
+ const participantLegend = [];
210
+ const messageLegend = [];
211
+ const autonumber = lines.some((line) => /^\s*autonumber(?:\s|$)/iu.test(line));
212
+ let participantNumber = 0;
213
+ let messageNumber = 0;
214
+ const compactLines = lines.map((line) => {
215
+ const match = /^(\s*)(participant|actor)\s+(\S+?)(?:\s+as\s+(.+?))?\s*$/iu.exec(line);
216
+ if (match) {
217
+ const [, indent = "", declaration = "participant", id = "", rawLabel] = match;
218
+ participantNumber += 1;
219
+ const marker = `P${participantNumber}`;
220
+ const label = rawLabel ? normalizeMermaidLegendLabel(rawLabel) : id;
221
+ participantLegend.push(`${marker} (${id}) — ${label}`);
222
+ return `${indent}${declaration} ${id} as ${marker}`;
223
+ }
224
+ const message = mermaidSequenceMessage(line);
225
+ if (!message)
226
+ return line;
227
+ messageNumber += 1;
228
+ if (message.label.length === 0)
229
+ return line;
230
+ messageLegend.push(`${messageNumber}. ${normalizeMermaidLegendLabel(message.label)}`);
231
+ return autonumber ? message.prefix : `${message.prefix}: [${messageNumber}]`;
232
+ });
233
+ return participantLegend.length > 0 || messageLegend.length > 0
234
+ ? { source: compactLines.join("\n"), participantLegend, messageLegend }
235
+ : undefined;
236
+ }
237
+ function mermaidSequenceMessage(line) {
238
+ for (const operator of MERMAID_SEQUENCE_MESSAGE_OPERATORS) {
239
+ const operatorIndex = line.indexOf(operator);
240
+ if (operatorIndex < 0)
241
+ continue;
242
+ const colonIndex = line.indexOf(":", operatorIndex + operator.length);
243
+ const targetEnd = colonIndex < 0 ? line.length : colonIndex;
244
+ const from = line.slice(0, operatorIndex).trim();
245
+ const to = line.slice(operatorIndex + operator.length, targetEnd).trim();
246
+ if (!from || !to || /\s/u.test(from) || /\s/u.test(to))
247
+ return undefined;
248
+ return {
249
+ prefix: line.slice(0, targetEnd).trimEnd(),
250
+ label: colonIndex < 0 ? "" : line.slice(colonIndex + 1).trim(),
251
+ };
252
+ }
253
+ return undefined;
254
+ }
255
+ function normalizeMermaidLegendLabel(label) {
256
+ const normalized = label
257
+ .replace(/<br\s*\/?\s*>/giu, " ")
258
+ .replace(/<\/?[A-Za-z][^>]*>/gu, "")
259
+ .replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, "$1$2")
260
+ .trim();
261
+ return normalized || label.trim();
262
+ }
263
+ function mermaidPanelStarts(artWidth, panelWidth) {
264
+ if (artWidth <= panelWidth)
265
+ return [0];
266
+ const overlap = Math.min(24, Math.max(8, Math.floor(panelWidth / 5)));
267
+ const stride = Math.max(1, panelWidth - overlap);
268
+ const panelCount = 1 + Math.ceil((artWidth - panelWidth) / stride);
269
+ return Array.from({ length: panelCount }, (_, index) => Math.min(index * stride, artWidth - panelWidth));
270
+ }
271
+ function mermaidArtLines(art, width, panelStarts) {
272
+ if (panelStarts.length === 1) {
273
+ return art.plain.map((text, rowIndex) => {
274
+ const diagramSegments = mermaidDiagramSegments(text, art.styled[rowIndex] ?? []);
275
+ return { text, ...(diagramSegments.length > 0 ? { diagramSegments } : {}) };
276
+ });
277
+ }
278
+ const lines = [];
279
+ for (const [panelIndex, startColumn] of panelStarts.entries()) {
280
+ const continuesLeft = startColumn > 0;
281
+ const continuesRight = startColumn + width < art.width;
282
+ const title = `Mermaid ${panelIndex + 1}/${panelStarts.length}${continuesLeft ? " ←" : ""}${continuesRight ? " →" : ""}`;
283
+ lines.push({
284
+ text: title,
285
+ diagramSegments: [{ start: 0, end: title.length, class: "title" }],
286
+ });
287
+ for (const [rowIndex, text] of art.plain.entries()) {
288
+ lines.push(sliceMermaidDiagramRow(text, art.styled[rowIndex] ?? [], startColumn, width));
289
+ }
290
+ }
291
+ return lines;
292
+ }
293
+ function mermaidLegendLines(title, entries, width) {
294
+ if (entries.length === 0)
295
+ return [];
296
+ const lines = [{
297
+ text: title,
298
+ diagramSegments: [{ start: 0, end: title.length, class: "title" }],
299
+ }];
300
+ for (const entry of entries) {
301
+ for (const wrapped of wrapRenderedMarkdownLine({ text: entry, segments: [], links: [] }, width, {})) {
302
+ lines.push({
303
+ text: wrapped.text,
304
+ ...(wrapped.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
305
+ });
127
306
  }
128
307
  }
129
308
  return lines;
130
309
  }
310
+ function sliceMermaidDiagramRow(text, spans, startColumn, width) {
311
+ const endColumn = startColumn + width;
312
+ const diagramSegments = [];
313
+ let result = "";
314
+ let column = 0;
315
+ let spanIndex = 0;
316
+ let spanEnd = spans[0]?.text.length ?? 0;
317
+ for (const grapheme of displayGraphemes(text)) {
318
+ const nextColumn = column + grapheme.width;
319
+ if (nextColumn <= startColumn) {
320
+ column = nextColumn;
321
+ continue;
322
+ }
323
+ if (column >= endColumn)
324
+ break;
325
+ while (spanIndex < spans.length - 1 && grapheme.start >= spanEnd) {
326
+ spanIndex += 1;
327
+ spanEnd += spans[spanIndex]?.text.length ?? 0;
328
+ }
329
+ const fullyVisible = column >= startColumn && nextColumn <= endColumn;
330
+ const chunk = fullyVisible
331
+ ? grapheme.text
332
+ : " ".repeat(Math.max(0, Math.min(nextColumn, endColumn) - Math.max(column, startColumn)));
333
+ const chunkStart = result.length;
334
+ result += chunk;
335
+ const spanClass = spans[spanIndex]?.cls;
336
+ if (fullyVisible && spanClass && spanClass !== "none" && spanClass !== "text") {
337
+ const previous = diagramSegments.at(-1);
338
+ if (previous && previous.class === spanClass && previous.end === chunkStart) {
339
+ previous.end = result.length;
340
+ }
341
+ else {
342
+ diagramSegments.push({ start: chunkStart, end: result.length, class: spanClass });
343
+ }
344
+ }
345
+ column = nextColumn;
346
+ }
347
+ return { text: result, ...(diagramSegments.length > 0 ? { diagramSegments } : {}) };
348
+ }
349
+ function cachedMermaidRender(source) {
350
+ if (mermaidRenderCache.has(source)) {
351
+ const cached = mermaidRenderCache.get(source) ?? null;
352
+ mermaidRenderCache.delete(source);
353
+ mermaidRenderCache.set(source, cached);
354
+ return cached;
355
+ }
356
+ let art;
357
+ try {
358
+ art = renderMermaid(source.replace(/<br\s*\/?\s*>/giu, " "));
359
+ }
360
+ catch {
361
+ art = null;
362
+ }
363
+ mermaidRenderCache.set(source, art);
364
+ if (mermaidRenderCache.size > MERMAID_CACHE_LIMIT) {
365
+ const oldestKey = mermaidRenderCache.keys().next().value;
366
+ if (oldestKey !== undefined)
367
+ mermaidRenderCache.delete(oldestKey);
368
+ }
369
+ return art;
370
+ }
371
+ function mermaidDiagramSegments(text, spans) {
372
+ const segments = [];
373
+ let start = 0;
374
+ for (const span of spans) {
375
+ const end = start + span.text.length;
376
+ if (start < text.length && span.cls !== "none" && span.cls !== "text") {
377
+ segments.push({ start, end: Math.min(end, text.length), class: span.cls });
378
+ }
379
+ start = end;
380
+ }
381
+ return segments;
382
+ }
383
+ function isMermaidFence(fence) {
384
+ const token = fence.info.trim().split(/\s+/, 1)[0]?.toLowerCase().replace(/^[{.]+|[}.]+$/gu, "") ?? "";
385
+ return token === "mermaid";
386
+ }
131
387
  export function markdownSyntaxHighlightsForText(text, startColumn = 0) {
132
388
  const highlights = [];
133
389
  let fence;
package/dist/theme.d.ts CHANGED
@@ -18,6 +18,8 @@ export type Theme = {
18
18
  assistantMessageBackground: string;
19
19
  userMessageBackground: string;
20
20
  thinkingMessageBackground: string;
21
+ codeBlockForeground: string;
22
+ codeBlockBackground: string;
21
23
  inputCursorBackground: string;
22
24
  popupForeground: string;
23
25
  popupBackground: string;
package/dist/theme.js CHANGED
@@ -19,6 +19,8 @@ export const THEMES = {
19
19
  assistantMessageBackground: "",
20
20
  userMessageBackground: "",
21
21
  thinkingMessageBackground: "",
22
+ codeBlockForeground: "#e6edf3",
23
+ codeBlockBackground: "#2a2f36",
22
24
  inputCursorBackground: "#7fb3c8",
23
25
  popupForeground: "#e6edf3",
24
26
  popupBackground: "#1e1e1e",
@@ -75,6 +77,8 @@ export const THEMES = {
75
77
  assistantMessageBackground: "",
76
78
  userMessageBackground: "",
77
79
  thinkingMessageBackground: "",
80
+ codeBlockForeground: "#0f172a",
81
+ codeBlockBackground: "#f1f5f9",
78
82
  inputCursorBackground: "#0284c7",
79
83
  popupForeground: "#0f172a",
80
84
  popupBackground: "#ffffff",
@@ -11,6 +11,7 @@ This package keeps shared Pi tools as ordinary source folders under `src/` and r
11
11
  - `src/lsp` — shared LSP diagnostics hook/library that enriches mutating tool results with diagnostics and shuts down language servers on session shutdown
12
12
  - `src/comment-checker` — AI-slop comment guard that listens to the `tool_result` event for `write` / `edit` / `apply_patch` mutations, extracts net-new code comment lines, classifies them (filler phrasing, restating code, decorative separators, generic paraphrasing, or — under aggressive strictness — any non-valuable comment), and appends a short nudge to the tool result so the agent removes unnecessary comments on its next turn; TODO/FIXME, license headers, docstrings, pragmas, linter directives, shebangs, and decorators are never flagged; language-agnostic across `//` / `/* */` / `#` / `--` / `<!-- -->` / triple-quote comment styles; per-session deduplication (at most one nudge per 30 s) prevents fix/remark loops; configured via the `commentChecker` section (`enabled`, `strictness`: `conservative` | `balanced` | `aggressive`, default `balanced`) or `PI_COMMENT_CHECKER_ENABLED` / `PI_COMMENT_CHECKER_STRICTNESS`
13
13
  - `src/session-name` — `session_name` tool for reading or setting the current session title directly from tool calls, without relying on slash-command parsing
14
+ - `src/session-recovery` — branch- and compaction-aware `session_overview`, `session_read_section`, `session_search`, and `session_recovery_context` tools for bounded recovery from Pi's raw append-only session history
14
15
  - `src/repo-discovery` — `/idx-init`, `/idx-update`, and indexed-only `repo_architecture` / `repo_structure` / `repo_ast` / `repo_search` / `repo_explain` / `repo_deps`; tools register only when the launch project has `.indexer-cli`
15
16
  - `src/antigravity-auth` — `antigravity` custom provider with Google Antigravity OAuth login, startup account list, auth.json-only runtime account loading, `/antigravity-add-account` OAuth append into rotation, `/antigravity-account` status display, account rotation/failover, Antigravity plus Gemini CLI model registration, and streaming through the Cloud Code Assist unified gateway
16
17
  - `src/opencode-import` — `/opencode-import` for bounded migration of supported OpenCode OpenAI/Codex, GitHub Copilot, Z.ai, and Antigravity credentials into Pi; existing entries are preserved unless `--force` is passed
@@ -24,7 +25,19 @@ This package keeps shared Pi tools as ordinary source folders under `src/` and r
24
25
 
25
26
  `index.ts` is intentionally only a thin auto-discovery shim that re-exports `src/index.ts`. There is no `pi.extensions` manifest here, so local Pi auto-discovery loads the suite once via `~/.pi/agent/extensions/pi-tools-suite/index.ts` and does not double-register tools.
26
27
 
27
- Registration order is preserved in `src/index.ts`: coding-discipline, ast-grep, async-subagents, lsp, comment-checker, session-name, repo-discovery command/tool gate, antigravity-auth provider, OpenCode import, todo, model-tools, usage, web-search, dcp, prompt-commands, skill-installer, credential-firewall, then codex-reasoning-fix. Tool metadata and active model-specific tool sets have two modes: standard and repo-aware. When `.indexer-cli` enables `repo_*`, those tools stay active ahead of overlapping lower-level aliases so the indexed discovery surface has priority.
28
+ Registration order is preserved in `src/index.ts`: coding-discipline, ast-grep, async-subagents, lsp, comment-checker, session-name, session-recovery, repo-discovery command/tool gate, antigravity-auth provider, OpenCode import, todo, model-tools, usage, web-search, dcp, prompt-commands, skill-installer, credential-firewall, then codex-reasoning-fix. Tool metadata and active model-specific tool sets have two modes: standard and repo-aware. When `.indexer-cli` enables `repo_*`, those tools stay active ahead of overlapping lower-level aliases so the indexed discovery surface has priority.
29
+
30
+ ## Session recovery
31
+
32
+ When context compaction obscures the task, start with `session_overview`, inspect a
33
+ relevant ID with `session_read_section`, and use `session_search` once a concrete
34
+ phrase, path, symbol, tool, or error is known. `session_recovery_context` is the
35
+ compact convenience view for original/latest user instructions, file evidence,
36
+ recent errors, pending calls, and the last meaningful action. All four tools read
37
+ through Pi's active `SessionManager`; they do not accept arbitrary session paths.
38
+ They default to the active branch, while `scope: "all"` includes abandoned branches.
39
+ See [`docs/session-recovery.md`](docs/session-recovery.md) for the full contract and
40
+ limits.
28
41
 
29
42
  ## Disabling modules
30
43
 
@@ -0,0 +1,84 @@
1
+ # Session recovery tools
2
+
3
+ Status: implemented MVP contract (semantic search is intentionally deferred).
4
+
5
+ ## Goal
6
+
7
+ Let an agent recover the task, recent instructions, file activity, and useful raw
8
+ session history after context compaction. Recovery reads the session already
9
+ owned by Pi's `SessionManager`; it never opens an arbitrary session path.
10
+
11
+ ## Scope
12
+
13
+ The `session-recovery` module registers four read-only, headless tools:
14
+
15
+ - `session_overview` maps a session into stable, bounded sections.
16
+ - `session_read_section` renders one section by ID.
17
+ - `session_search` performs bounded lexical search over raw entries.
18
+ - `session_recovery_context` summarizes deterministic recovery signals.
19
+
20
+ Every tool defaults to the active root-to-leaf branch. `scope: "all"` includes
21
+ abandoned branches through `SessionManager.getEntries()`. Both modes use raw
22
+ append-only entries, not `buildContextEntries()`, so content hidden from the
23
+ active model context by compaction remains discoverable.
24
+
25
+ ## Contracts
26
+
27
+ ### Sections
28
+
29
+ A section starts at the first selected entry, a user message, a compaction, or a
30
+ branch summary. Its stable ID is derived from the start entry ID. The overview
31
+ reports bounded head and tail sections with entry ranges, counts, and compact
32
+ role/tool/error/file statistics. Labels are previews, not inferred decisions.
33
+
34
+ ### Reading and search
35
+
36
+ `session_read_section` requires a section ID produced for the same scope. It
37
+ renders message roles and text, tool calls and arguments, tool results,
38
+ compaction summaries, and branch summaries with per-entry and total output
39
+ limits.
40
+
41
+ `session_search` is case-insensitive by default and searches message text, tool
42
+ arguments/results, custom-message content, and compaction or branch summaries.
43
+ It returns entry and section IDs plus bounded snippets. Regex and semantic
44
+ search are out of scope for the MVP.
45
+
46
+ ### Recovery context
47
+
48
+ `session_recovery_context` reports only evidence that can be derived
49
+ deterministically: the original and latest user messages, recent tool errors,
50
+ unmatched tool calls, read and modified files, the last meaningful action, and
51
+ compaction count. It calls errors `recentErrors`; it does not claim that they
52
+ remain unresolved. It does not infer decisions. The currently executing
53
+ recovery tool call is excluded from unmatched-call reporting.
54
+
55
+ File activity comes from recognized tool calls and Pi-generated compaction or
56
+ branch-summary details. Read and modified paths remain separate, and unknown
57
+ tools are not guessed to be mutations.
58
+
59
+ ## Limits and edge cases
60
+
61
+ - Results use small defaults and hard caps for result count, entry body size,
62
+ and total text size.
63
+ - Empty or in-memory sessions return a normal explanatory result.
64
+ - Unknown or partially shaped entries are ignored or rendered conservatively.
65
+ - Concurrent sibling tool results might not yet be visible when recovery runs.
66
+ - A section ID is stable while its start entry ID is stable, but scope changes
67
+ can change section membership.
68
+ - Parent-session metadata is reported when Pi exposes it; parent files are not
69
+ traversed.
70
+
71
+ ## Verification
72
+
73
+ Deterministic tests cover active versus all branches, raw pre-compaction search,
74
+ stable section IDs, Unicode case-insensitive search, bounded output, empty
75
+ sessions, current-call exclusion, recent errors, file carry-forward details,
76
+ and conservative handling of unknown entries. Release verification runs the
77
+ suite typecheck/tests/smoke gate, host `npm run check`, and then syncs the suite
78
+ with `npm run sync:pi-tools-suite`.
79
+
80
+ ## Evidence
81
+
82
+ Evidence is recorded by the implementation tests in
83
+ `test/session-recovery.test.ts` and the verification commands reported with the
84
+ change.
@@ -16,6 +16,7 @@ export const MODULES: Array<{ name: string; load: () => Promise<ExtensionModule>
16
16
  { name: "lsp", load: () => import("./lsp/index") },
17
17
  { name: "comment-checker", load: () => import("./comment-checker/index") },
18
18
  { name: "session-name", load: () => import("./session-name/index") },
19
+ { name: "session-recovery", load: () => import("./session-recovery/index") },
19
20
  { name: "repo-discovery", load: () => import("./repo-discovery/index") },
20
21
  { name: "antigravity-auth", load: () => import("./antigravity-auth/index") },
21
22
  { name: "opencode-import", load: () => import("./opencode-import/index") },