@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,215 @@
1
+ /**
2
+ * Model Catalog — Test Suite
3
+ *
4
+ * Spec: provides model metadata (context window, pricing, display names)
5
+ * for any model ID, with graceful fallback for unknown models.
6
+ *
7
+ * Key contracts:
8
+ * - Exact match by model ID
9
+ * - Normalized match (strip date suffix)
10
+ * - Prefix match (partial model names)
11
+ * - Unknown models get sensible defaults
12
+ * - Cost calculation is accurate per pricing
13
+ */
14
+ import { describe, expect, test } from 'bun:test'
15
+ import {
16
+ calculateCost,
17
+ getContextWindowSize,
18
+ getModelDisplayName,
19
+ getModelInfo,
20
+ listModels,
21
+ } from '../lib/model-catalog'
22
+
23
+ // ═══════════════════════════════════════════════════════════
24
+ // Model lookup
25
+ // ═══════════════════════════════════════════════════════════
26
+
27
+ describe('getModelInfo', () => {
28
+ test('exact match returns correct info', () => {
29
+ const info = getModelInfo('claude-sonnet-4-6')
30
+ expect(info.displayName).toBe('Claude Sonnet 4.6')
31
+ expect(info.contextWindow).toBe(200_000)
32
+ expect(info.inputPricePerM).toBe(3.0)
33
+ })
34
+
35
+ test('date suffix is stripped for matching', () => {
36
+ const info = getModelInfo('claude-sonnet-4-5-20250514')
37
+ expect(info.displayName).toBe('Claude Sonnet 4.5')
38
+ expect(info.contextWindow).toBe(200_000)
39
+ })
40
+
41
+ test('another date suffix variant', () => {
42
+ const info = getModelInfo('claude-opus-4-6-20260301')
43
+ expect(info.displayName).toBe('Claude Opus 4.6')
44
+ expect(info.maxOutput).toBe(32_768)
45
+ })
46
+
47
+ test('prefix match for extended model IDs', () => {
48
+ const info = getModelInfo('claude-haiku-4-5-something-extra')
49
+ // Should match 'claude-haiku-4-5' prefix
50
+ expect(info.displayName).toBe('Claude Haiku 4.5')
51
+ })
52
+
53
+ test('unknown model returns defaults with model ID as name', () => {
54
+ const info = getModelInfo('gpt-5-turbo')
55
+ expect(info.displayName).toBe('gpt-5-turbo')
56
+ expect(info.contextWindow).toBe(200_000) // safe default
57
+ })
58
+
59
+ test('opus models have higher pricing', () => {
60
+ const sonnet = getModelInfo('claude-sonnet-4-6')
61
+ const opus = getModelInfo('claude-opus-4-6')
62
+ expect(opus.inputPricePerM).toBeGreaterThan(sonnet.inputPricePerM)
63
+ expect(opus.outputPricePerM).toBeGreaterThan(sonnet.outputPricePerM)
64
+ })
65
+
66
+ test('haiku models have lower pricing', () => {
67
+ const sonnet = getModelInfo('claude-sonnet-4-6')
68
+ const haiku = getModelInfo('claude-haiku-4-5')
69
+ expect(haiku.inputPricePerM).toBeLessThan(sonnet.inputPricePerM)
70
+ expect(haiku.outputPricePerM).toBeLessThan(sonnet.outputPricePerM)
71
+ })
72
+ })
73
+
74
+ // ═══════════════════════════════════════════════════════════
75
+ // Convenience accessors
76
+ // ═══════════════════════════════════════════════════════════
77
+
78
+ describe('getModelDisplayName', () => {
79
+ test('returns human-readable name', () => {
80
+ expect(getModelDisplayName('claude-sonnet-4-6')).toBe('Claude Sonnet 4.6')
81
+ })
82
+
83
+ test('returns model ID for unknown models', () => {
84
+ expect(getModelDisplayName('mystery-model')).toBe('mystery-model')
85
+ })
86
+ })
87
+
88
+ describe('getContextWindowSize', () => {
89
+ test('returns context window for known model', () => {
90
+ expect(getContextWindowSize('claude-opus-4-6')).toBe(200_000)
91
+ })
92
+
93
+ test('returns default 200K for unknown model', () => {
94
+ expect(getContextWindowSize('unknown-model')).toBe(200_000)
95
+ })
96
+ })
97
+
98
+ // ═══════════════════════════════════════════════════════════
99
+ // Cost calculation
100
+ // ═══════════════════════════════════════════════════════════
101
+
102
+ describe('calculateCost', () => {
103
+ test('basic input/output cost for Sonnet', () => {
104
+ // Sonnet: $3/M input, $15/M output
105
+ const cost = calculateCost('claude-sonnet-4-6', {
106
+ inputTokens: 1_000_000,
107
+ outputTokens: 1_000_000,
108
+ })
109
+ expect(cost).toBeCloseTo(18.0, 2) // $3 + $15
110
+ })
111
+
112
+ test('cost with cache tokens', () => {
113
+ // Cache tokens reduce the regular input cost
114
+ // 100K total input, 50K cache read, 20K cache write
115
+ // Regular input = 100K - 50K - 20K = 30K
116
+ const cost = calculateCost('claude-sonnet-4-6', {
117
+ inputTokens: 100_000,
118
+ outputTokens: 50_000,
119
+ cacheReadTokens: 50_000,
120
+ cacheWriteTokens: 20_000,
121
+ })
122
+
123
+ // Regular: 30K * $3/M = $0.09
124
+ // Output: 50K * $15/M = $0.75
125
+ // Cache read: 50K * $0.3/M = $0.015
126
+ // Cache write: 20K * $3.75/M = $0.075
127
+ const expected = 0.09 + 0.75 + 0.015 + 0.075
128
+ expect(cost).toBeCloseTo(expected, 4)
129
+ })
130
+
131
+ test('zero tokens = zero cost', () => {
132
+ const cost = calculateCost('claude-sonnet-4-6', {
133
+ inputTokens: 0,
134
+ outputTokens: 0,
135
+ })
136
+ expect(cost).toBe(0)
137
+ })
138
+
139
+ test('opus is significantly more expensive', () => {
140
+ const tokens = { inputTokens: 100_000, outputTokens: 10_000 }
141
+ const sonnetCost = calculateCost('claude-sonnet-4-6', tokens)
142
+ const opusCost = calculateCost('claude-opus-4-6', tokens)
143
+ expect(opusCost).toBeGreaterThan(sonnetCost * 3)
144
+ })
145
+ })
146
+
147
+ // ═══════════════════════════════════════════════════════════
148
+ // Catalog enumeration
149
+ // ═══════════════════════════════════════════════════════════
150
+
151
+ describe('listModels', () => {
152
+ test('returns all known models', () => {
153
+ const models = listModels()
154
+ expect(models.length).toBeGreaterThanOrEqual(7)
155
+ expect(models).toContain('claude-sonnet-4-6')
156
+ expect(models).toContain('claude-opus-4-6')
157
+ expect(models).toContain('claude-haiku-4-5')
158
+ })
159
+ })
160
+
161
+ // ═══════════════════════════════════════════════════════════
162
+ // Extended context [1m] variants
163
+ // ═══════════════════════════════════════════════════════════
164
+
165
+ describe('1M context variants', () => {
166
+ test('exact match for opus [1m] variant', () => {
167
+ const info = getModelInfo('claude-opus-4-6[1m]')
168
+ expect(info.displayName).toBe('Claude Opus 4.6 (1M)')
169
+ expect(info.contextWindow).toBe(1_000_000)
170
+ expect(info.maxOutput).toBe(128_000)
171
+ })
172
+
173
+ test('exact match for sonnet [1m] variant', () => {
174
+ const info = getModelInfo('claude-sonnet-4-6[1m]')
175
+ expect(info.displayName).toBe('Claude Sonnet 4.6 (1M)')
176
+ expect(info.contextWindow).toBe(1_000_000)
177
+ })
178
+
179
+ test('[1m] variant with date suffix is normalized correctly', () => {
180
+ const info = getModelInfo('claude-opus-4-6-20260301[1m]')
181
+ expect(info.displayName).toBe('Claude Opus 4.6 (1M)')
182
+ expect(info.contextWindow).toBe(1_000_000)
183
+ })
184
+
185
+ test('[1m] variant has premium pricing (2x input)', () => {
186
+ const standard = getModelInfo('claude-opus-4-6')
187
+ const extended = getModelInfo('claude-opus-4-6[1m]')
188
+ expect(extended.inputPricePerM).toBe(standard.inputPricePerM * 2)
189
+ })
190
+
191
+ test('[1m] variant has premium pricing (1.5x output)', () => {
192
+ const standard = getModelInfo('claude-opus-4-6')
193
+ const extended = getModelInfo('claude-opus-4-6[1m]')
194
+ expect(extended.outputPricePerM).toBe(standard.outputPricePerM * 1.5)
195
+ })
196
+
197
+ test('cost calculation uses [1m] premium pricing', () => {
198
+ const tokens = { inputTokens: 500_000, outputTokens: 10_000 }
199
+ const standardCost = calculateCost('claude-opus-4-6', tokens)
200
+ const extendedCost = calculateCost('claude-opus-4-6[1m]', tokens)
201
+ expect(extendedCost).toBeGreaterThan(standardCost)
202
+ })
203
+
204
+ test('[1m] variants are listed in catalog', () => {
205
+ const models = listModels()
206
+ expect(models).toContain('claude-opus-4-6[1m]')
207
+ expect(models).toContain('claude-sonnet-4-6[1m]')
208
+ })
209
+
210
+ test('regular date suffix normalization still works', () => {
211
+ // Ensure the [1m] changes didn't break normal normalization
212
+ const info = getModelInfo('claude-sonnet-4-5-20250514')
213
+ expect(info.displayName).toBe('Claude Sonnet 4.5')
214
+ })
215
+ })
@@ -0,0 +1,185 @@
1
+ /**
2
+ * /model Command — Test Suite
3
+ *
4
+ * Spec: view or change the active model at runtime.
5
+ *
6
+ * Key contracts:
7
+ * - No args: shows current model + available aliases
8
+ * - With alias (opus, sonnet, haiku, sonnet-4.5): sets model override
9
+ * - With full model ID: sets model override directly
10
+ * - /model clear or /model reset: removes override
11
+ * - When updateSessionMeta is not available: returns error
12
+ */
13
+ import { describe, expect, test } from 'bun:test'
14
+ import type { CommandContext } from '../lib/command-handler'
15
+ import { modelCommand } from '../lib/commands/model'
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' },
25
+ text: '/model',
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
+ // No args — show current model
38
+ // ═══════════════════════════════════════════════════════════
39
+
40
+ describe('/model no args', () => {
41
+ test('shows "Current Model" header', () => {
42
+ const result = modelCommand('', makeCtx())
43
+ expect(result).toContain('Current Model')
44
+ })
45
+
46
+ test('shows available aliases (opus, sonnet, haiku)', () => {
47
+ const result = modelCommand('', makeCtx())
48
+ expect(result).toContain('Available aliases')
49
+ expect(result).toContain('opus')
50
+ expect(result).toContain('sonnet')
51
+ expect(result).toContain('haiku')
52
+ })
53
+
54
+ test('shows current model from channelPersona.model', () => {
55
+ const ctx = makeCtx({
56
+ channelPersona: { model: 'claude-opus-4-6' } as any,
57
+ })
58
+ const result = modelCommand('', ctx)
59
+ expect(result).toContain('claude-opus-4-6')
60
+ expect(result).toContain('Claude Opus 4.6')
61
+ })
62
+
63
+ test('falls back to moon.model when no channelPersona.model', () => {
64
+ const ctx = makeCtx({
65
+ moon: { model: 'claude-haiku-4-5', name: 'TestMoon', systemPrompt: '', files: {} } as any,
66
+ })
67
+ const result = modelCommand('', ctx)
68
+ expect(result).toContain('claude-haiku-4-5')
69
+ })
70
+
71
+ test('shows "default" when neither channelPersona nor moon has model', () => {
72
+ const result = modelCommand('', makeCtx())
73
+ expect(result).toContain('default')
74
+ })
75
+
76
+ test('shows override hint when session has modelOverride', () => {
77
+ const ctx = makeCtx({
78
+ getSessionMeta: () => ({ modelOverride: 'claude-opus-4-6' }),
79
+ })
80
+ const result = modelCommand('', ctx)
81
+ expect(result).toContain('Override active')
82
+ expect(result).toContain('/model clear')
83
+ })
84
+ })
85
+
86
+ // ═══════════════════════════════════════════════════════════
87
+ // Set model with alias
88
+ // ═══════════════════════════════════════════════════════════
89
+
90
+ describe('/model set alias', () => {
91
+ test('/model opus sets claude-opus-4-6', () => {
92
+ let captured: unknown = null
93
+ const ctx = makeCtx({
94
+ updateSessionMeta: (u) => { captured = u },
95
+ })
96
+ modelCommand('opus', ctx)
97
+ expect(captured).toEqual({ modelOverride: 'claude-opus-4-6' })
98
+ })
99
+
100
+ test('/model sonnet sets claude-sonnet-4-6', () => {
101
+ let captured: unknown = null
102
+ const ctx = makeCtx({
103
+ updateSessionMeta: (u) => { captured = u },
104
+ })
105
+ modelCommand('sonnet', ctx)
106
+ expect(captured).toEqual({ modelOverride: 'claude-sonnet-4-6' })
107
+ })
108
+
109
+ test('/model haiku sets claude-haiku-4-5', () => {
110
+ let captured: unknown = null
111
+ const ctx = makeCtx({
112
+ updateSessionMeta: (u) => { captured = u },
113
+ })
114
+ modelCommand('haiku', ctx)
115
+ expect(captured).toEqual({ modelOverride: 'claude-haiku-4-5' })
116
+ })
117
+
118
+ test('/model with full ID sets it directly', () => {
119
+ let captured: unknown = null
120
+ const ctx = makeCtx({
121
+ updateSessionMeta: (u) => { captured = u },
122
+ })
123
+ modelCommand('claude-opus-4-6', ctx)
124
+ expect(captured).toEqual({ modelOverride: 'claude-opus-4-6' })
125
+ })
126
+
127
+ test('shows model display name in response after setting', () => {
128
+ const ctx = makeCtx({
129
+ updateSessionMeta: () => {},
130
+ })
131
+ const result = modelCommand('opus', ctx)
132
+ expect(result).toContain('Claude Opus 4.6')
133
+ expect(result).toContain('claude-opus-4-6')
134
+ })
135
+ })
136
+
137
+ // ═══════════════════════════════════════════════════════════
138
+ // Clear / Reset
139
+ // ═══════════════════════════════════════════════════════════
140
+
141
+ describe('/model clear and reset', () => {
142
+ test('/model clear calls updateSessionMeta with modelOverride null', () => {
143
+ let captured: unknown = null
144
+ const ctx = makeCtx({
145
+ updateSessionMeta: (u) => { captured = u },
146
+ })
147
+ modelCommand('clear', ctx)
148
+ expect(captured).toEqual({ modelOverride: null })
149
+ })
150
+
151
+ test('/model reset also clears', () => {
152
+ let captured: unknown = null
153
+ const ctx = makeCtx({
154
+ updateSessionMeta: (u) => { captured = u },
155
+ })
156
+ modelCommand('reset', ctx)
157
+ expect(captured).toEqual({ modelOverride: null })
158
+ })
159
+
160
+ test('/model clear shows reverted model', () => {
161
+ const ctx = makeCtx({
162
+ updateSessionMeta: () => {},
163
+ channelPersona: { model: 'claude-sonnet-4-6' } as any,
164
+ })
165
+ const result = modelCommand('clear', ctx)
166
+ expect(result).toContain('override cleared')
167
+ expect(result).toContain('claude-sonnet-4-6')
168
+ })
169
+ })
170
+
171
+ // ═══════════════════════════════════════════════════════════
172
+ // Error: updateSessionMeta not available
173
+ // ═══════════════════════════════════════════════════════════
174
+
175
+ describe('/model without updateSessionMeta', () => {
176
+ test('returns "not available" when setting model without updateSessionMeta', () => {
177
+ const result = modelCommand('opus', makeCtx())
178
+ expect(result).toContain('not available')
179
+ })
180
+
181
+ test('returns "not available" when clearing without updateSessionMeta', () => {
182
+ const result = modelCommand('clear', makeCtx())
183
+ expect(result).toContain('not available')
184
+ })
185
+ })