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,440 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // Mock the log module before importing cron-store
4
+ vi.mock("../util/log.js", () => ({
5
+ log: vi.fn(),
6
+ logError: vi.fn(),
7
+ logWarn: vi.fn(),
8
+ }));
9
+
10
+ const existsSyncMock = vi.fn(() => false);
11
+ const readFileSyncMock = vi.fn(() => "{}");
12
+ const mkdirSyncMock = vi.fn();
13
+
14
+ // Mock fs to avoid real filesystem side effects
15
+ vi.mock("node:fs", () => ({
16
+ existsSync: existsSyncMock,
17
+ readFileSync: readFileSyncMock,
18
+ writeFileSync: vi.fn(),
19
+ mkdirSync: mkdirSyncMock,
20
+ }));
21
+
22
+ const writeFileSyncMock = vi.fn();
23
+
24
+ vi.mock("write-file-atomic", () => ({
25
+ default: { sync: writeFileSyncMock },
26
+ }));
27
+
28
+ import type { CronJob } from "../storage/cron-store.js";
29
+
30
+ const {
31
+ loadCronJobs,
32
+ flushCronJobs,
33
+ addCronJob,
34
+ getCronJob,
35
+ getCronJobsForChat,
36
+ getAllCronJobs,
37
+ updateCronJob,
38
+ deleteCronJob,
39
+ recordCronRun,
40
+ generateCronId,
41
+ validateCronExpression,
42
+ } = await import("../storage/cron-store.js");
43
+
44
+ function makeCronJob(overrides: Partial<CronJob> = {}): CronJob {
45
+ return {
46
+ id: generateCronId(),
47
+ chatId: "chat-1",
48
+ schedule: "0 9 * * *",
49
+ type: "message",
50
+ content: "Good morning!",
51
+ name: "Morning greeting",
52
+ enabled: true,
53
+ createdAt: Date.now(),
54
+ runCount: 0,
55
+ ...overrides,
56
+ };
57
+ }
58
+
59
+ describe("cron-store", () => {
60
+ beforeEach(() => {
61
+ vi.clearAllMocks();
62
+ });
63
+
64
+ describe("addCronJob and getCronJob", () => {
65
+ it("creates a job and it is retrievable", () => {
66
+ const job = makeCronJob({ id: "test-add-1" });
67
+ addCronJob(job);
68
+ const retrieved = getCronJob("test-add-1");
69
+ expect(retrieved).toBeDefined();
70
+ expect(retrieved!.id).toBe("test-add-1");
71
+ expect(retrieved!.name).toBe("Morning greeting");
72
+ expect(retrieved!.schedule).toBe("0 9 * * *");
73
+ });
74
+
75
+ it("stores all fields correctly", () => {
76
+ const job = makeCronJob({
77
+ id: "test-add-full",
78
+ chatId: "chat-full",
79
+ schedule: "30 14 * * 1-5",
80
+ type: "query",
81
+ content: "What is the weather?",
82
+ name: "Weather check",
83
+ enabled: false,
84
+ timezone: "Europe/London",
85
+ });
86
+ addCronJob(job);
87
+ const retrieved = getCronJob("test-add-full")!;
88
+ expect(retrieved.type).toBe("query");
89
+ expect(retrieved.content).toBe("What is the weather?");
90
+ expect(retrieved.enabled).toBe(false);
91
+ expect(retrieved.timezone).toBe("Europe/London");
92
+ expect(retrieved.schedule).toBe("30 14 * * 1-5");
93
+ });
94
+
95
+ it("overwrites existing job with same id", () => {
96
+ addCronJob(makeCronJob({ id: "overwrite-1", name: "Original" }));
97
+ addCronJob(makeCronJob({ id: "overwrite-1", name: "Replaced" }));
98
+ const retrieved = getCronJob("overwrite-1")!;
99
+ expect(retrieved.name).toBe("Replaced");
100
+ });
101
+ });
102
+
103
+ describe("getCronJob", () => {
104
+ it("returns undefined for nonexistent ID", () => {
105
+ const result = getCronJob("nonexistent-id-xyz-999");
106
+ expect(result).toBeUndefined();
107
+ });
108
+ });
109
+
110
+ describe("getCronJobsForChat", () => {
111
+ it("filters by chatId", () => {
112
+ const jobA = makeCronJob({ id: "filter-a", chatId: "chat-filter-1" });
113
+ const jobB = makeCronJob({ id: "filter-b", chatId: "chat-filter-2" });
114
+ const jobC = makeCronJob({ id: "filter-c", chatId: "chat-filter-1" });
115
+ addCronJob(jobA);
116
+ addCronJob(jobB);
117
+ addCronJob(jobC);
118
+
119
+ const result = getCronJobsForChat("chat-filter-1");
120
+ expect(result).toHaveLength(2);
121
+ expect(result.map((j) => j.id)).toContain("filter-a");
122
+ expect(result.map((j) => j.id)).toContain("filter-c");
123
+ });
124
+
125
+ it("returns empty array when no jobs match", () => {
126
+ const result = getCronJobsForChat("no-jobs-here-123");
127
+ expect(result).toEqual([]);
128
+ });
129
+ });
130
+
131
+ describe("getAllCronJobs", () => {
132
+ it("returns all jobs", () => {
133
+ const before = getAllCronJobs().length;
134
+ addCronJob(makeCronJob({ id: "all-1", chatId: "all-chat" }));
135
+ addCronJob(makeCronJob({ id: "all-2", chatId: "all-chat" }));
136
+ const after = getAllCronJobs().length;
137
+ expect(after - before).toBe(2);
138
+ });
139
+ });
140
+
141
+ describe("updateCronJob", () => {
142
+ it("updates specific fields", () => {
143
+ const job = makeCronJob({ id: "update-1", name: "Original" });
144
+ addCronJob(job);
145
+
146
+ const updated = updateCronJob("update-1", {
147
+ name: "Updated",
148
+ enabled: false,
149
+ });
150
+
151
+ expect(updated).toBeDefined();
152
+ expect(updated!.name).toBe("Updated");
153
+ expect(updated!.enabled).toBe(false);
154
+ // Unchanged fields should remain
155
+ expect(updated!.schedule).toBe("0 9 * * *");
156
+ expect(updated!.chatId).toBe("chat-1");
157
+ });
158
+
159
+ it("returns undefined for nonexistent job", () => {
160
+ const result = updateCronJob("nonexistent-update", { name: "nope" });
161
+ expect(result).toBeUndefined();
162
+ });
163
+
164
+ it("can update schedule and content", () => {
165
+ addCronJob(makeCronJob({ id: "update-sched" }));
166
+ const updated = updateCronJob("update-sched", {
167
+ schedule: "*/5 * * * *",
168
+ content: "New content",
169
+ });
170
+ expect(updated!.schedule).toBe("*/5 * * * *");
171
+ expect(updated!.content).toBe("New content");
172
+ });
173
+
174
+ it("can update lastRunAt and runCount", () => {
175
+ addCronJob(makeCronJob({ id: "update-run" }));
176
+ const ts = Date.now();
177
+ const updated = updateCronJob("update-run", {
178
+ lastRunAt: ts,
179
+ runCount: 10,
180
+ });
181
+ expect(updated!.lastRunAt).toBe(ts);
182
+ expect(updated!.runCount).toBe(10);
183
+ });
184
+ });
185
+
186
+ describe("deleteCronJob", () => {
187
+ it("removes a job and returns true", () => {
188
+ const job = makeCronJob({ id: "delete-1" });
189
+ addCronJob(job);
190
+ expect(getCronJob("delete-1")).toBeDefined();
191
+
192
+ const result = deleteCronJob("delete-1");
193
+ expect(result).toBe(true);
194
+ expect(getCronJob("delete-1")).toBeUndefined();
195
+ });
196
+
197
+ it("returns false for nonexistent job", () => {
198
+ const result = deleteCronJob("nonexistent-delete");
199
+ expect(result).toBe(false);
200
+ });
201
+
202
+ it("job is no longer returned by getCronJobsForChat after deletion", () => {
203
+ addCronJob(makeCronJob({ id: "del-chat-1", chatId: "del-chat" }));
204
+ addCronJob(makeCronJob({ id: "del-chat-2", chatId: "del-chat" }));
205
+ deleteCronJob("del-chat-1");
206
+ const jobs = getCronJobsForChat("del-chat");
207
+ expect(jobs).toHaveLength(1);
208
+ expect(jobs[0].id).toBe("del-chat-2");
209
+ });
210
+ });
211
+
212
+ describe("recordCronRun", () => {
213
+ it("increments runCount and sets lastRunAt", () => {
214
+ const job = makeCronJob({ id: "run-1", runCount: 0 });
215
+ addCronJob(job);
216
+
217
+ recordCronRun("run-1");
218
+ const after1 = getCronJob("run-1")!;
219
+ expect(after1.runCount).toBe(1);
220
+ expect(after1.lastRunAt).toBeGreaterThan(0);
221
+
222
+ recordCronRun("run-1");
223
+ const after2 = getCronJob("run-1")!;
224
+ expect(after2.runCount).toBe(2);
225
+ });
226
+
227
+ it("is a no-op for nonexistent job", () => {
228
+ // Should not throw
229
+ expect(() => recordCronRun("nonexistent-run")).not.toThrow();
230
+ });
231
+ });
232
+
233
+ describe("generateCronId", () => {
234
+ it("returns unique IDs", () => {
235
+ const ids = new Set<string>();
236
+ for (let i = 0; i < 100; i++) {
237
+ ids.add(generateCronId());
238
+ }
239
+ expect(ids.size).toBe(100);
240
+ });
241
+
242
+ it("starts with 'cron_' prefix", () => {
243
+ const id = generateCronId();
244
+ expect(id.startsWith("cron_")).toBe(true);
245
+ });
246
+
247
+ it("contains a timestamp component", () => {
248
+ const id = generateCronId();
249
+ const parts = id.split("_");
250
+ expect(parts.length).toBe(3);
251
+ const ts = Number(parts[1]);
252
+ expect(ts).toBeGreaterThan(0);
253
+ expect(ts).toBeLessThanOrEqual(Date.now());
254
+ });
255
+ });
256
+
257
+ describe("validateCronExpression", () => {
258
+ it("valid expression returns valid: true with next date", () => {
259
+ const result = validateCronExpression("0 9 * * *");
260
+ expect(result.valid).toBe(true);
261
+ expect(result.next).toBeDefined();
262
+ // next should be a valid ISO string
263
+ expect(() => new Date(result.next!)).not.toThrow();
264
+ expect(new Date(result.next!).getTime()).toBeGreaterThan(Date.now());
265
+ });
266
+
267
+ it("invalid expression returns valid: false with error", () => {
268
+ const result = validateCronExpression("not a cron expression");
269
+ expect(result.valid).toBe(false);
270
+ expect(result.error).toBeDefined();
271
+ expect(typeof result.error).toBe("string");
272
+ });
273
+
274
+ it("accepts timezone parameter", () => {
275
+ const result = validateCronExpression("0 9 * * *", "America/New_York");
276
+ expect(result.valid).toBe(true);
277
+ expect(result.next).toBeDefined();
278
+ });
279
+
280
+ it("validates various valid cron patterns", () => {
281
+ // Every 5 minutes
282
+ expect(validateCronExpression("*/5 * * * *").valid).toBe(true);
283
+ // Weekdays at noon
284
+ expect(validateCronExpression("0 12 * * 1-5").valid).toBe(true);
285
+ // First day of month at midnight
286
+ expect(validateCronExpression("0 0 1 * *").valid).toBe(true);
287
+ // Every hour
288
+ expect(validateCronExpression("0 * * * *").valid).toBe(true);
289
+ });
290
+
291
+ it("rejects invalid timezone", () => {
292
+ const result = validateCronExpression("0 9 * * *", "Not/A/Timezone");
293
+ expect(result.valid).toBe(false);
294
+ expect(result.error).toBeDefined();
295
+ });
296
+
297
+ it("validates with different valid timezones", () => {
298
+ expect(validateCronExpression("0 9 * * *", "Europe/London").valid).toBe(true);
299
+ expect(validateCronExpression("0 9 * * *", "Asia/Tokyo").valid).toBe(true);
300
+ expect(validateCronExpression("0 9 * * *", "US/Pacific").valid).toBe(true);
301
+ });
302
+
303
+ it("returns no error field on valid expression", () => {
304
+ const result = validateCronExpression("0 9 * * *");
305
+ expect(result.error).toBeUndefined();
306
+ });
307
+ });
308
+
309
+ describe("loadCronJobs", () => {
310
+ it("loads jobs from object format file", () => {
311
+ const stored = {
312
+ "job-1": {
313
+ id: "job-1",
314
+ chatId: "chat-1",
315
+ schedule: "0 9 * * *",
316
+ type: "message",
317
+ content: "Hello",
318
+ name: "Greeting",
319
+ enabled: true,
320
+ createdAt: 1000,
321
+ runCount: 5,
322
+ },
323
+ "job-2": {
324
+ id: "job-2",
325
+ chatId: "chat-2",
326
+ schedule: "0 12 * * *",
327
+ type: "query",
328
+ content: "Status?",
329
+ name: "Status check",
330
+ enabled: false,
331
+ createdAt: 2000,
332
+ runCount: 0,
333
+ },
334
+ };
335
+ existsSyncMock.mockReturnValue(true);
336
+ readFileSyncMock.mockReturnValue(JSON.stringify(stored));
337
+
338
+ loadCronJobs();
339
+
340
+ expect(getCronJob("job-1")).toBeDefined();
341
+ expect(getCronJob("job-1")!.name).toBe("Greeting");
342
+ expect(getCronJob("job-2")).toBeDefined();
343
+ expect(getCronJob("job-2")!.type).toBe("query");
344
+ });
345
+
346
+ it("loads jobs from legacy array format", () => {
347
+ const stored = [
348
+ {
349
+ id: "legacy-1",
350
+ chatId: "chat-1",
351
+ schedule: "0 9 * * *",
352
+ type: "message",
353
+ content: "Hello",
354
+ name: "Greeting",
355
+ enabled: true,
356
+ createdAt: 1000,
357
+ runCount: 0,
358
+ },
359
+ {
360
+ id: "legacy-2",
361
+ chatId: "chat-2",
362
+ schedule: "0 12 * * *",
363
+ type: "query",
364
+ content: "Status?",
365
+ name: "Status check",
366
+ enabled: true,
367
+ createdAt: 2000,
368
+ runCount: 3,
369
+ },
370
+ ];
371
+ existsSyncMock.mockReturnValue(true);
372
+ readFileSyncMock.mockReturnValue(JSON.stringify(stored));
373
+
374
+ loadCronJobs();
375
+
376
+ expect(getCronJob("legacy-1")).toBeDefined();
377
+ expect(getCronJob("legacy-1")!.name).toBe("Greeting");
378
+ expect(getCronJob("legacy-2")).toBeDefined();
379
+ });
380
+
381
+ it("does nothing when store file does not exist", () => {
382
+ existsSyncMock.mockReturnValue(false);
383
+ expect(() => loadCronJobs()).not.toThrow();
384
+ });
385
+
386
+ it("handles JSON parse errors gracefully (resets to empty)", () => {
387
+ existsSyncMock.mockReturnValue(true);
388
+ readFileSyncMock.mockReturnValue("not valid json{{{");
389
+
390
+ expect(() => loadCronJobs()).not.toThrow();
391
+ });
392
+ });
393
+
394
+ describe("flushCronJobs", () => {
395
+ it("writes jobs to disk when dirty", () => {
396
+ addCronJob(makeCronJob({ id: "flush-1" }));
397
+
398
+ existsSyncMock.mockReturnValue(true);
399
+ flushCronJobs();
400
+
401
+ expect(writeFileSyncMock).toHaveBeenCalled();
402
+ // Last write call is the actual data (earlier calls may be .bak backups)
403
+ const lastCall = writeFileSyncMock.mock.calls[writeFileSyncMock.mock.calls.length - 1];
404
+ const writtenData = lastCall[1] as string;
405
+ const parsed = JSON.parse(writtenData.trim());
406
+ expect(parsed["flush-1"]).toBeDefined();
407
+ });
408
+
409
+ it("creates workspace directory if it does not exist during addCronJob save", () => {
410
+ // addCronJob calls save() internally, so we need to set up the mock
411
+ // before calling addCronJob to catch the mkdir call
412
+ existsSyncMock.mockReturnValue(false);
413
+ mkdirSyncMock.mockClear();
414
+
415
+ addCronJob(makeCronJob({ id: "flush-mkdir-1" }));
416
+
417
+ expect(mkdirSyncMock).toHaveBeenCalledWith(expect.any(String), { recursive: true });
418
+ });
419
+
420
+ it("does not write when not dirty", () => {
421
+ // flushCronJobs calls save() which checks dirty flag.
422
+ // Since we haven't modified anything since last flush, the writeFileSync
423
+ // should not be called. But flushCronJobs doesn't set dirty=true like
424
+ // flushHistory does. Let's verify that behavior.
425
+ // Actually, looking at the code: flushCronJobs just calls save() directly
426
+ // without setting dirty=true. So if nothing was modified, it won't write.
427
+ writeFileSyncMock.mockClear();
428
+ existsSyncMock.mockReturnValue(true);
429
+
430
+ // Load cleans state, then flush immediately should be no-op
431
+ existsSyncMock.mockReturnValue(false);
432
+ loadCronJobs();
433
+ writeFileSyncMock.mockClear();
434
+ flushCronJobs();
435
+
436
+ // After loadCronJobs + no changes, dirty is false, so save() should not write
437
+ expect(writeFileSyncMock).not.toHaveBeenCalled();
438
+ });
439
+ });
440
+ });
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import {
3
+ mkdirSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ rmSync,
7
+ existsSync,
8
+ readdirSync,
9
+ } from "node:fs";
10
+
11
+ // Mock log to prevent pino initialization
12
+ vi.mock("../util/log.js", () => ({
13
+ log: vi.fn(),
14
+ logError: vi.fn(),
15
+ logWarn: vi.fn(),
16
+ logDebug: vi.fn(),
17
+ }));
18
+ import { join } from "node:path";
19
+ import { tmpdir } from "node:os";
20
+
21
+ // Use a unique temp directory for each test run
22
+ const TEST_ROOT = join(tmpdir(), `talon-daily-log-test-${Date.now()}`);
23
+ const LOGS_DIR = join(TEST_ROOT, ".talon", "workspace", "logs");
24
+
25
+ // paths.ts uses os.homedir() — mock it to point to our temp directory
26
+ vi.mock("node:os", async (importOriginal) => {
27
+ const actual = await importOriginal() as Record<string, unknown>;
28
+ return { ...actual, homedir: () => TEST_ROOT };
29
+ });
30
+
31
+ beforeEach(() => {
32
+ vi.resetModules();
33
+ if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true });
34
+ });
35
+
36
+ afterEach(() => {
37
+ if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true });
38
+ });
39
+
40
+ describe("daily-log", () => {
41
+ describe("appendDailyLog", () => {
42
+ it("creates the log file if missing", async () => {
43
+ // Re-import to pick up the mocked cwd
44
+ const { appendDailyLog, getLogsDir } = await import(
45
+ "../storage/daily-log.js"
46
+ );
47
+
48
+ // Ensure logs dir doesn't exist yet
49
+ expect(existsSync(LOGS_DIR)).toBe(false);
50
+
51
+ appendDailyLog("TestChat", "User asked about weather");
52
+
53
+ // The logs dir should now exist
54
+ const logsDir = getLogsDir();
55
+ expect(existsSync(logsDir)).toBe(true);
56
+
57
+ // There should be a log file for today
58
+ const files = readdirSync(logsDir);
59
+ expect(files.length).toBeGreaterThanOrEqual(1);
60
+
61
+ const todayStr = new Date().toISOString().slice(0, 10);
62
+ const todayFile = files.find((f) => f.startsWith(todayStr));
63
+ expect(todayFile).toBeDefined();
64
+ });
65
+
66
+ it("appends to existing file", async () => {
67
+ const { appendDailyLog, getLogsDir } = await import(
68
+ "../storage/daily-log.js"
69
+ );
70
+
71
+ appendDailyLog("Chat1", "First entry");
72
+ appendDailyLog("Chat2", "Second entry");
73
+
74
+ const logsDir = getLogsDir();
75
+ const todayStr = new Date().toISOString().slice(0, 10);
76
+ const logFile = join(logsDir, `${todayStr}.md`);
77
+
78
+ const content = readFileSync(logFile, "utf-8");
79
+ expect(content).toContain("First entry");
80
+ expect(content).toContain("Second entry");
81
+ expect(content).toContain("[Chat1]");
82
+ expect(content).toContain("[Chat2]");
83
+ });
84
+
85
+ it("uses correct log format (## HH:MM -- [name])", async () => {
86
+ const { appendDailyLog, getLogsDir } = await import(
87
+ "../storage/daily-log.js"
88
+ );
89
+
90
+ appendDailyLog("MyChat", "Did some testing");
91
+
92
+ const logsDir = getLogsDir();
93
+ const todayStr = new Date().toISOString().slice(0, 10);
94
+ const logFile = join(logsDir, `${todayStr}.md`);
95
+
96
+ const content = readFileSync(logFile, "utf-8");
97
+
98
+ // Format: ## HH:MM -- [chatName]
99
+ expect(content).toMatch(/## \d{2}:\d{2} -- \[MyChat\]/);
100
+ // Summary line
101
+ expect(content).toContain("Did some testing");
102
+ });
103
+
104
+ it("uses .talon/workspace/logs/ directory", async () => {
105
+ const { appendDailyLog, getLogsDir } = await import(
106
+ "../storage/daily-log.js"
107
+ );
108
+
109
+ appendDailyLog("LogDirTest", "checking path");
110
+
111
+ const logsDir = getLogsDir();
112
+ expect(logsDir).toContain(".talon");
113
+ expect(logsDir).toContain("logs");
114
+ });
115
+ });
116
+
117
+ describe("cleanupOldLogs", () => {
118
+ it("deletes logs older than 30 days", async () => {
119
+ const { cleanupOldLogs } = await import("../storage/daily-log.js");
120
+ mkdirSync(LOGS_DIR, { recursive: true });
121
+
122
+ // Create an old log file (40 days ago)
123
+ const oldDate = new Date();
124
+ oldDate.setDate(oldDate.getDate() - 40);
125
+ const oldName = oldDate.toISOString().slice(0, 10) + ".md";
126
+ writeFileSync(join(LOGS_DIR, oldName), "old log content");
127
+
128
+ // Create a recent log file (5 days ago)
129
+ const recentDate = new Date();
130
+ recentDate.setDate(recentDate.getDate() - 5);
131
+ const recentName = recentDate.toISOString().slice(0, 10) + ".md";
132
+ writeFileSync(join(LOGS_DIR, recentName), "recent log content");
133
+
134
+ cleanupOldLogs();
135
+
136
+ const remaining = readdirSync(LOGS_DIR);
137
+ expect(remaining).not.toContain(oldName);
138
+ expect(remaining).toContain(recentName);
139
+ });
140
+
141
+ it("handles missing logs directory", async () => {
142
+ const { cleanupOldLogs } = await import("../storage/daily-log.js");
143
+ expect(() => cleanupOldLogs()).not.toThrow();
144
+ });
145
+ });
146
+ });