@onmars/lunar-core 0.1.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onmars/lunar-core",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -1510,3 +1510,139 @@ channels:
1510
1510
  expect(notifications.agentId).toBe('hermes')
1511
1511
  })
1512
1512
  })
1513
+
1514
+ // ═══════════════════════════════════════════════════════════════════
1515
+ // Thinking config (agent-level + channel-level)
1516
+ // ═══════════════════════════════════════════════════════════════════
1517
+
1518
+ describe('thinking config', () => {
1519
+ it('parses thinking field from agent entry', () => {
1520
+ const config = loadWorkspaceConfig(
1521
+ setup(`
1522
+ agents:
1523
+ deimos:
1524
+ workspace: "."
1525
+ thinking: high
1526
+ default: true
1527
+ hermes:
1528
+ workspace: "moons/hermes"
1529
+ thinking: off
1530
+ `),
1531
+ )
1532
+
1533
+ expect(config.agents[0].thinking).toBe('high')
1534
+ expect(config.agents[1].thinking).toBe('off')
1535
+ })
1536
+
1537
+ it('agent without thinking field has undefined thinking', () => {
1538
+ const config = loadWorkspaceConfig(
1539
+ setup(`
1540
+ agents:
1541
+ deimos:
1542
+ workspace: "."
1543
+ default: true
1544
+ `),
1545
+ )
1546
+
1547
+ expect(config.agents[0].thinking).toBeUndefined()
1548
+ })
1549
+
1550
+ it('ignores invalid thinking values on agents', () => {
1551
+ const config = loadWorkspaceConfig(
1552
+ setup(`
1553
+ agents:
1554
+ deimos:
1555
+ workspace: "."
1556
+ thinking: extreme
1557
+ default: true
1558
+ `),
1559
+ )
1560
+
1561
+ expect(config.agents[0].thinking).toBeUndefined()
1562
+ })
1563
+
1564
+ it('parses thinking field from channel persona', () => {
1565
+ process.env.T_THINK = 'token'
1566
+
1567
+ const config = loadWorkspaceConfig(
1568
+ setup(`
1569
+ platforms:
1570
+ discord:
1571
+ token: "\${T_THINK}"
1572
+ channels:
1573
+ orbit:
1574
+ id: "111"
1575
+ thinking: medium
1576
+ research:
1577
+ id: "222"
1578
+ thinking: high
1579
+ general:
1580
+ id: "333"
1581
+ `),
1582
+ )
1583
+
1584
+ const orbit = config.channels.find((c) => c.name === 'orbit')!
1585
+ expect(orbit.thinking).toBe('medium')
1586
+
1587
+ const research = config.channels.find((c) => c.name === 'research')!
1588
+ expect(research.thinking).toBe('high')
1589
+
1590
+ const general = config.channels.find((c) => c.name === 'general')!
1591
+ expect(general.thinking).toBeUndefined()
1592
+
1593
+ delete process.env.T_THINK
1594
+ })
1595
+
1596
+ it('ignores invalid thinking values on channels', () => {
1597
+ process.env.T_THINK2 = 'token'
1598
+
1599
+ const config = loadWorkspaceConfig(
1600
+ setup(`
1601
+ platforms:
1602
+ discord:
1603
+ token: "\${T_THINK2}"
1604
+ channels:
1605
+ orbit:
1606
+ id: "111"
1607
+ thinking: turbo
1608
+ `),
1609
+ )
1610
+
1611
+ const orbit = config.channels.find((c) => c.name === 'orbit')!
1612
+ expect(orbit.thinking).toBeUndefined()
1613
+
1614
+ delete process.env.T_THINK2
1615
+ })
1616
+
1617
+ it('accepts all valid thinking levels', () => {
1618
+ process.env.T_THINK3 = 'token'
1619
+
1620
+ const config = loadWorkspaceConfig(
1621
+ setup(`
1622
+ platforms:
1623
+ discord:
1624
+ token: "\${T_THINK3}"
1625
+ channels:
1626
+ a:
1627
+ id: "1"
1628
+ thinking: "off"
1629
+ b:
1630
+ id: "2"
1631
+ thinking: "low"
1632
+ c:
1633
+ id: "3"
1634
+ thinking: "medium"
1635
+ d:
1636
+ id: "4"
1637
+ thinking: "high"
1638
+ `),
1639
+ )
1640
+
1641
+ expect(config.channels.find((c) => c.name === 'a')!.thinking).toBe('off')
1642
+ expect(config.channels.find((c) => c.name === 'b')!.thinking).toBe('low')
1643
+ expect(config.channels.find((c) => c.name === 'c')!.thinking).toBe('medium')
1644
+ expect(config.channels.find((c) => c.name === 'd')!.thinking).toBe('high')
1645
+
1646
+ delete process.env.T_THINK3
1647
+ })
1648
+ })
@@ -1066,6 +1066,274 @@ describe('Router', () => {
1066
1066
  })
