@swarmclawai/swarmclaw 0.9.2 → 0.9.4

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 (75) hide show
  1. package/README.md +12 -10
  2. package/bundled-skills/google-workspace/SKILL.md +2 -0
  3. package/package.json +1 -1
  4. package/src/app/agents/page.tsx +2 -1
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +1 -1
  6. package/src/app/api/clawhub/install/route.ts +2 -0
  7. package/src/app/api/skills/[id]/route.ts +4 -0
  8. package/src/app/api/skills/route.ts +4 -0
  9. package/src/app/globals.css +28 -0
  10. package/src/app/home/page.tsx +11 -0
  11. package/src/app/settings/page.tsx +12 -5
  12. package/src/components/agents/agent-sheet.tsx +5 -5
  13. package/src/components/connectors/connector-list.tsx +2 -5
  14. package/src/components/logs/log-list.tsx +2 -5
  15. package/src/components/providers/provider-list.tsx +2 -5
  16. package/src/components/runs/run-list.tsx +2 -6
  17. package/src/components/schedules/schedule-list.tsx +7 -1
  18. package/src/components/ui/full-screen-loader.tsx +0 -29
  19. package/src/components/ui/page-loader.tsx +69 -0
  20. package/src/lib/runtime/runtime-loop.ts +21 -1
  21. package/src/lib/server/agents/agent-thread-session.test.ts +64 -0
  22. package/src/lib/server/agents/agent-thread-session.ts +1 -1
  23. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +77 -0
  24. package/src/lib/server/agents/main-agent-loop.ts +259 -0
  25. package/src/lib/server/agents/orchestrator-lg.ts +12 -8
  26. package/src/lib/server/agents/orchestrator.ts +11 -7
  27. package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +11 -10
  28. package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +116 -3
  29. package/src/lib/server/chat-execution/chat-execution-utils.test.ts +56 -0
  30. package/src/lib/server/chat-execution/chat-execution-utils.ts +24 -0
  31. package/src/lib/server/chat-execution/chat-execution.ts +116 -29
  32. package/src/lib/server/chat-execution/chat-streaming-utils.ts +1 -38
  33. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +67 -76
  34. package/src/lib/server/chat-execution/stream-agent-chat.ts +119 -110
  35. package/src/lib/server/chat-execution/stream-continuation.ts +1 -1
  36. package/src/lib/server/chatrooms/chatroom-helpers.test.ts +26 -0
  37. package/src/lib/server/chatrooms/chatroom-helpers.ts +11 -8
  38. package/src/lib/server/connectors/contact-boundaries.ts +101 -0
  39. package/src/lib/server/connectors/manager.test.ts +504 -73
  40. package/src/lib/server/connectors/manager.ts +41 -10
  41. package/src/lib/server/connectors/session-consolidation.ts +2 -0
  42. package/src/lib/server/connectors/session-kind.ts +7 -0
  43. package/src/lib/server/connectors/session.test.ts +104 -0
  44. package/src/lib/server/connectors/session.ts +5 -2
  45. package/src/lib/server/identity-continuity.test.ts +4 -3
  46. package/src/lib/server/identity-continuity.ts +8 -4
  47. package/src/lib/server/memory/memory-policy.test.ts +5 -15
  48. package/src/lib/server/memory/memory-policy.ts +11 -41
  49. package/src/lib/server/memory/session-archive-memory.ts +2 -1
  50. package/src/lib/server/runtime/heartbeat-service.test.ts +46 -0
  51. package/src/lib/server/runtime/heartbeat-service.ts +5 -1
  52. package/src/lib/server/runtime/runtime-settings.test.ts +4 -4
  53. package/src/lib/server/runtime/runtime-settings.ts +4 -0
  54. package/src/lib/server/runtime/session-run-manager.ts +2 -0
  55. package/src/lib/server/session-reset-policy.test.ts +17 -3
  56. package/src/lib/server/session-reset-policy.ts +4 -2
  57. package/src/lib/server/session-tools/connector.ts +11 -10
  58. package/src/lib/server/session-tools/crud.ts +41 -7
  59. package/src/lib/server/session-tools/delegate.ts +3 -3
  60. package/src/lib/server/session-tools/index.ts +2 -0
  61. package/src/lib/server/session-tools/manage-skills.test.ts +194 -0
  62. package/src/lib/server/session-tools/memory.ts +209 -48
  63. package/src/lib/server/session-tools/skill-runtime.test.ts +175 -0
  64. package/src/lib/server/session-tools/skill-runtime.ts +382 -0
  65. package/src/lib/server/session-tools/skills.ts +575 -0
  66. package/src/lib/server/skills/runtime-skill-resolver.test.ts +162 -0
  67. package/src/lib/server/skills/runtime-skill-resolver.ts +750 -0
  68. package/src/lib/server/skills/skill-discovery.ts +4 -0
  69. package/src/lib/server/skills/skills-normalize.test.ts +28 -0
  70. package/src/lib/server/skills/skills-normalize.ts +93 -1
  71. package/src/lib/server/storage.ts +1 -1
  72. package/src/lib/server/tasks/task-followups.test.ts +124 -0
  73. package/src/lib/server/tasks/task-followups.ts +88 -13
  74. package/src/types/index.ts +30 -2
  75. package/src/views/settings/section-runtime-loop.tsx +38 -0
