@phi-code-admin/phi-code 0.75.3 → 0.75.5

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.
@@ -411,6 +411,49 @@ export default function memoryExtension(pi: ExtensionAPI) {
411
411
  },
412
412
  });
413
413
 
414
+ /**
415
+ * Per-turn reminder injection (Combo strategy D).
416
+ *
417
+ * Pi LLMs (Claude, Kimi, GLM, MiniMax) consistently ignore "MANDATORY"
418
+ * guidelines buried in long system prompts. To enforce the
419
+ * memory_search/memory_write rules, we prepend a fresh <system-reminder>
420
+ * block to the system prompt at EVERY turn. This pattern is what Claude
421
+ * Code and Cursor use successfully in production for high-priority rules.
422
+ *
423
+ * The reminder is short and concrete (includes the user's current prompt
424
+ * truncated) so it grabs the LLM's attention even mid-conversation when
425
+ * the original critical_rule has receded due to context pressure.
426
+ */
427
+ pi.on("before_agent_start", async (event, _ctx) => {
428
+ // Skip during /plan orchestration: the orchestrator manages its own
429
+ // systemPrompt per phase via the same hook. Conflicting overrides would
430
+ // override the orchestrator's agent personas.
431
+ if ((globalThis as any).__phiOrchestrationActive) {
432
+ return {};
433
+ }
434
+ const userPrompt = (event.prompt ?? "").trim();
435
+ if (userPrompt.length === 0) {
436
+ return {};
437
+ }
438
+ const truncated = userPrompt.length > 200 ? `${userPrompt.slice(0, 200)}...` : userPrompt;
439
+ const reminder = `<system-reminder>
440
+ You are about to respond to a new user message:
441
+ "${truncated.replace(/"/g, '\\"')}"
442
+
443
+ REMINDER (project rule, applies every turn):
444
+ 1. Call \`memory_search\` FIRST with keywords from the user's intent. Recent
445
+ project context, prior decisions, and saved learnings are accessible
446
+ ONLY via this tool.
447
+ 2. AFTER completing significant work, call \`memory_write\` to save what
448
+ you did and learned.
449
+
450
+ These two tool calls are not optional. Skipping them violates project rules.
451
+ </system-reminder>
452
+
453
+ `;
454
+ return { systemPrompt: reminder + event.systemPrompt };
455
+ });
456
+
414
457
  /**
415
458
  * Auto-load AGENTS.md on session start
416
459
  * Checks both project directory and ~/.phi/memory/
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Models Extension - `/models` command for live model catalog management.
3
+ *
4
+ * Subcommands:
5
+ * /models : list models grouped by provider (uses cached/live data)
6
+ * /models list <id> : list models for a specific provider
7
+ * /models refresh : re-fetch the model catalog for every configured provider
8
+ * (writes the result into ~/.phi/agent/models.json,
9
+ * triggering ApiKeyStore hot-reload + ModelRegistry refresh).
10
+ * /models refresh <id>: refresh a single provider
11
+ *
12
+ * This is what keeps the model picker (/model) and the wizards (/setup,
13
+ * /phi-init) in sync with each provider's upstream catalog — for instance
14
+ * when OpenCode Go publishes a new model, a single `/models refresh` makes
15
+ * it appear everywhere without restarting Phi Code.
16
+ */
17
+
18
+ import { ApiKeyStore, type ExtensionAPI, getApiKeyStore } from "phi-code";
19
+ import { fetchLiveModels, peekCache, resetLiveModelsCache, toPersistedModel } from "./providers/live-models.js";
20
+
21
+ const PROVIDER_DISPLAY: Record<string, string> = {
22
+ "opencode-go": "OpenCode Go",
23
+ "alibaba-codingplan": "Alibaba Coding Plan (OpenAI-compat)",
24
+ "alibaba-codingplan-anthropic": "Alibaba Coding Plan (Anthropic-compat)",
25
+ openai: "OpenAI",
26
+ anthropic: "Anthropic",
27
+ google: "Google Gemini",
28
+ openrouter: "OpenRouter",
29
+ groq: "Groq",
30
+ ollama: "Ollama (local)",
31
+ "lm-studio": "LM Studio (local)",
32
+ };
33
+
34
+ function displayName(id: string): string {
35
+ return PROVIDER_DISPLAY[id] ?? id;
36
+ }
37
+
38
+ interface RefreshOutcome {
39
+ provider: string;
40
+ source: "live" | "cache" | "fallback" | "unsupported" | "skipped";
41
+ count: number;
42
+ error?: string;
43
+ }
44
+
45
+ async function refreshOne(
46
+ store: ApiKeyStore,
47
+ providerId: string,
48
+ ): Promise<RefreshOutcome> {
49
+ const stored = store.getProvider(providerId);
50
+ const apiKey = stored?.apiKey && !stored.apiKey.startsWith("$") && stored.apiKey !== "local"
51
+ ? stored.apiKey
52
+ : undefined;
53
+
54
+ resetLiveModelsCache(providerId);
55
+ const result = await fetchLiveModels(providerId, {
56
+ apiKey,
57
+ forceRefresh: true,
58
+ timeoutMs: 8_000,
59
+ });
60
+
61
+ if (result.source === "unsupported") {
62
+ return { provider: providerId, source: "skipped", count: 0, error: result.error };
63
+ }
64
+
65
+ const persisted = result.models.map(toPersistedModel);
66
+ if (persisted.length === 0) {
67
+ return { provider: providerId, source: result.source, count: 0, error: result.error };
68
+ }
69
+
70
+ // Preserve baseUrl/api/apiKey/headers from existing config; only models change.
71
+ const baseUrl =
72
+ stored?.baseUrl ??
73
+ (providerId === "opencode-go"
74
+ ? "https://opencode.ai/zen/go/v1"
75
+ : providerId === "alibaba-codingplan"
76
+ ? "https://coding-intl.dashscope.aliyuncs.com/v1"
77
+ : providerId === "alibaba-codingplan-anthropic"
78
+ ? "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
79
+ : providerId === "openai"
80
+ ? "https://api.openai.com/v1"
81
+ : providerId === "anthropic"
82
+ ? "https://api.anthropic.com/v1"
83
+ : providerId === "google"
84
+ ? "https://generativelanguage.googleapis.com/v1beta"
85
+ : providerId === "openrouter"
86
+ ? "https://openrouter.ai/api/v1"
87
+ : providerId === "groq"
88
+ ? "https://api.groq.com/openai/v1"
89
+ : providerId === "ollama"
90
+ ? "http://localhost:11434/v1"
91
+ : providerId === "lm-studio"
92
+ ? "http://localhost:1234/v1"
93
+ : undefined);
94
+
95
+ if (!baseUrl) {
96
+ return { provider: providerId, source: "skipped", count: 0, error: "unknown baseUrl" };
97
+ }
98
+
99
+ store.setKey(providerId, stored?.apiKey ?? "local", {
100
+ baseUrl,
101
+ api: stored?.api,
102
+ models: persisted,
103
+ });
104
+
105
+ return { provider: providerId, source: result.source, count: persisted.length, error: result.error };
106
+ }
107
+
108
+ export default function modelsExtension(pi: ExtensionAPI) {
109
+ const store = getApiKeyStore();
110
+
111
+ pi.registerCommand("models", {
112
+ description: "List or refresh the live model catalog (use `/models refresh` after a provider adds a new model)",
113
+ handler: async (args, ctx) => {
114
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
115
+ const sub = tokens[0]?.toLowerCase() ?? "";
116
+ const target = tokens[1];
117
+
118
+ try {
119
+ if (sub === "" || sub === "list") {
120
+ await listCommand(target, ctx);
121
+ return;
122
+ }
123
+ if (sub === "refresh") {
124
+ await refreshCommand(target, ctx);
125
+ return;
126
+ }
127
+ ctx.ui.notify(
128
+ "Unknown subcommand. Use: `/models [list|refresh] [provider-id]`",
129
+ "warning",
130
+ );
131
+ } catch (err) {
132
+ ctx.ui.notify(
133
+ `/models error: ${err instanceof Error ? err.message : String(err)}`,
134
+ "error",
135
+ );
136
+ }
137
+ },
138
+ });
139
+
140
+ async function listCommand(target: string | undefined, ctx: { ui: { notify: (m: string, t?: "info" | "warning" | "error") => void } }): Promise<void> {
141
+ const providers = target ? [target] : store.listProviders();
142
+ if (providers.length === 0) {
143
+ ctx.ui.notify(
144
+ "No providers configured. Run `/setup` or `/phi-init` to add one.",
145
+ "info",
146
+ );
147
+ return;
148
+ }
149
+
150
+ let out = `**Model catalog (${providers.length} provider(s))**\n\n`;
151
+ for (const id of providers) {
152
+ const stored = store.getProvider(id);
153
+ const cached = peekCache(id);
154
+ const models = (Array.isArray(stored?.models) ? stored?.models : []) as Array<{ id?: string; name?: string }>;
155
+ const ageMin = cached ? Math.round(cached.ageMs / 60_000) : undefined;
156
+
157
+ out += ` **${displayName(id)}** \`${id}\``;
158
+ out += ` — ${models.length} model(s) persisted`;
159
+ if (ageMin !== undefined) out += ` (cache age: ${ageMin}m)`;
160
+ out += "\n";
161
+ if (models.length > 0) {
162
+ const ids = models.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
163
+ out += ` ${ids.join(", ")}\n`;
164
+ }
165
+ }
166
+ out += `\nUse \`/models refresh\` to re-fetch from each provider's API.`;
167
+ ctx.ui.notify(out, "info");
168
+ }
169
+
170
+ // Background refresh on session_start so every new Phi Code session reflects
171
+ // the latest provider catalogs without the user typing `/models refresh`.
172
+ // Failures are silent — startup must never be blocked by upstream API hiccups.
173
+ pi.on("session_start", async (_event, ctx) => {
174
+ try {
175
+ store.load();
176
+ } catch {
177
+ // no models.json yet
178
+ }
179
+ const providers = store.listProviders();
180
+ if (providers.length === 0) return;
181
+
182
+ // Fire-and-forget. Hot-reload via models_json_changed event surfaces results.
183
+ void (async () => {
184
+ let changed = 0;
185
+ for (const id of providers) {
186
+ const outcome = await refreshOne(store, id).catch(() => undefined);
187
+ if (outcome && outcome.source === "live" && outcome.count > 0) changed++;
188
+ }
189
+ if (changed > 0) {
190
+ try {
191
+ ctx.ui.notify(
192
+ `Refreshed ${changed}/${providers.length} provider catalog(s) in the background.`,
193
+ "info",
194
+ );
195
+ } catch {
196
+ // notify may fail if the TUI is mid-shutdown — ignore
197
+ }
198
+ pi.events.emit("models_json_changed", { source: "session-start-refresh" });
199
+ }
200
+ })();
201
+ });
202
+
203
+ async function refreshCommand(
204
+ target: string | undefined,
205
+ ctx: { ui: { notify: (m: string, t?: "info" | "warning" | "error") => void; setStatus?: (k: string, v?: string) => void } },
206
+ ): Promise<void> {
207
+ const providers = target ? [target] : store.listProviders();
208
+ if (providers.length === 0) {
209
+ ctx.ui.notify("No providers configured.", "warning");
210
+ return;
211
+ }
212
+ ctx.ui.notify(`Refreshing ${providers.length} provider(s)...`, "info");
213
+ ctx.ui.setStatus?.("models-refresh", "Fetching live model catalogs...");
214
+
215
+ const outcomes: RefreshOutcome[] = [];
216
+ for (const id of providers) {
217
+ const outcome = await refreshOne(store, id).catch((err) => ({
218
+ provider: id,
219
+ source: "skipped" as const,
220
+ count: 0,
221
+ error: err instanceof Error ? err.message : String(err),
222
+ }));
223
+ outcomes.push(outcome);
224
+ }
225
+ ctx.ui.setStatus?.("models-refresh", undefined);
226
+
227
+ let out = "**Refresh report:**\n";
228
+ for (const o of outcomes) {
229
+ const icon = o.source === "live" ? "[ok]" : o.source === "fallback" ? "[fb]" : o.source === "cache" ? "[c]" : "[--]";
230
+ out += ` ${icon} ${displayName(o.provider)} \`${o.provider}\` — ${o.count} model(s) (${o.source}${o.error ? `, ${o.error}` : ""})\n`;
231
+ }
232
+ out += `\nModels persisted to \`${store.configPath}\`. \`/model\` picker now reflects this catalog.`;
233
+ ctx.ui.notify(out, "info");
234
+ pi.events.emit("models_json_changed", { source: "models-refresh" });
235
+ }
236
+ }