Merge PR #2903: fix(mnemopi): make proactive linking configurable from host settings (@wolfiesch)

This commit is contained in:
can1357
2026-06-21 00:09:10 +02:00
14 changed files with 240 additions and 17 deletions
+1 -1
View File
@@ -98,7 +98,7 @@ note text
When you deliberately interrupt the agent (Esc, or a cancel from collab, ACP, RPC, the SDK, or an extension), the advisor stops auto-resuming it. An interrupting `concern`/`blocker` raised while the run is stopped is recorded as a visible advisor card instead of restarting the turn, and a concern already in flight when you interrupt is preserved the same way rather than driving a surprise resume. The advice re-enters context the next time you resume — a new message, the `.`/`c` continue shortcut, or a steer/follow-up. A normal yield is unaffected: the advisor can still steer and resume a run the agent ended on its own.
`advisor.immuneTurns` limits interruption frequency. After the advisor successfully delivers a `concern` or `blocker` through the steering channel, later concerns/blockers are routed as non-interrupting asides until the configured number of primary turns has completed. The default is `3`. `nit` notes are unchanged, and advice raised while user-interrupt auto-resume suppression is active is still preserved instead of restarting a stopped run.
`advisor.immuneTurns` limits interruption frequency. After the advisor successfully delivers a `concern` or `blocker` through the steering channel, later concerns/blockers are routed as non-interrupting asides until the configured number of primary turns has completed. The default is `3`. `nit` notes are unchanged, and advice raised while user-interrupt auto-resume suppression is active is still preserved instead of restarting a stopped run.
## Bounded catch-up with `advisor.syncBacklog`
+3
View File
@@ -268,6 +268,9 @@
### Added
- Added a copy affordance for completed `/btw` answers so users can copy the visible side-answer text before branching or dismissing the panel.
### Fixed
- Fixed mnemopi proactive linking being configurable only through the `MNEMOPI_PROACTIVE_LINKING` environment variable, unlike the sibling `mnemopi.polyphonicRecall` / `mnemopi.enhancedRecall` settings: added a `mnemopi.proactiveLinking` config.yml setting (off by default, `/settings` → Memory → Mnemopi) that ingests new memories into the episodic graph as they are stored, linking them to related entities and memories; `MNEMOPI_PROACTIVE_LINKING` still overrides the configured value when set ([#2440](https://github.com/can1357/oh-my-pi/issues/2440)).
## [16.0.6] - 2026-06-18
@@ -2257,6 +2257,18 @@ export const SETTINGS_SCHEMA = {
condition: "mnemopiActive",
},
},
"mnemopi.proactiveLinking": {
type: "boolean",
default: false,
ui: {
tab: "memory",
group: "Mnemopi",
label: "Mnemopi Proactive Linking",
description:
"Ingest new memories into the episodic graph as they are stored, linking them to related entities and memories",
condition: "mnemopiActive",
},
},
"mnemopi.noEmbeddings": {
type: "boolean",
default: false,
@@ -26,6 +26,7 @@ export interface MnemopiBackendConfig {
autoRetain: boolean;
polyphonicRecall: boolean;
enhancedRecall: boolean;
proactiveLinking: boolean;
retainEveryNTurns: number;
recallLimit: number;
recallContextTurns: number;
@@ -71,6 +72,7 @@ export function loadMnemopiConfig(settings: Settings, agentDir: string): Mnemopi
autoRetain: settings.get("mnemopi.autoRetain"),
polyphonicRecall: settings.get("mnemopi.polyphonicRecall"),
enhancedRecall: settings.get("mnemopi.enhancedRecall"),
proactiveLinking: settings.get("mnemopi.proactiveLinking"),
retainEveryNTurns: Math.max(1, Math.floor(settings.get("mnemopi.retainEveryNTurns"))),
recallLimit: Math.max(1, Math.floor(settings.get("mnemopi.recallLimit"))),
recallContextTurns: Math.max(1, Math.floor(settings.get("mnemopi.recallContextTurns"))),
+3 -1
View File
@@ -450,7 +450,8 @@ export class MnemopiSessionState {
// shared bank, then merging recall results while keeping writes project-local.
function createScopedResources(config: MnemopiBackendConfig): MnemopiScopedResources {
// Env vars (MNEMOPI_POLYPHONIC_RECALL / MNEMOPI_ENHANCED_RECALL) still override
// these config-driven defaults inside the core gates.
// 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,
@@ -576,6 +577,7 @@ function createMemory(config: MnemopiBackendConfig, bank: string): Mnemopi {
authorType: "agent",
channelId: bank,
...providerOptions,
proactiveLinking: config.proactiveLinking,
} as ConstructorParameters<typeof Mnemopi>[0]);
}
@@ -129,6 +129,7 @@ function makeMnemopiConfig(
autoRetain: true,
polyphonicRecall: false,
enhancedRecall: false,
proactiveLinking: false,
retainEveryNTurns: 3,
recallLimit: 10,
recallContextTurns: 1,
+4
View File
@@ -17,6 +17,10 @@
- Fixed background fact extraction skipping runtime-configured remote LLM endpoints when `MNEMOPI_LLM_BASE_URL` was unset, so `remember(..., { extract: true })` now stores remote-distilled facts from `mnemopi.llm` config instead of falling back to regex heuristics. ([#3041](https://github.com/can1357/oh-my-pi/issues/3041))
- Fixed local fastembed startup on macOS ARM64 by letting `fastembed@2.1.0` install its matching `onnxruntime-node@1.21.0` native runtime instead of forcing `1.26.0`, and by repairing missing tokenizer sidecars from the upstream Hugging Face model cache when a stale fastembed archive lacks them. ([#3054](https://github.com/can1357/oh-my-pi/issues/3054))
### Fixed
- Fixed the proactive-linking write path ignoring host configuration: `proactiveLinkIfEnabled` read `MNEMOPI_PROACTIVE_LINKING` directly, so a host that enabled proactive linking through `configureRecallFeatures()` had no effect unless the environment variable was also set. `proactiveLinking` is now a `RecallFeatureFlags` option resolved through a `proactiveLinkingEnabled()` fallback, matching the existing polyphonic and enhanced recall flags, with the `MNEMOPI_PROACTIVE_LINKING` environment variable still taking precedence whenever it is set. ([#2440](https://github.com/can1357/oh-my-pi/issues/2440))
## [16.0.6] - 2026-06-18
### Fixed
+12 -7
View File
@@ -268,27 +268,27 @@ export function autoMigrateEnabled(env: Env = process.env): boolean {
return envString("MNEMOPI_AUTO_MIGRATE", "1", env) !== "0";
}
export function proactiveLinkingEnabled(env: Env = process.env): boolean {
return envString("MNEMOPI_PROACTIVE_LINKING", "0", env) === "1";
}
export interface RecallFeatureFlags {
polyphonicRecall?: boolean;
enhancedRecall?: boolean;
proactiveLinking?: boolean;
}
let polyphonicRecallDefault = false;
let enhancedRecallDefault = false;
let proactiveLinkingDefault = false;
/**
* Sets process-wide defaults for the env-gated recall features. Host configuration
* (e.g. the coding-agent `mnemopi.polyphonicRecall` / `mnemopi.enhancedRecall`
* settings) lands here; the `MNEMOPI_POLYPHONIC_RECALL` / `MNEMOPI_ENHANCED_RECALL`
* environment variables still win whenever they are set.
* (e.g. the coding-agent `mnemopi.polyphonicRecall` / `mnemopi.enhancedRecall` /
* `mnemopi.proactiveLinking` settings) lands here; the `MNEMOPI_POLYPHONIC_RECALL` /
* `MNEMOPI_ENHANCED_RECALL` / `MNEMOPI_PROACTIVE_LINKING` environment variables still
* win whenever they are set.
*/
export function configureRecallFeatures(flags: RecallFeatureFlags): void {
if (flags.polyphonicRecall !== undefined) polyphonicRecallDefault = flags.polyphonicRecall;
if (flags.enhancedRecall !== undefined) enhancedRecallDefault = flags.enhancedRecall;
if (flags.proactiveLinking !== undefined) proactiveLinkingDefault = flags.proactiveLinking;
}
export function polyphonicRecallEnabled(env: Env = process.env): boolean {
@@ -305,6 +305,11 @@ export function enhancedRecallEnabled(env: Env = process.env): boolean {
return value === undefined ? enhancedRecallDefault : value === "1";
}
export function proactiveLinkingEnabled(env: Env = process.env): boolean {
const value = envOptionalString("MNEMOPI_PROACTIVE_LINKING", env);
return value === undefined ? proactiveLinkingDefault : value === "1";
}
export function llmEnabled(env: Env = process.env): boolean {
return envBool("MNEMOPI_LLM_ENABLED", true, env);
}
+4 -1
View File
@@ -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,13 @@ const DEFAULT_CONFIG: BeamConfig = {
useCloud: false,
localLlmEnabled: false,
maxEpisodeChars: 100_000,
proactiveLinking: false,
};
function normalizeConfig(options: BeamMemoryOptions): BeamConfig {
const configured = options.config ?? {};
const useCloud = options.useCloud ?? configured.useCloud ?? DEFAULT_CONFIG.useCloud;
const proactiveLinking = options.proactiveLinking ?? configured.proactiveLinking ?? proactiveLinkingEnabled({});
return {
workingMemoryLimit: configured.workingMemoryLimit ?? DEFAULT_CONFIG.workingMemoryLimit,
workingMemoryTtlHours: configured.workingMemoryTtlHours ?? DEFAULT_CONFIG.workingMemoryTtlHours,
@@ -84,6 +86,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 {
+6 -1
View File
@@ -187,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 (process.env.MNEMOPI_PROACTIVE_LINKING !== "1") return;
if (!proactiveLinkingAllowed(beam)) return;
try {
const graph =
beam.episodicGraph instanceof EpisodicGraph
+2
View File
@@ -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;
+2
View File
@@ -43,6 +43,7 @@ export interface MnemopiOptions {
readonly llmApiKey?: ApiKey;
readonly llmModel?: string | Model<Api>;
readonly llm?: false | MnemopiLlmRuntimeOptions | Model<Api> | MnemopiLlmCompletion;
readonly proactiveLinking?: boolean;
/** Escalate best-effort failure logs (embedding pipeline) from debug to warn. */
readonly debug?: boolean;
/**
@@ -383,6 +384,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) {
@@ -1,13 +1,16 @@
import { afterEach, describe, expect, it } from "bun:test";
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;
afterEach(() => {
if (previousProactive === undefined) delete process.env.MNEMOPI_PROACTIVE_LINKING;
else process.env.MNEMOPI_PROACTIVE_LINKING = previousProactive;
configureRecallFeatures({ proactiveLinking: false });
});
function linkedIds(edges: readonly RelatedMemory[]): Set<string> {
@@ -39,6 +42,170 @@ describe("proactive memory linking", () => {
}
});
it("honors host configuration when the environment variable is unset", () => {
delete process.env.MNEMOPI_PROACTIVE_LINKING;
configureRecallFeatures({ proactiveLinking: true });
const beam = new BeamMemory({ sessionId: "proactive-host-config", dbPath: ":memory:" });
try {
const first = beam.remember("Alice set up the CI/CD pipeline for backend deployment", {
importance: 0.8,
});
const second = beam.remember("Alice configured the deployment pipeline for continuous integration", {
importance: 0.8,
});
const edges = graphOf(beam).findRelatedMemories(second, 1);
expect(linkedIds(edges).has(first)).toBe(true);
expect(edges.some(edge => edge.memoryId === first && edge.edgeType === "related_to")).toBe(true);
} finally {
beam.close();
}
});
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 snapshot a construction-time environment override into instance defaults", () => {
process.env.MNEMOPI_PROACTIVE_LINKING = "1";
const beam = new BeamMemory({
sessionId: "proactive-env-snapshot",
dbPath: ":memory:",
proactiveLinking: false,
});
delete process.env.MNEMOPI_PROACTIVE_LINKING;
try {
const first = beam.remember("Alice set up the CI/CD pipeline for backend deployment", {
importance: 0.8,
});
const second = beam.remember("Alice configured the deployment pipeline for continuous integration", {
importance: 0.8,
});
expect(linkedIds(graphOf(beam).findRelatedMemories(second, 1)).has(first)).toBe(false);
} finally {
beam.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:" });
@@ -1,39 +1,48 @@
import { afterEach, describe, expect, it } from "bun:test";
import { configureRecallFeatures, enhancedRecallEnabled, polyphonicRecallEnabled } from "@oh-my-pi/pi-mnemopi/config";
import {
configureRecallFeatures,
enhancedRecallEnabled,
polyphonicRecallEnabled,
proactiveLinkingEnabled,
} from "@oh-my-pi/pi-mnemopi/config";
import { polyphonicRecallIsEnabled } from "@oh-my-pi/pi-mnemopi/core/polyphonic-recall";
import { isEnhancedRecallEnabled, isQueryCacheEnabled } from "@oh-my-pi/pi-mnemopi/core/query-cache";
afterEach(() => {
configureRecallFeatures({ polyphonicRecall: false, enhancedRecall: false });
configureRecallFeatures({ polyphonicRecall: false, enhancedRecall: false, proactiveLinking: false });
});
describe("configureRecallFeatures", () => {
it("keeps both recall gates off by default", () => {
it("keeps all recall gates off by default", () => {
expect(polyphonicRecallEnabled({})).toBe(false);
expect(enhancedRecallEnabled({})).toBe(false);
expect(proactiveLinkingEnabled({})).toBe(false);
expect(isEnhancedRecallEnabled({})).toBe(false);
expect(isQueryCacheEnabled(true, {})).toBe(false);
});
it("enables the gates from host configuration when the env vars are unset", () => {
configureRecallFeatures({ polyphonicRecall: true, enhancedRecall: true });
configureRecallFeatures({ polyphonicRecall: true, enhancedRecall: true, proactiveLinking: true });
expect(polyphonicRecallEnabled({})).toBe(true);
expect(polyphonicRecallIsEnabled({})).toBe(true);
expect(enhancedRecallEnabled({})).toBe(true);
expect(proactiveLinkingEnabled({})).toBe(true);
expect(isEnhancedRecallEnabled({})).toBe(true);
expect(isQueryCacheEnabled(true, {})).toBe(true);
expect(isQueryCacheEnabled(false, {})).toBe(false);
});
it("lets the env vars override the configured value in both directions", () => {
configureRecallFeatures({ polyphonicRecall: true, enhancedRecall: true });
configureRecallFeatures({ polyphonicRecall: true, enhancedRecall: true, proactiveLinking: true });
expect(polyphonicRecallEnabled({ MNEMOPI_POLYPHONIC_RECALL: "0" })).toBe(false);
expect(enhancedRecallEnabled({ MNEMOPI_ENHANCED_RECALL: "0" })).toBe(false);
expect(proactiveLinkingEnabled({ MNEMOPI_PROACTIVE_LINKING: "0" })).toBe(false);
expect(isQueryCacheEnabled(true, { MNEMOPI_ENHANCED_RECALL: "0" })).toBe(false);
configureRecallFeatures({ polyphonicRecall: false, enhancedRecall: false });
configureRecallFeatures({ polyphonicRecall: false, enhancedRecall: false, proactiveLinking: false });
expect(polyphonicRecallEnabled({ MNEMOPI_POLYPHONIC_RECALL: "1" })).toBe(true);
expect(enhancedRecallEnabled({ MNEMOPI_ENHANCED_RECALL: "1" })).toBe(true);
expect(proactiveLinkingEnabled({ MNEMOPI_PROACTIVE_LINKING: "1" })).toBe(true);
expect(isQueryCacheEnabled(true, { MNEMOPI_ENHANCED_RECALL: "1" })).toBe(true);
});
@@ -41,8 +50,14 @@ describe("configureRecallFeatures", () => {
configureRecallFeatures({ polyphonicRecall: true });
expect(polyphonicRecallEnabled({})).toBe(true);
expect(enhancedRecallEnabled({})).toBe(false);
expect(proactiveLinkingEnabled({})).toBe(false);
configureRecallFeatures({ enhancedRecall: true });
expect(polyphonicRecallEnabled({})).toBe(true);
expect(enhancedRecallEnabled({})).toBe(true);
expect(proactiveLinkingEnabled({})).toBe(false);
configureRecallFeatures({ proactiveLinking: true });
expect(polyphonicRecallEnabled({})).toBe(true);
expect(enhancedRecallEnabled({})).toBe(true);
expect(proactiveLinkingEnabled({})).toBe(true);
});
});