Skip to content

Commit a1d832c

Browse files
committed
feat(plugin): proactive memory embedding
Embed memories immediately after ctx_memory write (fire-and-forget) and sweep for unembedded memories on the periodic dream timer. The lazy search-time fallback remains as a safety net.
1 parent a219ae1 commit a1d832c

File tree

4 files changed

+163
-14
lines changed

4 files changed

+163
-14
lines changed

packages/plugin/src/features/magic-context/memory/embedding.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Database } from "bun:sqlite";
12
import type { EmbeddingConfig } from "../../../config/schema/magic-context";
23
import { DEFAULT_LOCAL_EMBEDDING_MODEL } from "../../../config/schema/magic-context";
34
import { log } from "../../../shared/logger";
@@ -6,6 +7,7 @@ import { LocalEmbeddingProvider } from "./embedding-local";
67
import { OpenAICompatibleEmbeddingProvider } from "./embedding-openai";
78
import type { EmbeddingProvider } from "./embedding-provider";
89
import { computeNormalizedHash } from "./normalize-hash";
10+
import { saveEmbedding } from "./storage-memory-embeddings";
911

1012
const DEFAULT_EMBEDDING_CONFIG: EmbeddingConfig = {
1113
provider: "local",
@@ -15,6 +17,36 @@ const DEFAULT_EMBEDDING_CONFIG: EmbeddingConfig = {
1517
let embeddingConfig: EmbeddingConfig = DEFAULT_EMBEDDING_CONFIG;
1618
let provider: EmbeddingProvider | null = null;
1719

20+
type PreparedStatement = ReturnType<Database["prepare"]>;
21+
22+
interface UnembeddedMemoryRow {
23+
id: number;
24+
content: string;
25+
}
26+
27+
const loadUnembeddedMemoriesStatements = new WeakMap<Database, PreparedStatement>();
28+
29+
function isUnembeddedMemoryRow(row: unknown): row is UnembeddedMemoryRow {
30+
if (row === null || typeof row !== "object") {
31+
return false;
32+
}
33+
34+
const candidate = row as Record<string, unknown>;
35+
return typeof candidate.id === "number" && typeof candidate.content === "string";
36+
}
37+
38+
function getLoadUnembeddedMemoriesStatement(db: Database): PreparedStatement {
39+
let stmt = loadUnembeddedMemoriesStatements.get(db);
40+
if (!stmt) {
41+
stmt = db.prepare(
42+
"SELECT m.id AS id, m.content AS content FROM memories m LEFT JOIN memory_embeddings me ON m.id = me.memory_id WHERE m.project_path = ? AND m.status = 'active' AND me.memory_id IS NULL LIMIT ?",
43+
);
44+
loadUnembeddedMemoriesStatements.set(db, stmt);
45+
}
46+
47+
return stmt;
48+
}
49+
1850
function resolveEmbeddingConfig(config?: EmbeddingConfig): EmbeddingConfig {
1951
if (!config || config.provider === "local") {
2052
return {
@@ -140,6 +172,55 @@ export async function embedBatch(texts: string[]): Promise<(Float32Array | null)
140172
return currentProvider.embedBatch(texts);
141173
}
142174

175+
export async function embedUnembeddedMemories(
176+
db: Database,
177+
projectPath: string,
178+
config: EmbeddingConfig,
179+
batchSize = 10,
180+
): Promise<number> {
181+
const normalizedBatchSize = Math.max(1, Math.floor(batchSize));
182+
const resolvedConfig = resolveEmbeddingConfig(config);
183+
184+
if (resolvedConfig.provider === "off") {
185+
return 0;
186+
}
187+
188+
initializeEmbedding(resolvedConfig);
189+
190+
const memories = getLoadUnembeddedMemoriesStatement(db)
191+
.all(projectPath, normalizedBatchSize)
192+
.filter(isUnembeddedMemoryRow);
193+
if (memories.length === 0) {
194+
return 0;
195+
}
196+
197+
try {
198+
const embeddings = await embedBatch(memories.map((memory) => memory.content));
199+
const modelId = getEmbeddingModelId();
200+
if (modelId === "off") {
201+
return 0;
202+
}
203+
204+
let embeddedCount = 0;
205+
db.transaction(() => {
206+
for (const [index, memory] of memories.entries()) {
207+
const embedding = embeddings[index];
208+
if (!embedding) {
209+
continue;
210+
}
211+
212+
saveEmbedding(db, memory.id, embedding, modelId);
213+
embeddedCount += 1;
214+
}
215+
})();
216+
217+
return embeddedCount;
218+
} catch (error) {
219+
log("[magic-context] failed to proactively embed missing memories:", error);
220+
return 0;
221+
}
222+
}
223+
143224
export function getEmbeddingModelId(): string {
144225
return getOrCreateProvider()?.modelId ?? "off";
145226
}

packages/plugin/src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,13 @@ const plugin: Plugin = async (ctx) => {
3434

3535
// Start independent dream schedule timer at plugin level (not inside hooks)
3636
// so overnight dreaming works even when the user isn't chatting.
37-
if (pluginConfig.dreamer) {
37+
if (pluginConfig.enabled) {
3838
startDreamScheduleTimer({
39+
directory: ctx.directory,
3940
client: ctx.client,
4041
dreamerConfig: pluginConfig.dreamer,
42+
embeddingConfig: pluginConfig.embedding,
43+
memoryEnabled: pluginConfig.memory?.enabled === true,
4144
});
4245
}
4346

packages/plugin/src/plugin/dream-timer.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import type { DreamerConfig } from "../config/schema/magic-context";
1+
import type { DreamerConfig, EmbeddingConfig } from "../config/schema/magic-context";
22
import { checkScheduleAndEnqueue, processDreamQueue } from "../features/magic-context/dreamer";
3+
import { embedUnembeddedMemories } from "../features/magic-context/memory/embedding";
4+
import { resolveProjectIdentity } from "../features/magic-context/memory/project-identity";
35
import { openDatabase } from "../features/magic-context/storage";
46
import { log } from "../shared/logger";
57
import type { PluginContext } from "./types";
@@ -15,17 +17,42 @@ const DREAM_TIMER_INTERVAL_MS = 15 * 60 * 1000;
1517
* The timer is unref'd so it doesn't prevent the process from exiting.
1618
*/
1719
export function startDreamScheduleTimer(args: {
20+
directory: string;
1821
client: PluginContext["client"];
19-
dreamerConfig: DreamerConfig;
22+
dreamerConfig?: DreamerConfig;
23+
embeddingConfig: EmbeddingConfig;
24+
memoryEnabled: boolean;
2025
}): (() => void) | undefined {
21-
const { client, dreamerConfig } = args;
26+
const { client, directory, dreamerConfig, embeddingConfig, memoryEnabled } = args;
27+
const dreamingEnabled = Boolean(dreamerConfig?.enabled && dreamerConfig.schedule?.trim());
28+
const embeddingSweepEnabled = memoryEnabled && embeddingConfig.provider !== "off";
2229

23-
if (!dreamerConfig.enabled || !dreamerConfig.schedule?.trim()) {
30+
if (!dreamingEnabled && !embeddingSweepEnabled) {
2431
return;
2532
}
2633

34+
const projectPath = embeddingSweepEnabled ? resolveProjectIdentity(directory) : null;
35+
2736
const timer = setInterval(() => {
2837
try {
38+
if (embeddingSweepEnabled && projectPath) {
39+
void embedUnembeddedMemories(openDatabase(), projectPath, embeddingConfig)
40+
.then((embeddedCount) => {
41+
if (embeddedCount > 0) {
42+
log(
43+
`[magic-context] proactively embedded ${embeddedCount} ${embeddedCount === 1 ? "memory" : "memories"} for project ${projectPath}`,
44+
);
45+
}
46+
})
47+
.catch((error: unknown) => {
48+
log("[magic-context] periodic memory embedding sweep failed:", error);
49+
});
50+
}
51+
52+
if (!dreamingEnabled || !dreamerConfig?.schedule?.trim()) {
53+
return;
54+
}
55+
2956
const db = openDatabase();
3057
checkScheduleAndEnqueue(db, dreamerConfig.schedule);
3158

@@ -39,7 +66,7 @@ export function startDreamScheduleTimer(args: {
3966
log("[dreamer] timer-triggered queue processing failed:", error);
4067
});
4168
} catch (error) {
42-
log("[dreamer] timer-triggered schedule check failed:", error);
69+
log("[magic-context] timer-triggered maintenance check failed:", error);
4370
}
4471
}, DREAM_TIMER_INTERVAL_MS);
4572

packages/plugin/src/tools/ctx-memory/tools.ts

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
} from "../../features/magic-context/memory";
1818
import { embedText, getEmbeddingModelId } from "../../features/magic-context/memory/embedding";
1919
import { computeNormalizedHash } from "../../features/magic-context/memory/normalize-hash";
20-
import { log } from "../../shared/logger";
20+
import { sessionLog } from "../../shared/logger";
2121
import { CTX_MEMORY_DESCRIPTION, CTX_MEMORY_TOOL_NAME, DEFAULT_SEARCH_LIMIT } from "./constants";
2222
import {
2323
CTX_MEMORY_DREAMER_ACTIONS,
@@ -117,16 +117,39 @@ function filterByCategory(memories: Memory[], category?: string): Memory[] {
117117
return memories.filter((memory) => memory.category === category);
118118
}
119119

120-
function queueMemoryEmbedding(deps: CtxMemoryToolDeps, memoryId: number, content: string): void {
120+
function queueMemoryEmbedding(args: {
121+
deps: CtxMemoryToolDeps;
122+
sessionId: string;
123+
memoryId: number;
124+
content: string;
125+
}): void {
126+
if (!args.deps.embeddingEnabled) {
127+
return;
128+
}
129+
121130
void (async () => {
122-
const embedding = await embedText(content);
131+
const embedding = await embedText(args.content);
123132
if (!embedding) {
133+
sessionLog(
134+
args.sessionId,
135+
`memory embedding skipped for memory ${args.memoryId}: provider unavailable or embedding generation failed.`,
136+
);
137+
return;
138+
}
139+
140+
const modelId = getEmbeddingModelId();
141+
if (modelId === "off") {
142+
sessionLog(
143+
args.sessionId,
144+
`memory embedding skipped for memory ${args.memoryId}: embedding provider is off.`,
145+
);
124146
return;
125147
}
126148

127-
saveEmbedding(deps.db, memoryId, embedding, getEmbeddingModelId());
149+
saveEmbedding(args.deps.db, args.memoryId, embedding, modelId);
150+
sessionLog(args.sessionId, `proactively embedded memory ${args.memoryId}.`);
128151
})().catch((error: unknown) => {
129-
log("[ctx-memory] failed to save memory embedding:", error);
152+
sessionLog(args.sessionId, `memory embedding failed for memory ${args.memoryId}:`, error);
130153
});
131154
}
132155

@@ -231,7 +254,12 @@ function createCtxMemoryTool(deps: CtxMemoryToolDeps): ToolDefinition {
231254
toolContext.agent === DREAMER_AGENT ? "dreamer" : getSourceType(deps),
232255
});
233256

234-
queueMemoryEmbedding(deps, memory.id, content);
257+
queueMemoryEmbedding({
258+
deps,
259+
sessionId: toolContext.sessionID,
260+
memoryId: memory.id,
261+
content,
262+
});
235263

236264
return `Saved memory [ID: ${memory.id}] in ${category}.`;
237265
}
@@ -288,7 +316,12 @@ function createCtxMemoryTool(deps: CtxMemoryToolDeps): ToolDefinition {
288316
}
289317

290318
updateMemoryContent(deps.db, memory.id, content, normalizedHash);
291-
queueMemoryEmbedding(deps, memory.id, content);
319+
queueMemoryEmbedding({
320+
deps,
321+
sessionId: toolContext.sessionID,
322+
memoryId: memory.id,
323+
content,
324+
});
292325

293326
return `Updated memory [ID: ${memory.id}] in ${memory.category}.`;
294327
}
@@ -415,7 +448,12 @@ function createCtxMemoryTool(deps: CtxMemoryToolDeps): ToolDefinition {
415448
return nextCanonical;
416449
})();
417450

418-
queueMemoryEmbedding(deps, canonicalMemory.id, content);
451+
queueMemoryEmbedding({
452+
deps,
453+
sessionId: toolContext.sessionID,
454+
memoryId: canonicalMemory.id,
455+
content,
456+
});
419457

420458
const supersededIds = sourceMemories
421459
.map((memory) => memory.id)

0 commit comments

Comments
 (0)