pi-ui-extend 1.0.35 → 1.0.36

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
@@ -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") },
@@ -0,0 +1,674 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { StringEnum } from "@earendil-works/pi-ai";
3
+ import { Type } from "typebox";
4
+
5
+ import { SESSION_RECOVERY_TOOL_DESCRIPTIONS } from "../tool-descriptions.js";
6
+
7
+ type Scope = "active" | "all";
8
+ type UnknownRecord = Record<string, unknown>;
9
+
10
+ type SessionManagerLike = {
11
+ getBranch?: () => unknown;
12
+ getEntries?: () => unknown;
13
+ getHeader?: () => unknown;
14
+ getSessionFile?: () => unknown;
15
+ getSessionId?: () => unknown;
16
+ getSessionName?: () => unknown;
17
+ };
18
+
19
+ type EntryLike = UnknownRecord & {
20
+ id: string;
21
+ type: string;
22
+ parentId?: string | null;
23
+ timestamp?: string;
24
+ };
25
+
26
+ type ToolCallLike = {
27
+ id: string;
28
+ name: string;
29
+ arguments: unknown;
30
+ entryId: string;
31
+ };
32
+
33
+ type FileEvidence = {
34
+ readFiles: string[];
35
+ modifiedFiles: string[];
36
+ };
37
+
38
+ type Section = {
39
+ id: string;
40
+ startEntryId: string;
41
+ endEntryId: string;
42
+ entries: EntryLike[];
43
+ label: string;
44
+ };
45
+
46
+ const SCOPE_SCHEMA = StringEnum(["active", "all"] as const, {
47
+ description: "Raw session scope. Defaults to the active root-to-leaf branch; all includes abandoned branches.",
48
+ });
49
+
50
+ const MAX_OUTPUT_CHARS = 30_000;
51
+ const DEFAULT_BODY_CHARS = 2_000;
52
+ const MAX_BODY_CHARS = 8_000;
53
+ const DEFAULT_SECTION_ENTRIES = 20;
54
+ const MAX_SECTION_ENTRIES = 50;
55
+ const DEFAULT_SEARCH_RESULTS = 20;
56
+ const MAX_SEARCH_RESULTS = 50;
57
+ const DEFAULT_OVERVIEW_SECTIONS = 20;
58
+ const MAX_OVERVIEW_SECTIONS = 100;
59
+ const DEFAULT_RECENT_ERRORS = 5;
60
+ const MAX_RECENT_ERRORS = 20;
61
+ const MAX_RECOVERY_FILES = 200;
62
+ const MAX_PENDING_TOOL_CALLS = 50;
63
+ const MAX_SEARCH_QUERY_CHARS = 500;
64
+
65
+ const READ_TOOL_NAMES = new Set([
66
+ "read",
67
+ "grep",
68
+ "glob",
69
+ "find",
70
+ "ls",
71
+ "ast_grep",
72
+ "repo_architecture",
73
+ "repo_structure",
74
+ "repo_ast",
75
+ "repo_search",
76
+ "repo_explain",
77
+ "repo_deps",
78
+ ]);
79
+
80
+ const MUTATING_TOOL_NAMES = new Set(["write", "edit", "apply_patch", "ast_apply"]);
81
+
82
+ function isRecord(value: unknown): value is UnknownRecord {
83
+ return typeof value === "object" && value !== null && !Array.isArray(value);
84
+ }
85
+
86
+ function isEntry(value: unknown): value is EntryLike {
87
+ return isRecord(value) && typeof value.id === "string" && value.id.length > 0
88
+ && typeof value.type === "string" && value.type.length > 0;
89
+ }
90
+
91
+ function clampInteger(value: unknown, fallback: number, minimum: number, maximum: number): number {
92
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
93
+ return Math.min(maximum, Math.max(minimum, Math.floor(value)));
94
+ }
95
+
96
+ function scopeFrom(value: unknown): Scope {
97
+ return value === "all" ? "all" : "active";
98
+ }
99
+
100
+ function callSafely<T>(operation: (() => unknown) | undefined, fallback: T): T {
101
+ if (!operation) return fallback;
102
+ try {
103
+ return operation() as T;
104
+ } catch {
105
+ return fallback;
106
+ }
107
+ }
108
+
109
+ function sessionManagerFrom(context: unknown): SessionManagerLike | undefined {
110
+ if (!isRecord(context) || !isRecord(context.sessionManager)) return undefined;
111
+ return context.sessionManager as SessionManagerLike;
112
+ }
113
+
114
+ function entriesFor(manager: SessionManagerLike | undefined, scope: Scope): EntryLike[] {
115
+ if (!manager) return [];
116
+ const raw = callSafely<unknown>(
117
+ scope === "all"
118
+ ? () => manager.getEntries?.()
119
+ : () => manager.getBranch?.(),
120
+ [],
121
+ );
122
+ return Array.isArray(raw) ? raw.filter(isEntry) : [];
123
+ }
124
+
125
+ function messageFrom(entry: EntryLike): UnknownRecord | undefined {
126
+ return entry.type === "message" && isRecord(entry.message) ? entry.message : undefined;
127
+ }
128
+
129
+ function messageRole(entry: EntryLike): string | undefined {
130
+ const role = messageFrom(entry)?.role;
131
+ return typeof role === "string" ? role : undefined;
132
+ }
133
+
134
+ function textFromContent(content: unknown): string {
135
+ if (typeof content === "string") return content;
136
+ if (!Array.isArray(content)) return "";
137
+
138
+ return content
139
+ .filter((part) => isRecord(part) && part.type === "text" && typeof part.text === "string")
140
+ .map((part) => (part as UnknownRecord).text as string)
141
+ .join("\n");
142
+ }
143
+
144
+ function toolCallsFrom(entry: EntryLike): ToolCallLike[] {
145
+ const message = messageFrom(entry);
146
+ if (!message || !Array.isArray(message.content)) return [];
147
+
148
+ const calls: ToolCallLike[] = [];
149
+ for (const part of message.content) {
150
+ if (!isRecord(part) || part.type !== "toolCall" || typeof part.id !== "string" || typeof part.name !== "string") continue;
151
+ calls.push({ id: part.id, name: part.name, arguments: part.arguments, entryId: entry.id });
152
+ }
153
+ return calls;
154
+ }
155
+
156
+ function toolResultFrom(entry: EntryLike): UnknownRecord | undefined {
157
+ const message = messageFrom(entry);
158
+ return message?.role === "toolResult" ? message : undefined;
159
+ }
160
+
161
+ function summaryFrom(entry: EntryLike): string {
162
+ if ((entry.type === "compaction" || entry.type === "branch_summary") && typeof entry.summary === "string") {
163
+ return entry.summary;
164
+ }
165
+ return "";
166
+ }
167
+
168
+ function entryText(entry: EntryLike): string {
169
+ const message = messageFrom(entry);
170
+ const chunks: string[] = [];
171
+
172
+ if (message) {
173
+ const text = textFromContent(message.content);
174
+ if (text) chunks.push(text);
175
+ for (const call of toolCallsFrom(entry)) {
176
+ chunks.push(call.name, serializeJson(call.arguments));
177
+ }
178
+ } else if (entry.type === "custom_message") {
179
+ const text = textFromContent(entry.content);
180
+ if (text) chunks.push(text);
181
+ }
182
+
183
+ const summary = summaryFrom(entry);
184
+ if (summary) chunks.push(summary);
185
+ return chunks.join("\n");
186
+ }
187
+
188
+ function preview(value: string, maximum = 120): string {
189
+ const singleLine = value.replace(/\s+/g, " ").trim();
190
+ if (singleLine.length <= maximum) return singleLine;
191
+ return `${singleLine.slice(0, Math.max(0, maximum - 1))}…`;
192
+ }
193
+
194
+ function truncate(value: string, maximum: number): string {
195
+ if (value.length <= maximum) return value;
196
+ return `${value.slice(0, Math.max(0, maximum - 20))}\n…[truncated]`;
197
+ }
198
+
199
+ function safeJson(value: unknown, maximum = 2_000): string {
200
+ return truncate(serializeJson(value), maximum);
201
+ }
202
+
203
+ function serializeJson(value: unknown): string {
204
+ try {
205
+ const json = JSON.stringify(value);
206
+ return json ?? String(value);
207
+ } catch {
208
+ return "[unserializable]";
209
+ }
210
+ }
211
+
212
+ function sectionLabel(entry: EntryLike): string {
213
+ const role = messageRole(entry);
214
+ const text = entryText(entry);
215
+ if (role === "user") return preview(text) || "User message";
216
+ if (entry.type === "compaction") return `Compaction: ${preview(text) || entry.id}`;
217
+ if (entry.type === "branch_summary") return `Branch summary: ${preview(text) || entry.id}`;
218
+ return preview(text) || `${entry.type} ${entry.id}`;
219
+ }
220
+
221
+ function isSectionStart(entry: EntryLike, index: number): boolean {
222
+ return index === 0 || messageRole(entry) === "user" || entry.type === "compaction" || entry.type === "branch_summary";
223
+ }
224
+
225
+ function buildSections(entries: EntryLike[]): Section[] {
226
+ const sections: Section[] = [];
227
+ for (let index = 0; index < entries.length; index += 1) {
228
+ const entry = entries[index]!;
229
+ if (isSectionStart(entry, index)) {
230
+ sections.push({
231
+ id: `section:${entry.id}`,
232
+ startEntryId: entry.id,
233
+ endEntryId: entry.id,
234
+ entries: [entry],
235
+ label: sectionLabel(entry),
236
+ });
237
+ continue;
238
+ }
239
+
240
+ const section = sections[sections.length - 1]!;
241
+ section.entries.push(entry);
242
+ section.endEntryId = entry.id;
243
+ }
244
+ return sections;
245
+ }
246
+
247
+ function stringValues(value: unknown): string[] {
248
+ if (typeof value === "string") return value.trim() ? [value.trim()] : [];
249
+ if (!Array.isArray(value)) return [];
250
+ return value.filter((item): item is string => typeof item === "string" && item.trim().length > 0).map((item) => item.trim());
251
+ }
252
+
253
+ function pathsFromArguments(argumentsValue: unknown): string[] {
254
+ if (!isRecord(argumentsValue)) return [];
255
+ const paths = [
256
+ ...stringValues(argumentsValue.path),
257
+ ...stringValues(argumentsValue.file_path),
258
+ ...stringValues(argumentsValue.paths),
259
+ ];
260
+ return [...new Set(paths)];
261
+ }
262
+
263
+ function patchPaths(argumentsValue: unknown): string[] {
264
+ if (!isRecord(argumentsValue)) return [];
265
+ let patch = "";
266
+ if (typeof argumentsValue.input === "string") patch = argumentsValue.input;
267
+ else if (typeof argumentsValue.patch === "string") patch = argumentsValue.patch;
268
+ if (!patch) return [];
269
+
270
+ const paths: string[] = [];
271
+ for (const line of patch.split(/\r?\n/)) {
272
+ const piHeader = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/) ?? line.match(/^\*\*\* Move to: (.+)$/);
273
+ const unifiedHeader = line.match(/^\+\+\+ (?:b\/)?(.+)$/);
274
+ const candidate = piHeader?.[1] ?? unifiedHeader?.[1];
275
+ if (candidate && candidate !== "/dev/null") paths.push(candidate.trim());
276
+ }
277
+ return [...new Set(paths.filter(Boolean))];
278
+ }
279
+
280
+ function evidenceFromEntries(entries: EntryLike[]): FileEvidence {
281
+ const readFiles = new Set<string>();
282
+ const modifiedFiles = new Set<string>();
283
+
284
+ for (const entry of entries) {
285
+ for (const call of toolCallsFrom(entry)) {
286
+ const normalizedName = call.name.toLowerCase();
287
+ if (READ_TOOL_NAMES.has(normalizedName)) {
288
+ for (const file of pathsFromArguments(call.arguments)) readFiles.add(file);
289
+ }
290
+ if (MUTATING_TOOL_NAMES.has(normalizedName)) {
291
+ for (const file of pathsFromArguments(call.arguments)) modifiedFiles.add(file);
292
+ if (normalizedName === "apply_patch") {
293
+ for (const file of patchPaths(call.arguments)) modifiedFiles.add(file);
294
+ }
295
+ }
296
+ }
297
+
298
+ if ((entry.type === "compaction" || entry.type === "branch_summary") && isRecord(entry.details)) {
299
+ for (const file of stringValues(entry.details.readFiles)) readFiles.add(file);
300
+ for (const file of stringValues(entry.details.modifiedFiles)) modifiedFiles.add(file);
301
+ }
302
+ }
303
+
304
+ return { readFiles: [...readFiles], modifiedFiles: [...modifiedFiles] };
305
+ }
306
+
307
+ function sectionSummary(section: Section): UnknownRecord {
308
+ const roles: Record<string, number> = {};
309
+ let toolCalls = 0;
310
+ let toolResults = 0;
311
+ let errors = 0;
312
+
313
+ for (const entry of section.entries) {
314
+ const role = messageRole(entry);
315
+ if (role) roles[role] = (roles[role] ?? 0) + 1;
316
+ toolCalls += toolCallsFrom(entry).length;
317
+ const result = toolResultFrom(entry);
318
+ if (result) {
319
+ toolResults += 1;
320
+ if (result.isError === true) errors += 1;
321
+ }
322
+ }
323
+
324
+ const files = evidenceFromEntries(section.entries);
325
+ return {
326
+ id: section.id,
327
+ label: section.label,
328
+ startEntryId: section.startEntryId,
329
+ endEntryId: section.endEntryId,
330
+ entryCount: section.entries.length,
331
+ roles,
332
+ toolCalls,
333
+ toolResults,
334
+ errors,
335
+ readFileCount: files.readFiles.length,
336
+ modifiedFileCount: files.modifiedFiles.length,
337
+ };
338
+ }
339
+
340
+ function boundedHeadAndTail<T>(values: T[], maximum: number): { values: T[]; omitted: number } {
341
+ if (values.length <= maximum) return { values, omitted: 0 };
342
+ const headCount = Math.ceil(maximum / 2);
343
+ const tailCount = Math.floor(maximum / 2);
344
+ return {
345
+ values: [...values.slice(0, headCount), ...values.slice(values.length - tailCount)],
346
+ omitted: values.length - maximum,
347
+ };
348
+ }
349
+
350
+ function leafCount(entries: EntryLike[]): number {
351
+ if (entries.length === 0) return 0;
352
+ const ids = new Set(entries.map((entry) => entry.id));
353
+ const parents = new Set(
354
+ entries
355
+ .map((entry) => entry.parentId)
356
+ .filter((parentId): parentId is string => typeof parentId === "string" && ids.has(parentId)),
357
+ );
358
+ return entries.reduce((count, entry) => count + (parents.has(entry.id) ? 0 : 1), 0);
359
+ }
360
+
361
+ function renderEntry(entry: EntryLike, bodyChars: number): string {
362
+ const heading = [`[${entry.id}]`, entry.timestamp, entry.type, messageRole(entry)].filter(Boolean).join(" ");
363
+ const lines = [heading];
364
+ const message = messageFrom(entry);
365
+
366
+ if (message) {
367
+ const text = textFromContent(message.content);
368
+ if (text) lines.push(truncate(text, bodyChars));
369
+ for (const call of toolCallsFrom(entry)) {
370
+ lines.push(`tool_call ${call.name}#${call.id} ${safeJson(call.arguments, bodyChars)}`);
371
+ }
372
+ if (message.role === "toolResult") {
373
+ const toolName = typeof message.toolName === "string" ? message.toolName : "unknown";
374
+ const callId = typeof message.toolCallId === "string" ? message.toolCallId : "unknown";
375
+ lines.push(`tool_result ${toolName}#${callId}${message.isError === true ? " error" : ""}`);
376
+ }
377
+ } else if (entry.type === "compaction") {
378
+ lines.push(truncate(summaryFrom(entry), bodyChars));
379
+ if (typeof entry.firstKeptEntryId === "string") lines.push(`firstKeptEntryId: ${entry.firstKeptEntryId}`);
380
+ if (typeof entry.tokensBefore === "number") lines.push(`tokensBefore: ${entry.tokensBefore}`);
381
+ } else if (entry.type === "branch_summary") {
382
+ lines.push(truncate(summaryFrom(entry), bodyChars));
383
+ if (typeof entry.fromId === "string") lines.push(`fromId: ${entry.fromId}`);
384
+ } else if (entry.type === "custom_message") {
385
+ const text = textFromContent(entry.content);
386
+ if (text) lines.push(truncate(text, bodyChars));
387
+ } else {
388
+ const fields = Object.fromEntries(
389
+ Object.entries(entry).filter(([key]) => !["id", "parentId", "timestamp", "type"].includes(key)),
390
+ );
391
+ if (Object.keys(fields).length > 0) lines.push(safeJson(fields, bodyChars));
392
+ }
393
+
394
+ return lines.join("\n");
395
+ }
396
+
397
+ function contentResult(payload: unknown, details: UnknownRecord): { content: Array<{ type: "text"; text: string }>; details: UnknownRecord } {
398
+ const serialized = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
399
+ return {
400
+ content: [{ type: "text", text: truncate(serialized, MAX_OUTPUT_CHARS) }],
401
+ details,
402
+ };
403
+ }
404
+
405
+ function emptyResult(scope: Scope): ReturnType<typeof contentResult> {
406
+ return contentResult(
407
+ `No raw session entries are available for scope ${scope}. The session may be new, ephemeral, or unavailable in this context.`,
408
+ { scope, entryCount: 0 },
409
+ );
410
+ }
411
+
412
+ function sectionIdByEntry(sections: Section[]): Map<string, string> {
413
+ const map = new Map<string, string>();
414
+ for (const section of sections) {
415
+ for (const entry of section.entries) map.set(entry.id, section.id);
416
+ }
417
+ return map;
418
+ }
419
+
420
+ function searchSnippet(text: string, query: string, caseSensitive: boolean, maximum = 360): string {
421
+ const haystack = caseSensitive ? text : text.toLocaleLowerCase();
422
+ const needle = caseSensitive ? query : query.toLocaleLowerCase();
423
+ const index = haystack.indexOf(needle);
424
+ if (index < 0) return preview(text, maximum);
425
+ const start = Math.max(0, index - Math.floor((maximum - needle.length) / 2));
426
+ const end = Math.min(text.length, start + maximum);
427
+ return `${start > 0 ? "…" : ""}${text.slice(start, end).replace(/\s+/g, " ").trim()}${end < text.length ? "…" : ""}`;
428
+ }
429
+
430
+ function meaningfulAction(entry: EntryLike, excludedToolCallId: string): UnknownRecord | undefined {
431
+ const role = messageRole(entry);
432
+ const calls = toolCallsFrom(entry).filter((call) => call.id !== excludedToolCallId);
433
+ const message = messageFrom(entry);
434
+ const messageText = message ? textFromContent(message.content) : "";
435
+ const text = messageText || summaryFrom(entry) || (entry.type === "custom_message" ? textFromContent(entry.content) : "");
436
+ if (!text && calls.length === 0 && entry.type !== "compaction" && entry.type !== "branch_summary") return undefined;
437
+ return {
438
+ entryId: entry.id,
439
+ type: entry.type,
440
+ ...(role ? { role } : {}),
441
+ ...(text ? { preview: preview(text, 300) } : {}),
442
+ ...(calls.length > 0 ? { toolCalls: calls.map((call) => ({ id: call.id, name: call.name })) } : {}),
443
+ };
444
+ }
445
+
446
+ export default function sessionRecovery(pi: ExtensionAPI): void {
447
+ pi.registerTool({
448
+ ...SESSION_RECOVERY_TOOL_DESCRIPTIONS.overview,
449
+ parameters: Type.Object({
450
+ scope: Type.Optional(SCOPE_SCHEMA),
451
+ max_sections: Type.Optional(Type.Number({
452
+ description: "Maximum section summaries to return, split between the head and tail.",
453
+ minimum: 1,
454
+ maximum: MAX_OVERVIEW_SECTIONS,
455
+ })),
456
+ }, { additionalProperties: false }),
457
+ async execute(_toolCallId: string, params: { scope?: Scope; max_sections?: number }, _signal: AbortSignal, _onUpdate: unknown, ctx: unknown) {
458
+ const scope = scopeFrom(params.scope);
459
+ const manager = sessionManagerFrom(ctx);
460
+ const entries = entriesFor(manager, scope);
461
+ if (entries.length === 0) return emptyResult(scope);
462
+
463
+ const activeEntries = entriesFor(manager, "active");
464
+ const allEntries = entriesFor(manager, "all");
465
+ const sections = buildSections(entries);
466
+ const maximum = clampInteger(params.max_sections, DEFAULT_OVERVIEW_SECTIONS, 1, MAX_OVERVIEW_SECTIONS);
467
+ const selected = boundedHeadAndTail(sections, maximum);
468
+ const allLeaves = leafCount(allEntries);
469
+ const header = callSafely<unknown>(() => manager?.getHeader?.(), undefined);
470
+ const sessionId = callSafely<unknown>(() => manager?.getSessionId?.(), undefined);
471
+ const sessionName = callSafely<unknown>(() => manager?.getSessionName?.(), undefined);
472
+ const sessionFile = callSafely<unknown>(() => manager?.getSessionFile?.(), undefined);
473
+ const payload = {
474
+ scope,
475
+ session: {
476
+ id: typeof sessionId === "string" ? sessionId : null,
477
+ name: typeof sessionName === "string" ? sessionName : null,
478
+ persisted: typeof sessionFile === "string" && sessionFile.length > 0,
479
+ hasParentSession: isRecord(header) && header.parentSession != null,
480
+ },
481
+ counts: {
482
+ selectedEntries: entries.length,
483
+ activeEntries: activeEntries.length,
484
+ allEntries: allEntries.length,
485
+ sections: sections.length,
486
+ compactions: entries.filter((entry) => entry.type === "compaction").length,
487
+ leaves: allLeaves,
488
+ otherBranches: Math.max(0, allLeaves - (activeEntries.length > 0 ? 1 : 0)),
489
+ },
490
+ sections: selected.values.map(sectionSummary),
491
+ omittedSections: selected.omitted,
492
+ };
493
+ return contentResult(payload, { scope, entryCount: entries.length, sectionCount: sections.length, omittedSections: selected.omitted });
494
+ },
495
+ });
496
+
497
+ pi.registerTool({
498
+ ...SESSION_RECOVERY_TOOL_DESCRIPTIONS.readSection,
499
+ parameters: Type.Object({
500
+ section_id: Type.String({ description: "Stable section ID returned by session_overview or session_search.", maxLength: 200 }),
501
+ scope: Type.Optional(SCOPE_SCHEMA),
502
+ max_entries: Type.Optional(Type.Number({ description: "Maximum entries to render from the section.", minimum: 1, maximum: MAX_SECTION_ENTRIES })),
503
+ max_body_chars: Type.Optional(Type.Number({ description: "Maximum rendered body characters per entry.", minimum: 100, maximum: MAX_BODY_CHARS })),
504
+ }, { additionalProperties: false }),
505
+ async execute(_toolCallId: string, params: { section_id: string; scope?: Scope; max_entries?: number; max_body_chars?: number }, _signal: AbortSignal, _onUpdate: unknown, ctx: unknown) {
506
+ const scope = scopeFrom(params.scope);
507
+ const entries = entriesFor(sessionManagerFrom(ctx), scope);
508
+ if (entries.length === 0) return emptyResult(scope);
509
+ const section = buildSections(entries).find((candidate) => candidate.id === params.section_id);
510
+ if (!section) {
511
+ return contentResult(
512
+ `Section ${params.section_id} was not found in scope ${scope}. Run session_overview with the same scope to refresh section IDs.`,
513
+ { scope, sectionId: params.section_id, found: false },
514
+ );
515
+ }
516
+
517
+ const maximum = clampInteger(params.max_entries, DEFAULT_SECTION_ENTRIES, 1, MAX_SECTION_ENTRIES);
518
+ const bodyChars = clampInteger(params.max_body_chars, DEFAULT_BODY_CHARS, 100, MAX_BODY_CHARS);
519
+ const rendered: string[] = [];
520
+ let renderedChars = 0;
521
+ let truncatedOutput = false;
522
+ for (const entry of section.entries.slice(0, maximum)) {
523
+ const next = renderEntry(entry, bodyChars);
524
+ if (renderedChars + next.length + 2 > MAX_OUTPUT_CHARS - 500) {
525
+ truncatedOutput = true;
526
+ break;
527
+ }
528
+ rendered.push(next);
529
+ renderedChars += next.length + 2;
530
+ }
531
+ const omittedEntries = section.entries.length - rendered.length;
532
+ return contentResult(
533
+ [`Section ${section.id}: ${section.label}`, ...rendered, omittedEntries > 0 ? `… ${omittedEntries} entries omitted` : ""].filter(Boolean).join("\n\n"),
534
+ {
535
+ scope,
536
+ sectionId: section.id,
537
+ entryCount: section.entries.length,
538
+ renderedCount: rendered.length,
539
+ omittedEntries,
540
+ truncated: truncatedOutput || omittedEntries > 0,
541
+ },
542
+ );
543
+ },
544
+ });
545
+
546
+ pi.registerTool({
547
+ ...SESSION_RECOVERY_TOOL_DESCRIPTIONS.search,
548
+ parameters: Type.Object({
549
+ query: Type.String({
550
+ description: "Literal substring to find in raw session text and tool arguments.",
551
+ minLength: 1,
552
+ maxLength: MAX_SEARCH_QUERY_CHARS,
553
+ }),
554
+ scope: Type.Optional(SCOPE_SCHEMA),
555
+ case_sensitive: Type.Optional(Type.Boolean({ description: "Use exact case matching. Defaults to false." })),
556
+ limit: Type.Optional(Type.Number({ description: "Maximum matches to return.", minimum: 1, maximum: MAX_SEARCH_RESULTS })),
557
+ }, { additionalProperties: false }),
558
+ async execute(_toolCallId: string, params: { query: string; scope?: Scope; case_sensitive?: boolean; limit?: number }, _signal: AbortSignal, _onUpdate: unknown, ctx: unknown) {
559
+ const scope = scopeFrom(params.scope);
560
+ const entries = entriesFor(sessionManagerFrom(ctx), scope);
561
+ if (entries.length === 0) return emptyResult(scope);
562
+ const query = params.query.trim().slice(0, MAX_SEARCH_QUERY_CHARS);
563
+ if (!query) return contentResult("Search query must not be empty.", { scope, query, matchCount: 0 });
564
+
565
+ const caseSensitive = params.case_sensitive === true;
566
+ const needle = caseSensitive ? query : query.toLocaleLowerCase();
567
+ const limit = clampInteger(params.limit, DEFAULT_SEARCH_RESULTS, 1, MAX_SEARCH_RESULTS);
568
+ const sections = buildSections(entries);
569
+ const entrySections = sectionIdByEntry(sections);
570
+ const matches: UnknownRecord[] = [];
571
+ let totalMatches = 0;
572
+
573
+ for (const entry of entries) {
574
+ const text = entryText(entry);
575
+ const haystack = caseSensitive ? text : text.toLocaleLowerCase();
576
+ if (!text || !haystack.includes(needle)) continue;
577
+ totalMatches += 1;
578
+ if (matches.length >= limit) continue;
579
+ matches.push({
580
+ entryId: entry.id,
581
+ sectionId: entrySections.get(entry.id),
582
+ type: entry.type,
583
+ ...(messageRole(entry) ? { role: messageRole(entry) } : {}),
584
+ timestamp: entry.timestamp ?? null,
585
+ snippet: searchSnippet(text, query, caseSensitive),
586
+ });
587
+ }
588
+
589
+ const payload = { scope, query, caseSensitive, totalMatches, returnedMatches: matches.length, matches };
590
+ return contentResult(payload, { scope, query, matchCount: totalMatches, returnedCount: matches.length, truncated: totalMatches > matches.length });
591
+ },
592
+ });
593
+
594
+ pi.registerTool({
595
+ ...SESSION_RECOVERY_TOOL_DESCRIPTIONS.recoveryContext,
596
+ parameters: Type.Object({
597
+ scope: Type.Optional(SCOPE_SCHEMA),
598
+ recent_error_limit: Type.Optional(Type.Number({ description: "Maximum recent error tool results to report.", minimum: 1, maximum: MAX_RECENT_ERRORS })),
599
+ }, { additionalProperties: false }),
600
+ async execute(toolCallId: string, params: { scope?: Scope; recent_error_limit?: number }, _signal: AbortSignal, _onUpdate: unknown, ctx: unknown) {
601
+ const scope = scopeFrom(params.scope);
602
+ const entries = entriesFor(sessionManagerFrom(ctx), scope);
603
+ if (entries.length === 0) return emptyResult(scope);
604
+
605
+ const sections = buildSections(entries);
606
+ const entrySections = sectionIdByEntry(sections);
607
+ const userEntries = entries.filter((entry) => messageRole(entry) === "user" && entryText(entry).trim().length > 0);
608
+ const pending = new Map<string, ToolCallLike>();
609
+ const errors: UnknownRecord[] = [];
610
+
611
+ for (const entry of entries) {
612
+ for (const call of toolCallsFrom(entry)) pending.set(call.id, call);
613
+ const result = toolResultFrom(entry);
614
+ if (!result) continue;
615
+ if (typeof result.toolCallId === "string") pending.delete(result.toolCallId);
616
+ if (result.isError === true) {
617
+ errors.push({
618
+ entryId: entry.id,
619
+ sectionId: entrySections.get(entry.id),
620
+ toolCallId: typeof result.toolCallId === "string" ? result.toolCallId : null,
621
+ toolName: typeof result.toolName === "string" ? result.toolName : null,
622
+ message: preview(textFromContent(result.content), 500),
623
+ });
624
+ }
625
+ }
626
+ pending.delete(toolCallId);
627
+
628
+ const errorLimit = clampInteger(params.recent_error_limit, DEFAULT_RECENT_ERRORS, 1, MAX_RECENT_ERRORS);
629
+ const firstUser = userEntries[0];
630
+ const latestUser = userEntries[userEntries.length - 1];
631
+ const files = evidenceFromEntries(entries);
632
+ const readFiles = boundedHeadAndTail(files.readFiles, MAX_RECOVERY_FILES);
633
+ const modifiedFiles = boundedHeadAndTail(files.modifiedFiles, MAX_RECOVERY_FILES);
634
+ const pendingCalls = boundedHeadAndTail([...pending.values()], MAX_PENDING_TOOL_CALLS);
635
+ const lastAction = [...entries].reverse().map((entry) => meaningfulAction(entry, toolCallId)).find(Boolean) ?? null;
636
+ const payload = {
637
+ scope,
638
+ originalUserRequest: firstUser ? {
639
+ entryId: firstUser.id,
640
+ sectionId: entrySections.get(firstUser.id),
641
+ text: truncate(entryText(firstUser), DEFAULT_BODY_CHARS),
642
+ } : null,
643
+ latestUserInstruction: latestUser ? {
644
+ entryId: latestUser.id,
645
+ sectionId: entrySections.get(latestUser.id),
646
+ text: truncate(entryText(latestUser), DEFAULT_BODY_CHARS),
647
+ } : null,
648
+ readFiles: readFiles.values,
649
+ modifiedFiles: modifiedFiles.values,
650
+ omittedReadFiles: readFiles.omitted,
651
+ omittedModifiedFiles: modifiedFiles.omitted,
652
+ recentErrors: errors.slice(-errorLimit),
653
+ pendingToolCalls: pendingCalls.values.map((call) => ({
654
+ id: call.id,
655
+ name: call.name,
656
+ entryId: call.entryId,
657
+ sectionId: entrySections.get(call.entryId),
658
+ })),
659
+ omittedPendingToolCalls: pendingCalls.omitted,
660
+ lastMeaningfulAction: lastAction,
661
+ compactionCount: entries.filter((entry) => entry.type === "compaction").length,
662
+ entryCount: entries.length,
663
+ sectionCount: sections.length,
664
+ };
665
+ return contentResult(payload, {
666
+ scope,
667
+ entryCount: entries.length,
668
+ sectionCount: sections.length,
669
+ recentErrorCount: payload.recentErrors.length,
670
+ pendingToolCallCount: payload.pendingToolCalls.length,
671
+ });
672
+ },
673
+ });
674
+ }
@@ -250,6 +250,46 @@ export const SESSION_NAME_TOOL_DESCRIPTION: ToolDescription = {
250
250
  ],
