mercury-agent 0.4.5

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 (218) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +438 -0
  3. package/container/Dockerfile +127 -0
  4. package/container/Dockerfile.base +109 -0
  5. package/container/Dockerfile.power +17 -0
  6. package/container/agent-package.json +8 -0
  7. package/container/build.sh +54 -0
  8. package/docs/TODOS.md +147 -0
  9. package/docs/auth/dashboard.md +28 -0
  10. package/docs/auth/overview.md +109 -0
  11. package/docs/auth/whatsapp.md +173 -0
  12. package/docs/configuration.md +54 -0
  13. package/docs/container-lifecycle.md +349 -0
  14. package/docs/context-architecture.md +87 -0
  15. package/docs/deployment.md +199 -0
  16. package/docs/extensions.md +375 -0
  17. package/docs/graceful-shutdown.md +62 -0
  18. package/docs/kb-distillation.md +77 -0
  19. package/docs/media/overview.md +140 -0
  20. package/docs/media/whatsapp.md +171 -0
  21. package/docs/memory.md +137 -0
  22. package/docs/permissions.md +217 -0
  23. package/docs/pipeline.md +228 -0
  24. package/docs/prd-chat-memory.md +76 -0
  25. package/docs/prd-config-load.md +82 -0
  26. package/docs/rate-limiting.md +166 -0
  27. package/docs/scheduler.md +288 -0
  28. package/docs/setup-discord.md +100 -0
  29. package/docs/setup-slack.md +119 -0
  30. package/docs/setup-whatsapp.md +94 -0
  31. package/docs/subagents.md +166 -0
  32. package/docs/web-search.md +62 -0
  33. package/examples/extensions/README.md +12 -0
  34. package/examples/extensions/charts/index.ts +13 -0
  35. package/examples/extensions/charts/skill/SKILL.md +98 -0
  36. package/examples/extensions/gws/README.md +52 -0
  37. package/examples/extensions/gws/index.ts +106 -0
  38. package/examples/extensions/gws/skill/SKILL.md +57 -0
  39. package/examples/extensions/gws/skill/references/calendar.md +101 -0
  40. package/examples/extensions/gws/skill/references/docs.md +65 -0
  41. package/examples/extensions/gws/skill/references/drive.md +79 -0
  42. package/examples/extensions/gws/skill/references/gmail.md +85 -0
  43. package/examples/extensions/gws/skill/references/sheets.md +60 -0
  44. package/examples/extensions/napkin/index.ts +821 -0
  45. package/examples/extensions/napkin/prompts/consolidation-monthly.md +73 -0
  46. package/examples/extensions/napkin/prompts/consolidation-weekly.md +67 -0
  47. package/examples/extensions/napkin/prompts/kb-distillation.md +176 -0
  48. package/examples/extensions/napkin/skill/SKILL.md +728 -0
  49. package/examples/extensions/pdf/index.ts +23 -0
  50. package/examples/extensions/pdf/skill/LICENSE.txt +30 -0
  51. package/examples/extensions/pdf/skill/SKILL.md +314 -0
  52. package/examples/extensions/pdf/skill/forms.md +294 -0
  53. package/examples/extensions/pdf/skill/reference.md +612 -0
  54. package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -0
  55. package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -0
  56. package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -0
  57. package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -0
  58. package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -0
  59. package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -0
  60. package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -0
  61. package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -0
  62. package/examples/extensions/permission-guard/index.ts +65 -0
  63. package/examples/extensions/pinchtab/index.ts +199 -0
  64. package/examples/extensions/pinchtab/lib/session-injector.ts +144 -0
  65. package/examples/extensions/pinchtab/skill/SKILL.md +224 -0
  66. package/examples/extensions/pinchtab/skill/TRUST.md +69 -0
  67. package/examples/extensions/pinchtab/skill/references/api.md +297 -0
  68. package/examples/extensions/pinchtab/skill/references/env.md +45 -0
  69. package/examples/extensions/pinchtab/skill/references/profiles.md +107 -0
  70. package/examples/extensions/tradestation/host/refresh.ts +102 -0
  71. package/examples/extensions/tradestation/index.ts +153 -0
  72. package/examples/extensions/tradestation/skill/SKILL.md +67 -0
  73. package/examples/extensions/tradestation/skill/scripts/ts-cli.ts +111 -0
  74. package/examples/extensions/voice-synth/index.ts +94 -0
  75. package/examples/extensions/voice-synth/skill/SKILL.md +38 -0
  76. package/examples/extensions/voice-transcribe/index.ts +381 -0
  77. package/examples/extensions/voice-transcribe/requirements.txt +8 -0
  78. package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -0
  79. package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -0
  80. package/examples/extensions/web-search/index.ts +22 -0
  81. package/examples/extensions/web-search/skill/SKILL.md +114 -0
  82. package/examples/extensions/web-search/skill/references/apartments.md +178 -0
  83. package/examples/extensions/web-search/skill/references/car-purchase.md +132 -0
  84. package/examples/extensions/web-search/skill/references/car-rental.md +113 -0
  85. package/examples/extensions/web-search/skill/references/flights.md +133 -0
  86. package/examples/extensions/web-search/skill/references/hotels.md +148 -0
  87. package/examples/extensions/yahoo-mail/cli/bun.lock +66 -0
  88. package/examples/extensions/yahoo-mail/cli/package.json +13 -0
  89. package/examples/extensions/yahoo-mail/cli/ymail.mjs +353 -0
  90. package/examples/extensions/yahoo-mail/index.ts +57 -0
  91. package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -0
  92. package/package.json +106 -0
  93. package/resources/agents/explore.md +50 -0
  94. package/resources/agents/worker.md +24 -0
  95. package/resources/builtin-extensions.txt +3 -0
  96. package/resources/connection-env-vars.json +25 -0
  97. package/resources/extensions/.gitkeep +0 -0
  98. package/resources/pi-extensions/subagent/agents.ts +126 -0
  99. package/resources/pi-extensions/subagent/index.ts +964 -0
  100. package/resources/profiles/coding/AGENTS.md +43 -0
  101. package/resources/profiles/coding/mercury-profile.yaml +15 -0
  102. package/resources/profiles/general/AGENTS.md +31 -0
  103. package/resources/profiles/general/mercury-profile.yaml +15 -0
  104. package/resources/profiles/research/AGENTS.md +40 -0
  105. package/resources/profiles/research/mercury-profile.yaml +15 -0
  106. package/resources/skills/config/SKILL.md +25 -0
  107. package/resources/skills/context/SKILL.md +33 -0
  108. package/resources/skills/conversation-recap/SKILL.md +19 -0
  109. package/resources/skills/media/SKILL.md +27 -0
  110. package/resources/skills/mutes/SKILL.md +31 -0
  111. package/resources/skills/permissions/SKILL.md +19 -0
  112. package/resources/skills/preferences/SKILL.md +31 -0
  113. package/resources/skills/recall/SKILL.md +24 -0
  114. package/resources/skills/roles/SKILL.md +18 -0
  115. package/resources/skills/spaces/SKILL.md +18 -0
  116. package/resources/skills/tasks/SKILL.md +45 -0
  117. package/resources/templates/AGENTS.md +157 -0
  118. package/resources/templates/env.template +34 -0
  119. package/resources/templates/mercury.example.yaml +75 -0
  120. package/src/adapters/discord-native.ts +534 -0
  121. package/src/adapters/discord.ts +38 -0
  122. package/src/adapters/setup.ts +89 -0
  123. package/src/adapters/slack.ts +9 -0
  124. package/src/adapters/whatsapp-media.ts +337 -0
  125. package/src/adapters/whatsapp.ts +629 -0
  126. package/src/agent/api-socket.ts +127 -0
  127. package/src/agent/container-entry.ts +967 -0
  128. package/src/agent/container-error.ts +49 -0
  129. package/src/agent/container-runner.ts +1272 -0
  130. package/src/agent/model-capabilities-core.ts +23 -0
  131. package/src/agent/model-capabilities.ts +231 -0
  132. package/src/agent/pi-failure-class.ts +83 -0
  133. package/src/agent/pi-jsonl-parser.ts +306 -0
  134. package/src/agent/preferences-prompt.ts +20 -0
  135. package/src/agent/user-error-messages.ts +78 -0
  136. package/src/bridges/discord.ts +171 -0
  137. package/src/bridges/slack.ts +177 -0
  138. package/src/bridges/teams.ts +160 -0
  139. package/src/bridges/telegram.ts +571 -0
  140. package/src/bridges/whatsapp.ts +290 -0
  141. package/src/chat-shim.ts +259 -0
  142. package/src/cli/mercury.ts +2508 -0
  143. package/src/cli/mrctl-http.ts +27 -0
  144. package/src/cli/mrctl.ts +611 -0
  145. package/src/cli/whatsapp-auth.ts +260 -0
  146. package/src/config-file.ts +397 -0
  147. package/src/config-model-chain.ts +30 -0
  148. package/src/config.ts +316 -0
  149. package/src/core/api-types.ts +58 -0
  150. package/src/core/api.ts +105 -0
  151. package/src/core/commands.ts +76 -0
  152. package/src/core/conversation.ts +47 -0
  153. package/src/core/handler.ts +206 -0
  154. package/src/core/media.ts +200 -0
  155. package/src/core/mute-duration.ts +22 -0
  156. package/src/core/outbox.ts +76 -0
  157. package/src/core/permissions.ts +192 -0
  158. package/src/core/profiles.ts +245 -0
  159. package/src/core/rate-limiter.ts +127 -0
  160. package/src/core/router.ts +191 -0
  161. package/src/core/routes/chat.ts +172 -0
  162. package/src/core/routes/config-builtin.ts +107 -0
  163. package/src/core/routes/config.ts +81 -0
  164. package/src/core/routes/connections.ts +190 -0
  165. package/src/core/routes/console.ts +668 -0
  166. package/src/core/routes/control.ts +46 -0
  167. package/src/core/routes/conversations.ts +66 -0
  168. package/src/core/routes/dashboard.ts +2491 -0
  169. package/src/core/routes/extensions.ts +37 -0
  170. package/src/core/routes/index.ts +14 -0
  171. package/src/core/routes/media.ts +72 -0
  172. package/src/core/routes/messages.ts +37 -0
  173. package/src/core/routes/mutes.ts +89 -0
  174. package/src/core/routes/prefs.ts +95 -0
  175. package/src/core/routes/roles.ts +125 -0
  176. package/src/core/routes/spaces.ts +60 -0
  177. package/src/core/routes/storage.ts +126 -0
  178. package/src/core/routes/tasks.ts +189 -0
  179. package/src/core/routes/tradestation.ts +268 -0
  180. package/src/core/routes/tts.ts +51 -0
  181. package/src/core/runtime.ts +1140 -0
  182. package/src/core/space-queue.ts +103 -0
  183. package/src/core/storage-cleanup.ts +140 -0
  184. package/src/core/storage-guard.ts +24 -0
  185. package/src/core/task-scheduler.ts +132 -0
  186. package/src/core/telegram-format.ts +178 -0
  187. package/src/core/trigger.ts +142 -0
  188. package/src/dashboard/index.html +729 -0
  189. package/src/dashboard/tokens.css +53 -0
  190. package/src/extensions/api.ts +252 -0
  191. package/src/extensions/catalog.ts +117 -0
  192. package/src/extensions/config-registry.ts +83 -0
  193. package/src/extensions/context.ts +36 -0
  194. package/src/extensions/hooks.ts +156 -0
  195. package/src/extensions/image-builder.ts +617 -0
  196. package/src/extensions/installer.ts +306 -0
  197. package/src/extensions/jobs.ts +122 -0
  198. package/src/extensions/loader.ts +271 -0
  199. package/src/extensions/permission-guard.ts +52 -0
  200. package/src/extensions/reserved.ts +28 -0
  201. package/src/extensions/skills.ts +123 -0
  202. package/src/extensions/types.ts +462 -0
  203. package/src/logger.ts +174 -0
  204. package/src/main.ts +586 -0
  205. package/src/server.ts +391 -0
  206. package/src/storage/db.ts +1624 -0
  207. package/src/storage/memory.ts +45 -0
  208. package/src/storage/pi-auth.ts +95 -0
  209. package/src/text/markdown.ts +117 -0
  210. package/src/text/rtl.ts +38 -0
  211. package/src/tradestation/host-api.ts +77 -0
  212. package/src/tradestation/pending-orders.ts +69 -0
  213. package/src/tts/azure.ts +52 -0
  214. package/src/tts/google.ts +128 -0
  215. package/src/tts/index.ts +8 -0
  216. package/src/tts/language.ts +20 -0
  217. package/src/tts/synthesize.ts +133 -0
  218. package/src/types.ts +295 -0
