From 900420e5d2bea2931a9b943cf38220e5981045c3 Mon Sep 17 00:00:00 2001
From: RooberSmoth <96537304+RooberSmoth@users.noreply.github.com>
Date: Mon, 10 Aug 2026 10:58:22 +0900
Subject: [PATCH 01/60] Changed AI enhance reasoning to stick to the chosen
language unless stated so.
---
agent_core/core/prompts/reasoning.py | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/agent_core/core/prompts/reasoning.py b/agent_core/core/prompts/reasoning.py
index a4ee895f..1173f961 100644
--- a/agent_core/core/prompts/reasoning.py
+++ b/agent_core/core/prompts/reasoning.py
@@ -60,6 +60,11 @@
RULE 7 — ONE ACTION FRAME
Do not chain unrelated actions into one prompt. If the user asked for one
thing, keep it as one thing. Do not add "and also..." unless the user said so.
+
+RULE 8 - PRESERVE INITIAL LANGUAGE
+If the user wrote their message in another language, only enhance in the detected
+language. Never stray or use another language other than what the user has written in
+unless the user said so.
@@ -70,6 +75,7 @@
4. simple or complex task? (single-shot vs. multi-step + verify)
5. Any scheduling signal? (one-time vs. recurring)
6. Any pronouns to replace with actual nouns?
+7. What is the intended language?
@@ -81,6 +87,7 @@
- Do NOT exceed 4 sentences
- Do NOT use passive voice — use active imperative verbs
- Do NOT leave platform names implicit when a platform is involved
+- Do NOT start using another language other than the one written in by the user initially unless asked for by the user
From 5e8626ac428de180570080d7ba015f1393051358 Mon Sep 17 00:00:00 2001
From: RooberSmoth <96537304+RooberSmoth@users.noreply.github.com>
Date: Mon, 10 Aug 2026 12:29:13 +0900
Subject: [PATCH 02/60] If the input is empty, stop enhancing and reset to
false state.
---
.../frontend/src/components/Chat/Chat.tsx | 17 ++++++++++++++++-
1 file changed, 16 insertions(+), 1 deletion(-)
diff --git a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx
index 99f58432..418c0a06 100644
--- a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx
+++ b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx
@@ -780,11 +780,26 @@ export function Chat({ sessionId, placeholder }: ChatProps) {
// Consume enhanced prompt from context when WS response arrives
useEffect(() => {
if (enhancedPrompt === null) return
+ if (input.trim() === ''){
+ //Is the box cleared? Do not repopulate the deleted text box.
+ setEnhancing(false)
+ clearEnhancedPrompt()
+ return
+ }
setInput(enhancedPrompt)
setEnhancing(false)
clearEnhancedPrompt()
inputRef.current?.focus()
- }, [enhancedPrompt, clearEnhancedPrompt, setInput])
+ }, [enhancedPrompt, clearEnhancedPrompt, setInput, input])
+
+ // Deleting the draft cancels any in-flight/pending enhance and resets the
+ //enhance button's display state
+ useEffect(() => {
+ if (input.trim() !== '') return
+ if (enhancing) setEnhancing(false)
+ if (plusOpen) setPlusOpen (false)
+ },[input, enhancing, plusOpen]
+ )
// Reset enhancing spinner if the WebSocket disconnects mid-request
useEffect(() => {
From ddd9693ed4ec1101925eedc1a28896af15ab27f7 Mon Sep 17 00:00:00 2001
From: RooberSmoth <96537304+RooberSmoth@users.noreply.github.com>
Date: Mon, 10 Aug 2026 12:30:34 +0900
Subject: [PATCH 03/60] Updated chat.tsx as i committed the wrong version.
---
app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx
index 418c0a06..f8ea152d 100644
--- a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx
+++ b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx
@@ -796,9 +796,9 @@ export function Chat({ sessionId, placeholder }: ChatProps) {
//enhance button's display state
useEffect(() => {
if (input.trim() !== '') return
- if (enhancing) setEnhancing(false)
- if (plusOpen) setPlusOpen (false)
- },[input, enhancing, plusOpen]
+ setEnhancing(false)
+ setPlusOpen (false)
+ },[input]
)
// Reset enhancing spinner if the WebSocket disconnects mid-request
From 47dc53c64a5fc29ec30f595af24c26ea23edc40b Mon Sep 17 00:00:00 2001
From: CraftBot
Date: Mon, 10 Aug 2026 14:01:13 +0900
Subject: [PATCH 04/60] Fix issue #416
---
.../frontend/src/pages/Chat/ChatPage.module.css | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.module.css b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.module.css
index e6d3b358..0da8ab3f 100644
--- a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.module.css
+++ b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.module.css
@@ -115,6 +115,12 @@
border-radius: var(--radius-lg);
background: var(--bg-tertiary);
width: 100%;
+ /* Never exceed the wrapper's clamped width. Without this the bubble grows
+ to the content's max-content size (e.g. a wide, non-wrapping code block),
+ so the wrapper's max-width clamp is bypassed and the bubble overflows the
+ panel. Capping here keeps wide
/
content inside the bubble so
+ their own overflow-x scrolls instead. */
+ max-width: 100%;
opacity: 1;
transition: opacity 150ms ease;
}
@@ -633,6 +639,10 @@
flex-direction: column;
gap: var(--space-2);
min-width: 0;
+ /* Cross-axis clamp: this flex item is not stretched (the wrapper aligns it),
+ so without a max-width it sizes to its content's max-content width and
+ overflows the wrapper. 100% resolves against the wrapper's clamped width. */
+ max-width: 100%;
}
/* Action buttons (copy) outside the bubble - positioned in the
From 05849bf2bbd702c4860303e59650765b0f81fac4 Mon Sep 17 00:00:00 2001
From: RooberSmoth <96537304+RooberSmoth@users.noreply.github.com>
Date: Mon, 10 Aug 2026 14:48:55 +0900
Subject: [PATCH 05/60] Added loading spinner to + button for visual
communication.
---
app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx
index f8ea152d..2d1b14fc 100644
--- a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx
+++ b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx
@@ -1412,7 +1412,9 @@ export function Chat({ sessionId, placeholder }: ChatProps) {
aria-label="Attach and tools"
aria-expanded={plusOpen}
>
-
+ {enhancing
+ ?
+ : }
{plusOpen && (
From b756d1e413c3691d50c772f9f061db344ff83435 Mon Sep 17 00:00:00 2001
From: RooberSmoth <96537304+RooberSmoth@users.noreply.github.com>
Date: Mon, 10 Aug 2026 15:13:53 +0900
Subject: [PATCH 06/60] Disabled send and dictate button, added 'no entry'
thing when hovering on dictate.
---
.../browser/frontend/src/components/Chat/Chat.module.css | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/app/ui_layer/browser/frontend/src/components/Chat/Chat.module.css b/app/ui_layer/browser/frontend/src/components/Chat/Chat.module.css
index 5a5e1a09..c3302833 100644
--- a/app/ui_layer/browser/frontend/src/components/Chat/Chat.module.css
+++ b/app/ui_layer/browser/frontend/src/components/Chat/Chat.module.css
@@ -596,6 +596,11 @@
color: var(--color-error, #ef4444);
}
+.micCombo:disabled {
+ color: var(--text-muted);
+ cursor: not-allowed;
+}
+
.micIconWrap {
position: relative;
display: flex;
From cb80a08dfdafae690a9ec33e722c1a2f9dc2cf8e Mon Sep 17 00:00:00 2001
From: CraftBot
Date: Mon, 10 Aug 2026 15:21:05 +0900
Subject: [PATCH 07/60] splash page loading animation
---
app/ui_layer/browser/frontend/src/App.tsx | 29 ++++----
.../components/Mascot/LoadingMascot.tsx | 72 +++++++++++++++++++
app/ui_layer/components/Mascot/index.ts | 1 +
3 files changed, 89 insertions(+), 13 deletions(-)
create mode 100644 app/ui_layer/components/Mascot/LoadingMascot.tsx
diff --git a/app/ui_layer/browser/frontend/src/App.tsx b/app/ui_layer/browser/frontend/src/App.tsx
index b3004665..e09ee5aa 100644
--- a/app/ui_layer/browser/frontend/src/App.tsx
+++ b/app/ui_layer/browser/frontend/src/App.tsx
@@ -8,6 +8,7 @@ import { SettingsPage } from './pages/Settings'
import { OnboardingPage } from './pages/Onboarding'
import { LivingUIPage } from './pages/LivingUI'
import { useWebSocket } from './contexts/WebSocketContext'
+import { LoadingMascot } from '@mascot'
// Forces LivingUIPage to remount per-project so useState initializers
// (theme, custom colors) always start fresh — not carried over from a previous project.
@@ -46,22 +47,24 @@ function App() {
-
-
-
-
-
-
+ {/* Loading indicator: the mascot jumping in place (same character +
+ jump beats as the Living UI build view). */}
+
+
+
+ Waking up CraftBot
+
)
diff --git a/app/ui_layer/components/Mascot/LoadingMascot.tsx b/app/ui_layer/components/Mascot/LoadingMascot.tsx
new file mode 100644
index 00000000..7139b556
--- /dev/null
+++ b/app/ui_layer/components/Mascot/LoadingMascot.tsx
@@ -0,0 +1,72 @@
+import { useEffect, useRef } from 'react'
+import { CraftBotMascot } from './CraftBotMascot'
+import {
+ JUMP_IN_PLACE_DURATION_MS,
+ buildJumpInPlaceKeyframes,
+ pickJumpRest,
+} from './mascotEngine'
+import styles from './Mascot.module.css'
+
+// LoadingMascot — a loading indicator: the CraftBot mascot jumping in place
+// on a loop. It reuses the exact squash-and-stretch jump beats the engine
+// plays during its `waitingJump` phase (buildJumpInPlaceKeyframes), so it
+// reads as the same character — just standing in for a spinner.
+//
+// Deliberately minimal: no wander, no sleep, no eye tracking, no engine FSM.
+// It can render before the app is interactive (boot splash), so it must not
+// depend on WebSocket state or any store.
+interface Props {
+ /** Pixel size of the mascot SVG. */
+ size?: number
+}
+
+export function LoadingMascot({ size = 88 }: Props) {
+ const bodyRef = useRef(null)
+
+ // Loop: jump in place → short random rest → jump again. Mirrors the
+ // waitingJump loop in useMascotBehavior (a `cancelled` closure flag stops
+ // every async continuation before it schedules the next step).
+ useEffect(() => {
+ const el = bodyRef.current
+ if (!el) return
+ // Reduced motion: hold the mascot still rather than looping the jump.
+ if (
+ typeof window.matchMedia === 'function' &&
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
+ ) {
+ return
+ }
+
+ let cancelled = false
+ let restTimer: number | undefined
+ let anim: Animation | null = null
+
+ const jump = () => {
+ if (cancelled || !bodyRef.current) return
+ anim = el.animate(buildJumpInPlaceKeyframes(), {
+ duration: JUMP_IN_PLACE_DURATION_MS,
+ easing: 'ease-in-out', // same curve as the hop — linear reads floaty
+ fill: 'forwards',
+ })
+ anim.onfinish = () => {
+ if (cancelled) return
+ restTimer = window.setTimeout(jump, pickJumpRest())
+ }
+ }
+
+ jump()
+ return () => {
+ cancelled = true
+ window.clearTimeout(restTimer)
+ try { anim?.cancel() } catch { /* already gone */ }
+ }
+ }, [])
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/app/ui_layer/components/Mascot/index.ts b/app/ui_layer/components/Mascot/index.ts
index fef0631e..14bd9e26 100644
--- a/app/ui_layer/components/Mascot/index.ts
+++ b/app/ui_layer/components/Mascot/index.ts
@@ -1,5 +1,6 @@
export { CraftBotMascot } from './CraftBotMascot'
export { DraftMascot, DRAFT_MASCOT_EXIT_MS } from './DraftMascot'
+export { LoadingMascot } from './LoadingMascot'
export { MascotBackground } from './MascotBackground'
export { MascotDisplay } from './MascotDisplay'
export { SpeechBubble } from './SpeechBubble'
From aef2fd72c0a906b97806eaaf21a28d3d923d439f Mon Sep 17 00:00:00 2001
From: RooberSmoth <96537304+RooberSmoth@users.noreply.github.com>
Date: Mon, 10 Aug 2026 16:24:18 +0900
Subject: [PATCH 08/60] Added 30s timeout - needs testing?
---
.../frontend/src/components/Chat/Chat.tsx | 47 +++++++++++++++----
1 file changed, 38 insertions(+), 9 deletions(-)
diff --git a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx
index 2d1b14fc..aefcbcd9 100644
--- a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx
+++ b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx
@@ -84,6 +84,7 @@ interface SuggestedPlaybook {
}
const SUGGESTED_PLAYBOOK_COUNT = 3
+const ENHANCE_TIMEOUT_MS = 30000
// Chat-delivery actions — the ones whose visible form IS a chat bubble in
// this interface. A chunk whose only actions are these renders nothing at
@@ -321,6 +322,22 @@ export function Chat({ sessionId, placeholder }: ChatProps) {
}, [dispatch, sessionId])
const [enhancing, setEnhancing] = useState(false)
+ // Guards against the user waiting forever if the enhance WS response
+ // never comes back. Cleared (never fires) on success, disconnect, or the
+ // draft being emptied — see the effects/handler below.
+ const enhanceTimeoutRef = useRef | null>(null)
+
+ // Single choke point for turning enhancing off: always cancels the
+ // timeout alongside it, so success/disconnect/clear-draft paths can't
+ // leave a stale timer that fires 30s later on an already-finished request.
+ const stopEnhancing = useCallback(() => {
+ if (enhanceTimeoutRef.current !== null) {
+ clearTimeout(enhanceTimeoutRef.current)
+ enhanceTimeoutRef.current = null
+ }
+ setEnhancing(false)
+ }, [])
+
// Reply-to-bubble: set from an agent bubble's hover Reply action. The
// next send carries the quoted original so the event stream records
// which message the user replied to. No routing — session is explicit.
@@ -782,35 +799,46 @@ export function Chat({ sessionId, placeholder }: ChatProps) {
if (enhancedPrompt === null) return
if (input.trim() === ''){
//Is the box cleared? Do not repopulate the deleted text box.
- setEnhancing(false)
+ stopEnhancing()
clearEnhancedPrompt()
return
}
setInput(enhancedPrompt)
- setEnhancing(false)
+ stopEnhancing()
clearEnhancedPrompt()
inputRef.current?.focus()
- }, [enhancedPrompt, clearEnhancedPrompt, setInput, input])
+ }, [enhancedPrompt, clearEnhancedPrompt, setInput, input, stopEnhancing])
// Deleting the draft cancels any in-flight/pending enhance and resets the
//enhance button's display state
useEffect(() => {
if (input.trim() !== '') return
- setEnhancing(false)
+ stopEnhancing()
setPlusOpen (false)
- },[input]
+ },[input, stopEnhancing]
)
// Reset enhancing spinner if the WebSocket disconnects mid-request
useEffect(() => {
- if (!connected) setEnhancing(false)
- }, [connected])
+ if (!connected) stopEnhancing()
+ }, [connected, stopEnhancing])
const handleEnhancePrompt = useCallback(() => {
if (!input.trim() || enhancing) return
setEnhancing(true)
enhancePrompt(input.trim())
- }, [input, enhancing, enhancePrompt])
+ enhanceTimeoutRef.current = setTimeout(() => {
+ enhanceTimeoutRef.current = null
+ setEnhancing(false)
+ showToast('error', 'AI enhance timed out — please try again.')
+ }, ENHANCE_TIMEOUT_MS)
+ }, [input, enhancing, enhancePrompt, showToast])
+
+ useEffect(() => {
+ return () => {
+ if (enhanceTimeoutRef.current !== null) clearTimeout(enhanceTimeoutRef.current)
+ }
+}, [])
const handleOptionClick = useCallback((value: string, messageId: string) => {
if (value === 'open_settings_model') {
@@ -1446,6 +1474,7 @@ export function Chat({ sessionId, placeholder }: ChatProps) {
className={`${styles.micCombo}${isListening ? ` ${styles.micComboActive}` : ''}`}
title={isListening ? 'Stop listening' : 'Voice input'}
onClick={toggleListening}
+ disabled = {enhancing}
>
{isListening ? : }
@@ -1493,7 +1522,7 @@ export function Chat({ sessionId, placeholder }: ChatProps) {
type="button"
className={styles.sendBtn}
onClick={handleSend}
- disabled={(!input.trim() && pendingAttachments.length === 0) || !attachmentValidation.valid}
+ disabled={(!input.trim() && pendingAttachments.length === 0) || !attachmentValidation.valid || enhancing}
title="Send"
aria-label="Send message"
>
From f292f47fcba681cc2805809f13c4da33d5b06e80 Mon Sep 17 00:00:00 2001
From: CraftBot
Date: Mon, 10 Aug 2026 19:51:47 +0900
Subject: [PATCH 09/60] make chat input disabled
---
.../browser/frontend/src/components/Chat/Chat.module.css | 6 ++++++
app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx | 6 +++++-
2 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/app/ui_layer/browser/frontend/src/components/Chat/Chat.module.css b/app/ui_layer/browser/frontend/src/components/Chat/Chat.module.css
index c3302833..30dbf2d0 100644
--- a/app/ui_layer/browser/frontend/src/components/Chat/Chat.module.css
+++ b/app/ui_layer/browser/frontend/src/components/Chat/Chat.module.css
@@ -429,6 +429,12 @@
outline: none;
}
+.input:disabled {
+ color: var(--text-muted);
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
.input::placeholder {
color: var(--text-muted);
}
diff --git a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx
index aefcbcd9..1eee0307 100644
--- a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx
+++ b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx
@@ -806,7 +806,10 @@ export function Chat({ sessionId, placeholder }: ChatProps) {
setInput(enhancedPrompt)
stopEnhancing()
clearEnhancedPrompt()
- inputRef.current?.focus()
+ // Defer focus: the textarea is still disabled in the DOM this tick
+ // (enhancing→false hasn't re-rendered yet), so a synchronous focus()
+ // would be ignored.
+ setTimeout(() => inputRef.current?.focus(), 0)
}, [enhancedPrompt, clearEnhancedPrompt, setInput, input, stopEnhancing])
// Deleting the draft cancels any in-flight/pending enhance and resets the
@@ -1428,6 +1431,7 @@ export function Chat({ sessionId, placeholder }: ChatProps) {
rows={1}
lang={micLang}
inputMode="text"
+ disabled={enhancing}
/>
From 5b6d0659bf9a5098839f4539225ba22cd5bfb554 Mon Sep 17 00:00:00 2001
From: CraftBot
Date: Mon, 10 Aug 2026 20:55:08 +0900
Subject: [PATCH 10/60] Remove outdated sections in living UI setting page
---
agent_file_system/GLOBAL_LIVING_UI.md | 25 +-
.../GLOBAL_LIVING_UI.md | 25 +-
.../src/pages/Settings/LivingUISettings.tsx | 475 +-----------------
.../src/store/selectors/livingUiSettings.ts | 4 -
.../src/store/slices/generalSettingsSlice.ts | 4 +-
.../src/store/slices/livingUiSettingsSlice.ts | 31 +-
app/ui_layer/settings/general_settings.py | 4 +-
7 files changed, 27 insertions(+), 541 deletions(-)
diff --git a/agent_file_system/GLOBAL_LIVING_UI.md b/agent_file_system/GLOBAL_LIVING_UI.md
index a5a7060f..56561d1a 100644
--- a/agent_file_system/GLOBAL_LIVING_UI.md
+++ b/agent_file_system/GLOBAL_LIVING_UI.md
@@ -35,21 +35,14 @@ Per-project settings from Phase 0 Q&A override these when they conflict.
- Text must have sufficient contrast against background (dark text on light backgrounds, light text on dark backgrounds)
- Never use light text on light backgrounds or dark text on dark backgrounds
-## Optional Rules
-
-- [x] Enable drag-and-drop for reordering items
-- [x] Add keyboard shortcuts for common actions
-- [x] Show item count badges on categories/sections
-- [x] Add search/filter bar to all list views
-- [x] Support bulk selection and batch operations
-- [ ] Enable dark mode only (ignore system preference)
-- [ ] Add animations and transitions to UI interactions
-- [ ] Show timestamps on all items (created/updated)
-- [ ] Enable infinite scroll instead of pagination
-- [ ] Add undo/redo support for user actions
-- [ ] Show breadcrumb navigation for nested views
-
## Custom Rules
-
-
+
+
+- Enable drag-and-drop for reordering items
+- Add keyboard shortcuts for common actions
+- Show item count badges on categories/sections
+- Add search/filter bar to all list views
+- Support bulk selection and batch operations
+- Add animations and transitions to UI interactions
+- Add undo/redo support for user actions
diff --git a/app/data/agent_file_system_template/GLOBAL_LIVING_UI.md b/app/data/agent_file_system_template/GLOBAL_LIVING_UI.md
index a5a7060f..56561d1a 100644
--- a/app/data/agent_file_system_template/GLOBAL_LIVING_UI.md
+++ b/app/data/agent_file_system_template/GLOBAL_LIVING_UI.md
@@ -35,21 +35,14 @@ Per-project settings from Phase 0 Q&A override these when they conflict.
- Text must have sufficient contrast against background (dark text on light backgrounds, light text on dark backgrounds)
- Never use light text on light backgrounds or dark text on dark backgrounds
-## Optional Rules
-
-- [x] Enable drag-and-drop for reordering items
-- [x] Add keyboard shortcuts for common actions
-- [x] Show item count badges on categories/sections
-- [x] Add search/filter bar to all list views
-- [x] Support bulk selection and batch operations
-- [ ] Enable dark mode only (ignore system preference)
-- [ ] Add animations and transitions to UI interactions
-- [ ] Show timestamps on all items (created/updated)
-- [ ] Enable infinite scroll instead of pagination
-- [ ] Add undo/redo support for user actions
-- [ ] Show breadcrumb navigation for nested views
-
## Custom Rules
-
-
+
+
+- Enable drag-and-drop for reordering items
+- Add keyboard shortcuts for common actions
+- Show item count badges on categories/sections
+- Add search/filter bar to all list views
+- Support bulk selection and batch operations
+- Add animations and transitions to UI interactions
+- Add undo/redo support for user actions
diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/LivingUISettings.tsx b/app/ui_layer/browser/frontend/src/pages/Settings/LivingUISettings.tsx
index e0b9f547..89a40c7c 100644
--- a/app/ui_layer/browser/frontend/src/pages/Settings/LivingUISettings.tsx
+++ b/app/ui_layer/browser/frontend/src/pages/Settings/LivingUISettings.tsx
@@ -1,105 +1,28 @@
-import { useState, useEffect, useRef } from 'react'
+import { useState, useEffect } from 'react'
import {
Play,
Square,
Trash2,
Loader2,
- RotateCcw,
Check,
- X,
- Plus,
Download,
Copy,
ChevronRight,
} from 'lucide-react'
-import { Button, Badge, ConfirmModal } from '../../components/ui'
+import { Button, ConfirmModal } from '../../components/ui'
import { useConfirmModal } from '../../hooks'
import styles from './SettingsPage.module.css'
import { useSettingsWebSocket } from './useSettingsWebSocket'
import { useAppDispatch, useAppSelector } from '../../store/hooks'
import {
- setGlobalConfig as setSliceGlobalConfig,
updateProjectSetting,
type LivingUISettingsProject as LivingUIProject,
} from '../../store/slices/livingUiSettingsSlice'
import {
selectLivingUiSettingsProjects,
selectLivingUiSettingsHasLoadedProjects,
- selectLivingUiGlobalConfig,
- selectLivingUiHasLoadedGlobalConfig,
} from '../../store/selectors/livingUiSettings'
-interface ParsedRule {
- enabled: boolean
- text: string
- lineIndex: number
-}
-
-interface ParsedPref {
- key: string
- value: string
- lineIndex: number
-}
-
-interface ParsedSection {
- title: string
- rules: ParsedRule[]
- prefs: ParsedPref[]
-}
-
-const FONT_OPTIONS: Array<{ value: string; label: string }> = [
- { value: 'System default (Segoe UI, sans-serif)', label: 'System Default' },
- { value: 'Inter, sans-serif', label: 'Inter' },
- { value: 'Roboto, sans-serif', label: 'Roboto' },
- { value: 'Open Sans, sans-serif', label: 'Open Sans' },
- { value: 'Poppins, sans-serif', label: 'Poppins' },
- { value: 'Lato, sans-serif', label: 'Lato' },
- { value: 'Nunito, sans-serif', label: 'Nunito' },
- { value: 'Source Sans Pro, sans-serif', label: 'Source Sans Pro' },
- { value: 'JetBrains Mono, monospace', label: 'JetBrains Mono' },
- { value: 'Fira Code, monospace', label: 'Fira Code' },
-]
-
-function parseGlobalConfig(content: string): { sections: ParsedSection[]; rawLines: string[] } {
- const lines = content.split('\n')
- const sections: ParsedSection[] = []
- let currentSection: ParsedSection | null = null
-
- lines.forEach((line, i) => {
- const sectionMatch = line.match(/^##\s+(.+)/)
- if (sectionMatch) {
- currentSection = { title: sectionMatch[1], rules: [], prefs: [] }
- sections.push(currentSection)
- return
- }
- const ruleMatch = line.match(/^- \[(x| )\]\s+(.+)/)
- if (ruleMatch && currentSection) {
- currentSection.rules.push({ enabled: ruleMatch[1] === 'x', text: ruleMatch[2], lineIndex: i })
- return
- }
- const prefMatch = line.match(/^- \*\*(.+?):\*\*\s*(.*)/)
- if (prefMatch && currentSection) {
- currentSection.prefs.push({ key: prefMatch[1], value: prefMatch[2], lineIndex: i })
- }
- })
-
- return { sections, rawLines: lines }
-}
-
-function rebuildConfig(rawLines: string[], changes: Map): string {
- return rawLines.map((line, i) => {
- if (changes.has(i)) {
- const newVal = changes.get(i)!
- if (newVal === 'true' || newVal === 'false') {
- return line.replace(/^- \[(x| )\]/, newVal === 'true' ? '- [x]' : '- [ ]')
- }
- const prefMatch = line.match(/^(- \*\*.+?:\*\*\s*)(.*)/)
- if (prefMatch) return prefMatch[1] + newVal
- }
- return line
- }).join('\n')
-}
-
export function LivingUISettings() {
const { send, onMessage, isConnected } = useSettingsWebSocket()
const dispatch = useAppDispatch()
@@ -108,69 +31,18 @@ export function LivingUISettings() {
// Slice-backed: cached across remounts.
const projects = useAppSelector(selectLivingUiSettingsProjects)
const hasLoadedProjects = useAppSelector(selectLivingUiSettingsHasLoadedProjects)
- const originalConfig = useAppSelector(selectLivingUiGlobalConfig)
- const hasLoadedGlobalConfig = useAppSelector(selectLivingUiHasLoadedGlobalConfig)
const loading = !hasLoadedProjects
- const globalLoading = !hasLoadedGlobalConfig
// Transient UI state.
const [actionInProgress, setActionInProgress] = useState(null)
- const [globalConfig, setLocalGlobalConfig] = useState('')
- const [globalSaving, setGlobalSaving] = useState(false)
- const [globalSaveStatus, setGlobalSaveStatus] = useState<'idle' | 'success' | 'error'>('idle')
- const [newRule, setNewRule] = useState('')
- const [rulesExpanded, setRulesExpanded] = useState(true)
const [expandedProjects, setExpandedProjects] = useState>(new Set())
- const [lineChanges, setLineChanges] = useState
)}
From 83cb33cb4aa49f7e25fd9690f7cfc4f729c3a370 Mon Sep 17 00:00:00 2001
From: CraftBot
Date: Thu, 13 Aug 2026 21:54:58 +0900
Subject: [PATCH 14/60] Update graph UI and update relevent memories preview
logic
---
agent_core/core/impl/memory/injector.py | 14 +-
agent_core/core/impl/memory/manager.py | 126 ++++++++++++++++--
agent_file_system/AGENT.md | 2 +-
app/data/agent_file_system_template/AGENT.md | 2 +-
.../src/pages/Memory/MemoryGraphCanvas.tsx | 77 ++++++++++-
5 files changed, 199 insertions(+), 22 deletions(-)
diff --git a/agent_core/core/impl/memory/injector.py b/agent_core/core/impl/memory/injector.py
index e6bf3b64..432ea197 100644
--- a/agent_core/core/impl/memory/injector.py
+++ b/agent_core/core/impl/memory/injector.py
@@ -75,13 +75,25 @@ def inject_memory_event(query: str, session_id: Optional[str] = None) -> None:
if not pointers:
return
+ # These are TRUNCATED previews (pointers), not full memories: each line is
+ # a snippet centred on the query match, and a leading/trailing "..." marks
+ # omitted text. The header says so explicitly because "..." alone is an
+ # ambiguous cut-off signal — the agent must know to expand a relevant-but-
+ # clipped preview (memory_search / grep_files / read the source file)
+ # before relying on it.
+ header = (
+ "Relevant memory previews (TRUNCATED pointers, not full records; "
+ '"..." marks omitted text). If a preview is relevant but clipped, '
+ "read the source file or memory_search/grep for the full memory "
+ "before relying on it:"
+ )
lines = []
for ptr in pointers:
lines.append(
f"- [{ptr.file_path}] {ptr.section_path}: {ptr.summary} "
f"(relevance: {ptr.relevance_score:.2f})"
)
- message = "\n".join(lines)
+ message = header + "\n" + "\n".join(lines)
# session_id=None means "no task context" — log directly to the main
# stream rather than going through .log(task_id=None), which would fall
diff --git a/agent_core/core/impl/memory/manager.py b/agent_core/core/impl/memory/manager.py
index 406e5d66..dab1613b 100644
--- a/agent_core/core/impl/memory/manager.py
+++ b/agent_core/core/impl/memory/manager.py
@@ -75,6 +75,14 @@
RECENCY_MAX_BONUS = 0.05
RECENCY_HALF_LIFE_DAYS = 30.0
+# Query-aware preview window. The injected memory preview is centred on the
+# query match instead of the chunk's head, so the fact that made the chunk
+# relevant is not truncated away (a from-the-start summary once cut off
+# "Tobias Garcia" and the agent had to grep for it). PREVIEW_MAX_CHARS bounds
+# the snippet; PREVIEW_LEAD keeps a little context before the match.
+PREVIEW_MAX_CHARS = 180
+PREVIEW_LEAD = 40
+
# Log-line preview limits. Keep multi-line queries and long summaries from
# bleeding across log entries.
_LOG_QUERY_MAX_CHARS = 300
@@ -451,12 +459,15 @@ def retrieve(
ids = (results.get("ids") or [[]])[0]
metadatas = (results.get("metadatas") or [[]])[0]
distances = (results.get("distances") or [[]])[0]
+ documents = (results.get("documents") or [[]])[0]
for i, chunk_id in enumerate(ids):
meta = metadatas[i] if i < len(metadatas) else {}
distance = distances[i] if i < len(distances) else 1.0
vector_hits[chunk_id] = {
"score": _cosine_distance_to_similarity(distance),
"metadata": meta,
+ # Kept for the query-aware preview snippet (built below).
+ "document": documents[i] if i < len(documents) else "",
"rank": i,
}
except Exception as e:
@@ -511,9 +522,12 @@ def retrieve(
in set(file_filter)
}
- # Pull metadata for any BM25-only hits so we can build pointers + age.
+ # Pull metadata + documents for any non-vector hits so we can build
+ # pointers, age them, and window a query-aware preview.
missing_ids = [cid for cid in candidate_ids if cid not in vector_hits]
- extra_meta = self._fetch_metadata(missing_ids) if missing_ids else {}
+ extra_meta, extra_docs = (
+ self._fetch_meta_and_docs(missing_ids) if missing_ids else ({}, {})
+ )
pointers: List[MemoryPointer] = []
@@ -548,13 +562,26 @@ def retrieve(
if final < min_relevance and graph_score < GRAPH_ELIGIBILITY_SCORE:
continue
+ # Query-aware preview: window the snippet around the query match
+ # rather than the chunk head. Prefer the item's clean content
+ # (MEMORY.md items), else the raw document (file chunks), else the
+ # stored summary as a last resort.
+ full_text = (
+ meta.get("item_content")
+ or (
+ vector_hits[chunk_id].get("document")
+ if chunk_id in vector_hits
+ else extra_docs.get(chunk_id, "")
+ )
+ or meta.get("summary", "")
+ )
pointers.append(
MemoryPointer(
chunk_id=chunk_id,
file_path=meta.get("file_path", ""),
section_path=meta.get("section_path", ""),
title=meta.get("title", ""),
- summary=meta.get("summary", ""),
+ summary=self._preview_snippet(query, full_text),
relevance_score=final,
metadata={
k: v
@@ -719,6 +746,30 @@ def _fetch_metadata(self, chunk_ids: List[str]) -> Dict[str, Dict[str, Any]]:
logger.warning(f"[MEMORY] Metadata fetch failed: {e}")
return {}
+ def _fetch_meta_and_docs(
+ self, chunk_ids: List[str]
+ ) -> tuple[Dict[str, Dict[str, Any]], Dict[str, str]]:
+ """Fetch metadata AND documents for a set of chunk ids in one call.
+
+ Used for non-vector candidates so the query-aware preview can window
+ the full chunk text (the vector channel already carries its own docs).
+ """
+ if not chunk_ids:
+ return {}, {}
+ try:
+ result = self.collection.get(
+ ids=chunk_ids, include=["metadatas", "documents"]
+ )
+ ids = result.get("ids") or []
+ metas = result.get("metadatas") or []
+ docs = result.get("documents") or []
+ meta_map = {ids[i]: metas[i] for i in range(len(ids))}
+ doc_map = {ids[i]: (docs[i] if i < len(docs) else "") for i in range(len(ids))}
+ return meta_map, doc_map
+ except Exception as e:
+ logger.warning(f"[MEMORY] Metadata/document fetch failed: {e}")
+ return {}, {}
+
def retrieve_full_content(self, chunk_id: str) -> Optional[str]:
"""
Retrieve the full content of a specific chunk by its ID.
@@ -1223,32 +1274,79 @@ def _split_by_sentences(self, text: str) -> List[str]:
return chunks
- def _create_summary(self, content: str, max_length: int = 150) -> str:
- """
- Create a brief summary of content for the memory pointer.
+ def _clean_for_preview(self, content: str) -> str:
+ """Strip markdown SYNTAX positionally for a readable preview.
- Takes the first meaningful text, cleans it up, and truncates.
- Markdown SYNTAX is stripped positionally — never characters inside
- words, or snake_case identifiers like list_available_integrations
- collapse into unreadable mush.
+ Never removes characters inside words, or snake_case identifiers like
+ list_available_integrations collapse into unreadable mush.
"""
- clean = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", content) # Links
+ clean = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", content or "") # Links
clean = re.sub(r"^#{1,6}\s+", "", clean, flags=re.MULTILINE) # Headings
clean = clean.replace("`", "") # Inline-code markers
clean = re.sub(r"\*+", "", clean) # Bold/italic markers
clean = re.sub(r"\s+", " ", clean).strip() # Whitespace
+ return clean
- # Take first max_length chars, break at word boundary
+ @staticmethod
+ def _truncate_preview(clean: str, max_length: int) -> str:
+ """Head-of-text truncation at a word boundary, with trailing '...'."""
if len(clean) <= max_length:
return clean
-
truncated = clean[:max_length]
last_space = truncated.rfind(" ")
if last_space > max_length * 0.7:
truncated = truncated[:last_space]
-
return truncated + "..."
+ def _create_summary(self, content: str, max_length: int = 150) -> str:
+ """Brief from-the-head summary of content for the stored pointer."""
+ return self._truncate_preview(self._clean_for_preview(content), max_length)
+
+ def _preview_snippet(self, query: str, content: str) -> str:
+ """A query-CENTRED preview of a chunk (keyword-in-context).
+
+ Cleans markdown like the stored summary, then returns a window
+ centred on the first query match that covers the most query terms,
+ with leading/trailing ellipses marking omitted text. Degrades to the
+ head-of-content summary when no query term appears, so non-matching
+ previews look exactly as before. Built at retrieval time because the
+ stored summary is query-independent.
+ """
+ clean = self._clean_for_preview(content)
+ if len(clean) <= PREVIEW_MAX_CHARS:
+ return clean
+
+ terms = [
+ t for t in re.findall(r"[a-z0-9]+", (query or "").lower()) if len(t) > 2
+ ]
+ low = clean.lower()
+ # Anchor on the term occurrence whose window covers the most distinct
+ # query terms, so multi-word matches stay together.
+ anchor = -1
+ best_hits = 0
+ for term in terms:
+ i = low.find(term)
+ while i != -1:
+ hits = sum(1 for u in terms if u in low[i : i + PREVIEW_MAX_CHARS])
+ if hits > best_hits:
+ best_hits = hits
+ anchor = i
+ i = low.find(term, i + len(term))
+
+ if anchor < 0:
+ # No query term in the chunk — fall back to the head snippet.
+ return self._truncate_preview(clean, PREVIEW_MAX_CHARS)
+
+ start = max(0, anchor - PREVIEW_LEAD)
+ end = min(len(clean), start + PREVIEW_MAX_CHARS)
+ start = max(0, end - PREVIEW_MAX_CHARS) # re-widen left near the tail
+ snippet = clean[start:end].strip()
+ if start > 0:
+ snippet = "..." + snippet
+ if end < len(clean):
+ snippet = snippet + "..."
+ return snippet
+
# ───────────────────────────── Indexing Helpers ─────────────────────────────
def _index_file(self, file_path: Path) -> int:
diff --git a/agent_file_system/AGENT.md b/agent_file_system/AGENT.md
index cdb3b9d5..c0dd9c43 100644
--- a/agent_file_system/AGENT.md
+++ b/agent_file_system/AGENT.md
@@ -3090,7 +3090,7 @@ This list is opinion, not authoritative. The user has the final say.
Memory is your long-term recall. It is RAG-backed (relevance search over MEMORY.md and a few other files), not text-grep. Items reach MEMORY.md only after the daily memory-processing pipeline distills them from the event stream. You do NOT write MEMORY.md directly.
Two ways memory reaches you:
-- **Automatic injection (passive).** On every user message, the most relevant memories (top 5, relevance ≥ 0.5) are retrieved and dropped into your context as a `relevant_memories` event — one line per pointer: `- [file_path] section_path: summary (relevance: 0.XX)`. If nothing clears the threshold, no event is emitted. You do NOT need to call `memory_search` just to see what you already know.
+- **Automatic injection (passive).** On every user message, the most relevant memories (top 5, relevance ≥ 0.5) are retrieved and dropped into your context as a `relevant_memories` event — one line per pointer: `- [file_path] section_path: summary (relevance: 0.XX)`. If nothing clears the threshold, no event is emitted. You do NOT need to call `memory_search` just to see what you already know. Each `summary` is a TRUNCATED preview (a pointer), not the full memory: it is a snippet centred on the words that matched your query, and a leading/trailing `...` marks text that was cut. Treat these as leads, not complete records — if a preview is on-topic but clipped where it matters, expand it with `memory_search` or by reading the source file before you rely on it.
- **`memory_search` action (active).** Use it when you need to dig deeper on a specific question mid-run, beyond what got auto-injected.
Code: [agent_core/core/impl/memory/manager.py](agent_core/core/impl/memory/manager.py) (`MemoryManager`), [agent_core/core/impl/memory/memory_file_watcher.py](agent_core/core/impl/memory/memory_file_watcher.py) (incremental re-indexing), [app/data/action/memory_search.py](app/data/action/memory_search.py) (action).
diff --git a/app/data/agent_file_system_template/AGENT.md b/app/data/agent_file_system_template/AGENT.md
index 28675368..941f78c5 100644
--- a/app/data/agent_file_system_template/AGENT.md
+++ b/app/data/agent_file_system_template/AGENT.md
@@ -3090,7 +3090,7 @@ This list is opinion, not authoritative. The user has the final say.
Memory is your long-term recall. It is RAG-backed (relevance search over MEMORY.md and a few other files), not text-grep. Items reach MEMORY.md only after the daily memory-processing pipeline distills them from the event stream. You do NOT write MEMORY.md directly.
Two ways memory reaches you:
-- **Automatic injection (passive).** On every user message, the most relevant memories (top 5, relevance ≥ 0.5) are retrieved and dropped into your context as a `relevant_memories` event — one line per pointer: `- [file_path] section_path: summary (relevance: 0.XX)`. If nothing clears the threshold, no event is emitted. You do NOT need to call `memory_search` just to see what you already know.
+- **Automatic injection (passive).** On every user message, the most relevant memories (top 5, relevance ≥ 0.5) are retrieved and dropped into your context as a `relevant_memories` event — one line per pointer: `- [file_path] section_path: summary (relevance: 0.XX)`. If nothing clears the threshold, no event is emitted. You do NOT need to call `memory_search` just to see what you already know. Each `summary` is a TRUNCATED preview (a pointer), not the full memory: it is a snippet centred on the words that matched your query, and a leading/trailing `...` marks text that was cut. Treat these as leads, not complete records — if a preview is on-topic but clipped where it matters, expand it with `memory_search` or by reading the source file before you rely on it.
- **`memory_search` action (active).** Use it when you need to dig deeper on a specific question mid-run, beyond what got auto-injected.
Code: [agent_core/core/impl/memory/manager.py](agent_core/core/impl/memory/manager.py) (`MemoryManager`), [agent_core/core/impl/memory/memory_file_watcher.py](agent_core/core/impl/memory/memory_file_watcher.py) (incremental re-indexing), [app/data/action/memory_search.py](app/data/action/memory_search.py) (action).
diff --git a/app/ui_layer/browser/frontend/src/pages/Memory/MemoryGraphCanvas.tsx b/app/ui_layer/browser/frontend/src/pages/Memory/MemoryGraphCanvas.tsx
index 5531fa9d..da194489 100644
--- a/app/ui_layer/browser/frontend/src/pages/Memory/MemoryGraphCanvas.tsx
+++ b/app/ui_layer/browser/frontend/src/pages/Memory/MemoryGraphCanvas.tsx
@@ -32,6 +32,12 @@ const COMMUNITY_COLORS = [
const FRICTION = 0.8
const REPULSION = 2400
const REPULSION_CUTOFF = 320
+// A node's repulsion scales with how connected it is: a well-connected hub
+// pushes other nodes away harder, so its dense cloud of memories spreads out
+// instead of piling on top of neighbouring hubs. Sub-linear (log of degree)
+// so a 60-link hub is strongly repulsive without detonating the layout;
+// degree-1 leaves keep the base repulsion (multiplier 1).
+const HUB_REPULSION = 2.0
// Extra clearance every pair of nodes keeps beyond their radii.
const NODE_CLEARANCE = 22
// Hard no-overlap guarantee: pairs closer than radii + this gap are
@@ -77,6 +83,8 @@ interface SimNode {
vy: number
radius: number
color: string
+ // Repulsion multiplier from this node's edge degree — hubs shove harder.
+ repel: number
// Celestial rendering: precomputed "r,g,b" strings so the per-frame
// rgba() concatenation stays allocation-cheap.
starHalo: string // desaturated community tint for the outer glow
@@ -252,6 +260,15 @@ export function MemoryGraphCanvas({ graph, selectedId, onSelect, fitNonce = 0, r
const cx = width / 2
const cy = height / 2
+ // Degree = how connected each node is; drives the hub-repulsion boost.
+ const degreeOf = new Map()
+ for (const e of graph?.edges || []) {
+ degreeOf.set(e.source, (degreeOf.get(e.source) || 0) + 1)
+ degreeOf.set(e.target, (degreeOf.get(e.target) || 0) + 1)
+ }
+ const repelOf = (id: string) =>
+ 1 + HUB_REPULSION * Math.log2(Math.max(1, degreeOf.get(id) || 1))
+
const fresh: MemoryGraphNode[] = []
for (const data of graph?.nodes || []) {
incoming.add(data.id)
@@ -259,6 +276,7 @@ export function MemoryGraphCanvas({ graph, selectedId, onSelect, fitNonce = 0, r
if (existing) {
existing.data = data
existing.radius = nodeRadius(data)
+ existing.repel = repelOf(data.id)
existing.color = nodeColor(data)
const star = starColors(existing.color)
existing.starHalo = star.halo
@@ -312,6 +330,7 @@ export function MemoryGraphCanvas({ graph, selectedId, onSelect, fitNonce = 0, r
x, y,
vx: 0, vy: 0,
radius: nodeRadius(data),
+ repel: repelOf(data.id),
color,
starHalo: star.halo,
starCore: star.core,
@@ -583,7 +602,8 @@ export function MemoryGraphCanvas({ graph, selectedId, onSelect, fitNonce = 0, r
const sizeBoost = 1 + (n1.radius + n2.radius) * 0.04
const minDist = n1.radius + n2.radius + NODE_CLEARANCE
const overlapRamp = d < minDist ? 1 + 2 * (minDist - d) / minDist : 1
- const force = (REPULSION * sizeBoost * overlapRamp / d2) * alpha
+ // n2 repels n1; scale by n2's connectedness so hubs shove hardest.
+ const force = (REPULSION * sizeBoost * overlapRamp * n2.repel / d2) * alpha
n1.vx += (dx / d) * force
n1.vy += (dy / d) * force
}
@@ -879,9 +899,9 @@ export function MemoryGraphCanvas({ graph, selectedId, onSelect, fitNonce = 0, r
}
// ── Nodes: solid, workspace-friendly ──
- // entity — solid disc, community colour, sized by mention count
+ // entity — solid core + thin detached ring, sized by mention count
// memory — smaller solid dot
- // file — solid disc with a thin detached ring
+ // file — short filled document glyph (FileText icon)
// No gradients: flat colour reads cleanly on dark AND light themes.
for (const n of nodes) {
if (!inView(n.x, n.y)) continue
@@ -917,9 +937,54 @@ export function MemoryGraphCanvas({ graph, selectedId, onSelect, fitNonce = 0, r
}
ctx.globalAlpha = vis
- ctx.fillStyle = n.color
if (kind === 'file') {
- // Solid core + thin detached ring marks a file.
+ // Files render as a FILLED document glyph so they read as files,
+ // not as another coloured node. Portrait page with rounded corners
+ // and a folded top-right corner; text lines carved in the
+ // background colour. Geometry uses the full radius (not the
+ // entrance-scaled r) in a 24×24 local space.
+ const sc = (n.radius * 3.0) / 24
+ const L = 6.5, R = 17.5, T = 4, B = 20 // page bounds (portrait)
+ const RAD = 1.8 // corner radius
+ const FOLD = 4.5 // dog-ear size
+ const FX = R - FOLD, FY = T + FOLD // fold start / diagonal end
+ ctx.save()
+ ctx.translate(p.x, p.y)
+ ctx.scale(sc, sc)
+ ctx.translate(-12, -12)
+ ctx.lineJoin = 'round'
+ ctx.lineCap = 'round'
+ // Page body: top edge → fold diagonal → rounded right/bottom/left.
+ ctx.fillStyle = n.color
+ ctx.beginPath()
+ ctx.moveTo(L + RAD, T)
+ ctx.lineTo(FX, T)
+ ctx.lineTo(R, FY)
+ ctx.arcTo(R, B, R - RAD, B, RAD)
+ ctx.arcTo(L, B, L, B - RAD, RAD)
+ ctx.arcTo(L, T, L + RAD, T, RAD)
+ ctx.closePath()
+ ctx.fill()
+ // Folded corner: a darker flap so it reads as turned-down paper.
+ ctx.fillStyle = 'rgba(0,0,0,0.22)'
+ ctx.beginPath()
+ ctx.moveTo(FX, T)
+ ctx.lineTo(FX, FY)
+ ctx.lineTo(R, FY)
+ ctx.closePath()
+ ctx.fill()
+ // Two text lines carved in the background colour.
+ ctx.strokeStyle = bg
+ ctx.lineWidth = 1.1 / (t.k * sc)
+ ctx.beginPath()
+ ctx.moveTo(9, 13); ctx.lineTo(15, 13)
+ ctx.moveTo(9, 16); ctx.lineTo(15, 16)
+ ctx.stroke()
+ ctx.restore()
+ ctx.lineWidth = 1 / t.k
+ } else if (kind === 'entity') {
+ // Entities: solid core + thin detached ring (the old file design).
+ ctx.fillStyle = n.color
ctx.beginPath()
ctx.arc(p.x, p.y, r * 0.62, 0, Math.PI * 2)
ctx.fill()
@@ -930,6 +995,8 @@ export function MemoryGraphCanvas({ graph, selectedId, onSelect, fitNonce = 0, r
ctx.stroke()
ctx.lineWidth = 1 / t.k
} else {
+ // Memories: plain filled dot.
+ ctx.fillStyle = n.color
ctx.beginPath()
ctx.arc(p.x, p.y, r, 0, Math.PI * 2)
ctx.fill()
From 47df7bb16f46dbe2bca19bd4dc250a437836a512 Mon Sep 17 00:00:00 2001
From: CraftBot
Date: Fri, 14 Aug 2026 00:35:34 +0900
Subject: [PATCH 15/60] entity in memory graph are stored and retrieved as
embedding
---
agent_core/core/impl/memory/manager.py | 125 ++++++++++++++++++++++++-
1 file changed, 123 insertions(+), 2 deletions(-)
diff --git a/agent_core/core/impl/memory/manager.py b/agent_core/core/impl/memory/manager.py
index dab1613b..9592655a 100644
--- a/agent_core/core/impl/memory/manager.py
+++ b/agent_core/core/impl/memory/manager.py
@@ -22,7 +22,7 @@
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
-from typing import Any, Callable, Dict, List, Optional
+from typing import Any, Callable, Dict, List, Optional, Tuple
import chromadb
@@ -69,6 +69,13 @@
# surface despite sharing no words with the query.
GRAPH_ELIGIBILITY_SCORE = 0.5
+# Minimum cosine similarity for the SEMANTIC entity match (graph channel).
+# The query is embedded and compared against each entity's name embedding;
+# below this a match is treated as noise. This is what resolves partial names
+# ("Tobias" → "Tobias Garcia") without hand-rolled token rules. The string
+# matcher still catches exact / all-token hits at full strength regardless.
+ENTITY_MATCH_MIN_SCORE = 0.6
+
# Recency bonus: newest items get up to +RECENCY_MAX_BONUS, halving every
# RECENCY_HALF_LIFE_DAYS. Small on purpose — recency is a tiebreaker, not
# a ranking signal of its own.
@@ -242,6 +249,10 @@ class MemoryManager:
# disk; if the chunking shape changes, clear it and re-index.
COLLECTION_NAME = "agent_memory"
FILE_INDEX_COLLECTION = "agent_memory_file_index"
+ # Entity-name embeddings for the graph channel's semantic entity match.
+ # A separate collection so entity vectors never mix with chunk vectors;
+ # a derived cache, reseeded from the graph on every rebuild.
+ ENTITY_COLLECTION = "agent_memory_entities"
def __init__(
self,
@@ -300,6 +311,18 @@ def __init__(
metadata={"description": "File index for incremental updates"},
)
+ # Entity-name embeddings for the graph channel's semantic entity match.
+ # Same embedding function as the chunks; cosine space for [0,1] scores.
+ self.entity_collection = self._open_collection(
+ name=self.ENTITY_COLLECTION,
+ embedding_fn=embedding_fn,
+ metadata={
+ "description": "Entity name embeddings for graph-channel matching",
+ "hnsw:space": "cosine",
+ "embedding_model": MEMORY_EMBEDDING_MODEL,
+ },
+ )
+
# In-memory cache of file indices
self._file_index_cache: Dict[str, FileIndex] = {}
self._load_file_index_cache()
@@ -493,7 +516,13 @@ def retrieve(
try:
self._ensure_graph_built()
if self._graph is not None:
- seeds = self._graph.match_entities(query)
+ # String seeds (exact / all-token) at full strength, unioned
+ # with semantic seeds (entity-name embedding ≥ threshold) for
+ # partial names. Union keeps the strongest strength per entity.
+ seeds = self._merge_entity_seeds(
+ self._graph.match_entities(query),
+ self._match_entities_semantic(query),
+ )
if seeds:
graph_hits = self._graph.bfs_item_scores(
seeds, include_superseded=include_superseded
@@ -646,6 +675,9 @@ def _ensure_graph_built(self) -> None:
self._load_full_corpus(), registry, confirmed_files
)
self._graph_dirty = False
+ # Keep the entity embedding collection in lock-step with the graph
+ # so the semantic entity match sees the current entity set.
+ self._rebuild_entity_index()
logger.debug(
f"[MEMORY] Graph rebuilt: {len(self._graph.entities)} entities, "
f"{len(self._graph.items)} items, {len(self._graph.files)} files"
@@ -669,6 +701,86 @@ def _load_full_corpus(self) -> List[Dict[str, Any]]:
for i in range(len(ids))
]
+ def _rebuild_entity_index(self) -> None:
+ """Sync the entity embedding collection with the current graph.
+
+ One record per entity (id = entity key, document = display name),
+ embedded with the same function as the chunks so the graph channel
+ can resolve entities by name similarity. Incremental: only new
+ entities are embedded and dropped ones removed. It is a derived cache
+ rebuilt from the graph, never migrated.
+ """
+ if self._graph is None:
+ return
+ try:
+ current = {
+ key: (node.name or key)
+ for key, node in self._graph.entities.items()
+ if key
+ }
+ existing = set(self.entity_collection.get().get("ids") or [])
+ current_ids = set(current.keys())
+
+ to_remove = list(existing - current_ids)
+ if to_remove:
+ self.entity_collection.delete(ids=to_remove)
+
+ to_add = [k for k in current_ids if k not in existing]
+ if to_add:
+ self.entity_collection.add(
+ ids=to_add,
+ documents=[current[k] for k in to_add],
+ metadatas=[{"name": current[k], "key": k} for k in to_add],
+ )
+ except Exception as e:
+ logger.warning(f"[MEMORY] Failed to rebuild entity index: {e}")
+
+ def _match_entities_semantic(
+ self, query: str, max_seeds: int = 5, min_score: float = ENTITY_MATCH_MIN_SCORE
+ ) -> List[Tuple[str, float]]:
+ """Resolve query → entities by NAME embedding similarity.
+
+ Returns (entity_key, similarity) pairs at or above ``min_score``.
+ This is the fuzzy/partial channel — "Tobias" resolves to the
+ "Tobias Garcia" node here where the string matcher cannot.
+ """
+ if not query or not query.strip():
+ return []
+ try:
+ count = self.entity_collection.count()
+ if count == 0:
+ return []
+ result = self.entity_collection.query(
+ query_texts=[query],
+ n_results=min(max_seeds, count),
+ include=["distances"],
+ )
+ ids = (result.get("ids") or [[]])[0]
+ distances = (result.get("distances") or [[]])[0]
+ seeds: List[Tuple[str, float]] = []
+ for i, key in enumerate(ids):
+ sim = _cosine_distance_to_similarity(
+ distances[i] if i < len(distances) else 1.0
+ )
+ if sim >= min_score:
+ seeds.append((key, sim))
+ return seeds
+ except Exception as e:
+ logger.warning(f"[MEMORY] Semantic entity match failed: {e}")
+ return []
+
+ @staticmethod
+ def _merge_entity_seeds(
+ *seed_lists: List[Tuple[str, float]], max_seeds: int = 8
+ ) -> List[Tuple[str, float]]:
+ """Union entity seeds keeping the strongest strength per entity."""
+ best: Dict[str, float] = {}
+ for seeds in seed_lists:
+ for key, strength in seeds:
+ if strength > best.get(key, 0.0):
+ best[key] = strength
+ return sorted(best.items(), key=lambda kv: (-kv[1], kv[0]))[:max_seeds]
+
def graph_snapshot(self) -> Dict[str, Any]:
"""Full graph serialisation for the Memory panel (nodes/edges/stats)."""
self._ensure_graph_built()
@@ -1474,6 +1586,15 @@ def _clear_index(self) -> None:
metadata={"description": "File index for incremental updates"},
)
+ # Empty the entity embedding collection; the next graph rebuild
+ # reseeds it from the fresh entity set (derived cache, no migration).
+ try:
+ existing = self.entity_collection.get().get("ids") or []
+ if existing:
+ self.entity_collection.delete(ids=existing)
+ except Exception:
+ pass
+
self._file_index_cache.clear()
self._bm25_dirty = True
self._graph_dirty = True
From e52578cad93ae361ba228ca6147ffa562276960e Mon Sep 17 00:00:00 2001
From: Aima
Date: Fri, 14 Aug 2026 07:08:39 +0500
Subject: [PATCH 16/60] Advertisement Widget
Advertisement widget for CraftBot dashboard
---
.../pages/Dashboard/layout/defaultLayout.ts | 1 +
.../Dashboard/layout/normalizeLayouts.ts | 17 +-
.../Dashboard/widgets/CraftBotIntroWidget.tsx | 323 +++++++++++++++++
.../src/pages/Dashboard/widgets/registry.ts | 11 +
.../Dashboard/widgets/widgets.module.css | 338 ++++++++++++++++++
5 files changed, 688 insertions(+), 2 deletions(-)
create mode 100644 app/ui_layer/browser/frontend/src/pages/Dashboard/widgets/CraftBotIntroWidget.tsx
diff --git a/app/ui_layer/browser/frontend/src/pages/Dashboard/layout/defaultLayout.ts b/app/ui_layer/browser/frontend/src/pages/Dashboard/layout/defaultLayout.ts
index 03d21061..e73aff53 100644
--- a/app/ui_layer/browser/frontend/src/pages/Dashboard/layout/defaultLayout.ts
+++ b/app/ui_layer/browser/frontend/src/pages/Dashboard/layout/defaultLayout.ts
@@ -26,6 +26,7 @@ import { WIDGET_REGISTRY } from '../widgets/registry'
type Placement = { id: string; x: number; y: number; w: number; h: number }
const ORIGINAL_ORDER = [
+ 'craftBotIntro',
'taskStats',
'tokenUsage',
'systemResources',
diff --git a/app/ui_layer/browser/frontend/src/pages/Dashboard/layout/normalizeLayouts.ts b/app/ui_layer/browser/frontend/src/pages/Dashboard/layout/normalizeLayouts.ts
index 8ed67029..429fe37a 100644
--- a/app/ui_layer/browser/frontend/src/pages/Dashboard/layout/normalizeLayouts.ts
+++ b/app/ui_layer/browser/frontend/src/pages/Dashboard/layout/normalizeLayouts.ts
@@ -105,13 +105,26 @@ function normalizeBreakpoint(items: unknown, widgetIds: string[], bp: Breakpoint
* bounds is pulled back to the nearest one rather than left as an odd shape.
*/
export function normalizeLayout(layout: NamedLayout): NamedLayout {
- const widgetIds = (Array.isArray(layout.widgetIds) ? layout.widgetIds : [])
+ const baseWidgetIds = (Array.isArray(layout.widgetIds) ? layout.widgetIds : [])
.filter(id => typeof id === 'string' && !!WIDGET_REGISTRY[id])
+ // Ensure craftBotIntro is ALWAYS included at top-left by default if missing
+ const widgetIds = baseWidgetIds.includes('craftBotIntro')
+ ? baseWidgetIds
+ : ['craftBotIntro', ...baseWidgetIds]
+
const stored = (layout.layouts ?? {}) as Partial
const layouts = BREAKPOINT_KEYS.reduce((acc, bp) => {
- acc[bp] = normalizeBreakpoint(stored[bp], widgetIds, bp)
+ const bpItems = normalizeBreakpoint(stored[bp], widgetIds, bp)
+ const introIndex = bpItems.findIndex(item => item.i === 'craftBotIntro')
+ if (introIndex !== -1) {
+ bpItems[introIndex] = { ...bpItems[introIndex], x: 0, y: 0 }
+ } else {
+ const bounds = boundsFor(bp, 'craftBotIntro')
+ bpItems.unshift({ i: 'craftBotIntro', x: 0, y: 0, w: 1, h: 1, ...bounds })
+ }
+ acc[bp] = bpItems
return acc
}, {} as BreakpointLayouts)
diff --git a/app/ui_layer/browser/frontend/src/pages/Dashboard/widgets/CraftBotIntroWidget.tsx b/app/ui_layer/browser/frontend/src/pages/Dashboard/widgets/CraftBotIntroWidget.tsx
new file mode 100644
index 00000000..5ddd7544
--- /dev/null
+++ b/app/ui_layer/browser/frontend/src/pages/Dashboard/widgets/CraftBotIntroWidget.tsx
@@ -0,0 +1,323 @@
+import { useState, useRef, useEffect } from 'react'
+import { Zap, Plug, Brain, Box, ChevronRight, ArrowLeft, ExternalLink } from 'lucide-react'
+import { CraftBotMascot, useMascotState } from '@mascot'
+import styles from './widgets.module.css'
+
+interface BannerItem {
+ id: string
+ categoryLabel: string
+ title: string
+ subtitle: string
+ simpleDesc: string
+ extendedPoints: string[]
+ fourBlockPoints: string[]
+ icon: typeof Zap
+}
+
+const BANNERS: BannerItem[] = [
+ {
+ id: 'autonomous',
+ categoryLabel: 'Autonomous',
+ title: 'Autonomous Execution',
+ subtitle: 'Self-directed AI development engine',
+ simpleDesc: 'Solves coding tasks step-by-step, edits files, and tests code automatically.',
+ extendedPoints: [
+ 'Solves multi-step coding tasks & feature workflows',
+ 'Edits, creates, and refactors project files automatically',
+ 'Runs automated tests & verifies error diagnostics'
+ ],
+ fourBlockPoints: [
+ 'Solves multi-step coding tasks & end-to-end feature workflows',
+ 'Edits, creates, and refactors workspace files automatically',
+ 'Executes unit tests & automatically verifies runtime diagnostics',
+ 'Traces stack traces to isolate and fix root causes directly',
+ 'Maintains strict control flow scoping & error resilience'
+ ],
+ icon: Zap
+ },
+ {
+ id: 'mcp',
+ categoryLabel: 'MCP Tools',
+ title: 'MCP Tools & API Integration',
+ subtitle: 'Extensible Model Context Protocol ecosystem',
+ simpleDesc: 'Connects directly to databases, external services, web APIs, and tools.',
+ extendedPoints: [
+ 'Connects to external services, databases & web APIs',
+ 'Integrates with Model Context Protocol (MCP) toolkits',
+ 'Executes terminal commands & verified workspace scripts'
+ ],
+ fourBlockPoints: [
+ 'Connects to external services, databases, and third-party APIs',
+ 'Integrates with Model Context Protocol (MCP) server toolkits',
+ 'Executes verified terminal shell commands & workspace scripts',
+ 'Fetches live web documentation & parses API payload schemas',
+ 'Handles async background tasks with status reporting'
+ ],
+ icon: Plug
+ },
+ {
+ id: 'memory',
+ categoryLabel: 'Smart Memory',
+ title: 'Smart Context & Memory',
+ subtitle: 'Long-term vector codebase intelligence',
+ simpleDesc: 'Remembers your project structure and keeps chat sessions fast and smart.',
+ extendedPoints: [
+ 'Maintains vector-indexed memory of your codebase',
+ 'Compresses chat context to keep responses lightning fast',
+ 'Optimizes token budgets for long development sessions'
+ ],
+ fourBlockPoints: [
+ 'Maintains vector-indexed embeddings of your codebase',
+ 'Compresses chat context history to keep responses lightning fast',
+ 'Optimizes token budgets for multi-hour development sessions',
+ 'Recalls past architectural decisions & file symbol dependencies',
+ 'Monitors token context windows to prevent truncation losses'
+ ],
+ icon: Brain
+ },
+ {
+ id: 'livingui',
+ categoryLabel: 'Living UI',
+ title: 'Living UI & Micro-Apps',
+ subtitle: 'On-demand interactive web generator',
+ simpleDesc: 'Generates interactive web screens, live charts, and custom dashboard cards.',
+ extendedPoints: [
+ 'Generates interactive web components on demand',
+ 'Displays real-time status dashboards & telemetry graphs',
+ 'Integrates with drag-and-drop widget layouts'
+ ],
+ fourBlockPoints: [
+ 'Generates interactive web components & micro-apps on demand',
+ 'Displays real-time status telemetry & dynamic analytics charts',
+ 'Integrates smoothly with drag-and-drop widget grid layouts',
+ 'Renders responsive glassmorphism UI widgets with live data',
+ 'Persists widget configuration state & layout grid preferences'
+ ],
+ icon: Box
+ }
+]
+
+export function CraftBotIntroWidget() {
+ const mascotState = useMascotState()
+ const containerRef = useRef(null)
+ const bannerScrollRef = useRef(null)
+
+ const [showDetails, setShowDetails] = useState(false)
+ const [currentBannerIndex, setCurrentBannerIndex] = useState(0)
+ const [isEnlarged, setIsEnlarged] = useState(false)
+ const [isFourBlocks, setIsFourBlocks] = useState(false)
+ const [isHovered, setIsHovered] = useState(false)
+
+ const [reaction, setReaction] = useState<'happy' | null>(null)
+ const [beacon, setBeacon] = useState(false)
+ const [completedCount, setCompletedCount] = useState(0)
+ const timerRef = useRef(null)
+
+ // Track widget dimensions for 1x1, 2x1/1x2, and 2x2 (4 blocks)
+ useEffect(() => {
+ const node = containerRef.current
+ if (!node) return
+
+ const observer = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ const { width, height } = entry.contentRect
+ setIsFourBlocks(width >= 400 && height >= 340)
+ setIsEnlarged(width > 320 || height > 270)
+ }
+ })
+
+ observer.observe(node)
+ return () => observer.disconnect()
+ }, [])
+
+ // Auto-advance slides every 4.5 seconds when open and not hovered
+ useEffect(() => {
+ if (!showDetails || isHovered) return
+
+ const interval = setInterval(() => {
+ setCurrentBannerIndex((prev) => {
+ const nextIdx = (prev + 1) % BANNERS.length
+ scrollToIndex(nextIdx)
+ return nextIdx
+ })
+ }, 4500)
+
+ return () => clearInterval(interval)
+ }, [showDetails, isHovered])
+
+ const handleMascotClick = () => {
+ setReaction('happy')
+ setBeacon(true)
+ setCompletedCount((prev) => prev + 1)
+
+ if (timerRef.current !== null) {
+ clearTimeout(timerRef.current)
+ }
+
+ timerRef.current = window.setTimeout(() => {
+ setReaction(null)
+ setBeacon(false)
+ }, 1800)
+ }
+
+ const handleScroll = () => {
+ if (!bannerScrollRef.current) return
+ const { scrollLeft, clientWidth } = bannerScrollRef.current
+ if (clientWidth > 0) {
+ const idx = Math.round(scrollLeft / clientWidth)
+ setCurrentBannerIndex(idx)
+ }
+ }
+
+ const scrollToIndex = (index: number) => {
+ if (!bannerScrollRef.current) return
+ const width = bannerScrollRef.current.clientWidth
+ bannerScrollRef.current.scrollTo({
+ left: index * width,
+ behavior: 'smooth'
+ })
+ setCurrentBannerIndex(index)
+ }
+
+ if (showDetails) {
+ return (
+
- Long-term memories stored in MEMORY.md. These are facts the agent has learned from interactions.
+ Once a day, CraftBot distills recent events into long-term memory.
+ Choose when it runs and how many new events must be waiting; if
+ fewer have accumulated by then, the run is skipped, so quiet days
+ do nothing. You can also process everything waiting right now.
- {isLoadingItems ? (
+ {!hasLoadedSchedule ? (
-
- Loading memory items...
-
- ) : items.length === 0 ? (
-
-
-
No memory items yet.
-
- Memory items are created when the agent processes events or when you add them manually.
-
+
+
+ unprocessed events
+
+
+
+ minimum to run
+
+
+
+
+
+ {gateReached ? (
+ <>
+
+
+ Enough events have accumulated: memory will be processed{' '}
+ {nextRunPhrase}.
+
+ >
+ ) : (
+
+ {threshold === 0
+ ? 'No events waiting. The next scheduled run will be skipped.'
+ : `${threshold - unprocessed} more ${threshold - unprocessed === 1 ? 'event' : 'events'} needed. Scheduled runs are skipped until then.`}
+
+ )}
+
+
+
+ : }
+ >
+ {isProcessing ? 'Processing...' : 'Process Memory Now'}
+
+
+ Processes all waiting events immediately, ignoring the
+ schedule and minimum.
+
+
+ >
)}
- {/* Memory Processing */}
+ {/* Rebuild Index */}
-
Memory Processing
+
Rebuild Index
- Memory processing analyzes unprocessed events and extracts important facts into long-term memory.
- This normally runs automatically at 3 AM daily.
+ Rebuilds the search index, knowledge graph, and entity embeddings
+ from your memory files. Your memories are not changed — use this if
+ recall seems stale or out of sync.
- {isFourBlocks
- ? 'Your Autonomous AI Agent Workspace & Living UI Platform'
- : 'Autonomous Agent & Living UI Platform'}
+ One agent. Every kind of work.
- }
+ iconPosition="right"
className={styles.compactLearnMoreBtn}
onClick={() => setShowDetails(true)}
>
- Learn More
-
-
+ Learn More
+
)
}
diff --git a/app/ui_layer/browser/frontend/src/pages/Dashboard/widgets/widgets.module.css b/app/ui_layer/browser/frontend/src/pages/Dashboard/widgets/widgets.module.css
index 340fe136..9a9add8d 100644
--- a/app/ui_layer/browser/frontend/src/pages/Dashboard/widgets/widgets.module.css
+++ b/app/ui_layer/browser/frontend/src/pages/Dashboard/widgets/widgets.module.css
@@ -735,9 +735,6 @@
height: 100%;
width: 100%;
padding: calc(var(--w-pad) * 0.7);
- background: var(--bg-tertiary);
- border-radius: var(--radius-sm);
- border: 1px solid var(--border-color, rgba(0, 0, 0, 0.05));
box-sizing: border-box;
}
@@ -790,30 +787,12 @@
max-width: 100%;
}
-.compactLearnMoreBtn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 5px;
- padding: 5px 16px;
- background: var(--primary-color, var(--color-primary, #3b82f6));
- color: #ffffff;
- font-family: inherit;
- font-size: calc(var(--w-text-xs) * 0.85);
- font-weight: 600;
- letter-spacing: -0.005em;
- border: none;
- border-radius: 20px;
- cursor: pointer;
- transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
- box-shadow: 0 2px 8px rgba(59, 130, 246, 0.25);
- margin: 2px auto 0 auto;
-}
-
-.compactLearnMoreBtn:hover {
- background: var(--primary-hover, var(--color-primary-hover, #2563eb));
- transform: translateY(-1px);
- box-shadow: 0 4px 12px rgba(59, 130, 246, 0.35);
+.compactSimpleContainer .compactLearnMoreBtn {
+ margin-top: 12px;
+ border-radius: 9999px;
+ /* Extra left padding offsets the chevron's optical whitespace on the right */
+ padding-left: 26px;
+ padding-right: 22px;
}
/* Compact Detailed View (Showcase Carousel) */
@@ -823,9 +802,6 @@
height: 100%;
gap: calc(var(--w-gap) * 0.4);
padding: calc(var(--w-pad) * 0.6);
- background: var(--bg-tertiary);
- border-radius: var(--radius-sm);
- border: 1px solid var(--border-color, rgba(0, 0, 0, 0.05));
overflow: hidden;
box-sizing: border-box;
}
@@ -860,21 +836,13 @@
background: var(--bg-tertiary);
}
-.introDetailsTitle {
- font-family: inherit;
- font-size: calc(var(--w-text-xs) * 0.95);
- font-weight: 700;
- letter-spacing: -0.01em;
- color: var(--text-primary);
-}
-
.heroBannerShowcase {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
border-radius: var(--radius-sm);
- background: var(--bg-secondary);
+ background: var(--bg-tertiary);
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
}
@@ -893,106 +861,191 @@
display: none;
}
+/* Carousel card. Type and spacing scale with the widget box via cqmin
+ (the WidgetChrome .chrome element is the size container), so the card
+ reads like a proper marketing slide at 2x2 without dwarfing 1x1. */
.heroBannerCard {
flex: 0 0 100%;
width: 100%;
height: 100%;
scroll-snap-align: start;
display: flex;
- align-items: stretch;
box-sizing: border-box;
- padding: calc(var(--w-pad) * 0.6);
- gap: 12px;
+ padding: clamp(18px, calc(9px + 4.5cqmin), 36px);
}
-.heroVisualSide {
+/* Content column: grouped at the vertical center with tight rhythm —
+ eyebrow / title+subtitle / items / CTA — instead of scattered. */
+.heroContentSide {
+ flex: 1;
+ min-width: 0;
display: flex;
- align-items: center;
+ flex-direction: column;
+ align-items: flex-start;
justify-content: center;
- width: 30%;
- min-width: 64px;
- background: var(--bg-tertiary);
- border-radius: var(--radius-xs, 6px);
- border: 1px solid var(--border-color, rgba(0, 0, 0, 0.06));
- flex-shrink: 0;
}
-.heroMascotBox {
- display: flex;
+.visionCard .heroContentSide {
align-items: center;
+ text-align: center;
+}
+
+.visionCard .chipRow {
justify-content: center;
- cursor: pointer;
- transition: transform 0.2s ease;
}
-.heroMascotBox:hover {
- transform: scale(1.05);
+.visionCard .heroTitle {
+ display: inline-flex;
+ align-items: center;
+ gap: clamp(6px, calc(3px + 1.5cqmin), 11px);
}
-.heroContentSide {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- justify-content: space-evenly;
+.titleFavicon {
+ width: clamp(18px, calc(9px + 3.4cqmin), 30px);
+ height: clamp(18px, calc(9px + 3.4cqmin), 30px);
+ flex-shrink: 0;
+}
+
+.cardEyebrow {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-family: inherit;
+ font-size: clamp(9px, calc(7.5px + 0.6cqmin), 11.5px);
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--color-primary, #3b82f6);
+ margin-bottom: clamp(6px, calc(2px + 1.8cqmin), 16px);
}
.heroTitle {
font-family: inherit;
- font-size: calc(var(--w-text-xs) * 1.2);
+ font-size: clamp(13px, calc(4.5px + 3.4cqmin), 25px);
font-weight: 750;
- letter-spacing: -0.015em;
+ letter-spacing: -0.02em;
color: var(--text-primary);
margin: 0;
- line-height: 1.25;
+ line-height: 1.15;
+ text-wrap: balance;
+ max-width: 26ch;
}
.heroSubtitle {
font-family: inherit;
- font-size: calc(var(--w-text-xs) * 0.85);
- font-weight: 500;
+ font-size: clamp(11px, calc(7.5px + 1.4cqmin), 15px);
+ font-weight: 480;
letter-spacing: -0.005em;
- color: var(--text-muted);
- margin: 0;
- line-height: 1.35;
+ color: var(--text-secondary);
+ margin: clamp(3px, calc(1px + 1.2cqmin), 10px) 0 0;
+ line-height: 1.45;
+ max-width: 46ch;
}
.heroSimpleDesc {
font-family: inherit;
- font-size: calc(var(--w-text-xs) * 0.88);
+ font-size: clamp(11px, calc(8px + 1.1cqmin), 13.5px);
font-weight: 450;
+ color: var(--text-secondary);
+ line-height: 1.45;
+ margin: clamp(6px, calc(2px + 1.8cqmin), 16px) 0 0;
+ max-width: 46ch;
+}
+
+/* Card variant: chip/tag showcase (marketplace apps, agent bundle roles) */
+.chipRow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: clamp(10px, calc(5px + 2cqmin), 20px);
+}
+
+.chip {
+ font-family: inherit;
+ font-size: clamp(10px, calc(8px + 0.8cqmin), 12.5px);
+ font-weight: 550;
color: var(--text-primary);
- line-height: 1.4;
- margin: 0;
+ padding: clamp(2px, calc(1px + 0.6cqmin), 4px) clamp(8px, calc(5px + 1.2cqmin), 12px);
+ border-radius: 9999px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
+ white-space: nowrap;
}
-.heroPointsList {
- list-style: none;
- margin: 0;
- padding: 0;
+/* Card variant: article rows (blog) */
+.articleList {
display: flex;
flex-direction: column;
- gap: 4px;
+ gap: 6px;
+ margin-top: clamp(10px, calc(5px + 2cqmin), 20px);
+ align-self: stretch;
+ min-width: 0;
}
-.heroPointsList li {
+.articleRow {
display: flex;
- align-items: flex-start;
- gap: 6px;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ padding: clamp(6px, calc(3px + 1.2cqmin), 10px) clamp(10px, calc(6px + 1.5cqmin), 14px);
+ border-radius: var(--radius-sm, 8px);
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
+ text-decoration: none;
+ transition: border-color 0.15s ease;
+}
+
+.articleRow:hover {
+ border-color: var(--color-primary, #3b82f6);
+}
+
+.articleTag {
font-family: inherit;
- font-size: calc(var(--w-text-xs) * 0.84);
- font-weight: 450;
+ font-size: clamp(8.5px, calc(7px + 0.6cqmin), 10.5px);
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--color-primary, #3b82f6);
+ background: color-mix(in srgb, var(--color-primary, #3b82f6) 14%, transparent);
+ padding: 2px 7px;
+ border-radius: 4px;
+ flex-shrink: 0;
+}
+
+.articleTitle {
+ font-family: inherit;
+ font-size: clamp(11px, calc(8.5px + 1cqmin), 13.5px);
+ font-weight: 500;
color: var(--text-primary);
line-height: 1.35;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.heroBullet {
- width: 4px;
- height: 4px;
- border-radius: 50%;
- background: var(--primary-color, var(--color-primary, #3b82f6));
- margin-top: 6px;
- flex-shrink: 0;
+/* Per-card call-to-action: pill button matching the compact Learn More */
+.cardCta {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ margin-top: clamp(10px, calc(4px + 2.6cqmin), 24px);
+ height: clamp(24px, calc(14px + 2.8cqmin), 34px);
+ padding: 0 clamp(11px, calc(6px + 1.8cqmin), 18px);
+ border-radius: 9999px;
+ background: var(--color-primary, #3b82f6);
+ color: var(--color-white, #ffffff);
+ font-family: inherit;
+ font-size: clamp(10.5px, calc(8.5px + 0.8cqmin), 12.5px);
+ font-weight: 600;
+ letter-spacing: -0.005em;
+ text-decoration: none;
+ transition: background 0.15s ease;
+}
+
+.cardCta:hover {
+ background: var(--color-primary-hover, #2563eb);
+ color: var(--color-white, #ffffff);
+ text-decoration: none;
}
/* Hero Footer Row */
@@ -1046,18 +1099,13 @@
gap: 4px;
font-family: inherit;
font-size: calc(var(--w-text-xs) * 0.85);
- font-weight: 600;
+ font-weight: 500;
letter-spacing: -0.005em;
- color: var(--primary-color, var(--color-primary, #3b82f6));
- text-decoration: none;
- padding: 2px 8px;
- border-radius: 4px;
- background: rgba(59, 130, 246, 0.08);
- border: 1px solid rgba(59, 130, 246, 0.2);
- transition: all 0.2s ease;
+ color: var(--color-primary, #3b82f6);
+ text-decoration: underline;
}
.redirectLink:hover {
- background: rgba(59, 130, 246, 0.18);
- transform: translateY(-1px);
+ color: var(--color-primary, #3b82f6);
+ text-decoration-color: currentColor;
}
From 6c6562ca792c0a5beef018a2f914f83e87d5c9f9 Mon Sep 17 00:00:00 2001
From: CraftBot
Date: Fri, 14 Aug 2026 16:09:58 +0900
Subject: [PATCH 19/60] minor update to the token usage widget
---
.../frontend/src/pages/Dashboard/widgets/widgets.module.css | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/ui_layer/browser/frontend/src/pages/Dashboard/widgets/widgets.module.css b/app/ui_layer/browser/frontend/src/pages/Dashboard/widgets/widgets.module.css
index 9a9add8d..845df73f 100644
--- a/app/ui_layer/browser/frontend/src/pages/Dashboard/widgets/widgets.module.css
+++ b/app/ui_layer/browser/frontend/src/pages/Dashboard/widgets/widgets.module.css
@@ -191,7 +191,7 @@
display: flex;
justify-content: center;
flex-wrap: wrap;
- gap: var(--w-gap);
+ column-gap: clamp(12px, calc(6px + 2cqmin), 20px);
row-gap: calc(var(--w-gap) * 0.5);
}
From 3b3b58d7de5701a6e86ef958f65a2dfe813217bf Mon Sep 17 00:00:00 2001
From: CraftBot
Date: Fri, 14 Aug 2026 16:56:13 +0900
Subject: [PATCH 20/60] Code clean up and refactor
---
.../core/impl/memory/entity_extractor.py | 173 ------------------
agent_core/core/impl/memory/manager.py | 25 +--
agent_file_system/ENTITIES.md | 93 ----------
mkdocs/docs/core/concepts/memory.md | 2 +-
4 files changed, 6 insertions(+), 287 deletions(-)
delete mode 100644 agent_core/core/impl/memory/entity_extractor.py
diff --git a/agent_core/core/impl/memory/entity_extractor.py b/agent_core/core/impl/memory/entity_extractor.py
deleted file mode 100644
index fcbba5ca..00000000
--- a/agent_core/core/impl/memory/entity_extractor.py
+++ /dev/null
@@ -1,173 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
-Lightweight heuristic entity extractor for memory chunks.
-
-This is intentionally simple — Phase 1 just needs to surface proper-noun-like
-tokens so they end up in chunk metadata (and in the BM25 corpus). Higher-quality
-LLM-based NER is a future phase.
-
-The extractor pulls:
-- Capitalised multi-word sequences (proper nouns)
-- Tokens that look like identifiers (CamelCase, snake_case with caps)
-- Quoted strings
-
-Stopword filtering trims common English starters that get capitalised at
-sentence boundaries.
-"""
-
-from __future__ import annotations
-
-import re
-from typing import List
-
-_STOP = {
- "the",
- "a",
- "an",
- "and",
- "or",
- "but",
- "of",
- "in",
- "on",
- "at",
- "to",
- "for",
- "with",
- "by",
- "from",
- "as",
- "is",
- "are",
- "was",
- "were",
- "be",
- "been",
- "being",
- "have",
- "has",
- "had",
- "do",
- "does",
- "did",
- "will",
- "would",
- "should",
- "could",
- "may",
- "might",
- "must",
- "can",
- "i",
- "you",
- "he",
- "she",
- "it",
- "we",
- "they",
- "this",
- "that",
- "these",
- "those",
- "do",
- "not",
- "no",
- "if",
- "when",
- "then",
- "also",
- "only",
- "never",
- "always",
- "before",
- "after",
- "use",
- "id",
- "url",
- "ok",
- "user",
- "agent",
- "task",
- "action",
- "event",
- "memory",
- "system",
- "note",
- "today",
- "yesterday",
- "tomorrow",
- "monday",
- "tuesday",
- "wednesday",
- "thursday",
- "friday",
- "saturday",
- "sunday",
- "january",
- "february",
- "march",
- "april",
- "may",
- "june",
- "july",
- "august",
- "september",
- "october",
- "november",
- "december",
-}
-
-# Capitalised words (incl. CamelCase), optionally chained: "Trading View",
-# "OpenAI", "CraftBot", "John Doe"
-_PROPER_NOUN_RE = re.compile(r"\b[A-Z][A-Za-z0-9]*(?:[ \-_][A-Z][A-Za-z0-9]*)*\b")
-
-# Quoted strings (single or double)
-_QUOTED_RE = re.compile(r"\"([^\"]{2,40})\"|'([^']{2,40})'")
-
-
-def extract_entities(text: str, max_entities: int = 12) -> List[str]:
- """Extract candidate entity strings from text.
-
- Returns a deduplicated, order-preserving list. The cap exists so chunk
- metadata stays compact (ChromaDB stores it for every chunk).
- """
- if not text:
- return []
-
- seen: set[str] = set()
- out: List[str] = []
-
- for match in _PROPER_NOUN_RE.finditer(text):
- candidate = match.group(0).strip()
- if not candidate:
- continue
- lowered = candidate.lower()
- if lowered in _STOP:
- continue
- # Reject chains made entirely of stopwords ("Do NOT", "When If"):
- # capitalised grammar words at sentence starts, not entities.
- words = re.split(r"[ \-_]+", lowered)
- if words and all(w in _STOP for w in words):
- continue
- # Drop single-letter or pure-numeric tokens
- if len(candidate) < 2:
- continue
- if candidate.isdigit():
- continue
- if lowered in seen:
- continue
- seen.add(lowered)
- out.append(candidate)
- if len(out) >= max_entities:
- return out
-
- for match in _QUOTED_RE.finditer(text):
- candidate = (match.group(1) or match.group(2) or "").strip()
- if not candidate or candidate.lower() in seen:
- continue
- seen.add(candidate.lower())
- out.append(candidate)
- if len(out) >= max_entities:
- break
-
- return out
diff --git a/agent_core/core/impl/memory/manager.py b/agent_core/core/impl/memory/manager.py
index a72bf0ed..259defa2 100644
--- a/agent_core/core/impl/memory/manager.py
+++ b/agent_core/core/impl/memory/manager.py
@@ -28,7 +28,6 @@
from agent_core.utils.logger import logger
from agent_core.core.impl.memory.bm25_index import BM25Index
-from agent_core.core.impl.memory.entity_extractor import extract_entities
from agent_core.core.impl.memory.graph import (
ENTITY_REGISTRY_FILE,
MemoryGraph,
@@ -823,9 +822,8 @@ def _ensure_bm25_built(self) -> None:
def _load_bm25_corpus(self) -> Dict[str, str]:
"""Pull every chunk's searchable text from ChromaDB.
- We concatenate the document body, summary, and extracted_entities so
- BM25 has the strongest possible keyword signal — especially proper
- nouns that vector embeddings often miss.
+ We concatenate the document body and summary so BM25 has the full
+ keyword signal of each chunk.
"""
try:
result = self.collection.get(
@@ -844,8 +842,7 @@ def _load_bm25_corpus(self) -> Dict[str, str]:
body = docs[i] if i < len(docs) else ""
meta = metas[i] if i < len(metas) else {}
summary = meta.get("summary", "")
- entities = meta.get("extracted_entities", "")
- corpus[chunk_id] = f"{body}\n{summary}\n{entities}"
+ corpus[chunk_id] = f"{body}\n{summary}"
return corpus
def _fetch_metadata(self, chunk_ids: List[str]) -> Dict[str, Dict[str, Any]]:
@@ -1102,11 +1099,6 @@ def _chunk_memory_log(self, content: str, file_path: str) -> List[MemoryChunk]:
category = category.lower()
clean_text, declared_entities, superseded = split_item_fields(item_text)
- # Graph entities come from the LLM-written {entities: ...} field
- # only; the BM25 keyword field keeps the loose extraction —
- # noisy proper nouns help keyword recall but must never become
- # graph entities.
- keyword_entities = extract_entities(clean_text)
summary = self._create_summary(clean_text)
if is_memory_file:
@@ -1134,9 +1126,8 @@ def _chunk_memory_log(self, content: str, file_path: str) -> List[MemoryChunk]:
"timestamp": timestamp_iso,
"category": category,
# ChromaDB metadata values must be primitives; serialise
- # entity lists as comma-joined strings. extracted_entities
- # feeds BM25 (loose), entities feeds the graph (curated).
- "extracted_entities": ", ".join(keyword_entities),
+ # the entity list as a comma-joined string. Entities come
+ # from the LLM-written {entities: ...} field only.
"entities": ", ".join(declared_entities or []),
# None → no {entities:} field yet → unreviewed by the
# entity-indexer → the graph gives it pending links.
@@ -1208,9 +1199,6 @@ def chunk_id_for(section_path: str, chunk_content: str) -> str:
"header_level": section["level"],
"part": i + 1,
"total_parts": len(sub_chunks),
- "extracted_entities": ", ".join(
- extract_entities(sub_content)
- ),
},
)
chunks.append(chunk)
@@ -1227,9 +1215,6 @@ def chunk_id_for(section_path: str, chunk_content: str) -> str:
indexed_at=now,
metadata={
"header_level": section["level"],
- "extracted_entities": ", ".join(
- extract_entities(section_content)
- ),
},
)
chunks.append(chunk)
diff --git a/agent_file_system/ENTITIES.md b/agent_file_system/ENTITIES.md
index edc1779e..0a8c36e0 100644
--- a/agent_file_system/ENTITIES.md
+++ b/agent_file_system/ENTITIES.md
@@ -9,96 +9,3 @@ Format: [path.md] [content-hash] marker line, plus [path.md] [content-hash] [sec
## Entities
-[AGENT.md] [45f44985676a]
-[AGENT.md] [45f44985676a] [Introduction] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Index] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime > ### Sessions] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime > ### Trigger anatomy (part 1)] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime > ### Trigger anatomy (part 2)] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime > ### Trigger aggregation] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime > ### react() order] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime > ### Workflow runs (memory / proactive) (part 1)] CraftBot, CraftOS, Memory, Proactive
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime > ### Workflow runs (memory / proactive) (part 2)] CraftBot, CraftOS, Memory, Proactive
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime > ### Waiting for the user] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime > ### Force-stop] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime > ### Components attached at construction] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime > ### State and context every turn] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Runs] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Runs > ### Quick work] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Runs > ### Substantial work] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Runs > ### The action surface] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Runs > ### `send_message.continue_work`] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Runs > ### Spinning off and deferring work: `schedule_task`] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Runs > ### Lock the deliverable spec: `set_requirement`] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Runs > ### Todo phase prefixes] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Runs > ### Output destinations] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Runs > ### Common mistakes to avoid] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Sub-Agents] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Sub-Agents > ### When to delegate] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Sub-Agents > ### How to write a good `query`] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Sub-Agents > ### Fan out for breadth] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Sub-Agents > ### Reading the result] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Sub-Agents > ### When a sub-agent misbehaves] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Communication Rules (part 1)] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Communication Rules (part 2)] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Errors] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Errors > ### Action result schema (read this first)] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Errors > ### Error event kinds in the event stream] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Errors > ### LLM error classes (from `classify_llm_error`) (part 1)] CraftBot, CraftOS, OpenAI
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Errors > ### LLM error classes (from `classify_llm_error`) (part 2)] CraftBot, CraftOS, OpenAI
-[AGENT.md] [45f44985676a] [# AGENT.md > ## File System] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## File System > ### GLOBAL_LIVING_UI.md] CraftBot, CraftOS, Living UI
-[AGENT.md] [45f44985676a] [# AGENT.md > ## File System > ### Living UI projects (workspace/living_ui/) (part 1)] CraftBot, CraftOS, Living UI
-[AGENT.md] [45f44985676a] [# AGENT.md > ## File System > ### Living UI projects (workspace/living_ui/) (part 2)] CraftBot, CraftOS, Living UI
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Living UI] CraftBot, CraftOS, Living UI
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Living UI > ### Action surface (`living_ui` set) (part 1)] CraftBot, CraftOS, Living UI
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Living UI > ### Action surface (`living_ui` set) (part 2)] CraftBot, CraftOS, Living UI
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Living UI > ### Action surface (`living_ui` set) (part 3)] CraftBot, CraftOS, Living UI
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Living UI > ### Build / delivery lifecycle] CraftBot, CraftOS, Living UI
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Living UI > ### Skills] CraftBot, CraftOS, Living UI
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Living UI > ### Design rules] CraftBot, CraftOS, Living UI
-[AGENT.md] [45f44985676a] [# AGENT.md > ## MCP] CraftBot, CraftOS, MCP
-[AGENT.md] [45f44985676a] [# AGENT.md > ## MCP > ### How MCP fits in] CraftBot, CraftOS, MCP
-[AGENT.md] [45f44985676a] [# AGENT.md > ## MCP > ### Pre-defined servers in this codebase] CraftBot, CraftOS, MCP
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Integrations > ### What's wired in (part 1)] CraftBot, CraftOS, Gmail, GitHub, Google Calendar, Notion, Slack, Discord, Telegram, WhatsApp
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Integrations > ### What's wired in (part 2)] CraftBot, CraftOS, Stripe, HubSpot, Jira, Lark, LINE, LinkedIn, Twitter, Outlook
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Integrations > ### What's wired in (part 3)] CraftBot, CraftOS, Google Drive, Google Docs, Google YouTube
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Models > ### Providers and what they support (part 1)] CraftBot, CraftOS, OpenAI, Anthropic, Google
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Models > ### Providers and what they support (part 2)] CraftBot, CraftOS, OpenAI, Anthropic, Google
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Models > ### Providers and what they support (part 3)] CraftBot, CraftOS, OpenAI, Anthropic, Google
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Models > ### Subscription sign-in (ChatGPT / Grok)] CraftBot, CraftOS, OpenAI, ChatGPT, Grok
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Memory] CraftBot, CraftOS, Memory
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Proactive] CraftBot, CraftOS, Proactive
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Self-Improvement] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Self-Edit] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Glossary (part 1)] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Glossary (part 2)] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Glossary (part 3)] CraftBot, CraftOS
-[AGENT.md] [45f44985676a] [# AGENT.md > ## Glossary (part 4)] CraftBot, CraftOS
-
-[PROACTIVE.md] [74724c00ef31]
-[PROACTIVE.md] [74724c00ef31] [Introduction] CraftBot, CraftOS, Proactive
-[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks] CraftBot, CraftOS, Proactive
-[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## How Proactive Tasks Work] CraftBot, CraftOS, Proactive
-[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## Decision Rubric] CraftBot, CraftOS, Proactive
-[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## Permission Tiers] CraftBot, CraftOS, Proactive
-[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## Task Definitions] CraftBot, CraftOS, Proactive
-[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## Task Definitions > ### [FREQUENCY] Task Name (part 1)] CraftBot, CraftOS, Proactive
-[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## Task Definitions > ### [FREQUENCY] Task Name (part 2)] CraftBot, CraftOS, Proactive
-[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## Task Definitions > ### [FREQUENCY] Task Name (part 3)] CraftBot, CraftOS, Proactive
-[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## Goals, Plan, and Status] CraftBot, CraftOS, tham yik foong, GitHub, Notion, Living UI, CraftOS Command Center, Google Sheets, Lucas Ceccon, K K Surendran, Sandra Arias, Startpass, ahmad-ajmal, CraftBot.live
-[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## Goals, Plan, and Status > ### Long-Term Goals] CraftBot, CraftOS
-[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## Goals, Plan, and Status > ### Current Focus] CraftBot, CraftOS, tham yik foong, GitHub, craftbot-live
-[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## Goals, Plan, and Status > ### Recent Accomplishments] CraftBot, CraftOS, Living UI, Lucas Ceccon, K K Surendran, Sandra Arias, Notion, Startpass, Google Sheets, CraftOS Command Center
-[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## Goals, Plan, and Status > ### Upcoming Priorities] CraftBot, CraftOS, CraftOS Command Center, Living UI, ahmad-ajmal, Notion, CraftBot.live, TSLA
-
-[USER.md] [8039d7965618]
-[USER.md] [8039d7965618] [## Identity] tham yik foong, zfoong
-[USER.md] [8039d7965618] [## Communication Preferences] tham yik foong
-[USER.md] [8039d7965618] [## Agent Interaction] tham yik foong
-[USER.md] [8039d7965618] [## Background] tham yik foong, Kuala Lumpur, Malaysia
-[USER.md] [8039d7965618] [## Life Goals] tham yik foong
-[USER.md] [8039d7965618] [## Personality] tham yik foong
-
diff --git a/mkdocs/docs/core/concepts/memory.md b/mkdocs/docs/core/concepts/memory.md
index 36214ba0..c4e09082 100644
--- a/mkdocs/docs/core/concepts/memory.md
+++ b/mkdocs/docs/core/concepts/memory.md
@@ -82,7 +82,7 @@ With `memory.enabled: false`, the agent still works. It just starts every sessio
**Privacy.** The entire pipeline is local: facts live in markdown on your disk, ChromaDB runs embedded (no server), and embeddings are computed on your machine by a local model. The only stage that leaves your machine is what always does: LLM calls to your configured provider, which includes the nightly distillation task reading your event buffer. With a local provider such as Ollama, nothing leaves at all.
!!! note "Implementation files"
- `agent_core/core/impl/memory/manager.py` holds `MemoryManager` (chunking, hybrid `retrieve()`, `create_memory_processing_task`). `injector.py` holds `inject_memory_event`, called from message arrival and task creation. `bm25_index.py` and `entity_extractor.py` implement the keyword channel, and `memory_file_watcher.py` re-indexes on file change. Item thresholds are read live from settings by `app/ui_layer/settings/memory_settings.py`. The distillation workflow is in the `memory-processor` skill.
+ `agent_core/core/impl/memory/manager.py` holds `MemoryManager` (chunking, hybrid `retrieve()`, `create_memory_processing_task`). `injector.py` holds `inject_memory_event`, called from message arrival and task creation. `bm25_index.py` implements the keyword channel, and `memory_file_watcher.py` re-indexes on file change. Item thresholds are read live from settings by `app/ui_layer/settings/memory_settings.py`. The distillation workflow is in the `memory-processor` skill.
## Next
From c4090a9182d7c991f69bf5b7d58da1e75bddaea2 Mon Sep 17 00:00:00 2001
From: CraftBot
Date: Fri, 14 Aug 2026 18:09:43 +0900
Subject: [PATCH 21/60] refactor memory system and consolidate magic numbers
into a file
---
agent_core/core/impl/event_stream/manager.py | 8 +-
agent_core/core/impl/memory/bm25_index.py | 5 +-
agent_core/core/impl/memory/graph.py | 56 +++---
agent_core/core/impl/memory/injector.py | 7 +-
agent_core/core/impl/memory/manager.py | 196 ++++++++-----------
agent_core/core/impl/memory/tuning.py | 143 ++++++++++++++
agent_file_system/AGENT.md | 2 +-
app/data/agent_file_system_template/AGENT.md | 2 +-
app/ui_layer/adapters/browser_adapter.py | 21 +-
app/ui_layer/settings/memory_settings.py | 57 +++---
mkdocs/docs/core/concepts/event-stream.md | 2 +-
11 files changed, 312 insertions(+), 187 deletions(-)
create mode 100644 agent_core/core/impl/memory/tuning.py
diff --git a/agent_core/core/impl/event_stream/manager.py b/agent_core/core/impl/event_stream/manager.py
index 79d562bb..41a2a812 100644
--- a/agent_core/core/impl/event_stream/manager.py
+++ b/agent_core/core/impl/event_stream/manager.py
@@ -234,7 +234,7 @@ def _log_to_files(self, kind: str, message: str) -> None:
Append an event to EVENT.md and optionally EVENT_UNPROCESSED.md.
This method is thread-safe and handles file I/O errors gracefully.
- Events are written in the format: [YYYY/MM/DD HH:MM:SS] [kind]: message
+ Events are written in the format: [YYYY-MM-DD HH:MM:SS] [kind]: message
Args:
kind: Event category (e.g., "action", "trigger")
@@ -243,9 +243,9 @@ def _log_to_files(self, kind: str, message: str) -> None:
if not self._agent_file_system_path:
return
- # Format: [YYYY/MM/DD HH:MM:SS] [kind]: message — LOCAL time, matching
- # the loguru log files.
- timestamp = datetime.now().astimezone().strftime("%Y/%m/%d %H:%M:%S")
+ # Format: [YYYY-MM-DD HH:MM:SS] [kind]: message — LOCAL time, in the
+ # canonical stamp format shared with MEMORY.md items.
+ timestamp = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S")
event_line = f"[{timestamp}] [{kind}]: {message}\n"
with self._file_lock:
diff --git a/agent_core/core/impl/memory/bm25_index.py b/agent_core/core/impl/memory/bm25_index.py
index 93d67a99..6e8b775d 100644
--- a/agent_core/core/impl/memory/bm25_index.py
+++ b/agent_core/core/impl/memory/bm25_index.py
@@ -22,6 +22,7 @@
BM25Okapi = None
_HAS_BM25 = False
+from agent_core.core.impl.memory.tuning import BM25_SEARCH_TOP_K
from agent_core.utils.logger import logger
@@ -76,7 +77,9 @@ def rebuild(self, chunks: Dict[str, str]) -> None:
logger.warning(f"[BM25Index] Failed to build index: {e}")
self._bm25 = None
- def search(self, query: str, top_k: int = 20) -> List[Tuple[str, float]]:
+ def search(
+ self, query: str, top_k: int = BM25_SEARCH_TOP_K
+ ) -> List[Tuple[str, float]]:
"""Return ``[(chunk_id, score)]`` sorted high-to-low. Empty when index unavailable."""
if not query or not query.strip():
return []
diff --git a/agent_core/core/impl/memory/graph.py b/agent_core/core/impl/memory/graph.py
index 6f359964..93702bc7 100644
--- a/agent_core/core/impl/memory/graph.py
+++ b/agent_core/core/impl/memory/graph.py
@@ -66,6 +66,15 @@
from datetime import datetime
from typing import Any, Dict, List, Optional, Set, Tuple
+# All numeric behavior constants live in tuning.py — the single typed home
+# of the memory system's magic numbers.
+from agent_core.core.impl.memory.tuning import (
+ ENTITY_SEED_STRENGTH,
+ LABEL_PROPAGATION_ROUNDS,
+ SECOND_HOP_DECAY,
+ STRING_SEEDS_MAX,
+)
+
# ───────────────────────────── Item grammar ─────────────────────────────
# Marks an invalidated fact. The memory-processor appends this marker
@@ -89,31 +98,22 @@
r"^\[([^\]]+)\]\s+\[([0-9a-fA-F]{6,40})\]\s+\[(.*)\]\s*(.*?)\s*$"
)
-# BFS scoring: items directly attached to a seed entity score full seed
-# strength; items reached through one intermediate entity decay by this.
-_SECOND_HOP_DECAY = 0.45
-
-# Label propagation rounds. The graph is small (hundreds of nodes); label
-# propagation converges in a handful of rounds.
-_LABEL_PROPAGATION_ROUNDS = 10
def normalize_timestamp(ts: str) -> str:
- """Canonicalise an item timestamp to 'YYYY-MM-DD HH:MM:SS'.
+ """Validate an item timestamp against the canonical 'YYYY-MM-DD HH:MM:SS'.
- Accepts '/' or '-' date separators, 'T' or space, and missing seconds
- (the memory-processor has historically written both '03:00' and
- '03:00:00'). Returns '' when unparseable. Every consumer that derives
- an item id MUST go through this so the same line always hashes to the
- same identity.
+ That is the ONLY stamp format; every writer emits it exactly. Returns
+ the stamp when valid, '' when it is not. Every consumer that derives an
+ item id MUST go through this so the same line always hashes to the same
+ identity.
"""
- cleaned = (ts or "").replace("/", "-").replace("T", " ").strip()
- for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
- try:
- return datetime.strptime(cleaned, fmt).strftime("%Y-%m-%d %H:%M:%S")
- except ValueError:
- continue
- return ""
+ cleaned = (ts or "").strip()
+ try:
+ datetime.strptime(cleaned, "%Y-%m-%d %H:%M:%S")
+ except ValueError:
+ return ""
+ return cleaned
def compute_item_id(timestamp: str, content: str) -> str:
@@ -519,7 +519,7 @@ def _compute_communities(self) -> None:
nodes = sorted(self._adjacency.keys())
labels: Dict[str, int] = {key: i for i, key in enumerate(nodes)}
- for _ in range(_LABEL_PROPAGATION_ROUNDS):
+ for _ in range(LABEL_PROPAGATION_ROUNDS):
changed = False
for key in nodes:
neighbour_labels = Counter(
@@ -557,11 +557,13 @@ def community_count(self) -> int:
# ───────────────────────────── Retrieval ─────────────────────────────
- def match_entities(self, query: str, max_seeds: int = 5) -> List[Tuple[str, float]]:
+ def match_entities(
+ self, query: str, max_seeds: int = STRING_SEEDS_MAX
+ ) -> List[Tuple[str, float]]:
"""Match query text against entity names.
- Returns (entity_key, strength) pairs. Exact phrase presence scores
- 1.0; all name tokens present somewhere in the query scores 0.8.
+ Returns (entity_key, strength) pairs. Exact phrase presence and
+ all name tokens present both score ENTITY_SEED_STRENGTH.
"""
if not query or not self.entities:
return []
@@ -575,11 +577,11 @@ def match_entities(self, query: str, max_seeds: int = 5) -> List[Tuple[str, floa
if not name_norm:
continue
if f" {name_norm} " in query_lower:
- matches.append((key, 1.0))
+ matches.append((key, ENTITY_SEED_STRENGTH))
continue
tokens = name_norm.split()
if len(tokens) > 1 and all(t in query_tokens for t in tokens):
- matches.append((key, 0.8))
+ matches.append((key, ENTITY_SEED_STRENGTH))
matches.sort(key=lambda pair: (-pair[1], pair[0]))
return matches[:max_seeds]
@@ -614,7 +616,7 @@ def bfs_item_scores(
item = self.items.get(item_id)
if item is None or (item.superseded and not include_superseded):
continue
- hop_score = strength * _SECOND_HOP_DECAY
+ hop_score = strength * SECOND_HOP_DECAY
scores[item_id] = max(scores.get(item_id, 0.0), hop_score)
return scores
diff --git a/agent_core/core/impl/memory/injector.py b/agent_core/core/impl/memory/injector.py
index 432ea197..cc6a0653 100644
--- a/agent_core/core/impl/memory/injector.py
+++ b/agent_core/core/impl/memory/injector.py
@@ -8,7 +8,7 @@
event that prompted the retrieval.
Behaviour:
-- Runs `MemoryManager.retrieve()` with min_relevance=0.5.
+- Runs `MemoryManager.retrieve()` with the tuning.INJECT_* bounds.
- If nothing passes the threshold, nothing is logged.
- Otherwise emits one event with kind="relevant_memories" into the
caller's event stream (per-task when session_id is provided, otherwise
@@ -24,12 +24,11 @@
from agent_core.core.registry.memory import get_memory_manager_or_none
from agent_core.core.registry.event_stream import get_event_stream_manager_or_none
from agent_core.core.event_stream.event import EventType
+from agent_core.core.impl.memory.tuning import INJECT_MIN_RELEVANCE, INJECT_TOP_K
from agent_core.utils.logger import logger
_MEMORY_EVENT_KIND = "relevant_memories"
-_MIN_RELEVANCE = 0.5
-_TOP_K = 5
def _is_memory_enabled() -> bool:
@@ -66,7 +65,7 @@ def inject_memory_event(query: str, session_id: Optional[str] = None) -> None:
try:
pointers = memory_manager.retrieve(
- query, top_k=_TOP_K, min_relevance=_MIN_RELEVANCE
+ query, top_k=INJECT_TOP_K, min_relevance=INJECT_MIN_RELEVANCE
)
except Exception as e:
logger.warning(f"[MEMORY] inject_memory_event retrieval failed: {e}")
diff --git a/agent_core/core/impl/memory/manager.py b/agent_core/core/impl/memory/manager.py
index 259defa2..17a49513 100644
--- a/agent_core/core/impl/memory/manager.py
+++ b/agent_core/core/impl/memory/manager.py
@@ -38,63 +38,43 @@
)
from agent_core.core.impl.memory.text_extract import extract_text, is_indexable_file
+# All numeric behavior constants live in tuning.py — the single typed home
+# of the memory system's magic numbers.
+from agent_core.core.impl.memory.tuning import (
+ CANDIDATE_POOL_FLOOR,
+ CANDIDATE_POOL_MULTIPLIER,
+ CHUNK_OVERLAP,
+ CHUNK_SIZE_LIMIT,
+ ENTITY_MATCH_MIN_SCORE,
+ GRAPH_ELIGIBILITY_SCORE,
+ HYBRID_WEIGHTS,
+ LOG_QUERY_MAX_CHARS,
+ LOG_SUMMARY_MAX_CHARS,
+ MERGED_SEEDS_MAX,
+ PREVIEW_LEAD,
+ PREVIEW_MAX_CHARS,
+ RECENCY_HALF_LIFE_DAYS,
+ RECENCY_MAX_BONUS,
+ RETRIEVE_MIN_RELEVANCE,
+ RETRIEVE_TOP_K,
+ SEMANTIC_SEEDS_MAX,
+)
+
# Files that are flat lists of "[timestamp] [category] content" items.
# These get per-item chunking so each fact has its own embedding, instead of
# the whole list collapsing into a single section chunk under "## Memory".
PER_ITEM_FILES = frozenset({"MEMORY.md", "EVENT_UNPROCESSED.md"})
-# Matches a memory item line. Tolerates both "/" and "-" date separators,
-# either "[YYYY-MM-DD HH:MM:SS]" (MEMORY.md) or "[YYYY/MM/DD HH:MM:SS]"
-# (EVENT_UNPROCESSED.md), and missing seconds — the memory-processor has
-# written "[YYYY-MM-DD HH:MM]" stamps too. Captures: timestamp, category,
-# content.
+# Matches a memory item line. The stamp is the canonical
+# "[YYYY-MM-DD HH:MM:SS]" — every writer emits exactly this form; lines with
+# any other stamp are invalid. The optional colon after the category bracket
+# is the EVENT_UNPROCESSED.md event-line separator ("[kind]: message").
+# Captures: timestamp, category, content.
MEMORY_ITEM_LINE_RE = re.compile(
- r"^\s*\[(\d{4}[-/]\d{2}[-/]\d{2}[ T]\d{2}:\d{2}(?::\d{2})?)\]\s+\[([\w\-]+)\]\s*:?\s*(.+?)\s*$"
+ r"^\s*\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]\s+\[([\w\-]+)\]\s*:?\s*(.+?)\s*$"
)
-# Hybrid-retrieval weights. Vector is the primary signal, BM25 backstops
-# proper nouns and dates, the graph channel boosts items connected to
-# entities mentioned in the query (including 2-hop neighbours the other
-# channels can miss entirely).
-HYBRID_WEIGHTS = {
- "vector": 0.55,
- "bm25": 0.30,
- "graph": 0.15,
-}
-
-# A strongly graph-connected item is eligible even when its combined score
-# sits below min_relevance — this is what lets 2-hop related memories
-# surface despite sharing no words with the query.
-GRAPH_ELIGIBILITY_SCORE = 0.5
-
-# Minimum cosine similarity for the SEMANTIC entity match (graph channel).
-# The query is embedded and compared against each entity's name embedding;
-# below this a match is treated as noise. This is what resolves partial names
-# ("Tobias" → "Tobias Garcia") without hand-rolled token rules. The string
-# matcher still catches exact / all-token hits at full strength regardless.
-ENTITY_MATCH_MIN_SCORE = 0.6
-
-# Recency bonus: newest items get up to +RECENCY_MAX_BONUS, halving every
-# RECENCY_HALF_LIFE_DAYS. Small on purpose — recency is a tiebreaker, not
-# a ranking signal of its own.
-RECENCY_MAX_BONUS = 0.05
-RECENCY_HALF_LIFE_DAYS = 30.0
-
-# Query-aware preview window. The injected memory preview is centred on the
-# query match instead of the chunk's head, so the fact that made the chunk
-# relevant is not truncated away (a from-the-start summary once cut off
-# "Tobias Garcia" and the agent had to grep for it). PREVIEW_MAX_CHARS bounds
-# the snippet; PREVIEW_LEAD keeps a little context before the match.
-PREVIEW_MAX_CHARS = 180
-PREVIEW_LEAD = 40
-
-# Log-line preview limits. Keep multi-line queries and long summaries from
-# bleeding across log entries.
-_LOG_QUERY_MAX_CHARS = 300
-_LOG_SUMMARY_MAX_CHARS = 120
-
-
def _log_preview(text: str, max_chars: int) -> str:
"""Collapse whitespace and truncate text for safe logging."""
flat = " ".join((text or "").split())
@@ -257,8 +237,8 @@ def __init__(
self,
agent_file_system_path: str = "./agent_file_system",
chroma_path: str = "./chroma_db_memory",
- chunk_size_limit: int = 1500, # Max chars per chunk
- chunk_overlap: int = 100, # Overlap between chunks when splitting large sections
+ chunk_size_limit: int = CHUNK_SIZE_LIMIT,
+ chunk_overlap: int = CHUNK_OVERLAP,
extra_files_provider: Optional[Callable[[], List[str]]] = None,
):
"""
@@ -288,9 +268,10 @@ def __init__(
# Build the embedding function. Default ChromaDB uses MiniLM-L6-v2
# (weak — ~0.65 verbatim self-similarity). MEMORY_EMBEDDING_MODEL
- # points to a stronger sentence-transformers model by default.
- # Silent fallback to ChromaDB's bundled MiniLM if sentence-transformers
- # isn't installed, so the system keeps working on minimal installs.
+ # points to a stronger sentence-transformers model by default; if it
+ # can't load, construction fails — retrieval thresholds are calibrated
+ # for the configured model, so running with a substitute is worse than
+ # not starting.
# Stored so _clear_index can rebuild every collection with the SAME
# embedding function — a force rebuild must not silently downgrade the
# model (e.g. bge-small back to ChromaDB's default MiniLM).
@@ -381,11 +362,9 @@ def _open_collection(self, name: str, embedding_fn, metadata: Dict[str, Any]):
def _build_embedding_function():
"""Construct ChromaDB's embedding function.
- Honours the MEMORY_EMBEDDING_MODEL constant. Falls back to
- ChromaDB's bundled default (ONNX all-MiniLM-L6-v2) silently when
- sentence-transformers is missing or the model can't load — so
- the agent never fails to start because of an embedding-model
- installation issue.
+ Honours the MEMORY_EMBEDDING_MODEL constant. Every retrieval
+ threshold is calibrated for the configured model, so a load
+ failure raises instead of degrading to a different model.
"""
if MEMORY_EMBEDDING_MODEL == "default":
return None # ChromaDB applies its bundled default
@@ -393,33 +372,22 @@ def _build_embedding_function():
from chromadb.utils.embedding_functions import (
SentenceTransformerEmbeddingFunction,
)
+ except ImportError as e:
+ raise RuntimeError(
+ "[MEMORY] sentence-transformers is required for the configured "
+ f"embedding model '{MEMORY_EMBEDDING_MODEL}'. Install with: "
+ "conda install -c conda-forge sentence-transformers"
+ ) from e
- return SentenceTransformerEmbeddingFunction(
- model_name=MEMORY_EMBEDDING_MODEL
- )
- except ImportError:
- logger.warning(
- "[MEMORY] sentence-transformers not installed — falling back "
- "to ChromaDB's default MiniLM embeddings. Retrieval quality "
- "will be poor. Install with: conda install -c conda-forge "
- "sentence-transformers"
- )
- return None
- except Exception as e:
- logger.warning(
- f"[MEMORY] Failed to load embedding model "
- f"'{MEMORY_EMBEDDING_MODEL}' ({e}); falling back to ChromaDB "
- f"default."
- )
- return None
+ return SentenceTransformerEmbeddingFunction(model_name=MEMORY_EMBEDDING_MODEL)
# ───────────────────────────── Public API ─────────────────────────────
def retrieve(
self,
query: str,
- top_k: int = 5,
- min_relevance: float = 0.55,
+ top_k: int = RETRIEVE_TOP_K,
+ min_relevance: float = RETRIEVE_MIN_RELEVANCE,
file_filter: Optional[List[str]] = None,
include_superseded: bool = False,
) -> List[MemoryPointer]:
@@ -462,7 +430,7 @@ def retrieve(
# Cast a wider net than top_k so the hybrid re-rank has signal to work
# with. ChromaDB and BM25 each return up to candidate_pool items.
- candidate_pool = max(top_k * 4, 20)
+ candidate_pool = max(top_k * CANDIDATE_POOL_MULTIPLIER, CANDIDATE_POOL_FLOOR)
where_filter = None
if file_filter:
@@ -470,34 +438,30 @@ def retrieve(
# Render single-line so multi-line queries don't bleed into the next
# log entry. Full query is still passed to the retriever.
- logger.info(f"[MEMORY QUERY] {_log_preview(query, _LOG_QUERY_MAX_CHARS)}")
+ logger.info(f"[MEMORY QUERY] {_log_preview(query, LOG_QUERY_MAX_CHARS)}")
# ── Channel 1: vector similarity ──
vector_hits: Dict[str, Dict[str, Any]] = {}
- try:
- results = self.collection.query(
- query_texts=[query],
- n_results=min(candidate_pool, collection_count),
- where=where_filter,
- include=["metadatas", "distances", "documents"],
- )
- ids = (results.get("ids") or [[]])[0]
- metadatas = (results.get("metadatas") or [[]])[0]
- distances = (results.get("distances") or [[]])[0]
- documents = (results.get("documents") or [[]])[0]
- for i, chunk_id in enumerate(ids):
- meta = metadatas[i] if i < len(metadatas) else {}
- distance = distances[i] if i < len(distances) else 1.0
- vector_hits[chunk_id] = {
- "score": _cosine_distance_to_similarity(distance),
- "metadata": meta,
- # Kept for the query-aware preview snippet (built below).
- "document": documents[i] if i < len(documents) else "",
- "rank": i,
- }
- except Exception as e:
- logger.error(f"Error querying ChromaDB: {e}")
- # Continue — BM25 alone may still return useful results.
+ results = self.collection.query(
+ query_texts=[query],
+ n_results=min(candidate_pool, collection_count),
+ where=where_filter,
+ include=["metadatas", "distances", "documents"],
+ )
+ ids = (results.get("ids") or [[]])[0]
+ metadatas = (results.get("metadatas") or [[]])[0]
+ distances = (results.get("distances") or [[]])[0]
+ documents = (results.get("documents") or [[]])[0]
+ for i, chunk_id in enumerate(ids):
+ meta = metadatas[i] if i < len(metadatas) else {}
+ distance = distances[i] if i < len(distances) else 1.0
+ vector_hits[chunk_id] = {
+ "score": _cosine_distance_to_similarity(distance),
+ "metadata": meta,
+ # Kept for the query-aware preview snippet (built below).
+ "document": documents[i] if i < len(documents) else "",
+ "rank": i,
+ }
# ── Channel 2: BM25 keyword search ──
self._ensure_bm25_built()
@@ -562,7 +526,6 @@ def retrieve(
pointers: List[MemoryPointer] = []
- w = HYBRID_WEIGHTS
for chunk_id in candidate_ids:
meta = (
vector_hits[chunk_id]["metadata"]
@@ -582,9 +545,9 @@ def retrieve(
graph_score = graph_hits.get(chunk_id, 0.0)
final = (
- w["vector"] * vector_score
- + w["bm25"] * bm25_score
- + w["graph"] * graph_score
+ HYBRID_WEIGHTS.vector * vector_score
+ + HYBRID_WEIGHTS.bm25 * bm25_score
+ + HYBRID_WEIGHTS.graph * graph_score
+ _recency_bonus(meta.get("timestamp", ""))
)
@@ -636,7 +599,7 @@ def retrieve(
logger.info(
f"[MEMORY RESULT] #{i} score={p.relevance_score:.3f} "
f"file={p.file_path} section={p.section_path} "
- f":: {_log_preview(p.summary, _LOG_SUMMARY_MAX_CHARS)}"
+ f":: {_log_preview(p.summary, LOG_SUMMARY_MAX_CHARS)}"
)
return pointers
@@ -738,7 +701,10 @@ def _rebuild_entity_index(self) -> None:
logger.warning(f"[MEMORY] Failed to rebuild entity index: {e}")
def _match_entities_semantic(
- self, query: str, max_seeds: int = 5, min_score: float = ENTITY_MATCH_MIN_SCORE
+ self,
+ query: str,
+ max_seeds: int = SEMANTIC_SEEDS_MAX,
+ min_score: float = ENTITY_MATCH_MIN_SCORE,
) -> List[Tuple[str, float]]:
"""Resolve query → entities by NAME embedding similarity.
@@ -773,7 +739,7 @@ def _match_entities_semantic(
@staticmethod
def _merge_entity_seeds(
- *seed_lists: List[Tuple[str, float]], max_seeds: int = 8
+ *seed_lists: List[Tuple[str, float]], max_seeds: int = MERGED_SEEDS_MAX
) -> List[Tuple[str, float]]:
"""Union entity seeds keeping the strongest strength per entity."""
best: Dict[str, float] = {}
@@ -1815,10 +1781,10 @@ def _recency_bonus(timestamp: str) -> float:
def _normalize_timestamp(ts: str) -> str:
- """Canonical 'YYYY-MM-DD HH:MM:SS', tolerant of '/'-dates, 'T', and
- missing seconds. Delegates to the shared graph helper so item ids are
- derived from the identical canonical form everywhere. Returns '' when
- parsing fails; the timestamp feeds the recency bonus in retrieval.
+ """Validate against the canonical 'YYYY-MM-DD HH:MM:SS' stamp format.
+ Delegates to the shared graph helper so item ids are derived from the
+ identical canonical form everywhere. Returns '' when the stamp is
+ invalid; the timestamp feeds the recency bonus in retrieval.
"""
from agent_core.core.impl.memory.graph import normalize_timestamp
diff --git a/agent_core/core/impl/memory/tuning.py b/agent_core/core/impl/memory/tuning.py
new file mode 100644
index 00000000..682f8761
--- /dev/null
+++ b/agent_core/core/impl/memory/tuning.py
@@ -0,0 +1,143 @@
+# -*- coding: utf-8 -*-
+"""Every tuning number of the memory system, in one typed place.
+
+Retrieval weights, thresholds, seed caps, chunking sizes, processing
+defaults, and scan bounds all live here — no other memory-system module
+defines a numeric behavior constant. Change a value here and every
+consumer (manager, graph, BM25, injector, settings, adapter) follows.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Final
+
+
+# ───────────────────────── Hybrid retrieval ─────────────────────────
+
+@dataclass(frozen=True)
+class HybridWeights:
+ """Channel weights of the hybrid score. Vector is the primary signal,
+ BM25 backstops proper nouns and dates, the graph channel boosts items
+ connected to entities mentioned in the query (including 2-hop
+ neighbours the other channels can miss entirely)."""
+
+ vector: float
+ bm25: float
+ graph: float
+
+
+HYBRID_WEIGHTS: Final[HybridWeights] = HybridWeights(
+ vector=0.55,
+ bm25=0.30,
+ graph=0.15,
+)
+
+# Default result count and relevance floor of MemoryManager.retrieve().
+RETRIEVE_TOP_K: Final[int] = 5
+RETRIEVE_MIN_RELEVANCE: Final[float] = 0.55
+
+# Per-channel candidate net cast before the hybrid re-rank:
+# max(top_k * multiplier, floor).
+CANDIDATE_POOL_MULTIPLIER: Final[int] = 4
+CANDIDATE_POOL_FLOOR: Final[int] = 20
+
+# A strongly graph-connected item is eligible even when its combined score
+# sits below min_relevance — this is what lets 2-hop related memories
+# surface despite sharing no words with the query.
+GRAPH_ELIGIBILITY_SCORE: Final[float] = 0.5
+
+# Recency bonus: newest items get up to +RECENCY_MAX_BONUS, halving every
+# RECENCY_HALF_LIFE_DAYS. Small on purpose — recency is a tiebreaker, not
+# a ranking signal of its own.
+RECENCY_MAX_BONUS: Final[float] = 0.05
+RECENCY_HALF_LIFE_DAYS: Final[float] = 30.0
+
+# Default result count of BM25Index.search().
+BM25_SEARCH_TOP_K: Final[int] = 20
+
+
+# ───────────────────────── Graph channel seeds ─────────────────────────
+
+# Strength assigned to every string-matched entity seed (exact phrase and
+# all-tokens-present alike).
+ENTITY_SEED_STRENGTH: Final[float] = 1.0
+
+# Minimum cosine similarity for the SEMANTIC entity match (graph channel).
+# The query is embedded and compared against each entity's name embedding;
+# below this a match is treated as noise. This is what resolves partial names
+# ("Tobias" → "Tobias Garcia") without hand-rolled token rules. The string
+# matcher still catches exact / all-token hits at full strength regardless.
+ENTITY_MATCH_MIN_SCORE: Final[float] = 0.6
+
+# Seed caps: string-matched seeds, semantic seeds, and the union of both.
+STRING_SEEDS_MAX: Final[int] = 5
+SEMANTIC_SEEDS_MAX: Final[int] = 5
+MERGED_SEEDS_MAX: Final[int] = 8
+
+# BFS scoring: items directly attached to a seed entity score full seed
+# strength; items reached through one intermediate entity decay by this.
+SECOND_HOP_DECAY: Final[float] = 0.45
+
+# Community detection rounds. The graph is small (hundreds of nodes); label
+# propagation converges in a handful of rounds.
+LABEL_PROPAGATION_ROUNDS: Final[int] = 10
+
+
+# ───────────────────────────── Chunking ─────────────────────────────
+
+# Max characters per chunk before splitting, and the character overlap
+# carried between chunks when a large section is split.
+CHUNK_SIZE_LIMIT: Final[int] = 1500
+CHUNK_OVERLAP: Final[int] = 100
+
+
+# ──────────────────────── Previews and logging ────────────────────────
+
+# Query-aware preview window. The injected memory preview is centred on the
+# query match instead of the chunk's head, so the fact that made the chunk
+# relevant is not truncated away (a from-the-start summary once cut off
+# "Tobias Garcia" and the agent had to grep for it). PREVIEW_MAX_CHARS bounds
+# the snippet; PREVIEW_LEAD keeps a little context before the match.
+PREVIEW_MAX_CHARS: Final[int] = 180
+PREVIEW_LEAD: Final[int] = 40
+
+# Log-line preview limits. Keep multi-line queries and long summaries from
+# bleeding across log entries.
+LOG_QUERY_MAX_CHARS: Final[int] = 300
+LOG_SUMMARY_MAX_CHARS: Final[int] = 120
+
+
+# ──────────────────────── Trigger-driven injection ────────────────────────
+
+# Relevance floor and max preview count for memories auto-injected into the
+# event stream on message arrival / task creation.
+INJECT_MIN_RELEVANCE: Final[float] = 0.5
+INJECT_TOP_K: Final[int] = 5
+
+
+# ──────────────────────── Processing and pruning ────────────────────────
+
+# Unprocessed-event count that fires processing immediately (threshold-
+# driven) and gates the daily scheduled run; 0 disables the gate. MAX is
+# the upper bound the settings slider allows.
+PROCESSING_THRESHOLD_DEFAULT: Final[int] = 25
+PROCESSING_THRESHOLD_MAX: Final[int] = 100
+
+# MEMORY.md size management: item cap that triggers pruning, the count
+# pruning shrinks down to, and the per-item word limit.
+MEMORY_MAX_ITEMS_DEFAULT: Final[int] = 200
+MEMORY_PRUNE_TARGET_DEFAULT: Final[int] = 135
+MEMORY_ITEM_WORD_LIMIT_DEFAULT: Final[int] = 150
+
+# Default daily auto-processing time (24h clock).
+SCHEDULE_HOUR_DEFAULT: Final[int] = 3
+SCHEDULE_MINUTE_DEFAULT: Final[int] = 0
+
+
+# ──────────────────────── Indexed-file candidate scan ────────────────────────
+
+# Bounds of the workspace scan that offers files in the index picker —
+# keeps the picker responsive on large workspaces.
+CANDIDATE_MAX_DEPTH: Final[int] = 10
+CANDIDATE_MAX_RESULTS: Final[int] = 500
diff --git a/agent_file_system/AGENT.md b/agent_file_system/AGENT.md
index c0dd9c43..12e24619 100644
--- a/agent_file_system/AGENT.md
+++ b/agent_file_system/AGENT.md
@@ -866,7 +866,7 @@ Editing any of these triggers re-indexing via [agent_core/core/impl/memory/memor
- Purpose: complete chronological event log. Append-only.
- Write access: EventStreamManager. Hard rule: DO NOT edit.
- Read pattern: `read_file` / `grep_files` for self-troubleshooting. See `## Errors` for log workflow.
-- Format: `[YYYY/MM/DD HH:MM:SS] [event_type]: payload`. Multi-line payloads continue on subsequent lines.
+- Format: `[YYYY-MM-DD HH:MM:SS] [event_type]: payload`. Multi-line payloads continue on subsequent lines.
- Auto-rotated when size threshold is exceeded.
### EVENT_UNPROCESSED.md
diff --git a/app/data/agent_file_system_template/AGENT.md b/app/data/agent_file_system_template/AGENT.md
index 941f78c5..2ce18233 100644
--- a/app/data/agent_file_system_template/AGENT.md
+++ b/app/data/agent_file_system_template/AGENT.md
@@ -866,7 +866,7 @@ Editing any of these triggers re-indexing via [agent_core/core/impl/memory/memor
- Purpose: complete chronological event log. Append-only.
- Write access: EventStreamManager. Hard rule: DO NOT edit.
- Read pattern: `read_file` / `grep_files` for self-troubleshooting. See `## Errors` for log workflow.
-- Format: `[YYYY/MM/DD HH:MM:SS] [event_type]: payload`. Multi-line payloads continue on subsequent lines.
+- Format: `[YYYY-MM-DD HH:MM:SS] [event_type]: payload`. Multi-line payloads continue on subsequent lines.
- Auto-rotated when size threshold is exceeded.
### EVENT_UNPROCESSED.md
diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py
index 3ea9ed7b..1e2e6c6e 100644
--- a/app/ui_layer/adapters/browser_adapter.py
+++ b/app/ui_layer/adapters/browser_adapter.py
@@ -17,6 +17,11 @@
from aiohttp.client_exceptions import ClientConnectionResetError
+from agent_core.core.impl.memory.tuning import (
+ PROCESSING_THRESHOLD_DEFAULT,
+ SCHEDULE_HOUR_DEFAULT,
+ SCHEDULE_MINUTE_DEFAULT,
+)
from agent_core.utils.logger import logger
from app.config import AGENT_WORKSPACE_ROOT, APP_DATA_PATH
from app.ui_layer.adapters.base import InterfaceAdapter
@@ -5191,8 +5196,12 @@ async def _handle_memory_schedule_get(self) -> None:
"data": {
"success": True,
"schedule": {
- "hour": sched.hour if sched.hour is not None else 3,
- "minute": sched.minute or 0,
+ "hour": (
+ sched.hour
+ if sched.hour is not None
+ else SCHEDULE_HOUR_DEFAULT
+ ),
+ "minute": sched.minute or SCHEDULE_MINUTE_DEFAULT,
},
"threshold": get_memory_processing_threshold(),
"threshold_max": get_memory_processing_threshold_max(),
@@ -5217,10 +5226,12 @@ async def _handle_memory_schedule_set(self, data: dict) -> None:
"""
try:
agent = self._controller.agent
- set_memory_processing_threshold(int(data.get("threshold", 25)))
+ set_memory_processing_threshold(
+ int(data.get("threshold", PROCESSING_THRESHOLD_DEFAULT))
+ )
expr = memory_schedule_expression(
- hour=int(data.get("hour", 3)),
- minute=int(data.get("minute", 0)),
+ hour=int(data.get("hour", SCHEDULE_HOUR_DEFAULT)),
+ minute=int(data.get("minute", SCHEDULE_MINUTE_DEFAULT)),
)
agent.scheduler.update_schedule(
"memory-processing", schedule=expr, enabled=True
diff --git a/app/ui_layer/settings/memory_settings.py b/app/ui_layer/settings/memory_settings.py
index 94fd8a6b..b68177cd 100644
--- a/app/ui_layer/settings/memory_settings.py
+++ b/app/ui_layer/settings/memory_settings.py
@@ -22,14 +22,25 @@
split_item_fields,
)
from agent_core.core.impl.memory.text_extract import is_indexable_file
+from agent_core.core.impl.memory.tuning import (
+ CANDIDATE_MAX_DEPTH,
+ CANDIDATE_MAX_RESULTS,
+ MEMORY_ITEM_WORD_LIMIT_DEFAULT,
+ MEMORY_MAX_ITEMS_DEFAULT,
+ MEMORY_PRUNE_TARGET_DEFAULT,
+ PROCESSING_THRESHOLD_DEFAULT,
+ PROCESSING_THRESHOLD_MAX,
+ SCHEDULE_HOUR_DEFAULT,
+ SCHEDULE_MINUTE_DEFAULT,
+)
-# Memory item regex pattern: [YYYY-MM-DD HH:MM(:SS)] [category] content
-# (seconds optional — historic items were stamped without them).
+# Memory item regex pattern: [YYYY-MM-DD HH:MM:SS] [category] content — the
+# canonical stamp format, the only one writers emit.
# Content may carry structured tail fields ({entities: ...}, {superseded});
# those are parsed out by _parse_memory_items via the shared graph helpers.
MEMORY_ITEM_PATTERN = re.compile(
- r"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})?)\]\s+\[([\w\-]+)\]\s+(.+)$"
+ r"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]\s+\[([\w\-]+)\]\s+(.+)$"
)
# Files that are always indexed (mirrors MemoryManager.INDEX_TARGET_FILES).
@@ -50,10 +61,6 @@
"TASK_HISTORY.md",
}
-# Candidate scan bounds — keeps the picker responsive on large workspaces.
-_CANDIDATE_MAX_DEPTH = 3
-_CANDIDATE_MAX_RESULTS = 200
-
# Directories never descended into during the candidate scan: dependency
# trees and build output in the workspace can be enormous (and on Windows
# can exceed MAX_PATH, which makes blind recursion raise).
@@ -68,17 +75,9 @@
"venv",
}
-# Memory size and length thresholds — live-read from settings.json via the
+# Memory size and length thresholds are live-read from settings.json via the
# getter functions below, so values can be tuned without a code change.
-# Defaults kick in only when a key is missing from settings.json.
-_MEMORY_MAX_ITEMS_DEFAULT = 200
-_MEMORY_PRUNE_TARGET_DEFAULT = 135
-_MEMORY_ITEM_WORD_LIMIT_DEFAULT = 150
-# Unprocessed-event count that fires processing immediately (threshold-driven),
-# instead of waiting for the scheduled sweep. 0 disables it (schedule only).
-_MEMORY_PROCESSING_THRESHOLD_DEFAULT = 25
-# Upper bound the threshold slider allows the user to set.
-_MEMORY_PROCESSING_THRESHOLD_MAX = 100
+# Defaults (tuning.py) kick in only when a key is missing from settings.json.
# ─────────────────────────────────────────────────────────────────────
# Memory Mode Control
@@ -129,13 +128,13 @@ def is_memory_enabled() -> bool:
def get_memory_max_items() -> int:
"""Upper bound on MEMORY.md item count before pruning kicks in."""
return int(
- _load_settings().get("memory", {}).get("max_items", _MEMORY_MAX_ITEMS_DEFAULT)
+ _load_settings().get("memory", {}).get("max_items", MEMORY_MAX_ITEMS_DEFAULT)
)
def get_memory_processing_threshold_max() -> int:
"""Upper bound the threshold slider allows."""
- return _MEMORY_PROCESSING_THRESHOLD_MAX
+ return PROCESSING_THRESHOLD_MAX
def get_memory_processing_threshold() -> int:
@@ -143,15 +142,15 @@ def get_memory_processing_threshold() -> int:
raw = int(
_load_settings()
.get("memory", {})
- .get("processing_threshold", _MEMORY_PROCESSING_THRESHOLD_DEFAULT)
+ .get("processing_threshold", PROCESSING_THRESHOLD_DEFAULT)
)
- return max(0, min(_MEMORY_PROCESSING_THRESHOLD_MAX, raw))
+ return max(0, min(PROCESSING_THRESHOLD_MAX, raw))
def set_memory_processing_threshold(value: int) -> bool:
"""Persist the threshold-driven processing count (clamped to [0, max])."""
settings = _load_settings()
- clamped = max(0, min(_MEMORY_PROCESSING_THRESHOLD_MAX, int(value)))
+ clamped = max(0, min(PROCESSING_THRESHOLD_MAX, int(value)))
settings.setdefault("memory", {})["processing_threshold"] = clamped
return _save_settings(settings)
@@ -207,7 +206,9 @@ def _time_phrase(hour: int, minute: int) -> str:
return f"{hour12}{suffix}" if not minute else f"{hour12}:{minute:02d}{suffix}"
-def memory_schedule_expression(hour: int = 3, minute: int = 0) -> str:
+def memory_schedule_expression(
+ hour: int = SCHEDULE_HOUR_DEFAULT, minute: int = SCHEDULE_MINUTE_DEFAULT
+) -> str:
"""Build the daily memory-processing schedule expression.
Auto-processing is daily by design; only the time of day is configurable.
@@ -220,7 +221,7 @@ def get_memory_prune_target() -> int:
return int(
_load_settings()
.get("memory", {})
- .get("prune_target", _MEMORY_PRUNE_TARGET_DEFAULT)
+ .get("prune_target", MEMORY_PRUNE_TARGET_DEFAULT)
)
@@ -229,7 +230,7 @@ def get_memory_item_word_limit() -> int:
return int(
_load_settings()
.get("memory", {})
- .get("item_word_limit", _MEMORY_ITEM_WORD_LIMIT_DEFAULT)
+ .get("item_word_limit", MEMORY_ITEM_WORD_LIMIT_DEFAULT)
)
@@ -732,7 +733,7 @@ def list_indexable_candidates() -> Dict[str, Any]:
# deep paths can exceed MAX_PATH and raise mid-iteration).
candidates: List[Dict[str, Any]] = []
frontier: List[tuple] = [(root, 0)]
- while frontier and len(candidates) < _CANDIDATE_MAX_RESULTS:
+ while frontier and len(candidates) < CANDIDATE_MAX_RESULTS:
directory, depth = frontier.pop(0)
try:
entries = sorted(directory.iterdir(), key=lambda p: p.name.lower())
@@ -742,7 +743,7 @@ def list_indexable_candidates() -> Dict[str, Any]:
try:
if entry.is_dir():
if (
- depth + 1 < _CANDIDATE_MAX_DEPTH
+ depth + 1 < CANDIDATE_MAX_DEPTH
and entry.name not in _CANDIDATE_SKIP_DIRS
and not entry.name.startswith(".")
):
@@ -754,7 +755,7 @@ def list_indexable_candidates() -> Dict[str, Any]:
if rel in core or rel in selected or entry.name in _CANDIDATE_EXCLUDE:
continue
candidates.append({"path": rel, "size": entry.stat().st_size})
- if len(candidates) >= _CANDIDATE_MAX_RESULTS:
+ if len(candidates) >= CANDIDATE_MAX_RESULTS:
break
except OSError:
continue
diff --git a/mkdocs/docs/core/concepts/event-stream.md b/mkdocs/docs/core/concepts/event-stream.md
index ffcea6c7..de7d782e 100644
--- a/mkdocs/docs/core/concepts/event-stream.md
+++ b/mkdocs/docs/core/concepts/event-stream.md
@@ -45,7 +45,7 @@ Every event is also appended to markdown files in `agent_file_system/` (see [Age
| File | Contents |
|---|---|
-| `EVENT.md` | The complete history: every event from every stream, in `[YYYY/MM/DD HH:MM:SS] [kind]: message` format. Auto-rotated when it grows too large. |
+| `EVENT.md` | The complete history: every event from every stream, in `[YYYY-MM-DD HH:MM:SS] [kind]: message` format. Auto-rotated when it grows too large. |
| `EVENT_UNPROCESSED.md` | The staging buffer for the [memory pipeline](memory.md): the subset of events awaiting distillation into `MEMORY.md`, cleared after each processing run. |
Routine event kinds that the memory processor would always discard (action starts/ends, reasoning, todos, errors, waiting notices, memory-retrieval pointers) are filtered out at write time, so `EVENT_UNPROCESSED.md` contains only dialogue and meaningful state changes. During a memory-processing task the buffer is frozen entirely, so the processor's own events can't loop back into it.
From 64b9c8272b1b4562501ae68a9aa02c100b82a4e3 Mon Sep 17 00:00:00 2001
From: CraftBot
Date: Sat, 15 Aug 2026 01:33:41 +0900
Subject: [PATCH 22/60] fix entities and connection pipeline
---
agent_core/core/impl/memory/graph.py | 412 +++++++++++-------
agent_core/core/impl/memory/manager.py | 183 +++++---
agent_core/core/impl/memory/tuning.py | 14 +
agent_file_system/ENTITIES.md | 6 +-
app/agent_base.py | 205 ++-------
.../agent_file_system_template/ENTITIES.md | 6 +-
app/ui_layer/adapters/browser_adapter.py | 46 +-
.../frontend/src/pages/Memory/MemoryPage.tsx | 7 +-
.../src/pages/Settings/MemorySettings.tsx | 30 --
app/ui_layer/settings/memory_settings.py | 54 +--
skills/entity-indexer/SKILL.md | 209 ++++-----
skills/memory-processor/SKILL.md | 20 +-
12 files changed, 582 insertions(+), 610 deletions(-)
diff --git a/agent_core/core/impl/memory/graph.py b/agent_core/core/impl/memory/graph.py
index 93702bc7..fa61139b 100644
--- a/agent_core/core/impl/memory/graph.py
+++ b/agent_core/core/impl/memory/graph.py
@@ -21,26 +21,30 @@
Entity co-occurrence is implicit through shared memory neighbours, which
keeps the edge count low and the visualisation readable.
-A memory↔entity link is one of two states:
-- CONFIRMED — recorded by the entity-indexer LLM: a MEMORY.md item's
- ``{entities: ...}`` field, or the ENTITIES.md registry for an indexed
- file whose content still matches the registry hash.
-- PENDING — a deterministic provisional link. When a memory has NOT yet
- been reviewed by the entity-indexer, it is attached to any ALREADY-KNOWN
- entity whose name appears in its text. Pending links are shown distinctly
- and are confirmed-or-corrected on the next entity-indexer run. They never
- create entities — they only attach to entities the LLM has established.
-
-ONLY THE ENTITY-INDEXER CREATES/EDITS ENTITIES:
-- MEMORY.md items are annotated inline with ``{entities: Name1, Name2}`` by
- the entity-indexer (the memory-processor writes plain items and does no
- entity work). An item without the field is unreviewed → pending links.
-- File chunks map to entities through the ENTITIES.md registry, maintained
- by the entity-indexer skill: per-section lines
- ``[path] [content-hash] [section key] Name1, Name2`` whose section keys
- are the chunker's exact section paths (supplied to the skill verbatim).
-Confirmed links only ever PARSE those records; pending links only ever
-match against entities those records have already created.
+CONNECTIONS ARE ESTABLISHED IN EXACTLY ONE PLACE: the graph build. For
+every memory, the deterministic matcher connects it to each known entity
+whose name appears in its text. Nothing else creates a connection — not
+the entity-indexer, not any record.
+
+CONNECTIONS ARE RECORDED IN ENTITIES.md BY THE SYSTEM: after every build,
+the ``## Connections`` section is re-synced to one line per memory —
+``[chunk-id] [status] names :: text preview`` — carrying each established
+connection's state as a mark on the entity name: plain = CONFIRMED,
+``!`` = REJECTED (no edge), ``?`` = PENDING (edge drawn as provisional,
+awaiting judgment). The entity-indexer's ONLY connection job is flipping
+``?`` marks to plain or ``!`` and setting the line's status to [judged];
+it never adds names. A mark on a name the matcher did not establish is
+ignored — structurally, nothing but the matcher can introduce a
+connection. Dead chunk ids (memory changed or deleted) drop out of the
+section automatically at the next sync; changed content produces a new
+chunk id whose line starts pending again, so the records self-invalidate
+with no hashes and no staleness bookkeeping.
+
+ENTITIES COME FROM EXACTLY ONE PLACE: the ``## Entities`` list in
+ENTITIES.md (one name per line), created and maintained solely by the
+entity-indexer skill. The matcher's known-entity set IS that list. When a
+new entity is created, the next build matches it and the sync appends it
+as a ``?`` candidate on the affected memories' lines for judgment.
Communities are computed with deterministic label propagation (no LLM, no
external dependency) and are used for graph colouring and as retrieval
@@ -69,6 +73,9 @@
# All numeric behavior constants live in tuning.py — the single typed home
# of the memory system's magic numbers.
from agent_core.core.impl.memory.tuning import (
+ CONNECTION_PREVIEW_MAX_CHARS,
+ ENTITY_HUB_FRACTION,
+ ENTITY_HUB_MIN_LINKS,
ENTITY_SEED_STRENGTH,
LABEL_PROPAGATION_ROUNDS,
SECOND_HOP_DECAY,
@@ -81,22 +88,29 @@
# instead of deleting contradicted items.
SUPERSEDED_MARKER = "{superseded}"
-# Structured entity field on an item line, written by the memory-processor:
-# {entities: Name1, Name2}. An empty field ({entities:}) means "annotated,
-# no entities"; an absent field means "not yet annotated".
+# Legacy structured entity field on an item line ({entities: Name1, ...}).
+# It is part of the item-line grammar only so its markup is STRIPPED from
+# item content; it plays no role in the connection system.
ENTITIES_FIELD_RE = re.compile(r"\{entities:([^{}]*)\}")
-# The per-file entity registry maintained by the entity-indexer skill.
-# Two line shapes per indexed file:
-# [path] [content-hash] — processed marker
-# [path] [content-hash] [section key] Name1, ... — one per section with entities
-# Section keys are the chunker's exact section paths, supplied to the skill
-# verbatim in the task instruction so no fuzzy matching is ever needed.
+# The entity registry file, with two code-defined sections:
+# - "## Entities": one entity name per line, created only by the
+# entity-indexer skill. The graph's entire entity set.
+# - "## Connections": one record per memory, WRITTEN AND RE-SYNCED BY THE
+# SYSTEM after every graph build. The entity-indexer only flips marks.
ENTITY_REGISTRY_FILE = "ENTITIES.md"
-_REGISTRY_MARKER_RE = re.compile(r"^\[([^\]]+)\]\s+\[([0-9a-fA-F]{6,40})\]\s*$")
-_REGISTRY_SECTION_RE = re.compile(
- r"^\[([^\]]+)\]\s+\[([0-9a-fA-F]{6,40})\]\s+\[(.*)\]\s*(.*?)\s*$"
+
+# A connection record line under "## Connections":
+# [] [pending|judged] Name1, !Name2, ?Name3 ::
+# Chunk ids are the memory content hashes ("m"/"c" + 12 hex, optional "-N"
+# duplicate suffix) — the one identity shared by Chroma, graph, and UI.
+# Name marks: plain = confirmed, "!" = rejected, "?" = awaiting judgment.
+# Status is [pending] while any "?" remains (or the memory was never
+# judged), [judged] once the entity-indexer has decided every name.
+CONNECTION_LINE_RE = re.compile(
+ r"^\[([mc][0-9a-f]{12}(?:-\d+)?)\]\s+\[(pending|judged)\]\s*(.*)$"
)
+_CONNECTION_TEXT_SEPARATOR = " :: "
@@ -165,60 +179,55 @@ def split_item_fields(content: str) -> Tuple[str, Optional[List[str]], bool]:
return clean, entities, superseded
-def item_entities(content: str) -> List[str]:
- """Entity names for an item: its ``{entities: ...}`` field, nothing else.
-
- The field is written by the memory-processor LLM. Items without the
- field have no entities until its backfill phase annotates them.
- """
- entities = split_item_fields(content)[1]
- return entities or []
-
-
-def registry_content_hash(content: bytes) -> str:
- """Fingerprint of an indexed file as recorded in ENTITIES.md.
-
- The entity-index pre-check writes this into the registry and the graph's
- confirmed-file check compares against it, so the derivation lives in ONE
- place: both sides must hash identically or staleness detection silently
- breaks.
- """
- return hashlib.md5(content).hexdigest()[:12]
-
-
-def parse_entity_registry(content: str) -> Dict[str, Dict[str, Any]]:
- """Parse ENTITIES.md into ``{path: {"hash": str, "sections": {key: [names]}}}``.
+def parse_entity_registry(content: str) -> Dict[str, Any]:
+ """Parse ENTITIES.md into ``{"entities": [...], "connections": {...}}``.
- Registry lines are written by the entity-indexer skill. Each processed
- file has a marker line ``[path] [hash]`` plus one
- ``[path] [hash] [section key] Name1, Name2`` line per section with
- entities. The hash is the file's raw-content md5 prefix at extraction
- time, supplied to the skill by the trigger pre-check; comparing it
- against the current file hash is how staleness is detected.
+ - ``entities``: the names listed one-per-line under ``## Entities``
+ (entity-indexer-owned; the graph's entire entity set).
+ - ``connections``: ``{chunk_id: {"status", "confirmed", "rejected",
+ "pending"}}`` from the system-synced connection record lines. Name
+ marks: plain = confirmed, ``!`` = rejected, ``?`` = awaiting
+ judgment. The text preview after ``" :: "`` is display-only and
+ ignored here (the sync regenerates it).
"""
- registry: Dict[str, Dict[str, Any]] = {}
-
- def entry(path: str, digest: str) -> Dict[str, Any]:
- path = path.strip().replace("\\", "/")
- record = registry.setdefault(path, {"hash": "", "sections": {}})
- record["hash"] = digest.lower()
- return record
+ entities: List[str] = []
+ connections: Dict[str, Dict[str, Any]] = {}
+ in_entities_section = False
for line in (content or "").splitlines():
line = line.strip()
- if not line or line.startswith("#") or line.startswith(">"):
+ if line.startswith("#"):
+ in_entities_section = line.lstrip("#").strip().lower() == "entities"
+ continue
+ if not line or line.startswith(">"):
continue
- marker = _REGISTRY_MARKER_RE.match(line)
- if marker:
- entry(marker.group(1), marker.group(2))
+ match = CONNECTION_LINE_RE.match(line)
+ if match:
+ names_part = match.group(3).split(_CONNECTION_TEXT_SEPARATOR, 1)[0]
+ confirmed: List[str] = []
+ rejected: List[str] = []
+ pending: List[str] = []
+ for raw in names_part.split(","):
+ name = raw.strip()
+ if not name:
+ continue
+ if name.startswith("!"):
+ rejected.append(name[1:].strip())
+ elif name.startswith("?"):
+ pending.append(name[1:].strip())
+ else:
+ confirmed.append(name)
+ connections[match.group(1)] = {
+ "status": match.group(2),
+ "confirmed": _dedup_names(confirmed),
+ "rejected": _dedup_names(rejected),
+ "pending": _dedup_names(pending),
+ }
continue
- section = _REGISTRY_SECTION_RE.match(line)
- if section:
- record = entry(section.group(1), section.group(2))
- names = _dedup_names(section.group(4).split(",")) if section.group(4) else []
- if names:
- record["sections"][section.group(3).strip()] = names
- return registry
+ if in_entities_section:
+ entities.append(line)
+
+ return {"entities": _dedup_names(entities), "connections": connections}
# ───────────────────────────── Graph model ─────────────────────────────
@@ -256,6 +265,9 @@ class _ItemNode:
category: str
content: str # clean text, structured fields stripped
entities: List[str] = field(default_factory=list) # CONFIRMED entity keys
+ # Matcher-established connections the entity-indexer REJECTED — no
+ # edge, kept so the connection-record sync preserves the "!" marks.
+ rejected_entities: List[str] = field(default_factory=list)
# Provisional entity keys from the deterministic matcher, present only
# on unreviewed memories. Confirmed by the entity-indexer on its next run.
pending_entities: List[str] = field(default_factory=list)
@@ -293,6 +305,11 @@ def __init__(self) -> None:
self.files: Dict[str, _FileNode] = {}
self._adjacency: Dict[str, Set[str]] = {}
self._communities: Dict[str, int] = {}
+ # Parsed ## Connections records keyed by chunk id: each holds the
+ # lowered confirmed / rejected name sets and the line status. A
+ # matched entity's state comes from its mark; matched entities with
+ # no mark (or no record) are pending.
+ self._records: Dict[str, Dict[str, Any]] = {}
# ───────────────────────────── Building ─────────────────────────────
@@ -300,34 +317,33 @@ def __init__(self) -> None:
def build(
cls,
chunks: List[Dict[str, Any]],
- file_registry: Optional[Dict[str, Dict[str, Any]]] = None,
- confirmed_files: Optional[Set[str]] = None,
+ registry: Optional[Dict[str, Any]] = None,
) -> "MemoryGraph":
"""Build the graph from the indexed chunk corpus.
Chunks of indexed files ARE memories: each section chunk becomes a
- memory node (source="file") grouped under its file node. Confirmed
- entities come from LLM-authored records only — each MEMORY.md item's
- ``{entities: ...}`` field, and the ENTITIES.md registry's
- per-section entries for file chunks whose file is up to date.
- Unreviewed memories then get PENDING links against the entity set
- those records established (see :meth:`_compute_pending_links`).
+ memory node (source="file") grouped under its file node. Entities
+ come solely from the registry's ``## Entities`` list. Connections
+ are then established here — and only here — by the deterministic
+ matcher (:meth:`_establish_connections`); the ``## Connections``
+ records supply each matched name's mark (confirmed / rejected /
+ pending).
Args:
chunks: dicts with ``chunk_id``, ``document`` and ``metadata``
(the full ChromaDB collection contents).
- file_registry: parse_entity_registry() output. Entries for
- files no longer indexed are ignored.
- confirmed_files: indexed-file paths whose current content still
- matches their ENTITIES.md registry hash. Only these files'
- chunks are treated as reviewed (their registry sections are
- authoritative, including "reviewed → no entities"); chunks
- of a file that is missing/stale in the registry are
- unreviewed and fall to pending links.
+ registry: parse_entity_registry() output. Records for chunk ids
+ no longer in the corpus are ignored (and dropped by the
+ next connection-record sync).
"""
graph = cls()
- registry = file_registry or {}
- confirmed = confirmed_files or set()
+ registry = registry or {}
+ graph._records = registry.get("connections", {})
+
+ # Entities exist ONLY from the ## Entities list — including ones
+ # nothing connects to yet.
+ for name in registry.get("entities", []):
+ graph._ensure_entity(name)
for chunk in chunks:
meta = chunk.get("metadata") or {}
@@ -342,23 +358,16 @@ def build(
elif file_path and file_path != ENTITY_REGISTRY_FILE:
# The registry file itself is bookkeeping, not a knowledge
# source worth nodes.
- is_reviewed = file_path in confirmed
- sections = (
- (registry.get(file_path) or {}).get("sections", {})
- if is_reviewed
- else {}
- )
graph._add_file_memory_chunk(
chunk.get("chunk_id", ""),
chunk.get("document", ""),
meta,
- sections,
- is_reviewed,
)
- # Deterministic provisional links come AFTER every confirmed record
- # is in, so the known-entity set they match against is complete.
- graph._compute_pending_links()
+ # THE single connection-establishment pass, then hub exclusion over
+ # the complete link set (pending + confirmed).
+ graph._establish_connections()
+ graph._prune_hub_entities()
graph._compute_communities()
return graph
@@ -386,19 +395,14 @@ def _add_item_chunk(self, chunk_id: str, document: str, meta: Dict[str, Any]) ->
superseded = bool(meta.get("superseded", False))
file_path = meta.get("file_path", "MEMORY.md")
- if "entities" in meta:
- entity_names = _dedup_names((meta.get("entities") or "").split(","))
- else:
- entity_names = item_entities(document)
-
item = _ItemNode(
item_id=chunk_id,
timestamp=meta.get("timestamp", ""),
category=meta.get("category", "fact"),
content=content,
- # Reviewed iff the item carries an {entities:} field (written by
- # the entity-indexer); the chunker records that as this flag.
- reviewed=bool(meta.get("entities_annotated")),
+ # Reviewed iff the connection record for this chunk id says
+ # [judged] — the entity-indexer has decided every mark on it.
+ reviewed=(self._records.get(chunk_id) or {}).get("status") == "judged",
superseded=superseded,
file_path=file_path,
)
@@ -413,31 +417,21 @@ def _add_item_chunk(self, chunk_id: str, document: str, meta: Dict[str, Any]) ->
file_node.chunk_ids.append(chunk_id)
self._link(f"f:{file_path}", f"i:{chunk_id}")
- for name in entity_names:
- entity = self._ensure_entity(name)
- entity.item_ids.add(chunk_id)
- item.entities.append(entity.key)
- self._link(f"i:{chunk_id}", f"e:{entity.key}")
-
def _add_file_memory_chunk(
self,
chunk_id: str,
document: str,
meta: Dict[str, Any],
- section_entities: Dict[str, List[str]],
- reviewed: bool,
) -> None:
"""A section chunk of an indexed file — a memory sourced from a file.
- Creates the chunk's memory node linked under its file node. When the
- file is reviewed (its registry hash matches), links it to the
- entities the ENTITIES.md registry records for its exact section key
- — and a reviewed section with no registry entities is genuinely
- entity-free, not pending. An unreviewed file's chunks get no
- confirmed entities and fall to pending links. The node carries the
- chunk's FULL text (the summary is a truncated derivative — showing
- it in detail views reads as the memory being cut off, which it is
- not).
+ Creates the chunk's memory node linked under its file node. Its
+ connection marks come from the chunk id's ## Connections record,
+ exactly like MEMORY.md items — chunk ids are content-derived, so a
+ changed section is a new id with no record: automatically pending.
+ The node carries the chunk's FULL text (the summary is a truncated
+ derivative — showing it in detail views reads as the memory being
+ cut off, which it is not).
"""
file_path = meta.get("file_path", "")
if not chunk_id or not file_path:
@@ -455,7 +449,7 @@ def _add_file_memory_chunk(
timestamp=meta.get("file_modified_at", ""),
category="file",
content=document,
- reviewed=reviewed,
+ reviewed=(self._records.get(chunk_id) or {}).get("status") == "judged",
source="file",
file_path=file_path,
section=section,
@@ -463,32 +457,64 @@ def _add_file_memory_chunk(
self.items[chunk_id] = item
self._link(f"f:{file_path}", f"i:{chunk_id}")
- for name in section_entities.get(section, []):
- entity = self._ensure_entity(name)
- entity.item_ids.add(chunk_id)
- entity.file_paths.add(file_path)
- item.entities.append(entity.key)
- node.entities.add(entity.key)
- self._link(f"i:{chunk_id}", f"e:{entity.key}")
-
- def _compute_pending_links(self) -> None:
- """Deterministic provisional memory→entity links.
-
- Runs once every confirmed record is loaded, so it matches against
- the COMPLETE known-entity set. For each unreviewed, non-superseded
- memory it attaches the memory to any already-known entity whose
- whole (normalised) name appears in the memory text. These links are
- marked pending on the item and mirrored into the adjacency (so the
- physics pulls the memory toward its provisional entity and the two
- colour together), but they never inflate an entity's canonical
- mention_count and never create a new entity.
+ def _prune_hub_entities(self) -> None:
+ """Exclude over-connected entities from the derived graph.
+
+ An entity connected (pending or confirmed) to more than
+ ENTITY_HUB_FRACTION of all memories (past the ENTITY_HUB_MIN_LINKS
+ floor) is ambient context: a link that attaches to almost
+ everything carries no information, floods the graph retrieval
+ channel, and collapses communities into one blob. The entity list
+ and verdict records stay untouched — exclusion is recomputed on
+ every build, so a hub drops out while it is over the threshold and
+ returns automatically (links intact) when the corpus shifts below
+ it.
+ """
+ total = len(self.items)
+ if total == 0:
+ return
+ limit = max(ENTITY_HUB_MIN_LINKS, ENTITY_HUB_FRACTION * total)
+ hub_keys = [
+ key
+ for key, entity in self.entities.items()
+ if len(entity.item_ids | entity.pending_item_ids) > limit
+ ]
+ for key in hub_keys:
+ entity = self.entities.pop(key)
+ entity_node = f"e:{key}"
+ for item_id in entity.item_ids | entity.pending_item_ids:
+ item = self.items.get(item_id)
+ if item is not None:
+ if key in item.entities:
+ item.entities.remove(key)
+ if key in item.pending_entities:
+ item.pending_entities.remove(key)
+ self._adjacency.get(f"i:{item_id}", set()).discard(entity_node)
+ for file_path in entity.file_paths:
+ file_node = self.files.get(file_path)
+ if file_node is not None:
+ file_node.entities.discard(key)
+ self._adjacency.pop(entity_node, None)
+
+ def _establish_connections(self) -> None:
+ """THE single place memory↔entity connections are made.
+
+ For every memory, the deterministic matcher connects it to each
+ known entity (the ``## Entities`` list) whose whole normalised name
+ appears in the memory's text. The chunk id's ## Connections record
+ then sets each matched name's state by its mark:
+ - confirmed mark (plain name) → CONFIRMED edge;
+ - rejected mark (``!``) → no edge (kept for the record sync);
+ - ``?`` mark, unmarked, or no record → PENDING edge.
+ A mark on a name the matcher did not establish does nothing — the
+ entity-indexer structurally cannot introduce a connection.
"""
if not self.entities:
return
- # Precompute " normalised name " needles once.
+ # Precompute " normalised name " needles once, in deterministic order.
needles: List[Tuple[str, str]] = []
- for key in self.entities:
+ for key in sorted(self.entities):
norm = re.sub(r"[^a-z0-9]+", " ", key).strip()
if norm:
needles.append((f" {norm} ", key))
@@ -496,17 +522,81 @@ def _compute_pending_links(self) -> None:
return
for item in self.items.values():
- # A reviewed memory (or one that already carries confirmed
- # entities) has been decided — never guess over the top of it.
- if item.reviewed or item.entities or item.superseded:
- continue
+ record = self._records.get(item.item_id) or {}
+ confirmed = {n.lower() for n in record.get("confirmed", [])}
+ rejected = {n.lower() for n in record.get("rejected", [])}
haystack = f" {re.sub(r'[^a-z0-9]+', ' ', item.content.lower())} "
for needle, key in needles:
- if needle in haystack:
+ if needle not in haystack:
+ continue
+ entity = self.entities[key]
+ if key in confirmed:
+ item.entities.append(key)
+ entity.item_ids.add(item.item_id)
+ if item.source == "file" and item.file_path:
+ entity.file_paths.add(item.file_path)
+ file_node = self.files.get(item.file_path)
+ if file_node is not None:
+ file_node.entities.add(key)
+ self._link(f"i:{item.item_id}", f"e:{key}")
+ elif key in rejected:
+ item.rejected_entities.append(key)
+ else:
+ # Superseded memories keep their judged history but
+ # never accrue new provisional links.
+ if item.superseded:
+ continue
item.pending_entities.append(key)
- self.entities[key].pending_item_ids.add(item.item_id)
+ entity.pending_item_ids.add(item.item_id)
self._link(f"i:{item.item_id}", f"e:{key}")
+ def connection_lines(self) -> List[str]:
+ """Render the ## Connections record lines for this build.
+
+ One line per memory that has any established (or previously judged)
+ connection state, sorted by chunk id for a deterministic file. Marks
+ carry each matched name's state: plain = confirmed, ``!`` =
+ rejected, ``?`` = pending. Chunk ids no longer in the graph simply
+ aren't rendered — that IS the record cleanup. Superseded memories
+ render only their judged marks (never ``?``), and a memory with no
+ connection state at all still gets a ``[pending]`` line so the
+ entity-indexer reviews its text once for new entities.
+ """
+ lines: List[str] = []
+ for item_id in sorted(self.items):
+ item = self.items[item_id]
+ parts: List[str] = []
+ for key in sorted(item.entities):
+ entity = self.entities.get(key)
+ if entity is not None:
+ parts.append(entity.name)
+ for key in sorted(item.rejected_entities):
+ entity = self.entities.get(key)
+ if entity is not None:
+ parts.append(f"!{entity.name}")
+ for key in sorted(item.pending_entities):
+ entity = self.entities.get(key)
+ if entity is not None:
+ parts.append(f"?{entity.name}")
+ if item.superseded and not parts:
+ continue
+ status = (
+ "judged"
+ if item.reviewed and not item.pending_entities
+ else "pending"
+ )
+ if item.superseded:
+ status = "judged"
+ preview = " ".join((item.content or "").split())
+ if len(preview) > CONNECTION_PREVIEW_MAX_CHARS:
+ preview = preview[: CONNECTION_PREVIEW_MAX_CHARS - 3] + "..."
+ names = f" {', '.join(parts)}" if parts else ""
+ lines.append(
+ f"[{item_id}] [{status}]{names}"
+ f"{_CONNECTION_TEXT_SEPARATOR}{preview}"
+ )
+ return lines
+
# ─────────────────────────── Communities ───────────────────────────
def _compute_communities(self) -> None:
diff --git a/agent_core/core/impl/memory/manager.py b/agent_core/core/impl/memory/manager.py
index 17a49513..8be06c49 100644
--- a/agent_core/core/impl/memory/manager.py
+++ b/agent_core/core/impl/memory/manager.py
@@ -18,7 +18,6 @@
import hashlib
import re
import os as _os
-import uuid
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
@@ -29,11 +28,11 @@
from agent_core.utils.logger import logger
from agent_core.core.impl.memory.bm25_index import BM25Index
from agent_core.core.impl.memory.graph import (
+ CONNECTION_LINE_RE,
ENTITY_REGISTRY_FILE,
MemoryGraph,
compute_item_id,
parse_entity_registry,
- registry_content_hash,
split_item_fields,
)
from agent_core.core.impl.memory.text_extract import extract_text, is_indexable_file
@@ -66,13 +65,17 @@
# the whole list collapsing into a single section chunk under "## Memory".
PER_ITEM_FILES = frozenset({"MEMORY.md", "EVENT_UNPROCESSED.md"})
-# Matches a memory item line. The stamp is the canonical
-# "[YYYY-MM-DD HH:MM:SS]" — every writer emits exactly this form; lines with
-# any other stamp are invalid. The optional colon after the category bracket
-# is the EVENT_UNPROCESSED.md event-line separator ("[kind]: message").
-# Captures: timestamp, category, content.
+# Matches a memory item line: "[stamp] [category] content". The stamp slot
+# accepts any bracketed token — stamp validity is METADATA, never a gate on
+# whether the memory exists. A canonical "YYYY-MM-DD HH:MM:SS" stamp (the
+# only recognized format, validated downstream by _normalize_timestamp)
+# yields timestamp metadata for identity and recency; any other stamp
+# content indexes the memory all the same with no timestamp metadata.
+# The optional colon after the category bracket is the EVENT_UNPROCESSED.md
+# event-line separator ("[kind]: message").
+# Captures: stamp, category, content.
MEMORY_ITEM_LINE_RE = re.compile(
- r"^\s*\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]\s+\[([\w\-]+)\]\s*:?\s*(.+?)\s*$"
+ r"^\s*\[([^\]]+)\]\s+\[([\w\-]+)\]\s*:?\s*(.+?)\s*$"
)
def _log_preview(text: str, max_chars: int) -> str:
@@ -610,39 +613,22 @@ def _ensure_graph_built(self) -> None:
if not self._graph_dirty and self._graph is not None:
return
try:
- # Per-section chunk→entity mappings come from the ENTITIES.md
- # registry (LLM-maintained by the entity-indexer skill).
- # Missing file means an empty registry — no entities for file
- # chunks yet.
+ # The registry supplies the entity list and each memory's
+ # connection marks. Missing file means an empty registry.
registry: Dict[str, Any] = {}
registry_path = self.agent_fs_path / ENTITY_REGISTRY_FILE
if registry_path.exists():
registry = parse_entity_registry(
registry_path.read_text(encoding="utf-8")
)
- # A file's chunks are "reviewed" only while its current content
- # still hashes to the registry's recorded fingerprint (the SAME
- # hash the entity-index pre-check writes — see
- # registry_content_hash). A stale/missing entry means the file
- # changed since its last extraction, so its chunks fall to
- # pending links until the entity-indexer catches up.
- confirmed_files = set()
- for rel, entry in registry.items():
- fpath = self.agent_fs_path / rel
- try:
- if fpath.exists():
- raw = registry_content_hash(fpath.read_bytes())
- if raw == (entry.get("hash") or "").lower():
- confirmed_files.add(rel)
- except OSError:
- continue
- self._graph = MemoryGraph.build(
- self._load_full_corpus(), registry, confirmed_files
- )
+ self._graph = MemoryGraph.build(self._load_full_corpus(), registry)
self._graph_dirty = False
# Keep the entity embedding collection in lock-step with the graph
# so the semantic entity match sees the current entity set.
self._rebuild_entity_index()
+ # Persist this build's established connections back into the
+ # ## Connections section (write only on change).
+ self._sync_connection_records(registry_path)
logger.debug(
f"[MEMORY] Graph rebuilt: {len(self._graph.entities)} entities, "
f"{len(self._graph.items)} items, {len(self._graph.files)} files"
@@ -651,6 +637,62 @@ def _ensure_graph_built(self) -> None:
logger.warning(f"[MEMORY] Failed to rebuild memory graph: {e}")
# Leave dirty so the next call retries.
+ def _sync_connection_records(self, registry_path: Path) -> None:
+ """Re-sync the connection record lines in ENTITIES.md.
+
+ Ownership is line-scoped, not section-scoped: the system may touch
+ ONLY lines matching the connection-record grammar
+ (CONNECTION_LINE_RE) — it removes them and regenerates them from
+ this build. Every other line — headers, prose, and above all the
+ ``## Entities`` names — is preserved verbatim, wherever it is and
+ however mangled the file may be, so no sync can ever damage the
+ entity list. The regenerated records are placed after the
+ ``## Connections`` header line (matched as a whole line, never as a
+ substring; appended at the end if the file lacks one). The file is
+ written only when the result differs, so the watcher's reindex of
+ this write converges instead of looping.
+ """
+ if self._graph is None:
+ return
+ current = (
+ registry_path.read_text(encoding="utf-8")
+ if registry_path.exists()
+ else ""
+ )
+ header = "## Connections"
+
+ kept: List[str] = []
+ for line in current.splitlines():
+ if CONNECTION_LINE_RE.match(line.strip()):
+ continue # system-owned record line; regenerated below
+ kept.append(line)
+ while kept and not kept[-1].strip():
+ kept.pop()
+
+ header_index = next(
+ (i for i, line in enumerate(kept) if line.strip() == header), None
+ )
+ if header_index is None:
+ if kept:
+ kept.append("")
+ kept.append(header)
+ header_index = len(kept) - 1
+ else:
+ # Blank lines directly under the header are re-added below.
+ while (
+ header_index + 1 < len(kept) and not kept[header_index + 1].strip()
+ ):
+ kept.pop(header_index + 1)
+
+ records = self._graph.connection_lines()
+ rebuilt = (
+ kept[: header_index + 1] + [""] + records + kept[header_index + 1 :]
+ )
+ rendered = "\n".join(rebuilt).rstrip("\n") + "\n"
+ if rendered != current:
+ registry_path.write_text(rendered, encoding="utf-8")
+ logger.debug("[MEMORY] Connection records synced to ENTITIES.md")
+
def _load_full_corpus(self) -> List[Dict[str, Any]]:
"""Pull every chunk (id, document, metadata) from ChromaDB."""
result = self.collection.get(include=["documents", "metadatas"])
@@ -915,7 +957,10 @@ def update(self) -> Dict[str, Any]:
current_hash = self._compute_file_hash(full_path)
cached_index = self._file_index_cache.get(file_path)
- if cached_index and cached_index.content_hash != current_hash:
+ if cached_index and (
+ cached_index.content_hash != current_hash
+ or self._expected_chunk_ids(full_path) != cached_index.chunk_ids
+ ):
modified_files.append(file_path)
# Index new files
@@ -970,8 +1015,12 @@ def index_all(self, force: bool = False) -> Dict[str, Any]:
# Skip if already indexed (and not forcing)
if not force and rel_path in self._file_index_cache:
+ cached = self._file_index_cache[rel_path]
current_hash = self._compute_file_hash(file_path)
- if self._file_index_cache[rel_path].content_hash == current_hash:
+ if (
+ cached.content_hash == current_hash
+ and self._expected_chunk_ids(file_path) == cached.chunk_ids
+ ):
stats["files_skipped"] += 1
continue
@@ -1050,7 +1099,6 @@ def _chunk_memory_log(self, content: str, file_path: str) -> List[MemoryChunk]:
chunks: List[MemoryChunk] = []
now = datetime.utcnow().isoformat()
seen_ids: Dict[str, int] = {}
- is_memory_file = Path(file_path).name == "MEMORY.md"
for raw_line in content.splitlines():
line = raw_line.strip()
@@ -1064,18 +1112,19 @@ def _chunk_memory_log(self, content: str, file_path: str) -> List[MemoryChunk]:
timestamp_iso = _normalize_timestamp(timestamp_str)
category = category.lower()
- clean_text, declared_entities, superseded = split_item_fields(item_text)
+ clean_text, _, superseded = split_item_fields(item_text)
summary = self._create_summary(clean_text)
- if is_memory_file:
- chunk_id = compute_item_id(timestamp_iso or timestamp_str, clean_text)
- # Identical duplicate lines get a stable ordinal suffix.
- dup = seen_ids.get(chunk_id, 0)
- seen_ids[chunk_id] = dup + 1
- if dup:
- chunk_id = f"{chunk_id}-{dup + 1}"
- else:
- chunk_id = str(uuid.uuid4())
+ # Deterministic id for every per-item chunk: same line → same id
+ # across rebuilds (graph node, Chroma chunk, and UI item share
+ # one identity, and cached index entries can be validated by
+ # re-deriving). Identical duplicate lines get a stable ordinal
+ # suffix.
+ chunk_id = compute_item_id(timestamp_iso or timestamp_str, clean_text)
+ dup = seen_ids.get(chunk_id, 0)
+ seen_ids[chunk_id] = dup + 1
+ if dup:
+ chunk_id = f"{chunk_id}-{dup + 1}"
chunks.append(
MemoryChunk(
@@ -1091,13 +1140,6 @@ def _chunk_memory_log(self, content: str, file_path: str) -> List[MemoryChunk]:
metadata={
"timestamp": timestamp_iso,
"category": category,
- # ChromaDB metadata values must be primitives; serialise
- # the entity list as a comma-joined string. Entities come
- # from the LLM-written {entities: ...} field only.
- "entities": ", ".join(declared_entities or []),
- # None → no {entities:} field yet → unreviewed by the
- # entity-indexer → the graph gives it pending links.
- "entities_annotated": declared_entities is not None,
"item_content": clean_text,
"superseded": superseded,
"item_kind": "memory_log",
@@ -1113,7 +1155,7 @@ def _chunk_by_sections(self, content: str, file_path: str) -> List[MemoryChunk]:
Chunk ids are deterministic hashes of (file, section, content):
file chunks ARE memories, so the graph node, the Chroma chunk, and
- the ENTITIES.md section entities must share one identity across
+ the ENTITIES.md connection records must share one identity across
rebuilds — same rule as MEMORY.md items.
"""
chunks: List[MemoryChunk] = []
@@ -1489,6 +1531,23 @@ def _index_file(self, file_path: Path) -> int:
logger.debug(f"Indexed {len(chunks)} chunks from {rel_path}")
return len(chunks)
+ def _expected_chunk_ids(self, file_path: Path) -> List[str]:
+ """Chunk ids the CURRENT chunker derives from the file's content.
+
+ Pure text derivation, no embedding. Chunk ids are deterministic
+ functions of content, so a cached index entry is valid only if its
+ stored ids equal this derivation — an entry produced by different
+ chunking code simply fails the comparison and the file reseeds.
+ Nothing about past code is stored or detected.
+ """
+ try:
+ content = extract_text(file_path)
+ except Exception as e:
+ logger.error(f"Error reading file {file_path}: {e}")
+ return []
+ rel_path = self._rel_path(file_path)
+ return [chunk.chunk_id for chunk in self._chunk_markdown(content, rel_path)]
+
def _remove_file_from_index(self, file_path: str) -> None:
"""Remove all chunks for a file from the index."""
file_index = self._file_index_cache.get(file_path)
@@ -1692,26 +1751,6 @@ def _rel_path(self, file_path: Path) -> str:
"""
return str(file_path.relative_to(self.agent_fs_path)).replace("\\", "/")
- def get_file_sections(self, rel_path: str) -> List[str]:
- """The chunker's exact section keys for one indexed file, in order.
-
- Supplied verbatim to the entity-indexer skill so its ENTITIES.md
- section lines match chunk section_paths exactly — no fuzzy
- matching anywhere.
- """
- file_path = self.agent_fs_path / rel_path
- if not file_path.exists():
- return []
- try:
- content = extract_text(file_path)
- except Exception:
- return []
- sections: List[str] = []
- for chunk in self._chunk_markdown(content, rel_path):
- if chunk.section_path not in sections:
- sections.append(chunk.section_path)
- return sections
-
def get_index_files_info(self) -> List[Dict[str, Any]]:
"""Per-file index status for the Memory panel."""
core = set(self.INDEX_TARGET_FILES)
diff --git a/agent_core/core/impl/memory/tuning.py b/agent_core/core/impl/memory/tuning.py
index 682f8761..2834f4e9 100644
--- a/agent_core/core/impl/memory/tuning.py
+++ b/agent_core/core/impl/memory/tuning.py
@@ -75,6 +75,16 @@ class HybridWeights:
SEMANTIC_SEEDS_MAX: Final[int] = 5
MERGED_SEEDS_MAX: Final[int] = 8
+# Hub-entity exclusion: an entity confirmed on more than this fraction of
+# all memories is ambient context, not information — it is left out of the
+# derived graph entirely (no node, no links, no retrieval seeding). The
+# annotations themselves are never touched, so exclusion is recomputed on
+# every build and reverses itself when the corpus shifts. The absolute
+# floor keeps small corpora intact (with 20 memories, 25% would be 5
+# links — normal for any legitimate entity).
+ENTITY_HUB_FRACTION: Final[float] = 0.25
+ENTITY_HUB_MIN_LINKS: Final[int] = 10
+
# BFS scoring: items directly attached to a seed entity score full seed
# strength; items reached through one intermediate entity decay by this.
SECOND_HOP_DECAY: Final[float] = 0.45
@@ -107,6 +117,10 @@ class HybridWeights:
LOG_QUERY_MAX_CHARS: Final[int] = 300
LOG_SUMMARY_MAX_CHARS: Final[int] = 120
+# Text preview appended to each ## Connections record line in ENTITIES.md —
+# enough for the entity-indexer to judge a connection from the line alone.
+CONNECTION_PREVIEW_MAX_CHARS: Final[int] = 160
+
# ──────────────────────── Trigger-driven injection ────────────────────────
diff --git a/agent_file_system/ENTITIES.md b/agent_file_system/ENTITIES.md
index 0a8c36e0..45dc9837 100644
--- a/agent_file_system/ENTITIES.md
+++ b/agent_file_system/ENTITIES.md
@@ -4,8 +4,10 @@ Agent DO NOT edit this file outside the entity-indexer skill.
## Overview
-Maps sections of indexed files to the entities they are about, decided by the entity-indexer skill.
-Format: [path.md] [content-hash] marker line, plus [path.md] [content-hash] [section key] Entity One, Entity Two per section.
+Entities the agent knows about, and the connection records between memories and entities.
+Under ## Entities: one entity name per line — the graph's entire entity set, created by the entity-indexer skill.
+Under ## Connections: one system-written record line per memory: [chunk-id] [pending|judged] names :: text preview. Name marks: plain = confirmed, ! = rejected, ? = awaiting the entity-indexer's judgment.
## Entities
+## Connections
diff --git a/app/agent_base.py b/app/agent_base.py
index 5e7411e6..842d81a0 100644
--- a/app/agent_base.py
+++ b/app/agent_base.py
@@ -775,196 +775,85 @@ def _prepare_memory_run(self) -> Optional[tuple[str, dict]]:
def _prepare_entity_index_run(self) -> Optional[tuple[str, dict]]:
"""Pre-check the entity-index trigger (fired by the indexing process).
- Returns (instruction, workflow_payload) when indexed files have
- stale/missing ENTITIES.md entries, or None to skip the turn.
+ Returns (instruction, workflow_payload) when ENTITIES.md holds
+ [pending] connection record lines awaiting judgment, or None to
+ skip the turn. The records themselves are written by the system's
+ connection sync after each graph build — building the graph here
+ refreshes them before counting.
"""
if not is_memory_enabled():
logger.info("[ENTITY-INDEX] Memory is disabled, skipping trigger")
return None
- # Deterministic tidy-up first: drop registry lines for files that are
- # gone (deleted or de-indexed) so ENTITIES.md doesn't accumulate them.
- self._prune_orphan_entity_registry()
-
- stale_files = self._stale_entity_files()
- unannotated_memories = self._unannotated_memory_items()
- if not stale_files and not unannotated_memories:
- logger.info("[ENTITY-INDEX] Nothing to extract or confirm")
+ pending = self._pending_connection_count()
+ if pending == 0:
+ logger.info("[ENTITY-INDEX] No pending connection records")
return None
# Freeze the unprocessed buffer so this run's own events don't feed
# back into memory processing. Reset when the run ends (_on_run_end).
self.event_stream_manager.set_skip_unprocessed_logging(True)
- parts: list[str] = []
- # MEMORY.md is annotated INLINE (not via the registry): every item
- # without an {entities: ...} field is unreviewed and currently
- # carries only provisional (pending) links — confirm or correct them.
- if unannotated_memories:
- parts.append(
- f"Confirm entity links for {unannotated_memories} MEMORY.md "
- f"item(s) that have no {{entities: ...}} field: read each item, "
- f"decide the entities it is about, and append the "
- f"{{entities: ...}} field to that line in place."
- )
- # Other indexed files are recorded in the ENTITIES.md registry. Each
- # entry carries its exact chunker section keys so the skill's
- # registry lines match chunk section_paths verbatim.
- if stale_files:
- file_specs = []
- for rel, digest in stale_files:
- sections = self.memory_manager.get_file_sections(rel)
- section_list = " ".join(f"[{s}]" for s in sections)
- file_specs.append(f"{rel} (hash {digest}) sections: {section_list}")
- parts.append(
- f"Extract entities for {len(stale_files)} indexed file(s) into "
- f"ENTITIES.md: {'; '.join(file_specs)}. For each file, read it, "
- f"decide the entities of each listed section, and update its "
- f"registry lines using the given hash and the section keys "
- f"exactly as listed."
- )
- parts.append("Follow the entity-indexer skill instructions.")
- instruction = " ".join(parts)
+ instruction = (
+ f"Judge the {pending} [pending] connection record line(s) under "
+ f"## Connections in ENTITIES.md. Each line is "
+ f"'[chunk-id] [status] names :: memory text'. For every name "
+ f"marked '?', decide from the line's text whether that memory is "
+ f"really about that entity: confirm by removing the '?', reject "
+ f"by replacing '?' with '!'. When a line has no '?' left, set "
+ f"its status to [judged]. Never add a name to any line — you "
+ f"judge marks, the system creates connections. Also add any "
+ f"genuinely new named things you see in the line texts to the "
+ f"## Entities list (one name per line); the system connects "
+ f"them on a later cycle. Work batch by batch: read about 30 "
+ f"record lines with read_file offset/limit, judge them all, and "
+ f"write the whole batch back with one stream_edit (old_string = "
+ f"the batch exactly as read, new_string = the judged batch). "
+ f"Follow the entity-indexer skill instructions. "
+ f"IMPORTANT: the pending count was re-derived from ENTITIES.md "
+ f"on disk moments ago; if the work were done, this run would "
+ f"not exist. Prior runs in the event stream claiming this work "
+ f"was already completed are wrong by construction; never skip "
+ f"this run based on history."
+ )
workflow = {
"run_source": TriggerSource.ENTITY_INDEX.value,
"workflow_skills": ["entity-indexer"],
"workflow_action_sets": ["file_operations"],
}
- logger.info(
- f"[ENTITY-INDEX] {unannotated_memories} MEMORY.md item(s) to confirm, "
- f"{len(stale_files)} file(s) to extract"
- )
+ logger.info(f"[ENTITY-INDEX] {pending} pending connection record(s)")
return instruction, workflow
- def _prune_orphan_entity_registry(self) -> None:
- """Drop ENTITIES.md registry lines for files that are no longer
- indexed (deleted from disk or removed from the index).
+ def _pending_connection_count(self) -> int:
+ """Count [pending] connection record lines in ENTITIES.md.
- Deterministic bookkeeping, no LLM: these entries are already ignored
- at graph build, so this only keeps the registry file from
- accumulating dead lines. A registry line's path that is not a
- currently-indexed, on-disk file is dropped; headers/comments/blank
- lines and every valid entry are preserved byte-for-byte.
+ Rebuilds the graph first (a no-op when nothing changed): the build's
+ connection sync is what refreshes the records, so the count always
+ reflects the corpus as it is on disk right now.
"""
- from agent_core.core.impl.memory.graph import ENTITY_REGISTRY_FILE
- from app.ui_layer.settings.memory_settings import (
- CORE_INDEX_FILES,
- get_memory_indexed_files,
+ from agent_core.core.impl.memory.graph import (
+ ENTITY_REGISTRY_FILE,
+ parse_entity_registry,
)
- registry_path = AGENT_FILE_SYSTEM_PATH / ENTITY_REGISTRY_FILE
- if not registry_path.exists():
- return
try:
- lines = registry_path.read_text(encoding="utf-8").splitlines(keepends=True)
+ self.memory_manager.graph_snapshot()
except Exception as e:
- logger.warning(f"[ENTITY-INDEX] Failed to read {ENTITY_REGISTRY_FILE}: {e}")
- return
-
- # A registry entry is valid only if its file is currently indexed AND
- # still present on disk.
- valid = {
- rel.replace("\\", "/")
- for rel in CORE_INDEX_FILES + get_memory_indexed_files()
- if (AGENT_FILE_SYSTEM_PATH / rel).exists()
- }
-
- kept: list[str] = []
- dropped = 0
- for line in lines:
- stripped = line.lstrip()
- match = re.match(r"^\s*\[([^\]]+)\]", line)
- # Keep headers, comments, blanks, and any non-entry line as-is.
- if not match or stripped.startswith(("#", ">")):
- kept.append(line)
- continue
- path = match.group(1).strip().replace("\\", "/")
- if path in valid:
- kept.append(line)
- else:
- dropped += 1
-
- if dropped:
- try:
- registry_path.write_text("".join(kept), encoding="utf-8")
- logger.info(
- f"[ENTITY-INDEX] Pruned {dropped} orphan registry line(s) "
- f"from {ENTITY_REGISTRY_FILE}"
- )
- except Exception as e:
- logger.warning(
- f"[ENTITY-INDEX] Failed to write {ENTITY_REGISTRY_FILE}: {e}"
- )
-
- def _unannotated_memory_items(self) -> int:
- """Count non-superseded MEMORY.md items with no {entities: ...} field.
-
- These are the memories the entity-indexer still has to review — they
- carry only provisional pending links until it annotates them inline.
- """
- memory_file = AGENT_FILE_SYSTEM_PATH / "MEMORY.md"
- if not memory_file.exists():
+ logger.warning(f"[ENTITY-INDEX] Graph refresh failed: {e}")
+ registry_path = AGENT_FILE_SYSTEM_PATH / ENTITY_REGISTRY_FILE
+ if not registry_path.exists():
return 0
try:
- items = _parse_memory_items(memory_file.read_text(encoding="utf-8"))
+ registry = parse_entity_registry(registry_path.read_text(encoding="utf-8"))
except Exception as e:
- logger.warning(f"[ENTITY-INDEX] Failed to inspect MEMORY.md: {e}")
+ logger.warning(f"[ENTITY-INDEX] Failed to parse {ENTITY_REGISTRY_FILE}: {e}")
return 0
return sum(
1
- for item in items
- if not item.get("entities_annotated") and not item.get("superseded")
+ for record in registry.get("connections", {}).values()
+ if record.get("status") == "pending"
)
- def _stale_entity_files(self) -> list[tuple[str, str]]:
- """Indexed files whose ENTITIES.md entry is missing or outdated.
-
- Returns (relative_path, content_hash) pairs; the hash is passed to
- the entity-indexer via the task instruction so it can be written
- verbatim into the registry line.
- """
- from agent_core.core.impl.memory.graph import (
- ENTITY_REGISTRY_FILE,
- parse_entity_registry,
- registry_content_hash,
- )
- from app.ui_layer.settings.memory_settings import (
- CORE_INDEX_FILES,
- get_memory_indexed_files,
- )
-
- # MEMORY.md items carry their own entity fields; the unprocessed
- # buffer is transient; the registry is bookkeeping.
- excluded = {"MEMORY.md", "EVENT_UNPROCESSED.md", ENTITY_REGISTRY_FILE}
-
- registry = {}
- registry_path = AGENT_FILE_SYSTEM_PATH / ENTITY_REGISTRY_FILE
- if registry_path.exists():
- try:
- registry = parse_entity_registry(
- registry_path.read_text(encoding="utf-8")
- )
- except Exception as e:
- logger.warning(f"[MEMORY] Failed to parse {ENTITY_REGISTRY_FILE}: {e}")
-
- stale: list[tuple[str, str]] = []
- seen = set()
- for rel in CORE_INDEX_FILES + get_memory_indexed_files():
- if rel in excluded or rel in seen:
- continue
- seen.add(rel)
- file_path = AGENT_FILE_SYSTEM_PATH / rel
- if not file_path.exists():
- continue
- try:
- digest = registry_content_hash(file_path.read_bytes())
- except OSError:
- continue
- entry = registry.get(rel)
- if entry is None or entry.get("hash") != digest:
- stale.append((rel, digest))
- return stale
-
def _prepare_proactive_run(self, trigger: Trigger) -> Optional[tuple[str, dict]]:
"""Pre-check a proactive heartbeat/planner trigger.
diff --git a/app/data/agent_file_system_template/ENTITIES.md b/app/data/agent_file_system_template/ENTITIES.md
index 0a8c36e0..45dc9837 100644
--- a/app/data/agent_file_system_template/ENTITIES.md
+++ b/app/data/agent_file_system_template/ENTITIES.md
@@ -4,8 +4,10 @@ Agent DO NOT edit this file outside the entity-indexer skill.
## Overview
-Maps sections of indexed files to the entities they are about, decided by the entity-indexer skill.
-Format: [path.md] [content-hash] marker line, plus [path.md] [content-hash] [section key] Entity One, Entity Two per section.
+Entities the agent knows about, and the connection records between memories and entities.
+Under ## Entities: one entity name per line — the graph's entire entity set, created by the entity-indexer skill.
+Under ## Connections: one system-written record line per memory: [chunk-id] [pending|judged] names :: text preview. Name marks: plain = confirmed, ! = rejected, ? = awaiting the entity-indexer's judgment.
## Entities
+## Connections
diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py
index 1e2e6c6e..e8a91a6f 100644
--- a/app/ui_layer/adapters/browser_adapter.py
+++ b/app/ui_layer/adapters/browser_adapter.py
@@ -1440,8 +1440,6 @@ async def _handle_ws_message(self, data: Dict[str, Any], ws=None) -> None:
elif msg_type == "memory_reset":
await self._handle_memory_reset()
- elif msg_type == "memory_reindex":
- await self._handle_memory_reindex()
elif msg_type == "memory_stats_get":
await self._handle_memory_stats_get()
@@ -5082,33 +5080,6 @@ async def _handle_memory_reset(self) -> None:
}
)
- async def _handle_memory_reindex(self) -> None:
- """Rebuild the memory index from the markdown (non-destructive).
-
- Discards and reseeds the derived caches — ChromaDB chunks, the graph,
- and the entity embedding collection — from the current agent files,
- WITHOUT changing any markdown content. Use when retrieval looks stale;
- unlike reset it preserves every memory item and indexed file.
- """
- try:
- agent = self._controller.agent
- if not hasattr(agent, "memory_manager"):
- raise RuntimeError("Memory manager unavailable")
- stats = agent.memory_manager.index_all(force=True)
- await self._broadcast(
- {
- "type": "memory_reindex",
- "data": {"success": True, "stats": stats},
- }
- )
- except Exception as e:
- await self._broadcast(
- {
- "type": "memory_reindex",
- "data": {"success": False, "error": str(e)},
- }
- )
-
async def _handle_memory_stats_get(self) -> None:
"""Get memory statistics."""
result = get_memory_stats()
@@ -5143,6 +5114,23 @@ async def _handle_memory_process_trigger(self) -> None:
)
return
+ # Same emptiness condition as the MEMORY run pre-check
+ # (_prepare_memory_run): with nothing to process the trigger
+ # would be silently dropped there — surface that here instead.
+ from app.ui_layer.settings.memory_settings import memory_needs_pruning
+
+ if get_unprocessed_event_count() == 0 and not memory_needs_pruning():
+ await self._broadcast(
+ {
+ "type": "memory_process_trigger",
+ "data": {
+ "success": False,
+ "error": "No unprocessed events to process.",
+ },
+ }
+ )
+ return
+
# Queue a memory-processing run in the main session. The agent's
# MEMORY pre-check decides whether there is actually work to do.
from app.triggers import TriggerSource, TriggerSpec
diff --git a/app/ui_layer/browser/frontend/src/pages/Memory/MemoryPage.tsx b/app/ui_layer/browser/frontend/src/pages/Memory/MemoryPage.tsx
index 13361314..f7d3ba23 100644
--- a/app/ui_layer/browser/frontend/src/pages/Memory/MemoryPage.tsx
+++ b/app/ui_layer/browser/frontend/src/pages/Memory/MemoryPage.tsx
@@ -403,12 +403,13 @@ export function MemoryPage() {
return root
}, [indexedFiles, candidates])
- const [collapsedFolders, setCollapsedFolders] = useState>(new Set())
+ // Folders are collapsed by default; only paths in this set render expanded.
+ const [expandedFolders, setExpandedFolders] = useState>(new Set())
// Files whose index/unindex request is in flight — their tree rows show
// a spinner until the backend confirms (memory_indexed_files_set).
const [pendingPaths, setPendingPaths] = useState>(new Set())
const toggleFolder = (path: string) => {
- setCollapsedFolders(prev => {
+ setExpandedFolders(prev => {
const next = new Set(prev)
if (next.has(path)) next.delete(path)
else next.add(path)
@@ -418,7 +419,7 @@ export function MemoryPage() {
const renderFolder = (folder: TreeFolder, depth: number): React.ReactNode => {
const isRoot = folder.path === ''
- const isCollapsed = collapsedFolders.has(folder.path)
+ const isCollapsed = !expandedFolders.has(folder.path)
return (
{!isRoot && (
diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/MemorySettings.tsx b/app/ui_layer/browser/frontend/src/pages/Settings/MemorySettings.tsx
index 20db7146..4f3651d8 100644
--- a/app/ui_layer/browser/frontend/src/pages/Settings/MemorySettings.tsx
+++ b/app/ui_layer/browser/frontend/src/pages/Settings/MemorySettings.tsx
@@ -6,7 +6,6 @@ import {
CheckCircle2,
Loader2,
RotateCcw,
- RefreshCw,
} from 'lucide-react'
import { Button, ConfirmModal } from '../../components/ui'
import { useToast } from '../../contexts/ToastContext'
@@ -31,7 +30,6 @@ export function MemorySettings() {
// UI state (transient)
const [isResetting, setIsResetting] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
- const [isReindexing, setIsReindexing] = useState(false)
// Daily auto-processing time + threshold (loaded from the scheduler).
const [autoTime, setAutoTime] = useState('03:00')
@@ -74,12 +72,6 @@ export function MemorySettings() {
if (d.success) showToast('success', d.message || 'Memory processing started')
else showToast('error', d.error || 'Failed to start memory processing')
}),
- onMessage('memory_reindex', (data: unknown) => {
- const d = data as { success: boolean; error?: string }
- setIsReindexing(false)
- if (d.success) showToast('success', 'Memory index rebuilt')
- else showToast('error', d.error || 'Failed to rebuild memory index')
- }),
onMessage('memory_schedule_get', (data: unknown) => {
const d = data as {
success: boolean
@@ -130,11 +122,6 @@ export function MemorySettings() {
})
}
- const handleReindex = () => {
- setIsReindexing(true)
- send('memory_reindex')
- }
-
// ── Threshold slider: drag the picker to set the minimum-events gate ──
const setThresholdFromPointer = (clientX: number) => {
const el = gateBarRef.current
@@ -355,23 +342,6 @@ export function MemorySettings() {
)}
- {/* Rebuild Index */}
-
-
Rebuild Index
-
- Rebuilds the search index, knowledge graph, and entity embeddings
- from your memory files. Your memories are not changed — use this if
- recall seems stale or out of sync.
-
{/* Reset Memory */}
diff --git a/app/ui_layer/settings/memory_settings.py b/app/ui_layer/settings/memory_settings.py
index b68177cd..c72b7a0a 100644
--- a/app/ui_layer/settings/memory_settings.py
+++ b/app/ui_layer/settings/memory_settings.py
@@ -35,12 +35,15 @@
)
-# Memory item regex pattern: [YYYY-MM-DD HH:MM:SS] [category] content — the
-# canonical stamp format, the only one writers emit.
+# Memory item regex pattern: [stamp] [category] content. The stamp slot
+# accepts any bracketed token — stamp validity is metadata, never a gate on
+# whether the item is listed. The canonical "YYYY-MM-DD HH:MM:SS" (validated
+# by normalize_timestamp) is the only recognized timestamp format; other
+# stamp content still lists the item, with the raw stamp as its identity.
# Content may carry structured tail fields ({entities: ...}, {superseded});
# those are parsed out by _parse_memory_items via the shared graph helpers.
MEMORY_ITEM_PATTERN = re.compile(
- r"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]\s+\[([\w\-]+)\]\s+(.+)$"
+ r"^\[([^\]]+)\]\s+\[([\w\-]+)\]\s+(.+)$"
)
# Files that are always indexed (mirrors MemoryManager.INDEX_TARGET_FILES).
@@ -321,9 +324,10 @@ def _parse_memory_items(content: str) -> List[Dict[str, Any]]:
"category": category.lower(),
"content": clean_content,
"display_content": clean_content,
+ # Legacy {entities: ...} markup found on the line, exposed
+ # for display only. Connections live in ENTITIES.md; the
+ # serializer never writes this field back.
"entities": entities or [],
- # None = the memory-processor hasn't annotated this item
- # yet (backfill pending); [] = annotated, no entities.
"entities_annotated": entities is not None,
"superseded": superseded,
"raw": line,
@@ -334,12 +338,15 @@ def _parse_memory_items(content: str) -> List[Dict[str, Any]]:
def _serialize_memory_items(items: List[Dict[str, Any]]) -> str:
- """Serialize memory items back to MEMORY.md format."""
+ """Serialize memory items back to MEMORY.md format.
+
+ Items are plain lines; the ONLY structured tail field is {superseded}.
+ Legacy {entities: ...} markup is dropped on rewrite — connections are
+ recorded in ENTITIES.md, never inline.
+ """
lines = []
for item in items:
fields = ""
- if item.get("entities_annotated") or item.get("entities"):
- fields += " {entities: " + ", ".join(item.get("entities") or []) + "}"
if item.get("superseded"):
fields += f" {SUPERSEDED_MARKER}"
line = f"[{item['timestamp']}] [{item['category']}] {item['content']}{fields}"
@@ -439,11 +446,13 @@ def add_memory_item(
header, items_section = _read_memory_file()
items = _parse_memory_items(items_section)
- # Create new item. Entities are NOT derived from the text — the
- # memory-processor's backfill annotates the item on its next run
- # (unless the caller explicitly wrote an {entities: ...} field).
- clean_content, entities, superseded = split_item_fields(content)
- new_line = f"[{timestamp}] [{category.lower()}] {content}"
+ # Create new item as a plain line ({superseded} is the only tail
+ # field). Connections are established by the graph and recorded in
+ # ENTITIES.md — never inline here.
+ clean_content, _, superseded = split_item_fields(content)
+ new_line = f"[{timestamp}] [{category.lower()}] {clean_content}" + (
+ f" {SUPERSEDED_MARKER}" if superseded else ""
+ )
new_item = {
"id": compute_item_id(
normalize_timestamp(timestamp) or timestamp, clean_content
@@ -452,8 +461,8 @@ def add_memory_item(
"category": category.lower(),
"content": clean_content,
"display_content": clean_content,
- "entities": entities or [],
- "entities_annotated": entities is not None,
+ "entities": [],
+ "entities_annotated": False,
"superseded": superseded,
"raw": new_line,
}
@@ -507,22 +516,16 @@ def update_memory_item(
if category is not None:
item_found["category"] = category.lower()
if content is not None:
- clean_content, entities, _ = split_item_fields(content)
+ # Structured tail markup in the edited text is stripped; items
+ # are plain lines and connections live in ENTITIES.md.
+ clean_content, _, _ = split_item_fields(content)
item_found["content"] = clean_content
- # An explicit {entities: ...} field in the edited text replaces
- # the annotation; otherwise the existing annotation is kept —
- # entities are never derived from the text.
- if entities is not None:
- item_found["entities"] = entities
- item_found["entities_annotated"] = True
if superseded is not None:
item_found["superseded"] = superseded
# Refresh derived fields
item_found["display_content"] = item_found["content"]
fields = ""
- if item_found.get("entities_annotated") or item_found.get("entities"):
- fields += " {entities: " + ", ".join(item_found.get("entities") or []) + "}"
if item_found.get("superseded"):
fields += f" {SUPERSEDED_MARKER}"
item_found["raw"] = (
@@ -644,7 +647,8 @@ def reset_entity_registry() -> Dict[str, Any]:
shutil.copy(template_path, target_path)
else:
target_path.write_text(
- "# Entity Registry\n\n## Entities\n\n", encoding="utf-8"
+ "# Entity Registry\n\n## Entities\n\n## Connections\n",
+ encoding="utf-8",
)
return {"success": True}
except Exception as e:
diff --git a/skills/entity-indexer/SKILL.md b/skills/entity-indexer/SKILL.md
index 5b60d5d9..8fda155d 100644
--- a/skills/entity-indexer/SKILL.md
+++ b/skills/entity-indexer/SKILL.md
@@ -1,6 +1,6 @@
---
name: entity-indexer
-description: Extract per-section entities from indexed files into the ENTITIES.md registry using LLM judgement.
+description: Create entities and judge the pending connection records in ENTITIES.md (flip marks; never create connections).
user-invocable: false
action-sets:
- file_operations
@@ -8,145 +8,106 @@ action-sets:
# Entity Indexer
-The single owner of entities in the memory graph: only you create, edit, or
-connect them. You run AFTER memory processing (chained automatically). You
-have two jobs, and a task may ask for either or both:
-
-1. **MEMORY.md items (inline).** Items the memory-processor wrote have no
- `{entities: ...}` field. Until you review them the graph shows only
- PROVISIONAL (pending) links — a deterministic guess that matched their
- text against already-known entities. You read each such item, decide the
- real entities with your own judgement, and append the `{entities: ...}`
- field IN PLACE. That confirms or corrects the pending links.
-2. **Other indexed files (registry).** Every section of another indexed
- file is a memory; you decide which entities each section is about and
- record them in the ENTITIES.md registry. The graph links those file
- chunks to entities using ONLY this registry.
-
-Decide entities with your own judgement, never by mechanical text-matching.
-The provisional links are only a starting hint — trust your reading of the
-item over them.
-
-## Files
-
-- `agent_file_system/MEMORY.md` - Source AND destination when the task asks
- to confirm MEMORY.md items (append `{entities: ...}` inline; change
- nothing else on the line)
-- The other indexed files named in the task instruction - Source (read only)
-- `agent_file_system/ENTITIES.md` - Destination for the file registry
-
-## Registry Format (Strict)
-
-Under the `## Entities` header, each processed file gets:
-
-1. **One marker line** (always, even when no section has entities):
- ```
- [relative/path.md] [content-hash]
- ```
-2. **One line per section that has entities**:
- ```
- [relative/path.md] [content-hash] [section key] Entity One, Entity Two
- ```
-
-- `relative/path.md` — the file's path exactly as given in the instruction
-- `content-hash` — copied VERBATIM from the instruction (the file's
- fingerprint at extraction time; the system detects staleness with it).
- Never invent or modify it. Same hash on every line of the file.
-- `section key` — copied VERBATIM from the instruction's section list,
- including any `>` hierarchy and `(part N)` suffixes. These keys are how
- entities attach to the right section; a reworded key attaches nothing.
-- Sections with no entities get no section line.
-
-## Task Input
-
-The instruction may contain either or both directives:
-
-- **MEMORY.md confirmation** — "Confirm entity links for N MEMORY.md
- item(s) that have no `{entities: ...}` field". Handle these inline (see
- "MEMORY.md Items" below). No hash or section keys are given for MEMORY.md.
-- **File extraction** — lists each changed file with its hash and its exact
- section keys, e.g.:
-
- ```
- workspace/notes.md (hash a1b2c3d4e5f6) sections: [Introduction] [## Living UI plan] [## Budget]
- ```
-
- Only process the files listed. Files not listed are up to date — leave all
- their registry lines untouched.
+You have exactly two jobs, and a hard boundary around them:
-## Todo Tracking (REQUIRED)
+1. **Create entities.** You are the only thing that decides what entities
+ exist. Entities live as one name per line under `## Entities` in
+ `ENTITIES.md` — that list is the graph's entire entity set.
+2. **Judge pending connections.** The system establishes every connection
+ itself and records them under `## Connections` in `ENTITIES.md`, one
+ line per memory. Your job is to judge the undecided ones by flipping
+ marks on those lines. You never add names, never remove lines, never
+ touch the chunk ids or the text after `::`.
+
+## The record line format
+
+```
+[m4f2a1b2c3d4] [pending] John, ?Acme Corp, !Berlin :: John presented the Acme Corp roadmap at a conference in Berlin...
+```
-Use `update_todos`: one todo for MEMORY.md confirmation (if requested), one
-todo per listed file, plus a final validation todo.
-
-## MEMORY.md Items (inline)
-
-Only when the instruction asks to confirm MEMORY.md items.
-
-1. `read_file` MEMORY.md from line 11 and find every non-superseded item
- line with no `{entities:` field.
-2. For each such line, decide the entities it is about (Entity-decision
- rules below), then `stream_edit` to append the field to the END of that
- line, changing NOTHING else (timestamp, category, wording, order must
- survive byte-identical apart from the appended field):
- ```
- before: [2026-08-11 03:00:00] [fact] John moved to the CraftOS Tokyo office
- after: [2026-08-11 03:00:00] [fact] John moved to the CraftOS Tokyo office {entities: John, CraftOS}
- ```
-3. An item genuinely about no named entity gets an empty field `{entities:}`
- (never omit it — an omitted field marks the item unreviewed and it will
- be handed back to you every run).
-
-## Workflow (per file)
-
-1. **Read the file** with `read_file`. Large files: read in batches
- (offset/limit ~200 lines), tracking which listed section you are in.
- Indexed files may be markdown, plain text, or PDF — `read_file`
- returns PDFs as extracted text with `## Page N` headings, which are
- exactly the section keys the instruction lists for them.
-2. **Decide each section's entities** — named things the section is
- meaningfully about:
- - people, companies, teams, projects, products, tools, services, places
- - canonical names: check ENTITIES.md and MEMORY.md for spellings already
- in use and match them exactly ("Living UI", not "living-ui")
- - NOT: dates, numbers, generic nouns, the section's own heading text as
- a phrase, code keywords, capitalised sentence-starters
- - Prefer precision over recall: an entity should matter to someone
- asking "what does the agent know about X?". Typically 0-5 entities
- per section.
-3. **Update the registry**: `read_file` ENTITIES.md, then `stream_edit`:
- - Remove ALL existing lines for this path (marker + sections), then
- write the fresh marker line and the new section lines.
+- `[m...]`/`[c...]` — the memory's id. NEVER edit it.
+- `[pending]` / `[judged]` — line status.
+- Names, comma-separated, each in one of three states:
+ - `?Name` — awaiting YOUR judgment
+ - `Name` (plain) — confirmed: the memory is really about this entity
+ - `!Name` — rejected: the name appears in the text, but the memory is
+ not about it
+- ` :: text` — the memory's text, your judging evidence. Read only.
+
+## Judging (the core loop)
+
+Work batch by batch until no `[pending]` line remains:
+
+1. `read_file` ENTITIES.md with offset/limit to load the next batch of
+ record lines (about 30 lines).
+2. Judge every `?Name` in the batch from its own line's text: the memory
+ is meaningfully about that entity → plain name; it is not → `!Name`.
+ A line with no `?` left gets status `[judged]`.
+3. Write the whole batch with ONE `stream_edit`: `old_string` is the
+ batch's lines exactly as read, `new_string` is the same lines with
+ your marks and statuses applied.
+
+A `[pending]` line with no names still needs you: read its text for new
+entities (below), then set it to `[judged]` in the same batch edit.
+
+## Creating entities
+
+While judging, the line texts will show you named things that deserve to
+exist but aren't entities yet. Add each as one line under `## Entities`:
+
+- people, companies, teams, projects, products, tools, services, places
+- canonical names: match spellings already in `## Entities` and MEMORY.md
+ exactly ("Living UI", not "living-ui")
+- NOT: dates, numbers, generic nouns, common terms, role words ("User",
+ "Agent"), code keywords, capitalised sentence-starters
+- Prefer precision over recall: an entity should matter to someone asking
+ "what does the agent know about X?"
+
+Do NOT touch any connection line for a new entity — the system will attach
+it as a `?` candidate on the affected lines after the next rebuild, and
+you judge it on your next run. Never remove or rename existing
+`## Entities` lines.
## Validation (final todo)
-- If MEMORY.md confirmation was requested: every non-superseded item now
- carries an `{entities: ...}` field (possibly empty), and no line changed
- apart from its appended field.
-- Every file listed in the instruction has exactly one marker line with
- the instructed hash, and only section lines whose keys came from the
- instruction.
-- No leftover lines with an old hash for the processed paths.
+- Every line you processed has no `?` marks and status `[judged]`.
+- You added no names to any connection line, edited no chunk id, and
+ edited no `::` text.
+- Any new entities are single lines under `## Entities`.
- `end_turn` when validation passes.
+## Todo Tracking (REQUIRED)
+
+Use `update_todos`: one todo per batch of lines, plus a final validation
+todo.
+
## Rules
- Silent background task. NEVER use send_message or interact with the user.
-- Edit ONLY `ENTITIES.md` (the registry) and `MEMORY.md` (inline
- `{entities:}` fields). Never edit any other file — for other indexed
- files you record entities in the registry, you do NOT modify the file.
-- Never touch registry lines for files not listed in the instruction.
+- Edit ONLY `ENTITIES.md`. Never edit MEMORY.md or any other file.
+- One `stream_edit` writes one batch of judged lines.
## Example
-Instruction: `workspace/notes.md (hash a1b2c3d4e5f6) sections: [Introduction] [## Living UI plan]`
+Batch as read:
+
+```
+[m9c1d2e3f4a5] [pending] ?Blue Bottle Diner, ?Acme Corp :: Blue Bottle Diner is a breakfast spot two blocks from the Acme Corp office...
+[m7b8a9c0d1e2] [pending] ?Acme Corp :: John joined Acme Corp as a data engineer in March...
+[c4d5e6f7a8b9] [pending] :: Quick lookup of the terms used throughout this manual...
+```
+
+Judged: the first memory is about the diner and only mentions Acme Corp as
+a landmark; the second is about Acme Corp (and "John" is already in
+`## Entities`); the third has no connections and no new entities in its
+text.
-The intro is throat-clearing; the plan section describes a Living UI
-dashboard for John built on PocketBase. Registry lines written:
+One `stream_edit` (old_string = the three lines above, new_string below):
```
-[workspace/notes.md] [a1b2c3d4e5f6]
-[workspace/notes.md] [a1b2c3d4e5f6] [## Living UI plan] Living UI, John, PocketBase
+[m9c1d2e3f4a5] [judged] Blue Bottle Diner, !Acme Corp :: Blue Bottle Diner is a breakfast spot two blocks from the Acme Corp office...
+[m7b8a9c0d1e2] [judged] Acme Corp :: John joined Acme Corp as a data engineer in March...
+[c4d5e6f7a8b9] [judged] :: Quick lookup of the terms used throughout this manual...
```
## Allowed Actions
diff --git a/skills/memory-processor/SKILL.md b/skills/memory-processor/SKILL.md
index 39c1bd86..bc818db7 100644
--- a/skills/memory-processor/SKILL.md
+++ b/skills/memory-processor/SKILL.md
@@ -55,6 +55,12 @@ Process 50 lines at a time to avoid memory issues.
- Greetings, small talk, acknowledgments ("hi", "thanks", "ok")
- Screen descriptions ("The current screen displays...")
- Truncated text ending in `...`
+- `[user message]` lines are NEVER discarded by type. They are the PRIMARY
+ source of memories: apply the distillation rules to the CONTENT of every
+ `[user message]` line. A user message that contains a preference, fact,
+ contact, decision, or dated event MUST produce a distilled memory even
+ when it is phrased inside an ordinary request (e.g. "I'm allergic to
+ peanuts, find me a lunch spot" → save the allergy, discard the request).
### Format (Strict)
@@ -65,9 +71,12 @@ Process 50 lines at a time to avoid memory issues.
Categories (closed set — never invent new ones):
`[fact]`, `[preference]`, `[event]`, `[decision]`, `[learning]`, `[project]`, `[contact]`
-**Do NOT add any `{entities: ...}` field.** Entity linkage is not your job —
-the entity-indexer skill owns it entirely and annotates these items later.
-Write the plain item line and nothing more.
+**Write the plain item line and NOTHING more.** The only structured tail
+field that exists is ` {superseded}`. NEVER append an `{entities: ...}` or
+any other field — entity connections are established and recorded by a
+different system entirely (in ENTITIES.md, not here). Some older MEMORY.md
+lines may still carry an `{entities: ...}` field; that is legacy markup —
+never copy the pattern onto lines you write.
### DISTILL, Don't Copy
@@ -119,7 +128,10 @@ Never truncate mid-sentence; never end an item with `...`.
**NEVER save (these belong in EVENT.md, not MEMORY.md):**
- Run lifecycle: `trigger`, `action_start`, `action_end`, `end_turn`
-- Conversation content: `user_request`, `user message`, `agent message`
+- Raw transcripts: never copy a `[user message]` or `[agent message]` line
+ into MEMORY.md verbatim. This bans COPYING the conversation, not saving
+ from it — `[user message]` content is exactly what you distill memories
+ FROM (see DISTILL, Don't Copy). `[agent message]` lines are discarded.
- Transient actions: what user asked agent to do, what agent did
- Status updates: "completed X", "working on Y", "finished Z"
- One-time context: information only relevant to the current task
From e1195d9314c19fee31ad3db0ca101c2e2c2a892d Mon Sep 17 00:00:00 2001
From: CraftBot
Date: Sat, 15 Aug 2026 12:28:22 +0900
Subject: [PATCH 23/60] bug:invoking skill and command cause new chat session
to stuck
---
app/ui_layer/adapters/browser_adapter.py | 31 ++++++++++++++++++-
.../src/contexts/WebSocketContext.tsx | 27 +++++++++++++---
app/ui_layer/commands/base.py | 31 +++++++++++++++++++
app/ui_layer/commands/builtin/clear.py | 7 +++++
app/ui_layer/commands/builtin/skill_invoke.py | 13 ++++++++
5 files changed, 104 insertions(+), 5 deletions(-)
diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py
index 094e55c2..7a7c8834 100644
--- a/app/ui_layer/adapters/browser_adapter.py
+++ b/app/ui_layer/adapters/browser_adapter.py
@@ -1224,10 +1224,39 @@ async def _handle_ws_message(self, data: Dict[str, Any], ws=None) -> None:
await self._handle_chat_attachment_upload(data)
elif msg_type == "command":
- # User sent a command
+ # User sent a slash command. Mirror the "message" branch's lazy
+ # draft-session creation, but only for commands that operate on or
+ # produce the conversation they were typed in (skills, /clear —
+ # they declare requires_session). A draft must materialize a real
+ # session before those run: otherwise a skill turn leaks into the
+ # main session and session-scoped output orphans in the never-
+ # committed draft. Global informational commands (/help, /mcp, …)
+ # run in place — their output stays in the draft as immediate
+ # feedback without spawning an empty session in the sidebar.
command = data.get("command", "")
session_id = data.get("sessionId") or "main"
+ client_id = data.get("clientId")
if command:
+ if session_id == "new":
+ name = command.strip().split()[0].lower() if command.strip() else ""
+ cmd = self._controller.command_registry.get(name) if name else None
+ if cmd is not None and cmd.requires_session:
+ session = self._controller.agent.create_chat_session()
+ session_id = session.id
+ await self._broadcast(
+ {
+ "type": "session_created",
+ "data": {
+ "session": self._session_info(session),
+ "clientId": client_id,
+ # Only skills launch a turn; a state command
+ # like /clear commits a session but starts no
+ # run, so the draft handoff must not show a
+ # phantom typing indicator on it.
+ "startsRun": cmd.starts_run,
+ },
+ }
+ )
await self.submit_message(command, session_id=session_id)
elif msg_type == "enhance_prompt":
diff --git a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx
index b793a484..6ea8b819 100644
--- a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx
+++ b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx
@@ -311,9 +311,10 @@ export function WebSocketProvider({ children }: { children: ReactNode }) {
// by a message THIS client sent from /session/new, drop the draft
// bucket (the server echoes the user message into the real session)
// and replace the route with the real session's.
- const { session, clientId } = (msg.data || {}) as {
+ const { session, clientId, startsRun } = (msg.data || {}) as {
session?: SessionInfo
clientId?: string | null
+ startsRun?: boolean
}
if (session && clientId && pendingDraftClientIdsRef.current.has(clientId)) {
pendingDraftClientIdsRef.current.delete(clientId)
@@ -327,9 +328,15 @@ export function WebSocketProvider({ children }: { children: ReactNode }) {
// this reply arrived, so it isn't lost when the route switches.
dispatch(chatInputTransferDraft({ from: 'new', to: session.id }))
// Transfer the optimistic busy flag from the draft to the real
- // session so the typing indicator survives the handoff.
+ // session so the typing indicator survives the handoff. A message
+ // or skill send omits/sets startsRun and shows the indicator; a
+ // state command like /clear sets startsRun=false and must NOT —
+ // it starts no turn, so nothing would ever clear a phantom one.
dispatch(setSessionRunState({ sessionId: 'new', state: 'idle' }))
- dispatch(setSessionRunState({ sessionId: session.id, state: 'running' }))
+ dispatch(setSessionRunState({
+ sessionId: session.id,
+ state: startsRun === false ? 'idle' : 'running',
+ }))
navigateRef.current(`/session/${session.id}`, { replace: true })
}
break
@@ -430,7 +437,19 @@ export function WebSocketProvider({ children }: { children: ReactNode }) {
}, [sendOrQueue, dispatch])
const sendCommand = useCallback((command: string, sessionId: string) => {
- sendOrQueue(JSON.stringify({ type: 'command', command, sessionId }))
+ const clientId = newClientId()
+
+ // Draft view: a conversation-producing command (skills, /clear) makes the
+ // backend create a real session and broadcast session_created carrying
+ // this clientId. Register it so the handoff handler recognizes the new
+ // session as ours and navigates /session/new -> /session/{id}, exactly
+ // like a message send. Global commands produce no session_created and this
+ // entry simply never matches — harmless.
+ if (sessionId === 'new') {
+ pendingDraftClientIdsRef.current.add(clientId)
+ }
+
+ sendOrQueue(JSON.stringify({ type: 'command', command, sessionId, clientId }))
}, [sendOrQueue])
// Force-stop a session's in-flight run (chat input's stop button).
diff --git a/app/ui_layer/commands/base.py b/app/ui_layer/commands/base.py
index ae4714e5..582222ef 100644
--- a/app/ui_layer/commands/base.py
+++ b/app/ui_layer/commands/base.py
@@ -113,6 +113,37 @@ def hidden(self) -> bool:
"""
return False
+ @property
+ def requires_session(self) -> bool:
+ """
+ Whether this command operates on or produces the conversation it was
+ typed in.
+
+ When True, invoking the command from the draft view (/session/new)
+ first materializes a real session so the command runs there — the
+ same lazy commit a chat message triggers. This keeps skill turns from
+ leaking into the main session and keeps session-scoped output from
+ orphaning in a draft that never becomes a real chat.
+
+ Global/informational commands (/help, /mcp, /tokens, …) leave this
+ False: they run in place and render their output in the draft without
+ cluttering the sidebar with an empty session.
+ """
+ return False
+
+ @property
+ def starts_run(self) -> bool:
+ """
+ Whether executing this command launches an agent turn.
+
+ Only meaningful alongside requires_session=True: it tells the draft
+ handoff whether to show the typing indicator on the freshly committed
+ session. Skill invocations start a run (True); a state command like
+ /clear commits a session but starts no turn (False), so the session
+ must not be left showing a phantom "working…" indicator.
+ """
+ return False
+
@abstractmethod
async def execute(
self,
diff --git a/app/ui_layer/commands/builtin/clear.py b/app/ui_layer/commands/builtin/clear.py
index 4c8bbd15..ce21eea0 100644
--- a/app/ui_layer/commands/builtin/clear.py
+++ b/app/ui_layer/commands/builtin/clear.py
@@ -24,6 +24,13 @@ def aliases(self) -> List[str]:
def description(self) -> str:
return "Clear this session's conversation"
+ @property
+ def requires_session(self) -> bool:
+ # Operates on the session it was typed in. In a draft this commits a
+ # real session and navigates to it, so the "Conversation cleared."
+ # note lands in a live chat instead of leaving the draft stuck.
+ return True
+
async def execute(
self,
args: List[str],
diff --git a/app/ui_layer/commands/builtin/skill_invoke.py b/app/ui_layer/commands/builtin/skill_invoke.py
index a80ec654..72b033b2 100644
--- a/app/ui_layer/commands/builtin/skill_invoke.py
+++ b/app/ui_layer/commands/builtin/skill_invoke.py
@@ -54,6 +54,19 @@ def help_text(self) -> str:
def hidden(self) -> bool:
return True
+ @property
+ def requires_session(self) -> bool:
+ # Skill invocations route into an agent turn; without a real session
+ # the turn falls back to the main session (see _handle_chat_message),
+ # so a draft must be committed to a real session first.
+ return True
+
+ @property
+ def starts_run(self) -> bool:
+ # invoke_skill() routes into _handle_chat_message, launching an agent
+ # turn — the committed session should show the typing indicator.
+ return True
+
async def execute(
self,
args: List[str],
From 2565ae34eb3d49ddb1481448b04f9e7dfaa555ec Mon Sep 17 00:00:00 2001
From: CraftBot
Date: Sun, 16 Aug 2026 18:59:25 +0900
Subject: [PATCH 24/60] Added new guide tour with driver js
---
.../browser/frontend/package-lock.json | 7 +
app/ui_layer/browser/frontend/package.json | 1 +
app/ui_layer/browser/frontend/src/App.tsx | 30 ++-
.../frontend/src/components/Chat/Chat.tsx | 3 +
.../frontend/src/components/layout/Layout.tsx | 15 ++
.../frontend/src/components/layout/NavBar.tsx | 26 ++-
.../src/components/ui/CreateLivingUIModal.tsx | 16 ++
.../Dashboard/widgets/CraftBotIntroWidget.tsx | 18 +-
.../src/pages/Settings/GeneralSettings.tsx | 19 ++
.../src/pages/Settings/SettingsPage.tsx | 42 +++-
.../frontend/src/tour/TourProvider.tsx | 138 +++++++++++
.../browser/frontend/src/tour/anchors.ts | 43 ++++
.../browser/frontend/src/tour/controller.ts | 215 ++++++++++++++++++
.../browser/frontend/src/tour/index.ts | 5 +
.../browser/frontend/src/tour/storage.ts | 30 +++
.../browser/frontend/src/tour/tour.css | 123 ++++++++++
.../browser/frontend/src/tour/tours/core.ts | 188 +++++++++++++++
.../browser/frontend/src/tour/tours/index.ts | 8 +
.../browser/frontend/src/tour/types.ts | 50 ++++
19 files changed, 949 insertions(+), 28 deletions(-)
create mode 100644 app/ui_layer/browser/frontend/src/tour/TourProvider.tsx
create mode 100644 app/ui_layer/browser/frontend/src/tour/anchors.ts
create mode 100644 app/ui_layer/browser/frontend/src/tour/controller.ts
create mode 100644 app/ui_layer/browser/frontend/src/tour/index.ts
create mode 100644 app/ui_layer/browser/frontend/src/tour/storage.ts
create mode 100644 app/ui_layer/browser/frontend/src/tour/tour.css
create mode 100644 app/ui_layer/browser/frontend/src/tour/tours/core.ts
create mode 100644 app/ui_layer/browser/frontend/src/tour/tours/index.ts
create mode 100644 app/ui_layer/browser/frontend/src/tour/types.ts
diff --git a/app/ui_layer/browser/frontend/package-lock.json b/app/ui_layer/browser/frontend/package-lock.json
index b341e4dd..7ad29ffb 100644
--- a/app/ui_layer/browser/frontend/package-lock.json
+++ b/app/ui_layer/browser/frontend/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@reduxjs/toolkit": "^2.12.0",
"@tanstack/react-virtual": "^3.13.23",
+ "driver.js": "^1.8.0",
"lucide-react": "^0.344.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.2.0",
@@ -2190,6 +2191,12 @@
"node": ">=6.0.0"
}
},
+ "node_modules/driver.js": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.8.0.tgz",
+ "integrity": "sha512-+8/IO7h1v14IzWh2GP60N7T3PFZweXwdn5e5POuxRSBoCYUojsBxzqawPeXh3YZIibRy7EehYNEyxe7slwwtdg==",
+ "license": "MIT"
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.321",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
diff --git a/app/ui_layer/browser/frontend/package.json b/app/ui_layer/browser/frontend/package.json
index 08cf5c29..5a441431 100644
--- a/app/ui_layer/browser/frontend/package.json
+++ b/app/ui_layer/browser/frontend/package.json
@@ -12,6 +12,7 @@
"dependencies": {
"@reduxjs/toolkit": "^2.12.0",
"@tanstack/react-virtual": "^3.13.23",
+ "driver.js": "^1.8.0",
"lucide-react": "^0.344.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.2.0",
diff --git a/app/ui_layer/browser/frontend/src/App.tsx b/app/ui_layer/browser/frontend/src/App.tsx
index e09ee5aa..02ca4621 100644
--- a/app/ui_layer/browser/frontend/src/App.tsx
+++ b/app/ui_layer/browser/frontend/src/App.tsx
@@ -8,6 +8,7 @@ import { SettingsPage } from './pages/Settings'
import { OnboardingPage } from './pages/Onboarding'
import { LivingUIPage } from './pages/LivingUI'
import { useWebSocket } from './contexts/WebSocketContext'
+import { TourProvider } from './tour'
import { LoadingMascot } from '@mascot'
// Forces LivingUIPage to remount per-project so useState initializers
@@ -74,19 +75,24 @@ function App() {
return
}
+ // TourProvider wraps the ready app (past hard onboarding), so the first-run
+ // walkthrough can never collide with the onboarding wizard. It sits inside
+ // the router, so the tour can navigate between pages.
return (
-
-
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
-
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
)
}
diff --git a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx
index 1eee0307..4e729b72 100644
--- a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx
+++ b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx
@@ -30,6 +30,7 @@ import {
import { selectSessionActivity } from '../../store/selectors/activity'
import { selectSessionBusy, selectSessionRunState } from '../../store/selectors/agent'
import type { ActionItem, ChatMessage } from '../../types'
+import { tourAnchorProps } from '../../tour'
import styles from './Chat.module.css'
// Pending attachment type
@@ -1359,6 +1360,7 @@ export function Chat({ sessionId, placeholder }: ChatProps) {
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
+ {...tourAnchorProps('chat-composer')}
>
{replyTarget && (
@@ -1443,6 +1445,7 @@ export function Chat({ sessionId, placeholder }: ChatProps) {
title="Attach and tools"
aria-label="Attach and tools"
aria-expanded={plusOpen}
+ {...tourAnchorProps('chat-plus')}
>
{enhancing
?
diff --git a/app/ui_layer/browser/frontend/src/components/layout/Layout.tsx b/app/ui_layer/browser/frontend/src/components/layout/Layout.tsx
index 6d69fb28..20f57fa1 100644
--- a/app/ui_layer/browser/frontend/src/components/layout/Layout.tsx
+++ b/app/ui_layer/browser/frontend/src/components/layout/Layout.tsx
@@ -3,8 +3,13 @@ import { useLocation } from 'react-router-dom'
import { Menu, X } from 'lucide-react'
import { NavBar } from './NavBar'
import { useFullscreen } from '../../contexts/FullscreenContext'
+import { useTourEnvAction } from '../../tour'
import styles from './Layout.module.css'
+// Matches the mobile breakpoint in Layout.module.css, where the sidebar
+// becomes an off-canvas drawer.
+const MOBILE_QUERY = '(max-width: 768px)'
+
interface LayoutProps {
children: ReactNode
}
@@ -54,6 +59,16 @@ export function Layout({ children }: LayoutProps) {
})
}
+ // Let the guided tour reveal the sidebar before highlighting a nav item.
+ // Expanding it in memory only (not persisting COLLAPSED_KEY) keeps the user's
+ // saved preference intact for their next session.
+ useTourEnvAction('ensureSidebarVisible', () => {
+ setCollapsed(false)
+ if (window.matchMedia(MOBILE_QUERY).matches) {
+ setMobileOpen(true)
+ }
+ })
+
return (
{!isFullscreen && (
diff --git a/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx b/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx
index ac335022..29f04c30 100644
--- a/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx
+++ b/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx
@@ -23,6 +23,7 @@ import {
} from 'lucide-react'
import { useWebSocket } from '../../contexts/WebSocketContext'
import { useTheme } from '../../contexts/ThemeContext'
+import { tourAnchorProps, useTourEnvAction, type TourAnchorId } from '../../tour'
import { useSkillCreator } from '../../hooks'
import { CreateLivingUIModal } from '../ui/CreateLivingUIModal'
import { SkillCreatorModal } from '../ui/SkillCreatorModal'
@@ -39,6 +40,7 @@ interface NavItem {
label: string
icon: React.ReactNode
path: string
+ tourAnchor?: TourAnchorId
}
// Sidebar title with a typewriter reveal: when the auto-title replaces the
@@ -84,8 +86,8 @@ function AnimatedSessionTitle({ title }: { title: string }) {
}
const utilityNavItems: NavItem[] = [
- { id: 'dashboard', label: 'Dashboard', icon: , path: '/dashboard' },
- { id: 'workspace', label: 'Workspace', icon: , path: '/workspace' },
+ { id: 'dashboard', label: 'Dashboard', icon: , path: '/dashboard', tourAnchor: 'nav-dashboard' },
+ { id: 'workspace', label: 'Workspace', icon: , path: '/workspace', tourAnchor: 'nav-workspace' },
]
const settingsItem: NavItem = { id: 'settings', label: 'Settings', icon: , path: '/settings' }
@@ -322,6 +324,19 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) {
navigate('/session/new')
}
+ // Let the guided tour open a fresh New Chat via the exact same action as the
+ // button, so the chat is demonstrated on a clean draft, not the Main session.
+ useTourEnvAction('openNewChat', startNewChat)
+
+ // Let the tour expand the Chats group so the pinned Main row is on screen
+ // before it highlights it.
+ useTourEnvAction('ensureChatsExpanded', () => setChatsExpanded(true))
+
+ // Let the tour open and close the "Add Living UI" modal while it walks the
+ // creation methods.
+ useTourEnvAction('openLivingUIModal', () => setShowCreateModal(true))
+ useTourEnvAction('closeLivingUIModal', () => setShowCreateModal(false))
+
// Close any open context menu when clicking anywhere else.
useEffect(() => {
if (!menu) return
@@ -455,6 +470,7 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) {
key={session.id}
className={`${styles.sessionRow} ${active ? styles.sessionRowActive : ''} ${opts.isMain ? styles.sessionRowMain : ''}`}
title={opts.isMain ? 'Main' : session.title}
+ {...(opts.isMain ? tourAnchorProps('nav-main-session') : {})}
>
{renaming ? (
New Chat
@@ -594,6 +611,7 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) {
className={`${styles.navItem} ${isActive(item.path) ? styles.active : ''}`}
onClick={() => navigate(item.path)}
title={item.label}
+ {...(item.tourAnchor ? tourAnchorProps(item.tourAnchor) : {})}
>
{item.icon}{item.label}
@@ -631,7 +649,7 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) {
) : (
<>
{/* Living UI group */}
-
+
setLivingUIExpanded(v => !v)}
@@ -697,7 +715,7 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) {
{/* Chats group — Main always pinned first inside it */}
-
+
setChatsExpanded(v => !v)}
diff --git a/app/ui_layer/browser/frontend/src/components/ui/CreateLivingUIModal.tsx b/app/ui_layer/browser/frontend/src/components/ui/CreateLivingUIModal.tsx
index aec6bde3..b2eee7da 100644
--- a/app/ui_layer/browser/frontend/src/components/ui/CreateLivingUIModal.tsx
+++ b/app/ui_layer/browser/frontend/src/components/ui/CreateLivingUIModal.tsx
@@ -4,8 +4,16 @@ import { Button } from './Button'
import { Modal } from './Modal'
import { CreateCustomWizard } from './CreateCustomWizard'
import { useSettingsWebSocket } from '../../pages/Settings/useSettingsWebSocket'
+import { tourAnchorProps, useTourEnvAction, type TourAnchorId } from '../../tour'
import styles from './CreateLivingUIModal.module.css'
+// The modal's tabs, in the order the guided tour walks them.
+const TAB_TOUR_ANCHORS: Record<'marketplace' | 'custom' | 'import', TourAnchorId> = {
+ marketplace: 'livingui-tab-marketplace',
+ custom: 'livingui-tab-custom',
+ import: 'livingui-tab-import',
+}
+
export interface CreateLivingUIModalProps {
isOpen: boolean
onClose: () => void
@@ -63,6 +71,13 @@ export function CreateLivingUIModal({ isOpen, onClose, onInstalled }: CreateLivi
useEffect(() => { onInstalledRef.current = onInstalled }, [onInstalled])
useEffect(() => () => { installTimeoutsRef.current.forEach(t => clearTimeout(t)) }, [])
+ // Let the guided tour switch the modal's tab so each creation method is shown.
+ useTourEnvAction('openLivingUITab', (arg) => {
+ if (arg === 'marketplace' || arg === 'custom' || arg === 'import') {
+ setActiveTab(arg)
+ }
+ })
+
// Chat-path requirements phase: living_ui_scaffold generated setup
// questions (creating nothing yet) and the backend summons the SAME
// Create Custom wizard, pre-seeded and opened at the interview step
@@ -348,6 +363,7 @@ export function CreateLivingUIModal({ isOpen, onClose, onInstalled }: CreateLivi
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`${styles.tab} ${activeTab === tab.id ? styles.tabActive : ''}`}
+ {...tourAnchorProps(TAB_TOUR_ANCHORS[tab.id])}
>
{tab.icon}
{tab.label}
diff --git a/app/ui_layer/browser/frontend/src/pages/Dashboard/widgets/CraftBotIntroWidget.tsx b/app/ui_layer/browser/frontend/src/pages/Dashboard/widgets/CraftBotIntroWidget.tsx
index f53def10..77f4b418 100644
--- a/app/ui_layer/browser/frontend/src/pages/Dashboard/widgets/CraftBotIntroWidget.tsx
+++ b/app/ui_layer/browser/frontend/src/pages/Dashboard/widgets/CraftBotIntroWidget.tsx
@@ -1,8 +1,9 @@
import { useState, useRef, useEffect } from 'react'
-import { Cloud, Users, Github, Box, ChevronRight, ArrowLeft, ExternalLink } from 'lucide-react'
+import { Cloud, Users, Github, Box, ChevronRight, ArrowLeft, ExternalLink, Compass } from 'lucide-react'
import { CraftBotMascot, useMascotState, getPose } from '@mascot'
import type { MascotState } from '@mascot'
import { Button } from '../../../components/ui'
+import { useTour } from '../../../tour'
import styles from './widgets.module.css'
interface IntroCard {
@@ -99,6 +100,7 @@ const CARDS: IntroCard[] = [
]
export function CraftBotIntroWidget() {
+ const { startTour } = useTour()
const mascotState = useMascotState()
// This widget's mascot never sleeps: any state whose pose renders the
// sleeping silhouette shows the awake 'resting' pose here instead. Scoped to
@@ -347,6 +349,20 @@ export function CraftBotIntroWidget() {
>
Learn More
+
+ {/* Replay the first-run walkthrough. Hidden at the smallest widget size
+ so it never crowds the mascot + Learn More stack. */}
+ {isEnlarged && (
+ }
+ onClick={() => startTour('core', { restart: true })}
+ style={{ marginTop: 'var(--space-2)' }}
+ >
+ Take a tour
+
+ )}