Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,25 @@ memories do not consume slots that could hold eligible memories. Setting
`chatMessage.maxMemories` to `0` disables project-memory injection while still allowing
the user profile to be injected when enabled.

### Update-on-capture (optional)

Opt-in path that keeps memories current as facts change. When `updateOnCaptureEnabled` is
`true`, auto-capture embeds the new summary, searches for similar memories above
`updateOnCaptureSimilarityThreshold` (default `0.75`), and asks the capture model to
choose `add`, `update` (rewrite in place), or `supersede` (save new + soft-delete old into
trash). Pinned memories are skipped. Default is off so existing installs keep add-only
behavior.

```jsonc
"updateOnCaptureEnabled": true,
"updateOnCaptureSimilarityThreshold": 0.75,
"updateOnCaptureMaxCandidates": 5,
```

Tip: with `Xenova/nomic-embed-text-v1`, enabling `embeddingUseTaskPrefixes: true` (and
optionally lowering recall `similarityThreshold`) often improves match quality for longer
memories. Prefixes stay opt-in by default.

### Dreaming (optional memory consolidation)

Opt-in background pass that finds near-duplicate memories (same similarity threshold as
Expand All @@ -288,6 +307,9 @@ touched. Configure under `dreaming` in `opencode-mem.jsonc` (`enabled` default `
The web UI exposes **Trash** (restore) and **Dream** (manual run). Soft-deleted rows are
hard-purged after `dreaming.trashRetentionDays` during normal cleanup.

Deeper related-threshold dreaming (update/supersede/flag, cross-project) lands in the
follow-up PR for #464.

### Choosing / configuring embeddings

Embeddings power similarity search for memories and the user profile. Configure them in the same file (`~/.config/opencode/opencode-mem.jsonc`). There is **no MLX backend** — local embeddings use `@huggingface/transformers` with ONNX, not Apple MLX.
Expand Down
13 changes: 11 additions & 2 deletions opencode-mem.example.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -155,13 +155,16 @@
// "injectionMarkers": ["<my-plugin-banner>"],
},

// Opt-in background consolidation ("dreaming"): merge near-duplicates via LLM
// and soft-delete superseded memories into recoverable trash.
// Opt-in background consolidation ("dreaming"): merge/update/supersede related
// memories via LLM and soft-delete superseded rows into recoverable trash.
"dreaming": {
"enabled": false,
"intervalHours": 24,
"maxGroupsPerRun": 5,
"trashRetentionDays": 30,
"relatedThreshold": 0.75,
// Also effective when memory.defaultScope is "all-projects".
"crossProject": false,
},

// ============================================
Expand Down Expand Up @@ -205,6 +208,12 @@

"autoCaptureEnabled": true,

// Opt-in: before saving a new auto-capture memory, search for similar entries
// and let the model choose add / update / supersede (old rows go to trash).
// "updateOnCaptureEnabled": true,
// "updateOnCaptureSimilarityThreshold": 0.75,
// "updateOnCaptureMaxCandidates": 5,

// When both opencodeProvider and manual memoryModel/memoryApiUrl are set, which
// channel to try first for auto-capture summaries. Default "opencode-first".
// Prefer "external-first" if the host session API is slow/busy and you want the
Expand Down
72 changes: 69 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ interface OpenCodeMemConfig {
autoCaptureMaxRetries?: number;
autoCaptureMaxContextBytes?: number;
autoCaptureLanguage?: string;
/**
* When true, auto-capture searches for similar memories before insert and
* may update or supersede an existing entry instead of always adding.
* Default false (opt-in).
*/
updateOnCaptureEnabled?: boolean;
/** Cosine/hybrid floor for update-on-capture candidates. Default 0.75. */
updateOnCaptureSimilarityThreshold?: number;
/** Max similar memories shown to the decision LLM. Default 5. */
updateOnCaptureMaxCandidates?: number;
/**
* Which auto-capture summary channel to try first when both opencode and
* manual (memoryModel/memoryApiUrl) providers are configured.
Expand Down Expand Up @@ -239,6 +249,16 @@ interface OpenCodeMemConfig {
intervalHours?: number;
maxGroupsPerRun?: number;
trashRetentionDays?: number;
/**
* Similarity floor for related (not only near-duplicate) dreaming groups.
* Default 0.75. Independent of deduplicationSimilarityThreshold.
*/
relatedThreshold?: number;
/**
* Allow grouping across project container tags. Also effective when
* memory.defaultScope is "all-projects". User-scope stays separate.
*/
crossProject?: boolean;
};
}

Expand Down Expand Up @@ -326,6 +346,9 @@ const DEFAULTS: Required<
autoCaptureMaxRetries: 3,
autoCaptureMaxContextBytes: 131072,
autoCaptureProviderOrder: "opencode-first",
updateOnCaptureEnabled: false,
updateOnCaptureSimilarityThreshold: 0.75,
updateOnCaptureMaxCandidates: 5,
aiSessionRetentionDays: 7,
webServerEnabled: true,
webServerPort: 4747,
Expand Down Expand Up @@ -389,6 +412,8 @@ const DEFAULTS: Required<
intervalHours: 24,
maxGroupsPerRun: 5,
trashRetentionDays: 30,
relatedThreshold: 0.75,
crossProject: false,
},
};

Expand Down Expand Up @@ -600,6 +625,12 @@ const CONFIG_TEMPLATE = `{
// Note: Ollama may not support tool calling. Use OpenAI, Anthropic, or Groq for best results.

"autoCaptureEnabled": true,

// Opt-in: before saving a new auto-capture memory, search for similar entries
// and let the model choose add / update / supersede (old rows go to trash).
// "updateOnCaptureEnabled": true,
// "updateOnCaptureSimilarityThreshold": 0.75,
// "updateOnCaptureMaxCandidates": 5,

// Provider type: "api-route" | "atlas-cloud" | "cheaper-inference" | "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "orcarouter" | "runcrate"
// Note: "openai-chat" is a generic OpenAI API-compatible mode.
Expand Down Expand Up @@ -890,13 +921,18 @@ const CONFIG_TEMPLATE = `{
// "injectionMarkers": ["<my-plugin-banner>"]
},

// Opt-in background consolidation: merge near-duplicate memories via the
// configured LLM and soft-delete superseded rows into trash (recoverable).
// Opt-in background consolidation: merge/update/supersede related memories
// via the configured LLM and soft-delete superseded rows into trash.
"dreaming": {
"enabled": false,
"intervalHours": 24,
"maxGroupsPerRun": 5,
"trashRetentionDays": 30
"trashRetentionDays": 30,
// Related (not only near-duplicate) grouping floor; independent of
// deduplicationSimilarityThreshold (default 0.9).
"relatedThreshold": 0.75,
// Also effective when memory.defaultScope is "all-projects".
"crossProject": false
}
}
`;
Expand Down Expand Up @@ -1158,6 +1194,26 @@ export function normalizeDreamingTrashRetentionDays(value: unknown): number {
return value;
}

export function normalizeSimilarityUnitInterval(value: unknown, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
if (value < 0) return 0;
if (value > 1) return 1;
return value;
}

export function normalizeUpdateOnCaptureMaxCandidates(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return 5;
}
return Math.max(1, Math.min(20, Math.trunc(value)));
}

export function normalizeDreamingRelatedThreshold(value: unknown): number {
return normalizeSimilarityUnitInterval(value, 0.75);
}

/**
* User-supplied markers extend the built-in set rather than replacing it, so
* adding one marker cannot silently disable protection against all the others.
Expand Down Expand Up @@ -1288,6 +1344,14 @@ function buildConfig(fileConfig: OpenCodeMemConfig) {
autoCaptureProviderOrder: normalizeAutoCaptureProviderOrder(
fileConfig.autoCaptureProviderOrder
),
updateOnCaptureEnabled: fileConfig.updateOnCaptureEnabled ?? DEFAULTS.updateOnCaptureEnabled,
updateOnCaptureSimilarityThreshold: normalizeSimilarityUnitInterval(
fileConfig.updateOnCaptureSimilarityThreshold,
DEFAULTS.updateOnCaptureSimilarityThreshold
),
updateOnCaptureMaxCandidates: normalizeUpdateOnCaptureMaxCandidates(
fileConfig.updateOnCaptureMaxCandidates
),
memoryProvider,
memoryModel: fileConfig.memoryModel,
memoryApiUrl: fileConfig.memoryApiUrl,
Expand Down Expand Up @@ -1416,6 +1480,8 @@ function buildConfig(fileConfig: OpenCodeMemConfig) {
trashRetentionDays: normalizeDreamingTrashRetentionDays(
fileConfig.dreaming?.trashRetentionDays
),
relatedThreshold: normalizeDreamingRelatedThreshold(fileConfig.dreaming?.relatedThreshold),
crossProject: fileConfig.dreaming?.crossProject ?? DEFAULTS.dreaming.crossProject,
},
};
}
Expand Down
103 changes: 5 additions & 98 deletions src/services/api-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { tursoShardManager } from "./turso/shard-manager.js";
import { tursoVectorSearch } from "./turso/vector-search.js";
import { tursoConnectionManager } from "./turso/connection-manager.js";
import { ensureTursoReady } from "./turso/ready.js";
import { formatTagsForEmbedding, parseSessionIdFromMetadata } from "./turso/vector-utils.js";
import { formatTagsForEmbedding } from "./turso/vector-utils.js";
import { extractScopeFromContainerTag, tryExtractScopeFromContainerTag } from "./memory-scope.js";
import { log } from "./logger.js";
import { CONFIG } from "../config.js";
Expand Down Expand Up @@ -592,104 +592,11 @@ export async function handleUpdateMemory(
data: { content?: string; type?: MemoryType; tags?: string[] }
): Promise<ApiResponse<void>> {
try {
if (!id) return { success: false, error: "id is required" };
await embeddingService.warmup();

// Find the existing memory first (read-only — no data modified yet)
const shards = await getAllMemoryShards();
let foundShard = null,
existingMemory = null;
for (const shard of shards) {
const db = await tursoConnectionManager.getConnection(shard.dbPath);
const memory = await tursoVectorSearch.getMemoryById(db, id);
if (memory) {
foundShard = shard;
existingMemory = memory;
break;
}
const { memoryClient } = await import("./client.js");
const result = await memoryClient.updateMemory(id, data);
if (!result.success) {
return { success: false, error: result.error || "Update failed" };
}
if (!foundShard || !existingMemory) return { success: false, error: "Memory not found" };

// STEP 1: Generate new embeddings FIRST (safe — no data deleted yet)
const newContent = data.content || String(existingMemory.content);
const existingTags = existingMemory.tags ? String(existingMemory.tags) : "";
const tags =
data.tags || (existingTags ? existingTags.split(",").map((t: string) => t.trim()) : []);
const vector = await embeddingService.embedWithTimeout(newContent, { task: "document" });
let tagsVector: Float32Array | undefined = undefined;
if (tags.length > 0) {
tagsVector = await embeddingService.embedWithTimeout(formatTagsForEmbedding(tags), {
task: "document",
});
}

const db = await tursoConnectionManager.getConnection(foundShard.dbPath);
const updatedAt = Date.now();
const memoryType = data.type || (existingMemory.type ? String(existingMemory.type) : undefined);
const containerTag = String(existingMemory.container_tag);
const existingMeta = safeJSONParse(existingMemory.metadata) || {};

if (isMarkdownBackend()) {
writeMemoryMarkdownFile({
id,
content: newContent,
type: memoryType,
tags,
containerTag,
createdAt: Number(existingMemory.created_at),
updatedAt,
source: typeof existingMeta.source === "string" ? existingMeta.source : "api",
title: existingMemory.display_name ? String(existingMemory.display_name) : undefined,
});
existingMeta.markdownRelPath = pluginMemoryRelPath(id);
}

const metadataJson = JSON.stringify(existingMeta);

const isPinned = Number(existingMemory.is_pinned ?? 0) === 1 ? 1 : 0;
const deletedAt =
existingMemory.deleted_at == null || existingMemory.deleted_at === undefined
? null
: Number(existingMemory.deleted_at);

await db.transaction("write", async (tx) => {
await tx.execute({ sql: `DELETE FROM memories WHERE id = ?`, args: [id] });
await tx.execute({
sql: `
INSERT INTO memories (
id, content, vector, tags_vector, container_tag, tags, type, created_at, updated_at,
metadata, session_id, display_name, user_name, user_email, project_path, project_name, git_repo_url,
is_pinned, deleted_at
) VALUES (?, ?, vector32(?), ${tagsVector ? "vector32(?)" : "NULL"}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
args: [
id,
newContent,
JSON.stringify(Array.from(vector)),
...(tagsVector ? [JSON.stringify(Array.from(tagsVector))] : []),
containerTag,
tags.length > 0 ? tags.join(",") : null,
memoryType || null,
Number(existingMemory.created_at),
updatedAt,
metadataJson,
existingMemory.session_id
? String(existingMemory.session_id)
: parseSessionIdFromMetadata(
existingMemory.metadata ? String(existingMemory.metadata) : null
),
existingMemory.display_name ? String(existingMemory.display_name) : null,
existingMemory.user_name ? String(existingMemory.user_name) : null,
existingMemory.user_email ? String(existingMemory.user_email) : null,
existingMemory.project_path ? String(existingMemory.project_path) : null,
existingMemory.project_name ? String(existingMemory.project_name) : null,
existingMemory.git_repo_url ? String(existingMemory.git_repo_url) : null,
isPinned,
deletedAt,
],
});
});

return { success: true };
} catch (error) {
log("handleUpdateMemory: error", { error: String(error) });
Expand Down
Loading
Loading