@phi-code-admin/phi-code 0.86.0 → 0.87.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/docs/fork-policy.md +18 -0
  3. package/extensions/phi/agents.ts +7 -4
  4. package/extensions/phi/benchmark.ts +129 -49
  5. package/extensions/phi/browser.ts +10 -37
  6. package/extensions/phi/btw/btw.ts +1 -7
  7. package/extensions/phi/chrome/index.ts +1283 -741
  8. package/extensions/phi/commit.ts +9 -14
  9. package/extensions/phi/goal/index.ts +10 -33
  10. package/extensions/phi/init.ts +37 -47
  11. package/extensions/phi/keys.ts +2 -7
  12. package/extensions/phi/mcp/callback-server.ts +162 -165
  13. package/extensions/phi/mcp/config.ts +122 -136
  14. package/extensions/phi/mcp/errors.ts +18 -23
  15. package/extensions/phi/mcp/index.ts +322 -355
  16. package/extensions/phi/mcp/oauth-provider.ts +289 -289
  17. package/extensions/phi/mcp/server-manager.ts +390 -413
  18. package/extensions/phi/mcp/tool-bridge.ts +381 -415
  19. package/extensions/phi/memory.ts +2 -2
  20. package/extensions/phi/models.ts +27 -26
  21. package/extensions/phi/orchestrator.ts +338 -229
  22. package/extensions/phi/productivity.ts +4 -2
  23. package/extensions/phi/providers/agent-def.ts +1 -1
  24. package/extensions/phi/providers/alibaba.ts +56 -7
  25. package/extensions/phi/providers/context-window.ts +4 -1
  26. package/extensions/phi/providers/live-models.ts +5 -20
  27. package/extensions/phi/providers/orchestrator-helpers.ts +19 -5
  28. package/extensions/phi/providers/phase-machine.ts +220 -0
  29. package/extensions/phi/setup.ts +196 -169
  30. package/extensions/phi/skill-loader.ts +14 -17
  31. package/extensions/phi/smart-router.ts +6 -6
  32. package/extensions/phi/web-search.ts +90 -50
  33. package/package.json +1 -1
