diff --git a/packages/coding-agent/src/mnemopi/state.ts b/packages/coding-agent/src/mnemopi/state.ts index 4f2f1ccf9..43e41876f 100644 --- a/packages/coding-agent/src/mnemopi/state.ts +++ b/packages/coding-agent/src/mnemopi/state.ts @@ -421,13 +421,12 @@ export class MnemopiSessionState { // `per-project-tagged` is implemented by opening both the project bank and the // shared bank, then merging recall results while keeping writes project-local. function createScopedResources(config: MnemopiBackendConfig): MnemopiScopedResources { - // Env vars (MNEMOPI_POLYPHONIC_RECALL / MNEMOPI_ENHANCED_RECALL / - // MNEMOPI_PROACTIVE_LINKING) still override these config-driven defaults inside - // the core gates. + // Env vars (MNEMOPI_POLYPHONIC_RECALL / MNEMOPI_ENHANCED_RECALL) still override + // these config-driven defaults inside the core gates. Proactive linking is + // per-memory instance below so concurrent sessions cannot clobber each other. requireMnemopi().configureRecallFeatures({ polyphonicRecall: config.polyphonicRecall, enhancedRecall: config.enhancedRecall, - proactiveLinking: config.proactiveLinking, }); const banks = resolveScopedBanks(config); const memories = new Map(); @@ -550,6 +549,7 @@ function createMemory(config: MnemopiBackendConfig, bank: string): Mnemopi { authorType: "agent", channelId: bank, ...providerOptions, + proactiveLinking: config.proactiveLinking, } as ConstructorParameters[0]); } diff --git a/packages/mnemopi/src/core/beam/index.ts b/packages/mnemopi/src/core/beam/index.ts index 407fda522..fb56c0d11 100644 --- a/packages/mnemopi/src/core/beam/index.ts +++ b/packages/mnemopi/src/core/beam/index.ts @@ -1,6 +1,6 @@ import type { Database } from "bun:sqlite"; import { existsSync } from "node:fs"; -import { ftsWeight, importanceWeight, maxEpisodeChars, vectorWeight } from "../../config"; +import { ftsWeight, importanceWeight, maxEpisodeChars, proactiveLinkingEnabled, vectorWeight } from "../../config"; import { closeQuietly, openDatabase } from "../../db"; import { AnnotationStore } from "../annotations"; import { EpisodicGraph } from "../episodic-graph"; @@ -69,11 +69,22 @@ const DEFAULT_CONFIG: BeamConfig = { useCloud: false, localLlmEnabled: false, maxEpisodeChars: 100_000, + proactiveLinking: false, }; +function envProactiveLinkingOverride(): boolean | undefined { + const value = process.env.MNEMOPI_PROACTIVE_LINKING; + return value === undefined ? undefined : value === "1"; +} + function normalizeConfig(options: BeamMemoryOptions): BeamConfig { const configured = options.config ?? {}; const useCloud = options.useCloud ?? configured.useCloud ?? DEFAULT_CONFIG.useCloud; + const proactiveLinking = + envProactiveLinkingOverride() ?? + options.proactiveLinking ?? + configured.proactiveLinking ?? + proactiveLinkingEnabled(); return { workingMemoryLimit: configured.workingMemoryLimit ?? DEFAULT_CONFIG.workingMemoryLimit, workingMemoryTtlHours: configured.workingMemoryTtlHours ?? DEFAULT_CONFIG.workingMemoryTtlHours, @@ -84,6 +95,7 @@ function normalizeConfig(options: BeamMemoryOptions): BeamConfig { useCloud, localLlmEnabled: configured.localLlmEnabled ?? DEFAULT_CONFIG.localLlmEnabled, maxEpisodeChars: configured.maxEpisodeChars ?? maxEpisodeChars(), + proactiveLinking, }; } function autoMigrateAnnotations(db: Database, dbPath: string | undefined): void { diff --git a/packages/mnemopi/src/core/beam/store.ts b/packages/mnemopi/src/core/beam/store.ts index 2a98e7a0a..e2885763a 100644 --- a/packages/mnemopi/src/core/beam/store.ts +++ b/packages/mnemopi/src/core/beam/store.ts @@ -1,6 +1,5 @@ import type { Database, SQLQueryBindings } from "bun:sqlite"; import { logger } from "@oh-my-pi/pi-utils"; -import { proactiveLinkingEnabled } from "../../config"; import { transaction } from "../../db"; import { toUtcIso } from "../../util/datetime"; import { generateId } from "../../util/ids"; @@ -188,13 +187,18 @@ function addTemporalAnnotations(beam: BeamMemoryState, memoryId: string, timesta } } +function proactiveLinkingAllowed(beam: BeamMemoryState): boolean { + const override = process.env.MNEMOPI_PROACTIVE_LINKING; + return override === undefined ? beam.config.proactiveLinking === true : override === "1"; +} + function proactiveLinkIfEnabled( beam: BeamMemoryState, memoryId: string, content: string, extractEntities: boolean, ): void { - if (!proactiveLinkingEnabled()) return; + if (!proactiveLinkingAllowed(beam)) return; try { const graph = beam.episodicGraph instanceof EpisodicGraph diff --git a/packages/mnemopi/src/core/beam/types.ts b/packages/mnemopi/src/core/beam/types.ts index dc52c6575..751327853 100644 --- a/packages/mnemopi/src/core/beam/types.ts +++ b/packages/mnemopi/src/core/beam/types.ts @@ -54,6 +54,7 @@ export interface BeamConfig { useCloud: boolean; localLlmEnabled: boolean; maxEpisodeChars: number; + proactiveLinking?: boolean; } export interface BeamMemoryOptions { @@ -63,6 +64,7 @@ export interface BeamMemoryOptions { authorType?: string | null; channelId?: string | null; useCloud?: boolean; + proactiveLinking?: boolean; eventEmitter?: (event: BeamEvent) => void; pluginManager?: BeamPluginManager | null; annotations?: AnnotationStoreLike | null; diff --git a/packages/mnemopi/src/core/memory.ts b/packages/mnemopi/src/core/memory.ts index 912d787cd..19729cafb 100644 --- a/packages/mnemopi/src/core/memory.ts +++ b/packages/mnemopi/src/core/memory.ts @@ -43,6 +43,7 @@ export interface MnemopiOptions { readonly llmApiKey?: ApiKey; readonly llmModel?: string | Model; readonly llm?: false | MnemopiLlmRuntimeOptions | Model | MnemopiLlmCompletion; + readonly proactiveLinking?: boolean; /** Escalate best-effort failure logs (embedding pipeline) from debug to warn. */ readonly debug?: boolean; /** @@ -380,6 +381,7 @@ export class Mnemopi { authorId: this.authorId, authorType: this.authorType, channelId: this.channelId, + proactiveLinking: options.proactiveLinking, }); this.#ownsDb = options.db === undefined; if (options.db !== undefined) { diff --git a/packages/mnemopi/test/proactive-linking.test.ts b/packages/mnemopi/test/proactive-linking.test.ts index 5a3766fdb..b705df908 100644 --- a/packages/mnemopi/test/proactive-linking.test.ts +++ b/packages/mnemopi/test/proactive-linking.test.ts @@ -3,6 +3,7 @@ import "./setup"; import { configureRecallFeatures } from "@oh-my-pi/pi-mnemopi/config"; import { BeamMemory } from "@oh-my-pi/pi-mnemopi/core/beam"; import type { EpisodicGraph, RelatedMemory } from "@oh-my-pi/pi-mnemopi/core/episodic-graph"; +import { Mnemopi } from "@oh-my-pi/pi-mnemopi/core/memory"; const previousProactive = process.env.MNEMOPI_PROACTIVE_LINKING; @@ -61,6 +62,128 @@ describe("proactive memory linking", () => { } }); + it("keeps host configuration scoped to each BeamMemory instance", () => { + delete process.env.MNEMOPI_PROACTIVE_LINKING; + const enabled = new BeamMemory({ + sessionId: "proactive-instance-on", + dbPath: ":memory:", + proactiveLinking: true, + }); + configureRecallFeatures({ proactiveLinking: false }); + const disabled = new BeamMemory({ + sessionId: "proactive-instance-off", + dbPath: ":memory:", + proactiveLinking: false, + }); + try { + const enabledFirst = enabled.remember("Alice set up the CI/CD pipeline for backend deployment", { + importance: 0.8, + }); + const enabledSecond = enabled.remember("Alice configured the deployment pipeline for continuous integration", { + importance: 0.8, + }); + const disabledFirst = disabled.remember("Alice set up the CI/CD pipeline for backend deployment", { + importance: 0.8, + }); + const disabledSecond = disabled.remember( + "Alice configured the deployment pipeline for continuous integration", + { + importance: 0.8, + }, + ); + + expect(linkedIds(graphOf(enabled).findRelatedMemories(enabledSecond, 1)).has(enabledFirst)).toBe(true); + expect(linkedIds(graphOf(disabled).findRelatedMemories(disabledSecond, 1)).has(disabledFirst)).toBe(false); + } finally { + enabled.close(); + disabled.close(); + } + }); + + it("keeps host configuration scoped to each Mnemopi instance", () => { + delete process.env.MNEMOPI_PROACTIVE_LINKING; + const enabled = new Mnemopi({ + sessionId: "proactive-mnemopi-on", + dbPath: ":memory:", + proactiveLinking: true, + }); + configureRecallFeatures({ proactiveLinking: false }); + const disabled = new Mnemopi({ + sessionId: "proactive-mnemopi-off", + dbPath: ":memory:", + proactiveLinking: false, + }); + try { + const enabledFirst = enabled.remember("Database indexing improves query performance significantly", { + importance: 0.8, + }); + const enabledSecond = enabled.remember("Database indexing optimizes query performance and efficiency", { + importance: 0.8, + }); + const disabledFirst = disabled.remember("Database indexing improves query performance significantly", { + importance: 0.8, + }); + const disabledSecond = disabled.remember("Database indexing optimizes query performance and efficiency", { + importance: 0.8, + }); + + expect(linkedIds(graphOf(enabled.beam).findRelatedMemories(enabledSecond, 1)).has(enabledFirst)).toBe(true); + expect(linkedIds(graphOf(disabled.beam).findRelatedMemories(disabledSecond, 1)).has(disabledFirst)).toBe( + false, + ); + } finally { + enabled.close(); + disabled.close(); + } + }); + + it("lets the environment variable override instance configuration", () => { + process.env.MNEMOPI_PROACTIVE_LINKING = "0"; + const disabledByEnv = new BeamMemory({ + sessionId: "proactive-env-off", + dbPath: ":memory:", + proactiveLinking: true, + }); + try { + const disabledFirst = disabledByEnv.remember("Alice set up the CI/CD pipeline for backend deployment", { + importance: 0.8, + }); + const disabledSecond = disabledByEnv.remember( + "Alice configured the deployment pipeline for continuous integration", + { + importance: 0.8, + }, + ); + process.env.MNEMOPI_PROACTIVE_LINKING = "1"; + const enabledByEnv = new BeamMemory({ + sessionId: "proactive-env-on", + dbPath: ":memory:", + proactiveLinking: false, + }); + try { + const enabledFirst = enabledByEnv.remember("Alice set up the CI/CD pipeline for backend deployment", { + importance: 0.8, + }); + const enabledSecond = enabledByEnv.remember( + "Alice configured the deployment pipeline for continuous integration", + { + importance: 0.8, + }, + ); + + expect(linkedIds(graphOf(enabledByEnv).findRelatedMemories(enabledSecond, 1)).has(enabledFirst)).toBe(true); + } finally { + enabledByEnv.close(); + } + + expect(linkedIds(graphOf(disabledByEnv).findRelatedMemories(disabledSecond, 1)).has(disabledFirst)).toBe( + false, + ); + } finally { + disabledByEnv.close(); + } + }); + it("does not create recall-similarity edges for unrelated content", () => { process.env.MNEMOPI_PROACTIVE_LINKING = "1"; const beam = new BeamMemory({ sessionId: "proactive-unrelated", dbPath: ":memory:" });