@pi-unipi/unipi 2.4.2 → 2.6.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 +49 -0
- package/README.md +2 -0
- package/docs/prefix-cache-architecture.md +89 -0
- package/package.json +24 -22
- package/packages/ask-user/package.json +2 -2
- package/packages/autocomplete/package.json +1 -1
- package/packages/autocomplete/src/constants.ts +2 -0
- package/packages/btw/package.json +2 -2
- package/packages/cocoindex/README.md +2 -1
- package/packages/cocoindex/index.ts +6 -13
- package/packages/cocoindex/package.json +4 -3
- package/packages/cocoindex/tools.ts +45 -11
- package/packages/compactor/README.md +4 -2
- package/packages/compactor/package.json +3 -3
- package/packages/compactor/src/session/snapshot.ts +3 -2
- package/packages/compactor/src/tools/register.ts +6 -2
- package/packages/compactor/src/tools/vcc-recall.ts +18 -3
- package/packages/core/bounded-output.ts +106 -0
- package/packages/core/constants.ts +2 -0
- package/packages/core/index.ts +1 -0
- package/packages/core/package.json +1 -1
- package/packages/core/sandbox.ts +7 -6
- package/packages/footer/package.json +2 -2
- package/packages/image/package.json +2 -2
- package/packages/info-screen/package.json +2 -2
- package/packages/input-shortcuts/package.json +2 -2
- package/packages/kanboard/package.json +2 -2
- package/packages/mcp/README.md +13 -0
- package/packages/mcp/package.json +5 -2
- package/packages/mcp/src/bridge/registry.ts +244 -137
- package/packages/mcp/src/bridge/translator.ts +96 -21
- package/packages/mcp/src/index.ts +43 -67
- package/packages/mcp/src/tui/settings-overlay.ts +3 -9
- package/packages/memory/README.md +15 -11
- package/packages/memory/bridge/mempalace_bridge.py +636 -0
- package/packages/memory/index.ts +56 -34
- package/packages/memory/mempalace.ts +169 -18
- package/packages/memory/package.json +3 -3
- package/packages/memory/storage.ts +27 -10
- package/packages/milestone/README.md +11 -3
- package/packages/milestone/hooks.ts +103 -29
- package/packages/milestone/index.ts +1 -1
- package/packages/milestone/package.json +5 -2
- package/packages/notify/package.json +2 -2
- package/packages/ralph/index.ts +12 -18
- package/packages/ralph/package.json +6 -3
- package/packages/ralph/reminder.ts +40 -0
- package/packages/ralph/tools.ts +5 -1
- package/packages/subagents/README.md +2 -0
- package/packages/subagents/package.json +1 -1
- package/packages/subagents/src/agent-manager.ts +5 -1
- package/packages/subagents/src/agent-runner.ts +2 -2
- package/packages/subagents/src/core-compat.ts +73 -0
- package/packages/subagents/src/custom-agents.ts +10 -2
- package/packages/subagents/src/index.ts +14 -3
- package/packages/subagents/src/types.ts +2 -0
- package/packages/unipi/bundled.js +1445 -686
- package/packages/updater/package.json +2 -2
- package/packages/utility/README.md +9 -0
- package/packages/utility/package.json +2 -2
- package/packages/utility/src/index.ts +48 -0
- package/packages/utility/src/lifecycle/cleanup.ts +29 -0
- package/packages/utility/src/prefix-cache.ts +263 -0
- package/packages/utility/src/types.ts +1 -1
- package/packages/web-api/package.json +2 -2
- package/packages/workflow/README.md +8 -0
- package/packages/workflow/commands.ts +16 -26
- package/packages/workflow/index.ts +165 -85
- package/packages/workflow/package.json +5 -2
|
@@ -1,22 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @pi-unipi/milestone — Lifecycle hooks
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Agent start: append milestone progress as a hidden, persistent context snapshot.
|
|
5
5
|
* Session end: auto-sync completed items from workflow docs.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import * as fs from "node:fs";
|
|
9
9
|
import * as path from "node:path";
|
|
10
|
-
import
|
|
10
|
+
import {
|
|
11
|
+
buildSessionContext,
|
|
12
|
+
type ExtensionAPI,
|
|
13
|
+
type SessionEntry,
|
|
14
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
15
|
import { MILESTONE_DIRS, UNIPI_EVENTS, safeMtimeMs, tryRead } from "@pi-unipi/core";
|
|
12
|
-
import {
|
|
16
|
+
import { getProgressSummary, updateItemStatus } from "./milestone.js";
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
let sessionStartMs = 0;
|
|
18
|
+
export const MILESTONE_SNAPSHOT_TYPE = "unipi-milestone-snapshot";
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
interface MilestoneSnapshotDetails {
|
|
21
|
+
active: boolean;
|
|
22
|
+
workspace: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface EffectiveSnapshot {
|
|
26
|
+
content: unknown;
|
|
27
|
+
details?: MilestoneSnapshotDetails;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Format the active milestone progress included in a snapshot. */
|
|
20
31
|
function formatMilestoneContext(filePath: string): string | null {
|
|
21
32
|
const summary = getProgressSummary(filePath);
|
|
22
33
|
if (summary.totalItems === 0) return null;
|
|
@@ -39,23 +50,84 @@ function formatMilestoneContext(filePath: string): string | null {
|
|
|
39
50
|
.join("\n");
|
|
40
51
|
}
|
|
41
52
|
|
|
53
|
+
/** Build an append-only snapshot that explicitly invalidates earlier snapshots. */
|
|
54
|
+
export function formatMilestoneSnapshot(workspace: string, context: string | null): string {
|
|
55
|
+
return [
|
|
56
|
+
"# UniPi Milestone Snapshot",
|
|
57
|
+
"This snapshot supersedes all prior UniPi milestone snapshots; use only this snapshot for milestone status.",
|
|
58
|
+
`Workspace: ${workspace}`,
|
|
59
|
+
`Status: ${context ? "active" : "inactive"}`,
|
|
60
|
+
context ?? "No milestones are active for this workspace.",
|
|
61
|
+
].join("\n\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function latestEffectiveSnapshot(branch: SessionEntry[]): EffectiveSnapshot | undefined {
|
|
65
|
+
const messages = buildSessionContext(branch).messages;
|
|
66
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
67
|
+
const message = messages[index];
|
|
68
|
+
if (message.role === "custom" && message.customType === MILESTONE_SNAPSHOT_TYPE) {
|
|
69
|
+
return {
|
|
70
|
+
content: message.content,
|
|
71
|
+
details: message.details as MilestoneSnapshotDetails | undefined,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Find state that may have been folded into a compaction summary. */
|
|
79
|
+
function latestHistoricalSnapshot(branch: SessionEntry[]): EffectiveSnapshot | undefined {
|
|
80
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
81
|
+
const entry = branch[index];
|
|
82
|
+
if (entry.type === "custom_message" && entry.customType === MILESTONE_SNAPSHOT_TYPE) {
|
|
83
|
+
return {
|
|
84
|
+
content: entry.content,
|
|
85
|
+
details: entry.details as MilestoneSnapshotDetails | undefined,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isActiveSnapshot(snapshot: EffectiveSnapshot): boolean {
|
|
93
|
+
if (typeof snapshot.details?.active === "boolean") return snapshot.details.active;
|
|
94
|
+
return typeof snapshot.content === "string" && snapshot.content.includes("Status: active");
|
|
95
|
+
}
|
|
96
|
+
|
|
42
97
|
/**
|
|
43
|
-
* Register
|
|
98
|
+
* Register the agent-start hook. Snapshots are hidden from the transcript but
|
|
99
|
+
* persist in the append-only session and therefore keep the system prefix stable.
|
|
44
100
|
*/
|
|
45
101
|
export function registerSessionStartHook(pi: ExtensionAPI): void {
|
|
46
|
-
pi.on("before_agent_start", (
|
|
47
|
-
|
|
102
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
103
|
+
const workspace = ctx.cwd;
|
|
104
|
+
const milestonesPath = path.join(workspace, MILESTONE_DIRS.MILESTONES);
|
|
105
|
+
const context = formatMilestoneContext(milestonesPath);
|
|
106
|
+
const branch = ctx.sessionManager.getBranch();
|
|
107
|
+
const latest = latestEffectiveSnapshot(branch);
|
|
108
|
+
const historical = latestHistoricalSnapshot(branch);
|
|
48
109
|
|
|
49
|
-
|
|
50
|
-
|
|
110
|
+
// A genuinely clean workspace/session needs no synthetic context. Raw
|
|
111
|
+
// history is checked because compaction may have folded an old active
|
|
112
|
+
// snapshot into summary prose while removing its custom-message identity.
|
|
113
|
+
if (!context && !latest && !historical) return undefined;
|
|
51
114
|
|
|
52
|
-
const
|
|
53
|
-
if (!context) return undefined;
|
|
115
|
+
const prior = latest ?? historical;
|
|
116
|
+
if (!context && prior && !isActiveSnapshot(prior)) return undefined;
|
|
117
|
+
|
|
118
|
+
const content = formatMilestoneSnapshot(workspace, context);
|
|
119
|
+
if (latest?.content === content) return undefined;
|
|
54
120
|
|
|
55
|
-
// Append milestone context to the system prompt
|
|
56
|
-
const currentPrompt = (event as any).systemPrompt ?? "";
|
|
57
121
|
return {
|
|
58
|
-
|
|
122
|
+
message: {
|
|
123
|
+
customType: MILESTONE_SNAPSHOT_TYPE,
|
|
124
|
+
content,
|
|
125
|
+
display: false,
|
|
126
|
+
details: {
|
|
127
|
+
active: context !== null,
|
|
128
|
+
workspace,
|
|
129
|
+
} satisfies MilestoneSnapshotDetails,
|
|
130
|
+
},
|
|
59
131
|
};
|
|
60
132
|
});
|
|
61
133
|
}
|
|
@@ -140,19 +212,21 @@ function scanModifiedDocs(dirs: string[], since: number): string[] {
|
|
|
140
212
|
* scans modified docs, and auto-updates MILESTONES.md.
|
|
141
213
|
*/
|
|
142
214
|
export function registerSessionEndHook(pi: ExtensionAPI): void {
|
|
143
|
-
//
|
|
215
|
+
// Capture the session workspace because process.cwd() can change before shutdown.
|
|
144
216
|
const baselineSnapshots = new Map<string, string>();
|
|
217
|
+
let sessionStartMs = 0;
|
|
218
|
+
let sessionWorkspace: string | null = null;
|
|
145
219
|
|
|
146
220
|
// Capture baselines on session start
|
|
147
|
-
pi.on("session_start", () => {
|
|
221
|
+
pi.on("session_start", (_event, ctx) => {
|
|
148
222
|
sessionStartMs = Date.now();
|
|
223
|
+
sessionWorkspace = ctx.cwd;
|
|
149
224
|
baselineSnapshots.clear();
|
|
150
225
|
|
|
151
|
-
const cwd = process.cwd();
|
|
152
226
|
const scanDirs = [
|
|
153
|
-
path.join(
|
|
154
|
-
path.join(
|
|
155
|
-
path.join(
|
|
227
|
+
path.join(sessionWorkspace, ".unipi/docs/specs"),
|
|
228
|
+
path.join(sessionWorkspace, ".unipi/docs/plans"),
|
|
229
|
+
path.join(sessionWorkspace, ".unipi/docs/quick-work"),
|
|
156
230
|
];
|
|
157
231
|
|
|
158
232
|
for (const dir of scanDirs) {
|
|
@@ -168,15 +242,15 @@ export function registerSessionEndHook(pi: ExtensionAPI): void {
|
|
|
168
242
|
});
|
|
169
243
|
|
|
170
244
|
const syncModifiedDocs = () => {
|
|
171
|
-
|
|
172
|
-
const milestonesPath = path.join(cwd, MILESTONE_DIRS.MILESTONES);
|
|
245
|
+
if (!sessionWorkspace) return;
|
|
173
246
|
|
|
247
|
+
const milestonesPath = path.join(sessionWorkspace, MILESTONE_DIRS.MILESTONES);
|
|
174
248
|
if (!fs.existsSync(milestonesPath)) return;
|
|
175
249
|
|
|
176
250
|
const scanDirs = [
|
|
177
|
-
path.join(
|
|
178
|
-
path.join(
|
|
179
|
-
path.join(
|
|
251
|
+
path.join(sessionWorkspace, ".unipi/docs/specs"),
|
|
252
|
+
path.join(sessionWorkspace, ".unipi/docs/plans"),
|
|
253
|
+
path.join(sessionWorkspace, ".unipi/docs/quick-work"),
|
|
180
254
|
];
|
|
181
255
|
|
|
182
256
|
const modifiedFiles = scanModifiedDocs(scanDirs, sessionStartMs);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* @pi-unipi/milestone — Extension entry point
|
|
3
3
|
*
|
|
4
4
|
* Lifecycle layer for project-level goals. Tracks progress via MILESTONES.md,
|
|
5
|
-
*
|
|
5
|
+
* appends hidden context snapshots before agent turns, and auto-syncs on session end.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/milestone",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "Lifecycle layer for project-level goals — MILESTONES.md tracking, session hooks, auto-sync",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "npx tsx --test tests/hooks.test.ts"
|
|
9
|
+
},
|
|
7
10
|
"license": "MIT",
|
|
8
11
|
"author": "Neuron Mr White",
|
|
9
12
|
"repository": {
|
|
@@ -29,7 +32,7 @@
|
|
|
29
32
|
"access": "public"
|
|
30
33
|
},
|
|
31
34
|
"dependencies": {
|
|
32
|
-
"@pi-unipi/core": "2.
|
|
35
|
+
"@pi-unipi/core": "2.6.0"
|
|
33
36
|
},
|
|
34
37
|
"peerDependencies": {
|
|
35
38
|
"@earendil-works/pi-coding-agent": "^0.80.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/notify",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "Cross-platform notification extension for Pi — native OS, Gotify, and Telegram notifications for agent lifecycle events",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"access": "public"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@pi-unipi/core": "2.
|
|
37
|
+
"@pi-unipi/core": "2.6.0",
|
|
38
38
|
"node-notifier": "^10.0.1"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
package/packages/ralph/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ const VERSION = getPackageVersion(dirname(fileURLToPath(import.meta.url)));
|
|
|
30
30
|
/** Current loop manager instance (recreated on session reload) */
|
|
31
31
|
let manager: RalphLoopManager | null = null;
|
|
32
32
|
|
|
33
|
+
|
|
33
34
|
/**
|
|
34
35
|
* Get or create the loop manager for the current context.
|
|
35
36
|
*/
|
|
@@ -42,9 +43,14 @@ function getManager(ctx: ExtensionContext, pi: ExtensionAPI): RalphLoopManager {
|
|
|
42
43
|
return manager;
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
export { buildRalphLoopReminder } from "./reminder.js";
|
|
47
|
+
import { buildRalphLoopReminder, latestRalphReminder, RALPH_REMINDER_TYPE } from "./reminder.js";
|
|
48
|
+
|
|
45
49
|
export default function (pi: ExtensionAPI) {
|
|
46
|
-
// Register
|
|
47
|
-
//
|
|
50
|
+
// Register static tool definitions at extension load. Their executors resolve
|
|
51
|
+
// the session-scoped manager lazily, so schemas never arrive late or reorder
|
|
52
|
+
// the provider tool array during session_start.
|
|
53
|
+
registerRalphTools(pi, (ctx) => getManager(ctx, pi));
|
|
48
54
|
|
|
49
55
|
// Register commands
|
|
50
56
|
registerCommands(pi);
|
|
@@ -157,20 +163,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
157
163
|
const state = mgr.loadState(currentLoop);
|
|
158
164
|
if (!state || state.status !== "active") return;
|
|
159
165
|
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
let instructions = `You are in a Ralph loop working on: ${state.taskFile}\n`;
|
|
163
|
-
if (state.itemsPerIteration > 0) {
|
|
164
|
-
instructions += `- Work on ~${state.itemsPerIteration} items this iteration\n`;
|
|
165
|
-
}
|
|
166
|
-
instructions += `- Update the task file as you progress\n`;
|
|
167
|
-
instructions += `- When FULLY COMPLETE: ${RALPH_COMPLETE_MARKER}\n`;
|
|
168
|
-
instructions += `- Otherwise, call ralph_done tool to proceed to next iteration`;
|
|
166
|
+
const content = buildRalphLoopReminder(state);
|
|
167
|
+
if (latestRalphReminder(ctx) === content) return;
|
|
169
168
|
|
|
170
169
|
return {
|
|
171
170
|
message: {
|
|
172
|
-
customType:
|
|
173
|
-
content
|
|
171
|
+
customType: RALPH_REMINDER_TYPE,
|
|
172
|
+
content,
|
|
174
173
|
display: false,
|
|
175
174
|
},
|
|
176
175
|
};
|
|
@@ -187,11 +186,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
187
186
|
manager = null;
|
|
188
187
|
});
|
|
189
188
|
|
|
190
|
-
// Register tools after manager setup
|
|
191
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
192
|
-
const mgr = getManager(ctx, pi);
|
|
193
|
-
registerRalphTools(pi, mgr);
|
|
194
|
-
});
|
|
195
189
|
}
|
|
196
190
|
|
|
197
191
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/ralph",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "Long-running iterative development loops for Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -26,9 +26,12 @@
|
|
|
26
26
|
"publishConfig": {
|
|
27
27
|
"access": "public"
|
|
28
28
|
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "npx tsx --test reminder.test.ts"
|
|
31
|
+
},
|
|
29
32
|
"dependencies": {
|
|
30
|
-
"@pi-unipi/core": "2.
|
|
31
|
-
"@pi-unipi/info-screen": "2.
|
|
33
|
+
"@pi-unipi/core": "2.6.0",
|
|
34
|
+
"@pi-unipi/info-screen": "2.6.0"
|
|
32
35
|
},
|
|
33
36
|
"peerDependencies": {
|
|
34
37
|
"@earendil-works/pi-ai": "^0.80.0",
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { RALPH_COMPLETE_MARKER } from "@pi-unipi/core";
|
|
3
|
+
|
|
4
|
+
export const RALPH_REMINDER_TYPE = "unipi-ralph-loop-reminder";
|
|
5
|
+
|
|
6
|
+
export interface RalphReminderInput {
|
|
7
|
+
name: string;
|
|
8
|
+
iteration: number;
|
|
9
|
+
maxIterations: number;
|
|
10
|
+
taskFile: string;
|
|
11
|
+
itemsPerIteration: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Build the exact deterministic hidden reminder used by the live hook. */
|
|
15
|
+
export function buildRalphLoopReminder(state: RalphReminderInput): string {
|
|
16
|
+
const iterStr = `${state.iteration}${state.maxIterations > 0 ? `/${state.maxIterations}` : ""}`;
|
|
17
|
+
let instructions = "This snapshot supersedes all earlier Ralph loop reminders.\n";
|
|
18
|
+
instructions += `You are in a Ralph loop working on: ${state.taskFile}\n`;
|
|
19
|
+
if (state.itemsPerIteration > 0) {
|
|
20
|
+
instructions += `- Work on ~${state.itemsPerIteration} items this iteration\n`;
|
|
21
|
+
}
|
|
22
|
+
instructions += "- Update the task file as you progress\n";
|
|
23
|
+
instructions += `- When FULLY COMPLETE: ${RALPH_COMPLETE_MARKER}\n`;
|
|
24
|
+
instructions += "- Otherwise, call ralph_done tool to proceed to next iteration";
|
|
25
|
+
return `[RALPH LOOP - ${state.name} - Iteration ${iterStr}]\n\n${instructions}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function latestRalphReminder(ctx: Pick<ExtensionContext, "sessionManager">): string | null {
|
|
29
|
+
const branch = ctx.sessionManager.getBranch() as SessionEntry[];
|
|
30
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
31
|
+
const entry = branch[index];
|
|
32
|
+
if (entry.type === "custom_message" && entry.customType === RALPH_REMINDER_TYPE) {
|
|
33
|
+
return typeof entry.content === "string" ? entry.content : null;
|
|
34
|
+
}
|
|
35
|
+
// A compacted summary may contain the old reminder but no longer retains a
|
|
36
|
+
// dedicated custom entry. Inject the current snapshot once in the new epoch.
|
|
37
|
+
if (entry.type === "compaction") break;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
package/packages/ralph/tools.ts
CHANGED
|
@@ -9,10 +9,12 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
9
9
|
import { RALPH_COMPLETE_MARKER, RALPH_DEFAULTS, RALPH_TOOLS } from "@pi-unipi/core";
|
|
10
10
|
import { RalphLoopManager, DEFAULT_REFLECT_INSTRUCTIONS } from "./ralph-loop.js";
|
|
11
11
|
|
|
12
|
+
type ManagerProvider = (ctx: ExtensionContext) => RalphLoopManager;
|
|
13
|
+
|
|
12
14
|
/**
|
|
13
15
|
* Register ralph_start and ralph_done tools.
|
|
14
16
|
*/
|
|
15
|
-
export function registerRalphTools(pi: ExtensionAPI,
|
|
17
|
+
export function registerRalphTools(pi: ExtensionAPI, getManager: ManagerProvider): void {
|
|
16
18
|
// --- ralph_start tool ---
|
|
17
19
|
pi.registerTool({
|
|
18
20
|
name: RALPH_TOOLS.START,
|
|
@@ -44,6 +46,7 @@ export function registerRalphTools(pi: ExtensionAPI, manager: RalphLoopManager):
|
|
|
44
46
|
),
|
|
45
47
|
}),
|
|
46
48
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
49
|
+
const manager = getManager(ctx);
|
|
47
50
|
const taskFile = `.unipi/ralph/${params.name.replace(/[^a-zA-Z0-9_-]/g, "_")}.md`;
|
|
48
51
|
|
|
49
52
|
if (manager.loadState(params.name)?.status === "active") {
|
|
@@ -92,6 +95,7 @@ export function registerRalphTools(pi: ExtensionAPI, manager: RalphLoopManager):
|
|
|
92
95
|
],
|
|
93
96
|
parameters: Type.Object({}),
|
|
94
97
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
98
|
+
const manager = getManager(ctx);
|
|
95
99
|
if (!manager.getCurrentLoop()) {
|
|
96
100
|
return {
|
|
97
101
|
content: [{ type: "text", text: "No active Ralph loop." }],
|
|
@@ -69,6 +69,8 @@ spawn_helper(
|
|
|
69
69
|
get_helper_result(agent_id: "helper_abc123")
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
+
Foreground and retrieved background results have a hard 64 KiB model-visible ceiling. For raw results up to 16 MiB, larger output includes a bounded head/tail preview and a path to the complete private mode-0600 artifact under a mode-0700 `~/.unipi/tool-results/` directory. Use `read` with offset/limit to inspect only the needed region. Repeated retrieval reuses the same artifact. Results above the safety cap or artifact-write failures still return a preview with an explicit non-retention warning.
|
|
73
|
+
|
|
72
74
|
## Custom Agent Types
|
|
73
75
|
|
|
74
76
|
Create markdown files defining agent behavior:
|
|
@@ -13,6 +13,10 @@ import { resolveModel, type ModelRegistry } from "./model-resolver.js";
|
|
|
13
13
|
import type { AgentRecord, AgentConfig, AgentType, ThinkingLevel, SubagentsConfig } from "./types.js";
|
|
14
14
|
import { BUILTIN_CONFIGS } from "./types.js";
|
|
15
15
|
import { loadCustomAgents } from "./custom-agents.js";
|
|
16
|
+
|
|
17
|
+
function compareCodeUnits(a: string, b: string): number {
|
|
18
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
19
|
+
}
|
|
16
20
|
import { FileLock } from "./file-lock.js";
|
|
17
21
|
|
|
18
22
|
export type OnAgentComplete = (record: AgentRecord) => void;
|
|
@@ -88,7 +92,7 @@ export class AgentManager {
|
|
|
88
92
|
...Object.keys(BUILTIN_CONFIGS).filter((type) => type !== "name-gen"),
|
|
89
93
|
...this.customAgents.keys(),
|
|
90
94
|
...Object.keys(this.typeSettings),
|
|
91
|
-
])];
|
|
95
|
+
])].sort(compareCodeUnits);
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
/** A type is enabled only when both JSON config and agent frontmatter allow it. */
|
|
@@ -19,8 +19,8 @@ import {
|
|
|
19
19
|
} from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { BUILTIN_CONFIGS, type AgentConfig, type AgentType, type ThinkingLevel } from "./types.js";
|
|
21
21
|
|
|
22
|
-
/** Tools excluded from subagents to prevent nesting. */
|
|
23
|
-
const EXCLUDED_TOOL_NAMES = ["Agent", "get_result"];
|
|
22
|
+
/** Tools excluded from subagents to prevent nesting. Legacy names remain for older hosts. */
|
|
23
|
+
const EXCLUDED_TOOL_NAMES = ["spawn_helper", "get_helper_result", "Agent", "get_result"];
|
|
24
24
|
|
|
25
25
|
/** All known built-in tool names. */
|
|
26
26
|
const BUILTIN_TOOL_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { chmodSync, lstatSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
2
6
|
|
|
3
7
|
export const MODULES = { SUBAGENTS: "subagents" } as const;
|
|
4
8
|
|
|
@@ -24,6 +28,75 @@ export function emitEvent(
|
|
|
24
28
|
}
|
|
25
29
|
}
|
|
26
30
|
|
|
31
|
+
export interface BoundedHelperOutput {
|
|
32
|
+
text: string;
|
|
33
|
+
truncated: boolean;
|
|
34
|
+
originalBytes: number;
|
|
35
|
+
artifactPath?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Standalone-package twin of core's bounded-output policy. */
|
|
39
|
+
const MAX_RAW_HELPER_ARTIFACT_BYTES = 16 * 1024 * 1024;
|
|
40
|
+
|
|
41
|
+
export function boundHelperOutput(
|
|
42
|
+
text: string,
|
|
43
|
+
maxBytes = 64 * 1024,
|
|
44
|
+
existingArtifactPath?: string,
|
|
45
|
+
): BoundedHelperOutput {
|
|
46
|
+
const originalBytes = Buffer.byteLength(text, "utf8");
|
|
47
|
+
if (originalBytes <= maxBytes) return { text, truncated: false, originalBytes };
|
|
48
|
+
|
|
49
|
+
let artifactPath = existingArtifactPath;
|
|
50
|
+
let artifactWarning: string | undefined;
|
|
51
|
+
if (!artifactPath && originalBytes <= MAX_RAW_HELPER_ARTIFACT_BYTES) {
|
|
52
|
+
try {
|
|
53
|
+
const dir = join(homedir(), ".unipi", "tool-results");
|
|
54
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
55
|
+
let stat = lstatSync(dir);
|
|
56
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
57
|
+
throw new Error(`Refusing unsafe tool-result directory: ${dir}`);
|
|
58
|
+
}
|
|
59
|
+
if ((stat.mode & 0o077) !== 0) {
|
|
60
|
+
chmodSync(dir, 0o700);
|
|
61
|
+
stat = lstatSync(dir);
|
|
62
|
+
if ((stat.mode & 0o077) !== 0) throw new Error(`Refusing non-private tool-result directory: ${dir}`);
|
|
63
|
+
}
|
|
64
|
+
artifactPath = join(dir, `helper-${randomUUID()}.txt`);
|
|
65
|
+
writeFileSync(artifactPath, text, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
66
|
+
} catch (error) {
|
|
67
|
+
artifactWarning = `Full-output artifact unavailable: ${error instanceof Error ? error.message : String(error)}`;
|
|
68
|
+
artifactPath = undefined;
|
|
69
|
+
}
|
|
70
|
+
} else if (!artifactPath) {
|
|
71
|
+
artifactWarning = `Full output exceeded the ${MAX_RAW_HELPER_ARTIFACT_BYTES}-byte local artifact safety cap and was not retained.`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const marker = [
|
|
75
|
+
"",
|
|
76
|
+
"--- output bounded by UniPi ---",
|
|
77
|
+
artifactPath ? `Full output: ${artifactPath}` : artifactWarning!,
|
|
78
|
+
`Original size: ${originalBytes} bytes; model-visible ceiling: ${maxBytes} bytes.`,
|
|
79
|
+
...(artifactPath ? ["Use the read tool with offset/limit to inspect only the needed region."] : []),
|
|
80
|
+
].join("\n");
|
|
81
|
+
const markerBytes = Buffer.byteLength(marker, "utf8");
|
|
82
|
+
const contentBudget = Math.max(1, maxBytes - markerBytes - 80);
|
|
83
|
+
const bytes = Buffer.from(text, "utf8");
|
|
84
|
+
const headBytes = Math.ceil(contentBudget * 0.75);
|
|
85
|
+
const tailBytes = Math.max(0, contentBudget - headBytes);
|
|
86
|
+
const head = bytes.subarray(0, headBytes).toString("utf8").replace(/\uFFFD+$/u, "");
|
|
87
|
+
const tail = bytes.subarray(originalBytes - tailBytes).toString("utf8").replace(/^\uFFFD+/u, "");
|
|
88
|
+
let bounded = `${head}\n… ${Math.max(0, originalBytes - contentBudget)} bytes omitted …\n${tail}${marker}`;
|
|
89
|
+
if (Buffer.byteLength(bounded, "utf8") > maxBytes) {
|
|
90
|
+
bounded = Buffer.from(bounded, "utf8").subarray(0, maxBytes).toString("utf8").replace(/\uFFFD+$/u, "");
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
text: bounded,
|
|
94
|
+
truncated: true,
|
|
95
|
+
originalBytes,
|
|
96
|
+
artifactPath,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
27
100
|
export async function withHerdrBlocked<T>(
|
|
28
101
|
pi: Pick<ExtensionAPI, "events">,
|
|
29
102
|
label: string,
|
|
@@ -12,6 +12,10 @@ import { homedir } from "node:os";
|
|
|
12
12
|
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import type { AgentConfig } from "./types.js";
|
|
14
14
|
|
|
15
|
+
function compareCodeUnits(a: string, b: string): number {
|
|
16
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
15
19
|
/** Backup a corrupted file by renaming to .bak */
|
|
16
20
|
function backupCorrupted(filePath: string): void {
|
|
17
21
|
const backupPath = filePath + ".bak";
|
|
@@ -93,7 +97,9 @@ export function loadCustomAgents(cwd: string): Map<string, AgentConfig> {
|
|
|
93
97
|
// Load global agents first
|
|
94
98
|
const globalDir = getGlobalAgentsDir();
|
|
95
99
|
if (existsSync(globalDir)) {
|
|
96
|
-
const files = readdirSync(globalDir)
|
|
100
|
+
const files = readdirSync(globalDir)
|
|
101
|
+
.filter((f) => f.endsWith(".md"))
|
|
102
|
+
.sort(compareCodeUnits);
|
|
97
103
|
for (const file of files) {
|
|
98
104
|
const agent = loadAgentFromFile(join(globalDir, file), "global");
|
|
99
105
|
if (agent) {
|
|
@@ -105,7 +111,9 @@ export function loadCustomAgents(cwd: string): Map<string, AgentConfig> {
|
|
|
105
111
|
// Load project agents (overrides global)
|
|
106
112
|
const projectDir = getProjectAgentsDir(cwd);
|
|
107
113
|
if (existsSync(projectDir)) {
|
|
108
|
-
const files = readdirSync(projectDir)
|
|
114
|
+
const files = readdirSync(projectDir)
|
|
115
|
+
.filter((f) => f.endsWith(".md"))
|
|
116
|
+
.sort(compareCodeUnits);
|
|
109
117
|
for (const file of files) {
|
|
110
118
|
const agent = loadAgentFromFile(join(projectDir, file), "project");
|
|
111
119
|
if (agent) {
|
|
@@ -12,7 +12,7 @@ import { Type } from "typebox";
|
|
|
12
12
|
import { existsSync, readdirSync } from "node:fs";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
|
-
import { emitEvent, MODULES, UNIPI_EVENTS, withHerdrBlocked, type UnipiBadgeGenerateRequestEvent } from "./core-compat.js";
|
|
15
|
+
import { boundHelperOutput, emitEvent, MODULES, UNIPI_EVENTS, withHerdrBlocked, type UnipiBadgeGenerateRequestEvent } from "./core-compat.js";
|
|
16
16
|
import { AgentManager } from "./agent-manager.js";
|
|
17
17
|
import { initConfig } from "./config.js";
|
|
18
18
|
import { type AgentActivity, type NotificationDetails, BUILTIN_TYPES } from "./types.js";
|
|
@@ -756,9 +756,11 @@ Guidelines:
|
|
|
756
756
|
});
|
|
757
757
|
}
|
|
758
758
|
|
|
759
|
+
const output = boundHelperOutput(record.result?.trim() || "No output.");
|
|
760
|
+
record.resultArtifactPath = output.artifactPath;
|
|
759
761
|
return textResult(
|
|
760
762
|
`Agent completed in ${(durationMs / 1000).toFixed(1)}s (${record.toolUses} tool uses${tokenText ? `, ${tokenText} tokens` : ""}).\n\n` +
|
|
761
|
-
|
|
763
|
+
output.text,
|
|
762
764
|
{
|
|
763
765
|
status: "completed",
|
|
764
766
|
toolUses: record.toolUses,
|
|
@@ -766,6 +768,9 @@ Guidelines:
|
|
|
766
768
|
durationMs,
|
|
767
769
|
turnCount: fgState.turnCount,
|
|
768
770
|
maxTurns: fgState.maxTurns,
|
|
771
|
+
truncated: output.truncated,
|
|
772
|
+
originalBytes: output.originalBytes,
|
|
773
|
+
artifactPath: output.artifactPath,
|
|
769
774
|
},
|
|
770
775
|
);
|
|
771
776
|
},
|
|
@@ -851,7 +856,13 @@ Guidelines:
|
|
|
851
856
|
} else if (record.status === "error") {
|
|
852
857
|
output += `Error: ${record.error}`;
|
|
853
858
|
} else {
|
|
854
|
-
|
|
859
|
+
const bounded = boundHelperOutput(
|
|
860
|
+
record.result?.trim() || "No output.",
|
|
861
|
+
64 * 1024,
|
|
862
|
+
record.resultArtifactPath,
|
|
863
|
+
);
|
|
864
|
+
record.resultArtifactPath = bounded.artifactPath;
|
|
865
|
+
output += bounded.text;
|
|
855
866
|
}
|
|
856
867
|
|
|
857
868
|
if (record.status !== "running" && record.status !== "queued") {
|
|
@@ -90,6 +90,8 @@ export interface AgentRecord {
|
|
|
90
90
|
description: string;
|
|
91
91
|
status: "queued" | "running" | "completed" | "aborted" | "stopped" | "error";
|
|
92
92
|
result?: string;
|
|
93
|
+
/** Private artifact holding the complete result when model-visible output is bounded. */
|
|
94
|
+
resultArtifactPath?: string;
|
|
93
95
|
error?: string;
|
|
94
96
|
toolUses: number;
|
|
95
97
|
startedAt: number;
|