shariq-pi-extensions 0.2.14 → 0.2.15

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.
@@ -38,6 +38,8 @@ Installed package directories are treated as immutable. Extensions resolve writa
38
38
 
39
39
  - Cursor's authenticated Composer/Grok catalog cache: `<agent-dir>/cursor/models.json`
40
40
  - Factory key selection, Droid metadata, and throttled per-credential limit cache: `<agent-dir>/factory/`
41
+ - Smart Compaction configuration and details ledgers: `<agent-dir>/smart-compaction.json`
42
+ - Input mode configuration: `<agent-dir>/input-mode.json`
41
43
  - Pi Memory state: `<agent-dir>/pi-memory/`
42
44
  - Subagent configuration and catalog: paths derived from `getAgentDir()`
43
45
  - Orchestration settings and run ledgers: `<agent-dir>/orchestration/`
@@ -45,9 +45,9 @@ Keep secrets out of examples and fixtures. Tests should inject temporary roots,
45
45
 
46
46
  ## Skills paired with extensions
47
47
 
48
- The root manifest declares `skills/background-terminals` and `skills/subagents`. Pi loads them directly from the managed Git package. Do not copy them into the agent directory from install scripts; that would create duplicates and leave stale files after removal.
48
+ The root manifest declares `skills/background-terminals`, `skills/orchestration`, and `skills/subagents`. Pi loads them directly from the managed package. Do not copy them into the agent directory from install scripts; that would create duplicates and leave stale files after removal.
49
49
 
50
- When either skill changes, validate its structure and keep its behavior aligned with the corresponding extension tools.
50
+ When any paired skill changes, validate its structure and keep its behavior aligned with the corresponding extension tools.
51
51
 
52
52
  ## Pi Memory
53
53
 
@@ -10,7 +10,7 @@ Registers the `antigravity` provider and `/login antigravity` flow for the lates
10
10
 
11
11
  ### [Cursor provider](../extensions/cursor-provider/README.md)
12
12
 
13
- Registers one `cursor` provider for Cursor-hosted Composer and Cursor Grok models through the native Cursor SDK. `/login cursor` supports browser-minted or existing user API keys, images and native Pi tool delegation are enabled, and `/cursor` shows Cursor's authoritative current-month total, Auto/Composer and named/API percentages, reset date, plan, and on-demand limits. It does not expose ACP, third-party models, or Factory-style 5-hour/weekly pools. The authenticated catalog cache belongs in `<agent-dir>/cursor/models.json`.
13
+ Registers one `cursor` provider for Cursor-hosted Composer and Cursor Grok models through the native Cursor SDK with warm agent instance pooling (LRU pool with 10-minute idle TTL) and ambient settings suppression (`settingSources: []`) to eliminate per-turn startup latency. `/login cursor` supports browser-minted or existing user API keys, images and native Pi tool delegation are enabled, credential redaction automatically scrubs API keys and tokens from error logs, and `/cursor` shows Cursor's authoritative current-month total, Auto/Composer and named/API percentages, reset date, plan, and on-demand limits. It does not expose ACP, third-party models, or Factory-style 5-hour/weekly pools. The authenticated catalog cache belongs in `<agent-dir>/cursor/models.json`.
14
14
 
15
15
  ### [Factory provider](../extensions/factory-provider/README.md)
16
16
 
@@ -48,9 +48,15 @@ The model-facing `create_orchestration` tool starts planning only after an expli
48
48
 
49
49
  ### [Smart Compaction](../extensions/smart-compaction/README.md)
50
50
 
51
- Replaces standard context compaction with a high-fidelity continuity engine. It intercepts `session_before_compact` events and synthesizes multi-turn conversations into structured checkpoint summaries capturing primary goals and negative constraints, progress ledgers (`Done`/`In Progress`/`Blocked`), verbatim code snippets for active/uncommitted edits, exact error root causes, architectural decisions, resume anchors, and deterministic `<read-files>`/`<modified-files>` metadata.
51
+ Replaces standard context compaction with a defensive, high-fidelity continuity engine. It intercepts `session_before_compact` events and synthesizes multi-turn conversations into structured checkpoint summaries capturing primary goals and negative constraints, progress ledgers (`Done`/`In Progress`/`Blocked`), verbatim code snippets for active/uncommitted edits, exact error root causes, architectural decisions, resume anchors, and deterministic file/diff state.
52
52
 
53
- Successive compactions utilize an incremental Delta-Merge to eliminate context degradation over long sessions. `/compaction-model` selects any custom compaction model (e.g. `factory/gemini-3.7-flash`, `cursor/cursor-grok-4.5-fast`) or defaults to inheriting the active session model (`inherit`). `/smart-compaction` toggles or inspects compaction configuration stored in `<agent-dir>/smart-compaction.json`.
53
+ Key capabilities include:
54
+ - **Fail-Closed Validation**: Strictly enforces `stopReason === "stop"`, rejects tool calls and length-truncated output, and requires all 6 section headings.
55
+ - **Deterministic State Ledger (Schema v3)**: Machine-readable tracking of `touchedReadFiles`, `touchedModifiedFiles`, and asynchronous NUL-delimited Git worktree parsing capturing `activeDirtyFiles`, staged diffs, unstaged diffs, and untracked file previews in `CompactionEntry.details` and `<uncommitted-diff>` context.
56
+ - **Hierarchical Delta-Merging**: Carries forward immutable goals and user constraints across 10+ compaction cycles while condensing older completed items to prevent summary bloat.
57
+ - **Classified Retry Ladder**: Distinguishes non-retryable fatal auth/quota errors from transient reasoning/length limits (retrying with reasoning off) and falling back to the active session model.
58
+ - **Two-Ended Truncation & Credential Redaction**: Retains both head and tail of tool outputs (ensuring final error traces and test results survive) while redacting secrets and sensitive paths.
59
+ - **Custom Model Routing**: `/compaction-model` selects any custom compaction model (e.g. `factory/gemini-3.7-flash`, `cursor/cursor-grok-4.5-fast`) or defaults to inheriting the active session model (`inherit`). `/smart-compaction` manages settings stored in `<agent-dir>/smart-compaction.json`.
54
60
 
55
61
  ### [Background terminals](../extensions/background-terminals/README.md)
56
62
 
@@ -1,42 +1,7 @@
1
1
  import path from "node:path";
2
+ import { isSensitivePath, redactSecrets } from "../../shared/redaction.ts";
2
3
 
