bermudis-pi-goodies 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -0
- package/copy-with-model.ts +157 -0
- package/fixed-defaults.ts +362 -0
- package/index.ts +34 -0
- package/kilo.ts +727 -0
- package/model-thinking.ts +304 -0
- package/name-with-ai.ts +199 -0
- package/package.json +31 -0
- package/prefer-tools.ts +433 -0
- package/provider-balance.ts +848 -0
- package/zed.ts +22 -0
package/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# bermudis-pi-goodies
|
|
2
|
+
|
|
3
|
+
A bundle of small, frequently-used [Pi](https://github.com/earendil-works/pi)
|
|
4
|
+
extensions. One entry point, eight independent features.
|
|
5
|
+
|
|
6
|
+
| Feature | Command / hook | What it does |
|
|
7
|
+
|---------|----------------|--------------|
|
|
8
|
+
| `copy-with-model` | `/copy-with-model` | Copy last assistant message to the clipboard in a code fence tagged with the model name. |
|
|
9
|
+
| `name-with-ai` | `/name-with-ai [name]` | Generate a short session name from the first user message (or set one manually). |
|
|
10
|
+
| `zed` | `/z` | Open Zed editor on the current working directory. |
|
|
11
|
+
| `prefer-tools` | hook (no command) | Nudge toward modern CLIs: `rg` over `grep`, `fd` over `find`, `uv` over bare `python`/`pip`/`pytest`/`mypy`. |
|
|
12
|
+
| `model-thinking` | hook + `/model-thinking` | Apply provider/model thinking defaults and explicitly save per-model defaults. |
|
|
13
|
+
| `fixed-defaults` | hook + `/fixed-defaults` | Keep the global startup provider, model, and thinking defaults fixed while allowing in-session model changes; `/fixed-defaults set` pins the current model and thinking level. |
|
|
14
|
+
| `kilo` | provider | Access Kilo Gateway models via `/login kilo` or `KILO_API_KEY`. |
|
|
15
|
+
| `provider-balance` | footer (no command) | Show remaining Kilo or OpenRouter credits, z.ai token-plan quota, or OpenAI Codex quota on the right side of the working-directory footer line. |
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
After publishing the package to npm:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pi install npm:bermudis-pi-goodies@0.1.0
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Remove any old `bermudis-pi-goodies.ts` symlink before reloading Pi. Each
|
|
26
|
+
feature is independent — disabling one is a one-line edit in `index.ts`.
|
|
27
|
+
Kilo's provider and its balance footer are bundled here.
|
|
28
|
+
|
|
29
|
+
## Model-specific thinking
|
|
30
|
+
|
|
31
|
+
This feature is deliberately opt-in: models not covered by the config retain
|
|
32
|
+
Pi's native thinking-level behavior. Create `~/.pi/agent/model-thinking.json`
|
|
33
|
+
with provider defaults, exact model defaults, or both:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"providers": {
|
|
38
|
+
"anthropic": "high",
|
|
39
|
+
"openai-codex": "xhigh"
|
|
40
|
+
},
|
|
41
|
+
"models": {
|
|
42
|
+
"anthropic/claude-haiku-4-5": "low"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Exact `provider/model-id` entries take precedence over provider defaults. Use
|
|
48
|
+
`/model-thinking set` to explicitly save the current model's current thinking
|
|
49
|
+
level as an exact-model entry; it also bootstraps models not yet covered by the
|
|
50
|
+
config and overwrites an existing entry. Manual thinking-level changes in Pi do
|
|
51
|
+
not modify this file. All current Pi levels are accepted:
|
|
52
|
+
`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`.
|
|
53
|
+
`/model-thinking` shows the active resolution and config path;
|
|
54
|
+
`/model-thinking reset` deletes the whole config.
|
|
55
|
+
|
|
56
|
+
## Fixed startup defaults
|
|
57
|
+
|
|
58
|
+
Pi normally saves the last model and thinking level selected in the global
|
|
59
|
+
settings file. The bundle pins those cross-session defaults to:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"defaultProvider": "openai-codex",
|
|
64
|
+
"defaultModel": "gpt-5.6-luna",
|
|
65
|
+
"defaultThinkingLevel": "max"
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Selecting a different model still changes the active session and its transcript;
|
|
70
|
+
`fixed-defaults` only restores the startup values after Pi persists a selection.
|
|
71
|
+
|
|
72
|
+
The pinned values are the built-ins above, optionally overridden per field by
|
|
73
|
+
`~/.pi/agent/fixed-defaults.json` — omit a field to keep the built-in value for
|
|
74
|
+
it, and hand edits are picked up without a reload. Manage it from Pi:
|
|
75
|
+
|
|
76
|
+
- `/fixed-defaults set` — pin the currently active model and thinking level as
|
|
77
|
+
the new startup defaults (written to the override file and applied to
|
|
78
|
+
`settings.json` immediately).
|
|
79
|
+
- `/fixed-defaults` — show the effective pin, the active model, and the
|
|
80
|
+
override file path.
|
|
81
|
+
- `/fixed-defaults reset` — delete the override file and fall back to the
|
|
82
|
+
built-in values.
|
|
83
|
+
|
|
84
|
+
## Provider and balance details
|
|
85
|
+
|
|
86
|
+
Kilo registration is network-free: it starts with `kilo-auto/free`, restores
|
|
87
|
+
an authenticated catalog from Pi's model store, and normally revalidates that
|
|
88
|
+
catalog no more than every four hours. Balance and quota requests run in the background so they
|
|
89
|
+
never delay session startup, model selection, or post-run input readiness.
|
|
90
|
+
The footer also reads OpenRouter remaining credits for `openrouter`, z.ai GLM
|
|
91
|
+
Coding Plan token quota for `zai` (Global) and `zai-coding-cn` (BigModel China),
|
|
92
|
+
and OpenAI Codex's ChatGPT subscription quota when using `openai-codex` OAuth;
|
|
93
|
+
it skips platform API-key auth because that has no ChatGPT subscription quota.
|
|
94
|
+
OpenRouter uses `GET /api/v1/credits`; z.ai uses
|
|
95
|
+
`GET /api/monitor/usage/quota/limit`; Codex uses `GET /wham/usage`. z.ai and
|
|
96
|
+
Codex show both each quota's window length and its `nextResetTime`/`reset_at`
|
|
97
|
+
countdown (when supplied), and `CODEX_API_URL` or `CHATGPT_BASE_URL` can
|
|
98
|
+
override the Codex base URL.
|
|
99
|
+
|
|
100
|
+
All providers share one renderer: every balance is projected to a list of
|
|
101
|
+
segments and formatted the same way. Credits render as `$1.5k`; each usage
|
|
102
|
+
window renders as `[label ]<window> <remaining>%[ ↻<countdown>]`, e.g.
|
|
103
|
+
`7d 72% ↻4d4h` (`↻` = resets in). Multiple windows are joined with `·`, and
|
|
104
|
+
named extra limits like Codex Spark get a label: `7d 72% ↻4d4h · Spark 7d 74% ↻5d4h`.
|
|
105
|
+
The footer refreshes on session start, model
|
|
106
|
+
switch, and after each completed run (`agent_settled`), so it tracks both
|
|
107
|
+
consumption and external tier changes for whatever provider is active —
|
|
108
|
+
providers without a balance adapter are skipped, so this costs nothing for
|
|
109
|
+
unrelated sessions. Do not also load the standalone `kilo.ts` or
|
|
110
|
+
`provider-balance.ts` entries once this bundle is installed. If you previously
|
|
111
|
+
symlinked the standalone `provider-balance.ts`, remove that link — the feature
|
|
112
|
+
now ships in this bundle.
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /copy-with-model — Copy last assistant message wrapped in a code block
|
|
3
|
+
* tagged with the model name. Escapes backticks if needed.
|
|
4
|
+
*
|
|
5
|
+
* Example output for claude-sonnet-4:
|
|
6
|
+
* ```claude-sonnet-4
|
|
7
|
+
* Ok
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { execSync, spawn } from "node:child_process";
|
|
13
|
+
import { platform } from "node:os";
|
|
14
|
+
|
|
15
|
+
// ── Clipboard ─────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
function copyToClipboard(text: string): void {
|
|
18
|
+
const p = platform();
|
|
19
|
+
try {
|
|
20
|
+
if (p === "darwin") {
|
|
21
|
+
execSync("pbcopy", { input: text, timeout: 5000 });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (p === "win32") {
|
|
25
|
+
execSync("clip", { input: text, timeout: 5000 });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
// Linux — Wayland
|
|
29
|
+
if (process.env.WAYLAND_DISPLAY) {
|
|
30
|
+
const proc = spawn("wl-copy", [], {
|
|
31
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
32
|
+
});
|
|
33
|
+
proc.stdin.on("error", () => {});
|
|
34
|
+
proc.stdin.write(text);
|
|
35
|
+
proc.stdin.end();
|
|
36
|
+
proc.unref();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// Linux — X11
|
|
40
|
+
if (process.env.DISPLAY) {
|
|
41
|
+
try {
|
|
42
|
+
execSync("xclip -selection clipboard", { input: text, timeout: 5000 });
|
|
43
|
+
} catch {
|
|
44
|
+
execSync("xsel --clipboard --input", { input: text, timeout: 5000 });
|
|
45
|
+
}
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// OSC 52 fallback
|
|
50
|
+
const encoded = Buffer.from(text).toString("base64");
|
|
51
|
+
if (encoded.length <= 100_000) {
|
|
52
|
+
process.stdout.write(`\x1b]52;c;${encoded}\x07`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
throw new Error("No clipboard available");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/** Get text content of the last assistant entry from session entries. */
|
|
62
|
+
function getLastAssistantText(entries: any[]): string | undefined {
|
|
63
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
64
|
+
const entry = entries[i];
|
|
65
|
+
if (entry.type !== "message") continue;
|
|
66
|
+
const msg = entry.message;
|
|
67
|
+
if (!msg || msg.role !== "assistant") continue;
|
|
68
|
+
// Skip aborted messages with no content
|
|
69
|
+
if (
|
|
70
|
+
msg.stopReason === "aborted" &&
|
|
71
|
+
(!msg.content || msg.content.length === 0)
|
|
72
|
+
)
|
|
73
|
+
continue;
|
|
74
|
+
let text = "";
|
|
75
|
+
for (const block of msg.content ?? []) {
|
|
76
|
+
if (block.type === "text") text += block.text;
|
|
77
|
+
}
|
|
78
|
+
return text.trim() || undefined;
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Derive a short model tag from the full model id. */
|
|
84
|
+
function modelTag(model: { provider: string; id: string }): string {
|
|
85
|
+
// e.g. "anthropic/claude-sonnet-4" → "claude-sonnet-4"
|
|
86
|
+
// "openai/gpt-4o" → "gpt-4o"
|
|
87
|
+
return model.id;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Wrap text in a code fence, escalating fence level if the content
|
|
92
|
+
* already contains backtick sequences.
|
|
93
|
+
*
|
|
94
|
+
* ``` → ````
|
|
95
|
+
* ```` → `````
|
|
96
|
+
* etc.
|
|
97
|
+
*/
|
|
98
|
+
function wrapInCodeBlock(tag: string, text: string): string {
|
|
99
|
+
// Find the longest run of consecutive backticks in the content
|
|
100
|
+
let maxRun = 0;
|
|
101
|
+
for (const line of text.split("\n")) {
|
|
102
|
+
let run = 0;
|
|
103
|
+
for (const ch of line) {
|
|
104
|
+
if (ch === "`") {
|
|
105
|
+
run++;
|
|
106
|
+
maxRun = Math.max(maxRun, run);
|
|
107
|
+
} else {
|
|
108
|
+
run = 0;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Fence needs at least 3 backticks and one more than the longest run
|
|
114
|
+
const fenceLen = Math.max(3, maxRun + 1);
|
|
115
|
+
const fence = "`".repeat(fenceLen);
|
|
116
|
+
|
|
117
|
+
return `${fence}${tag}\n${text}\n${fence}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Extension ─────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
export default function (pi: ExtensionAPI) {
|
|
123
|
+
pi.registerCommand("copy-with-model", {
|
|
124
|
+
description:
|
|
125
|
+
"Copy last assistant message to clipboard in a code block tagged with the model name",
|
|
126
|
+
handler: async (_args, ctx) => {
|
|
127
|
+
await ctx.waitForIdle();
|
|
128
|
+
|
|
129
|
+
const entries = ctx.sessionManager.getEntries();
|
|
130
|
+
const text = getLastAssistantText(entries);
|
|
131
|
+
|
|
132
|
+
if (!text) {
|
|
133
|
+
ctx.ui.notify("No assistant messages to copy", "error");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const model = ctx.model;
|
|
138
|
+
if (!model) {
|
|
139
|
+
ctx.ui.notify("No model selected", "error");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const tag = modelTag(model);
|
|
144
|
+
const wrapped = wrapInCodeBlock(tag, text);
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
copyToClipboard(wrapped);
|
|
148
|
+
ctx.ui.notify(`Copied with model \`${tag}\``, "info");
|
|
149
|
+
} catch (err) {
|
|
150
|
+
ctx.ui.notify(
|
|
151
|
+
`Failed to copy: ${err instanceof Error ? err.message : err}`,
|
|
152
|
+
"error",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getAgentDir,
|
|
3
|
+
SettingsManager,
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
type ExtensionContext,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import {
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
|
|
16
|
+
const CONFIG_FILENAME = "fixed-defaults.json";
|
|
17
|
+
const ALL_LEVELS = [
|
|
18
|
+
"off",
|
|
19
|
+
"minimal",
|
|
20
|
+
"low",
|
|
21
|
+
"medium",
|
|
22
|
+
"high",
|
|
23
|
+
"xhigh",
|
|
24
|
+
"max",
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Values read from the override file. `thinkingLevel` may layer independently;
|
|
31
|
+
* `provider` and `model` are a coupled pair — a model id is meaningless without
|
|
32
|
+
* its provider, so both must be present if either is. Missing fields fall back
|
|
33
|
+
* to the built-in defaults.
|
|
34
|
+
*/
|
|
35
|
+
interface FixedDefaultsOverride {
|
|
36
|
+
provider?: string;
|
|
37
|
+
model?: string;
|
|
38
|
+
thinkingLevel?: ThinkingLevel;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface FixedDefaultsOptions {
|
|
42
|
+
/** Internal seam used by tests; normal callers use Pi's global agent dir. */
|
|
43
|
+
agentDir?: string;
|
|
44
|
+
/** Internal seam used by tests; defaults to <agentDir>/fixed-defaults.json. */
|
|
45
|
+
configPath?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function describeError(error: unknown): string {
|
|
49
|
+
return error instanceof Error ? error.message : String(error);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isThinkingLevel(value: unknown): value is ThinkingLevel {
|
|
53
|
+
return (
|
|
54
|
+
typeof value === "string" &&
|
|
55
|
+
(ALL_LEVELS as readonly string[]).includes(value)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseOverride(value: unknown): FixedDefaultsOverride {
|
|
60
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
61
|
+
throw new Error("the top level must be an object");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const input = value as Record<string, unknown>;
|
|
65
|
+
const unknownKeys = Object.keys(input).filter(
|
|
66
|
+
(key) => key !== "provider" && key !== "model" && key !== "thinkingLevel",
|
|
67
|
+
);
|
|
68
|
+
if (unknownKeys.length > 0) {
|
|
69
|
+
throw new Error(`unknown field(s): ${unknownKeys.join(", ")}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const override: FixedDefaultsOverride = {};
|
|
73
|
+
if (input.provider !== undefined) {
|
|
74
|
+
if (typeof input.provider !== "string" || input.provider.length === 0) {
|
|
75
|
+
throw new Error("`provider` must be a non-empty string");
|
|
76
|
+
}
|
|
77
|
+
override.provider = input.provider;
|
|
78
|
+
}
|
|
79
|
+
if (input.model !== undefined) {
|
|
80
|
+
if (typeof input.model !== "string" || input.model.length === 0) {
|
|
81
|
+
throw new Error("`model` must be a non-empty string");
|
|
82
|
+
}
|
|
83
|
+
override.model = input.model;
|
|
84
|
+
}
|
|
85
|
+
if (input.thinkingLevel !== undefined) {
|
|
86
|
+
if (!isThinkingLevel(input.thinkingLevel)) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`\`thinkingLevel\` must be one of: ${ALL_LEVELS.join(", ")}`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
override.thinkingLevel = input.thinkingLevel;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// provider and model are a coupled pair — a model id is meaningless without
|
|
95
|
+
// its provider. Require both if either is present so a hand-edited partial
|
|
96
|
+
// file can't pin a broken default. thinkingLevel may still layer alone.
|
|
97
|
+
if (
|
|
98
|
+
(override.provider !== undefined && override.model === undefined) ||
|
|
99
|
+
(override.model !== undefined && override.provider === undefined)
|
|
100
|
+
) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
"`provider` and `model` must be specified together — a model id is meaningless without its provider",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return override;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Read/write the override file. Reads are uncached (the file is tiny and the
|
|
111
|
+
* events are rare), so hand edits are picked up without a reload. Writes are
|
|
112
|
+
* atomic via temp file + rename.
|
|
113
|
+
*/
|
|
114
|
+
class DefaultsStore {
|
|
115
|
+
readonly path: string;
|
|
116
|
+
|
|
117
|
+
constructor(path: string) {
|
|
118
|
+
this.path = path;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Load the override; returns { override: {}, error: null } when missing. */
|
|
122
|
+
load(): LoadResult {
|
|
123
|
+
let raw: string;
|
|
124
|
+
try {
|
|
125
|
+
raw = readFileSync(this.path, "utf8");
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
128
|
+
return { override: {}, error: null };
|
|
129
|
+
}
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
return {
|
|
135
|
+
override: parseOverride(JSON.parse(raw) as unknown),
|
|
136
|
+
error: null,
|
|
137
|
+
};
|
|
138
|
+
} catch (error) {
|
|
139
|
+
const message = describeError(error);
|
|
140
|
+
console.error(
|
|
141
|
+
`[fixed-defaults] invalid config at ${this.path}:`,
|
|
142
|
+
message,
|
|
143
|
+
);
|
|
144
|
+
return { override: {}, error: message };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
save(override: FixedDefaultsOverride): void {
|
|
149
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
150
|
+
const temporaryPath = `${this.path}.${process.pid}.${Date.now()}.tmp`;
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
writeFileSync(temporaryPath, `${JSON.stringify(override, null, 2)}\n`, {
|
|
154
|
+
encoding: "utf8",
|
|
155
|
+
mode: 0o600,
|
|
156
|
+
});
|
|
157
|
+
renameSync(temporaryPath, this.path);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
try {
|
|
160
|
+
unlinkSync(temporaryPath);
|
|
161
|
+
} catch {
|
|
162
|
+
// The temporary file usually does not exist when the initial write failed.
|
|
163
|
+
}
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
reset(): boolean {
|
|
169
|
+
try {
|
|
170
|
+
unlinkSync(this.path);
|
|
171
|
+
return true;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Result of reading the override file: the parsed values plus any error. */
|
|
180
|
+
interface LoadResult {
|
|
181
|
+
override: FixedDefaultsOverride;
|
|
182
|
+
/** Non-null when the file existed but failed to parse or validate. */
|
|
183
|
+
error: string | null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Keep Pi's cross-session defaults stable while still allowing model changes in
|
|
188
|
+
* the current session. Pi intentionally saves the last selected model and
|
|
189
|
+
* thinking level, so this runs after those notifications and restores the
|
|
190
|
+
* configured startup defaults in the global settings file.
|
|
191
|
+
*
|
|
192
|
+
* The pin lives in `<agentDir>/fixed-defaults.json` and is the sole source of
|
|
193
|
+
* truth — there are no built-in defaults, so with no override file the
|
|
194
|
+
* extension is dormant and Pi's native last-selection behavior is preserved.
|
|
195
|
+
* `thinkingLevel` may layer independently; `provider` and `model` are a
|
|
196
|
+
* coupled pair. `/fixed-defaults set` pins the currently active model and
|
|
197
|
+
* thinking level; `/fixed-defaults reset` stops pinning; `/fixed-defaults`
|
|
198
|
+
* shows the active pin.
|
|
199
|
+
*/
|
|
200
|
+
export default function fixedDefaults(
|
|
201
|
+
pi: ExtensionAPI,
|
|
202
|
+
options: FixedDefaultsOptions = {},
|
|
203
|
+
): void {
|
|
204
|
+
const agentDir = options.agentDir ?? getAgentDir();
|
|
205
|
+
const store = new DefaultsStore(
|
|
206
|
+
options.configPath ?? join(agentDir, CONFIG_FILENAME),
|
|
207
|
+
);
|
|
208
|
+
let pending = Promise.resolve();
|
|
209
|
+
|
|
210
|
+
async function restore(ctx: ExtensionContext): Promise<void> {
|
|
211
|
+
const { override, error } = store.load();
|
|
212
|
+
// A broken or absent override means no pin: leave settings untouched so
|
|
213
|
+
// Pi's native last-selection behavior is preserved rather than guessed at.
|
|
214
|
+
if (error) return;
|
|
215
|
+
const hasModelPin =
|
|
216
|
+
override.provider !== undefined && override.model !== undefined;
|
|
217
|
+
const hasThinkingPin = override.thinkingLevel !== undefined;
|
|
218
|
+
if (!hasModelPin && !hasThinkingPin) return;
|
|
219
|
+
|
|
220
|
+
const settings = SettingsManager.create(ctx.cwd, agentDir, {
|
|
221
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
222
|
+
});
|
|
223
|
+
if (hasModelPin) {
|
|
224
|
+
settings.setDefaultModelAndProvider(override.provider!, override.model!);
|
|
225
|
+
}
|
|
226
|
+
if (hasThinkingPin) {
|
|
227
|
+
settings.setDefaultThinkingLevel(override.thinkingLevel!);
|
|
228
|
+
}
|
|
229
|
+
await settings.flush();
|
|
230
|
+
|
|
231
|
+
const errors = settings.drainErrors();
|
|
232
|
+
if (errors.length > 0) {
|
|
233
|
+
const details = errors
|
|
234
|
+
.map(({ scope, error }) => `${scope}: ${describeError(error)}`)
|
|
235
|
+
.join("; ");
|
|
236
|
+
throw new Error(`failed to persist fixed defaults (${details})`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function schedule(ctx: ExtensionContext): Promise<void> {
|
|
241
|
+
const operation = pending.then(() => restore(ctx));
|
|
242
|
+
// Keep later notifications serviceable after one failed write. The
|
|
243
|
+
// original operation is still returned so Pi can report that failure.
|
|
244
|
+
pending = operation.catch((error: unknown) => {
|
|
245
|
+
console.error("[fixed-defaults] failed to restore defaults:", error);
|
|
246
|
+
});
|
|
247
|
+
return operation;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
pi.on("session_start", (_event, ctx) => schedule(ctx));
|
|
251
|
+
pi.on("model_select", (_event, ctx) => schedule(ctx));
|
|
252
|
+
pi.on("thinking_level_select", (_event, ctx) => schedule(ctx));
|
|
253
|
+
|
|
254
|
+
pi.registerCommand("fixed-defaults", {
|
|
255
|
+
description:
|
|
256
|
+
"Show, set, or reset the pinned startup model/thinking defaults",
|
|
257
|
+
handler: async (args, ctx) => {
|
|
258
|
+
const command = args.trim();
|
|
259
|
+
|
|
260
|
+
if (command === "set") {
|
|
261
|
+
const model = ctx.model;
|
|
262
|
+
if (!model) {
|
|
263
|
+
ctx.ui.notify(
|
|
264
|
+
"Cannot pin defaults without an active model.",
|
|
265
|
+
"warning",
|
|
266
|
+
);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const level = pi.getThinkingLevel();
|
|
271
|
+
const override: FixedDefaultsOverride = {
|
|
272
|
+
provider: model.provider,
|
|
273
|
+
model: model.id,
|
|
274
|
+
thinkingLevel: level,
|
|
275
|
+
};
|
|
276
|
+
try {
|
|
277
|
+
store.save(override);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
console.error("[fixed-defaults] failed to save override:", error);
|
|
280
|
+
ctx.ui.notify("Failed to save the new fixed defaults.", "error");
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
// Bring settings.json in line with the new pin right away instead
|
|
286
|
+
// of waiting for the next model/thinking/session event.
|
|
287
|
+
await schedule(ctx);
|
|
288
|
+
} catch (error) {
|
|
289
|
+
console.error("[fixed-defaults] failed to apply override:", error);
|
|
290
|
+
ctx.ui.notify(
|
|
291
|
+
"Defaults saved, but applying them to settings.json failed.",
|
|
292
|
+
"warning",
|
|
293
|
+
);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
ctx.ui.notify(
|
|
298
|
+
`Pinned ${model.provider}/${model.id} · thinking ${level} as startup defaults`,
|
|
299
|
+
"info",
|
|
300
|
+
);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (command === "reset") {
|
|
305
|
+
let removed: boolean;
|
|
306
|
+
try {
|
|
307
|
+
removed = store.reset();
|
|
308
|
+
} catch (error) {
|
|
309
|
+
console.error("[fixed-defaults] failed to reset override:", error);
|
|
310
|
+
ctx.ui.notify("Failed to reset fixed defaults.", "error");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Removing the override makes future events no-ops; there is nothing
|
|
315
|
+
// to apply to settings.json now. Existing defaults are left as-is so
|
|
316
|
+
// reset means "stop pinning," not "revert to an earlier state."
|
|
317
|
+
ctx.ui.notify(
|
|
318
|
+
removed
|
|
319
|
+
? "Fixed-defaults pin removed. Pi will use your last selection."
|
|
320
|
+
: "No fixed-defaults override file to reset.",
|
|
321
|
+
"info",
|
|
322
|
+
);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (command !== "") {
|
|
327
|
+
ctx.ui.notify("Usage: /fixed-defaults [set|reset]", "warning");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const { override, error } = store.load();
|
|
332
|
+
const model = ctx.model;
|
|
333
|
+
const lines = [
|
|
334
|
+
`model: ${model ? `${model.provider}/${model.id}` : "none"}`,
|
|
335
|
+
`thinking: ${pi.getThinkingLevel()}`,
|
|
336
|
+
"",
|
|
337
|
+
];
|
|
338
|
+
if (error) {
|
|
339
|
+
lines.push(`⚠ override file invalid — no pin active: ${error}`);
|
|
340
|
+
} else if (Object.keys(override).length === 0) {
|
|
341
|
+
lines.push("No pin active. Pi will use your last selection.");
|
|
342
|
+
} else {
|
|
343
|
+
if (override.provider !== undefined && override.model !== undefined) {
|
|
344
|
+
lines.push(`pinned provider: ${override.provider}`);
|
|
345
|
+
lines.push(`pinned model: ${override.model}`);
|
|
346
|
+
}
|
|
347
|
+
if (override.thinkingLevel !== undefined) {
|
|
348
|
+
lines.push(`pinned thinking: ${override.thinkingLevel}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
lines.push("", `override file: ${store.path}`);
|
|
352
|
+
lines.push(
|
|
353
|
+
"",
|
|
354
|
+
"run `/fixed-defaults set` to pin the current model and thinking level; `/fixed-defaults reset` to stop pinning",
|
|
355
|
+
);
|
|
356
|
+
const message = lines.join("\n");
|
|
357
|
+
|
|
358
|
+
if (ctx.hasUI) ctx.ui.notify(message, "info");
|
|
359
|
+
else console.log(message);
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bermudis-pi-goodies — a bundle of small, frequently-used Pi extensions.
|
|
3
|
+
*
|
|
4
|
+
* Composes independent modules, each registering its own commands/hooks
|
|
5
|
+
* against the shared ExtensionAPI:
|
|
6
|
+
* - copy-with-model /copy-with-model copy last reply tagged with the model
|
|
7
|
+
* - name-with-ai /name-with-ai generate a session name via the model
|
|
8
|
+
* - zed /z open Zed on cwd
|
|
9
|
+
* - prefer-tools hook block legacy tools (use trash/rg/fd/uv)
|
|
10
|
+
* - model-thinking hook + command remember thinking levels by provider/model
|
|
11
|
+
* - fixed-defaults hook + command keep startup defaults stable; /fixed-defaults set pins the current model
|
|
12
|
+
* - kilo provider access Kilo Gateway models
|
|
13
|
+
* - provider-balance footer show Kilo credits or Codex quota in the footer
|
|
14
|
+
*/
|
|
15
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import copyWithModel from "./copy-with-model.ts";
|
|
17
|
+
import fixedDefaults from "./fixed-defaults.ts";
|
|
18
|
+
import kilo from "./kilo.ts";
|
|
19
|
+
import modelThinking from "./model-thinking.ts";
|
|
20
|
+
import providerBalance from "./provider-balance.ts";
|
|
21
|
+
import nameWithAi from "./name-with-ai.ts";
|
|
22
|
+
import zed from "./zed.ts";
|
|
23
|
+
import preferTools from "./prefer-tools.ts";
|
|
24
|
+
|
|
25
|
+
export default function bermudisPiGoodies(pi: ExtensionAPI): void {
|
|
26
|
+
copyWithModel(pi);
|
|
27
|
+
nameWithAi(pi);
|
|
28
|
+
zed(pi);
|
|
29
|
+
preferTools(pi);
|
|
30
|
+
modelThinking(pi);
|
|
31
|
+
fixedDefaults(pi);
|
|
32
|
+
providerBalance(pi);
|
|
33
|
+
kilo(pi);
|
|
34
|
+
}
|