@lattices/cli 0.3.0 → 0.4.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/README.md +85 -9
- package/app/Package.swift +8 -1
- package/app/Sources/AdvisorLearningStore.swift +90 -0
- package/app/Sources/AgentSession.swift +377 -0
- package/app/Sources/AppDelegate.swift +44 -12
- package/app/Sources/AppShellView.swift +81 -8
- package/app/Sources/AudioProvider.swift +386 -0
- package/app/Sources/CheatSheetHUD.swift +261 -19
- package/app/Sources/DaemonProtocol.swift +13 -0
- package/app/Sources/DaemonServer.swift +8 -0
- package/app/Sources/DesktopModel.swift +164 -5
- package/app/Sources/DesktopModelTypes.swift +2 -0
- package/app/Sources/DiagnosticLog.swift +104 -2
- package/app/Sources/EventBus.swift +1 -0
- package/app/Sources/HUDBottomBar.swift +279 -0
- package/app/Sources/HUDController.swift +1158 -0
- package/app/Sources/HUDLeftBar.swift +849 -0
- package/app/Sources/HUDMinimap.swift +179 -0
- package/app/Sources/HUDRightBar.swift +774 -0
- package/app/Sources/HUDState.swift +367 -0
- package/app/Sources/HUDTopBar.swift +243 -0
- package/app/Sources/HandsOffSession.swift +733 -0
- package/app/Sources/HomeDashboardView.swift +125 -0
- package/app/Sources/HotkeyManager.swift +2 -0
- package/app/Sources/HotkeyStore.swift +45 -9
- package/app/Sources/IntentEngine.swift +925 -0
- package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
- package/app/Sources/Intents/DistributeIntent.swift +56 -0
- package/app/Sources/Intents/FocusIntent.swift +69 -0
- package/app/Sources/Intents/HelpIntent.swift +41 -0
- package/app/Sources/Intents/KillIntent.swift +47 -0
- package/app/Sources/Intents/LatticeIntent.swift +78 -0
- package/app/Sources/Intents/LaunchIntent.swift +67 -0
- package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
- package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
- package/app/Sources/Intents/ScanIntent.swift +52 -0
- package/app/Sources/Intents/SearchIntent.swift +190 -0
- package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
- package/app/Sources/Intents/TileIntent.swift +61 -0
- package/app/Sources/LatticesApi.swift +1235 -30
- package/app/Sources/LauncherHUD.swift +348 -0
- package/app/Sources/MainView.swift +147 -44
- package/app/Sources/OcrModel.swift +34 -1
- package/app/Sources/OmniSearchState.swift +99 -102
- package/app/Sources/OnboardingView.swift +457 -0
- package/app/Sources/PermissionChecker.swift +2 -12
- package/app/Sources/PiChatDock.swift +454 -0
- package/app/Sources/PiChatSession.swift +815 -0
- package/app/Sources/PiWorkspaceView.swift +364 -0
- package/app/Sources/PlacementSpec.swift +195 -0
- package/app/Sources/Preferences.swift +59 -0
- package/app/Sources/ProjectScanner.swift +1 -1
- package/app/Sources/ScreenMapState.swift +701 -55
- package/app/Sources/ScreenMapView.swift +843 -103
- package/app/Sources/ScreenMapWindowController.swift +22 -0
- package/app/Sources/SessionLayerStore.swift +285 -0
- package/app/Sources/SessionManager.swift +4 -1
- package/app/Sources/SettingsView.swift +186 -3
- package/app/Sources/Theme.swift +9 -8
- package/app/Sources/TmuxModel.swift +7 -0
- package/app/Sources/TmuxQuery.swift +27 -3
- package/app/Sources/VoiceChatView.swift +192 -0
- package/app/Sources/VoiceCommandWindow.swift +1594 -0
- package/app/Sources/VoiceIntentResolver.swift +671 -0
- package/app/Sources/VoxClient.swift +454 -0
- package/app/Sources/WindowTiler.swift +348 -87
- package/app/Sources/WorkspaceManager.swift +127 -18
- package/bin/client.ts +16 -0
- package/bin/{daemon-client.js → daemon-client.ts} +49 -30
- package/bin/handsoff-infer.ts +280 -0
- package/bin/handsoff-worker.ts +731 -0
- package/bin/{lattices-app.js → lattices-app.ts} +67 -32
- package/bin/lattices-dev +160 -0
- package/bin/{lattices.js → lattices.ts} +600 -137
- package/bin/project-twin.ts +645 -0
- package/docs/agent-execution-plan.md +562 -0
- package/docs/agents.md +142 -0
- package/docs/api.md +153 -34
- package/docs/app.md +29 -1
- package/docs/config.md +5 -1
- package/docs/handsoff-test-scenarios.md +84 -0
- package/docs/layers.md +20 -20
- package/docs/ocr.md +14 -5
- package/docs/overview.md +5 -1
- package/docs/presentation-execution-review.md +491 -0
- package/docs/prompts/hands-off-system.md +374 -0
- package/docs/prompts/hands-off-turn.md +30 -0
- package/docs/prompts/voice-advisor.md +31 -0
- package/docs/prompts/voice-fallback.md +23 -0
- package/docs/tiling-reference.md +167 -0
- package/docs/twins.md +138 -0
- package/docs/voice-command-protocol.md +278 -0
- package/docs/voice.md +219 -0
- package/package.json +21 -10
- package/bin/client.js +0 -4
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { basename, join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export type ProjectTwinThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
6
|
+
|
|
7
|
+
export interface ProjectTwinOptions {
|
|
8
|
+
cwd: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
piCommand?: string[];
|
|
11
|
+
provider?: string;
|
|
12
|
+
model?: string;
|
|
13
|
+
thinking?: ProjectTwinThinkingLevel;
|
|
14
|
+
tools?: string[];
|
|
15
|
+
extensions?: string[];
|
|
16
|
+
skills?: string[];
|
|
17
|
+
promptTemplates?: string[];
|
|
18
|
+
disableExtensions?: boolean;
|
|
19
|
+
disableSkills?: boolean;
|
|
20
|
+
disablePromptTemplates?: boolean;
|
|
21
|
+
systemPrompt?: string;
|
|
22
|
+
appendSystemPrompt?: string;
|
|
23
|
+
storageDir?: string;
|
|
24
|
+
sessionDir?: string;
|
|
25
|
+
env?: Record<string, string>;
|
|
26
|
+
defaultTimeoutMs?: number;
|
|
27
|
+
autoLoadOpenScoutRelay?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ProjectTwinInvokeRequest {
|
|
31
|
+
task: string;
|
|
32
|
+
caller?: string;
|
|
33
|
+
context?: string | string[];
|
|
34
|
+
memory?: string | string[];
|
|
35
|
+
protocol?: string;
|
|
36
|
+
protocolContext?: unknown;
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ProjectTwinState {
|
|
41
|
+
sessionId: string;
|
|
42
|
+
sessionFile?: string;
|
|
43
|
+
sessionName?: string;
|
|
44
|
+
isStreaming: boolean;
|
|
45
|
+
messageCount: number;
|
|
46
|
+
pendingMessageCount: number;
|
|
47
|
+
autoCompactionEnabled: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ProjectTwinResult {
|
|
51
|
+
text: string;
|
|
52
|
+
state: ProjectTwinState;
|
|
53
|
+
events: ProjectTwinEvent[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface OpenScoutRelayContext {
|
|
57
|
+
relayDir: string;
|
|
58
|
+
linkPath?: string;
|
|
59
|
+
configPath?: string;
|
|
60
|
+
channelLogPath?: string;
|
|
61
|
+
hub?: string;
|
|
62
|
+
linkedAt?: string;
|
|
63
|
+
agentCount?: number;
|
|
64
|
+
config?: Record<string, unknown>;
|
|
65
|
+
recentChannelLines: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ProjectTwinEvent {
|
|
69
|
+
type: string;
|
|
70
|
+
[key: string]: unknown;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface PiRpcResponse {
|
|
74
|
+
id?: string;
|
|
75
|
+
type: "response";
|
|
76
|
+
command: string;
|
|
77
|
+
success: boolean;
|
|
78
|
+
data?: unknown;
|
|
79
|
+
error?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface PiRpcStateResponse {
|
|
83
|
+
sessionId: string;
|
|
84
|
+
sessionFile?: string;
|
|
85
|
+
sessionName?: string;
|
|
86
|
+
isStreaming: boolean;
|
|
87
|
+
messageCount: number;
|
|
88
|
+
pendingMessageCount?: number;
|
|
89
|
+
autoCompactionEnabled?: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const DEFAULT_TOOLS = ["read", "bash", "edit", "write"];
|
|
93
|
+
|
|
94
|
+
const DEFAULT_TWIN_APPEND_SYSTEM_PROMPT = [
|
|
95
|
+
"You are the persistent project twin for the current working directory.",
|
|
96
|
+
"A project twin is the project-native runtime that mediates between a primary agent and project-specific protocols, tools, and memory.",
|
|
97
|
+
"Treat caller messages as invocations from another agent, not as end-user chat.",
|
|
98
|
+
"Prefer concise operational handoffs that the caller can act on immediately.",
|
|
99
|
+
"Keep project-specific protocol semantics behind this boundary instead of teaching them back to the caller unless explicitly asked.",
|
|
100
|
+
"If context is missing, inspect the project and say what is missing instead of inventing it.",
|
|
101
|
+
].join(" ");
|
|
102
|
+
|
|
103
|
+
function slugify(value: string): string {
|
|
104
|
+
return value
|
|
105
|
+
.trim()
|
|
106
|
+
.toLowerCase()
|
|
107
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
108
|
+
.replace(/^-+|-+$/g, "") || "project-twin";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeTextBlock(value: string | string[] | undefined): string | undefined {
|
|
112
|
+
if (value === undefined) return undefined;
|
|
113
|
+
return Array.isArray(value) ? value.filter(Boolean).join("\n\n") : value.trim();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function renderUnknown(value: unknown): string | undefined {
|
|
117
|
+
if (value === undefined || value === null) return undefined;
|
|
118
|
+
if (typeof value === "string") return value.trim();
|
|
119
|
+
return JSON.stringify(value, null, 2);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function pushSection(parts: string[], tag: string, value: string | undefined): void {
|
|
123
|
+
if (!value) return;
|
|
124
|
+
parts.push(`<${tag}>\n${value}\n</${tag}>`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function encodeJsonLine(value: unknown): string {
|
|
128
|
+
return `${JSON.stringify(value)}\n`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function defaultTwinName(cwd: string, name?: string): string {
|
|
132
|
+
return name?.trim() || `${basename(cwd)}-twin`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function defaultStorageDir(cwd: string, name?: string): string {
|
|
136
|
+
return join(cwd, ".openscout", "twins", slugify(defaultTwinName(cwd, name)));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function buildTwinAppendSystemPrompt(options: ProjectTwinOptions): string {
|
|
140
|
+
const parts = [DEFAULT_TWIN_APPEND_SYSTEM_PROMPT];
|
|
141
|
+
if (options.appendSystemPrompt?.trim()) {
|
|
142
|
+
parts.push(options.appendSystemPrompt.trim());
|
|
143
|
+
}
|
|
144
|
+
return parts.join("\n\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function buildInvocationPrompt(
|
|
148
|
+
twinName: string,
|
|
149
|
+
cwd: string,
|
|
150
|
+
request: ProjectTwinInvokeRequest,
|
|
151
|
+
relayContext?: OpenScoutRelayContext,
|
|
152
|
+
): string {
|
|
153
|
+
const parts: string[] = [
|
|
154
|
+
`This is a mediated invocation into the project twin "${twinName}" for ${cwd}.`,
|
|
155
|
+
"Resume with the right project context, do whatever local inspection or protocol work is needed, and return a concise handoff to the calling agent.",
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
pushSection(parts, "caller", request.caller?.trim());
|
|
159
|
+
pushSection(parts, "context", normalizeTextBlock(request.context));
|
|
160
|
+
pushSection(parts, "memory", normalizeTextBlock(request.memory));
|
|
161
|
+
|
|
162
|
+
if (request.protocol?.trim()) {
|
|
163
|
+
parts.push(`<protocol>\n${request.protocol.trim()}\n</protocol>`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
pushSection(parts, "protocol-context", renderUnknown(request.protocolContext));
|
|
167
|
+
|
|
168
|
+
if (relayContext) {
|
|
169
|
+
pushSection(parts, "openscout-relay", renderUnknown(relayContext));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
pushSection(parts, "task", request.task.trim());
|
|
173
|
+
|
|
174
|
+
parts.push(
|
|
175
|
+
[
|
|
176
|
+
"Respond in Markdown with these sections:",
|
|
177
|
+
"## Outcome",
|
|
178
|
+
"## Actions",
|
|
179
|
+
"## Notes",
|
|
180
|
+
"## Next",
|
|
181
|
+
].join("\n"),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
return parts.join("\n\n");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function readIfExists(path: string): Promise<string | undefined> {
|
|
188
|
+
try {
|
|
189
|
+
return await readFile(path, "utf8");
|
|
190
|
+
} catch {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function readJsonIfExists(path: string): Promise<Record<string, unknown> | undefined> {
|
|
196
|
+
const text = await readIfExists(path);
|
|
197
|
+
if (!text) return undefined;
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const parsed = JSON.parse(text) as unknown;
|
|
201
|
+
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : undefined;
|
|
202
|
+
} catch {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function readTailLines(path: string, count: number): Promise<string[]> {
|
|
208
|
+
const text = await readIfExists(path);
|
|
209
|
+
if (!text) return [];
|
|
210
|
+
return text
|
|
211
|
+
.split("\n")
|
|
212
|
+
.map((line) => line.trim())
|
|
213
|
+
.filter(Boolean)
|
|
214
|
+
.slice(-count);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function readOpenScoutRelayContext(cwd: string): Promise<OpenScoutRelayContext | undefined> {
|
|
218
|
+
const openScoutDir = join(cwd, ".openscout");
|
|
219
|
+
const linkPath = join(openScoutDir, "relay.json");
|
|
220
|
+
const relayDir = join(openScoutDir, "relay");
|
|
221
|
+
const configPath = join(relayDir, "config.json");
|
|
222
|
+
const channelLogPath = join(relayDir, "channel.log");
|
|
223
|
+
|
|
224
|
+
const [linkMeta, config, recentChannelLines] = await Promise.all([
|
|
225
|
+
readJsonIfExists(linkPath),
|
|
226
|
+
readJsonIfExists(configPath),
|
|
227
|
+
readTailLines(channelLogPath, 10),
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
if (!linkMeta && !config && recentChannelLines.length === 0) {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const agents = Array.isArray(config?.agents) ? config.agents : undefined;
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
relayDir,
|
|
238
|
+
linkPath: linkMeta ? linkPath : undefined,
|
|
239
|
+
configPath: config ? configPath : undefined,
|
|
240
|
+
channelLogPath: recentChannelLines.length > 0 ? channelLogPath : undefined,
|
|
241
|
+
hub: typeof linkMeta?.hub === "string" ? linkMeta.hub : undefined,
|
|
242
|
+
linkedAt: typeof linkMeta?.linkedAt === "string" ? linkMeta.linkedAt : undefined,
|
|
243
|
+
agentCount: agents?.length,
|
|
244
|
+
config,
|
|
245
|
+
recentChannelLines,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
class PiRpcClient {
|
|
250
|
+
private process: ChildProcess | null = null;
|
|
251
|
+
private stdoutBuffer = "";
|
|
252
|
+
private stderrBuffer = "";
|
|
253
|
+
private listeners = new Set<(event: ProjectTwinEvent) => void>();
|
|
254
|
+
private pendingRequests = new Map<
|
|
255
|
+
string,
|
|
256
|
+
{ resolve: (response: PiRpcResponse) => void; reject: (error: Error) => void; timeout: Timer }
|
|
257
|
+
>();
|
|
258
|
+
private requestCounter = 0;
|
|
259
|
+
|
|
260
|
+
constructor(
|
|
261
|
+
private readonly command: string[],
|
|
262
|
+
private readonly cwd: string,
|
|
263
|
+
private readonly env: NodeJS.ProcessEnv,
|
|
264
|
+
private readonly defaultTimeoutMs: number,
|
|
265
|
+
) {}
|
|
266
|
+
|
|
267
|
+
async start(): Promise<void> {
|
|
268
|
+
if (this.process) return;
|
|
269
|
+
if (this.command.length === 0) {
|
|
270
|
+
throw new Error("Pi command cannot be empty");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const [bin, ...args] = this.command;
|
|
274
|
+
const child = spawn(bin, args, {
|
|
275
|
+
cwd: this.cwd,
|
|
276
|
+
env: this.env,
|
|
277
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
await new Promise<void>((resolvePromise, rejectPromise) => {
|
|
281
|
+
const onError = (error: Error) => {
|
|
282
|
+
child.off("spawn", onSpawn);
|
|
283
|
+
rejectPromise(
|
|
284
|
+
new Error(
|
|
285
|
+
`Failed to start Pi RPC process (${bin}). Install pi, set PI_BIN, or pass piCommand explicitly. ${error.message}`,
|
|
286
|
+
),
|
|
287
|
+
);
|
|
288
|
+
};
|
|
289
|
+
const onSpawn = () => {
|
|
290
|
+
child.off("error", onError);
|
|
291
|
+
resolvePromise();
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
child.once("error", onError);
|
|
295
|
+
child.once("spawn", onSpawn);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
child.stdout?.on("data", (chunk: Buffer | string) => {
|
|
299
|
+
this.handleStdoutChunk(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
child.stderr?.on("data", (chunk: Buffer | string) => {
|
|
303
|
+
this.stderrBuffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
child.on("exit", () => {
|
|
307
|
+
this.process = null;
|
|
308
|
+
for (const pending of this.pendingRequests.values()) {
|
|
309
|
+
clearTimeout(pending.timeout);
|
|
310
|
+
pending.reject(new Error(`Pi RPC process exited. Stderr: ${this.stderrBuffer.trim()}`));
|
|
311
|
+
}
|
|
312
|
+
this.pendingRequests.clear();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
this.process = child;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async stop(): Promise<void> {
|
|
319
|
+
if (!this.process) return;
|
|
320
|
+
|
|
321
|
+
const child = this.process;
|
|
322
|
+
this.process = null;
|
|
323
|
+
|
|
324
|
+
await new Promise<void>((resolvePromise) => {
|
|
325
|
+
const timer = setTimeout(() => {
|
|
326
|
+
child.kill("SIGKILL");
|
|
327
|
+
resolvePromise();
|
|
328
|
+
}, 1000);
|
|
329
|
+
|
|
330
|
+
child.once("exit", () => {
|
|
331
|
+
clearTimeout(timer);
|
|
332
|
+
resolvePromise();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
child.kill("SIGTERM");
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
onEvent(listener: (event: ProjectTwinEvent) => void): () => void {
|
|
340
|
+
this.listeners.add(listener);
|
|
341
|
+
return () => {
|
|
342
|
+
this.listeners.delete(listener);
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async promptAndWait(message: string, timeoutMs = this.defaultTimeoutMs): Promise<ProjectTwinEvent[]> {
|
|
347
|
+
const collector = this.collectEventsUntilIdle(timeoutMs);
|
|
348
|
+
try {
|
|
349
|
+
await this.send({ type: "prompt", message }, timeoutMs);
|
|
350
|
+
return await collector.promise;
|
|
351
|
+
} catch (error) {
|
|
352
|
+
collector.dispose();
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async newSession(parentSession?: string): Promise<{ cancelled: boolean }> {
|
|
358
|
+
const response = await this.send({ type: "new_session", parentSession }, this.defaultTimeoutMs);
|
|
359
|
+
return this.unwrapData<{ cancelled: boolean }>(response);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async getState(): Promise<ProjectTwinState> {
|
|
363
|
+
const response = await this.send({ type: "get_state" }, this.defaultTimeoutMs);
|
|
364
|
+
const data = this.unwrapData<PiRpcStateResponse>(response);
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
sessionId: data.sessionId,
|
|
368
|
+
sessionFile: data.sessionFile,
|
|
369
|
+
sessionName: data.sessionName,
|
|
370
|
+
isStreaming: data.isStreaming,
|
|
371
|
+
messageCount: data.messageCount,
|
|
372
|
+
pendingMessageCount: data.pendingMessageCount ?? 0,
|
|
373
|
+
autoCompactionEnabled: data.autoCompactionEnabled ?? true,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async getLastAssistantText(): Promise<string | null> {
|
|
378
|
+
const response = await this.send({ type: "get_last_assistant_text" }, this.defaultTimeoutMs);
|
|
379
|
+
const data = this.unwrapData<{ text: string | null }>(response);
|
|
380
|
+
return data.text;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async setSessionName(name: string): Promise<void> {
|
|
384
|
+
await this.send({ type: "set_session_name", name }, this.defaultTimeoutMs);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private collectEventsUntilIdle(timeoutMs: number): {
|
|
388
|
+
promise: Promise<ProjectTwinEvent[]>;
|
|
389
|
+
dispose: () => void;
|
|
390
|
+
} {
|
|
391
|
+
let cleanup = () => {};
|
|
392
|
+
|
|
393
|
+
const promise = new Promise<ProjectTwinEvent[]>((resolvePromise, rejectPromise) => {
|
|
394
|
+
const events: ProjectTwinEvent[] = [];
|
|
395
|
+
const timer = setTimeout(() => {
|
|
396
|
+
unsubscribe();
|
|
397
|
+
rejectPromise(new Error(`Timed out waiting for Pi to become idle. Stderr: ${this.stderrBuffer.trim()}`));
|
|
398
|
+
}, timeoutMs);
|
|
399
|
+
|
|
400
|
+
const unsubscribe = this.onEvent((event) => {
|
|
401
|
+
events.push(event);
|
|
402
|
+
if (event.type === "agent_end") {
|
|
403
|
+
clearTimeout(timer);
|
|
404
|
+
unsubscribe();
|
|
405
|
+
resolvePromise(events);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
cleanup = () => {
|
|
410
|
+
clearTimeout(timer);
|
|
411
|
+
unsubscribe();
|
|
412
|
+
};
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
promise,
|
|
417
|
+
dispose: cleanup,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private async send(command: Record<string, unknown>, timeoutMs: number): Promise<PiRpcResponse> {
|
|
422
|
+
if (!this.process?.stdin) {
|
|
423
|
+
throw new Error("Pi RPC client is not started");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const id = `req_${++this.requestCounter}`;
|
|
427
|
+
const payload = { ...command, id };
|
|
428
|
+
|
|
429
|
+
return new Promise<PiRpcResponse>((resolvePromise, rejectPromise) => {
|
|
430
|
+
const timeout = setTimeout(() => {
|
|
431
|
+
this.pendingRequests.delete(id);
|
|
432
|
+
rejectPromise(
|
|
433
|
+
new Error(
|
|
434
|
+
`Timed out waiting for Pi RPC response to ${String(command.type)}. Stderr: ${this.stderrBuffer.trim()}`,
|
|
435
|
+
),
|
|
436
|
+
);
|
|
437
|
+
}, timeoutMs);
|
|
438
|
+
|
|
439
|
+
this.pendingRequests.set(id, {
|
|
440
|
+
resolve: resolvePromise,
|
|
441
|
+
reject: rejectPromise,
|
|
442
|
+
timeout,
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
this.process?.stdin?.write(encodeJsonLine(payload));
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private unwrapData<T>(response: PiRpcResponse): T {
|
|
450
|
+
if (!response.success) {
|
|
451
|
+
throw new Error(response.error || `Pi RPC command ${response.command} failed`);
|
|
452
|
+
}
|
|
453
|
+
return response.data as T;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private handleStdoutChunk(chunk: string): void {
|
|
457
|
+
this.stdoutBuffer += chunk;
|
|
458
|
+
|
|
459
|
+
while (true) {
|
|
460
|
+
const newlineIndex = this.stdoutBuffer.indexOf("\n");
|
|
461
|
+
if (newlineIndex === -1) return;
|
|
462
|
+
|
|
463
|
+
const rawLine = this.stdoutBuffer.slice(0, newlineIndex);
|
|
464
|
+
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
|
|
465
|
+
|
|
466
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
467
|
+
if (!line.trim()) continue;
|
|
468
|
+
|
|
469
|
+
this.handleStdoutLine(line);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private handleStdoutLine(line: string): void {
|
|
474
|
+
let payload: unknown;
|
|
475
|
+
try {
|
|
476
|
+
payload = JSON.parse(line);
|
|
477
|
+
} catch {
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (!payload || typeof payload !== "object") return;
|
|
482
|
+
|
|
483
|
+
const response = payload as PiRpcResponse;
|
|
484
|
+
if (response.type === "response" && response.id && this.pendingRequests.has(response.id)) {
|
|
485
|
+
const pending = this.pendingRequests.get(response.id)!;
|
|
486
|
+
clearTimeout(pending.timeout);
|
|
487
|
+
this.pendingRequests.delete(response.id);
|
|
488
|
+
pending.resolve(response);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const event = payload as ProjectTwinEvent;
|
|
493
|
+
for (const listener of this.listeners) {
|
|
494
|
+
listener(event);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export class ProjectTwin {
|
|
500
|
+
readonly cwd: string;
|
|
501
|
+
readonly name: string;
|
|
502
|
+
readonly storageDir: string;
|
|
503
|
+
readonly sessionDir: string;
|
|
504
|
+
|
|
505
|
+
private readonly options: ProjectTwinOptions;
|
|
506
|
+
private rpc: PiRpcClient | null = null;
|
|
507
|
+
private startPromise: Promise<void> | null = null;
|
|
508
|
+
private invokeQueue: Promise<unknown> = Promise.resolve();
|
|
509
|
+
|
|
510
|
+
constructor(options: ProjectTwinOptions) {
|
|
511
|
+
this.options = {
|
|
512
|
+
defaultTimeoutMs: 60000,
|
|
513
|
+
autoLoadOpenScoutRelay: true,
|
|
514
|
+
disableExtensions: true,
|
|
515
|
+
disableSkills: true,
|
|
516
|
+
disablePromptTemplates: true,
|
|
517
|
+
tools: DEFAULT_TOOLS,
|
|
518
|
+
...options,
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
this.cwd = resolve(this.options.cwd);
|
|
522
|
+
this.name = defaultTwinName(this.cwd, this.options.name);
|
|
523
|
+
this.storageDir = this.options.storageDir
|
|
524
|
+
? resolve(this.options.storageDir)
|
|
525
|
+
: defaultStorageDir(this.cwd, this.name);
|
|
526
|
+
this.sessionDir = this.options.sessionDir
|
|
527
|
+
? resolve(this.options.sessionDir)
|
|
528
|
+
: join(this.storageDir, "sessions");
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async start(): Promise<void> {
|
|
532
|
+
if (this.rpc) return;
|
|
533
|
+
if (this.startPromise) return this.startPromise;
|
|
534
|
+
|
|
535
|
+
this.startPromise = this.startInternal().finally(() => {
|
|
536
|
+
this.startPromise = null;
|
|
537
|
+
});
|
|
538
|
+
return this.startPromise;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async stop(): Promise<void> {
|
|
542
|
+
await this.rpc?.stop();
|
|
543
|
+
this.rpc = null;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async reset(parentSession?: string): Promise<{ cancelled: boolean }> {
|
|
547
|
+
await this.start();
|
|
548
|
+
return this.rpc!.newSession(parentSession);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async getState(): Promise<ProjectTwinState> {
|
|
552
|
+
await this.start();
|
|
553
|
+
return this.rpc!.getState();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
invoke(request: ProjectTwinInvokeRequest): Promise<ProjectTwinResult> {
|
|
557
|
+
const run = this.invokeQueue.then(() => this.invokeInternal(request));
|
|
558
|
+
this.invokeQueue = run.catch(() => {});
|
|
559
|
+
return run;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private async startInternal(): Promise<void> {
|
|
563
|
+
await mkdir(this.sessionDir, { recursive: true });
|
|
564
|
+
|
|
565
|
+
const piCommand = this.options.piCommand ?? (process.env.PI_BIN ? [process.env.PI_BIN] : ["pi"]);
|
|
566
|
+
const command = [...piCommand, ...this.buildPiArgs()];
|
|
567
|
+
|
|
568
|
+
this.rpc = new PiRpcClient(
|
|
569
|
+
command,
|
|
570
|
+
this.cwd,
|
|
571
|
+
{ ...process.env, ...this.options.env },
|
|
572
|
+
this.options.defaultTimeoutMs!,
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
await this.rpc.start();
|
|
576
|
+
await this.rpc.setSessionName(this.name);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private buildPiArgs(): string[] {
|
|
580
|
+
const args = ["--mode", "rpc", "--session-dir", this.sessionDir];
|
|
581
|
+
|
|
582
|
+
if (this.options.provider) {
|
|
583
|
+
args.push("--provider", this.options.provider);
|
|
584
|
+
}
|
|
585
|
+
if (this.options.model) {
|
|
586
|
+
args.push("--model", this.options.model);
|
|
587
|
+
}
|
|
588
|
+
if (this.options.thinking) {
|
|
589
|
+
args.push("--thinking", this.options.thinking);
|
|
590
|
+
}
|
|
591
|
+
if (this.options.systemPrompt?.trim()) {
|
|
592
|
+
args.push("--system-prompt", this.options.systemPrompt.trim());
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
args.push("--append-system-prompt", buildTwinAppendSystemPrompt(this.options));
|
|
596
|
+
|
|
597
|
+
const tools = this.options.tools?.filter(Boolean);
|
|
598
|
+
if (tools && tools.length > 0) {
|
|
599
|
+
args.push("--tools", tools.join(","));
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (this.options.disableExtensions) {
|
|
603
|
+
args.push("--no-extensions");
|
|
604
|
+
}
|
|
605
|
+
for (const extension of this.options.extensions ?? []) {
|
|
606
|
+
args.push("--extension", extension);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (this.options.disableSkills) {
|
|
610
|
+
args.push("--no-skills");
|
|
611
|
+
}
|
|
612
|
+
for (const skill of this.options.skills ?? []) {
|
|
613
|
+
args.push("--skill", skill);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (this.options.disablePromptTemplates) {
|
|
617
|
+
args.push("--no-prompt-templates");
|
|
618
|
+
}
|
|
619
|
+
for (const promptTemplate of this.options.promptTemplates ?? []) {
|
|
620
|
+
args.push("--prompt-template", promptTemplate);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return args;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
private async invokeInternal(request: ProjectTwinInvokeRequest): Promise<ProjectTwinResult> {
|
|
627
|
+
await this.start();
|
|
628
|
+
|
|
629
|
+
const relayContext =
|
|
630
|
+
this.options.autoLoadOpenScoutRelay === false ? undefined : await readOpenScoutRelayContext(this.cwd);
|
|
631
|
+
const prompt = buildInvocationPrompt(this.name, this.cwd, request, relayContext);
|
|
632
|
+
const events = await this.rpc!.promptAndWait(prompt, request.timeoutMs ?? this.options.defaultTimeoutMs);
|
|
633
|
+
const [text, state] = await Promise.all([this.rpc!.getLastAssistantText(), this.rpc!.getState()]);
|
|
634
|
+
|
|
635
|
+
return {
|
|
636
|
+
text: text ?? "",
|
|
637
|
+
state,
|
|
638
|
+
events,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export function createProjectTwin(options: ProjectTwinOptions): ProjectTwin {
|
|
644
|
+
return new ProjectTwin(options);
|
|
645
|
+
}
|