@fickydev/pigent 0.1.11 → 0.1.13

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 CHANGED
@@ -58,6 +58,9 @@
58
58
  - Added session-scoped model and thinking level overrides with `/model` and `/thinking` Telegram commands.
59
59
  - Planned Telegram inline-button model and thinking level pickers backed by configured model choices.
60
60
  - Added Telegram inline-button pickers for `/model` and `/thinking`, callback handling, and bot command menu registration.
61
+ - Added `/task` to Telegram bot command list (shows subcommands in help text).
62
+ - Added `/new` Telegram command to start a fresh active chat session.
63
+ - Added `/status` Telegram command with session/model/workspace/task info and estimated context usage.
61
64
  - Changed `/model` picker to use Pi's currently available models automatically when explicit `modelChoices` are not configured.
62
65
  - Kept daemon process alive after startup so CLI runs do not exit after `pigent ready`.
63
66
 
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
 
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`
@@ -183,6 +184,8 @@
183
184
  - [ ] `/default-agent <id>` set chat default
184
185
  - [ ] `/instructions <text>` set chat instructions
185
186
  - [ ] `/sessions` list active sessions for chat
187
+ - [x] `/new` start a fresh active chat session
188
+ - [x] `/status` show current session, model, workspace, tasks, and estimated context usage
186
189
  - [ ] `/reset-session <agentId>` clear chat session
187
190
  - [ ] `/heartbeat status` show heartbeat state
188
191
  - [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`);
@@ -29,6 +29,13 @@
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
32
39
  }
33
40
  ]
34
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fickydev/pigent",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Autonomous multi-agent daemon using Pi as core execution engine.",
@@ -1,8 +1,9 @@
1
+ import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
1
2
  import type { InboundMessage, InlineKeyboardButton } from "../channels/types";
2
3
  import type { ModelChoiceConfig } from "../config/schemas";
3
4
  import type { Repositories } from "../db/repositories";
4
5
  import { getAvailableModelChoices } from "../pi/PiAvailableModels";
5
- import { isValidModelRef } from "../pi/PiModelResolver";
6
+ import { isValidModelRef, resolveModelSelection } from "../pi/PiModelResolver";
6
7
  import type { AgentRegistry } from "./AgentRegistry";
7
8
  import type { Scheduler } from "../daemon/Scheduler";
8
9
 
