skimpyclaw 0.3.14 → 0.4.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 (222) hide show
  1. package/README.md +47 -37
  2. package/dist/__tests__/adapter-types.test.d.ts +4 -0
  3. package/dist/__tests__/adapter-types.test.js +63 -0
  4. package/dist/__tests__/anthropic-adapter.test.d.ts +4 -0
  5. package/dist/__tests__/anthropic-adapter.test.js +264 -0
  6. package/dist/__tests__/api.test.js +0 -1
  7. package/dist/__tests__/cli.integration.test.js +2 -4
  8. package/dist/__tests__/cli.test.js +0 -1
  9. package/dist/__tests__/code-agents-notifications.test.js +137 -0
  10. package/dist/__tests__/code-agents-parser.test.js +19 -1
  11. package/dist/__tests__/code-agents-preflight.test.js +3 -28
  12. package/dist/__tests__/code-agents-utils.test.js +34 -9
  13. package/dist/__tests__/code-agents-worktrees.test.js +116 -0
  14. package/dist/__tests__/codex-adapter.test.js +184 -0
  15. package/dist/__tests__/codex-auth.test.js +66 -0
  16. package/dist/__tests__/codex-provider-gating.test.js +35 -0
  17. package/dist/__tests__/codex-unified-loop.test.js +111 -0
  18. package/dist/__tests__/config-security.test.js +127 -0
  19. package/dist/__tests__/config.test.js +23 -0
  20. package/dist/__tests__/context-manager.test.js +243 -164
  21. package/dist/__tests__/cron-run.test.js +250 -0
  22. package/dist/__tests__/cron.test.js +12 -38
  23. package/dist/__tests__/digests.test.js +67 -0
  24. package/dist/__tests__/discord-attachments.test.js +211 -0
  25. package/dist/__tests__/discord-docs.test.d.ts +1 -0
  26. package/dist/__tests__/discord-docs.test.js +27 -0
  27. package/dist/__tests__/discord-thread-agents.test.d.ts +1 -0
  28. package/dist/__tests__/discord-thread-agents.test.js +115 -0
  29. package/dist/__tests__/discord-thread-context.test.d.ts +1 -0
  30. package/dist/__tests__/discord-thread-context.test.js +42 -0
  31. package/dist/__tests__/doctor.formatters.test.js +4 -4
  32. package/dist/__tests__/doctor.index.test.js +1 -1
  33. package/dist/__tests__/doctor.runner.test.js +3 -15
  34. package/dist/__tests__/env-sanitizer.test.d.ts +1 -0
  35. package/dist/__tests__/env-sanitizer.test.js +45 -0
  36. package/dist/__tests__/exec-approval.test.js +61 -0
  37. package/dist/__tests__/fetch-tool.test.d.ts +1 -0
  38. package/dist/__tests__/fetch-tool.test.js +85 -0
  39. package/dist/__tests__/gateway-status-auth.test.d.ts +1 -0
  40. package/dist/__tests__/gateway-status-auth.test.js +72 -0
  41. package/dist/__tests__/heartbeat.test.js +3 -3
  42. package/dist/__tests__/interactive-sessions.test.d.ts +1 -0
  43. package/dist/__tests__/interactive-sessions.test.js +96 -0
  44. package/dist/__tests__/langfuse.test.js +6 -18
  45. package/dist/__tests__/model-selection.test.js +3 -4
  46. package/dist/__tests__/providers-init.test.js +2 -8
  47. package/dist/__tests__/providers-routing.test.js +1 -1
  48. package/dist/__tests__/providers-utils.test.js +13 -3
  49. package/dist/__tests__/sessions.test.js +14 -10
  50. package/dist/__tests__/setup.test.js +12 -29
  51. package/dist/__tests__/skills.test.js +10 -7
  52. package/dist/__tests__/stream-formatter.test.d.ts +1 -0
  53. package/dist/__tests__/stream-formatter.test.js +114 -0
  54. package/dist/__tests__/token-efficiency.test.js +131 -15
  55. package/dist/__tests__/tool-loop.test.d.ts +4 -0
  56. package/dist/__tests__/tool-loop.test.js +505 -0
  57. package/dist/__tests__/tools.test.js +101 -276
  58. package/dist/__tests__/utils.test.d.ts +1 -0
  59. package/dist/__tests__/utils.test.js +14 -0
  60. package/dist/__tests__/voice.test.js +21 -0
  61. package/dist/agent.js +35 -4
  62. package/dist/api.js +113 -37
  63. package/dist/channels/discord/attachments.d.ts +50 -0
  64. package/dist/channels/discord/attachments.js +137 -0
  65. package/dist/channels/discord/delegation.d.ts +5 -0
  66. package/dist/channels/discord/delegation.js +136 -0
  67. package/dist/channels/discord/handlers.js +694 -7
  68. package/dist/channels/discord/index.d.ts +16 -1
  69. package/dist/channels/discord/index.js +64 -1
  70. package/dist/channels/discord/thread-agents.d.ts +54 -0
  71. package/dist/channels/discord/thread-agents.js +323 -0
  72. package/dist/channels/discord/threads.d.ts +58 -0
  73. package/dist/channels/discord/threads.js +192 -0
  74. package/dist/channels/discord/types.js +4 -2
  75. package/dist/channels/discord/utils.d.ts +16 -0
  76. package/dist/channels/discord/utils.js +86 -6
  77. package/dist/channels/telegram/index.d.ts +1 -1
  78. package/dist/channels/telegram/types.js +1 -1
  79. package/dist/channels/telegram/utils.js +9 -3
  80. package/dist/channels.d.ts +1 -1
  81. package/dist/cli.js +20 -400
  82. package/dist/code-agents/executor.d.ts +1 -1
  83. package/dist/code-agents/executor.js +101 -45
  84. package/dist/code-agents/index.d.ts +2 -7
  85. package/dist/code-agents/index.js +111 -80
  86. package/dist/code-agents/interactive-resume.d.ts +6 -0
  87. package/dist/code-agents/interactive-resume.js +98 -0
  88. package/dist/code-agents/interactive-sessions.d.ts +20 -0
  89. package/dist/code-agents/interactive-sessions.js +132 -0
  90. package/dist/code-agents/parser.js +5 -1
  91. package/dist/code-agents/registry.d.ts +7 -1
  92. package/dist/code-agents/registry.js +11 -23
  93. package/dist/code-agents/stream-formatter.d.ts +8 -0
  94. package/dist/code-agents/stream-formatter.js +92 -0
  95. package/dist/code-agents/types.d.ts +16 -24
  96. package/dist/code-agents/utils.d.ts +35 -11
  97. package/dist/code-agents/utils.js +349 -95
  98. package/dist/code-agents/worktrees.d.ts +37 -0
  99. package/dist/code-agents/worktrees.js +116 -0
  100. package/dist/config.d.ts +2 -4
  101. package/dist/config.js +123 -23
  102. package/dist/cron.d.ts +1 -6
  103. package/dist/cron.js +175 -82
  104. package/dist/dashboard/assets/index-B345aOO-.js +65 -0
  105. package/dist/dashboard/assets/index-ZWK4dalJ.css +1 -0
  106. package/dist/dashboard/index.html +2 -2
  107. package/dist/digests.d.ts +1 -0
  108. package/dist/digests.js +132 -42
  109. package/dist/doctor/checks.d.ts +0 -3
  110. package/dist/doctor/checks.js +1 -108
  111. package/dist/doctor/runner.js +1 -4
  112. package/dist/env-sanitizer.d.ts +2 -0
  113. package/dist/env-sanitizer.js +61 -0
  114. package/dist/exec-approval.d.ts +11 -1
  115. package/dist/exec-approval.js +17 -4
  116. package/dist/gateway.d.ts +3 -1
  117. package/dist/gateway.js +17 -7
  118. package/dist/heartbeat.js +1 -6
  119. package/dist/langfuse.js +3 -29
  120. package/dist/model-selection.js +3 -1
  121. package/dist/providers/adapter.d.ts +118 -0
  122. package/dist/providers/adapter.js +6 -0
  123. package/dist/providers/adapters/anthropic-adapter.d.ts +22 -0
  124. package/dist/providers/adapters/anthropic-adapter.js +204 -0
  125. package/dist/providers/adapters/codex-adapter.d.ts +26 -0
  126. package/dist/providers/adapters/codex-adapter.js +203 -0
  127. package/dist/providers/anthropic.d.ts +1 -0
  128. package/dist/providers/anthropic.js +10 -272
  129. package/dist/providers/codex.d.ts +21 -0
  130. package/dist/providers/codex.js +149 -330
  131. package/dist/providers/content.d.ts +1 -1
  132. package/dist/providers/content.js +2 -2
  133. package/dist/providers/context-manager.d.ts +18 -6
  134. package/dist/providers/context-manager.js +199 -223
  135. package/dist/providers/index.d.ts +9 -1
  136. package/dist/providers/index.js +73 -64
  137. package/dist/providers/loop-utils.d.ts +20 -0
  138. package/dist/providers/loop-utils.js +30 -0
  139. package/dist/providers/tool-loop.d.ts +12 -0
  140. package/dist/providers/tool-loop.js +251 -0
  141. package/dist/providers/utils.d.ts +19 -3
  142. package/dist/providers/utils.js +100 -29
  143. package/dist/secure-store.d.ts +8 -0
  144. package/dist/secure-store.js +80 -0
  145. package/dist/service.js +3 -28
  146. package/dist/sessions.d.ts +3 -0
  147. package/dist/sessions.js +147 -18
  148. package/dist/setup-templates.js +13 -25
  149. package/dist/setup.d.ts +10 -6
  150. package/dist/setup.js +84 -292
  151. package/dist/skills.js +3 -11
  152. package/dist/tools/agent-delegation.d.ts +19 -0
  153. package/dist/tools/agent-delegation.js +49 -0
  154. package/dist/tools/bash-tool.js +89 -34
  155. package/dist/tools/definitions.d.ts +199 -302
  156. package/dist/tools/definitions.js +70 -123
  157. package/dist/tools/execute-context.d.ts +13 -4
  158. package/dist/tools/fetch-tool.js +109 -13
  159. package/dist/tools/file-tools.js +7 -1
  160. package/dist/tools.d.ts +7 -7
  161. package/dist/tools.js +133 -151
  162. package/dist/types.d.ts +37 -30
  163. package/dist/utils.js +4 -6
  164. package/dist/voice.d.ts +1 -1
  165. package/dist/voice.js +17 -4
  166. package/package.json +33 -23
  167. package/templates/TOOLS.md +0 -27
  168. package/dist/__tests__/audit.test.js +0 -122
  169. package/dist/__tests__/code-agents-orchestrator.test.js +0 -216
  170. package/dist/__tests__/code-agents-sandbox.test.js +0 -163
  171. package/dist/__tests__/orchestrator.test.js +0 -425
  172. package/dist/__tests__/sandbox-bridge.test.js +0 -116
  173. package/dist/__tests__/sandbox-manager.test.js +0 -144
  174. package/dist/__tests__/sandbox-mount-security.test.js +0 -139
  175. package/dist/__tests__/sandbox-runtime.test.js +0 -176
  176. package/dist/__tests__/subagent.test.js +0 -240
  177. package/dist/__tests__/telegram.test.js +0 -42
  178. package/dist/code-agents/orchestrator.d.ts +0 -29
  179. package/dist/code-agents/orchestrator.js +0 -694
  180. package/dist/code-agents/worktree.d.ts +0 -40
  181. package/dist/code-agents/worktree.js +0 -215
  182. package/dist/dashboard/assets/index-BoTHPby4.js +0 -65
  183. package/dist/dashboard/assets/index-D4mufvBg.css +0 -1
  184. package/dist/dashboard.d.ts +0 -8
  185. package/dist/dashboard.js +0 -4071
  186. package/dist/discord.d.ts +0 -8
  187. package/dist/discord.js +0 -792
  188. package/dist/mcp-context-a8c.d.ts +0 -13
  189. package/dist/mcp-context-a8c.js +0 -34
  190. package/dist/orchestrator.d.ts +0 -15
  191. package/dist/orchestrator.js +0 -676
  192. package/dist/providers/openai.d.ts +0 -10
  193. package/dist/providers/openai.js +0 -355
  194. package/dist/sandbox/bridge.d.ts +0 -5
  195. package/dist/sandbox/bridge.js +0 -63
  196. package/dist/sandbox/index.d.ts +0 -5
  197. package/dist/sandbox/index.js +0 -4
  198. package/dist/sandbox/manager.d.ts +0 -7
  199. package/dist/sandbox/manager.js +0 -100
  200. package/dist/sandbox/mount-security.d.ts +0 -12
  201. package/dist/sandbox/mount-security.js +0 -122
  202. package/dist/sandbox/runtime.d.ts +0 -39
  203. package/dist/sandbox/runtime.js +0 -192
  204. package/dist/sandbox-utils.d.ts +0 -6
  205. package/dist/sandbox-utils.js +0 -36
  206. package/dist/subagent.d.ts +0 -19
  207. package/dist/subagent.js +0 -407
  208. package/dist/telegram.d.ts +0 -2
  209. package/dist/telegram.js +0 -11
  210. package/dist/tools/browser-tool.d.ts +0 -3
  211. package/dist/tools/browser-tool.js +0 -266
  212. package/sandbox/Dockerfile +0 -40
  213. /package/dist/__tests__/{audit.test.d.ts → code-agents-notifications.test.d.ts} +0 -0
  214. /package/dist/__tests__/{code-agents-orchestrator.test.d.ts → code-agents-worktrees.test.d.ts} +0 -0
  215. /package/dist/__tests__/{code-agents-sandbox.test.d.ts → codex-adapter.test.d.ts} +0 -0
  216. /package/dist/__tests__/{orchestrator.test.d.ts → codex-auth.test.d.ts} +0 -0
  217. /package/dist/__tests__/{sandbox-bridge.test.d.ts → codex-provider-gating.test.d.ts} +0 -0
  218. /package/dist/__tests__/{sandbox-manager.test.d.ts → codex-unified-loop.test.d.ts} +0 -0
  219. /package/dist/__tests__/{sandbox-mount-security.test.d.ts → config-security.test.d.ts} +0 -0
  220. /package/dist/__tests__/{sandbox-runtime.test.d.ts → cron-run.test.d.ts} +0 -0
  221. /package/dist/__tests__/{subagent.test.d.ts → digests.test.d.ts} +0 -0
  222. /package/dist/__tests__/{telegram.test.d.ts → discord-attachments.test.d.ts} +0 -0
