@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,71 @@
1
+ /**
2
+ * /model — View or change the active model at runtime.
3
+ *
4
+ * Usage:
5
+ * /model — Show current model and available aliases
6
+ * /model <alias> — Set model override for this session
7
+ * /model clear — Remove override, revert to configured model
8
+ */
9
+
10
+ import type { CommandContext } from '../command-handler'
11
+ import { getModelInfo } from '../model-catalog'
12
+
13
+ /** Well-known aliases for user convenience */
14
+ const ALIASES: Record<string, string> = {
15
+ opus: 'claude-opus-4-6',
16
+ sonnet: 'claude-sonnet-4-6',
17
+ 'sonnet-4.5': 'claude-sonnet-4-5',
18
+ haiku: 'claude-haiku-4-5',
19
+ }
20
+
21
+ export function modelCommand(args: string, ctx: CommandContext): string {
22
+ const requestedModel = args.trim()
23
+
24
+ // No arg: show current model + available aliases
25
+ if (!requestedModel) {
26
+ const lines: string[] = []
27
+ lines.push('**Current Model**')
28
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
29
+
30
+ const meta = ctx.getSessionMeta?.()
31
+ const sessionOverride = meta?.modelOverride as string | undefined
32
+ const effective = sessionOverride ?? ctx.channelPersona?.model ?? ctx.moon?.model ?? 'default'
33
+ const info = getModelInfo(effective)
34
+
35
+ lines.push(`**Active:** ${info.displayName} (\`${effective}\`)`)
36
+ if (sessionOverride) {
37
+ lines.push('_Override active for this session. Use `/model clear` to revert._')
38
+ }
39
+
40
+ lines.push('')
41
+ lines.push('**Available aliases:**')
42
+ for (const [alias, full] of Object.entries(ALIASES)) {
43
+ const aInfo = getModelInfo(full)
44
+ lines.push(` \`${alias}\` → ${aInfo.displayName}`)
45
+ }
46
+
47
+ return lines.join('\n')
48
+ }
49
+
50
+ // "clear" / "reset" subcommand: remove override
51
+ if (requestedModel === 'clear' || requestedModel === 'reset') {
52
+ if (!ctx.updateSessionMeta) {
53
+ return 'Session metadata not available.'
54
+ }
55
+ ctx.updateSessionMeta({ modelOverride: null })
56
+ const effective = ctx.channelPersona?.model ?? ctx.moon?.model ?? 'default'
57
+ return `Model override cleared. Reverted to: \`${effective}\``
58
+ }
59
+
60
+ // Set model override
61
+ if (!ctx.updateSessionMeta) {
62
+ return 'Session metadata not available.'
63
+ }
64
+
65
+ const resolved = ALIASES[requestedModel.toLowerCase()] ?? requestedModel
66
+ const info = getModelInfo(resolved)
67
+
68
+ ctx.updateSessionMeta({ modelOverride: resolved })
69
+
70
+ return `Model set to **${info.displayName}** (\`${resolved}\`) for this session.\nUse \`/model clear\` to revert.`
71
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * /ping — Quick latency check and daemon uptime.
3
+ */
4
+
5
+ import type { CommandContext } from '../command-handler'
6
+ import { formatDuration } from '../session-tracker'
7
+
8
+ export function pingCommand(_args: string, ctx: CommandContext): string {
9
+ const now = Date.now()
10
+ const latencyMs = now - ctx.message.timestamp.getTime()
11
+
12
+ const lines: string[] = []
13
+ lines.push('**Pong!**')
14
+
15
+ if (latencyMs >= 0 && latencyMs < 60_000) {
16
+ lines.push(`Latency: ${latencyMs}ms`)
17
+ }
18
+
19
+ if (ctx.daemonStartedAt > 0) {
20
+ lines.push(`Uptime: ${formatDuration(now - ctx.daemonStartedAt)}`)
21
+ }
22
+
23
+ return lines.join('\n')
24
+ }
@@ -0,0 +1,75 @@
1
+ import type { CommandContext, CommandFn } from '../command-handler'
2
+ import type { Job } from '../scheduler'
3
+
4
+ export const remindCommand: CommandFn = async (args: string, ctx: CommandContext) => {
5
+ if (!ctx.scheduler) {
6
+ return '❌ Scheduler is not enabled in config.yaml'
7
+ }
8
+
9
+ if (!args.trim()) {
10
+ return 'Usage: `/remind <time> <message>`\nExamples:\n- `/remind in 15m Turn off oven`\n- `/remind in 2h Check email`\n- `/remind 2026-03-01T15:30 Meeting time`\n- `/remind 14:30 Daily summary`'
11
+ }
12
+
13
+ const match = args.match(
14
+ /^(in\s+\d+[mh]|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(?:Z|[+-]\d{2}:\d{2})?|\d{2}:\d{2})\s+(.+)$/i,
15
+ )
16
+ if (!match) {
17
+ return '❌ Invalid format. Use `in 15m <msg>`, `in 2h <msg>`, `HH:MM <msg>` or `YYYY-MM-DDTHH:MMZ <msg>`.'
18
+ }
19
+
20
+ let atTime: Date
21
+ const timeStr = match[1]
22
+ const message = match[3] // match[3] since the optional (:ss)? is 2, wait, let's just split by the first space.
23
+
24
+ // Re-parse to avoid capturing group index issues
25
+ const firstSpace = args.search(/\s+(.+)/)
26
+ let rawTime = args.trim()
27
+ let rawMsg = ''
28
+
29
+ if (args.toLowerCase().startsWith('in ')) {
30
+ const nextSpace = args.indexOf(' ', 3)
31
+ rawTime = args.substring(0, nextSpace)
32
+ rawMsg = args.substring(nextSpace + 1)
33
+ } else {
34
+ const nextSpace = args.indexOf(' ')
35
+ rawTime = args.substring(0, nextSpace)
36
+ rawMsg = args.substring(nextSpace + 1)
37
+ }
38
+
39
+ if (rawTime.toLowerCase().startsWith('in ')) {
40
+ const amountStr = rawTime.substring(3).trim()
41
+ const amount = Number.parseInt(amountStr.slice(0, -1), 10)
42
+ const unit = amountStr.slice(-1).toLowerCase()
43
+ const ms = unit === 'h' ? amount * 3600000 : amount * 60000
44
+ atTime = new Date(Date.now() + ms)
45
+ } else if (rawTime.includes('T')) {
46
+ atTime = new Date(rawTime)
47
+ } else {
48
+ // HH:MM today
49
+ const [h, m] = rawTime.split(':').map(Number)
50
+ atTime = new Date()
51
+ atTime.setHours(h, m, 0, 0)
52
+ if (atTime.getTime() < Date.now()) {
53
+ // If time already passed today, assume tomorrow
54
+ atTime.setDate(atTime.getDate() + 1)
55
+ }
56
+ }
57
+
58
+ if (Number.isNaN(atTime.getTime())) {
59
+ return '❌ Invalid time format.'
60
+ }
61
+
62
+ const job: Job = {
63
+ id: crypto.randomUUID(),
64
+ schedule: { kind: 'at', at: atTime.toISOString() },
65
+ payload: { kind: 'message', text: rawMsg.trim() },
66
+ channel: ctx.channelPersona?.name || 'orbit',
67
+ enabled: true,
68
+ oneShot: true,
69
+ createdAt: Math.floor(Date.now() / 1000),
70
+ }
71
+
72
+ ctx.scheduler.getStore().add(job)
73
+
74
+ return `✅ Reminder set for **${atTime.toLocaleString()}**`
75
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * /status — Session status command.
3
+ *
4
+ * Displays real-time session metrics:
5
+ * - Model, channel info
6
+ * - Tokens: actual new tokens (non-cached input + output)
7
+ * - Cache: hit rate, cached reads, new cache writes
8
+ * - Context: window usage from last API call
9
+ * - Account quota: 5h/7d usage windows (from Anthropic API)
10
+ * - Cost, duration, memory, uptime
11
+ */
12
+
13
+ import type { CommandContext } from '../command-handler'
14
+ import { getModelInfo } from '../model-catalog'
15
+ import { buildProgressBar, formatDuration, formatTokens } from '../session-tracker'
16
+
17
+ export function statusCommand(_args: string, ctx: CommandContext): string {
18
+ const status = ctx.tracker.getStatus(ctx.sessionKey)
19
+ const moonName = ctx.moon?.name ?? 'none'
20
+ const channelName = ctx.channelPersona?.name ?? `channel-${ctx.message.channelId.slice(-6)}`
21
+ const platform = ctx.channelPersona?.platform ?? 'unknown'
22
+
23
+ const lines: string[] = []
24
+
25
+ // ═══════════════════════════════════════════
26
+ // Header
27
+ // ═══════════════════════════════════════════
28
+
29
+ lines.push(`🌑 **${moonName}** — Status`)
30
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
31
+
32
+ // ═══════════════════════════════════════════
33
+ // System info
34
+ // ═══════════════════════════════════════════
35
+
36
+ // Model: show actual (from API) or configured (from moon), with context1m badge
37
+ const moonModel = ctx.moon?.model
38
+ const isContext1m = ctx.moon?.context1m === true
39
+
40
+ if (status?.usage.model) {
41
+ const modelInfo = getModelInfo(status.usage.model)
42
+ const context1mBadge = isContext1m ? ' · **1M context**' : ''
43
+ lines.push(`🤖 **Model:** ${modelInfo.displayName}${context1mBadge}`)
44
+ } else if (moonModel) {
45
+ const context1mBadge = isContext1m ? ' · **1M context**' : ''
46
+ lines.push(`🤖 **Model:** ${moonModel} (configured)${context1mBadge}`)
47
+ } else {
48
+ lines.push('🤖 **Model:** not yet determined')
49
+ }
50
+
51
+ lines.push(`📡 **Channel:** #${channelName} (${platform})`)
52
+
53
+ // ═══════════════════════════════════════════
54
+ // Session usage
55
+ // ═══════════════════════════════════════════
56
+
57
+ if (status) {
58
+ const { usage } = status
59
+ const turns = usage.queryCount
60
+
61
+ // Tokens: actual new tokens (non-cached)
62
+ lines.push(
63
+ `🔢 **Tokens:** ${formatTokens(usage.inputTokens)} in / ${formatTokens(usage.outputTokens)} out`,
64
+ )
65
+
66
+ // Cache: hit rate + breakdown
67
+ const totalCache = usage.cacheReadTokens + usage.cacheWriteTokens
68
+ if (totalCache > 0) {
69
+ const totalInput = usage.inputTokens + totalCache
70
+ const hitRate = totalInput > 0 ? Math.round((usage.cacheReadTokens / totalInput) * 100) : 0
71
+ lines.push(
72
+ `💾 **Cache:** ${hitRate}% hit · ${formatTokens(usage.cacheReadTokens)} cached, ${formatTokens(usage.cacheWriteTokens)} new`,
73
+ )
74
+ }
75
+
76
+ // Context: actual window usage from last API call
77
+ const pct = Math.round(status.contextUsedPercent)
78
+ const bar = buildProgressBar(pct, 10)
79
+ lines.push(
80
+ `📊 **Context:** ${bar} ${formatTokens(usage.lastContextTokens)}/${formatTokens(status.contextWindowSize)} (${pct}%)`,
81
+ )
82
+
83
+ // Cost estimate
84
+ const costStr =
85
+ status.estimatedCostUsd < 0.01 ? '<$0.01' : `~$${status.estimatedCostUsd.toFixed(2)}`
86
+ lines.push(`💰 **Cost:** ${costStr}`)
87
+
88
+ // Session info
89
+ lines.push(
90
+ `💬 **Session:** ${usage.messageCount} messages · ${turns} turn${turns !== 1 ? 's' : ''} · ${status.sessionAge}`,
91
+ )
92
+
93
+ // API time
94
+ if (usage.totalApiDurationMs > 0) {
95
+ lines.push(`⏱️ **API time:** ${formatDuration(usage.totalApiDurationMs)}`)
96
+ }
97
+ } else {
98
+ lines.push('')
99
+ lines.push('_No queries yet in this session._')
100
+ }
101
+
102
+ // ═══════════════════════════════════════════
103
+ // System footer
104
+ // ═══════════════════════════════════════════
105
+
106
+ const systemParts: string[] = []
107
+ if (ctx.memory) systemParts.push(`Memory: ${ctx.memory.id} ✅`)
108
+ if (ctx.daemonStartedAt > 0) {
109
+ systemParts.push(`Uptime: ${formatDuration(Date.now() - ctx.daemonStartedAt)}`)
110
+ }
111
+ systemParts.push(`Agent: ${ctx.agent.name}`)
112
+
113
+ if (systemParts.length > 0) {
114
+ lines.push(`⚙️ ${systemParts.join(' · ')}`)
115
+ }
116
+
117
+ return lines.join('\n')
118
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * /stop — Cancel the currently running query.
3
+ *
4
+ * Kills the active CLI subprocess for the agent handling this channel.
5
+ * The in-flight query will terminate and return an error/empty response.
6
+ */
7
+
8
+ import type { CommandContext } from '../command-handler'
9
+
10
+ export function stopCommand(_args: string, ctx: CommandContext): string {
11
+ if (!ctx.abortAgent) {
12
+ return 'Stop not available for this agent.'
13
+ }
14
+
15
+ ctx.abortAgent()
16
+
17
+ return 'Stopping current query...'
18
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * /think — Set the thinking level for the current session.
3
+ *
4
+ * Usage:
5
+ * /think — Show current level
6
+ * /think <level> — Set level (off, low, medium, high)
7
+ */
8
+
9
+ import type { CommandContext } from '../command-handler'
10
+
11
+ const VALID_LEVELS = ['off', 'low', 'medium', 'high'] as const
12
+
13
+ const DESCRIPTIONS: Record<string, string> = {
14
+ off: 'Thinking disabled — fastest responses',
15
+ low: 'Brief internal reasoning',
16
+ medium: 'Moderate step-by-step reasoning',
17
+ high: 'Deep extended thinking — most thorough',
18
+ }
19
+
20
+ export function thinkCommand(args: string, ctx: CommandContext): string {
21
+ const level = args.trim().toLowerCase()
22
+
23
+ // No arg: show current level
24
+ if (!level) {
25
+ const meta = ctx.getSessionMeta?.()
26
+ const current = (meta?.thinkingLevel as string) ?? 'default'
27
+
28
+ return `**Thinking level:** ${current}\nAvailable: ${VALID_LEVELS.join(', ')}\nUsage: \`/think <level>\``
29
+ }
30
+
31
+ if (!(VALID_LEVELS as readonly string[]).includes(level)) {
32
+ return `Invalid level: \`${level}\`. Available: ${VALID_LEVELS.join(', ')}`
33
+ }
34
+
35
+ if (!ctx.updateSessionMeta) {
36
+ return 'Session metadata not available.'
37
+ }
38
+
39
+ ctx.updateSessionMeta({ thinkingLevel: level })
40
+
41
+ return `Thinking set to **${level}** for this session.\n_${DESCRIPTIONS[level]}_`
42
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * /usage — Show session usage and account quota.
3
+ *
4
+ * Session: tokens, turns, cost (from SessionTracker).
5
+ * Account: 5h/7d rolling windows (from Anthropic OAuth API).
6
+ */
7
+
8
+ import type { CommandContext } from '../command-handler'
9
+ import { buildProgressBar, formatDuration, formatTokens } from '../session-tracker'
10
+ import { fetchAccountUsage, hasOAuthCredentials } from '../usage-api'
11
+
12
+ export async function usageCommand(_args: string, ctx: CommandContext): Promise<string> {
13
+ const lines: string[] = []
14
+ const moonName = ctx.moon?.name ?? 'Lunar'
15
+
16
+ lines.push(`**${moonName}** — Usage`)
17
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
18
+
19
+ const status = ctx.tracker.getStatus(ctx.sessionKey)
20
+ if (status) {
21
+ const { usage } = status
22
+ lines.push('**Session:**')
23
+ lines.push(` Model: ${status.modelDisplayName}`)
24
+ lines.push(` Tokens: ${formatTokens(usage.inputTokens)} in / ${formatTokens(usage.outputTokens)} out`)
25
+ lines.push(` Turns: ${usage.queryCount} · Messages: ${usage.messageCount}`)
26
+ const costStr = status.estimatedCostUsd < 0.01 ? '<$0.01' : `~$${status.estimatedCostUsd.toFixed(2)}`
27
+ lines.push(` Cost: ${costStr} · Duration: ${status.sessionAge}`)
28
+ } else {
29
+ lines.push('**Session:** no queries yet')
30
+ }
31
+
32
+ if (hasOAuthCredentials()) {
33
+ const account = await fetchAccountUsage()
34
+ if (account) {
35
+ lines.push('')
36
+ lines.push('**Account Quota:**')
37
+
38
+ if (account.fiveHour) {
39
+ const bar = buildProgressBar(account.fiveHour.percent, 10)
40
+ lines.push(` 5h: ${bar} ${account.fiveHour.percent}% (${formatTokens(account.fiveHour.used)}/${formatTokens(account.fiveHour.limit)})`)
41
+ }
42
+ if (account.sevenDay) {
43
+ const bar = buildProgressBar(account.sevenDay.percent, 10)
44
+ lines.push(` 7d: ${bar} ${account.sevenDay.percent}% (${formatTokens(account.sevenDay.used)}/${formatTokens(account.sevenDay.limit)})`)
45
+ }
46
+ } else {
47
+ lines.push('')
48
+ lines.push('**Account:** could not fetch quota')
49
+ }
50
+ } else {
51
+ lines.push('')
52
+ lines.push('**Account:** no OAuth credentials (quota unavailable)')
53
+ }
54
+
55
+ return lines.join('\n')
56
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * /whoami — Show sender identity and current channel info.
3
+ */
4
+
5
+ import type { CommandContext } from '../command-handler'
6
+
7
+ export function whoamiCommand(_args: string, ctx: CommandContext): string {
8
+ const { sender } = ctx.message
9
+ const platform = ctx.channelPersona?.platform ?? 'unknown'
10
+ const channel = ctx.channelPersona?.name ?? ctx.message.channelId
11
+
12
+ const lines: string[] = []
13
+ lines.push('**Who Am I**')
14
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
15
+ lines.push(`**Name:** ${sender.name}`)
16
+ if (sender.username) lines.push(`**Username:** ${sender.username}`)
17
+ lines.push(`**ID:** \`${sender.id}\``)
18
+ lines.push(`**Platform:** ${platform}`)
19
+ lines.push(`**Channel:** #${channel}`)
20
+ lines.push(`**Agent:** ${ctx.agent.name} (\`${ctx.agent.id}\`)`)
21
+
22
+ return lines.join('\n')
23
+ }