@makefinks/daemon 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -241,28 +241,6 @@ export class DaemonAvatarRenderable extends FrameBufferRenderable {
241
241
  const w = fb.width;
242
242
  const h = fb.height;
243
243
  fb.clear(RGBA.fromValues(0, 0, 0, 0));
244
-
245
- const glyph = w >= 18 ? "⟡  SUMMONING  ⟡" : "SUMMONING";
246
- const gx = Math.max(0, Math.floor(w / 2) - Math.floor(glyph.length / 2));
247
- const gy = Math.floor(h / 2);
248
- fb.drawText(
249
- glyph,
250
- gx,
251
- Math.max(0, Math.min(h - 1, gy)),
252
- RGBA.fromInts(180, 255, 245, 230),
253
- RGBA.fromInts(0, 0, 0, 0),
254
- TextAttributes.DIM
255
- );
256
- if (h >= 2) {
257
- fb.drawText(
258
- "DAEMON",
259
- Math.max(0, Math.floor(w / 2) - 3),
260
- Math.max(0, Math.min(h - 1, gy + 1)),
261
- RGBA.fromInts(130, 190, 255, 200),
262
- RGBA.fromInts(0, 0, 0, 0),
263
- TextAttributes.BOLD
264
- );
265
- }
266
244
  } else {
267
245
  this.kickRenderFrame();
268
246
  }
