@fickydev/pigent 0.1.12 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +8 -0
- package/TODO.md +6 -1
- package/drizzle/migrations/0004_active_agent_sessions.sql +4 -0
- package/drizzle/migrations/0005_pi_session_path.sql +1 -0
- package/drizzle/migrations/meta/_journal.json +14 -0
- package/package.json +1 -1
- package/src/agents/AgentRunner.ts +9 -2
- package/src/agents/BotCommandHandler.ts +147 -4
- package/src/daemon/AgentDaemon.ts +2 -0
- package/src/db/repositories/MessageRepository.ts +24 -0
- package/src/db/repositories/SessionRepository.ts +69 -26
- package/src/db/schema.ts +6 -2
- package/src/pi/PiAgentRunner.ts +55 -13
- package/src/pi/PiSessionFactory.ts +49 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.1.14 - 2026-05-18
|
|
6
|
+
|
|
5
7
|
### Added
|
|
6
8
|
|
|
7
9
|
- Added `task_runs` table and `TaskRepository` for scheduled task persistence.
|
|
@@ -59,6 +61,10 @@
|
|
|
59
61
|
- Planned Telegram inline-button model and thinking level pickers backed by configured model choices.
|
|
60
62
|
- Added Telegram inline-button pickers for `/model` and `/thinking`, callback handling, and bot command menu registration.
|
|
61
63
|
- Added `/task` to Telegram bot command list (shows subcommands in help text).
|
|
64
|
+
- Added `/new` Telegram command to start a fresh active chat session.
|
|
65
|
+
- Added `/status` Telegram command with session/model/workspace/task info and estimated context usage.
|
|
66
|
+
- Added persistent Pi session files per Pigent session so chat memory survives across messages and daemon restarts.
|
|
67
|
+
- Added Pi session metadata to `/status`, with real Pi context usage when available.
|
|
62
68
|
- Changed `/model` picker to use Pi's currently available models automatically when explicit `modelChoices` are not configured.
|
|
63
69
|
- Kept daemon process alive after startup so CLI runs do not exit after `pigent ready`.
|
|
64
70
|
|
package/README.md
CHANGED
|
@@ -354,6 +354,13 @@ Available Telegram commands:
|
|
|
354
354
|
/help
|
|
355
355
|
/start
|
|
356
356
|
/agents
|
|
357
|
+
/new
|
|
358
|
+
/status
|
|
359
|
+
/model
|
|
360
|
+
/thinking
|
|
361
|
+
/task list
|
|
362
|
+
/task create <intervalMs> <prompt>
|
|
363
|
+
/task remove <id>
|
|
357
364
|
/agent <agentId> <message>
|
|
358
365
|
```
|
|
359
366
|
|
|
@@ -373,6 +380,7 @@ Planned commands:
|
|
|
373
380
|
- Keep Telegram-specific logic inside `src/channels/telegram` or routing config.
|
|
374
381
|
- Channel adapters must not call Pi directly.
|
|
375
382
|
- Pi runner must not know channel secrets.
|
|
383
|
+
- Pi sessions are persisted under `~/.pigent/pi-sessions` by default (`PIGENT_PI_SESSION_DIR` overrides this).
|
|
376
384
|
- Use repositories for database access.
|
|
377
385
|
- Keep SQLite/PostgreSQL portability in mind.
|
|
378
386
|
- Never inject secrets or raw `.env` values into model prompts.
|
package/TODO.md
CHANGED
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
- [x] Define `runtime_kv` table for offsets and daemon state
|
|
73
73
|
- [x] Add `agent_sessions.model` for session model override
|
|
74
74
|
- [x] Add `agent_sessions.thinking_level` for session thinking override
|
|
75
|
+
- [x] Add active/inactive session lifecycle fields for `/new` sessions
|
|
75
76
|
- [x] Implement repositories
|
|
76
77
|
- [x] `AgentRepository`
|
|
77
78
|
- [x] `SessionRepository`
|
|
@@ -138,7 +139,9 @@
|
|
|
138
139
|
- [x] Apply session model override
|
|
139
140
|
- [x] Apply thinking level selection
|
|
140
141
|
- [x] Confirm SDK APIs needed for session creation
|
|
141
|
-
- [
|
|
142
|
+
- [x] Confirm persistent session manager approach
|
|
143
|
+
- [x] Persist Pi session file path per active Pigent session
|
|
144
|
+
- [x] Reuse Pi session files across messages
|
|
142
145
|
- [ ] Confirm per-agent system prompt injection
|
|
143
146
|
- [ ] Confirm per-agent skills loading
|
|
144
147
|
- [ ] Confirm per-agent extensions loading
|
|
@@ -183,6 +186,8 @@
|
|
|
183
186
|
- [ ] `/default-agent <id>` set chat default
|
|
184
187
|
- [ ] `/instructions <text>` set chat instructions
|
|
185
188
|
- [ ] `/sessions` list active sessions for chat
|
|
189
|
+
- [x] `/new` start a fresh active chat session
|
|
190
|
+
- [x] `/status` show current session, model, workspace, tasks, and estimated context usage
|
|
186
191
|
- [ ] `/reset-session <agentId>` clear chat session
|
|
187
192
|
- [ ] `/heartbeat status` show heartbeat state
|
|
188
193
|
- [x] `/model` show current model for default chat agent session
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
ALTER TABLE `agent_sessions` ADD `active` integer DEFAULT true NOT NULL;
|
|
2
|
+
ALTER TABLE `agent_sessions` ADD `ended_at` integer;
|
|
3
|
+
DROP INDEX `agent_sessions_key_unique`;
|
|
4
|
+
CREATE INDEX `agent_sessions_active_key_idx` ON `agent_sessions` (`agent_id`, `channel`, `chat_id`, `thread_id`, `active`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE `agent_sessions` ADD `pi_session_path` text;
|
|
@@ -29,6 +29,20 @@
|
|
|
29
29
|
"when": 1779097464805,
|
|
30
30
|
"tag": "0003_secret_stone_men",
|
|
31
31
|
"breakpoints": true
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"idx": 4,
|
|
35
|
+
"version": "6",
|
|
36
|
+
"when": 1779099600000,
|
|
37
|
+
"tag": "0004_active_agent_sessions",
|
|
38
|
+
"breakpoints": true
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"idx": 5,
|
|
42
|
+
"version": "6",
|
|
43
|
+
"when": 1779099900000,
|
|
44
|
+
"tag": "0005_pi_session_path",
|
|
45
|
+
"breakpoints": true
|
|
32
46
|
}
|
|
33
47
|
]
|
|
34
48
|
}
|
package/package.json
CHANGED
|
@@ -104,14 +104,21 @@ export class AgentRunner {
|
|
|
104
104
|
if (!agent) return { text: `Unknown agent: ${input.agentId}` };
|
|
105
105
|
|
|
106
106
|
try {
|
|
107
|
-
const
|
|
107
|
+
const result = await this.piRunner.run({
|
|
108
108
|
agent,
|
|
109
109
|
profile: this.registry.getProfile(agent.profile),
|
|
110
110
|
session,
|
|
111
111
|
prompt: this.composePrompt(input, chatInstructions),
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
if (session.piSessionId !== result.piSessionId || session.piSessionPath !== result.piSessionPath) {
|
|
115
|
+
await this.repositories.sessions.updatePiSession(session.id, {
|
|
116
|
+
piSessionId: result.piSessionId,
|
|
117
|
+
piSessionPath: result.piSessionPath,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { text: result.text };
|
|
115
122
|
} catch (error) {
|
|
116
123
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
117
124
|
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
1
2
|
import type { InboundMessage, InlineKeyboardButton } from "../channels/types";
|
|
2
|
-
import type { ModelChoiceConfig } from "../config/schemas";
|
|
3
|
+
import type { LoadedAgentConfig, ModelChoiceConfig, ProfileConfig } from "../config/schemas";
|
|
4
|
+
import type { AgentSessionRow } from "../db/schema";
|
|
3
5
|
import type { Repositories } from "../db/repositories";
|
|
6
|
+
import { PiAgentRunner, type PiContextUsage } from "../pi/PiAgentRunner";
|
|
4
7
|
import { getAvailableModelChoices } from "../pi/PiAvailableModels";
|
|
5
|
-
import { isValidModelRef } from "../pi/PiModelResolver";
|
|
8
|
+
import { isValidModelRef, resolveModelSelection } from "../pi/PiModelResolver";
|
|
6
9
|
import type { AgentRegistry } from "./AgentRegistry";
|
|
7
10
|
import type { Scheduler } from "../daemon/Scheduler";
|
|
8
11
|
|
|
@@ -20,6 +23,8 @@ const THINKING_LEVELS = ["off", "low", "medium", "high"] as const;
|
|
|
20
23
|
type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
21
24
|
|
|
22
25
|
export class BotCommandHandler {
|
|
26
|
+
private readonly piRunner = new PiAgentRunner();
|
|
27
|
+
|
|
23
28
|
constructor(
|
|
24
29
|
private readonly registry: AgentRegistry,
|
|
25
30
|
private readonly repositories: Repositories,
|
|
@@ -38,6 +43,10 @@ export class BotCommandHandler {
|
|
|
38
43
|
return { handled: true, text: this.helpText() };
|
|
39
44
|
case "/agents":
|
|
40
45
|
return { handled: true, text: await this.agentsText(message) };
|
|
46
|
+
case "/new":
|
|
47
|
+
return { handled: true, text: await this.newSessionText(message) };
|
|
48
|
+
case "/status":
|
|
49
|
+
return { handled: true, text: await this.statusText(message) };
|
|
41
50
|
case "/model":
|
|
42
51
|
return await this.modelResult(message);
|
|
43
52
|
case "/thinking":
|
|
@@ -60,6 +69,8 @@ export class BotCommandHandler {
|
|
|
60
69
|
"Pigent commands:",
|
|
61
70
|
"/help - show this help",
|
|
62
71
|
"/agents - list agents for this chat",
|
|
72
|
+
"/new - start a fresh session for this chat",
|
|
73
|
+
"/status - show current session status",
|
|
63
74
|
"/model - show model picker for this chat session",
|
|
64
75
|
"/model <provider/modelId> - set model for this chat session",
|
|
65
76
|
"/model default - clear model override for this chat session",
|
|
@@ -96,6 +107,90 @@ export class BotCommandHandler {
|
|
|
96
107
|
].join("\n");
|
|
97
108
|
}
|
|
98
109
|
|
|
110
|
+
private async newSessionText(message: InboundMessage): Promise<string> {
|
|
111
|
+
const sessionResult = await this.getDefaultSession(message);
|
|
112
|
+
if (!sessionResult.ok) return sessionResult.message;
|
|
113
|
+
|
|
114
|
+
const session = await this.repositories.sessions.startNew({
|
|
115
|
+
agentId: sessionResult.agentId,
|
|
116
|
+
channel: message.channel,
|
|
117
|
+
chatId: message.chatId,
|
|
118
|
+
threadId: message.threadId,
|
|
119
|
+
userId: message.senderId,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return [
|
|
123
|
+
"New session created.",
|
|
124
|
+
"",
|
|
125
|
+
"Agent: " + session.agentId,
|
|
126
|
+
"Session: " + session.id,
|
|
127
|
+
"Model: " + (session.model ?? "default"),
|
|
128
|
+
"Thinking: " + (session.thinkingLevel ?? "default"),
|
|
129
|
+
"Workspace: " + formatPath(sessionResult.agent.workspace),
|
|
130
|
+
].join("\n");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async statusText(message: InboundMessage): Promise<string> {
|
|
134
|
+
const sessionResult = await this.getDefaultSession(message);
|
|
135
|
+
if (!sessionResult.ok) return sessionResult.message;
|
|
136
|
+
|
|
137
|
+
const agent = sessionResult.agent;
|
|
138
|
+
const profile = this.registry.getProfile(agent.profile);
|
|
139
|
+
const messageStats = await this.repositories.messages.statsBySession(sessionResult.session.id);
|
|
140
|
+
const modelInfo = this.resolveStatusModel(sessionResult.session, agent, profile);
|
|
141
|
+
const piStatus = await this.loadPiStatus(sessionResult.session, agent, profile);
|
|
142
|
+
const contextText = piStatus?.contextUsage
|
|
143
|
+
? formatPiContext(piStatus.contextUsage)
|
|
144
|
+
: formatEstimatedContext(messageStats.estimatedTokens, modelInfo.contextWindow);
|
|
145
|
+
const contextSource = piStatus?.contextUsage ? "Pi session" : "Pigent DB estimate";
|
|
146
|
+
const taskCount = this.scheduler
|
|
147
|
+
? this.scheduler.getTasks().filter((task) => task.chatId === message.chatId || !task.chatId).length
|
|
148
|
+
: null;
|
|
149
|
+
|
|
150
|
+
return [
|
|
151
|
+
"Pigent status",
|
|
152
|
+
"",
|
|
153
|
+
"Agent: " + agent.id,
|
|
154
|
+
"Profile: " + agent.profile,
|
|
155
|
+
"Session: " + sessionResult.session.id,
|
|
156
|
+
"Session age: " + formatDuration(Date.now() - sessionResult.session.createdAt),
|
|
157
|
+
"Messages: " + messageStats.count,
|
|
158
|
+
"Last message: " + (messageStats.lastMessageAt ? formatDuration(Date.now() - messageStats.lastMessageAt) + " ago" : "none"),
|
|
159
|
+
"Model: " + modelInfo.model,
|
|
160
|
+
"Thinking: " + (sessionResult.session.thinkingLevel ?? agent.thinkingLevel ?? profile?.thinkingLevel ?? "default"),
|
|
161
|
+
"Context: " + contextText,
|
|
162
|
+
"Context source: " + contextSource,
|
|
163
|
+
"Pi session: " + (piStatus?.piSessionId ?? sessionResult.session.piSessionId ?? "not created"),
|
|
164
|
+
"Workspace: " + formatPath(agent.workspace),
|
|
165
|
+
"Chat: " + message.chatId,
|
|
166
|
+
"Thread: " + (message.threadId ?? "none"),
|
|
167
|
+
"Tasks: " + (taskCount === null ? "scheduler unavailable" : String(taskCount)),
|
|
168
|
+
"Fake runner: " + (process.env.PIGENT_FAKE_AGENT === "1" ? "on" : "off"),
|
|
169
|
+
].join("\n");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private async loadPiStatus(session: AgentSessionRow, agent: LoadedAgentConfig, profile: ProfileConfig | null) {
|
|
173
|
+
try {
|
|
174
|
+
return await this.piRunner.status({ agent, profile, session });
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private resolveStatusModel(session: { model: string | null }, agent: { model?: string | null }, profile: { model?: string | null } | null) {
|
|
181
|
+
const configuredModel = session.model ?? agent.model ?? profile?.model ?? null;
|
|
182
|
+
if (!configuredModel) return { model: "default", contextWindow: null as number | null };
|
|
183
|
+
|
|
184
|
+
const authStorage = AuthStorage.create();
|
|
185
|
+
const modelRegistry = ModelRegistry.create(authStorage);
|
|
186
|
+
const resolved = resolveModelSelection(modelRegistry, [session, agent, profile ?? {}]);
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
model: configuredModel,
|
|
190
|
+
contextWindow: resolved.model?.contextWindow ?? null,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
99
194
|
private async modelResult(message: InboundMessage): Promise<BotCommandResult> {
|
|
100
195
|
const sessionResult = await this.getDefaultSession(message);
|
|
101
196
|
if (!sessionResult.ok) return { handled: true, text: sessionResult.message };
|
|
@@ -343,7 +438,8 @@ export class BotCommandHandler {
|
|
|
343
438
|
return { ok: false as const, message: "No default agent configured for this chat." };
|
|
344
439
|
}
|
|
345
440
|
|
|
346
|
-
|
|
441
|
+
const agent = this.registry.getAgent(agentId);
|
|
442
|
+
if (!agent) {
|
|
347
443
|
return { ok: false as const, message: "Unknown agent: " + agentId };
|
|
348
444
|
}
|
|
349
445
|
|
|
@@ -359,7 +455,7 @@ export class BotCommandHandler {
|
|
|
359
455
|
userId: message.senderId,
|
|
360
456
|
});
|
|
361
457
|
|
|
362
|
-
return { ok: true as const, agentId, session };
|
|
458
|
+
return { ok: true as const, agentId, agent, session };
|
|
363
459
|
}
|
|
364
460
|
}
|
|
365
461
|
|
|
@@ -376,3 +472,50 @@ function chunk<T>(items: T[], size: number): T[][] {
|
|
|
376
472
|
|
|
377
473
|
return rows;
|
|
378
474
|
}
|
|
475
|
+
|
|
476
|
+
function formatPiContext(contextUsage: PiContextUsage): string {
|
|
477
|
+
if (contextUsage.percent === null || contextUsage.tokens === null) {
|
|
478
|
+
return "? / " + formatTokens(contextUsage.contextWindow);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return contextUsage.percent.toFixed(1) + "% / " + formatTokens(contextUsage.contextWindow);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function formatEstimatedContext(tokens: number, contextWindow: number | null): string {
|
|
485
|
+
if (!contextWindow || contextWindow <= 0) {
|
|
486
|
+
return "~" + formatTokens(tokens) + " tokens estimated / unknown limit";
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const percent = (tokens / contextWindow) * 100;
|
|
490
|
+
return "~" + percent.toFixed(1) + "% / " + formatTokens(contextWindow) + " tokens estimated";
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function formatTokens(count: number): string {
|
|
494
|
+
if (count < 1000) return String(count);
|
|
495
|
+
if (count < 10000) return (count / 1000).toFixed(1) + "k";
|
|
496
|
+
if (count < 1000000) return Math.round(count / 1000) + "k";
|
|
497
|
+
if (count < 10000000) return (count / 1000000).toFixed(1) + "M";
|
|
498
|
+
return Math.round(count / 1000000) + "M";
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function formatDuration(ms: number): string {
|
|
502
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
503
|
+
if (seconds < 60) return seconds + "s";
|
|
504
|
+
|
|
505
|
+
const minutes = Math.floor(seconds / 60);
|
|
506
|
+
if (minutes < 60) return minutes + "m";
|
|
507
|
+
|
|
508
|
+
const hours = Math.floor(minutes / 60);
|
|
509
|
+
if (hours < 48) return hours + "h " + (minutes % 60) + "m";
|
|
510
|
+
|
|
511
|
+
return Math.floor(hours / 24) + "d " + (hours % 24) + "h";
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function formatPath(path: string): string {
|
|
515
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
516
|
+
if (home && path.startsWith(home)) {
|
|
517
|
+
return "~" + path.slice(home.length);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return path;
|
|
521
|
+
}
|
|
@@ -178,6 +178,8 @@ function telegramCommands() {
|
|
|
178
178
|
return [
|
|
179
179
|
{ command: "help", description: "Show Pigent commands" },
|
|
180
180
|
{ command: "agents", description: "List agents available in this chat" },
|
|
181
|
+
{ command: "new", description: "Start a fresh chat session" },
|
|
182
|
+
{ command: "status", description: "Show current session status" },
|
|
181
183
|
{ command: "model", description: "Choose model for this chat session" },
|
|
182
184
|
{ command: "thinking", description: "Choose thinking level for this chat session" },
|
|
183
185
|
{ command: "agent", description: "Send message to a specific agent" },
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
1
2
|
import { nanoid } from "nanoid";
|
|
2
3
|
import type { DbClient } from "../client";
|
|
3
4
|
import { messages, type MessageRow } from "../schema";
|
|
@@ -42,4 +43,27 @@ export class MessageRepository {
|
|
|
42
43
|
if (!row) throw new Error(`failed to create message ${id}`);
|
|
43
44
|
return row;
|
|
44
45
|
}
|
|
46
|
+
|
|
47
|
+
async statsBySession(sessionId: string): Promise<{ count: number; estimatedTokens: number; lastMessageAt: number | null }> {
|
|
48
|
+
const rows = await this.db
|
|
49
|
+
.select({ content: messages.content, createdAt: messages.createdAt })
|
|
50
|
+
.from(messages)
|
|
51
|
+
.where(eq(messages.sessionId, sessionId));
|
|
52
|
+
|
|
53
|
+
let chars = 0;
|
|
54
|
+
let lastMessageAt: number | null = null;
|
|
55
|
+
|
|
56
|
+
for (const row of rows) {
|
|
57
|
+
chars += row.content.length;
|
|
58
|
+
if (lastMessageAt === null || row.createdAt > lastMessageAt) {
|
|
59
|
+
lastMessageAt = row.createdAt;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
count: rows.length,
|
|
65
|
+
estimatedTokens: Math.ceil(chars / 4),
|
|
66
|
+
lastMessageAt,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
45
69
|
}
|
|
@@ -17,27 +17,25 @@ export class SessionRepository {
|
|
|
17
17
|
constructor(private readonly db: DbClient) {}
|
|
18
18
|
|
|
19
19
|
async getOrCreate(key: SessionKey): Promise<AgentSessionRow> {
|
|
20
|
-
const existing = await this.
|
|
20
|
+
const existing = await this.findActiveByKey(key);
|
|
21
21
|
if (existing) return existing;
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
return await this.create(key);
|
|
24
|
+
}
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
agentId: key.agentId,
|
|
29
|
-
channel: key.channel,
|
|
30
|
-
chatId: key.chatId,
|
|
31
|
-
threadId: key.threadId ?? null,
|
|
32
|
-
userId: key.userId ?? null,
|
|
33
|
-
createdAt: now,
|
|
34
|
-
updatedAt: now,
|
|
35
|
-
});
|
|
26
|
+
async startNew(key: SessionKey): Promise<AgentSessionRow> {
|
|
27
|
+
const now = Date.now();
|
|
36
28
|
|
|
37
|
-
|
|
38
|
-
|
|
29
|
+
await this.db
|
|
30
|
+
.update(agentSessions)
|
|
31
|
+
.set({
|
|
32
|
+
active: false,
|
|
33
|
+
endedAt: now,
|
|
34
|
+
updatedAt: now,
|
|
35
|
+
})
|
|
36
|
+
.where(this.keyPredicate(key, true));
|
|
39
37
|
|
|
40
|
-
return
|
|
38
|
+
return await this.create(key);
|
|
41
39
|
}
|
|
42
40
|
|
|
43
41
|
async updateModel(id: string, model: string | null): Promise<AgentSessionRow> {
|
|
@@ -64,6 +62,19 @@ export class SessionRepository {
|
|
|
64
62
|
return this.requireById(id);
|
|
65
63
|
}
|
|
66
64
|
|
|
65
|
+
async updatePiSession(id: string, input: { piSessionId: string; piSessionPath: string }): Promise<AgentSessionRow> {
|
|
66
|
+
await this.db
|
|
67
|
+
.update(agentSessions)
|
|
68
|
+
.set({
|
|
69
|
+
piSessionId: input.piSessionId,
|
|
70
|
+
piSessionPath: input.piSessionPath,
|
|
71
|
+
updatedAt: Date.now(),
|
|
72
|
+
})
|
|
73
|
+
.where(eq(agentSessions.id, id));
|
|
74
|
+
|
|
75
|
+
return this.requireById(id);
|
|
76
|
+
}
|
|
77
|
+
|
|
67
78
|
async findById(id: string): Promise<AgentSessionRow | null> {
|
|
68
79
|
const row = await this.db.query.agentSessions.findFirst({
|
|
69
80
|
where: eq(agentSessions.id, id),
|
|
@@ -79,20 +90,52 @@ export class SessionRepository {
|
|
|
79
90
|
return row;
|
|
80
91
|
}
|
|
81
92
|
|
|
82
|
-
private async
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
93
|
+
private async create(key: SessionKey): Promise<AgentSessionRow> {
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
const id = nanoid();
|
|
96
|
+
|
|
97
|
+
await this.db.insert(agentSessions).values({
|
|
98
|
+
id,
|
|
99
|
+
agentId: key.agentId,
|
|
100
|
+
channel: key.channel,
|
|
101
|
+
chatId: key.chatId,
|
|
102
|
+
threadId: key.threadId ?? null,
|
|
103
|
+
userId: key.userId ?? null,
|
|
104
|
+
active: true,
|
|
105
|
+
createdAt: now,
|
|
106
|
+
updatedAt: now,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const created = await this.findById(id);
|
|
110
|
+
if (!created) throw new Error(`failed to create session ${id}`);
|
|
86
111
|
|
|
112
|
+
return created;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private async findActiveByKey(key: SessionKey): Promise<AgentSessionRow | null> {
|
|
87
116
|
const row = await this.db.query.agentSessions.findFirst({
|
|
88
|
-
where:
|
|
89
|
-
eq(agentSessions.agentId, key.agentId),
|
|
90
|
-
eq(agentSessions.channel, key.channel),
|
|
91
|
-
eq(agentSessions.chatId, key.chatId),
|
|
92
|
-
threadPredicate,
|
|
93
|
-
),
|
|
117
|
+
where: this.keyPredicate(key, true),
|
|
94
118
|
});
|
|
95
119
|
|
|
96
120
|
return row ?? null;
|
|
97
121
|
}
|
|
122
|
+
|
|
123
|
+
private keyPredicate(key: SessionKey, active?: boolean) {
|
|
124
|
+
const threadPredicate = key.threadId
|
|
125
|
+
? eq(agentSessions.threadId, key.threadId)
|
|
126
|
+
: isNull(agentSessions.threadId);
|
|
127
|
+
|
|
128
|
+
const predicates = [
|
|
129
|
+
eq(agentSessions.agentId, key.agentId),
|
|
130
|
+
eq(agentSessions.channel, key.channel),
|
|
131
|
+
eq(agentSessions.chatId, key.chatId),
|
|
132
|
+
threadPredicate,
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
if (active !== undefined) {
|
|
136
|
+
predicates.push(eq(agentSessions.active, active));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return and(...predicates);
|
|
140
|
+
}
|
|
98
141
|
}
|
package/src/db/schema.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
|
1
|
+
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
|
2
2
|
|
|
3
3
|
export const agents = sqliteTable("agents", {
|
|
4
4
|
id: text("id").primaryKey(),
|
|
@@ -47,18 +47,22 @@ export const agentSessions = sqliteTable(
|
|
|
47
47
|
threadId: text("thread_id"),
|
|
48
48
|
userId: text("user_id"),
|
|
49
49
|
piSessionId: text("pi_session_id"),
|
|
50
|
+
piSessionPath: text("pi_session_path"),
|
|
50
51
|
instructionsHash: text("instructions_hash"),
|
|
51
52
|
model: text("model"),
|
|
52
53
|
thinkingLevel: text("thinking_level", { enum: ["off", "low", "medium", "high"] }),
|
|
54
|
+
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
|
55
|
+
endedAt: integer("ended_at"),
|
|
53
56
|
createdAt: integer("created_at").notNull(),
|
|
54
57
|
updatedAt: integer("updated_at").notNull(),
|
|
55
58
|
},
|
|
56
59
|
(table) => ({
|
|
57
|
-
|
|
60
|
+
sessionActiveKeyIndex: index("agent_sessions_active_key_idx").on(
|
|
58
61
|
table.agentId,
|
|
59
62
|
table.channel,
|
|
60
63
|
table.chatId,
|
|
61
64
|
table.threadId,
|
|
65
|
+
table.active,
|
|
62
66
|
),
|
|
63
67
|
}),
|
|
64
68
|
);
|
package/src/pi/PiAgentRunner.ts
CHANGED
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
DefaultResourceLoader,
|
|
5
5
|
ModelRegistry,
|
|
6
6
|
getAgentDir,
|
|
7
|
-
SessionManager,
|
|
8
7
|
SettingsManager,
|
|
9
8
|
} from "@earendil-works/pi-coding-agent";
|
|
10
9
|
import { mkdir } from "node:fs/promises";
|
|
@@ -12,6 +11,7 @@ import { resolve } from "node:path";
|
|
|
12
11
|
import type { LoadedAgentConfig, ProfileConfig } from "../config/schemas";
|
|
13
12
|
import type { AgentSessionRow } from "../db/schema";
|
|
14
13
|
import { resolveModelSelection } from "./PiModelResolver";
|
|
14
|
+
import { loadOrCreatePiSession } from "./PiSessionFactory";
|
|
15
15
|
|
|
16
16
|
export type PiAgentRunInput = {
|
|
17
17
|
agent: LoadedAgentConfig;
|
|
@@ -20,8 +20,58 @@ export type PiAgentRunInput = {
|
|
|
20
20
|
prompt: string;
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
export type PiContextUsage = {
|
|
24
|
+
tokens: number | null;
|
|
25
|
+
contextWindow: number;
|
|
26
|
+
percent: number | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type PiAgentRunResult = {
|
|
30
|
+
text: string;
|
|
31
|
+
piSessionId: string;
|
|
32
|
+
piSessionPath: string;
|
|
33
|
+
contextUsage?: PiContextUsage;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type PiSessionStatus = {
|
|
37
|
+
piSessionId: string;
|
|
38
|
+
piSessionPath: string;
|
|
39
|
+
contextUsage?: PiContextUsage;
|
|
40
|
+
};
|
|
41
|
+
|
|
23
42
|
export class PiAgentRunner {
|
|
24
|
-
async run(input: PiAgentRunInput): Promise<
|
|
43
|
+
async run(input: PiAgentRunInput): Promise<PiAgentRunResult> {
|
|
44
|
+
const prepared = await this.prepare(input);
|
|
45
|
+
|
|
46
|
+
let response = "";
|
|
47
|
+
prepared.session.subscribe((event) => {
|
|
48
|
+
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
49
|
+
response += event.assistantMessageEvent.delta;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await prepared.session.prompt(input.prompt);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
text: response.trim() || "(no response)",
|
|
57
|
+
piSessionId: prepared.piSession.sessionId,
|
|
58
|
+
piSessionPath: prepared.piSession.sessionPath,
|
|
59
|
+
contextUsage: prepared.session.getContextUsage(),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async status(input: Omit<PiAgentRunInput, "prompt">): Promise<PiSessionStatus | null> {
|
|
64
|
+
if (!input.session.piSessionPath) return null;
|
|
65
|
+
|
|
66
|
+
const prepared = await this.prepare({ ...input, prompt: "" });
|
|
67
|
+
return {
|
|
68
|
+
piSessionId: prepared.piSession.sessionId,
|
|
69
|
+
piSessionPath: prepared.piSession.sessionPath,
|
|
70
|
+
contextUsage: prepared.session.getContextUsage(),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private async prepare(input: PiAgentRunInput) {
|
|
25
75
|
const workspace = resolve(input.agent.workspace);
|
|
26
76
|
await mkdir(workspace, { recursive: true });
|
|
27
77
|
|
|
@@ -41,6 +91,7 @@ export class PiAgentRunner {
|
|
|
41
91
|
const authStorage = AuthStorage.create();
|
|
42
92
|
const modelRegistry = ModelRegistry.create(authStorage);
|
|
43
93
|
const modelSelection = resolveModelSelection(modelRegistry, [input.session, input.agent, input.profile ?? {}]);
|
|
94
|
+
const piSession = await loadOrCreatePiSession(workspace, input.session);
|
|
44
95
|
const { session } = await createAgentSession({
|
|
45
96
|
cwd: workspace,
|
|
46
97
|
authStorage,
|
|
@@ -48,22 +99,13 @@ export class PiAgentRunner {
|
|
|
48
99
|
model: modelSelection.model,
|
|
49
100
|
thinkingLevel: modelSelection.thinkingLevel,
|
|
50
101
|
resourceLoader,
|
|
51
|
-
sessionManager:
|
|
102
|
+
sessionManager: piSession.manager,
|
|
52
103
|
settingsManager,
|
|
53
104
|
agentDir,
|
|
54
105
|
tools: [],
|
|
55
106
|
});
|
|
56
107
|
|
|
57
|
-
|
|
58
|
-
session.subscribe((event) => {
|
|
59
|
-
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
60
|
-
response += event.assistantMessageEvent.delta;
|
|
61
|
-
}
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
await session.prompt(input.prompt);
|
|
65
|
-
|
|
66
|
-
return response.trim() || "(no response)";
|
|
108
|
+
return { session, piSession };
|
|
67
109
|
}
|
|
68
110
|
}
|
|
69
111
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import type { AgentSessionRow } from "../db/schema";
|
|
7
|
+
|
|
8
|
+
export type PiSessionHandle = {
|
|
9
|
+
manager: SessionManager;
|
|
10
|
+
sessionId: string;
|
|
11
|
+
sessionPath: string;
|
|
12
|
+
created: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export async function loadOrCreatePiSession(workspace: string, session: AgentSessionRow): Promise<PiSessionHandle> {
|
|
16
|
+
const sessionDir = piSessionDir();
|
|
17
|
+
await mkdir(sessionDir, { recursive: true });
|
|
18
|
+
|
|
19
|
+
if (session.piSessionPath && existsSync(session.piSessionPath)) {
|
|
20
|
+
const manager = SessionManager.open(session.piSessionPath, sessionDir, workspace);
|
|
21
|
+
return {
|
|
22
|
+
manager,
|
|
23
|
+
sessionId: manager.getSessionId(),
|
|
24
|
+
sessionPath: manager.getSessionFile() ?? session.piSessionPath,
|
|
25
|
+
created: false,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const manager = SessionManager.create(workspace, sessionDir);
|
|
30
|
+
manager.newSession({ id: session.piSessionId ?? session.id });
|
|
31
|
+
|
|
32
|
+
const sessionPath = manager.getSessionFile();
|
|
33
|
+
if (!sessionPath) {
|
|
34
|
+
throw new Error("failed to create persisted Pi session file");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await mkdir(dirname(sessionPath), { recursive: true });
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
manager,
|
|
41
|
+
sessionId: manager.getSessionId(),
|
|
42
|
+
sessionPath,
|
|
43
|
+
created: true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function piSessionDir(): string {
|
|
48
|
+
return resolve(process.env.PIGENT_PI_SESSION_DIR ?? join(process.env.PIGENT_HOME ?? join(homedir(), ".pigent"), "pi-sessions"));
|
|
49
|
+
}
|