shariq-pi-extensions 0.2.14 → 0.2.16

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,17 @@ 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.
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`.
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
+
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
+ - **Lockfile & Bundle Diff Filtering**: Automatically isolates `package-lock.json`, `Cargo.lock`, `yarn.lock`, and minified assets from raw diffs to preserve token budgets for source code logic.
57
+ - **Active Background Terminal Awareness**: Automatically identifies running background processes and records them under `<active-background-processes>` to prevent duplicate server launches.
58
+ - **Hierarchical Delta-Merging**: Carries forward immutable goals and user constraints across 10+ compaction cycles while condensing older completed items to prevent summary bloat.
59
+ - **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.
60
+ - **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.
61
+ - **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
62
 
55
63
  ### [Background terminals](../extensions/background-terminals/README.md)
56
64
 
@@ -48,6 +48,10 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
48
48
  const getManager = (): TerminalManager => {
49
49
  if (manager) return manager;
50
50
  manager = new TerminalManager();
51
+ (globalThis as any).__pi_get_active_terminals = () => {
52
+ if (!manager) return [];
53
+ return manager.list().filter((s) => s.status === "running").map((s) => `${s.id}: "${oneLine(s.title)}" (pid ${s.pid})`);
54
+ };
51
55
  manager.setOnSettled((snapshot) => {
52
56
  if (!modelOwned.delete(snapshot.id)) {
53
57
  ui?.notify(
@@ -393,4 +397,8 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
393
397
  description: "Open the background terminal control center",
394
398
  handler: async (_args, ctx) => openCommand(ctx),
395
399
  });
400
+
401
+ pi.on("session_shutdown", () => {
402
+ delete (globalThis as any).__pi_get_active_terminals;
403
+ });
396
404
  }
@@ -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,25 @@ 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>`, `<modified-lockfiles-and-assets>`, `<active-background-processes>`, and bounded `<uncommitted-diff>` blocks. Lockfiles and minified bundles are automatically excluded from raw diffing to preserve token budgets for real source code, while active background terminals/daemons are recorded to prevent port conflicts.
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
+ - **Lockfile & Bundle Diff Exclusion**: Automatically excludes `package-lock.json`, `Cargo.lock`, `yarn.lock`, `pnpm-lock.yaml`, and minified assets from raw diffs, recording their status under `<modified-lockfiles-and-assets>` to preserve 100% of diff token headroom for source code.
27
+ - **Background Daemon & Terminal Awareness**: Automatically detects running background terminals/processes and injects their status into `<active-background-processes>` so the successor agent never launches duplicate services.
26
28
  - **Retry Ladder**: If an attempt encounters output limits or transient reasoning timeouts:
27
29
  1. Primary configured model with requested reasoning.
28
30
  2. Primary model with reasoning off (unblocks reasoning/token caps).
29
31
  3. Session model with reasoning off.
30
32
  4. Graceful fallback to Pi's default compactor if all stages fail.
31
33
  - **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.
34
+ - **Secret-Safe Persistence**: Redacts credential-shaped values and omits sensitive tool paths, results, dirty files, and patches before durable compaction state is created.
35
+ - **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
36
 
34
37
  ## Model Selection
35
38
 
@@ -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,53 @@ 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
+ lockfilesAndGeneratedAssets: string[];
29
+ }
30
+
13
31
  export interface SmartCompactionDetails {
14
- schemaVersion: 2;
32
+ schemaVersion: 3;
15
33
  customCompactor: "smart-compaction";
16
- model: string;
34
+ configuredModel: string;
35
+ resolvedModel: string;
17
36
  isInherited: boolean;
18
- readFiles: string[];
19
- modifiedFiles: string[];
37
+ touchedReadFiles: string[];
38
+ touchedModifiedFiles: string[];
39
+ activeDirtyFiles: string[];
40
+ activeDirtyFileStates: DirtyFileState[];
41
+ activeDirtyPatch: string;
42
+ dirtyStateAvailable: boolean;
43
+ sensitiveDirtyFilesOmitted: number;
44
+ sensitiveTouchedFilesOmitted: number;
45
+ activeBackgroundProcesses?: string[];
46
+ lockfilesAndGeneratedAssets?: string[];
20
47
  cycleCount: number;
21
48
  timestamp: number;
22
49
  }
