@ogulcancelik/pi-spar 0.1.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/LICENSE +21 -0
- package/README.md +58 -0
- package/core.ts +879 -0
- package/index.ts +760 -0
- package/package.json +41 -0
- package/peek.ts +683 -0
package/core.ts
ADDED
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spar Core - Agent-to-agent communication via pi RPC
|
|
3
|
+
*
|
|
4
|
+
* Extracted from pi-spar.ts for use as a native tool.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawn } from "child_process";
|
|
8
|
+
import * as readline from "readline";
|
|
9
|
+
import * as path from "path";
|
|
10
|
+
import * as fs from "fs";
|
|
11
|
+
import * as net from "net";
|
|
12
|
+
import * as os from "os";
|
|
13
|
+
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// Configuration
|
|
16
|
+
// =============================================================================
|
|
17
|
+
|
|
18
|
+
// Session storage in pi's config directory (persistent across reboots)
|
|
19
|
+
const SPAR_DIR = path.join(os.homedir(), ".pi", "agent", "spar");
|
|
20
|
+
const SESSION_DIR = path.join(SPAR_DIR, "sessions");
|
|
21
|
+
const CONFIG_PATH = path.join(SPAR_DIR, "config.json");
|
|
22
|
+
|
|
23
|
+
// Default timeout: 30 minutes (sliding - resets on activity)
|
|
24
|
+
export const DEFAULT_TIMEOUT = 1800000;
|
|
25
|
+
|
|
26
|
+
// Default tools for peer agent (read-only)
|
|
27
|
+
const DEFAULT_TOOLS = "read,grep,find,ls";
|
|
28
|
+
|
|
29
|
+
// =============================================================================
|
|
30
|
+
// Spar Config — user-configured models via /spar-models
|
|
31
|
+
// =============================================================================
|
|
32
|
+
|
|
33
|
+
export interface SparModelConfig {
|
|
34
|
+
alias: string; // short name like "gpt5", "opus"
|
|
35
|
+
provider: string; // pi provider like "openai", "anthropic"
|
|
36
|
+
id: string; // model id like "gpt-5.4", "claude-opus-4-6"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SparConfig {
|
|
40
|
+
models: SparModelConfig[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function loadSparConfig(): SparConfig {
|
|
44
|
+
try {
|
|
45
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
46
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
|
|
47
|
+
}
|
|
48
|
+
} catch {}
|
|
49
|
+
return { models: [] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function saveSparConfig(config: SparConfig): void {
|
|
53
|
+
fs.mkdirSync(SPAR_DIR, { recursive: true });
|
|
54
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Build alias → provider:model map from config */
|
|
58
|
+
function getModelAliases(): Record<string, string> {
|
|
59
|
+
const config = loadSparConfig();
|
|
60
|
+
const aliases: Record<string, string> = {};
|
|
61
|
+
for (const m of config.models) {
|
|
62
|
+
aliases[m.alias] = `${m.provider}:${m.id}`;
|
|
63
|
+
}
|
|
64
|
+
return aliases;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Get configured models for tool description */
|
|
68
|
+
export function getConfiguredModelsDescription(): string {
|
|
69
|
+
const config = loadSparConfig();
|
|
70
|
+
if (config.models.length === 0) {
|
|
71
|
+
return "No models configured. Run /spar-models to set up sparring models.";
|
|
72
|
+
}
|
|
73
|
+
return config.models
|
|
74
|
+
.map(m => `- \`${m.alias}\` - ${m.provider}/${m.id}`)
|
|
75
|
+
.join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// =============================================================================
|
|
79
|
+
// Types
|
|
80
|
+
// =============================================================================
|
|
81
|
+
|
|
82
|
+
export interface SessionInfo {
|
|
83
|
+
id: string;
|
|
84
|
+
model: string;
|
|
85
|
+
provider: string;
|
|
86
|
+
modelId: string;
|
|
87
|
+
thinking?: string;
|
|
88
|
+
tools: string;
|
|
89
|
+
sessionFile: string;
|
|
90
|
+
createdAt: number;
|
|
91
|
+
lastActivity?: number;
|
|
92
|
+
messageCount?: number;
|
|
93
|
+
status?: "active" | "closed" | "failed";
|
|
94
|
+
error?: string;
|
|
95
|
+
failedAt?: number;
|
|
96
|
+
closedAt?: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface SendResult {
|
|
100
|
+
response: string;
|
|
101
|
+
usage?: {
|
|
102
|
+
input: number;
|
|
103
|
+
output: number;
|
|
104
|
+
cost: number;
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ProgressStatus {
|
|
109
|
+
model: string;
|
|
110
|
+
sessionId: string;
|
|
111
|
+
startTime: number;
|
|
112
|
+
status: "thinking" | "tool" | "streaming" | "done" | "error";
|
|
113
|
+
toolName?: string;
|
|
114
|
+
toolArgs?: string;
|
|
115
|
+
elapsed?: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface SessionSummary {
|
|
119
|
+
name: string;
|
|
120
|
+
model: string;
|
|
121
|
+
modelAlias?: string;
|
|
122
|
+
messageCount: number;
|
|
123
|
+
lastActivity: number;
|
|
124
|
+
status: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// =============================================================================
|
|
128
|
+
// Directory Management
|
|
129
|
+
// =============================================================================
|
|
130
|
+
|
|
131
|
+
function ensureSessionDir(): void {
|
|
132
|
+
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getSessionInfoPath(sessionId: string): string {
|
|
136
|
+
return path.join(SESSION_DIR, `${sessionId}.info.json`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getSessionFilePath(sessionId: string): string {
|
|
140
|
+
return path.join(SESSION_DIR, `${sessionId}.jsonl`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function getSessionLogPath(sessionId: string): string {
|
|
144
|
+
return path.join(SESSION_DIR, `${sessionId}.log`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function getSocketPath(sessionId: string): string {
|
|
148
|
+
return `/tmp/pi-spar-${sessionId}.sock`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// =============================================================================
|
|
152
|
+
// Session Logger
|
|
153
|
+
// =============================================================================
|
|
154
|
+
|
|
155
|
+
class SessionLogger {
|
|
156
|
+
private logPath: string;
|
|
157
|
+
private stream: fs.WriteStream | null = null;
|
|
158
|
+
|
|
159
|
+
constructor(sessionId: string) {
|
|
160
|
+
this.logPath = getSessionLogPath(sessionId);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private timestamp(): string {
|
|
164
|
+
return new Date().toISOString();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private write(level: string, category: string, message: string, data?: any) {
|
|
168
|
+
const entry = {
|
|
169
|
+
ts: this.timestamp(),
|
|
170
|
+
level,
|
|
171
|
+
category,
|
|
172
|
+
message,
|
|
173
|
+
...(data !== undefined ? { data } : {}),
|
|
174
|
+
};
|
|
175
|
+
const line = JSON.stringify(entry) + "\n";
|
|
176
|
+
|
|
177
|
+
if (!this.stream) {
|
|
178
|
+
this.stream = fs.createWriteStream(this.logPath, { flags: "a" });
|
|
179
|
+
}
|
|
180
|
+
this.stream.write(line);
|
|
181
|
+
|
|
182
|
+
if (process.env.PI_SPAR_DEBUG) {
|
|
183
|
+
console.error(`[${level}] ${category}: ${message}`, data ? JSON.stringify(data).slice(0, 200) : "");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
info(category: string, message: string, data?: any) { this.write("INFO", category, message, data); }
|
|
188
|
+
error(category: string, message: string, data?: any) { this.write("ERROR", category, message, data); }
|
|
189
|
+
warn(category: string, message: string, data?: any) { this.write("WARN", category, message, data); }
|
|
190
|
+
debug(category: string, message: string, data?: any) { this.write("DEBUG", category, message, data); }
|
|
191
|
+
|
|
192
|
+
rpcEvent(event: any) {
|
|
193
|
+
this.write("DEBUG", "rpc-event", event.type, {
|
|
194
|
+
type: event.type,
|
|
195
|
+
...(event.type === "tool_execution_start" ? { tool: event.toolName, args: event.args } : {}),
|
|
196
|
+
...(event.type === "tool_execution_end" ? { tool: event.toolName } : {}),
|
|
197
|
+
...(event.type === "response" ? { success: event.success, error: event.error, id: event.id } : {}),
|
|
198
|
+
...(event.type === "message_end" ? { errorMessage: event.message?.errorMessage } : {}),
|
|
199
|
+
...(event.type === "agent_end" ? { messageCount: event.messages?.length } : {}),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
stderr(chunk: string) { this.write("STDERR", "pi-process", chunk.trim()); }
|
|
204
|
+
|
|
205
|
+
close() {
|
|
206
|
+
if (this.stream) {
|
|
207
|
+
this.stream.end();
|
|
208
|
+
this.stream = null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// =============================================================================
|
|
214
|
+
// Event Broadcaster (for peek extension)
|
|
215
|
+
// =============================================================================
|
|
216
|
+
|
|
217
|
+
class EventBroadcaster {
|
|
218
|
+
private server: net.Server | null = null;
|
|
219
|
+
private connections: net.Socket[] = [];
|
|
220
|
+
private socketPath: string;
|
|
221
|
+
|
|
222
|
+
// Track state for sync on connect
|
|
223
|
+
private currentStatus: "thinking" | "streaming" | "tool" | "done" = "thinking";
|
|
224
|
+
private currentToolName?: string;
|
|
225
|
+
private currentPartialMessage: any = null; // The full partial AssistantMessage
|
|
226
|
+
|
|
227
|
+
constructor(sessionId: string) {
|
|
228
|
+
this.socketPath = getSocketPath(sessionId);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
start(): void {
|
|
232
|
+
try {
|
|
233
|
+
if (fs.existsSync(this.socketPath)) {
|
|
234
|
+
fs.unlinkSync(this.socketPath);
|
|
235
|
+
}
|
|
236
|
+
} catch {}
|
|
237
|
+
|
|
238
|
+
this.server = net.createServer((conn) => {
|
|
239
|
+
this.connections.push(conn);
|
|
240
|
+
|
|
241
|
+
// Send sync event with current state to new client
|
|
242
|
+
const syncEvent = {
|
|
243
|
+
type: "sync",
|
|
244
|
+
status: this.currentStatus,
|
|
245
|
+
toolName: this.currentToolName,
|
|
246
|
+
partialMessage: this.currentPartialMessage,
|
|
247
|
+
};
|
|
248
|
+
try { conn.write(JSON.stringify(syncEvent) + "\n"); } catch {}
|
|
249
|
+
|
|
250
|
+
conn.on("close", () => {
|
|
251
|
+
const idx = this.connections.indexOf(conn);
|
|
252
|
+
if (idx >= 0) this.connections.splice(idx, 1);
|
|
253
|
+
});
|
|
254
|
+
conn.on("error", () => {
|
|
255
|
+
const idx = this.connections.indexOf(conn);
|
|
256
|
+
if (idx >= 0) this.connections.splice(idx, 1);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
this.server.listen(this.socketPath);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
broadcast(event: any): void {
|
|
264
|
+
// Track state for sync
|
|
265
|
+
if (event.type === "message_start" && event.message?.role === "assistant") {
|
|
266
|
+
this.currentPartialMessage = event.message;
|
|
267
|
+
this.currentStatus = "thinking";
|
|
268
|
+
} else if (event.type === "message_update" && event.message?.role === "assistant") {
|
|
269
|
+
// event.message is the full accumulated partial AssistantMessage
|
|
270
|
+
this.currentPartialMessage = event.message;
|
|
271
|
+
const delta = event.assistantMessageEvent;
|
|
272
|
+
if (delta?.type === "thinking_delta") {
|
|
273
|
+
this.currentStatus = "thinking";
|
|
274
|
+
} else if (delta?.type === "text_delta") {
|
|
275
|
+
this.currentStatus = "streaming";
|
|
276
|
+
}
|
|
277
|
+
} else if (event.type === "tool_execution_start") {
|
|
278
|
+
this.currentStatus = "tool";
|
|
279
|
+
this.currentToolName = event.toolName;
|
|
280
|
+
} else if (event.type === "message_end" || event.type === "agent_end") {
|
|
281
|
+
this.currentPartialMessage = null;
|
|
282
|
+
this.currentStatus = "done";
|
|
283
|
+
this.currentToolName = undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const line = JSON.stringify(event) + "\n";
|
|
287
|
+
for (const conn of this.connections) {
|
|
288
|
+
try { conn.write(line); } catch {}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
stop(): void {
|
|
293
|
+
for (const conn of this.connections) {
|
|
294
|
+
try { conn.end(); } catch {}
|
|
295
|
+
}
|
|
296
|
+
this.connections = [];
|
|
297
|
+
|
|
298
|
+
if (this.server) {
|
|
299
|
+
this.server.close();
|
|
300
|
+
this.server = null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
if (fs.existsSync(this.socketPath)) {
|
|
305
|
+
fs.unlinkSync(this.socketPath);
|
|
306
|
+
}
|
|
307
|
+
} catch {}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
// =============================================================================
|
|
314
|
+
// Session Management
|
|
315
|
+
// =============================================================================
|
|
316
|
+
|
|
317
|
+
function validateSessionName(name: string): void {
|
|
318
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
319
|
+
throw new Error(`Invalid session name: "${name}". Only alphanumeric, hyphens, and underscores allowed.`);
|
|
320
|
+
}
|
|
321
|
+
if (name.length > 64) {
|
|
322
|
+
throw new Error(`Session name too long (max 64 chars): "${name}"`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function saveSessionInfo(info: SessionInfo): void {
|
|
327
|
+
ensureSessionDir();
|
|
328
|
+
fs.writeFileSync(getSessionInfoPath(info.id), JSON.stringify(info, null, 2));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function loadSessionInfo(sessionId: string): SessionInfo | null {
|
|
332
|
+
validateSessionName(sessionId);
|
|
333
|
+
const infoPath = getSessionInfoPath(sessionId);
|
|
334
|
+
if (!fs.existsSync(infoPath)) {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
return JSON.parse(fs.readFileSync(infoPath, "utf-8"));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function markSessionFailed(sessionId: string, error: string): void {
|
|
341
|
+
try {
|
|
342
|
+
const info = loadSessionInfo(sessionId);
|
|
343
|
+
if (info) {
|
|
344
|
+
info.status = "failed";
|
|
345
|
+
info.error = error;
|
|
346
|
+
info.failedAt = Date.now();
|
|
347
|
+
saveSessionInfo(info);
|
|
348
|
+
}
|
|
349
|
+
} catch {}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function countSessionMessages(sessionFile: string): number {
|
|
353
|
+
if (!fs.existsSync(sessionFile)) return 0;
|
|
354
|
+
try {
|
|
355
|
+
const content = fs.readFileSync(sessionFile, "utf-8");
|
|
356
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
357
|
+
// Count user messages (approximate)
|
|
358
|
+
let count = 0;
|
|
359
|
+
for (const line of lines) {
|
|
360
|
+
try {
|
|
361
|
+
const entry = JSON.parse(line);
|
|
362
|
+
if (entry.type === "message" && entry.message?.role === "user") {
|
|
363
|
+
count++;
|
|
364
|
+
}
|
|
365
|
+
} catch {}
|
|
366
|
+
}
|
|
367
|
+
return count;
|
|
368
|
+
} catch {
|
|
369
|
+
return 0;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// =============================================================================
|
|
374
|
+
// Public API
|
|
375
|
+
// =============================================================================
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Resolve model alias or provider:model string to components.
|
|
379
|
+
* Accepts: "opus", "gpt5" (configured aliases), or "provider:model" directly.
|
|
380
|
+
*/
|
|
381
|
+
export function resolveModel(model: string): { provider: string; modelId: string; fullModel: string } {
|
|
382
|
+
const aliases = getModelAliases();
|
|
383
|
+
const fullModel = aliases[model] || model;
|
|
384
|
+
const parts = fullModel.split(":");
|
|
385
|
+
if (parts.length < 2) {
|
|
386
|
+
const available = Object.keys(aliases);
|
|
387
|
+
const hint = available.length > 0
|
|
388
|
+
? `Use ${available.map(a => `"${a}"`).join(", ")}, or "provider:model".`
|
|
389
|
+
: `Use "provider:model" format. Run /spar-models to configure aliases.`;
|
|
390
|
+
throw new Error(`Invalid model: "${model}". ${hint}`);
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
provider: parts[0],
|
|
394
|
+
modelId: parts.slice(1).join(":"),
|
|
395
|
+
fullModel,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Get model alias from full model string (for display)
|
|
401
|
+
*/
|
|
402
|
+
export function getModelAlias(fullModel: string): string | undefined {
|
|
403
|
+
const aliases = getModelAliases();
|
|
404
|
+
for (const [alias, model] of Object.entries(aliases)) {
|
|
405
|
+
if (model === fullModel) return alias;
|
|
406
|
+
}
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* List all sessions
|
|
412
|
+
*/
|
|
413
|
+
export function listSessions(): SessionSummary[] {
|
|
414
|
+
ensureSessionDir();
|
|
415
|
+
const files = fs.readdirSync(SESSION_DIR).filter(f => f.endsWith(".info.json"));
|
|
416
|
+
const sessions: SessionSummary[] = [];
|
|
417
|
+
|
|
418
|
+
for (const file of files) {
|
|
419
|
+
try {
|
|
420
|
+
const info: SessionInfo = JSON.parse(fs.readFileSync(path.join(SESSION_DIR, file), "utf-8"));
|
|
421
|
+
const messageCount = info.messageCount ?? countSessionMessages(info.sessionFile);
|
|
422
|
+
sessions.push({
|
|
423
|
+
name: info.id,
|
|
424
|
+
model: info.model,
|
|
425
|
+
modelAlias: getModelAlias(info.model),
|
|
426
|
+
messageCount,
|
|
427
|
+
lastActivity: info.lastActivity ?? info.createdAt,
|
|
428
|
+
status: info.status ?? "active",
|
|
429
|
+
});
|
|
430
|
+
} catch {}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Sort by last activity (most recent first)
|
|
434
|
+
sessions.sort((a, b) => b.lastActivity - a.lastActivity);
|
|
435
|
+
return sessions;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Check if a session exists
|
|
440
|
+
*/
|
|
441
|
+
export function sessionExists(name: string): boolean {
|
|
442
|
+
validateSessionName(name);
|
|
443
|
+
return fs.existsSync(getSessionInfoPath(name));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Get session info
|
|
448
|
+
*/
|
|
449
|
+
export function getSession(name: string): SessionInfo | null {
|
|
450
|
+
return loadSessionInfo(name);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Get session history (past exchanges)
|
|
455
|
+
*/
|
|
456
|
+
export interface Exchange {
|
|
457
|
+
user: string;
|
|
458
|
+
assistant: string;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function getSessionHistory(name: string, count: number = 5): Exchange[] {
|
|
462
|
+
validateSessionName(name);
|
|
463
|
+
const sessionFile = getSessionFilePath(name);
|
|
464
|
+
|
|
465
|
+
if (!fs.existsSync(sessionFile)) {
|
|
466
|
+
return [];
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const exchanges: Exchange[] = [];
|
|
470
|
+
let currentUser: string | null = null;
|
|
471
|
+
|
|
472
|
+
try {
|
|
473
|
+
const content = fs.readFileSync(sessionFile, "utf-8");
|
|
474
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
475
|
+
|
|
476
|
+
for (const line of lines) {
|
|
477
|
+
try {
|
|
478
|
+
const entry = JSON.parse(line);
|
|
479
|
+
if (entry.type !== "message") continue;
|
|
480
|
+
|
|
481
|
+
const msg = entry.message;
|
|
482
|
+
if (msg?.role === "user") {
|
|
483
|
+
// Extract text from user message
|
|
484
|
+
currentUser = extractTextFromContent(msg.content);
|
|
485
|
+
} else if (msg?.role === "assistant" && currentUser) {
|
|
486
|
+
// Extract text from assistant message
|
|
487
|
+
const assistantText = extractTextFromContent(msg.content);
|
|
488
|
+
if (assistantText) {
|
|
489
|
+
exchanges.push({ user: currentUser, assistant: assistantText });
|
|
490
|
+
currentUser = null;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
} catch {}
|
|
494
|
+
}
|
|
495
|
+
} catch {}
|
|
496
|
+
|
|
497
|
+
// Return last N exchanges
|
|
498
|
+
return exchanges.slice(-count);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function extractTextFromContent(content: any): string {
|
|
502
|
+
if (typeof content === "string") return content;
|
|
503
|
+
if (Array.isArray(content)) {
|
|
504
|
+
return content
|
|
505
|
+
.filter((c: any) => c?.type === "text" && typeof c.text === "string")
|
|
506
|
+
.map((c: any) => c.text)
|
|
507
|
+
.join("\n\n");
|
|
508
|
+
}
|
|
509
|
+
return "";
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Create a new session
|
|
514
|
+
*/
|
|
515
|
+
export function createSession(name: string, model: string, thinking?: string): SessionInfo {
|
|
516
|
+
validateSessionName(name);
|
|
517
|
+
|
|
518
|
+
if (sessionExists(name)) {
|
|
519
|
+
throw new Error(`Session "${name}" already exists.`);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const { provider, modelId, fullModel } = resolveModel(model);
|
|
523
|
+
|
|
524
|
+
const info: SessionInfo = {
|
|
525
|
+
id: name,
|
|
526
|
+
model: fullModel,
|
|
527
|
+
provider,
|
|
528
|
+
modelId,
|
|
529
|
+
thinking,
|
|
530
|
+
tools: DEFAULT_TOOLS,
|
|
531
|
+
sessionFile: getSessionFilePath(name),
|
|
532
|
+
createdAt: Date.now(),
|
|
533
|
+
messageCount: 0,
|
|
534
|
+
status: "active",
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
saveSessionInfo(info);
|
|
538
|
+
return info;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Send a message to a session
|
|
543
|
+
*/
|
|
544
|
+
export async function sendMessage(
|
|
545
|
+
sessionName: string,
|
|
546
|
+
message: string,
|
|
547
|
+
options: {
|
|
548
|
+
model?: string;
|
|
549
|
+
thinking?: string;
|
|
550
|
+
timeout?: number;
|
|
551
|
+
signal?: AbortSignal;
|
|
552
|
+
onProgress?: (status: ProgressStatus) => void;
|
|
553
|
+
} = {}
|
|
554
|
+
): Promise<SendResult> {
|
|
555
|
+
validateSessionName(sessionName);
|
|
556
|
+
|
|
557
|
+
let info = loadSessionInfo(sessionName);
|
|
558
|
+
|
|
559
|
+
// Create session if it doesn't exist
|
|
560
|
+
if (!info) {
|
|
561
|
+
if (!options.model) {
|
|
562
|
+
throw new Error(`Session "${sessionName}" doesn't exist. Provide a model to create it.`);
|
|
563
|
+
}
|
|
564
|
+
info = createSession(sessionName, options.model, options.thinking);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
|
568
|
+
const result = await sendToAgent(message, info, timeout, options.onProgress, options.signal);
|
|
569
|
+
|
|
570
|
+
// Update session info
|
|
571
|
+
info.lastActivity = Date.now();
|
|
572
|
+
info.messageCount = (info.messageCount ?? 0) + 1;
|
|
573
|
+
saveSessionInfo(info);
|
|
574
|
+
|
|
575
|
+
return result;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// =============================================================================
|
|
579
|
+
// Core: Send message to pi via RPC
|
|
580
|
+
// =============================================================================
|
|
581
|
+
|
|
582
|
+
function extractTextFromMessage(message: any): string {
|
|
583
|
+
if (!message) return "";
|
|
584
|
+
const content = message.content;
|
|
585
|
+
if (typeof content === "string") return content;
|
|
586
|
+
if (Array.isArray(content)) {
|
|
587
|
+
return content
|
|
588
|
+
.filter((c: any) => c?.type === "text" && typeof c.text === "string")
|
|
589
|
+
.map((c: any) => c.text)
|
|
590
|
+
.join("\n\n");
|
|
591
|
+
}
|
|
592
|
+
return "";
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async function sendToAgent(
|
|
596
|
+
message: string,
|
|
597
|
+
info: SessionInfo,
|
|
598
|
+
timeout: number,
|
|
599
|
+
onProgress?: (status: ProgressStatus) => void,
|
|
600
|
+
signal?: AbortSignal,
|
|
601
|
+
): Promise<SendResult> {
|
|
602
|
+
const piBin = process.env.PI_SPAR_PI_BIN || "pi";
|
|
603
|
+
|
|
604
|
+
const logger = new SessionLogger(info.id);
|
|
605
|
+
logger.info("session", "Starting sendToAgent", {
|
|
606
|
+
model: info.model,
|
|
607
|
+
timeout,
|
|
608
|
+
messageLength: message.length,
|
|
609
|
+
messagePreview: message.slice(0, 200) + (message.length > 200 ? "..." : ""),
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
const broadcaster = new EventBroadcaster(info.id);
|
|
613
|
+
broadcaster.start();
|
|
614
|
+
|
|
615
|
+
const startTime = Date.now();
|
|
616
|
+
const modelName = info.modelId;
|
|
617
|
+
const sessionId = info.id;
|
|
618
|
+
|
|
619
|
+
const updateProgress = (status: ProgressStatus) => {
|
|
620
|
+
status.elapsed = Math.floor((Date.now() - status.startTime) / 1000);
|
|
621
|
+
onProgress?.(status);
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
updateProgress({ model: modelName, sessionId, startTime, status: "thinking" });
|
|
625
|
+
|
|
626
|
+
const args = [
|
|
627
|
+
"--mode", "rpc",
|
|
628
|
+
"--no-extensions",
|
|
629
|
+
"--provider", info.provider,
|
|
630
|
+
"--model", info.modelId,
|
|
631
|
+
"--session", info.sessionFile,
|
|
632
|
+
];
|
|
633
|
+
|
|
634
|
+
if (info.thinking) {
|
|
635
|
+
args.push("--thinking", info.thinking);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (info.tools) {
|
|
639
|
+
args.push("--tools", info.tools);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
logger.info("spawn", "Spawning pi process", { bin: piBin, args });
|
|
643
|
+
|
|
644
|
+
const proc = spawn(piBin, args, {
|
|
645
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
646
|
+
env: { ...process.env },
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
let stderr = "";
|
|
650
|
+
proc.stderr?.on("data", (data) => {
|
|
651
|
+
const chunk = data.toString();
|
|
652
|
+
stderr += chunk;
|
|
653
|
+
logger.stderr(chunk);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
const rl = readline.createInterface({
|
|
657
|
+
input: proc.stdout!,
|
|
658
|
+
terminal: false,
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
let responseText = "";
|
|
662
|
+
let usage: SendResult["usage"];
|
|
663
|
+
let agentMessages: any[] = [];
|
|
664
|
+
let finished = false;
|
|
665
|
+
|
|
666
|
+
let reqId = 0;
|
|
667
|
+
const pending = new Map<string, { resolve: (data: any) => void; reject: (err: Error) => void }>();
|
|
668
|
+
|
|
669
|
+
function sendCommand(command: Record<string, unknown>): Promise<any> {
|
|
670
|
+
const id = `req-${++reqId}`;
|
|
671
|
+
const payload = JSON.stringify({ id, ...command });
|
|
672
|
+
proc.stdin!.write(payload + "\n");
|
|
673
|
+
return new Promise((resolve, reject) => {
|
|
674
|
+
pending.set(id, { resolve, reject });
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
let resolveDone!: () => void;
|
|
679
|
+
let rejectDone!: (err: Error) => void;
|
|
680
|
+
const donePromise = new Promise<void>((resolve, reject) => {
|
|
681
|
+
resolveDone = resolve;
|
|
682
|
+
rejectDone = reject;
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// Handle abort signal (user pressed Escape)
|
|
686
|
+
if (signal) {
|
|
687
|
+
if (signal.aborted) {
|
|
688
|
+
proc.kill("SIGTERM");
|
|
689
|
+
broadcaster.stop();
|
|
690
|
+
logger.close();
|
|
691
|
+
throw new Error("Cancelled");
|
|
692
|
+
}
|
|
693
|
+
signal.addEventListener("abort", () => {
|
|
694
|
+
logger.info("abort", "Cancelled by user");
|
|
695
|
+
finished = true;
|
|
696
|
+
proc.kill("SIGTERM");
|
|
697
|
+
rejectDone(new Error("Cancelled"));
|
|
698
|
+
}, { once: true });
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
rl.on("line", (line) => {
|
|
702
|
+
let event: any;
|
|
703
|
+
try {
|
|
704
|
+
event = JSON.parse(line);
|
|
705
|
+
} catch {
|
|
706
|
+
logger.debug("parse", "Non-JSON line from pi", { line: line.slice(0, 200) });
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
logger.rpcEvent(event);
|
|
711
|
+
broadcaster.broadcast(event);
|
|
712
|
+
|
|
713
|
+
if (event.type === "response") {
|
|
714
|
+
const waiter = event.id ? pending.get(event.id) : undefined;
|
|
715
|
+
if (event.id) pending.delete(event.id);
|
|
716
|
+
|
|
717
|
+
if (waiter) {
|
|
718
|
+
if (!event.success) {
|
|
719
|
+
const err = event.error || "Unknown error";
|
|
720
|
+
logger.error("rpc-response", `Command failed: ${err}`, { id: event.id });
|
|
721
|
+
waiter.reject(new Error(err));
|
|
722
|
+
} else {
|
|
723
|
+
waiter.resolve(event.data);
|
|
724
|
+
}
|
|
725
|
+
} else if (!event.success) {
|
|
726
|
+
const err = event.error || "Unknown error";
|
|
727
|
+
logger.error("rpc-response", `Untracked error response: ${err}`, event);
|
|
728
|
+
rejectDone(new Error(err));
|
|
729
|
+
}
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (event.type === "message_update") {
|
|
734
|
+
const delta = event.assistantMessageEvent;
|
|
735
|
+
if (delta?.type === "text_delta") {
|
|
736
|
+
responseText += delta.delta;
|
|
737
|
+
resetTimeout("text_delta");
|
|
738
|
+
updateProgress({ model: modelName, sessionId, startTime, status: "streaming" });
|
|
739
|
+
}
|
|
740
|
+
if (delta?.type === "thinking_delta") {
|
|
741
|
+
resetTimeout("thinking_delta");
|
|
742
|
+
updateProgress({ model: modelName, sessionId, startTime, status: "thinking" });
|
|
743
|
+
}
|
|
744
|
+
if (delta?.type === "error") {
|
|
745
|
+
const err = delta.reason ?? "Streaming error";
|
|
746
|
+
logger.error("streaming", `Streaming error: ${err}`, delta);
|
|
747
|
+
rejectDone(new Error(err));
|
|
748
|
+
}
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (event.type === "message_end") {
|
|
753
|
+
const msg = event.message;
|
|
754
|
+
if (msg?.errorMessage) {
|
|
755
|
+
logger.error("message-end", `Message error: ${msg.errorMessage}`, msg);
|
|
756
|
+
rejectDone(new Error(msg.errorMessage));
|
|
757
|
+
}
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (event.type === "tool_execution_start") {
|
|
762
|
+
resetTimeout(`tool_start:${event.toolName}`);
|
|
763
|
+
logger.info("tool", `Tool started: ${event.toolName}`, { args: event.args });
|
|
764
|
+
updateProgress({
|
|
765
|
+
model: modelName,
|
|
766
|
+
sessionId,
|
|
767
|
+
startTime,
|
|
768
|
+
status: "tool",
|
|
769
|
+
toolName: event.toolName,
|
|
770
|
+
toolArgs: JSON.stringify(event.args || {}).slice(0, 100)
|
|
771
|
+
});
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
if (event.type === "tool_execution_update" || event.type === "tool_execution_end") {
|
|
775
|
+
resetTimeout(`tool_${event.type}`);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (event.type === "agent_end") {
|
|
780
|
+
agentMessages = event.messages || [];
|
|
781
|
+
|
|
782
|
+
usage = { input: 0, output: 0, cost: 0 };
|
|
783
|
+
for (const msg of agentMessages) {
|
|
784
|
+
if (msg.role === "assistant" && msg.usage) {
|
|
785
|
+
usage.input += msg.usage.input || 0;
|
|
786
|
+
usage.output += msg.usage.output || 0;
|
|
787
|
+
usage.cost += msg.usage.cost?.total || 0;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if (responseText.trim() === "") {
|
|
792
|
+
const lastAssistant = [...agentMessages].reverse().find((m: any) => m?.role === "assistant");
|
|
793
|
+
responseText = extractTextFromMessage(lastAssistant);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
logger.info("complete", "Agent completed", {
|
|
797
|
+
usage,
|
|
798
|
+
responseLength: responseText.length,
|
|
799
|
+
messageCount: agentMessages.length,
|
|
800
|
+
});
|
|
801
|
+
finished = true;
|
|
802
|
+
resolveDone();
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
if (event.type === "hook_error") {
|
|
807
|
+
const err = `Hook error: ${event.error || "Unknown"}`;
|
|
808
|
+
logger.error("hook", err, event);
|
|
809
|
+
rejectDone(new Error(err));
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
proc.on("exit", (code, signal) => {
|
|
814
|
+
if (!finished) {
|
|
815
|
+
const err = `pi process exited unexpectedly (code=${code}, signal=${signal})`;
|
|
816
|
+
logger.error("exit", err, { code, signal, stderr });
|
|
817
|
+
rejectDone(new Error(`${err}\nStderr: ${stderr}`));
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
let timeoutHandle: ReturnType<typeof setTimeout>;
|
|
822
|
+
let timeoutReject: (err: Error) => void;
|
|
823
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
824
|
+
timeoutReject = reject;
|
|
825
|
+
timeoutHandle = setTimeout(() => {
|
|
826
|
+
const err = `Timeout after ${timeout}ms waiting for response`;
|
|
827
|
+
logger.error("timeout", err, { stderr, elapsed: Date.now() - startTime });
|
|
828
|
+
reject(new Error(`${err}\nStderr: ${stderr}`));
|
|
829
|
+
}, timeout);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
let lastResetAt = startTime;
|
|
833
|
+
let resetCount = 0;
|
|
834
|
+
|
|
835
|
+
function resetTimeout(reason: string) {
|
|
836
|
+
clearTimeout(timeoutHandle);
|
|
837
|
+
resetCount++;
|
|
838
|
+
const now = Date.now();
|
|
839
|
+
lastResetAt = now;
|
|
840
|
+
logger.debug("timeout-reset", `Reset #${resetCount}: ${reason}`, {
|
|
841
|
+
elapsed: now - startTime,
|
|
842
|
+
});
|
|
843
|
+
timeoutHandle = setTimeout(() => {
|
|
844
|
+
const err = `Timeout after ${timeout}ms of inactivity`;
|
|
845
|
+
logger.error("timeout", err, {
|
|
846
|
+
stderr,
|
|
847
|
+
elapsed: Date.now() - startTime,
|
|
848
|
+
resetCount,
|
|
849
|
+
});
|
|
850
|
+
timeoutReject(new Error(`${err}\nStderr: ${stderr}`));
|
|
851
|
+
}, timeout);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
try {
|
|
855
|
+
await Promise.race([sendCommand({ type: "get_state" }), timeoutPromise]);
|
|
856
|
+
await Promise.race([sendCommand({ type: "prompt", message }), timeoutPromise]);
|
|
857
|
+
await Promise.race([donePromise, timeoutPromise]);
|
|
858
|
+
|
|
859
|
+
logger.info("session", "sendToAgent completed successfully", { elapsed: Date.now() - startTime });
|
|
860
|
+
return {
|
|
861
|
+
response: responseText.trim(),
|
|
862
|
+
usage,
|
|
863
|
+
};
|
|
864
|
+
} catch (err: any) {
|
|
865
|
+
logger.error("session", `sendToAgent failed: ${err.message}`, {
|
|
866
|
+
elapsed: Date.now() - startTime,
|
|
867
|
+
stderr,
|
|
868
|
+
});
|
|
869
|
+
markSessionFailed(info.id, err.message);
|
|
870
|
+
throw err;
|
|
871
|
+
} finally {
|
|
872
|
+
clearTimeout(timeoutHandle!);
|
|
873
|
+
broadcaster.stop();
|
|
874
|
+
logger.close();
|
|
875
|
+
rl.close();
|
|
876
|
+
proc.stdin?.end();
|
|
877
|
+
proc.kill("SIGTERM");
|
|
878
|
+
}
|
|
879
|
+
}
|