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.
- package/docs/ARCHITECTURE.md +2 -0
- package/docs/DEVELOPMENT.md +2 -2
- package/docs/EXTENSIONS.md +9 -3
- package/extensions/pi-memory/src/redaction.ts +2 -37
- package/extensions/shared/redaction.ts +55 -0
- package/extensions/smart-compaction/README.md +5 -4
- package/extensions/smart-compaction/engine.ts +284 -49
- package/extensions/smart-compaction/index.ts +23 -19
- package/extensions/smart-compaction/prompt.ts +66 -19
- package/package.json +1 -1
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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/`
|
package/docs/DEVELOPMENT.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
package/docs/EXTENSIONS.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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**:
|
|
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
|
-
- **
|
|
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:
|
|
31
|
+
schemaVersion: 3;
|
|
15
32
|
customCompactor: "smart-compaction";
|
|
16
|
-
|
|
33
|
+
configuredModel: string;
|
|
34
|
+
resolvedModel: string;
|
|
17
35
|
isInherited: boolean;
|
|
18
|
-
|
|
19
|
-
|
|
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 {
|
|
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
|
-
|
|
59
|
-
|
|
91
|
+
touchedReadFiles: Set<string>;
|
|
92
|
+
touchedModifiedFiles: Set<string>;
|
|
60
93
|
cycleCount: number;
|
|
61
94
|
} {
|
|
62
|
-
const
|
|
63
|
-
const
|
|
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 {
|
|
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> & {
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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 {
|
|
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
|
|
98
|
-
throw new Error(
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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.
|
|
503
|
+
...prior.touchedModifiedFiles,
|
|
287
504
|
...(currentOps?.written ?? []),
|
|
288
505
|
...(currentOps?.edited ?? []),
|
|
289
506
|
]);
|
|
290
507
|
|
|
291
508
|
const combinedRead = new Set([
|
|
292
|
-
...prior.
|
|
509
|
+
...prior.touchedReadFiles,
|
|
293
510
|
...(currentOps?.read ?? []),
|
|
294
511
|
]);
|
|
295
512
|
|
|
296
|
-
const
|
|
297
|
-
const
|
|
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
|
-
|
|
301
|
-
|
|
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:
|
|
536
|
+
schemaVersion: 3,
|
|
309
537
|
customCompactor: "smart-compaction",
|
|
310
|
-
|
|
538
|
+
configuredModel: config.model || "inherit",
|
|
539
|
+
resolvedModel: modelKey(activeModel),
|
|
311
540
|
isInherited: activeIsInherited,
|
|
312
|
-
|
|
313
|
-
|
|
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:
|
|
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
|
-
//
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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 =
|
|
176
|
+
const maxTokensDesc = `${config.maxSummaryTokens ?? 8192} tokens`;
|
|
174
177
|
|
|
175
178
|
const status = [
|
|
176
179
|
`Smart Compaction: ${config.enabled ? "ENABLED" : "DISABLED"}`,
|
|
177
|
-
`Model: ${
|
|
180
|
+
`Configured Model: ${config.model}`,
|
|
181
|
+
`Resolved Model: ${resolvedInfo}`,
|
|
178
182
|
`Thinking Level: ${currentThinkingDesc}`,
|
|
179
|
-
`
|
|
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, "&")
|
|
111
|
+
.replace(/</g, "<")
|
|
112
|
+
.replace(/>/g, ">")
|
|
113
|
+
.replace(/"/g, """)
|
|
114
|
+
.replace(/'/g, "'");
|
|
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${
|
|
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${
|
|
192
|
+
parts.push(`[Assistant Thinking]:\n${safeTranscriptText(truncateHeadAndTail(combinedThinking, 800, 800))}`);
|
|
173
193
|
}
|
|
174
194
|
if (textBlocks.length > 0) {
|
|
175
|
-
parts.push(`[Assistant]:\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${
|
|
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${
|
|
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${
|
|
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${
|
|
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${
|
|
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(
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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 (
|
|
213
|
-
sections.push(`<
|
|
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
|
}
|