@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.
- package/LICENSE +21 -0
- package/README.md +13 -0
- package/package.json +32 -0
- package/src/__tests__/clear-command.test.ts +214 -0
- package/src/__tests__/command-handler.test.ts +169 -0
- package/src/__tests__/compact-command.test.ts +80 -0
- package/src/__tests__/config-command.test.ts +240 -0
- package/src/__tests__/config-loader.test.ts +1512 -0
- package/src/__tests__/config.test.ts +429 -0
- package/src/__tests__/cron-command.test.ts +418 -0
- package/src/__tests__/cron-parser.test.ts +259 -0
- package/src/__tests__/daemon.test.ts +346 -0
- package/src/__tests__/dedup.test.ts +404 -0
- package/src/__tests__/e2e-sanitization.ts +168 -0
- package/src/__tests__/e2e-skill-loader.test.ts +176 -0
- package/src/__tests__/fixtures/AGENTS.md +4 -0
- package/src/__tests__/fixtures/IDENTITY.md +2 -0
- package/src/__tests__/fixtures/SOUL.md +3 -0
- package/src/__tests__/fixtures/moons/athena/IDENTITY.md +2 -0
- package/src/__tests__/fixtures/moons/athena/SOUL.md +3 -0
- package/src/__tests__/fixtures/moons/hermes/SOUL.md +3 -0
- package/src/__tests__/fixtures/skills/brain/SKILL.md +6 -0
- package/src/__tests__/fixtures/skills/empty/SKILL.md +3 -0
- package/src/__tests__/fixtures/skills/multiline/SKILL.md +7 -0
- package/src/__tests__/fixtures/skills/no-desc/SKILL.md +5 -0
- package/src/__tests__/fixtures/skills/notion/SKILL.md +6 -0
- package/src/__tests__/fixtures/skills/quoted/SKILL.md +6 -0
- package/src/__tests__/hook-runner.test.ts +1689 -0
- package/src/__tests__/input-sanitization.test.ts +367 -0
- package/src/__tests__/logger.test.ts +163 -0
- package/src/__tests__/memory-orchestrator.test.ts +552 -0
- package/src/__tests__/model-catalog.test.ts +215 -0
- package/src/__tests__/model-command.test.ts +185 -0
- package/src/__tests__/moon-loader.test.ts +398 -0
- package/src/__tests__/ping-command.test.ts +85 -0
- package/src/__tests__/plugin.test.ts +258 -0
- package/src/__tests__/remind-command.test.ts +368 -0
- package/src/__tests__/reset-command.test.ts +92 -0
- package/src/__tests__/router.test.ts +1246 -0
- package/src/__tests__/scheduler.test.ts +469 -0
- package/src/__tests__/security.test.ts +214 -0
- package/src/__tests__/session-meta.test.ts +101 -0
- package/src/__tests__/session-tracker.test.ts +389 -0
- package/src/__tests__/session.test.ts +241 -0
- package/src/__tests__/skill-loader.test.ts +153 -0
- package/src/__tests__/status-command.test.ts +153 -0
- package/src/__tests__/stop-command.test.ts +60 -0
- package/src/__tests__/think-command.test.ts +146 -0
- package/src/__tests__/usage-api.test.ts +222 -0
- package/src/__tests__/usage-command-api-fail.test.ts +48 -0
- package/src/__tests__/usage-command-no-oauth.test.ts +48 -0
- package/src/__tests__/usage-command.test.ts +173 -0
- package/src/__tests__/whoami-command.test.ts +124 -0
- package/src/index.ts +122 -0
- package/src/lib/command-handler.ts +135 -0
- package/src/lib/commands/clear.ts +69 -0
- package/src/lib/commands/compact.ts +14 -0
- package/src/lib/commands/config-show.ts +49 -0
- package/src/lib/commands/cron.ts +118 -0
- package/src/lib/commands/help.ts +26 -0
- package/src/lib/commands/model.ts +71 -0
- package/src/lib/commands/ping.ts +24 -0
- package/src/lib/commands/remind.ts +75 -0
- package/src/lib/commands/status.ts +118 -0
- package/src/lib/commands/stop.ts +18 -0
- package/src/lib/commands/think.ts +42 -0
- package/src/lib/commands/usage.ts +56 -0
- package/src/lib/commands/whoami.ts +23 -0
- package/src/lib/config-loader.ts +1449 -0
- package/src/lib/config.ts +202 -0
- package/src/lib/cron-parser.ts +388 -0
- package/src/lib/daemon.ts +216 -0
- package/src/lib/dedup.ts +414 -0
- package/src/lib/hook-runner.ts +1270 -0
- package/src/lib/logger.ts +55 -0
- package/src/lib/memory-orchestrator.ts +415 -0
- package/src/lib/model-catalog.ts +240 -0
- package/src/lib/moon-loader.ts +291 -0
- package/src/lib/plugin.ts +148 -0
- package/src/lib/router.ts +1135 -0
- package/src/lib/scheduler.ts +422 -0
- package/src/lib/security.ts +259 -0
- package/src/lib/session-tracker.ts +222 -0
- package/src/lib/session.ts +158 -0
- package/src/lib/skill-loader.ts +166 -0
- package/src/lib/usage-api.ts +145 -0
- package/src/types/agent.ts +86 -0
- package/src/types/channel.ts +93 -0
- package/src/types/index.ts +32 -0
- package/src/types/memory.ts +92 -0
- package/src/types/moon.ts +56 -0
- 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
|
+
}
|