@onmars/lunar-core 0.4.5 → 0.5.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/lib/hook-runner.ts +29 -15
- package/src/lib/memory-orchestrator.ts +50 -1
- package/src/lib/router.ts +106 -20
- package/src/types/memory.ts +1 -1
package/package.json
CHANGED
package/src/lib/hook-runner.ts
CHANGED
|
@@ -42,7 +42,7 @@ export interface HookContext {
|
|
|
42
42
|
/** Current session message count */
|
|
43
43
|
messageCount: number
|
|
44
44
|
/** Session messages (for summarize, promote) */
|
|
45
|
-
messages?: Array<{ role: string; content: string }>
|
|
45
|
+
messages?: Array<{ role: string; content: string; timestamp?: string }>
|
|
46
46
|
/** Recall results — mutable (afterRecall boost modifies scores) */
|
|
47
47
|
results?: MemoryResult[]
|
|
48
48
|
/** Search query — mutable (beforeRecall can expand/modify) */
|
|
@@ -144,8 +144,22 @@ export function evaluateGuard(
|
|
|
144
144
|
// Constants
|
|
145
145
|
// ════════════════════════════════════════════════════════════
|
|
146
146
|
|
|
147
|
-
/** Maximum transcript characters sent to LLM for summarization (~
|
|
148
|
-
const MAX_TRANSCRIPT_CHARS =
|
|
147
|
+
/** Maximum transcript characters sent to LLM for summarization (~4000 tokens) */
|
|
148
|
+
const MAX_TRANSCRIPT_CHARS = 16000
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Smart truncation: if the full text fits, return it.
|
|
152
|
+
* Otherwise keep the first 20% and last 80% with a cut marker.
|
|
153
|
+
* This ensures recent context (decisions, conclusions) is always captured.
|
|
154
|
+
*/
|
|
155
|
+
function smartTruncate(text: string, maxChars: number): string {
|
|
156
|
+
if (text.length <= maxChars) return text
|
|
157
|
+
const marker = '\n\n[... middle of conversation truncated ...]\n\n'
|
|
158
|
+
const available = maxChars - marker.length
|
|
159
|
+
const headSize = Math.floor(available * 0.2)
|
|
160
|
+
const tailSize = available - headSize
|
|
161
|
+
return text.slice(0, headSize) + marker + text.slice(-tailSize)
|
|
162
|
+
}
|
|
149
163
|
|
|
150
164
|
/**
|
|
151
165
|
* System prompt for LLM-powered session summarization.
|
|
@@ -474,10 +488,10 @@ const executeSummarize: ActionExecutor = async (hook, context, providers) => {
|
|
|
474
488
|
|
|
475
489
|
if (!summary && context.llm && context.messages.length >= 2) {
|
|
476
490
|
try {
|
|
477
|
-
const transcript =
|
|
478
|
-
.map((m) => `${m.role}: ${m.content}`)
|
|
479
|
-
|
|
480
|
-
|
|
491
|
+
const transcript = smartTruncate(
|
|
492
|
+
context.messages.map((m) => `${m.role}: ${m.content}`).join('\n'),
|
|
493
|
+
MAX_TRANSCRIPT_CHARS,
|
|
494
|
+
)
|
|
481
495
|
|
|
482
496
|
log.debug(
|
|
483
497
|
{
|
|
@@ -647,10 +661,10 @@ const executeJournal: ActionExecutor = async (hook, context, providers) => {
|
|
|
647
661
|
}
|
|
648
662
|
|
|
649
663
|
try {
|
|
650
|
-
const transcript =
|
|
651
|
-
.map((m) => `${m.role}: ${m.content}`)
|
|
652
|
-
|
|
653
|
-
|
|
664
|
+
const transcript = smartTruncate(
|
|
665
|
+
context.messages.map((m) => `${m.role}: ${m.content}`).join('\n'),
|
|
666
|
+
MAX_TRANSCRIPT_CHARS,
|
|
667
|
+
)
|
|
654
668
|
|
|
655
669
|
const journal = await context.llm(JOURNAL_PROMPT + transcript)
|
|
656
670
|
|
|
@@ -723,10 +737,10 @@ const executeExperience: ActionExecutor = async (hook, context, providers) => {
|
|
|
723
737
|
}
|
|
724
738
|
|
|
725
739
|
try {
|
|
726
|
-
const transcript =
|
|
727
|
-
.map((m) => `${m.role}: ${m.content}`)
|
|
728
|
-
|
|
729
|
-
|
|
740
|
+
const transcript = smartTruncate(
|
|
741
|
+
context.messages.map((m) => `${m.role}: ${m.content}`).join('\n'),
|
|
742
|
+
MAX_TRANSCRIPT_CHARS,
|
|
743
|
+
)
|
|
730
744
|
|
|
731
745
|
const experience = await context.llm(EXPERIENCE_PROMPT + transcript)
|
|
732
746
|
|
|
@@ -151,6 +151,55 @@ export class MemoryOrchestrator implements MemoryProvider {
|
|
|
151
151
|
return this.diaryResults.length
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Refresh episode for a new session (called after /clear).
|
|
156
|
+
* Creates a new episode with today's date, replacing the stale one.
|
|
157
|
+
*/
|
|
158
|
+
async refreshEpisode(channel?: string): Promise<void> {
|
|
159
|
+
if (!this.hookRunner?.hasHooks('sessionStart')) return
|
|
160
|
+
|
|
161
|
+
const context: HookContext = {
|
|
162
|
+
messageCount: 0,
|
|
163
|
+
channel,
|
|
164
|
+
sessionDate: new Date().toISOString().split('T')[0],
|
|
165
|
+
}
|
|
166
|
+
const result = await this.hookRunner.run('sessionStart', context)
|
|
167
|
+
if (result.episodeId) {
|
|
168
|
+
this.currentEpisodeId = result.episodeId
|
|
169
|
+
this.currentSessionDate = result.sessionDate
|
|
170
|
+
log.info(
|
|
171
|
+
{ episodeId: result.episodeId, sessionDate: result.sessionDate },
|
|
172
|
+
'Episode refreshed after clear',
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
if (result.results?.length) {
|
|
176
|
+
this.diaryResults = result.results
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get or create an episode for a specific date (used by day-split in /clear).
|
|
182
|
+
* Does NOT change the current orchestrator state.
|
|
183
|
+
*/
|
|
184
|
+
async getEpisodeForDate(date: string, channel?: string): Promise<string | undefined> {
|
|
185
|
+
// Find a provider that supports getOrCreateEpisode (Brain provider)
|
|
186
|
+
const brain = [...this.providersByName.values()].find(
|
|
187
|
+
(p) => (p as any).getOrCreateEpisode !== undefined,
|
|
188
|
+
) as any
|
|
189
|
+
if (!brain?.getOrCreateEpisode) return undefined
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const episode = await brain.getOrCreateEpisode(date, channel)
|
|
193
|
+
return episode.id
|
|
194
|
+
} catch (err) {
|
|
195
|
+
log.warn(
|
|
196
|
+
{ error: err instanceof Error ? err.message : String(err), date },
|
|
197
|
+
'Failed to get episode for date',
|
|
198
|
+
)
|
|
199
|
+
return undefined
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
154
203
|
/**
|
|
155
204
|
* Destroy all providers.
|
|
156
205
|
*/
|
|
@@ -291,7 +340,7 @@ export class MemoryOrchestrator implements MemoryProvider {
|
|
|
291
340
|
* 3. afterSessionEnd hooks (promote → Engram facts → Brain)
|
|
292
341
|
*/
|
|
293
342
|
async onSessionEnd(context: {
|
|
294
|
-
messages: Array<{ role: string; content: string }>
|
|
343
|
+
messages: Array<{ role: string; content: string; timestamp?: string }>
|
|
295
344
|
sessionSummary?: string
|
|
296
345
|
topics?: string[]
|
|
297
346
|
episodeId?: string
|
package/src/lib/router.ts
CHANGED
|
@@ -60,7 +60,7 @@ export class Router {
|
|
|
60
60
|
* In-memory message history per session — used for graceful session close.
|
|
61
61
|
* Capped at MAX_SESSION_HISTORY messages per session to bound memory usage.
|
|
62
62
|
*/
|
|
63
|
-
private sessionMessages = new Map<string, Array<{ role: string; content: string }>>()
|
|
63
|
+
private sessionMessages = new Map<string, Array<{ role: string; content: string; timestamp?: string }>>()
|
|
64
64
|
private static readonly MAX_SESSION_HISTORY = 200
|
|
65
65
|
|
|
66
66
|
/** Tracks cumulative token usage per session */
|
|
@@ -71,6 +71,9 @@ export class Router {
|
|
|
71
71
|
/** Agent-level thinking defaults: agentId → thinking level */
|
|
72
72
|
private agentThinkingMap = new Map<string, string>()
|
|
73
73
|
|
|
74
|
+
/** Per-session lock to serialize concurrent messages and prevent race conditions */
|
|
75
|
+
private routeLocks = new Map<string, Promise<void>>()
|
|
76
|
+
|
|
74
77
|
/** Slash command handler */
|
|
75
78
|
private commandHandler: CommandHandler
|
|
76
79
|
|
|
@@ -219,6 +222,19 @@ export class Router {
|
|
|
219
222
|
// When Deimos and Hermes both handle different channels, their sessions
|
|
220
223
|
// must not interfere even if they share a channel adapter.
|
|
221
224
|
const sessionKey = `${agent.id}:${channelId}:${message.channelId}`
|
|
225
|
+
|
|
226
|
+
// Serialize concurrent messages for the same session to prevent race conditions
|
|
227
|
+
// (e.g., double "Nueva sesión" blocks when messages arrive while agent is responding)
|
|
228
|
+
const pending = this.routeLocks.get(sessionKey)
|
|
229
|
+
if (pending) {
|
|
230
|
+
await pending
|
|
231
|
+
}
|
|
232
|
+
let unlockRoute!: () => void
|
|
233
|
+
const routeLock = new Promise<void>((resolve) => {
|
|
234
|
+
unlockRoute = resolve
|
|
235
|
+
})
|
|
236
|
+
this.routeLocks.set(sessionKey, routeLock)
|
|
237
|
+
|
|
222
238
|
const session = this.sessions.get(sessionKey)
|
|
223
239
|
|
|
224
240
|
// ─── Command interception (before system prompt — zero token cost) ───
|
|
@@ -261,6 +277,8 @@ export class Router {
|
|
|
261
277
|
const outgoing: OutgoingMessage = { text: this.redact(response) }
|
|
262
278
|
await channel.send(message.channelId, outgoing)
|
|
263
279
|
log.info({ command: message.text.split(/\s+/)[0] }, 'Command handled')
|
|
280
|
+
unlockRoute()
|
|
281
|
+
this.routeLocks.delete(sessionKey)
|
|
264
282
|
return
|
|
265
283
|
}
|
|
266
284
|
}
|
|
@@ -539,6 +557,10 @@ export class Router {
|
|
|
539
557
|
text: '⚠️ Something went wrong. Please try again.',
|
|
540
558
|
replyTo: message.id,
|
|
541
559
|
})
|
|
560
|
+
} finally {
|
|
561
|
+
// Release the per-session lock so queued messages can proceed
|
|
562
|
+
unlockRoute()
|
|
563
|
+
this.routeLocks.delete(sessionKey)
|
|
542
564
|
}
|
|
543
565
|
}
|
|
544
566
|
|
|
@@ -560,18 +582,53 @@ export class Router {
|
|
|
560
582
|
if (messages.length > 0) {
|
|
561
583
|
try {
|
|
562
584
|
const orchestrator = this.memory as unknown as MemoryOrchestrator
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
585
|
+
|
|
586
|
+
// Group messages by day (using timestamp if available, else today)
|
|
587
|
+
const today = new Date().toISOString().split('T')[0]
|
|
588
|
+
const messagesByDay = new Map<string, typeof messages>()
|
|
589
|
+
|
|
590
|
+
for (const msg of messages) {
|
|
591
|
+
const day = msg.timestamp ? msg.timestamp.split('T')[0] : today
|
|
592
|
+
if (!messagesByDay.has(day)) messagesByDay.set(day, [])
|
|
593
|
+
messagesByDay.get(day)!.push(msg)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Sort days chronologically
|
|
597
|
+
const sortedDays = [...messagesByDay.keys()].sort()
|
|
598
|
+
|
|
599
|
+
// Extract channel from sessionKey (format: agentId:platform:channelId)
|
|
600
|
+
const channel = sessionKey.split(':').slice(1).join(':')
|
|
601
|
+
|
|
602
|
+
// Run hooks for each day's messages
|
|
603
|
+
for (const day of sortedDays) {
|
|
604
|
+
const dayMessages = messagesByDay.get(day)!
|
|
605
|
+
const clearContext: Record<string, unknown> = {
|
|
606
|
+
messages: dayMessages,
|
|
607
|
+
topics: this.extractTopics(dayMessages),
|
|
608
|
+
sessionDate: day,
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Get or create episode for this specific day
|
|
612
|
+
if (orchestrator?.getEpisodeForDate) {
|
|
613
|
+
const episodeId = await orchestrator.getEpisodeForDate(day, channel)
|
|
614
|
+
if (episodeId) clearContext.episodeId = episodeId
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
await this.memory!.onSessionEnd(
|
|
618
|
+
clearContext as Parameters<NonNullable<MemoryProvider['onSessionEnd']>>[0],
|
|
619
|
+
)
|
|
566
620
|
}
|
|
567
|
-
if (orchestrator?.episodeId) clearContext.episodeId = orchestrator.episodeId
|
|
568
|
-
if (orchestrator?.sessionDate) clearContext.sessionDate = orchestrator.sessionDate
|
|
569
621
|
|
|
570
|
-
await this.memory.onSessionEnd(
|
|
571
|
-
clearContext as Parameters<NonNullable<MemoryProvider['onSessionEnd']>>[0],
|
|
572
|
-
)
|
|
573
622
|
hooksRan = true
|
|
574
|
-
log.info(
|
|
623
|
+
log.info(
|
|
624
|
+
{ sessionKey, messageCount: messages.length, days: sortedDays.length },
|
|
625
|
+
'Memory hooks ran before clear',
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
// Refresh episode for next session
|
|
629
|
+
if (orchestrator?.refreshEpisode) {
|
|
630
|
+
await orchestrator.refreshEpisode(channel)
|
|
631
|
+
}
|
|
575
632
|
} catch (err) {
|
|
576
633
|
const errMsg = err instanceof Error ? err.message : String(err)
|
|
577
634
|
log.warn({ sessionKey, error: errMsg }, 'Memory hooks failed during /clear')
|
|
@@ -772,19 +829,48 @@ export class Router {
|
|
|
772
829
|
closePromises.push(
|
|
773
830
|
(async () => {
|
|
774
831
|
try {
|
|
775
|
-
// Pass episodeId from orchestrator if available
|
|
776
832
|
const orchestrator = this.memory as unknown as MemoryOrchestrator
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
833
|
+
|
|
834
|
+
// Group messages by day (using timestamp if available, else today)
|
|
835
|
+
const today = new Date().toISOString().split('T')[0]
|
|
836
|
+
const messagesByDay = new Map<string, typeof messages>()
|
|
837
|
+
|
|
838
|
+
for (const msg of messages) {
|
|
839
|
+
const day = msg.timestamp ? msg.timestamp.split('T')[0] : today
|
|
840
|
+
if (!messagesByDay.has(day)) messagesByDay.set(day, [])
|
|
841
|
+
messagesByDay.get(day)!.push(msg)
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Sort days chronologically
|
|
845
|
+
const sortedDays = [...messagesByDay.keys()].sort()
|
|
846
|
+
|
|
847
|
+
// Extract channel from sessionKey (format: agentId:platform:channelId)
|
|
848
|
+
const channel = sessionKey.split(':').slice(1).join(':')
|
|
849
|
+
|
|
850
|
+
// Run hooks for each day's messages
|
|
851
|
+
for (const day of sortedDays) {
|
|
852
|
+
const dayMessages = messagesByDay.get(day)!
|
|
853
|
+
const sessionContext: Record<string, unknown> = {
|
|
854
|
+
messages: dayMessages,
|
|
855
|
+
topics: this.extractTopics(dayMessages),
|
|
856
|
+
sessionDate: day,
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Get or create episode for this specific day
|
|
860
|
+
if (orchestrator?.getEpisodeForDate) {
|
|
861
|
+
const episodeId = await orchestrator.getEpisodeForDate(day, channel)
|
|
862
|
+
if (episodeId) sessionContext.episodeId = episodeId
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
await this.memory!.onSessionEnd!(
|
|
866
|
+
sessionContext as Parameters<NonNullable<MemoryProvider['onSessionEnd']>>[0],
|
|
867
|
+
)
|
|
780
868
|
}
|
|
781
|
-
if (orchestrator?.episodeId) sessionContext.episodeId = orchestrator.episodeId
|
|
782
|
-
if (orchestrator?.sessionDate) sessionContext.sessionDate = orchestrator.sessionDate
|
|
783
869
|
|
|
784
|
-
|
|
785
|
-
|
|
870
|
+
log.info(
|
|
871
|
+
{ sessionKey, messageCount: messages.length, days: sortedDays.length },
|
|
872
|
+
'Session closed gracefully',
|
|
786
873
|
)
|
|
787
|
-
log.info({ sessionKey, messageCount: messages.length }, 'Session closed gracefully')
|
|
788
874
|
} catch (err) {
|
|
789
875
|
const errMsg = err instanceof Error ? err.message : String(err)
|
|
790
876
|
log.warn(
|
|
@@ -827,7 +913,7 @@ export class Router {
|
|
|
827
913
|
this.sessionMessages.set(sessionKey, history)
|
|
828
914
|
}
|
|
829
915
|
|
|
830
|
-
history.push({ role, content })
|
|
916
|
+
history.push({ role, content, timestamp: new Date().toISOString() })
|
|
831
917
|
|
|
832
918
|
// Cap to prevent unbounded memory growth
|
|
833
919
|
if (history.length > Router.MAX_SESSION_HISTORY) {
|
package/src/types/memory.ts
CHANGED
|
@@ -78,7 +78,7 @@ export interface MemoryProvider {
|
|
|
78
78
|
* - Markdown: write daily note
|
|
79
79
|
*/
|
|
80
80
|
onSessionEnd?(context: {
|
|
81
|
-
messages: Array<{ role: string; content: string }>
|
|
81
|
+
messages: Array<{ role: string; content: string; timestamp?: string }>
|
|
82
82
|
sessionSummary?: string
|
|
83
83
|
topics?: string[]
|
|
84
84
|
}): Promise<void>
|