bermudis-pi-goodies 0.2.0 → 0.4.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 +35 -22
- package/copy-trajectory.ts +166 -0
- package/fixed-defaults.ts +179 -137
- package/index.ts +4 -1
- package/json-file.ts +44 -0
- package/model-thinking.ts +68 -57
- package/package.json +1 -1
- package/provider-balance.ts +133 -1
package/README.md
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
# bermudis-pi-goodies
|
|
2
2
|
|
|
3
3
|
A bundle of small, frequently-used [Pi](https://github.com/earendil-works/pi)
|
|
4
|
-
extensions. One entry point,
|
|
4
|
+
extensions. One entry point, ten independent features.
|
|
5
5
|
|
|
6
6
|
| Feature | Command / hook | What it does |
|
|
7
7
|
|---------|----------------|--------------|
|
|
8
8
|
| `copy-with-model` | `/copy-with-model` | Copy last assistant message to the clipboard in a code fence tagged with the model name. |
|
|
9
|
+
| `copy-trajectory` | `/copy-trajectory [thinking]` | Copy the whole conversation (user + assistant text, tool calls stripped) to the clipboard; `thinking` also includes assistant thinking blocks. |
|
|
9
10
|
| `name-with-ai` | `/name-with-ai [name]` | Generate a short session name from the first user message (or set one manually). |
|
|
10
11
|
| `zed` | `/z` | Open Zed editor on the current working directory. |
|
|
11
12
|
| `prefer-tools` | hook (no command) | Nudge toward modern CLIs: `rg` over `grep`, `fd` over `find`, `uv` over bare `python`/`pip`/`pytest`/`mypy`. |
|
|
12
13
|
| `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
|
|
14
|
+
| `fixed-defaults` | hook + `/fixed-defaults` | Keep the global startup provider and model fixed while allowing in-session model changes; `/fixed-defaults set` pins the current model. |
|
|
14
15
|
| `kilo` | provider | Access Kilo Gateway models via `/login kilo` or `KILO_API_KEY`. |
|
|
15
16
|
| `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
|
| `tps` | hook (no command) | Notify tokens/sec and in/out/cache token usage at the end of each agent turn. |
|
|
@@ -20,7 +21,7 @@ extensions. One entry point, nine independent features.
|
|
|
20
21
|
After publishing the package to npm:
|
|
21
22
|
|
|
22
23
|
```bash
|
|
23
|
-
pi install npm:bermudis-pi-goodies@0.
|
|
24
|
+
pi install npm:bermudis-pi-goodies@0.4.0
|
|
24
25
|
```
|
|
25
26
|
|
|
26
27
|
Remove any old `bermudis-pi-goodies.ts` symlink before reloading Pi. Each
|
|
@@ -52,35 +53,47 @@ config and overwrites an existing entry. Manual thinking-level changes in Pi do
|
|
|
52
53
|
not modify this file. All current Pi levels are accepted:
|
|
53
54
|
`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`.
|
|
54
55
|
`/model-thinking` shows the active resolution and config path;
|
|
55
|
-
`/model-thinking reset` deletes the whole config.
|
|
56
|
+
`/model-thinking reset` deletes the whole config. A malformed config is reported
|
|
57
|
+
and ignored; `/model-thinking set` refuses to overwrite it until you repair the
|
|
58
|
+
file or reset it.
|
|
56
59
|
|
|
57
|
-
## Fixed startup
|
|
60
|
+
## Fixed startup model
|
|
58
61
|
|
|
59
62
|
Pi normally saves the last model and thinking level selected in the global
|
|
60
|
-
settings file.
|
|
63
|
+
settings file. `fixed-defaults` pins only the cross-session provider and model;
|
|
64
|
+
`model-thinking` is the sole owner of model-specific thinking levels.
|
|
65
|
+
|
|
66
|
+
Create `~/.pi/agent/fixed-defaults.json` to pin a startup model:
|
|
61
67
|
|
|
62
68
|
```json
|
|
63
69
|
{
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"defaultThinkingLevel": "max"
|
|
70
|
+
"provider": "openai-codex",
|
|
71
|
+
"model": "gpt-5.6-luna"
|
|
67
72
|
}
|
|
68
73
|
```
|
|
69
74
|
|
|
70
75
|
Selecting a different model still changes the active session and its transcript;
|
|
71
|
-
`fixed-defaults`
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
76
|
+
`fixed-defaults` restores the startup model after Pi persists a selection. Pi
|
|
77
|
+
chooses the initial model before extensions receive `session_start`, so if you
|
|
78
|
+
manually create or edit a pin for B while settings still name A, the current
|
|
79
|
+
session remains on A and B starts with the next fresh session. Resuming an
|
|
80
|
+
existing session restores that session's model instead.
|
|
81
|
+
|
|
82
|
+
Older config files may contain `thinkingLevel`; that field is accepted for
|
|
83
|
+
compatibility but ignored and should be managed in `model-thinking.json` instead.
|
|
84
|
+
`fixed-defaults` logs a warning and shows the migration in its status when it
|
|
85
|
+
finds the legacy field. The `/fixed-defaults set` command rewrites the file in
|
|
86
|
+
the provider/model-only format.
|
|
87
|
+
|
|
88
|
+
Manage the pin from Pi:
|
|
89
|
+
|
|
90
|
+
- `/fixed-defaults set` — pin the currently active model as the startup model
|
|
91
|
+
(written to the override file and applied to `settings.json` immediately).
|
|
92
|
+
- `/fixed-defaults` — show the active model, pinned startup model, and override
|
|
93
|
+
path.
|
|
94
|
+
- `/fixed-defaults reset` — save the currently active model as Pi's last
|
|
95
|
+
selection, then delete the override file and stop pinning. With no active
|
|
96
|
+
model, it refuses to remove the pin.
|
|
84
97
|
|
|
85
98
|
## Provider and balance details
|
|
86
99
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* copy-trajectory — /copy-trajectory
|
|
3
|
+
*
|
|
4
|
+
* Copies the current session's trajectory to the system clipboard as plain
|
|
5
|
+
* text, keeping only the human-readable conversation: user messages and
|
|
6
|
+
* assistant text. Tool calls, tool results, and session metadata are stripped.
|
|
7
|
+
*
|
|
8
|
+
* /copy-trajectory copy user + assistant text
|
|
9
|
+
* /copy-trajectory thinking also include assistant thinking blocks
|
|
10
|
+
*
|
|
11
|
+
* Uses ctx.sessionManager.getBranch() for the active (compaction-aware) branch
|
|
12
|
+
* and @earendil-works/pi-coding-agent's copyToClipboard for the clipboard write.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
copyToClipboard,
|
|
17
|
+
type ExtensionAPI,
|
|
18
|
+
type SessionEntry,
|
|
19
|
+
} from "@earendil-works/pi-coding-agent";
|
|
20
|
+
|
|
21
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
22
|
+
typeof v === "object" && v !== null;
|
|
23
|
+
|
|
24
|
+
/** Extract `text` content blocks from a message body (string or block array). */
|
|
25
|
+
const extractTextParts = (content: unknown): string[] => {
|
|
26
|
+
if (typeof content === "string") return [content];
|
|
27
|
+
if (!Array.isArray(content)) return [];
|
|
28
|
+
const parts: string[] = [];
|
|
29
|
+
for (const block of content) {
|
|
30
|
+
if (!isRecord(block)) continue;
|
|
31
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
32
|
+
parts.push(block.text);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return parts;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Extract `thinking` content blocks from an assistant message body. */
|
|
39
|
+
const extractThinkingParts = (content: unknown): string[] => {
|
|
40
|
+
if (!Array.isArray(content)) return [];
|
|
41
|
+
const parts: string[] = [];
|
|
42
|
+
for (const block of content) {
|
|
43
|
+
if (!isRecord(block)) continue;
|
|
44
|
+
if (block.type === "thinking" && typeof block.thinking === "string") {
|
|
45
|
+
parts.push(block.thinking);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return parts;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type Turn = {
|
|
52
|
+
role: "User" | "Assistant";
|
|
53
|
+
/** For assistant turns: the model that actually served the reply (or the
|
|
54
|
+
* requested model when the provider doesn't report the resolved one). */
|
|
55
|
+
model?: string;
|
|
56
|
+
body: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Build the readable turns from a branch of session entries. */
|
|
60
|
+
function buildTrajectory(
|
|
61
|
+
entries: readonly SessionEntry[],
|
|
62
|
+
includeThinking: boolean,
|
|
63
|
+
): Turn[] {
|
|
64
|
+
const turns: Turn[] = [];
|
|
65
|
+
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
// getBranch() returns the full SessionEntry union; the `message` member
|
|
68
|
+
// is discriminated by `type: "message"`.
|
|
69
|
+
if (entry.type !== "message") continue;
|
|
70
|
+
const message = entry.message;
|
|
71
|
+
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
72
|
+
|
|
73
|
+
const chunks: string[] = [...extractTextParts(message.content)];
|
|
74
|
+
|
|
75
|
+
let model: string | undefined;
|
|
76
|
+
if (message.role === "assistant") {
|
|
77
|
+
// AssistantMessage carries both the requested `model` and, when the
|
|
78
|
+
// provider echoes it back, `responseModel` (the model that actually ran).
|
|
79
|
+
model = message.responseModel ?? message.model;
|
|
80
|
+
if (includeThinking) {
|
|
81
|
+
for (const t of extractThinkingParts(message.content)) {
|
|
82
|
+
chunks.push(
|
|
83
|
+
t
|
|
84
|
+
.split("\n")
|
|
85
|
+
.map((line) => `> ${line}`)
|
|
86
|
+
.join("\n"),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const body = chunks.join("\n").trim();
|
|
93
|
+
if (!body) continue; // skip empty / tool-only turns
|
|
94
|
+
|
|
95
|
+
turns.push({
|
|
96
|
+
role: message.role === "user" ? "User" : "Assistant",
|
|
97
|
+
model,
|
|
98
|
+
body,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return turns;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const renderTrajectory = (turns: readonly Turn[]): string =>
|
|
106
|
+
turns
|
|
107
|
+
.map((t) => {
|
|
108
|
+
const header =
|
|
109
|
+
t.role === "Assistant" && t.model
|
|
110
|
+
? `## Assistant (${t.model})`
|
|
111
|
+
: `## ${t.role}`;
|
|
112
|
+
return `${header}\n\n${t.body}`;
|
|
113
|
+
})
|
|
114
|
+
.join("\n\n");
|
|
115
|
+
|
|
116
|
+
export default function (pi: ExtensionAPI) {
|
|
117
|
+
pi.registerCommand("copy-trajectory", {
|
|
118
|
+
description:
|
|
119
|
+
"Copy the conversation (user + assistant text, no tool calls) to the clipboard",
|
|
120
|
+
getArgumentCompletions: (prefix) => {
|
|
121
|
+
const opts = ["thinking"].filter((o) => o.startsWith(prefix));
|
|
122
|
+
return opts.length > 0 ? opts.map((o) => ({ value: o, label: o })) : null;
|
|
123
|
+
},
|
|
124
|
+
handler: async (args, ctx) => {
|
|
125
|
+
// Don't snapshot a half-streamed message.
|
|
126
|
+
await ctx.waitForIdle();
|
|
127
|
+
|
|
128
|
+
const arg = args.trim();
|
|
129
|
+
if (arg !== "" && arg !== "thinking") {
|
|
130
|
+
ctx.ui.notify(
|
|
131
|
+
`Unknown argument ${JSON.stringify(arg)}. Usage: /copy-trajectory [thinking]`,
|
|
132
|
+
"warning",
|
|
133
|
+
);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const includeThinking = arg === "thinking";
|
|
137
|
+
|
|
138
|
+
const turns = buildTrajectory(
|
|
139
|
+
ctx.sessionManager.getBranch(),
|
|
140
|
+
includeThinking,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
if (turns.length === 0) {
|
|
144
|
+
ctx.ui.notify("No messages to copy yet", "warning");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const text = renderTrajectory(turns);
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
await copyToClipboard(text);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
ctx.ui.notify(
|
|
154
|
+
`Failed to copy: ${err instanceof Error ? err.message : String(err)}`,
|
|
155
|
+
"error",
|
|
156
|
+
);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
ctx.ui.notify(
|
|
161
|
+
`Copied ${turns.length} message${turns.length === 1 ? "" : "s"} (${text.length.toLocaleString()} chars) to clipboard`,
|
|
162
|
+
"info",
|
|
163
|
+
);
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
package/fixed-defaults.ts
CHANGED
|
@@ -4,38 +4,27 @@ import {
|
|
|
4
4
|
type ExtensionAPI,
|
|
5
5
|
type ExtensionContext,
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
7
9
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
writeFileSync,
|
|
13
|
-
} from "node:fs";
|
|
14
|
-
import { dirname, join } from "node:path";
|
|
10
|
+
describeError,
|
|
11
|
+
unlinkIfPresent,
|
|
12
|
+
writeJsonFileAtomic,
|
|
13
|
+
} from "./json-file.ts";
|
|
15
14
|
|
|
16
15
|
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
16
|
/**
|
|
30
|
-
* Values read from the override file. `
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
17
|
+
* Values read from the override file. `provider` and `model` are a coupled
|
|
18
|
+
* pair — a model id is meaningless without its provider, so both must be
|
|
19
|
+
* present if either is.
|
|
20
|
+
*
|
|
21
|
+
* `thinkingLevel` was accepted by older versions. It is deliberately ignored
|
|
22
|
+
* for compatibility; model-thinking.ts is now the sole owner of thinking
|
|
23
|
+
* policy.
|
|
34
24
|
*/
|
|
35
25
|
interface FixedDefaultsOverride {
|
|
36
26
|
provider?: string;
|
|
37
27
|
model?: string;
|
|
38
|
-
thinkingLevel?: ThinkingLevel;
|
|
39
28
|
}
|
|
40
29
|
|
|
41
30
|
interface FixedDefaultsOptions {
|
|
@@ -45,17 +34,6 @@ interface FixedDefaultsOptions {
|
|
|
45
34
|
configPath?: string;
|
|
46
35
|
}
|
|
47
36
|
|
|
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
37
|
function parseOverride(value: unknown): FixedDefaultsOverride {
|
|
60
38
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
61
39
|
throw new Error("the top level must be an object");
|
|
@@ -82,18 +60,11 @@ function parseOverride(value: unknown): FixedDefaultsOverride {
|
|
|
82
60
|
}
|
|
83
61
|
override.model = input.model;
|
|
84
62
|
}
|
|
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
63
|
|
|
94
64
|
// provider and model are a coupled pair — a model id is meaningless without
|
|
95
65
|
// its provider. Require both if either is present so a hand-edited partial
|
|
96
|
-
// file can't pin a broken default.
|
|
66
|
+
// file can't pin a broken default. The legacy thinkingLevel field is
|
|
67
|
+
// intentionally ignored; model-thinking.ts owns thinking policy.
|
|
97
68
|
if (
|
|
98
69
|
(override.provider !== undefined && override.model === undefined) ||
|
|
99
70
|
(override.model !== undefined && override.provider === undefined)
|
|
@@ -106,6 +77,15 @@ function parseOverride(value: unknown): FixedDefaultsOverride {
|
|
|
106
77
|
return override;
|
|
107
78
|
}
|
|
108
79
|
|
|
80
|
+
function hasLegacyThinkingLevel(value: unknown): boolean {
|
|
81
|
+
return (
|
|
82
|
+
value !== null &&
|
|
83
|
+
typeof value === "object" &&
|
|
84
|
+
!Array.isArray(value) &&
|
|
85
|
+
Object.prototype.hasOwnProperty.call(value, "thinkingLevel")
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
109
89
|
/**
|
|
110
90
|
* Read/write the override file. Reads are uncached (the file is tiny and the
|
|
111
91
|
* events are rare), so hand edits are picked up without a reload. Writes are
|
|
@@ -113,27 +93,43 @@ function parseOverride(value: unknown): FixedDefaultsOverride {
|
|
|
113
93
|
*/
|
|
114
94
|
class DefaultsStore {
|
|
115
95
|
readonly path: string;
|
|
96
|
+
private warnedLegacyThinkingLevel = false;
|
|
116
97
|
|
|
117
98
|
constructor(path: string) {
|
|
118
99
|
this.path = path;
|
|
119
100
|
}
|
|
120
101
|
|
|
121
|
-
/** Load the override;
|
|
102
|
+
/** Load the override; an absent file is a valid, inactive configuration. */
|
|
122
103
|
load(): LoadResult {
|
|
123
104
|
let raw: string;
|
|
124
105
|
try {
|
|
125
106
|
raw = readFileSync(this.path, "utf8");
|
|
126
107
|
} catch (error) {
|
|
127
108
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
128
|
-
return {
|
|
109
|
+
return {
|
|
110
|
+
present: false,
|
|
111
|
+
override: {},
|
|
112
|
+
error: null,
|
|
113
|
+
legacyThinkingLevel: false,
|
|
114
|
+
};
|
|
129
115
|
}
|
|
130
116
|
throw error;
|
|
131
117
|
}
|
|
132
118
|
|
|
133
119
|
try {
|
|
120
|
+
const value = JSON.parse(raw) as unknown;
|
|
121
|
+
const legacyThinkingLevel = hasLegacyThinkingLevel(value);
|
|
122
|
+
if (legacyThinkingLevel && !this.warnedLegacyThinkingLevel) {
|
|
123
|
+
console.warn(
|
|
124
|
+
`[fixed-defaults] legacy thinkingLevel found in ${this.path}; it is ignored. Add the policy to model-thinking.json instead.`,
|
|
125
|
+
);
|
|
126
|
+
this.warnedLegacyThinkingLevel = true;
|
|
127
|
+
}
|
|
134
128
|
return {
|
|
135
|
-
|
|
129
|
+
present: true,
|
|
130
|
+
override: parseOverride(value),
|
|
136
131
|
error: null,
|
|
132
|
+
legacyThinkingLevel,
|
|
137
133
|
};
|
|
138
134
|
} catch (error) {
|
|
139
135
|
const message = describeError(error);
|
|
@@ -141,61 +137,51 @@ class DefaultsStore {
|
|
|
141
137
|
`[fixed-defaults] invalid config at ${this.path}:`,
|
|
142
138
|
message,
|
|
143
139
|
);
|
|
144
|
-
return {
|
|
140
|
+
return {
|
|
141
|
+
present: true,
|
|
142
|
+
override: {},
|
|
143
|
+
error: message,
|
|
144
|
+
legacyThinkingLevel: false,
|
|
145
|
+
};
|
|
145
146
|
}
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
save(override: FixedDefaultsOverride): void {
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
}
|
|
150
|
+
writeJsonFileAtomic(this.path, override);
|
|
151
|
+
this.warnedLegacyThinkingLevel = false;
|
|
166
152
|
}
|
|
167
153
|
|
|
168
|
-
reset():
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
174
|
-
throw error;
|
|
175
|
-
}
|
|
154
|
+
reset(): void {
|
|
155
|
+
// Deletion is intentionally idempotent: if another process removes the
|
|
156
|
+
// pin after it was loaded, reset has still reached the requested end state.
|
|
157
|
+
unlinkIfPresent(this.path);
|
|
158
|
+
this.warnedLegacyThinkingLevel = false;
|
|
176
159
|
}
|
|
177
160
|
}
|
|
178
161
|
|
|
179
162
|
/** Result of reading the override file: the parsed values plus any error. */
|
|
180
163
|
interface LoadResult {
|
|
164
|
+
/** Whether the override file existed when it was read. */
|
|
165
|
+
present: boolean;
|
|
181
166
|
override: FixedDefaultsOverride;
|
|
182
167
|
/** Non-null when the file existed but failed to parse or validate. */
|
|
183
168
|
error: string | null;
|
|
169
|
+
/** True when an old, ignored thinkingLevel field should be migrated. */
|
|
170
|
+
legacyThinkingLevel: boolean;
|
|
184
171
|
}
|
|
185
172
|
|
|
186
173
|
/**
|
|
187
|
-
* Keep Pi's cross-session defaults stable while still allowing
|
|
188
|
-
* the current session. Pi intentionally saves the last
|
|
189
|
-
*
|
|
190
|
-
* configured startup
|
|
174
|
+
* Keep Pi's cross-session provider/model defaults stable while still allowing
|
|
175
|
+
* model changes in the current session. Pi intentionally saves the last
|
|
176
|
+
* selected model, so this runs after model-selection notifications and restores
|
|
177
|
+
* the configured startup model in the global settings file.
|
|
191
178
|
*
|
|
192
179
|
* The pin lives in `<agentDir>/fixed-defaults.json` and is the sole source of
|
|
193
180
|
* truth — there are no built-in defaults, so with no override file the
|
|
194
181
|
* extension is dormant and Pi's native last-selection behavior is preserved.
|
|
195
|
-
* `
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
* shows the active pin.
|
|
182
|
+
* `provider` and `model` are a coupled pair. Thinking policy belongs to
|
|
183
|
+
* model-thinking.ts. `/fixed-defaults set` pins the currently active model;
|
|
184
|
+
* `/fixed-defaults reset` stops pinning; `/fixed-defaults` shows the active pin.
|
|
199
185
|
*/
|
|
200
186
|
export default function fixedDefaults(
|
|
201
187
|
pi: ExtensionAPI,
|
|
@@ -207,25 +193,15 @@ export default function fixedDefaults(
|
|
|
207
193
|
);
|
|
208
194
|
let pending = Promise.resolve();
|
|
209
195
|
|
|
210
|
-
async function
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const hasModelPin =
|
|
216
|
-
override.provider !== undefined && override.model !== undefined;
|
|
217
|
-
const hasThinkingPin = override.thinkingLevel !== undefined;
|
|
218
|
-
if (!hasModelPin && !hasThinkingPin) return;
|
|
219
|
-
|
|
196
|
+
async function persistModel(
|
|
197
|
+
ctx: ExtensionContext,
|
|
198
|
+
provider: string,
|
|
199
|
+
modelId: string,
|
|
200
|
+
): Promise<void> {
|
|
220
201
|
const settings = SettingsManager.create(ctx.cwd, agentDir, {
|
|
221
202
|
projectTrusted: ctx.isProjectTrusted(),
|
|
222
203
|
});
|
|
223
|
-
|
|
224
|
-
settings.setDefaultModelAndProvider(override.provider!, override.model!);
|
|
225
|
-
}
|
|
226
|
-
if (hasThinkingPin) {
|
|
227
|
-
settings.setDefaultThinkingLevel(override.thinkingLevel!);
|
|
228
|
-
}
|
|
204
|
+
settings.setDefaultModelAndProvider(provider, modelId);
|
|
229
205
|
await settings.flush();
|
|
230
206
|
|
|
231
207
|
const errors = settings.drainErrors();
|
|
@@ -237,23 +213,47 @@ export default function fixedDefaults(
|
|
|
237
213
|
}
|
|
238
214
|
}
|
|
239
215
|
|
|
240
|
-
function
|
|
241
|
-
const
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
return
|
|
216
|
+
async function restore(ctx: ExtensionContext): Promise<void> {
|
|
217
|
+
const { override, error } = store.load();
|
|
218
|
+
// A broken or absent override means no pin: leave settings untouched so
|
|
219
|
+
// Pi's native last-selection behavior is preserved rather than guessed at.
|
|
220
|
+
if (error) return;
|
|
221
|
+
const hasModelPin =
|
|
222
|
+
override.provider !== undefined && override.model !== undefined;
|
|
223
|
+
if (!hasModelPin) return;
|
|
224
|
+
|
|
225
|
+
await persistModel(ctx, override.provider!, override.model!);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function enqueue<T>(
|
|
229
|
+
operation: () => Promise<T>,
|
|
230
|
+
failureMessage: string,
|
|
231
|
+
): Promise<T> {
|
|
232
|
+
const queued = pending.then(operation);
|
|
233
|
+
// Log once at the queue boundary, then recover only the internal tail so a
|
|
234
|
+
// failed write does not prevent later operations. The returned promise
|
|
235
|
+
// still rejects, allowing commands to notify the user.
|
|
236
|
+
pending = queued.then(
|
|
237
|
+
() => undefined,
|
|
238
|
+
(error: unknown) => {
|
|
239
|
+
console.error(failureMessage, error);
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
return queued;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function schedule(
|
|
246
|
+
ctx: ExtensionContext,
|
|
247
|
+
failureMessage = "[fixed-defaults] failed to restore defaults:",
|
|
248
|
+
): Promise<void> {
|
|
249
|
+
return enqueue(() => restore(ctx), failureMessage);
|
|
248
250
|
}
|
|
249
251
|
|
|
250
252
|
pi.on("session_start", (_event, ctx) => schedule(ctx));
|
|
251
253
|
pi.on("model_select", (_event, ctx) => schedule(ctx));
|
|
252
|
-
pi.on("thinking_level_select", (_event, ctx) => schedule(ctx));
|
|
253
254
|
|
|
254
255
|
pi.registerCommand("fixed-defaults", {
|
|
255
|
-
description:
|
|
256
|
-
"Show, set, or reset the pinned startup model/thinking defaults",
|
|
256
|
+
description: "Show, set, or reset the pinned startup model",
|
|
257
257
|
handler: async (args, ctx) => {
|
|
258
258
|
const command = args.trim();
|
|
259
259
|
|
|
@@ -267,11 +267,9 @@ export default function fixedDefaults(
|
|
|
267
267
|
return;
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
-
const level = pi.getThinkingLevel();
|
|
271
270
|
const override: FixedDefaultsOverride = {
|
|
272
271
|
provider: model.provider,
|
|
273
272
|
model: model.id,
|
|
274
|
-
thinkingLevel: level,
|
|
275
273
|
};
|
|
276
274
|
try {
|
|
277
275
|
store.save(override);
|
|
@@ -283,10 +281,9 @@ export default function fixedDefaults(
|
|
|
283
281
|
|
|
284
282
|
try {
|
|
285
283
|
// Bring settings.json in line with the new pin right away instead
|
|
286
|
-
// of waiting for the next model/
|
|
287
|
-
await schedule(ctx);
|
|
288
|
-
} catch
|
|
289
|
-
console.error("[fixed-defaults] failed to apply override:", error);
|
|
284
|
+
// of waiting for the next model/session event.
|
|
285
|
+
await schedule(ctx, "[fixed-defaults] failed to apply override:");
|
|
286
|
+
} catch {
|
|
290
287
|
ctx.ui.notify(
|
|
291
288
|
"Defaults saved, but applying them to settings.json failed.",
|
|
292
289
|
"warning",
|
|
@@ -295,31 +292,74 @@ export default function fixedDefaults(
|
|
|
295
292
|
}
|
|
296
293
|
|
|
297
294
|
ctx.ui.notify(
|
|
298
|
-
`Pinned ${model.provider}/${model.id}
|
|
295
|
+
`Pinned ${model.provider}/${model.id} as the startup model`,
|
|
299
296
|
"info",
|
|
300
297
|
);
|
|
301
298
|
return;
|
|
302
299
|
}
|
|
303
300
|
|
|
304
301
|
if (command === "reset") {
|
|
305
|
-
|
|
302
|
+
type ResetResult =
|
|
303
|
+
| { status: "missing" }
|
|
304
|
+
| { status: "removed-inactive" }
|
|
305
|
+
| { status: "no-active-model" }
|
|
306
|
+
| {
|
|
307
|
+
status: "removed";
|
|
308
|
+
model: NonNullable<ExtensionContext["model"]>;
|
|
309
|
+
};
|
|
310
|
+
|
|
306
311
|
try {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
+
const result = await enqueue<ResetResult>(async () => {
|
|
313
|
+
// Load and validate before touching settings. Invalid, legacy-only,
|
|
314
|
+
// and partial files are inactive, so remove them without replacing
|
|
315
|
+
// Pi's last selection with the currently active model.
|
|
316
|
+
const loaded = store.load();
|
|
317
|
+
if (!loaded.present) return { status: "missing" };
|
|
318
|
+
|
|
319
|
+
const hasModelPin =
|
|
320
|
+
loaded.error === null &&
|
|
321
|
+
loaded.override.provider !== undefined &&
|
|
322
|
+
loaded.override.model !== undefined;
|
|
323
|
+
if (!hasModelPin) {
|
|
324
|
+
store.reset();
|
|
325
|
+
return { status: "removed-inactive" };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const model = ctx.model;
|
|
329
|
+
if (!model) return { status: "no-active-model" };
|
|
330
|
+
|
|
331
|
+
// Save the active model before removing the valid pin. Otherwise
|
|
332
|
+
// the old pinned model remains in settings.json and wins at the
|
|
333
|
+
// next fresh start.
|
|
334
|
+
await persistModel(ctx, model.provider, model.id);
|
|
335
|
+
store.reset();
|
|
336
|
+
return { status: "removed", model };
|
|
337
|
+
}, "[fixed-defaults] failed to reset override:");
|
|
338
|
+
|
|
339
|
+
if (result.status === "missing") {
|
|
340
|
+
ctx.ui.notify("No fixed-defaults override file to reset.", "info");
|
|
341
|
+
} else if (result.status === "removed-inactive") {
|
|
342
|
+
ctx.ui.notify(
|
|
343
|
+
"Inactive or invalid fixed-defaults override removed; settings were left unchanged.",
|
|
344
|
+
"info",
|
|
345
|
+
);
|
|
346
|
+
} else if (result.status === "no-active-model") {
|
|
347
|
+
ctx.ui.notify(
|
|
348
|
+
"Cannot reset fixed defaults without an active model; the pin was left in place.",
|
|
349
|
+
"warning",
|
|
350
|
+
);
|
|
351
|
+
} else {
|
|
352
|
+
ctx.ui.notify(
|
|
353
|
+
`Fixed-defaults pin removed. Pi will use ${result.model.provider}/${result.model.id} as its last selection.`,
|
|
354
|
+
"info",
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
} catch {
|
|
358
|
+
ctx.ui.notify(
|
|
359
|
+
"Failed to reset fixed defaults. Pin removal could not be confirmed; settings may already reflect the active model.",
|
|
360
|
+
"error",
|
|
361
|
+
);
|
|
312
362
|
}
|
|
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
363
|
return;
|
|
324
364
|
}
|
|
325
365
|
|
|
@@ -328,11 +368,10 @@ export default function fixedDefaults(
|
|
|
328
368
|
return;
|
|
329
369
|
}
|
|
330
370
|
|
|
331
|
-
const { override, error } = store.load();
|
|
371
|
+
const { override, error, legacyThinkingLevel } = store.load();
|
|
332
372
|
const model = ctx.model;
|
|
333
373
|
const lines = [
|
|
334
374
|
`model: ${model ? `${model.provider}/${model.id}` : "none"}`,
|
|
335
|
-
`thinking: ${pi.getThinkingLevel()}`,
|
|
336
375
|
"",
|
|
337
376
|
];
|
|
338
377
|
if (error) {
|
|
@@ -344,14 +383,17 @@ export default function fixedDefaults(
|
|
|
344
383
|
lines.push(`pinned provider: ${override.provider}`);
|
|
345
384
|
lines.push(`pinned model: ${override.model}`);
|
|
346
385
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
386
|
+
}
|
|
387
|
+
if (legacyThinkingLevel) {
|
|
388
|
+
lines.push(
|
|
389
|
+
"",
|
|
390
|
+
"⚠ legacy thinkingLevel is present but ignored; manage it with /model-thinking",
|
|
391
|
+
);
|
|
350
392
|
}
|
|
351
393
|
lines.push("", `override file: ${store.path}`);
|
|
352
394
|
lines.push(
|
|
353
395
|
"",
|
|
354
|
-
"run `/fixed-defaults set` to pin the current model
|
|
396
|
+
"run `/fixed-defaults set` to pin the current model; `/fixed-defaults reset` to stop pinning",
|
|
355
397
|
);
|
|
356
398
|
const message = lines.join("\n");
|
|
357
399
|
|
package/index.ts
CHANGED
|
@@ -4,16 +4,18 @@
|
|
|
4
4
|
* Composes independent modules, each registering its own commands/hooks
|
|
5
5
|
* against the shared ExtensionAPI:
|
|
6
6
|
* - copy-with-model /copy-with-model copy last reply tagged with the model
|
|
7
|
+
* - copy-trajectory /copy-trajectory copy the whole conversation (text only) to the clipboard
|
|
7
8
|
* - name-with-ai /name-with-ai generate a session name via the model
|
|
8
9
|
* - zed /z open Zed on cwd
|
|
9
10
|
* - prefer-tools hook block legacy tools (use trash/rg/fd/uv)
|
|
10
11
|
* - model-thinking hook + command remember thinking levels by provider/model
|
|
11
|
-
* - fixed-defaults hook + command keep startup
|
|
12
|
+
* - fixed-defaults hook + command keep startup provider/model stable; /fixed-defaults set pins the current model
|
|
12
13
|
* - kilo provider access Kilo Gateway models
|
|
13
14
|
* - provider-balance footer show Kilo credits or Codex quota in the footer
|
|
14
15
|
* - tps hook notify tokens/sec and usage at each agent turn end
|
|
15
16
|
*/
|
|
16
17
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import copyTrajectory from "./copy-trajectory.ts";
|
|
17
19
|
import copyWithModel from "./copy-with-model.ts";
|
|
18
20
|
import fixedDefaults from "./fixed-defaults.ts";
|
|
19
21
|
import kilo from "./kilo.ts";
|
|
@@ -26,6 +28,7 @@ import preferTools from "./prefer-tools.ts";
|
|
|
26
28
|
|
|
27
29
|
export default function bermudisPiGoodies(pi: ExtensionAPI): void {
|
|
28
30
|
copyWithModel(pi);
|
|
31
|
+
copyTrajectory(pi);
|
|
29
32
|
nameWithAi(pi);
|
|
30
33
|
zed(pi);
|
|
31
34
|
preferTools(pi);
|
package/json-file.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function describeError(error: unknown): string {
|
|
6
|
+
return error instanceof Error ? error.message : String(error);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Write JSON atomically without exposing a partially written config file. */
|
|
10
|
+
export function writeJsonFileAtomic(path: string, value: unknown): void {
|
|
11
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
12
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, {
|
|
16
|
+
encoding: "utf8",
|
|
17
|
+
mode: 0o600,
|
|
18
|
+
});
|
|
19
|
+
renameSync(temporaryPath, path);
|
|
20
|
+
} catch (writeError) {
|
|
21
|
+
try {
|
|
22
|
+
unlinkSync(temporaryPath);
|
|
23
|
+
} catch (cleanupError) {
|
|
24
|
+
if ((cleanupError as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
25
|
+
throw new AggregateError(
|
|
26
|
+
[writeError, cleanupError],
|
|
27
|
+
`failed to write ${path} and clean up ${temporaryPath}`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
throw writeError;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Delete a file if present; surface every error except an absent file. */
|
|
36
|
+
export function unlinkIfPresent(path: string): boolean {
|
|
37
|
+
try {
|
|
38
|
+
unlinkSync(path);
|
|
39
|
+
return true;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
package/model-thinking.ts
CHANGED
|
@@ -3,15 +3,13 @@ import {
|
|
|
3
3
|
type ExtensionAPI,
|
|
4
4
|
type ExtensionContext,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
6
8
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
unlinkSync,
|
|
12
|
-
writeFileSync,
|
|
13
|
-
} from "node:fs";
|
|
14
|
-
import { dirname, join } from "node:path";
|
|
9
|
+
describeError,
|
|
10
|
+
unlinkIfPresent,
|
|
11
|
+
writeJsonFileAtomic,
|
|
12
|
+
} from "./json-file.ts";
|
|
15
13
|
|
|
16
14
|
const CONFIG_FILENAME = "model-thinking.json";
|
|
17
15
|
const ALL_LEVELS = [
|
|
@@ -99,86 +97,81 @@ function fileStamp(path: string): string | undefined {
|
|
|
99
97
|
}
|
|
100
98
|
}
|
|
101
99
|
|
|
100
|
+
interface ConfigLoadResult {
|
|
101
|
+
config: ModelThinkingConfig;
|
|
102
|
+
/** Non-null when the file exists but could not be read or validated. */
|
|
103
|
+
error: string | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
102
106
|
class ConfigStore {
|
|
103
107
|
readonly path: string;
|
|
104
108
|
private initialized = false;
|
|
105
109
|
private cachedStamp: string | undefined;
|
|
106
|
-
private
|
|
110
|
+
private cachedResult: ConfigLoadResult = { config: {}, error: null };
|
|
107
111
|
|
|
108
112
|
constructor(path: string) {
|
|
109
113
|
this.path = path;
|
|
110
114
|
}
|
|
111
115
|
|
|
112
|
-
load():
|
|
116
|
+
load(): ConfigLoadResult {
|
|
113
117
|
let stamp: string | undefined;
|
|
114
118
|
try {
|
|
115
119
|
stamp = fileStamp(this.path);
|
|
116
120
|
} catch (error) {
|
|
117
|
-
|
|
118
|
-
|
|
121
|
+
const message = describeError(error);
|
|
122
|
+
console.error(`[model-thinking] failed to stat ${this.path}:`, message);
|
|
123
|
+
return { config: {}, error: message };
|
|
119
124
|
}
|
|
120
125
|
|
|
121
|
-
|
|
122
|
-
|
|
126
|
+
// Keep retrying a file that previously failed to load. A repair can leave
|
|
127
|
+
// its size and mtime unchanged, so an error result must not be cached by
|
|
128
|
+
// the same stamp as a successful read.
|
|
129
|
+
if (
|
|
130
|
+
this.initialized &&
|
|
131
|
+
stamp === this.cachedStamp &&
|
|
132
|
+
this.cachedResult.error === null
|
|
133
|
+
) {
|
|
134
|
+
return this.cachedResult;
|
|
123
135
|
}
|
|
124
136
|
|
|
125
137
|
this.initialized = true;
|
|
126
138
|
this.cachedStamp = stamp;
|
|
127
139
|
if (stamp === undefined) {
|
|
128
|
-
this.
|
|
129
|
-
return this.
|
|
140
|
+
this.cachedResult = { config: {}, error: null };
|
|
141
|
+
return this.cachedResult;
|
|
130
142
|
}
|
|
131
143
|
|
|
132
144
|
try {
|
|
133
|
-
this.
|
|
134
|
-
|
|
135
|
-
|
|
145
|
+
this.cachedResult = {
|
|
146
|
+
config: parseConfig(
|
|
147
|
+
JSON.parse(readFileSync(this.path, "utf8")) as unknown,
|
|
148
|
+
),
|
|
149
|
+
error: null,
|
|
150
|
+
};
|
|
136
151
|
} catch (error) {
|
|
152
|
+
const message = describeError(error);
|
|
137
153
|
console.error(
|
|
138
154
|
`[model-thinking] invalid config at ${this.path}:`,
|
|
139
|
-
|
|
155
|
+
message,
|
|
140
156
|
);
|
|
141
|
-
this.
|
|
157
|
+
this.cachedResult = { config: {}, error: message };
|
|
142
158
|
}
|
|
143
|
-
return this.
|
|
159
|
+
return this.cachedResult;
|
|
144
160
|
}
|
|
145
161
|
|
|
146
162
|
save(config: ModelThinkingConfig): void {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
try {
|
|
151
|
-
writeFileSync(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, {
|
|
152
|
-
encoding: "utf8",
|
|
153
|
-
mode: 0o600,
|
|
154
|
-
});
|
|
155
|
-
renameSync(temporaryPath, this.path);
|
|
156
|
-
} catch (error) {
|
|
157
|
-
try {
|
|
158
|
-
unlinkSync(temporaryPath);
|
|
159
|
-
} catch {
|
|
160
|
-
// The temporary file usually does not exist when the initial write failed.
|
|
161
|
-
}
|
|
162
|
-
throw error;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
this.cachedConfig = config;
|
|
163
|
+
writeJsonFileAtomic(this.path, config);
|
|
164
|
+
this.cachedResult = { config, error: null };
|
|
166
165
|
this.cachedStamp = fileStamp(this.path);
|
|
167
166
|
this.initialized = true;
|
|
168
167
|
}
|
|
169
168
|
|
|
170
169
|
reset(): boolean {
|
|
171
|
-
|
|
172
|
-
unlinkSync(this.path);
|
|
173
|
-
} catch (error) {
|
|
174
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
175
|
-
throw error;
|
|
176
|
-
}
|
|
177
|
-
|
|
170
|
+
const removed = unlinkIfPresent(this.path);
|
|
178
171
|
this.initialized = true;
|
|
179
172
|
this.cachedStamp = undefined;
|
|
180
|
-
this.
|
|
181
|
-
return
|
|
173
|
+
this.cachedResult = { config: {}, error: null };
|
|
174
|
+
return removed;
|
|
182
175
|
}
|
|
183
176
|
}
|
|
184
177
|
|
|
@@ -207,8 +200,13 @@ export default function modelThinking(
|
|
|
207
200
|
);
|
|
208
201
|
function apply(ctx: ExtensionContext, silent: boolean): void {
|
|
209
202
|
const model = ctx.model;
|
|
210
|
-
const
|
|
211
|
-
|
|
203
|
+
const loaded = store.load();
|
|
204
|
+
// Never treat a broken file as an empty policy: doing so would hide the
|
|
205
|
+
// error and make a later `/model-thinking set` overwrite it.
|
|
206
|
+
if (loaded.error || !model) return;
|
|
207
|
+
|
|
208
|
+
const level = resolveThinkingLevel(loaded.config, model);
|
|
209
|
+
if (level === undefined) return;
|
|
212
210
|
|
|
213
211
|
const before = pi.getThinkingLevel();
|
|
214
212
|
pi.setThinkingLevel(level);
|
|
@@ -246,10 +244,18 @@ export default function modelThinking(
|
|
|
246
244
|
const key = modelKey(model);
|
|
247
245
|
const level = pi.getThinkingLevel();
|
|
248
246
|
try {
|
|
249
|
-
const
|
|
247
|
+
const loaded = store.load();
|
|
248
|
+
if (loaded.error) {
|
|
249
|
+
ctx.ui.notify(
|
|
250
|
+
`Cannot save model-thinking config: ${loaded.error}. Repair ${store.path} or run /model-thinking reset first.`,
|
|
251
|
+
"error",
|
|
252
|
+
);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
250
256
|
store.save({
|
|
251
|
-
...config,
|
|
252
|
-
models: { ...config.models, [key]: level },
|
|
257
|
+
...loaded.config,
|
|
258
|
+
models: { ...loaded.config.models, [key]: level },
|
|
253
259
|
});
|
|
254
260
|
ctx.ui.notify(`Saved ${key}: ${level}`, "info");
|
|
255
261
|
} catch (error) {
|
|
@@ -284,14 +290,19 @@ export default function modelThinking(
|
|
|
284
290
|
}
|
|
285
291
|
|
|
286
292
|
const model = ctx.model;
|
|
287
|
-
const
|
|
288
|
-
const resolved =
|
|
293
|
+
const loaded = store.load();
|
|
294
|
+
const resolved = loaded.error
|
|
295
|
+
? undefined
|
|
296
|
+
: resolveThinkingLevel(loaded.config, model);
|
|
289
297
|
const lines = [
|
|
290
298
|
`model: ${model ? modelKey(model) : "none"}`,
|
|
291
299
|
`managed: ${resolved === undefined ? "no" : "yes"}`,
|
|
292
300
|
`file: ${store.path}`,
|
|
293
301
|
`saved: ${resolved ?? "none — pi handles this model natively"}`,
|
|
294
302
|
`current: ${pi.getThinkingLevel()}`,
|
|
303
|
+
...(loaded.error
|
|
304
|
+
? [`⚠ config invalid — no policy applied: ${loaded.error}`]
|
|
305
|
+
: []),
|
|
295
306
|
"",
|
|
296
307
|
"run `/model-thinking set` to save this model and level; `/model-thinking reset` to clear all configured levels",
|
|
297
308
|
];
|
package/package.json
CHANGED
package/provider-balance.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type {
|
|
|
5
5
|
ExtensionContext,
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
8
|
+
import { readFileSync, renameSync, mkdirSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
8
10
|
|
|
9
11
|
const KILO_API_BASE = process.env.KILO_API_URL || "https://api.kilo.ai";
|
|
10
12
|
const KILO_BALANCE_ENDPOINT = `${KILO_API_BASE}/api/profile/balance`;
|
|
@@ -23,6 +25,23 @@ const BALANCE_FETCH_TIMEOUT_MS = 5_000;
|
|
|
23
25
|
/** Refresh the footer balance every Nth turn end during a run. See turn_end handler. */
|
|
24
26
|
const REFRESH_EVERY_N_TURNS = 5;
|
|
25
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Balance cache shared across every pi process on the machine, keyed by
|
|
30
|
+
* provider. Two motivations:
|
|
31
|
+
*
|
|
32
|
+
* 1. The user runs several pi instances against the same metered account
|
|
33
|
+
* (quota/credits are per-account, not per-session), so every session may
|
|
34
|
+
* show the freshest reading any instance fetched.
|
|
35
|
+
* 2. On session switch pi tears this runtime down and reloads the extension
|
|
36
|
+
* for the new session (session_shutdown reason "resume" -> session_start
|
|
37
|
+
* reason "resume"), wiping in-memory state. Without a shared cache the new
|
|
38
|
+
* session's footer is blank/stale until its own first fetch lands, which
|
|
39
|
+
* can be agent_settled or the 5th turn_end.
|
|
40
|
+
*/
|
|
41
|
+
const BALANCE_CACHE_FILE = `${process.env.HOME ?? ""}/.pi/agent/cache/provider-balance.json`;
|
|
42
|
+
/** Ignore cache entries older than this; stale balances mislead. */
|
|
43
|
+
const BALANCE_CACHE_TTL_MS = 30 * 60 * 1000;
|
|
44
|
+
|
|
26
45
|
interface BalanceAdapter {
|
|
27
46
|
fetch(token: string, signal: AbortSignal): Promise<Balance>;
|
|
28
47
|
requiresOAuth?: boolean;
|
|
@@ -538,6 +557,100 @@ const BALANCE_ADAPTERS: Readonly<Record<string, BalanceAdapter>> = {
|
|
|
538
557
|
"openai-codex": { fetch: fetchCodexQuota, requiresOAuth: true },
|
|
539
558
|
};
|
|
540
559
|
|
|
560
|
+
// --- Shared balance cache ---------------------------------------------------
|
|
561
|
+
|
|
562
|
+
interface BalanceCacheEntry {
|
|
563
|
+
fetchedAt: number;
|
|
564
|
+
balance: Balance;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
type BalanceCache = Record<string, BalanceCacheEntry>;
|
|
568
|
+
|
|
569
|
+
function parseCachedBalance(value: unknown): Balance | null {
|
|
570
|
+
if (!Array.isArray(value)) return null;
|
|
571
|
+
const segments: BalanceSegment[] = [];
|
|
572
|
+
for (const candidate of value) {
|
|
573
|
+
const segment = asRecord(candidate);
|
|
574
|
+
if (!segment) return null;
|
|
575
|
+
const credits = numericProperty(segment, "credits");
|
|
576
|
+
const quotaRecord = asRecord(segment.quota);
|
|
577
|
+
const remainingPercent = numericProperty(quotaRecord, "remainingPercent");
|
|
578
|
+
if (
|
|
579
|
+
credits === null &&
|
|
580
|
+
(quotaRecord === null || remainingPercent === null)
|
|
581
|
+
) {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
const label = stringProperty(segment, "label") ?? undefined;
|
|
585
|
+
const windowSeconds = numericProperty(quotaRecord, "windowSeconds");
|
|
586
|
+
const resetAt = numericProperty(quotaRecord, "resetAt");
|
|
587
|
+
segments.push({
|
|
588
|
+
...(label !== undefined ? { label } : {}),
|
|
589
|
+
...(credits !== null ? { credits } : {}),
|
|
590
|
+
...(quotaRecord !== null && remainingPercent !== null
|
|
591
|
+
? {
|
|
592
|
+
quota: {
|
|
593
|
+
remainingPercent,
|
|
594
|
+
...(windowSeconds !== null ? { windowSeconds } : {}),
|
|
595
|
+
...(resetAt !== null ? { resetAt } : {}),
|
|
596
|
+
},
|
|
597
|
+
}
|
|
598
|
+
: {}),
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
return segments;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Read the freshest non-expired cached balance for a provider. */
|
|
605
|
+
export function readCachedBalance(
|
|
606
|
+
provider: string,
|
|
607
|
+
nowMs = Date.now(),
|
|
608
|
+
): Balance | null {
|
|
609
|
+
let raw: string;
|
|
610
|
+
try {
|
|
611
|
+
raw = readFileSync(BALANCE_CACHE_FILE, "utf8");
|
|
612
|
+
} catch {
|
|
613
|
+
return null; // Missing/unreadable cache is a normal cold start.
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
try {
|
|
617
|
+
const entry = asRecord(asRecord(JSON.parse(raw))?.[provider]);
|
|
618
|
+
const fetchedAt = numericProperty(entry, "fetchedAt");
|
|
619
|
+
if (entry === null || fetchedAt === null) return null;
|
|
620
|
+
if (nowMs - fetchedAt >= BALANCE_CACHE_TTL_MS) return null;
|
|
621
|
+
return parseCachedBalance(entry.balance);
|
|
622
|
+
} catch {
|
|
623
|
+
return null; // Corrupt cache must never break the footer.
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Persist a fresh reading so other live sessions and future freshly-loaded
|
|
629
|
+
* sessions render it instantly. Write is atomic (tmp + rename) because
|
|
630
|
+
* several pi processes can fetch concurrently.
|
|
631
|
+
*/
|
|
632
|
+
function writeCachedBalance(provider: string, balance: Balance): void {
|
|
633
|
+
let cache: BalanceCache = {};
|
|
634
|
+
try {
|
|
635
|
+
const existing = asRecord(
|
|
636
|
+
JSON.parse(readFileSync(BALANCE_CACHE_FILE, "utf8")),
|
|
637
|
+
);
|
|
638
|
+
if (existing) cache = existing as BalanceCache;
|
|
639
|
+
} catch {
|
|
640
|
+
// Missing or corrupt cache: start fresh rather than failing the write.
|
|
641
|
+
}
|
|
642
|
+
cache[provider] = { fetchedAt: Date.now(), balance };
|
|
643
|
+
|
|
644
|
+
try {
|
|
645
|
+
mkdirSync(dirname(BALANCE_CACHE_FILE), { recursive: true });
|
|
646
|
+
const tempFile = `${BALANCE_CACHE_FILE}.${process.pid}.tmp`;
|
|
647
|
+
writeFileSync(tempFile, JSON.stringify(cache));
|
|
648
|
+
renameSync(tempFile, BALANCE_CACHE_FILE);
|
|
649
|
+
} catch {
|
|
650
|
+
// The cache is an accelerator only; the footer works without it.
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
541
654
|
type FooterSession = ConstructorParameters<typeof FooterComponent>[0];
|
|
542
655
|
type FooterFactory = NonNullable<
|
|
543
656
|
Parameters<ExtensionContext["ui"]["setFooter"]>[0]
|
|
@@ -726,6 +839,16 @@ export default function providerBalance(pi: ExtensionAPI): void {
|
|
|
726
839
|
const providerId = provider;
|
|
727
840
|
const adapter = providerId ? BALANCE_ADAPTERS[providerId] : undefined;
|
|
728
841
|
if (!adapter || !providerId) return;
|
|
842
|
+
|
|
843
|
+
// Paint the freshest known value for this account immediately: another
|
|
844
|
+
// live pi instance may have fetched seconds ago, and on session switch
|
|
845
|
+
// this is what keeps the new session's footer warm instead of blank until
|
|
846
|
+
// its own first fetch lands.
|
|
847
|
+
const cached = readCachedBalance(providerId);
|
|
848
|
+
if (cached) {
|
|
849
|
+
balance = cached;
|
|
850
|
+
requestRender?.();
|
|
851
|
+
}
|
|
729
852
|
if (
|
|
730
853
|
adapter.requiresOAuth &&
|
|
731
854
|
(!model ||
|
|
@@ -739,7 +862,10 @@ export default function providerBalance(pi: ExtensionAPI): void {
|
|
|
739
862
|
const token = await ctx.modelRegistry.getApiKeyForProvider(providerId);
|
|
740
863
|
if (!token || generation !== refreshGeneration) return;
|
|
741
864
|
balance = await adapter.fetch(token, controller.signal);
|
|
742
|
-
if (generation === refreshGeneration)
|
|
865
|
+
if (generation === refreshGeneration) {
|
|
866
|
+
writeCachedBalance(providerId, balance);
|
|
867
|
+
requestRender?.();
|
|
868
|
+
}
|
|
743
869
|
} catch (error) {
|
|
744
870
|
if (generation !== refreshGeneration || controller.signal.aborted) return;
|
|
745
871
|
// This is a best-effort background refresh. Writing to stdout/stderr while
|
|
@@ -793,6 +919,12 @@ export default function providerBalance(pi: ExtensionAPI): void {
|
|
|
793
919
|
});
|
|
794
920
|
}
|
|
795
921
|
|
|
922
|
+
// Fires for startup, reload, and every session switch/new/fork: pi tears
|
|
923
|
+
// the old runtime down (session_shutdown) and starts a fresh one, so this
|
|
924
|
+
// is both our initializer and our "user switched sessions" signal.
|
|
925
|
+
// refreshBalance seeds from the shared cache first, so a session resumed
|
|
926
|
+
// mid-run elsewhere shows the other instance's last reading immediately
|
|
927
|
+
// instead of going stale until agent_settled or the 5th turn_end.
|
|
796
928
|
pi.on("session_start", (_event, ctx) => {
|
|
797
929
|
activeContext = ctx;
|
|
798
930
|
activeThinkingLevel = restoredThinkingLevel(ctx);
|