@@ -0,0 +1,103 @@
1
+ type Work<T> = () => Promise<T>;
2
+
3
+ export class SpaceQueue {
4
+ private readonly perSpacePending = new Map<string, Array<() => void>>();
5
+ private readonly activeSpaces = new Set<string>();
6
+ private activeGlobal = 0;
7
+
8
+ constructor(private readonly maxConcurrency: number) {}
9
+
10
+ enqueue<T>(spaceId: string, work: Work<T>): Promise<T> {
11
+ return new Promise<T>((resolve, reject) => {
12
+ const run = async () => {
13
+ this.activeGlobal += 1;
14
+ this.activeSpaces.add(spaceId);
15
+ try {
16
+ resolve(await work());
17
+ } catch (error) {
18
+ reject(error);
19
+ } finally {
20
+ this.activeGlobal -= 1;
21
+ this.activeSpaces.delete(spaceId);
22
+ this.startNext(spaceId);
23
+ this.drainOtherSpaces();
24
+ }
25
+ };
26
+
27
+ const queue = this.perSpacePending.get(spaceId) ?? [];
28
+ queue.push(run);
29
+ this.perSpacePending.set(spaceId, queue);
30
+ this.drainOtherSpaces();
31
+ });
32
+ }
33
+
34
+ cancelAll(): number {
35
+ let total = 0;
36
+ for (const [_spaceId, queue] of this.perSpacePending) {
37
+ total += queue.length;
38
+ }
39
+ this.perSpacePending.clear();
40
+ return total;
41
+ }
42
+
43
+ get activeCount(): number {
44
+ return this.activeGlobal;
45
+ }
46
+
47
+ get pendingCount(): number {
48
+ let total = 0;
49
+ for (const queue of this.perSpacePending.values()) {
50
+ total += queue.length;
51
+ }
52
+ return total;
53
+ }
54
+
55
+ waitForActive(timeoutMs: number): Promise<boolean> {
56
+ if (this.activeGlobal === 0) return Promise.resolve(true);
57
+ return new Promise<boolean>((resolve) => {
58
+ const checkInterval = setInterval(() => {
59
+ if (this.activeGlobal === 0) {
60
+ clearInterval(checkInterval);
61
+ resolve(true);
62
+ }
63
+ }, 100);
64
+ setTimeout(() => {
65
+ clearInterval(checkInterval);
66
+ resolve(this.activeGlobal === 0);
67
+ }, timeoutMs);
68
+ });
69
+ }
70
+
71
+ cancelPending(spaceId: string): number {
72
+ const queue = this.perSpacePending.get(spaceId);
73
+ if (!queue || queue.length === 0) return 0;
74
+ const count = queue.length;
75
+ this.perSpacePending.delete(spaceId);
76
+ return count;
77
+ }
78
+
79
+ isActive(spaceId: string): boolean {
80
+ return this.activeSpaces.has(spaceId);
81
+ }
82
+
83
+ private canStart(spaceId: string): boolean {
84
+ return (
85
+ this.activeGlobal < this.maxConcurrency && !this.activeSpaces.has(spaceId)
86
+ );
87
+ }
88
+
89
+ private startNext(spaceId: string): void {
90
+ const queue = this.perSpacePending.get(spaceId);
91
+ if (!queue || queue.length === 0 || !this.canStart(spaceId)) return;
92
+ const next = queue.shift();
93
+ if (queue.length === 0) this.perSpacePending.delete(spaceId);
94
+ next?.();
95
+ }
96
+
97
+ private drainOtherSpaces(): void {
98
+ for (const spaceId of this.perSpacePending.keys()) {
99
+ if (this.activeGlobal >= this.maxConcurrency) return;
100
+ this.startNext(spaceId);
101
+ }
102
+ }
103
+ }
@@ -0,0 +1,140 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { AppConfig } from "../config.js";
4
+ import type { Logger } from "../logger.js";
5
+ import type { Db } from "../storage/db.js";
6
+
7
+ interface CleanupOpts {
8
+ config: AppConfig;
9
+ db: Db;
10
+ log: Logger;
11
+ isSpaceActive: (spaceId: string) => boolean;
12
+ }
13
+
14
+ interface CleanupResult {
15
+ spacesScanned: number;
16
+ spacesSkipped: number;
17
+ filesDeleted: number;
18
+ bytesFreed: number;
19
+ attachmentsNullified: number;
20
+ }
21
+
22
+ /**
23
+ * Scan all space directories and delete inbox/outbox files older than their TTL.
24
+ * Skips spaces with active container runs.
25
+ */
26
+ export async function runStorageCleanup(
27
+ opts: CleanupOpts,
28
+ ): Promise<CleanupResult> {
29
+ const { config, db, log, isSpaceActive } = opts;
30
+ const spacesDir = config.spacesDir;
31
+ const inboxMaxAge = config.inboxTtlDays * 24 * 60 * 60 * 1000;
32
+ const outboxMaxAge = config.outboxTtlDays * 24 * 60 * 60 * 1000;
33
+ const now = Date.now();
34
+
35
+ const result: CleanupResult = {
36
+ spacesScanned: 0,
37
+ spacesSkipped: 0,
38
+ filesDeleted: 0,
39
+ bytesFreed: 0,
40
+ attachmentsNullified: 0,
41
+ };
42
+
43
+ let spaceDirs: string[];
44
+ try {
45
+ spaceDirs = fs
46
+ .readdirSync(spacesDir, { withFileTypes: true })
47
+ .filter((e) => e.isDirectory())
48
+ .map((e) => e.name);
49
+ } catch {
50
+ return result;
51
+ }
52
+
53
+ for (const spaceId of spaceDirs) {
54
+ result.spacesScanned++;
55
+
56
+ if (isSpaceActive(spaceId)) {
57
+ result.spacesSkipped++;
58
+ continue;
59
+ }
60
+
61
+ const spaceDir = path.join(spacesDir, spaceId);
62
+ let inboxCleaned = false;
63
+
64
+ const inboxResult = cleanDir(
65
+ path.join(spaceDir, "inbox"),
66
+ now,
67
+ inboxMaxAge,
68
+ );
69
+ result.filesDeleted += inboxResult.deleted;
70
+ result.bytesFreed += inboxResult.bytesFreed;
71
+ if (inboxResult.deleted > 0) inboxCleaned = true;
72
+
73
+ const outboxResult = cleanDir(
74
+ path.join(spaceDir, "outbox"),
75
+ now,
76
+ outboxMaxAge,
77
+ );
78
+ result.filesDeleted += outboxResult.deleted;
79
+ result.bytesFreed += outboxResult.bytesFreed;
80
+
81
+ if (inboxCleaned) {
82
+ const nullified = db.clearSpaceAttachments(spaceId);
83
+ result.attachmentsNullified += nullified;
84
+ }
85
+ }
86
+
87
+ if (result.filesDeleted > 0) {
88
+ log.info("Storage cleanup complete", {
89
+ spacesScanned: result.spacesScanned,
90
+ spacesSkipped: result.spacesSkipped,
91
+ filesDeleted: result.filesDeleted,
92
+ bytesFreed: result.bytesFreed,
93
+ attachmentsNullified: result.attachmentsNullified,
94
+ });
95
+ }
96
+
97
+ return result;
98
+ }
99
+
100
+ function cleanDir(
101
+ dir: string,
102
+ now: number,
103
+ maxAgeMs: number,
104
+ ): { deleted: number; bytesFreed: number } {
105
+ let entries: fs.Dirent[];
106
+ try {
107
+ entries = fs.readdirSync(dir, { withFileTypes: true });
108
+ } catch {
109
+ return { deleted: 0, bytesFreed: 0 };
110
+ }
111
+
112
+ let deleted = 0;
113
+ let bytesFreed = 0;
114
+
115
+ for (const entry of entries) {
116
+ if (!entry.isFile()) continue;
117
+ if (entry.name.startsWith(".")) continue;
118
+
119
+ const filePath = path.join(dir, entry.name);
120
+
121
+ let stat: fs.Stats;
122
+ try {
123
+ stat = fs.statSync(filePath);
124
+ } catch {
125
+ continue;
126
+ }
127
+
128
+ if (now - stat.mtimeMs < maxAgeMs) continue;
129
+
130
+ try {
131
+ fs.unlinkSync(filePath);
132
+ deleted++;
133
+ bytesFreed += stat.size;
134
+ } catch {
135
+ // File may have been deleted between stat and unlink
136
+ }
137
+ }
138
+
139
+ return { deleted, bytesFreed };
140
+ }
@@ -0,0 +1,24 @@
1
+ import { statfs } from "node:fs/promises";
2
+ import type { AppConfig } from "../config.js";
3
+
4
+ /**
5
+ * Check whether the agent's data directory is over its disk quota.
6
+ * Returns false (fail-open) when:
7
+ * - maxDiskMb is unset (local/self-hosted — no enforcement)
8
+ * - statfs fails (Windows, unmounted dir)
9
+ * - statfs returns 0 total bytes (unknown filesystem)
10
+ */
11
+ export async function isOverQuota(config: AppConfig): Promise<boolean> {
12
+ if (!config.maxDiskMb) return false;
13
+
14
+ try {
15
+ const fs = await statfs(config.spacesDir);
16
+ const totalBytes = fs.blocks * fs.bsize;
17
+ if (totalBytes === 0) return false;
18
+ const freeBytes = fs.bavail * fs.bsize;
19
+ const usedBytes = totalBytes - freeBytes;
20
+ return usedBytes > config.maxDiskMb * 1024 * 1024;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
@@ -0,0 +1,132 @@
1
+ import { CronExpressionParser } from "cron-parser";
2
+ import { logger } from "../logger.js";
3
+ import type { Db } from "../storage/db.js";
4
+
5
+ type TaskHandler = (task: {
6
+ id: number;
7
+ spaceId: string;
8
+ prompt: string;
9
+ createdBy: string;
10
+ silent: boolean;
11
+ }) => Promise<void>;
12
+
13
+ export class TaskScheduler {
14
+ private timer: NodeJS.Timeout | null = null;
15
+ private handler: TaskHandler | null = null;
16
+
17
+ constructor(
18
+ private readonly db: Db,
19
+ private readonly pollIntervalMs = 5_000,
20
+ ) {}
21
+
22
+ start(handler: TaskHandler) {
23
+ if (this.timer) return;
24
+ this.handler = handler;
25
+ this.recalculateNextRuns();
26
+
27
+ const tick = async () => {
28
+ try {
29
+ const due = this.db.getDueTasks(Date.now());
30
+ for (const task of due) {
31
+ // For cron tasks, update next run before execution
32
+ // For at-tasks, we'll delete after execution
33
+ if (task.cron) {
34
+ const next = this.computeNextRun(
35
+ task.cron,
36
+ task.timezone ?? undefined,
37
+ );
38
+ this.db.updateTaskNextRun(task.id, next);
39
+ }
40
+
41
+ try {
42
+ await handler({
43
+ id: task.id,
44
+ spaceId: task.spaceId,
45
+ prompt: task.prompt,
46
+ createdBy: task.createdBy,
47
+ silent: task.silent === 1,
48
+ });
49
+ } catch (error) {
50
+ logger.error("Scheduler task handler failed", {
51
+ taskId: task.id,
52
+ spaceId: task.spaceId,
53
+ error: error instanceof Error ? error.message : String(error),
54
+ });
55
+ }
56
+
57
+ // For at-tasks, delete after execution (regardless of success/failure)
58
+ if (task.at) {
59
+ this.db.deleteTaskById(task.id);
60
+ logger.info("One-shot task completed and deleted", {
61
+ taskId: task.id,
62
+ spaceId: task.spaceId,
63
+ });
64
+ }
65
+ }
66
+ } catch (error) {
67
+ logger.error(
68
+ "Scheduler error",
69
+ error instanceof Error ? error : undefined,
70
+ );
71
+ } finally {
72
+ this.timer = setTimeout(tick, this.pollIntervalMs);
73
+ }
74
+ };
75
+
76
+ this.timer = setTimeout(tick, this.pollIntervalMs);
77
+ }
78
+
79
+ stop() {
80
+ if (!this.timer) return;
81
+ clearTimeout(this.timer);
82
+ this.timer = null;
83
+ }
84
+
85
+ computeNextRun(cron: string, timezone?: string, from = new Date()): number {
86
+ try {
87
+ const interval = CronExpressionParser.parse(cron, {
88
+ currentDate: from,
89
+ tz: timezone ?? "UTC",
90
+ });
91
+ return interval.next().getTime();
92
+ } catch {
93
+ // Timezone rejected by cron-parser — fall back to UTC
94
+ const interval = CronExpressionParser.parse(cron, { currentDate: from });
95
+ return interval.next().getTime();
96
+ }
97
+ }
98
+
99
+ private recalculateNextRuns(): void {
100
+ const tasks = this.db.listTasks();
101
+ let updated = 0;
102
+ for (const task of tasks) {
103
+ if (!task.active || !task.cron) continue;
104
+ const correct = this.computeNextRun(
105
+ task.cron,
106
+ task.timezone ?? undefined,
107
+ );
108
+ if (correct !== task.nextRunAt) {
109
+ this.db.updateTaskNextRun(task.id, correct);
110
+ updated++;
111
+ }
112
+ }
113
+ if (updated > 0) {
114
+ logger.info(`Recalculated next_run_at for ${updated} cron task(s)`);
115
+ }
116
+ }
117
+
118
+ async triggerTask(taskId: number): Promise<boolean> {
119
+ if (!this.handler) return false;
120
+ const task = this.db.getTask(taskId);
121
+ if (!task?.active) return false;
122
+
123
+ await this.handler({
124
+ id: task.id,
125
+ spaceId: task.spaceId,
126
+ prompt: task.prompt,
127
+ createdBy: task.createdBy,
128
+ silent: task.silent === 1,
129
+ });
130
+ return true;
131
+ }
132
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Convert agent Markdown-style output to Telegram HTML for parse_mode.
3
+ * Order: escape → [text](url) → (https://…) anchors → ### headers → **bold** → *italic* → collapse nested <b>.
4
+ */
5
+
6
+ export const TELEGRAM_MESSAGE_LIMIT = 4096;
7
+
8
+ export function escapeHtml(text: string): string {
9
+ return text
10
+ .replace(/&/g, "&amp;")
11
+ .replace(/</g, "&lt;")
12
+ .replace(/>/g, "&gt;");
13
+ }
14
+
15
+ /** Telegram HTML rejects nested identical tags (e.g. <b><b>x</b></b>). */
16
+ function collapseNestedBold(html: string): string {
17
+ let out = html;
18
+ let prev: string;
19
+ do {
20
+ prev = out;
21
+ out = out.replace(/<b>\s*<b>/g, "<b>").replace(/<\/b>\s*<\/b>/g, "</b>");
22
+ } while (out !== prev);
23
+ return out;
24
+ }
25
+
26
+ function hrefAttr(url: string): string {
27
+ return url.replace(/"/g, "&quot;");
28
+ }
29
+
30
+ /**
31
+ * Wrap (https://...) style URLs so the closing ")" is not part of the link target
32
+ * and Telegram gets a valid <a href>.
33
+ */
34
+ function parentheticalUrlsToAnchors(html: string): string {
35
+ return html.replace(
36
+ /\((https?:\/\/[^)\s<]+)\)/g,
37
+ (_, url: string) => `(<a href="${hrefAttr(url)}">${url}</a>)`,
38
+ );
39
+ }
40
+
41
+ type TelegramOpenTag = "b" | "i" | "a";
42
+
43
+ /** Tags produced by markdownToTelegramHtml (Telegram HTML parse_mode). */
44
+ function collectOpenTelegramTags(s: string): TelegramOpenTag[] {
45
+ const stack: TelegramOpenTag[] = [];
46
+ let i = 0;
47
+ while (i < s.length) {
48
+ if (s[i] !== "<") {
49
+ i++;
50
+ continue;
51
+ }
52
+ if (s.startsWith("</b>", i)) {
53
+ if (stack[stack.length - 1] === "b") stack.pop();
54
+ i += 4;
55
+ continue;
56
+ }
57
+ if (s.startsWith("</i>", i)) {
58
+ if (stack[stack.length - 1] === "i") stack.pop();
59
+ i += 4;
60
+ continue;
61
+ }
62
+ if (s.startsWith("</a>", i)) {
63
+ if (stack[stack.length - 1] === "a") stack.pop();
64
+ i += 4;
65
+ continue;
66
+ }
67
+ if (s.startsWith("<b>", i)) {
68
+ stack.push("b");
69
+ i += 3;
70
+ continue;
71
+ }
72
+ if (s.startsWith("<i>", i)) {
73
+ stack.push("i");
74
+ i += 3;
75
+ continue;
76
+ }
77
+ if (s.startsWith('<a href="', i)) {
78
+ const close = s.indexOf('">', i);
79
+ if (close === -1) break;
80
+ stack.push("a");
81
+ i = close + 2;
82
+ continue;
83
+ }
84
+ i++;
85
+ }
86
+ return stack;
87
+ }
88
+
89
+ function closingTagsForStack(stack: TelegramOpenTag[]): string {
90
+ const closers: Record<TelegramOpenTag, string> = {
91
+ b: "</b>",
92
+ i: "</i>",
93
+ a: "</a>",
94
+ };
95
+ let out = "";
96
+ for (let k = stack.length - 1; k >= 0; k--) {
97
+ const tag = stack[k];
98
+ if (tag) out += closers[tag];
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /**
104
+ * Truncate formatted Telegram HTML without splitting tags or leaving them unclosed.
105
+ * Avoids HTTP 400 "can't parse entities" when the raw slice cuts inside `<b>` / `</a>` etc.
106
+ */
107
+ export function truncateTelegramHtml(html: string, maxLen: number): string {
108
+ if (html.length <= maxLen) return html;
109
+ if (maxLen <= 0) return "";
110
+
111
+ let cut = html.slice(0, maxLen);
112
+ const stripIncompleteTagTail = (s: string): string => {
113
+ const lastLt = s.lastIndexOf("<");
114
+ if (lastLt < 0) return s;
115
+ if (s.indexOf(">", lastLt) === -1) {
116
+ return s.slice(0, lastLt);
117
+ }
118
+ return s;
119
+ };
120
+ cut = stripIncompleteTagTail(cut);
121
+
122
+ let suffix = closingTagsForStack(collectOpenTelegramTags(cut));
123
+ while (cut.length + suffix.length > maxLen && cut.length > 0) {
124
+ const over = cut.length + suffix.length - maxLen;
125
+ cut = cut.slice(0, Math.max(0, cut.length - over - 1));
126
+ cut = stripIncompleteTagTail(cut);
127
+ suffix = closingTagsForStack(collectOpenTelegramTags(cut));
128
+ }
129
+
130
+ if (cut.length + suffix.length > maxLen) {
131
+ return escapeHtml(html).slice(0, maxLen);
132
+ }
133
+ return cut + suffix;
134
+ }
135
+
136
+ /**
137
+ * Convert Markdown-style text to Telegram HTML.
138
+ * Handles: **bold**, *italic*, [text](url), ### headers, (https://...) URLs.
139
+ * On error, returns original text.
140
+ */
141
+ export function markdownToTelegramHtml(text: string): string {
142
+ try {
143
+ if (!text || typeof text !== "string") return text;
144
+
145
+ // 1. Escape first so all content is safe; replacements use already-escaped content
146
+ let out = escapeHtml(text);
147
+
148
+ // 2. Links [text](url) — url already escaped; only escape " for attr
149
+ out = out.replace(
150
+ /\[([^\]]*)\]\(([^)]*)\)/g,
151
+ (_, linkText, url) => `<a href="${hrefAttr(url)}">${linkText}</a>`,
152
+ );
153
+
154
+ // 3. Plain (https://...) after labels — avoids ")" in href when users tap links
155
+ out = parentheticalUrlsToAnchors(out);
156
+
157
+ // 4. ### headers — before **bold** so "### **Title**" does not yield nested <b>…</b>
158
+ out = out.replace(
159
+ /(^|\n)###\s+([^\n]+)(\n)?/g,
160
+ (_, prefix, header, nl) => `${prefix}<b>${header}</b>${nl ?? "\n"}`,
161
+ );
162
+
163
+ // 5. Bold **text**
164
+ out = out.replace(/\*\*([^*]+)\*\*/g, (_, content) => `<b>${content}</b>`);
165
+
166
+ // 6. Italic *text* (not part of **)
167
+ out = out.replace(
168
+ /(?<!\*)\*([^*]+)\*(?!\*)/g,
169
+ (_, content) => `<i>${content}</i>`,
170
+ );
171
+
172
+ out = collapseNestedBold(out);
173
+
174
+ return out;
175
+ } catch {
176
+ return text;
177
+ }
178
+ }