@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 +1 -1
- package/src/__tests__/config-loader.test.ts +136 -0
- package/src/__tests__/router.test.ts +268 -0
- package/src/lib/config-loader.ts +10 -0
- package/src/lib/daemon.ts +4 -1
- package/src/lib/router.ts +48 -3
- package/src/types/moon.ts +3 -0
package/package.json
CHANGED
|
@@ -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', () => {
|
package/src/lib/config-loader.ts
CHANGED
|
@@ -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
|
}
|