3
- const SENSITIVE_PATH_PARTS = new Set([
4
- ".env",
5
- "auth.json",
6
- "credentials",
7
- "credential",
8
- "secrets",
9
- "secret",
10
- "keychain",
11
- ".ssh",
12
- ".aws",
13
- ".gnupg",
14
- ]);
15
-
16
- const CREDENTIAL_NAME = "(?:api[_-]?key|apiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|auth(?:orization)?|password|passwd|secret|cookie|session[_-]?token|sessionToken|aws[_-]?secret[_-]?access[_-]?key|aws[_-]?access[_-]?key[_-]?id|client[_-]?secret|clientSecret|private[_-]?key|privateKey|database[_-]?url|databaseUrl|connection[_-]?string|connectionString|dsn)";
17
-
18
- const REDACTIONS: Array<[RegExp, string]> = [
19
- [/\b([a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s/@]+@/gi, "$1<redacted>@"],
20
- [/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b/gi, "Bearer <redacted>"],
21
- [new RegExp(`(\\b${CREDENTIAL_NAME}\\b\\s*[:=]\\s*)(["'])(.*?)\\2`, "gi"), "$1$2<redacted>$2"],
22
- [new RegExp(`(\\b${CREDENTIAL_NAME}\\b\\s*[:=]\\s*)[^\\r\\n,;]+`, "gi"), "$1<redacted>"],
23
- [/\b(?:sk|pk|rk|ghp|github_pat|xox[baprs]|AIza)[-_A-Za-z0-9]{12,}\b/g, "<redacted-token>"],
24
- [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "<redacted-private-key>"],
25
- [/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, "<redacted-opaque-value>"],
26
- ];
27
-
28
- export function redactSecrets(value: string): string {
29
- let result = value;
30
- for (const [pattern, replacement] of REDACTIONS) result = result.replace(pattern, replacement);
31
- return result;
32
- }
33
-
34
- export function isSensitivePath(value: string): boolean {
35
- const normalized = value.toLowerCase().replaceAll("\\", "/");
36
- return normalized.split("/").some((part) => SENSITIVE_PATH_PARTS.has(part))
37
- || /(?:^|\/)\.env(?:\.|$)/.test(normalized)
38
- || /(?:^|\/)(?:id_rsa|id_ed25519|known_hosts)(?:$|\/)/.test(normalized);
39
- }
4
+ export { isSensitivePath, redactSecrets } from "../../shared/redaction.ts";
40
5
 
41
6
  export function hasSensitiveToolArguments(toolName: string, input: unknown): boolean {
42
7
  if (!["read", "write", "edit"].includes(toolName)) return false;
@@ -0,0 +1,55 @@
1
+ const SENSITIVE_PATH_PARTS = new Set([
2
+ ".env",
3
+ "auth.json",
4
+ "credentials",
5
+ "credential",
6
+ "secrets",
7
+ "secret",
8
+ "keychain",
9
+ ".ssh",
10
+ ".aws",
11
+ ".gnupg",
12
+ ]);
13
+
14
+ const CREDENTIAL_NAME = "(?:api[_-]?key|apiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|auth(?:orization)?|password|passwd|secret|cookie|session[_-]?token|sessionToken|aws[_-]?secret[_-]?access[_-]?key|aws[_-]?access[_-]?key[_-]?id|client[_-]?secret|clientSecret|private[_-]?key|privateKey|database[_-]?url|databaseUrl|connection[_-]?string|connectionString|dsn)";
15
+
16
+ const REDACTIONS: Array<[RegExp, string]> = [
17
+ [/\b([a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s/@]+@/gi, "$1<redacted>@"],
18
+ [/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b/gi, "Bearer <redacted>"],
19
+ [new RegExp(`(\\b${CREDENTIAL_NAME}\\b\\s*[:=]\\s*)(["'])(.*?)\\2`, "gi"), "$1$2<redacted>$2"],
20
+ [new RegExp(`(\\b${CREDENTIAL_NAME}\\b\\s*[:=]\\s*)[^\\r\\n,;]+`, "gi"), "$1<redacted>"],
21
+ [/\b(?:sk|pk|rk|ghp|github_pat|xox[baprs]|AIza)[-_A-Za-z0-9]{12,}\b/g, "<redacted-token>"],
22
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "<redacted-private-key>"],
23
+ [/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, "<redacted-opaque-value>"],
24
+ ];
25
+
26
+ export function redactSecrets(value: string): string {
27
+ let result = value;
28
+ for (const [pattern, replacement] of REDACTIONS) result = result.replace(pattern, replacement);
29
+ return result;
30
+ }
31
+
32
+ const CODE_SAFE_CREDENTIAL_NAME = "(?:api[_-]?key|apiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|auth(?:orization)?[_-]?token|password|passwd|secret|cookie|session[_-]?token|sessionToken|aws[_-]?secret[_-]?access[_-]?key|aws[_-]?access[_-]?key[_-]?id|client[_-]?secret|clientSecret|private[_-]?key|privateKey|database[_-]?url|databaseUrl|connection[_-]?string|connectionString|dsn)";
33
+
34
+ const CODE_SAFE_REDACTIONS: Array<[RegExp, string]> = [
35
+ [/\b([a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s/@]+@/gi, "$1<redacted>@"],
36
+ [/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b/gi, "Bearer <redacted>"],
37
+ [new RegExp(`(\\b${CODE_SAFE_CREDENTIAL_NAME}\\b\\s*[:=]\\s*)(["'])(.{8,}?)\\2`, "gi"), "$1$2<redacted>$2"],
38
+ [new RegExp(`(\\b${CODE_SAFE_CREDENTIAL_NAME}\\b\\s*[:=]\\s*)([^\\s,;]{8,})`, "gi"), "$1<redacted>"],
39
+ [/\b(?:sk|pk|rk|ghp|github_pat|xox[baprs]|AIza)[-_A-Za-z0-9]{12,}\b/g, "<redacted-token>"],
40
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "<redacted-private-key>"],
41
+ ];
42
+
43
+ /** Redact high-confidence secrets without treating ordinary code such as `auth = true` as sensitive. */
44
+ export function redactLikelySecrets(value: string): string {
45
+ let result = value;
46
+ for (const [pattern, replacement] of CODE_SAFE_REDACTIONS) result = result.replace(pattern, replacement);
47
+ return result;
48
+ }
49
+
50
+ export function isSensitivePath(value: string): boolean {
51
+ const normalized = value.toLowerCase().replaceAll("\\", "/");
52
+ return normalized.split("/").some((part) => SENSITIVE_PATH_PARTS.has(part))
53
+ || /(?:^|\/)\.env(?:\.|$)/.test(normalized)
54
+ || /(?:^|\/)(?:id_rsa|id_ed25519|known_hosts)(?:$|\/)/.test(normalized);
55
+ }
@@ -14,22 +14,23 @@ When long-running agent sessions reach context thresholds, standard compaction f
14
14
 
15
15
  1. **🎯 Primary Goal & Nuanced Intent** — Retains full user objectives, styling preferences, scope boundaries, and explicit negative constraints.
16
16
  2. **📋 Progress Ledger** — Strict `[x] Done`, `[ ] In Progress`, and `[!] Blocked` tracking.
17
- 3. **🛠️ Code Changes & In-Progress Snippets** — Captures verbatim code snippets of active work and recent edits so a successor agent resumes without re-reading or guessing.
17
+ 3. **🛠️ Code Changes & In-Progress Snippets** — Captures verbatim code snippets of active work and recent edits, supplemented by a bounded worktree patch so a successor can recover the current engineering state.
18
18
  4. **💥 Errors, Root Causes & Fixes** — Full error traces, root cause diagnostics, and verified solutions.
19
19
  5. **🧠 Key Decisions & Hypotheses** — Architectural choices, trade-offs, and discarded hypotheses.
20
20
  6. **📍 Resume Anchor & Immediate Next Action** — Verbatim quote or exact resume state with the single immediate next action.
21
- 7. **📂 Deterministic File Ledger** — Programmatic `<read-files>` and `<modified-files>` XML blocks merged deterministically across cycles in `details.readFiles` and `details.modifiedFiles`.
21
+ 7. **📂 Deterministic Engineering Ledger** — Programmatic `<read-files>`, `<touched-files>`, `<uncommitted-dirty-files>`, and bounded `<uncommitted-diff>` blocks. Historical touch state, current NUL-delimited porcelain status, and redacted patch data are persisted in versioned compaction details; sensitive paths are omitted.
22
22
 
23
23
  ## Defensive Reliability & Multi-Stage Retry Ladder
24
24
 
25
- - **Fail-Closed Validation**: Rejects `stopReason === "length"`, `stopReason === "error"`, accidental tool calls, or partial summaries missing required section headers.
25
+ - **Fail-Closed Validation**: Accepts only `stopReason === "stop"` and rejects tool calls, empty output, or summaries missing required section headers.
26
26
  - **Retry Ladder**: If an attempt encounters output limits or transient reasoning timeouts:
27
27
  1. Primary configured model with requested reasoning.
28
28
  2. Primary model with reasoning off (unblocks reasoning/token caps).
29
29
  3. Session model with reasoning off.
30
30
  4. Graceful fallback to Pi's default compactor if all stages fail.
31
31
  - **Two-Ended Head & Tail Truncation**: Preserves both the beginning (context) and end (stack traces, compiler errors, exit codes, test summaries) of tool results and command logs.
32
- - **Deterministic 10+ Cycle Stability**: Persists machine-readable file and cycle ledgers in `CompactionEntry.details` so file states survive indefinitely across successive compactions.
32
+ - **Secret-Safe Persistence**: Redacts credential-shaped values and omits sensitive tool paths, results, dirty files, and patches before durable compaction state is created.
33
+ - **Deterministic 10+ Cycle Stability**: Persists machine-readable touch, dirty-file, bounded-patch, and cycle ledgers in `CompactionEntry.details`; hierarchical delta merging keeps immutable constraints while condensing obsolete history.
33
34
 
34
35
  ## Model Selection
35
36
 
@@ -1,6 +1,11 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { promisify } from "node:util";
1
5
  import { uuidv7, type Api, type Context, type Model, type Usage, type AssistantMessage } from "@earendil-works/pi-ai";
2
6
  import type { ExtensionContext, SessionBeforeCompactEvent } from "@earendil-works/pi-coding-agent";
3
7
  import type { SmartCompactionConfig } from "./config.ts";
8
+ import { isSensitivePath, redactLikelySecrets } from "../shared/redaction.ts";
4
9
  import {
5
10
  formatFileOperationsXml,
6
11
  sanitizeTagContent,
@@ -10,27 +15,50 @@ import {
10
15
  serializeConversationForCompaction,
11
16
  } from "./prompt.ts";
12
17
 
18
+ export interface DirtyFileState {
19
+ path: string;
20
+ status: string;
21
+ }
22
+
23
+ export interface GitEngineeringState {
24
+ available: boolean;
25
+ files: DirtyFileState[];
26
+ patch: string;
27
+ sensitiveFilesOmitted: number;
28
+ }
29
+
13
30
  export interface SmartCompactionDetails {
14
- schemaVersion: 2;
31
+ schemaVersion: 3;
15
32
  customCompactor: "smart-compaction";
16
- model: string;
33
+ configuredModel: string;
34
+ resolvedModel: string;
17
35
  isInherited: boolean;
18
- readFiles: string[];
19
- modifiedFiles: string[];
36
+ touchedReadFiles: string[];
37
+ touchedModifiedFiles: string[];
38
+ activeDirtyFiles: string[];
39
+ activeDirtyFileStates: DirtyFileState[];
40
+ activeDirtyPatch: string;
41
+ dirtyStateAvailable: boolean;
42
+ sensitiveDirtyFilesOmitted: number;
43
+ sensitiveTouchedFilesOmitted: number;
20
44
  cycleCount: number;
21
45
  timestamp: number;
22
46
  }
23
47
 
48
+ export function modelKey(model: Pick<Model<Api>, "provider" | "id">): string {
49
+ return `${model.provider}/${model.id}`;
50
+ }
51
+
24
52
  export function resolveCompactionModel(
25
53
  ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
26
54
  configuredModelString?: string,
27
- ): { model: Model<Api>; isInherited: boolean } {
55
+ ): { model: Model<Api>; isInherited: boolean; isFallback: boolean; fallbackReason?: string } {
28
56
  const trimmed = configuredModelString?.trim();
29
57
  if (!trimmed || trimmed === "inherit") {
30
58
  if (!ctx.model) {
31
59
  throw new Error("No active session model available to inherit for compaction.");
32
60
  }
33
- return { model: ctx.model, isInherited: true };
61
+ return { model: ctx.model, isInherited: true, isFallback: false };
34
62
  }
35
63
 
36
64
  // Parse "provider/model" or modelId
@@ -44,36 +72,48 @@ export function resolveCompactionModel(
44
72
  }
45
73
 
46
74
  if (candidate) {
47
- return { model: candidate, isInherited: false };
75
+ return { model: candidate, isInherited: false, isFallback: false };
48
76
  }
49
77
 
50
78
  if (ctx.model) {
51
- return { model: ctx.model, isInherited: true };
79
+ return {
80
+ model: ctx.model,
81
+ isInherited: true,
82
+ isFallback: true,
83
+ fallbackReason: `Configured compaction model "${trimmed}" is unavailable in model registry.`,
84
+ };
52
85
  }
53
86
 
54
87
  throw new Error(`Configured compaction model "${trimmed}" was not found in model registry.`);
55
88
  }
56
89
 
57
90
  export function extractPriorFileState(branchEntries?: any[]): {
58
- readFiles: Set<string>;
59
- modifiedFiles: Set<string>;
91
+ touchedReadFiles: Set<string>;
92
+ touchedModifiedFiles: Set<string>;
60
93
  cycleCount: number;
61
94
  } {
62
- const readFiles = new Set<string>();
63
- const modifiedFiles = new Set<string>();
95
+ const touchedReadFiles = new Set<string>();
96
+ const touchedModifiedFiles = new Set<string>();
64
97
  let cycleCount = 0;
65
98
 
66
- if (!Array.isArray(branchEntries)) return { readFiles, modifiedFiles, cycleCount };
99
+ if (!Array.isArray(branchEntries)) return { touchedReadFiles, touchedModifiedFiles, cycleCount };
67
100
 
68
101
  for (let i = branchEntries.length - 1; i >= 0; i--) {
69
102
  const entry = branchEntries[i];
70
103
  if (entry?.type === "compaction" && entry.details) {
71
- const details = entry.details as Partial<SmartCompactionDetails> & { readFiles?: string[]; modifiedFiles?: string[] };
72
- if (Array.isArray(details.readFiles)) {
73
- for (const file of details.readFiles) readFiles.add(file);
104
+ const details = entry.details as Partial<SmartCompactionDetails> & {
105
+ readFiles?: string[];
106
+ modifiedFiles?: string[];
107
+ touchedReadFiles?: string[];
108
+ touchedModifiedFiles?: string[];
109
+ };
110
+ const readList = details.touchedReadFiles ?? details.readFiles;
111
+ if (Array.isArray(readList)) {
112
+ for (const file of readList) touchedReadFiles.add(file);
74
113
  }
75
- if (Array.isArray(details.modifiedFiles)) {
76
- for (const file of details.modifiedFiles) modifiedFiles.add(file);
114
+ const modifiedList = details.touchedModifiedFiles ?? details.modifiedFiles;
115
+ if (Array.isArray(modifiedList)) {
116
+ for (const file of modifiedList) touchedModifiedFiles.add(file);
77
117
  }
78
118
  if (typeof details.cycleCount === "number") {
79
119
  cycleCount = Math.max(cycleCount, details.cycleCount);
@@ -81,7 +121,124 @@ export function extractPriorFileState(branchEntries?: any[]): {
81
121
  }
82
122
  }
83
123
 
84
- return { readFiles, modifiedFiles, cycleCount };
124
+ return { touchedReadFiles, touchedModifiedFiles, cycleCount };
125
+ }
126
+
127
+ const execFileAsync = promisify(execFile);
128
+ const GIT_TIMEOUT_MS = 5_000;
129
+ const GIT_OUTPUT_LIMIT = 2 * 1024 * 1024;
130
+ const DIRTY_PATCH_CHARS = 16_000;
131
+ const UNTRACKED_FILE_CHARS = 4_000;
132
+
133
+ export function parseGitStatusPorcelainV1Z(output: string): DirtyFileState[] {
134
+ const records = output.split("\0");
135
+ const files: DirtyFileState[] = [];
136
+ for (let index = 0; index < records.length; index++) {
137
+ const record = records[index];
138
+ if (!record || record.length < 4) continue;
139
+ const status = record.slice(0, 2);
140
+ const filePath = record.slice(3);
141
+ files.push({ path: filePath, status });
142
+ // In porcelain v1 -z output, rename/copy records are followed by the source path.
143
+ if (status.includes("R") || status.includes("C")) index++;
144
+ }
145
+ return [...new Map(files.map((file) => [file.path, file])).values()];
146
+ }
147
+
148
+ function truncatePatch(text: string): string {
149
+ if (text.length <= DIRTY_PATCH_CHARS) return text;
150
+ const half = Math.floor(DIRTY_PATCH_CHARS / 2);
151
+ const omitted = text.length - (half * 2);
152
+ return `${text.slice(0, half)}\n\n[... ${omitted} patch characters omitted ...]\n\n${text.slice(-half)}`;
153
+ }
154
+
155
+ async function runGit(cwd: string, args: string[], signal?: AbortSignal): Promise<string> {
156
+ const result = await execFileAsync("git", args, {
157
+ cwd,
158
+ encoding: "utf8",
159
+ timeout: GIT_TIMEOUT_MS,
160
+ maxBuffer: GIT_OUTPUT_LIMIT,
161
+ signal,
162
+ });
163
+ return result.stdout;
164
+ }
165
+
166
+ async function readUntrackedPreviews(
167
+ root: string,
168
+ files: DirtyFileState[],
169
+ ): Promise<string> {
170
+ const sections: string[] = [];
171
+ let remaining = DIRTY_PATCH_CHARS;
172
+ for (const file of files) {
173
+ if (file.status !== "??" || isSensitivePath(file.path) || remaining <= 0) continue;
174
+ const absolute = path.resolve(root, file.path);
175
+ const relative = path.relative(root, absolute);
176
+ if (relative.startsWith("..") || path.isAbsolute(relative)) continue;
177
+ try {
178
+ const metadata = await stat(absolute);
179
+ if (!metadata.isFile()) continue;
180
+ const buffer = await readFile(absolute);
181
+ const header = `\n--- /dev/null\n+++ b/${file.path}\n`;
182
+ if (buffer.includes(0)) {
183
+ const binary = `${header}[binary untracked file: ${buffer.length} bytes]\n`;
184
+ sections.push(binary.slice(0, remaining));
185
+ remaining -= binary.length;
186
+ continue;
187
+ }
188
+ const text = buffer.toString("utf8");
189
+ const limit = Math.min(UNTRACKED_FILE_CHARS, remaining);
190
+ const preview = text.length <= limit
191
+ ? text
192
+ : `${text.slice(0, Math.floor(limit / 2))}\n[... untracked content truncated ...]\n${text.slice(-Math.floor(limit / 2))}`;
193
+ const section = `${header}${preview}\n`;
194
+ sections.push(section.slice(0, remaining));
195
+ remaining -= section.length;
196
+ } catch {
197
+ // A file can disappear between status and snapshot; its status remains useful.
198
+ }
199
+ }
200
+ return sections.join("");
201
+ }
202
+
203
+ export async function getGitEngineeringState(cwd?: string, signal?: AbortSignal): Promise<GitEngineeringState> {
204
+ if (!cwd) return { available: false, files: [], patch: "", sensitiveFilesOmitted: 0 };
205
+ try {
206
+ const root = (await runGit(cwd, ["rev-parse", "--show-toplevel"], signal)).trim();
207
+ const status = await runGit(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"], signal);
208
+ const allFiles = parseGitStatusPorcelainV1Z(status);
209
+ const sensitiveFilesOmitted = allFiles.filter((file) => isSensitivePath(file.path)).length;
210
+ const files = allFiles.filter((file) => !isSensitivePath(file.path));
211
+ const trackedPaths = files.filter((file) => file.status !== "??").map((file) => file.path).slice(0, 250);
212
+ const stagedArgs = ["diff", "--cached", "--no-ext-diff", "--no-color", "--unified=2"];
213
+ const unstagedArgs = ["diff", "--no-ext-diff", "--no-color", "--unified=2"];
214
+ if (trackedPaths.length > 0) {
215
+ stagedArgs.push("--", ...trackedPaths);
216
+ unstagedArgs.push("--", ...trackedPaths);
217
+ } else {
218
+ // An unmatched pathspec avoids reading unrelated or sensitive tracked diffs.
219
+ stagedArgs.push("--", ":(exclude,top)**");
220
+ unstagedArgs.push("--", ":(exclude,top)**");
221
+ }
222
+ const [staged, unstaged, untracked] = await Promise.all([
223
+ runGit(root, stagedArgs, signal),
224
+ runGit(root, unstagedArgs, signal),
225
+ readUntrackedPreviews(root, files),
226
+ ]);
227
+ const sections = [
228
+ staged ? `## Staged changes\n${staged}` : "",
229
+ unstaged ? `## Unstaged changes\n${unstaged}` : "",
230
+ untracked ? `## Untracked files${untracked}` : "",
231
+ ].filter(Boolean);
232
+ return {
233
+ available: true,
234
+ files,
235
+ patch: truncatePatch(redactLikelySecrets(sections.join("\n\n"))),
236
+ sensitiveFilesOmitted,
237
+ };
238
+ } catch (error) {
239
+ if (signal?.aborted) throw error;
240
+ return { available: false, files: [], patch: "", sensitiveFilesOmitted: 0 };
241
+ }
85
242
  }
86
243
 
87
244
  const REQUIRED_SECTION_PATTERNS = [
@@ -94,11 +251,8 @@ const REQUIRED_SECTION_PATTERNS = [
94
251
  ];
95
252
 
96
253
  export function validateSummaryOutput(response: AssistantMessage): string {
97
- if (response.stopReason === "length") {
98
- throw new Error("Compaction summary was truncated due to output length limit (stopReason=length).");
99
- }
100
- if (response.stopReason === "error") {
101
- throw new Error("Compaction model reported stopReason=error.");
254
+ if (response.stopReason !== "stop") {
255
+ throw new Error(`Compaction model did not complete successfully (stopReason="${response.stopReason}").`);
102
256
  }
103
257
 
104
258
  // Reject accidental tool calls
@@ -136,15 +290,72 @@ export function computeCompactionTokenCeiling(
136
290
  ? config.maxSummaryTokens
137
291
  : 8192;
138
292
 
139
- const reserveDerived = Math.max(4096, Math.floor(0.8 * reserveTokens));
293
+ if (!Number.isFinite(reserveTokens) || reserveTokens <= 0) {
294
+ throw new Error(`Compaction reserveTokens must be positive; received ${reserveTokens}.`);
295
+ }
296
+ const reserveDerived = Math.max(1, Math.floor(0.8 * reserveTokens));
140
297
  const modelLimit = model.maxTokens > 0 ? model.maxTokens : configuredMax;
141
298
 
142
299
  return Math.min(configuredMax, reserveDerived, modelLimit);
143
300
  }
144
301
 
302
+ function errorStatus(err: unknown): number | undefined {
303
+ if (!err || typeof err !== "object") return undefined;
304
+ for (const key of ["status", "statusCode", "httpStatus"]) {
305
+ const value = (err as Record<string, unknown>)[key];
306
+ if (typeof value === "number") return value;
307
+ }
308
+ return undefined;
309
+ }
310
+
311
+ export function isFatalCompactionError(err: unknown): boolean {
312
+ if (!err) return false;
313
+ const status = errorStatus(err);
314
+ if (status === 401 || status === 402 || status === 403) return true;
315
+ const name = err instanceof Error ? err.name.toLowerCase() : "";
316
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
317
+ return (
318
+ name === "aborterror" ||
319
+ msg.includes("cancelled") ||
320
+ msg.includes("canceled") ||
321
+ msg.includes("unauthorized") ||
322
+ msg.includes("invalid_api_key") ||
323
+ msg.includes("authentication failed") ||
324
+ msg.includes("forbidden") ||
325
+ msg.includes("insufficient_quota") ||
326
+ msg.includes("billing exhausted") ||
327
+ msg.includes("payment required")
328
+ );
329
+ }
330
+
331
+ export function combineCompactionUsage(first?: Usage, second?: Usage): Usage | undefined {
332
+ if (!first) return second;
333
+ if (!second) return first;
334
+ return {
335
+ input: first.input + second.input,
336
+ output: first.output + second.output,
337
+ cacheRead: first.cacheRead + second.cacheRead,
338
+ cacheWrite: first.cacheWrite + second.cacheWrite,
339
+ ...(first.cacheWrite1h !== undefined || second.cacheWrite1h !== undefined
340
+ ? { cacheWrite1h: (first.cacheWrite1h ?? 0) + (second.cacheWrite1h ?? 0) }
341
+ : {}),
342
+ ...(first.reasoning !== undefined || second.reasoning !== undefined
343
+ ? { reasoning: (first.reasoning ?? 0) + (second.reasoning ?? 0) }
344
+ : {}),
345
+ totalTokens: first.totalTokens + second.totalTokens,
346
+ cost: {
347
+ input: first.cost.input + second.cost.input,
348
+ output: first.cost.output + second.cost.output,
349
+ cacheRead: first.cost.cacheRead + second.cost.cacheRead,
350
+ cacheWrite: first.cost.cacheWrite + second.cost.cacheWrite,
351
+ total: first.cost.total + second.cost.total,
352
+ },
353
+ };
354
+ }
355
+
145
356
  export interface RunSmartCompactionOptions {
146
357
  event: SessionBeforeCompactEvent;
147
- ctx: Pick<ExtensionContext, "model" | "modelRegistry" | "thinkingLevel">;
358
+ ctx: Pick<ExtensionContext, "model" | "modelRegistry" | "thinkingLevel" | "cwd">;
148
359
  config: SmartCompactionConfig;
149
360
  }
150
361
 
@@ -196,10 +407,6 @@ export async function runSmartCompaction(
196
407
  ],
197
408
  };
198
409
 
199
- // Multi-Stage Retry Ladder:
200
- // Stage 1: Primary configured model with requested reasoning
201
- // Stage 2: Primary configured model with reasoning OFF
202
- // Stage 3: Session model with reasoning OFF (if different)
203
410
  type AttemptPlan = {
204
411
  model: Model<Api>;
205
412
  reasoning?: "off" | "low" | "medium" | "high" | "max";
@@ -211,22 +418,27 @@ export async function runSmartCompaction(
211
418
  ? ctx.thinkingLevel
212
419
  : config.thinkingLevel;
213
420
 
421
+ const primaryReasoning = primaryModel.reasoning && desiredThinking && desiredThinking !== "off"
422
+ ? (desiredThinking as AttemptPlan["reasoning"])
423
+ : undefined;
214
424
  const plans: AttemptPlan[] = [
215
425
  {
216
426
  model: primaryModel,
217
- reasoning: primaryModel.reasoning && desiredThinking && desiredThinking !== "off" ? (desiredThinking as any) : undefined,
427
+ reasoning: primaryReasoning,
218
428
  isInherited: primaryIsInherited,
219
- stageLabel: "primary model with reasoning",
429
+ stageLabel: primaryReasoning ? "primary model with reasoning" : "primary model",
220
430
  },
221
- {
431
+ ];
432
+ if (primaryReasoning) {
433
+ plans.push({
222
434
  model: primaryModel,
223
435
  reasoning: "off",
224
436
  isInherited: primaryIsInherited,
225
437
  stageLabel: "primary model without reasoning",
226
- },
227
- ];
438
+ });
439
+ }
228
440
 
229
- if (sessionModel && sessionModel.id !== primaryModel.id) {
441
+ if (sessionModel && modelKey(sessionModel) !== modelKey(primaryModel)) {
230
442
  plans.push({
231
443
  model: sessionModel,
232
444
  reasoning: "off",
@@ -237,7 +449,7 @@ export async function runSmartCompaction(
237
449
 
238
450
  let lastError: Error | undefined;
239
451
  let finalSummaryText = "";
240
- let finalUsage: Usage | undefined;
452
+ let accumulatedUsage: Usage | undefined;
241
453
  let activeModel = primaryModel;
242
454
  let activeIsInherited = primaryIsInherited;
243
455
 
@@ -263,12 +475,17 @@ export async function runSmartCompaction(
263
475
  try {
264
476
  const response = await ctx.modelRegistry.complete(plan.model, context, completeOptions as any);
265
477
  signal?.throwIfAborted();
478
+ if (response.usage) {
479
+ accumulatedUsage = combineCompactionUsage(accumulatedUsage, response.usage);
480
+ }
266
481
  finalSummaryText = validateSummaryOutput(response);
267
- finalUsage = response.usage;
268
482
  lastError = undefined;
269
483
  break; // Success!
270
484
  } catch (err) {
271
485
  if (signal?.aborted) throw err;
486
+ if (isFatalCompactionError(err)) {
487
+ throw err instanceof Error ? err : new Error(String(err));
488
+ }
272
489
  lastError = err instanceof Error ? err : new Error(String(err));
273
490
  // Continue to next stage in retry ladder
274
491
  }
@@ -283,34 +500,52 @@ export async function runSmartCompaction(
283
500
  const currentOps = preparation.fileOps;
284
501
 
285
502
  const combinedModified = new Set([
286
- ...prior.modifiedFiles,
503
+ ...prior.touchedModifiedFiles,
287
504
  ...(currentOps?.written ?? []),
288
505
  ...(currentOps?.edited ?? []),
289
506
  ]);
290
507
 
291
508
  const combinedRead = new Set([
292
- ...prior.readFiles,
509
+ ...prior.touchedReadFiles,
293
510
  ...(currentOps?.read ?? []),
294
511
  ]);
295
512
 
296
- const readFilesList = [...combinedRead].filter((f) => !combinedModified.has(f)).sort();
297
- const modifiedFilesList = [...combinedModified].sort();
513
+ const allReadFiles = [...combinedRead].filter((file) => !combinedModified.has(file));
514
+ const allTouchedModifiedFiles = [...combinedModified];
515
+ const sensitiveTouchedFilesOmitted = new Set(
516
+ [...allReadFiles, ...allTouchedModifiedFiles].filter(isSensitivePath),
517
+ ).size;
518
+ const readFilesList = allReadFiles.filter((file) => !isSensitivePath(file)).sort();
519
+ const touchedModifiedFilesList = allTouchedModifiedFiles.filter((file) => !isSensitivePath(file)).sort();
520
+ const gitState = await getGitEngineeringState(ctx.cwd, signal);
521
+ const activeDirtyFilesList = gitState.files.map((file) => file.path);
298
522
 
299
523
  const fileOpsXml = formatFileOperationsXml({
300
- read: readFilesList,
301
- written: modifiedFilesList,
524
+ readFiles: readFilesList,
525
+ touchedModifiedFiles: touchedModifiedFilesList,
526
+ activeDirtyFiles: activeDirtyFilesList,
527
+ dirtyPatch: gitState.patch,
528
+ dirtyStateAvailable: gitState.available,
529
+ sensitiveFilesOmitted: gitState.sensitiveFilesOmitted + sensitiveTouchedFilesOmitted,
302
530
  });
303
531
 
304
532
  const finalSummary = `${finalSummaryText}${fileOpsXml}`;
305
533
  const cycleCount = prior.cycleCount + 1;
306
534
 
307
535
  const details: SmartCompactionDetails = {
308
- schemaVersion: 2,
536
+ schemaVersion: 3,
309
537
  customCompactor: "smart-compaction",
310
- model: `${activeModel.provider}/${activeModel.id}`,
538
+ configuredModel: config.model || "inherit",
539
+ resolvedModel: modelKey(activeModel),
311
540
  isInherited: activeIsInherited,
312
- readFiles: readFilesList,
313
- modifiedFiles: modifiedFilesList,
541
+ touchedReadFiles: readFilesList,
542
+ touchedModifiedFiles: touchedModifiedFilesList,
543
+ activeDirtyFiles: activeDirtyFilesList,
544
+ activeDirtyFileStates: gitState.files,
545
+ activeDirtyPatch: gitState.patch,
546
+ dirtyStateAvailable: gitState.available,
547
+ sensitiveDirtyFilesOmitted: gitState.sensitiveFilesOmitted,
548
+ sensitiveTouchedFilesOmitted,
314
549
  cycleCount,
315
550
  timestamp: Date.now(),
316
551
  };
@@ -319,7 +554,7 @@ export async function runSmartCompaction(
319
554
  summary: finalSummary,
320
555
  firstKeptEntryId: preparation.firstKeptEntryId,
321
556
  tokensBefore: preparation.tokensBefore,
322
- usage: finalUsage,
557
+ usage: accumulatedUsage,
323
558
  details,
324
559
  };
325
560
  }
@@ -10,7 +10,7 @@ import {
10
10
  saveSmartCompactionConfig,
11
11
  type SmartCompactionConfig,
12
12
  } from "./config.ts";
13
- import { runSmartCompaction } from "./engine.ts";
13
+ import { resolveCompactionModel, runSmartCompaction } from "./engine.ts";
14
14
 
15
15
  const STATUS_KEY = "smart-compaction";
16
16
 
@@ -64,7 +64,7 @@ export function createSmartCompactionExtension(options: SmartCompactionExtension
64
64
  if (event.fromExtension) {
65
65
  const details = event.compactionEntry.details as Record<string, unknown> | undefined;
66
66
  if (details?.customCompactor === "smart-compaction") {
67
- const model = String(details.model ?? "session model");
67
+ const model = String(details.resolvedModel ?? details.model ?? "session model");
68
68
  ctx.ui?.notify(`Smart Compaction completed (${model})`, "info");
69
69
  }
70
70
  }
@@ -85,17 +85,21 @@ export function createSmartCompactionExtension(options: SmartCompactionExtension
85
85
  return;
86
86
  }
87
87
 
88
- // Validate if model exists in registry
88
+ // Strict validation against available models in registry
89
89
  const available = cmdCtx.modelRegistry.getAvailable();
90
90
  const match = available.find(
91
91
  (m) => m.id === requested || `${m.provider}/${m.id}` === requested,
92
92
  );
93
93
 
94
94
  if (!match) {
95
- cmdCtx.ui.notify(`Model "${requested}" not found in available models. Setting anyway.`, "warning");
95
+ cmdCtx.ui.notify(
96
+ `Model "${requested}" not found in available models. Run /compaction-model without arguments to select from active providers.`,
97
+ "error",
98
+ );
99
+ return;
96
100
  }
97
101
 
98
- config.model = match ? `${match.provider}/${match.id}` : requested;
102
+ config.model = `${match.provider}/${match.id}`;
99
103
  saveSmartCompactionConfig(config, options.configFile);
100
104
  updateStatus();
101
105
  cmdCtx.ui.notify(`Compaction model set to: ${config.model}`, "info");
@@ -154,29 +158,29 @@ export function createSmartCompactionExtension(options: SmartCompactionExtension
154
158
  cmdCtx.ui.notify("Smart Compaction disabled (using default compactor).", "info");
155
159
  return;
156
160
  }
157
- if (sub.startsWith("model ")) {
158
- const target = args.trim().slice(6).trim();
159
- config.model = target || "inherit";
160
- saveSmartCompactionConfig(config, options.configFile);
161
- updateStatus();
162
- cmdCtx.ui.notify(`Smart Compaction model set to: ${config.model}`, "info");
163
- return;
161
+
162
+ let resolvedInfo = "inherit";
163
+ try {
164
+ const { model, isFallback, fallbackReason } = resolveCompactionModel(cmdCtx, config.model);
165
+ resolvedInfo = `${model.provider}/${model.id}`;
166
+ if (isFallback) {
167
+ resolvedInfo += ` (FALLBACK: ${fallbackReason})`;
168
+ }
169
+ } catch {
170
+ resolvedInfo = "unresolved";
164
171
  }
165
172
 
166
- // Default status
167
- const currentModelDesc = config.model === "inherit"
168
- ? `inherit (${cmdCtx.model ? `${cmdCtx.model.provider}/${cmdCtx.model.id}` : "active session model"})`
169
- : config.model;
170
173
  const currentThinkingDesc = config.thinkingLevel === "inherit"
171
174
  ? `inherit (${cmdCtx.thinkingLevel ?? "session default"})`
172
175
  : (config.thinkingLevel ?? "inherit");
173
- const maxTokensDesc = config.maxSummaryTokens ? `${config.maxSummaryTokens}` : "unlimited (full model output capacity)";
176
+ const maxTokensDesc = `${config.maxSummaryTokens ?? 8192} tokens`;
174
177
 
175
178
  const status = [
176
179
  `Smart Compaction: ${config.enabled ? "ENABLED" : "DISABLED"}`,
177
- `Model: ${currentModelDesc}`,
180
+ `Configured Model: ${config.model}`,
181
+ `Resolved Model: ${resolvedInfo}`,
178
182
  `Thinking Level: ${currentThinkingDesc}`,
179
- `Max Output Tokens: ${maxTokensDesc}`,
183
+ `Summary Token Ceiling: ${maxTokensDesc}`,
180
184
  "",
181
185
  "Commands:",
182
186
  " /smart-compaction enable | disable",
@@ -1,4 +1,5 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import { isSensitivePath, redactLikelySecrets } from "../shared/redaction.ts";
2
3
 
3
4
  export const SMART_COMPACTION_SYSTEM_PROMPT = `You are a high-fidelity context continuity synthesizer for an autonomous coding agent.
4
5
  Your task is to analyze the preceding conversation and produce a comprehensive, structured checkpoint summary.
@@ -93,7 +94,6 @@ Use this EXACT format with all 6 numbered section headings:
93
94
 
94
95
  const TOOL_RESULT_HEAD_CHARS = 1200;
95
96
  const TOOL_RESULT_TAIL_CHARS = 1200;
96
- const TOOL_RESULT_TOTAL_BUDGET = TOOL_RESULT_HEAD_CHARS + TOOL_RESULT_TAIL_CHARS;
97
97
 
98
98
  export function truncateHeadAndTail(text: string, headChars = TOOL_RESULT_HEAD_CHARS, tailChars = TOOL_RESULT_TAIL_CHARS): string {
99
99
  const maxTotal = headChars + tailChars;
@@ -105,6 +105,15 @@ export function truncateHeadAndTail(text: string, headChars = TOOL_RESULT_HEAD_C
105
105
  return `${head}\n\n[... ${omitted} characters omitted; showing beginning and end of output ...]\n\n${tail}`;
106
106
  }
107
107
 
108
+ export function escapeXml(text: string): string {
109
+ return text
110
+ .replace(/&/g, "&amp;")
111
+ .replace(/</g, "&lt;")
112
+ .replace(/>/g, "&gt;")
113
+ .replace(/"/g, "&quot;")
114
+ .replace(/'/g, "&apos;");
115
+ }
116
+
108
117
  export function sanitizeTagContent(text: string): string {
109
118
  return text
110
119
  .replace(/<\/conversation>/gi, "<\\/conversation>")
@@ -137,11 +146,13 @@ function extractTextContent(content: unknown): string {
137
146
 
138
147
  export function serializeConversationForCompaction(messages: AgentMessage[]): string {
139
148
  const parts: string[] = [];
149
+ const sensitiveToolCallIds = new Set<string>();
150
+ const safeTranscriptText = (text: string) => sanitizeTagContent(redactLikelySecrets(text));
140
151
 
141
152
  for (const msg of messages) {
142
153
  if (msg.role === "user") {
143
154
  const text = extractTextContent((msg as any).content);
144
- if (text) parts.push(`[User]:\n${sanitizeTagContent(text)}`);
155
+ if (text) parts.push(`[User]:\n${safeTranscriptText(text)}`);
145
156
  } else if (msg.role === "assistant") {
146
157
  const content = (msg as any).content;
147
158
  const thinkingBlocks: string[] = [];
@@ -157,8 +168,17 @@ export function serializeConversationForCompaction(messages: AgentMessage[]): st
157
168
  textBlocks.push(block.text.trim());
158
169
  } else if (block.type === "toolCall") {
159
170
  const args = block.arguments as Record<string, unknown>;
171
+ const targetPath = typeof args?.path === "string" ? args.path : "";
172
+ const sensitive = ["read", "write", "edit"].includes(block.name)
173
+ && targetPath
174
+ && isSensitivePath(targetPath);
175
+ if (sensitive) {
176
+ if (typeof block.id === "string") sensitiveToolCallIds.add(block.id);
177
+ toolCallBlocks.push(`${block.name}([sensitive path and arguments omitted])`);
178
+ continue;
179
+ }
160
180
  const formattedArgs = Object.entries(args ?? {})
161
- .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
181
+ .map(([k, v]) => `${k}=${redactLikelySecrets(JSON.stringify(v))}`)
162
182
  .join(", ");
163
183
  toolCallBlocks.push(`${block.name}(${formattedArgs})`);
164
184
  }
@@ -169,49 +189,76 @@ export function serializeConversationForCompaction(messages: AgentMessage[]): st
169
189
 
170
190
  if (thinkingBlocks.length > 0) {
171
191
  const combinedThinking = thinkingBlocks.join("\n");
172
- parts.push(`[Assistant Thinking]:\n${sanitizeTagContent(truncateHeadAndTail(combinedThinking, 800, 800))}`);
192
+ parts.push(`[Assistant Thinking]:\n${safeTranscriptText(truncateHeadAndTail(combinedThinking, 800, 800))}`);
173
193
  }
174
194
  if (textBlocks.length > 0) {
175
- parts.push(`[Assistant]:\n${sanitizeTagContent(textBlocks.join("\n"))}`);
195
+ parts.push(`[Assistant]:\n${safeTranscriptText(textBlocks.join("\n"))}`);
176
196
  }
177
197
  if (toolCallBlocks.length > 0) {
178
- parts.push(`[Assistant Tool Calls]:\n${sanitizeTagContent(toolCallBlocks.join("\n"))}`);
198
+ parts.push(`[Assistant Tool Calls]:\n${safeTranscriptText(toolCallBlocks.join("\n"))}`);
179
199
  }
180
200
  } else if (msg.role === "toolResult") {
201
+ if (sensitiveToolCallIds.has((msg as any).toolCallId)) {
202
+ parts.push("[Tool Result]:\n[sensitive tool result omitted]");
203
+ continue;
204
+ }
181
205
  const text = extractTextContent((msg as any).content);
182
206
  if (text) {
183
- parts.push(`[Tool Result]:\n${sanitizeTagContent(truncateHeadAndTail(text, TOOL_RESULT_HEAD_CHARS, TOOL_RESULT_TAIL_CHARS))}`);
207
+ parts.push(`[Tool Result]:\n${safeTranscriptText(truncateHeadAndTail(text, TOOL_RESULT_HEAD_CHARS, TOOL_RESULT_TAIL_CHARS))}`);
184
208
  }
185
209
  } else if (msg.role === "custom") {
186
210
  const text = extractTextContent((msg as any).content);
187
- if (text) parts.push(`[System Event]:\n${sanitizeTagContent(text)}`);
211
+ if (text) parts.push(`[System Event]:\n${safeTranscriptText(text)}`);
188
212
  } else if (msg.role === "bashExecution") {
189
213
  const cmd = (msg as any).command ?? "";
190
214
  const out = (msg as any).output ?? "";
191
- parts.push(`[Command Executed]:\n$ ${cmd}\n${sanitizeTagContent(truncateHeadAndTail(out, 800, 800))}`);
215
+ parts.push(`[Command Executed]:\n$ ${safeTranscriptText(cmd)}\n${safeTranscriptText(truncateHeadAndTail(out, 800, 800))}`);
192
216
  } else if (msg.role === "compactionSummary" || msg.role === "branchSummary") {
193
217
  const summary = (msg as any).summary ?? "";
194
- if (summary) parts.push(`[Prior Summary]:\n${sanitizeTagContent(summary)}`);
218
+ if (summary) parts.push(`[Prior Summary]:\n${safeTranscriptText(summary)}`);
195
219
  }
196
220
  }
197
221
 
198
222
  return parts.join("\n\n---\n\n");
199
223
  }
200
224
 
201
- export function formatFileOperationsXml(fileOps?: { read?: Iterable<string>; written?: Iterable<string>; edited?: Iterable<string> }): string {
202
- if (!fileOps) return "";
203
- const readSet = new Set(fileOps.read ?? []);
204
- const modifiedSet = new Set([...(fileOps.written ?? []), ...(fileOps.edited ?? [])]);
205
- const readOnly = [...readSet].filter((f) => !modifiedSet.has(f)).sort();
206
- const modified = [...modifiedSet].sort();
225
+ export function formatFileOperationsXml(options?: {
226
+ readFiles?: Iterable<string>;
227
+ touchedModifiedFiles?: Iterable<string>;
228
+ activeDirtyFiles?: Iterable<string>;
229
+ dirtyPatch?: string;
230
+ dirtyStateAvailable?: boolean;
231
+ sensitiveFilesOmitted?: number;
232
+ }): string {
233
+ if (!options) return "";
234
+ const readSet = new Set(options.readFiles ?? []);
235
+ const touchedSet = new Set(options.touchedModifiedFiles ?? []);
236
+ const dirtySet = new Set(options.activeDirtyFiles ?? []);
237
+
238
+ const readOnly = [...readSet].filter((f) => !touchedSet.has(f)).sort();
239
+ const touched = [...touchedSet].sort();
240
+ const dirty = [...dirtySet].sort();
207
241
 
208
242
  const sections: string[] = [];
209
243
  if (readOnly.length > 0) {
210
- sections.push(`<read-files>\n${readOnly.join("\n")}\n</read-files>`);
244
+ sections.push(`<read-files>\n${readOnly.map(escapeXml).join("\n")}\n</read-files>`);
245
+ }
246
+ if (touched.length > 0) {
247
+ sections.push(`<touched-files>\n${touched.map(escapeXml).join("\n")}\n</touched-files>`);
211
248
  }
212
- if (modified.length > 0) {
213
- sections.push(`<modified-files>\n${modified.join("\n")}\n</modified-files>`);
249
+ if (dirty.length > 0) {
250
+ sections.push(`<uncommitted-dirty-files>\n${dirty.map(escapeXml).join("\n")}\n</uncommitted-dirty-files>`);
214
251
  }
252
+ if (options.dirtyPatch) {
253
+ sections.push(`<uncommitted-diff>\n${escapeXml(options.dirtyPatch)}\n</uncommitted-diff>`);
254
+ }
255
+ if (options.dirtyStateAvailable === false) {
256
+ sections.push("<uncommitted-state-unavailable />");
257
+ }
258
+ if ((options.sensitiveFilesOmitted ?? 0) > 0) {
259
+ sections.push(`<sensitive-dirty-files-omitted count="${options.sensitiveFilesOmitted}" />`);
260
+ }
261
+
215
262
  if (sections.length === 0) return "";
216
263
  return `\n\n${sections.join("\n\n")}`;
217
264
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shariq-pi-extensions",
3
- "version": "0.2.14",
3
+ "version": "0.2.15",
4
4
  "description": "Cross-platform extension suite for the Pi coding agent.",
5
5
  "license": "MIT",
6
6
  "author": "Shariq Riaz",