@inceptionstack/roundhouse 0.2.2 → 0.3.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 +321 -9
- package/architecture.md +77 -8
- package/package.json +3 -1
- package/src/agents/pi.ts +433 -26
- package/src/agents/registry.ts +8 -0
- package/src/cli/cli.ts +384 -189
- package/src/cli/cron.ts +296 -0
- package/src/cli/doctor/checks/agent.ts +68 -0
- package/src/cli/doctor/checks/config.ts +88 -0
- package/src/cli/doctor/checks/credentials.ts +62 -0
- package/src/cli/doctor/checks/disk.ts +69 -0
- package/src/cli/doctor/checks/stt.ts +76 -0
- package/src/cli/doctor/checks/system.ts +86 -0
- package/src/cli/doctor/checks/systemd.ts +76 -0
- package/src/cli/doctor/output.ts +58 -0
- package/src/cli/doctor/runner.ts +142 -0
- package/src/cli/doctor/shell.ts +33 -0
- package/src/cli/doctor/types.ts +44 -0
- package/src/cli/doctor.ts +48 -0
- package/src/cli/setup-telegram.ts +148 -0
- package/src/cli/setup.ts +936 -0
- package/src/commands.ts +23 -0
- package/src/config.ts +188 -0
- package/src/cron/constants.ts +54 -0
- package/src/cron/durations.ts +33 -0
- package/src/cron/format.ts +139 -0
- package/src/cron/helpers.ts +30 -0
- package/src/cron/runner.ts +148 -0
- package/src/cron/schedule.ts +101 -0
- package/src/cron/scheduler.ts +295 -0
- package/src/cron/store.ts +125 -0
- package/src/cron/template.ts +89 -0
- package/src/cron/types.ts +76 -0
- package/src/gateway.ts +927 -18
- package/src/index.ts +1 -58
- package/src/memory/bootstrap.ts +98 -0
- package/src/memory/files.ts +100 -0
- package/src/memory/inject.ts +41 -0
- package/src/memory/lifecycle.ts +245 -0
- package/src/memory/policy.ts +122 -0
- package/src/memory/prompts.ts +42 -0
- package/src/memory/state.ts +43 -0
- package/src/memory/types.ts +90 -0
- package/src/notify/telegram.ts +48 -0
- package/src/types.ts +68 -1
- package/src/util.ts +28 -2
- package/src/voice/providers/whisper.ts +339 -0
- package/src/voice/stt-service.ts +284 -0
- package/src/voice/types.ts +63 -0
package/src/agents/pi.ts
CHANGED
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { mkdir } from "node:fs/promises";
|
|
10
|
-
import {
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { join, dirname } from "node:path";
|
|
11
12
|
import { homedir } from "node:os";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const __piAdapterDir = dirname(fileURLToPath(import.meta.url));
|
|
12
16
|
|
|
13
17
|
import {
|
|
14
18
|
AuthStorage,
|
|
@@ -19,8 +23,8 @@ import {
|
|
|
19
23
|
type AgentSessionEvent,
|
|
20
24
|
} from "@mariozechner/pi-coding-agent";
|
|
21
25
|
|
|
22
|
-
import type { AgentAdapter, AgentAdapterFactory, AgentResponse } from "../types";
|
|
23
|
-
import { threadIdToDir } from "../util";
|
|
26
|
+
import type { AgentAdapter, AgentAdapterFactory, AgentMessage, AgentResponse, AgentStreamEvent } from "../types";
|
|
27
|
+
import { DEBUG_STREAM, threadIdToDir } from "../util";
|
|
24
28
|
|
|
25
29
|
interface SessionEntry {
|
|
26
30
|
session: AgentSession;
|
|
@@ -45,10 +49,115 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
|
45
49
|
const authStorage = AuthStorage.create();
|
|
46
50
|
const modelRegistry = ModelRegistry.create(authStorage);
|
|
47
51
|
const sessions = new Map<string, SessionEntry>();
|
|
48
|
-
// Track in-flight session creation to prevent races
|
|
49
52
|
const creating = new Map<string, Promise<SessionEntry>>();
|
|
53
|
+
let memoryCapabilities: { hasMemoryExtension: boolean; memoryTools: string[]; extensions: string[] } | undefined;
|
|
50
54
|
let reapInterval: ReturnType<typeof setInterval> | undefined;
|
|
51
55
|
|
|
56
|
+
async function drainSessionEvents(session: AgentSession): Promise<void> {
|
|
57
|
+
// AgentSession._handleAgentEvent queues event processing on a private
|
|
58
|
+
// promise chain (_agentEventQueue). session.prompt() / agent.continue()
|
|
59
|
+
// resolve when the agent loop finishes, but NOT when the queue drains.
|
|
60
|
+
// We must await the queue so that:
|
|
61
|
+
// 1. agent_end extension handlers (e.g. pi-lgtm review) complete
|
|
62
|
+
// 2. followUp messages they queue are visible via hasQueuedMessages()
|
|
63
|
+
// 3. message_end events for custom messages reach our subscribe() handler
|
|
64
|
+
// BEFORE we unsubscribe in the finally block.
|
|
65
|
+
//
|
|
66
|
+
// WARNING: _agentEventQueue is a private field of AgentSession (not part
|
|
67
|
+
// of the public pi-coding-agent API). Tested against
|
|
68
|
+
// @mariozechner/pi-coding-agent version bundled via `latest` in
|
|
69
|
+
// package.json at the time of this commit. If upstream renames or changes
|
|
70
|
+
// this field, extension custom messages (e.g. pi-lgtm review bubbles)
|
|
71
|
+
// will stop reaching Telegram. The `if (queue)` check fails silently
|
|
72
|
+
// on purpose because a missing field is not fatal — it just reverts to
|
|
73
|
+
// the pre-fix race condition. A public `session.flushEvents()` or
|
|
74
|
+
// `session.waitForIdle()` upstream would obsolete this.
|
|
75
|
+
const queue = (session as unknown as { _agentEventQueue?: Promise<void> })._agentEventQueue;
|
|
76
|
+
if (queue) {
|
|
77
|
+
await queue;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function customContentToText(content: unknown): string {
|
|
82
|
+
if (typeof content === "string") return content;
|
|
83
|
+
if (Array.isArray(content)) {
|
|
84
|
+
return content
|
|
85
|
+
.filter((part): part is { type: "text"; text: string } =>
|
|
86
|
+
!!part && typeof part === "object" && (part as any).type === "text"
|
|
87
|
+
)
|
|
88
|
+
.map((part) => part.text)
|
|
89
|
+
.join("");
|
|
90
|
+
}
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Extract displayable text from a session event if it is an extension custom
|
|
96
|
+
* message (e.g. pi-lgtm review) with display=true. Returns null otherwise.
|
|
97
|
+
* Shared helper so promptStream() and doPrompt() use identical filter logic.
|
|
98
|
+
*/
|
|
99
|
+
function extractCustomMessage(event: AgentSessionEvent): { customType: string; content: string } | null {
|
|
100
|
+
if (event.type !== "message_end") return null;
|
|
101
|
+
const message = (event as any).message;
|
|
102
|
+
if (!message || message.role !== "custom" || !message.display) return null;
|
|
103
|
+
const content = customContentToText(message.content);
|
|
104
|
+
if (!content.trim()) return null;
|
|
105
|
+
const customType = message.customType ?? "";
|
|
106
|
+
return { customType, content };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function runPromptAndFollowUps(entry: SessionEntry, text: string, onDraining?: () => void, onDrainComplete?: () => void): Promise<void> {
|
|
110
|
+
await entry.session.prompt(text);
|
|
111
|
+
await drainSessionEvents(entry.session);
|
|
112
|
+
|
|
113
|
+
// Check for pending follow-up work AFTER drainSessionEvents — that's
|
|
114
|
+
// where agent_end extension handlers run and queue follow-up messages
|
|
115
|
+
// (e.g. pi-lgtm calls sendMessage with deliverAs: "followUp").
|
|
116
|
+
// The actual long wait is in the while loop's waitForIdle() below.
|
|
117
|
+
let notifiedDraining = false;
|
|
118
|
+
if (onDraining && (entry.session.isStreaming || entry.session.agent.hasQueuedMessages())) {
|
|
119
|
+
onDraining();
|
|
120
|
+
notifiedDraining = true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Loop until the session is fully idle. Two separate conditions can keep
|
|
124
|
+
// work in flight after the initial prompt resolves:
|
|
125
|
+
//
|
|
126
|
+
// (a) `hasQueuedMessages()` — an extension called `pi.sendMessage(...,
|
|
127
|
+
// { triggerTurn: true, deliverAs: "followUp" })` *while isStreaming
|
|
128
|
+
// was true* — pi queued onto `agent.followUp()`, so we manually
|
|
129
|
+
// drain it with `continue()`.
|
|
130
|
+
//
|
|
131
|
+
// (b) `isStreaming === true` — an extension called the same sendMessage
|
|
132
|
+
// *after isStreaming became false*. In that path pi's
|
|
133
|
+
// `sendCustomMessage` skips the queue entirely and calls
|
|
134
|
+
// `agent.prompt(appMessage)` directly as fire-and-forget, kicking
|
|
135
|
+
// off a brand-new agent run. `hasQueuedMessages()` returns false
|
|
136
|
+
// for this run, but `isStreaming` is true — we have to
|
|
137
|
+
// `waitForIdle()` so subscribers see the new run's events (e.g. the
|
|
138
|
+
// agent's reply to a pi-lgtm code review) before we unsubscribe.
|
|
139
|
+
//
|
|
140
|
+
// Without (b), pi CLI works (its subscriber stays attached across runs)
|
|
141
|
+
// but roundhouse delivers the review bubble then goes silent.
|
|
142
|
+
while (true) {
|
|
143
|
+
if (entry.session.isStreaming) {
|
|
144
|
+
await entry.session.agent.waitForIdle();
|
|
145
|
+
await drainSessionEvents(entry.session);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (entry.session.agent.hasQueuedMessages()) {
|
|
149
|
+
await entry.session.agent.continue();
|
|
150
|
+
await drainSessionEvents(entry.session);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (notifiedDraining && onDrainComplete) {
|
|
157
|
+
onDrainComplete();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
52
161
|
async function createSession(threadId: string): Promise<SessionEntry> {
|
|
53
162
|
const threadDir = join(sessionsDir, threadIdToDir(threadId));
|
|
54
163
|
await mkdir(threadDir, { recursive: true });
|
|
@@ -74,21 +183,56 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
|
74
183
|
modelRegistry,
|
|
75
184
|
});
|
|
76
185
|
|
|
186
|
+
if (result.extensionsResult.extensions.length > 0) {
|
|
187
|
+
console.log(
|
|
188
|
+
`[pi-agent] extensions loaded: ${result.extensionsResult.extensions.map((e: any) => e.name || e.path).join(", ")}`
|
|
189
|
+
);
|
|
190
|
+
} else {
|
|
191
|
+
console.log(`[pi-agent] no extensions loaded`);
|
|
192
|
+
}
|
|
193
|
+
if (result.extensionsResult.errors.length > 0) {
|
|
194
|
+
for (const err of result.extensionsResult.errors) {
|
|
195
|
+
console.warn(`[pi-agent] extension error: ${err.path}: ${err.error}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
77
199
|
if (result.modelFallbackMessage) {
|
|
78
200
|
console.log(`[pi-agent] model fallback: ${result.modelFallbackMessage}`);
|
|
79
201
|
}
|
|
80
202
|
|
|
81
203
|
const entry: SessionEntry = { session: result.session, lastUsed: Date.now() };
|
|
82
204
|
sessions.set(threadId, entry);
|
|
205
|
+
|
|
206
|
+
// Detect memory capabilities from loaded extensions (first session only)
|
|
207
|
+
if (!memoryCapabilities) {
|
|
208
|
+
const allTools = new Set<string>();
|
|
209
|
+
const extNames: string[] = [];
|
|
210
|
+
for (const ext of result.extensionsResult.extensions) {
|
|
211
|
+
extNames.push(ext.sourceInfo?.source || ext.path);
|
|
212
|
+
for (const toolName of ext.tools.keys()) {
|
|
213
|
+
allTools.add(toolName);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
memoryCapabilities = {
|
|
217
|
+
hasMemoryExtension: allTools.has("memory_search") || allTools.has("memory_remember"),
|
|
218
|
+
memoryTools: ["memory_search", "memory_remember", "memory_forget", "memory_lessons", "memory_stats"]
|
|
219
|
+
.filter(t => allTools.has(t)),
|
|
220
|
+
extensions: extNames,
|
|
221
|
+
};
|
|
222
|
+
if (memoryCapabilities.hasMemoryExtension) {
|
|
223
|
+
console.log(`[pi-agent] memory extension detected (tools: ${memoryCapabilities.memoryTools.join(", ")})`);
|
|
224
|
+
} else {
|
|
225
|
+
console.log(`[pi-agent] no memory extension detected — roundhouse memory will manage`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
83
229
|
return entry;
|
|
84
230
|
}
|
|
85
231
|
|
|
86
232
|
async function getOrCreate(threadId: string): Promise<SessionEntry> {
|
|
87
|
-
// Fast path: already created
|
|
88
233
|
const existing = sessions.get(threadId);
|
|
89
234
|
if (existing) return existing;
|
|
90
235
|
|
|
91
|
-
// Prevent concurrent creation for the same threadId
|
|
92
236
|
let pending = creating.get(threadId);
|
|
93
237
|
if (pending) return pending;
|
|
94
238
|
|
|
@@ -110,34 +254,173 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
|
110
254
|
}
|
|
111
255
|
}
|
|
112
256
|
|
|
113
|
-
// Start reaper (unref so it doesn't prevent Node from exiting)
|
|
114
257
|
reapInterval = setInterval(reap, 60_000);
|
|
115
258
|
reapInterval.unref();
|
|
116
259
|
|
|
260
|
+
// Per-thread serialization for both prompt() and promptStream()
|
|
261
|
+
const threadQueues = new Map<string, Promise<any>>();
|
|
262
|
+
|
|
263
|
+
function enqueue<T>(threadId: string, fn: () => Promise<T>): Promise<T> {
|
|
264
|
+
const previous = threadQueues.get(threadId) ?? Promise.resolve();
|
|
265
|
+
const current = previous.catch(() => {}).then(fn);
|
|
266
|
+
threadQueues.set(threadId, current);
|
|
267
|
+
return current.finally(() => {
|
|
268
|
+
if (threadQueues.get(threadId) === current) {
|
|
269
|
+
threadQueues.delete(threadId);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Format an AgentMessage into the text string sent to the Pi session.
|
|
276
|
+
* Attachments are rendered as a fenced JSON manifest appended to the user text.
|
|
277
|
+
*/
|
|
278
|
+
function formatMessage(message: AgentMessage): string {
|
|
279
|
+
let text = message.text;
|
|
280
|
+
if (message.attachments?.length) {
|
|
281
|
+
const manifest = JSON.stringify(
|
|
282
|
+
message.attachments.map((a) => {
|
|
283
|
+
const entry: Record<string, unknown> = {
|
|
284
|
+
id: a.id,
|
|
285
|
+
type: a.mediaType,
|
|
286
|
+
name: a.name,
|
|
287
|
+
localPath: a.localPath,
|
|
288
|
+
mime: a.mime,
|
|
289
|
+
sizeBytes: a.sizeBytes,
|
|
290
|
+
untrusted: true,
|
|
291
|
+
};
|
|
292
|
+
if (a.transcript?.status === "completed" && a.transcript.text) {
|
|
293
|
+
entry.transcript = {
|
|
294
|
+
text: a.transcript.text,
|
|
295
|
+
language: a.transcript.language,
|
|
296
|
+
provider: a.transcript.provider,
|
|
297
|
+
approximate: true,
|
|
298
|
+
};
|
|
299
|
+
} else if (a.transcript?.status === "failed") {
|
|
300
|
+
entry.transcript = {
|
|
301
|
+
status: "failed",
|
|
302
|
+
error: a.transcript.error,
|
|
303
|
+
approximate: true,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
return entry;
|
|
307
|
+
}),
|
|
308
|
+
null,
|
|
309
|
+
2,
|
|
310
|
+
);
|
|
311
|
+
const block = [
|
|
312
|
+
"Chat attachments saved locally. Inspect files with tools before making claims. Transcripts are approximate; use the raw file if exact wording matters.",
|
|
313
|
+
"```json",
|
|
314
|
+
manifest,
|
|
315
|
+
"```",
|
|
316
|
+
].join("\n");
|
|
317
|
+
text = text ? `${text}\n\n${block}` : block;
|
|
318
|
+
}
|
|
319
|
+
return text;
|
|
320
|
+
}
|
|
321
|
+
|
|
117
322
|
const adapter: AgentAdapter = {
|
|
118
323
|
name: "pi",
|
|
119
324
|
|
|
120
|
-
async prompt(threadId: string,
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
let fullText = "";
|
|
125
|
-
const unsub = entry.session.subscribe((event: AgentSessionEvent) => {
|
|
126
|
-
if (
|
|
127
|
-
event.type === "message_update" &&
|
|
128
|
-
event.assistantMessageEvent.type === "text_delta"
|
|
129
|
-
) {
|
|
130
|
-
fullText += event.assistantMessageEvent.delta;
|
|
131
|
-
}
|
|
132
|
-
});
|
|
325
|
+
async prompt(threadId: string, message: AgentMessage): Promise<AgentResponse> {
|
|
326
|
+
return enqueue(threadId, () => doPrompt(threadId, formatMessage(message)));
|
|
327
|
+
},
|
|
133
328
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
329
|
+
promptStream(threadId: string, message: AgentMessage): AsyncIterable<AgentStreamEvent> {
|
|
330
|
+
const text = formatMessage(message);
|
|
331
|
+
// Return an async iterable that is single-use by design.
|
|
332
|
+
// State is scoped inside the iterator factory to prevent sharing.
|
|
333
|
+
let consumed = false;
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
[Symbol.asyncIterator]() {
|
|
337
|
+
if (consumed) throw new Error("promptStream() iterable can only be consumed once");
|
|
338
|
+
consumed = true;
|
|
339
|
+
|
|
340
|
+
let eventQueue: AgentStreamEvent[] = [];
|
|
341
|
+
let resolve: (() => void) | null = null;
|
|
342
|
+
let done = false;
|
|
343
|
+
let error: Error | null = null;
|
|
344
|
+
|
|
345
|
+
// Start the prompt in the thread queue
|
|
346
|
+
const promptDone = enqueue(threadId, async () => {
|
|
347
|
+
const entry = await getOrCreate(threadId);
|
|
348
|
+
entry.lastUsed = Date.now();
|
|
349
|
+
|
|
350
|
+
const unsub = entry.session.subscribe((event: AgentSessionEvent) => {
|
|
351
|
+
if (DEBUG_STREAM) {
|
|
352
|
+
const extra =
|
|
353
|
+
event.type === "message_end" || event.type === "message_start"
|
|
354
|
+
? ` role=${(event as any).message?.role}`
|
|
355
|
+
: event.type === "message_update"
|
|
356
|
+
? ` subType=${(event as any).assistantMessageEvent?.type}`
|
|
357
|
+
: "";
|
|
358
|
+
console.log(`[pi-agent/sub] event=${event.type}${extra}`);
|
|
359
|
+
}
|
|
360
|
+
let streamEvent: AgentStreamEvent | null = null;
|
|
361
|
+
|
|
362
|
+
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
363
|
+
streamEvent = { type: "text_delta", text: event.assistantMessageEvent.delta };
|
|
364
|
+
} else {
|
|
365
|
+
const custom = extractCustomMessage(event);
|
|
366
|
+
if (custom) {
|
|
367
|
+
streamEvent = { type: "custom_message", customType: custom.customType, content: custom.content };
|
|
368
|
+
} else if (event.type === "tool_execution_start") {
|
|
369
|
+
streamEvent = { type: "tool_start", toolName: event.toolName, toolCallId: event.toolCallId };
|
|
370
|
+
} else if (event.type === "tool_execution_end") {
|
|
371
|
+
streamEvent = { type: "tool_end", toolName: event.toolName, toolCallId: event.toolCallId, isError: event.isError };
|
|
372
|
+
} else if (event.type === "turn_end") {
|
|
373
|
+
streamEvent = { type: "turn_end" };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
139
376
|
|
|
140
|
-
|
|
377
|
+
if (streamEvent) {
|
|
378
|
+
eventQueue.push(streamEvent);
|
|
379
|
+
resolve?.();
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
await runPromptAndFollowUps(entry, text, () => {
|
|
385
|
+
eventQueue.push({ type: "draining" });
|
|
386
|
+
resolve?.();
|
|
387
|
+
}, () => {
|
|
388
|
+
eventQueue.push({ type: "drain_complete" });
|
|
389
|
+
resolve?.();
|
|
390
|
+
});
|
|
391
|
+
// Final drain — guarantees all subscriber events have been delivered
|
|
392
|
+
// before we unsubscribe below.
|
|
393
|
+
await drainSessionEvents(entry.session);
|
|
394
|
+
} finally {
|
|
395
|
+
unsub();
|
|
396
|
+
eventQueue.push({ type: "agent_end" });
|
|
397
|
+
done = true;
|
|
398
|
+
resolve?.();
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
promptDone.catch((err) => {
|
|
403
|
+
error = err instanceof Error ? err : new Error(String(err));
|
|
404
|
+
done = true;
|
|
405
|
+
resolve?.();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
async next(): Promise<IteratorResult<AgentStreamEvent>> {
|
|
410
|
+
while (true) {
|
|
411
|
+
if (eventQueue.length > 0) {
|
|
412
|
+
return { value: eventQueue.shift()!, done: false };
|
|
413
|
+
}
|
|
414
|
+
if (error) throw error;
|
|
415
|
+
if (done) return { value: undefined as any, done: true };
|
|
416
|
+
// Wait for next event
|
|
417
|
+
await new Promise<void>((r) => { resolve = r; });
|
|
418
|
+
resolve = null;
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
},
|
|
423
|
+
};
|
|
141
424
|
},
|
|
142
425
|
|
|
143
426
|
async dispose(): Promise<void> {
|
|
@@ -147,8 +430,132 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
|
147
430
|
}
|
|
148
431
|
sessions.clear();
|
|
149
432
|
creating.clear();
|
|
433
|
+
threadQueues.clear();
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
async restart(threadId: string): Promise<void> {
|
|
437
|
+
await enqueue(threadId, async () => {
|
|
438
|
+
const existing = sessions.get(threadId);
|
|
439
|
+
if (existing) {
|
|
440
|
+
existing.session.dispose();
|
|
441
|
+
sessions.delete(threadId);
|
|
442
|
+
console.log(`[pi-agent] disposed session for ${threadId}`);
|
|
443
|
+
}
|
|
444
|
+
memoryCapabilities = undefined; // re-detect on next session creation
|
|
445
|
+
// Next prompt() or promptStream() call will create a fresh session
|
|
446
|
+
});
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
async compact(threadId: string): Promise<{ tokensBefore: number; tokensAfter: number | null } | null> {
|
|
450
|
+
return enqueue(threadId, async () => {
|
|
451
|
+
const entry = sessions.get(threadId);
|
|
452
|
+
if (!entry) return null;
|
|
453
|
+
|
|
454
|
+
const result = await entry.session.compact();
|
|
455
|
+
const usage = entry.session.getContextUsage();
|
|
456
|
+
return {
|
|
457
|
+
tokensBefore: result.tokensBefore,
|
|
458
|
+
tokensAfter: usage?.tokens ?? null,
|
|
459
|
+
};
|
|
460
|
+
});
|
|
461
|
+
},
|
|
462
|
+
|
|
463
|
+
async abort(threadId: string): Promise<void> {
|
|
464
|
+
const entry = sessions.get(threadId);
|
|
465
|
+
if (entry) {
|
|
466
|
+
await entry.session.abort();
|
|
467
|
+
entry.session.abortCompaction();
|
|
468
|
+
console.log(`[pi-agent] aborted session for ${threadId}`);
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
|
|
472
|
+
getInfo(threadId?: string): Record<string, unknown> {
|
|
473
|
+
// Get model from the requested thread's session, or most recently used
|
|
474
|
+
let modelInfo: string | undefined;
|
|
475
|
+
let contextUsage: { tokens: number | null; contextWindow: number; percent: number | null } | undefined;
|
|
476
|
+
const threadEntry = threadId ? sessions.get(threadId) : undefined;
|
|
477
|
+
|
|
478
|
+
if (threadEntry) {
|
|
479
|
+
const model = threadEntry.session.model;
|
|
480
|
+
if (model) modelInfo = `${model.provider}/${model.id}`;
|
|
481
|
+
contextUsage = threadEntry.session.getContextUsage() ?? undefined;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (!modelInfo) {
|
|
485
|
+
let latestUsed = 0;
|
|
486
|
+
for (const [, entry] of sessions) {
|
|
487
|
+
if (entry.lastUsed > latestUsed) {
|
|
488
|
+
latestUsed = entry.lastUsed;
|
|
489
|
+
const model = entry.session.model;
|
|
490
|
+
if (model) modelInfo = `${model.provider}/${model.id}`;
|
|
491
|
+
if (!contextUsage) contextUsage = entry.session.getContextUsage() ?? undefined;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Fall back to configured default from settings.json
|
|
497
|
+
if (!modelInfo) {
|
|
498
|
+
try {
|
|
499
|
+
const settingsPath = join(homedir(), ".pi", "agent", "settings.json");
|
|
500
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
501
|
+
if (settings.defaultProvider && settings.defaultModel) {
|
|
502
|
+
modelInfo = `${settings.defaultProvider}/${settings.defaultModel}`;
|
|
503
|
+
}
|
|
504
|
+
} catch (err) {
|
|
505
|
+
console.warn(`[pi-agent] could not read settings.json for model info:`, (err as Error).message);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Read agent version
|
|
510
|
+
let version = "unknown";
|
|
511
|
+
try {
|
|
512
|
+
const piPkgPath = join(__piAdapterDir, "..", "..", "node_modules", "@mariozechner", "pi-coding-agent", "package.json");
|
|
513
|
+
version = JSON.parse(readFileSync(piPkgPath, "utf8")).version;
|
|
514
|
+
} catch {}
|
|
515
|
+
|
|
516
|
+
return {
|
|
517
|
+
version,
|
|
518
|
+
model: modelInfo ?? "unknown",
|
|
519
|
+
activeSessions: sessions.size,
|
|
520
|
+
cwd,
|
|
521
|
+
contextTokens: contextUsage?.tokens ?? null,
|
|
522
|
+
contextWindow: contextUsage?.contextWindow ?? null,
|
|
523
|
+
contextPercent: contextUsage?.percent ?? null,
|
|
524
|
+
hasMemoryExtension: memoryCapabilities?.hasMemoryExtension ?? null,
|
|
525
|
+
memoryTools: memoryCapabilities?.memoryTools ?? [],
|
|
526
|
+
extensions: memoryCapabilities?.extensions ?? [],
|
|
527
|
+
};
|
|
150
528
|
},
|
|
151
529
|
};
|
|
152
530
|
|
|
531
|
+
async function doPrompt(threadId: string, text: string): Promise<AgentResponse> {
|
|
532
|
+
const entry = await getOrCreate(threadId);
|
|
533
|
+
entry.lastUsed = Date.now();
|
|
534
|
+
|
|
535
|
+
let fullText = "";
|
|
536
|
+
const unsub = entry.session.subscribe((event: AgentSessionEvent) => {
|
|
537
|
+
if (
|
|
538
|
+
event.type === "message_update" &&
|
|
539
|
+
event.assistantMessageEvent.type === "text_delta"
|
|
540
|
+
) {
|
|
541
|
+
fullText += event.assistantMessageEvent.delta;
|
|
542
|
+
} else {
|
|
543
|
+
const custom = extractCustomMessage(event);
|
|
544
|
+
if (custom) {
|
|
545
|
+
fullText += "\n\n" + custom.content;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
try {
|
|
551
|
+
await runPromptAndFollowUps(entry, text);
|
|
552
|
+
await drainSessionEvents(entry.session);
|
|
553
|
+
} finally {
|
|
554
|
+
unsub();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return { text: fullText };
|
|
558
|
+
}
|
|
559
|
+
|
|
153
560
|
return adapter;
|
|
154
561
|
};
|
package/src/agents/registry.ts
CHANGED
|
@@ -9,9 +9,12 @@ import type { AgentAdapterFactory } from "../types";
|
|
|
9
9
|
import { createPiAgentAdapter } from "./pi";
|
|
10
10
|
|
|
11
11
|
const registry = new Map<string, AgentAdapterFactory>();
|
|
12
|
+
const sdkPackages = new Map<string, string>();
|
|
12
13
|
|
|
13
14
|
registry.set("pi", createPiAgentAdapter);
|
|
15
|
+
sdkPackages.set("pi", "@mariozechner/pi-coding-agent");
|
|
14
16
|
// registry.set("kiro", createKiroAgentAdapter);
|
|
17
|
+
// sdkPackages.set("kiro", "@kiro/...");
|
|
15
18
|
|
|
16
19
|
export function getAgentFactory(type: string): AgentAdapterFactory {
|
|
17
20
|
const factory = registry.get(type);
|
|
@@ -23,3 +26,8 @@ export function getAgentFactory(type: string): AgentAdapterFactory {
|
|
|
23
26
|
}
|
|
24
27
|
return factory;
|
|
25
28
|
}
|
|
29
|
+
|
|
30
|
+
/** Get the npm package name for an agent type's SDK (for version display) */
|
|
31
|
+
export function getAgentSdkPackage(type: string): string | undefined {
|
|
32
|
+
return sdkPackages.get(type);
|
|
33
|
+
}
|