@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,124 @@
1
+ /**
2
+ * /whoami Command — Test Suite
3
+ *
4
+ * Spec: shows sender identity, platform, channel, and agent info.
5
+ *
6
+ * Key contracts:
7
+ * - Always shows sender name and ID
8
+ * - Shows username only when present
9
+ * - Platform comes from channelPersona, defaults to "unknown"
10
+ * - Channel comes from channelPersona.name, falls back to channelId
11
+ * - Shows current agent info
12
+ */
13
+ import { describe, expect, test } from 'bun:test'
14
+ import type { CommandContext } from '../lib/command-handler'
15
+ import { whoamiCommand } from '../lib/commands/whoami'
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: '/whoami',
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
+ // Sender identity
38
+ // ═══════════════════════════════════════════════════════════
39
+
40
+ describe('/whoami sender', () => {
41
+ test('shows sender name', () => {
42
+ const result = whoamiCommand('', makeCtx())
43
+ expect(result).toContain('Test User')
44
+ })
45
+
46
+ test('shows username when present', () => {
47
+ const result = whoamiCommand('', makeCtx())
48
+ expect(result).toContain('testuser')
49
+ expect(result).toContain('**Username:**')
50
+ })
51
+
52
+ test('omits username line when not present', () => {
53
+ const ctx = makeCtx({
54
+ message: {
55
+ id: 'msg-1',
56
+ channelId: 'channel-1',
57
+ sender: { id: 'user-1', name: 'Test User' },
58
+ text: '/whoami',
59
+ timestamp: new Date(),
60
+ },
61
+ })
62
+ const result = whoamiCommand('', ctx)
63
+ expect(result).not.toContain('**Username:**')
64
+ })
65
+
66
+ test('shows sender ID', () => {
67
+ const result = whoamiCommand('', makeCtx())
68
+ expect(result).toContain('user-1')
69
+ })
70
+ })
71
+
72
+ // ═══════════════════════════════════════════════════════════
73
+ // Platform & channel
74
+ // ═══════════════════════════════════════════════════════════
75
+
76
+ describe('/whoami platform & channel', () => {
77
+ test('shows platform from channelPersona', () => {
78
+ const ctx = makeCtx({
79
+ channelPersona: {
80
+ id: 'channel-1',
81
+ name: 'orbit',
82
+ platform: 'discord',
83
+ },
84
+ })
85
+ const result = whoamiCommand('', ctx)
86
+ expect(result).toContain('discord')
87
+ })
88
+
89
+ test('defaults to "unknown" platform when no channelPersona', () => {
90
+ const ctx = makeCtx({ channelPersona: undefined })
91
+ const result = whoamiCommand('', ctx)
92
+ expect(result).toContain('unknown')
93
+ })
94
+
95
+ test('shows channel name from channelPersona', () => {
96
+ const ctx = makeCtx({
97
+ channelPersona: {
98
+ id: 'channel-1',
99
+ name: 'orbit',
100
+ platform: 'discord',
101
+ },
102
+ })
103
+ const result = whoamiCommand('', ctx)
104
+ expect(result).toContain('#orbit')
105
+ })
106
+
107
+ test('falls back to channelId when no channelPersona name', () => {
108
+ const ctx = makeCtx({ channelPersona: undefined })
109
+ const result = whoamiCommand('', ctx)
110
+ expect(result).toContain('#channel-1')
111
+ })
112
+ })
113
+
114
+ // ═══════════════════════════════════════════════════════════
115
+ // Agent info
116
+ // ═══════════════════════════════════════════════════════════
117
+
118
+ describe('/whoami agent', () => {
119
+ test('shows agent name and id', () => {
120
+ const result = whoamiCommand('', makeCtx())
121
+ expect(result).toContain('Claude Code (CLI)')
122
+ expect(result).toContain('claude')
123
+ })
124
+ })
package/src/index.ts ADDED
@@ -0,0 +1,122 @@
1
+ // Types — the public interfaces that adapters implement
2
+
3
+ export type { ClearResult, CommandContext, CommandFn } from './lib/command-handler'
4
+ // Command handler
5
+ export { CommandHandler } from './lib/command-handler'
6
+ export type { LunarConfig } from './lib/config'
7
+ export { loadConfig } from './lib/config'
8
+ export type {
9
+ AgentBackendConfig,
10
+ AgentEntry,
11
+ DedupConfig,
12
+ HookCondition,
13
+ HookEntry,
14
+ HooksConfig,
15
+ InputSanitizationConfig,
16
+ LifecycleEvent,
17
+ MemoryConfig,
18
+ MoonEntry,
19
+ PlatformConfig,
20
+ ProviderConfig,
21
+ RecallConfig,
22
+ SchedulerConfigParsed,
23
+ SchedulerJobConfig,
24
+ SecurityConfig,
25
+ VoiceConfig,
26
+ VoiceSTTConfig,
27
+ VoiceTTSConfig,
28
+ WorkspaceConfig,
29
+ } from './lib/config-loader'
30
+ // Config loader (workspace config.yaml — v2)
31
+ export { loadWorkspaceConfig, parseSchedulerConfig } from './lib/config-loader'
32
+ export type { CronFields } from './lib/cron-parser'
33
+ export { nextCron, nextOccurrence, parseCron, parseField } from './lib/cron-parser'
34
+ export type { DaemonOptions } from './lib/daemon'
35
+ // Lib — the runtime core
36
+ export { Daemon } from './lib/daemon'
37
+ export type { HookContext, LLMFunction } from './lib/hook-runner'
38
+ // Hook runner
39
+ export {
40
+ evaluateGuard,
41
+ getAction,
42
+ HookRunner,
43
+ listActions,
44
+ parseTimeWindow,
45
+ registerAction,
46
+ } from './lib/hook-runner'
47
+ export type { LoggerOptions } from './lib/logger'
48
+ export { createLogger, log, reconfigureLogger } from './lib/logger'
49
+ export type { OrchestratorConfig } from './lib/memory-orchestrator'
50
+
51
+ // Memory orchestrator
52
+ export { MemoryOrchestrator } from './lib/memory-orchestrator'
53
+ export type { ModelInfo } from './lib/model-catalog'
54
+ // Model catalog
55
+ export {
56
+ calculateCost,
57
+ getContextWindowSize,
58
+ getModelDisplayName,
59
+ getModelInfo,
60
+ listModels,
61
+ } from './lib/model-catalog'
62
+ export { MoonLoader } from './lib/moon-loader'
63
+ export { PluginRegistry } from './lib/plugin'
64
+ export { Router } from './lib/router'
65
+ export type {
66
+ DispatchFn,
67
+ Job,
68
+ JobConfig,
69
+ JobPayload,
70
+ Schedule,
71
+ SchedulerConfig,
72
+ } from './lib/scheduler'
73
+ // Scheduler (v0.3)
74
+ export { JobStore, Scheduler } from './lib/scheduler'
75
+ export type { SanitizationResult } from './lib/security'
76
+ // Security
77
+ export { buildSafeEnv, createRedactor, createSanitizer, sanitizeInput } from './lib/security'
78
+ export { SessionStore } from './lib/session'
79
+ export type { SessionStatus, SessionUsage } from './lib/session-tracker'
80
+ // Session tracker
81
+ export {
82
+ buildProgressBar,
83
+ formatDuration,
84
+ formatTokens,
85
+ SessionTracker,
86
+ } from './lib/session-tracker'
87
+ export type { SkillMeta } from './lib/skill-loader'
88
+ export { SkillLoader } from './lib/skill-loader'
89
+ export type { AccountUsage, UsageWindow } from './lib/usage-api'
90
+ // Usage API
91
+ export { fetchAccountUsage, hasOAuthCredentials } from './lib/usage-api'
92
+ export type {
93
+ Agent,
94
+ AgentAttachment,
95
+ AgentConfig,
96
+ AgentEvent,
97
+ AgentInput,
98
+ AgentUsage,
99
+ } from './types/agent'
100
+ export type {
101
+ Attachment,
102
+ Channel,
103
+ ChannelConfig,
104
+ IncomingMessage,
105
+ OutgoingAttachment,
106
+ OutgoingMessage,
107
+ } from './types/channel'
108
+ export type {
109
+ Fact,
110
+ MemoryProvider,
111
+ MemoryResult,
112
+ SearchOptions,
113
+ } from './types/memory'
114
+ export type { ChannelPersona, Moon } from './types/moon'
115
+ export type {
116
+ STTOptions,
117
+ STTProvider,
118
+ STTResult,
119
+ TTSOptions,
120
+ TTSProvider,
121
+ TTSResult,
122
+ } from './types/voice'
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Command Handler — Slash command interception and dispatch.
3
+ *
4
+ * Detects messages starting with "/" and routes them to registered
5
+ * command handlers. Commands are processed by the Router BEFORE
6
+ * being sent to the agent — zero token cost, instant response.
7
+ *
8
+ * Design:
9
+ * - Commands are simple functions: (args, context) → string | null
10
+ * - null return = command not handled, fall through to agent
11
+ * - string return = response sent directly to channel
12
+ * - Context provides access to session tracker, memory, daemon info
13
+ */
14
+
15
+ import type { IncomingMessage } from '../types/channel'
16
+ import type { MemoryProvider } from '../types/memory'
17
+ import type { ChannelPersona, Moon } from '../types/moon'
18
+ import type { LunarConfig } from './config'
19
+ import type { Scheduler } from './scheduler'
20
+ import type { SessionStatus, SessionTracker } from './session-tracker'
21
+
22
+ /**
23
+ * Result of a session clear operation.
24
+ */
25
+ export interface ClearResult {
26
+ /** Whether the session was successfully cleared */
27
+ sessionCleared: boolean
28
+ /** Whether memory lifecycle hooks ran before clearing */
29
+ hooksRan: boolean
30
+ }
31
+
32
+ export interface CommandContext {
33
+ /** Session key for the current channel */
34
+ sessionKey: string
35
+ /** The incoming message */
36
+ message: IncomingMessage
37
+ /** Session tracker with usage data */
38
+ tracker: SessionTracker
39
+ /** Active moon for this channel */
40
+ moon?: Moon
41
+ /** Channel persona */
42
+ channelPersona?: ChannelPersona
43
+ /** Memory provider (for health checks) */
44
+ memory?: MemoryProvider
45
+ /** Scheduler instance (if enabled) */
46
+ scheduler?: Scheduler
47
+ /** Daemon start time (ms since epoch) */
48
+ daemonStartedAt: number
49
+ /** Agent ID and name */
50
+ agent: { id: string; name: string }
51
+ /** Active workspace configuration */
52
+ config?: LunarConfig
53
+ /** Update session metadata (for /model, /think overrides) */
54
+ updateSessionMeta?: (updates: Record<string, unknown>) => void
55
+ /** Get current session metadata */
56
+ getSessionMeta?: () => Record<string, unknown> | null
57
+ /** Abort the currently running agent query (for /stop) */
58
+ abortAgent?: () => void
59
+ /**
60
+ * Clear the current session: Claude context, token counters, message history.
61
+ * By default runs memory hooks (summarize, promote) before clearing.
62
+ * Pass { skipHooks: true } to clear immediately without hooks.
63
+ */
64
+ clearSession: (options?: { skipHooks?: boolean }) => Promise<ClearResult>
65
+ }
66
+
67
+ /**
68
+ * A command handler function.
69
+ * @param args - The text after the command name (e.g., "/status foo" → args = "foo")
70
+ * @param ctx - Command context with access to framework state
71
+ * @returns Response text to send, or null to pass through to agent
72
+ */
73
+ export type CommandFn = (
74
+ args: string,
75
+ ctx: CommandContext,
76
+ ) => Promise<string | null> | string | null
77
+
78
+ interface RegisteredCommand {
79
+ name: string
80
+ description: string
81
+ handler: CommandFn
82
+ }
83
+
84
+ /**
85
+ * Command registry and dispatcher.
86
+ */
87
+ export class CommandHandler {
88
+ private commands = new Map<string, RegisteredCommand>()
89
+
90
+ /**
91
+ * Register a slash command.
92
+ */
93
+ register(name: string, description: string, handler: CommandFn): void {
94
+ // Normalize: store without leading /
95
+ const normalized = name.startsWith('/') ? name.slice(1) : name
96
+ this.commands.set(normalized, { name: normalized, description, handler })
97
+ }
98
+
99
+ /**
100
+ * Check if a message is a slash command.
101
+ */
102
+ isCommand(text: string): boolean {
103
+ const trimmed = text.trim()
104
+ if (!trimmed.startsWith('/')) return false
105
+
106
+ const commandName = trimmed.slice(1).split(/\s+/)[0]?.toLowerCase()
107
+ return commandName ? this.commands.has(commandName) : false
108
+ }
109
+
110
+ /**
111
+ * Try to handle a message as a command.
112
+ * Returns the response string if handled, null otherwise.
113
+ */
114
+ async handle(text: string, ctx: CommandContext): Promise<string | null> {
115
+ const trimmed = text.trim()
116
+ if (!trimmed.startsWith('/')) return null
117
+
118
+ const parts = trimmed.slice(1).split(/\s+/)
119
+ const commandName = parts[0]?.toLowerCase()
120
+ if (!commandName) return null
121
+
122
+ const command = this.commands.get(commandName)
123
+ if (!command) return null
124
+
125
+ const args = parts.slice(1).join(' ')
126
+ return command.handler(args, ctx)
127
+ }
128
+
129
+ /**
130
+ * List all registered commands (for /help).
131
+ */
132
+ list(): Array<{ name: string; description: string }> {
133
+ return [...this.commands.values()].map(({ name, description }) => ({ name, description }))
134
+ }
135
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * /clear — Clear session command.
3
+ *
4
+ * Resets the current session: clears Claude Code's accumulated context,
5
+ * token counters, and message history. The next query starts a fresh
6
+ * conversation (no --resume flag).
7
+ *
8
+ * By default, runs memory lifecycle hooks before clearing (summarize,
9
+ * promote, etc.) so session knowledge is preserved. Use `--hard` to
10
+ * skip hooks and clear immediately.
11
+ *
12
+ * Usage:
13
+ * /clear — Graceful: run hooks, then clear
14
+ * /clear --hard — Hard: skip hooks, clear immediately
15
+ */
16
+
17
+ import type { CommandContext } from '../command-handler'
18
+ import { formatTokens } from '../session-tracker'
19
+
20
+ export async function clearCommand(args: string, ctx: CommandContext): Promise<string> {
21
+ const skipHooks = args.includes('--hard') || args.trim().toLowerCase() === 'hard'
22
+
23
+ // Capture previous status before clearing
24
+ const previousStatus = ctx.tracker.getStatus(ctx.sessionKey)
25
+
26
+ // Execute the clear
27
+ const result = await ctx.clearSession({ skipHooks })
28
+
29
+ const moonName = ctx.moon?.name ?? 'Lunar'
30
+ const lines: string[] = []
31
+
32
+ lines.push(`🌑 **${moonName}** — Session Cleared`)
33
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
34
+
35
+ // Show what was cleared (previous stats)
36
+ if (previousStatus) {
37
+ const { usage } = previousStatus
38
+
39
+ const totalTokens =
40
+ usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
41
+ lines.push(
42
+ `🗑️ **Cleared:** ${formatTokens(totalTokens)} tokens · ${usage.messageCount} messages · ${usage.queryCount} turns`,
43
+ )
44
+
45
+ if (previousStatus.estimatedCostUsd > 0) {
46
+ const costStr =
47
+ previousStatus.estimatedCostUsd < 0.01
48
+ ? '<$0.01'
49
+ : `~$${previousStatus.estimatedCostUsd.toFixed(2)}`
50
+ lines.push(`💰 **Session cost:** ${costStr} · ${previousStatus.sessionAge}`)
51
+ }
52
+ } else {
53
+ lines.push('🗑️ **Cleared:** no active session data')
54
+ }
55
+
56
+ // Hooks status
57
+ if (result.hooksRan) {
58
+ lines.push('🧠 **Memory:** hooks ran (summary saved)')
59
+ } else if (skipHooks) {
60
+ lines.push('⚡ **Memory:** hooks skipped (--hard)')
61
+ } else {
62
+ lines.push('🧠 **Memory:** no hooks to run')
63
+ }
64
+
65
+ lines.push('')
66
+ lines.push('_Next message starts a fresh session._')
67
+
68
+ return lines.join('\n')
69
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * /compact — Force context compaction (clears session, starts fresh).
3
+ *
4
+ * v1: thin wrapper around /clear with different wording.
5
+ * Future: could summarize context before clearing.
6
+ */
7
+
8
+ import type { CommandContext } from '../command-handler'
9
+ import { clearCommand } from './clear'
10
+
11
+ export async function compactCommand(args: string, ctx: CommandContext): Promise<string> {
12
+ const result = await clearCommand(args, ctx)
13
+ return result ? result.replace('Session Cleared', 'Session Compacted') : result
14
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * /config — Show active runtime configuration.
3
+ *
4
+ * Usage:
5
+ * /config — Show all config
6
+ * /config show — Same as above
7
+ */
8
+
9
+ import type { CommandContext } from '../command-handler'
10
+
11
+ export function configCommand(args: string, ctx: CommandContext): string {
12
+ const sub = args.trim().toLowerCase().split(/\s+/)[0] || 'show'
13
+
14
+ if (sub !== 'show' && sub !== '') {
15
+ return `Unknown subcommand: \`${sub}\`. Available: \`show\``
16
+ }
17
+
18
+ const lines: string[] = []
19
+ const moonName = ctx.moon?.name ?? 'none'
20
+
21
+ lines.push('**Configuration**')
22
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
23
+
24
+ lines.push(`**Moon:** ${moonName}`)
25
+
26
+ const model = ctx.channelPersona?.model ?? ctx.moon?.model ?? 'default'
27
+ lines.push(`**Model:** ${model}`)
28
+
29
+ if (ctx.moon?.context1m) {
30
+ lines.push('**Context:** 1M extended')
31
+ }
32
+
33
+ if (ctx.channelPersona) {
34
+ lines.push(`**Channel:** #${ctx.channelPersona.name} (${ctx.channelPersona.platform})`)
35
+ if (ctx.channelPersona.tone) lines.push(`**Tone:** ${ctx.channelPersona.tone}`)
36
+ if (ctx.channelPersona.focus) lines.push(`**Focus:** ${ctx.channelPersona.focus}`)
37
+ if (ctx.channelPersona.skills?.length) {
38
+ lines.push(`**Skills:** ${ctx.channelPersona.skills.join(', ')}`)
39
+ }
40
+ if (ctx.channelPersona.agentId) {
41
+ lines.push(`**Agent routing:** ${ctx.channelPersona.agentId}`)
42
+ }
43
+ }
44
+
45
+ lines.push(`**Memory:** ${ctx.memory ? `${ctx.memory.id} (active)` : 'disabled'}`)
46
+ lines.push(`**Agent:** ${ctx.agent.name} (\`${ctx.agent.id}\`)`)
47
+
48
+ return lines.join('\n')
49
+ }
@@ -0,0 +1,118 @@
1
+ import type { CommandContext, CommandFn } from '../command-handler'
2
+ import { parseCron } from '../cron-parser'
3
+ import type { Job } from '../scheduler'
4
+
5
+ export const cronCommand: CommandFn = async (args: string, ctx: CommandContext) => {
6
+ if (!ctx.scheduler) {
7
+ return '❌ Scheduler is not enabled in config.yaml'
8
+ }
9
+
10
+ const parts = args.trim().split(/\s+/)
11
+ const subcommand = parts[0]?.toLowerCase()
12
+
13
+ if (!subcommand || subcommand === 'help') {
14
+ return `**Cron Commands:**
15
+ \`/cron list\` - List all jobs
16
+ \`/cron add <expr> <channel> <prompt>\` - Add recurring query job
17
+ \`/cron remove <id|name>\` - Remove a job
18
+ \`/cron enable <id|name>\` - Enable a job
19
+ \`/cron disable <id|name>\` - Disable a job`
20
+ }
21
+
22
+ const store = ctx.scheduler.getStore()
23
+
24
+ if (subcommand === 'list') {
25
+ const jobs = store.list()
26
+ if (jobs.length === 0) return 'No jobs scheduled.'
27
+
28
+ const lines = jobs.map((j) => {
29
+ const state = j.enabled ? '🟢' : '🔴'
30
+ const sched = j.schedule.kind === 'cron' ? j.schedule.expr : j.schedule.kind
31
+ const next = j.nextRun ? new Date(j.nextRun * 1000).toLocaleString() : 'None'
32
+ const name = j.name ? `**${j.name}**` : `\`${j.id.slice(0, 8)}\``
33
+ return `${state} ${name} | \`${sched}\` | Next: ${next} | Ch: #${j.channel}`
34
+ })
35
+ return `**Scheduled Jobs (${jobs.length}):**\n${lines.join('\n')}`
36
+ }
37
+
38
+ if (subcommand === 'remove') {
39
+ const target = parts[1]
40
+ if (!target) return 'Usage: `/cron remove <id|name>`'
41
+ const success = store.remove(target)
42
+ return success ? `✅ Removed job \`${target}\`` : `❌ Job \`${target}\` not found.`
43
+ }
44
+
45
+ if (subcommand === 'enable') {
46
+ const target = parts[1]
47
+ if (!target) return 'Usage: `/cron enable <id|name>`'
48
+ const job = store.get(target)
49
+ if (!job) return `❌ Job \`${target}\` not found.`
50
+ store.enable(job.id)
51
+ return `✅ Enabled job \`${target}\``
52
+ }
53
+
54
+ if (subcommand === 'disable') {
55
+ const target = parts[1]
56
+ if (!target) return 'Usage: `/cron disable <id|name>`'
57
+ const job = store.get(target)
58
+ if (!job) return `❌ Job \`${target}\` not found.`
59
+ store.disable(job.id)
60
+ return `✅ Disabled job \`${target}\``
61
+ }
62
+
63
+ if (subcommand === 'add') {
64
+ // /cron add "45 6 * * 1-5" orbit "Good morning!"
65
+ // /cron add 0 * * * * orbit "Hourly check"
66
+
67
+ // Extract expression
68
+ let expr = ''
69
+ let remaining = args.substring(3).trim()
70
+
71
+ if (remaining.startsWith('"') || remaining.startsWith("'")) {
72
+ const quote = remaining[0]
73
+ const endQuote = remaining.indexOf(quote, 1)
74
+ if (endQuote === -1) return '❌ Missing closing quote for cron expression.'
75
+ expr = remaining.substring(1, endQuote)
76
+ remaining = remaining.substring(endQuote + 1).trim()
77
+ } else {
78
+ // 5 parts for cron
79
+ const ep = remaining.split(/\s+/)
80
+ if (ep.length < 5) return '❌ Invalid cron expression. Need 5 fields.'
81
+ expr = ep.slice(0, 5).join(' ')
82
+ remaining = ep.slice(5).join(' ')
83
+ }
84
+
85
+ try {
86
+ parseCron(expr) // validate
87
+ } catch (e) {
88
+ return `❌ Invalid cron expression: ${e instanceof Error ? e.message : String(e)}`
89
+ }
90
+
91
+ const remainingParts = remaining.split(/\s+/)
92
+ const channel = remainingParts[0]
93
+ let prompt = remainingParts.slice(1).join(' ')
94
+
95
+ if (prompt.startsWith('"') || prompt.startsWith("'")) {
96
+ prompt = prompt.slice(1, prompt.length - 1)
97
+ }
98
+
99
+ if (!channel || !prompt) {
100
+ return 'Usage: `/cron add "<expr>" <channel> <prompt>`'
101
+ }
102
+
103
+ const job: Job = {
104
+ id: crypto.randomUUID(),
105
+ schedule: { kind: 'cron', expr },
106
+ payload: { kind: 'query', prompt },
107
+ channel,
108
+ enabled: true,
109
+ oneShot: false,
110
+ createdAt: Math.floor(Date.now() / 1000),
111
+ }
112
+
113
+ store.add(job)
114
+ return `✅ Added cron job: \`${expr}\` -> #${channel}`
115
+ }
116
+
117
+ return `❌ Unknown subcommand \`${subcommand}\``
118
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * /help — List available commands.
3
+ */
4
+
5
+ import type { CommandContext, CommandHandler } from '../command-handler'
6
+
7
+ /**
8
+ * Creates a /help command that lists all registered commands.
9
+ * Needs the handler reference to enumerate commands.
10
+ */
11
+ export function createHelpCommand(handler: CommandHandler) {
12
+ return function helpCommand(_args: string, ctx: CommandContext): string {
13
+ const commands = handler.list()
14
+ const moonName = ctx.moon?.name ?? 'Lunar'
15
+
16
+ const lines: string[] = []
17
+ lines.push(`🌑 **${moonName}** — Commands`)
18
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
19
+
20
+ for (const cmd of commands) {
21
+ lines.push(`**/${cmd.name}** — ${cmd.description}`)
22
+ }
23
+
24
+ return lines.join('\n')
25
+ }
26
+ }