@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.
- package/README.md +12 -10
- package/bundled-skills/google-workspace/SKILL.md +2 -0
- package/package.json +1 -1
- package/src/app/agents/page.tsx +2 -1
- package/src/app/api/chatrooms/[id]/chat/route.ts +1 -1
- package/src/app/api/clawhub/install/route.ts +2 -0
- package/src/app/api/skills/[id]/route.ts +4 -0
- package/src/app/api/skills/route.ts +4 -0
- package/src/app/globals.css +28 -0
- package/src/app/home/page.tsx +11 -0
- package/src/app/settings/page.tsx +12 -5
- package/src/components/agents/agent-sheet.tsx +5 -5
- package/src/components/connectors/connector-list.tsx +2 -5
- package/src/components/logs/log-list.tsx +2 -5
- package/src/components/providers/provider-list.tsx +2 -5
- package/src/components/runs/run-list.tsx +2 -6
- package/src/components/schedules/schedule-list.tsx +7 -1
- package/src/components/ui/full-screen-loader.tsx +0 -29
- package/src/components/ui/page-loader.tsx +69 -0
- package/src/lib/runtime/runtime-loop.ts +21 -1
- package/src/lib/server/agents/agent-thread-session.test.ts +64 -0
- package/src/lib/server/agents/agent-thread-session.ts +1 -1
- package/src/lib/server/agents/main-agent-loop-advanced.test.ts +77 -0
- package/src/lib/server/agents/main-agent-loop.ts +259 -0
- package/src/lib/server/agents/orchestrator-lg.ts +12 -8
- package/src/lib/server/agents/orchestrator.ts +11 -7
- package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +11 -10
- package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +116 -3
- package/src/lib/server/chat-execution/chat-execution-utils.test.ts +56 -0
- package/src/lib/server/chat-execution/chat-execution-utils.ts +24 -0
- package/src/lib/server/chat-execution/chat-execution.ts +116 -29
- package/src/lib/server/chat-execution/chat-streaming-utils.ts +1 -38
- package/src/lib/server/chat-execution/stream-agent-chat.test.ts +67 -76
- package/src/lib/server/chat-execution/stream-agent-chat.ts +119 -110
- package/src/lib/server/chat-execution/stream-continuation.ts +1 -1
- package/src/lib/server/chatrooms/chatroom-helpers.test.ts +26 -0
- package/src/lib/server/chatrooms/chatroom-helpers.ts +11 -8
- package/src/lib/server/connectors/contact-boundaries.ts +101 -0
- package/src/lib/server/connectors/manager.test.ts +504 -73
- package/src/lib/server/connectors/manager.ts +41 -10
- package/src/lib/server/connectors/session-consolidation.ts +2 -0
- package/src/lib/server/connectors/session-kind.ts +7 -0
- package/src/lib/server/connectors/session.test.ts +104 -0
- package/src/lib/server/connectors/session.ts +5 -2
- package/src/lib/server/identity-continuity.test.ts +4 -3
- package/src/lib/server/identity-continuity.ts +8 -4
- package/src/lib/server/memory/memory-policy.test.ts +5 -15
- package/src/lib/server/memory/memory-policy.ts +11 -41
- package/src/lib/server/memory/session-archive-memory.ts +2 -1
- package/src/lib/server/runtime/heartbeat-service.test.ts +46 -0
- package/src/lib/server/runtime/heartbeat-service.ts +5 -1
- package/src/lib/server/runtime/runtime-settings.test.ts +4 -4
- package/src/lib/server/runtime/runtime-settings.ts +4 -0
- package/src/lib/server/runtime/session-run-manager.ts +2 -0
- package/src/lib/server/session-reset-policy.test.ts +17 -3
- package/src/lib/server/session-reset-policy.ts +4 -2
- package/src/lib/server/session-tools/connector.ts +11 -10
- package/src/lib/server/session-tools/crud.ts +41 -7
- package/src/lib/server/session-tools/delegate.ts +3 -3
- package/src/lib/server/session-tools/index.ts +2 -0
- package/src/lib/server/session-tools/manage-skills.test.ts +194 -0
- package/src/lib/server/session-tools/memory.ts +209 -48
- package/src/lib/server/session-tools/skill-runtime.test.ts +175 -0
- package/src/lib/server/session-tools/skill-runtime.ts +382 -0
- package/src/lib/server/session-tools/skills.ts +575 -0
- package/src/lib/server/skills/runtime-skill-resolver.test.ts +162 -0
- package/src/lib/server/skills/runtime-skill-resolver.ts +750 -0
- package/src/lib/server/skills/skill-discovery.ts +4 -0
- package/src/lib/server/skills/skills-normalize.test.ts +28 -0
- package/src/lib/server/skills/skills-normalize.ts +93 -1
- package/src/lib/server/storage.ts +1 -1
- package/src/lib/server/tasks/task-followups.test.ts +124 -0
- package/src/lib/server/tasks/task-followups.ts +88 -13
- package/src/types/index.ts +30 -2
- 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 '
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
641
|
-
const
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
const
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
const
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
.
|
|
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
|
-
'-
|
|
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) =>
|
|
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
|
+
})
|