@@ -47,6 +47,7 @@ export function HotkeysPane({ onClose }: HotkeysPaneProps) {
47
47
  {
48
48
  title: "MENUS",
49
49
  items: [
50
+ { key: "B", label: "Memories" },
50
51
  { key: "D", label: "Devices" },
51
52
  { key: "M", label: "Models" },
52
53
  { key: "P", label: "Providers" },
@@ -0,0 +1,338 @@
1
+ import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core";
2
+ import { useKeyboard } from "@opentui/react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { getMemoryManager, isMemoryAvailable } from "../ai/memory";
5
+ import { useMenuKeyboard } from "../hooks/use-menu-keyboard";
6
+ import type { MemoryEntry } from "../types";
7
+ import { COLORS } from "../ui/constants";
8
+
9
+ interface MemoryMenuProps {
10
+ onClose: () => void;
11
+ }
12
+
13
+ const MEMORY_ITEM_HEIGHT = 2;
14
+ const MAX_SCROLLBOX_HEIGHT = 16;
15
+
16
+ function formatTimestamp(value: string | undefined): string {
17
+ if (!value) return "";
18
+ return value.replace("T", " ").slice(0, 16);
19
+ }
20
+
21
+ function truncateText(text: string, maxLen: number): string {
22
+ if (text.length <= maxLen) return text;
23
+ if (maxLen <= 3) return text.slice(0, Math.max(0, maxLen));
24
+ return text.slice(0, Math.max(0, maxLen - 3)) + "...";
25
+ }
26
+
27
+ export function MemoryMenu({ onClose }: MemoryMenuProps) {
28
+ const [searchQuery, setSearchQuery] = useState("");
29
+ const [isSearchFocused, setIsSearchFocused] = useState(false);
30
+ const [memories, setMemories] = useState<MemoryEntry[]>([]);
31
+ const [isLoading, setIsLoading] = useState(true);
32
+ const [error, setError] = useState<string | null>(null);
33
+ const searchInputRef = useRef<TextareaRenderable | null>(null);
34
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null);
35
+
36
+ // Load all memories on mount
37
+ useEffect(() => {
38
+ let cancelled = false;
39
+
40
+ const loadMemories = async () => {
41
+ if (!isMemoryAvailable()) {
42
+ setError("Memory system not available (requires OPENAI_API_KEY and OPENROUTER_API_KEY)");
43
+ setIsLoading(false);
44
+ return;
45
+ }
46
+
47
+ try {
48
+ const manager = getMemoryManager();
49
+ await manager.initialize();
50
+
51
+ if (!manager.isAvailable) {
52
+ setError("Memory system not available (check API keys and configuration)");
53
+ setIsLoading(false);
54
+ return;
55
+ }
56
+
57
+ const allMemories = await manager.getAll();
58
+ if (!cancelled) {
59
+ setMemories(allMemories);
60
+ setIsLoading(false);
61
+ }
62
+ } catch (err) {
63
+ if (!cancelled) {
64
+ setError(err instanceof Error ? err.message : String(err));
65
+ setIsLoading(false);
66
+ }
67
+ }
68
+ };
69
+
70
+ void loadMemories();
71
+ return () => {
72
+ cancelled = true;
73
+ };
74
+ }, []);
75
+
76
+ const filteredMemories = useMemo(() => {
77
+ const query = searchQuery.trim().toLowerCase();
78
+ if (!query) return memories;
79
+ return memories.filter((memory) => memory.memory.toLowerCase().includes(query));
80
+ }, [memories, searchQuery]);
81
+
82
+ const handleDelete = useCallback(
83
+ async (index: number) => {
84
+ const memory = filteredMemories[index];
85
+ if (!memory) return;
86
+
87
+ try {
88
+ const manager = getMemoryManager();
89
+ const success = await manager.delete(memory.id);
90
+ if (success) {
91
+ setMemories((prev) => prev.filter((entry) => entry.id !== memory.id));
92
+ }
93
+ } catch {
94
+ // Silently fail delete
95
+ }
96
+ },
97
+ [filteredMemories]
98
+ );
99
+
100
+ const { selectedIndex } = useMenuKeyboard({
101
+ itemCount: filteredMemories.length,
102
+ onClose,
103
+ onSelect: () => {}, // No action on select, just navigation
104
+ enableViKeys: !isSearchFocused,
105
+ ignoreEscape: isSearchFocused,
106
+ closeOnSelect: false,
107
+ });
108
+
109
+ useKeyboard((key) => {
110
+ if (key.eventType !== "press") return;
111
+
112
+ // X to delete selected memory
113
+ if (!isSearchFocused && (key.name === "x" || key.sequence?.toLowerCase() === "x")) {
114
+ void handleDelete(selectedIndex);
115
+ key.preventDefault();
116
+ return;
117
+ }
118
+
119
+ // / or Shift+Tab to focus search
120
+ if ((key.name === "tab" && key.shift) || (!isSearchFocused && key.name === "/")) {
121
+ setIsSearchFocused(true);
122
+ searchInputRef.current?.focus();
123
+ key.preventDefault();
124
+ }
125
+ });
126
+
127
+ const scrollboxHeight = Math.min(
128
+ MAX_SCROLLBOX_HEIGHT,
129
+ Math.max(MEMORY_ITEM_HEIGHT, filteredMemories.length * MEMORY_ITEM_HEIGHT)
130
+ );
131
+
132
+ // Auto-scroll to selected item
133
+ useEffect(() => {
134
+ const scrollbox = scrollRef.current;
135
+ if (!scrollbox) return;
136
+ const viewportHeight = scrollbox.viewport?.height ?? 0;
137
+ if (viewportHeight <= 0) return;
138
+
139
+ const maxScrollTop = Math.max(0, scrollbox.scrollHeight - viewportHeight);
140
+ const itemTop = selectedIndex * MEMORY_ITEM_HEIGHT;
141
+ const itemBottom = itemTop + MEMORY_ITEM_HEIGHT;
142
+ const currentTop = scrollbox.scrollTop;
143
+ const currentBottom = currentTop + viewportHeight;
144
+ let nextTop = currentTop;
145
+
146
+ if (itemTop < currentTop) {
147
+ nextTop = itemTop;
148
+ } else if (itemBottom > currentBottom) {
149
+ nextTop = itemBottom - viewportHeight;
150
+ }
151
+
152
+ nextTop = Math.max(0, Math.min(nextTop, maxScrollTop));
153
+ if (nextTop !== currentTop) {
154
+ scrollbox.scrollTop = nextTop;
155
+ }
156
+ }, [filteredMemories.length, selectedIndex]);
157
+
158
+ return (
159
+ <box
160
+ position="absolute"
161
+ left={0}
162
+ top={0}
163
+ width="100%"
164
+ height="100%"
165
+ flexDirection="column"
166
+ alignItems="center"
167
+ justifyContent="center"
168
+ zIndex={100}
169
+ >
170
+ <box
171
+ flexDirection="column"
172
+ alignItems="flex-start"
173
+ backgroundColor={COLORS.MENU_BG}
174
+ borderStyle="single"
175
+ borderColor={COLORS.MENU_BORDER}
176
+ paddingLeft={2}
177
+ paddingRight={2}
178
+ paddingTop={1}
179
+ paddingBottom={1}
180
+ width="80%"
181
+ minWidth={60}
182
+ maxWidth={140}
183
+ >
184
+ <box marginBottom={1}>
185
+ <text>
186
+ <span fg={COLORS.DAEMON_LABEL}>[ MEMORIES ]</span>
187
+ </text>
188
+ </box>
189
+
190
+ <box marginBottom={1}>
191
+ <text>
192
+ <span fg={COLORS.REASONING_DIM}>↑/↓ j/k navigate · X delete · / search · ESC close</span>
193
+ </text>
194
+ </box>
195
+
196
+ <box marginBottom={0}>
197
+ <text>
198
+ <span fg={COLORS.USER_LABEL}>— SEARCH —</span>
199
+ </text>
200
+ </box>
201
+
202
+ <box
203
+ marginBottom={1}
204
+ marginTop={0}
205
+ width="100%"
206
+ height={1}
207
+ flexDirection="row"
208
+ alignItems="center"
209
+ paddingLeft={1}
210
+ backgroundColor={isSearchFocused ? COLORS.MENU_SELECTED_BG : COLORS.MENU_BG}
211
+ >
212
+ <box width={2}>
213
+ <text>
214
+ <span fg={isSearchFocused ? COLORS.TYPING_PROMPT : COLORS.REASONING_DIM}>/ </span>
215
+ </text>
216
+ </box>
217
+ <box flexGrow={1} height={1}>
218
+ <textarea
219
+ ref={searchInputRef}
220
+ focused={isSearchFocused}
221
+ width="100%"
222
+ height={1}
223
+ placeholder="Type to search memories... (/ or Shift+Tab)"
224
+ style={{
225
+ backgroundColor: "transparent",
226
+ focusedBackgroundColor: "transparent",
227
+ textColor: COLORS.MENU_TEXT,
228
+ focusedTextColor: COLORS.TYPING_PROMPT,
229
+ cursorColor: COLORS.TYPING_PROMPT,
230
+ }}
231
+ onContentChange={() => {
232
+ const text = searchInputRef.current?.plainText ?? "";
233
+ const cleaned = text.replace(/[\r\n]/g, "");
234
+ if (cleaned !== text) {
235
+ searchInputRef.current?.setText(cleaned);
236
+ }
237
+ setSearchQuery(cleaned);
238
+ }}
239
+ onKeyDown={(key) => {
240
+ if (key.eventType === "press") {
241
+ if (key.name === "escape") {
242
+ setIsSearchFocused(false);
243
+ key.preventDefault();
244
+ }
245
+ if (key.name === "return") {
246
+ key.preventDefault();
247
+ }
248
+ }
249
+ }}
250
+ />
251
+ </box>
252
+ </box>
253
+
254
+ <box marginBottom={0}>
255
+ <text>
256
+ <span fg={COLORS.USER_LABEL}>— MEMORIES ({filteredMemories.length}) —</span>
257
+ </text>
258
+ </box>
259
+
260
+ {isLoading ? (
261
+ <box marginTop={1} paddingLeft={1}>
262
+ <text>
263
+ <span fg={COLORS.REASONING_DIM}>Loading memories...</span>
264
+ </text>
265
+ </box>
266
+ ) : error ? (
267
+ <box marginTop={1} paddingLeft={1}>
268
+ <text>
269
+ <span fg={COLORS.STATUS_FAILED}>{error}</span>
270
+ </text>
271
+ </box>
272
+ ) : filteredMemories.length === 0 ? (
273
+ <box marginTop={1} paddingLeft={1}>
274
+ <text>
275
+ <span fg={COLORS.REASONING_DIM}>
276
+ {searchQuery ? "No memories match your search" : "No memories stored yet"}
277
+ </span>
278
+ </text>
279
+ </box>
280
+ ) : (
281
+ <scrollbox
282
+ ref={scrollRef}
283
+ height={scrollboxHeight}
284
+ alignSelf="flex-start"
285
+ focused={false}
286
+ scrollY={true}
287
+ scrollX={false}
288
+ style={{
289
+ rootOptions: { backgroundColor: COLORS.MENU_BG },
290
+ wrapperOptions: { backgroundColor: COLORS.MENU_BG },
291
+ viewportOptions: { backgroundColor: COLORS.MENU_BG },
292
+ contentOptions: { backgroundColor: COLORS.MENU_BG },
293
+ }}
294
+ >
295
+ <box flexDirection="column" width="100%">
296
+ {filteredMemories.map((memory, idx) => {
297
+ const isSelected = idx === selectedIndex;
298
+ const labelColor = isSelected ? COLORS.DAEMON_LABEL : COLORS.MENU_TEXT;
299
+ const detailColor = COLORS.REASONING_DIM;
300
+ const scoreText = memory.score !== undefined ? ` (${(memory.score * 100).toFixed(0)}%)` : "";
301
+ const dateText = formatTimestamp(memory.createdAt || memory.updatedAt);
302
+
303
+ return (
304
+ <box
305
+ key={memory.id}
306
+ backgroundColor={isSelected ? COLORS.MENU_SELECTED_BG : COLORS.MENU_BG}
307
+ paddingLeft={1}
308
+ paddingRight={1}
309
+ flexDirection="column"
310
+ width="100%"
311
+ >
312
+ <box>
313
+ <text>
314
+ <span fg={labelColor}>
315
+ {isSelected ? "▶ " : " "}
316
+ {truncateText(memory.memory, 80)}
317
+ </span>
318
+ {scoreText && <span fg={COLORS.STATUS_COMPLETED}>{scoreText}</span>}
319
+ </text>
320
+ </box>
321
+ <box marginLeft={4}>
322
+ <text>
323
+ <span fg={detailColor}>
324
+ {dateText}
325
+ {memory.metadata?.category ? ` · ${memory.metadata.category}` : ""}
326
+ </span>
327
+ </text>
328
+ </box>
329
+ </box>
330
+ );
331
+ })}
332
+ </box>
333
+ </scrollbox>
334
+ )}
335
+ </box>
336
+ </box>
337
+ );
338
+ }
@@ -43,6 +43,8 @@ export interface UseAppContextBuilderParams {
43
43
  setShowUrlMenu: React.Dispatch<React.SetStateAction<boolean>>;
44
44
  showToolsMenu: boolean;
45
45
  setShowToolsMenu: React.Dispatch<React.SetStateAction<boolean>>;
46
+ showMemoryMenu: boolean;
47
+ setShowMemoryMenu: React.Dispatch<React.SetStateAction<boolean>>;
46
48
  };
47
49
 
48
50
  device: {
@@ -91,6 +91,8 @@ export function useAppController({
91
91
  setShowUrlMenu,
92
92
  showToolsMenu,
93
93
  setShowToolsMenu,
94
+ showMemoryMenu,
95
+ setShowMemoryMenu,
94
96
  } = menus;
95
97
 
96
98
  const session = useSessionController({ showSessionMenu });
@@ -286,6 +288,7 @@ export function useAppController({
286
288
  setShowGroundingMenu,
287
289
  setShowUrlMenu,
288
290
  setShowToolsMenu,
291
+ setShowMemoryMenu,
289
292
  setTypingInput: daemon.typing.setTypingInput,
290
293
  setCurrentTranscription: daemon.setCurrentTranscription,
291
294
  setCurrentResponse: daemon.setCurrentResponse,
@@ -310,6 +313,7 @@ export function useAppController({
310
313
  setShowGroundingMenu,
311
314
  setShowUrlMenu,
312
315
  setShowToolsMenu,
316
+ setShowMemoryMenu,
313
317
  daemon.typing.setTypingInput,
314
318
  daemon.setCurrentTranscription,
315
319
  daemon.setCurrentResponse,
@@ -335,6 +339,7 @@ export function useAppController({
335
339
  showGroundingMenu,
336
340
  showUrlMenu,
337
341
  showToolsMenu,
342
+ showMemoryMenu,
338
343
  onboardingActive: bootstrap.onboardingActive,
339
344
  },
340
345
  {
@@ -347,6 +352,7 @@ export function useAppController({
347
352
  setShowGroundingMenu,
348
353
  setShowUrlMenu,
349
354
  setShowToolsMenu,
355
+ setShowMemoryMenu,
350
356
  }
351
357
  );
352
358
 
@@ -437,6 +443,8 @@ export function useAppController({
437
443
  setShowUrlMenu,
438
444
  showToolsMenu,
439
445
  setShowToolsMenu,
446
+ showMemoryMenu,
447
+ setShowMemoryMenu,
440
448
  },
441
449
  device: {
442
450
  devices: bootstrap.devices,
@@ -27,6 +27,9 @@ export interface UseAppMenusReturn {
27
27
 
28
28
  showToolsMenu: boolean;
29
29
  setShowToolsMenu: React.Dispatch<React.SetStateAction<boolean>>;
30
+
31
+ showMemoryMenu: boolean;
32
+ setShowMemoryMenu: React.Dispatch<React.SetStateAction<boolean>>;
30
33
  }
31
34
 
32
35
  export function useAppMenus(): UseAppMenusReturn {
@@ -39,6 +42,7 @@ export function useAppMenus(): UseAppMenusReturn {
39
42
  const [showGroundingMenu, setShowGroundingMenu] = useState(false);
40
43
  const [showUrlMenu, setShowUrlMenu] = useState(false);
41
44
  const [showToolsMenu, setShowToolsMenu] = useState(false);
45
+ const [showMemoryMenu, setShowMemoryMenu] = useState(false);
42
46
 
43
47
  return {
44
48
  showDeviceMenu,
@@ -59,5 +63,7 @@ export function useAppMenus(): UseAppMenusReturn {
59
63
  setShowUrlMenu,
60
64
  showToolsMenu,
61
65
  setShowToolsMenu,
66
+ showMemoryMenu,
67
+ setShowMemoryMenu,
62
68
  };
63
69
  }
@@ -25,6 +25,7 @@ export interface KeyboardHandlerActions {
25
25
  setShowGroundingMenu: (show: boolean) => void;
26
26
  setShowUrlMenu: (show: boolean) => void;
27
27
  setShowToolsMenu: (show: boolean) => void;
28
+ setShowMemoryMenu: (show: boolean) => void;
28
29
  setTypingInput: (input: string | ((prev: string) => string)) => void;
29
30
  setCurrentTranscription: (text: string) => void;
30
31
  setCurrentResponse: (text: string) => void;
@@ -54,6 +55,7 @@ export function useDaemonKeyboard(state: KeyboardHandlerState, actions: Keyboard
54
55
  actions.setShowGroundingMenu(false);
55
56
  actions.setShowUrlMenu(false);
56
57
  actions.setShowToolsMenu(false);
58
+ actions.setShowMemoryMenu(false);
57
59
  }, [actions]);
58
60
 
59
61
  const handleKeyPress = useCallback(
@@ -232,6 +234,20 @@ export function useDaemonKeyboard(state: KeyboardHandlerState, actions: Keyboard
232
234
  return;
233
235
  }
234
236
 
237
+ // 'B' key to open memory menu (in IDLE, SPEAKING, or RESPONDING state)
238
+ if (
239
+ (key.sequence === "b" || key.sequence === "B") &&
240
+ key.eventType === "press" &&
241
+ (currentState === DaemonState.IDLE ||
242
+ currentState === DaemonState.SPEAKING ||
243
+ currentState === DaemonState.RESPONDING)
244
+ ) {
245
+ closeAllMenus();
246
+ actions.setShowMemoryMenu(true);
247
+ key.preventDefault();
248
+ return;
249
+ }
250
+
235
251
  // 'R' key to toggle full reasoning display (in IDLE, SPEAKING, or RESPONDING state)
236
252
  if (
237
253
  (key.sequence === "r" || key.sequence === "R") &&
@@ -293,7 +309,7 @@ export function useDaemonKeyboard(state: KeyboardHandlerState, actions: Keyboard
293
309
  !key.meta &&
294
310
  currentState !== DaemonState.TYPING
295
311
  ) {
296
- if (currentState === DaemonState.IDLE) {
312
+ if (currentState === DaemonState.IDLE || currentState === DaemonState.SPEAKING) {
297
313
  // Check for OpenRouter API key first (needed for any AI response)
298
314
  if (!process.env.OPENROUTER_API_KEY) {
299
315
  actions.setApiKeyMissingError(
@@ -10,6 +10,7 @@ export interface OverlayControllerState {
10
10
  showGroundingMenu: boolean;
11
11
  showUrlMenu: boolean;
12
12
  showToolsMenu: boolean;
13
+ showMemoryMenu: boolean;
13
14
  onboardingActive: boolean;
14
15
  }
15
16
 
@@ -23,6 +24,7 @@ export interface OverlayControllerActions {
23
24
  setShowGroundingMenu: (show: boolean) => void;
24
25
  setShowUrlMenu: (show: boolean) => void;
25
26
  setShowToolsMenu: (show: boolean) => void;
27
+ setShowMemoryMenu: (show: boolean) => void;
26
28
  }
27
29
 
28
30
  export function useOverlayController(state: OverlayControllerState, actions: OverlayControllerActions) {
@@ -36,6 +38,7 @@ export function useOverlayController(state: OverlayControllerState, actions: Ove
36
38
  showGroundingMenu,
37
39
  showUrlMenu,
38
40
  showToolsMenu,
41
+ showMemoryMenu,
39
42
  onboardingActive,
40
43
  } = state;
41
44
 
@@ -50,6 +53,7 @@ export function useOverlayController(state: OverlayControllerState, actions: Ove
50
53
  showGroundingMenu ||
51
54
  showUrlMenu ||
52
55
  showToolsMenu ||
56
+ showMemoryMenu ||
53
57
  onboardingActive
54
58
  );
55
59
  }, [
@@ -62,6 +66,7 @@ export function useOverlayController(state: OverlayControllerState, actions: Ove
62
66
  showGroundingMenu,
63
67
  showUrlMenu,
64
68
  showToolsMenu,
69
+ showMemoryMenu,
65
70
  onboardingActive,
66
71
  ]);
67
72
 
@@ -75,6 +80,7 @@ export function useOverlayController(state: OverlayControllerState, actions: Ove
75
80
  actions.setShowGroundingMenu(false);
76
81
  actions.setShowUrlMenu(false);
77
82
  actions.setShowToolsMenu(false);
83
+ actions.setShowMemoryMenu(false);
78
84
  }, [actions]);
79
85
 
80
86
  return {
@@ -33,6 +33,8 @@ export interface MenuState {
33
33
  setShowUrlMenu: React.Dispatch<React.SetStateAction<boolean>>;
34
34
  showToolsMenu: boolean;
35
35
  setShowToolsMenu: React.Dispatch<React.SetStateAction<boolean>>;
36
+ showMemoryMenu: boolean;
37
+ setShowMemoryMenu: React.Dispatch<React.SetStateAction<boolean>>;
36
38
  }
37
39
 
38
40
  export interface DeviceState {
@@ -183,10 +183,18 @@ class DaemonStateManager {
183
183
  * Start listening for voice input (called when space is pressed)
184
184
  */
185
185
  startListening(): void {
186
- if (this._state !== DaemonState.IDLE && this._state !== DaemonState.TYPING) {
186
+ if (
187
+ this._state !== DaemonState.IDLE &&
188
+ this._state !== DaemonState.TYPING &&
189
+ this._state !== DaemonState.SPEAKING
190
+ ) {
187
191
  return;
188
192
  }
189
193
 
194
+ if (this._state === DaemonState.SPEAKING) {
195
+ this.stopSpeaking();
196
+ }
197
+
190
198
  this._transcription = "";
191
199
  this._response = "";
192
200
  this.setState(DaemonState.LISTENING);
@@ -442,3 +442,39 @@ export interface UrlMenuItem {
442
442
  error?: string;
443
443
  lastSeenIndex: number;
444
444
  }
445
+
446
+ // ============================================================
447
+ // Memory Types
448
+ // ============================================================
449
+
450
+ /** A single memory entry returned from mem0 */
451
+ export interface MemoryEntry {
452
+ id: string;
453
+ memory: string;
454
+ hash?: string;
455
+ metadata?: Record<string, unknown>;
456
+ score?: number;
457
+ createdAt?: string;
458
+ updatedAt?: string;
459
+ }
460
+
461
+ /** Result from memory search operations */
462
+ export interface MemorySearchResult {
463
+ results: MemoryEntry[];
464
+ }
465
+
466
+ /** Result from memory add operations */
467
+ export interface MemoryAddResult {
468
+ results: Array<{
469
+ id: string;
470
+ memory: string;
471
+ event: "ADD" | "UPDATE" | "DELETE" | "NONE";
472
+ }>;
473
+ }
474
+
475
+ /** Memory context injected into first message */
476
+ export interface MemoryContext {
477
+ memories: MemoryEntry[];
478
+ retrievedAt: number;
479
+ query: string;
480
+ }
@@ -0,0 +1,65 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { getAppConfigDir } from "./preferences";
4
+
5
+ const CONFIG_FILE = "config.json";
6
+
7
+ export interface ManualConfig {
8
+ memoryModel?: string;
9
+ }
10
+
11
+ let cachedConfig: ManualConfig | null = null;
12
+ let configLoadedAt: number | null = null;
13
+
14
+ const CACHE_TTL_MS = 5000;
15
+
16
+ function getConfigPath(): string {
17
+ return path.join(getAppConfigDir(), CONFIG_FILE);
18
+ }
19
+
20
+ export function loadManualConfig(): ManualConfig {
21
+ const now = Date.now();
22
+
23
+ if (cachedConfig !== null && configLoadedAt !== null && now - configLoadedAt < CACHE_TTL_MS) {
24
+ return cachedConfig;
25
+ }
26
+
27
+ const configPath = getConfigPath();
28
+
29
+ if (!existsSync(configPath)) {
30
+ cachedConfig = {};
31
+ configLoadedAt = now;
32
+ return cachedConfig;
33
+ }
34
+
35
+ try {
36
+ const contents = readFileSync(configPath, "utf8");
37
+ const parsed = JSON.parse(contents) as unknown;
38
+
39
+ if (typeof parsed !== "object" || parsed === null) {
40
+ cachedConfig = {};
41
+ } else {
42
+ cachedConfig = parseManualConfig(parsed as Record<string, unknown>);
43
+ }
44
+ } catch {
45
+ cachedConfig = {};
46
+ }
47
+
48
+ configLoadedAt = now;
49
+ return cachedConfig;
50
+ }
51
+
52
+ function parseManualConfig(raw: Record<string, unknown>): ManualConfig {
53
+ const config: ManualConfig = {};
54
+
55
+ if (typeof raw.memoryModel === "string" && raw.memoryModel.trim().length > 0) {
56
+ config.memoryModel = raw.memoryModel.trim();
57
+ }
58
+
59
+ return config;
60
+ }
61
+
62
+ export function clearConfigCache(): void {
63
+ cachedConfig = null;
64
+ configLoadedAt = null;
65
+ }