23
50
 
51
+ export function modelKey(model: Pick<Model<Api>, "provider" | "id">): string {
52
+ return `${model.provider}/${model.id}`;
53
+ }
54
+
24
55
  export function resolveCompactionModel(
25
56
  ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
26
57
  configuredModelString?: string,
27
- ): { model: Model<Api>; isInherited: boolean } {
58
+ ): { model: Model<Api>; isInherited: boolean; isFallback: boolean; fallbackReason?: string } {
28
59
  const trimmed = configuredModelString?.trim();
29
60
  if (!trimmed || trimmed === "inherit") {
30
61
  if (!ctx.model) {
31
62
  throw new Error("No active session model available to inherit for compaction.");
32
63
  }
33
- return { model: ctx.model, isInherited: true };
64
+ return { model: ctx.model, isInherited: true, isFallback: false };
34
65
  }
35
66
 
36
67
  // Parse "provider/model" or modelId
@@ -44,36 +75,48 @@ export function resolveCompactionModel(
44
75
  }
45
76
 
46
77
  if (candidate) {
47
- return { model: candidate, isInherited: false };
78
+ return { model: candidate, isInherited: false, isFallback: false };
48
79
  }
49
80
 
50
81
  if (ctx.model) {
51
- return { model: ctx.model, isInherited: true };
82
+ return {
83
+ model: ctx.model,
84
+ isInherited: true,
85
+ isFallback: true,
86
+ fallbackReason: `Configured compaction model "${trimmed}" is unavailable in model registry.`,
87
+ };
52
88
  }
53
89
 
54
90
  throw new Error(`Configured compaction model "${trimmed}" was not found in model registry.`);
55
91
  }
56
92
 
