talon-agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +137 -0
  2. package/bin/talon.js +5 -0
  3. package/package.json +86 -0
  4. package/prompts/base.md +13 -0
  5. package/prompts/custom.md.example +22 -0
  6. package/prompts/dream.md +41 -0
  7. package/prompts/identity.md +45 -0
  8. package/prompts/teams.md +52 -0
  9. package/prompts/telegram.md +89 -0
  10. package/prompts/terminal.md +13 -0
  11. package/src/__tests__/chat-id.test.ts +91 -0
  12. package/src/__tests__/chat-settings.test.ts +337 -0
  13. package/src/__tests__/config.test.ts +546 -0
  14. package/src/__tests__/cron-store.test.ts +440 -0
  15. package/src/__tests__/daily-log.test.ts +146 -0
  16. package/src/__tests__/dispatcher.test.ts +383 -0
  17. package/src/__tests__/errors.test.ts +240 -0
  18. package/src/__tests__/fuzz.test.ts +302 -0
  19. package/src/__tests__/gateway-actions.test.ts +1453 -0
  20. package/src/__tests__/gateway-context.test.ts +102 -0
  21. package/src/__tests__/gateway-http.test.ts +245 -0
  22. package/src/__tests__/handlers.test.ts +351 -0
  23. package/src/__tests__/history-persistence.test.ts +172 -0
  24. package/src/__tests__/history.test.ts +659 -0
  25. package/src/__tests__/integration.test.ts +189 -0
  26. package/src/__tests__/log.test.ts +110 -0
  27. package/src/__tests__/media-index.test.ts +277 -0
  28. package/src/__tests__/plugin.test.ts +317 -0
  29. package/src/__tests__/prompt-builder.test.ts +71 -0
  30. package/src/__tests__/sessions.test.ts +594 -0
  31. package/src/__tests__/teams-frontend.test.ts +239 -0
  32. package/src/__tests__/telegram.test.ts +177 -0
  33. package/src/__tests__/terminal-commands.test.ts +367 -0
  34. package/src/__tests__/terminal-frontend.test.ts +141 -0
  35. package/src/__tests__/terminal-renderer.test.ts +278 -0
  36. package/src/__tests__/watchdog.test.ts +287 -0
  37. package/src/__tests__/workspace.test.ts +184 -0
  38. package/src/backend/claude-sdk/index.ts +438 -0
  39. package/src/backend/claude-sdk/tools.ts +605 -0
  40. package/src/backend/opencode/index.ts +252 -0
  41. package/src/bootstrap.ts +134 -0
  42. package/src/cli.ts +611 -0
  43. package/src/core/cron.ts +148 -0
  44. package/src/core/dispatcher.ts +126 -0
  45. package/src/core/dream.ts +295 -0
  46. package/src/core/errors.ts +206 -0
  47. package/src/core/gateway-actions.ts +267 -0
  48. package/src/core/gateway.ts +258 -0
  49. package/src/core/plugin.ts +432 -0
  50. package/src/core/prompt-builder.ts +43 -0
  51. package/src/core/pulse.ts +175 -0
  52. package/src/core/types.ts +85 -0
  53. package/src/frontend/teams/actions.ts +101 -0
  54. package/src/frontend/teams/formatting.ts +220 -0
  55. package/src/frontend/teams/graph.ts +297 -0
  56. package/src/frontend/teams/index.ts +308 -0
  57. package/src/frontend/teams/proxy-fetch.ts +28 -0
  58. package/src/frontend/teams/tools.ts +177 -0
  59. package/src/frontend/telegram/actions.ts +437 -0
  60. package/src/frontend/telegram/admin.ts +178 -0
  61. package/src/frontend/telegram/callbacks.ts +251 -0
  62. package/src/frontend/telegram/commands.ts +543 -0
  63. package/src/frontend/telegram/formatting.ts +101 -0
  64. package/src/frontend/telegram/handlers.ts +1008 -0
  65. package/src/frontend/telegram/helpers.ts +105 -0
  66. package/src/frontend/telegram/index.ts +130 -0
  67. package/src/frontend/telegram/middleware.ts +177 -0
  68. package/src/frontend/telegram/userbot.ts +546 -0
  69. package/src/frontend/terminal/commands.ts +303 -0
  70. package/src/frontend/terminal/index.ts +282 -0
  71. package/src/frontend/terminal/input.ts +297 -0
  72. package/src/frontend/terminal/renderer.ts +248 -0
  73. package/src/index.ts +144 -0
  74. package/src/login.ts +89 -0
  75. package/src/storage/chat-settings.ts +218 -0
  76. package/src/storage/cron-store.ts +165 -0
  77. package/src/storage/daily-log.ts +97 -0
  78. package/src/storage/history.ts +278 -0
  79. package/src/storage/media-index.ts +116 -0
  80. package/src/storage/sessions.ts +328 -0
  81. package/src/util/chat-id.ts +21 -0
  82. package/src/util/config.ts +244 -0
  83. package/src/util/log.ts +122 -0
  84. package/src/util/paths.ts +80 -0
  85. package/src/util/time.ts +86 -0
  86. package/src/util/trace.ts +35 -0
  87. package/src/util/watchdog.ts +108 -0
  88. package/src/util/workspace.ts +208 -0
  89. package/tsconfig.json +13 -0
