@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onmars/lunar-core",
3
- "version": "0.4.5",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -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 (~1000 tokens) */
148
- const MAX_TRANSCRIPT_CHARS = 4000
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 = context.messages
478
- .map((m) => `${m.role}: ${m.content}`)
479
- .join('\n')
480
- .slice(0, MAX_TRANSCRIPT_CHARS)
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 = context.messages
651
- .map((m) => `${m.role}: ${m.content}`)
652
- .join('\n')
653
- .slice(0, MAX_TRANSCRIPT_CHARS)
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 = context.messages
727
- .map((m) => `${m.role}: ${m.content}`)
728
- .join('\n')
729
- .slice(0, MAX_TRANSCRIPT_CHARS)
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
- const clearContext: Record<string, unknown> = {
564
- messages,
565
- topics: this.extractTopics(messages),
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({ sessionKey, messageCount: messages.length }, 'Memory hooks ran before clear')
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
- const sessionContext: Record<string, unknown> = {
778
- messages,
779
- topics: this.extractTopics(messages),
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
- await this.memory!.onSessionEnd!(
785
- sessionContext as Parameters<NonNullable<MemoryProvider['onSessionEnd']>>[0],
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) {
@@ -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>