1067
1067
  })
1068
1068
 
1069
+ // ─── Thinking resolution chain ──────────────────────────────
1070
+
1071
+ describe('thinking resolution chain — session > channel > agent > undefined', () => {
1072
+ it('uses channel-level thinking when no session override', async () => {
1073
+ const spy = createSpyAgent()
1074
+ registry.registerAgent(spy, true)
1075
+
1076
+ const channels: import('../types/moon').ChannelPersona[] = [
1077
+ { id: 'chan-1', name: 'orbit', platform: 'discord', thinking: 'high' },
1078
+ ]
1079
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
1080
+
1081
+ await router.route('discord', msg('Hello'))
1082
+
1083
+ expect(spy.capturedInput.thinking).toBe('high')
1084
+ })
1085
+
1086
+ it('uses agent-level thinking when no channel or session override', async () => {
1087
+ const spy = createSpyAgent()
1088
+ registry.registerAgent(spy, true)
1089
+
1090
+ const channels: import('../types/moon').ChannelPersona[] = [
1091
+ { id: 'chan-1', name: 'orbit', platform: 'discord' },
1092
+ ]
1093
+ const agents = [{ id: 'claude', thinking: 'medium', default: true }]
1094
+ const router = new Router(
1095
+ registry,
1096
+ sessions,
1097
+ mockConfig,
1098
+ moonLoader,
1099
+ channels,
1100
+ undefined,
1101
+ undefined,
1102
+ undefined,
1103
+ undefined,
1104
+ undefined,
1105
+ undefined,
1106
+ undefined,
1107
+ agents as any,
1108
+ )
1109
+
1110
+ await router.route('discord', msg('Hello'))
1111
+
1112
+ expect(spy.capturedInput.thinking).toBe('medium')
1113
+ })
1114
+
1115
+ it('channel thinking takes priority over agent thinking', async () => {
1116
+ const spy = createSpyAgent()
1117
+ registry.registerAgent(spy, true)
1118
+
1119
+ const channels: import('../types/moon').ChannelPersona[] = [
1120
+ { id: 'chan-1', name: 'orbit', platform: 'discord', thinking: 'low' },
1121
+ ]
1122
+ const agents = [{ id: 'claude', thinking: 'high', default: true }]
1123
+ const router = new Router(
1124
+ registry,
1125
+ sessions,
1126
+ mockConfig,
1127
+ moonLoader,
1128
+ channels,
1129
+ undefined,
1130
+ undefined,
1131
+ undefined,
1132
+ undefined,
1133
+ undefined,
1134
+ undefined,
1135
+ undefined,
1136
+ agents as any,
1137
+ )
1138
+
1139
+ await router.route('discord', msg('Hello'))
1140
+
1141
+ expect(spy.capturedInput.thinking).toBe('low')
1142
+ })
1143
+
1144
+ it('session thinking override takes priority over all', async () => {
1145
+ const spy = createSpyAgent()
1146
+ registry.registerAgent(spy, true)
1147
+
1148
+ const channels: import('../types/moon').ChannelPersona[] = [
1149
+ { id: 'chan-1', name: 'orbit', platform: 'discord', thinking: 'low' },
1150
+ ]
1151
+ const agents = [{ id: 'claude', thinking: 'medium', default: true }]
1152
+ const router = new Router(
1153
+ registry,
1154
+ sessions,
1155
+ mockConfig,
1156
+ moonLoader,
1157
+ channels,
1158
+ undefined,
1159
+ undefined,
1160
+ undefined,
1161
+ undefined,
1162
+ undefined,
1163
+ undefined,
1164
+ undefined,
1165
+ agents as any,
1166
+ )
1167
+
1168
+ // Set session-level thinking override (simulates /think command)
1169
+ const sessionKey = 'claude:discord:chan-1'
1170
+ sessions.get(sessionKey) // ensure session exists
1171
+ sessions.updateMetadata(sessionKey, JSON.stringify({ thinkingLevel: 'off' }))
1172
+
1173
+ await router.route('discord', msg('Hello'))
1174
+
1175
+ expect(spy.capturedInput.thinking).toBe('off')
1176
+ })
1177
+
1178
+ it('thinking is undefined when no level set anywhere', async () => {
1179
+ const spy = createSpyAgent()
1180
+ registry.registerAgent(spy, true)
1181
+
1182
+ const channels: import('../types/moon').ChannelPersona[] = [
1183
+ { id: 'chan-1', name: 'orbit', platform: 'discord' },
1184
+ ]
1185
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
1186
+
1187
+ await router.route('discord', msg('Hello'))
1188
+
1189
+ expect(spy.capturedInput.thinking).toBeUndefined()
1190
+ })
1191
+ })
1192
+
1193
+ // ─── Attachment download and injection ──────────────────────
1194
+
1195
+ describe('image/file attachment handling', () => {
1196
+ it('downloads image attachment and prepends path to prompt', async () => {
1197
+ const spy = createSpyAgent()
1198
+ registry.registerAgent(spy, true)
1199
+
1200
+ const channels: import('../types/moon').ChannelPersona[] = [
1201
+ { id: 'chan-1', name: 'orbit', platform: 'discord' },
1202
+ ]
1203
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
1204
+
1205
+ // Mock global fetch to return fake image data
1206
+ const originalFetch = globalThis.fetch
1207
+ globalThis.fetch = (async () =>
1208
+ new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
1209
+ status: 200,
1210
+ })) as unknown as typeof fetch
1211
+
1212
+ try {
1213
+ const message: IncomingMessage = {
1214
+ id: 'msg-1',
1215
+ channelId: 'chan-1',
1216
+ text: 'What is this?',
1217
+ sender: { id: 'user-123', name: 'Test User' },
1218
+ timestamp: new Date(),
1219
+ attachments: [
1220
+ {
1221
+ type: 'image',
1222
+ url: 'https://cdn.discord.com/attachments/test/image.png',
1223
+ filename: 'photo.png',
1224
+ mimeType: 'image/png',
1225
+ },
1226
+ ],
1227
+ }
1228
+
1229
+ await router.route('discord', message)
1230
+
1231
+ expect(spy.capturedInput).not.toBeNull()
1232
+ expect(spy.capturedInput.prompt).toContain('[media attached:')
1233
+ expect(spy.capturedInput.prompt).toContain('.png')
1234
+ expect(spy.capturedInput.prompt).toContain('What is this?')
1235
+ } finally {
1236
+ globalThis.fetch = originalFetch
1237
+ }
1238
+ })
1239
+
1240
+ it('handles multiple attachments', async () => {
1241
+ const spy = createSpyAgent()
1242
+ registry.registerAgent(spy, true)
1243
+
1244
+ const channels: import('../types/moon').ChannelPersona[] = [
1245
+ { id: 'chan-1', name: 'orbit', platform: 'discord' },
1246
+ ]
1247
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
1248
+
1249
+ const originalFetch = globalThis.fetch
1250
+ globalThis.fetch = (async () =>
1251
+ new Response(new Uint8Array([0x00, 0x01]), { status: 200 })) as unknown as typeof fetch
1252
+
1253
+ try {
1254
+ const message: IncomingMessage = {
1255
+ id: 'msg-1',
1256
+ channelId: 'chan-1',
1257
+ text: 'Check these',
1258
+ sender: { id: 'user-123', name: 'Test User' },
1259
+ timestamp: new Date(),
1260
+ attachments: [
1261
+ { type: 'image', url: 'https://example.com/a.png', filename: 'a.png', mimeType: 'image/png' },
1262
+ { type: 'file', url: 'https://example.com/b.pdf', filename: 'b.pdf', mimeType: 'application/pdf' },
1263
+ ],
1264
+ }
1265
+
1266
+ await router.route('discord', message)
1267
+
1268
+ expect(spy.capturedInput.prompt).toContain('.png')
1269
+ expect(spy.capturedInput.prompt).toContain('.pdf')
1270
+ expect(spy.capturedInput.prompt).toContain('Check these')
1271
+ } finally {
1272
+ globalThis.fetch = originalFetch
1273
+ }
1274
+ })
1275
+
1276
+ it('skips audio attachments (handled by STT)', async () => {
1277
+ const spy = createSpyAgent()
1278
+ registry.registerAgent(spy, true)
1279
+
1280
+ const channels: import('../types/moon').ChannelPersona[] = [
1281
+ { id: 'chan-1', name: 'orbit', platform: 'discord' },
1282
+ ]
1283
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
1284
+
1285
+ const message: IncomingMessage = {
1286
+ id: 'msg-1',
1287
+ channelId: 'chan-1',
1288
+ text: 'Hello',
1289
+ sender: { id: 'user-123', name: 'Test User' },
1290
+ timestamp: new Date(),
1291
+ attachments: [
1292
+ { type: 'audio', url: 'https://example.com/voice.ogg', filename: 'voice.ogg' },
1293
+ ],
1294
+ }
1295
+
1296
+ await router.route('discord', message)
1297
+
1298
+ // Audio should not be downloaded by the image handler (no fetch mock needed)
1299
+ expect(spy.capturedInput.prompt).toBe('Hello')
1300
+ })
1301
+
1302
+ it('continues gracefully when attachment download fails', async () => {
1303
+ const spy = createSpyAgent()
1304
+ registry.registerAgent(spy, true)
1305
+
1306
+ const channels: import('../types/moon').ChannelPersona[] = [
1307
+ { id: 'chan-1', name: 'orbit', platform: 'discord' },
1308
+ ]
1309
+ const router = new Router(registry, sessions, mockConfig, moonLoader, channels)
1310
+
1311
+ const originalFetch = globalThis.fetch
1312
+ globalThis.fetch = (async () =>
1313
+ new Response(null, { status: 404 })) as unknown as typeof fetch
1314
+
1315
+ try {
1316
+ const message: IncomingMessage = {
1317
+ id: 'msg-1',
1318
+ channelId: 'chan-1',
1319
+ text: 'Check this',
1320
+ sender: { id: 'user-123', name: 'Test User' },
1321
+ timestamp: new Date(),
1322
+ attachments: [
1323
+ { type: 'image', url: 'https://example.com/broken.png', filename: 'broken.png' },
1324
+ ],
1325
+ }
1326
+
1327
+ await router.route('discord', message)
1328
+
1329
+ // Should still send the text without attachment reference
1330
+ expect(spy.capturedInput.prompt).toBe('Check this')
1331
+ } finally {
1332
+ globalThis.fetch = originalFetch
1333
+ }
1334
+ })
1335
+ })
1336
+
1069
1337
  // ─── Multi-agent routing (Phase B) ────────────────────────
