@phi-code-admin/phi-code 0.78.0 → 0.80.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 +70 -0
- package/dist/core/compaction/utils.d.ts.map +1 -1
- package/dist/core/compaction/utils.js +7 -1
- package/dist/core/compaction/utils.js.map +1 -1
- package/dist/core/tools/bash.d.ts.map +1 -1
- package/dist/core/tools/bash.js +1 -1
- package/dist/core/tools/bash.js.map +1 -1
- package/extensions/phi/commit.ts +253 -0
- package/extensions/phi/memory.ts +129 -2
- package/extensions/phi/orchestrator.ts +112 -17
- package/extensions/phi/productivity.ts +476 -0
- package/extensions/phi/{orchestrator-helpers.ts → providers/orchestrator-helpers.ts} +3 -2
- package/extensions/phi/web-search.ts +167 -3
- package/package.json +2 -1
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commit Extension - /commit command for deterministic, LLM-free git commits.
|
|
3
|
+
*
|
|
4
|
+
* Inspired by examples/extensions/auto-commit-on-exit.ts, but exposed as an
|
|
5
|
+
* explicit user command and hardened with a strict safety protocol. There is
|
|
6
|
+
* NO model call anywhere in this file: the commit message is derived purely
|
|
7
|
+
* from git state and the local session, so the result is fully reproducible
|
|
8
|
+
* and never depends on a flaky structured-output proxy.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* /commit : commit what is already staged (no auto-staging)
|
|
12
|
+
* /commit <message> : commit already-staged changes with that message
|
|
13
|
+
* /commit --all : stage everything (git add -A) then commit
|
|
14
|
+
* /commit --all <message> : stage everything then commit with that message
|
|
15
|
+
*
|
|
16
|
+
* Message format: always prefixed with "[phi] ". When the user passes a
|
|
17
|
+
* message it is used verbatim after the prefix. Otherwise the message is
|
|
18
|
+
* derived deterministically from the staged files (git diff --staged --stat /
|
|
19
|
+
* git status) plus the first line of the last assistant message when present.
|
|
20
|
+
*
|
|
21
|
+
* Safety protocol (strict):
|
|
22
|
+
* - Refuses to commit if a sensitive file is staged (.env, *.pem, *secret*,
|
|
23
|
+
* id_rsa). The user is warned and nothing is committed.
|
|
24
|
+
* - Never uses --amend.
|
|
25
|
+
* - Only stages with `git add -A` when --all is passed; otherwise it commits
|
|
26
|
+
* strictly what is already in the index.
|
|
27
|
+
* - The message is passed via a single pi.exec("git", ["commit", "-m", msg])
|
|
28
|
+
* call: no shell string interpolation, robust cross-OS.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "phi-code";
|
|
32
|
+
|
|
33
|
+
/** Max length of the derived subject line (without the "[phi] " prefix). */
|
|
34
|
+
const MAX_SUBJECT_LENGTH = 60;
|
|
35
|
+
|
|
36
|
+
/** Max number of file names listed in an auto-derived commit message. */
|
|
37
|
+
const MAX_LISTED_FILES = 3;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Patterns for files that must never be committed by this command.
|
|
41
|
+
* Matched against the path of each staged file (case-insensitive on basename).
|
|
42
|
+
*/
|
|
43
|
+
const SENSITIVE_PATTERNS: { label: string; test: (path: string) => boolean }[] = [
|
|
44
|
+
{
|
|
45
|
+
label: ".env file",
|
|
46
|
+
test: (p) => {
|
|
47
|
+
const base = basename(p).toLowerCase();
|
|
48
|
+
return base === ".env" || base.startsWith(".env.");
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{ label: "PEM/key file", test: (p) => p.toLowerCase().endsWith(".pem") },
|
|
52
|
+
{ label: "secret-named file", test: (p) => basename(p).toLowerCase().includes("secret") },
|
|
53
|
+
{ label: "SSH private key", test: (p) => basename(p).toLowerCase() === "id_rsa" },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
/** Return the last path segment, tolerating both / and \ separators. */
|
|
57
|
+
function basename(path: string): string {
|
|
58
|
+
const normalized = path.replace(/\\/g, "/");
|
|
59
|
+
const idx = normalized.lastIndexOf("/");
|
|
60
|
+
return idx === -1 ? normalized : normalized.slice(idx + 1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse `git status --porcelain` output into staged file paths.
|
|
65
|
+
*
|
|
66
|
+
* Porcelain v1 lines are "XY <path>" where X is the index (staged) status.
|
|
67
|
+
* A file is considered staged when X is not a space and not "?" (untracked).
|
|
68
|
+
* Rename entries use "orig -> dest"; we keep the destination path.
|
|
69
|
+
*/
|
|
70
|
+
function parseStagedFiles(porcelain: string): string[] {
|
|
71
|
+
const files: string[] = [];
|
|
72
|
+
for (const rawLine of porcelain.split("\n")) {
|
|
73
|
+
const line = rawLine.replace(/\r$/, "");
|
|
74
|
+
if (line.length < 4) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const indexStatus = line[0];
|
|
78
|
+
if (indexStatus === " " || indexStatus === "?") {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
let pathPart = line.slice(3).trim();
|
|
82
|
+
const renameSep = pathPart.indexOf(" -> ");
|
|
83
|
+
if (renameSep !== -1) {
|
|
84
|
+
pathPart = pathPart.slice(renameSep + 4).trim();
|
|
85
|
+
}
|
|
86
|
+
// Porcelain quotes paths containing special chars; strip surrounding quotes.
|
|
87
|
+
if (pathPart.startsWith('"') && pathPart.endsWith('"')) {
|
|
88
|
+
pathPart = pathPart.slice(1, -1);
|
|
89
|
+
}
|
|
90
|
+
if (pathPart.length > 0) {
|
|
91
|
+
files.push(pathPart);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return files;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Find the first sensitive file among the staged paths, if any. */
|
|
98
|
+
function findSensitiveFile(stagedFiles: string[]): { path: string; label: string } | undefined {
|
|
99
|
+
for (const path of stagedFiles) {
|
|
100
|
+
for (const pattern of SENSITIVE_PATTERNS) {
|
|
101
|
+
if (pattern.test(path)) {
|
|
102
|
+
return { path, label: pattern.label };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Extract the first line of the last assistant message from the session. */
|
|
110
|
+
function lastAssistantFirstLine(ctx: ExtensionCommandContext): string {
|
|
111
|
+
const entries = ctx.sessionManager.getEntries();
|
|
112
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
113
|
+
const entry = entries[i];
|
|
114
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const content = entry.message.content;
|
|
118
|
+
let text = "";
|
|
119
|
+
if (typeof content === "string") {
|
|
120
|
+
text = content;
|
|
121
|
+
} else if (Array.isArray(content)) {
|
|
122
|
+
text = content
|
|
123
|
+
.filter((c): c is { type: "text"; text: string } => c?.type === "text" && typeof c.text === "string")
|
|
124
|
+
.map((c) => c.text)
|
|
125
|
+
.join("\n");
|
|
126
|
+
}
|
|
127
|
+
const firstLine = text.split("\n").find((l) => l.trim().length > 0);
|
|
128
|
+
return firstLine ? firstLine.trim() : "";
|
|
129
|
+
}
|
|
130
|
+
return "";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Collapse whitespace and clamp to MAX_SUBJECT_LENGTH with an ellipsis. */
|
|
134
|
+
function clampSubject(text: string): string {
|
|
135
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
136
|
+
if (collapsed.length <= MAX_SUBJECT_LENGTH) {
|
|
137
|
+
return collapsed;
|
|
138
|
+
}
|
|
139
|
+
return `${collapsed.slice(0, MAX_SUBJECT_LENGTH - 3)}...`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Build a deterministic subject derived from the staged files plus the first
|
|
144
|
+
* line of the last assistant message. No LLM involved.
|
|
145
|
+
*/
|
|
146
|
+
function deriveSubject(stagedFiles: string[], assistantHint: string): string {
|
|
147
|
+
const names = stagedFiles.map(basename);
|
|
148
|
+
let filesPart: string;
|
|
149
|
+
if (names.length === 0) {
|
|
150
|
+
filesPart = "changes";
|
|
151
|
+
} else if (names.length <= MAX_LISTED_FILES) {
|
|
152
|
+
filesPart = names.join(", ");
|
|
153
|
+
} else {
|
|
154
|
+
const shown = names.slice(0, MAX_LISTED_FILES).join(", ");
|
|
155
|
+
filesPart = `${shown} (+${names.length - MAX_LISTED_FILES} more)`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const hint = assistantHint.trim();
|
|
159
|
+
const subject = hint.length > 0 ? `update ${filesPart}: ${hint}` : `update ${filesPart}`;
|
|
160
|
+
return clampSubject(subject);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export default function commitExtension(pi: ExtensionAPI) {
|
|
164
|
+
pi.registerCommand("commit", {
|
|
165
|
+
description: "Deterministically commit staged changes ([phi] prefix, no LLM). Use --all to stage everything first.",
|
|
166
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
167
|
+
try {
|
|
168
|
+
// 1. Parse args: extract the --all flag, keep the rest as the message.
|
|
169
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
170
|
+
const stageAll = tokens.includes("--all");
|
|
171
|
+
const userMessage = tokens.filter((t) => t !== "--all").join(" ").trim();
|
|
172
|
+
|
|
173
|
+
// 2. Confirm we are inside a git repo with changes.
|
|
174
|
+
const status = await pi.exec("git", ["status", "--porcelain"], { cwd: ctx.cwd });
|
|
175
|
+
if (status.code !== 0) {
|
|
176
|
+
ctx.ui.notify(
|
|
177
|
+
"Not a git repository (or git unavailable). Nothing to commit.",
|
|
178
|
+
"warning",
|
|
179
|
+
);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (status.stdout.trim().length === 0) {
|
|
183
|
+
ctx.ui.notify("Working tree clean: nothing to commit.", "info");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 3. Optionally stage everything (only when explicitly requested).
|
|
188
|
+
if (stageAll) {
|
|
189
|
+
const add = await pi.exec("git", ["add", "-A"], { cwd: ctx.cwd });
|
|
190
|
+
if (add.code !== 0) {
|
|
191
|
+
ctx.ui.notify(`git add -A failed: ${add.stderr.trim() || "unknown error"}`, "error");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 4. Re-read status to know exactly what is staged after any add.
|
|
197
|
+
const afterStatus = await pi.exec("git", ["status", "--porcelain"], { cwd: ctx.cwd });
|
|
198
|
+
const stagedFiles = parseStagedFiles(afterStatus.stdout);
|
|
199
|
+
if (stagedFiles.length === 0) {
|
|
200
|
+
ctx.ui.notify(
|
|
201
|
+
"No staged changes to commit. Stage files first, or run `/commit --all`.",
|
|
202
|
+
"warning",
|
|
203
|
+
);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 5. Safety protocol: refuse if a sensitive file is staged.
|
|
208
|
+
const sensitive = findSensitiveFile(stagedFiles);
|
|
209
|
+
if (sensitive) {
|
|
210
|
+
ctx.ui.notify(
|
|
211
|
+
`Refusing to commit: sensitive ${sensitive.label} is staged (\`${sensitive.path}\`). ` +
|
|
212
|
+
`Unstage it with \`git restore --staged ${sensitive.path}\` before committing.`,
|
|
213
|
+
"error",
|
|
214
|
+
);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 6. Build the deterministic message.
|
|
219
|
+
let subject: string;
|
|
220
|
+
if (userMessage.length > 0) {
|
|
221
|
+
subject = userMessage;
|
|
222
|
+
} else {
|
|
223
|
+
const hint = lastAssistantFirstLine(ctx);
|
|
224
|
+
subject = deriveSubject(stagedFiles, hint);
|
|
225
|
+
}
|
|
226
|
+
const message = `[phi] ${subject}`;
|
|
227
|
+
|
|
228
|
+
// 7. Commit via a single exec call (no shell, no --amend).
|
|
229
|
+
const commit = await pi.exec("git", ["commit", "-m", message], { cwd: ctx.cwd });
|
|
230
|
+
if (commit.code !== 0) {
|
|
231
|
+
ctx.ui.notify(
|
|
232
|
+
`git commit failed: ${commit.stderr.trim() || commit.stdout.trim() || "unknown error"}`,
|
|
233
|
+
"error",
|
|
234
|
+
);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 8. Report the result with the short hash.
|
|
239
|
+
const rev = await pi.exec("git", ["rev-parse", "--short", "HEAD"], { cwd: ctx.cwd });
|
|
240
|
+
const shortHash = rev.code === 0 ? rev.stdout.trim() : "(unknown)";
|
|
241
|
+
ctx.ui.notify(
|
|
242
|
+
`Committed ${shortHash}: ${message} (${stagedFiles.length} file${stagedFiles.length === 1 ? "" : "s"}).`,
|
|
243
|
+
"info",
|
|
244
|
+
);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
ctx.ui.notify(
|
|
247
|
+
`/commit error: ${err instanceof Error ? err.message : String(err)}`,
|
|
248
|
+
"error",
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
}
|
package/extensions/phi/memory.ts
CHANGED
|
@@ -64,6 +64,113 @@ function withFrontmatter(content: string, name: string): string {
|
|
|
64
64
|
return `---\nname: "${name}"\ndescription: "${description}"\n---\n\n${content}`;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/** Hard caps for the deterministic recall manifest (no LLM involved). */
|
|
68
|
+
const MANIFEST_MAX_ENTRIES = 40;
|
|
69
|
+
const MANIFEST_MAX_CHARS = 2000;
|
|
70
|
+
const MANIFEST_DESC_MAX = 100;
|
|
71
|
+
// Chars reserved for the trailing "… (N notes total …)" overflow line so the
|
|
72
|
+
// final manifest still respects MANIFEST_MAX_CHARS once it is appended.
|
|
73
|
+
const MANIFEST_OVERFLOW_RESERVE = 96;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extract a one-line summary for a single note file, deterministically.
|
|
77
|
+
*
|
|
78
|
+
* Prefers the YAML frontmatter `description:` field (written by
|
|
79
|
+
* withFrontmatter). Falls back to the first non-empty, non-frontmatter line.
|
|
80
|
+
* Returns a trimmed, single-line string truncated to MANIFEST_DESC_MAX chars.
|
|
81
|
+
*/
|
|
82
|
+
function summarizeNote(content: string): string {
|
|
83
|
+
const text = content.replace(/\r\n/g, "\n");
|
|
84
|
+
let body = text;
|
|
85
|
+
|
|
86
|
+
// If a frontmatter block is present, scan it for a description first.
|
|
87
|
+
if (text.startsWith("---\n")) {
|
|
88
|
+
const end = text.indexOf("\n---", 4);
|
|
89
|
+
if (end !== -1) {
|
|
90
|
+
const fm = text.slice(4, end);
|
|
91
|
+
for (const rawLine of fm.split("\n")) {
|
|
92
|
+
const m = rawLine.match(/^\s*description\s*:\s*(.+?)\s*$/);
|
|
93
|
+
if (m) {
|
|
94
|
+
// Strip surrounding quotes if present.
|
|
95
|
+
const value = m[1].replace(/^["']|["']$/g, "").trim();
|
|
96
|
+
if (value) return collapseLine(value);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// No description in frontmatter: summarize the body that follows it.
|
|
100
|
+
body = text.slice(end + 4);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const rawLine of body.split("\n")) {
|
|
105
|
+
const line = rawLine.replace(/^#+\s*/, "").trim();
|
|
106
|
+
if (line && line !== "---") {
|
|
107
|
+
return collapseLine(line);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return "";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Collapse internal whitespace to single spaces and truncate cleanly. */
|
|
114
|
+
function collapseLine(value: string): string {
|
|
115
|
+
const flat = value.replace(/\s+/g, " ").trim();
|
|
116
|
+
return flat.length > MANIFEST_DESC_MAX ? `${flat.slice(0, MANIFEST_DESC_MAX - 1).trimEnd()}…` : flat;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build a deterministic one-line-per-file manifest of all memory notes.
|
|
121
|
+
*
|
|
122
|
+
* Purely local: lists note files and reads their frontmatter / first line.
|
|
123
|
+
* No LLM call, no vector search. Bounded to MANIFEST_MAX_ENTRIES entries and
|
|
124
|
+
* MANIFEST_MAX_CHARS characters so it stays cheap to inject every turn.
|
|
125
|
+
*
|
|
126
|
+
* Returns an empty string when there are no notes, so callers can skip
|
|
127
|
+
* injection entirely.
|
|
128
|
+
*/
|
|
129
|
+
function buildMemoryManifest(sigmaMemory: SigmaMemory): string {
|
|
130
|
+
let files: Array<{ name: string; size: number; date: string }>;
|
|
131
|
+
try {
|
|
132
|
+
files = sigmaMemory.notes.list();
|
|
133
|
+
} catch {
|
|
134
|
+
return "";
|
|
135
|
+
}
|
|
136
|
+
if (files.length === 0) {
|
|
137
|
+
return "";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const lines: string[] = [];
|
|
141
|
+
let total = 0;
|
|
142
|
+
let truncated = false;
|
|
143
|
+
|
|
144
|
+
for (const file of files) {
|
|
145
|
+
if (lines.length >= MANIFEST_MAX_ENTRIES) {
|
|
146
|
+
truncated = true;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
let summary = "";
|
|
150
|
+
try {
|
|
151
|
+
summary = summarizeNote(sigmaMemory.notes.read(file.name));
|
|
152
|
+
} catch {
|
|
153
|
+
// Unreadable note: still list its name so the model knows it exists.
|
|
154
|
+
summary = "";
|
|
155
|
+
}
|
|
156
|
+
const entry = summary ? `- ${file.name}: ${summary}` : `- ${file.name}`;
|
|
157
|
+
if (total + entry.length + 1 > MANIFEST_MAX_CHARS - MANIFEST_OVERFLOW_RESERVE) {
|
|
158
|
+
truncated = true;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
lines.push(entry);
|
|
162
|
+
total += entry.length + 1;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (lines.length === 0) {
|
|
166
|
+
return "";
|
|
167
|
+
}
|
|
168
|
+
if (truncated || lines.length < files.length) {
|
|
169
|
+
lines.push(`- … (${files.length} notes total; use memory_read/memory_search for full content)`);
|
|
170
|
+
}
|
|
171
|
+
return lines.join("\n");
|
|
172
|
+
}
|
|
173
|
+
|
|
67
174
|
export default function memoryExtension(pi: ExtensionAPI) {
|
|
68
175
|
// Initialize sigma-memory with embedded vector store
|
|
69
176
|
const sigmaMemory = new SigmaMemory();
|
|
@@ -244,8 +351,19 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
244
351
|
)
|
|
245
352
|
.join("\n");
|
|
246
353
|
|
|
354
|
+
// Lead with the deterministic recall manifest (file + summary)
|
|
355
|
+
// so a no-arg memory_read surfaces what each note contains, not
|
|
356
|
+
// just file names. Falls back gracefully when empty.
|
|
357
|
+
const manifest = buildMemoryManifest(sigmaMemory);
|
|
358
|
+
const manifestSection = manifest ? `## Memory index (summaries)\n\n${manifest}\n\n` : "";
|
|
359
|
+
|
|
247
360
|
return {
|
|
248
|
-
content: [
|
|
361
|
+
content: [
|
|
362
|
+
{
|
|
363
|
+
type: "text",
|
|
364
|
+
text: `${manifestSection}## Available memory files (${files.length})\n\n${fileList}`,
|
|
365
|
+
},
|
|
366
|
+
],
|
|
249
367
|
details: { action: "list", fileCount: files.length },
|
|
250
368
|
};
|
|
251
369
|
}
|
|
@@ -529,10 +647,19 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
529
647
|
// Neutralize angle brackets so user content cannot close the
|
|
530
648
|
// <system-reminder> block early and inject trusted instructions.
|
|
531
649
|
const safe = truncated.replace(/[<>]/g, (c) => (c === "<" ? "<" : ">")).replace(/"/g, '\\"');
|
|
650
|
+
|
|
651
|
+
// Deterministic recall index: a one-line-per-file manifest of memory
|
|
652
|
+
// notes so the model ALWAYS sees which facts exist without having to
|
|
653
|
+
// guess and call memory_search blindly. Built locally (no LLM).
|
|
654
|
+
const manifest = buildMemoryManifest(sigmaMemory);
|
|
655
|
+
const manifestBlock = manifest
|
|
656
|
+
? `\nMEMORY INDEX (existing saved notes, read with \`memory_read <file>\`):\n${manifest}\n`
|
|
657
|
+
: "";
|
|
658
|
+
|
|
532
659
|
const reminder = `<system-reminder>
|
|
533
660
|
You are about to respond to a new user message:
|
|
534
661
|
"${safe}"
|
|
535
|
-
|
|
662
|
+
${manifestBlock}
|
|
536
663
|
REMINDER (project rule, applies every turn):
|
|
537
664
|
1. Call \`memory_search\` FIRST with keywords from the user's intent. Recent
|
|
538
665
|
project context, prior decisions, and saved learnings are accessible
|
|
@@ -20,14 +20,14 @@ import { Type } from "@sinclair/typebox";
|
|
|
20
20
|
import type { ExtensionAPI } from "phi-code";
|
|
21
21
|
import { writeFile, mkdir, readdir, readFile } from "node:fs/promises";
|
|
22
22
|
import { join } from "node:path";
|
|
23
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
23
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
24
24
|
import { homedir } from "node:os";
|
|
25
25
|
import {
|
|
26
26
|
extractBlockingFindings,
|
|
27
27
|
extractHandoff,
|
|
28
28
|
isTransientError,
|
|
29
29
|
parsePhaseVerdict,
|
|
30
|
-
} from "./orchestrator-helpers.js";
|
|
30
|
+
} from "./providers/orchestrator-helpers.js";
|
|
31
31
|
|
|
32
32
|
// ─── Types ───────────────────────────────────────────────────────────────
|
|
33
33
|
|
|
@@ -152,6 +152,7 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
|
|
|
152
152
|
if (orchestrationActive) {
|
|
153
153
|
return {
|
|
154
154
|
content: [{ type: "text", text: "⚠️ Orchestration is already active via /plan. Do NOT call orchestrate during /plan phases. Follow the phase instructions instead." }],
|
|
155
|
+
details: undefined,
|
|
155
156
|
};
|
|
156
157
|
}
|
|
157
158
|
|
|
@@ -229,6 +230,9 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
|
|
|
229
230
|
// When true, switchModelForPhase resolves the fallback model first (used on retry
|
|
230
231
|
// to swap to a different model family rather than re-hit the one that just failed).
|
|
231
232
|
useFallback?: boolean;
|
|
233
|
+
// 0..4 for the five base phases (explore..review); undefined for synthetic
|
|
234
|
+
// phases (the review fix + re-review). Used to checkpoint resume position.
|
|
235
|
+
baseIndex?: number;
|
|
232
236
|
}
|
|
233
237
|
|
|
234
238
|
let phaseQueue: OrchestratorPhase[] = [];
|
|
@@ -251,6 +255,8 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
|
|
|
251
255
|
let currentPhase: OrchestratorPhase | null = null;
|
|
252
256
|
// Timestamp shared by all phase report file names in the current run.
|
|
253
257
|
let currentRunTs = "";
|
|
258
|
+
// The original /plan description, kept so a checkpoint can be resumed.
|
|
259
|
+
let currentDescription = "";
|
|
254
260
|
// Hard cap: at most one REVIEW->fix->re-REVIEW cycle per run.
|
|
255
261
|
let reviewFixRounds = 0;
|
|
256
262
|
// Set true right before an INTERNAL ctx.abort() (phase timeout) so the
|
|
@@ -266,6 +272,41 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
|
|
|
266
272
|
return null;
|
|
267
273
|
}
|
|
268
274
|
|
|
275
|
+
// ─── Resume checkpoint ───────────────────────────────────────────────
|
|
276
|
+
// A tiny JSON marker tracking POSITION in the 5 base phases, so a crashed /
|
|
277
|
+
// aborted orchestration can be resumed with `/plan --resume`. The handoff .md
|
|
278
|
+
// files hold the real content; this only records where to pick up.
|
|
279
|
+
function checkpointPath(): string {
|
|
280
|
+
return join(process.cwd(), ".phi", "plans", "orchestration-state.json");
|
|
281
|
+
}
|
|
282
|
+
function writeCheckpoint(nextBaseIndex: number): void {
|
|
283
|
+
try {
|
|
284
|
+
if (nextBaseIndex >= 5) { clearCheckpoint(); return; }
|
|
285
|
+
writeFileSync(
|
|
286
|
+
checkpointPath(),
|
|
287
|
+
JSON.stringify({ description: currentDescription, ts: currentRunTs, nextBaseIndex }, null, 2),
|
|
288
|
+
"utf-8",
|
|
289
|
+
);
|
|
290
|
+
} catch { /* best effort */ }
|
|
291
|
+
}
|
|
292
|
+
function readCheckpoint(): { description: string; ts: string; nextBaseIndex: number } | null {
|
|
293
|
+
try {
|
|
294
|
+
const p = checkpointPath();
|
|
295
|
+
if (!existsSync(p)) return null;
|
|
296
|
+
const c = JSON.parse(readFileSync(p, "utf-8"));
|
|
297
|
+
if (typeof c?.description === "string" && typeof c?.ts === "string" && typeof c?.nextBaseIndex === "number") {
|
|
298
|
+
return c;
|
|
299
|
+
}
|
|
300
|
+
} catch { /* ignore */ }
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
function clearCheckpoint(): void {
|
|
304
|
+
try {
|
|
305
|
+
const p = checkpointPath();
|
|
306
|
+
if (existsSync(p)) unlinkSync(p);
|
|
307
|
+
} catch { /* best effort */ }
|
|
308
|
+
}
|
|
309
|
+
|
|
269
310
|
/**
|
|
270
311
|
* Common rules appended to every phase instruction. Pure prompt (zero latency,
|
|
271
312
|
* robust to the proxy): honest reporting, autonomous operation, a canonical
|
|
@@ -321,7 +362,7 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
|
|
|
321
362
|
* Load routing config and build phase queue with model assignments + agent definitions.
|
|
322
363
|
* Each phase now reads outputs from previous phases and writes structured outputs.
|
|
323
364
|
*/
|
|
324
|
-
function buildPhases(description: string): OrchestratorPhase[] {
|
|
365
|
+
function buildPhases(description: string, tsOverride?: string): OrchestratorPhase[] {
|
|
325
366
|
const routingPath = join(homedir(), ".phi", "agent", "routing.json");
|
|
326
367
|
let routing: any = { routes: {}, default: { model: "default" } };
|
|
327
368
|
try {
|
|
@@ -342,15 +383,15 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
|
|
|
342
383
|
const test = getModel("test");
|
|
343
384
|
const review = getModel("review");
|
|
344
385
|
|
|
345
|
-
const ts = timestamp();
|
|
346
|
-
currentRunTs = ts; // shared by all phase report file names this run
|
|
386
|
+
const ts = tsOverride || timestamp();
|
|
387
|
+
currentRunTs = ts; // shared by all phase report file names this run (or resumed)
|
|
347
388
|
// Inject runtime info so agents can adapt to the host OS
|
|
348
389
|
const shellNote = process.platform === 'win32'
|
|
349
390
|
? `\nShell: bash (Git Bash), NOT cmd.exe. Always use Unix syntax: rm not del, test -f not if exist, / not \\\\`
|
|
350
391
|
: '';
|
|
351
392
|
const runtimeInfo = `\n\nRuntime: ${process.platform} (${process.arch})${shellNote}`;
|
|
352
393
|
|
|
353
|
-
|
|
394
|
+
const phases: OrchestratorPhase[] = [
|
|
354
395
|
{
|
|
355
396
|
key: "explore", label: "🔍 Phase 1 — EXPLORE", model: explore.preferred, fallback: explore.fallback,
|
|
356
397
|
agent: loadAgentDef("explore"),
|
|
@@ -573,7 +614,7 @@ After testing, use \`memory_write\` to save test results, bugs found, and lesson
|
|
|
573
614
|
|
|
574
615
|
**Project Request:** ${description}
|
|
575
616
|
|
|
576
|
-
**Step 1:** Get the diff to review. Run \`git diff\` (and \`git status\`) if it is a git repo; otherwise read the files listed in the progress/handoff reports. Skip test/fixture hunks.
|
|
617
|
+
**Step 1:** Get the diff to review. Run \`git diff\` and \`git diff --stat\` (and \`git status\`) if it is a git repo; otherwise read the files listed in the progress/handoff reports. Skip test/fixture hunks. **Scale your effort to the diff size:** a small diff (under ~50 changed lines) gets one focused precision pass, bias to high confidence, at most 4 findings; a large diff gets all three angles below plus the verification step. Never spend so long that you risk the phase time limit.
|
|
577
618
|
**Step 2:** Review in SEQUENTIAL ANGLES (do all in this one turn, no sub-agents). Collect raw candidates first, judge after:
|
|
578
619
|
- **Angle 1 - Correctness:** read the diff line by line. What behaviour did it change or remove? Off-by-one, null/undefined, wrong condition, broken invariant.
|
|
579
620
|
- **Angle 2 - Cross-file:** grep the callers and callees of changed symbols. Did a signature/return/contract change break a caller elsewhere?
|
|
@@ -615,6 +656,8 @@ Tag the note with relevant keywords for vector search.
|
|
|
615
656
|
- Save any new architectural decisions or patterns discovered` + runtimeInfo + COMMON_PHASE_RULES,
|
|
616
657
|
},
|
|
617
658
|
];
|
|
659
|
+
phases.forEach((p, i) => { p.baseIndex = i; });
|
|
660
|
+
return phases;
|
|
618
661
|
}
|
|
619
662
|
|
|
620
663
|
/**
|
|
@@ -684,13 +727,14 @@ Tag the note with relevant keywords for vector search.
|
|
|
684
727
|
savedTools = pi.getActiveTools();
|
|
685
728
|
}
|
|
686
729
|
if (phase.agent) {
|
|
730
|
+
const agentDef = phase.agent;
|
|
687
731
|
// Set agent's system prompt (will be injected via before_agent_start)
|
|
688
|
-
activeAgentPrompt =
|
|
732
|
+
activeAgentPrompt = agentDef.systemPrompt;
|
|
689
733
|
// Restrict tools to agent's allowed tools
|
|
690
|
-
if (
|
|
734
|
+
if (agentDef.tools.length > 0) {
|
|
691
735
|
// Always include memory tools in orchestration phases
|
|
692
736
|
const memoryTools = ['memory_search', 'memory_write', 'memory_read', 'ontology_add', 'ontology_query'];
|
|
693
|
-
const agentTools = [...
|
|
737
|
+
const agentTools = [...agentDef.tools, ...memoryTools.filter(t => !agentDef.tools.includes(t))];
|
|
694
738
|
activeAgentTools = agentTools;
|
|
695
739
|
pi.setActiveTools(agentTools);
|
|
696
740
|
} else if (savedTools) {
|
|
@@ -752,6 +796,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
752
796
|
// All phases done — clean up and notify
|
|
753
797
|
setOrchestrationActive(false);
|
|
754
798
|
phasePending = false;
|
|
799
|
+
clearCheckpoint();
|
|
755
800
|
deactivateAgent();
|
|
756
801
|
// Restore the user's model so /plan does not overwrite their /model choice.
|
|
757
802
|
if (originalModel) {
|
|
@@ -883,14 +928,16 @@ Tag the note with relevant keywords for vector search.
|
|
|
883
928
|
const testResults: string[] = [];
|
|
884
929
|
let toolCallCount = 0;
|
|
885
930
|
|
|
886
|
-
for (const
|
|
931
|
+
for (const rawMsg of messages) {
|
|
932
|
+
// Pi message variants are scanned dynamically; cast to a loose shape once.
|
|
933
|
+
const msg = rawMsg as { role?: string; content?: unknown; name?: string; toolName?: string; isError?: boolean };
|
|
887
934
|
// Pi uses role: "toolResult" instead of "tool"
|
|
888
935
|
if (msg.role === 'tool' || msg.role === 'function' || msg.role === 'toolResult') {
|
|
889
936
|
toolCallCount++;
|
|
890
937
|
const content = Array.isArray(msg.content)
|
|
891
938
|
? msg.content.map((c: any) => c.text || '').join('')
|
|
892
939
|
: String(msg.content || '');
|
|
893
|
-
const name =
|
|
940
|
+
const name = msg.name || msg.toolName || '';
|
|
894
941
|
// Track writes
|
|
895
942
|
if (name === 'write' && content.includes('Successfully wrote')) {
|
|
896
943
|
const match = content.match(/wrote \d+ bytes to (.+)/);
|
|
@@ -898,7 +945,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
898
945
|
}
|
|
899
946
|
// Track edits — the edit tool returns "Successfully replaced N block(s) in <path>."
|
|
900
947
|
// Anchor the path capture so it does not over-capture unrelated text.
|
|
901
|
-
if (name === 'edit' && !content.includes('ERR') && !
|
|
948
|
+
if (name === 'edit' && !content.includes('ERR') && !msg.isError) {
|
|
902
949
|
const match = content.match(/replaced \d+ block\(s\) in (.+?)\.?$/m);
|
|
903
950
|
if (match) filesEdited.push(match[1]);
|
|
904
951
|
}
|
|
@@ -913,7 +960,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
913
960
|
}
|
|
914
961
|
// Track test results
|
|
915
962
|
if (content.includes('PASS') || content.includes('✅') || content.includes('✗') || content.includes('❌')) {
|
|
916
|
-
const lines = content.split('\n').filter(l => /PASS|FAIL|✅|❌|✗/.test(l));
|
|
963
|
+
const lines = content.split('\n').filter((l: string) => /PASS|FAIL|✅|❌|✗/.test(l));
|
|
917
964
|
testResults.push(...lines.slice(0, 10));
|
|
918
965
|
}
|
|
919
966
|
}
|
|
@@ -1027,6 +1074,12 @@ Tag the note with relevant keywords for vector search.
|
|
|
1027
1074
|
}
|
|
1028
1075
|
} catch { /* verdict logic is best-effort */ }
|
|
1029
1076
|
|
|
1077
|
+
// Checkpoint resume position after each base phase (synthetic fix/re-review
|
|
1078
|
+
// phases have no baseIndex and do not advance it).
|
|
1079
|
+
if (currentPhase && typeof currentPhase.baseIndex === "number") {
|
|
1080
|
+
writeCheckpoint(currentPhase.baseIndex + 1);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1030
1083
|
// Phase complete — chain to next
|
|
1031
1084
|
completedPhases++;
|
|
1032
1085
|
phasePending = false;
|
|
@@ -1038,10 +1091,51 @@ Tag the note with relevant keywords for vector search.
|
|
|
1038
1091
|
pi.registerCommand("plan", {
|
|
1039
1092
|
description: "Plan AND execute a project — 5 phases, each with its own model from routing.json",
|
|
1040
1093
|
handler: async (args, ctx) => {
|
|
1041
|
-
const
|
|
1094
|
+
const rawArgs = args.trim();
|
|
1095
|
+
|
|
1096
|
+
// Resume a crashed / aborted orchestration from its checkpoint.
|
|
1097
|
+
if (/^(--resume|resume)\b/i.test(rawArgs)) {
|
|
1098
|
+
const cp = readCheckpoint();
|
|
1099
|
+
if (!cp) {
|
|
1100
|
+
ctx.ui.notify("No orchestration checkpoint to resume. Start one with `/plan <description>`.", "warning");
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
await ensurePlansDir();
|
|
1104
|
+
currentDescription = cp.description;
|
|
1105
|
+
// Reuse the saved timestamp so the rebuilt phases point at the same
|
|
1106
|
+
// .phi/plans/*.md handoff files that the earlier run produced.
|
|
1107
|
+
const rphases = buildPhases(cp.description, cp.ts);
|
|
1108
|
+
const remaining = rphases.slice(Math.max(0, Math.min(5, cp.nextBaseIndex)));
|
|
1109
|
+
if (remaining.length === 0) {
|
|
1110
|
+
clearCheckpoint();
|
|
1111
|
+
ctx.ui.notify("Checkpoint already complete — nothing to resume.", "info");
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
setOrchestrationActive(true);
|
|
1115
|
+
phasePending = true;
|
|
1116
|
+
originalModel = ctx.model || null;
|
|
1117
|
+
savedTools = pi.getActiveTools();
|
|
1118
|
+
completedPhases = cp.nextBaseIndex;
|
|
1119
|
+
skippedPhases = 0;
|
|
1120
|
+
reviewFixRounds = 0;
|
|
1121
|
+
internalAbort = false;
|
|
1122
|
+
phaseStartTime = Date.now();
|
|
1123
|
+
const firstResume = remaining[0];
|
|
1124
|
+
currentPhase = firstResume;
|
|
1125
|
+
phaseQueue = remaining.slice(1);
|
|
1126
|
+
ctx.ui.notify(`📋 **Resuming orchestration** at ${firstResume.label} (${remaining.length} phase(s) left)\n`, "info");
|
|
1127
|
+
const { modelId, warning } = await switchModelForPhase(firstResume, ctx);
|
|
1128
|
+
activateAgent(firstResume, ctx);
|
|
1129
|
+
ctx.ui.notify(`${firstResume.label} → \`${modelId}\``, "info");
|
|
1130
|
+
if (warning) ctx.ui.notify(`\n⚠️ ${warning}`, "warning");
|
|
1131
|
+
setTimeout(() => pi.sendUserMessage(firstResume.instruction, { deliverAs: "followUp" }), 200);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const description = rawArgs;
|
|
1042
1136
|
|
|
1043
1137
|
if (!description) {
|
|
1044
|
-
ctx.ui.notify(`**Usage:** \`/plan <project description>\`
|
|
1138
|
+
ctx.ui.notify(`**Usage:** \`/plan <project description>\` (or \`/plan --resume\` to continue a checkpointed run)
|
|
1045
1139
|
|
|
1046
1140
|
**Examples:**
|
|
1047
1141
|
/plan Build a REST API for user authentication with JWT
|
|
@@ -1060,6 +1154,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
1060
1154
|
await writeFile(join(plansDir, specFile), `# ${description}\n\n**Created:** ${new Date().toLocaleString()}\n`, "utf-8");
|
|
1061
1155
|
|
|
1062
1156
|
// Build phases with model assignments + agent definitions
|
|
1157
|
+
currentDescription = description;
|
|
1063
1158
|
const phases = buildPhases(description);
|
|
1064
1159
|
phaseQueue = phases.slice(1); // Queue phases 2-5
|
|
1065
1160
|
setOrchestrationActive(true);
|
|
@@ -1072,9 +1167,9 @@ Tag the note with relevant keywords for vector search.
|
|
|
1072
1167
|
completedPhases = 0;
|
|
1073
1168
|
skippedPhases = 0;
|
|
1074
1169
|
reviewFixRounds = 0;
|
|
1075
|
-
currentPhase = null;
|
|
1076
1170
|
internalAbort = false;
|
|
1077
1171
|
const firstPhase = phases[0];
|
|
1172
|
+
currentPhase = firstPhase;
|
|
1078
1173
|
|
|
1079
1174
|
ctx.ui.notify(`📋 **Orchestrator started** — 5 phases with model routing + agent roles\n`, "info");
|
|
1080
1175
|
|