enhance pi configuration and skills
This commit is contained in:
parent
623512cbf5
commit
ab0a86a707
18 changed files with 2000 additions and 159 deletions
24
flake.lock
generated
24
flake.lock
generated
|
|
@ -42,11 +42,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1782772816,
|
"lastModified": 1784665499,
|
||||||
"narHash": "sha256-s9BuFv0mRuZx9C1MF8qPHRdcAK14ONi0A5m6E2wqOoM=",
|
"narHash": "sha256-9BMxlTxCCDAeoNLtb1a/st7udtTIJep+wpUzquA29VU=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "bun2nix",
|
"repo": "bun2nix",
|
||||||
"rev": "5a39d717029e94163ac223aee8d5c9946cafed1c",
|
"rev": "0f2a1f0b6f42cebe3b149bf62d38754c5e0e9729",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -223,11 +223,11 @@
|
||||||
"treefmt-nix": "treefmt-nix"
|
"treefmt-nix": "treefmt-nix"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784067614,
|
"lastModified": 1784877872,
|
||||||
"narHash": "sha256-pOuOsr3EUz1Uh91FN6CFIhUrjZ+ZOhV6if5ENyomrxs=",
|
"narHash": "sha256-Y270/EgraTJpT4uI84YJKpcqOcVFJwDBZpHYT4Ydtpw=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "llm-agents.nix",
|
"repo": "llm-agents.nix",
|
||||||
"rev": "5e0b7a75654e606175b917ec446c1858078696c9",
|
"rev": "f9a30d16da12fcf83886b66d3df9b1b8c168a3f7",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -329,11 +329,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1783816212,
|
"lastModified": 1784555310,
|
||||||
"narHash": "sha256-2aZisVTVGSFEk3MYcOiWQp3zvwyzbqpktB69Rcfp150=",
|
"narHash": "sha256-/FCliTPgiuV1owejZFNx3Ch9irdvkOfOFl+HHZ+DrtM=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "3b32825de172d0bc85664f495edb096b10862524",
|
"rev": "421eebfd0ec7bccd4abe826ce62d7e6e83129493",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -467,11 +467,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1780220602,
|
"lastModified": 1784369104,
|
||||||
"narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=",
|
"narHash": "sha256-47cxbcZODibHv3rELFQ9vZly0vUNkND/atn/U7HLeb0=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "treefmt-nix",
|
"repo": "treefmt-nix",
|
||||||
"rev": "db947814a175b7ca6ded66e21383d938df01c227",
|
"rev": "df3c0640565d04a0261253cdd89fce78ec50168a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
|
||||||
|
|
@ -23,18 +23,6 @@ let
|
||||||
# Pinned llm-agents for Claude Code 2.0.64
|
# Pinned llm-agents for Claude Code 2.0.64
|
||||||
llmPkgsPinned = inputs.llm-agents-pinned.packages.${system};
|
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
|
# Z.AI Gateway wrapper for Claude Code
|
||||||
zaiWrapperPackages =
|
zaiWrapperPackages =
|
||||||
let
|
let
|
||||||
|
|
@ -287,20 +275,21 @@ in
|
||||||
home.packages = [
|
home.packages = [
|
||||||
llmPkgs.claude-code # latest
|
llmPkgs.claude-code # latest
|
||||||
llmPkgs.opencode # latest
|
llmPkgs.opencode # latest
|
||||||
piPatched
|
llmPkgs.pi # latest
|
||||||
llmPkgs.ccstatusline # latest
|
llmPkgs.ccstatusline # latest
|
||||||
llmPkgs.ccusage # latest
|
llmPkgs.ccusage # latest
|
||||||
llmPkgs.codex
|
llmPkgs.codex
|
||||||
llmPkgs.rtk
|
llmPkgs.rtk
|
||||||
llmPkgs.cursor-agent
|
llmPkgs.cursor-agent
|
||||||
llmPkgs.herdr # latest - terminal agent multiplexer
|
llmPkgs.herdr # latest - terminal agent multiplexer
|
||||||
|
llmPkgs.grok
|
||||||
]
|
]
|
||||||
++ zaiWrapperPackages
|
++ zaiWrapperPackages
|
||||||
++ kimiWrapperPackages
|
++ kimiWrapperPackages
|
||||||
++ cfg.extraPackages;
|
++ cfg.extraPackages;
|
||||||
|
|
||||||
home.activation.installPiFff = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
|
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_SKIP_VERSION_CHECK=1
|
||||||
export PI_TELEMETRY=0
|
export PI_TELEMETRY=0
|
||||||
export PATH=${pkgs.nodejs}/bin:${pkgs.git}/bin:$PATH
|
export PATH=${pkgs.nodejs}/bin:${pkgs.git}/bin:$PATH
|
||||||
|
|
@ -309,10 +298,10 @@ in
|
||||||
mkdir -p "$(dirname "$settings_file")"
|
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));"
|
${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 llmPkgs.pi} list | ${pkgs.gnugrep}/bin/grep -q '@ff-labs/pi-fff' || \
|
||||||
${lib.getExe piPatched} install npm:@ff-labs/pi-fff@0.9.4
|
${lib.getExe llmPkgs.pi} 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 llmPkgs.pi} 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} install npm:@juicesharp/rpiv-ask-user-question@1.20.0
|
||||||
fi
|
fi
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 <path-to-openai-codex-responses.js>
|
|
||||||
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);
|
|
||||||
21
pi/extensions/openai-server-compaction/LICENSE.md
Normal file
21
pi/extensions/openai-server-compaction/LICENSE.md
Normal file
|
|
@ -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.
|
||||||
81
pi/extensions/openai-server-compaction/README.md
Normal file
81
pi/extensions/openai-server-compaction/README.md
Normal file
|
|
@ -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.
|
||||||
59
pi/extensions/openai-server-compaction/config.ts
Normal file
59
pi/extensions/openai-server-compaction/config.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
|
||||||
|
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<ExtensionConfig> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
348
pi/extensions/openai-server-compaction/index.ts
Normal file
348
pi/extensions/openai-server-compaction/index.ts
Normal file
|
|
@ -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<typeof modelKey>[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<typeof getRemoteCompactionState> {
|
||||||
|
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<string>;
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
117
pi/extensions/openai-server-compaction/openai.ts
Normal file
117
pi/extensions/openai-server-compaction/openai.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
1070
pi/extensions/openai-server-compaction/remote-compaction.ts
Normal file
1070
pi/extensions/openai-server-compaction/remote-compaction.ts
Normal file
File diff suppressed because it is too large
Load diff
62
pi/extensions/openai-server-compaction/state.ts
Normal file
62
pi/extensions/openai-server-compaction/state.ts
Normal file
|
|
@ -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<string, RemoteCompactionSessionState>();
|
||||||
|
const requestShapeBySessionId = new Map<string, ResponsesRequestShapeState>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,19 @@
|
||||||
"cacheRead": 0.08,
|
"cacheRead": 0.08,
|
||||||
"cacheWrite": 0
|
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7
pi/skills/bro/SKILL.md
Normal file
7
pi/skills/bro/SKILL.md
Normal file
|
|
@ -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.
|
||||||
47
pi/skills/domain-modeling/ADR-FORMAT.md
Normal file
47
pi/skills/domain-modeling/ADR-FORMAT.md
Normal file
|
|
@ -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.
|
||||||
60
pi/skills/domain-modeling/CONTEXT-FORMAT.md
Normal file
60
pi/skills/domain-modeling/CONTEXT-FORMAT.md
Normal file
|
|
@ -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.
|
||||||
74
pi/skills/domain-modeling/SKILL.md
Normal file
74
pi/skills/domain-modeling/SKILL.md
Normal file
|
|
@ -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).
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
---
|
---
|
||||||
name: grill-me
|
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.
|
Run a `/grilling` session.
|
||||||
|
|
||||||
Ask the questions one at a time.
|
|
||||||
|
|
||||||
If a question can be answered by exploring the codebase, explore the codebase instead.
|
|
||||||
|
|
|
||||||
7
pi/skills/grill-with-docs/SKILL.md
Normal file
7
pi/skills/grill-with-docs/SKILL.md
Normal file
|
|
@ -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.
|
||||||
12
pi/skills/grilling/SKILL.md
Normal file
12
pi/skills/grilling/SKILL.md
Normal file
|
|
@ -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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue