one-code-extension 0.1.0
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/LICENSE +32 -0
- package/README.md +202 -0
- package/agents/explore.md +24 -0
- package/agents/general-purpose.md +21 -0
- package/agents/plan.md +22 -0
- package/docs/decisions/auto-mode.md +526 -0
- package/docs/decisions/branding.md +127 -0
- package/docs/decisions/code-review-remediation.md +106 -0
- package/docs/decisions/compaction.md +140 -0
- package/docs/decisions/distribution.md +133 -0
- package/docs/decisions/hooks.md +64 -0
- package/docs/decisions/lsp.md +58 -0
- package/docs/decisions/mcp.md +104 -0
- package/docs/decisions/memory-state.md +170 -0
- package/docs/decisions/model-policy.md +126 -0
- package/docs/decisions/modes.md +178 -0
- package/docs/decisions/skills-plugins.md +56 -0
- package/docs/decisions/subagents-workflows.md +225 -0
- package/docs/decisions/system-prompt.md +98 -0
- package/docs/decisions/tools.md +407 -0
- package/docs/decisions/tui.md +202 -0
- package/docs/decisions.md +148 -0
- package/extensions/ask-user/index.ts +124 -0
- package/extensions/ask-user/questions.ts +60 -0
- package/extensions/auto-mode/actions.ts +78 -0
- package/extensions/auto-mode/classifier.ts +388 -0
- package/extensions/auto-mode/config.ts +237 -0
- package/extensions/auto-mode/decision-log.ts +60 -0
- package/extensions/auto-mode/defaults.ts +65 -0
- package/extensions/auto-mode/instructions.ts +74 -0
- package/extensions/auto-mode/model-picker.ts +225 -0
- package/extensions/auto-mode/model-select.ts +178 -0
- package/extensions/auto-mode/paths.ts +109 -0
- package/extensions/auto-mode/pause.ts +96 -0
- package/extensions/auto-mode/prompt.ts +307 -0
- package/extensions/auto-mode/rules.ts +96 -0
- package/extensions/auto-mode/safety-floor.ts +100 -0
- package/extensions/auto-mode/sensitive.ts +137 -0
- package/extensions/auto-mode/shell-analysis.ts +702 -0
- package/extensions/background/index.ts +405 -0
- package/extensions/background/registry.ts +81 -0
- package/extensions/background/wakeup.ts +41 -0
- package/extensions/bash/background.ts +136 -0
- package/extensions/bash/index.ts +149 -0
- package/extensions/branding/assistant-marker.ts +53 -0
- package/extensions/branding/index.ts +366 -0
- package/extensions/branding/prompt-editor.ts +42 -0
- package/extensions/branding/prompt-marker.ts +29 -0
- package/extensions/branding/startup.ts +152 -0
- package/extensions/claude-compat/index.ts +50 -0
- package/extensions/claude-context/index.ts +79 -0
- package/extensions/compaction/index.ts +231 -0
- package/extensions/compaction/prompt.ts +191 -0
- package/extensions/context-management/index.ts +147 -0
- package/extensions/effort/index.ts +216 -0
- package/extensions/effort/slider.ts +253 -0
- package/extensions/exit/index.ts +28 -0
- package/extensions/file-tracker/index.ts +103 -0
- package/extensions/file-tracker/tracker.ts +145 -0
- package/extensions/hooks/debug.ts +53 -0
- package/extensions/hooks/executor.ts +132 -0
- package/extensions/hooks/index.ts +310 -0
- package/extensions/hooks/matcher.ts +72 -0
- package/extensions/hooks/plugin-hooks.ts +79 -0
- package/extensions/hooks/protocol.ts +151 -0
- package/extensions/hooks/settings.ts +163 -0
- package/extensions/hooks/trust.ts +151 -0
- package/extensions/lib/branch-restore.ts +35 -0
- package/extensions/lib/claude-context.ts +148 -0
- package/extensions/lib/deferred.ts +119 -0
- package/extensions/lib/git.ts +14 -0
- package/extensions/lib/memory.ts +219 -0
- package/extensions/lib/model-policy.ts +436 -0
- package/extensions/lib/model-tier.ts +99 -0
- package/extensions/lib/notifications.ts +18 -0
- package/extensions/lib/paths.ts +26 -0
- package/extensions/lib/per-cwd.ts +21 -0
- package/extensions/lib/persisted-output.ts +70 -0
- package/extensions/lib/pi-version.ts +54 -0
- package/extensions/lib/plugins.ts +227 -0
- package/extensions/lib/reminders.ts +212 -0
- package/extensions/lib/scratchpad.ts +68 -0
- package/extensions/lib/tui-render.ts +522 -0
- package/extensions/lsp/client.ts +278 -0
- package/extensions/lsp/format.ts +46 -0
- package/extensions/lsp/index.ts +193 -0
- package/extensions/lsp/protocol.ts +64 -0
- package/extensions/lsp/servers.ts +79 -0
- package/extensions/mcp/client.ts +205 -0
- package/extensions/mcp/config.ts +199 -0
- package/extensions/mcp/index.ts +373 -0
- package/extensions/mcp/schema.ts +161 -0
- package/extensions/memory/index.ts +90 -0
- package/extensions/notebook/index.ts +77 -0
- package/extensions/notebook/notebook.ts +108 -0
- package/extensions/permissions/index.ts +876 -0
- package/extensions/permissions/matcher.ts +455 -0
- package/extensions/permissions/modes.ts +107 -0
- package/extensions/permissions/protected-paths.ts +125 -0
- package/extensions/permissions/settings.ts +98 -0
- package/extensions/plan-mode/index.ts +209 -0
- package/extensions/plan-mode/reminder.ts +33 -0
- package/extensions/plan-mode/slug.ts +28 -0
- package/extensions/plan-mode/viewer.ts +124 -0
- package/extensions/plugins/index.ts +115 -0
- package/extensions/plugins/template.ts +42 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +24 -0
- package/extensions/skill/index.ts +170 -0
- package/extensions/subagents/agents.ts +126 -0
- package/extensions/subagents/child.ts +414 -0
- package/extensions/subagents/default-model.ts +122 -0
- package/extensions/subagents/index.ts +1119 -0
- package/extensions/subagents/model-select.ts +346 -0
- package/extensions/subagents/rpc-turns.ts +123 -0
- package/extensions/subagents/runs.ts +93 -0
- package/extensions/subagents/usage.ts +53 -0
- package/extensions/subagents/worktree.ts +69 -0
- package/extensions/system-prompt/environment.ts +35 -0
- package/extensions/system-prompt/index.ts +54 -0
- package/extensions/system-prompt/template.ts +103 -0
- package/extensions/system-prompt/tiers/common.ts +63 -0
- package/extensions/system-prompt/tiers/frontier.ts +24 -0
- package/extensions/system-prompt/tiers/low.ts +71 -0
- package/extensions/system-prompt/tiers/mid.ts +56 -0
- package/extensions/system-reminder/index.ts +37 -0
- package/extensions/tasks/index.ts +140 -0
- package/extensions/tasks/store.ts +174 -0
- package/extensions/todo/index.ts +133 -0
- package/extensions/tool-search/index.ts +168 -0
- package/extensions/tool-style/index.ts +84 -0
- package/extensions/web/index.ts +43 -0
- package/extensions/web-fetch/extract.ts +139 -0
- package/extensions/web-fetch/index.ts +291 -0
- package/extensions/web-fetch/summarize.ts +78 -0
- package/extensions/workflow/agent-session.ts +368 -0
- package/extensions/workflow/globals.ts +251 -0
- package/extensions/workflow/index.ts +364 -0
- package/extensions/workflow/journal.ts +75 -0
- package/extensions/workflow/permission-gate.ts +71 -0
- package/extensions/workflow/run-manager.ts +306 -0
- package/extensions/workflow/saved-workflows.ts +45 -0
- package/extensions/workflow/script-source.ts +201 -0
- package/extensions/workflow/types.ts +99 -0
- package/extensions/workflow/vm-runtime.ts +31 -0
- package/extensions/workflow/widget.ts +56 -0
- package/extensions/worktree/index.ts +266 -0
- package/extensions/worktree/rewrite.ts +59 -0
- package/package.json +101 -0
- package/themes/one-code-light.json +76 -0
- package/themes/one-code.json +76 -0
- package/types/app.d.ts +5 -0
- package/types/community-packages.d.ts +12 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact startup sections for the banner: Context, Skills, Themes.
|
|
3
|
+
*
|
|
4
|
+
* pi's own startup listing has no per-section switch — hiding the noisy
|
|
5
|
+
* [Extensions] block (20 internal module names) means `quietStartup: true`,
|
|
6
|
+
* which hides everything. So when quiet startup is on, the banner shows its
|
|
7
|
+
* own compact versions of the sections that ARE useful. pi's resourceLoader
|
|
8
|
+
* is not exposed to extensions, so these are re-derived the same way our
|
|
9
|
+
* other extensions derive them (claude-compat's skill dirs, pi's git-root
|
|
10
|
+
* context walk); pi-only extras such as the agent dir's skills/ are included
|
|
11
|
+
* for parity (the caller passes the live agent dir, which honours
|
|
12
|
+
* PI_CODING_AGENT_DIR isolation).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
16
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
17
|
+
import { findGitRoot } from "../lib/git.ts";
|
|
18
|
+
import { discoverPlugins } from "../lib/plugins.ts";
|
|
19
|
+
|
|
20
|
+
export interface StartupSection {
|
|
21
|
+
label: string;
|
|
22
|
+
items: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** CLAUDE.md / CLAUDE.local.md / AGENTS.md from cwd up to the git root (or just cwd outside a repo). */
|
|
26
|
+
export function contextFileNames(cwd: string): string[] {
|
|
27
|
+
const stop = findGitRoot(cwd) ?? cwd;
|
|
28
|
+
const found: string[] = [];
|
|
29
|
+
let dir = cwd;
|
|
30
|
+
while (true) {
|
|
31
|
+
for (const name of ["CLAUDE.md", "CLAUDE.local.md", "AGENTS.md"]) {
|
|
32
|
+
const path = join(dir, name);
|
|
33
|
+
if (existsSync(path)) found.push(relative(cwd, path) || name);
|
|
34
|
+
}
|
|
35
|
+
if (dir === stop) break;
|
|
36
|
+
const parent = dirname(dir);
|
|
37
|
+
if (parent === dir) break;
|
|
38
|
+
dir = parent;
|
|
39
|
+
}
|
|
40
|
+
return found;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Skills across the same sources our extensions feed to pi: project/user
|
|
45
|
+
* Claude Code dirs, pi's own user dir, and installed plugins. An entry counts
|
|
46
|
+
* when <dir>/<name>/SKILL.md exists — existsSync follows symlinked skill
|
|
47
|
+
* directories, which readdir's isDirectory() would miss.
|
|
48
|
+
*/
|
|
49
|
+
export function skillNames(cwd: string, home: string, agentDir: string): string[] {
|
|
50
|
+
const dirs = [
|
|
51
|
+
join(cwd, ".claude", "skills"),
|
|
52
|
+
join(home, ".claude", "skills"),
|
|
53
|
+
join(agentDir, "skills"),
|
|
54
|
+
];
|
|
55
|
+
const names = new Set<string>();
|
|
56
|
+
for (const dir of dirs) {
|
|
57
|
+
if (!existsSync(dir)) continue;
|
|
58
|
+
try {
|
|
59
|
+
for (const entry of readdirSync(dir)) {
|
|
60
|
+
if (existsSync(join(dir, entry, "SKILL.md"))) names.add(entry);
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// Unreadable dir: skip, same as pi would.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const skill of discoverPlugins(join(home, ".claude")).skills) {
|
|
67
|
+
names.add(skill.name);
|
|
68
|
+
}
|
|
69
|
+
return [...names].sort((a, b) => a.localeCompare(b));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Saved workflow names from the Claude Code layout dirs (project shadows user). */
|
|
73
|
+
export function workflowNames(cwd: string, home: string): string[] {
|
|
74
|
+
const names = new Set<string>();
|
|
75
|
+
for (const dir of [join(cwd, ".claude", "workflows"), join(home, ".claude", "workflows")]) {
|
|
76
|
+
if (!existsSync(dir)) continue;
|
|
77
|
+
try {
|
|
78
|
+
for (const entry of readdirSync(dir)) {
|
|
79
|
+
if (entry.endsWith(".js") || entry.endsWith(".mjs")) names.add(entry.replace(/\.(js|mjs)$/, ""));
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// Unreadable dir: skip, same as pi would.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return [...names].sort((a, b) => a.localeCompare(b));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Theme names bundled with this package. */
|
|
89
|
+
export function themeNames(packageThemesDir: string): string[] {
|
|
90
|
+
try {
|
|
91
|
+
return readdirSync(packageThemesDir)
|
|
92
|
+
.filter((f) => f.endsWith(".json"))
|
|
93
|
+
.map((f) => basename(f, ".json"))
|
|
94
|
+
.sort((a, b) => a.localeCompare(b));
|
|
95
|
+
} catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Whether One Code should default thinking blocks to collapsed (the Claude Code
|
|
102
|
+
* look: a one-line label, expanded on demand). Only when the user has never
|
|
103
|
+
* chosen: a `hideThinkingBlock` key in pi's global settings — written by
|
|
104
|
+
* ctrl+t, /settings, or a previous run of this default — is their decision and
|
|
105
|
+
* is never overridden. An unreadable settings file means "don't touch it".
|
|
106
|
+
*/
|
|
107
|
+
export function shouldDefaultHideThinking(settingsRaw: string | undefined): boolean {
|
|
108
|
+
if (settingsRaw === undefined) return true;
|
|
109
|
+
try {
|
|
110
|
+
const settings = JSON.parse(settingsRaw);
|
|
111
|
+
return !(settings && typeof settings === "object" && "hideThinkingBlock" in settings);
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Whether One Code should default pi's output padding to 0 — flush-left — so the
|
|
119
|
+
* assistant "●" marker (see `assistant-marker.ts`) lines up with the tool "●"
|
|
120
|
+
* bullets, which render at column 0. Same rule as the thinking default: only when
|
|
121
|
+
* the user has never set `outputPad` themselves (via `/settings` or a previous
|
|
122
|
+
* run of this default). An unreadable settings file means "don't touch it".
|
|
123
|
+
*/
|
|
124
|
+
export function shouldDefaultFlushOutputPad(settingsRaw: string | undefined): boolean {
|
|
125
|
+
if (settingsRaw === undefined) return true;
|
|
126
|
+
try {
|
|
127
|
+
const settings = JSON.parse(settingsRaw);
|
|
128
|
+
return !(settings && typeof settings === "object" && "outputPad" in settings);
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** True when pi's own startup listing is silenced, so ours should render instead. */
|
|
135
|
+
export function quietStartupEnabled(piSettingsPath: string): boolean {
|
|
136
|
+
try {
|
|
137
|
+
const settings = JSON.parse(readFileSync(piSettingsPath, "utf8"));
|
|
138
|
+
return settings?.quietStartup === true;
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function collectStartupSections(cwd: string, home: string, packageThemesDir: string, agentDir: string): StartupSection[] {
|
|
145
|
+
const sections: StartupSection[] = [
|
|
146
|
+
{ label: "context", items: contextFileNames(cwd) },
|
|
147
|
+
{ label: "skills", items: skillNames(cwd, home, agentDir) },
|
|
148
|
+
{ label: "workflows", items: workflowNames(cwd, home) },
|
|
149
|
+
{ label: "themes", items: themeNames(packageThemesDir) },
|
|
150
|
+
];
|
|
151
|
+
return sections.filter((s) => s.items.length > 0);
|
|
152
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claude-compat extension — points pi's resource discovery at Claude Code's
|
|
3
|
+
* directory layout:
|
|
4
|
+
*
|
|
5
|
+
* ~/.claude/skills, <project>/.claude/skills → skills (same Agent Skills standard)
|
|
6
|
+
* ~/.agents/skills, <project>/.agents/skills → skills (the cross-tool Agent Skills
|
|
7
|
+
* directory, which Claude Code also reads)
|
|
8
|
+
* ~/.claude/commands, <project>/.claude/commands → slash commands (prompt templates;
|
|
9
|
+
* Claude Code's $ARGUMENTS works as-is)
|
|
10
|
+
*
|
|
11
|
+
* CLAUDE.md needs no handling — pi discovers it natively alongside AGENTS.md.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { claudeConfigDir } from "../lib/paths.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `claudeDir` is Claude Code's config dir (honours CLAUDE_CONFIG_DIR), defaulting
|
|
22
|
+
* to `home/.claude`; `home` itself is used only for the unrelated
|
|
23
|
+
* `~/.agents/skills` cross-tool convention, which Claude Code does not relocate
|
|
24
|
+
* via that env var.
|
|
25
|
+
*/
|
|
26
|
+
export function claudeResourcePaths(
|
|
27
|
+
cwd: string,
|
|
28
|
+
home: string,
|
|
29
|
+
claudeDir: string = join(home, ".claude"),
|
|
30
|
+
): { skillPaths: string[]; promptPaths: string[] } {
|
|
31
|
+
const candidates = {
|
|
32
|
+
skillPaths: [
|
|
33
|
+
join(claudeDir, "skills"),
|
|
34
|
+
join(home, ".agents", "skills"),
|
|
35
|
+
join(cwd, ".claude", "skills"),
|
|
36
|
+
join(cwd, ".agents", "skills"),
|
|
37
|
+
],
|
|
38
|
+
promptPaths: [join(claudeDir, "commands"), join(cwd, ".claude", "commands")],
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
skillPaths: candidates.skillPaths.filter((p) => existsSync(p)),
|
|
42
|
+
promptPaths: candidates.promptPaths.filter((p) => existsSync(p)),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default function claudeCompatExtension(pi: ExtensionAPI) {
|
|
47
|
+
pi.on("resources_discover", (event) => {
|
|
48
|
+
return claudeResourcePaths(event.cwd, os.homedir(), claudeConfigDir());
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claude-context extension — assembles Claude Code's `# claudeMd` context block
|
|
3
|
+
* (CLAUDE.md files + MEMORY.md index + userEmail + currentDate) and injects it as
|
|
4
|
+
* a `<system-reminder>` at the front of the first user message, matching Claude
|
|
5
|
+
* Code byte-for-byte (see extensions/lib/claude-context.ts for the format).
|
|
6
|
+
*
|
|
7
|
+
* The block is emitted `first-prepend` at CONTEXT_ORDER.claudeMd — last in Claude
|
|
8
|
+
* Code's context stack (deferred tools → agents → MCP → skills → claudeMd), just
|
|
9
|
+
* before the user's text. It rides the reminder queue (transient per-request via
|
|
10
|
+
* pi's `context` event), so it never persists to the session and never doubles up
|
|
11
|
+
* on resume.
|
|
12
|
+
*
|
|
13
|
+
* Paths are re-derived from home/cwd here rather than shared with the memory
|
|
14
|
+
* extension (jiti gives each extension its own module instance). CLAUDE.md is no
|
|
15
|
+
* longer put in the system prompt, and MEMORY.md is no longer a separate reminder
|
|
16
|
+
* — both fold into this one block, as Claude Code does.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execFileSync } from "node:child_process";
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { buildClaudeMdBlock, discoverContextFiles } from "../lib/claude-context.ts";
|
|
25
|
+
import { projectMemoryDir, truncateIndex } from "../lib/memory.ts";
|
|
26
|
+
import { claudeConfigDir } from "../lib/paths.ts";
|
|
27
|
+
import { CONTEXT_ORDER, REMINDER_CHANNEL } from "../lib/reminders.ts";
|
|
28
|
+
|
|
29
|
+
const REMINDER_KEY = "claude-context";
|
|
30
|
+
|
|
31
|
+
/** The account email Claude Code stamps as `# userEmail`. git config is our proxy. */
|
|
32
|
+
function resolveEmail(cwd: string): string | null {
|
|
33
|
+
try {
|
|
34
|
+
const email = execFileSync("git", ["config", "user.email"], { cwd, encoding: "utf8" }).trim();
|
|
35
|
+
if (email) return email;
|
|
36
|
+
} catch {
|
|
37
|
+
// no git / no config — fall through
|
|
38
|
+
}
|
|
39
|
+
return process.env.GIT_AUTHOR_EMAIL?.trim() || process.env.EMAIL?.trim() || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readMemoryIndex(cwd: string): { path: string; content: string } | null {
|
|
43
|
+
const dir = projectMemoryDir(cwd, os.homedir());
|
|
44
|
+
const path = join(dir, "MEMORY.md");
|
|
45
|
+
try {
|
|
46
|
+
const raw = readFileSync(path, "utf8");
|
|
47
|
+
if (!raw.trim()) return null;
|
|
48
|
+
return { path, content: truncateIndex(raw) };
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default function claudeContextExtension(pi: ExtensionAPI) {
|
|
55
|
+
pi.on("session_start", (_event, ctx) => {
|
|
56
|
+
const inner = buildClaudeMdBlock({
|
|
57
|
+
contextFiles: discoverContextFiles({
|
|
58
|
+
cwd: ctx.cwd,
|
|
59
|
+
homeClaudeDir: claudeConfigDir(),
|
|
60
|
+
}),
|
|
61
|
+
memoryIndex: readMemoryIndex(ctx.cwd),
|
|
62
|
+
email: resolveEmail(ctx.cwd),
|
|
63
|
+
// Per-session date, matching the cached Environment section; Claude Code's
|
|
64
|
+
// block changes only across days too.
|
|
65
|
+
date: new Date().toISOString().slice(0, 10),
|
|
66
|
+
});
|
|
67
|
+
if (!inner) return;
|
|
68
|
+
|
|
69
|
+
pi.events.emit(REMINDER_CHANNEL, {
|
|
70
|
+
text: inner,
|
|
71
|
+
scope: "every-turn",
|
|
72
|
+
key: REMINDER_KEY,
|
|
73
|
+
placement: "first-prepend",
|
|
74
|
+
order: CONTEXT_ORDER.claudeMd,
|
|
75
|
+
// Claude Code's claudeMd block ends `</system-reminder>\n\n` on the wire.
|
|
76
|
+
suffix: "\n\n",
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compaction extension — replaces pi's summarizer with Claude Code's.
|
|
3
|
+
*
|
|
4
|
+
* Hooks `session_before_compact` (manual /compact, threshold, and overflow
|
|
5
|
+
* alike), reuses pi's already-computed cut point, and sends Claude Code's
|
|
6
|
+
* compaction instruction; only the <summary> block of the reply survives as
|
|
7
|
+
* context.
|
|
8
|
+
*
|
|
9
|
+
* The call is built to hit the provider prompt cache, which is why Claude
|
|
10
|
+
* Code compacts on the *session model* rather than something cheaper: it
|
|
11
|
+
* keeps the session's system prompt, tools, and message prefix intact and
|
|
12
|
+
* merely appends the instruction, so the summarization call is mostly cache
|
|
13
|
+
* reads. Same here — session model, session system prompt, the active tool
|
|
14
|
+
* definitions, and, crucially, the session's *last actual request messages*
|
|
15
|
+
* (captured from the `context` event, see below), with the instruction riding
|
|
16
|
+
* as a final user message of <system-reminder> blocks.
|
|
17
|
+
*
|
|
18
|
+
* A cache hit needs the whole request to match the session's, so the call also
|
|
19
|
+
* replays two request options the naive shape got wrong:
|
|
20
|
+
* - `reasoning`: mirrors the session's thinking level. On the Responses API the
|
|
21
|
+
* reasoning config is part of the prompt-cache identity — a byte-identical
|
|
22
|
+
* prefix still misses without it (measured). Safe where the classifier's
|
|
23
|
+
* cross-model reasoning trade is not: it is the session's own model, already
|
|
24
|
+
* proven to accept the level, and pi clamps an unsupported level down.
|
|
25
|
+
* - On Anthropic, the context-management beta + `clear_thinking` body edit that
|
|
26
|
+
* the session's requests carry (added by the context-management extension via
|
|
27
|
+
* hooks completeSimple bypasses). Without them the session caches its message
|
|
28
|
+
* prefix thinking-cleared while this call sends full thinking blocks, and the
|
|
29
|
+
* mismatch invalidates the message-block cache (system+tools still read).
|
|
30
|
+
*
|
|
31
|
+
* Any failure returns nothing, so pi's own compaction serves — a different
|
|
32
|
+
* summary style, never a broken compaction. CC_COMPACTION=0 opts out.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
36
|
+
import type { Api, Model, Tool } from "@earendil-works/pi-ai";
|
|
37
|
+
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
38
|
+
import {
|
|
39
|
+
convertToLlm,
|
|
40
|
+
type ExtensionAPI,
|
|
41
|
+
type SessionBeforeCompactEvent,
|
|
42
|
+
} from "@earendil-works/pi-coding-agent";
|
|
43
|
+
import {
|
|
44
|
+
type AnthropicModelCompat,
|
|
45
|
+
anthropicBetas,
|
|
46
|
+
clearThinkingApplies,
|
|
47
|
+
clearThinkingEnabled,
|
|
48
|
+
isAnthropicOAuth,
|
|
49
|
+
looksLikeAnthropicRequest,
|
|
50
|
+
withClearThinking,
|
|
51
|
+
} from "../context-management/index.ts";
|
|
52
|
+
import { buildCompactionInstruction, COMPACTION_MAX_TOKENS, continuationSummary, extractSummary } from "./prompt.ts";
|
|
53
|
+
|
|
54
|
+
/** Compaction reads a whole context window; give it more room than the classifier's 30s. */
|
|
55
|
+
const COMPACTION_TIMEOUT_MS = 120_000;
|
|
56
|
+
|
|
57
|
+
export default function compactionExtension(pi: ExtensionAPI) {
|
|
58
|
+
/**
|
|
59
|
+
* The exact message array the session last handed the provider, captured
|
|
60
|
+
* from the `context` event AFTER every extension has mutated it — most
|
|
61
|
+
* importantly system-reminder, which injects <system-reminder> blocks at
|
|
62
|
+
* request time (memory index, the every-turn subagent-models reminder, …).
|
|
63
|
+
*
|
|
64
|
+
* Byte-matching the prefix is necessary for the cache but not sufficient (the
|
|
65
|
+
* `reasoning` and, on Anthropic, `clear_thinking` options below must match too).
|
|
66
|
+
* pi's agent loop builds a request by applying `transformContext` (this very
|
|
67
|
+
* event) and then `convertToLlm` (agent-loop.ts); we call the *same*
|
|
68
|
+
* `convertToLlm` on the captured array, so the message prefix we send is
|
|
69
|
+
* byte-identical to the turn's, and appending only the instruction keeps that
|
|
70
|
+
* whole prefix reusable. Reconstructing from session entries instead (the
|
|
71
|
+
* fallback) diverges at message one, because those injected reminders never
|
|
72
|
+
* become entries — so it cannot reproduce the cached prefix at all.
|
|
73
|
+
*
|
|
74
|
+
* We hold the reference, not a copy: `emitContext` hands each handler a fresh
|
|
75
|
+
* structuredClone the session never mutates again, and compaction is the
|
|
76
|
+
* last extension with a `context` handler, so this reference is exactly the
|
|
77
|
+
* array the turn sent. Returning nothing keeps the handler a pure observer.
|
|
78
|
+
*/
|
|
79
|
+
let capturedMessages: AgentMessage[] | undefined;
|
|
80
|
+
pi.on("context", (event) => {
|
|
81
|
+
capturedMessages = event.messages;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
pi.on("session_before_compact", async (event, ctx) => {
|
|
85
|
+
if (process.env.CC_COMPACTION === "0") return undefined;
|
|
86
|
+
|
|
87
|
+
const model = ctx.model;
|
|
88
|
+
if (!model) return undefined;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
92
|
+
if (!auth.ok) return undefined;
|
|
93
|
+
const baseUrl = (auth as { baseUrl?: string }).baseUrl;
|
|
94
|
+
|
|
95
|
+
// Prefer the captured request prefix — it is cache-aligned and already
|
|
96
|
+
// carries the leading compactionSummary message on a re-compaction.
|
|
97
|
+
// Fall back to reconstructing from session entries only when nothing
|
|
98
|
+
// has been captured yet (e.g. /compact as the first action in a freshly
|
|
99
|
+
// resumed session, before any turn ran); that path cannot hit the cache.
|
|
100
|
+
const messages = capturedMessages ?? reconstructFromEntries(event.preparation);
|
|
101
|
+
|
|
102
|
+
const instruction = buildCompactionInstruction({
|
|
103
|
+
reason: event.reason,
|
|
104
|
+
customInstructions: event.customInstructions,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// The active tool definitions, in their active order — kept in the
|
|
108
|
+
// request purely so the cached prefix (tools come first) still matches.
|
|
109
|
+
// A mismatch costs cache hits, never correctness.
|
|
110
|
+
const byName = new Map(pi.getAllTools().map((tool) => [tool.name, tool]));
|
|
111
|
+
const tools = pi
|
|
112
|
+
.getActiveTools()
|
|
113
|
+
.map((name) => byName.get(name))
|
|
114
|
+
.filter((tool) => tool !== undefined)
|
|
115
|
+
.map(({ name, description, parameters }) => ({ name, description, parameters }) as Tool);
|
|
116
|
+
|
|
117
|
+
// When context-management (clear_thinking) is active for this session,
|
|
118
|
+
// the agent loop's cached message prefix has old thinking blocks cleared
|
|
119
|
+
// and every request carries the context-management beta. completeSimple
|
|
120
|
+
// bypasses our before_provider_headers/before_provider_request handlers,
|
|
121
|
+
// so the compaction request would omit both — and on Anthropic that
|
|
122
|
+
// mismatch invalidates the *message* cache (system+tools still read, but
|
|
123
|
+
// the whole history re-caches; measured cacheRead ~13k of ~56k). Replay
|
|
124
|
+
// the same beta header and clear_thinking body edit so the replayed
|
|
125
|
+
// prefix matches the session's cache and the history stays a cache read.
|
|
126
|
+
// No-op off Anthropic (clearThinkingEnabled is false there), where
|
|
127
|
+
// automatic prefix caching needs neither.
|
|
128
|
+
// model.compat is a provider union; the clear_thinking helpers only read
|
|
129
|
+
// the Anthropic-shaped fields, and are gated to Anthropic anyway.
|
|
130
|
+
const cmModel = model as { api?: string; provider?: string; baseUrl?: string; compat?: AnthropicModelCompat };
|
|
131
|
+
const clearThinking = clearThinkingEnabled(process.env.CC_CLEAR_THINKING, cmModel);
|
|
132
|
+
const headers = clearThinking
|
|
133
|
+
? { ...auth.headers, "anthropic-beta": anthropicBetas(isAnthropicOAuth(), cmModel.compat) }
|
|
134
|
+
: auth.headers;
|
|
135
|
+
|
|
136
|
+
const timeout = AbortSignal.timeout(COMPACTION_TIMEOUT_MS);
|
|
137
|
+
const result = await completeSimple(
|
|
138
|
+
baseUrl ? ({ ...model, baseUrl } as Model<Api>) : model,
|
|
139
|
+
{
|
|
140
|
+
systemPrompt: ctx.getSystemPrompt(),
|
|
141
|
+
messages: [...convertToLlm(messages), { role: "user", content: instruction, timestamp: Date.now() }],
|
|
142
|
+
tools,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
apiKey: auth.apiKey,
|
|
146
|
+
headers,
|
|
147
|
+
env: auth.env,
|
|
148
|
+
signal: AbortSignal.any([event.signal, timeout]),
|
|
149
|
+
maxTokens: Math.min(COMPACTION_MAX_TOKENS, model.maxTokens || COMPACTION_MAX_TOKENS),
|
|
150
|
+
// The session id is the prompt-cache key / routing affinity on the
|
|
151
|
+
// providers that use one (openai-codex prompt_cache_key, session
|
|
152
|
+
// headers). Without it the replayed prefix cannot hit the session's
|
|
153
|
+
// cache no matter how well the bytes match.
|
|
154
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
155
|
+
// Reasoning config is part of the cache identity on the Responses
|
|
156
|
+
// API: with a byte-identical prefix, prompt_cache_key, tools, and
|
|
157
|
+
// system prompt, omitting `reasoning` on a reasoning-configured
|
|
158
|
+
// session still misses the cache entirely (measured: cacheRead 0).
|
|
159
|
+
// Mirror the session's own thinking level so the request matches
|
|
160
|
+
// the prefix the session cached. Unlike the auto-mode classifier —
|
|
161
|
+
// which may run a *different*, unvalidated model where reasoning
|
|
162
|
+
// fails closed — this is the session's own model, already proven to
|
|
163
|
+
// accept this level, and streamSimple clamps an unsupported level
|
|
164
|
+
// down rather than erroring, so mirroring never fails closed here.
|
|
165
|
+
// "off" carries no reasoning (matching what such a session sends).
|
|
166
|
+
reasoning: ctx.thinkingLevel === "off" ? undefined : ctx.thinkingLevel,
|
|
167
|
+
// The clear_thinking body edit, matching the session's requests (see
|
|
168
|
+
// the header note above). Only attached on Anthropic requests that
|
|
169
|
+
// carry thinking, exactly as the context-management extension gates it.
|
|
170
|
+
onPayload: clearThinking
|
|
171
|
+
? (payload: unknown) =>
|
|
172
|
+
looksLikeAnthropicRequest(payload) &&
|
|
173
|
+
clearThinkingApplies(payload as Record<string, unknown>, cmModel.compat?.forceAdaptiveThinking === true)
|
|
174
|
+
? withClearThinking(payload as Record<string, unknown>)
|
|
175
|
+
: payload
|
|
176
|
+
: undefined,
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const text = result.content
|
|
181
|
+
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
|
182
|
+
.map((block) => block.text)
|
|
183
|
+
.join("\n");
|
|
184
|
+
const summary = extractSummary(text);
|
|
185
|
+
if (!summary) return undefined;
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
compaction: {
|
|
189
|
+
// Undefined in --no-session runs; the pointer line is dropped.
|
|
190
|
+
summary: continuationSummary(summary, ctx.sessionManager.getSessionFile()),
|
|
191
|
+
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
|
192
|
+
tokensBefore: event.preparation.tokensBefore,
|
|
193
|
+
usage: result.usage,
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
} catch {
|
|
197
|
+
// pi's default compaction runs instead: degraded style, never broken.
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Fallback message construction from session entries, used only when no live
|
|
205
|
+
* request was captured. This cannot hit the prompt cache (the request-time
|
|
206
|
+
* <system-reminder> injections are absent from entries), but it still yields a
|
|
207
|
+
* correct summary — and if it fails, pi's own compaction serves.
|
|
208
|
+
*
|
|
209
|
+
* A previous compaction's summary is excluded from messagesToSummarize
|
|
210
|
+
* (prepareCompaction starts after that entry), yet the live context carries it
|
|
211
|
+
* as its leading compactionSummary message. Reattach it in exactly that shape so
|
|
212
|
+
* convertToLlm renders the same bytes the session's context holds. A split
|
|
213
|
+
* turn's discarded prefix has no separate summary on this path, so it is
|
|
214
|
+
* summarized with the rest.
|
|
215
|
+
*/
|
|
216
|
+
function reconstructFromEntries(preparation: SessionBeforeCompactEvent["preparation"]): AgentMessage[] {
|
|
217
|
+
const doomed = preparation.isSplitTurn
|
|
218
|
+
? [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages]
|
|
219
|
+
: preparation.messagesToSummarize;
|
|
220
|
+
return preparation.previousSummary
|
|
221
|
+
? [
|
|
222
|
+
{
|
|
223
|
+
role: "compactionSummary",
|
|
224
|
+
summary: preparation.previousSummary,
|
|
225
|
+
tokensBefore: 0,
|
|
226
|
+
timestamp: Date.now(),
|
|
227
|
+
} as AgentMessage,
|
|
228
|
+
...doomed,
|
|
229
|
+
]
|
|
230
|
+
: doomed;
|
|
231
|
+
}
|