Skip to content

Commit cf00e48

Browse files
committed
feat: improve memory availability handling
1 parent fbe4a5d commit cf00e48

7 files changed

Lines changed: 173 additions & 25 deletions

File tree

src/ai/daemon-ai.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,25 @@ export type { ModelMessage } from "ai";
2424

2525
const openai = createOpenAI({});
2626

27+
function isMemoryPipelineUsableForCurrentProvider(): boolean {
28+
if (!isMemoryAvailable()) {
29+
return false;
30+
}
31+
32+
return true;
33+
}
34+
35+
async function isMemoryWriteUsableForCurrentProvider(): Promise<boolean> {
36+
if (!isMemoryPipelineUsableForCurrentProvider()) {
37+
return false;
38+
}
39+
const memoryManager = getMemoryManager();
40+
await memoryManager.initialize();
41+
return memoryManager.isWriteEnabled;
42+
}
43+
2744
async function buildMemoryInjectionForPrompt(userMessage: string): Promise<string | undefined> {
28-
if (!getDaemonManager().memoryEnabled || !isMemoryAvailable()) {
45+
if (!getDaemonManager().memoryEnabled || !isMemoryPipelineUsableForCurrentProvider()) {
2946
return undefined;
3047
}
3148

@@ -136,10 +153,10 @@ async function persistConversationMemory(
136153

137154
if (!userTextForMemory || !assistantTextForMemory) return null;
138155
if (!getDaemonManager().memoryEnabled) return null;
139-
if (!isMemoryAvailable()) return null;
156+
if (!isMemoryPipelineUsableForCurrentProvider()) return null;
157+
if (!(await isMemoryWriteUsableForCurrentProvider())) return null;
140158

141159
const memoryManager = getMemoryManager();
142-
await memoryManager.initialize();
143160
if (!memoryManager.isAvailable) return null;
144161

145162
try {

src/ai/memory/memory-manager.ts

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ class MemoryManager {
5656
private memory: Memory | null = null;
5757
private initPromise: Promise<void> | null = null;
5858
private _isAvailable = false;
59+
private _writeEnabled = false;
5960

6061
private constructor() {}
6162

@@ -71,6 +72,11 @@ class MemoryManager {
7172
return this._isAvailable;
7273
}
7374

75+
/** Check whether memory write/extraction is available */
76+
get isWriteEnabled(): boolean {
77+
return this._writeEnabled;
78+
}
79+
7480
/** Initialize mem0 with configuration */
7581
async initialize(): Promise<boolean> {
7682
// Return cached result if already initialized
@@ -94,11 +100,8 @@ class MemoryManager {
94100
return;
95101
}
96102

97-
if (!openrouterKey) {
98-
debug.info("memory-init", "Memory system unavailable: OPENROUTER_API_KEY not set");
99-
this._isAvailable = false;
100-
return;
101-
}
103+
// Read/search works with embeddings only; writing/inference requires OpenRouter.
104+
const writeEnabled = Boolean(openrouterKey);
102105

103106
try {
104107
const configDir = getAppConfigDir();
@@ -188,28 +191,35 @@ Rules:
188191
},
189192
},
190193
disableHistory: true,
191-
llm: {
192-
provider: "openai",
193-
config: {
194-
apiKey: openrouterKey,
195-
model: llmModel,
196-
baseURL: "https://openrouter.ai/api/v1",
197-
},
198-
},
194+
...(openrouterKey
195+
? {
196+
llm: {
197+
provider: "openai",
198+
config: {
199+
apiKey: openrouterKey,
200+
model: llmModel,
201+
baseURL: "https://openrouter.ai/api/v1",
202+
},
203+
},
204+
}
205+
: {}),
199206
});
200207

201208
this._isAvailable = true;
209+
this._writeEnabled = writeEnabled;
202210
debug.info("memory-init", {
203211
message: `Memory system initialized`,
204212
vectorDbPath,
205213
llmModel,
214+
writeEnabled,
206215
});
207216
} catch (error) {
208217
debug.error("memory-init", {
209218
message: "Memory initialization failed",
210219
error: error instanceof Error ? error.message : String(error),
211220
});
212221
this._isAvailable = false;
222+
this._writeEnabled = false;
213223
}
214224
}
215225

@@ -265,6 +275,9 @@ Rules:
265275
if (!this.memory || !this._isAvailable) {
266276
throw new Error("Memory system not available");
267277
}
278+
if (!this._writeEnabled) {
279+
throw new Error("Memory write unavailable: OPENROUTER_API_KEY not set");
280+
}
268281

269282
const sanitizedMessages = messages.map((message) => {
270283
if (message.role !== "user") return message;
@@ -398,5 +411,5 @@ export function getMemoryManager(): MemoryManager {
398411

399412
/** Check if memory is available without full initialization */
400413
export function isMemoryAvailable(): boolean {
401-
return Boolean(process.env.OPENAI_API_KEY && process.env.OPENROUTER_API_KEY);
414+
return Boolean(process.env.OPENAI_API_KEY);
402415
}

src/ai/model-config.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,14 +140,17 @@ export function buildOpenRouterChatSettings(
140140
// Transcription model (OpenAI)
141141
export const TRANSCRIPTION_MODEL = "gpt-4o-mini-transcribe-2025-12-15";
142142

143-
// Default model for memory operations (cheap & fast)
144-
export const DEFAULT_MEMORY_MODEL = "x-ai/grok-4.1-fast";
143+
// Default model for memory operations.
144+
export const DEFAULT_MEMORY_MODEL_OPENROUTER = "x-ai/grok-4.1-fast";
145145

146146
/**
147147
* Get the model ID for memory operations (deduplication, extraction).
148-
* Checks config.json for override, otherwise uses DEFAULT_MEMORY_MODEL.
148+
* Checks config.json for override, otherwise returns DEFAULT_MEMORY_MODEL_OPENROUTER.
149149
*/
150150
export function getMemoryModel(): string {
151151
const config = loadManualConfig();
152-
return config.memoryModel ?? DEFAULT_MEMORY_MODEL;
152+
if (config.memoryModel) {
153+
return config.memoryModel;
154+
}
155+
return DEFAULT_MEMORY_MODEL_OPENROUTER;
153156
}

src/ai/providers/copilot-provider.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,48 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: s
8383
}
8484
}
8585

86+
async function withInactivityTimeout<T>(
87+
promise: Promise<T>,
88+
timeoutMs: number,
89+
getLastActivityAt: () => number | null,
90+
message: string
91+
): Promise<T> {
92+
if (timeoutMs <= 0) {
93+
return promise;
94+
}
95+
96+
const pollIntervalMs = Math.min(1000, Math.max(250, Math.floor(timeoutMs / 6)));
97+
98+
return await new Promise<T>((resolve, reject) => {
99+
let settled = false;
100+
const cleanup = () => {
101+
if (settled) return;
102+
settled = true;
103+
clearInterval(intervalId);
104+
};
105+
106+
const intervalId = setInterval(() => {
107+
const lastActivityAt = getLastActivityAt();
108+
if (lastActivityAt === null) return;
109+
if (Date.now() - lastActivityAt > timeoutMs) {
110+
cleanup();
111+
reject(new Error(message));
112+
}
113+
}, pollIntervalMs);
114+
115+
promise.then(
116+
(value) => {
117+
cleanup();
118+
resolve(value);
119+
},
120+
(error) => {
121+
cleanup();
122+
reject(error);
123+
}
124+
);
125+
});
126+
}
127+
86128
function modelMessageContentToText(message: ModelMessage): string {
87129
if (typeof message.content === "string") {
88130
return message.content;
@@ -477,7 +519,8 @@ async function streamCopilotSession(params: {
477519
DEFAULT_COPILOT_IDLE_TIMEOUT_MS
478520
);
479521
const sendTimeoutMessage = "Copilot request timed out while submitting prompt.";
480-
const idleTimeoutMessage = "Copilot request timed out while waiting for response completion.";
522+
const idleTimeoutMessage =
523+
"Copilot request timed out due to inactivity while waiting for response completion.";
481524

482525
sendStartedAt = Date.now();
483526
await withTimeout(
@@ -492,7 +535,7 @@ async function streamCopilotSession(params: {
492535
rememberEvent({ type: "session.send.complete" });
493536

494537
idleWaitStartedAt = Date.now();
495-
await withTimeout(idlePromise, idleTimeoutMs, idleTimeoutMessage);
538+
await withInactivityTimeout(idlePromise, idleTimeoutMs, () => lastEventAt, idleTimeoutMessage);
496539

497540
if (aborted) {
498541
return null;

src/components/MemoryMenu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export function MemoryMenu({ onClose }: MemoryMenuProps) {
3939

4040
const loadMemories = async () => {
4141
if (!isMemoryAvailable()) {
42-
setError("Memory system not available (requires OPENAI_API_KEY and OPENROUTER_API_KEY)");
42+
setError("Memory system not available (requires OPENAI_API_KEY)");
4343
setIsLoading(false);
4444
return;
4545
}

src/components/SettingsMenu.tsx

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { KeyEvent } from "@opentui/core";
22
import { useKeyboard } from "@opentui/react";
33
import { useEffect, useState } from "react";
4+
import { getMemoryManager, isMemoryAvailable } from "../ai/memory";
45
import type {
56
AppPreferences,
67
InteractionMode,
@@ -80,13 +81,77 @@ export function SettingsMenu({
8081
persistPreferences,
8182
}: SettingsMenuProps) {
8283
const [selectedIdx, setSelectedIdx] = useState(0);
84+
const [storedMemoryCount, setStoredMemoryCount] = useState<number | null>(null);
8385
const manager = getDaemonManager();
86+
const openAiKeyMissing = !process.env.OPENAI_API_KEY;
87+
const openRouterKeyMissing = !process.env.OPENROUTER_API_KEY;
88+
const hasStoredMemories = (storedMemoryCount ?? 0) > 0;
89+
const memoryCountKnown = storedMemoryCount !== null;
90+
const memoryToggleDisabled =
91+
openAiKeyMissing || (openRouterKeyMissing && (!memoryCountKnown || storedMemoryCount === 0));
92+
93+
useEffect(() => {
94+
if (!openAiKeyMissing || !memoryEnabled) {
95+
return;
96+
}
97+
98+
manager.memoryEnabled = false;
99+
setMemoryEnabled(false);
100+
persistPreferences({ memoryEnabled: false });
101+
}, [manager, memoryEnabled, openAiKeyMissing, persistPreferences, setMemoryEnabled]);
102+
103+
useEffect(() => {
104+
let cancelled = false;
105+
106+
const loadStoredMemoryCount = async () => {
107+
if (!isMemoryAvailable()) {
108+
if (!cancelled) {
109+
setStoredMemoryCount(0);
110+
}
111+
return;
112+
}
113+
114+
try {
115+
const memoryManager = getMemoryManager();
116+
await memoryManager.initialize();
117+
if (!memoryManager.isAvailable) {
118+
if (!cancelled) {
119+
setStoredMemoryCount(0);
120+
}
121+
return;
122+
}
123+
const storedMemories = await memoryManager.getAll();
124+
if (!cancelled) {
125+
setStoredMemoryCount(storedMemories.length);
126+
}
127+
} catch {
128+
if (!cancelled) {
129+
setStoredMemoryCount(0);
130+
}
131+
}
132+
};
133+
134+
void loadStoredMemoryCount();
135+
return () => {
136+
cancelled = true;
137+
};
138+
}, []);
139+
84140
const interactionModeLocked = !canEnableVoiceOutput && interactionMode === "text";
85141
const interactionModeDescription = interactionModeLocked
86142
? "[LOCKED] OpenAI key required for voice output"
87143
: interactionMode === "voice"
88144
? "Conversational responses and speech output"
89145
: "Markdown responses for terminal";
146+
const memoryDescription = openAiKeyMissing
147+
? "[LOCKED] OPENAI_API_KEY is required for memory"
148+
: openRouterKeyMissing && !memoryCountKnown
149+
? "[LOCKED] Checking stored memories... OPENROUTER_API_KEY missing: no new memories added"
150+
: memoryToggleDisabled
151+
? "[LOCKED] No stored memories and OPENROUTER_API_KEY is missing, so no new memories can be added"
152+
: openRouterKeyMissing && hasStoredMemories
153+
? "Inject stored memories only (OPENROUTER_API_KEY missing: no new memories added)"
154+
: "Auto-save messages + inject relevant memories";
90155

91156
const items: SettingsMenuItem[] = [
92157
{
@@ -146,8 +211,9 @@ export function SettingsMenu({
146211
id: "memory-enabled",
147212
label: "Memory",
148213
value: memoryEnabled ? "ON" : "OFF",
149-
description: "Auto-save messages + inject relevant memories",
214+
description: memoryDescription,
150215
isToggle: true,
216+
disabled: memoryToggleDisabled,
151217
},
152218
];
153219

@@ -221,6 +287,7 @@ export function SettingsMenu({
221287
showFullReasoning,
222288
showToolOutput,
223289
memoryEnabled,
290+
memoryToggleDisabled,
224291
setSelectedIdx,
225292
toggleInteractionMode,
226293
cycleModelProvider,

src/hooks/keyboard-handlers.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,7 @@ interface SettingsMenuContext {
329329
showFullReasoning: boolean;
330330
showToolOutput: boolean;
331331
memoryEnabled: boolean;
332+
memoryToggleDisabled: boolean;
332333
setSelectedIdx: (fn: (prev: number) => number) => void;
333334
toggleInteractionMode: () => void;
334335
cycleModelProvider: () => void;
@@ -437,6 +438,10 @@ export function handleSettingsMenuKey(key: KeyEvent, ctx: SettingsMenuContext):
437438
settingIdx++;
438439

439440
if (ctx.selectedIdx === settingIdx) {
441+
if (ctx.memoryToggleDisabled) {
442+
key.preventDefault();
443+
return true;
444+
}
440445
const next = !ctx.manager.memoryEnabled;
441446
ctx.manager.memoryEnabled = next;
442447
ctx.setMemoryEnabled(next);

0 commit comments

Comments
 (0)