@onmars/lunar-core 0.1.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 (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -0
  3. package/package.json +32 -0
  4. package/src/__tests__/clear-command.test.ts +214 -0
  5. package/src/__tests__/command-handler.test.ts +169 -0
  6. package/src/__tests__/compact-command.test.ts +80 -0
  7. package/src/__tests__/config-command.test.ts +240 -0
  8. package/src/__tests__/config-loader.test.ts +1512 -0
  9. package/src/__tests__/config.test.ts +429 -0
  10. package/src/__tests__/cron-command.test.ts +418 -0
  11. package/src/__tests__/cron-parser.test.ts +259 -0
  12. package/src/__tests__/daemon.test.ts +346 -0
  13. package/src/__tests__/dedup.test.ts +404 -0
  14. package/src/__tests__/e2e-sanitization.ts +168 -0
  15. package/src/__tests__/e2e-skill-loader.test.ts +176 -0
  16. package/src/__tests__/fixtures/AGENTS.md +4 -0
  17. package/src/__tests__/fixtures/IDENTITY.md +2 -0
  18. package/src/__tests__/fixtures/SOUL.md +3 -0
  19. package/src/__tests__/fixtures/moons/athena/IDENTITY.md +2 -0
  20. package/src/__tests__/fixtures/moons/athena/SOUL.md +3 -0
  21. package/src/__tests__/fixtures/moons/hermes/SOUL.md +3 -0
  22. package/src/__tests__/fixtures/skills/brain/SKILL.md +6 -0
  23. package/src/__tests__/fixtures/skills/empty/SKILL.md +3 -0
  24. package/src/__tests__/fixtures/skills/multiline/SKILL.md +7 -0
  25. package/src/__tests__/fixtures/skills/no-desc/SKILL.md +5 -0
  26. package/src/__tests__/fixtures/skills/notion/SKILL.md +6 -0
  27. package/src/__tests__/fixtures/skills/quoted/SKILL.md +6 -0
  28. package/src/__tests__/hook-runner.test.ts +1689 -0
  29. package/src/__tests__/input-sanitization.test.ts +367 -0
  30. package/src/__tests__/logger.test.ts +163 -0
  31. package/src/__tests__/memory-orchestrator.test.ts +552 -0
  32. package/src/__tests__/model-catalog.test.ts +215 -0
  33. package/src/__tests__/model-command.test.ts +185 -0
  34. package/src/__tests__/moon-loader.test.ts +398 -0
  35. package/src/__tests__/ping-command.test.ts +85 -0
  36. package/src/__tests__/plugin.test.ts +258 -0
  37. package/src/__tests__/remind-command.test.ts +368 -0
  38. package/src/__tests__/reset-command.test.ts +92 -0
  39. package/src/__tests__/router.test.ts +1246 -0
  40. package/src/__tests__/scheduler.test.ts +469 -0
  41. package/src/__tests__/security.test.ts +214 -0
  42. package/src/__tests__/session-meta.test.ts +101 -0
  43. package/src/__tests__/session-tracker.test.ts +389 -0
  44. package/src/__tests__/session.test.ts +241 -0
  45. package/src/__tests__/skill-loader.test.ts +153 -0
  46. package/src/__tests__/status-command.test.ts +153 -0
  47. package/src/__tests__/stop-command.test.ts +60 -0
  48. package/src/__tests__/think-command.test.ts +146 -0
  49. package/src/__tests__/usage-api.test.ts +222 -0
  50. package/src/__tests__/usage-command-api-fail.test.ts +48 -0
  51. package/src/__tests__/usage-command-no-oauth.test.ts +48 -0
  52. package/src/__tests__/usage-command.test.ts +173 -0
  53. package/src/__tests__/whoami-command.test.ts +124 -0
  54. package/src/index.ts +122 -0
  55. package/src/lib/command-handler.ts +135 -0
  56. package/src/lib/commands/clear.ts +69 -0
  57. package/src/lib/commands/compact.ts +14 -0
  58. package/src/lib/commands/config-show.ts +49 -0
  59. package/src/lib/commands/cron.ts +118 -0
  60. package/src/lib/commands/help.ts +26 -0
  61. package/src/lib/commands/model.ts +71 -0
  62. package/src/lib/commands/ping.ts +24 -0
  63. package/src/lib/commands/remind.ts +75 -0
  64. package/src/lib/commands/status.ts +118 -0
  65. package/src/lib/commands/stop.ts +18 -0
  66. package/src/lib/commands/think.ts +42 -0
  67. package/src/lib/commands/usage.ts +56 -0
  68. package/src/lib/commands/whoami.ts +23 -0
  69. package/src/lib/config-loader.ts +1449 -0
  70. package/src/lib/config.ts +202 -0
  71. package/src/lib/cron-parser.ts +388 -0
  72. package/src/lib/daemon.ts +216 -0
  73. package/src/lib/dedup.ts +414 -0
  74. package/src/lib/hook-runner.ts +1270 -0
  75. package/src/lib/logger.ts +55 -0
  76. package/src/lib/memory-orchestrator.ts +415 -0
  77. package/src/lib/model-catalog.ts +240 -0
  78. package/src/lib/moon-loader.ts +291 -0
  79. package/src/lib/plugin.ts +148 -0
  80. package/src/lib/router.ts +1135 -0
  81. package/src/lib/scheduler.ts +422 -0
  82. package/src/lib/security.ts +259 -0
  83. package/src/lib/session-tracker.ts +222 -0
  84. package/src/lib/session.ts +158 -0
  85. package/src/lib/skill-loader.ts +166 -0
  86. package/src/lib/usage-api.ts +145 -0
  87. package/src/types/agent.ts +86 -0
  88. package/src/types/channel.ts +93 -0
  89. package/src/types/index.ts +32 -0
  90. package/src/types/memory.ts +92 -0
  91. package/src/types/moon.ts +56 -0
  92. package/src/types/voice.ts +74 -0
@@ -0,0 +1,258 @@
1
+ /**
2
+ * # PluginRegistry — Functional Specification
3
+ *
4
+ * Central registry for all Lunar plugins: channels, agents, memory, TTS, STT.
5
+ *
6
+ * ## Design principles:
7
+ * - **Register → Get pattern**: register a plugin instance, retrieve it later by id.
8
+ * - **First-agent-wins default**: the first registered agent becomes the default
9
+ * unless explicitly overridden with `registerAgent(agent, true)`.
10
+ * - **Strict uniqueness**: duplicate ids throw immediately (fail fast, not silently overwrite).
11
+ * - **Lifecycle management**: `initAll()` connects everything, `destroyAll()` tears down.
12
+ * Individual failures in initAll don't block others (Promise.allSettled).
13
+ * - **Empty is valid**: empty registry works fine — no plugins means no-ops.
14
+ *
15
+ * ## Plugin types:
16
+ * | Type | Register | Get | List |
17
+ * |---------|------------------|-----------------|-----------------|
18
+ * | Channel | registerChannel | getChannel(id) | listChannels() |
19
+ * | Agent | registerAgent | getAgent(id?) | listAgents() |
20
+ * | Memory | registerMemory | getMemory(id) | listMemory() |
21
+ */
22
+ import { beforeEach, describe, expect, it } from 'bun:test'
23
+ import { PluginRegistry } from '../lib/plugin'
24
+ import type { Agent } from '../types/agent'
25
+ import type { Channel } from '../types/channel'
26
+ import type { MemoryProvider } from '../types/memory'
27
+
28
+ // ─── Mock factories ────────────────────────────────────────────────
29
+
30
+ function createMockChannel(id: string): Channel {
31
+ return {
32
+ id,
33
+ name: `Mock ${id}`,
34
+ connect: async () => {},
35
+ disconnect: async () => {},
36
+ isConnected: () => false,
37
+ send: async () => undefined,
38
+ sendTyping: async () => {},
39
+ onMessage: () => {},
40
+ }
41
+ }
42
+
43
+ function createMockAgent(id: string): Agent {
44
+ return {
45
+ id,
46
+ name: `Mock ${id}`,
47
+ init: async () => {},
48
+ destroy: async () => {},
49
+ query: async function* () {
50
+ yield { type: 'text' as const, content: 'test' }
51
+ yield { type: 'done' as const, sessionId: 'test-session' }
52
+ },
53
+ health: async () => ({ ok: true }),
54
+ }
55
+ }
56
+
57
+ function createMockMemory(id: string): MemoryProvider {
58
+ return {
59
+ id,
60
+ name: `Mock ${id}`,
61
+ init: async () => {},
62
+ destroy: async () => {},
63
+ recall: async () => [],
64
+ save: async () => {},
65
+ health: async () => ({ ok: true }),
66
+ }
67
+ }
68
+
69
+ // ═══════════════════════════════════════════════════════════════════
70
+
71
+ describe('PluginRegistry', () => {
72
+ let registry: PluginRegistry
73
+
74
+ beforeEach(() => {
75
+ registry = new PluginRegistry()
76
+ })
77
+
78
+ // ─── Channel Registration ──────────────────────────────────────
79
+
80
+ describe('channels — platform adapters (Discord, Telegram, etc.)', () => {
81
+ it('registers and lists a channel by id', () => {
82
+ registry.registerChannel(createMockChannel('discord'))
83
+ expect(registry.listChannels()).toEqual(['discord'])
84
+ })
85
+
86
+ it('throws on duplicate channel id (fail-fast)', () => {
87
+ registry.registerChannel(createMockChannel('discord'))
88
+ expect(() => registry.registerChannel(createMockChannel('discord'))).toThrow(
89
+ "Channel 'discord' already registered",
90
+ )
91
+ })
92
+
93
+ it('supports multiple simultaneous channels', () => {
94
+ registry.registerChannel(createMockChannel('discord'))
95
+ registry.registerChannel(createMockChannel('telegram'))
96
+ expect(registry.listChannels()).toEqual(['discord', 'telegram'])
97
+ })
98
+ })
99
+
100
+ // ─── Agent Registration ────────────────────────────────────────
101
+
102
+ describe('agents — LLM backends (Claude, GPT, etc.)', () => {
103
+ it('registers and lists an agent', () => {
104
+ registry.registerAgent(createMockAgent('claude'))
105
+ expect(registry.listAgents()).toEqual(['claude'])
106
+ })
107
+
108
+ it('first registered agent becomes the default', () => {
109
+ registry.registerAgent(createMockAgent('claude'))
110
+ expect(registry.getAgent()!.id).toBe('claude')
111
+ })
112
+
113
+ it('explicit default=true overrides first-wins', () => {
114
+ registry.registerAgent(createMockAgent('gpt'))
115
+ registry.registerAgent(createMockAgent('claude'), true)
116
+ expect(registry.getAgent()!.id).toBe('claude')
117
+ })
118
+
119
+ it('throws on duplicate agent id (fail-fast)', () => {
120
+ registry.registerAgent(createMockAgent('claude'))
121
+ expect(() => registry.registerAgent(createMockAgent('claude'))).toThrow(
122
+ "Agent 'claude' already registered",
123
+ )
124
+ })
125
+
126
+ it('retrieves specific agent by id', () => {
127
+ registry.registerAgent(createMockAgent('claude'))
128
+ registry.registerAgent(createMockAgent('gpt'))
129
+
130
+ expect(registry.getAgent('gpt')!.id).toBe('gpt')
131
+ expect(registry.getAgent('claude')!.id).toBe('claude')
132
+ })
133
+
134
+ it('returns undefined for unknown agent id', () => {
135
+ expect(registry.getAgent('nonexistent')).toBeUndefined()
136
+ })
137
+ })
138
+
139
+ // ─── Memory Registration ───────────────────────────────────────
140
+
141
+ describe('memory — semantic memory backends (Brain, Mem0, etc.)', () => {
142
+ it('registers and retrieves memory provider', () => {
143
+ registry.registerMemory(createMockMemory('brain'))
144
+ expect(registry.getMemory('brain')!.id).toBe('brain')
145
+ })
146
+
147
+ it('returns undefined for unknown provider id', () => {
148
+ expect(registry.getMemory('nonexistent')).toBeUndefined()
149
+ })
150
+
151
+ it('supports multiple concurrent providers', () => {
152
+ registry.registerMemory(createMockMemory('brain'))
153
+ registry.registerMemory(createMockMemory('engram'))
154
+ expect(registry.listMemory()).toEqual(['brain', 'engram'])
155
+ })
156
+ })
157
+
158
+ // ─── Lifecycle ─────────────────────────────────────────────────
159
+
160
+ describe('lifecycle — initAll / destroyAll', () => {
161
+ it('initAll initializes channels, agents, and memory', async () => {
162
+ const inited = { channel: false, agent: false, memory: false }
163
+
164
+ const channel = createMockChannel('discord')
165
+ channel.connect = async () => {
166
+ inited.channel = true
167
+ }
168
+
169
+ const agent = createMockAgent('claude')
170
+ agent.init = async () => {
171
+ inited.agent = true
172
+ }
173
+
174
+ const memory = createMockMemory('brain')
175
+ memory.init = async () => {
176
+ inited.memory = true
177
+ }
178
+
179
+ registry.registerChannel(channel)
180
+ registry.registerAgent(agent)
181
+ registry.registerMemory(memory)
182
+
183
+ await registry.initAll()
184
+
185
+ expect(inited.channel).toBe(true)
186
+ expect(inited.agent).toBe(true)
187
+ expect(inited.memory).toBe(true)
188
+ })
189
+
190
+ it('initAll continues even when one plugin fails (resilient)', async () => {
191
+ const channel = createMockChannel('discord')
192
+ channel.connect = async () => {
193
+ throw new Error('Connect failed')
194
+ }
195
+
196
+ const agent = createMockAgent('claude')
197
+ let agentInited = false
198
+ agent.init = async () => {
199
+ agentInited = true
200
+ }
201
+
202
+ registry.registerChannel(channel)
203
+ registry.registerAgent(agent)
204
+
205
+ // Should NOT throw — uses Promise.allSettled internally
206
+ await registry.initAll()
207
+ expect(agentInited).toBe(true)
208
+ })
209
+
210
+ it('destroyAll tears down all plugins', async () => {
211
+ const destroyed = { channel: false, agent: false, memory: false }
212
+
213
+ const channel = createMockChannel('discord')
214
+ channel.disconnect = async () => {
215
+ destroyed.channel = true
216
+ }
217
+
218
+ const agent = createMockAgent('claude')
219
+ agent.destroy = async () => {
220
+ destroyed.agent = true
221
+ }
222
+
223
+ const memory = createMockMemory('brain')
224
+ memory.destroy = async () => {
225
+ destroyed.memory = true
226
+ }
227
+
228
+ registry.registerChannel(channel)
229
+ registry.registerAgent(agent)
230
+ registry.registerMemory(memory)
231
+
232
+ await registry.destroyAll()
233
+
234
+ expect(destroyed.channel).toBe(true)
235
+ expect(destroyed.agent).toBe(true)
236
+ expect(destroyed.memory).toBe(true)
237
+ })
238
+ })
239
+
240
+ // ─── Empty state ───────────────────────────────────────────────
241
+
242
+ describe('empty registry — valid zero-plugin state', () => {
243
+ it('all lists return empty arrays', () => {
244
+ expect(registry.listChannels()).toEqual([])
245
+ expect(registry.listAgents()).toEqual([])
246
+ expect(registry.listMemory()).toEqual([])
247
+ })
248
+
249
+ it('getAgent returns undefined (no default)', () => {
250
+ expect(registry.getAgent()).toBeUndefined()
251
+ })
252
+
253
+ it('lifecycle methods are no-ops on empty registry', async () => {
254
+ await registry.initAll()
255
+ await registry.destroyAll()
256
+ })
257
+ })
258
+ })
@@ -0,0 +1,368 @@
1
+ /**
2
+ * /remind Command — Test Suite
3
+ *
4
+ * Key contracts:
5
+ * - Parses relative time: "in 15m", "in 2h"
6
+ * - Parses absolute time: "14:30" (HH:MM today or tomorrow)
7
+ * - Parses ISO-8601: "2026-03-01T15:30", "2026-03-01T15:30Z"
8
+ * - Creates a one-shot scheduler job with kind: 'at'
9
+ * - Returns usage help when no args
10
+ * - Returns error when scheduler is not available
11
+ * - Returns error for invalid time formats
12
+ * - Uses channel persona name as channel, defaults to 'orbit'
13
+ */
14
+ import { beforeEach, describe, expect, mock, test } from 'bun:test'
15
+ import type { CommandContext } from '../lib/command-handler'
16
+ import { remindCommand } from '../lib/commands/remind'
17
+ import type { Job } from '../lib/scheduler'
18
+ import { SessionTracker } from '../lib/session-tracker'
19
+
20
+ // ═══════════════════════════════════════════════════════════
21
+ // Mock store and scheduler
22
+ // ═══════════════════════════════════════════════════════════
23
+
24
+ function makeStore() {
25
+ return {
26
+ add: mock((_job: Job) => {}),
27
+ list: mock(() => []),
28
+ remove: mock(() => false),
29
+ get: mock(() => null),
30
+ enable: mock(() => {}),
31
+ disable: mock(() => {}),
32
+ }
33
+ }
34
+
35
+ function makeScheduler() {
36
+ const store = makeStore()
37
+ return {
38
+ scheduler: { getStore: () => store },
39
+ store,
40
+ }
41
+ }
42
+
43
+ function makeCtx(overrides?: Partial<CommandContext>): CommandContext {
44
+ return {
45
+ sessionKey: 'test:channel-1',
46
+ message: {
47
+ id: 'msg-1',
48
+ channelId: 'channel-1',
49
+ sender: { id: 'user-1', name: 'Test User' },
50
+ text: '/remind',
51
+ timestamp: new Date(),
52
+ },
53
+ tracker: new SessionTracker(),
54
+ daemonStartedAt: Date.now(),
55
+ agent: { id: 'claude', name: 'Claude Code (CLI)' },
56
+ clearSession: async () => ({ sessionCleared: true, hooksRan: false }),
57
+ ...overrides,
58
+ }
59
+ }
60
+
61
+ // ═══════════════════════════════════════════════════════════
62
+ // No scheduler
63
+ // ═══════════════════════════════════════════════════════════
64
+
65
+ describe('/remind — no scheduler', () => {
66
+ test('returns error when scheduler is not available', async () => {
67
+ const result = await remindCommand('in 15m test', makeCtx({ scheduler: undefined }))
68
+ expect(result).toContain('Scheduler is not enabled')
69
+ })
70
+ })
71
+
72
+ // ═══════════════════════════════════════════════════════════
73
+ // Usage / empty args
74
+ // ═══════════════════════════════════════════════════════════
75
+
76
+ describe('/remind usage', () => {
77
+ test('returns usage when no args', async () => {
78
+ const { scheduler } = makeScheduler()
79
+ const result = await remindCommand('', makeCtx({ scheduler: scheduler as any }))
80
+ expect(result).toContain('Usage')
81
+ expect(result).toContain('/remind')
82
+ expect(result).toContain('in 15m')
83
+ expect(result).toContain('in 2h')
84
+ })
85
+
86
+ test('returns usage when only whitespace', async () => {
87
+ const { scheduler } = makeScheduler()
88
+ const result = await remindCommand(' ', makeCtx({ scheduler: scheduler as any }))
89
+ expect(result).toContain('Usage')
90
+ })
91
+ })
92
+
93
+ // ═══════════════════════════════════════════════════════════
94
+ // Invalid format
95
+ // ═══════════════════════════════════════════════════════════
96
+
97
+ describe('/remind invalid format', () => {
98
+ test('rejects unparseable time format', async () => {
99
+ const { scheduler } = makeScheduler()
100
+ const result = await remindCommand(
101
+ 'tomorrow Do something',
102
+ makeCtx({ scheduler: scheduler as any }),
103
+ )
104
+ expect(result).toContain('Invalid format')
105
+ })
106
+
107
+ test('rejects time without message', async () => {
108
+ const { scheduler } = makeScheduler()
109
+ const result = await remindCommand('in 15m', makeCtx({ scheduler: scheduler as any }))
110
+ expect(result).toContain('Invalid format')
111
+ })
112
+
113
+ test('rejects "in" without valid duration', async () => {
114
+ const { scheduler } = makeScheduler()
115
+ const result = await remindCommand('in abc Do thing', makeCtx({ scheduler: scheduler as any }))
116
+ expect(result).toContain('Invalid format')
117
+ })
118
+ })
119
+
120
+ // ═══════════════════════════════════════════════════════════
121
+ // Relative time: "in Xm" / "in Xh"
122
+ // ═══════════════════════════════════════════════════════════
123
+
124
+ describe('/remind relative time', () => {
125
+ test('parses "in 15m" and creates job', async () => {
126
+ const { scheduler, store } = makeScheduler()
127
+ const before = Date.now()
128
+ const result = await remindCommand(
129
+ 'in 15m Turn off oven',
130
+ makeCtx({ scheduler: scheduler as any }),
131
+ )
132
+ const after = Date.now()
133
+
134
+ expect(result).toContain('Reminder set')
135
+ expect(store.add).toHaveBeenCalledTimes(1)
136
+
137
+ const job = (store.add as any).mock.calls[0][0] as Job
138
+ expect(job.schedule.kind).toBe('at')
139
+ expect(job.payload).toEqual({ kind: 'message', text: 'Turn off oven' })
140
+ expect(job.oneShot).toBe(true)
141
+ expect(job.enabled).toBe(true)
142
+
143
+ // Verify the scheduled time is ~15 minutes in the future
144
+ const atTime = new Date((job.schedule as any).at).getTime()
145
+ const expectedMin = before + 15 * 60_000
146
+ const expectedMax = after + 15 * 60_000
147
+ expect(atTime).toBeGreaterThanOrEqual(expectedMin - 100)
148
+ expect(atTime).toBeLessThanOrEqual(expectedMax + 100)
149
+ })
150
+
151
+ test('parses "in 2h" and creates job', async () => {
152
+ const { scheduler, store } = makeScheduler()
153
+ const before = Date.now()
154
+ const result = await remindCommand(
155
+ 'in 2h Check email',
156
+ makeCtx({ scheduler: scheduler as any }),
157
+ )
158
+ const after = Date.now()
159
+
160
+ expect(result).toContain('Reminder set')
161
+ expect(store.add).toHaveBeenCalledTimes(1)
162
+
163
+ const job = (store.add as any).mock.calls[0][0] as Job
164
+ const atTime = new Date((job.schedule as any).at).getTime()
165
+ const expectedMin = before + 2 * 3600_000
166
+ const expectedMax = after + 2 * 3600_000
167
+ expect(atTime).toBeGreaterThanOrEqual(expectedMin - 100)
168
+ expect(atTime).toBeLessThanOrEqual(expectedMax + 100)
169
+ })
170
+
171
+ test('parses "in 1m" for short durations', async () => {
172
+ const { scheduler, store } = makeScheduler()
173
+ const before = Date.now()
174
+ await remindCommand('in 1m Quick task', makeCtx({ scheduler: scheduler as any }))
175
+
176
+ const job = (store.add as any).mock.calls[0][0] as Job
177
+ const atTime = new Date((job.schedule as any).at).getTime()
178
+ expect(atTime).toBeGreaterThanOrEqual(before + 60_000 - 100)
179
+ })
180
+ })
181
+
182
+ // ═══════════════════════════════════════════════════════════
183
+ // Absolute time: HH:MM
184
+ // ═══════════════════════════════════════════════════════════
185
+
186
+ describe('/remind absolute time HH:MM', () => {
187
+ test('parses "14:30" format and creates job', async () => {
188
+ const { scheduler, store } = makeScheduler()
189
+ const result = await remindCommand(
190
+ '14:30 Daily summary',
191
+ makeCtx({ scheduler: scheduler as any }),
192
+ )
193
+
194
+ expect(result).toContain('Reminder set')
195
+ expect(store.add).toHaveBeenCalledTimes(1)
196
+
197
+ const job = (store.add as any).mock.calls[0][0] as Job
198
+ expect(job.schedule.kind).toBe('at')
199
+ expect(job.payload).toEqual({ kind: 'message', text: 'Daily summary' })
200
+ expect(job.oneShot).toBe(true)
201
+
202
+ // The scheduled time should have hours=14, minutes=30
203
+ const atTime = new Date((job.schedule as any).at)
204
+ // Either today or tomorrow depending on current time
205
+ expect(atTime.getHours()).toBe(14)
206
+ expect(atTime.getMinutes()).toBe(30)
207
+ })
208
+
209
+ test('parses "09:00" with leading zero', async () => {
210
+ const { scheduler, store } = makeScheduler()
211
+ await remindCommand('09:00 Morning standup', makeCtx({ scheduler: scheduler as any }))
212
+
213
+ const job = (store.add as any).mock.calls[0][0] as Job
214
+ const atTime = new Date((job.schedule as any).at)
215
+ expect(atTime.getHours()).toBe(9)
216
+ expect(atTime.getMinutes()).toBe(0)
217
+ })
218
+
219
+ test('if time already passed today, schedules for tomorrow', async () => {
220
+ const { scheduler, store } = makeScheduler()
221
+ // Use 00:01 which has almost certainly already passed (test runs during the day)
222
+ await remindCommand('00:01 Midnight check', makeCtx({ scheduler: scheduler as any }))
223
+
224
+ const job = (store.add as any).mock.calls[0][0] as Job
225
+ const atTime = new Date((job.schedule as any).at)
226
+ // Should be in the future
227
+ expect(atTime.getTime()).toBeGreaterThan(Date.now())
228
+ })
229
+ })
230
+
231
+ // ═══════════════════════════════════════════════════════════
232
+ // ISO-8601 time
233
+ // ═══════════════════════════════════════════════════════════
234
+
235
+ describe('/remind ISO-8601 time', () => {
236
+ test('parses ISO-8601 date without timezone', async () => {
237
+ const { scheduler, store } = makeScheduler()
238
+ const result = await remindCommand(
239
+ '2026-12-25T10:00 Christmas check',
240
+ makeCtx({ scheduler: scheduler as any }),
241
+ )
242
+
243
+ expect(result).toContain('Reminder set')
244
+ expect(store.add).toHaveBeenCalledTimes(1)
245
+
246
+ const job = (store.add as any).mock.calls[0][0] as Job
247
+ expect(job.schedule.kind).toBe('at')
248
+ const atTime = new Date((job.schedule as any).at)
249
+ expect(atTime.getFullYear()).toBe(2026)
250
+ expect(atTime.getMonth()).toBe(11) // December = 11
251
+ expect(atTime.getDate()).toBe(25)
252
+ })
253
+
254
+ test('parses ISO-8601 date with Z timezone', async () => {
255
+ const { scheduler, store } = makeScheduler()
256
+ const result = await remindCommand(
257
+ '2026-06-15T08:30Z Morning meeting',
258
+ makeCtx({ scheduler: scheduler as any }),
259
+ )
260
+
261
+ expect(result).toContain('Reminder set')
262
+ const job = (store.add as any).mock.calls[0][0] as Job
263
+ const atTime = new Date((job.schedule as any).at)
264
+ expect(atTime.getUTCHours()).toBe(8)
265
+ expect(atTime.getUTCMinutes()).toBe(30)
266
+ })
267
+
268
+ test('parses ISO-8601 date with offset timezone', async () => {
269
+ const { scheduler, store } = makeScheduler()
270
+ const result = await remindCommand(
271
+ '2026-06-15T08:30+05:00 Meeting',
272
+ makeCtx({ scheduler: scheduler as any }),
273
+ )
274
+
275
+ expect(result).toContain('Reminder set')
276
+ const job = (store.add as any).mock.calls[0][0] as Job
277
+ const atTime = new Date((job.schedule as any).at)
278
+ // 08:30+05:00 = 03:30 UTC
279
+ expect(atTime.getUTCHours()).toBe(3)
280
+ expect(atTime.getUTCMinutes()).toBe(30)
281
+ })
282
+
283
+ test('parses ISO-8601 with seconds', async () => {
284
+ const { scheduler, store } = makeScheduler()
285
+ const result = await remindCommand(
286
+ '2026-06-15T08:30:45Z Precise time',
287
+ makeCtx({ scheduler: scheduler as any }),
288
+ )
289
+
290
+ expect(result).toContain('Reminder set')
291
+ const job = (store.add as any).mock.calls[0][0] as Job
292
+ const atTime = new Date((job.schedule as any).at)
293
+ expect(atTime.getUTCSeconds()).toBe(45)
294
+ })
295
+ })
296
+
297
+ // ═══════════════════════════════════════════════════════════
298
+ // Job structure
299
+ // ═══════════════════════════════════════════════════════════
300
+
301
+ describe('/remind job structure', () => {
302
+ test('uses channel persona name when available', async () => {
303
+ const { scheduler, store } = makeScheduler()
304
+ await remindCommand(
305
+ 'in 10m Test',
306
+ makeCtx({
307
+ scheduler: scheduler as any,
308
+ channelPersona: { name: 'research' } as any,
309
+ }),
310
+ )
311
+
312
+ const job = (store.add as any).mock.calls[0][0] as Job
313
+ expect(job.channel).toBe('research')
314
+ })
315
+
316
+ test('defaults channel to "orbit" when no persona', async () => {
317
+ const { scheduler, store } = makeScheduler()
318
+ await remindCommand(
319
+ 'in 10m Test',
320
+ makeCtx({
321
+ scheduler: scheduler as any,
322
+ channelPersona: undefined,
323
+ }),
324
+ )
325
+
326
+ const job = (store.add as any).mock.calls[0][0] as Job
327
+ expect(job.channel).toBe('orbit')
328
+ })
329
+
330
+ test('job is marked oneShot', async () => {
331
+ const { scheduler, store } = makeScheduler()
332
+ await remindCommand('in 5m Check', makeCtx({ scheduler: scheduler as any }))
333
+
334
+ const job = (store.add as any).mock.calls[0][0] as Job
335
+ expect(job.oneShot).toBe(true)
336
+ })
337
+
338
+ test('job has valid UUID id', async () => {
339
+ const { scheduler, store } = makeScheduler()
340
+ await remindCommand('in 5m Check', makeCtx({ scheduler: scheduler as any }))
341
+
342
+ const job = (store.add as any).mock.calls[0][0] as Job
343
+ // UUID format: 8-4-4-4-12
344
+ expect(job.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
345
+ })
346
+
347
+ test('job schedule is ISO string', async () => {
348
+ const { scheduler, store } = makeScheduler()
349
+ await remindCommand('in 5m Check', makeCtx({ scheduler: scheduler as any }))
350
+
351
+ const job = (store.add as any).mock.calls[0][0] as Job
352
+ const at = (job.schedule as any).at
353
+ expect(typeof at).toBe('string')
354
+ // Should be a valid ISO date string
355
+ expect(new Date(at).toISOString()).toBe(at)
356
+ })
357
+
358
+ test('message text is trimmed', async () => {
359
+ const { scheduler, store } = makeScheduler()
360
+ await remindCommand('in 5m Some spaced message ', makeCtx({ scheduler: scheduler as any }))
361
+
362
+ const job = (store.add as any).mock.calls[0][0] as Job
363
+ const text = (job.payload as any).text
364
+ // Should be trimmed
365
+ expect(text).not.toMatch(/^\s/)
366
+ expect(text).not.toMatch(/\s$/)
367
+ })
368
+ })