@@ -0,0 +1,239 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ vi.mock("../util/log.js", () => ({
4
+ log: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn(),
5
+ }));
6
+
7
+ vi.mock("../core/plugin.js", () => ({
8
+ handlePluginAction: vi.fn(async () => null),
9
+ }));
10
+
11
+ // ── Test formatting module ──────────────────────────────────────────────────
12
+
13
+ const { buildAdaptiveCard, splitTeamsMessage, stripHtml } = await import(
14
+ "../frontend/teams/formatting.js"
15
+ );
16
+
17
+ describe("teams formatting", () => {
18
+
19
+ describe("buildAdaptiveCard", () => {
20
+ it("builds a basic card with text", () => {
21
+ const card = buildAdaptiveCard("Hello Teams!");
22
+ expect(card.type).toBe("message");
23
+ expect(card.attachments).toHaveLength(1);
24
+ const attachment = (card.attachments as Array<Record<string, unknown>>)[0];
25
+ expect(attachment.contentType).toBe("application/vnd.microsoft.card.adaptive");
26
+ const content = attachment.content as Record<string, unknown>;
27
+ expect(content.type).toBe("AdaptiveCard");
28
+ expect(content.version).toBe("1.5");
29
+ const body = content.body as Array<Record<string, unknown>>;
30
+ expect(body[0].text).toBe("Hello Teams!");
31
+ expect(body[0].wrap).toBe(true);
32
+ });
33
+
34
+ it("includes URL buttons as OpenUrl actions", () => {
35
+ const card = buildAdaptiveCard("Pick one:", [
36
+ { text: "Docs", url: "https://docs.example.com" },
37
+ { text: "Repo", url: "https://github.com/example" },
38
+ ]);
39
+ const content = ((card.attachments as unknown[])[0] as Record<string, unknown>).content as Record<string, unknown>;
40
+ const actions = content.actions as Array<Record<string, unknown>>;
41
+ expect(actions).toHaveLength(2);
42
+ expect(actions[0].type).toBe("Action.OpenUrl");
43
+ expect(actions[0].title).toBe("Docs");
44
+ expect(actions[0].url).toBe("https://docs.example.com");
45
+ });
46
+
47
+ it("includes non-URL buttons as Submit actions", () => {
48
+ const card = buildAdaptiveCard("Choose:", [
49
+ { text: "Option A" },
50
+ { text: "Option B" },
51
+ ]);
52
+ const content = ((card.attachments as unknown[])[0] as Record<string, unknown>).content as Record<string, unknown>;
53
+ const actions = content.actions as Array<Record<string, unknown>>;
54
+ expect(actions[0].type).toBe("Action.Submit");
55
+ expect(actions[0].data).toEqual({ choice: "Option A" });
56
+ });
57
+
58
+ it("omits actions when no buttons provided", () => {
59
+ const card = buildAdaptiveCard("No buttons");
60
+ const content = ((card.attachments as unknown[])[0] as Record<string, unknown>).content as Record<string, unknown>;
61
+ expect(content.actions).toBeUndefined();
62
+ });
63
+
64
+ it("omits actions for empty button array", () => {
65
+ const card = buildAdaptiveCard("Empty buttons", []);
66
+ const content = ((card.attachments as unknown[])[0] as Record<string, unknown>).content as Record<string, unknown>;
67
+ expect(content.actions).toBeUndefined();
68
+ });
69
+ });
70
+
71
+ describe("splitTeamsMessage", () => {
72
+ it("returns single chunk for short messages", () => {
73
+ expect(splitTeamsMessage("short")).toEqual(["short"]);
74
+ });
75
+
76
+ it("splits on paragraph boundaries", () => {
77
+ const text = "A".repeat(5000) + "\n\n" + "B".repeat(5000) + "\n\n" + "C".repeat(100);
78
+ const chunks = splitTeamsMessage(text, 6000);
79
+ expect(chunks.length).toBeGreaterThanOrEqual(2);
80
+ expect(chunks[0]).toContain("A");
81
+ });
82
+
83
+ it("falls back to line boundaries when no paragraph break", () => {
84
+ const text = Array.from({ length: 200 }, (_, i) => `Line ${i}`).join("\n");
85
+ const chunks = splitTeamsMessage(text, 500);
86
+ expect(chunks.length).toBeGreaterThanOrEqual(2);
87
+ for (const chunk of chunks) {
88
+ expect(chunk.length).toBeLessThanOrEqual(500);
89
+ }
90
+ });
91
+
92
+ it("hard splits when no newlines available", () => {
93
+ const text = "X".repeat(25000);
94
+ const chunks = splitTeamsMessage(text, 10000);
95
+ expect(chunks.length).toBe(3);
96
+ });
97
+
98
+ it("default max is 10000 characters", () => {
99
+ const text = "Y".repeat(15000);
100
+ const chunks = splitTeamsMessage(text);
101
+ expect(chunks.length).toBe(2);
102
+ });
103
+ });
104
+
105
+ describe("stripHtml", () => {
106
+ it("strips HTML tags", () => {
107
+ expect(stripHtml("<p>Hello <b>world</b></p>")).toBe("Hello world");
108
+ });
109
+
110
+ it("handles nested tags", () => {
111
+ expect(stripHtml("<div><p>Text <em>here</em></p></div>")).toBe("Text here");
112
+ });
113
+
114
+ it("returns plain text unchanged", () => {
115
+ expect(stripHtml("no html here")).toBe("no html here");
116
+ });
117
+
118
+ it("handles empty string", () => {
119
+ expect(stripHtml("")).toBe("");
120
+ });
121
+
122
+ it("decodes HTML entities", () => {
123
+ expect(stripHtml("<p>&amp; &lt; &gt;</p>")).toBe("& < >");
124
+ });
125
+
126
+ it("handles Teams-style HTML with mentions", () => {
127
+ const html = '<p><at id="0">User Name</at> hello there</p>';
128
+ const result = stripHtml(html);
129
+ expect(result).toContain("User Name");
130
+ expect(result).toContain("hello there");
131
+ });
132
+ });
133
+ });
134
+
135
+ // ── Test action handler ─────────────────────────────────────────────────────
136
+
137
+ describe("teams actions", () => {
138
+ beforeEach(() => {
139
+ vi.resetModules();
140
+ });
141
+
142
+ it("send_message posts to webhook and increments messages", async () => {
143
+ vi.doMock("../frontend/teams/proxy-fetch.js", () => ({
144
+ proxyFetch: vi.fn(async () => ({ ok: true, text: async () => "" })),
145
+ }));
146
+
147
+ const { Gateway } = await import("../core/gateway.js");
148
+ const { createTeamsActionHandler } = await import("../frontend/teams/actions.js");
149
+
150
+ const gateway = new Gateway();
151
+ gateway.setContext(123);
152
+ const handler = createTeamsActionHandler("https://webhook.example.com", gateway);
153
+
154
+ const result = await handler({ action: "send_message", text: "Hello Teams!" }, 123);
155
+ expect(result?.ok).toBe(true);
156
+ expect(result?.message_id).toBeDefined();
157
+ expect(gateway.getMessageCount(123)).toBe(1);
158
+ gateway.clearContext(123);
159
+ });
160
+
161
+ it("send_message returns ok for empty text (no-op)", async () => {
162
+ const { Gateway } = await import("../core/gateway.js");
163
+ const { createTeamsActionHandler } = await import("../frontend/teams/actions.js");
164
+ const gateway = new Gateway();
165
+ const handler = createTeamsActionHandler("https://webhook.example.com", gateway);
166
+
167
+ const result = await handler({ action: "send_message", text: "" }, 123);
168
+ expect(result?.ok).toBe(true);
169
+ });
170
+
171
+ it("send_message handles webhook failure", async () => {
172
+ vi.doMock("../frontend/teams/proxy-fetch.js", () => ({
173
+ proxyFetch: vi.fn(async () => ({ ok: false, status: 500, text: async () => "Internal Error" })),
174
+ }));
175
+
176
+ const { Gateway } = await import("../core/gateway.js");
177
+ const { createTeamsActionHandler } = await import("../frontend/teams/actions.js");
178
+ const gateway = new Gateway();
179
+ const handler = createTeamsActionHandler("https://webhook.example.com", gateway);
180
+
181
+ const result = await handler({ action: "send_message", text: "fail" }, 123);
182
+ expect(result?.ok).toBe(false);
183
+ expect(result?.error).toContain("failed");
184
+ });
185
+
186
+ it("get_chat_info returns channel info", async () => {
187
+ const { Gateway } = await import("../core/gateway.js");
188
+ const { createTeamsActionHandler } = await import("../frontend/teams/actions.js");
189
+ const gateway = new Gateway();
190
+ const handler = createTeamsActionHandler("https://webhook.example.com", gateway);
191
+
192
+ const result = await handler({ action: "get_chat_info" }, 456);
193
+ expect(result?.ok).toBe(true);
194
+ expect(result?.type).toBe("channel");
195
+ expect(result?.id).toBe(456);
196
+ });
197
+
198
+ it("unsupported actions return ok (graceful no-ops)", async () => {
199
+ const { Gateway } = await import("../core/gateway.js");
200
+ const { createTeamsActionHandler } = await import("../frontend/teams/actions.js");
201
+ const gateway = new Gateway();
202
+ const handler = createTeamsActionHandler("https://webhook.example.com", gateway);
203
+
204
+ for (const action of ["react", "edit_message", "delete_message", "pin_message", "unpin_message", "forward_message"]) {
205
+ const result = await handler({ action }, 123);
206
+ expect(result?.ok).toBe(true);
207
+ }
208
+ });
209
+
210
+ it("unknown actions return null", async () => {
211
+ const { Gateway } = await import("../core/gateway.js");
212
+ const { createTeamsActionHandler } = await import("../frontend/teams/actions.js");
213
+ const gateway = new Gateway();
214
+ const handler = createTeamsActionHandler("https://webhook.example.com", gateway);
215
+
216
+ const result = await handler({ action: "totally_unknown" }, 123);
217
+ expect(result).toBeNull();
218
+ });
219
+ });
220
+
221
+ // ── Test proxy-fetch ────────────────────────────────────────────────────────
222
+
223
+ describe("proxy-fetch", () => {
224
+ it("exports proxyFetch function", async () => {
225
+ const { proxyFetch } = await import("../frontend/teams/proxy-fetch.js");
226
+ expect(typeof proxyFetch).toBe("function");
227
+ });
228
+ });
229
+
230
+ // ── Test graph client types ─────────────────────────────────────────────────
231
+
232
+ describe("graph module exports", () => {
233
+ it("exports initGraphClient and GraphClient", async () => {
234
+ const graph = await import("../frontend/teams/graph.js");
235
+ expect(typeof graph.initGraphClient).toBe("function");
236
+ expect(typeof graph.GraphClient).toBe("function");
237
+ expect(typeof graph.deviceCodeAuth).toBe("function");
238
+ });
239
+ });
@@ -0,0 +1,177 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ markdownToTelegramHtml,
4
+ splitMessage,
5
+ } from "../frontend/telegram/formatting.js";
6
+ import { friendlyMessage as friendlyError } from "../core/errors.js";
7
+
8
+ describe("markdownToTelegramHtml", () => {
9
+ it("converts bold markdown to <b> tags", () => {
10
+ expect(markdownToTelegramHtml("**bold**")).toBe("<b>bold</b>");
11
+ });
12
+
13
+ it("converts italic *text* to <i> tags", () => {
14
+ expect(markdownToTelegramHtml("*italic*")).toBe("<i>italic</i>");
15
+ });
16
+
17
+ it("converts italic _text_ to <i> tags", () => {
18
+ expect(markdownToTelegramHtml("_italic_")).toBe("<i>italic</i>");
19
+ });
20
+
21
+ it("converts inline code to <code> tags", () => {
22
+ expect(markdownToTelegramHtml("`code`")).toBe("<code>code</code>");
23
+ });
24
+
25
+ it("converts fenced code blocks with language", () => {
26
+ const input = "```python\nprint('hello')\n```";
27
+ const result = markdownToTelegramHtml(input);
28
+ expect(result).toContain('<code class="language-python">');
29
+ expect(result).toContain("print('hello')");
30
+ expect(result).toContain("<pre>");
31
+ expect(result).toContain("</pre>");
32
+ });
33
+
34
+ it("converts fenced code blocks without language", () => {
35
+ const input = "```\nsome code\n```";
36
+ const result = markdownToTelegramHtml(input);
37
+ expect(result).toContain("<pre><code>");
38
+ expect(result).toContain("some code");
39
+ expect(result).toContain("</code></pre>");
40
+ });
41
+
42
+ it("converts links to <a> tags", () => {
43
+ expect(markdownToTelegramHtml("[text](https://example.com)")).toBe(
44
+ '<a href="https://example.com">text</a>',
45
+ );
46
+ });
47
+
48
+ it("escapes HTML special characters in plain text", () => {
49
+ // escapeHtml handles &, <, > — single quotes are passed through
50
+ expect(markdownToTelegramHtml("<script>alert('xss')</script>")).toBe(
51
+ "&lt;script&gt;alert('xss')&lt;/script&gt;",
52
+ );
53
+ });
54
+
55
+ it("escapes ampersands in plain text", () => {
56
+ expect(markdownToTelegramHtml("A & B")).toBe("A &amp; B");
57
+ });
58
+
59
+ it("handles mixed formatting", () => {
60
+ const input = "**bold** and *italic* and `code`";
61
+ const result = markdownToTelegramHtml(input);
62
+ expect(result).toContain("<b>bold</b>");
63
+ expect(result).toContain("<i>italic</i>");
64
+ expect(result).toContain("<code>code</code>");
65
+ });
66
+
67
+ it("converts strikethrough to <s> tags", () => {
68
+ expect(markdownToTelegramHtml("~~deleted~~")).toBe("<s>deleted</s>");
69
+ });
70
+
71
+ it("does not process markdown inside code blocks", () => {
72
+ const input = "```\n**not bold** *not italic*\n```";
73
+ const result = markdownToTelegramHtml(input);
74
+ expect(result).not.toContain("<b>");
75
+ expect(result).not.toContain("<i>");
76
+ });
77
+
78
+ it("does not process markdown inside inline code", () => {
79
+ const input = "`**not bold**`";
80
+ const result = markdownToTelegramHtml(input);
81
+ expect(result).not.toContain("<b>");
82
+ expect(result).toContain("<code>");
83
+ });
84
+
85
+ it("returns empty string for empty input", () => {
86
+ expect(markdownToTelegramHtml("")).toBe("");
87
+ });
88
+ });
89
+
90
+ describe("splitMessage", () => {
91
+ it("returns single chunk for short messages", () => {
92
+ expect(splitMessage("hello", 4096)).toEqual(["hello"]);
93
+ });
94
+
95
+ it("splits at newlines when message exceeds max", () => {
96
+ const line = "a".repeat(50);
97
+ const text = `${line}\n${line}\n${line}`;
98
+ const chunks = splitMessage(text, 80);
99
+ expect(chunks.length).toBeGreaterThan(1);
100
+ for (const chunk of chunks) {
101
+ expect(chunk.length).toBeLessThanOrEqual(80);
102
+ }
103
+ });
104
+
105
+ it("splits at paragraph breaks preferentially", () => {
106
+ const para1 = "a".repeat(40);
107
+ const para2 = "b".repeat(40);
108
+ const text = `${para1}\n\n${para2}`;
109
+ const chunks = splitMessage(text, 60);
110
+ expect(chunks.length).toBe(2);
111
+ expect(chunks[0]).toBe(para1);
112
+ expect(chunks[1]).toBe(para2);
113
+ });
114
+
115
+ it("hard-splits when no good break point exists", () => {
116
+ const text = "a".repeat(200);
117
+ const chunks = splitMessage(text, 80);
118
+ expect(chunks.length).toBe(3);
119
+ expect(chunks[0].length).toBe(80);
120
+ });
121
+
122
+ it("returns original text in array when exactly at max", () => {
123
+ const text = "a".repeat(100);
124
+ expect(splitMessage(text, 100)).toEqual([text]);
125
+ });
126
+ });
127
+
128
+ describe("friendlyError", () => {
129
+ it("maps rate limit errors", () => {
130
+ expect(friendlyError(new Error("429 Too Many Requests"))).toContain(
131
+ "Rate limited",
132
+ );
133
+ });
134
+
135
+ it("extracts retry-after from rate limit messages", () => {
136
+ const result = friendlyError(
137
+ new Error("rate limit exceeded, retry after 30 seconds"),
138
+ );
139
+ expect(result).toContain("30 seconds");
140
+ });
141
+
142
+ it("maps context length errors", () => {
143
+ const result = friendlyError(new Error("context length exceeded"));
144
+ expect(result).toContain("too long");
145
+ expect(result).toContain("/reset");
146
+ });
147
+
148
+ it("maps authentication errors", () => {
149
+ const result = friendlyError(new Error("401 Unauthorized"));
150
+ expect(result).toContain("API key error");
151
+ });
152
+
153
+ it("maps overloaded errors", () => {
154
+ const result = friendlyError(new Error("503 Service Unavailable"));
155
+ expect(result).toContain("busy");
156
+ });
157
+
158
+ it("maps network errors", () => {
159
+ expect(friendlyError(new Error("ECONNREFUSED"))).toContain("Connection");
160
+ expect(friendlyError(new Error("fetch failed"))).toContain("Connection");
161
+ });
162
+
163
+ it("returns generic message for unknown errors", () => {
164
+ const result = friendlyError(new Error("some random failure"));
165
+ expect(result).toContain("Something went wrong");
166
+ });
167
+
168
+ it("accepts string errors", () => {
169
+ const result = friendlyError("rate limit hit");
170
+ expect(result).toContain("Rate limited");
171
+ });
172
+
173
+ it("passes through session/expired errors as-is", () => {
174
+ const msg = "session expired, please reconnect";
175
+ expect(friendlyError(new Error(msg))).toBe(msg);
176
+ });
177
+ });