251
251
  };
252
252
 
253
+ export const SESSION_RECOVERY_TOOL_DESCRIPTIONS = {
254
+ overview: {
255
+ name: "session_overview",
256
+ label: "Session Overview",
257
+ description: "Map raw persisted session history into stable, bounded sections. Defaults to the active branch and can include abandoned branches without applying context compaction.",
258
+ promptSnippet: "Map raw session history into stable sections before drilling into context lost to compaction.",
259
+ promptGuidelines: [
260
+ "Use session_overview first when task context was lost or compressed and no reliable search phrase is known; then inspect relevant section IDs.",
261
+ ],
262
+ },
263
+ readSection: {
264
+ name: "session_read_section",
265
+ label: "Session Read Section",
266
+ description: "Read a bounded raw-history section returned by session_overview or session_search, including messages, tool calls/results, and compaction summaries.",
267
+ promptSnippet: "Read one stable raw-session section by ID after session_overview or session_search.",
268
+ promptGuidelines: [
269
+ "Pass a section ID produced with the same active/all scope; keep entry and body limits small unless more detail is necessary.",
270
+ ],
271
+ },
272
+ search: {
273
+ name: "session_search",
274
+ label: "Session Search",
275
+ description: "Search raw session messages, summaries, tool results, and serialized tool arguments with a bounded literal substring query.",
276
+ promptSnippet: "Search raw session history lexically when a concrete phrase, path, symbol, tool, or error is known.",
277
+ promptGuidelines: [
278
+ "Use session_search after overview when a concrete query is known; it is lexical rather than semantic, and scope defaults to the active branch.",
279
+ ],
280
+ },
281
+ recoveryContext: {
282
+ name: "session_recovery_context",
283
+ label: "Session Recovery Context (after overview)",
284
+ description: "Post-overview convenience tool for summarizing deterministic task signals: user requests, file activity, recent errors, pending tool calls, last action, and compaction references. When the task and search terms are unknown, call session_overview first instead of this tool.",
285
+ promptSnippet: "Use only after session_overview has mapped the raw history; verify details through section reads or search.",
286
+ promptGuidelines: [
287
+ "Do not use session_recovery_context as the first tool when the task and useful search terms are unknown; start with session_overview.",
288
+ "Use it as a convenience after the overview, but treat recentErrors as historical evidence and verify ambiguous state with session_read_section.",
289
+ ],
290
+ },
291
+ } satisfies Record<string, ToolDescription>;
292
+
253
293
  export const WEB_SEARCH_TOOL_DESCRIPTIONS = {
254
294
  webSearch: {
255
295
  name: "web_search",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "1.0.35",
3
+ "version": "1.0.36",
4
4
  "description": "Pix: a workspace-first terminal UI for Pi with tabs, readable tool activity, voice input, and bundled agent tools.",
5
5
  "private": false,
6
6
  "repository": {
@@ -41,7 +41,9 @@
41
41
  "external/pi-tools-suite/src",
42
42
  "external/pi-tools-suite/docs",
43
43
  "external/pi-tools-suite/licenses",
44
- "README.md"
44
+ "README.md",
45
+ "!**/__pycache__",
46
+ "!**/*.pyc"
45
47
  ],
46
48
  "scripts": {
47
49
  "predev": "npm run link:pix --silent",