@swarmclawai/swarmclaw 0.9.2 → 0.9.3

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 (32) hide show
  1. package/package.json +1 -1
  2. package/src/app/agents/page.tsx +2 -1
  3. package/src/app/globals.css +28 -0
  4. package/src/app/home/page.tsx +11 -0
  5. package/src/app/settings/page.tsx +12 -5
  6. package/src/components/connectors/connector-list.tsx +2 -5
  7. package/src/components/logs/log-list.tsx +2 -5
  8. package/src/components/providers/provider-list.tsx +2 -5
  9. package/src/components/runs/run-list.tsx +2 -6
  10. package/src/components/schedules/schedule-list.tsx +7 -1
  11. package/src/components/ui/full-screen-loader.tsx +0 -29
  12. package/src/components/ui/page-loader.tsx +69 -0
  13. package/src/lib/runtime/runtime-loop.ts +21 -1
  14. package/src/lib/server/chat-execution/chat-execution-utils.test.ts +56 -0
  15. package/src/lib/server/chat-execution/chat-execution-utils.ts +24 -0
  16. package/src/lib/server/chat-execution/chat-execution.ts +43 -4
  17. package/src/lib/server/chat-execution/chat-streaming-utils.ts +1 -38
  18. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +2 -46
  19. package/src/lib/server/chat-execution/stream-agent-chat.ts +51 -86
  20. package/src/lib/server/chat-execution/stream-continuation.ts +1 -1
  21. package/src/lib/server/connectors/manager.ts +1 -1
  22. package/src/lib/server/memory/memory-policy.test.ts +5 -15
  23. package/src/lib/server/memory/memory-policy.ts +11 -41
  24. package/src/lib/server/runtime/heartbeat-service.test.ts +46 -0
  25. package/src/lib/server/runtime/heartbeat-service.ts +5 -1
  26. package/src/lib/server/runtime/runtime-settings.test.ts +4 -4
  27. package/src/lib/server/runtime/runtime-settings.ts +4 -0
  28. package/src/lib/server/runtime/session-run-manager.ts +2 -0
  29. package/src/lib/server/session-tools/delegate.ts +3 -3
  30. package/src/lib/server/session-tools/memory.ts +220 -48
  31. package/src/types/index.ts +4 -0
  32. package/src/views/settings/section-runtime-loop.tsx +38 -0
@@ -146,6 +146,7 @@ export interface HeartbeatConfig {
146
146
  showOk: boolean
147
147
  showAlerts: boolean
148
148
  target: string | null
149
+ lightContext: boolean
149
150
  }
150
151
 
151
152
  interface HeartbeatFileSession {
@@ -345,6 +346,7 @@ export function heartbeatConfigForSession(session: any, settings: Record<string,
345
346
  let showOk = resolveBool(settings, 'heartbeatShowOk', DEFAULT_HEARTBEAT_SHOW_OK)
346
347
  let showAlerts = resolveBool(settings, 'heartbeatShowAlerts', DEFAULT_HEARTBEAT_SHOW_ALERTS)
347
348
  let target: string | null = resolveStr(settings, 'heartbeatTarget', null)
349
+ let lightContext = resolveBool(settings, 'heartbeatLightContext', false)
348
350
 
349
351
  // Agent layer overrides
350
352
  if (session.agentId) {
@@ -361,6 +363,7 @@ export function heartbeatConfigForSession(session: any, settings: Record<string,
361
363
  showOk = resolveBool(agent, 'heartbeatShowOk', showOk)
362
364
  showAlerts = resolveBool(agent, 'heartbeatShowAlerts', showAlerts)
363
365
  target = resolveStr(agent, 'heartbeatTarget', target)
366
+ lightContext = resolveBool(agent, 'heartbeatLightContext', lightContext)
364
367
  }
365
368
  }
366
369
 
@@ -373,7 +376,7 @@ export function heartbeatConfigForSession(session: any, settings: Record<string,
373
376
  }
374
377
  target = resolveStr(session, 'heartbeatTarget', target)
375
378
 
376
- return { enabled: enabled && intervalSec > 0, intervalSec, prompt, model, ackMaxChars, showOk, showAlerts, target }
379
+ return { enabled: enabled && intervalSec > 0, intervalSec, prompt, model, ackMaxChars, showOk, showAlerts, target, lightContext }
377
380
  }
378
381
 
379
382
  function lastUserMessageAt(session: any): number {
@@ -560,6 +563,7 @@ async function tickHeartbeats() {
560
563
  showOk: cfg.showOk,
561
564
  showAlerts: cfg.showAlerts,
562
565
  target: cfg.target,
566
+ lightContext: cfg.lightContext,
563
567
  },
564
568
  })
565
569
 
