@agnishc/edb-agent-mode 0.12.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/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@agnishc/edb-agent-mode` will be documented in this file.
4
+
5
+ ## [0.12.0] - 2026-05-22
6
+
7
+ ### Added
8
+
9
+ - Initial release
10
+ - `/mode` command to switch agent personality/mode
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Agnish Chakraborty
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # edb-agent-mode
2
+
3
+ A [pi](https://pi.dev) extension that lets you switch the main agent's personality or mode via the `/mode` command.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pi install npm:@agnishc/edb-agent-mode
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```
14
+ /mode <mode-name>
15
+ ```
16
+
17
+ Switches the agent to the specified mode/personality.
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@agnishc/edb-agent-mode",
3
+ "version": "0.12.0",
4
+ "description": "Pi extension: switch the main agent's personality/mode via /mode command",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "edb"
9
+ ],
10
+ "type": "module",
11
+ "license": "MIT",
12
+ "author": "Agnish Chakraborty",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/agnishcc/pi-extention-monorepo.git",
16
+ "directory": "packages/edb-agent-mode"
17
+ },
18
+ "homepage": "https://github.com/agnishcc/pi-extention-monorepo/tree/main/packages/edb-agent-mode#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/agnishcc/pi-extention-monorepo/issues"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "scripts": {
26
+ "test": "vitest run"
27
+ },
28
+ "files": [
29
+ "src",
30
+ "README.md",
31
+ "LICENSE",
32
+ "CHANGELOG.md"
33
+ ],
34
+ "pi": {
35
+ "extensions": [
36
+ "./src/index.ts"
37
+ ]
38
+ },
39
+ "peerDependencies": {
40
+ "@earendil-works/pi-coding-agent": "*"
41
+ }
42
+ }
package/src/index.ts ADDED
@@ -0,0 +1,294 @@
1
+ /**
2
+ * edb-agent-mode — Agent mode switcher for pi.
3
+ *
4
+ * Loads mode definitions from:
5
+ * ~/.pi/agent/modes/*.md (global)
6
+ * <cwd>/.pi/modes/*.md (project, overrides global)
7
+ *
8
+ * Each .md file has YAML frontmatter:
9
+ * name: canonical mode name
10
+ * description: shown in the /mode picker
11
+ * append_mode: "append" (default) | "replace"
12
+ * model: optional model override (fuzzy match, e.g. "haiku", "sonnet", "anthropic/claude-haiku-4-5-20251001")
13
+ *
14
+ * Commands:
15
+ * /mode — open picker to select or clear a mode
16
+ * /mode off — clear the active mode
17
+ * /mode status — show current mode details without opening picker
18
+ *
19
+ * Keyboard:
20
+ * Ctrl+Shift+A — cycle through agent modes (toggle)
21
+ * Footer: active mode name shown in footer line 2 (right side, after thinking label).
22
+ * System prompt: active mode's body is appended (or replaces) on each turn.
23
+ * Model: if mode defines a model, it is set when mode is activated.
24
+ */
25
+
26
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
27
+ import { basename, join } from "node:path";
28
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
29
+ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
30
+ import { type ModelRegistry, resolveModel } from "./model-resolver.js";
31
+
32
+ const ENTRY_TYPE = "agent-mode:active";
33
+
34
+ // ── Types ─────────────────────────────────────────────────────────────────────
35
+
36
+ interface ModeConfig {
37
+ name: string;
38
+ description: string;
39
+ appendMode: "append" | "replace";
40
+ /** Optional model string — fuzzy or "provider/modelId". Set when mode is activated. */
41
+ model?: string;
42
+ systemPrompt: string;
43
+ source: "global" | "project";
44
+ }
45
+
46
+ // ── State ─────────────────────────────────────────────────────────────────────
47
+
48
+ let activeMode: ModeConfig | null = null;
49
+ let currentCwd = process.cwd();
50
+
51
+ // ── Mode discovery ────────────────────────────────────────────────────────────
52
+
53
+ function loadModes(cwd: string): Map<string, ModeConfig> {
54
+ const globalDir = join(getAgentDir(), "modes");
55
+ const projectDir = join(cwd, ".pi", "modes");
56
+
57
+ const modes = new Map<string, ModeConfig>();
58
+ loadFromDir(globalDir, modes, "global");
59
+ loadFromDir(projectDir, modes, "project"); // project overrides global
60
+ return modes;
61
+ }
62
+
63
+ function loadFromDir(dir: string, modes: Map<string, ModeConfig>, source: "global" | "project"): void {
64
+ if (!existsSync(dir)) return;
65
+ let files: string[];
66
+ try {
67
+ files = readdirSync(dir).filter((f) => f.endsWith(".md"));
68
+ } catch {
69
+ return;
70
+ }
71
+ for (const file of files) {
72
+ const name = basename(file, ".md");
73
+ let content: string;
74
+ try {
75
+ content = readFileSync(join(dir, file), "utf-8");
76
+ } catch {
77
+ continue;
78
+ }
79
+ const { frontmatter: fm, body } = parseFrontmatter<Record<string, unknown>>(content);
80
+ const modeName = typeof fm.name === "string" ? fm.name : name;
81
+ const appendMode = fm.append_mode === "replace" ? "replace" : "append";
82
+ modes.set(modeName, {
83
+ name: modeName,
84
+ description: typeof fm.description === "string" ? fm.description : modeName,
85
+ appendMode,
86
+ model: typeof fm.model === "string" ? fm.model : undefined,
87
+ systemPrompt: body.trim(),
88
+ source,
89
+ });
90
+ }
91
+ }
92
+
93
+ // ── Session persistence ────────────────────────────────────────────────────────
94
+
95
+ function getModelShortName(model: string): string {
96
+ const name = model.includes("/") ? model.split("/").pop()! : model;
97
+ return name.replace(/-\d{8}$/, "");
98
+ }
99
+
100
+ function persistMode(pi: ExtensionAPI): void {
101
+ if (activeMode) {
102
+ pi.appendEntry(ENTRY_TYPE, { mode: activeMode.name, source: activeMode.source });
103
+ } else {
104
+ pi.appendEntry(ENTRY_TYPE, { mode: null, source: null });
105
+ }
106
+ }
107
+
108
+ // ── Model activation ──────────────────────────────────────────────────────────
109
+
110
+ async function applyModeModel(pi: ExtensionAPI, mode: ModeConfig, ctx: ExtensionCommandContext): Promise<void> {
111
+ if (!mode.model) return;
112
+ const resolved = resolveModel(mode.model, ctx.modelRegistry as unknown as ModelRegistry);
113
+ if (typeof resolved === "string") {
114
+ // Resolution failed — resolved is an error string
115
+ ctx.ui.notify(`Mode model not found: "${mode.model}"\n${resolved}`, "warning");
116
+ return;
117
+ }
118
+ const ok = await pi.setModel(resolved);
119
+ if (!ok) {
120
+ ctx.ui.notify(`No API key available for model: ${mode.model}`, "warning");
121
+ }
122
+ }
123
+
124
+ // ── Cycle logic ────────────────────────────────────────────────────────────────
125
+
126
+ async function cycleMode(pi: ExtensionAPI, ctx: any): Promise<void> {
127
+ const modes = loadModes(currentCwd);
128
+ const modeList = [...modes.values()];
129
+ if (modeList.length === 0) {
130
+ ctx.ui.notify("No modes defined. Create .md files in ~/.pi/agent/modes/", "info");
131
+ return;
132
+ }
133
+
134
+ // Note: we don't wait for idle here (shortcuts should be responsive)
135
+ // The mode change takes effect on the next agent turn
136
+
137
+ // Find current mode index
138
+ const current = activeMode;
139
+ const currentIndex = current ? modeList.findIndex((m) => m.name === current.name) : -1;
140
+
141
+ // Cycle: active mode → next mode → off (then wraps to first mode)
142
+ if (currentIndex === -1) {
143
+ // No mode active — activate first mode
144
+ const first = modeList[0]!;
145
+ activeMode = first;
146
+ persistMode(pi);
147
+ applyModeModel(pi, first, ctx).catch(() => {});
148
+ ctx.ui.notify(`Mode: ${first.name}`, "info");
149
+ } else if (currentIndex < modeList.length - 1) {
150
+ // Cycle to next mode
151
+ const nextMode = modeList[currentIndex + 1]!;
152
+ activeMode = nextMode;
153
+ persistMode(pi);
154
+ applyModeModel(pi, nextMode, ctx).catch(() => {});
155
+ ctx.ui.notify(`Mode: ${nextMode.name}`, "info");
156
+ } else {
157
+ // After last mode — turn off
158
+ const prevName = activeMode!.name;
159
+ activeMode = null;
160
+ persistMode(pi);
161
+ ctx.ui.notify(`Mode cleared (was: ${prevName})`, "info");
162
+ }
163
+ }
164
+
165
+ // ── Extension ─────────────────────────────────────────────────────────────────
166
+
167
+ export default function agentModeExtension(pi: ExtensionAPI): void {
168
+ pi.on("session_start", async (_event, ctx) => {
169
+ currentCwd = ctx.cwd;
170
+ });
171
+
172
+ pi.on("before_agent_start", async (event) => {
173
+ if (!activeMode) return;
174
+ if (!activeMode.systemPrompt) return;
175
+
176
+ if (activeMode.appendMode === "replace") {
177
+ return { systemPrompt: activeMode.systemPrompt };
178
+ }
179
+ // append mode
180
+ return {
181
+ systemPrompt: `${event.systemPrompt}\n\n<agent_mode name="${activeMode.name}">\n${activeMode.systemPrompt}\n</agent_mode>`,
182
+ };
183
+ });
184
+
185
+ // ── Keyboard shortcut: Ctrl+Shift+A ─────────────────────────────────────
186
+ pi.registerShortcut("ctrl+shift+a", {
187
+ description: "Cycle through agent modes",
188
+ handler: async (ctx: any) => {
189
+ await cycleMode(pi, ctx);
190
+ },
191
+ });
192
+
193
+ pi.registerCommand("mode", {
194
+ description: "Switch agent mode — appends a system prompt profile for this session",
195
+ handler: async (args, ctx: ExtensionCommandContext) => {
196
+ const trimmedArg = args.trim().toLowerCase();
197
+
198
+ // /mode status — show current mode details
199
+ if (trimmedArg === "status") {
200
+ if (!activeMode) {
201
+ ctx.ui.notify("No mode active.", "info");
202
+ } else {
203
+ const modelNote = activeMode.model ? `\nModel: ${activeMode.model}` : "";
204
+ const promptPreview =
205
+ activeMode.systemPrompt.length > 200
206
+ ? `${activeMode.systemPrompt.slice(0, 200)}…`
207
+ : activeMode.systemPrompt;
208
+ ctx.ui.notify(
209
+ `Mode: ${activeMode.name} (${activeMode.source})\n${activeMode.description}` +
210
+ `\nPrompt mode: ${activeMode.appendMode}${modelNote}\n\n${promptPreview}`,
211
+ "info",
212
+ );
213
+ }
214
+ return;
215
+ }
216
+
217
+ // /mode off — clear active mode
218
+ if (trimmedArg === "off" || trimmedArg === "none" || trimmedArg === "clear") {
219
+ if (activeMode) {
220
+ const prev = activeMode.name;
221
+ activeMode = null;
222
+ persistMode(pi);
223
+ ctx.ui.notify(`Mode cleared (was: ${prev})`, "info");
224
+ } else {
225
+ ctx.ui.notify("No mode is active.", "info");
226
+ }
227
+ return;
228
+ }
229
+
230
+ if (!ctx.hasUI) {
231
+ if (activeMode) {
232
+ ctx.ui.notify(`Active mode: ${activeMode.name}`, "info");
233
+ } else {
234
+ ctx.ui.notify("No mode active.", "info");
235
+ }
236
+ return;
237
+ }
238
+
239
+ await ctx.waitForIdle();
240
+
241
+ const modes = loadModes(currentCwd);
242
+
243
+ if (modes.size === 0) {
244
+ ctx.ui.notify(
245
+ `No modes found. Create .md files in:\n ~/.pi/agent/modes/\n ${join(currentCwd, ".pi", "modes")}/`,
246
+ "info",
247
+ );
248
+ return;
249
+ }
250
+
251
+ // Build picker options
252
+ const CLEAR_OPTION = "◌ No mode (clear)";
253
+ const options: string[] = [CLEAR_OPTION];
254
+ const modeList = [...modes.values()];
255
+ for (const m of modeList) {
256
+ const active = activeMode?.name === m.name ? " ✓" : "";
257
+ const scope = m.source === "project" ? " (project)" : "";
258
+ const modelHint = m.model ? ` · model: ${getModelShortName(m.model)}` : "";
259
+ options.push(`${m.name}${active} · ${m.description}${modelHint}${scope}`);
260
+ }
261
+
262
+ const choice = await ctx.ui.select(
263
+ activeMode ? `Mode: ${activeMode.name} — change or clear` : "Select a mode",
264
+ options,
265
+ );
266
+ if (!choice) return;
267
+
268
+ if (choice === CLEAR_OPTION) {
269
+ activeMode = null;
270
+ persistMode(pi);
271
+ ctx.ui.notify("Mode cleared.", "info");
272
+ return;
273
+ }
274
+
275
+ // Extract mode name from choice string (before " ·")
276
+ const chosenName = choice.split(" ·")[0].replace(/ ✓$/, "").trim();
277
+ const selected = modes.get(chosenName);
278
+ if (!selected) return;
279
+
280
+ activeMode = selected;
281
+ persistMode(pi);
282
+
283
+ // Apply model if specified
284
+ await applyModeModel(pi, selected, ctx);
285
+
286
+ const modelNote = selected.model ? `\nModel: ${selected.model}` : "";
287
+ ctx.ui.notify(
288
+ `Mode set: ${selected.name}\n${selected.description}` +
289
+ `\nPrompt will be ${selected.appendMode === "replace" ? "replaced" : "appended"} on next turn.${modelNote}`,
290
+ "info",
291
+ );
292
+ },
293
+ });
294
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Model resolution: exact match ("provider/modelId") with fuzzy fallback.
3
+ */
4
+
5
+ export interface ModelEntry {
6
+ id: string;
7
+ name: string;
8
+ provider: string;
9
+ }
10
+
11
+ export interface ModelRegistry {
12
+ find(provider: string, modelId: string): any;
13
+ getAll(): any[];
14
+ getAvailable?(): any[];
15
+ }
16
+
17
+ /**
18
+ * Resolve a model string to a Model instance.
19
+ * Tries exact match first ("provider/modelId"), then fuzzy match against all available models.
20
+ * Returns the Model on success, or an error message string on failure.
21
+ */
22
+ export function resolveModel(input: string, registry: ModelRegistry): any | string {
23
+ // Available models (those with auth configured)
24
+ const all = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[];
25
+ const availableSet = new Set(all.map((m) => `${m.provider}/${m.id}`.toLowerCase()));
26
+
27
+ // 1. Exact match: "provider/modelId" — only if available (has auth)
28
+ const slashIdx = input.indexOf("/");
29
+ if (slashIdx !== -1) {
30
+ const provider = input.slice(0, slashIdx);
31
+ const modelId = input.slice(slashIdx + 1);
32
+ if (availableSet.has(input.toLowerCase())) {
33
+ const found = registry.find(provider, modelId);
34
+ if (found) return found;
35
+ }
36
+ }
37
+
38
+ // 2. Fuzzy match against available models
39
+ const query = input.toLowerCase();
40
+
41
+ // Score each model: prefer exact id match > id contains > name contains > provider+id contains
42
+ let bestMatch: ModelEntry | undefined;
43
+ let bestScore = 0;
44
+
45
+ for (const m of all) {
46
+ const id = m.id.toLowerCase();
47
+ const name = m.name.toLowerCase();
48
+ const full = `${m.provider}/${m.id}`.toLowerCase();
49
+
50
+ let score = 0;
51
+ if (id === query || full === query) {
52
+ score = 100; // exact
53
+ } else if (id.includes(query) || full.includes(query)) {
54
+ score = 60 + (query.length / id.length) * 30; // substring, prefer tighter matches
55
+ } else if (name.includes(query)) {
56
+ score = 40 + (query.length / name.length) * 20;
57
+ } else if (
58
+ query
59
+ .split(/[\s\-/]+/)
60
+ .every((part) => id.includes(part) || name.includes(part) || m.provider.toLowerCase().includes(part))
61
+ ) {
62
+ score = 20; // all parts present somewhere
63
+ }
64
+
65
+ if (score > bestScore) {
66
+ bestScore = score;
67
+ bestMatch = m;
68
+ }
69
+ }
70
+
71
+ if (bestMatch && bestScore >= 20) {
72
+ const found = registry.find(bestMatch.provider, bestMatch.id);
73
+ if (found) return found;
74
+ }
75
+
76
+ // 3. No match — list available models
77
+ const modelList = all
78
+ .map((m) => ` ${m.provider}/${m.id}`)
79
+ .sort()
80
+ .join("\n");
81
+ return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
82
+ }