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.
Files changed (153) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +202 -0
  3. package/agents/explore.md +24 -0
  4. package/agents/general-purpose.md +21 -0
  5. package/agents/plan.md +22 -0
  6. package/docs/decisions/auto-mode.md +526 -0
  7. package/docs/decisions/branding.md +127 -0
  8. package/docs/decisions/code-review-remediation.md +106 -0
  9. package/docs/decisions/compaction.md +140 -0
  10. package/docs/decisions/distribution.md +133 -0
  11. package/docs/decisions/hooks.md +64 -0
  12. package/docs/decisions/lsp.md +58 -0
  13. package/docs/decisions/mcp.md +104 -0
  14. package/docs/decisions/memory-state.md +170 -0
  15. package/docs/decisions/model-policy.md +126 -0
  16. package/docs/decisions/modes.md +178 -0
  17. package/docs/decisions/skills-plugins.md +56 -0
  18. package/docs/decisions/subagents-workflows.md +225 -0
  19. package/docs/decisions/system-prompt.md +98 -0
  20. package/docs/decisions/tools.md +407 -0
  21. package/docs/decisions/tui.md +202 -0
  22. package/docs/decisions.md +148 -0
  23. package/extensions/ask-user/index.ts +124 -0
  24. package/extensions/ask-user/questions.ts +60 -0
  25. package/extensions/auto-mode/actions.ts +78 -0
  26. package/extensions/auto-mode/classifier.ts +388 -0
  27. package/extensions/auto-mode/config.ts +237 -0
  28. package/extensions/auto-mode/decision-log.ts +60 -0
  29. package/extensions/auto-mode/defaults.ts +65 -0
  30. package/extensions/auto-mode/instructions.ts +74 -0
  31. package/extensions/auto-mode/model-picker.ts +225 -0
  32. package/extensions/auto-mode/model-select.ts +178 -0
  33. package/extensions/auto-mode/paths.ts +109 -0
  34. package/extensions/auto-mode/pause.ts +96 -0
  35. package/extensions/auto-mode/prompt.ts +307 -0
  36. package/extensions/auto-mode/rules.ts +96 -0
  37. package/extensions/auto-mode/safety-floor.ts +100 -0
  38. package/extensions/auto-mode/sensitive.ts +137 -0
  39. package/extensions/auto-mode/shell-analysis.ts +702 -0
  40. package/extensions/background/index.ts +405 -0
  41. package/extensions/background/registry.ts +81 -0
  42. package/extensions/background/wakeup.ts +41 -0
  43. package/extensions/bash/background.ts +136 -0
  44. package/extensions/bash/index.ts +149 -0
  45. package/extensions/branding/assistant-marker.ts +53 -0
  46. package/extensions/branding/index.ts +366 -0
  47. package/extensions/branding/prompt-editor.ts +42 -0
  48. package/extensions/branding/prompt-marker.ts +29 -0
  49. package/extensions/branding/startup.ts +152 -0
  50. package/extensions/claude-compat/index.ts +50 -0
  51. package/extensions/claude-context/index.ts +79 -0
  52. package/extensions/compaction/index.ts +231 -0
  53. package/extensions/compaction/prompt.ts +191 -0
  54. package/extensions/context-management/index.ts +147 -0
  55. package/extensions/effort/index.ts +216 -0
  56. package/extensions/effort/slider.ts +253 -0
  57. package/extensions/exit/index.ts +28 -0
  58. package/extensions/file-tracker/index.ts +103 -0
  59. package/extensions/file-tracker/tracker.ts +145 -0
  60. package/extensions/hooks/debug.ts +53 -0
  61. package/extensions/hooks/executor.ts +132 -0
  62. package/extensions/hooks/index.ts +310 -0
  63. package/extensions/hooks/matcher.ts +72 -0
  64. package/extensions/hooks/plugin-hooks.ts +79 -0
  65. package/extensions/hooks/protocol.ts +151 -0
  66. package/extensions/hooks/settings.ts +163 -0
  67. package/extensions/hooks/trust.ts +151 -0
  68. package/extensions/lib/branch-restore.ts +35 -0
  69. package/extensions/lib/claude-context.ts +148 -0
  70. package/extensions/lib/deferred.ts +119 -0
  71. package/extensions/lib/git.ts +14 -0
  72. package/extensions/lib/memory.ts +219 -0
  73. package/extensions/lib/model-policy.ts +436 -0
  74. package/extensions/lib/model-tier.ts +99 -0
  75. package/extensions/lib/notifications.ts +18 -0
  76. package/extensions/lib/paths.ts +26 -0
  77. package/extensions/lib/per-cwd.ts +21 -0
  78. package/extensions/lib/persisted-output.ts +70 -0
  79. package/extensions/lib/pi-version.ts +54 -0
  80. package/extensions/lib/plugins.ts +227 -0
  81. package/extensions/lib/reminders.ts +212 -0
  82. package/extensions/lib/scratchpad.ts +68 -0
  83. package/extensions/lib/tui-render.ts +522 -0
  84. package/extensions/lsp/client.ts +278 -0
  85. package/extensions/lsp/format.ts +46 -0
  86. package/extensions/lsp/index.ts +193 -0
  87. package/extensions/lsp/protocol.ts +64 -0
  88. package/extensions/lsp/servers.ts +79 -0
  89. package/extensions/mcp/client.ts +205 -0
  90. package/extensions/mcp/config.ts +199 -0
  91. package/extensions/mcp/index.ts +373 -0
  92. package/extensions/mcp/schema.ts +161 -0
  93. package/extensions/memory/index.ts +90 -0
  94. package/extensions/notebook/index.ts +77 -0
  95. package/extensions/notebook/notebook.ts +108 -0
  96. package/extensions/permissions/index.ts +876 -0
  97. package/extensions/permissions/matcher.ts +455 -0
  98. package/extensions/permissions/modes.ts +107 -0
  99. package/extensions/permissions/protected-paths.ts +125 -0
  100. package/extensions/permissions/settings.ts +98 -0
  101. package/extensions/plan-mode/index.ts +209 -0
  102. package/extensions/plan-mode/reminder.ts +33 -0
  103. package/extensions/plan-mode/slug.ts +28 -0
  104. package/extensions/plan-mode/viewer.ts +124 -0
  105. package/extensions/plugins/index.ts +115 -0
  106. package/extensions/plugins/template.ts +42 -0
  107. package/extensions/search-tools/index.ts +0 -0
  108. package/extensions/search-tools/tools.ts +24 -0
  109. package/extensions/skill/index.ts +170 -0
  110. package/extensions/subagents/agents.ts +126 -0
  111. package/extensions/subagents/child.ts +414 -0
  112. package/extensions/subagents/default-model.ts +122 -0
  113. package/extensions/subagents/index.ts +1119 -0
  114. package/extensions/subagents/model-select.ts +346 -0
  115. package/extensions/subagents/rpc-turns.ts +123 -0
  116. package/extensions/subagents/runs.ts +93 -0
  117. package/extensions/subagents/usage.ts +53 -0
  118. package/extensions/subagents/worktree.ts +69 -0
  119. package/extensions/system-prompt/environment.ts +35 -0
  120. package/extensions/system-prompt/index.ts +54 -0
  121. package/extensions/system-prompt/template.ts +103 -0
  122. package/extensions/system-prompt/tiers/common.ts +63 -0
  123. package/extensions/system-prompt/tiers/frontier.ts +24 -0
  124. package/extensions/system-prompt/tiers/low.ts +71 -0
  125. package/extensions/system-prompt/tiers/mid.ts +56 -0
  126. package/extensions/system-reminder/index.ts +37 -0
  127. package/extensions/tasks/index.ts +140 -0
  128. package/extensions/tasks/store.ts +174 -0
  129. package/extensions/todo/index.ts +133 -0
  130. package/extensions/tool-search/index.ts +168 -0
  131. package/extensions/tool-style/index.ts +84 -0
  132. package/extensions/web/index.ts +43 -0
  133. package/extensions/web-fetch/extract.ts +139 -0
  134. package/extensions/web-fetch/index.ts +291 -0
  135. package/extensions/web-fetch/summarize.ts +78 -0
  136. package/extensions/workflow/agent-session.ts +368 -0
  137. package/extensions/workflow/globals.ts +251 -0
  138. package/extensions/workflow/index.ts +364 -0
  139. package/extensions/workflow/journal.ts +75 -0
  140. package/extensions/workflow/permission-gate.ts +71 -0
  141. package/extensions/workflow/run-manager.ts +306 -0
  142. package/extensions/workflow/saved-workflows.ts +45 -0
  143. package/extensions/workflow/script-source.ts +201 -0
  144. package/extensions/workflow/types.ts +99 -0
  145. package/extensions/workflow/vm-runtime.ts +31 -0
  146. package/extensions/workflow/widget.ts +56 -0
  147. package/extensions/worktree/index.ts +266 -0
  148. package/extensions/worktree/rewrite.ts +59 -0
  149. package/package.json +101 -0
  150. package/themes/one-code-light.json +76 -0
  151. package/themes/one-code.json +76 -0
  152. package/types/app.d.ts +5 -0
  153. 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
+ }