@aaroncql/pim-agent 0.0.1

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.
Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin/pim.ts +109 -0
  4. package/package.json +49 -0
  5. package/src/extensions/_init/index.ts +109 -0
  6. package/src/extensions/bash/capture.test.ts +126 -0
  7. package/src/extensions/bash/capture.ts +80 -0
  8. package/src/extensions/bash/format.test.ts +240 -0
  9. package/src/extensions/bash/format.ts +76 -0
  10. package/src/extensions/bash/index.ts +86 -0
  11. package/src/extensions/bash/run.test.ts +262 -0
  12. package/src/extensions/bash/run.ts +207 -0
  13. package/src/extensions/bash/schema.ts +54 -0
  14. package/src/extensions/command-picker/index.ts +52 -0
  15. package/src/extensions/command-picker/ranker.test.ts +46 -0
  16. package/src/extensions/command-picker/ranker.ts +17 -0
  17. package/src/extensions/edit/edit.test.ts +285 -0
  18. package/src/extensions/edit/edit.ts +382 -0
  19. package/src/extensions/edit/index.ts +54 -0
  20. package/src/extensions/edit/schema.ts +37 -0
  21. package/src/extensions/file-picker/catalog.test.ts +263 -0
  22. package/src/extensions/file-picker/catalog.ts +219 -0
  23. package/src/extensions/file-picker/index.test.ts +168 -0
  24. package/src/extensions/file-picker/index.ts +119 -0
  25. package/src/extensions/file-picker/ranker.test.ts +94 -0
  26. package/src/extensions/file-picker/ranker.ts +76 -0
  27. package/src/extensions/footer/git.test.ts +76 -0
  28. package/src/extensions/footer/git.ts +87 -0
  29. package/src/extensions/footer/index.test.ts +161 -0
  30. package/src/extensions/footer/index.ts +148 -0
  31. package/src/extensions/footer/powerline.ts +87 -0
  32. package/src/extensions/footer/segments.test.ts +164 -0
  33. package/src/extensions/footer/segments.ts +234 -0
  34. package/src/extensions/glob/glob.test.ts +171 -0
  35. package/src/extensions/glob/glob.ts +34 -0
  36. package/src/extensions/glob/index.test.ts +68 -0
  37. package/src/extensions/glob/index.ts +136 -0
  38. package/src/extensions/glob/render.test.ts +126 -0
  39. package/src/extensions/glob/render.ts +74 -0
  40. package/src/extensions/glob/schema.ts +52 -0
  41. package/src/extensions/grep/grep.test.ts +387 -0
  42. package/src/extensions/grep/grep.ts +215 -0
  43. package/src/extensions/grep/index.test.ts +68 -0
  44. package/src/extensions/grep/index.ts +158 -0
  45. package/src/extensions/grep/render.test.ts +269 -0
  46. package/src/extensions/grep/render.ts +243 -0
  47. package/src/extensions/grep/schema.ts +92 -0
  48. package/src/extensions/read/index.ts +84 -0
  49. package/src/extensions/read/read.test.ts +177 -0
  50. package/src/extensions/read/read.ts +206 -0
  51. package/src/extensions/read/render.test.ts +61 -0
  52. package/src/extensions/read/render.ts +33 -0
  53. package/src/extensions/read/schema.ts +27 -0
  54. package/src/extensions/subagent/index.test.ts +44 -0
  55. package/src/extensions/subagent/index.ts +30 -0
  56. package/src/extensions/subagent/render.test.ts +292 -0
  57. package/src/extensions/subagent/render.ts +359 -0
  58. package/src/extensions/subagent/schema.ts +9 -0
  59. package/src/extensions/subagent/subagent.test.ts +315 -0
  60. package/src/extensions/subagent/subagent.ts +418 -0
  61. package/src/extensions/system-prompt/index.ts +28 -0
  62. package/src/extensions/system-prompt/prompt.test.ts +64 -0
  63. package/src/extensions/system-prompt/prompt.ts +213 -0
  64. package/src/extensions/todo/index.test.ts +244 -0
  65. package/src/extensions/todo/index.ts +122 -0
  66. package/src/extensions/todo/render.test.ts +180 -0
  67. package/src/extensions/todo/render.ts +172 -0
  68. package/src/extensions/todo/schema.ts +24 -0
  69. package/src/extensions/todo/todo.test.ts +222 -0
  70. package/src/extensions/todo/todo.ts +188 -0
  71. package/src/extensions/tps/index.test.ts +254 -0
  72. package/src/extensions/tps/index.ts +136 -0
  73. package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
  74. package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
  75. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
  76. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
  77. package/src/extensions/web-fetch/fetch.test.ts +244 -0
  78. package/src/extensions/web-fetch/fetch.ts +249 -0
  79. package/src/extensions/web-fetch/index.ts +107 -0
  80. package/src/extensions/web-fetch/render.test.ts +56 -0
  81. package/src/extensions/web-fetch/render.ts +39 -0
  82. package/src/extensions/web-fetch/schema.ts +23 -0
  83. package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
  84. package/src/extensions/web-search/ExaMcpClient.ts +258 -0
  85. package/src/extensions/web-search/index.ts +118 -0
  86. package/src/extensions/web-search/render.test.ts +21 -0
  87. package/src/extensions/web-search/render.ts +9 -0
  88. package/src/extensions/web-search/schema.ts +21 -0
  89. package/src/extensions/web-search/search.test.ts +53 -0
  90. package/src/extensions/web-search/search.ts +23 -0
  91. package/src/extensions/working-indicator/index.test.ts +21 -0
  92. package/src/extensions/working-indicator/index.ts +77 -0
  93. package/src/extensions/write/index.ts +76 -0
  94. package/src/extensions/write/render.test.ts +64 -0
  95. package/src/extensions/write/schema.ts +14 -0
  96. package/src/extensions/write/write.test.ts +108 -0
  97. package/src/extensions/write/write.ts +104 -0
  98. package/src/shared/DiffLines.test.ts +193 -0
  99. package/src/shared/DiffLines.ts +307 -0
  100. package/src/shared/DiffRenderer.test.ts +206 -0
  101. package/src/shared/DiffRenderer.ts +396 -0
  102. package/src/shared/DiffView.ts +199 -0
  103. package/src/shared/EditMatcher.test.ts +123 -0
  104. package/src/shared/EditMatcher.ts +826 -0
  105. package/src/shared/FileScanner.test.ts +158 -0
  106. package/src/shared/FileScanner.ts +41 -0
  107. package/src/shared/Fs.ts +46 -0
  108. package/src/shared/FsErrors.ts +72 -0
  109. package/src/shared/FuzzyMatcher.test.ts +114 -0
  110. package/src/shared/FuzzyMatcher.ts +73 -0
  111. package/src/shared/GitignoreFilter.test.ts +64 -0
  112. package/src/shared/GitignoreFilter.ts +142 -0
  113. package/src/shared/GlobExclusions.ts +23 -0
  114. package/src/shared/Levenshtein.ts +33 -0
  115. package/src/shared/Lines.test.ts +25 -0
  116. package/src/shared/Lines.ts +77 -0
  117. package/src/shared/McpClient.test.ts +235 -0
  118. package/src/shared/McpClient.ts +406 -0
  119. package/src/shared/OutputBudget.test.ts +99 -0
  120. package/src/shared/OutputBudget.ts +79 -0
  121. package/src/shared/Paths.test.ts +51 -0
  122. package/src/shared/Paths.ts +52 -0
  123. package/src/shared/PimSettings.test.ts +90 -0
  124. package/src/shared/PimSettings.ts +124 -0
  125. package/src/shared/Renderer.test.ts +190 -0
  126. package/src/shared/Renderer.ts +256 -0
  127. package/src/shared/SpillCache.test.ts +94 -0
  128. package/src/shared/SpillCache.ts +89 -0
  129. package/src/shared/Tools.test.ts +392 -0
  130. package/src/shared/Tools.ts +636 -0
  131. package/src/telegram/Bot.ts +198 -0
  132. package/src/telegram/Commands.ts +721 -0
  133. package/src/telegram/Config.test.ts +275 -0
  134. package/src/telegram/Config.ts +162 -0
  135. package/src/telegram/Markdown.test.ts +143 -0
  136. package/src/telegram/Markdown.ts +177 -0
  137. package/src/telegram/Message.ts +211 -0
  138. package/src/telegram/Renderer.test.ts +216 -0
  139. package/src/telegram/Renderer.ts +713 -0
  140. package/src/telegram/SendFileSchema.ts +19 -0
  141. package/src/telegram/SendFileTool.ts +94 -0
  142. package/src/telegram/Session.ts +579 -0
  143. package/src/telegram/SessionRegistry.test.ts +89 -0
  144. package/src/telegram/SessionRegistry.ts +170 -0
  145. package/src/telegram/Supervisor.ts +357 -0
  146. package/src/telegram/TaskScheduler.test.ts +278 -0
  147. package/src/telegram/TaskScheduler.ts +293 -0
  148. package/src/telegram/TaskSchema.ts +88 -0
  149. package/src/telegram/TaskStore.ts +73 -0
  150. package/src/telegram/TaskTool.test.ts +179 -0
  151. package/src/telegram/TaskTool.ts +159 -0
  152. package/src/telegram/TypingIndicator.ts +43 -0
  153. package/src/telegram/index.ts +32 -0
  154. package/src/themes/pim-dark.json +84 -0
  155. package/src/themes/pim-light.json +84 -0