@@ -38,6 +39,10 @@ export class BotCommandHandler {
38
39
  return { handled: true, text: this.helpText() };
39
40
  case "/agents":
40
41
  return { handled: true, text: await this.agentsText(message) };
42
+ case "/new":
43
+ return { handled: true, text: await this.newSessionText(message) };
44
+ case "/status":
45
+ return { handled: true, text: await this.statusText(message) };
41
46
  case "/model":
42
47
  return await this.modelResult(message);
43
48
  case "/thinking":
@@ -60,6 +65,8 @@ export class BotCommandHandler {
60
65
  "Pigent commands:",
61
66
  "/help - show this help",
62
67
  "/agents - list agents for this chat",
68
+ "/new - start a fresh session for this chat",
69
+ "/status - show current session status",
63
70
  "/model - show model picker for this chat session",
64
71
  "/model <provider/modelId> - set model for this chat session",
65
72
  "/model default - clear model override for this chat session",
@@ -96,6 +103,76 @@ export class BotCommandHandler {
96
103
  ].join("\n");
97
104
  }
98
105
 
106
+ private async newSessionText(message: InboundMessage): Promise<string> {
107
+ const sessionResult = await this.getDefaultSession(message);
108
+ if (!sessionResult.ok) return sessionResult.message;
109
+
110
+ const session = await this.repositories.sessions.startNew({
111
+ agentId: sessionResult.agentId,
112
+ channel: message.channel,
113
+ chatId: message.chatId,
114
+ threadId: message.threadId,
115
+ userId: message.senderId,
116
+ });
117
+
118
+ return [
119
+ "New session created.",
120
+ "",
121
+ "Agent: " + session.agentId,
122
+ "Session: " + session.id,
123
+ "Model: " + (session.model ?? "default"),
124
+ "Thinking: " + (session.thinkingLevel ?? "default"),
125
+ "Workspace: " + formatPath(sessionResult.agent.workspace),
126
+ ].join("\n");
127
+ }
128
+
129
+ private async statusText(message: InboundMessage): Promise<string> {
130
+ const sessionResult = await this.getDefaultSession(message);
131
+ if (!sessionResult.ok) return sessionResult.message;
132
+
133
+ const agent = sessionResult.agent;
134
+ const profile = this.registry.getProfile(agent.profile);
135
+ const messageStats = await this.repositories.messages.statsBySession(sessionResult.session.id);
136
+ const modelInfo = this.resolveStatusModel(sessionResult.session, agent, profile);
137
+ const contextText = formatContext(messageStats.estimatedTokens, modelInfo.contextWindow);
138
+ const taskCount = this.scheduler
139
+ ? this.scheduler.getTasks().filter((task) => task.chatId === message.chatId || !task.chatId).length
140
+ : null;
141
+
142
+ return [
143
+ "Pigent status",
144
+ "",
145
+ "Agent: " + agent.id,
146
+ "Profile: " + agent.profile,
147
+ "Session: " + sessionResult.session.id,
148
+ "Session age: " + formatDuration(Date.now() - sessionResult.session.createdAt),
149
+ "Messages: " + messageStats.count,
150
+ "Last message: " + (messageStats.lastMessageAt ? formatDuration(Date.now() - messageStats.lastMessageAt) + " ago" : "none"),
151
+ "Model: " + modelInfo.model,
152
+ "Thinking: " + (sessionResult.session.thinkingLevel ?? agent.thinkingLevel ?? profile?.thinkingLevel ?? "default"),
153
+ "Context: " + contextText,
154
+ "Workspace: " + formatPath(agent.workspace),
155
+ "Chat: " + message.chatId,
156
+ "Thread: " + (message.threadId ?? "none"),
157
+ "Tasks: " + (taskCount === null ? "scheduler unavailable" : String(taskCount)),
158
+ "Fake runner: " + (process.env.PIGENT_FAKE_AGENT === "1" ? "on" : "off"),
159
+ ].join("\n");
160
+ }
161
+
162
+ private resolveStatusModel(session: { model: string | null }, agent: { model?: string | null }, profile: { model?: string | null } | null) {
163
+ const configuredModel = session.model ?? agent.model ?? profile?.model ?? null;
164
+ if (!configuredModel) return { model: "default", contextWindow: null as number | null };
165
+
166
+ const authStorage = AuthStorage.create();
167
+ const modelRegistry = ModelRegistry.create(authStorage);
168
+ const resolved = resolveModelSelection(modelRegistry, [session, agent, profile ?? {}]);
169
+
170
+ return {
171
+ model: configuredModel,
172
+ contextWindow: resolved.model?.contextWindow ?? null,
173
+ };
174
+ }
175
+
99
176
  private async modelResult(message: InboundMessage): Promise<BotCommandResult> {
100
177
  const sessionResult = await this.getDefaultSession(message);
101
178
  if (!sessionResult.ok) return { handled: true, text: sessionResult.message };
@@ -343,7 +420,8 @@ export class BotCommandHandler {
343
420
  return { ok: false as const, message: "No default agent configured for this chat." };
344
421
  }
345
422
 
346
- if (!this.registry.hasAgent(agentId)) {
423
+ const agent = this.registry.getAgent(agentId);
424
+ if (!agent) {
347
425
  return { ok: false as const, message: "Unknown agent: " + agentId };
348
426
  }
349
427
 
@@ -359,7 +437,7 @@ export class BotCommandHandler {
359
437
  userId: message.senderId,
360
438
  });
361
439
 
362
- return { ok: true as const, agentId, session };
440
+ return { ok: true as const, agentId, agent, session };
363
441
  }
364
442
  }
365
443
 
@@ -376,3 +454,42 @@ function chunk<T>(items: T[], size: number): T[][] {
376
454
 
377
455
  return rows;
378
456
  }
457
+
458
+ function formatContext(tokens: number, contextWindow: number | null): string {
459
+ if (!contextWindow || contextWindow <= 0) {
460
+ return "~" + formatTokens(tokens) + " tokens estimated / unknown limit";
461
+ }
462
+
463
+ const percent = (tokens / contextWindow) * 100;
464
+ return "~" + percent.toFixed(1) + "% / " + formatTokens(contextWindow) + " tokens estimated";
465
+ }
466
+
467
+ function formatTokens(count: number): string {
468
+ if (count < 1000) return String(count);
469
+ if (count < 10000) return (count / 1000).toFixed(1) + "k";
470
+ if (count < 1000000) return Math.round(count / 1000) + "k";
471
+ if (count < 10000000) return (count / 1000000).toFixed(1) + "M";
472
+ return Math.round(count / 1000000) + "M";
473
+ }
474
+
475
+ function formatDuration(ms: number): string {
476
+ const seconds = Math.max(0, Math.floor(ms / 1000));
477
+ if (seconds < 60) return seconds + "s";
478
+
479
+ const minutes = Math.floor(seconds / 60);
480
+ if (minutes < 60) return minutes + "m";
481
+
482
+ const hours = Math.floor(minutes / 60);
483
+ if (hours < 48) return hours + "h " + (minutes % 60) + "m";
484
+
485
+ return Math.floor(hours / 24) + "d " + (hours % 24) + "h";
486
+ }
487
+
488
+ function formatPath(path: string): string {
489
+ const home = process.env.HOME || process.env.USERPROFILE;
490
+ if (home && path.startsWith(home)) {
491
+ return "~" + path.slice(home.length);
492
+ }
493
+
494
+ return path;
495
+ }
@@ -178,8 +178,11 @@ 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" },
186
+ { command: "task", description: "Manage scheduled tasks" },
184
187
  ];