57
93
  export function extractPriorFileState(branchEntries?: any[]): {
58
- readFiles: Set<string>;
59
- modifiedFiles: Set<string>;
94
+ touchedReadFiles: Set<string>;
95
+ touchedModifiedFiles: Set<string>;
60
96
  cycleCount: number;
61
97
  } {
62
- const readFiles = new Set<string>();
63
- const modifiedFiles = new Set<string>();
98
+ const touchedReadFiles = new Set<string>();
99
+ const touchedModifiedFiles = new Set<string>();
64
100
  let cycleCount = 0;
65
101
 
66
- if (!Array.isArray(branchEntries)) return { readFiles, modifiedFiles, cycleCount };
102
+ if (!Array.isArray(branchEntries)) return { touchedReadFiles, touchedModifiedFiles, cycleCount };
67
103
 
68
104
  for (let i = branchEntries.length - 1; i >= 0; i--) {
69
105
  const entry = branchEntries[i];
70
106
  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);
107
+ const details = entry.details as Partial<SmartCompactionDetails> & {
108
+ readFiles?: string[];
109
+ modifiedFiles?: string[];
110
+ touchedReadFiles?: string[];
111
+ touchedModifiedFiles?: string[];
112
+ };
113
+ const readList = details.touchedReadFiles ?? details.readFiles;
114
+ if (Array.isArray(readList)) {
115
+ for (const file of readList) touchedReadFiles.add(file);
74
116
  }
75
- if (Array.isArray(details.modifiedFiles)) {
76
- for (const file of details.modifiedFiles) modifiedFiles.add(file);
117
+ const modifiedList = details.touchedModifiedFiles ?? details.modifiedFiles;
118
+ if (Array.isArray(modifiedList)) {
119
+ for (const file of modifiedList) touchedModifiedFiles.add(file);
77
120
  }
78
121
  if (typeof details.cycleCount === "number") {
79
122
  cycleCount = Math.max(cycleCount, details.cycleCount);
@@ -81,7 +124,165 @@ export function extractPriorFileState(branchEntries?: any[]): {
81
124
  }
82
125
  }
83
126
 
84
- return { readFiles, modifiedFiles, cycleCount };
127
+ return { touchedReadFiles, touchedModifiedFiles, cycleCount };
128
+ }
129
+
130
+ const GENERATED_OR_LOCKFILE_PATTERNS = [
131
+ /(?:^|\/)package-lock\.json$/i,
132
+ /(?:^|\/)pnpm-lock\.yaml$/i,
133
+ /(?:^|\/)yarn\.lock$/i,
134
+ /(?:^|\/)Cargo\.lock$/i,
135
+ /(?:^|\/)poetry\.lock$/i,
136
+ /(?:^|\/)bun\.lockb?$/i,
137
+ /(?:^|\/)composer\.lock$/i,
138
+ /(?:^|\/)flake\.lock$/i,
139
+ /(?:^|\/)mise\.lock$/i,
140
+ /\.min\.(?:js|css|mjs)$/i,
141
+ /\.map$/i,
142
+ /\.wasm$/i,
143
+ /(?:^|\/)(?:dist|build|out|\.next|\.nuxt|\.turbo|\.parcel-cache)\//i,
144
+ ];
145
+
146
+ export function isGeneratedOrLockfile(filePath: string): boolean {
147
+ const normalized = filePath.replace(/\\/g, "/");
148
+ return GENERATED_OR_LOCKFILE_PATTERNS.some((pattern) => pattern.test(normalized));
149
+ }
150
+
151
+ export function getActiveBackgroundProcesses(): string[] {
152
+ try {
153
+ const fn = (globalThis as any).__pi_get_active_terminals;
154
+ if (typeof fn === "function") {
155
+ const active = fn();
156
+ if (Array.isArray(active)) {
157
+ return active.filter((item): item is string => typeof item === "string" && Boolean(item.trim()));
158
+ }
159
+ }
160
+ } catch {
161
+ // Best-effort inspection.
162
+ }
163
+ return [];
164
+ }
165
+
166
+ const execFileAsync = promisify(execFile);
167
+ const GIT_TIMEOUT_MS = 5_000;
168
+ const GIT_OUTPUT_LIMIT = 2 * 1024 * 1024;
169
+ const DIRTY_PATCH_CHARS = 16_000;
170
+ const UNTRACKED_FILE_CHARS = 4_000;
171
+
172
+ export function parseGitStatusPorcelainV1Z(output: string): DirtyFileState[] {
173
+ const records = output.split("\0");
174
+ const files: DirtyFileState[] = [];
175
+ for (let index = 0; index < records.length; index++) {
176
+ const record = records[index];
177
+ if (!record || record.length < 4) continue;
178
+ const status = record.slice(0, 2);
179
+ const filePath = record.slice(3);
180
+ files.push({ path: filePath, status });
181
+ // In porcelain v1 -z output, rename/copy records are followed by the source path.
182
+ if (status.includes("R") || status.includes("C")) index++;
183
+ }
184
+ return [...new Map(files.map((file) => [file.path, file])).values()];
185
+ }
186
+
187
+ function truncatePatch(text: string): string {
188
+ if (text.length <= DIRTY_PATCH_CHARS) return text;
189
+ const half = Math.floor(DIRTY_PATCH_CHARS / 2);
190
+ const omitted = text.length - (half * 2);
191
+ return `${text.slice(0, half)}\n\n[... ${omitted} patch characters omitted ...]\n\n${text.slice(-half)}`;
192
+ }
193
+
194
+ async function runGit(cwd: string, args: string[], signal?: AbortSignal): Promise<string> {
195
+ const result = await execFileAsync("git", args, {
196
+ cwd,
197
+ encoding: "utf8",
198
+ timeout: GIT_TIMEOUT_MS,
199
+ maxBuffer: GIT_OUTPUT_LIMIT,
200
+ signal,
201
+ });
202
+ return result.stdout;
203
+ }
204
+
205
+ async function readUntrackedPreviews(
206
+ root: string,
207
+ files: DirtyFileState[],
208
+ ): Promise<string> {
209
+ const sections: string[] = [];
210
+ let remaining = DIRTY_PATCH_CHARS;
211
+ for (const file of files) {
212
+ if (file.status !== "??" || isSensitivePath(file.path) || isGeneratedOrLockfile(file.path) || remaining <= 0) continue;
213
+ const absolute = path.resolve(root, file.path);
214
+ const relative = path.relative(root, absolute);
215
+ if (relative.startsWith("..") || path.isAbsolute(relative)) continue;
216
+ try {
217
+ const metadata = await stat(absolute);
218
+ if (!metadata.isFile()) continue;
219
+ const buffer = await readFile(absolute);
220
+ const header = `\n--- /dev/null\n+++ b/${file.path}\n`;
221
+ if (buffer.includes(0)) {
222
+ const binary = `${header}[binary untracked file: ${buffer.length} bytes]\n`;
223
+ sections.push(binary.slice(0, remaining));
224
+ remaining -= binary.length;
225
+ continue;
226
+ }
227
+ const text = buffer.toString("utf8");
228
+ const limit = Math.min(UNTRACKED_FILE_CHARS, remaining);
229
+ const preview = text.length <= limit
230
+ ? text
231
+ : `${text.slice(0, Math.floor(limit / 2))}\n[... untracked content truncated ...]\n${text.slice(-Math.floor(limit / 2))}`;
232
+ const section = `${header}${preview}\n`;
233
+ sections.push(section.slice(0, remaining));
234
+ remaining -= section.length;
235
+ } catch {
236
+ // A file can disappear between status and snapshot; its status remains useful.
237
+ }
238
+ }
239
+ return sections.join("");
240
+ }
241
+
242
+ export async function getGitEngineeringState(cwd?: string, signal?: AbortSignal): Promise<GitEngineeringState> {
243
+ if (!cwd) return { available: false, files: [], patch: "", sensitiveFilesOmitted: 0, lockfilesAndGeneratedAssets: [] };
244
+ try {
245
+ const root = (await runGit(cwd, ["rev-parse", "--show-toplevel"], signal)).trim();
246
+ const status = await runGit(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"], signal);
247
+ const allFiles = parseGitStatusPorcelainV1Z(status);
248
+ const sensitiveFilesOmitted = allFiles.filter((file) => isSensitivePath(file.path)).length;
249
+ const files = allFiles.filter((file) => !isSensitivePath(file.path));
250
+
251
+ const codeFiles = files.filter((file) => !isGeneratedOrLockfile(file.path));
252
+ const lockOrGeneratedFiles = files.filter((file) => isGeneratedOrLockfile(file.path)).map((file) => file.path);
253
+
254
+ const trackedCodePaths = codeFiles.filter((file) => file.status !== "??").map((file) => file.path).slice(0, 250);
255
+ const stagedArgs = ["diff", "--cached", "--no-ext-diff", "--no-color", "--unified=2"];
256
+ const unstagedArgs = ["diff", "--no-ext-diff", "--no-color", "--unified=2"];
257
+ if (trackedCodePaths.length > 0) {
258
+ stagedArgs.push("--", ...trackedCodePaths);
259
+ unstagedArgs.push("--", ...trackedCodePaths);
260
+ } else {
261
+ // An unmatched pathspec avoids reading unrelated or sensitive tracked diffs.
262
+ stagedArgs.push("--", ":(exclude,top)**");
263
+ unstagedArgs.push("--", ":(exclude,top)**");
264
+ }
265
+ const [staged, unstaged, untracked] = await Promise.all([
266
+ runGit(root, stagedArgs, signal),
267
+ runGit(root, unstagedArgs, signal),
268
+ readUntrackedPreviews(root, codeFiles),
269
+ ]);
270
+ const sections = [
271
+ staged ? `## Staged changes\n${staged}` : "",
272
+ unstaged ? `## Unstaged changes\n${unstaged}` : "",
273
+ untracked ? `## Untracked files${untracked}` : "",
274
+ ].filter(Boolean);
275
+ return {
276
+ available: true,
277
+ files,
278
+ patch: truncatePatch(redactLikelySecrets(sections.join("\n\n"))),
279
+ sensitiveFilesOmitted,
280
+ lockfilesAndGeneratedAssets: lockOrGeneratedFiles,
281
+ };
282
+ } catch (error) {
283
+ if (signal?.aborted) throw error;
284
+ return { available: false, files: [], patch: "", sensitiveFilesOmitted: 0, lockfilesAndGeneratedAssets: [] };
285
+ }
85
286
  }
86
287
 
87
288
  const REQUIRED_SECTION_PATTERNS = [
@@ -94,11 +295,8 @@ const REQUIRED_SECTION_PATTERNS = [
94
295
  ];
95
296
 
96
297
  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.");
298
+ if (response.stopReason !== "stop") {
299
+ throw new Error(`Compaction model did not complete successfully (stopReason="${response.stopReason}").`);
102
300
  }
103
301
 
104
302
  // Reject accidental tool calls
@@ -136,15 +334,72 @@ export function computeCompactionTokenCeiling(
136
334
  ? config.maxSummaryTokens
137
335
  : 8192;
138
336
 
139
- const reserveDerived = Math.max(4096, Math.floor(0.8 * reserveTokens));
337
+ if (!Number.isFinite(reserveTokens) || reserveTokens <= 0) {
338
+ throw new Error(`Compaction reserveTokens must be positive; received ${reserveTokens}.`);
339
+ }
340
+ const reserveDerived = Math.max(1, Math.floor(0.8 * reserveTokens));
140
341
  const modelLimit = model.maxTokens > 0 ? model.maxTokens : configuredMax;
141
342
 
142
343
  return Math.min(configuredMax, reserveDerived, modelLimit);
143
344
  }
144
345
 
346
+ function errorStatus(err: unknown): number | undefined {
347
+ if (!err || typeof err !== "object") return undefined;
348
+ for (const key of ["status", "statusCode", "httpStatus"]) {
349
+ const value = (err as Record<string, unknown>)[key];
350
+ if (typeof value === "number") return value;
351
+ }
352
+ return undefined;
353
+ }
354
+
355
+ export function isFatalCompactionError(err: unknown): boolean {
356
+ if (!err) return false;
357
+ const status = errorStatus(err);
358
+ if (status === 401 || status === 402 || status === 403) return true;
359
+ const name = err instanceof Error ? err.name.toLowerCase() : "";
360
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
361
+ return (
362
+ name === "aborterror" ||
363
+ msg.includes("cancelled") ||
364
+ msg.includes("canceled") ||
365
+ msg.includes("unauthorized") ||
366
+ msg.includes("invalid_api_key") ||
367
+ msg.includes("authentication failed") ||
368
+ msg.includes("forbidden") ||
369
+ msg.includes("insufficient_quota") ||
370
+ msg.includes("billing exhausted") ||
371
+ msg.includes("payment required")
372
+ );
373
+ }
374
+
375
+ export function combineCompactionUsage(first?: Usage, second?: Usage): Usage | undefined {
376
+ if (!first) return second;
377
+ if (!second) return first;
378
+ return {
379
+ input: first.input + second.input,
380
+ output: first.output + second.output,
381
+ cacheRead: first.cacheRead + second.cacheRead,
382
+ cacheWrite: first.cacheWrite + second.cacheWrite,
383
+ ...(first.cacheWrite1h !== undefined || second.cacheWrite1h !== undefined
384
+ ? { cacheWrite1h: (first.cacheWrite1h ?? 0) + (second.cacheWrite1h ?? 0) }
385
+ : {}),
386
+ ...(first.reasoning !== undefined || second.reasoning !== undefined
387
+ ? { reasoning: (first.reasoning ?? 0) + (second.reasoning ?? 0) }
388
+ : {}),
389
+ totalTokens: first.totalTokens + second.totalTokens,
390
+ cost: {
391
+ input: first.cost.input + second.cost.input,
392
+ output: first.cost.output + second.cost.output,
393
+ cacheRead: first.cost.cacheRead + second.cost.cacheRead,
394
+ cacheWrite: first.cost.cacheWrite + second.cost.cacheWrite,
395
+ total: first.cost.total + second.cost.total,
396
+ },
397
+ };
398
+ }
399
+
145
400
  export interface RunSmartCompactionOptions {
146
401
  event: SessionBeforeCompactEvent;
147
- ctx: Pick<ExtensionContext, "model" | "modelRegistry" | "thinkingLevel">;
402
+ ctx: Pick<ExtensionContext, "model" | "modelRegistry" | "thinkingLevel" | "cwd">;
148
403
  config: SmartCompactionConfig;
149
404
  }
150
405
 
@@ -196,10 +451,6 @@ export async function runSmartCompaction(
196
451
  ],
197
452
  };
198
453
 
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
454
  type AttemptPlan = {
204
455
  model: Model<Api>;
205
456
  reasoning?: "off" | "low" | "medium" | "high" | "max";
@@ -211,22 +462,27 @@ export async function runSmartCompaction(
211
462
  ? ctx.thinkingLevel
212
463
  : config.thinkingLevel;
213
464
 
465
+ const primaryReasoning = primaryModel.reasoning && desiredThinking && desiredThinking !== "off"
466
+ ? (desiredThinking as AttemptPlan["reasoning"])
467
+ : undefined;
214
468
  const plans: AttemptPlan[] = [
215
469
  {
216
470
  model: primaryModel,
217
- reasoning: primaryModel.reasoning && desiredThinking && desiredThinking !== "off" ? (desiredThinking as any) : undefined,
471
+ reasoning: primaryReasoning,
218
472
  isInherited: primaryIsInherited,
219
- stageLabel: "primary model with reasoning",
473
+ stageLabel: primaryReasoning ? "primary model with reasoning" : "primary model",
220
474
  },
221
- {
475
+ ];
476
+ if (primaryReasoning) {
477
+ plans.push({
222
478
  model: primaryModel,
223
479
  reasoning: "off",
224
480
  isInherited: primaryIsInherited,
225
481
  stageLabel: "primary model without reasoning",
226
- },
227
- ];
482
+ });
483
+ }
228
484
 
229
- if (sessionModel && sessionModel.id !== primaryModel.id) {
485
+ if (sessionModel && modelKey(sessionModel) !== modelKey(primaryModel)) {
230
486
  plans.push({
231
487
  model: sessionModel,
232
488
  reasoning: "off",
@@ -237,7 +493,7 @@ export async function runSmartCompaction(
237
493
 
238
494
  let lastError: Error | undefined;
239
495
  let finalSummaryText = "";
240
- let finalUsage: Usage | undefined;
496
+ let accumulatedUsage: Usage | undefined;
241
497
  let activeModel = primaryModel;
242
498
  let activeIsInherited = primaryIsInherited;
243
499
 
@@ -263,12 +519,17 @@ export async function runSmartCompaction(
263
519
  try {
264
520
  const response = await ctx.modelRegistry.complete(plan.model, context, completeOptions as any);
265
521
  signal?.throwIfAborted();
522
+ if (response.usage) {
523
+ accumulatedUsage = combineCompactionUsage(accumulatedUsage, response.usage);
524
+ }
266
525
  finalSummaryText = validateSummaryOutput(response);
267
- finalUsage = response.usage;
268
526
  lastError = undefined;
269
527
  break; // Success!
270
528
  } catch (err) {
271
529
  if (signal?.aborted) throw err;
530
+ if (isFatalCompactionError(err)) {
531
+ throw err instanceof Error ? err : new Error(String(err));
532
+ }
272
533
  lastError = err instanceof Error ? err : new Error(String(err));
273
534
  // Continue to next stage in retry ladder
274
535
  }
@@ -283,34 +544,57 @@ export async function runSmartCompaction(
283
544
  const currentOps = preparation.fileOps;
284
545
 
285
546
  const combinedModified = new Set([
286
- ...prior.modifiedFiles,
547
+ ...prior.touchedModifiedFiles,
287
548
  ...(currentOps?.written ?? []),
288
549
  ...(currentOps?.edited ?? []),
289
550
  ]);
290
551
 
291
552
  const combinedRead = new Set([
292
- ...prior.readFiles,
553
+ ...prior.touchedReadFiles,
293
554
  ...(currentOps?.read ?? []),
294
555
  ]);
295
556
 
296
- const readFilesList = [...combinedRead].filter((f) => !combinedModified.has(f)).sort();
297
- const modifiedFilesList = [...combinedModified].sort();
557
+ const allReadFiles = [...combinedRead].filter((file) => !combinedModified.has(file));
558
+ const allTouchedModifiedFiles = [...combinedModified];
559
+ const sensitiveTouchedFilesOmitted = new Set(
560
+ [...allReadFiles, ...allTouchedModifiedFiles].filter(isSensitivePath),
561
+ ).size;
562
+ const readFilesList = allReadFiles.filter((file) => !isSensitivePath(file)).sort();
563
+ const touchedModifiedFilesList = allTouchedModifiedFiles.filter((file) => !isSensitivePath(file)).sort();
564
+ const gitState = await getGitEngineeringState(ctx.cwd, signal);
565
+ const activeDirtyFilesList = gitState.files.map((file) => file.path);
566
+ const activeBackgroundProcesses = getActiveBackgroundProcesses();
298
567
 
299
568
  const fileOpsXml = formatFileOperationsXml({
300
- read: readFilesList,
301
- written: modifiedFilesList,
569
+ readFiles: readFilesList,
570
+ touchedModifiedFiles: touchedModifiedFilesList,
571
+ activeDirtyFiles: activeDirtyFilesList,
572
+ dirtyPatch: gitState.patch,
573
+ dirtyStateAvailable: gitState.available,
574
+ sensitiveFilesOmitted: gitState.sensitiveFilesOmitted + sensitiveTouchedFilesOmitted,
575
+ activeBackgroundProcesses,
576
+ lockfilesAndGeneratedAssets: gitState.lockfilesAndGeneratedAssets,
302
577
  });
303
578
 
304
579
  const finalSummary = `${finalSummaryText}${fileOpsXml}`;
305
580
  const cycleCount = prior.cycleCount + 1;
306
581
 
307
582
  const details: SmartCompactionDetails = {
308
- schemaVersion: 2,
583
+ schemaVersion: 3,
309
584
  customCompactor: "smart-compaction",
310
- model: `${activeModel.provider}/${activeModel.id}`,
585
+ configuredModel: config.model || "inherit",
586
+ resolvedModel: modelKey(activeModel),
311
587
  isInherited: activeIsInherited,
312
- readFiles: readFilesList,
313
- modifiedFiles: modifiedFilesList,
588
+ touchedReadFiles: readFilesList,
589
+ touchedModifiedFiles: touchedModifiedFilesList,
590
+ activeDirtyFiles: activeDirtyFilesList,
591
+ activeDirtyFileStates: gitState.files,
592
+ activeDirtyPatch: gitState.patch,
593
+ dirtyStateAvailable: gitState.available,
594
+ sensitiveDirtyFilesOmitted: gitState.sensitiveFilesOmitted,
595
+ sensitiveTouchedFilesOmitted,
596
+ activeBackgroundProcesses: activeBackgroundProcesses.length > 0 ? activeBackgroundProcesses : undefined,
597
+ lockfilesAndGeneratedAssets: gitState.lockfilesAndGeneratedAssets.length > 0 ? gitState.lockfilesAndGeneratedAssets : undefined,
314
598
  cycleCount,
315
599
  timestamp: Date.now(),
316
600
  };
@@ -319,7 +603,7 @@ export async function runSmartCompaction(
319
603
  summary: finalSummary,
320
604
  firstKeptEntryId: preparation.firstKeptEntryId,
321
605
  tokensBefore: preparation.tokensBefore,
322
- usage: finalUsage,
606
+ usage: accumulatedUsage,
323
607
  details,
324
608
  };
325
609
  }
@@ -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,88 @@ 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
+ activeBackgroundProcesses?: Iterable<string>;
233
+ lockfilesAndGeneratedAssets?: Iterable<string>;
234
+ }): string {
235
+ if (!options) return "";
236
+ const readSet = new Set(options.readFiles ?? []);
237
+ const touchedSet = new Set(options.touchedModifiedFiles ?? []);
238
+ const dirtySet = new Set(options.activeDirtyFiles ?? []);
239
+ const backgroundSet = new Set(options.activeBackgroundProcesses ?? []);
240
+ const lockfilesSet = new Set(options.lockfilesAndGeneratedAssets ?? []);
241
+
242
+ const readOnly = [...readSet].filter((f) => !touchedSet.has(f)).sort();
243
+ const touched = [...touchedSet].sort();
244
+ const dirty = [...dirtySet].sort();
245
+ const background = [...backgroundSet].sort();
246
+ const lockfiles = [...lockfilesSet].sort();
207
247
 
208
248
  const sections: string[] = [];
209
249
  if (readOnly.length > 0) {
210
- sections.push(`<read-files>\n${readOnly.join("\n")}\n</read-files>`);
250
+ sections.push(`<read-files>\n${readOnly.map(escapeXml).join("\n")}\n</read-files>`);
251
+ }
252
+ if (touched.length > 0) {
253
+ sections.push(`<touched-files>\n${touched.map(escapeXml).join("\n")}\n</touched-files>`);
254
+ }
255
+ if (dirty.length > 0) {
256
+ sections.push(`<uncommitted-dirty-files>\n${dirty.map(escapeXml).join("\n")}\n</uncommitted-dirty-files>`);
211
257
  }
212
- if (modified.length > 0) {
213
- sections.push(`<modified-files>\n${modified.join("\n")}\n</modified-files>`);
258
+ if (lockfiles.length > 0) {
259
+ sections.push(`<modified-lockfiles-and-assets>\n${lockfiles.map(escapeXml).join("\n")}\n</modified-lockfiles-and-assets>`);
214
260
  }
261
+ if (background.length > 0) {
262
+ sections.push(`<active-background-processes>\n${background.map(escapeXml).join("\n")}\n</active-background-processes>`);
263
+ }
264
+ if (options.dirtyPatch) {
265
+ sections.push(`<uncommitted-diff>\n${escapeXml(options.dirtyPatch)}\n</uncommitted-diff>`);
266
+ }
267
+ if (options.dirtyStateAvailable === false) {
268
+ sections.push("<uncommitted-state-unavailable />");
269
+ }
270
+ if ((options.sensitiveFilesOmitted ?? 0) > 0) {
271
+ sections.push(`<sensitive-dirty-files-omitted count="${options.sensitiveFilesOmitted}" />`);
272
+ }
273
+
215
274
  if (sections.length === 0) return "";
216
275
  return `\n\n${sections.join("\n\n")}`;
217
276
  }
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.16",
4
4
  "description": "Cross-platform extension suite for the Pi coding agent.",
5
5
  "license": "MIT",
6
6
  "author": "Shariq Riaz",