@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 onMars Tech
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # @onmars/lunar-core
2
+
3
+ Core runtime for [Lunar](https://github.com/onmars-tech/lunar) — types, config loader, session store, router, and plugin system.
4
+
5
+ This package is used internally by `@onmars/lunar-cli`. Install the CLI instead:
6
+
7
+ ```bash
8
+ bun install -g @onmars/lunar-cli
9
+ ```
10
+
11
+ ## License
12
+
13
+ MIT — [onMars Tech](https://github.com/onmars-tech)
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@onmars/lunar-core",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./lib/*": "./src/lib/*.ts"
10
+ },
11
+ "files": ["src/", "LICENSE"],
12
+ "dependencies": {
13
+ "pino": "^10"
14
+ },
15
+ "description": "Core runtime for Lunar — types, config loader, session store, router, and plugin system",
16
+ "author": "onMars Tech",
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/onmars-tech/lunar",
21
+ "directory": "packages/core"
22
+ },
23
+ "homepage": "https://github.com/onmars-tech/lunar",
24
+ "bugs": "https://github.com/onmars-tech/lunar/issues",
25
+ "keywords": ["lunar", "ai", "chatbot", "bun", "runtime"],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "engines": {
30
+ "bun": ">=1.2"
31
+ }
32
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * /clear Command — Test Suite
3
+ *
4
+ * Spec: clears the current session (Claude context, tokens, message history).
5
+ * The next query starts a fresh Claude Code session without --resume.
6
+ *
7
+ * Key contracts:
8
+ * - Default: runs memory hooks before clearing (graceful)
9
+ * - --hard: skips hooks, clears immediately
10
+ * - Shows previous usage stats in the response
11
+ * - Response confirms what was cleared
12
+ * - Works even when no prior session exists
13
+ */
14
+ import { describe, expect, test } from 'bun:test'
15
+ import type { ClearResult, CommandContext } from '../lib/command-handler'
16
+ import { clearCommand } from '../lib/commands/clear'
17
+ import { SessionTracker } from '../lib/session-tracker'
18
+
19
+ function makeCtx(
20
+ overrides?: Partial<CommandContext> & {
21
+ clearResult?: ClearResult
22
+ },
23
+ ): CommandContext {
24
+ const { clearResult, ...rest } = overrides ?? {}
25
+
26
+ return {
27
+ sessionKey: 'test:channel-1',
28
+ message: {
29
+ id: 'msg-1',
30
+ channelId: 'channel-1',
31
+ sender: { id: 'user-1', name: 'Test User' },
32
+ text: '/clear',
33
+ timestamp: new Date(),
34
+ },
35
+ tracker: new SessionTracker(),
36
+ daemonStartedAt: Date.now(),
37
+ agent: { id: 'claude', name: 'Claude Code (CLI)' },
38
+ clearSession: async () => clearResult ?? { sessionCleared: true, hooksRan: false },
39
+ ...rest,
40
+ }
41
+ }
42
+
43
+ function makeCtxWithUsage(opts?: { clearResult?: ClearResult; moonName?: string }): CommandContext {
44
+ const tracker = new SessionTracker()
45
+
46
+ // Simulate a session with some usage
47
+ tracker.recordQuery('test:channel-1', {
48
+ inputTokens: 5000,
49
+ outputTokens: 2000,
50
+ cacheReadTokens: 150000,
51
+ cacheWriteTokens: 10000,
52
+ contextTokens: 165000,
53
+ model: 'claude-sonnet-4-5-20250514',
54
+ durationMs: 12000,
55
+ })
56
+ tracker.recordMessage('test:channel-1')
57
+ tracker.recordMessage('test:channel-1')
58
+ tracker.recordMessage('test:channel-1')
59
+
60
+ return makeCtx({
61
+ tracker,
62
+ clearResult: opts?.clearResult,
63
+ moon: opts?.moonName
64
+ ? ({ name: opts.moonName, systemPrompt: '', files: {} } as any)
65
+ : undefined,
66
+ })
67
+ }
68
+
69
+ // ═══════════════════════════════════════════════════════════
70
+ // Basic behavior
71
+ // ═══════════════════════════════════════════════════════════
72
+
73
+ describe('/clear basic', () => {
74
+ test('returns a response (never null)', async () => {
75
+ const result = await clearCommand('', makeCtx())
76
+ expect(result).not.toBeNull()
77
+ expect(typeof result).toBe('string')
78
+ })
79
+
80
+ test('response includes "Session Cleared"', async () => {
81
+ const result = await clearCommand('', makeCtx())
82
+ expect(result).toContain('Session Cleared')
83
+ })
84
+
85
+ test('response includes "fresh session" indication', async () => {
86
+ const result = await clearCommand('', makeCtx())
87
+ expect(result).toContain('fresh session')
88
+ })
89
+
90
+ test('uses moon name in header', async () => {
91
+ const ctx = makeCtx({
92
+ moon: { name: 'Clawmos', systemPrompt: '', files: {} } as any,
93
+ })
94
+ const result = await clearCommand('', ctx)
95
+ expect(result).toContain('Clawmos')
96
+ })
97
+
98
+ test('defaults to "Lunar" when no moon', async () => {
99
+ const result = await clearCommand('', makeCtx())
100
+ expect(result).toContain('Lunar')
101
+ })
102
+ })
103
+
104
+ // ═══════════════════════════════════════════════════════════
105
+ // Usage display
106
+ // ═══════════════════════════════════════════════════════════
107
+
108
+ describe('/clear usage display', () => {
109
+ test('shows token count from previous session', async () => {
110
+ const result = await clearCommand('', makeCtxWithUsage())
111
+ // Total tokens: 5000 + 2000 + 150000 + 10000 = 167000 ≈ 167K
112
+ expect(result).toContain('167K')
113
+ })
114
+
115
+ test('shows message count', async () => {
116
+ const result = await clearCommand('', makeCtxWithUsage())
117
+ expect(result).toContain('3 messages')
118
+ })
119
+
120
+ test('shows turn count', async () => {
121
+ const result = await clearCommand('', makeCtxWithUsage())
122
+ expect(result).toContain('1 turn')
123
+ })
124
+
125
+ test('shows "no active session data" when no prior usage', async () => {
126
+ const result = await clearCommand('', makeCtx())
127
+ expect(result).toContain('no active session data')
128
+ })
129
+ })
130
+
131
+ // ═══════════════════════════════════════════════════════════
132
+ // Hooks behavior
133
+ // ═══════════════════════════════════════════════════════════
134
+
135
+ describe('/clear hooks', () => {
136
+ test('calls clearSession without skipHooks by default', async () => {
137
+ let capturedOpts: any = null
138
+ const ctx = makeCtx({
139
+ clearSession: async (opts) => {
140
+ capturedOpts = opts
141
+ return { sessionCleared: true, hooksRan: true }
142
+ },
143
+ })
144
+
145
+ await clearCommand('', ctx)
146
+ expect(capturedOpts).toEqual({ skipHooks: false })
147
+ })
148
+
149
+ test('shows hooks ran when result.hooksRan is true', async () => {
150
+ const ctx = makeCtxWithUsage({
151
+ clearResult: { sessionCleared: true, hooksRan: true },
152
+ })
153
+ const result = await clearCommand('', ctx)
154
+ expect(result).toContain('hooks ran')
155
+ })
156
+
157
+ test('shows "no hooks to run" when graceful but no hooks ran', async () => {
158
+ const ctx = makeCtxWithUsage({
159
+ clearResult: { sessionCleared: true, hooksRan: false },
160
+ })
161
+ const result = await clearCommand('', ctx)
162
+ expect(result).toContain('no hooks to run')
163
+ })
164
+ })
165
+
166
+ // ═══════════════════════════════════════════════════════════
167
+ // --hard flag
168
+ // ═══════════════════════════════════════════════════════════
169
+
170
+ describe('/clear --hard', () => {
171
+ test('passes skipHooks: true with --hard flag', async () => {
172
+ let capturedOpts: any = null
173
+ const ctx = makeCtx({
174
+ clearSession: async (opts) => {
175
+ capturedOpts = opts
176
+ return { sessionCleared: true, hooksRan: false }
177
+ },
178
+ })
179
+
180
+ await clearCommand('--hard', ctx)
181
+ expect(capturedOpts).toEqual({ skipHooks: true })
182
+ })
183
+
184
+ test('passes skipHooks: true with bare "hard" arg', async () => {
185
+ let capturedOpts: any = null
186
+ const ctx = makeCtx({
187
+ clearSession: async (opts) => {
188
+ capturedOpts = opts
189
+ return { sessionCleared: true, hooksRan: false }
190
+ },
191
+ })
192
+
193
+ await clearCommand('hard', ctx)
194
+ expect(capturedOpts).toEqual({ skipHooks: true })
195
+ })
196
+
197
+ test('shows "hooks skipped" in response', async () => {
198
+ const result = await clearCommand('--hard', makeCtx())
199
+ expect(result).toContain('hooks skipped')
200
+ expect(result).toContain('--hard')
201
+ })
202
+ })
203
+
204
+ // ═══════════════════════════════════════════════════════════
205
+ // Cost display
206
+ // ═══════════════════════════════════════════════════════════
207
+
208
+ describe('/clear cost', () => {
209
+ test('shows cost estimate when > 0', async () => {
210
+ const result = await clearCommand('', makeCtxWithUsage())
211
+ // Should show some cost (Sonnet pricing on 167K tokens)
212
+ expect(result).toMatch(/\$/)
213
+ })
214
+ })
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Command Handler — Test Suite
3
+ *
4
+ * Spec: intercepts slash commands before they reach the agent.
5
+ * Commands are processed at zero token cost with instant response.
6
+ *
7
+ * Key contracts:
8
+ * - Only messages starting with "/" are candidates
9
+ * - Only registered commands are handled
10
+ * - Unknown /commands pass through to agent (return null)
11
+ * - Args are parsed correctly (text after command name)
12
+ * - Commands can be async
13
+ */
14
+ import { describe, expect, test } from 'bun:test'
15
+ import { type CommandContext, CommandHandler } from '../lib/command-handler'
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: '/test',
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
+ // Command detection
38
+ // ═══════════════════════════════════════════════════════════
39
+
40
+ describe('isCommand', () => {
41
+ test('recognizes registered commands', () => {
42
+ const handler = new CommandHandler()
43
+ handler.register('status', 'Show status', () => 'ok')
44
+
45
+ expect(handler.isCommand('/status')).toBe(true)
46
+ })
47
+
48
+ test('case insensitive', () => {
49
+ const handler = new CommandHandler()
50
+ handler.register('status', 'Show status', () => 'ok')
51
+
52
+ expect(handler.isCommand('/STATUS')).toBe(true)
53
+ expect(handler.isCommand('/Status')).toBe(true)
54
+ })
55
+
56
+ test('rejects unregistered commands', () => {
57
+ const handler = new CommandHandler()
58
+ handler.register('status', 'Show status', () => 'ok')
59
+
60
+ expect(handler.isCommand('/unknown')).toBe(false)
61
+ })
62
+
63
+ test('rejects non-slash messages', () => {
64
+ const handler = new CommandHandler()
65
+ handler.register('status', 'Show status', () => 'ok')
66
+
67
+ expect(handler.isCommand('hello')).toBe(false)
68
+ expect(handler.isCommand('status')).toBe(false)
69
+ expect(handler.isCommand('')).toBe(false)
70
+ })
71
+
72
+ test('handles whitespace', () => {
73
+ const handler = new CommandHandler()
74
+ handler.register('status', 'Show status', () => 'ok')
75
+
76
+ expect(handler.isCommand(' /status ')).toBe(true)
77
+ })
78
+
79
+ test('handles command with args', () => {
80
+ const handler = new CommandHandler()
81
+ handler.register('status', 'Show status', () => 'ok')
82
+
83
+ expect(handler.isCommand('/status verbose')).toBe(true)
84
+ })
85
+ })
86
+
87
+ // ═══════════════════════════════════════════════════════════
88
+ // Command handling
89
+ // ═══════════════════════════════════════════════════════════
90
+
91
+ describe('handle', () => {
92
+ test('executes registered command', async () => {
93
+ const handler = new CommandHandler()
94
+ handler.register('status', 'Show status', () => 'Status: OK')
95
+
96
+ const result = await handler.handle('/status', makeCtx())
97
+ expect(result).toBe('Status: OK')
98
+ })
99
+
100
+ test('passes args to handler', async () => {
101
+ const handler = new CommandHandler()
102
+ handler.register('echo', 'Echo text', (args) => `Echo: ${args}`)
103
+
104
+ const result = await handler.handle('/echo hello world', makeCtx())
105
+ expect(result).toBe('Echo: hello world')
106
+ })
107
+
108
+ test('returns null for non-commands', async () => {
109
+ const handler = new CommandHandler()
110
+ handler.register('status', 'Show status', () => 'ok')
111
+
112
+ const result = await handler.handle('hello', makeCtx())
113
+ expect(result).toBeNull()
114
+ })
115
+
116
+ test('returns null for unregistered commands', async () => {
117
+ const handler = new CommandHandler()
118
+ handler.register('status', 'Show status', () => 'ok')
119
+
120
+ const result = await handler.handle('/unknown', makeCtx())
121
+ expect(result).toBeNull()
122
+ })
123
+
124
+ test('supports async handlers', async () => {
125
+ const handler = new CommandHandler()
126
+ handler.register('async', 'Async test', async () => {
127
+ await new Promise((resolve) => setTimeout(resolve, 1))
128
+ return 'async result'
129
+ })
130
+
131
+ const result = await handler.handle('/async', makeCtx())
132
+ expect(result).toBe('async result')
133
+ })
134
+
135
+ test('handler can return null to pass through', async () => {
136
+ const handler = new CommandHandler()
137
+ handler.register('maybe', 'Maybe handle', (args) => {
138
+ return args === 'yes' ? 'handled' : null
139
+ })
140
+
141
+ expect(await handler.handle('/maybe yes', makeCtx())).toBe('handled')
142
+ expect(await handler.handle('/maybe no', makeCtx())).toBeNull()
143
+ })
144
+ })
145
+
146
+ // ═══════════════════════════════════════════════════════════
147
+ // Registration and listing
148
+ // ═══════════════════════════════════════════════════════════
149
+
150
+ describe('register and list', () => {
151
+ test('list returns all registered commands', () => {
152
+ const handler = new CommandHandler()
153
+ handler.register('status', 'Show status', () => 'ok')
154
+ handler.register('help', 'Show help', () => 'ok')
155
+
156
+ const commands = handler.list()
157
+ expect(commands).toHaveLength(2)
158
+ expect(commands.find((c) => c.name === 'status')?.description).toBe('Show status')
159
+ expect(commands.find((c) => c.name === 'help')?.description).toBe('Show help')
160
+ })
161
+
162
+ test('register with leading / is normalized', () => {
163
+ const handler = new CommandHandler()
164
+ handler.register('/status', 'Show status', () => 'ok')
165
+
166
+ expect(handler.isCommand('/status')).toBe(true)
167
+ expect(handler.list()[0].name).toBe('status')
168
+ })
169
+ })
@@ -0,0 +1,80 @@
1
+ /**
2
+ * /compact Command — Test Suite
3
+ *
4
+ * Spec: force context compaction (clears session with different wording).
5
+ *
6
+ * Key contracts:
7
+ * - Returns "Session Compacted" (not "Session Cleared")
8
+ * - Delegates to clearSession
9
+ * - Forwards --hard flag
10
+ * - Shows "fresh session" indication
11
+ */
12
+ import { describe, expect, test } from 'bun:test'
13
+ import type { ClearResult, CommandContext } from '../lib/command-handler'
14
+ import { compactCommand } from '../lib/commands/compact'
15
+ import { SessionTracker } from '../lib/session-tracker'
16
+
17
+ function makeCtx(
18
+ overrides?: Partial<CommandContext> & {
19
+ clearResult?: ClearResult
20
+ },
21
+ ): CommandContext {
22
+ const { clearResult, ...rest } = overrides ?? {}
23
+
24
+ return {
25
+ sessionKey: 'test:channel-1',
26
+ message: {
27
+ id: 'msg-1',
28
+ channelId: 'channel-1',
29
+ sender: { id: 'user-1', name: 'Test User' },
30
+ text: '/compact',
31
+ timestamp: new Date(),
32
+ },
33
+ tracker: new SessionTracker(),
34
+ daemonStartedAt: Date.now(),
35
+ agent: { id: 'claude', name: 'Claude Code (CLI)' },
36
+ clearSession: async () => clearResult ?? { sessionCleared: true, hooksRan: false },
37
+ ...rest,
38
+ }
39
+ }
40
+
41
+ // ═══════════════════════════════════════════════════════════
42
+ // Basic behavior
43
+ // ═══════════════════════════════════════════════════════════
44
+
45
+ describe('/compact basic', () => {
46
+ test('returns "Session Compacted" (not "Session Cleared")', async () => {
47
+ const result = await compactCommand('', makeCtx())
48
+ expect(result).toContain('Session Compacted')
49
+ expect(result).not.toContain('Session Cleared')
50
+ })
51
+
52
+ test('delegates to clearSession', async () => {
53
+ let clearCalled = false
54
+ const ctx = makeCtx({
55
+ clearSession: async () => {
56
+ clearCalled = true
57
+ return { sessionCleared: true, hooksRan: false }
58
+ },
59
+ })
60
+ await compactCommand('', ctx)
61
+ expect(clearCalled).toBe(true)
62
+ })
63
+
64
+ test('forwards --hard flag', async () => {
65
+ let capturedOpts: any = null
66
+ const ctx = makeCtx({
67
+ clearSession: async (opts) => {
68
+ capturedOpts = opts
69
+ return { sessionCleared: true, hooksRan: false }
70
+ },
71
+ })
72
+ await compactCommand('--hard', ctx)
73
+ expect(capturedOpts).toEqual({ skipHooks: true })
74
+ })
75
+
76
+ test('shows "fresh session" indication', async () => {
77
+ const result = await compactCommand('', makeCtx())
78
+ expect(result).toContain('fresh session')
79
+ })
80
+ })