@@ -24,10 +24,33 @@ import {
24
24
  shouldAutoCaptureMemoryTurn,
25
25
  shouldInjectMemoryContext,
26
26
  } from '@/lib/server/memory/memory-policy'
27
+ import { isDirectConnectorSession } from '@/lib/server/connectors/session-kind'
27
28
 
28
29
  /**
29
30
  * Advanced Database-Backed Memory logic.
30
31
  */
32
+
33
+ type DisabledAgentMemoryCacheEntry = {
34
+ pinned: MemoryEntry[]
35
+ allRecent: MemoryEntry[]
36
+ }
37
+
38
+ function getCachedAgentMemories(agentId: string): DisabledAgentMemoryCacheEntry | null {
39
+ void agentId
40
+ return null
41
+ }
42
+
43
+ function setCachedAgentMemories(agentId: string, pinned: MemoryEntry[], allRecent: MemoryEntry[]): void {
44
+ void agentId
45
+ void pinned
46
+ void allRecent
47
+ // Intentionally disabled until we can prove memory reads stay complete and fresh.
48
+ }
49
+
50
+ function invalidateAgentMemoryCache(agentId?: string | null): void {
51
+ void agentId
52
+ // Intentionally disabled: the per-agent in-memory cache is not used.
53
+ }
31
54
  type MemoryActionContext = Partial<Session> & {
32
55
  sessionId?: string | null
33
56
  memoryScopeMode?: string | null
@@ -445,7 +468,7 @@ export async function executeMemoryAction(input: unknown, ctx: MemoryActionConte
445
468
  ? valueText
446
469
  : fallbackValueText
447
470
  if (!storedValueText.trim()) {
448
- return 'Memory store requires a non-empty value.'
471
+ 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
472
  }
450
473
  let storedImage: MemoryImage | null = null
451
474
  if (imagePath && fs.existsSync(imagePath)) {
@@ -470,6 +493,7 @@ export async function executeMemoryAction(input: unknown, ctx: MemoryActionConte
470
493
  })
471
494
  if (updated) {
472
495
  supersedeCompetingMemories(updated.id, memoryTitle, storedValueText, related)
496
+ invalidateAgentMemoryCache(currentAgentId)
473
497
  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
498
  }
475
499
  }
@@ -487,6 +511,7 @@ export async function executeMemoryAction(input: unknown, ctx: MemoryActionConte
487
511
  pinned: pinned === true,
488
512
  sharedWith: Array.isArray(sharedWith) ? sharedWith : undefined,
489
513
  })
514
+ invalidateAgentMemoryCache(currentAgentId)
490
515
  return `Stored memory "${entry.title}" (id: ${entry.id}) in ${normalizedCategory}. No further memory lookup is needed unless the user asked you to verify.`
491
516
  }
492
517
 
@@ -525,6 +550,7 @@ export async function executeMemoryAction(input: unknown, ctx: MemoryActionConte
525
550
  const found = memDb.get(memoryId)
526
551
  if (!found || !canMutateMemory(found)) return 'Memory not found or access denied.'
527
552
  memDb.delete(memoryId)
553
+ invalidateAgentMemoryCache(currentAgentId)
528
554
  return `Deleted memory "${memoryId}"`
529
555
  }
530
556
 
@@ -561,6 +587,7 @@ export async function executeMemoryAction(input: unknown, ctx: MemoryActionConte
561
587
  pinned: pinned === true,
562
588
  sharedWith: Array.isArray(sharedWith) ? sharedWith : undefined,
563
589
  })
