@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,101 @@
1
+ /**
2
+ * SessionStore Metadata — Test Suite
3
+ *
4
+ * Spec: updateMetadata and getMetadata methods on SessionStore.
5
+ *
6
+ * Key contracts:
7
+ * - getMetadata returns null when no session exists
8
+ * - updateMetadata creates a placeholder session if none exists
9
+ * - getMetadata returns parsed JSON after updateMetadata
10
+ * - updateMetadata replaces metadata (merging is done by caller)
11
+ * - getMetadata returns null for invalid JSON
12
+ * - updateMetadata on existing session preserves agent_session_id
13
+ */
14
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
15
+ import { mkdtempSync, rmSync } from 'node:fs'
16
+ import { tmpdir } from 'node:os'
17
+ import { join } from 'node:path'
18
+ import { SessionStore } from '../lib/session'
19
+
20
+ let store: SessionStore
21
+ let tmpDir: string
22
+
23
+ beforeEach(() => {
24
+ tmpDir = mkdtempSync(join(tmpdir(), 'lunar-test-'))
25
+ store = new SessionStore(join(tmpDir, 'test.db'))
26
+ })
27
+
28
+ afterEach(() => {
29
+ store.close()
30
+ rmSync(tmpDir, { recursive: true, force: true })
31
+ })
32
+
33
+ // ═══════════════════════════════════════════════════════════
34
+ // getMetadata
35
+ // ═══════════════════════════════════════════════════════════
36
+
37
+ describe('getMetadata', () => {
38
+ test('returns null when no session exists', () => {
39
+ const result = store.getMetadata('nonexistent:key')
40
+ expect(result).toBeNull()
41
+ })
42
+
43
+ test('returns null for session without metadata', () => {
44
+ store.set('test:key', 'session-1')
45
+ const result = store.getMetadata('test:key')
46
+ expect(result).toBeNull()
47
+ })
48
+
49
+ test('returns parsed JSON after updateMetadata', () => {
50
+ store.updateMetadata('test:key', '{"a":1}')
51
+ const result = store.getMetadata('test:key')
52
+ expect(result).toEqual({ a: 1 })
53
+ })
54
+
55
+ test('returns null for invalid JSON', () => {
56
+ store.updateMetadata('test:key', 'not-json{')
57
+ const result = store.getMetadata('test:key')
58
+ expect(result).toBeNull()
59
+ })
60
+ })
61
+
62
+ // ═══════════════════════════════════════════════════════════
63
+ // updateMetadata
64
+ // ═══════════════════════════════════════════════════════════
65
+
66
+ describe('updateMetadata', () => {
67
+ test('creates placeholder session if none exists', () => {
68
+ store.updateMetadata('new:key', '{"foo":"bar"}')
69
+ const session = store.get('new:key')
70
+ expect(session).not.toBeNull()
71
+ expect(session!.key).toBe('new:key')
72
+ expect(session!.metadata).toBe('{"foo":"bar"}')
73
+ })
74
+
75
+ test('replaces metadata on subsequent calls', () => {
76
+ store.updateMetadata('test:key', '{"a":1}')
77
+ store.updateMetadata('test:key', '{"b":2}')
78
+ const result = store.getMetadata('test:key')
79
+ expect(result).toEqual({ b: 2 })
80
+ })
81
+
82
+ test('preserves agent_session_id on existing session', () => {
83
+ store.set('test:key', 'my-session-id')
84
+ store.updateMetadata('test:key', '{"override":"opus"}')
85
+ const session = store.get('test:key')
86
+ expect(session).not.toBeNull()
87
+ // updateMetadata uses ON CONFLICT DO UPDATE SET metadata only,
88
+ // so agent_session_id should remain from the original INSERT
89
+ // Actually, looking at the SQL: it does INSERT with '' as agent_session_id
90
+ // and ON CONFLICT updates only metadata + last_active.
91
+ // So existing agent_session_id is preserved.
92
+ expect(session!.agentSessionId).toBe('my-session-id')
93
+ })
94
+
95
+ test('placeholder session has empty agent_session_id', () => {
96
+ store.updateMetadata('new:key', '{"x":1}')
97
+ const session = store.get('new:key')
98
+ expect(session).not.toBeNull()
99
+ expect(session!.agentSessionId).toBe('')
100
+ })
101
+ })
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Session Tracker — Test Suite
3
+ *
4
+ * Spec: accumulates token usage per session from AgentEvent 'done' events.
5
+ * Provides session status with context %, cost estimates, and formatted output.
6
+ *
7
+ * Key contracts:
8
+ * - Accumulates tokens across multiple queries
9
+ * - Tracks cache tokens separately
10
+ * - Calculates context usage from last query's input tokens
11
+ * - Provides cost estimates via model-catalog pricing
12
+ * - Formatting helpers produce human-readable output
13
+ */
14
+ import { describe, expect, test } from 'bun:test'
15
+ import {
16
+ buildProgressBar,
17
+ formatDuration,
18
+ formatTokens,
19
+ SessionTracker,
20
+ } from '../lib/session-tracker'
21
+
22
+ // ═══════════════════════════════════════════════════════════
23
+ // Token accumulation
24
+ // ═══════════════════════════════════════════════════════════
25
+
26
+ describe('SessionTracker accumulation', () => {
27
+ test('first query initializes session', () => {
28
+ const tracker = new SessionTracker()
29
+ tracker.recordQuery('session-1', {
30
+ inputTokens: 5000,
31
+ outputTokens: 1000,
32
+ model: 'claude-sonnet-4-6',
33
+ durationMs: 2000,
34
+ })
35
+
36
+ const usage = tracker.getUsage('session-1')
37
+ expect(usage).toBeDefined()
38
+ expect(usage!.inputTokens).toBe(5000)
39
+ expect(usage!.outputTokens).toBe(1000)
40
+ expect(usage!.queryCount).toBe(1)
41
+ expect(usage!.model).toBe('claude-sonnet-4-6')
42
+ })
43
+
44
+ test('multiple queries accumulate tokens', () => {
45
+ const tracker = new SessionTracker()
46
+
47
+ tracker.recordQuery('s1', {
48
+ inputTokens: 5000,
49
+ outputTokens: 1000,
50
+ model: 'claude-sonnet-4-6',
51
+ durationMs: 1000,
52
+ })
53
+ tracker.recordQuery('s1', {
54
+ inputTokens: 10000,
55
+ outputTokens: 2000,
56
+ model: 'claude-sonnet-4-6',
57
+ durationMs: 1500,
58
+ })
59
+
60
+ const usage = tracker.getUsage('s1')!
61
+ expect(usage.inputTokens).toBe(15000)
62
+ expect(usage.outputTokens).toBe(3000)
63
+ expect(usage.queryCount).toBe(2)
64
+ expect(usage.totalApiDurationMs).toBe(2500)
65
+ })
66
+
67
+ test('cache tokens tracked separately', () => {
68
+ const tracker = new SessionTracker()
69
+ tracker.recordQuery('s1', {
70
+ inputTokens: 10000,
71
+ outputTokens: 2000,
72
+ cacheReadTokens: 5000,
73
+ cacheWriteTokens: 3000,
74
+ model: 'claude-sonnet-4-6',
75
+ durationMs: 1000,
76
+ })
77
+
78
+ const usage = tracker.getUsage('s1')!
79
+ expect(usage.cacheReadTokens).toBe(5000)
80
+ expect(usage.cacheWriteTokens).toBe(3000)
81
+ })
82
+
83
+ test('lastContextTokens updated per query', () => {
84
+ const tracker = new SessionTracker()
85
+
86
+ tracker.recordQuery('s1', {
87
+ inputTokens: 5000,
88
+ outputTokens: 1000,
89
+ contextTokens: 50_000,
90
+ model: 'claude-sonnet-4-6',
91
+ durationMs: 1000,
92
+ })
93
+ expect(tracker.getUsage('s1')!.lastContextTokens).toBe(50_000)
94
+
95
+ tracker.recordQuery('s1', {
96
+ inputTokens: 10000,
97
+ outputTokens: 2000,
98
+ contextTokens: 80_000,
99
+ model: 'claude-sonnet-4-6',
100
+ durationMs: 1500,
101
+ })
102
+ expect(tracker.getUsage('s1')!.lastContextTokens).toBe(80_000)
103
+ })
104
+
105
+ test('lastContextTokens falls back to input + cache when contextTokens not provided', () => {
106
+ const tracker = new SessionTracker()
107
+
108
+ tracker.recordQuery('s1', {
109
+ inputTokens: 5000,
110
+ outputTokens: 1000,
111
+ cacheReadTokens: 40_000,
112
+ cacheWriteTokens: 10_000,
113
+ model: 'claude-sonnet-4-6',
114
+ durationMs: 1000,
115
+ })
116
+ // 5000 + 40000 + 10000 = 55000
117
+ expect(tracker.getUsage('s1')!.lastContextTokens).toBe(55_000)
118
+ })
119
+
120
+ test('model updated to latest', () => {
121
+ const tracker = new SessionTracker()
122
+
123
+ tracker.recordQuery('s1', {
124
+ inputTokens: 5000,
125
+ outputTokens: 1000,
126
+ model: 'claude-sonnet-4-5',
127
+ durationMs: 1000,
128
+ })
129
+ tracker.recordQuery('s1', {
130
+ inputTokens: 10000,
131
+ outputTokens: 2000,
132
+ model: 'claude-sonnet-4-6',
133
+ durationMs: 1500,
134
+ })
135
+
136
+ expect(tracker.getUsage('s1')!.model).toBe('claude-sonnet-4-6')
137
+ })
138
+
139
+ test('message count tracked separately from queries', () => {
140
+ const tracker = new SessionTracker()
141
+ tracker.recordQuery('s1', {
142
+ inputTokens: 5000,
143
+ outputTokens: 1000,
144
+ model: 'claude-sonnet-4-6',
145
+ durationMs: 1000,
146
+ })
147
+ tracker.recordMessage('s1')
148
+ tracker.recordMessage('s1')
149
+
150
+ expect(tracker.getUsage('s1')!.messageCount).toBe(2)
151
+ })
152
+
153
+ test('recordMessage on unknown session is a no-op', () => {
154
+ const tracker = new SessionTracker()
155
+ tracker.recordMessage('nonexistent')
156
+ expect(tracker.getUsage('nonexistent')).toBeUndefined()
157
+ })
158
+ })
159
+
160
+ // ═══════════════════════════════════════════════════════════
161
+ // Session status
162
+ // ═══════════════════════════════════════════════════════════
163
+
164
+ describe('SessionTracker.getStatus', () => {
165
+ test('returns null for unknown session', () => {
166
+ const tracker = new SessionTracker()
167
+ expect(tracker.getStatus('unknown')).toBeNull()
168
+ })
169
+
170
+ test('calculates context percentage from contextTokens', () => {
171
+ const tracker = new SessionTracker()
172
+ tracker.recordQuery('s1', {
173
+ inputTokens: 10_000,
174
+ outputTokens: 5000,
175
+ contextTokens: 100_000, // 50% of 200K
176
+ model: 'claude-sonnet-4-6',
177
+ durationMs: 3000,
178
+ })
179
+
180
+ const status = tracker.getStatus('s1')!
181
+ expect(status.contextWindowSize).toBe(200_000)
182
+ expect(status.contextUsedPercent).toBeCloseTo(50, 0)
183
+ })
184
+
185
+ test('context with no contextTokens falls back to input + cache', () => {
186
+ const tracker = new SessionTracker()
187
+ tracker.recordQuery('s1', {
188
+ inputTokens: 50_000,
189
+ outputTokens: 5000,
190
+ cacheReadTokens: 50_000,
191
+ model: 'claude-sonnet-4-6',
192
+ durationMs: 3000,
193
+ })
194
+
195
+ const status = tracker.getStatus('s1')!
196
+ // 50K + 50K = 100K = 50%
197
+ expect(status.contextUsedPercent).toBeCloseTo(50, 0)
198
+ })
199
+
200
+ test('context capped at 100%', () => {
201
+ const tracker = new SessionTracker()
202
+ tracker.recordQuery('s1', {
203
+ inputTokens: 10_000,
204
+ outputTokens: 5000,
205
+ contextTokens: 250_000, // over 200K limit
206
+ model: 'claude-sonnet-4-6',
207
+ durationMs: 3000,
208
+ })
209
+
210
+ const status = tracker.getStatus('s1')!
211
+ expect(status.contextUsedPercent).toBe(100)
212
+ })
213
+
214
+ test('provides cost estimate', () => {
215
+ const tracker = new SessionTracker()
216
+ tracker.recordQuery('s1', {
217
+ inputTokens: 100_000,
218
+ outputTokens: 10_000,
219
+ model: 'claude-sonnet-4-6',
220
+ durationMs: 3000,
221
+ })
222
+
223
+ const status = tracker.getStatus('s1')!
224
+ expect(status.estimatedCostUsd).toBeGreaterThan(0)
225
+ })
226
+
227
+ test('provides model display name', () => {
228
+ const tracker = new SessionTracker()
229
+ tracker.recordQuery('s1', {
230
+ inputTokens: 5000,
231
+ outputTokens: 1000,
232
+ model: 'claude-opus-4-6',
233
+ durationMs: 1000,
234
+ })
235
+
236
+ const status = tracker.getStatus('s1')!
237
+ expect(status.modelDisplayName).toBe('Claude Opus 4.6')
238
+ })
239
+
240
+ test('session age increases over time', () => {
241
+ const tracker = new SessionTracker()
242
+ tracker.recordQuery('s1', {
243
+ inputTokens: 5000,
244
+ outputTokens: 1000,
245
+ model: 'claude-sonnet-4-6',
246
+ durationMs: 1000,
247
+ })
248
+
249
+ const status = tracker.getStatus('s1')!
250
+ expect(status.sessionAgeMs).toBeGreaterThanOrEqual(0)
251
+ expect(status.sessionAge).toBeTruthy()
252
+ })
253
+ })
254
+
255
+ // ═══════════════════════════════════════════════════════════
256
+ // Session lifecycle
257
+ // ═══════════════════════════════════════════════════════════
258
+
259
+ describe('SessionTracker lifecycle', () => {
260
+ test('listSessions returns tracked sessions', () => {
261
+ const tracker = new SessionTracker()
262
+ tracker.recordQuery('s1', {
263
+ inputTokens: 100,
264
+ outputTokens: 50,
265
+ durationMs: 100,
266
+ model: 'test',
267
+ })
268
+ tracker.recordQuery('s2', {
269
+ inputTokens: 200,
270
+ outputTokens: 100,
271
+ durationMs: 200,
272
+ model: 'test',
273
+ })
274
+
275
+ const sessions = tracker.listSessions()
276
+ expect(sessions).toContain('s1')
277
+ expect(sessions).toContain('s2')
278
+ })
279
+
280
+ test('clear removes a specific session', () => {
281
+ const tracker = new SessionTracker()
282
+ tracker.recordQuery('s1', {
283
+ inputTokens: 100,
284
+ outputTokens: 50,
285
+ durationMs: 100,
286
+ model: 'test',
287
+ })
288
+ tracker.recordQuery('s2', {
289
+ inputTokens: 200,
290
+ outputTokens: 100,
291
+ durationMs: 200,
292
+ model: 'test',
293
+ })
294
+
295
+ tracker.clear('s1')
296
+ expect(tracker.getUsage('s1')).toBeUndefined()
297
+ expect(tracker.getUsage('s2')).toBeDefined()
298
+ })
299
+
300
+ test('clearAll removes everything', () => {
301
+ const tracker = new SessionTracker()
302
+ tracker.recordQuery('s1', {
303
+ inputTokens: 100,
304
+ outputTokens: 50,
305
+ durationMs: 100,
306
+ model: 'test',
307
+ })
308
+ tracker.recordQuery('s2', {
309
+ inputTokens: 200,
310
+ outputTokens: 100,
311
+ durationMs: 200,
312
+ model: 'test',
313
+ })
314
+
315
+ tracker.clearAll()
316
+ expect(tracker.listSessions()).toHaveLength(0)
317
+ })
318
+ })
319
+
320
+ // ═══════════════════════════════════════════════════════════
321
+ // Formatting helpers
322
+ // ═══════════════════════════════════════════════════════════
323
+
324
+ describe('formatDuration', () => {
325
+ test('seconds only', () => {
326
+ expect(formatDuration(45_000)).toBe('45s')
327
+ })
328
+
329
+ test('minutes and seconds', () => {
330
+ expect(formatDuration(125_000)).toBe('2m 5s')
331
+ })
332
+
333
+ test('minutes only (exact)', () => {
334
+ expect(formatDuration(300_000)).toBe('5m')
335
+ })
336
+
337
+ test('hours and minutes', () => {
338
+ expect(formatDuration(7_380_000)).toBe('2h 3m')
339
+ })
340
+
341
+ test('hours only (exact)', () => {
342
+ expect(formatDuration(3_600_000)).toBe('1h')
343
+ })
344
+
345
+ test('zero', () => {
346
+ expect(formatDuration(0)).toBe('0s')
347
+ })
348
+ })
349
+
350
+ describe('formatTokens', () => {
351
+ test('small numbers as-is', () => {
352
+ expect(formatTokens(850)).toBe('850')
353
+ })
354
+
355
+ test('thousands with decimal', () => {
356
+ expect(formatTokens(1_234)).toBe('1.2K')
357
+ })
358
+
359
+ test('tens of thousands rounded', () => {
360
+ expect(formatTokens(12_450)).toBe('12K')
361
+ })
362
+
363
+ test('millions with decimal', () => {
364
+ expect(formatTokens(1_500_000)).toBe('1.5M')
365
+ })
366
+ })
367
+
368
+ describe('buildProgressBar', () => {
369
+ test('0% is all empty', () => {
370
+ expect(buildProgressBar(0, 10)).toBe('░░░░░░░░░░')
371
+ })
372
+
373
+ test('100% is all filled', () => {
374
+ expect(buildProgressBar(100, 10)).toBe('██████████')
375
+ })
376
+
377
+ test('50% is half filled', () => {
378
+ expect(buildProgressBar(50, 10)).toBe('█████░░░░░')
379
+ })
380
+
381
+ test('clamped to 0-100', () => {
382
+ expect(buildProgressBar(-10, 10)).toBe('░░░░░░░░░░')
383
+ expect(buildProgressBar(150, 10)).toBe('██████████')
384
+ })
385
+
386
+ test('custom width', () => {
387
+ expect(buildProgressBar(50, 20)).toBe('██████████░░░░░░░░░░')
388
+ })
389
+ })