185
188
  }
@@ -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.findByKey(key);
20
+ const existing = await this.findActiveByKey(key);
21
21
  if (existing) return existing;
22
22
 
23
- const now = Date.now();
24
- const id = nanoid();
23
+ return await this.create(key);
24
+ }
25
25
 
26
- await this.db.insert(agentSessions).values({
27
- id,
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
- const created = await this.findById(id);
38
- if (!created) throw new Error(`failed to create session ${id}`);
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 created;
38
+ return await this.create(key);
41
39
  }
42
40
 
43
41
  async updateModel(id: string, model: string | null): Promise<AgentSessionRow> {
@@ -79,20 +77,52 @@ export class SessionRepository {
79
77
  return row;
80
78
  }
81
79
 
82
- private async findByKey(key: SessionKey): Promise<AgentSessionRow | null> {
83
- const threadPredicate = key.threadId
84
- ? eq(agentSessions.threadId, key.threadId)
85
- : isNull(agentSessions.threadId);
80
+ private async create(key: SessionKey): Promise<AgentSessionRow> {
81
+ const now = Date.now();
82
+ const id = nanoid();
86
83
 
84
+ await this.db.insert(agentSessions).values({
85
+ id,
86
+ agentId: key.agentId,
87
+ channel: key.channel,
88
+ chatId: key.chatId,
89
+ threadId: key.threadId ?? null,
90
+ userId: key.userId ?? null,
91
+ active: true,
92
+ createdAt: now,
93
+ updatedAt: now,
94
+ });
95
+
96
+ const created = await this.findById(id);
97
+ if (!created) throw new Error(`failed to create session ${id}`);
98
+
99
+ return created;
100
+ }
101
+
102
+ private async findActiveByKey(key: SessionKey): Promise<AgentSessionRow | null> {
87
103
  const row = await this.db.query.agentSessions.findFirst({
88
- where: and(
89
- eq(agentSessions.agentId, key.agentId),
90
- eq(agentSessions.channel, key.channel),
91
- eq(agentSessions.chatId, key.chatId),
92
- threadPredicate,
93
- ),
104
+ where: this.keyPredicate(key, true),
94
105
  });
95
106
 
96
107
  return row ?? null;
97
108
  }
109
+
110
+ private keyPredicate(key: SessionKey, active?: boolean) {
111
+ const threadPredicate = key.threadId
112
+ ? eq(agentSessions.threadId, key.threadId)
113
+ : isNull(agentSessions.threadId);
114
+
115
+ const predicates = [
116
+ eq(agentSessions.agentId, key.agentId),
117
+ eq(agentSessions.channel, key.channel),
118
+ eq(agentSessions.chatId, key.chatId),
119
+ threadPredicate,
120
+ ];
121
+
122
+ if (active !== undefined) {
123
+ predicates.push(eq(agentSessions.active, active));
124
+ }
125
+
126
+ return and(...predicates);
127
+ }
98
128
  }
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(),
@@ -50,15 +50,18 @@ export const agentSessions = sqliteTable(
50
50
  instructionsHash: text("instructions_hash"),
51
51
  model: text("model"),
52
52
  thinkingLevel: text("thinking_level", { enum: ["off", "low", "medium", "high"] }),
53
+ active: integer("active", { mode: "boolean" }).notNull().default(true),
54
+ endedAt: integer("ended_at"),
53
55
  createdAt: integer("created_at").notNull(),
54
56
  updatedAt: integer("updated_at").notNull(),
55
57
  },
56
58
  (table) => ({
57
- sessionKeyUnique: uniqueIndex("agent_sessions_key_unique").on(
59
+ sessionActiveKeyIndex: index("agent_sessions_active_key_idx").on(
58
60
  table.agentId,
59
61
  table.channel,
60
62
  table.chatId,
61
63
  table.threadId,
64
+ table.active,
62
65
  ),
63
66
  }),
64
67
  );