@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,291 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs'
2
+ import { basename, resolve } from 'node:path'
3
+ import type { ChannelPersona, Moon } from '../types/moon'
4
+ import { log } from './logger'
5
+ import type { SkillLoader } from './skill-loader'
6
+
7
+ /**
8
+ * MoonLoader — Discovers, loads, and manages Moon personalities.
9
+ *
10
+ * Conventions:
11
+ * - Default moon: workspace root (SOUL.md, AGENTS.md, IDENTITY.md)
12
+ * - Additional moons: `moons/<name>/` subdirectories
13
+ * - Minimum requirement: SOUL.md must exist
14
+ * - `moons` config section only needed for custom paths
15
+ *
16
+ * Usage:
17
+ * const loader = new MoonLoader()
18
+ * await loader.loadAll('~/.lunar')
19
+ * const moon = loader.resolve(channelPersona)
20
+ * const prompt = loader.buildSystemPrompt(moon, channelPersona)
21
+ */
22
+ export class MoonLoader {
23
+ private moons = new Map<string, Moon>()
24
+ private defaultMoonName = ''
25
+ private skillManifest = ''
26
+ private skillLoader?: SkillLoader
27
+
28
+ /** Get number of loaded moons */
29
+ get size(): number {
30
+ return this.moons.size
31
+ }
32
+
33
+ /** Set the skills manifest to inject into system prompts */
34
+ setSkillManifest(manifest: string): void {
35
+ this.skillManifest = manifest
36
+ }
37
+
38
+ /** Set the full SkillLoader for per-channel skill filtering */
39
+ setSkillLoader(loader: SkillLoader): void {
40
+ this.skillLoader = loader
41
+ this.skillManifest = loader.getManifest() // backward compat fallback
42
+ }
43
+
44
+ /**
45
+ * Load all moons from workspace.
46
+ *
47
+ * 1. Root workspace → default moon
48
+ * 2. `moons/` subdirectories → additional moons (auto-discovered)
49
+ * 3. `moonsConfig` overrides → custom paths for specific moons
50
+ *
51
+ * @param workspacePath — root workspace directory
52
+ * @param moonsConfig — optional name→path mapping for custom moon locations
53
+ */
54
+ /**
55
+ * Moon config entry for loadAll.
56
+ * Includes path + optional model/context1m overrides.
57
+ */
58
+ async loadAll(
59
+ workspacePath: string,
60
+ moonsConfig?: Record<string, string | { path: string; model?: string; context1m?: boolean }>,
61
+ defaultMoonOverrides?: { model?: string; context1m?: boolean },
62
+ ): Promise<void> {
63
+ this.moons.clear()
64
+ this.defaultMoonName = ''
65
+
66
+ // 1. Load root moon (default)
67
+ const rootSoul = resolve(workspacePath, 'SOUL.md')
68
+ if (existsSync(rootSoul)) {
69
+ const rootMoon = await this.loadFromPath(workspacePath, undefined, defaultMoonOverrides)
70
+ this.moons.set(rootMoon.name, rootMoon)
71
+ this.defaultMoonName = rootMoon.name
72
+ log.info(
73
+ { name: rootMoon.name, path: workspacePath, model: rootMoon.model ?? '(default)' },
74
+ 'Default moon loaded',
75
+ )
76
+ } else {
77
+ log.warn({ path: workspacePath }, 'No SOUL.md in workspace root — no default moon')
78
+ }
79
+
80
+ // 2. Auto-discover moons in moons/ directory
81
+ const moonsDir = resolve(workspacePath, 'moons')
82
+ if (existsSync(moonsDir) && statSync(moonsDir).isDirectory()) {
83
+ for (const entry of readdirSync(moonsDir)) {
84
+ const moonPath = resolve(moonsDir, entry)
85
+ if (!statSync(moonPath).isDirectory()) continue
86
+ if (!existsSync(resolve(moonPath, 'SOUL.md'))) continue
87
+
88
+ // Skip if already loaded via moonsConfig (config takes precedence)
89
+ if (moonsConfig?.[entry]) continue
90
+
91
+ const moon = await this.loadFromPath(moonPath, entry)
92
+ this.moons.set(entry, moon)
93
+ log.info({ name: entry, path: moonPath }, 'Moon auto-discovered')
94
+ }
95
+ }
96
+
97
+ // 3. Load explicitly configured moons (override auto-discovered)
98
+ if (moonsConfig) {
99
+ for (const [name, value] of Object.entries(moonsConfig)) {
100
+ const moonPath = typeof value === 'string' ? value : value.path
101
+ const overrides =
102
+ typeof value === 'object' ? { model: value.model, context1m: value.context1m } : undefined
103
+ const fullPath = resolve(workspacePath, moonPath)
104
+
105
+ if (!existsSync(resolve(fullPath, 'SOUL.md'))) {
106
+ log.warn({ name, path: fullPath }, 'Configured moon has no SOUL.md — skipping')
107
+ continue
108
+ }
109
+
110
+ const moon = await this.loadFromPath(fullPath, name, overrides)
111
+ this.moons.set(name, moon)
112
+ log.info(
113
+ { name, path: fullPath, model: moon.model ?? '(default)' },
114
+ 'Moon loaded from config',
115
+ )
116
+ }
117
+ }
118
+
119
+ log.info(
120
+ { total: this.moons.size, default: this.defaultMoonName, names: [...this.moons.keys()] },
121
+ 'All moons loaded',
122
+ )
123
+ }
124
+
125
+ /**
126
+ * Load a single moon from a directory path.
127
+ * Backward-compatible: can be used standalone for single-moon setups.
128
+ */
129
+ async load(workspacePath: string): Promise<Moon> {
130
+ const moon = await this.loadFromPath(workspacePath)
131
+ this.moons.set(moon.name, moon)
132
+ if (!this.defaultMoonName) this.defaultMoonName = moon.name
133
+ return moon
134
+ }
135
+
136
+ /** Get a loaded moon by name */
137
+ get(name: string): Moon | undefined {
138
+ return this.moons.get(name)
139
+ }
140
+
141
+ /** Get the default (root) moon */
142
+ getDefault(): Moon | undefined {
143
+ return this.defaultMoonName ? this.moons.get(this.defaultMoonName) : undefined
144
+ }
145
+
146
+ /** List all loaded moon names */
147
+ list(): string[] {
148
+ return [...this.moons.keys()]
149
+ }
150
+
151
+ /**
152
+ * Resolve which moon should handle a channel.
153
+ *
154
+ * Priority:
155
+ * 1. ChannelPersona.moon → look up by name
156
+ * 2. No moon specified → use default (root) moon
157
+ * 3. Not found → undefined (caller should handle gracefully)
158
+ */
159
+ resolve(channel?: ChannelPersona): Moon | undefined {
160
+ if (!channel?.moon) return this.getDefault()
161
+
162
+ const moon = this.moons.get(channel.moon)
163
+ if (!moon) {
164
+ log.warn(
165
+ { requested: channel.moon, channel: channel.name, available: this.list() },
166
+ 'Moon not found for channel — falling back to default',
167
+ )
168
+ return this.getDefault()
169
+ }
170
+ return moon
171
+ }
172
+
173
+ /**
174
+ * Build the complete system prompt for a channel.
175
+ *
176
+ * Structure:
177
+ * # Moon: <name>
178
+ * ## SOUL.md — personality core
179
+ * ## AGENTS.md — operational rules
180
+ * ## IDENTITY.md — identity metadata
181
+ * ## Workspace — available files
182
+ * ## Channel: <name> — per-channel overlay (if present)
183
+ */
184
+ buildSystemPrompt(moon: Moon, channel?: ChannelPersona): string {
185
+ const sections: string[] = []
186
+
187
+ // Header
188
+ sections.push(`# Moon: ${moon.name}`)
189
+
190
+ // Soul (personality core — always first)
191
+ if (moon.soul.trim()) {
192
+ sections.push(`## SOUL.md\n${moon.soul.trim()}`)
193
+ }
194
+
195
+ // Agents (operational rules)
196
+ if (moon.agents.trim()) {
197
+ sections.push(`## AGENTS.md\n${moon.agents.trim()}`)
198
+ }
199
+
200
+ // Identity
201
+ if (moon.identity.trim()) {
202
+ sections.push(`## IDENTITY.md\n${moon.identity.trim()}`)
203
+ }
204
+
205
+ // Workspace reference
206
+ const workspaceLines = [
207
+ '## Workspace',
208
+ `Your workspace is: ${moon.path}`,
209
+ '',
210
+ 'Files available on-demand (read with tools when needed):',
211
+ '- `USER.md` — Information about the user',
212
+ '- `MEMORY.md` — Persistent context and decisions',
213
+ '- `memory/` — Session notes and daily logs',
214
+ ]
215
+ sections.push(workspaceLines.join('\n'))
216
+
217
+ // Skills manifest — per-channel filter if SkillLoader available, else global manifest
218
+ const manifest = this.skillLoader
219
+ ? this.skillLoader.getManifest(channel?.skills)
220
+ : this.skillManifest
221
+ if (manifest) {
222
+ sections.push(manifest.trim())
223
+ }
224
+ // Channel persona overlay
225
+ if (channel) {
226
+ const channelLines = [`## Channel: #${channel.name}`]
227
+ if (channel.tone) channelLines.push(`- **Tone:** ${channel.tone}`)
228
+ if (channel.focus) channelLines.push(`- **Focus:** ${channel.focus}`)
229
+ if (channel.instructions) {
230
+ channelLines.push('')
231
+ channelLines.push(channel.instructions)
232
+ }
233
+ sections.push(channelLines.join('\n'))
234
+ }
235
+
236
+ return sections.join('\n\n---\n\n')
237
+ }
238
+
239
+ /**
240
+ * Find a ChannelPersona by Discord channel ID from a list of channel configs.
241
+ * Returns undefined if no matching channel is configured (open routing).
242
+ */
243
+ findChannel(discordChannelId: string, channels: ChannelPersona[]): ChannelPersona | undefined {
244
+ return channels.find((c) => c.id === discordChannelId)
245
+ }
246
+
247
+ // --- Private ---
248
+
249
+ private async loadFromPath(
250
+ moonPath: string,
251
+ nameOverride?: string,
252
+ overrides?: { model?: string; context1m?: boolean },
253
+ ): Promise<Moon> {
254
+ const soulPath = resolve(moonPath, 'SOUL.md')
255
+ const agentsPath = resolve(moonPath, 'AGENTS.md')
256
+ const identityPath = resolve(moonPath, 'IDENTITY.md')
257
+
258
+ if (!existsSync(soulPath)) {
259
+ throw new Error(`Missing required SOUL.md at ${soulPath}`)
260
+ }
261
+
262
+ const soul = await Bun.file(soulPath).text()
263
+ const agents = existsSync(agentsPath) ? await Bun.file(agentsPath).text() : ''
264
+ const identity = existsSync(identityPath) ? await Bun.file(identityPath).text() : ''
265
+
266
+ if (!soul.trim()) {
267
+ log.warn({ path: soulPath }, 'SOUL.md exists but is empty — moon will have no personality')
268
+ }
269
+
270
+ // Display name: prefer IDENTITY.md, fall back to nameOverride or dir name
271
+ const name = this.parseName(identity, moonPath) || (nameOverride ?? basename(moonPath))
272
+
273
+ return {
274
+ name,
275
+ soul,
276
+ agents,
277
+ identity,
278
+ path: moonPath,
279
+ model: overrides?.model,
280
+ context1m: overrides?.context1m,
281
+ }
282
+ }
283
+
284
+ private parseName(identity: string, moonPath: string): string {
285
+ const nameMatch = identity.match(/\*\*Name:\*\*\s*(.+)/i)
286
+ if (nameMatch?.[1]) {
287
+ return nameMatch[1].trim()
288
+ }
289
+ return basename(moonPath)
290
+ }
291
+ }
@@ -0,0 +1,148 @@
1
+ import type { Agent } from '../types/agent'
2
+ import type { Channel } from '../types/channel'
3
+ import type { MemoryProvider } from '../types/memory'
4
+ import type { STTProvider, TTSProvider } from '../types/voice'
5
+ import { log } from './logger'
6
+
7
+ /**
8
+ * Plugin registry — the central hub that connects all adapters.
9
+ *
10
+ * Register channels, agents, memory providers, and voice providers here.
11
+ * The router uses the registry to dispatch messages.
12
+ */
13
+ export class PluginRegistry {
14
+ private channels = new Map<string, Channel>()
15
+ private agents = new Map<string, Agent>()
16
+ private memoryProviders = new Map<string, MemoryProvider>()
17
+ private ttsProviders = new Map<string, TTSProvider>()
18
+ private sttProviders = new Map<string, STTProvider>()
19
+
20
+ private defaultAgentId?: string
21
+
22
+ // --- Registration ---
23
+
24
+ registerChannel(channel: Channel): void {
25
+ if (this.channels.has(channel.id)) {
26
+ throw new Error(`Channel '${channel.id}' already registered`)
27
+ }
28
+ this.channels.set(channel.id, channel)
29
+ log.info({ channel: channel.id }, 'Channel registered')
30
+ }
31
+
32
+ registerAgent(agent: Agent, isDefault = false): void {
33
+ if (this.agents.has(agent.id)) {
34
+ throw new Error(`Agent '${agent.id}' already registered`)
35
+ }
36
+ this.agents.set(agent.id, agent)
37
+ if (isDefault || !this.defaultAgentId) {
38
+ this.defaultAgentId = agent.id
39
+ }
40
+ log.info({ agent: agent.id, default: isDefault }, 'Agent registered')
41
+ }
42
+
43
+ registerMemory(provider: MemoryProvider): void {
44
+ this.memoryProviders.set(provider.id, provider)
45
+ log.info({ memory: provider.id }, 'Memory provider registered')
46
+ }
47
+
48
+ registerTTS(provider: TTSProvider): void {
49
+ this.ttsProviders.set(provider.id, provider)
50
+ log.info({ tts: provider.id }, 'TTS provider registered')
51
+ }
52
+
53
+ registerSTT(provider: STTProvider): void {
54
+ this.sttProviders.set(provider.id, provider)
55
+ log.info({ stt: provider.id }, 'STT provider registered')
56
+ }
57
+
58
+ // --- Retrieval ---
59
+
60
+ getChannel(id: string): Channel | undefined {
61
+ return this.channels.get(id)
62
+ }
63
+
64
+ getAgent(id?: string): Agent | undefined {
65
+ if (id) return this.agents.get(id)
66
+ if (this.defaultAgentId) return this.agents.get(this.defaultAgentId)
67
+ return undefined
68
+ }
69
+
70
+ getMemory(id: string): MemoryProvider | undefined {
71
+ return this.memoryProviders.get(id)
72
+ }
73
+
74
+ getTTS(id?: string): TTSProvider | undefined {
75
+ if (id) return this.ttsProviders.get(id)
76
+ // Return first available
77
+ const [first] = this.ttsProviders.values()
78
+ return first
79
+ }
80
+
81
+ getSTT(id?: string): STTProvider | undefined {
82
+ if (id) return this.sttProviders.get(id)
83
+ const [first] = this.sttProviders.values()
84
+ return first
85
+ }
86
+
87
+ // --- Lifecycle ---
88
+
89
+ /** Initialize all registered plugins */
90
+ async initAll(): Promise<void> {
91
+ const inits: Promise<void>[] = []
92
+
93
+ for (const agent of this.agents.values()) {
94
+ inits.push(
95
+ agent.init().catch((e) => log.error({ agent: agent.id, err: e }, 'Agent init failed')),
96
+ )
97
+ }
98
+ for (const channel of this.channels.values()) {
99
+ inits.push(
100
+ channel
101
+ .connect()
102
+ .catch((e) => log.error({ channel: channel.id, err: e }, 'Channel connect failed')),
103
+ )
104
+ }
105
+ for (const mem of this.memoryProviders.values()) {
106
+ inits.push(
107
+ mem.init().catch((e) => log.error({ memory: mem.id, err: e }, 'Memory init failed')),
108
+ )
109
+ }
110
+ for (const tts of this.ttsProviders.values()) {
111
+ inits.push(tts.init().catch((e) => log.error({ tts: tts.id, err: e }, 'TTS init failed')))
112
+ }
113
+ for (const stt of this.sttProviders.values()) {
114
+ inits.push(stt.init().catch((e) => log.error({ stt: stt.id, err: e }, 'STT init failed')))
115
+ }
116
+
117
+ await Promise.allSettled(inits)
118
+ log.info('All plugins initialized')
119
+ }
120
+
121
+ /** Gracefully destroy all plugins */
122
+ async destroyAll(): Promise<void> {
123
+ const destroys: Promise<void>[] = []
124
+
125
+ for (const channel of this.channels.values()) destroys.push(channel.disconnect())
126
+ for (const agent of this.agents.values()) destroys.push(agent.destroy())
127
+ for (const mem of this.memoryProviders.values()) destroys.push(mem.destroy())
128
+ for (const tts of this.ttsProviders.values()) destroys.push(tts.destroy())
129
+ for (const stt of this.sttProviders.values()) destroys.push(stt.destroy())
130
+
131
+ await Promise.allSettled(destroys)
132
+ log.info('All plugins destroyed')
133
+ }
134
+
135
+ // --- Inspection ---
136
+
137
+ listChannels(): string[] {
138
+ return [...this.channels.keys()]
139
+ }
140
+
141
+ listAgents(): string[] {
142
+ return [...this.agents.keys()]
143
+ }
144
+
145
+ listMemory(): string[] {
146
+ return [...this.memoryProviders.keys()]
147
+ }
148
+ }