@@ -46,13 +46,13 @@ describe('runtime settings defaults', () => {
46
46
  `)
47
47
 
48
48
  assert.equal(output.settings.loopMode, 'bounded')
49
- assert.equal(output.settings.agentLoopRecursionLimit, 120)
49
+ assert.equal(output.settings.agentLoopRecursionLimit, 300)
50
50
  assert.equal(output.settings.orchestratorLoopRecursionLimit, 80)
51
51
  assert.equal(output.settings.legacyOrchestratorMaxTurns, 16)
52
52
  assert.equal(output.settings.ongoingLoopMaxIterations, 250)
53
53
  assert.equal(output.settings.ongoingLoopMaxRuntimeMinutes, 60)
54
54
  assert.equal(output.settings.delegationMaxDepth, 3)
55
- assert.equal(output.settings.shellCommandTimeoutSec, 30)
55
+ assert.equal(output.settings.shellCommandTimeoutSec, 120)
56
56
  assert.equal(output.settings.claudeCodeTimeoutSec, 1800)
57
57
  assert.equal(output.settings.cliProcessTimeoutSec, 1800)
58
58
  assert.equal(output.settings.heartbeatIntervalSec, 1800)
@@ -61,7 +61,7 @@ describe('runtime settings defaults', () => {
61
61
  assert.equal(output.settings.heartbeatShowAlerts, true)
62
62
  assert.equal(output.settings.heartbeatTarget, null)
63
63
  assert.equal(output.settings.heartbeatPrompt, null)
64
- assert.equal(output.runtime.agentLoopRecursionLimit, 120)
64
+ assert.equal(output.runtime.agentLoopRecursionLimit, 300)
65
65
  assert.equal(output.runtime.orchestratorLoopRecursionLimit, 80)
66
66
  assert.equal(output.runtime.legacyOrchestratorMaxTurns, 16)
67
67
  })
@@ -99,7 +99,7 @@ describe('runtime settings defaults', () => {
99
99
  `)
100
100
 
101
101
  assert.equal(output.settings.loopMode, 'bounded')
102
- assert.equal(output.settings.agentLoopRecursionLimit, 200)
102
+ assert.equal(output.settings.agentLoopRecursionLimit, 500)
103
103
  assert.equal(output.settings.orchestratorLoopRecursionLimit, 1)
104
104
  assert.equal(output.settings.legacyOrchestratorMaxTurns, 1)
105
105
  assert.equal(output.settings.ongoingLoopMaxIterations, 5000)
@@ -15,6 +15,8 @@ export interface RuntimeSettings {
15
15
  shellCommandTimeoutMs: number
16
16
  claudeCodeTimeoutMs: number
17
17
  cliProcessTimeoutMs: number
18
+ streamIdleStallMs: number
19
+ requiredToolKickoffMs: number
18
20
  }
19
21
 
20
22
  export function loadRuntimeSettings(): RuntimeSettings {
@@ -32,6 +34,8 @@ export function loadRuntimeSettings(): RuntimeSettings {
32
34
  shellCommandTimeoutMs: normalized.shellCommandTimeoutSec * 1000,
33
35
  claudeCodeTimeoutMs: normalized.claudeCodeTimeoutSec * 1000,
34
36
  cliProcessTimeoutMs: normalized.cliProcessTimeoutSec * 1000,
37
+ streamIdleStallMs: normalized.streamIdleStallSec * 1000,
38
+ requiredToolKickoffMs: normalized.requiredToolKickoffSec * 1000,
35
39
  }
36
40
  }
37
41
 
@@ -52,6 +52,7 @@ interface QueueEntry {
52
52
  showAlerts: boolean
53
53
  target: string | null
54
54
  deliveryMode?: 'default' | 'tool_only'
55
+ lightContext?: boolean
55
56
  }
56
57
  replyToId?: string
57
58
  resolve: (value: ExecuteChatTurnResult) => void
@@ -567,6 +568,7 @@ export interface EnqueueSessionRunInput {
567
568
  showAlerts: boolean
568
569
  target: string | null
569
570
  deliveryMode?: 'default' | 'tool_only'
571
+ lightContext?: boolean
570
572
  }
571
573
  replyToId?: string
572
574
  /** Optional shared execution lane key. When set, multiple sessions can be serialized together. */
@@ -931,13 +931,13 @@ const DelegatePlugin: Plugin = {
931
931
  name: 'Core Delegate',
932
932
  description: 'Delegate complex multi-file tasks to specialized CLI backends or other agents.',
933
933
  hooks: {
934
- getCapabilityDescription: () => 'I can hand off deep coding work to Claude Code, Codex, or Gemini CLI (`delegate`) for complex multi-file refactors and code generation. Resume IDs may come back via `[delegate_meta]`.',
935
- getOperatingGuidance: () => ['CRITICAL: `execute_command` (not delegation) for running servers, installs, scripts. Delegation sessions end and kill processes.', 'Delegate only for deep multi-file code work: refactors, debugging, generation, test suites.'],
934
+ getCapabilityDescription: () => 'I can hand off coding work to Claude Code, Codex, OpenCode, or Gemini CLI (`delegate`) for file creation, refactoring, debugging, code generation, and multi-file edits. Resume IDs may come back via `[delegate_meta]`.',
935
+ getOperatingGuidance: () => ['CRITICAL: `execute_command` (not delegation) for running servers, installs, scripts. Delegation sessions end and kill processes.', 'Delegate for code tasks: writing/creating files, refactors, debugging, generation, test suites, data exports to files.'],
936
936
  } as PluginHooks,
937
937
  tools: [
938
938
  {
939
939
  name: 'delegate',
940
- description: 'Delegate to a specialized backend (Claude, Codex, OpenCode, Gemini). Supports background jobs with action=status|list|wait|cancel.',
940
+ description: 'Delegate to a specialized backend (Claude, Codex, OpenCode, Gemini) for code tasks: writing files, refactoring, debugging, code generation, and multi-file edits. Supports background jobs with action=status|list|wait|cancel.',
941
941
  parameters: {
942
942
  type: 'object',
943
943
  properties: {
@@ -28,6 +28,40 @@ import {
28
28
  /**
29
29
  * Advanced Database-Backed Memory logic.
30
30
  */
31
+
32
+ /**
33
+ * Lightweight in-memory cache for per-agent memory lookups (pinned + recent).
34
+ * TTL-based with invalidation on any write operation.
35
+ */
36
+ const MEMORY_CACHE_TTL_MS = 30_000
37
+ interface AgentMemoryCache {
38
+ pinned: MemoryEntry[]
39
+ allRecent: MemoryEntry[]
40
+ cachedAt: number
41
+ }
42
+ const agentMemoryCache = new Map<string, AgentMemoryCache>()
43
+
44
+ function getCachedAgentMemories(agentId: string): AgentMemoryCache | null {
45
+ const cached = agentMemoryCache.get(agentId)
46
+ if (!cached) return null
47
+ if (Date.now() - cached.cachedAt > MEMORY_CACHE_TTL_MS) {
48
+ agentMemoryCache.delete(agentId)
49
+ return null
50
+ }
51
+ return cached
52
+ }
53
+
54
+ function setCachedAgentMemories(agentId: string, pinned: MemoryEntry[], allRecent: MemoryEntry[]): void {
55
+ agentMemoryCache.set(agentId, { pinned, allRecent, cachedAt: Date.now() })
56
+ }
57
+
58
+ function invalidateAgentMemoryCache(agentId?: string | null): void {
59
+ if (agentId) {
60
+ agentMemoryCache.delete(agentId)
61
+ } else {
62
+ agentMemoryCache.clear()
63
+ }
64
+ }
31
65
  type MemoryActionContext = Partial<Session> & {
32
66
  sessionId?: string | null
33
67
  memoryScopeMode?: string | null
@@ -445,7 +479,7 @@ export async function executeMemoryAction(input: unknown, ctx: MemoryActionConte
445
479
  ? valueText
446
480
  : fallbackValueText
447
481
  if (!storedValueText.trim()) {
448
- return 'Memory store requires a non-empty value.'
482
+ return 'Error: memory_store requires a non-empty value and is only for remembering user facts/preferences. If you need to create a file, write code, or export data, use the `files` tool instead: files({action:"write", files:[{path:"path/to/file", content:"..."}]})'
449
483
  }
450
484
  let storedImage: MemoryImage | null = null
451
485
  if (imagePath && fs.existsSync(imagePath)) {
@@ -470,6 +504,7 @@ export async function executeMemoryAction(input: unknown, ctx: MemoryActionConte
470
504
  })
471
505
  if (updated) {
472
506
  supersedeCompetingMemories(updated.id, memoryTitle, storedValueText, related)
507
+ invalidateAgentMemoryCache(currentAgentId)
473
508
  return `Stored memory "${updated.title}" (id: ${updated.id}) in ${normalizedCategory} by updating the canonical entry. No further memory lookup is needed unless the user asked you to verify.`
474
509
  }
475
510
  }
@@ -487,6 +522,7 @@ export async function executeMemoryAction(input: unknown, ctx: MemoryActionConte
487
522
  pinned: pinned === true,
488
523
  sharedWith: Array.isArray(sharedWith) ? sharedWith : undefined,
489
524
  })
525
+ invalidateAgentMemoryCache(currentAgentId)
490
526
  return `Stored memory "${entry.title}" (id: ${entry.id}) in ${normalizedCategory}. No further memory lookup is needed unless the user asked you to verify.`
491
527
  }
492
528
 
@@ -525,6 +561,7 @@ export async function executeMemoryAction(input: unknown, ctx: MemoryActionConte
525
561
  const found = memDb.get(memoryId)
526
562
  if (!found || !canMutateMemory(found)) return 'Memory not found or access denied.'
527
563
  memDb.delete(memoryId)
564
+ invalidateAgentMemoryCache(currentAgentId)
528
565
  return `Deleted memory "${memoryId}"`
529
566
  }
530
567
 
@@ -561,6 +598,7 @@ export async function executeMemoryAction(input: unknown, ctx: MemoryActionConte
561
598
  pinned: pinned === true,
562
599
  sharedWith: Array.isArray(sharedWith) ? sharedWith : undefined,
563
600
  })
601
+ invalidateAgentMemoryCache(currentAgentId)
564
602
  return `Updated memory "${created.title}" (id: ${created.id}) by creating a new canonical entry. No further memory lookup is needed unless the user asked you to verify.`
565
603
  }
566
604
  const nextTitle = typeof n.title === 'string' && n.title.trim() ? n.title.trim() : found.title
@@ -581,6 +619,7 @@ export async function executeMemoryAction(input: unknown, ctx: MemoryActionConte
581
619
  const updated = memDb.update(found.id, updates)
582
620
  if (!updated) return `Memory not found: ${memoryId}`
583
621
  supersedeCompetingMemories(updated.id, nextTitle, nextContent, related)
622
+ invalidateAgentMemoryCache(currentAgentId)
584
623
  return `Updated memory "${updated.title}" (id: ${updated.id}). No further memory lookup is needed unless the user asked you to verify.`
585
624
  }
586
625
 
@@ -596,6 +635,7 @@ export async function executeMemoryAction(input: unknown, ctx: MemoryActionConte
596
635
  ? memDb.link(memoryId, ids, true)
597
636
  : memDb.unlink(memoryId, ids, true)
598
637
  if (!updated) return `Memory not found: ${memoryId}`
638
+ invalidateAgentMemoryCache(currentAgentId)
599
639
  return `${resolvedAction === 'link' ? 'Linked' : 'Unlinked'} ${ids.length} memories for "${updated.title}" (id: ${updated.id})`
600
640
  }
601
641
 
@@ -617,17 +657,13 @@ const MemoryPlugin: Plugin = {
617
657
  getAgentContext: async (ctx) => {
618
658
  const agentId = ctx.session.agentId
619
659
  if (!agentId) return null
620
- if (!shouldInjectMemoryContext(ctx.message || '')) return null
621
660
 
622
- const memDb = getMemoryDb()
623
- const memoryQuerySeed = [
624
- ctx.message,
625
- ...ctx.history
626
- .slice(-4)
627
- .filter((h) => h.role === 'user')
628
- .map((h) => h.text),
629
- ].join('\n')
661
+ // QMD scope: identity/* memories and contact resolution are private (DM/peer only).
662
+ // Group channels, threads, and shared "main" sessions don't see them.
663
+ const connCtx = ctx.session.connectorContext
664
+ const isPrivateContext = !connCtx || !connCtx.isGroup
630
665
 
666
+ const memDb = getMemoryDb()
631
667
  const seen = new Set<string>()
632
668
  const formatMemoryLine = (m: { category?: string; title?: string; content?: string; pinned?: boolean }) => {
633
669
  const category = String(m.category || 'note')
@@ -636,46 +672,141 @@ const MemoryPlugin: Plugin = {
636
672
  const pin = m.pinned ? ' [pinned]' : ''
637
673
  return `- [${category}]${pin} ${title}: ${snippet}`
638
674
  }
675
+ const dedup = (m: MemoryEntry): boolean => {
676
+ if (!m?.id || seen.has(m.id)) return false
677
+ if (shouldHideFromDurableRecall(m)) return false
678
+ seen.add(m.id)
679
+ return true
680
+ }
639
681
 
640
- const pinned = memDb.listPinned(agentId, 5)
641
- const pinnedLines = pinned
642
- .filter((m) => {
643
- if (!m?.id || seen.has(m.id)) return false
644
- if (shouldHideFromDurableRecall(m)) return false
645
- seen.add(m.id)
646
- return true
647
- })
648
- .map(formatMemoryLine)
649
-
650
- const relevantSlice = Math.max(2, 6 - pinnedLines.length)
651
- const relevantLookup = memDb.searchWithLinked(memoryQuerySeed, agentId, 1, 10, 14)
652
- const recent = memDb.list(agentId, 12).slice(0, 6)
653
- const relevantByTier = partitionMemoriesByTier(relevantLookup.entries)
654
- const recentByTier = partitionMemoriesByTier(recent)
655
-
656
- const relevantLines = relevantByTier.durable
657
- .filter((m) => {
658
- if (!m?.id || seen.has(m.id)) return false
659
- if (shouldHideFromDurableRecall(m)) return false
660
- seen.add(m.id)
661
- return true
662
- })
663
- .slice(0, relevantSlice)
664
- .map(formatMemoryLine)
665
-
666
- const recentLines = recentByTier.durable
667
- .filter((m) => {
668
- if (!m?.id || seen.has(m.id)) return false
669
- if (shouldHideFromDurableRecall(m)) return false
670
- seen.add(m.id)
671
- return true
672
- })
673
- .map(formatMemoryLine)
682
+ // --- Always-on: pinned + identity memories (bypass shouldInjectMemoryContext gate) ---
683
+ const cached = getCachedAgentMemories(agentId)
684
+ const pinned = cached?.pinned ?? memDb.listPinned(agentId, 5)
685
+ const allRecent = cached?.allRecent ?? memDb.list(agentId, 100)
686
+ if (!cached) setCachedAgentMemories(agentId, pinned, allRecent)
687
+
688
+ const pinnedLines = pinned.filter(dedup).map(formatMemoryLine)
689
+
690
+ // Fetch identity/* category memories — only in private (DM/peer) contexts
691
+ const identityMemories = isPrivateContext
692
+ ? allRecent.filter((m) => m.category?.startsWith('identity/') && dedup(m))
693
+ : []
694
+ const identityLines = identityMemories.map(formatMemoryLine)
695
+
696
+ // --- Contact resolution for connector messages (private contexts only) ---
697
+ const lastUserMsg = [...ctx.history].reverse().find((m) => m.role === 'user')
698
+ const senderName = lastUserMsg?.source?.senderName || connCtx?.senderName || null
699
+
700
+ let contactBlock = ''
701
+ let resolvedContactName: string | null = null
702
+ if (isPrivateContext && connCtx) {
703
+ // Collect all possible identifiers for the sender (senderId, senderIdAlt, channelId, etc.)
704
+ const rawSenderIds = [
705
+ lastUserMsg?.source?.senderId,
706
+ connCtx.senderId,
707
+ connCtx.senderIdAlt,
708
+ connCtx.channelId,
709
+ connCtx.channelIdAlt,
710
+ ...(connCtx.allKnownPeerIds || []),
711
+ ].filter((v): v is string => typeof v === 'string' && v.length > 0)
712
+
713
+ // Normalize a phone string to bare trailing digits for suffix matching.
714
+ // Handles: "+44 76 2422 8104", "076 2422 8104", "447624228104@s.whatsapp.net", LIDs, etc.
715
+ // UK local numbers starting with 0 are converted to 44 prefix.
716
+ const toDigits = (raw: string): string => {
717
+ const stripped = raw.replace(/@.*$/, '').replace(/[^\d]/g, '')
718
+ if (stripped.startsWith('0') && stripped.length >= 10) return '44' + stripped.slice(1)
719
+ return stripped
720
+ }
721
+
722
+ // Build a set of digit-strings from the sender's identifiers
723
+ const senderDigits = new Set(
724
+ rawSenderIds
725
+ .map(toDigits)
726
+ .filter((d) => d.length >= 6),
727
+ )
728
+
729
+ if (senderDigits.size > 0 || senderName) {
730
+ const extractPhoneDigits = (text: string): string[] => {
731
+ const matches = text.match(/(?:\+?\d[\d\s\-().]{6,}\d)/g) || []
732
+ return matches.map(toDigits).filter((d) => d.length >= 6)
733
+ }
734
+
735
+ const contactHits = allRecent.filter((m) => {
736
+ if (m.category !== 'identity/contacts' && m.category !== 'identity/relationships') return false
737
+ const content = (m.content || '').toLowerCase()
738
+ const title = (m.title || '').toLowerCase()
739
+ for (const rawId of rawSenderIds) {
740
+ const rid = rawId.toLowerCase()
741
+ if (content.includes(rid) || title.includes(rid)) return true
742
+ }
743
+ const memoryPhones = extractPhoneDigits(m.content || '')
744
+ const metaIds = (() => {
745
+ const meta = m.metadata as Record<string, unknown> | undefined
746
+ return Array.isArray(meta?.identifiers) ? (meta.identifiers as string[]).map(toDigits) : []
747
+ })()
748
+ const allMemDigits = [...memoryPhones, ...metaIds]
749
+ for (const memDigit of allMemDigits) {
750
+ for (const senderDigit of senderDigits) {
751
+ if (senderDigit.endsWith(memDigit) || memDigit.endsWith(senderDigit)) return true
752
+ }
753
+ }
754
+ return false
755
+ })
756
+ if (contactHits.length) {
757
+ const contact = contactHits[0]
758
+ const displayId = rawSenderIds[0] || senderName || 'unknown'
759
+ resolvedContactName = contact.title || null
760
+ contactBlock = [
761
+ '## Known Sender',
762
+ `The current sender (${displayId}${senderName ? `, name: ${senderName}` : ''}) is: ${contact.title}`,
763
+ contact.content || '',
764
+ ].join('\n')
765
+ }
766
+ }
767
+ }
768
+
769
+ // --- Relevance-based search (gated on message quality) ---
770
+ let relevantLines: string[] = []
771
+ let recentLines: string[] = []
772
+ if (shouldInjectMemoryContext(ctx.message || '')) {
773
+ // Prepend resolved contact name so person-specific memories rank higher
774
+ const contactQueryHint = resolvedContactName || senderName || ''
775
+ const memoryQuerySeed = [
776
+ contactQueryHint,
777
+ ctx.message,
778
+ ...ctx.history
779
+ .slice(-4)
780
+ .filter((h) => h.role === 'user')
781
+ .map((h) => h.text),
782
+ ].join('\n')
783
+
784
+ const relevantSlice = Math.max(2, 6 - pinnedLines.length)
785
+ const relevantLookup = memDb.searchWithLinked(memoryQuerySeed, agentId, 1, 10, 14)
786
+ const recent = memDb.list(agentId, 12).slice(0, 6)
787
+ const relevantByTier = partitionMemoriesByTier(relevantLookup.entries)
788
+ const recentByTier = partitionMemoriesByTier(recent)
789
+
790
+ relevantLines = relevantByTier.durable
791
+ .filter(dedup)
792
+ .slice(0, relevantSlice)
793
+ .map(formatMemoryLine)
794
+
795
+ recentLines = recentByTier.durable
796
+ .filter(dedup)
797
+ .map(formatMemoryLine)
798
+ }
674
799
 
675
800
  const parts: string[] = []
801
+ if (contactBlock) {
802
+ parts.push(contactBlock)
803
+ }
676
804
  if (pinnedLines.length) {
677
805
  parts.push(['## Pinned Memories', 'Always-loaded memories marked as important.', ...pinnedLines].join('\n'))
678
806
  }
807
+ if (identityLines.length) {
808
+ parts.push(['## Identity & Preferences', 'Always-loaded identity memories (preferences, relationships, contacts).', ...identityLines].join('\n'))
809
+ }
679
810
  if (relevantLines.length) {
680
811
  parts.push(['## Relevant Memory Hits', 'These memories were retrieved by relevance for the current objective.', ...relevantLines].join('\n'))
681
812
  }
@@ -695,6 +826,7 @@ const MemoryPlugin: Plugin = {
695
826
  '- What I\'ve discovered about projects, codebases, or environments',
696
827
  '- Problems I\'ve hit and how I solved them',
697
828
  '- Who people are and how they relate to each other',
829
+ '- Contact details: phone numbers, emails, platform IDs — use category "contacts"',
698
830
  '- Configuration details and environment specifics that I\'ll need again',
699
831
  '',
700
832
  '**Not worth cluttering my memory with:**',
@@ -703,9 +835,26 @@ const MemoryPlugin: Plugin = {
703
835
  '- Things already in my system prompt',
704
836
  '- Something I\'ve already stored',
705
837
  '',
838
+ '**Categories** — pick the one that fits best when storing:',
839
+ '- `identity/preferences` — Likes, dislikes, style choices, timezone, pronouns',
840
+ '- `identity/relationships` — Who people are and how they relate to each other',
841
+ '- `identity/contacts` — Phone numbers, emails, platform IDs for matching senders',
842
+ '- `identity/routines` — Recurring patterns: "picks up kids at 3pm", "checks in every morning"',
843
+ '- `identity/goals` — What the user is working toward: "launch MVP by Q2", "learn Spanish"',
844
+ '- `identity/events` — Significant life events: illness, birth, wedding, promotion, loss',
845
+ '- `knowledge/instructions` — Standing directives: "always respond in English", "use metric units"',
846
+ '- `knowledge/facts` — General knowledge, references, documentation',
847
+ '- `projects/decisions` — Decisions made and why',
848
+ '- `projects/learnings` — Lessons learned, solved problems, post-mortems',
849
+ '- `projects/context` — Project details, milestones, roadmap',
850
+ '- `operations/environment` — Config, credentials, endpoints, infrastructure',
851
+ '- `working/scratch` — Temporary notes that\'ll change soon',
852
+ '',
706
853
  '**Good habits:**',
707
854
  '- Give memories clear titles ("User prefers dark mode" not "Note 1")',
708
- '- Use categories: identity/preferences, identity/relationships, projects/decisions, projects/learnings, operations/environment, knowledge/facts',
855
+ '- For contacts, store identifiers (phone, email, platform IDs) in content so I can match senders automatically',
856
+ '- When storing something about a specific person, include their name in the title (e.g. "Wife prefers short replies") so it surfaces when they message',
857
+ '- Store behavioral rules about a person on their contact/relationship entry rather than as separate memories',
709
858
  '- Prefer durable memories first; only inspect session archives when transcript history is specifically needed',
710
859
  '- Check what I already know before storing something new',
711
860
  '- When I learn something that corrects old knowledge, update or remove the old memory',
@@ -784,7 +933,7 @@ const MemoryPlugin: Plugin = {
784
933
  },
785
934
  getCapabilityDescription: () => 'I have long-term memory (`memory_search`, `memory_get`, `memory_store`, `memory_update`, `memory_tool`) — I can remember things across conversations and recall them when needed.',
786
935
  getOperatingGuidance: () => [
787
- 'Memory: use narrow memory tools first. For past-conversation recall, prefer `memory_search` then `memory_get`. For direct writes or corrections, prefer `memory_store` or `memory_update`. Keep `memory_tool` for list/delete/link/doctor or when you truly need the generic surface.',
936
+ 'Memory: use narrow memory tools first. For past-conversation recall, prefer `memory_search` then `memory_get`. For direct writes or corrections, prefer `memory_store` or `memory_update`. Keep `memory_tool` for list/delete/link/doctor or when you truly need the generic surface. NEVER use memory tools to create files, CSV data, code, or documents — always use the `files` tool for those.',
788
937
  'For info already in the current conversation, respond directly without calling any memory tool.',
789
938
  'For questions about prior work, decisions, dates, people, preferences, or todos from earlier conversations: start with one durable `memory_search`, then use `memory_get` only if you need a more targeted read. Only use archive/session history when the user explicitly needs transcript-level detail or the durable search is insufficient.',
790
939
  'When the user directly says to remember, store, or correct a fact, do one `memory_store` or `memory_update` call immediately. Treat the newest direct user statement as authoritative.',
@@ -868,7 +1017,7 @@ const MemoryPlugin: Plugin = {
868
1017
  },
869
1018
  {
870
1019
  name: 'memory_store',
871
- description: 'Store a durable fact, preference, decision, or correction from the user. Use this immediately when the user says to remember something. If several related facts arrive in one request, prefer one canonical write over many near-duplicate calls.',
1020
+ description: 'Store a durable fact, preference, decision, or correction from the user. Use this immediately when the user says to remember something. If several related facts arrive in one request, prefer one canonical write over many near-duplicate calls. NOT for writing files, documents, code, or data exports — use the files tool for those.',
872
1021
  parameters: {
873
1022
  type: 'object',
874
1023
  properties: {
@@ -888,7 +1037,30 @@ const MemoryPlugin: Plugin = {
888
1037
  'If the user bundled multiple related facts into one remember request, store them together in one canonical write unless they asked for separate memories.',
889
1038
  ],
890
1039
  },
891
- execute: async (args, context) => executeNamedMemoryAction('store', args, context),
1040
+ execute: async (args, context) => {
1041
+ // Guard: reject file-like content and redirect to the files tool.
1042
+ // Weaker models often confuse memory_store with file creation.
1043
+ const value = typeof args?.value === 'string' ? args.value : ''
1044
+ const title = typeof args?.title === 'string' ? args.title : ''
1045
+ const key = typeof args?.key === 'string' ? args.key : ''
1046
+ const category = typeof args?.category === 'string' ? args.category : ''
1047
+ const allText = `${title} ${key} ${category} ${value}`
1048
+ const hasFileExtension = /\.\w{1,5}$/.test(title || key)
1049
+ const hasFilePath = /(?:^|[\s"'/])(?:\/[\w.-]+){2,}\.[\w]{1,5}\b/.test(allText)
1050
+ const mentionsFileOp = /\b(?:csv|file|refactor|code|script|document|spreadsheet|inventory)\b/i.test(allText)
1051
+ const lineCount = (value.match(/\n/g) || []).length + 1
1052
+ const looksLikeCode = /^(import |export |function |const |let |var |class |interface |type |def |from |#include|package |using )/m.test(value)
1053
+ const looksLikeCsv = lineCount >= 3 && (value.match(/,/g) || []).length >= lineCount * 2
1054
+ const looksLikeStructuredData = lineCount >= 5 && (/^\s*[\[{]/m.test(value) || looksLikeCsv)
1055
+ const redirectMsg = 'Error: memory_store is only for remembering facts, preferences, and decisions — NOT for creating files, CSV data, code, or documents. To write a file, use the `files` tool: files({action:"write", files:[{path:"path/to/file", content:"..."}]})'
1056
+ if (hasFileExtension || hasFilePath || (mentionsFileOp && (!value || value.length > 200))) {
1057
+ return redirectMsg
1058
+ }
1059
+ if (value.length > 500 && (looksLikeCode || looksLikeStructuredData || looksLikeCsv)) {
1060
+ return redirectMsg
1061
+ }
1062
+ return executeNamedMemoryAction('store', args, context)
1063
+ },
892
1064
  },
893
1065
  {
894
1066
  name: 'memory_update',
@@ -660,6 +660,7 @@ export interface Agent {
660
660
  heartbeatTarget?: 'last' | 'none' | string | null
661
661
  heartbeatGoal?: string | null
662
662
  heartbeatNextAction?: string | null
663
+ heartbeatLightContext?: boolean | null
663
664
  sessionResetMode?: SessionResetMode | null
664
665
  sessionIdleTimeoutSec?: number | null
665
666
  sessionMaxAgeSec?: number | null
@@ -1277,6 +1278,8 @@ export interface AppSettings {
1277
1278
  shellCommandTimeoutSec?: number
1278
1279
  claudeCodeTimeoutSec?: number
1279
1280
  cliProcessTimeoutSec?: number
1281
+ streamIdleStallSec?: number
1282
+ requiredToolKickoffSec?: number
1280
1283
  userAvatarSeed?: string
1281
1284
  elevenLabsEnabled?: boolean
1282
1285
  elevenLabsApiKey?: string | null
@@ -1298,6 +1301,7 @@ export interface AppSettings {
1298
1301
  heartbeatActiveStart?: string | null
1299
1302
  heartbeatActiveEnd?: string | null
1300
1303
  heartbeatTimezone?: string | null
1304
+ heartbeatLightContext?: boolean | null
1301
1305
  sessionResetMode?: SessionResetMode | null
1302
1306
  sessionIdleTimeoutSec?: number | null
1303
1307
  sessionMaxAgeSec?: number | null
@@ -10,6 +10,8 @@ import {
10
10
  DEFAULT_ONGOING_LOOP_MAX_RUNTIME_MINUTES,
11
11
  DEFAULT_ORCHESTRATOR_LOOP_RECURSION_LIMIT,
12
12
  DEFAULT_SHELL_COMMAND_TIMEOUT_SEC,
13
+ DEFAULT_STREAM_IDLE_STALL_SEC,
14
+ DEFAULT_REQUIRED_TOOL_KICKOFF_SEC,
13
15
  } from '@/lib/runtime/runtime-loop'
14
16
  import type { LoopMode } from '@/types'
15
17
  import type { SettingsSectionProps } from './types'
@@ -212,6 +214,42 @@ export function RuntimeLoopSection({ appSettings, patchSettings, inputClass }: S
212
214
  </div>
213
215
  </div>
214
216
 
217
+ <label className="flex items-center gap-1.5 font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mt-5 mb-3">Stream &amp; Kickoff Timeouts (Seconds) <HintTip text="Controls how long to wait for model output and required tool usage before aborting a turn" /></label>
218
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
219
+ <div>
220
+ <label className="block text-[11px] text-text-3 mb-2">Idle Stall Timeout</label>
221
+ <input
222
+ type="number"
223
+ min={30}
224
+ max={600}
225
+ value={appSettings.streamIdleStallSec ?? DEFAULT_STREAM_IDLE_STALL_SEC}
226
+ onChange={(e) => {
227
+ const n = Number.parseInt(e.target.value, 10)
228
+ patchSettings({ streamIdleStallSec: Number.isFinite(n) ? n : DEFAULT_STREAM_IDLE_STALL_SEC })
229
+ }}
230
+ className={inputClass}
231
+ style={{ fontFamily: 'inherit' }}
232
+ />
233
+ <p className="text-[11px] text-text-3/60 mt-2">Aborts a turn if no tokens arrive for this long. Raise for slow local models.</p>
234
+ </div>
235
+ <div>
236
+ <label className="block text-[11px] text-text-3 mb-2">Required Tool Kickoff</label>
237
+ <input
238
+ type="number"
239
+ min={10}
240
+ max={120}
241
+ value={appSettings.requiredToolKickoffSec ?? DEFAULT_REQUIRED_TOOL_KICKOFF_SEC}
242
+ onChange={(e) => {
243
+ const n = Number.parseInt(e.target.value, 10)
244
+ patchSettings({ requiredToolKickoffSec: Number.isFinite(n) ? n : DEFAULT_REQUIRED_TOOL_KICKOFF_SEC })
245
+ }}
246
+ className={inputClass}
247
+ style={{ fontFamily: 'inherit' }}
248
+ />
249
+ <p className="text-[11px] text-text-3/60 mt-2">Max wait for a required tool call before forcing a continuation.</p>
250
+ </div>
251
+ </div>
252
+
215
253
  <label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mt-6 mb-3">LLM Response Cache</label>
216
254
  <div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-5">
217
255
  <div className="md:col-span-3 flex items-center gap-3">