590
+ invalidateAgentMemoryCache(currentAgentId)
564
591
  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
592
  }
566
593
  const nextTitle = typeof n.title === 'string' && n.title.trim() ? n.title.trim() : found.title
@@ -581,6 +608,7 @@ export async function executeMemoryAction(input: unknown, ctx: MemoryActionConte
581
608
  const updated = memDb.update(found.id, updates)
582
609
  if (!updated) return `Memory not found: ${memoryId}`
583
610
  supersedeCompetingMemories(updated.id, nextTitle, nextContent, related)
611
+ invalidateAgentMemoryCache(currentAgentId)
584
612
  return `Updated memory "${updated.title}" (id: ${updated.id}). No further memory lookup is needed unless the user asked you to verify.`
585
613
  }
586
614
 
@@ -596,6 +624,7 @@ export async function executeMemoryAction(input: unknown, ctx: MemoryActionConte
596
624
  ? memDb.link(memoryId, ids, true)
597
625
  : memDb.unlink(memoryId, ids, true)
598
626
  if (!updated) return `Memory not found: ${memoryId}`
627
+ invalidateAgentMemoryCache(currentAgentId)
599
628
  return `${resolvedAction === 'link' ? 'Linked' : 'Unlinked'} ${ids.length} memories for "${updated.title}" (id: ${updated.id})`
600
629
  }
601
630
 
