Skip to content
17 changes: 17 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- None yet.

### Fixed

- **Plugin lifecycle hardened per code-review findings**
- **Embedding warmup wait is bounded**
- \*\*`updateVector` no longer wipes
- **The `memories_fts` update trigger is column-scoped**
- **Vector-index writes after a sqlite COMMIT no longer misreport failure**
- **Re-embed migrations now update the live vector index**
- **Keyword search no longer returns silent empty results for punctuated queries** — `don't`, `foo.js`, `email@example.com`, and reserved words like `AND` previously produced invalid FTS5 MATCH syntax that degraded to an empty result set. Tokens are now quoted as phrases (both the memory and transcript search paths) and unbracketed IPv6 Host headers parse correctly (Devin/Copilot findings on #64).
- **New `webServerAllowedHosts` setting** — binding the dashboard remotely (`webServerHost: "0.0.0.0"`) or behind a reverse proxy previously rejected every remote client's Host header (Codex P1 on #64); the documented workaround also changed the listen interface. The bind address and the accepted Host names are now configured separately, keeping the DNS-rebinding protection for loopback defaults.
— the migration wrote new vectors to sqlite but never touched an initialized in-memory index, so searches served stale old-model/old-dimension results until process restart (Copilot/Codex/Devin finding on #63). Migrated memories also get their tag embeddings re-generated from the stored tags text; after a successful shard both index kinds are force-rebuilt from sqlite on the next search.
— a failing backend insert used to reject `insertVector`/`updateVector`/`replaceVector` even though the memory was durably persisted (callers retried into duplicates), and the rebuild-dirty flag never actually repaired an initialized USearch index because `rebuildFromShard` skipped initialized indexes. Dirty shards now force a full index rebuild from sqlite on the next search (Devin finding on #63).
— routine `access_count`/score updates on every search result and decay cycle no longer re-tokenize content, cutting FTS write amplification (Copilot/Codex finding on #63).
`tags_vector` when only the content embedding is refreshed\*\* — semantic tag search survives content-only re-embeds (Devin finding on #63).
— a slow or hung local-model load no longer stalls the first chat prompt indefinitely; the search path degrades to text-only for that prompt while the model load continues in the background, instead of failing or permanently disabling embeddings.
— provider-state waits are bounded (a hung host bootstrap can no longer wedge auto-capture or profile learning forever), idle events arriving after `dispose` start no new work, a web server that finishes starting after `dispose` is stopped instead of running orphaned, and the initial score recalculation runs on a macrotask so the host receives the plugin before the shard scan.

## [2.23.2] - 2026-09-08

### Changed
Expand Down
17 changes: 9 additions & 8 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,14 +139,15 @@ Auto-capture observes chat exchanges and automatically extracts memorable inform

## Web UI Settings

| Setting | Type | Default | Description |
| ------------------ | --------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `webServerEnabled` | `boolean` | `true` | Enable the management web UI. |
| `webServerPort` | `number` | `4747` | Port for the web UI server. |
| `webServerHost` | `string` | `"127.0.0.1"` | Host binding for the web server. Defaults to loopback for security. **`webServerApiKey` is required if binding to a non-loopback address.** |
| `webServerApiKey` | `string` | — | API key for authenticating web UI requests. Required when `webServerHost` is not a loopback address (`127.0.0.1`, `localhost`, `::1`). Value is used as-is (no secret resolution). |

Requests are accepted only when the `Host` header is loopback (`127.0.0.1`, `localhost`, `[::1]`) or the configured `webServerHost` (hostname compared case-insensitively, port ignored). If you reverse-proxy the dashboard, set `webServerHost` to the proxy hostname.
| Setting | Type | Default | Description |
| ----------------------- | ---------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `webServerEnabled` | `boolean` | `true` | Enable the management web UI. |
| `webServerPort` | `number` | `4747` | Port for the web UI server. |
| `webServerHost` | `string` | `"127.0.0.1"` | Host binding for the web server. Defaults to loopback for security. **`webServerApiKey` is required if binding to a non-loopback address.** |
| `webServerApiKey` | `string` | — | API key for authenticating web UI requests. Required when `webServerHost` is not a loopback address (`127.0.0.1`, `localhost`, `::1`). Value is used as-is (no secret resolution). |
| `webServerAllowedHosts` | `string[]` | `[]` | Extra hostnames accepted in the `Host` header. Add the public hostname (or LAN IP) clients use to reach the dashboard when binding to `0.0.0.0`/an interface address or when running behind a reverse proxy. |

Requests are accepted only when the `Host` header is loopback (`127.0.0.1`, `localhost`, `[::1]`), the configured `webServerHost`, or one of `webServerAllowedHosts`. **Binding remotely or behind a reverse proxy:** keep `webServerHost` as the address to _bind_ (e.g. `127.0.0.1` behind a local proxy, `0.0.0.0` for LAN access) and list the hostname clients send in `Host` under `webServerAllowedHosts`.

## Vector Search Settings

Expand Down
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const OpenCodeMemConfigSchema = z.object({
webServerEnabled: z.boolean().optional(),
webServerPort: z.number().positive().max(65535).optional(),
webServerHost: z.string().optional(),
webServerAllowedHosts: z.array(z.string()).optional(),
webServerApiKey: z.string().optional(),
maxVectorsPerShard: z.number().positive().optional(),
autoCleanupEnabled: z.boolean().optional(),
Expand Down Expand Up @@ -165,6 +166,7 @@ const DEFAULTS: Partial<OpenCodeMemConfig> = {
webServerEnabled: true,
webServerPort: 4747,
webServerHost: "127.0.0.1",
webServerAllowedHosts: [],
maxVectorsPerShard: 50000,
autoCleanupEnabled: true,
autoCleanupRetentionDays: 30,
Expand Down Expand Up @@ -351,6 +353,7 @@ function mergeConfigWithDefaults(fileConfig: OpenCodeMemConfig) {
webServerEnabled: cfg.webServerEnabled ?? defaults.webServerEnabled,
webServerPort: cfg.webServerPort ?? defaults.webServerPort,
webServerHost: cfg.webServerHost ?? defaults.webServerHost,
webServerAllowedHosts: cfg.webServerAllowedHosts ?? defaults.webServerAllowedHosts,
webServerApiKey: cfg.webServerApiKey,
maxVectorsPerShard: cfg.maxVectorsPerShard ?? defaults.maxVectorsPerShard,
autoCleanupEnabled: cfg.autoCleanupEnabled ?? defaults.autoCleanupEnabled,
Expand Down
25 changes: 22 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ import type { UserProfileData } from "./services/user-profile/types.js";
import type { SearchResult } from "./services/sqlite/types.js";
import { getLanguageName } from "./services/language-detector.js";
import type { MemoryScope } from "./services/client.js";
import { setProviderStateInit } from "./services/ai/opencode-provider.js";
import {
isPluginDisposed,
markPluginDisposed,
setProviderStateInit,
} from "./services/ai/opencode-provider.js";

async function showToast(
ctx: PluginInput,
Expand Down Expand Up @@ -125,6 +129,8 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
const tags = getTags(directory);
let webServer: WebServer | null = null;
const sessionIdleTimers = new Map<string, NodeJS.Timeout>();
// Reset for repeated factory invocations (tests, host reloads).
markPluginDisposed(false);

const GLOBAL_PLUGIN_WARMUP_KEY = Symbol.for("opencode-mem0.plugin.warmedup");

Expand Down Expand Up @@ -215,8 +221,14 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
host: CONFIG.webServerHost,
enabled: CONFIG.webServerEnabled,
apiKey: CONFIG.webServerApiKey,
allowedHosts: CONFIG.webServerAllowedHosts,
})
.then(async (server) => {
// Disposed before listening completed — do not resurrect state.
if (isPluginDisposed()) {
server.stop();
return;
}
webServer = server;
const url = webServer.getUrl();

Expand Down Expand Up @@ -256,16 +268,19 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
});
}

let initialScoringTimer: ReturnType<typeof setTimeout> | undefined;
// Start background memory scoring recalculation
if (isConfigured() && CONFIG.memoryScoring.enabled) {
startScoringRecalculation();
void Promise.resolve().then(() => {
// setTimeout (macrotask), not a microtask: the host's await of this factory
// resumes before the scan runs, so plugin loading is not blocked by it.
initialScoringTimer = setTimeout(() => {
try {
recalculateAllScores(true);
} catch (error) {
log("Initial scoring recalculation failed", { error: String(error) });
}
});
}, 0);
}

// Start memory lifecycle job (STM/LTM decay, promotion, archiving)
Expand All @@ -283,13 +298,15 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {

const shutdownHandler = async () => {
delete (globalThis as any)[Symbol.for("opencode-mem0.shutdown")];
markPluginDisposed(true);
try {
for (const timer of sessionIdleTimers.values()) {
clearTimeout(timer);
}
sessionIdleTimers.clear();
stopScoringRecalculation();
stopLifecycleJob();
if (initialScoringTimer) clearTimeout(initialScoringTimer);
clearInterval(sessionCleanupTimer);
if (webServer) {
webServer.stop();
Expand Down Expand Up @@ -646,6 +663,8 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
event: async (input: { event: { type: string; properties?: Record<string, unknown> } }) => {
const event = input.event;

// Entry checkpoint: once disposed, no new idle/compaction work starts.
if (isPluginDisposed()) return;
if (event.type === "session.idle") {
await handleSessionIdle(event, ctx, directory, sessionIdleTimers, webServer);
}
Expand Down
32 changes: 31 additions & 1 deletion src/services/ai/opencode-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,42 @@ let _connectedProviders: string[] = [];

let providerStateInit: Promise<void> = Promise.resolve();

// Bounded: a hung host bootstrap must not wedge auto-capture/profile
// processing forever — after this timeout we proceed without provider state.
// ponytail: fixed timeout; make configurable if slow hosts become real.
const PROVIDER_STATE_TIMEOUT_MS = 10_000;

let disposed = false;

export function setProviderStateInit(promise: Promise<void>): void {
providerStateInit = promise;
}

export function markPluginDisposed(value: boolean): void {
disposed = value;
}

export function isPluginDisposed(): boolean {
return disposed;
}

export async function ensureProviderState(): Promise<void> {
await providerStateInit;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
providerStateInit,
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error("provider state init timeout")),
PROVIDER_STATE_TIMEOUT_MS
);
}),
]);
} catch {
log("opencode provider state not ready in time — proceeding without it");
} finally {
if (timer) clearTimeout(timer);
}
}

export function setStatePath(path: string): void {
Expand Down
3 changes: 2 additions & 1 deletion src/services/auto-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,9 @@ export async function performAutoCapture(
isCapturing = true;
let claimedPromptId: string | null = null;
try {
const { ensureProviderState } = await import("./ai/opencode-provider.js");
const { ensureProviderState, isPluginDisposed } = await import("./ai/opencode-provider.js");
await ensureProviderState();
if (isPluginDisposed()) return;
const prompt = userPromptManager.getLastUncapturedPrompt(sessionID);
if (!prompt) return;
if (!userPromptManager.claimPrompt(prompt.id)) return;
Expand Down
6 changes: 5 additions & 1 deletion src/services/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,11 @@ export class LocalMemoryClient {
try {
queryVector = await embeddingService.embedWithTimeout(query);
} catch (error) {
if (!embeddingService.embeddingAvailable) {
// Warmup-wait timeouts surface as AbortError while the service is
// still healthy — degrade to text-only search instead of failing the
// prompt (the model load keeps running for the next attempt).
const warmupPending = error instanceof Error && error.name === "AbortError";
if (!embeddingService.embeddingAvailable || warmupPending) {
log("Embedding unavailable — falling back to text-only search", {
queryLength: query.length,
queryHash: query.slice(0, 20),
Expand Down
32 changes: 31 additions & 1 deletion src/services/embedding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export class EmbeddingService {

try {
if (!this.isWarmedUp) {
await this.warmup();
await this.waitWarmupWithinBudget(signal);
}

if (CONFIG.embeddingApiUrl && CONFIG.embeddingApiKey) {
Expand Down Expand Up @@ -172,6 +172,36 @@ export class EmbeddingService {
clearTimeout(timeoutId);
}
}
// Bounded wait on model initialization: a slow or hung load must not
// stall callers indefinitely. Rejects AbortError-shaped so embed()'s
// catch rethrows without permanently disabling the service — the init
// promise keeps running, and the next call re-races it.
private async waitWarmupWithinBudget(signal?: AbortSignal): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
try {
await Promise.race([
this.warmup(),
new Promise<never>((_, reject) => {
const giveUp = () => {
const err = new Error("embedding warmup wait timed out");
err.name = "AbortError";
reject(err);
};
onAbort = () => {
const err = new Error("embedding warmup wait aborted");
err.name = "AbortError";
reject(err);
};
signal?.addEventListener("abort", onAbort, { once: true });
timer = setTimeout(giveUp, CONFIG.warmupTimeoutMs ?? 30_000);
}),
]);
} finally {
if (timer) clearTimeout(timer);
if (onAbort && signal) signal.removeEventListener("abort", onAbort);
}
}

clearCache(): void {
this.cache.clear();
Expand Down
33 changes: 30 additions & 3 deletions src/services/migration-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { embeddingService } from "./embedding.js";
import { CONFIG } from "../config.js";
import { log } from "./logger.js";
import type { ShardInfo } from "./sqlite/types.js";

export interface DimensionMismatch {
needsMigration: boolean;
Expand Down Expand Up @@ -227,11 +228,23 @@
processedCount: number,
totalMemories: number,
shardId: string,
db: ReturnType<typeof connectionManager.getConnection>
db: ReturnType<typeof connectionManager.getConnection>,
shard?: ShardInfo
): Promise<{ success: boolean; processedCount: number }> {
try {
const vector = await embeddingService.embedWithTimeout(memory.content);
await vectorSearch.updateVector(db, memory.id, vector);
// Re-embed the tags text too: after a dimension change the stored
// tags_vector has old dimensions and would poison a rebuilt index —
// reproducing the exact capture-time embedding text keeps tag search
// consistent (Devin findings on #63).
let tagsVector: Float32Array | undefined;
const tagsText = typeof memory.tags === "string" ? memory.tags.trim() : "";
if (tagsText) {
tagsVector = await embeddingService.embedWithTimeout(
`Topics: ${tagsText.split(",").join(", ")}`

Check warning on line 244 in src/services/migration-service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `String#replaceAll()` over `String#split().join()`.

See more on https://sonarcloud.io/project/issues?id=ZeR020_opencode-mem0&issues=AaCBuF229T9KWIVLyx1y&open=AaCBuF229T9KWIVLyx1y&pullRequest=66
);
}
await vectorSearch.updateVector(db, memory.id, vector, shard, tagsVector);
const nextCount = processedCount + 1;

this.reportProgress({
Expand All @@ -248,7 +261,7 @@
}
}

private async reEmbedMigration(

Check failure on line 264 in src/services/migration-service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ZeR020_opencode-mem0&issues=AaCBuF229T9KWIVLyx1z&open=AaCBuF229T9KWIVLyx1z&pullRequest=66
mismatch: DimensionMismatch,
startTime: number
): Promise<MigrationResult> {
Expand All @@ -263,6 +276,14 @@
total: totalMemories,
});

// The mismatch list carries dbPaths — resolve full ShardInfo so the
// re-embeds can update the live backend index (R1 finding on #63).
const shardByDbPath = new Map<string, ShardInfo>(
[...shardManager.getAllShards("user", ""), ...shardManager.getAllShards("project", "")].map(
(s) => [s.dbPath, s]
)
);

let reEmbeddedCount = 0;
let processedCount = 0;
let shardHadFailures = false;
Expand All @@ -283,13 +304,15 @@
const tempMemories = this._backupMemories(memories);
let thisShardFailed = false;

const shard = shardByDbPath.get(shardInfo.dbPath);
for (const memory of tempMemories) {
const result = await this._reEmbedSingleMemory(
memory,
processedCount,
totalMemories,
String(shardInfo.shardId),
db
db,
shard
);
processedCount = result.processedCount;
if (result.success) {
Expand All @@ -309,6 +332,10 @@
"embedding_model",
CONFIG.embeddingModel,
]);
// The dims may have changed: any initialized in-memory index
// holds old-dimension vectors. Force a rebuild from sqlite on
// next search — the live index must not go stale until restart.
if (shard) vectorSearch.markShardDirty(shard);
} else {
log("Migration: keeping original shard due to re-embedding failures", {
shardId: shardInfo.shardId,
Expand Down
9 changes: 7 additions & 2 deletions src/services/sqlite/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,14 @@ export function ensureMemoriesFts(db: Database): void {
END
`);

// Scoped to the indexed columns: an unscoped AFTER UPDATE fired on every
// UPDATE — including access_count/score touches on every search and decay
// cycle — re-tokenizing content on each (Copilot/Codex finding on #63).
// DROP+CREATE replaces the unscoped trigger from older databases.
db.run("DROP TRIGGER IF EXISTS memories_fts_update");
db.run(`
CREATE TRIGGER IF NOT EXISTS memories_fts_update
AFTER UPDATE ON memories BEGIN
CREATE TRIGGER memories_fts_update
AFTER UPDATE OF content, tags ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, id, content, tags)
VALUES ('delete', old.rowid, old.id, old.content, old.tags);
INSERT INTO memories_fts(rowid, id, content, tags)
Expand Down
Loading
Loading