@pstdio/pocketcoder-remote 0.2.0 → 0.2.1
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/dist/bin.js +17 -4
- package/dist/extension.js +17366 -0
- package/package.json +3 -5
- package/src/agentapi-events.ts +0 -104
- package/src/attachments.ts +0 -158
- package/src/bin.ts +0 -26
- package/src/client.ts +0 -344
- package/src/commands.ts +0 -149
- package/src/control-plane.ts +0 -30
- package/src/environment.ts +0 -19
- package/src/extension.ts +0 -200
- package/src/history.ts +0 -112
- package/src/launch.ts +0 -85
- package/src/renderers.ts +0 -76
- package/src/response-stream.ts +0 -43
- package/src/session-target.ts +0 -66
- package/src/status.ts +0 -121
package/src/commands.ts
DELETED
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import type { ControlPlaneClient, WorkspaceSummary } from "./control-plane";
|
|
3
|
-
import { WorkspaceTerminalError } from "./control-plane";
|
|
4
|
-
import { relayTarget, type TargetRef } from "./session-target";
|
|
5
|
-
|
|
6
|
-
export interface CommandUi {
|
|
7
|
-
select(title: string, options: string[]): Promise<string | undefined>;
|
|
8
|
-
confirm(title: string, message: string): Promise<boolean>;
|
|
9
|
-
input(title: string, placeholder?: string): Promise<string | undefined>;
|
|
10
|
-
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
11
|
-
setWorkingMessage(message?: string): void;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface CommandContext {
|
|
15
|
-
hasUI: boolean;
|
|
16
|
-
ui: CommandUi;
|
|
17
|
-
newSession(): Promise<{ cancelled: boolean }>;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface CommandRegistrar {
|
|
21
|
-
registerCommand(
|
|
22
|
-
name: string,
|
|
23
|
-
options: {
|
|
24
|
-
description?: string;
|
|
25
|
-
handler: (args: string, ctx: CommandContext) => Promise<void>;
|
|
26
|
-
},
|
|
27
|
-
): void;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export interface WorkspaceCommandDeps {
|
|
31
|
-
targets: TargetRef;
|
|
32
|
-
controlPlane: ControlPlaneClient;
|
|
33
|
-
waitTimeoutMs?: number;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function workspaceLabel(workspace: WorkspaceSummary): string {
|
|
37
|
-
return `${workspace.external_id} · ${workspace.template.name} · ${workspace.id.slice(0, 8)}`;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async function switchToWorkspace(
|
|
41
|
-
deps: WorkspaceCommandDeps,
|
|
42
|
-
ctx: CommandContext,
|
|
43
|
-
workspaceId: string,
|
|
44
|
-
): Promise<void> {
|
|
45
|
-
const target = deps.targets.current;
|
|
46
|
-
if (target.mode === "direct") return;
|
|
47
|
-
deps.targets.set(relayTarget(target.baseUrl, target.key, workspaceId));
|
|
48
|
-
await ctx.newSession();
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function requireRelayCapable(deps: WorkspaceCommandDeps, ctx: CommandContext): boolean {
|
|
52
|
-
if (!ctx.hasUI) return false;
|
|
53
|
-
if (deps.targets.current.mode === "direct") {
|
|
54
|
-
ctx.ui.notify(
|
|
55
|
-
"workspace commands need a PocketCoder server (POCKETCODER_URL), not a direct AgentAPI URL",
|
|
56
|
-
"warning",
|
|
57
|
-
);
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
60
|
-
return true;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async function pickWorkspace(deps: WorkspaceCommandDeps, ctx: CommandContext): Promise<void> {
|
|
64
|
-
const workspaces = (await deps.controlPlane.workspaces.list({ state: "ready" })).items;
|
|
65
|
-
if (workspaces.length === 0) {
|
|
66
|
-
ctx.ui.notify("no ready workspaces; use /workspace-create", "info");
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
const labels = workspaces.map(workspaceLabel);
|
|
70
|
-
const selection = await ctx.ui.select("Switch workspace", labels);
|
|
71
|
-
if (selection === undefined) return;
|
|
72
|
-
const workspace = workspaces[labels.indexOf(selection)];
|
|
73
|
-
if (workspace) await switchToWorkspace(deps, ctx, workspace.id);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
async function createWorkspace(deps: WorkspaceCommandDeps, ctx: CommandContext): Promise<void> {
|
|
77
|
-
const templates = await deps.controlPlane.templates.list();
|
|
78
|
-
if (templates.length === 0) {
|
|
79
|
-
ctx.ui.notify("no templates available to this key", "warning");
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
const labels = templates.map((template) => `${template.name}@${template.version}`);
|
|
83
|
-
const selection = await ctx.ui.select("Create workspace from template", labels);
|
|
84
|
-
if (selection === undefined) return;
|
|
85
|
-
const template = templates[labels.indexOf(selection)];
|
|
86
|
-
if (!template) return;
|
|
87
|
-
const externalId =
|
|
88
|
-
(await ctx.ui.input("External id", `pi-${randomUUID()}`))?.trim() || `pi-${randomUUID()}`;
|
|
89
|
-
|
|
90
|
-
try {
|
|
91
|
-
ctx.ui.setWorkingMessage(`creating workspace ${externalId}`);
|
|
92
|
-
const created = await deps.controlPlane.workspaces.create({
|
|
93
|
-
externalId,
|
|
94
|
-
templateName: template.name,
|
|
95
|
-
});
|
|
96
|
-
const ready = await deps.controlPlane.workspaces.waitForReady(
|
|
97
|
-
created,
|
|
98
|
-
deps.waitTimeoutMs ?? 300_000,
|
|
99
|
-
{
|
|
100
|
-
onTick: (workspace) => ctx.ui.setWorkingMessage(`workspace ${workspace.state}`),
|
|
101
|
-
},
|
|
102
|
-
);
|
|
103
|
-
await switchToWorkspace(deps, ctx, ready.id);
|
|
104
|
-
} catch (error) {
|
|
105
|
-
const message =
|
|
106
|
-
error instanceof WorkspaceTerminalError
|
|
107
|
-
? error.message
|
|
108
|
-
: `workspace create failed: ${String(error)}`;
|
|
109
|
-
ctx.ui.notify(message, "error");
|
|
110
|
-
} finally {
|
|
111
|
-
ctx.ui.setWorkingMessage();
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
async function cancelWorkspace(deps: WorkspaceCommandDeps, ctx: CommandContext): Promise<void> {
|
|
116
|
-
const target = deps.targets.current;
|
|
117
|
-
if (target.mode !== "relay") {
|
|
118
|
-
ctx.ui.notify("no workspace attached", "info");
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
const confirmed = await ctx.ui.confirm(
|
|
122
|
-
"Cancel workspace",
|
|
123
|
-
`Cancel workspace ${target.workspaceId}? The remote agent stops and the workspace is discarded.`,
|
|
124
|
-
);
|
|
125
|
-
if (!confirmed) return;
|
|
126
|
-
await deps.controlPlane.workspaces.cancel(target.workspaceId);
|
|
127
|
-
ctx.ui.notify(`workspace ${target.workspaceId.slice(0, 8)} canceled`, "info");
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export function registerWorkspaceCommands(pi: CommandRegistrar, deps: WorkspaceCommandDeps): void {
|
|
131
|
-
pi.registerCommand("workspace", {
|
|
132
|
-
description: "Switch to another ready PocketCoder workspace",
|
|
133
|
-
handler: async (_args, ctx) => {
|
|
134
|
-
if (requireRelayCapable(deps, ctx)) await pickWorkspace(deps, ctx);
|
|
135
|
-
},
|
|
136
|
-
});
|
|
137
|
-
pi.registerCommand("workspace-create", {
|
|
138
|
-
description: "Create a PocketCoder workspace from a template and switch to it",
|
|
139
|
-
handler: async (_args, ctx) => {
|
|
140
|
-
if (requireRelayCapable(deps, ctx)) await createWorkspace(deps, ctx);
|
|
141
|
-
},
|
|
142
|
-
});
|
|
143
|
-
pi.registerCommand("workspace-cancel", {
|
|
144
|
-
description: "Cancel the current PocketCoder workspace",
|
|
145
|
-
handler: async (_args, ctx) => {
|
|
146
|
-
if (requireRelayCapable(deps, ctx)) await cancelWorkspace(deps, ctx);
|
|
147
|
-
},
|
|
148
|
-
});
|
|
149
|
-
}
|
package/src/control-plane.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
ConversationGoneError,
|
|
3
|
-
type ConversationMessage,
|
|
4
|
-
PocketCoderClient,
|
|
5
|
-
PocketCoderError,
|
|
6
|
-
TERMINAL_WORKSPACE_STATES,
|
|
7
|
-
type WorkspaceSummary,
|
|
8
|
-
WorkspaceTerminalError,
|
|
9
|
-
} from "@pstdio/pocketcoder-client";
|
|
10
|
-
|
|
11
|
-
export {
|
|
12
|
-
ConversationGoneError,
|
|
13
|
-
type ConversationMessage,
|
|
14
|
-
PocketCoderError as ControlPlaneError,
|
|
15
|
-
TERMINAL_WORKSPACE_STATES,
|
|
16
|
-
type WorkspaceSummary,
|
|
17
|
-
WorkspaceTerminalError,
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export interface ControlPlaneConfig {
|
|
21
|
-
baseUrl: string;
|
|
22
|
-
key: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** @internal The remote UI keeps its historical config vocabulary at its boundary. */
|
|
26
|
-
export class ControlPlaneClient extends PocketCoderClient {
|
|
27
|
-
constructor(config: ControlPlaneConfig, fetchImpl: typeof fetch = fetch) {
|
|
28
|
-
super({ baseUrl: config.baseUrl, apiKey: config.key }, fetchImpl);
|
|
29
|
-
}
|
|
30
|
-
}
|
package/src/environment.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
export function serviceUrlFromEnvironment(env: NodeJS.ProcessEnv = process.env): {
|
|
2
|
-
serviceUrl: string;
|
|
3
|
-
key: string;
|
|
4
|
-
} {
|
|
5
|
-
const directUrl = env.POCKETCODER_AGENTAPI_URL;
|
|
6
|
-
const key = env.POCKETCODER_KEY;
|
|
7
|
-
if (!key) throw new Error("POCKETCODER_KEY is required");
|
|
8
|
-
if (directUrl) return { serviceUrl: directUrl, key };
|
|
9
|
-
|
|
10
|
-
const baseUrl = (env.POCKETCODER_URL ?? "http://127.0.0.1:7080").replace(/\/$/, "");
|
|
11
|
-
const workspaceId = env.POCKETCODER_WORKSPACE_ID;
|
|
12
|
-
if (!workspaceId) {
|
|
13
|
-
throw new Error("POCKETCODER_WORKSPACE_ID or POCKETCODER_AGENTAPI_URL is required");
|
|
14
|
-
}
|
|
15
|
-
return {
|
|
16
|
-
serviceUrl: `${baseUrl}/v1/workspaces/${encodeURIComponent(workspaceId)}/agent`,
|
|
17
|
-
key,
|
|
18
|
-
};
|
|
19
|
-
}
|
package/src/extension.ts
DELETED
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type Api,
|
|
3
|
-
type AssistantMessage,
|
|
4
|
-
type AssistantMessageEventStream,
|
|
5
|
-
type Context,
|
|
6
|
-
createAssistantMessageEventStream,
|
|
7
|
-
type Model,
|
|
8
|
-
} from "@earendil-works/pi-ai";
|
|
9
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import {
|
|
11
|
-
collectTurnFiles,
|
|
12
|
-
DIRECT_MODE_ATTACHMENT_ERROR,
|
|
13
|
-
registerAttachCommand,
|
|
14
|
-
uploadTurnFiles,
|
|
15
|
-
userTextOf,
|
|
16
|
-
} from "./attachments";
|
|
17
|
-
import { RemoteAgentClient } from "./client";
|
|
18
|
-
import { registerWorkspaceCommands } from "./commands";
|
|
19
|
-
import { ControlPlaneClient } from "./control-plane";
|
|
20
|
-
import { replayHistory } from "./history";
|
|
21
|
-
import { registerConversationRenderers } from "./renderers";
|
|
22
|
-
import { emitRemoteResponse } from "./response-stream";
|
|
23
|
-
import { relayTarget, TargetRef, targetFromEnvironment } from "./session-target";
|
|
24
|
-
import { STATUS_KEY, StatusPoller } from "./status";
|
|
25
|
-
|
|
26
|
-
const PROVIDER = "pocketcoder-agentapi";
|
|
27
|
-
const MODEL = "remote-agent";
|
|
28
|
-
|
|
29
|
-
function emptyUsage(): AssistantMessage["usage"] {
|
|
30
|
-
return {
|
|
31
|
-
input: 0,
|
|
32
|
-
output: 0,
|
|
33
|
-
cacheRead: 0,
|
|
34
|
-
cacheWrite: 0,
|
|
35
|
-
totalTokens: 0,
|
|
36
|
-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function remoteStream(
|
|
41
|
-
targets: TargetRef,
|
|
42
|
-
controlPlane: ControlPlaneClient | undefined,
|
|
43
|
-
attachmentQueue: string[],
|
|
44
|
-
model: Model<Api>,
|
|
45
|
-
context: Context,
|
|
46
|
-
signal?: AbortSignal,
|
|
47
|
-
onOutputStart?: () => void,
|
|
48
|
-
): AssistantMessageEventStream {
|
|
49
|
-
const stream = createAssistantMessageEventStream();
|
|
50
|
-
const output: AssistantMessage = {
|
|
51
|
-
role: "assistant",
|
|
52
|
-
content: [],
|
|
53
|
-
api: model.api,
|
|
54
|
-
provider: model.provider,
|
|
55
|
-
model: model.id,
|
|
56
|
-
usage: emptyUsage(),
|
|
57
|
-
stopReason: "pending",
|
|
58
|
-
timestamp: Date.now(),
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
void (async () => {
|
|
62
|
-
stream.push({ type: "start", partial: output });
|
|
63
|
-
try {
|
|
64
|
-
const target = targets.current;
|
|
65
|
-
if (target.mode === "unset") {
|
|
66
|
-
throw new Error("no workspace attached; run /workspace or /workspace-create first");
|
|
67
|
-
}
|
|
68
|
-
const files = collectTurnFiles(context, attachmentQueue);
|
|
69
|
-
if (files.length > 0 && (target.mode !== "relay" || !controlPlane)) {
|
|
70
|
-
throw new Error(DIRECT_MODE_ATTACHMENT_ERROR);
|
|
71
|
-
}
|
|
72
|
-
let attachmentIds: string[] = [];
|
|
73
|
-
if (files.length > 0 && target.mode === "relay" && controlPlane) {
|
|
74
|
-
attachmentIds = await uploadTurnFiles(controlPlane, target.workspaceId, files);
|
|
75
|
-
attachmentQueue.length = 0;
|
|
76
|
-
}
|
|
77
|
-
const client = new RemoteAgentClient({ serviceUrl: target.serviceUrl, key: target.key });
|
|
78
|
-
await emitRemoteResponse(
|
|
79
|
-
stream,
|
|
80
|
-
output,
|
|
81
|
-
async (onSnapshot) =>
|
|
82
|
-
await client.send(userTextOf(context), signal, attachmentIds, onSnapshot),
|
|
83
|
-
onOutputStart,
|
|
84
|
-
);
|
|
85
|
-
} catch (error) {
|
|
86
|
-
output.stopReason = signal?.aborted ? "aborted" : "error";
|
|
87
|
-
output.errorMessage = error instanceof Error ? error.message : String(error);
|
|
88
|
-
stream.push({ type: "error", reason: output.stopReason, error: output });
|
|
89
|
-
} finally {
|
|
90
|
-
stream.end();
|
|
91
|
-
}
|
|
92
|
-
})();
|
|
93
|
-
|
|
94
|
-
return stream;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async function pickInitialWorkspace(
|
|
98
|
-
controlPlane: ControlPlaneClient,
|
|
99
|
-
targets: TargetRef,
|
|
100
|
-
context: ExtensionContext,
|
|
101
|
-
): Promise<void> {
|
|
102
|
-
const target = targets.current;
|
|
103
|
-
if (target.mode !== "unset" || !context.hasUI) return;
|
|
104
|
-
const workspaces = (await controlPlane.workspaces.list({ state: "ready" })).items;
|
|
105
|
-
if (workspaces.length === 0) {
|
|
106
|
-
context.ui.notify("no ready workspaces; run /workspace-create", "warning");
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
const labels = workspaces.map(
|
|
110
|
-
(workspace) =>
|
|
111
|
-
`${workspace.external_id} · ${workspace.template.name} · ${workspace.id.slice(0, 8)}`,
|
|
112
|
-
);
|
|
113
|
-
const selection = await context.ui.select("Attach to workspace", labels);
|
|
114
|
-
if (selection === undefined) {
|
|
115
|
-
context.ui.notify("no workspace attached; run /workspace to attach", "warning");
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
const workspace = workspaces[labels.indexOf(selection)];
|
|
119
|
-
if (workspace) targets.set(relayTarget(target.baseUrl, target.key, workspace.id));
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export default function (pi: ExtensionAPI): void {
|
|
123
|
-
const targets = new TargetRef(targetFromEnvironment());
|
|
124
|
-
const initial = targets.current;
|
|
125
|
-
const controlPlane =
|
|
126
|
-
initial.mode === "direct"
|
|
127
|
-
? undefined
|
|
128
|
-
: new ControlPlaneClient({ baseUrl: initial.baseUrl, key: initial.key });
|
|
129
|
-
const attachmentQueue: string[] = [];
|
|
130
|
-
let poller: StatusPoller | undefined;
|
|
131
|
-
let activeContext: ExtensionContext | undefined;
|
|
132
|
-
|
|
133
|
-
pi.registerProvider(PROVIDER, {
|
|
134
|
-
name: "PocketCoder remote agent",
|
|
135
|
-
baseUrl: initial.mode === "direct" ? initial.serviceUrl : initial.baseUrl,
|
|
136
|
-
apiKey: "local-ui",
|
|
137
|
-
api: "openai-completions",
|
|
138
|
-
models: [
|
|
139
|
-
{
|
|
140
|
-
id: MODEL,
|
|
141
|
-
name: "PocketCoder remote agent",
|
|
142
|
-
reasoning: false,
|
|
143
|
-
input: ["text", "image"],
|
|
144
|
-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
145
|
-
contextWindow: 1_000_000,
|
|
146
|
-
maxTokens: 100_000,
|
|
147
|
-
},
|
|
148
|
-
],
|
|
149
|
-
streamSimple: (model, context, options) =>
|
|
150
|
-
remoteStream(targets, controlPlane, attachmentQueue, model, context, options?.signal, () =>
|
|
151
|
-
activeContext?.ui.setWorkingVisible(false),
|
|
152
|
-
),
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
registerConversationRenderers(pi);
|
|
156
|
-
registerAttachCommand(pi, { targets, queue: attachmentQueue });
|
|
157
|
-
if (controlPlane) registerWorkspaceCommands(pi, { targets, controlPlane });
|
|
158
|
-
|
|
159
|
-
pi.on("session_start", async (_event, context) => {
|
|
160
|
-
activeContext = context;
|
|
161
|
-
pi.setActiveTools([]);
|
|
162
|
-
await poller?.stop();
|
|
163
|
-
poller = undefined;
|
|
164
|
-
|
|
165
|
-
if (controlPlane) await pickInitialWorkspace(controlPlane, targets, context);
|
|
166
|
-
const target = targets.current;
|
|
167
|
-
if (target.mode === "direct") {
|
|
168
|
-
context.ui.setStatus(STATUS_KEY, "direct agentapi");
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
if (target.mode === "unset") {
|
|
172
|
-
context.ui.setStatus(STATUS_KEY, "no workspace");
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
context.ui.setStatus(STATUS_KEY, `ws ${target.workspaceId.slice(0, 8)}`);
|
|
176
|
-
if (!controlPlane) return;
|
|
177
|
-
try {
|
|
178
|
-
const workspace = await controlPlane.workspaces.get(target.workspaceId);
|
|
179
|
-
await replayHistory(pi, controlPlane, target.workspaceId);
|
|
180
|
-
poller = new StatusPoller(controlPlane, workspace, context.ui);
|
|
181
|
-
poller.start();
|
|
182
|
-
} catch (error) {
|
|
183
|
-
context.ui.notify(
|
|
184
|
-
`could not load workspace history: ${error instanceof Error ? error.message : String(error)}`,
|
|
185
|
-
"warning",
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
pi.on("turn_start", async () => poller?.pause());
|
|
191
|
-
pi.on("turn_end", async (_event, context) => {
|
|
192
|
-
context.ui.setWorkingVisible(true);
|
|
193
|
-
poller?.resume();
|
|
194
|
-
});
|
|
195
|
-
pi.on("session_shutdown", async () => {
|
|
196
|
-
await poller?.stop();
|
|
197
|
-
poller = undefined;
|
|
198
|
-
activeContext = undefined;
|
|
199
|
-
});
|
|
200
|
-
}
|
package/src/history.ts
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import type { ControlPlaneClient } from "./control-plane";
|
|
2
|
-
import { ConversationGoneError } from "./control-plane";
|
|
3
|
-
import {
|
|
4
|
-
type ConversationEntryData,
|
|
5
|
-
HISTORY_ENTRY_TYPE,
|
|
6
|
-
type HistoryNoticeData,
|
|
7
|
-
NOTICE_ENTRY_TYPE,
|
|
8
|
-
} from "./renderers";
|
|
9
|
-
|
|
10
|
-
export interface ReplayOptions {
|
|
11
|
-
pageLimit?: number;
|
|
12
|
-
maxMessages?: number;
|
|
13
|
-
maxPages?: number;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface ReplayOutcome {
|
|
17
|
-
replayed: number;
|
|
18
|
-
total: number;
|
|
19
|
-
truncated: boolean;
|
|
20
|
-
gone: boolean;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface EntrySink {
|
|
24
|
-
appendEntry<T = unknown>(customType: string, data?: T): void;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Replays the workspace's durable conversation into the local session as
|
|
29
|
-
* custom entries. The server transcript is the source of truth: this runs on
|
|
30
|
-
* every attach, and entries never participate in LLM context.
|
|
31
|
-
*/
|
|
32
|
-
interface TranscriptTail {
|
|
33
|
-
tail: ConversationEntryData[];
|
|
34
|
-
total: number;
|
|
35
|
-
pages: number;
|
|
36
|
-
exhaustedPages: boolean;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async function collectTranscriptTail(
|
|
40
|
-
controlPlane: ControlPlaneClient,
|
|
41
|
-
workspaceId: string,
|
|
42
|
-
options: Required<ReplayOptions>,
|
|
43
|
-
): Promise<TranscriptTail> {
|
|
44
|
-
const tail: ConversationEntryData[] = [];
|
|
45
|
-
let total = 0;
|
|
46
|
-
let pages = 0;
|
|
47
|
-
let cursor: string | undefined;
|
|
48
|
-
while (pages < options.maxPages) {
|
|
49
|
-
const page = await controlPlane.conversations.list(workspaceId, {
|
|
50
|
-
cursor,
|
|
51
|
-
limit: options.pageLimit,
|
|
52
|
-
});
|
|
53
|
-
pages += 1;
|
|
54
|
-
for (const message of page.items) {
|
|
55
|
-
total += 1;
|
|
56
|
-
tail.push({
|
|
57
|
-
role: message.role,
|
|
58
|
-
content: message.content,
|
|
59
|
-
seq: message.seq,
|
|
60
|
-
occurred_at: message.occurred_at,
|
|
61
|
-
kind: message.metadata?.kind,
|
|
62
|
-
metadata: message.metadata,
|
|
63
|
-
});
|
|
64
|
-
if (tail.length > options.maxMessages) tail.shift();
|
|
65
|
-
}
|
|
66
|
-
if (page.nextCursor === null) return { tail, total, pages, exhaustedPages: false };
|
|
67
|
-
cursor = page.nextCursor;
|
|
68
|
-
}
|
|
69
|
-
return { tail, total, pages, exhaustedPages: true };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export async function replayHistory(
|
|
73
|
-
pi: EntrySink,
|
|
74
|
-
controlPlane: ControlPlaneClient,
|
|
75
|
-
workspaceId: string,
|
|
76
|
-
options: ReplayOptions = {},
|
|
77
|
-
): Promise<ReplayOutcome> {
|
|
78
|
-
let transcript: TranscriptTail;
|
|
79
|
-
try {
|
|
80
|
-
transcript = await collectTranscriptTail(controlPlane, workspaceId, {
|
|
81
|
-
pageLimit: options.pageLimit ?? 200,
|
|
82
|
-
maxMessages: options.maxMessages ?? 1000,
|
|
83
|
-
maxPages: options.maxPages ?? 50,
|
|
84
|
-
});
|
|
85
|
-
} catch (error) {
|
|
86
|
-
if (error instanceof ConversationGoneError) {
|
|
87
|
-
pi.appendEntry<HistoryNoticeData>(NOTICE_ENTRY_TYPE, {
|
|
88
|
-
text:
|
|
89
|
-
error.code === "conversation.deleted"
|
|
90
|
-
? "conversation history was deleted"
|
|
91
|
-
: "conversation history has expired",
|
|
92
|
-
level: "warning",
|
|
93
|
-
});
|
|
94
|
-
return { replayed: 0, total: 0, truncated: false, gone: true };
|
|
95
|
-
}
|
|
96
|
-
throw error;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const { tail, total, pages, exhaustedPages } = transcript;
|
|
100
|
-
const truncated = total > tail.length || exhaustedPages;
|
|
101
|
-
if (truncated) {
|
|
102
|
-
pi.appendEntry<HistoryNoticeData>(NOTICE_ENTRY_TYPE, {
|
|
103
|
-
text: exhaustedPages
|
|
104
|
-
? `history partially replayed (stopped after ${pages} pages)`
|
|
105
|
-
: `showing last ${tail.length} of ${total} messages`,
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
for (const message of tail) {
|
|
109
|
-
pi.appendEntry<ConversationEntryData>(HISTORY_ENTRY_TYPE, message);
|
|
110
|
-
}
|
|
111
|
-
return { replayed: tail.length, total, truncated, gone: false };
|
|
112
|
-
}
|
package/src/launch.ts
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
|
-
import { dirname, resolve, sep } from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
|
|
5
|
-
export interface PiInvocation {
|
|
6
|
-
command: string;
|
|
7
|
-
args: string[];
|
|
8
|
-
env: NodeJS.ProcessEnv;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export interface ResolveOptions {
|
|
12
|
-
env?: NodeJS.ProcessEnv;
|
|
13
|
-
argv?: string[];
|
|
14
|
-
execPath?: string;
|
|
15
|
-
resolvePath?: (specifier: string) => string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const PI_FLAGS = [
|
|
19
|
-
"--provider",
|
|
20
|
-
"pocketcoder-agentapi",
|
|
21
|
-
"--model",
|
|
22
|
-
"remote-agent",
|
|
23
|
-
"--api-key",
|
|
24
|
-
"local-ui",
|
|
25
|
-
"--no-tools",
|
|
26
|
-
"--no-extensions",
|
|
27
|
-
"--no-skills",
|
|
28
|
-
"--no-context-files",
|
|
29
|
-
"--no-prompt-templates",
|
|
30
|
-
"--no-session",
|
|
31
|
-
"--offline",
|
|
32
|
-
];
|
|
33
|
-
|
|
34
|
-
function piBinPath(resolvePath: (specifier: string) => string): string {
|
|
35
|
-
// The package's exports map hides ./package.json, so locate the package
|
|
36
|
-
// root from its resolved main entry instead.
|
|
37
|
-
const entry = resolvePath("@earendil-works/pi-coding-agent");
|
|
38
|
-
const marker = `${sep}pi-coding-agent${sep}`;
|
|
39
|
-
const index = entry.lastIndexOf(marker);
|
|
40
|
-
if (index === -1) {
|
|
41
|
-
throw new Error(`could not locate @earendil-works/pi-coding-agent from ${entry}`);
|
|
42
|
-
}
|
|
43
|
-
const packageRoot = entry.slice(0, index + marker.length - 1);
|
|
44
|
-
const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")) as {
|
|
45
|
-
bin?: Record<string, string>;
|
|
46
|
-
};
|
|
47
|
-
const bin = manifest.bin?.pi;
|
|
48
|
-
if (!bin) throw new Error("@earendil-works/pi-coding-agent does not declare a pi bin");
|
|
49
|
-
return resolve(packageRoot, bin);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function extensionPath(): string {
|
|
53
|
-
// Works from both src/bin.ts (dev) and dist/bin.js (published): the
|
|
54
|
-
// package root is one directory up in both layouts.
|
|
55
|
-
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
56
|
-
return resolve(packageRoot, "src/extension.ts");
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function resolvePiInvocation(options: ResolveOptions = {}): PiInvocation {
|
|
60
|
-
const env = options.env ?? process.env;
|
|
61
|
-
if (!env.POCKETCODER_KEY) {
|
|
62
|
-
throw new Error(
|
|
63
|
-
"POCKETCODER_KEY is required. Issue a machine key with `pcd keys issue` and export it, along with POCKETCODER_URL for your PocketCoder server.",
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
// The pi package's exports map only defines the "import" condition, so
|
|
67
|
-
// resolution must go through ESM resolve, not createRequire.
|
|
68
|
-
const resolvePath =
|
|
69
|
-
options.resolvePath ?? ((specifier: string) => fileURLToPath(import.meta.resolve(specifier)));
|
|
70
|
-
const childEnv: NodeJS.ProcessEnv = { ...env };
|
|
71
|
-
// Pi must never talk to a model provider directly; every turn goes
|
|
72
|
-
// through the PocketCoder relay.
|
|
73
|
-
delete childEnv.OPENAI_API_KEY;
|
|
74
|
-
return {
|
|
75
|
-
command: options.execPath ?? process.execPath,
|
|
76
|
-
args: [
|
|
77
|
-
piBinPath(resolvePath),
|
|
78
|
-
...PI_FLAGS,
|
|
79
|
-
"--extension",
|
|
80
|
-
extensionPath(),
|
|
81
|
-
...(options.argv ?? []),
|
|
82
|
-
],
|
|
83
|
-
env: childEnv,
|
|
84
|
-
};
|
|
85
|
-
}
|
package/src/renderers.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
-
import { splitAttachmentManifest } from "@pstdio/pocketcoder-client";
|
|
4
|
-
|
|
5
|
-
export const HISTORY_ENTRY_TYPE = "pocketcoder-conversation";
|
|
6
|
-
export const NOTICE_ENTRY_TYPE = "pocketcoder-history-notice";
|
|
7
|
-
|
|
8
|
-
export interface ConversationEntryData {
|
|
9
|
-
role: string;
|
|
10
|
-
content: string;
|
|
11
|
-
seq: number;
|
|
12
|
-
occurred_at: string;
|
|
13
|
-
/** Reserved for future typed payloads (tool_use, permission_request, ...). */
|
|
14
|
-
kind?: string;
|
|
15
|
-
metadata?: Record<string, string>;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface HistoryNoticeData {
|
|
19
|
-
text: string;
|
|
20
|
-
level?: "info" | "warning";
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** The slice of Pi's Theme the formatters use; Theme satisfies it structurally. */
|
|
24
|
-
export interface ThemeLike {
|
|
25
|
-
fg(color: ThemeColor, text: string): string;
|
|
26
|
-
bold(text: string): string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function roleHeader(data: ConversationEntryData, theme: ThemeLike): string {
|
|
30
|
-
const time = data.occurred_at.replace("T", " ").replace(/\.\d+Z?$|Z$/, "");
|
|
31
|
-
return theme.fg("muted", `${data.role} · ${time}`);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// User messages may end in the generated attachment manifest; history shows
|
|
35
|
-
// the attachment names instead of the raw markup and managed paths.
|
|
36
|
-
function userBody(content: string, theme: ThemeLike): string {
|
|
37
|
-
const { text, attachments } = splitAttachmentManifest(content);
|
|
38
|
-
const body = theme.fg("userMessageText", text);
|
|
39
|
-
if (!attachments) return body;
|
|
40
|
-
const list = attachments
|
|
41
|
-
.map((attachment) => theme.fg("muted", `⌁ ${attachment.name} (${attachment.size_bytes} bytes)`))
|
|
42
|
-
.join("\n");
|
|
43
|
-
return `${body}\n${list}`;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function formatConversationMessage(data: ConversationEntryData, theme: ThemeLike): string {
|
|
47
|
-
switch (data.kind ?? "text") {
|
|
48
|
-
// Future typed kinds (tool_use, permission_request, ...) become new cases;
|
|
49
|
-
// unknown kinds deliberately fall through to the text rendering.
|
|
50
|
-
default: {
|
|
51
|
-
const header = roleHeader(data, theme);
|
|
52
|
-
const body =
|
|
53
|
-
data.role === "user"
|
|
54
|
-
? userBody(data.content, theme)
|
|
55
|
-
: data.role === "assistant"
|
|
56
|
-
? data.content
|
|
57
|
-
: theme.fg("dim", data.content);
|
|
58
|
-
return `${header}\n${body}`;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function formatHistoryNotice(data: HistoryNoticeData, theme: ThemeLike): string {
|
|
64
|
-
return theme.fg(data.level === "warning" ? "warning" : "muted", data.text);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function registerConversationRenderers(pi: ExtensionAPI): void {
|
|
68
|
-
pi.registerEntryRenderer<ConversationEntryData>(HISTORY_ENTRY_TYPE, (entry, _options, theme) => {
|
|
69
|
-
if (!entry.data) return undefined;
|
|
70
|
-
return new Text(formatConversationMessage(entry.data, theme), 1, 0);
|
|
71
|
-
});
|
|
72
|
-
pi.registerEntryRenderer<HistoryNoticeData>(NOTICE_ENTRY_TYPE, (entry, _options, theme) => {
|
|
73
|
-
if (!entry.data) return undefined;
|
|
74
|
-
return new Text(formatHistoryNotice(entry.data, theme), 1, 0);
|
|
75
|
-
});
|
|
76
|
-
}
|