@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,398 @@
1
+ /**
2
+ * # MoonLoader — Functional Specification
3
+ *
4
+ * Loads "moons" — persona configurations from the workspace filesystem.
5
+ * Each moon is a directory with SOUL.md, AGENTS.md (optional), IDENTITY.md (optional).
6
+ *
7
+ * ## Core concepts:
8
+ * - **Moon**: a persona (name, soul prompt, agents prompt, identity metadata)
9
+ * - **Root moon**: loaded from workspace root (SOUL.md at top level) — becomes default
10
+ * - **Auto-discovery**: scans `moons/` subdirectory for additional personas
11
+ * - **Config override**: `moonsConfig` map (name → path) takes precedence over auto-discovery
12
+ *
13
+ * ## Key behaviors:
14
+ * - `loadAll(workspace)` → loads root moon + discovers moons/ + applies config overrides
15
+ * - `resolve(channel?)` → returns the moon for a channel, or the default
16
+ * - `buildSystemPrompt(moon, channel?)` → assembles the full system prompt from parts
17
+ * - `findChannel(id)` → reverse lookup: Discord channel ID → ChannelPersona
18
+ * - Display name comes from IDENTITY.md `**Name:**` field, not directory name
19
+ */
20
+
21
+ import { beforeEach, describe, expect, it } from 'bun:test'
22
+ import { join, resolve } from 'node:path'
23
+ import { MoonLoader } from '../lib/moon-loader'
24
+ import { SkillLoader } from '../lib/skill-loader'
25
+ import type { ChannelPersona } from '../types/moon'
26
+
27
+ const FIXTURES = resolve(import.meta.dir, 'fixtures')
28
+
29
+ describe('MoonLoader', () => {
30
+ let loader: MoonLoader
31
+
32
+ beforeEach(() => {
33
+ loader = new MoonLoader()
34
+ })
35
+
36
+ describe('load (single moon, backward compat)', () => {
37
+ it('loads a moon from workspace root', async () => {
38
+ const moon = await loader.load(FIXTURES)
39
+
40
+ expect(moon.name).toBe('Deimos')
41
+ expect(moon.soul).toContain('I am Deimos')
42
+ expect(moon.agents).toContain('Be concise')
43
+ expect(moon.identity).toContain('🌑')
44
+ expect(moon.path).toBe(FIXTURES)
45
+ })
46
+
47
+ it('sets loaded moon as default', async () => {
48
+ await loader.load(FIXTURES)
49
+
50
+ const def = loader.getDefault()
51
+ expect(def).toBeDefined()
52
+ expect(def!.name).toBe('Deimos')
53
+ })
54
+ })
55
+
56
+ describe('loadAll', () => {
57
+ it('loads root moon as default', async () => {
58
+ await loader.loadAll(FIXTURES)
59
+
60
+ expect(loader.size).toBeGreaterThanOrEqual(1)
61
+ const def = loader.getDefault()
62
+ expect(def).toBeDefined()
63
+ expect(def!.name).toBe('Deimos')
64
+ })
65
+
66
+ it('auto-discovers moons in moons/ directory', async () => {
67
+ await loader.loadAll(FIXTURES)
68
+
69
+ const athena = loader.get('athena')
70
+ expect(athena).toBeDefined()
71
+ expect(athena!.name).toBe('Athena')
72
+ expect(athena!.soul).toContain('study tutor')
73
+ })
74
+
75
+ it('loads all moons (root + auto-discovered)', async () => {
76
+ await loader.loadAll(FIXTURES)
77
+ expect(loader.size).toBe(3) // root + athena + hermes
78
+ })
79
+
80
+ it('lists all moon names', async () => {
81
+ await loader.loadAll(FIXTURES)
82
+
83
+ const names = loader.list()
84
+ expect(names).toContain('Deimos')
85
+ expect(names).toContain('athena')
86
+ expect(names).toContain('hermes')
87
+ })
88
+
89
+ // ── Phase D: Agent Workspaces ──────────────────────────────
90
+
91
+ it('auto-discovers moon in moons/hermes/ when SOUL.md exists', async () => {
92
+ await loader.loadAll(FIXTURES)
93
+
94
+ const hermes = loader.get('hermes')
95
+ expect(hermes).toBeDefined()
96
+ expect(hermes!.soul).toContain('Hermes')
97
+ expect(hermes!.path).toBe(resolve(FIXTURES, 'moons/hermes'))
98
+ })
99
+
100
+ it('parses moon name from IDENTITY.md **Name:** line', async () => {
101
+ await loader.loadAll(FIXTURES)
102
+
103
+ // athena fixture has IDENTITY.md with "**Name:** Athena"
104
+ const athena = loader.get('athena')
105
+ expect(athena).toBeDefined()
106
+ expect(athena!.name).toBe('Athena') // parsed from IDENTITY.md, not directory name
107
+ })
108
+
109
+ it('falls back to directory name when IDENTITY.md is absent', async () => {
110
+ await loader.loadAll(FIXTURES)
111
+
112
+ // hermes fixture has SOUL.md but NO IDENTITY.md
113
+ const hermes = loader.get('hermes')
114
+ expect(hermes).toBeDefined()
115
+ expect(hermes!.name).toBe('hermes') // directory name, not parsed from IDENTITY.md
116
+ })
117
+
118
+ it('uses explicit moonsConfig paths', async () => {
119
+ await loader.loadAll(FIXTURES, {
120
+ 'custom-athena': 'moons/athena',
121
+ })
122
+
123
+ // auto-discovered 'athena' should be skipped (config takes precedence)
124
+ expect(loader.get('custom-athena')).toBeDefined()
125
+ expect(loader.get('custom-athena')!.soul).toContain('Athena')
126
+ })
127
+
128
+ it('passes model and context1m to configured moons', async () => {
129
+ await loader.loadAll(FIXTURES, {
130
+ 'athena-1m': { path: 'moons/athena', model: 'opus', context1m: true },
131
+ })
132
+
133
+ const moon = loader.get('athena-1m')
134
+ expect(moon).toBeDefined()
135
+ expect(moon!.model).toBe('opus')
136
+ expect(moon!.context1m).toBe(true)
137
+ })
138
+
139
+ it('moon without model config has undefined model', async () => {
140
+ await loader.loadAll(FIXTURES, {
141
+ 'plain-athena': 'moons/athena',
142
+ })
143
+
144
+ const moon = loader.get('plain-athena')
145
+ expect(moon).toBeDefined()
146
+ expect(moon!.model).toBeUndefined()
147
+ expect(moon!.context1m).toBeUndefined()
148
+ })
149
+
150
+ it('default moon receives overrides', async () => {
151
+ await loader.loadAll(FIXTURES, {}, { model: 'sonnet', context1m: true })
152
+
153
+ const defaultMoon = loader.getDefault()
154
+ expect(defaultMoon).toBeDefined()
155
+ expect(defaultMoon!.model).toBe('sonnet')
156
+ expect(defaultMoon!.context1m).toBe(true)
157
+ })
158
+
159
+ it('default moon without overrides has no model', async () => {
160
+ await loader.loadAll(FIXTURES)
161
+
162
+ const defaultMoon = loader.getDefault()
163
+ expect(defaultMoon).toBeDefined()
164
+ expect(defaultMoon!.model).toBeUndefined()
165
+ })
166
+
167
+ it('mixed string and object configs work together', async () => {
168
+ await loader.loadAll(FIXTURES, {
169
+ 'string-moon': 'moons/athena',
170
+ 'object-moon': { path: 'moons/athena', model: 'opus' },
171
+ })
172
+
173
+ const stringMoon = loader.get('string-moon')
174
+ const objectMoon = loader.get('object-moon')
175
+ expect(stringMoon!.model).toBeUndefined()
176
+ expect(objectMoon!.model).toBe('opus')
177
+ })
178
+ })
179
+
180
+ describe('resolve', () => {
181
+ beforeEach(async () => {
182
+ await loader.loadAll(FIXTURES)
183
+ })
184
+
185
+ it('returns default moon when no channel specified', () => {
186
+ const moon = loader.resolve()
187
+ expect(moon).toBeDefined()
188
+ expect(moon!.name).toBe('Deimos')
189
+ })
190
+
191
+ it('returns default moon when channel has no moon field', () => {
192
+ const channel: ChannelPersona = { id: '123', name: 'orbit', platform: 'discord' }
193
+ const moon = loader.resolve(channel)
194
+ expect(moon!.name).toBe('Deimos')
195
+ })
196
+
197
+ it('returns specific moon when channel references it', () => {
198
+ const channel: ChannelPersona = {
199
+ id: '456',
200
+ name: 'study',
201
+ platform: 'discord',
202
+ moon: 'athena',
203
+ }
204
+ const moon = loader.resolve(channel)
205
+ expect(moon).toBeDefined()
206
+ expect(moon!.name).toBe('Athena')
207
+ })
208
+
209
+ it('falls back to default when referenced moon not found', () => {
210
+ const channel: ChannelPersona = {
211
+ id: '789',
212
+ name: 'unknown',
213
+ platform: 'discord',
214
+ moon: 'nonexistent',
215
+ }
216
+ const moon = loader.resolve(channel)
217
+ expect(moon!.name).toBe('Deimos')
218
+ })
219
+ })
220
+
221
+ describe('buildSystemPrompt', () => {
222
+ beforeEach(async () => {
223
+ await loader.loadAll(FIXTURES)
224
+ })
225
+
226
+ it('includes moon name in header', () => {
227
+ const moon = loader.getDefault()!
228
+ const prompt = loader.buildSystemPrompt(moon)
229
+ expect(prompt).toContain('# Moon: Deimos')
230
+ })
231
+
232
+ it('includes SOUL.md content', () => {
233
+ const moon = loader.getDefault()!
234
+ const prompt = loader.buildSystemPrompt(moon)
235
+ expect(prompt).toContain('## SOUL.md')
236
+ expect(prompt).toContain('I am Deimos')
237
+ })
238
+
239
+ it('includes AGENTS.md content', () => {
240
+ const moon = loader.getDefault()!
241
+ const prompt = loader.buildSystemPrompt(moon)
242
+ expect(prompt).toContain('## AGENTS.md')
243
+ expect(prompt).toContain('Be concise')
244
+ })
245
+
246
+ it('includes workspace reference', () => {
247
+ const moon = loader.getDefault()!
248
+ const prompt = loader.buildSystemPrompt(moon)
249
+ expect(prompt).toContain('## Workspace')
250
+ expect(prompt).toContain('USER.md')
251
+ expect(prompt).toContain('MEMORY.md')
252
+ })
253
+
254
+ it('includes channel persona when provided', () => {
255
+ const moon = loader.getDefault()!
256
+ const channel: ChannelPersona = {
257
+ id: '123',
258
+ name: 'research',
259
+ platform: 'discord',
260
+ tone: 'Curious, thorough',
261
+ focus: 'Deep analysis',
262
+ instructions: 'No length limits when the topic deserves it.',
263
+ }
264
+ const prompt = loader.buildSystemPrompt(moon, channel)
265
+
266
+ expect(prompt).toContain('## Channel: #research')
267
+ expect(prompt).toContain('**Tone:** Curious, thorough')
268
+ expect(prompt).toContain('**Focus:** Deep analysis')
269
+ expect(prompt).toContain('No length limits')
270
+ })
271
+
272
+ it('omits channel section when no channel provided', () => {
273
+ const moon = loader.getDefault()!
274
+ const prompt = loader.buildSystemPrompt(moon)
275
+ expect(prompt).not.toContain('## Channel:')
276
+ })
277
+
278
+ it('builds different prompts for different moons', () => {
279
+ const deimos = loader.get('Deimos')!
280
+ const athena = loader.get('athena')!
281
+
282
+ const p1 = loader.buildSystemPrompt(deimos)
283
+ const p2 = loader.buildSystemPrompt(athena)
284
+
285
+ expect(p1).toContain('I am Deimos')
286
+ expect(p1).not.toContain('study tutor')
287
+
288
+ expect(p2).toContain('study tutor')
289
+ expect(p2).not.toContain('I am Deimos')
290
+ })
291
+ })
292
+
293
+ describe('findChannel', () => {
294
+ const channels: ChannelPersona[] = [
295
+ { id: '111', name: 'orbit', platform: 'discord', moon: 'lunar', tone: 'Direct' },
296
+ { id: '222', name: 'research', platform: 'discord', tone: 'Curious' },
297
+ { id: '333', name: 'athena', platform: 'telegram', moon: 'athena' },
298
+ ]
299
+
300
+ it('finds channel by Discord ID', () => {
301
+ const ch = loader.findChannel('222', channels)
302
+ expect(ch).toBeDefined()
303
+ expect(ch!.name).toBe('research')
304
+ })
305
+
306
+ it('returns undefined for unknown channel ID', () => {
307
+ const ch = loader.findChannel('999', channels)
308
+ expect(ch).toBeUndefined()
309
+ })
310
+ })
311
+
312
+ // ─── Per-channel skill filtering via setSkillLoader ──────────
313
+
314
+ describe('setSkillLoader + per-channel skill filtering', () => {
315
+ const skillFixtures = join(import.meta.dir, 'fixtures', 'skills')
316
+
317
+ it('setSkillLoader stores loader and sets skillManifest for backward compat', async () => {
318
+ await loader.loadAll(FIXTURES)
319
+ const skillLoader = new SkillLoader()
320
+ await skillLoader.scan([skillFixtures])
321
+
322
+ loader.setSkillLoader(skillLoader)
323
+
324
+ // buildSystemPrompt without channel should include all skills
325
+ const moon = loader.getDefault()!
326
+ const prompt = loader.buildSystemPrompt(moon)
327
+ expect(prompt).toContain('## Skills')
328
+ expect(prompt).toContain('brain')
329
+ expect(prompt).toContain('notion')
330
+ })
331
+
332
+ it('filters skills when channel has skills allowlist', async () => {
333
+ await loader.loadAll(FIXTURES)
334
+ const skillLoader = new SkillLoader()
335
+ await skillLoader.scan([skillFixtures])
336
+ loader.setSkillLoader(skillLoader)
337
+
338
+ const moon = loader.getDefault()!
339
+ const channel: ChannelPersona = {
340
+ id: '123',
341
+ name: 'dev',
342
+ platform: 'discord',
343
+ skills: ['brain'],
344
+ }
345
+
346
+ const prompt = loader.buildSystemPrompt(moon, channel)
347
+ expect(prompt).toContain('brain')
348
+ expect(prompt).not.toContain('notion')
349
+ })
350
+
351
+ it('includes all skills when channel has no skills array', async () => {
352
+ await loader.loadAll(FIXTURES)
353
+ const skillLoader = new SkillLoader()
354
+ await skillLoader.scan([skillFixtures])
355
+ loader.setSkillLoader(skillLoader)
356
+
357
+ const moon = loader.getDefault()!
358
+ const channel: ChannelPersona = {
359
+ id: '456',
360
+ name: 'orbit',
361
+ platform: 'discord',
362
+ }
363
+
364
+ const prompt = loader.buildSystemPrompt(moon, channel)
365
+ expect(prompt).toContain('## Skills')
366
+ expect(prompt).toContain('brain')
367
+ expect(prompt).toContain('notion')
368
+ })
369
+
370
+ it('omits skills section when channel has empty skills array', async () => {
371
+ await loader.loadAll(FIXTURES)
372
+ const skillLoader = new SkillLoader()
373
+ await skillLoader.scan([skillFixtures])
374
+ loader.setSkillLoader(skillLoader)
375
+
376
+ const moon = loader.getDefault()!
377
+ const channel: ChannelPersona = {
378
+ id: '789',
379
+ name: 'notifications',
380
+ platform: 'discord',
381
+ skills: [],
382
+ }
383
+
384
+ const prompt = loader.buildSystemPrompt(moon, channel)
385
+ expect(prompt).not.toContain('## Skills')
386
+ })
387
+
388
+ it('falls back to skillManifest string when no skillLoader is set', async () => {
389
+ await loader.loadAll(FIXTURES)
390
+ loader.setSkillManifest('## Skills\n\n- **custom** — Custom skill\n')
391
+
392
+ const moon = loader.getDefault()!
393
+ const prompt = loader.buildSystemPrompt(moon)
394
+ expect(prompt).toContain('## Skills')
395
+ expect(prompt).toContain('custom')
396
+ })
397
+ })
398
+ })
@@ -0,0 +1,85 @@
1
+ /**
2
+ * /ping Command — Test Suite
3
+ *
4
+ * Spec: quick latency check and daemon uptime.
5
+ *
6
+ * Key contracts:
7
+ * - Always returns "Pong!"
8
+ * - Shows latency when message timestamp is recent (< 60s)
9
+ * - Omits latency when timestamp is too old (>= 60s)
10
+ * - Shows uptime when daemonStartedAt > 0
11
+ * - Omits uptime when daemonStartedAt is 0
12
+ */
13
+ import { describe, expect, test } from 'bun:test'
14
+ import type { CommandContext } from '../lib/command-handler'
15
+ import { pingCommand } from '../lib/commands/ping'
16
+ import { SessionTracker } from '../lib/session-tracker'
17
+
18
+ function makeCtx(overrides?: Partial<CommandContext>): CommandContext {
19
+ return {
20
+ sessionKey: 'test:channel-1',
21
+ message: {
22
+ id: 'msg-1',
23
+ channelId: 'channel-1',
24
+ sender: { id: 'user-1', name: 'Test User', username: 'testuser' },
25
+ text: '/ping',
26
+ timestamp: new Date(),
27
+ },
28
+ tracker: new SessionTracker(),
29
+ daemonStartedAt: Date.now(),
30
+ agent: { id: 'claude', name: 'Claude Code (CLI)' },
31
+ clearSession: async () => ({ sessionCleared: true, hooksRan: false }),
32
+ ...overrides,
33
+ }
34
+ }
35
+
36
+ // ═══════════════════════════════════════════════════════════
37
+ // Basic response
38
+ // ═══════════════════════════════════════════════════════════
39
+
40
+ describe('/ping basic', () => {
41
+ test('always returns "Pong!"', () => {
42
+ const result = pingCommand('', makeCtx())
43
+ expect(result).toContain('**Pong!**')
44
+ })
45
+
46
+ test('shows latency in ms with a recent timestamp', () => {
47
+ const result = pingCommand('', makeCtx())
48
+ expect(result).toMatch(/Latency: \d+ms/)
49
+ })
50
+
51
+ test('omits latency when timestamp is too old (> 60s)', () => {
52
+ const ctx = makeCtx({
53
+ message: {
54
+ id: 'msg-1',
55
+ channelId: 'channel-1',
56
+ sender: { id: 'user-1', name: 'Test User' },
57
+ text: '/ping',
58
+ timestamp: new Date(Date.now() - 120_000), // 2 minutes ago
59
+ },
60
+ })
61
+ const result = pingCommand('', ctx)
62
+ expect(result).not.toContain('Latency:')
63
+ })
64
+ })
65
+
66
+ // ═══════════════════════════════════════════════════════════
67
+ // Uptime
68
+ // ═══════════════════════════════════════════════════════════
69
+
70
+ describe('/ping uptime', () => {
71
+ test('shows uptime when daemonStartedAt > 0', () => {
72
+ const ctx = makeCtx({
73
+ daemonStartedAt: Date.now() - 3_600_000, // 1 hour ago
74
+ })
75
+ const result = pingCommand('', ctx)
76
+ expect(result).toContain('Uptime:')
77
+ expect(result).toContain('1h')
78
+ })
79
+
80
+ test('omits uptime when daemonStartedAt is 0', () => {
81
+ const ctx = makeCtx({ daemonStartedAt: 0 })
82
+ const result = pingCommand('', ctx)
83
+ expect(result).not.toContain('Uptime:')
84
+ })
85
+ })