1070
1338
 
1071
1339
  describe('multi-agent routing — channelPersona.agentId directs to correct agent', () => {
@@ -260,6 +260,8 @@ export interface AgentEntry {
260
260
  binary?: string
261
261
  /** Whether to mark as default agent (first = default if not specified) */
262
262
  default?: boolean
263
+ /** Default thinking level for this agent (off, low, medium, high) */
264
+ thinking?: string
263
265
  }
264
266
 
265
267
  /**
@@ -648,6 +650,10 @@ function parsePlatformChannels(
648
650
  model: typeof ch.model === 'string' ? ch.model : undefined,
649
651
  skills: Array.isArray(ch.skills) ? (ch.skills as unknown[]).map(String) : undefined,
650
652
  agentId: typeof ch.agent === 'string' ? ch.agent : undefined,
653
+ thinking:
654
+ typeof ch.thinking === 'string' && ['off', 'low', 'medium', 'high'].includes(ch.thinking)
655
+ ? ch.thinking
656
+ : undefined,
651
657
  })
652
658
  }
653
659
 
@@ -1187,6 +1193,9 @@ function parseAgents(raw: unknown): AgentEntry[] {
1187
1193
  const isDefault = obj.default === true
1188
1194
  if (isDefault) hasExplicitDefault = true
1189
1195
 
1196
+ const thinking = typeof obj.thinking === 'string' ? obj.thinking : undefined
1197
+ const validThinking = thinking && ['off', 'low', 'medium', 'high'].includes(thinking) ? thinking : undefined
1198
+
1190
1199
  const entry: AgentEntry = {
1191
1200
  id,
1192
1201
  workspace: typeof obj.workspace === 'string' ? obj.workspace : undefined,
@@ -1194,6 +1203,7 @@ function parseAgents(raw: unknown): AgentEntry[] {
1194
1203
  context1m: obj.context1m === true ? true : undefined,
1195
1204
  binary: typeof obj.binary === 'string' ? obj.binary : undefined,
1196
1205
  default: isFirst && !hasExplicitDefault ? true : isDefault,
1206
+ thinking: validThinking,
1197
1207
  }
1198
1208
 
1199
1209
  agents.push(entry)
package/src/lib/daemon.ts CHANGED
@@ -3,7 +3,7 @@ import { dirname, resolve } from 'node:path'
3
3
  import type { MemoryProvider } from '../types/memory'
4
4
  import type { ChannelPersona } from '../types/moon'
5
5
  import type { LunarConfig } from './config'
6
- import type { MemoryConfig, RecallConfig, SecurityConfig, VoiceConfig } from './config-loader'
6
+ import type { AgentEntry, MemoryConfig, RecallConfig, SecurityConfig, VoiceConfig } from './config-loader'
7
7
  import { log } from './logger'
8
8
  import type { MoonLoader } from './moon-loader'
9
9
  import type { PluginRegistry } from './plugin'
@@ -31,6 +31,8 @@ export interface DaemonOptions {
31
31
  scheduler?: Scheduler
32
32
  /** Voice configuration (TTS mode, STT mode) */
33
33
  voiceConfig?: VoiceConfig
34
+ /** Agent entries (for agent-level defaults like thinking) */
35
+ agents?: AgentEntry[]
34
36
  }
35
37
 
36
38
  /**
@@ -61,6 +63,7 @@ export class Daemon {
61
63
  options.memoryConfig,
62
64
  options.scheduler,
63
65
  options.voiceConfig,
66
+ options.agents,
64
67
  )
65
68
  }
66
69
 
package/src/lib/router.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { existsSync, readFileSync } from 'node:fs'
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { randomUUID } from 'node:crypto'
2
3
  import { resolve } from 'node:path'
3
4
  import type { AgentEvent, AgentInput } from '../types/agent'
4
5
  import type { IncomingMessage, OutgoingMessage } from '../types/channel'
@@ -19,7 +20,7 @@ import { thinkCommand } from './commands/think'
19
20
  import { usageCommand } from './commands/usage'
20
21
  import { whoamiCommand } from './commands/whoami'
21
22
  import type { LunarConfig } from './config'
22
- import type { MemoryConfig, RecallConfig, SecurityConfig, VoiceConfig } from './config-loader'
23
+ import type { AgentEntry, MemoryConfig, RecallConfig, SecurityConfig, VoiceConfig } from './config-loader'
23
24
  import { log } from './logger'
24
25
  import type { MemoryOrchestrator } from './memory-orchestrator'
25
26
  import type { MoonLoader } from './moon-loader'
@@ -67,6 +68,9 @@ export class Router {
67
68
 
68
69
  private voiceConfig?: VoiceConfig
69
70
 
71
+ /** Agent-level thinking defaults: agentId → thinking level */
72
+ private agentThinkingMap = new Map<string, string>()
73
+
70
74
  /** Slash command handler */
71
75
  private commandHandler: CommandHandler
72
76
 
@@ -86,6 +90,7 @@ export class Router {
86
90
  private memoryConfig?: MemoryConfig,
87
91
  private scheduler?: Scheduler,
88
92
  voiceConfig?: VoiceConfig,
93
+ agents?: AgentEntry[],
89
94
  ) {
90
95
  this.redact = security ? createRedactor(security) : (t: string) => t
91
96
  this.sanitize = security
@@ -101,6 +106,15 @@ export class Router {
101
106
  this.memoryInstructions = this.loadMemoryInstructions()
102
107
  this.voiceConfig = voiceConfig
103
108
 
109
+ // Build agent-level thinking defaults map
110
+ if (agents) {
111
+ for (const a of agents) {
112
+ if (a.thinking) {
113
+ this.agentThinkingMap.set(a.id, a.thinking)
114
+ }
115
+ }
116
+ }
117
+
104
118
  // Build index: platform:channelId → ChannelPersona
105
119
  // Also index by bare ID for backward compat (single-platform setups)
106
120
  this.channelIndex = new Map()
@@ -303,6 +317,37 @@ export class Router {
303
317
  }
304
318
  }
305
319
 
320
+ // ─── Image/File attachments: download and save ───────────────
321
+ const nonAudioAttachments =
322
+ message.attachments?.filter((a) => a.type !== 'audio') ?? []
323
+
324
+ if (nonAudioAttachments.length > 0) {
325
+ const mediaDir = resolve(this.config.dataDir, 'media', 'inbound')
326
+ mkdirSync(mediaDir, { recursive: true })
327
+
328
+ for (const att of nonAudioAttachments) {
329
+ try {
330
+ const buffer = await this.downloadAttachment(att.url)
331
+ const ext =
332
+ att.filename?.split('.').pop() ?? (att.type === 'image' ? 'png' : 'bin')
333
+ const filename = `${randomUUID()}.${ext}`
334
+ const filepath = resolve(mediaDir, filename)
335
+ writeFileSync(filepath, buffer)
336
+
337
+ const label = att.type === 'image' ? 'image' : att.type
338
+ effectiveText = `[media attached: ${filepath} (${att.mimeType ?? label}) | ${filepath}]\n${effectiveText}`
339
+
340
+ log.info(
341
+ { type: att.type, filename: att.filename, size: buffer.length, path: filepath },
342
+ 'Attachment saved',
343
+ )
344
+ } catch (err) {
345
+ const errMsg = err instanceof Error ? err.message : String(err)
346
+ log.warn({ error: errMsg, url: att.url }, 'Failed to download attachment — skipping')
347
+ }
348
+ }
349
+ }
350
+
306
351
  // Build system prompt from moon + channel persona
307
352
  let systemPrompt = moon ? this.moonLoader.buildSystemPrompt(moon, channelPersona) : undefined
308
353
 
@@ -333,7 +378,7 @@ export class Router {
333
378
  systemPrompt,
334
379
  model: sessionModelOverride ?? channelPersona?.model ?? moon?.model,
335
380
  context1m: moon?.context1m,
336
- thinking: sessionThinking,
381
+ thinking: sessionThinking ?? channelPersona?.thinking ?? this.agentThinkingMap.get(agent.id),
337
382
  metadata: {
338
383
  channelId: message.channelId,
339
384
  channelName: channelPersona?.name,
package/src/types/moon.ts CHANGED
@@ -53,4 +53,7 @@ export interface ChannelPersona {
53
53
  /** Which agent handles this channel. Must match a registered agent ID.
54
54
  * Omit = use default agent (first registered or marked as default). */
55
55
  agentId?: string
56
+ /** Per-channel thinking level override (off, low, medium, high).
57
+ * Takes priority over agent-level thinking. */
58
+ thinking?: string
56
59
  }