@@ -617,17 +646,13 @@ const MemoryPlugin: Plugin = {
617
646
  getAgentContext: async (ctx) => {
618
647
  const agentId = ctx.session.agentId
619
648
  if (!agentId) return null
620
- if (!shouldInjectMemoryContext(ctx.message || '')) return null
621
649
 
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')
650
+ // QMD scope: identity/* memories and contact resolution are private (DM/peer only).
651
+ // Group channels, threads, and shared "main" sessions don't see them.
652
+ const connCtx = isDirectConnectorSession(ctx.session) ? ctx.session.connectorContext : null
653
+ const isPrivateContext = !connCtx || !connCtx.isGroup
630
654
 
655
+ const memDb = getMemoryDb()
631
656
  const seen = new Set<string>()
632
657
  const formatMemoryLine = (m: { category?: string; title?: string; content?: string; pinned?: boolean }) => {
633
658
  const category = String(m.category || 'note')
@@ -636,46 +661,141 @@ const MemoryPlugin: Plugin = {
636
661
  const pin = m.pinned ? ' [pinned]' : ''
637
662
  return `- [${category}]${pin} ${title}: ${snippet}`
638
663
  }
664
+ const dedup = (m: MemoryEntry): boolean => {
665
+ if (!m?.id || seen.has(m.id)) return false
666
+ if (shouldHideFromDurableRecall(m)) return false
667
+ seen.add(m.id)
668
+ return true
669
+ }
639
670
 
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)
671
+ // --- Always-on: pinned + identity memories (bypass shouldInjectMemoryContext gate) ---
672
+ const cached = getCachedAgentMemories(agentId)
673
+ const pinned = cached?.pinned ?? memDb.listPinned(agentId, 5)
674
+ const allRecent = cached?.allRecent ?? memDb.list(agentId, 100)
675
+ if (!cached) setCachedAgentMemories(agentId, pinned, allRecent)
676
+
677
+ const pinnedLines = pinned.filter(dedup).map(formatMemoryLine)
678
+
679
+ // Fetch identity/* category memories — only in private (DM/peer) contexts
680
+ const identityMemories = isPrivateContext
681
+ ? allRecent.filter((m) => m.category?.startsWith('identity/') && dedup(m))
682
+ : []
683
+ const identityLines = identityMemories.map(formatMemoryLine)
684
+
685
+ // --- Contact resolution for connector messages (private contexts only) ---
686
+ const lastUserMsg = [...ctx.history].reverse().find((m) => m.role === 'user')
687
+ const senderName = lastUserMsg?.source?.senderName || connCtx?.senderName || null
688
+
689
+ let contactBlock = ''
690
+ let resolvedContactName: string | null = null
691
+ if (isPrivateContext && connCtx) {
692
+ // Collect all possible identifiers for the sender (senderId, senderIdAlt, channelId, etc.)
693
+ const rawSenderIds = [
694
+ lastUserMsg?.source?.senderId,
695
+ connCtx.senderId,
696
+ connCtx.senderIdAlt,
697
+ connCtx.channelId,
698
+ connCtx.channelIdAlt,
699
+ ...(connCtx.allKnownPeerIds || []),
700
+ ].filter((v): v is string => typeof v === 'string' && v.length > 0)
701
+
702
+ // Normalize a phone string to bare trailing digits for suffix matching.
703
+ // Handles: "+44 76 2422 8104", "076 2422 8104", "447624228104@s.whatsapp.net", LIDs, etc.
704
+ // UK local numbers starting with 0 are converted to 44 prefix.
705
+ const toDigits = (raw: string): string => {
706
+ const stripped = raw.replace(/@.*$/, '').replace(/[^\d]/g, '')
707
+ if (stripped.startsWith('0') && stripped.length >= 10) return '44' + stripped.slice(1)
708
+ return stripped
709
+ }
710
+
711
+ // Build a set of digit-strings from the sender's identifiers
712
+ const senderDigits = new Set(
713
+ rawSenderIds
714
+ .map(toDigits)
715
+ .filter((d) => d.length >= 6),
716
+ )
717
+
718
+ if (senderDigits.size > 0 || senderName) {
719
+ const extractPhoneDigits = (text: string): string[] => {
720
+ const matches = text.match(/(?:\+?\d[\d\s\-().]{6,}\d)/g) || []
721
+ return matches.map(toDigits).filter((d) => d.length >= 6)
722
+ }
723
+
724
+ const contactHits = allRecent.filter((m) => {
725
+ if (m.category !== 'identity/contacts' && m.category !== 'identity/relationships') return false
726
+ const content = (m.content || '').toLowerCase()
727
+ const title = (m.title || '').toLowerCase()
728
+ for (const rawId of rawSenderIds) {
729
+ const rid = rawId.toLowerCase()
730
+ if (content.includes(rid) || title.includes(rid)) return true
731
+ }
732
+ const memoryPhones = extractPhoneDigits(m.content || '')
733
+ const metaIds = (() => {
734
+ const meta = m.metadata as Record<string, unknown> | undefined
735
+ return Array.isArray(meta?.identifiers) ? (meta.identifiers as string[]).map(toDigits) : []
736
+ })()
737
+ const allMemDigits = [...memoryPhones, ...metaIds]
738
+ for (const memDigit of allMemDigits) {
739
+ for (const senderDigit of senderDigits) {
740
+ if (senderDigit.endsWith(memDigit) || memDigit.endsWith(senderDigit)) return true
741
+ }
742
+ }
743
+ return false
744
+ })
745
+ if (contactHits.length) {
746
+ const contact = contactHits[0]
747
+ const displayId = rawSenderIds[0] || senderName || 'unknown'
748
+ resolvedContactName = contact.title || null
749
+ contactBlock = [
750
+ '## Known Sender',
751
+ `The current sender (${displayId}${senderName ? `, name: ${senderName}` : ''}) is: ${contact.title}`,
752
+ contact.content || '',
753
+ ].join('\n')
754
+ }
755
+ }
756
+ }
757
+
758
+ // --- Relevance-based search (gated on message quality) ---
759
+ let relevantLines: string[] = []
760
+ let recentLines: string[] = []
761
+ if (shouldInjectMemoryContext(ctx.message || '')) {
762
+ // Prepend resolved contact name so person-specific memories rank higher
763
+ const contactQueryHint = resolvedContactName || senderName || ''
764
+ const memoryQuerySeed = [
765
+ contactQueryHint,
766
+ ctx.message,
767
+ ...ctx.history
768
+ .slice(-4)
769
+ .filter((h) => h.role === 'user')
770
+ .map((h) => h.text),
771
+ ].join('\n')
772
+
773
+ const relevantSlice = Math.max(2, 6 - pinnedLines.length)
774
+ const relevantLookup = memDb.searchWithLinked(memoryQuerySeed, agentId, 1, 10, 14)
775
+ const recent = memDb.list(agentId, 12).slice(0, 6)
776
+ const relevantByTier = partitionMemoriesByTier(relevantLookup.entries)
777
+ const recentByTier = partitionMemoriesByTier(recent)
778
+
779
+ relevantLines = relevantByTier.durable
780
+ .filter(dedup)
781
+ .slice(0, relevantSlice)
782
+ .map(formatMemoryLine)
783
+
784
+ recentLines = recentByTier.durable
785
+ .filter(dedup)
786
+ .map(formatMemoryLine)
787
+ }
674
788
 
675
789
  const parts: string[] = []
790
+ if (contactBlock) {
791
+ parts.push(contactBlock)
792
+ }
676
793
  if (pinnedLines.length) {
677
794
  parts.push(['## Pinned Memories', 'Always-loaded memories marked as important.', ...pinnedLines].join('\n'))
678
795
  }
796
+ if (identityLines.length) {
797
+ parts.push(['## Identity & Preferences', 'Always-loaded identity memories (preferences, relationships, contacts).', ...identityLines].join('\n'))
798
+ }
679
799
  if (relevantLines.length) {
680
800
  parts.push(['## Relevant Memory Hits', 'These memories were retrieved by relevance for the current objective.', ...relevantLines].join('\n'))
681
801
  }
@@ -695,6 +815,7 @@ const MemoryPlugin: Plugin = {
695
815
  '- What I\'ve discovered about projects, codebases, or environments',
696
816
  '- Problems I\'ve hit and how I solved them',
697
817
  '- Who people are and how they relate to each other',
818
+ '- Contact details: phone numbers, emails, platform IDs — use category "contacts"',
698
819
  '- Configuration details and environment specifics that I\'ll need again',
699
820
  '',
700
821
  '**Not worth cluttering my memory with:**',
@@ -703,9 +824,26 @@ const MemoryPlugin: Plugin = {
703
824
  '- Things already in my system prompt',
704
825
  '- Something I\'ve already stored',
705
826
  '',
827
+ '**Categories** — pick the one that fits best when storing:',
828
+ '- `identity/preferences` — Likes, dislikes, style choices, timezone, pronouns',
829
+ '- `identity/relationships` — Who people are and how they relate to each other',
830
+ '- `identity/contacts` — Phone numbers, emails, platform IDs for matching senders',
831
+ '- `identity/routines` — Recurring patterns: "picks up kids at 3pm", "checks in every morning"',
832
+ '- `identity/goals` — What the user is working toward: "launch MVP by Q2", "learn Spanish"',
833
+ '- `identity/events` — Significant life events: illness, birth, wedding, promotion, loss',
834
+ '- `knowledge/instructions` — Standing directives: "always respond in English", "use metric units"',
835
+ '- `knowledge/facts` — General knowledge, references, documentation',
836
+ '- `projects/decisions` — Decisions made and why',
837
+ '- `projects/learnings` — Lessons learned, solved problems, post-mortems',
838
+ '- `projects/context` — Project details, milestones, roadmap',
839
+ '- `operations/environment` — Config, credentials, endpoints, infrastructure',
840
+ '- `working/scratch` — Temporary notes that\'ll change soon',
841
+ '',
706
842
  '**Good habits:**',
707
843
  '- 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',
844
+ '- For contacts, store identifiers (phone, email, platform IDs) in content so I can match senders automatically',
845
+ '- 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',
846
+ '- Store behavioral rules about a person on their contact/relationship entry rather than as separate memories',
709
847
  '- Prefer durable memories first; only inspect session archives when transcript history is specifically needed',
710
848
  '- Check what I already know before storing something new',
711
849
  '- When I learn something that corrects old knowledge, update or remove the old memory',
@@ -784,7 +922,7 @@ const MemoryPlugin: Plugin = {
784
922
  },
785
923
  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
924
  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.',
925
+ '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
926
  'For info already in the current conversation, respond directly without calling any memory tool.',
789
927
  '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
928
  '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 +1006,7 @@ const MemoryPlugin: Plugin = {
868
1006
  },
869
1007
  {
870
1008
  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.',
1009
+ 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
1010
  parameters: {
873
1011
  type: 'object',
874
1012
  properties: {
@@ -888,7 +1026,30 @@ const MemoryPlugin: Plugin = {
888
1026
  '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
1027
  ],
890
1028
  },
891
- execute: async (args, context) => executeNamedMemoryAction('store', args, context),
1029
+ execute: async (args, context) => {
1030
+ // Guard: reject file-like content and redirect to the files tool.
1031
+ // Weaker models often confuse memory_store with file creation.
1032
+ const value = typeof args?.value === 'string' ? args.value : ''
1033
+ const title = typeof args?.title === 'string' ? args.title : ''
1034
+ const key = typeof args?.key === 'string' ? args.key : ''
1035
+ const category = typeof args?.category === 'string' ? args.category : ''
1036
+ const allText = `${title} ${key} ${category} ${value}`
1037
+ const hasFileExtension = /\.\w{1,5}$/.test(title || key)
1038
+ const hasFilePath = /(?:^|[\s"'/])(?:\/[\w.-]+){2,}\.[\w]{1,5}\b/.test(allText)
1039
+ const mentionsFileOp = /\b(?:csv|file|refactor|code|script|document|spreadsheet|inventory)\b/i.test(allText)
1040
+ const lineCount = (value.match(/\n/g) || []).length + 1
1041
+ const looksLikeCode = /^(import |export |function |const |let |var |class |interface |type |def |from |#include|package |using )/m.test(value)
1042
+ const looksLikeCsv = lineCount >= 3 && (value.match(/,/g) || []).length >= lineCount * 2
1043
+ const looksLikeStructuredData = lineCount >= 5 && (/^\s*[\[{]/m.test(value) || looksLikeCsv)
1044
+ 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:"..."}]})'
1045
+ if (hasFileExtension || hasFilePath || (mentionsFileOp && (!value || value.length > 200))) {
1046
+ return redirectMsg
1047
+ }
1048
+ if (value.length > 500 && (looksLikeCode || looksLikeStructuredData || looksLikeCsv)) {
1049
+ return redirectMsg
1050
+ }
1051
+ return executeNamedMemoryAction('store', args, context)
1052
+ },
892
1053
  },
893
1054
  {
894
1055
  name: 'memory_update',
@@ -0,0 +1,175 @@
1
+ import { after, before, describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import type { Agent, Skill } from '@/types'
7
+
8
+ const originalEnv = {
9
+ DATA_DIR: process.env.DATA_DIR,
10
+ WORKSPACE_DIR: process.env.WORKSPACE_DIR,
11
+ SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
12
+ }
13
+
14
+ let tempDir = ''
15
+ let workspaceDir = ''
16
+ let buildSessionTools: Awaited<typeof import('./index')>['buildSessionTools']
17
+ let saveAgents: Awaited<typeof import('../storage')>['saveAgents']
18
+ let saveSessions: Awaited<typeof import('../storage')>['saveSessions']
19
+ let saveSkills: Awaited<typeof import('../storage')>['saveSkills']
20
+ let loadSession: Awaited<typeof import('../storage')>['loadSession']
21
+
22
+ async function buildUseSkillTool() {
23
+ const built = await buildSessionTools(workspaceDir, ['manage_skills'], {
24
+ sessionId: 'skill-runtime-session',
25
+ agentId: 'skill-runtime-agent',
26
+ platformAssignScope: 'self',
27
+ })
28
+ const tool = built.tools.find((entry) => entry.name === 'use_skill')
29
+ assert.ok(tool, 'expected use_skill tool')
30
+ return { built, tool: tool! }
31
+ }
32
+
33
+ before(async () => {
34
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-skill-runtime-'))
35
+ workspaceDir = path.join(tempDir, 'workspace')
36
+ process.env.DATA_DIR = path.join(tempDir, 'data')
37
+ process.env.WORKSPACE_DIR = workspaceDir
38
+ process.env.SWARMCLAW_BUILD_MODE = '1'
39
+ fs.mkdirSync(process.env.DATA_DIR, { recursive: true })
40
+ fs.mkdirSync(workspaceDir, { recursive: true })
41
+
42
+ const toolsMod = await import('./index')
43
+ buildSessionTools = toolsMod.buildSessionTools
44
+
45
+ const storageMod = await import('../storage')
46
+ saveAgents = storageMod.saveAgents
47
+ saveSessions = storageMod.saveSessions
48
+ saveSkills = storageMod.saveSkills
49
+ loadSession = storageMod.loadSession
50
+
51
+ saveAgents({
52
+ 'skill-runtime-agent': {
53
+ id: 'skill-runtime-agent',
54
+ name: 'Skill Runtime Tester',
55
+ description: 'Tests runtime skill execution.',
56
+ provider: 'openai',
57
+ model: 'gpt-test',
58
+ plugins: ['manage_skills'],
59
+ tools: ['manage_skills'],
60
+ skillIds: [],
61
+ platformAssignScope: 'self',
62
+ createdAt: Date.now(),
63
+ updatedAt: Date.now(),
64
+ } satisfies Agent,
65
+ })
66
+
67
+ saveSessions({
68
+ 'skill-runtime-session': {
69
+ id: 'skill-runtime-session',
70
+ name: 'Skill Runtime Session',
71
+ cwd: workspaceDir,
72
+ user: 'tester',
73
+ provider: 'openai',
74
+ model: 'gpt-test',
75
+ messages: [],
76
+ createdAt: Date.now(),
77
+ lastActiveAt: Date.now(),
78
+ sessionType: 'human',
79
+ agentId: 'skill-runtime-agent',
80
+ plugins: ['manage_skills'],
81
+ heartbeatEnabled: false,
82
+ },
83
+ })
84
+
85
+ saveSkills({
86
+ dispatch_skill: {
87
+ id: 'dispatch_skill',
88
+ name: 'dispatch-helper',
89
+ filename: 'dispatch-helper.md',
90
+ description: 'Dispatch through manage_skills status.',
91
+ content: '# Dispatch Helper\nRun manage_skills status.',
92
+ commandDispatch: {
93
+ kind: 'tool',
94
+ toolName: 'manage_skills',
95
+ argMode: 'raw',
96
+ },
97
+ createdAt: Date.now(),
98
+ updatedAt: Date.now(),
99
+ } satisfies Skill,
100
+ prompt_skill: {
101
+ id: 'prompt_skill',
102
+ name: 'prompt-helper',
103
+ filename: 'prompt-helper.md',
104
+ description: 'Guidance-only workflow.',
105
+ content: '# Prompt Helper\nFollow this checklist.',
106
+ createdAt: Date.now(),
107
+ updatedAt: Date.now(),
108
+ } satisfies Skill,
109
+ })
110
+ })
111
+
112
+ after(() => {
113
+ if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
114
+ else process.env.DATA_DIR = originalEnv.DATA_DIR
115
+ if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
116
+ else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
117
+ if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
118
+ else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
119
+ fs.rmSync(tempDir, { recursive: true, force: true })
120
+ })
121
+
122
+ describe('use_skill runtime tool', () => {
123
+ it('selects a skill and persists the selection on the session', async () => {
124
+ const { built, tool } = await buildUseSkillTool()
125
+ try {
126
+ const raw = await tool.invoke({ action: 'select', name: 'dispatch-helper' })
127
+ const result = JSON.parse(String(raw)) as Record<string, unknown>
128
+ const session = loadSession('skill-runtime-session')
129
+
130
+ assert.equal(result.ok, true)
131
+ assert.equal((result.skill as Record<string, unknown>)?.name, 'dispatch-helper')
132
+ assert.equal(session?.skillRuntimeState?.selectedSkillName, 'dispatch-helper')
133
+ } finally {
134
+ await built.cleanup()
135
+ }
136
+ })
137
+
138
+ it('runs an executable skill by dispatching into its bound tool', async () => {
139
+ const { built, tool } = await buildUseSkillTool()
140
+ try {
141
+ const raw = await tool.invoke({
142
+ action: 'run',
143
+ name: 'dispatch-helper',
144
+ toolArgs: { action: 'status', query: 'dispatch helper' },
145
+ })
146
+ const result = JSON.parse(String(raw)) as Record<string, unknown>
147
+ const toolOutput = result.toolOutput as Array<Record<string, unknown>>
148
+ const session = loadSession('skill-runtime-session')
149
+
150
+ assert.equal(result.ok, true)
151
+ assert.equal(result.executed, true)
152
+ assert.equal(result.dispatchedTool, 'manage_skills')
153
+ assert.ok(Array.isArray(toolOutput))
154
+ assert.equal(session?.skillRuntimeState?.lastAction, 'run')
155
+ assert.equal(session?.skillRuntimeState?.lastRunToolName, 'manage_skills')
156
+ } finally {
157
+ await built.cleanup()
158
+ }
159
+ })
160
+
161
+ it('falls back to prompt guidance for non-executable skills', async () => {
162
+ const { built, tool } = await buildUseSkillTool()
163
+ try {
164
+ const raw = await tool.invoke({ action: 'run', name: 'prompt-helper' })
165
+ const result = JSON.parse(String(raw)) as Record<string, unknown>
166
+
167
+ assert.equal(result.ok, true)
168
+ assert.equal(result.executed, false)
169
+ assert.equal(result.mode, 'prompt_guidance')
170
+ assert.match(String(result.guidance || ''), /Prompt Helper/)
171
+ } finally {
172
+ await built.cleanup()
173
+ }
174
+ })
175
+ })