@@ -0,0 +1,250 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ const { runAgentTurnMock, sendActiveChannelProactiveMessageMock, sendToDiscordThreadMock, sendToDiscordThreadWithVoiceMock, parseAndSaveDigestMock, synthesizeSpeechMock, } = vi.hoisted(() => ({
3
+ runAgentTurnMock: vi.fn(),
4
+ sendActiveChannelProactiveMessageMock: vi.fn(async () => true),
5
+ sendToDiscordThreadMock: vi.fn(async () => false),
6
+ sendToDiscordThreadWithVoiceMock: vi.fn(async () => false),
7
+ parseAndSaveDigestMock: vi.fn(),
8
+ synthesizeSpeechMock: vi.fn(),
9
+ }));
10
+ vi.mock('../agent.js', () => ({
11
+ runAgentTurn: runAgentTurnMock,
12
+ }));
13
+ vi.mock('../channels.js', () => ({
14
+ sendActiveChannelProactiveMessage: sendActiveChannelProactiveMessageMock,
15
+ sendActiveChannelProactiveVoice: vi.fn(async () => false),
16
+ getActiveChannelId: () => 'telegram',
17
+ }));
18
+ vi.mock('../digests.js', () => ({
19
+ parseAndSaveDigest: parseAndSaveDigestMock,
20
+ }));
21
+ vi.mock('../channels/discord/index.js', () => ({
22
+ sendToDiscordThread: sendToDiscordThreadMock,
23
+ sendToDiscordThreadWithVoice: sendToDiscordThreadWithVoiceMock,
24
+ }));
25
+ vi.mock('../config.js', () => ({
26
+ getLogsDir: () => '/tmp',
27
+ getConfigPath: () => '/tmp/config.json',
28
+ loadConfig: vi.fn(),
29
+ resolveAllowedPaths: () => ['/tmp'],
30
+ }));
31
+ vi.mock('../audit.js', () => ({
32
+ startTrace: vi.fn(() => 'trace-1'),
33
+ addEvent: vi.fn(),
34
+ endTrace: vi.fn(),
35
+ }));
36
+ vi.mock('../voice.js', () => ({
37
+ synthesizeSpeech: synthesizeSpeechMock,
38
+ }));
39
+ vi.mock('../env-sanitizer.js', () => ({
40
+ sanitizeCronEnv: () => ({}),
41
+ }));
42
+ import { runCronJob } from '../cron.js';
43
+ describe('runCronJob digest chat output', () => {
44
+ const config = {
45
+ agents: { default: 'default', list: { default: { model: 'claude-sonnet' } } },
46
+ channels: { active: 'telegram', telegram: { enabled: true, allowFrom: ['1'] }, discord: { enabled: false, allowFrom: [] } },
47
+ cron: {
48
+ jobs: [
49
+ {
50
+ id: 'tech-digest',
51
+ name: 'Tech Digest',
52
+ schedule: { kind: 'cron', expr: '* * * * *', tz: 'UTC' },
53
+ payload: { kind: 'agentTurn', message: 'digest please' },
54
+ },
55
+ ],
56
+ },
57
+ };
58
+ beforeEach(() => {
59
+ vi.clearAllMocks();
60
+ });
61
+ it('sends digest summary to chat when digest includes articles', async () => {
62
+ const digestText = '1. Story https://example.com/story';
63
+ runAgentTurnMock.mockResolvedValue(digestText);
64
+ parseAndSaveDigestMock.mockReturnValue({ summary: digestText, articles: [{ id: 'a1' }] });
65
+ await runCronJob('tech-digest', config);
66
+ expect(parseAndSaveDigestMock).toHaveBeenCalledWith('tech-digest', 'Tech Digest', digestText);
67
+ expect(sendActiveChannelProactiveMessageMock).toHaveBeenCalledWith(config, digestText);
68
+ });
69
+ it('does not send digest summary to chat when digest has no articles', async () => {
70
+ const digestText = 'No links today';
71
+ runAgentTurnMock.mockResolvedValue(digestText);
72
+ parseAndSaveDigestMock.mockReturnValue({ summary: digestText, articles: [] });
73
+ await runCronJob('tech-digest', config);
74
+ expect(sendActiveChannelProactiveMessageMock).not.toHaveBeenCalledWith(config, digestText);
75
+ });
76
+ it('skips overlapping execution for the same job id', async () => {
77
+ let releaseFirstRun = () => { };
78
+ runAgentTurnMock.mockImplementation(() => new Promise(resolve => {
79
+ releaseFirstRun = () => resolve('1. Story https://example.com/story');
80
+ }));
81
+ parseAndSaveDigestMock.mockReturnValue({ summary: '1. Story https://example.com/story', articles: [{ id: 'a1' }] });
82
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
83
+ const firstRun = runCronJob('tech-digest', config);
84
+ await Promise.resolve();
85
+ await runCronJob('tech-digest', config);
86
+ expect(runAgentTurnMock).toHaveBeenCalledTimes(1);
87
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Skipping overlapping run for job "tech-digest"'));
88
+ releaseFirstRun();
89
+ await firstRun;
90
+ warnSpy.mockRestore();
91
+ });
92
+ it('thread id set + successful send does not use active-channel send', async () => {
93
+ const digestText = 'No links today';
94
+ runAgentTurnMock.mockResolvedValue(digestText);
95
+ parseAndSaveDigestMock.mockReturnValue({ summary: digestText, articles: [] });
96
+ sendToDiscordThreadMock.mockResolvedValue(true);
97
+ const threadConfig = {
98
+ ...config,
99
+ cron: {
100
+ jobs: [
101
+ {
102
+ id: 'tech-digest',
103
+ name: 'Tech Digest',
104
+ schedule: { kind: 'cron', expr: '* * * * *', tz: 'UTC' },
105
+ payload: {
106
+ kind: 'agentTurn',
107
+ message: 'digest please',
108
+ discordThreadId: '123456789012345678',
109
+ },
110
+ },
111
+ ],
112
+ },
113
+ };
114
+ await runCronJob('tech-digest', threadConfig);
115
+ expect(sendToDiscordThreadMock).toHaveBeenCalled();
116
+ expect(sendActiveChannelProactiveMessageMock).not.toHaveBeenCalled();
117
+ });
118
+ it('thread id set + failed send does not fall back to active channel', async () => {
119
+ const digestText = 'No links today';
120
+ runAgentTurnMock.mockResolvedValue(digestText);
121
+ parseAndSaveDigestMock.mockReturnValue({ summary: digestText, articles: [] });
122
+ sendToDiscordThreadMock.mockResolvedValue(false);
123
+ const threadConfig = {
124
+ ...config,
125
+ cron: {
126
+ jobs: [
127
+ {
128
+ id: 'tech-digest',
129
+ name: 'Tech Digest',
130
+ schedule: { kind: 'cron', expr: '* * * * *', tz: 'UTC' },
131
+ payload: {
132
+ kind: 'agentTurn',
133
+ message: 'digest please',
134
+ discordThreadId: '123456789012345678',
135
+ },
136
+ },
137
+ ],
138
+ },
139
+ };
140
+ await runCronJob('tech-digest', threadConfig);
141
+ expect(sendToDiscordThreadMock).toHaveBeenCalled();
142
+ expect(sendActiveChannelProactiveMessageMock).not.toHaveBeenCalled();
143
+ });
144
+ it('falls back to active channel when discordThreadId is invalid', async () => {
145
+ const digestText = 'No links today';
146
+ runAgentTurnMock.mockResolvedValue(digestText);
147
+ parseAndSaveDigestMock.mockReturnValue({ summary: digestText, articles: [] });
148
+ const threadConfig = {
149
+ ...config,
150
+ cron: {
151
+ jobs: [
152
+ {
153
+ id: 'tech-digest',
154
+ name: 'Tech Digest',
155
+ schedule: { kind: 'cron', expr: '* * * * *', tz: 'UTC' },
156
+ payload: {
157
+ kind: 'agentTurn',
158
+ message: 'digest please',
159
+ discordThreadId: 'not-a-thread-id',
160
+ },
161
+ },
162
+ ],
163
+ },
164
+ };
165
+ await runCronJob('tech-digest', threadConfig);
166
+ expect(sendToDiscordThreadMock).not.toHaveBeenCalled();
167
+ expect(sendActiveChannelProactiveMessageMock).toHaveBeenCalledWith(threadConfig, expect.stringContaining('Cron: Tech Digest'));
168
+ });
169
+ it('no thread id uses active-channel send', async () => {
170
+ const digestText = 'No links today';
171
+ runAgentTurnMock.mockResolvedValue(digestText);
172
+ parseAndSaveDigestMock.mockReturnValue({ summary: digestText, articles: [] });
173
+ await runCronJob('tech-digest', config);
174
+ expect(sendToDiscordThreadMock).not.toHaveBeenCalled();
175
+ expect(sendActiveChannelProactiveMessageMock).toHaveBeenCalledWith(config, expect.stringContaining('Cron: Tech Digest'));
176
+ });
177
+ it('sends voice attachment to Discord thread when sendAsVoice is enabled', async () => {
178
+ const digestText = 'No links today';
179
+ runAgentTurnMock.mockResolvedValue(digestText);
180
+ parseAndSaveDigestMock.mockReturnValue({ summary: digestText, articles: [] });
181
+ sendToDiscordThreadMock.mockResolvedValue(true); // Start notification succeeds
182
+ sendToDiscordThreadWithVoiceMock.mockResolvedValue(true); // Final notification with voice succeeds
183
+ synthesizeSpeechMock.mockResolvedValue({
184
+ buffer: new Uint8Array([1, 2, 3]),
185
+ format: 'mp3',
186
+ provider: 'test-provider',
187
+ });
188
+ const voiceConfig = {
189
+ ...config,
190
+ voice: {
191
+ provider: 'test-provider',
192
+ apiKey: 'test-key',
193
+ },
194
+ cron: {
195
+ jobs: [
196
+ {
197
+ id: 'tech-digest',
198
+ name: 'Tech Digest',
199
+ schedule: { kind: 'cron', expr: '* * * * *', tz: 'UTC' },
200
+ payload: {
201
+ kind: 'agentTurn',
202
+ message: 'digest please',
203
+ sendAsVoice: true,
204
+ discordThreadId: '123456789012345678',
205
+ },
206
+ },
207
+ ],
208
+ },
209
+ };
210
+ await runCronJob('tech-digest', voiceConfig);
211
+ expect(synthesizeSpeechMock).toHaveBeenCalledWith(digestText, voiceConfig.voice);
212
+ expect(sendToDiscordThreadWithVoiceMock).toHaveBeenCalledWith('123456789012345678', expect.stringContaining('Cron: Tech Digest'), new Uint8Array([1, 2, 3]), 'mp3');
213
+ // Start notification goes to Discord thread, final notification goes to Discord thread with voice
214
+ expect(sendActiveChannelProactiveMessageMock).not.toHaveBeenCalled();
215
+ });
216
+ it('sends voice to active channel when Discord thread is not configured', async () => {
217
+ const digestText = 'No links today';
218
+ runAgentTurnMock.mockResolvedValue(digestText);
219
+ parseAndSaveDigestMock.mockReturnValue({ summary: digestText, articles: [] });
220
+ synthesizeSpeechMock.mockResolvedValue({
221
+ buffer: new Uint8Array([1, 2, 3]),
222
+ format: 'mp3',
223
+ provider: 'test-provider',
224
+ });
225
+ const voiceConfig = {
226
+ ...config,
227
+ voice: {
228
+ provider: 'test-provider',
229
+ apiKey: 'test-key',
230
+ },
231
+ cron: {
232
+ jobs: [
233
+ {
234
+ id: 'tech-digest',
235
+ name: 'Tech Digest',
236
+ schedule: { kind: 'cron', expr: '* * * * *', tz: 'UTC' },
237
+ payload: {
238
+ kind: 'agentTurn',
239
+ message: 'digest please',
240
+ sendAsVoice: true,
241
+ },
242
+ },
243
+ ],
244
+ },
245
+ };
246
+ await runCronJob('tech-digest', voiceConfig);
247
+ expect(synthesizeSpeechMock).toHaveBeenCalledWith(digestText, voiceConfig.voice);
248
+ expect(sendActiveChannelProactiveMessageMock).toHaveBeenCalled();
249
+ });
250
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { parseDualOutput, validatePrReviewOutput } from '../cron.js';
2
+ import { isRetryableCronAgentError, parseDualOutput } from '../cron.js';
3
3
  describe('parseDualOutput', () => {
4
4
  it('returns full response as text when no delimiters present', () => {
5
5
  const response = 'Hello, this is a regular response with no delimiters.';
@@ -64,43 +64,6 @@ Voice content here
64
64
  expect(result.text).toBe(fullResponse);
65
65
  });
66
66
  });
67
- describe('validatePrReviewOutput', () => {
68
- it('returns null for NO_CANDIDATES result', () => {
69
- const output = 'No PRs found.\n[PR_REVIEW_RESULT: NO_CANDIDATES]';
70
- expect(validatePrReviewOutput(output)).toBeNull();
71
- });
72
- it('returns null when candidates were reviewed with code_with_agent', () => {
73
- const output = 'Reviewed 3 PRs.\n[PR_REVIEW_RESULT: CANDIDATES=3 CODE_AGENT_CALLS=3 BLOCKED=0]';
74
- expect(validatePrReviewOutput(output)).toBeNull();
75
- });
76
- it('returns null when all candidates are blocked', () => {
77
- const output = 'All blocked.\n[PR_REVIEW_RESULT: CANDIDATES=2 CODE_AGENT_CALLS=0 BLOCKED=2]';
78
- expect(validatePrReviewOutput(output)).toBeNull();
79
- });
80
- it('returns alert when candidates exist but no code_with_agent calls', () => {
81
- const output = 'Inline review.\n[PR_REVIEW_RESULT: CANDIDATES=3 CODE_AGENT_CALLS=0 BLOCKED=0]';
82
- const result = validatePrReviewOutput(output);
83
- expect(result).not.toBeNull();
84
- expect(result).toContain('code_with_agent was never called');
85
- expect(result).toContain('3 PR candidate');
86
- });
87
- it('returns alert when result line is missing entirely', () => {
88
- const output = 'The agent just rambled about PRs without following the prompt.';
89
- const result = validatePrReviewOutput(output);
90
- expect(result).not.toBeNull();
91
- expect(result).toContain('Missing [PR_REVIEW_RESULT]');
92
- });
93
- it('returns null when some candidates reviewed and some blocked', () => {
94
- const output = '[PR_REVIEW_RESULT: CANDIDATES=4 CODE_AGENT_CALLS=2 BLOCKED=2]';
95
- expect(validatePrReviewOutput(output)).toBeNull();
96
- });
97
- it('returns alert when partially blocked but zero calls', () => {
98
- const output = '[PR_REVIEW_RESULT: CANDIDATES=3 CODE_AGENT_CALLS=0 BLOCKED=1]';
99
- const result = validatePrReviewOutput(output);
100
- expect(result).not.toBeNull();
101
- expect(result).toContain('code_with_agent was never called');
102
- });
103
- });
104
67
  describe('cron job tool injection', () => {
105
68
  it('isCronJob field exists on ExecuteToolContext', () => {
106
69
  // Verify the field is part of the type (compile-time check via assignment)
@@ -114,3 +77,14 @@ describe('cron job tool injection', () => {
114
77
  expect(ctx.isCronJob).toBeUndefined();
115
78
  });
116
79
  });
80
+ describe('isRetryableCronAgentError', () => {
81
+ it('matches transient Codex and provider connectivity failures', () => {
82
+ expect(isRetryableCronAgentError(new Error('Codex API 503: upstream connect error'))).toBe(true);
83
+ expect(isRetryableCronAgentError(new Error('remote connection failure: Connection refused'))).toBe(true);
84
+ expect(isRetryableCronAgentError(new Error('529 {"type":"error","error":{"type":"overloaded_error"}}'))).toBe(true);
85
+ });
86
+ it('does not match normal validation errors', () => {
87
+ expect(isRetryableCronAgentError(new Error('Tool use loop reached maximum iterations'))).toBe(false);
88
+ expect(isRetryableCronAgentError(new Error('Invalid model selection'))).toBe(false);
89
+ });
90
+ });
@@ -0,0 +1,67 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ const testState = vi.hoisted(() => ({
6
+ logsDir: '',
7
+ }));
8
+ vi.mock('../config.js', () => ({
9
+ getLogsDir: () => testState.logsDir,
10
+ }));
11
+ import { saveDigest, getDigest, deleteDigest, getDigestsDir, parseAndSaveDigest } from '../digests.js';
12
+ describe('digests index path resolution', () => {
13
+ beforeEach(() => {
14
+ testState.logsDir = fs.mkdtempSync(join(tmpdir(), 'skimpyclaw-digests-'));
15
+ });
16
+ afterEach(() => {
17
+ fs.rmSync(testState.logsDir, { recursive: true, force: true });
18
+ });
19
+ it('resolves get/delete by indexed path without scanning directories', () => {
20
+ const digest = {
21
+ id: 'tech-digest-deadbeef',
22
+ jobId: 'tech-digest',
23
+ jobName: 'Tech Digest',
24
+ createdAt: '2026-03-08T09:00:00.000Z',
25
+ articles: [{ id: 'a1', title: 'Story', source: 'Web', url: 'https://example.com' }],
26
+ };
27
+ saveDigest(digest);
28
+ const indexPath = join(getDigestsDir(), 'index.json');
29
+ const index = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
30
+ expect(index[digest.id]).toEqual({ jobId: digest.jobId, date: '2026-03-08' });
31
+ const loaded = getDigest(digest.id);
32
+ expect(loaded?.id).toBe(digest.id);
33
+ const deleted = deleteDigest(digest.id);
34
+ expect(deleted).toBe(true);
35
+ expect(getDigest(digest.id)).toBeNull();
36
+ });
37
+ it('rejects invalid digest ids and stale index entries safely', () => {
38
+ expect(getDigest('../etc/passwd')).toBeNull();
39
+ expect(deleteDigest('../etc/passwd')).toBe(false);
40
+ const indexPath = join(getDigestsDir(), 'index.json');
41
+ fs.writeFileSync(indexPath, JSON.stringify({
42
+ 'tech-digest-deadbeef': { jobId: '../escape', date: '2026-03-08' },
43
+ }), 'utf-8');
44
+ expect(getDigest('tech-digest-deadbeef')).toBeNull();
45
+ expect(deleteDigest('tech-digest-deadbeef')).toBe(false);
46
+ });
47
+ it('does not use markdown section headers as article titles', () => {
48
+ const digest = parseAndSaveDigest('news-digest', 'News Digest', [
49
+ '## World Headlines',
50
+ 'https://example.com/world-one',
51
+ 'https://example.com/world-two',
52
+ ].join('\n'));
53
+ expect(digest.articles).toHaveLength(2);
54
+ expect(digest.articles.map(a => a.title)).toEqual(['world one', 'world two']);
55
+ });
56
+ it('extracts titles from markdown links in digest output', () => {
57
+ const digest = parseAndSaveDigest('news-digest', 'News Digest', [
58
+ '# News Digest',
59
+ '',
60
+ '## US Headlines',
61
+ '1. [Direct article title](https://example.com/news/direct-article) *(Example News)*',
62
+ ].join('\n'));
63
+ expect(digest.articles).toHaveLength(1);
64
+ expect(digest.articles[0]?.title).toBe('Direct article title');
65
+ expect(digest.articles[0]?.url).toBe('https://example.com/news/direct-article');
66
+ });
67
+ });
@@ -0,0 +1,211 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ // Mock pdf-parse before importing the module under test
3
+ const mockGetText = vi.fn();
4
+ const mockDestroy = vi.fn();
5
+ vi.mock('pdf-parse', () => {
6
+ const MockPDFParse = vi.fn().mockImplementation(function () {
7
+ this.getText = mockGetText;
8
+ this.destroy = mockDestroy;
9
+ });
10
+ return { PDFParse: MockPDFParse };
11
+ });
12
+ // Mock global fetch
13
+ const mockFetch = vi.fn();
14
+ vi.stubGlobal('fetch', mockFetch);
15
+ import { isDocumentAttachment, processAttachment, processAttachments, supportedExtensions, registerHandler, MAX_ATTACHMENT_BYTES, MAX_TEXT_CHARS, } from '../channels/discord/attachments.js';
16
+ function fakeAttachment(overrides = {}) {
17
+ return {
18
+ name: 'test.txt',
19
+ url: 'https://cdn.discordapp.com/attachments/123/456/test.txt',
20
+ contentType: 'text/plain',
21
+ size: 100,
22
+ ...overrides,
23
+ };
24
+ }
25
+ function mockFetchOk(content) {
26
+ const buf = typeof content === 'string' ? Buffer.from(content) : content;
27
+ mockFetch.mockResolvedValueOnce({
28
+ ok: true,
29
+ arrayBuffer: () => Promise.resolve(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)),
30
+ });
31
+ }
32
+ beforeEach(() => {
33
+ vi.clearAllMocks();
34
+ });
35
+ // ── isDocumentAttachment ──────────────────────────────────────────
36
+ describe('isDocumentAttachment', () => {
37
+ it('returns true for .txt files', () => {
38
+ expect(isDocumentAttachment(fakeAttachment({ name: 'notes.txt', contentType: 'text/plain' }))).toBe(true);
39
+ });
40
+ it('returns true for .pdf files', () => {
41
+ expect(isDocumentAttachment(fakeAttachment({ name: 'report.pdf', contentType: 'application/pdf' }))).toBe(true);
42
+ });
43
+ it('returns true for .md files', () => {
44
+ expect(isDocumentAttachment(fakeAttachment({ name: 'README.md', contentType: 'text/markdown' }))).toBe(true);
45
+ });
46
+ it('returns true for .json files', () => {
47
+ expect(isDocumentAttachment(fakeAttachment({ name: 'data.json', contentType: 'application/json' }))).toBe(true);
48
+ });
49
+ it('returns true for source code files', () => {
50
+ expect(isDocumentAttachment(fakeAttachment({ name: 'app.py', contentType: 'text/x-python' }))).toBe(true);
51
+ expect(isDocumentAttachment(fakeAttachment({ name: 'index.ts', contentType: 'text/typescript' }))).toBe(true);
52
+ });
53
+ it('returns false for image files', () => {
54
+ expect(isDocumentAttachment(fakeAttachment({ name: 'photo.jpg', contentType: 'image/jpeg' }))).toBe(false);
55
+ });
56
+ it('returns false for audio files', () => {
57
+ expect(isDocumentAttachment(fakeAttachment({ name: 'voice.ogg', contentType: 'audio/ogg' }))).toBe(false);
58
+ });
59
+ it('returns false for unsupported types', () => {
60
+ expect(isDocumentAttachment(fakeAttachment({ name: 'archive.zip', contentType: 'application/zip' }))).toBe(false);
61
+ });
62
+ it('returns false for null content type with unsupported extension', () => {
63
+ expect(isDocumentAttachment(fakeAttachment({ name: 'file.xyz', contentType: null }))).toBe(false);
64
+ });
65
+ });
66
+ // ── processAttachment — text files ────────────────────────────────
67
+ describe('processAttachment — text files', () => {
68
+ it('extracts text from a .txt attachment', async () => {
69
+ const att = fakeAttachment({ name: 'hello.txt', contentType: 'text/plain', size: 50 });
70
+ mockFetchOk('Hello, world!');
71
+ const result = await processAttachment(att);
72
+ expect(result.ok).toBe(true);
73
+ expect(result.text).toBe('Hello, world!');
74
+ expect(result.filename).toBe('hello.txt');
75
+ });
76
+ it('extracts text from a .md attachment', async () => {
77
+ const att = fakeAttachment({ name: 'README.md', contentType: 'text/markdown', size: 30 });
78
+ mockFetchOk('# Title\n\nSome content');
79
+ const result = await processAttachment(att);
80
+ expect(result.ok).toBe(true);
81
+ expect(result.text).toContain('# Title');
82
+ });
83
+ it('truncates text exceeding MAX_TEXT_CHARS', async () => {
84
+ const att = fakeAttachment({ name: 'big.txt', contentType: 'text/plain', size: 200 });
85
+ const bigText = 'A'.repeat(MAX_TEXT_CHARS + 500);
86
+ mockFetchOk(bigText);
87
+ const result = await processAttachment(att);
88
+ expect(result.ok).toBe(true);
89
+ expect(result.text.length).toBeLessThan(bigText.length);
90
+ expect(result.text).toContain('[... truncated at');
91
+ });
92
+ });
93
+ // ── processAttachment — PDF files ─────────────────────────────────
94
+ describe('processAttachment — PDF files', () => {
95
+ it('extracts text from a PDF', async () => {
96
+ const att = fakeAttachment({ name: 'report.pdf', contentType: 'application/pdf', size: 500 });
97
+ mockFetchOk(Buffer.from('fake pdf content'));
98
+ mockGetText.mockResolvedValueOnce({ text: 'Extracted PDF text', pages: [], total: 1 });
99
+ mockDestroy.mockResolvedValueOnce(undefined);
100
+ const result = await processAttachment(att);
101
+ expect(result.ok).toBe(true);
102
+ expect(result.text).toBe('Extracted PDF text');
103
+ });
104
+ it('returns error for image-only PDF', async () => {
105
+ const att = fakeAttachment({ name: 'scan.pdf', contentType: 'application/pdf', size: 500 });
106
+ mockFetchOk(Buffer.from('fake'));
107
+ mockGetText.mockResolvedValueOnce({ text: '', pages: [], total: 1 });
108
+ mockDestroy.mockResolvedValueOnce(undefined);
109
+ const result = await processAttachment(att);
110
+ expect(result.ok).toBe(false);
111
+ expect(result.error).toContain('no extractable text');
112
+ });
113
+ it('handles pdf-parse failure gracefully', async () => {
114
+ const att = fakeAttachment({ name: 'corrupt.pdf', contentType: 'application/pdf', size: 500 });
115
+ mockFetchOk(Buffer.from('not a real pdf'));
116
+ mockGetText.mockRejectedValueOnce(new Error('Invalid PDF structure'));
117
+ mockDestroy.mockResolvedValueOnce(undefined);
118
+ const result = await processAttachment(att);
119
+ expect(result.ok).toBe(false);
120
+ expect(result.error).toContain('Invalid PDF structure');
121
+ });
122
+ });
123
+ // ── processAttachment — validation ────────────────────────────────
124
+ describe('processAttachment — validation', () => {
125
+ it('rejects files exceeding MAX_ATTACHMENT_BYTES', async () => {
126
+ const att = fakeAttachment({ name: 'huge.txt', contentType: 'text/plain', size: MAX_ATTACHMENT_BYTES + 1 });
127
+ const result = await processAttachment(att);
128
+ expect(result.ok).toBe(false);
129
+ expect(result.error).toContain('too large');
130
+ expect(mockFetch).not.toHaveBeenCalled();
131
+ });
132
+ it('rejects unsupported file types', async () => {
133
+ const att = fakeAttachment({ name: 'archive.zip', contentType: 'application/zip', size: 100 });
134
+ const result = await processAttachment(att);
135
+ expect(result.ok).toBe(false);
136
+ expect(result.error).toContain('Unsupported file type');
137
+ expect(result.error).toContain('Supported extensions');
138
+ });
139
+ it('handles fetch failure gracefully', async () => {
140
+ const att = fakeAttachment({ name: 'file.txt', contentType: 'text/plain', size: 100 });
141
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
142
+ const result = await processAttachment(att);
143
+ expect(result.ok).toBe(false);
144
+ expect(result.error).toContain('HTTP 404');
145
+ });
146
+ it('handles network errors gracefully', async () => {
147
+ const att = fakeAttachment({ name: 'file.txt', contentType: 'text/plain', size: 100 });
148
+ mockFetch.mockRejectedValueOnce(new Error('Network timeout'));
149
+ const result = await processAttachment(att);
150
+ expect(result.ok).toBe(false);
151
+ expect(result.error).toContain('Network timeout');
152
+ });
153
+ it('handles missing filename gracefully', async () => {
154
+ const att = fakeAttachment({ name: undefined, contentType: 'text/plain', size: 100 });
155
+ const result = await processAttachment(att);
156
+ expect(result.filename).toBe('unknown');
157
+ });
158
+ });
159
+ // ── processAttachments (batch) ────────────────────────────────────
160
+ describe('processAttachments', () => {
161
+ it('processes multiple attachments concurrently', async () => {
162
+ const att1 = fakeAttachment({ name: 'a.txt', contentType: 'text/plain', size: 10 });
163
+ const att2 = fakeAttachment({ name: 'b.txt', contentType: 'text/plain', size: 10 });
164
+ mockFetchOk('File A');
165
+ mockFetchOk('File B');
166
+ const results = await processAttachments([att1, att2]);
167
+ expect(results).toHaveLength(2);
168
+ expect(results[0].ok).toBe(true);
169
+ expect(results[0].text).toBe('File A');
170
+ expect(results[1].ok).toBe(true);
171
+ expect(results[1].text).toBe('File B');
172
+ });
173
+ it('returns mixed results when some fail', async () => {
174
+ const att1 = fakeAttachment({ name: 'ok.txt', contentType: 'text/plain', size: 10 });
175
+ const att2 = fakeAttachment({ name: 'huge.txt', contentType: 'text/plain', size: MAX_ATTACHMENT_BYTES + 1 });
176
+ mockFetchOk('Good file');
177
+ const results = await processAttachments([att1, att2]);
178
+ expect(results[0].ok).toBe(true);
179
+ expect(results[1].ok).toBe(false);
180
+ expect(results[1].error).toContain('too large');
181
+ });
182
+ });
183
+ // ── supportedExtensions ───────────────────────────────────────────
184
+ describe('supportedExtensions', () => {
185
+ it('includes txt and pdf', () => {
186
+ const exts = supportedExtensions();
187
+ expect(exts).toContain('txt');
188
+ expect(exts).toContain('pdf');
189
+ expect(exts).toContain('md');
190
+ });
191
+ it('returns sorted list', () => {
192
+ const exts = supportedExtensions();
193
+ const sorted = [...exts].sort();
194
+ expect(exts).toEqual(sorted);
195
+ });
196
+ });
197
+ // ── registerHandler ───────────────────────────────────────────────
198
+ describe('registerHandler', () => {
199
+ it('allows registering custom handlers', async () => {
200
+ registerHandler({
201
+ extensions: ['custom'],
202
+ mimeTypes: ['application/x-custom'],
203
+ extract: async (buffer) => `CUSTOM: ${buffer.toString('utf-8')}`,
204
+ });
205
+ const att = fakeAttachment({ name: 'data.custom', contentType: 'application/x-custom', size: 10 });
206
+ mockFetchOk('raw data');
207
+ const result = await processAttachment(att);
208
+ expect(result.ok).toBe(true);
209
+ expect(result.text).toBe('CUSTOM: raw data');
210
+ });
211
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ const repoRoot = process.cwd();
5
+ function read(relativePath) {
6
+ return readFileSync(resolve(repoRoot, relativePath), 'utf8');
7
+ }
8
+ describe('discord documentation maintenance', () => {
9
+ it('keeps Discord process and changelog docs present and linked from README', () => {
10
+ const processDocPath = 'docs/guide/discord-updates.md';
11
+ const changelogPath = 'docs/guide/changelog.md';
12
+ expect(existsSync(resolve(repoRoot, processDocPath))).toBe(true);
13
+ expect(existsSync(resolve(repoRoot, changelogPath))).toBe(true);
14
+ const readme = read('README.md');
15
+ expect(readme).toContain(`[${processDocPath}](${processDocPath})`);
16
+ expect(readme).toContain(`[${changelogPath}](${changelogPath})`);
17
+ });
18
+ it('documents required Discord update recording steps', () => {
19
+ const processDoc = read('docs/guide/discord-updates.md');
20
+ const changelog = read('docs/guide/changelog.md');
21
+ expect(processDoc).toContain('## Required Documentation Updates');
22
+ expect(processDoc).toContain('docs/guide/changelog.md');
23
+ expect(processDoc).toContain('## Validation Checklist');
24
+ expect(changelog).toContain('## Unreleased');
25
+ expect(changelog).toContain('Discord:');
26
+ });
27
+ });
@@ -0,0 +1 @@
1
+ export {};