@@ -332,7 +332,8 @@ export default function productivityExtension(pi: ExtensionAPI) {
332
332
  // /title
333
333
  // ------------------------------------------------------------------------
334
334
  pi.registerCommand("title", {
335
- description: "Derive a session title + kebab-case branch name from the first user message (deterministic, no LLM).",
335
+ description:
336
+ "Derive a session title + kebab-case branch name from the first user message (deterministic, no LLM).",
336
337
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
337
338
  try {
338
339
  const source = firstUserMessageText(ctx);
@@ -387,7 +388,8 @@ export default function productivityExtension(pi: ExtensionAPI) {
387
388
  // /dream
388
389
  // ------------------------------------------------------------------------
389
390
  pi.registerCommand("dream", {
390
- description: "Deterministic memory consolidation: report exact-duplicate notes that could be merged (no deletion).",
391
+ description:
392
+ "Deterministic memory consolidation: report exact-duplicate notes that could be merged (no deletion).",
391
393
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
392
394
  try {
393
395
  const files = memory.notes.list();
@@ -15,7 +15,7 @@
15
15
  * resolves to a real agents/ directory in both.
16
16
  */
17
17
 
18
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
18
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
19
19
  import { homedir } from "node:os";
20
20
  import { basename, join } from "node:path";
21
21
 
@@ -51,16 +51,65 @@ export interface AlibabaModelSpec {
51
51
  * are multimodal); the qwen3-coder / qwen3-max / glm / MiniMax entries are text.
52
52
  */
53
53
  export const ALIBABA_MODELS: readonly AlibabaModelSpec[] = [
54
- { id: "qwen3.7-plus", name: "Qwen 3.7 Plus", contextWindow: 1_000_000, maxTokens: 16_384, reasoning: true, vision: true },
55
- { id: "qwen3.6-plus", name: "Qwen 3.6 Plus", contextWindow: 1_000_000, maxTokens: 16_384, reasoning: true, vision: true },
56
- { id: "qwen3.5-plus", name: "Qwen 3.5 Plus", contextWindow: 1_000_000, maxTokens: 16_384, reasoning: true, vision: true },
57
- { id: "qwen3-max-2026-01-23", name: "Qwen 3 Max", contextWindow: 262_144, maxTokens: 16_384, reasoning: true, vision: false },
58
- { id: "qwen3-coder-plus", name: "Qwen 3 Coder Plus", contextWindow: 1_000_000, maxTokens: 16_384, reasoning: true, vision: false },
59
- { id: "qwen3-coder-next", name: "Qwen 3 Coder Next", contextWindow: 1_000_000, maxTokens: 16_384, reasoning: true, vision: false },
54
+ {
55
+ id: "qwen3.7-plus",
56
+ name: "Qwen 3.7 Plus",
57
+ contextWindow: 1_000_000,
58
+ maxTokens: 16_384,
59
+ reasoning: true,
60
+ vision: true,
61
+ },
62
+ {
63
+ id: "qwen3.6-plus",
64
+ name: "Qwen 3.6 Plus",
65
+ contextWindow: 1_000_000,
66
+ maxTokens: 16_384,
67
+ reasoning: true,
68
+ vision: true,
69
+ },
70
+ {
71
+ id: "qwen3.5-plus",
72
+ name: "Qwen 3.5 Plus",
73
+ contextWindow: 1_000_000,
74
+ maxTokens: 16_384,
75
+ reasoning: true,
76
+ vision: true,
77
+ },
78
+ {
79
+ id: "qwen3-max-2026-01-23",
80
+ name: "Qwen 3 Max",
81
+ contextWindow: 262_144,
82
+ maxTokens: 16_384,
83
+ reasoning: true,
84
+ vision: false,
85
+ },
86
+ {
87
+ id: "qwen3-coder-plus",
88
+ name: "Qwen 3 Coder Plus",
89
+ contextWindow: 1_000_000,
90
+ maxTokens: 16_384,
91
+ reasoning: true,
92
+ vision: false,
93
+ },
94
+ {
95
+ id: "qwen3-coder-next",
96
+ name: "Qwen 3 Coder Next",
97
+ contextWindow: 1_000_000,
98
+ maxTokens: 16_384,
99
+ reasoning: true,
100
+ vision: false,
101
+ },
60
102
  { id: "kimi-k2.5", name: "Kimi K2.5", contextWindow: 262_144, maxTokens: 16_384, reasoning: true, vision: true },
61
103
  { id: "glm-5", name: "GLM 5", contextWindow: 200_000, maxTokens: 128_000, reasoning: true, vision: false },
62
104
  { id: "glm-4.7", name: "GLM 4.7", contextWindow: 200_000, maxTokens: 128_000, reasoning: true, vision: false },
63
- { id: "MiniMax-M2.5", name: "MiniMax M2.5", contextWindow: 1_000_000, maxTokens: 16_384, reasoning: true, vision: false },
105
+ {
106
+ id: "MiniMax-M2.5",
107
+ name: "MiniMax M2.5",
108
+ contextWindow: 1_000_000,
109
+ maxTokens: 16_384,
110
+ reasoning: true,
111
+ vision: false,
112
+ },
64
113
  ] as const;
65
114
 
66
115
  export const ALIBABA_RATE_LIMITS = {
@@ -44,7 +44,10 @@ export function inferContextWindow(modelId: string, apiValue?: number, providerI
44
44
 
45
45
  /** Parse a context-window value like "256k", "1M", "1.5m", or "200000". */
46
46
  export function parseContextWindow(input: string): number | undefined {
47
- const m = input.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*([km])?$/);
47
+ const m = input
48
+ .trim()
49
+ .toLowerCase()
50
+ .match(/^(\d+(?:\.\d+)?)\s*([km])?$/);
48
51
  if (!m) return undefined;
49
52
  const n = Number.parseFloat(m[1]);
50
53
  if (!Number.isFinite(n) || n <= 0) return undefined;
@@ -19,13 +19,9 @@
19
19
  * are exposed so the wizard can show *something* before the first live fetch.
20
20
  */
21
21
 
22
- import {
23
- OPENCODE_GO_FALLBACK_MODELS,
24
- getOpenCodeGoModels,
25
- pingOpenCodeGo,
26
- } from "./opencode-go.js";
27
22
  import { ALIBABA_MODELS, ALIBABA_PROVIDERS, pingAlibaba } from "./alibaba.js";
28
23
  import { inferContextWindow } from "./context-window.js";
24
+ import { getOpenCodeGoModels, OPENCODE_GO_FALLBACK_MODELS, pingOpenCodeGo } from "./opencode-go.js";
29
25
 
30
26
  export const LAST_VERIFIED = "2026-07-10";
31
27
 
@@ -66,11 +62,7 @@ function isCacheValid(entry: CacheEntry | undefined, now: number): boolean {
66
62
  return entry !== undefined && now - entry.fetchedAt < CACHE_TTL_MS;
67
63
  }
68
64
 
69
- async function fetchJson(
70
- url: string,
71
- headers: Record<string, string>,
72
- timeoutMs: number,
73
- ): Promise<unknown> {
65
+ async function fetchJson(url: string, headers: Record<string, string>, timeoutMs: number): Promise<unknown> {
74
66
  const controller = new AbortController();
75
67
  const timer = setTimeout(() => controller.abort(), timeoutMs);
76
68
  try {
@@ -401,10 +393,7 @@ async function dispatchFetch(providerId: string, options: FetchOptions): Promise
401
393
  * 3. Previous cache entry, if any (even if stale).
402
394
  * 4. Static fallback (versioned).
403
395
  */
404
- export async function fetchLiveModels(
405
- providerId: string,
406
- options: FetchOptions = {},
407
- ): Promise<LiveModelsResult> {
396
+ export async function fetchLiveModels(providerId: string, options: FetchOptions = {}): Promise<LiveModelsResult> {
408
397
  const now = Date.now();
409
398
  const force = options.forceRefresh === true;
410
399
 
@@ -425,9 +414,7 @@ export async function fetchLiveModels(
425
414
  const models = await promise;
426
415
  if (models.length === 0) {
427
416
  const fallback = staticFallbackFor(providerId);
428
- return fallback.length > 0
429
- ? { models: fallback, source: "fallback" }
430
- : { models: [], source: "unsupported" };
417
+ return fallback.length > 0 ? { models: fallback, source: "fallback" } : { models: [], source: "unsupported" };
431
418
  }
432
419
  cache.set(providerId, { models, fetchedAt: now });
433
420
  return { models, source: "live" };
@@ -466,9 +453,7 @@ export async function pingProvider(
466
453
  default: {
467
454
  try {
468
455
  const models = await dispatchFetch(providerId, { apiKey, timeoutMs, forceRefresh: true });
469
- return models.length > 0
470
- ? { ok: true }
471
- : { ok: false, error: "no models returned" };
456
+ return models.length > 0 ? { ok: true } : { ok: false, error: "no models returned" };
472
457
  } catch (err) {
473
458
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
474
459
  }
@@ -21,14 +21,28 @@ export function parsePhaseVerdict(content: string): PhaseVerdict | null {
21
21
  }
22
22
 
23
23
  /**
24
- * Extract the body of a markdown section by heading name (e.g. "BLOCKING",
25
- * "HANDOFF"), up to the next heading or end of file. Returns "" if absent.
24
+ * Extract the body of a section by name (e.g. "BLOCKING", "HANDOFF"), up to the
25
+ * next section header or end of file. Returns "" if absent.
26
+ *
27
+ * Tolerant of the three header shapes models actually emit for the same
28
+ * section: a markdown heading (`## HANDOFF`), a standalone bold label
29
+ * (`**HANDOFF**`), or a plain label line (`HANDOFF:`). Termination stops at the
30
+ * next markdown heading or the next standalone bold/plain label line, so a
31
+ * report written entirely with bold labels still splits into sections instead
32
+ * of the first one swallowing the rest.
26
33
  */
27
34
  export function extractSection(content: string, heading: string): string {
28
35
  if (!content) return "";
29
- const re = new RegExp(`#{1,6}\\s*\\**\\s*${heading}\\b[^\\n]*\\n([\\s\\S]*?)(?:\\n#{1,6}\\s|$)`, "i");
30
- const m = content.match(re);
31
- return m ? m[1].trim() : "";
36
+ // Header line: optional leading heading hashes and/or bold stars, then the name.
37
+ const start = new RegExp(`(?:^|\\n)[ \\t]{0,3}(?:#{1,6}[ \\t]*)?\\*{0,2}[ \\t]*${heading}\\b[^\\n]*\\n`, "i");
38
+ const m = start.exec(content);
39
+ if (!m) return "";
40
+ const rest = content.slice(m.index + m[0].length);
41
+ // Next section boundary: a markdown heading line, or a line that is ONLY a
42
+ // bold label (`**LABEL**`) — not inline bold inside a bullet, which would
43
+ // truncate the body.
44
+ const end = rest.search(/\n[ \t]{0,3}(?:#{1,6}[ \t]|\*{2}[A-Za-z][^\n]*\*{2}[ \t]*(?:\n|$))/);
45
+ return (end === -1 ? rest : rest.slice(0, end)).trim();
32
46
  }
33
47
 
34
48
  export function extractBlockingFindings(content: string): string {
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Pure decision core of the /plan orchestrator's phase state machine.
3
+ *
4
+ * orchestrator.ts owns the I/O (UI notifications, queue mutation, checkpoint
5
+ * files, model switching); everything DECIDABLE from a finished phase's
6
+ * messages and report lives here so the exact transition behavior is unit
7
+ * tested — including what happens when a model deviates from the text
8
+ * contracts (missing report, unparseable verdict, zero tool calls).
9
+ *
10
+ * Behavior contract (mirrors the agent_end hook, in priority order):
11
+ * user abort > auth error (401) > transient retry (once, fallback model)
12
+ * > BLOCKED pause > review-FAIL fix cycle (once, unless looping)
13
+ * > continue (with diagnostics).
14
+ */
15
+
16
+ import { isTransientError, type PhaseVerdict } from "./orchestrator-helpers.js";
17
+
18
+ export interface PhaseEndAnalysis {
19
+ userAborted: boolean;
20
+ hasAuthError: boolean;
21
+ transient: boolean;
22
+ toolCallCount: number;
23
+ filesWritten: string[];
24
+ filesEdited: string[];
25
+ errorsHit: string[];
26
+ testResults: string[];
27
+ calledMemorySearch: boolean;
28
+ calledMemoryWrite: boolean;
29
+ }
30
+
31
+ interface LooseMessage {
32
+ role?: string;
33
+ content?: unknown;
34
+ name?: string;
35
+ toolName?: string;
36
+ isError?: boolean;
37
+ stopReason?: string;
38
+ }
39
+
40
+ function messageText(msg: LooseMessage): string {
41
+ if (typeof msg.content === "string") return msg.content;
42
+ if (Array.isArray(msg.content)) {
43
+ return msg.content.map((c: { text?: string }) => c?.text || "").join("");
44
+ }
45
+ return JSON.stringify(msg.content ?? "");
46
+ }
47
+
48
+ /**
49
+ * Analyze a finished phase's message list into the facts the transition
50
+ * decision needs. Pure: no I/O, no state.
51
+ */
52
+ export function analyzePhaseMessages(rawMessages: readonly unknown[]): PhaseEndAnalysis {
53
+ const messages = (rawMessages || []) as LooseMessage[];
54
+
55
+ const userAborted = messages.some((m) => m.role === "assistant" && m.stopReason === "aborted");
56
+
57
+ const hasAuthError = messages.some((m) => {
58
+ const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content || "");
59
+ return (
60
+ content.includes("401") &&
61
+ (content.includes("invalid access token") ||
62
+ content.includes("token expired") ||
63
+ content.includes("Unauthorized"))
64
+ );
65
+ });
66
+
67
+ const filesWritten: string[] = [];
68
+ const filesEdited: string[] = [];
69
+ const errorsHit: string[] = [];
70
+ const testResults: string[] = [];
71
+ let toolCallCount = 0;
72
+ const toolNames: string[] = [];
73
+
74
+ for (const msg of messages) {
75
+ // Pi uses role: "toolResult" instead of "tool"
76
+ if (msg.role !== "tool" && msg.role !== "function" && msg.role !== "toolResult") continue;
77
+ toolCallCount++;
78
+ const content = messageText(msg);
79
+ const name = msg.name || msg.toolName || "";
80
+ toolNames.push(name);
81
+
82
+ // Track writes
83
+ if (name === "write" && content.includes("Successfully wrote")) {
84
+ const match = content.match(/wrote \d+ bytes to (.+)/);
85
+ if (match) filesWritten.push(match[1]);
86
+ }
87
+ // Track edits — the edit tool returns "Successfully replaced N block(s) in <path>."
88
+ // Anchor the path capture so it does not over-capture unrelated text.
89
+ if (name === "edit" && !content.includes("ERR") && !msg.isError) {
90
+ const match = content.match(/replaced \d+ block\(s\) in (.+?)\.?$/m);
91
+ if (match) filesEdited.push(match[1]);
92
+ }
93
+ // Track errors — but filter out edit retries (old_text mismatch = normal retry, not error)
94
+ if (
95
+ (content.includes("ERR:") || content.includes("Error:") || content.includes("FAIL")) &&
96
+ !content.includes("old text must match") &&
97
+ !content.includes("The old text") &&
98
+ !content.includes("oldText not found") &&
99
+ !content.includes("old_text not found")
100
+ ) {
101
+ const preview = content.slice(0, 150).replace(/\n/g, " ");
102
+ errorsHit.push(`${name}: ${preview}`);
103
+ }
104
+ // Track test results
105
+ if (content.includes("PASS") || content.includes("✅") || content.includes("✗") || content.includes("❌")) {
106
+ const lines = content.split("\n").filter((l: string) => /PASS|FAIL|✅|❌|✗/.test(l));
107
+ testResults.push(...lines.slice(0, 10));
108
+ }
109
+ }
110
+
111
+ return {
112
+ userAborted,
113
+ hasAuthError,
114
+ transient: isTransientError(rawMessages),
115
+ toolCallCount,
116
+ filesWritten,
117
+ filesEdited,
118
+ errorsHit,
119
+ testResults,
120
+ calledMemorySearch: toolNames.includes("memory_search"),
121
+ calledMemoryWrite: toolNames.includes("memory_write"),
122
+ };
123
+ }
124
+
125
+ /** Build the heuristic summary injected into the next phase's instruction. */
126
+ export function buildPhaseSummary(analysis: PhaseEndAnalysis, maxToolCallsPerPhase: number): string {
127
+ const parts: string[] = [];
128
+ parts.push(`Tool calls: ${analysis.toolCallCount}`);
129
+ if (analysis.toolCallCount > maxToolCallsPerPhase) {
130
+ parts.push(
131
+ `⚠️ WARNING: Phase used ${analysis.toolCallCount} tool calls (limit: ${maxToolCallsPerPhase}). Possible loop detected.`,
132
+ );
133
+ }
134
+ if (analysis.filesWritten.length > 0) parts.push(`Files created/written: ${analysis.filesWritten.join(", ")}`);
135
+ if (analysis.filesEdited.length > 0) parts.push(`Files edited: ${analysis.filesEdited.join(", ")}`);
136
+ if (analysis.testResults.length > 0) parts.push(`Test results:\n${analysis.testResults.join("\n")}`);
137
+ if (analysis.errorsHit.length > 0)
138
+ parts.push(`Errors encountered: ${analysis.errorsHit.length}\n${analysis.errorsHit.slice(0, 5).join("\n")}`);
139
+ if (!analysis.calledMemorySearch) parts.push(`⚠️ Phase did NOT call memory_search (mandatory)`);
140
+ if (!analysis.calledMemoryWrite) parts.push(`⚠️ Phase did NOT call memory_write (mandatory)`);
141
+ return parts.join("\n");
142
+ }
143
+
144
+ /**
145
+ * Prefer the phase's canonical "## HANDOFF" block over the heuristic summary;
146
+ * fall back to the heuristic when the model did not write one.
147
+ */
148
+ export function buildNextBrief(
149
+ analysis: PhaseEndAnalysis,
150
+ handoff: string,
151
+ phaseLabel: string,
152
+ maxToolCallsPerPhase: number,
153
+ ): string {
154
+ return handoff
155
+ ? `Tool calls: ${analysis.toolCallCount}\n## HANDOFF (from ${phaseLabel})\n${handoff}`
156
+ : buildPhaseSummary(analysis, maxToolCallsPerPhase);
157
+ }
158
+
159
+ export type PhaseDecision =
160
+ | { action: "stop"; reason: "user-abort" | "auth-error" }
161
+ | { action: "retry-fallback" }
162
+ | { action: "pause-blocked" }
163
+ | { action: "review-fix-cycle" }
164
+ | {
165
+ action: "continue";
166
+ /** The phase answered with prose only — warn and nudge the next phase. */
167
+ zeroToolCalls: boolean;
168
+ /**
169
+ * The phase was expected to write a "VERDICT:" line but none parsed —
170
+ * a model deviated from the text contract. Surface it instead of
171
+ * silently treating the phase as passed.
172
+ */
173
+ missingVerdict: boolean;
174
+ };
175
+
176
+ export interface PhaseDecisionInput {
177
+ analysis: PhaseEndAnalysis;
178
+ /** Currently executing phase, or null when the queue raced. */
179
+ phase: { key: string; retried?: boolean } | null;
180
+ /** Verdict parsed from the phase's report file (null = absent/unparseable). */
181
+ verdict: PhaseVerdict | null;
182
+ /** Whether a report file existed at all for this phase. */
183
+ hasReport: boolean;
184
+ /** Completed review-fix cycles so far (bounded to one). */
185
+ reviewFixRounds: number;
186
+ maxToolCallsPerPhase: number;
187
+ }
188
+
189
+ /** Phases whose contract REQUIRES a leading "VERDICT:" line in their report. */
190
+ const VERDICT_PHASES = new Set(["test", "review"]);
191
+
192
+ /**
193
+ * Decide the transition after a phase's agent loop completed. Pure — the
194
+ * caller interprets the decision (notifications, queue edits, checkpoints).
195
+ */
196
+ export function decidePhaseTransition(input: PhaseDecisionInput): PhaseDecision {
197
+ const { analysis, phase, verdict, hasReport, reviewFixRounds, maxToolCallsPerPhase } = input;
198
+
199
+ if (analysis.userAborted) return { action: "stop", reason: "user-abort" };
200
+ if (analysis.hasAuthError) return { action: "stop", reason: "auth-error" };
201
+
202
+ // Transient provider/proxy failure: retry the SAME phase once on the
203
+ // fallback model (a different family often gets through).
204
+ if (phase && !phase.retried && analysis.transient) {
205
+ return { action: "retry-fallback" };
206
+ }
207
+
208
+ if (verdict === "BLOCKED" && phase) return { action: "pause-blocked" };
209
+
210
+ const looped = analysis.toolCallCount > maxToolCallsPerPhase;
211
+ if (phase?.key === "review" && verdict === "FAIL" && reviewFixRounds < 1 && !looped) {
212
+ return { action: "review-fix-cycle" };
213
+ }
214
+
215
+ return {
216
+ action: "continue",
217
+ zeroToolCalls: analysis.toolCallCount === 0,
218
+ missingVerdict: phase !== null && VERDICT_PHASES.has(phase.key) && hasReport && verdict === null,
219
+ };
220
+ }