From ab0a86a70764838745fbfd8b2631776568999b77 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Sat, 18 Jul 2026 00:35:18 +0700 Subject: [PATCH] enhance pi configuration and skills --- flake.lock | 24 +- modules/home/devtools/ai-tools.nix | 25 +- patches/pi/fix-codex-ws-usage-limit.mjs | 123 -- .../openai-server-compaction/LICENSE.md | 21 + .../openai-server-compaction/README.md | 81 ++ .../openai-server-compaction/config.ts | 59 + .../openai-server-compaction/index.ts | 348 ++++++ .../openai-server-compaction/openai.ts | 117 ++ .../remote-compaction.ts | 1070 +++++++++++++++++ .../openai-server-compaction/state.ts | 62 + pi/models.json | 13 + pi/skills/bro/SKILL.md | 7 + pi/skills/domain-modeling/ADR-FORMAT.md | 47 + pi/skills/domain-modeling/CONTEXT-FORMAT.md | 60 + pi/skills/domain-modeling/SKILL.md | 74 ++ pi/skills/grill-me/SKILL.md | 9 +- pi/skills/grill-with-docs/SKILL.md | 7 + pi/skills/grilling/SKILL.md | 12 + 18 files changed, 2000 insertions(+), 159 deletions(-) delete mode 100644 patches/pi/fix-codex-ws-usage-limit.mjs create mode 100644 pi/extensions/openai-server-compaction/LICENSE.md create mode 100644 pi/extensions/openai-server-compaction/README.md create mode 100644 pi/extensions/openai-server-compaction/config.ts create mode 100644 pi/extensions/openai-server-compaction/index.ts create mode 100644 pi/extensions/openai-server-compaction/openai.ts create mode 100644 pi/extensions/openai-server-compaction/remote-compaction.ts create mode 100644 pi/extensions/openai-server-compaction/state.ts create mode 100644 pi/skills/bro/SKILL.md create mode 100644 pi/skills/domain-modeling/ADR-FORMAT.md create mode 100644 pi/skills/domain-modeling/CONTEXT-FORMAT.md create mode 100644 pi/skills/domain-modeling/SKILL.md create mode 100644 pi/skills/grill-with-docs/SKILL.md create mode 100644 pi/skills/grilling/SKILL.md diff --git a/flake.lock b/flake.lock index a06c05c..c05dcf6 100644 --- a/flake.lock +++ b/flake.lock @@ -42,11 +42,11 @@ ] }, "locked": { - "lastModified": 1782772816, - "narHash": "sha256-s9BuFv0mRuZx9C1MF8qPHRdcAK14ONi0A5m6E2wqOoM=", + "lastModified": 1784665499, + "narHash": "sha256-9BMxlTxCCDAeoNLtb1a/st7udtTIJep+wpUzquA29VU=", "owner": "nix-community", "repo": "bun2nix", - "rev": "5a39d717029e94163ac223aee8d5c9946cafed1c", + "rev": "0f2a1f0b6f42cebe3b149bf62d38754c5e0e9729", "type": "github" }, "original": { @@ -223,11 +223,11 @@ "treefmt-nix": "treefmt-nix" }, "locked": { - "lastModified": 1784067614, - "narHash": "sha256-pOuOsr3EUz1Uh91FN6CFIhUrjZ+ZOhV6if5ENyomrxs=", + "lastModified": 1784877872, + "narHash": "sha256-Y270/EgraTJpT4uI84YJKpcqOcVFJwDBZpHYT4Ydtpw=", "owner": "numtide", "repo": "llm-agents.nix", - "rev": "5e0b7a75654e606175b917ec446c1858078696c9", + "rev": "f9a30d16da12fcf83886b66d3df9b1b8c168a3f7", "type": "github" }, "original": { @@ -329,11 +329,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1783816212, - "narHash": "sha256-2aZisVTVGSFEk3MYcOiWQp3zvwyzbqpktB69Rcfp150=", + "lastModified": 1784555310, + "narHash": "sha256-/FCliTPgiuV1owejZFNx3Ch9irdvkOfOFl+HHZ+DrtM=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "3b32825de172d0bc85664f495edb096b10862524", + "rev": "421eebfd0ec7bccd4abe826ce62d7e6e83129493", "type": "github" }, "original": { @@ -467,11 +467,11 @@ ] }, "locked": { - "lastModified": 1780220602, - "narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=", + "lastModified": 1784369104, + "narHash": "sha256-47cxbcZODibHv3rELFQ9vZly0vUNkND/atn/U7HLeb0=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "db947814a175b7ca6ded66e21383d938df01c227", + "rev": "df3c0640565d04a0261253cdd89fce78ec50168a", "type": "github" }, "original": { diff --git a/modules/home/devtools/ai-tools.nix b/modules/home/devtools/ai-tools.nix index 0a4e98e..b84199b 100644 --- a/modules/home/devtools/ai-tools.nix +++ b/modules/home/devtools/ai-tools.nix @@ -23,18 +23,6 @@ let # Pinned llm-agents for Claude Code 2.0.64 llmPkgsPinned = inputs.llm-agents-pinned.packages.${system}; - # pi with a local patch applied: maps Codex usage-limit errors that arrive - # over the (default) WebSocket transport to the same friendly - # "You have hit your ChatGPT usage limit" message the SSE path already emits. - # Without it, a normal ChatGPT-subscription quota trip surfaces as the raw, - # misleading "exceeded your current quota / check your billing" API text. - # See patches/pi/fix-codex-ws-usage-limit.mjs. - piPatched = llmPkgs.pi.overrideAttrs (o: { - postInstall = (o.postInstall or "") + '' - ${pkgs.nodejs}/bin/node ${./../../../patches/pi/fix-codex-ws-usage-limit.mjs} $out/lib/node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js - ''; - }); - # Z.AI Gateway wrapper for Claude Code zaiWrapperPackages = let @@ -287,20 +275,21 @@ in home.packages = [ llmPkgs.claude-code # latest llmPkgs.opencode # latest - piPatched + llmPkgs.pi # latest llmPkgs.ccstatusline # latest llmPkgs.ccusage # latest llmPkgs.codex llmPkgs.rtk llmPkgs.cursor-agent llmPkgs.herdr # latest - terminal agent multiplexer + llmPkgs.grok ] ++ zaiWrapperPackages ++ kimiWrapperPackages ++ cfg.extraPackages; home.activation.installPiFff = lib.hm.dag.entryAfter [ "writeBoundary" ] '' - if [ -x ${lib.getExe piPatched} ]; then + if [ -x ${lib.getExe llmPkgs.pi} ]; then export PI_SKIP_VERSION_CHECK=1 export PI_TELEMETRY=0 export PATH=${pkgs.nodejs}/bin:${pkgs.git}/bin:$PATH @@ -309,10 +298,10 @@ in mkdir -p "$(dirname "$settings_file")" ${pkgs.nodejs}/bin/node -e "const fs=require('fs'); const path=process.env.HOME+'/.pi/agent/settings.json'; let settings={}; if (fs.existsSync(path)) settings=JSON.parse(fs.readFileSync(path,'utf8')); settings.theme='dark'; settings.defaultProvider='openai-codex'; fs.writeFileSync(path, JSON.stringify(settings, null, 2)+String.fromCharCode(10));" - ${lib.getExe piPatched} list | ${pkgs.gnugrep}/bin/grep -q '@ff-labs/pi-fff' || \ - ${lib.getExe piPatched} install npm:@ff-labs/pi-fff@0.9.4 - ${lib.getExe piPatched} list | ${pkgs.gnugrep}/bin/grep -q '@juicesharp/rpiv-ask-user-question' || \ - ${lib.getExe piPatched} install npm:@juicesharp/rpiv-ask-user-question@1.20.0 + ${lib.getExe llmPkgs.pi} list | ${pkgs.gnugrep}/bin/grep -q '@ff-labs/pi-fff' || \ + ${lib.getExe llmPkgs.pi} install npm:@ff-labs/pi-fff@0.9.4 + ${lib.getExe llmPkgs.pi} list | ${pkgs.gnugrep}/bin/grep -q '@juicesharp/rpiv-ask-user-question' || \ + ${lib.getExe llmPkgs.pi} install npm:@juicesharp/rpiv-ask-user-question@1.20.0 fi ''; diff --git a/patches/pi/fix-codex-ws-usage-limit.mjs b/patches/pi/fix-codex-ws-usage-limit.mjs deleted file mode 100644 index a8c63ce..0000000 --- a/patches/pi/fix-codex-ws-usage-limit.mjs +++ /dev/null @@ -1,123 +0,0 @@ -// Patches @earendil-works/pi-ai's openai-codex-responses provider so that -// usage-limit errors arriving over the WebSocket transport are mapped to the -// same friendly "You have hit your ChatGPT usage limit" message that the -// SSE/fetch path already produces via parseErrorResponse(). -// -// Without this, the default `transport: auto` (WebSocket-first) path throws -// the raw upstream string verbatim, e.g.: -// "You exceeded your current quota, please check your plan and billing -// details. For more information on this error, read the docs: -// https://platform.openai.com/docs/guides/error-codes/api-errors." -// which is misleading: the account is a ChatGPT subscription (prolite plan), -// not API billing, and the real limit is a per-window ChatGPT/Codex quota. -// -// Usage: node fix-codex-ws-usage-limit.mjs -import { readFileSync, writeFileSync } from "node:fs"; - -const file = process.argv[2]; -if (!file) { - console.error("fix-codex-ws-usage-limit: missing target file argument"); - process.exit(1); -} - -let src = readFileSync(file, "utf8"); - -// 1. Insert a friendly-message helper just before mapCodexEvents. -const helperAnchor = "async function* mapCodexEvents(events) {"; -if (!src.includes(helperAnchor)) { - console.error( - "fix-codex-ws-usage-limit: anchor 'async function* mapCodexEvents(events) {' not found; refusing to patch an unfamiliar file.", - ); - process.exit(2); -} - -const helper = `function friendlyCodexUsageMessage(code, message, payload) { - const codeStr = typeof code === "string" ? code : ""; - const msgStr = typeof message === "string" ? message : ""; - const isUsageLimit = - /usage_limit_reached|usage_not_included|rate_limit_exceeded/i.test(codeStr) || - /insufficient_quota|exceeded your current quota|quota exceeded|out of budget|usage limit|billing/i.test(msgStr); - if (!isUsageLimit) - return undefined; - const src = (payload && typeof payload === "object") - ? (payload.response?.error ?? payload.error ?? payload) - : {}; - const plan = src.plan_type ? \` (\${String(src.plan_type).toLowerCase()} plan)\` : ""; - const resetsAt = src.resets_at ?? payload?.resets_at; - const mins = typeof resetsAt === "number" - ? Math.max(0, Math.round((resetsAt * 1000 - Date.now()) / 60000)) - : undefined; - const when = mins !== undefined ? \` Try again in ~\${mins} min.\` : ""; - return \`You have hit your ChatGPT usage limit\${plan}.\${when}\`.trim(); -} -`; - -if (!src.includes("function friendlyCodexUsageMessage(")) { - src = src.replace(helperAnchor, helper + helperAnchor); -} - -// 2. Map usage-limit errors in the `error` event branch. -const errorBranchOld = ` if (type === "error") { - const code = event.code || ""; - const message = event.message || ""; - throw new CodexApiError(\`Codex error: \${message || code || JSON.stringify(event)}\`, { - code: code || undefined, - payload: event, - }); - }`; -const errorBranchWithExtractorOld = ` if (type === "error") { - const { code, message } = extractCodexEventError(event); - throw new CodexApiError(\`Codex error: \${message || code || JSON.stringify(event)}\`, { - code, - payload: event, - }); - }`; -const errorBranchNew = ` if (type === "error") { - const code = event.code || ""; - const message = event.message || ""; - const friendly = friendlyCodexUsageMessage(code, message, event); - throw new CodexApiError(friendly || \`Codex error: \${message || code || JSON.stringify(event)}\`, { - code: code || undefined, - payload: event, - }); - }`; -const errorBranchWithExtractorNew = ` if (type === "error") { - const { code, message } = extractCodexEventError(event); - const friendly = friendlyCodexUsageMessage(code, message, event); - throw new CodexApiError(friendly || \`Codex error: \${message || code || JSON.stringify(event)}\`, { - code, - payload: event, - }); - }`; -if (src.includes(errorBranchOld)) { - src = src.replace(errorBranchOld, errorBranchNew); -} else if (src.includes(errorBranchWithExtractorOld)) { - src = src.replace(errorBranchWithExtractorOld, errorBranchWithExtractorNew); -} else if (!src.includes("const friendly = friendlyCodexUsageMessage(code, message, event);")) { - console.error("fix-codex-ws-usage-limit: could not locate the 'error' event throw branch; refusing to patch."); - process.exit(3); -} - -// 3. Map usage-limit errors in the `response.failed` event branch. -const failedBranchOld = ` if (type === "response.failed") { - const response = event.response; - const code = response?.error?.code; - const message = response?.error?.message; - throw new CodexApiError(message || "Codex response failed", { code, payload: event }); - }`; -const failedBranchNew = ` if (type === "response.failed") { - const response = event.response; - const code = response?.error?.code; - const message = response?.error?.message; - const friendly = friendlyCodexUsageMessage(code || "", message || "", event); - throw new CodexApiError(friendly || message || "Codex response failed", { code, payload: event }); - }`; -if (src.includes(failedBranchOld)) { - src = src.replace(failedBranchOld, failedBranchNew); -} else if (!src.includes('const friendly = friendlyCodexUsageMessage(code || "", message || "", event);')) { - console.error("fix-codex-ws-usage-limit: could not locate the 'response.failed' throw branch; refusing to patch."); - process.exit(4); -} - -writeFileSync(file, src); -console.error("fix-codex-ws-usage-limit: patched " + file); diff --git a/pi/extensions/openai-server-compaction/LICENSE.md b/pi/extensions/openai-server-compaction/LICENSE.md new file mode 100644 index 0000000..3243e95 --- /dev/null +++ b/pi/extensions/openai-server-compaction/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Alexis Gallagher + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pi/extensions/openai-server-compaction/README.md b/pi/extensions/openai-server-compaction/README.md new file mode 100644 index 0000000..acf5b71 --- /dev/null +++ b/pi/extensions/openai-server-compaction/README.md @@ -0,0 +1,81 @@ +# OpenAI server compaction + +A vendored Pi extension that uses OpenAI's Responses compaction protocol for GPT +models while preserving Pi's session, tree, and fallback behavior. + +This fork is based on +[`algal/pi-openai-server-compaction`](https://github.com/algal/pi-openai-server-compaction) +and retains its MIT license. It is adapted for this dotfiles repository and Pi +0.82. + +## Scope + +Remote compaction is intentionally limited to GPT models on these native Pi +providers: + +- `openai/*` using `openai-responses` +- `openai-codex/*` using `openai-codex-responses` + +Other models and providers are untouched and continue using Pi's default +compaction. + +## Compaction behavior + +Pi remains responsible for deciding when to compact, selecting the cut point, +and writing the compaction entry. On `session_before_compact`, this extension: + +1. asks OpenAI for an opaque `compaction` item through the Responses API; +2. generates a portable text summary in parallel; +3. stores the opaque replacement history in + `CompactionEntry.details.remoteCompaction`; and +4. replays that history on later requests to the exact same provider/API/model. + +If remote compaction fails but the portable summary succeeds, Pi uses that text +summary. If neither extension path succeeds, the handler returns control to +Pi's default compactor. + +The opaque artifact is model-specific. Switching models uses Pi's portable text +summary; switching back reconstructs the matching artifact from session JSONL. + +## Pi 0.82 adaptation + +Unlike upstream, this fork does not override Pi's OpenAI provider or install a +custom WebSocket transport. It uses Pi 0.82's native HTTP Responses transport. +This avoids the upstream WebSocket partial-rendering issue and removes the +runtime `ws` dependency. + +Because Pi's full replay payload is not safe to combine with +`previous_response_id`, this fork disables that optimization. It also leaves +normal pre-compaction requests unchanged instead of enabling OpenAI's automatic +`context_management`, whose compaction stream events Pi 0.82 does not natively +persist. Pi triggers compaction normally; post-compaction requests replay the +opaque artifact explicitly. + +## Data handling + +Conversation context is sent to OpenAI during compaction with `store: false`, +and returned encrypted artifacts are stored in Pi's local session JSONL. The +artifacts are not human-readable. OpenAI's normal API data-handling and abuse +monitoring policies still apply. + +## Configuration + +Configuration is read from: + +- `~/.pi/agent/openai-server-compaction.json` +- `.pi/openai-server-compaction.json` (takes precedence) + +```json +{ + "enabled": true, + "notify": false +} +``` + +Environment overrides: + +- `PI_OPENAI_SERVER_COMPACTION_ENABLED` +- `PI_OPENAI_SERVER_COMPACTION_NOTIFY` + +Set `PI_OPENAI_SERVER_COMPACTION_ENABLED=0` for a quick rollback, or start Pi +with `--no-extensions` to bypass all extensions. diff --git a/pi/extensions/openai-server-compaction/config.ts b/pi/extensions/openai-server-compaction/config.ts new file mode 100644 index 0000000..c768b96 --- /dev/null +++ b/pi/extensions/openai-server-compaction/config.ts @@ -0,0 +1,59 @@ +/** + * Configuration loading for the extension. + * + * Reads global/project JSON config files plus environment overrides and exposes + * a normalized, fully-populated runtime config object. + */ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export type JsonRecord = Record; + +export type ExtensionConfig = { + enabled?: boolean; + notify?: boolean; +}; + +export function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readJsonFile(path: string): JsonRecord | undefined { + if (!existsSync(path)) return undefined; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")); + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function toBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value !== "string") return undefined; + const normalized = value.trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) return true; + if (["0", "false", "no", "off"].includes(normalized)) return false; + return undefined; +} + +export function loadConfig(cwd: string): Required { + const globalPath = join(homedir(), ".pi", "agent", "openai-server-compaction.json"); + const projectPath = join(cwd, ".pi", "openai-server-compaction.json"); + const globalCfg = readJsonFile(globalPath) ?? {}; + const projectCfg = readJsonFile(projectPath) ?? {}; + const merged = { ...globalCfg, ...projectCfg }; + + return { + enabled: + toBoolean(process.env.PI_OPENAI_SERVER_COMPACTION_ENABLED) ?? + toBoolean(merged.enabled) ?? + true, + notify: + toBoolean(process.env.PI_OPENAI_SERVER_COMPACTION_NOTIFY) ?? + toBoolean(merged.notify) ?? + false, + }; +} diff --git a/pi/extensions/openai-server-compaction/index.ts b/pi/extensions/openai-server-compaction/index.ts new file mode 100644 index 0000000..6ea747c --- /dev/null +++ b/pi/extensions/openai-server-compaction/index.ts @@ -0,0 +1,348 @@ +/** + * Main extension entrypoint. + * + * Wires together request patching, remote compaction, runtime state + * reconstruction, session lifecycle cleanup, and Pi-native HTTP request patching. + */ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import { isRecord, loadConfig } from "./config.ts"; +import { + applyRemoteHistoryPayloadPatch, + extractResponsesReasoningConfig, + extractResponsesTextConfig, + isOpenAICodexResponsesModel, + looksLikeResponsesPayload, + messageMatchesModel, + modelKey, + supportsRemoteCompactionModel, + thinkingLevelToResponsesReasoning, +} from "./openai.ts"; +import { + buildCompactionSummaryText, + buildRemoteCompactionDetails, + buildToolsPayload, + callRemoteCompactionEndpoint, + generateBestEffortLocalSummary, + messageToResponseItems, + messagesToResponseItems, + normalizeResponseItemsForPrompt, + reconstructRemoteCompactionStateFromBranch, +} from "./remote-compaction.ts"; +import { + clearAllRuntimeState, + clearRemoteCompactionState, + clearResponsesRequestShapeState, + getRemoteCompactionState, + getResponsesRequestShapeState, + setRemoteCompactionState, + setResponsesRequestShapeState, +} from "./state.ts"; + +type TargetModel = Parameters[0]; + +type BranchEntry = { + type: string; + id: string; + details?: unknown; + message?: unknown; + thinkingLevel?: unknown; +}; + +type SessionContextLike = { + sessionManager: { + getSessionId(): string; + getBranch(): BranchEntry[]; + }; +}; + +function getSessionId(ctx: SessionContextLike): string { + return ctx.sessionManager.getSessionId(); +} + +function getBranchMessages(branchEntries: BranchEntry[]): AgentMessage[] { + return branchEntries.flatMap((entry) => + entry.type === "message" && entry.message ? [entry.message as AgentMessage] : [], + ); +} + +function getBranchThinkingLevel(branchEntries: BranchEntry[]): string | undefined { + for (let index = branchEntries.length - 1; index >= 0; index--) { + const entry = branchEntries[index]; + if (entry?.type !== "thinking_level_change") continue; + return typeof entry.thinkingLevel === "string" ? entry.thinkingLevel : undefined; + } + return undefined; +} + +function clearSessionRuntimeState(sessionId: string | undefined): void { + clearRemoteCompactionState(sessionId); + clearResponsesRequestShapeState(sessionId); +} + +function syncRemoteState(ctx: SessionContextLike): void { + const sessionId = getSessionId(ctx); + const branchEntries = ctx.sessionManager.getBranch() as Array<{ + type: string; + id: string; + details?: unknown; + message?: AgentMessage; + }>; + const state = reconstructRemoteCompactionStateFromBranch({ branchEntries }); + if (state) { + setRemoteCompactionState(sessionId, state); + } else { + clearRemoteCompactionState(sessionId); + } +} + +function getMatchingRemoteState( + sessionId: string, + model: TargetModel | undefined, +): ReturnType { + if (!model) return undefined; + const remoteState = getRemoteCompactionState(sessionId); + return remoteState && remoteState.modelKey === modelKey(model) ? remoteState : undefined; +} + +function extendRemoteHistoryIfCompatible(params: { + sessionId: string; + model: TargetModel | undefined; + message: AgentMessage; +}): void { + const remoteState = getMatchingRemoteState(params.sessionId, params.model); + if (!remoteState || !params.model) return; + if (params.message.role === "assistant" && !messageMatchesModel(params.message, params.model)) { + return; + } + + const items = messageToResponseItems(params.message); + if (items.length === 0) return; + + setRemoteCompactionState(params.sessionId, { + ...remoteState, + explicitHistory: [...remoteState.explicitHistory, ...items], + }); +} + +function maybeNotifyRequestFeatures(params: { + notifiedModels: Set; + hasUI: boolean; + notify: boolean; + ui: { notify(message: string, level: "info" | "warning"): void }; + model: TargetModel; + features: string[]; +}): void { + if (!params.notify || !params.hasUI || params.features.length === 0) return; + + const key = `${String(params.model.provider)}/${String(params.model.id)}`; + const noticeKey = `${key}:${params.features.join(",")}`; + if (params.notifiedModels.has(noticeKey)) return; + + params.notifiedModels.add(noticeKey); + params.ui.notify(`OpenAI compaction active for ${key} (${params.features.join(", ")})`, "info"); +} + +export default function openaiServerCompactionExtension(pi: ExtensionAPI) { + const notifiedModels = new Set(); + + pi.on("session_start", (_event, ctx) => { + const sessionId = getSessionId(ctx); + clearResponsesRequestShapeState(sessionId); + syncRemoteState(ctx); + }); + + const clearBeforeSessionChange = (_event: unknown, ctx: SessionContextLike): void => { + clearSessionRuntimeState(getSessionId(ctx)); + }; + pi.on("session_before_switch", clearBeforeSessionChange); + pi.on("session_before_fork", clearBeforeSessionChange); + pi.on("session_before_tree", clearBeforeSessionChange); + + const syncAfterSessionChange = (_event: unknown, ctx: SessionContextLike): void => { + syncRemoteState(ctx); + }; + pi.on("session_tree", syncAfterSessionChange); + pi.on("session_compact", syncAfterSessionChange); + + pi.on("model_select", (_event, ctx) => { + clearResponsesRequestShapeState(getSessionId(ctx)); + }); + + pi.on("session_shutdown", () => { + clearAllRuntimeState(); + }); + + pi.on("session_before_compact", async (event, ctx) => { + const cfg = loadConfig(ctx.cwd); + const model = ctx.model; + if (!cfg.enabled || !model || !supportsRemoteCompactionModel(model)) return undefined; + + const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); + if (!auth.ok || !auth.apiKey) return undefined; + + const tools = buildToolsPayload(pi.getAllTools(), pi.getActiveTools()); + const sessionId = getSessionId(ctx); + const branchEntries = event.branchEntries as BranchEntry[]; + const remoteState = getMatchingRemoteState(sessionId, model); + const observedRequestShape = getResponsesRequestShapeState(sessionId); + const fullBranchMessages = getBranchMessages(branchEntries); + const responseItems = remoteState + ? remoteState.explicitHistory + : messagesToResponseItems(fullBranchMessages); + const promptResponseItems = normalizeResponseItemsForPrompt(responseItems, model); + const thinkingLevel = pi.getThinkingLevel(); + const fallbackReasoning = model.reasoning + ? thinkingLevelToResponsesReasoning(thinkingLevel ?? getBranchThinkingLevel(branchEntries)) + : undefined; + const reasoning = observedRequestShape?.reasoning ?? fallbackReasoning; + const text = observedRequestShape?.text; + + const [localResult, remoteResult] = await Promise.allSettled([ + generateBestEffortLocalSummary({ + preparation: event.preparation, + messages: fullBranchMessages, + model, + apiKey: auth.apiKey, + headers: auth.headers, + customInstructions: event.customInstructions, + signal: event.signal, + thinkingLevel, + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + }), + callRemoteCompactionEndpoint({ + model, + apiKey: auth.apiKey, + headers: auth.headers, + sessionId, + input: promptResponseItems, + instructions: ctx.getSystemPrompt(), + tools, + parallelToolCalls: true, + reasoning, + text, + signal: event.signal, + }), + ]); + + if (remoteResult.status !== "fulfilled") { + if (localResult.status === "fulfilled") { + return { compaction: localResult.value }; + } + if (!event.signal.aborted && ctx.hasUI) { + const message = + remoteResult.reason instanceof Error + ? remoteResult.reason.message + : String(remoteResult.reason); + ctx.ui.notify( + `OpenAI remote compaction failed; falling back to default compaction. ${message}`, + "warning", + ); + } + return undefined; + } + + const remoteDetails = buildRemoteCompactionDetails( + model, + remoteResult.value.output, + remoteResult.value.usage, + ); + const localSummary = + localResult.status === "fulfilled" + ? localResult.value + : { + summary: buildCompactionSummaryText(model), + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + }; + + return { + compaction: { + summary: localSummary.summary, + firstKeptEntryId: localSummary.firstKeptEntryId, + tokensBefore: localSummary.tokensBefore, + details: { + ...(localSummary.details !== undefined + ? { localSummaryDetails: localSummary.details } + : {}), + remoteCompaction: remoteDetails, + }, + }, + }; + }); + + pi.on("message_end", (event, ctx) => { + const sessionId = getSessionId(ctx); + const model = ctx.model; + + extendRemoteHistoryIfCompatible({ + sessionId, + model, + message: event.message, + }); + }); + + pi.on("before_provider_request", (event, ctx) => { + const cfg = loadConfig(ctx.cwd); + if (!cfg.enabled) return undefined; + + const model = ctx.model; + if ( + !model || + !supportsRemoteCompactionModel(model) || + !isRecord(event.payload) || + !looksLikeResponsesPayload(event.payload) + ) { + return undefined; + } + + const sessionId = getSessionId(ctx); + setResponsesRequestShapeState(sessionId, { + updatedAt: Date.now(), + reasoning: extractResponsesReasoningConfig(event.payload), + text: extractResponsesTextConfig(event.payload), + }); + const remoteState = getMatchingRemoteState(sessionId, model); + + if (isOpenAICodexResponsesModel(model)) { + if (!remoteState) return undefined; + const payload = applyRemoteHistoryPayloadPatch({ + payload: event.payload, + explicitHistory: normalizeResponseItemsForPrompt( + remoteState.explicitHistory, + model, + ) as unknown[], + }); + maybeNotifyRequestFeatures({ + notifiedModels, + hasUI: ctx.hasUI, + notify: cfg.notify, + ui: ctx.ui, + model, + features: ["remote_compaction_history"], + }); + return payload; + } + + if (!remoteState) return undefined; + + const payload = applyRemoteHistoryPayloadPatch({ + payload: event.payload, + explicitHistory: normalizeResponseItemsForPrompt( + remoteState.explicitHistory, + model, + ) as unknown[], + }); + maybeNotifyRequestFeatures({ + notifiedModels, + hasUI: ctx.hasUI, + notify: cfg.notify, + ui: ctx.ui, + model, + features: ["remote_compaction_history", "native_http"], + }); + + return payload; + }); +} diff --git a/pi/extensions/openai-server-compaction/openai.ts b/pi/extensions/openai-server-compaction/openai.ts new file mode 100644 index 0000000..cd9adc0 --- /dev/null +++ b/pi/extensions/openai-server-compaction/openai.ts @@ -0,0 +1,117 @@ +/** + * OpenAI API and OpenAI Codex model/payload helpers. + * + * Keeps provider-specific detection, request patching, endpoint classification, + * and model-key logic out of the higher-level extension wiring. + */ +import type { JsonRecord } from "./config.ts"; +import type { ResponsesReasoningConfig, ResponsesTextConfig } from "./remote-compaction.ts"; +import { isRecord } from "./config.ts"; + +export type ModelLike = { + api?: unknown; + provider?: unknown; + id?: unknown; + baseUrl?: unknown; + reasoning?: unknown; + input?: readonly unknown[]; +}; + +export function hostnameFromBaseUrl(baseUrl: unknown): string | undefined { + if (typeof baseUrl !== "string" || !baseUrl.trim()) return undefined; + try { + return new URL(baseUrl).hostname.toLowerCase(); + } catch { + return undefined; + } +} + +export function isOpenAIResponsesModel(model: unknown): model is ModelLike { + return ( + isRecord(model) && + ( + model.api === "openai-responses" || + model.api === "openai-codex-responses" + ) + ); +} + +export function isDirectOpenAIResponsesModel(model: ModelLike): boolean { + if (model.api !== "openai-responses") return false; + if (model.provider !== "openai") return false; + const host = hostnameFromBaseUrl(model.baseUrl); + return host === undefined || host === "api.openai.com"; +} + +export function isOpenAICodexResponsesModel(model: ModelLike): boolean { + if (model.api !== "openai-codex-responses") return false; + const provider = typeof model.provider === "string" ? model.provider : ""; + if (provider === "openai-codex") return true; + const host = hostnameFromBaseUrl(model.baseUrl); + return host === "chatgpt.com"; +} + +function isGptModel(model: ModelLike): boolean { + return typeof model.id === "string" && model.id.startsWith("gpt-"); +} + +export function supportsRemoteCompactionModel(model: unknown): model is ModelLike { + if (!isOpenAIResponsesModel(model) || !isGptModel(model)) return false; + return isDirectOpenAIResponsesModel(model) || isOpenAICodexResponsesModel(model); +} + +export function looksLikeResponsesPayload(payload: JsonRecord): boolean { + return "input" in payload || "model" in payload || "messages" in payload; +} + +export function modelKey(model: ModelLike): string { + return `${String(model.provider)}:${String(model.api)}:${String(model.id)}`; +} + +export function thinkingLevelToResponsesReasoning( + thinkingLevel: unknown, +): ResponsesReasoningConfig | undefined { + if (thinkingLevel === "minimal") return { effort: "minimal", summary: "auto" }; + if (thinkingLevel === "low") return { effort: "low", summary: "auto" }; + if (thinkingLevel === "medium") return { effort: "medium", summary: "auto" }; + if (thinkingLevel === "high") return { effort: "high", summary: "auto" }; + if (thinkingLevel === "xhigh") return { effort: "xhigh", summary: "auto" }; + return undefined; +} + +export function applyRemoteHistoryPayloadPatch(params: { + payload: JsonRecord; + explicitHistory: unknown[]; +}): JsonRecord { + const nextPayload: JsonRecord = { + ...params.payload, + input: params.explicitHistory, + }; + delete nextPayload.messages; + delete nextPayload.previous_response_id; + return nextPayload; +} + +export function extractResponsesReasoningConfig(payload: unknown): ResponsesReasoningConfig | undefined { + if (!isRecord(payload) || !isRecord(payload.reasoning)) return undefined; + const effort = payload.reasoning.effort; + const summary = payload.reasoning.summary; + const normalized: ResponsesReasoningConfig = { + ...(typeof effort === "string" ? { effort: effort as ResponsesReasoningConfig["effort"] } : {}), + ...( + summary === null || typeof summary === "string" + ? { summary: summary as ResponsesReasoningConfig["summary"] } + : {} + ), + }; + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +export function extractResponsesTextConfig(payload: unknown): ResponsesTextConfig | undefined { + return isRecord(payload) && isRecord(payload.text) ? payload.text : undefined; +} + +export function messageMatchesModel(message: unknown, model: ModelLike): boolean { + if (!isRecord(message)) return false; + return message.provider === model.provider && message.model === model.id; +} diff --git a/pi/extensions/openai-server-compaction/remote-compaction.ts b/pi/extensions/openai-server-compaction/remote-compaction.ts new file mode 100644 index 0000000..1b8845c --- /dev/null +++ b/pi/extensions/openai-server-compaction/remote-compaction.ts @@ -0,0 +1,1070 @@ +/** + * Codex-style remote compaction helpers. + * + * Converts Pi messages into OpenAI Responses items, requests remote compaction + * through the Responses API's `compaction_trigger`, stores the returned opaque + * replacement history, and reconstructs replayable state from persisted Pi + * session entries. + */ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { arch, platform, release } from "node:os"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import type { SessionBeforeCompactEvent, ToolInfo } from "@earendil-works/pi-coding-agent"; +import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; +import { + compact, + convertToLlm, + serializeConversation, + type CompactionResult, +} from "@earendil-works/pi-coding-agent"; +import { calculateCost, type Model, type Usage } from "@earendil-works/pi-ai"; +import { complete } from "@earendil-works/pi-ai/compat"; +import { isRecord } from "./config.ts"; +import { + hostnameFromBaseUrl, + isDirectOpenAIResponsesModel, + isOpenAICodexResponsesModel, + supportsRemoteCompactionModel, + modelKey, +} from "./openai.ts"; + +type CompactionPreparation = SessionBeforeCompactEvent["preparation"]; +type AssistantPhase = "commentary" | "final_answer"; +type ToolResultOutputItem = + | { type: "input_text"; text: string } + | { type: "input_image"; image_url: string }; + +type ContentPartLike = { + type?: string; + text?: string; + data?: string; + mimeType?: string; + source?: unknown; +}; + +export type ResponseContentItem = + | { type: "input_text"; text: string } + | { type: "input_image"; image_url: string } + | { type: "output_text"; text: string }; + +export type ResponseItem = + | { + type: "message"; + role: string; + content: ResponseContentItem[]; + end_turn?: boolean; + phase?: AssistantPhase; + } + | { + type: "reasoning"; + summary: Array<{ type: "summary_text"; text: string }>; + content?: Array<{ type: "reasoning_text" | "text"; text: string }>; + encrypted_content: string | null; + } + | { type: "function_call"; name: string; arguments: string; call_id: string } + | { type: "function_call_output"; call_id: string; output: string | ToolResultOutputItem[] } + | { type: "compaction"; encrypted_content: string } + | { type: "compaction_summary"; encrypted_content: string } + | { type: "compaction_trigger" } + | { type: string; [key: string]: unknown }; + +export type ResponsesReasoningConfig = { + effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; + summary?: "auto" | "concise" | "detailed" | null; +}; + +export type ResponsesTextConfig = Record; + +export type RemoteCompactionUsageSnapshot = Usage; + +const IMAGE_CONTENT_OMITTED_PLACEHOLDER = "image content omitted because you do not support image input"; +const REMOTE_COMPACTION_V2_FEATURE = "remote_compaction_v2"; +const RETAINED_MESSAGE_TOKEN_BUDGET = 20_000; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export type RemoteCompactionDetails = { + version: 1 | 2; + provider: "openai-responses-compact" | "openai-responses-compaction"; + implementation?: "responses_compact_v1" | "responses_compaction_v2"; + modelKey: string; + replacementHistory: ResponseItem[]; + usage?: RemoteCompactionUsageSnapshot; +}; + +export type RemoteCompactionSessionState = { + compactionEntryId: string; + modelKey: string; + replacementHistory: ResponseItem[]; + explicitHistory: ResponseItem[]; +}; + +export type RemoteCompactionResult = { + output: ResponseItem[]; + usage?: RemoteCompactionUsageSnapshot; +}; + +function normalizeBaseUrl(baseUrl: string | undefined, fallback: string): string { + const trimmed = baseUrl?.trim(); + if (!trimmed) return fallback; + return trimmed.replace(/\/+$/, ""); +} + +function resolveDirectOpenAIResponsesEndpoint(model: Model): string { + const baseUrl = normalizeBaseUrl(typeof model.baseUrl === "string" ? model.baseUrl : undefined, "https://api.openai.com/v1"); + if (baseUrl.endsWith("/responses")) return baseUrl; + return baseUrl.endsWith("/v1") ? `${baseUrl}/responses` : `${baseUrl}/v1/responses`; +} + +function resolveCodexResponsesEndpoint(model: Model): string { + const baseUrl = normalizeBaseUrl(typeof model.baseUrl === "string" ? model.baseUrl : undefined, "https://chatgpt.com/backend-api"); + if (baseUrl.endsWith("/codex/responses")) return baseUrl; + if (baseUrl.endsWith("/codex")) return `${baseUrl}/responses`; + return `${baseUrl}/codex/responses`; +} + +export function remoteCompactionV2EndpointUrl(model: Model): string { + if (isDirectOpenAIResponsesModel(model)) { + return resolveDirectOpenAIResponsesEndpoint(model); + } + if (isOpenAICodexResponsesModel(model)) { + return resolveCodexResponsesEndpoint(model); + } + throw new Error("Remote compaction v2 is not supported for this model."); +} + +function resolveCodexHome(): string { + const configured = process.env.CODEX_HOME?.trim(); + return configured ? configured : join(homedir(), ".codex"); +} + +function resolveCodexInstallationId(): string { + const path = join(resolveCodexHome(), "installation_id"); + try { + if (existsSync(path)) { + const existing = readFileSync(path, "utf8").trim(); + if (UUID_RE.test(existing)) return existing.toLowerCase(); + } + } catch { + // Fall through and regenerate below, matching Codex's invalid-file behavior. + } + + const installationId = randomUUID(); + try { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, installationId); + } catch { + // Header is a parity hint, not a reason to fail compaction. + } + return installationId; +} + +export function buildCodexIdentityHeaders(sessionId?: string): Record { + if (!sessionId) { + return { + "x-codex-installation-id": resolveCodexInstallationId(), + }; + } + return { + "x-codex-installation-id": resolveCodexInstallationId(), + "x-codex-window-id": `${sessionId}:0`, + session_id: sessionId, + }; +} + +function extractCodexAccountId(token: string): string { + const parts = token.split("."); + if (parts.length !== 3) { + throw new Error("Failed to extract accountId from Codex token"); + } + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as { + [key: string]: unknown; + }; + const auth = isRecord(payload["https://api.openai.com/auth"]) + ? payload["https://api.openai.com/auth"] + : undefined; + const accountId = auth?.chatgpt_account_id; + if (typeof accountId !== "string" || !accountId) { + throw new Error("Failed to extract accountId from Codex token"); + } + return accountId; +} + +function withRemoteCompactionV2Feature(headers: Record): Record { + const configuredFeatures = Object.entries(headers) + .find(([name]) => name.toLowerCase() === "x-codex-beta-features")?.[1] + ?.split(",") + .map((feature) => feature.trim()) + .filter(Boolean) ?? []; + const headersWithoutFeature = Object.fromEntries( + Object.entries(headers).filter(([name]) => name.toLowerCase() !== "x-codex-beta-features"), + ); + const features = [...new Set([...configuredFeatures, REMOTE_COMPACTION_V2_FEATURE])]; + return { + ...headersWithoutFeature, + "x-codex-beta-features": features.join(","), + }; +} + +export function buildRemoteCompactionHeaders(params: { + model: Model; + apiKey: string; + headers?: Record; + sessionId?: string; +}): Record { + const codexIdentityHeaders = buildCodexIdentityHeaders(params.sessionId); + const commonHeaders = withRemoteCompactionV2Feature({ + authorization: `Bearer ${params.apiKey}`, + ...codexIdentityHeaders, + ...(params.headers ?? {}), + accept: "text/event-stream", + "content-type": "application/json", + }); + if (isDirectOpenAIResponsesModel(params.model)) { + return commonHeaders; + } + if (isOpenAICodexResponsesModel(params.model)) { + return { + ...commonHeaders, + "chatgpt-account-id": extractCodexAccountId(params.apiKey), + originator: "pi", + "user-agent": `pi-openai-server-compaction (${platform()} ${release()}; ${arch()})`, + "OpenAI-Beta": "responses=experimental", + }; + } + throw new Error("Remote compaction v2 headers are not supported for this model."); +} + +function isAssistantPhase(value: unknown): value is AssistantPhase { + return value === "commentary" || value === "final_answer"; +} + +function parseTextSignaturePhase(value: unknown): AssistantPhase | undefined { + if (typeof value !== "string" || !value.trim()) return undefined; + try { + const parsed = JSON.parse(value) as { phase?: unknown }; + return isAssistantPhase(parsed.phase) ? parsed.phase : undefined; + } catch { + return undefined; + } +} + +function contentToResponseContentItems(content: unknown): ResponseContentItem[] { + if (typeof content === "string") { + return content ? [{ type: "input_text", text: content }] : []; + } + if (!Array.isArray(content)) return []; + + const items: ResponseContentItem[] = []; + for (const part of content as ContentPartLike[]) { + if ( + (part.type === "text" || part.type === "input_text" || part.type === "output_text") && + typeof part.text === "string" + ) { + items.push({ type: "input_text", text: part.text }); + continue; + } + if (part.type === "image" && typeof part.data === "string" && typeof part.mimeType === "string") { + items.push({ type: "input_image", image_url: `data:${part.mimeType};base64,${part.data}` }); + continue; + } + if ( + part.type === "input_image" && + part.source && + typeof part.source === "object" && + (part.source as { type?: unknown }).type === "url" && + typeof (part.source as { url?: unknown }).url === "string" + ) { + items.push({ type: "input_image", image_url: (part.source as { url: string }).url }); + } + } + return items; +} + +function toolResultContentToOutput(content: unknown): string | ToolResultOutputItem[] { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + + const output: ToolResultOutputItem[] = []; + for (const item of content) { + if (!item || typeof item !== "object") continue; + const part = item as ContentPartLike; + if (part.type === "text" && typeof part.text === "string") { + output.push({ type: "input_text", text: part.text }); + } else if (part.type === "image" && typeof part.data === "string" && typeof part.mimeType === "string") { + output.push({ type: "input_image", image_url: `data:${part.mimeType};base64,${part.data}` }); + } + } + return output; +} + +function parseThinkingSignature(value: unknown): ResponseItem | undefined { + if (typeof value !== "string" || !value.trim()) return undefined; + try { + const parsed = JSON.parse(value); + if (!isRecord(parsed) || parsed.type !== "reasoning") return undefined; + + const summary = Array.isArray(parsed.summary) + ? parsed.summary + .map((item) => + isRecord(item) && typeof item.text === "string" + ? { type: "summary_text" as const, text: item.text } + : undefined, + ) + .filter((item): item is { type: "summary_text"; text: string } => Boolean(item)) + : []; + const content = Array.isArray(parsed.content) + ? parsed.content + .map((item) => { + if (!isRecord(item) || typeof item.text !== "string") return undefined; + return { + type: item.type === "reasoning_text" ? "reasoning_text" : "text", + text: item.text, + } as const; + }) + .filter((item): item is { type: "reasoning_text" | "text"; text: string } => Boolean(item)) + : undefined; + + return { + type: "reasoning", + summary, + ...(content && content.length > 0 ? { content } : {}), + encrypted_content: typeof parsed.encrypted_content === "string" ? parsed.encrypted_content : null, + }; + } catch { + return undefined; + } +} + +function isResponseItem(value: unknown): value is ResponseItem { + return isRecord(value) && typeof value.type === "string"; +} + +function buildPortableSummaryPrompt(conversation: string, customInstructions?: string): string { + const instructionSuffix = customInstructions + ? `\n\nAdditional summarization instructions:\n${customInstructions}` + : ""; + return `Summarize this conversation for future continuation in pi. Preserve goals, decisions, important facts, file paths, open questions, and next steps. Be concise but include information needed to continue work.${instructionSuffix}\n\n\n${conversation}\n`; +} + +export function messageToResponseItems(message: AgentMessage): ResponseItem[] { + const items: ResponseItem[] = []; + + if (message.role === "user") { + const content = contentToResponseContentItems(message.content); + if (content.length > 0) { + items.push({ type: "message", role: "user", content }); + } + return items; + } + + if (message.role === "assistant") { + let phase: AssistantPhase | undefined; + const textBlocks: string[] = []; + + const flushText = () => { + if (textBlocks.length === 0) return; + items.push({ + type: "message", + role: "assistant", + content: [{ type: "output_text", text: textBlocks.join("") }], + ...(phase ? { phase } : {}), + }); + textBlocks.length = 0; + }; + + for (const block of message.content) { + if (block.type === "text") { + if (!phase) { + phase = parseTextSignaturePhase(block.textSignature); + } + textBlocks.push(block.text); + continue; + } + if (block.type === "thinking") { + flushText(); + const reasoning = parseThinkingSignature(block.thinkingSignature); + if (reasoning) items.push(reasoning); + continue; + } + if (block.type !== "toolCall") continue; + + flushText(); + const callId = typeof block.id === "string" ? block.id.split("|", 1)[0] : block.id; + items.push({ + type: "function_call", + name: block.name, + call_id: typeof callId === "string" ? callId : String(callId), + arguments: JSON.stringify(block.arguments ?? {}), + }); + } + + flushText(); + return items; + } + + if (message.role === "toolResult") { + items.push({ + type: "function_call_output", + call_id: message.toolCallId.split("|", 1)[0], + output: toolResultContentToOutput(message.content), + }); + } + + return items; +} + +export function messagesToResponseItems(messages: AgentMessage[]): ResponseItem[] { + return messages.flatMap((message) => messageToResponseItems(message)); +} + +function cloneResponseItem(item: ResponseItem): ResponseItem { + return JSON.parse(JSON.stringify(item)) as ResponseItem; +} + +function responseItemCallId(item: ResponseItem): string | undefined { + const callId = (item as Record).call_id; + return typeof callId === "string" && callId ? callId : undefined; +} + +function responseItemOutput(item: ResponseItem): unknown { + return (item as Record).output; +} + +function syntheticOutputForCall(item: ResponseItem): ResponseItem | undefined { + const callId = responseItemCallId(item); + if (!callId) return undefined; + + if (item.type === "function_call" || item.type === "local_shell_call") { + return { type: "function_call_output", call_id: callId, output: "aborted" }; + } + if (item.type === "tool_search_call") { + return { + type: "tool_search_output", + call_id: callId, + status: "completed", + execution: "client", + tools: [], + }; + } + if (item.type === "custom_tool_call") { + return { type: "custom_tool_call_output", call_id: callId, output: "aborted" }; + } + return undefined; +} + +function outputTypeForCallType(type: string): string | undefined { + if (type === "function_call" || type === "local_shell_call") return "function_call_output"; + if (type === "tool_search_call") return "tool_search_output"; + if (type === "custom_tool_call") return "custom_tool_call_output"; + return undefined; +} + +function ensureCallOutputsPresent(items: ResponseItem[]): ResponseItem[] { + const normalized: ResponseItem[] = []; + for (const item of items) { + normalized.push(item); + const outputType = outputTypeForCallType(item.type); + const callId = responseItemCallId(item); + if (!outputType || !callId) continue; + + const hasOutput = items.some((candidate) => ( + candidate.type === outputType && + responseItemCallId(candidate) === callId + )); + if (!hasOutput) { + const synthetic = syntheticOutputForCall(item); + if (synthetic) normalized.push(synthetic); + } + } + return normalized; +} + +function removeOrphanOutputs(items: ResponseItem[]): ResponseItem[] { + const functionCallIds = new Set(); + const toolSearchCallIds = new Set(); + const customToolCallIds = new Set(); + + for (const item of items) { + const callId = responseItemCallId(item); + if (!callId) continue; + if (item.type === "function_call" || item.type === "local_shell_call") { + functionCallIds.add(callId); + } else if (item.type === "tool_search_call") { + toolSearchCallIds.add(callId); + } else if (item.type === "custom_tool_call") { + customToolCallIds.add(callId); + } + } + + return items.filter((item) => { + const callId = responseItemCallId(item); + if (item.type === "function_call_output") { + return Boolean(callId && functionCallIds.has(callId)); + } + if (item.type === "custom_tool_call_output") { + return Boolean(callId && customToolCallIds.has(callId)); + } + if (item.type === "tool_search_output") { + if (item.execution === "server" || callId === undefined) return true; + return toolSearchCallIds.has(callId); + } + return true; + }); +} + +function modelSupportsImageInput(model: { input?: readonly unknown[] }): boolean { + return Array.isArray(model.input) && model.input.includes("image"); +} + +function stripUnsupportedImageContentItems(items: ResponseContentItem[]): ResponseContentItem[] { + return items.map((item) => ( + item.type === "input_image" + ? { type: "input_text", text: IMAGE_CONTENT_OMITTED_PLACEHOLDER } + : item + )); +} + +function stripUnsupportedFunctionOutputImages(output: unknown): unknown { + if (Array.isArray(output)) { + return output.map((item) => ( + isRecord(item) && item.type === "input_image" + ? { type: "input_text", text: IMAGE_CONTENT_OMITTED_PLACEHOLDER } + : item + )); + } + if (isRecord(output) && Array.isArray(output.content)) { + return { + ...output, + content: stripUnsupportedFunctionOutputImages(output.content), + }; + } + return output; +} + +function stripImagesWhenUnsupported(items: ResponseItem[], model: { input?: readonly unknown[] }): ResponseItem[] { + if (modelSupportsImageInput(model)) return items; + + return items.map((item) => { + const next = cloneResponseItem(item); + if (next.type === "message" && Array.isArray(next.content)) { + next.content = stripUnsupportedImageContentItems(next.content); + } else if ( + (next.type === "function_call_output" || next.type === "custom_tool_call_output") && + "output" in next + ) { + next.output = stripUnsupportedFunctionOutputImages(responseItemOutput(next)); + } else if (next.type === "image_generation_call" && typeof next.result === "string") { + next.result = ""; + } + return next; + }); +} + +export function normalizeResponseItemsForPrompt( + items: ResponseItem[], + model: { input?: readonly unknown[] }, +): ResponseItem[] { + const withoutGhostSnapshots = items + .filter((item) => item.type !== "ghost_snapshot") + .map(cloneResponseItem); + const withCallOutputs = ensureCallOutputsPresent(withoutGhostSnapshots); + const withoutOrphanOutputs = removeOrphanOutputs(withCallOutputs); + return stripImagesWhenUnsupported(withoutOrphanOutputs, model); +} + +function isRealUserMessage(item: ResponseItem): boolean { + if (item.type !== "message" || item.role !== "user") return false; + if (typeof item.content === "string") return item.content.trim().length > 0; + return Array.isArray(item.content) && item.content.length > 0; +} + +function shouldKeepCompactedHistoryItem(item: ResponseItem): boolean { + if (item.type === "message" && item.role === "developer") return false; + if (item.type === "message" && item.role === "user") return isRealUserMessage(item); + if (item.type === "message" && item.role === "assistant") return true; + if (item.type === "compaction" || item.type === "compaction_summary") return true; + return false; +} + +export function processCompactedHistory(items: ResponseItem[]): ResponseItem[] { + return items.filter(shouldKeepCompactedHistoryItem).map(cloneResponseItem); +} + +function responseMessageText(item: ResponseItem): string { + if (item.type !== "message" || !Array.isArray(item.content)) return ""; + return item.content + .filter((content): content is Extract => + content.type === "input_text" || content.type === "output_text", + ) + .map((content) => content.text) + .join(""); +} + +function approximateMessageTokens(item: ResponseItem): number { + return Math.max(1, Math.ceil(responseMessageText(item).length / 4)); +} + +function truncateMessageToTokenBudget(item: ResponseItem, maxTokens: number): ResponseItem | undefined { + if (item.type !== "message" || !Array.isArray(item.content)) return cloneResponseItem(item); + let remainingCharacters = Math.max(0, maxTokens * 4); + const content = item.content.flatMap((part) => { + if (part.type === "input_image") return [part]; + if (remainingCharacters === 0) return []; + const text = part.text.slice(0, remainingCharacters); + remainingCharacters -= text.length; + return text ? [{ ...part, text }] : []; + }); + return content.length > 0 ? { ...cloneResponseItem(item), content } : undefined; +} + +function truncateRetainedMessages(items: ResponseItem[], maxTokens: number): ResponseItem[] { + let remainingTokens = maxTokens; + const retainedReversed: ResponseItem[] = []; + for (const item of [...items].reverse()) { + if (remainingTokens === 0) break; + const tokenCount = approximateMessageTokens(item); + if (tokenCount <= remainingTokens) { + retainedReversed.push(cloneResponseItem(item)); + remainingTokens -= tokenCount; + continue; + } + const truncated = truncateMessageToTokenBudget(item, remainingTokens); + if (truncated) retainedReversed.push(truncated); + remainingTokens = 0; + } + return retainedReversed.reverse(); +} + +export function buildRemoteCompactionV2History( + input: ResponseItem[], + compactionItem: ResponseItem, +): ResponseItem[] { + if (compactionItem.type !== "compaction") { + throw new Error("OpenAI remote compaction v2 did not return a compaction item."); + } + const retainedUserMessages = input.filter( + (item) => item.type === "message" && item.role === "user" && isRealUserMessage(item), + ); + return [ + ...truncateRetainedMessages(retainedUserMessages, RETAINED_MESSAGE_TOKEN_BUDGET), + cloneResponseItem(compactionItem), + ]; +} + +function toolInfoToResponseTool(tool: ToolInfo): Record { + return { + type: "function", + name: tool.name, + description: tool.description, + parameters: tool.parameters, + }; +} + +export function buildToolsPayload( + allTools: ToolInfo[], + activeToolNames: string[], +): Record[] { + const active = new Set(activeToolNames); + return allTools.filter((tool) => active.has(tool.name)).map(toolInfoToResponseTool); +} + +export async function generatePortableSummary(params: { + messages: AgentMessage[]; + model: Model; + apiKey: string; + headers?: Record; + customInstructions?: string; + signal?: AbortSignal; + firstKeptEntryId: string; + tokensBefore: number; +}): Promise { + const conversation = serializeConversation(convertToLlm(params.messages)); + const response = await complete( + params.model, + { + messages: [ + { + role: "user", + content: [{ type: "text", text: buildPortableSummaryPrompt(conversation, params.customInstructions) }], + timestamp: Date.now(), + }, + ], + }, + { + apiKey: params.apiKey, + headers: params.headers, + maxTokens: 4096, + signal: params.signal, + }, + ); + + const summary = response.content + .filter((item): item is { type: "text"; text: string } => item.type === "text") + .map((item) => item.text) + .join("\n") + .trim(); + + return { + summary: summary || buildCompactionSummaryText(params.model), + firstKeptEntryId: params.firstKeptEntryId, + tokensBefore: params.tokensBefore, + }; +} + +export async function generateBestEffortLocalSummary(params: { + preparation: CompactionPreparation; + messages: AgentMessage[]; + model: Model; + apiKey: string; + headers?: Record; + customInstructions?: string; + signal?: AbortSignal; + thinkingLevel?: ThinkingLevel; + firstKeptEntryId: string; + tokensBefore: number; +}): Promise { + try { + return await generatePortableSummary(params); + } catch { + return await compact( + params.preparation, + params.model, + params.apiKey, + params.headers, + params.customInstructions, + params.signal, + params.thinkingLevel, + ); + } +} + +function extractCacheWriteTokens(value: unknown): number { + if (!isRecord(value)) return 0; + const cacheCreationTokens = value.cache_creation_tokens; + if (typeof cacheCreationTokens === "number" && Number.isFinite(cacheCreationTokens)) { + return cacheCreationTokens; + } + const cacheWriteTokens = value.cache_write_tokens; + return typeof cacheWriteTokens === "number" && Number.isFinite(cacheWriteTokens) + ? cacheWriteTokens + : 0; +} + +function extractRemoteCompactionUsage(model: Model, value: unknown): RemoteCompactionUsageSnapshot | undefined { + if (!isRecord(value)) return undefined; + + const inputTokens = typeof value.input_tokens === "number" && Number.isFinite(value.input_tokens) + ? value.input_tokens + : 0; + const outputTokens = typeof value.output_tokens === "number" && Number.isFinite(value.output_tokens) + ? value.output_tokens + : 0; + const totalTokens = typeof value.total_tokens === "number" && Number.isFinite(value.total_tokens) + ? value.total_tokens + : inputTokens + outputTokens; + const inputTokenDetails = isRecord(value.input_tokens_details) ? value.input_tokens_details : undefined; + const cachedTokens = typeof inputTokenDetails?.cached_tokens === "number" && Number.isFinite(inputTokenDetails.cached_tokens) + ? inputTokenDetails.cached_tokens + : 0; + const cacheWriteTokens = extractCacheWriteTokens(inputTokenDetails); + + const usage: RemoteCompactionUsageSnapshot = { + input: Math.max(0, inputTokens - cachedTokens - cacheWriteTokens), + output: outputTokens, + cacheRead: cachedTokens, + cacheWrite: cacheWriteTokens, + totalTokens, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + calculateCost(model, usage); + return usage; +} + +function parseUsageCostSnapshot(value: unknown): RemoteCompactionUsageSnapshot["cost"] | undefined { + if (!isRecord(value)) return undefined; + const input = typeof value.input === "number" && Number.isFinite(value.input) ? value.input : 0; + const output = typeof value.output === "number" && Number.isFinite(value.output) ? value.output : 0; + const cacheRead = typeof value.cacheRead === "number" && Number.isFinite(value.cacheRead) ? value.cacheRead : 0; + const cacheWrite = typeof value.cacheWrite === "number" && Number.isFinite(value.cacheWrite) ? value.cacheWrite : 0; + const total = typeof value.total === "number" && Number.isFinite(value.total) + ? value.total + : input + output + cacheRead + cacheWrite; + return { input, output, cacheRead, cacheWrite, total }; +} + +function parseRemoteCompactionUsageSnapshot(value: unknown): RemoteCompactionUsageSnapshot | undefined { + if (!isRecord(value)) return undefined; + const input = typeof value.input === "number" && Number.isFinite(value.input) ? value.input : 0; + const output = typeof value.output === "number" && Number.isFinite(value.output) ? value.output : 0; + const cacheRead = typeof value.cacheRead === "number" && Number.isFinite(value.cacheRead) ? value.cacheRead : 0; + const cacheWrite = typeof value.cacheWrite === "number" && Number.isFinite(value.cacheWrite) ? value.cacheWrite : 0; + const totalTokens = typeof value.totalTokens === "number" && Number.isFinite(value.totalTokens) + ? value.totalTokens + : input + output + cacheRead + cacheWrite; + return { + input, + output, + cacheRead, + cacheWrite, + totalTokens, + cost: parseUsageCostSnapshot(value.cost) ?? { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }; +} + +export function buildRemoteCompactionRequestBody(params: { + model: Model; + input: ResponseItem[]; + instructions?: string; + tools: Record[]; + parallelToolCalls: boolean; + reasoning?: ResponsesReasoningConfig; + text?: ResponsesTextConfig; + sessionId?: string; +}): Record { + return { + model: params.model.id, + input: [...params.input, { type: "compaction_trigger" }], + instructions: params.instructions, + tools: params.tools, + parallel_tool_calls: params.parallelToolCalls, + tool_choice: "auto", + stream: true, + store: false, + include: ["reasoning.encrypted_content"], + ...(params.sessionId ? { prompt_cache_key: params.sessionId } : {}), + ...(params.reasoning ? { reasoning: params.reasoning } : {}), + ...(params.text ? { text: params.text } : {}), + }; +} + +type RemoteCompactionV2Events = { + compactionItem: ResponseItem; + usage?: unknown; +}; + +function parseSseData(text: string): unknown[] { + return text + .replace(/\r\n/g, "\n") + .split("\n\n") + .flatMap((block) => { + const data = block + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()) + .join("\n") + .trim(); + if (!data || data === "[DONE]") return []; + try { + return [JSON.parse(data) as unknown]; + } catch { + return []; + } + }); +} + +export function parseRemoteCompactionV2Events(events: unknown[]): RemoteCompactionV2Events { + let completed = false; + let usage: unknown; + const compactionItems: ResponseItem[] = []; + + for (const event of events) { + if (!isRecord(event)) continue; + if (event.type === "error") { + const message = typeof event.message === "string" ? event.message : "Unknown Responses API error"; + throw new Error(`OpenAI remote compaction v2 failed: ${message}`); + } + if (event.type === "response.failed") { + const response = isRecord(event.response) ? event.response : undefined; + const error = response && isRecord(response.error) ? response.error : undefined; + const message = typeof error?.message === "string" ? error.message : "Response failed"; + throw new Error(`OpenAI remote compaction v2 failed: ${message}`); + } + if (event.type === "response.output_item.done" && isResponseItem(event.item)) { + if (event.item.type === "compaction") compactionItems.push(event.item); + continue; + } + if (event.type === "response.completed") { + completed = true; + const response = isRecord(event.response) ? event.response : undefined; + usage = response?.usage; + } + } + + if (!completed) { + throw new Error("OpenAI remote compaction v2 stream ended before response.completed."); + } + if (compactionItems.length !== 1) { + throw new Error( + `OpenAI remote compaction v2 expected exactly one compaction item, got ${compactionItems.length}.`, + ); + } + return { compactionItem: compactionItems[0], usage }; +} + +export async function callRemoteCompactionEndpoint(params: { + model: Model; + apiKey: string; + headers?: Record; + sessionId?: string; + input: ResponseItem[]; + instructions?: string; + tools: Record[]; + parallelToolCalls: boolean; + reasoning?: ResponsesReasoningConfig; + text?: ResponsesTextConfig; + signal?: AbortSignal; +}): Promise { + if (!supportsRemoteCompactionModel(params.model)) { + throw new Error("Remote compaction v2 is currently only enabled for supported OpenAI-compatible Responses models."); + } + + const response = await fetch(remoteCompactionV2EndpointUrl(params.model), { + method: "POST", + headers: buildRemoteCompactionHeaders({ + model: params.model, + apiKey: params.apiKey, + headers: params.headers, + sessionId: params.sessionId, + }), + body: JSON.stringify(buildRemoteCompactionRequestBody({ + model: params.model, + input: params.input, + instructions: params.instructions, + tools: params.tools, + parallelToolCalls: params.parallelToolCalls, + reasoning: params.reasoning, + text: params.text, + sessionId: params.sessionId, + })), + signal: params.signal, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`OpenAI remote compaction v2 failed (${response.status}): ${text || response.statusText}`); + } + + const responseText = await response.text(); + const parsed = parseRemoteCompactionV2Events(parseSseData(responseText)); + return { + output: buildRemoteCompactionV2History(params.input, parsed.compactionItem), + usage: extractRemoteCompactionUsage(params.model, parsed.usage), + }; +} + +export function buildRemoteCompactionDetails( + model: Model, + replacementHistory: ResponseItem[], + usage?: RemoteCompactionUsageSnapshot, +): RemoteCompactionDetails { + return { + version: 2, + provider: "openai-responses-compaction", + implementation: "responses_compaction_v2", + modelKey: modelKey(model), + replacementHistory, + ...(usage ? { usage } : {}), + }; +} + +export function extractRemoteCompactionDetails(details: unknown): + | RemoteCompactionDetails + | undefined { + if (!isRecord(details)) return undefined; + + const remote = isRecord(details.remoteCompaction) ? details.remoteCompaction : details; + if (!isRecord(remote)) return undefined; + const isLegacy = remote.provider === "openai-responses-compact" && remote.version === 1; + const isV2 = remote.provider === "openai-responses-compaction" && remote.version === 2; + if (!isLegacy && !isV2) return undefined; + if (!Array.isArray(remote.replacementHistory)) return undefined; + + const replacementHistory = remote.replacementHistory.filter(isResponseItem); + if (replacementHistory.length === 0) return undefined; + + const usage = parseRemoteCompactionUsageSnapshot(remote.usage); + + return { + version: isV2 ? 2 : 1, + provider: isV2 ? "openai-responses-compaction" : "openai-responses-compact", + implementation: isV2 ? "responses_compaction_v2" : "responses_compact_v1", + modelKey: typeof remote.modelKey === "string" ? remote.modelKey : "", + replacementHistory, + ...(usage ? { usage } : {}), + }; +} + +function parseModelKeyParts( + value: string, +): { provider: string; api: string; id: string } | undefined { + const [provider, api, id] = value.split(":", 3); + if (!provider || !api || !id) return undefined; + return { provider, api, id }; +} + +function assistantMessageMatchesModelKey( + message: AgentMessage, + targetModelKey: string, +): boolean { + const target = parseModelKeyParts(targetModelKey); + if (!target) return false; + if (!isRecord(message)) return false; + return message.provider === target.provider && message.model === target.id; +} + +export function reconstructRemoteCompactionStateFromBranch(params: { + branchEntries: Array<{ type: string; id: string; details?: unknown; message?: AgentMessage }>; +}): RemoteCompactionSessionState | undefined { + let latestCompactionIndex = -1; + let latestCompactionEntryId = ""; + let latestDetails: RemoteCompactionDetails | undefined; + + params.branchEntries.forEach((entry, index) => { + if (entry.type !== "compaction") return; + latestCompactionIndex = index; + latestCompactionEntryId = entry.id; + latestDetails = extractRemoteCompactionDetails(entry.details); + }); + + if (!latestDetails || latestCompactionIndex < 0) return undefined; + + const trailingMessages: ResponseItem[] = []; + let pendingTurnItems: ResponseItem[] = []; + + for (const entry of params.branchEntries.slice(latestCompactionIndex + 1)) { + if (entry.type !== "message" || !entry.message) continue; + + const items = messageToResponseItems(entry.message); + if (items.length === 0) continue; + + if (entry.message.role === "assistant") { + if (assistantMessageMatchesModelKey(entry.message, latestDetails.modelKey)) { + trailingMessages.push(...pendingTurnItems, ...items); + } + pendingTurnItems = []; + continue; + } + + pendingTurnItems.push(...items); + } + + return { + compactionEntryId: latestCompactionEntryId, + modelKey: latestDetails.modelKey, + replacementHistory: latestDetails.replacementHistory, + explicitHistory: [...latestDetails.replacementHistory, ...trailingMessages], + }; +} + +export function buildCompactionSummaryText(model: Model): string { + const host = hostnameFromBaseUrl(model.baseUrl) ?? "api.openai.com"; + return `OpenAI remote compaction applied for ${model.provider}/${model.id} via ${host}. Pi keeps this textual summary for portability, while compatible future OpenAI turns can use provider-native replacement history stored in compaction details.`; +} diff --git a/pi/extensions/openai-server-compaction/state.ts b/pi/extensions/openai-server-compaction/state.ts new file mode 100644 index 0000000..ce75385 --- /dev/null +++ b/pi/extensions/openai-server-compaction/state.ts @@ -0,0 +1,62 @@ +/** + * In-memory per-session runtime state. + * + * This data is intentionally ephemeral. Persisted remote compaction artifacts + * live in Pi session entries; this module only caches the currently active + * continuation and reconstructed replay state for the running process. + */ +import type { + RemoteCompactionSessionState, + ResponsesReasoningConfig, + ResponsesTextConfig, +} from "./remote-compaction.ts"; + +export type ResponsesRequestShapeState = { + updatedAt: number; + reasoning?: ResponsesReasoningConfig; + text?: ResponsesTextConfig; +}; + +const remoteCompactionBySessionId = new Map(); +const requestShapeBySessionId = new Map(); + +export function getRemoteCompactionState( + sessionId: string, +): RemoteCompactionSessionState | undefined { + return remoteCompactionBySessionId.get(sessionId); +} + +export function setRemoteCompactionState( + sessionId: string, + state: RemoteCompactionSessionState, +): void { + remoteCompactionBySessionId.set(sessionId, state); +} + +export function clearRemoteCompactionState(sessionId: string | undefined): void { + if (!sessionId) return; + remoteCompactionBySessionId.delete(sessionId); +} + +export function getResponsesRequestShapeState( + sessionId: string, +): ResponsesRequestShapeState | undefined { + return requestShapeBySessionId.get(sessionId); +} + +export function setResponsesRequestShapeState( + sessionId: string, + state: ResponsesRequestShapeState, +): void { + requestShapeBySessionId.set(sessionId, state); +} + +export function clearResponsesRequestShapeState(sessionId: string | undefined): void { + if (!sessionId) return; + requestShapeBySessionId.delete(sessionId); +} + +export function clearAllRuntimeState(): void { + remoteCompactionBySessionId.clear(); + requestShapeBySessionId.clear(); +} diff --git a/pi/models.json b/pi/models.json index 75816e7..5ef7cf9 100644 --- a/pi/models.json +++ b/pi/models.json @@ -22,6 +22,19 @@ "cacheRead": 0.08, "cacheWrite": 0 } + }, + { + "id": "kimi-k3", + "name": "CrofAI Kimi K3", + "reasoning": true, + "contextWindow": 1000000, + "maxTokens": 262144, + "cost": { + "input": 2, + "output": 8, + "cacheRead": 0.25, + "cacheWrite": 0 + } } ] } diff --git a/pi/skills/bro/SKILL.md b/pi/skills/bro/SKILL.md new file mode 100644 index 0000000..b1701b7 --- /dev/null +++ b/pi/skills/bro/SKILL.md @@ -0,0 +1,7 @@ +--- +name: bro +description: Restate the last message in plain human language, with no jargon. +disable-model-invocation: true +--- + +Restate your last message. Stop using jargon and speak coherently. State it more simply and concisely, like one human talking to another. diff --git a/pi/skills/domain-modeling/ADR-FORMAT.md b/pi/skills/domain-modeling/ADR-FORMAT.md new file mode 100644 index 0000000..da7e78e --- /dev/null +++ b/pi/skills/domain-modeling/ADR-FORMAT.md @@ -0,0 +1,47 @@ +# ADR Format + +ADRs live in `docs/adr/` and use sequential numbering: `0001-slug.md`, `0002-slug.md`, etc. + +Create the `docs/adr/` directory lazily — only when the first ADR is needed. + +## Template + +```md +# {Short title of the decision} + +{1-3 sentences: what's the context, what did we decide, and why.} +``` + +That's it. An ADR can be a single paragraph. The value is in recording *that* a decision was made and *why* — not in filling out sections. + +## Optional sections + +Only include these when they add genuine value. Most ADRs won't need them. + +- **Status** frontmatter (`proposed | accepted | deprecated | superseded by ADR-NNNN`) — useful when decisions are revisited +- **Considered Options** — only when the rejected alternatives are worth remembering +- **Consequences** — only when non-obvious downstream effects need to be called out + +## Numbering + +Scan `docs/adr/` for the highest existing number and increment by one. + +## When to offer an ADR + +All three of these must be true: + +1. **Hard to reverse** — the cost of changing your mind later is meaningful +2. **Surprising without context** — a future reader will look at the code and wonder "why on earth did they do it this way?" +3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons + +If a decision is easy to reverse, skip it — you'll just reverse it. If it's not surprising, nobody will wonder why. If there was no real alternative, there's nothing to record beyond "we did the obvious thing." + +### What qualifies + +- **Architectural shape.** "We're using a monorepo." "The write model is event-sourced, the read model is projected into Postgres." +- **Integration patterns between contexts.** "Ordering and Billing communicate via domain events, not synchronous HTTP." +- **Technology choices that carry lock-in.** Database, message bus, auth provider, deployment target. Not every library — just the ones that would take a quarter to swap out. +- **Boundary and scope decisions.** "Customer data is owned by the Customer context; other contexts reference it by ID only." The explicit no-s are as valuable as the yes-s. +- **Deliberate deviations from the obvious path.** "We're using manual SQL instead of an ORM because X." Anything where a reasonable reader would assume the opposite. These stop the next engineer from "fixing" something that was deliberate. +- **Constraints not visible in the code.** "We can't use AWS because of compliance requirements." "Response times must be under 200ms because of the partner API contract." +- **Rejected alternatives when the rejection is non-obvious.** If you considered GraphQL and picked REST for subtle reasons, record it — otherwise someone will suggest GraphQL again in six months. diff --git a/pi/skills/domain-modeling/CONTEXT-FORMAT.md b/pi/skills/domain-modeling/CONTEXT-FORMAT.md new file mode 100644 index 0000000..eaf2a18 --- /dev/null +++ b/pi/skills/domain-modeling/CONTEXT-FORMAT.md @@ -0,0 +1,60 @@ +# CONTEXT.md Format + +## Structure + +```md +# {Context Name} + +{One or two sentence description of what this context is and why it exists.} + +## Language + +**Order**: +{A one or two sentence description of the term} +_Avoid_: Purchase, transaction + +**Invoice**: +A request for payment sent to a customer after delivery. +_Avoid_: Bill, payment request + +**Customer**: +A person or organization that places orders. +_Avoid_: Client, buyer, account +``` + +## Rules + +- **Be opinionated.** When multiple words exist for the same concept, pick the best one and list the others under `_Avoid_`. +- **Keep definitions tight.** One or two sentences max. Define what it IS, not what it does. +- **Only include terms specific to this project's context.** General programming concepts (timeouts, error types, utility patterns) don't belong even if the project uses them extensively. Before adding a term, ask: is this a concept unique to this context, or a general programming concept? Only the former belongs. +- **Group terms under subheadings** when natural clusters emerge. If all terms belong to a single cohesive area, a flat list is fine. + +## Single vs multi-context repos + +**Single context (most repos):** One `CONTEXT.md` at the repo root. + +**Multiple contexts:** A `CONTEXT-MAP.md` at the repo root lists the contexts, where they live, and how they relate to each other: + +```md +# Context Map + +## Contexts + +- [Ordering](./src/ordering/CONTEXT.md) — receives and tracks customer orders +- [Billing](./src/billing/CONTEXT.md) — generates invoices and processes payments +- [Fulfillment](./src/fulfillment/CONTEXT.md) — manages warehouse picking and shipping + +## Relationships + +- **Ordering → Fulfillment**: Ordering emits `OrderPlaced` events; Fulfillment consumes them to start picking +- **Fulfillment → Billing**: Fulfillment emits `ShipmentDispatched` events; Billing consumes them to generate invoices +- **Ordering ↔ Billing**: Shared types for `CustomerId` and `Money` +``` + +The skill infers which structure applies: + +- If `CONTEXT-MAP.md` exists, read it to find contexts +- If only a root `CONTEXT.md` exists, single context +- If neither exists, create a root `CONTEXT.md` lazily when the first term is resolved + +When multiple contexts exist, infer which one the current topic relates to. If unclear, ask. diff --git a/pi/skills/domain-modeling/SKILL.md b/pi/skills/domain-modeling/SKILL.md new file mode 100644 index 0000000..d0f7e1a --- /dev/null +++ b/pi/skills/domain-modeling/SKILL.md @@ -0,0 +1,74 @@ +--- +name: domain-modeling +description: Build and sharpen a project's domain model. Use when the user wants to pin down domain terminology or a ubiquitous language, record an architectural decision, or when another skill needs to maintain the domain model. +--- + +# Domain Modeling + +Actively build and sharpen the project's domain model as you design. This is the *active* discipline — challenging terms, inventing edge-case scenarios, and writing the glossary and decisions down the moment they crystallise. (Merely *reading* `CONTEXT.md` for vocabulary is not this skill — that's a one-line habit any skill can do. This skill is for when you're changing the model, not just consuming it.) + +## File structure + +Most repos have a single context: + +``` +/ +├── CONTEXT.md +├── docs/ +│ └── adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +If a `CONTEXT-MAP.md` exists at the root, the repo has multiple contexts. The map points to where each one lives: + +``` +/ +├── CONTEXT-MAP.md +├── docs/ +│ └── adr/ ← system-wide decisions +├── src/ +│ ├── ordering/ +│ │ ├── CONTEXT.md +│ │ └── docs/adr/ ← context-specific decisions +│ └── billing/ +│ ├── CONTEXT.md +│ └── docs/adr/ +``` + +Create files lazily — only when you have something to write. If no `CONTEXT.md` exists, create one when the first term is resolved. If no `docs/adr/` exists, create it when the first ADR is needed. + +## During the session + +### Challenge against the glossary + +When the user uses a term that conflicts with the existing language in `CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y — which is it?" + +### Sharpen fuzzy language + +When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account' — do you mean the Customer or the User? Those are different things." + +### Discuss concrete scenarios + +When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts. + +### Cross-reference with code + +When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible — which is right?" + +### Update CONTEXT.md inline + +When a term is resolved, update `CONTEXT.md` right there. Don't batch these up — capture them as they happen. Use the format in [CONTEXT-FORMAT.md](./CONTEXT-FORMAT.md). + +`CONTEXT.md` should be totally devoid of implementation details. Do not treat `CONTEXT.md` as a spec, a scratch pad, or a repository for implementation decisions. It is a glossary and nothing else. + +### Offer ADRs sparingly + +Only offer to create an ADR when all three are true: + +1. **Hard to reverse** — the cost of changing your mind later is meaningful +2. **Surprising without context** — a future reader will wonder "why did they do it this way?" +3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons + +If any of the three is missing, skip the ADR. Use the format in [ADR-FORMAT.md](./ADR-FORMAT.md). diff --git a/pi/skills/grill-me/SKILL.md b/pi/skills/grill-me/SKILL.md index bd04394..9470cfc 100644 --- a/pi/skills/grill-me/SKILL.md +++ b/pi/skills/grill-me/SKILL.md @@ -1,10 +1,7 @@ --- name: grill-me -description: Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions "grill me". +description: A relentless interview to sharpen a plan or design. +disable-model-invocation: true --- -Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. - -Ask the questions one at a time. - -If a question can be answered by exploring the codebase, explore the codebase instead. +Run a `/grilling` session. diff --git a/pi/skills/grill-with-docs/SKILL.md b/pi/skills/grill-with-docs/SKILL.md new file mode 100644 index 0000000..bed05d2 --- /dev/null +++ b/pi/skills/grill-with-docs/SKILL.md @@ -0,0 +1,7 @@ +--- +name: grill-with-docs +description: A relentless interview to sharpen a plan or design, which also creates docs (ADR's and glossary) as we go. +disable-model-invocation: true +--- + +Run a `/grilling` session, using the `/domain-modeling` skill. diff --git a/pi/skills/grilling/SKILL.md b/pi/skills/grilling/SKILL.md new file mode 100644 index 0000000..52d8eb3 --- /dev/null +++ b/pi/skills/grilling/SKILL.md @@ -0,0 +1,12 @@ +--- +name: grilling +description: Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases. +--- + +Interview me relentlessly about every aspect of this until we reach a shared understanding. Walk down each branch of the decision tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. + +Ask the questions one at a time, waiting for feedback on each question before continuing. Asking multiple questions at once is bewildering. + +If a *fact* can be found by exploring the environment (filesystem, tools, etc.), look it up rather than asking me. The *decisions*, though, are mine — put each one to me and wait for my answer. + +Do not act on it until I confirm we have reached a shared understanding.