@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.
- package/package.json +1 -1
- package/src/app/agents/page.tsx +2 -1
- 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/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/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 +43 -4
- package/src/lib/server/chat-execution/chat-streaming-utils.ts +1 -38
- package/src/lib/server/chat-execution/stream-agent-chat.test.ts +2 -46
- package/src/lib/server/chat-execution/stream-agent-chat.ts +51 -86
- package/src/lib/server/chat-execution/stream-continuation.ts +1 -1
- package/src/lib/server/connectors/manager.ts +1 -1
- 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/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-tools/delegate.ts +3 -3
- package/src/lib/server/session-tools/memory.ts +220 -48
- package/src/types/index.ts +4 -0
- 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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
935
|
-
getOperatingGuidance: () => ['CRITICAL: `execute_command` (not delegation) for running servers, installs, scripts. Delegation sessions end and kill processes.', 'Delegate
|
|
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 '
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
'-
|
|
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) =>
|
|
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',
|
package/src/types/index.ts
CHANGED
|
@@ -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 & 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">
|