@@ -0,0 +1,19 @@
1
+ import { type Static, Type } from "typebox";
2
+
3
+ export const MAX_DOCUMENT_BYTES = 50 * 1024 * 1024;
4
+ export const MAX_CAPTION_CHARS = 1024;
5
+
6
+ export const sendFileSchema = Type.Object({
7
+ path: Type.String({
8
+ minLength: 1,
9
+ description: "Absolute or relative path to file (resolved against cwd).",
10
+ }),
11
+ caption: Type.Optional(
12
+ Type.String({
13
+ description: `File caption in markdown. Max ${MAX_CAPTION_CHARS} chars.`,
14
+ maxLength: MAX_CAPTION_CHARS,
15
+ })
16
+ ),
17
+ });
18
+
19
+ export type SendFileInput = Static<typeof sendFileSchema>;
@@ -0,0 +1,94 @@
1
+ import {
2
+ defineTool,
3
+ type ToolDefinition,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { GrammyError, InputFile, type Api } from "grammy";
6
+ import { basename } from "node:path";
7
+
8
+ import { FsErrors } from "../shared/FsErrors";
9
+ import { Paths } from "../shared/Paths";
10
+ import { Markdown } from "./Markdown";
11
+ import {
12
+ MAX_CAPTION_CHARS,
13
+ MAX_DOCUMENT_BYTES,
14
+ sendFileSchema,
15
+ type SendFileInput,
16
+ } from "./SendFileSchema";
17
+ import type { SessionId } from "./Session";
18
+
19
+ export type SendFileDeps = {
20
+ readonly api: Api;
21
+ readonly sessionId: SessionId;
22
+ readonly cwd: string;
23
+ };
24
+
25
+ export class SendFileTool {
26
+ public static build(deps: SendFileDeps): ToolDefinition {
27
+ return defineTool({
28
+ name: "send_file",
29
+ label: "send_file",
30
+ description: `Send a local file to the current Telegram chat/thread as a document. Max ${MAX_DOCUMENT_BYTES / (1024 * 1024)} MB.`,
31
+ parameters: sendFileSchema,
32
+ async execute(_id, params) {
33
+ const { path: rawPath, caption } = params as SendFileInput;
34
+ const resolved = await SendFileTool.validate(rawPath, deps.cwd);
35
+ const trimmedCaption = caption?.slice(0, MAX_CAPTION_CHARS);
36
+ await SendFileTool.send(
37
+ deps.api,
38
+ deps.sessionId,
39
+ resolved.path,
40
+ trimmedCaption
41
+ );
42
+ return {
43
+ content: [{ type: "text", text: `Sent ${basename(resolved.path)}` }],
44
+ details: {
45
+ path: resolved.path,
46
+ bytes: resolved.size,
47
+ },
48
+ };
49
+ },
50
+ });
51
+ }
52
+
53
+ private static async validate(
54
+ rawPath: string,
55
+ cwd: string
56
+ ): Promise<{ readonly path: string; readonly size: number }> {
57
+ const path = Paths.resolve(rawPath, cwd);
58
+ const st = await FsErrors.statOrThrow(path);
59
+ if (!st.isFile()) {
60
+ throw new Error(`${rawPath} is not a regular file.`);
61
+ }
62
+ if (st.size > MAX_DOCUMENT_BYTES) {
63
+ throw new Error(
64
+ `${rawPath} is ${st.size} bytes; max allowed is ${MAX_DOCUMENT_BYTES}.`
65
+ );
66
+ }
67
+ return { path, size: st.size };
68
+ }
69
+
70
+ private static async send(
71
+ api: Api,
72
+ sessionId: SessionId,
73
+ path: string,
74
+ caption: string | undefined
75
+ ): Promise<void> {
76
+ const html = caption ? Markdown.toHtml(caption) : undefined;
77
+ try {
78
+ await api.sendDocument(sessionId.chatId, new InputFile(path), {
79
+ message_thread_id: sessionId.threadId,
80
+ caption: html,
81
+ parse_mode: html ? "HTML" : undefined,
82
+ });
83
+ } catch (err) {
84
+ if (err instanceof GrammyError && err.error_code === 400 && html) {
85
+ await api.sendDocument(sessionId.chatId, new InputFile(path), {
86
+ message_thread_id: sessionId.threadId,
87
+ caption,
88
+ });
89
+ return;
90
+ }
91
+ throw err;
92
+ }
93
+ }
94
+ }
@@ -0,0 +1,579 @@
1
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
+ import { getSupportedThinkingLevels as piGetSupportedThinkingLevels } from "@earendil-works/pi-ai";
3
+ import type { Api as ModelApi, Model } from "@earendil-works/pi-ai";
4
+ import {
5
+ AgentSession,
6
+ AuthStorage,
7
+ createAgentSession,
8
+ DefaultResourceLoader,
9
+ ModelRegistry,
10
+ SessionManager,
11
+ SettingsManager,
12
+ type CompactionResult,
13
+ } from "@earendil-works/pi-coding-agent";
14
+ import type { Api } from "grammy";
15
+ import { mkdir, rename, stat, unlink } from "node:fs/promises";
16
+ import { dirname, join } from "node:path";
17
+
18
+ import { FuzzyMatcher, type FuzzyCandidate } from "../shared/FuzzyMatcher";
19
+ import { Tools } from "../shared/Tools";
20
+ import type { LogsMode, TelegramConfig, ThinkingLevelOpt } from "./Config";
21
+ import { SendFileTool } from "./SendFileTool";
22
+ import type { TaskScheduler } from "./TaskScheduler";
23
+ import { TaskTool } from "./TaskTool";
24
+
25
+ export type SessionId = {
26
+ readonly chatId: number;
27
+ readonly threadId: number | undefined;
28
+ };
29
+
30
+ export type SessionSettings = {
31
+ readonly cwd?: string;
32
+ readonly model?: string;
33
+ readonly thinkingLevel?: ThinkingLevelOpt;
34
+ readonly logsMode?: LogsMode;
35
+ readonly sessionPath?: string;
36
+ readonly cumulativeCost?: number;
37
+ readonly temporary?: boolean;
38
+ };
39
+
40
+ export type SetCwdResult =
41
+ | { readonly ok: true }
42
+ | { readonly ok: false; readonly error: string };
43
+
44
+ export type SetModelResult =
45
+ | { readonly ok: true; readonly id: string }
46
+ | {
47
+ readonly ok: false;
48
+ readonly kind: "none" | "ambiguous";
49
+ readonly candidates: readonly string[];
50
+ };
51
+
52
+ export type SessionCompactResult = {
53
+ readonly compaction: CompactionResult;
54
+ readonly activeMessages: number;
55
+ };
56
+
57
+ export type SessionDeps = {
58
+ readonly id: SessionId;
59
+ readonly settings: SessionSettings;
60
+ readonly config: TelegramConfig;
61
+ readonly api: Api;
62
+ readonly agentDir: string;
63
+ readonly authStorage: AuthStorage;
64
+ readonly modelRegistry: ModelRegistry;
65
+ readonly scheduler: TaskScheduler;
66
+ readonly settingsManagerFor: (cwd: string) => SettingsManager;
67
+ readonly persistSettings: (patch: Partial<SessionSettings>) => Promise<void>;
68
+ readonly getBotUsername: () => string | undefined;
69
+ };
70
+
71
+ type ModelResolveResult =
72
+ | { readonly kind: "ok"; readonly model: Model<ModelApi> }
73
+ | { readonly kind: "ambiguous"; readonly candidates: readonly string[] }
74
+ | { readonly kind: "none"; readonly candidates: readonly string[] };
75
+
76
+ const MAIN = "main";
77
+
78
+ export class Session {
79
+ public readonly id: SessionId;
80
+ private readonly deps: SessionDeps;
81
+ private currentSettings: SessionSettings;
82
+ private cached: AgentSession | undefined;
83
+ private cachedUnsubscribe: (() => void) | undefined;
84
+ private cachedSystemInstruction: string | undefined;
85
+ private queue: Promise<void> = Promise.resolve();
86
+ public lastUsed = Date.now();
87
+
88
+ public constructor(deps: SessionDeps) {
89
+ this.deps = deps;
90
+ this.id = deps.id;
91
+ this.currentSettings = deps.settings;
92
+ }
93
+
94
+ public static encodeId(id: SessionId): string {
95
+ return `${id.chatId}-${id.threadId ?? MAIN}`;
96
+ }
97
+
98
+ public static decodeId(s: string): SessionId {
99
+ const idx = s.lastIndexOf("-");
100
+ const chatId = Number(s.slice(0, idx));
101
+ const tail = s.slice(idx + 1);
102
+ return {
103
+ chatId,
104
+ threadId: tail === MAIN ? undefined : Number(tail),
105
+ };
106
+ }
107
+
108
+ public get settings(): SessionSettings {
109
+ return this.currentSettings;
110
+ }
111
+
112
+ public get isStreaming(): boolean {
113
+ return this.cached?.isStreaming ?? false;
114
+ }
115
+
116
+ public get agentSession(): AgentSession | undefined {
117
+ return this.cached;
118
+ }
119
+
120
+ public get currentModelId(): string | undefined {
121
+ const model = this.cached?.model ?? this.resolveDefaultModel();
122
+ return model ? Session.modelId(model) : undefined;
123
+ }
124
+
125
+ public get supportedThinkingLevels(): readonly ThinkingLevelOpt[] {
126
+ const model = this.cached?.model ?? this.resolveDefaultModel();
127
+ if (!model) {
128
+ return [];
129
+ }
130
+ return piGetSupportedThinkingLevels(model) as ThinkingLevelOpt[];
131
+ }
132
+
133
+ public get currentThinkingLevel(): ThinkingLevelOpt {
134
+ if (this.currentSettings.thinkingLevel) {
135
+ return this.currentSettings.thinkingLevel;
136
+ }
137
+ if (this.cached) {
138
+ return this.cached.thinkingLevel as ThinkingLevelOpt;
139
+ }
140
+ const cwd = this.currentSettings.cwd ?? this.deps.config.cwd;
141
+ const sm = this.deps.settingsManagerFor(cwd);
142
+ return (sm.getDefaultThinkingLevel() as ThinkingLevelOpt) ?? "medium";
143
+ }
144
+
145
+ /**
146
+ * Run `work` as a turn against this session's agent. Serialized: turns for
147
+ * the same `SessionId` execute one at a time in the order they were
148
+ * submitted, so callers can fire-and-forget without races.
149
+ *
150
+ * Default (`isolated: false`): work runs against the cached `AgentSession`,
151
+ * which is built on first call and reused across turns (chat history
152
+ * persists, user instruction file is re-read between turns).
153
+ *
154
+ * `isolated: true`: work runs against a fresh `AgentSession` written under
155
+ * `isolated-sessions/`, disposed and unlinked when the work resolves. No
156
+ * history, no shared state with the cached agent. Used for scheduled tasks
157
+ * and chats in temporary mode that opt out of persistent chat history.
158
+ */
159
+ public run(
160
+ work: (agent: AgentSession) => Promise<void>,
161
+ opts?: { readonly isolated?: boolean }
162
+ ): Promise<void> {
163
+ return this.enqueue(async () => {
164
+ if (opts?.isolated) {
165
+ const { agent, sessionPath } = await this.buildIsolatedAgent();
166
+ try {
167
+ await work(agent);
168
+ } finally {
169
+ agent.dispose();
170
+ await unlink(sessionPath).catch((err: unknown) => {
171
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
172
+ console.warn(
173
+ `[session ${Session.encodeId(this.id)}] unlink ${sessionPath}:`,
174
+ err
175
+ );
176
+ }
177
+ });
178
+ }
179
+ return;
180
+ }
181
+ const agent = await this.ensureCached();
182
+ await work(agent);
183
+ });
184
+ }
185
+
186
+ public async cancel(): Promise<boolean> {
187
+ if (!this.cached || !this.cached.isStreaming) {
188
+ return false;
189
+ }
190
+ await this.cached.abort();
191
+ return true;
192
+ }
193
+
194
+ public clear(): Promise<void> {
195
+ return this.enqueue(async () => {
196
+ await this.tearDownCached();
197
+ await this.patchSettings({ sessionPath: undefined });
198
+ });
199
+ }
200
+
201
+ public setCwd(newCwd: string): Promise<SetCwdResult> {
202
+ return this.enqueueResult(async (): Promise<SetCwdResult> => {
203
+ try {
204
+ const st = await stat(newCwd);
205
+ if (!st.isDirectory()) {
206
+ return { ok: false, error: `not a directory: ${newCwd}` };
207
+ }
208
+ } catch (err) {
209
+ const code = (err as NodeJS.ErrnoException).code;
210
+ if (code === "ENOENT") {
211
+ return { ok: false, error: `path does not exist: ${newCwd}` };
212
+ }
213
+ return {
214
+ ok: false,
215
+ error: `stat failed: ${(err as Error).message}`,
216
+ };
217
+ }
218
+ await this.tearDownCached();
219
+ await this.patchSettings({ cwd: newCwd, sessionPath: undefined });
220
+ return { ok: true };
221
+ });
222
+ }
223
+
224
+ public setModel(pattern: string): Promise<SetModelResult> {
225
+ return this.enqueueResult(async (): Promise<SetModelResult> => {
226
+ const result = this.resolveModel(pattern);
227
+ if (result.kind === "none" || result.kind === "ambiguous") {
228
+ return {
229
+ ok: false,
230
+ kind: result.kind,
231
+ candidates: result.candidates,
232
+ };
233
+ }
234
+ const id = Session.modelId(result.model);
235
+ if (this.currentSettings.model === id) {
236
+ return { ok: true, id };
237
+ }
238
+ await this.patchSettings({ model: id });
239
+ if (this.cached) {
240
+ await this.cached.setModel(result.model);
241
+ }
242
+ return { ok: true, id };
243
+ });
244
+ }
245
+
246
+ public setThinkingLevel(level: ThinkingLevelOpt): Promise<void> {
247
+ return this.enqueue(async () => {
248
+ if (this.currentSettings.thinkingLevel === level) {
249
+ return;
250
+ }
251
+ await this.patchSettings({ thinkingLevel: level });
252
+ if (this.cached) {
253
+ this.cached.setThinkingLevel(level as ThinkingLevel);
254
+ }
255
+ });
256
+ }
257
+
258
+ public compact(customInstructions?: string): Promise<SessionCompactResult> {
259
+ return this.enqueueResult(async (): Promise<SessionCompactResult> => {
260
+ const agent = await this.ensureCached();
261
+ const compaction = await agent.compact(customInstructions);
262
+ return {
263
+ compaction,
264
+ activeMessages: agent.messages.length,
265
+ };
266
+ });
267
+ }
268
+
269
+ public setLogsMode(mode: LogsMode): Promise<void> {
270
+ return this.enqueue(async () => {
271
+ if (this.currentSettings.logsMode === mode) {
272
+ return;
273
+ }
274
+ await this.patchSettings({ logsMode: mode });
275
+ });
276
+ }
277
+
278
+ public get temporary(): boolean {
279
+ return this.currentSettings.temporary ?? false;
280
+ }
281
+
282
+ public setTemporary(value: boolean): Promise<void> {
283
+ return this.enqueue(async () => {
284
+ if ((this.currentSettings.temporary ?? false) === value) {
285
+ return;
286
+ }
287
+ await this.patchSettings({ temporary: value });
288
+ });
289
+ }
290
+
291
+ public dispose(): void {
292
+ if (this.cached) {
293
+ this.cachedUnsubscribe?.();
294
+ this.cached.dispose();
295
+ this.cached = undefined;
296
+ this.cachedUnsubscribe = undefined;
297
+ }
298
+ }
299
+
300
+ private enqueue(work: () => Promise<void>): Promise<void> {
301
+ const next = this.queue.then(work);
302
+ const tail = next.catch((err: unknown) => {
303
+ console.error(`[session ${Session.encodeId(this.id)}] work failed:`, err);
304
+ });
305
+ this.queue = tail;
306
+ this.lastUsed = Date.now();
307
+ return next;
308
+ }
309
+
310
+ private enqueueResult<T>(work: () => Promise<T>): Promise<T> {
311
+ let resolve!: (value: T) => void;
312
+ let reject!: (err: unknown) => void;
313
+ const result = new Promise<T>((res, rej) => {
314
+ resolve = res;
315
+ reject = rej;
316
+ });
317
+ void this.enqueue(async () => {
318
+ try {
319
+ resolve(await work());
320
+ } catch (err) {
321
+ reject(err);
322
+ }
323
+ });
324
+ return result;
325
+ }
326
+
327
+ private async ensureCached(): Promise<AgentSession> {
328
+ const systemInstruction = await this.getSystemInstruction();
329
+ if (this.cached) {
330
+ if (this.cachedSystemInstruction !== systemInstruction) {
331
+ this.cachedSystemInstruction = systemInstruction;
332
+ await this.cached.reload();
333
+ }
334
+ return this.cached;
335
+ }
336
+ const sessionPath =
337
+ this.currentSettings.sessionPath ?? this.defaultSessionPath();
338
+ const { agent, cwd } = await this.buildAgent(
339
+ sessionPath,
340
+ systemInstruction
341
+ );
342
+ this.cached = agent;
343
+ this.cachedSystemInstruction = systemInstruction;
344
+ this.cachedUnsubscribe = this.subscribeCumulativeCost(agent);
345
+ await this.patchSettings({ cwd, sessionPath });
346
+ return agent;
347
+ }
348
+
349
+ private subscribeCumulativeCost(agent: AgentSession): () => void {
350
+ let last = agent.getSessionStats().cost ?? 0;
351
+ return agent.subscribe((event) => {
352
+ if (event.type !== "turn_end") {
353
+ return;
354
+ }
355
+ const total = agent.getSessionStats().cost ?? 0;
356
+ const delta = total - last;
357
+ if (delta <= 0) {
358
+ return;
359
+ }
360
+ last = total;
361
+ void this.patchSettings({
362
+ cumulativeCost: (this.currentSettings.cumulativeCost ?? 0) + delta,
363
+ });
364
+ });
365
+ }
366
+
367
+ private async buildIsolatedAgent(): Promise<{
368
+ readonly agent: AgentSession;
369
+ readonly sessionPath: string;
370
+ }> {
371
+ const sessionPath = this.isolatedSessionPath();
372
+ await mkdir(dirname(sessionPath), { recursive: true });
373
+ const wrapped = await this.getSystemInstruction();
374
+ const { agent } = await this.buildAgent(sessionPath, wrapped);
375
+ this.subscribeCumulativeCost(agent);
376
+ return { agent, sessionPath };
377
+ }
378
+
379
+ private async buildAgent(
380
+ sessionPath: string,
381
+ wrapped: string | undefined
382
+ ): Promise<{ readonly agent: AgentSession; readonly cwd: string }> {
383
+ const cwd = this.currentSettings.cwd ?? this.deps.config.cwd;
384
+ const sessionManager = SessionManager.open(sessionPath, undefined, cwd);
385
+ const settingsManager = this.deps.settingsManagerFor(cwd);
386
+ const promptRef = { wrapped };
387
+ const loader = new DefaultResourceLoader({
388
+ cwd,
389
+ agentDir: this.deps.agentDir,
390
+ settingsManager,
391
+ appendSystemPromptOverride: (base) => {
392
+ return promptRef.wrapped ? [...base, promptRef.wrapped] : base;
393
+ },
394
+ });
395
+ await loader.reload();
396
+
397
+ const defaultModelId = this.currentSettings.model ?? this.deps.config.model;
398
+ let model: Model<ModelApi> | undefined;
399
+ if (defaultModelId) {
400
+ const resolved = this.resolveModel(defaultModelId);
401
+ if (resolved.kind === "ok") {
402
+ model = resolved.model;
403
+ } else {
404
+ console.warn(
405
+ `[session ${Session.encodeId(this.id)}] model "${defaultModelId}" did not resolve cleanly (${resolved.kind})`
406
+ );
407
+ }
408
+ }
409
+
410
+ const sendFile = SendFileTool.build({
411
+ api: this.deps.api,
412
+ sessionId: this.id,
413
+ cwd,
414
+ });
415
+ const taskTool = TaskTool.build({
416
+ scheduler: this.deps.scheduler,
417
+ sessionId: this.id,
418
+ });
419
+
420
+ const { session: agent } = await createAgentSession({
421
+ cwd,
422
+ agentDir: this.deps.agentDir,
423
+ authStorage: this.deps.authStorage,
424
+ modelRegistry: this.deps.modelRegistry,
425
+ settingsManager,
426
+ resourceLoader: loader,
427
+ sessionManager,
428
+ model,
429
+ thinkingLevel: this.currentSettings.thinkingLevel as
430
+ | ThinkingLevel
431
+ | undefined,
432
+ customTools: [Tools.wrap(sendFile), Tools.wrap(taskTool)],
433
+ });
434
+
435
+ return { agent, cwd };
436
+ }
437
+
438
+ private async tearDownCached(): Promise<void> {
439
+ if (this.cached) {
440
+ this.cachedUnsubscribe?.();
441
+ this.cached.dispose();
442
+ this.cached = undefined;
443
+ this.cachedUnsubscribe = undefined;
444
+ this.cachedSystemInstruction = undefined;
445
+ }
446
+ const path = this.currentSettings.sessionPath ?? this.defaultSessionPath();
447
+ const archived = `${path}.archived-${new Date().toISOString().replace(/[:.]/g, "-")}`;
448
+ try {
449
+ await rename(path, archived);
450
+ } catch (err) {
451
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
452
+ console.warn(
453
+ `[session ${Session.encodeId(this.id)}] archive ${path}:`,
454
+ err
455
+ );
456
+ }
457
+ }
458
+ }
459
+
460
+ private async patchSettings(patch: Partial<SessionSettings>): Promise<void> {
461
+ this.currentSettings = { ...this.currentSettings, ...patch };
462
+ await this.deps.persistSettings(patch);
463
+ }
464
+
465
+ private defaultSessionPath(): string {
466
+ return join(
467
+ this.deps.config.configDir,
468
+ "sessions",
469
+ `${Session.encodeId(this.id)}.jsonl`
470
+ );
471
+ }
472
+
473
+ private isolatedSessionPath(): string {
474
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
475
+ return join(
476
+ this.deps.config.configDir,
477
+ "isolated-sessions",
478
+ `${Session.encodeId(this.id)}-${ts}.jsonl`
479
+ );
480
+ }
481
+
482
+ private async getSystemInstruction(): Promise<string | undefined> {
483
+ const path = join(
484
+ this.deps.config.configDir,
485
+ "instructions",
486
+ `${Session.encodeId(this.id)}.md`
487
+ );
488
+ let userContent: string | undefined;
489
+ try {
490
+ userContent = (await Bun.file(path).text()).trim();
491
+ } catch (err) {
492
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
493
+ console.warn(
494
+ `[telegram-user-instruction] failed to read ${path}:`,
495
+ err
496
+ );
497
+ }
498
+ }
499
+ const username = this.deps.getBotUsername();
500
+ const handle = username ? ` (@${username})` : "";
501
+ const systemIx = `You are running as a Telegram bot${handle} powered by Pim Agent. The telegram_user_instructions below are your editable instructions - edit the file at its \`path\` attribute to update your instructions.`;
502
+ const userIx = `<telegram_user_instructions path="${path}">${userContent ? `\n${userContent}\n` : ""}</telegram_user_instructions>`;
503
+ return `<telegram_system_instructions>\n${systemIx}\n${userIx}\n</telegram_system_instructions>`;
504
+ }
505
+
506
+ private resolveDefaultModel(): Model<ModelApi> | undefined {
507
+ this.deps.modelRegistry.refresh();
508
+ const sessionModel = this.currentSettings.model;
509
+ if (sessionModel) {
510
+ const r = this.resolveModel(sessionModel);
511
+ if (r.kind === "ok") {
512
+ return r.model;
513
+ }
514
+ }
515
+ const configModel = this.deps.config.model;
516
+ if (configModel) {
517
+ const r = this.resolveModel(configModel);
518
+ if (r.kind === "ok") {
519
+ return r.model;
520
+ }
521
+ }
522
+ const cwd = this.currentSettings.cwd ?? this.deps.config.cwd;
523
+ const sm = this.deps.settingsManagerFor(cwd);
524
+ const provider = sm.getDefaultProvider();
525
+ const modelId = sm.getDefaultModel();
526
+ if (provider && modelId) {
527
+ const m = this.deps.modelRegistry.find(provider, modelId);
528
+ if (m) {
529
+ return m;
530
+ }
531
+ }
532
+ return this.deps.modelRegistry.getAvailable()[0];
533
+ }
534
+
535
+ private resolveModel(pattern: string): ModelResolveResult {
536
+ this.deps.modelRegistry.refresh();
537
+ const available = this.deps.modelRegistry.getAvailable();
538
+ const candidates: FuzzyCandidate<Model<ModelApi>>[] = available.map(
539
+ (m) => ({
540
+ item: m,
541
+ haystacks: [Session.modelId(m), m.id, m.name],
542
+ })
543
+ );
544
+
545
+ const exact = available.find(
546
+ (m) =>
547
+ Session.modelId(m) === pattern.trim() ||
548
+ m.id === pattern.trim() ||
549
+ m.name === pattern.trim()
550
+ );
551
+ if (exact) {
552
+ return { kind: "ok", model: exact };
553
+ }
554
+
555
+ const hits = FuzzyMatcher.rank(pattern, candidates, { limit: 5 });
556
+ if (hits.length === 0) {
557
+ return {
558
+ kind: "none",
559
+ candidates: available.slice(0, 8).map(Session.modelId),
560
+ };
561
+ }
562
+ if (hits.length === 1) {
563
+ return { kind: "ok", model: hits[0]!.item };
564
+ }
565
+ const top = hits[0]!;
566
+ const second = hits[1]!;
567
+ if (top.score > second.score * 1.5) {
568
+ return { kind: "ok", model: top.item };
569
+ }
570
+ return {
571
+ kind: "ambiguous",
572
+ candidates: hits.map((h) => Session.modelId(h.item)),
573
+ };
574
+ }
575
+
576
+ private static modelId(model: Model<ModelApi>): string {
577
+ return `${model.provider}/${model.id}`;
578
+ }
579
+ }