diff --git a/docs/mnemosyne-memory-backend.md b/docs/mnemosyne-memory-backend.md index 53c53729f..87b034a2b 100644 --- a/docs/mnemosyne-memory-backend.md +++ b/docs/mnemosyne-memory-backend.md @@ -9,6 +9,15 @@ memory: backend: mnemosyne ``` +Example: + +```yaml +memory: + backend: mnemosyne +mnemosyne: + scoping: per-project-tagged +``` + With this backend enabled, the coding agent: 1. Opens a local Mnemosyne SQLite database. @@ -24,7 +33,8 @@ Recalled memory is background context, not instructions. Current user messages a | --- | --- | --- | | `memory.backend` | `off` | Set to `mnemosyne` to enable this backend. | | `mnemosyne.dbPath` | agent memories dir | Optional SQLite database path. | -| `mnemosyne.bank` | project directory name | Bank/session name used to partition project memories. | +| `mnemosyne.bank` | project directory name | Base bank name passed to `Mnemosyne`; the coding-agent wrapper scopes from this base according to `mnemosyne.scoping`. | +| `mnemosyne.scoping` | `per-project` | Memory visibility mode: `global` = one shared bank, `per-project` = isolated project memory, `per-project-tagged` = project-local writes plus global recall visibility. | | `mnemosyne.autoRecall` | `true` | Recall memory on the first turn of a session. | | `mnemosyne.autoRetain` | `true` | Retain completed turns automatically. | | `mnemosyne.retainEveryNTurns` | `4` | Minimum user turns between automatic retain writes. | @@ -42,6 +52,15 @@ Recalled memory is background context, not instructions. Current user messages a | `mnemosyne.llmApiKey` | env/default | LLM API key for `llmMode: remote`. | | `mnemosyne.llmModel` | env/default | LLM model id for `llmMode: remote`. | +## Scoping + +The coding-agent wrapper applies scoping on top of the underlying `Mnemosyne` package: + +- `global` uses one shared bank for every project. +- `per-project` uses a separate bank per project. +- `per-project-tagged` keeps writes project-local while recall can also read from shared global memory. + +The combined project-plus-global behavior lives in the wrapper. The `@oh-my-pi/pi-mnemosyne` package itself still exposes banks and constructor options directly, including `bank` for selecting a bank name. ## LLM and embeddings The backend passes these settings to the `Mnemosyne` constructor; if a setting is omitted, Mnemosyne falls back to its `MNEMOSYNE_*` environment defaults. The backend does not download or run a local GGUF LLM. LLM-dependent paths use a configured pi-ai model, a dynamic completion function, a remote OpenAI-compatible endpoint, or deterministic no-LLM fallbacks. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f97ca4f90..ad6875541 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,26 @@ ## [Unreleased] +### Added + +- Added a persistent live agent roster pinned below the editor (focus it with `Ctrl+S` or `Alt+Down`), including view-as switching into delegated agent sessions with human-readable delegate names and UI pinning to suppress idle reaping while viewed. The roster stays hidden until at least one delegated agent exists and releases focus back to the editor once the last one is gone. +- Recorded the originating session ID alongside each prompt in `history.db` (new `session_id` column, surfaced as `HistoryEntry.sessionId`), so recalled prompts can be traced back to the session they came from. Existing history databases gain the column automatically on next launch. + +### Changed + +- Changed `irc` to treat the attached human as a first-class `User` peer, merging human prompts into `irc call User` with optional structured question payloads and adding `/dm ` for user-to-agent routing without switching views. +- Changed the `--resume` session picker (and the in-session resume selector) to also rank sessions by prompt-history matches from `history.db`, not just the session-list metadata. Because the session list only indexes the first 4KB of each file, this surfaces sessions by prompts typed deep into long conversations. Sessions matched by both signals lead, then metadata-only matches, then history-only matches — no metadata match is dropped. + +### Removed + +- Removed the standalone `ask`, `task`, and `yield` tools along with their obsolete prompts, docs, and tests; delegation now routes through persistent `delegate` agents plus IRC coordination. + +### Fixed + +- Fixed `Esc` in a delegated agent view returning to the main session instead of aborting the delegated agent's active turn. +- Fixed the agent roster staying pinned under the editor when all delegated agents are idle or dormant; it now reappears when explicitly focused with `Alt+Down` / session observe. +- Fixed selector-style UI components to honor `tui.select.up` and `tui.select.down` keybindings instead of hard-coding raw Up/Down arrow bytes ([#1535](https://github.com/can1357/oh-my-pi/issues/1535)). + ## [15.5.15] - 2026-05-30 ### Changed diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index efbc16b63..91344d31c 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -1333,7 +1333,36 @@ export const SETTINGS_SCHEMA = { ui: { tab: "memory", label: "Mnemosyne Bank", - description: "Memory bank/session name. Defaults to the current project directory name.", + description: "Optional shared bank base name. Per-project modes derive project-local banks from it.", + condition: "mnemosyneActive", + }, + }, + "mnemosyne.scoping": { + type: "enum", + values: ["global", "per-project", "per-project-tagged"] as const, + default: "per-project", + ui: { + tab: "memory", + label: "Mnemosyne Scoping", + description: + "global = one shared bank; per-project = isolated bank per cwd; per-project-tagged = project-local writes plus global recall visibility", + options: [ + { + value: "global", + label: "Global", + description: "One shared Mnemosyne bank for every project", + }, + { + value: "per-project", + label: "Per project", + description: "Project-local Mnemosyne bank per cwd basename", + }, + { + value: "per-project-tagged", + label: "Per project (tagged)", + description: "Write to a project-local bank but merge project + shared recall results", + }, + ], condition: "mnemosyneActive", }, }, diff --git a/packages/coding-agent/src/mnemosyne/config.ts b/packages/coding-agent/src/mnemosyne/config.ts index 322cf4d0f..6b4f90c60 100644 --- a/packages/coding-agent/src/mnemosyne/config.ts +++ b/packages/coding-agent/src/mnemosyne/config.ts @@ -5,6 +5,8 @@ import type { Settings } from "../config/settings"; export type MnemosyneLlmMode = "none" | "smol" | "remote"; +export type MnemosyneScoping = "global" | "per-project" | "per-project-tagged"; + export type MnemosyneProviderOptions = Pick< MnemosyneOptions, "noEmbeddings" | "embeddingModel" | "embeddingApiUrl" | "embeddingApiKey" | "llm" @@ -12,7 +14,12 @@ export type MnemosyneProviderOptions = Pick< export interface MnemosyneBackendConfig { dbPath: string; + baseBank?: string; bank: string; + globalBank?: string; + retainBank?: string; + recallBanks?: readonly string[]; + scoping?: MnemosyneScoping; autoRecall: boolean; autoRetain: boolean; retainEveryNTurns: number; @@ -31,11 +38,17 @@ export interface MnemosyneBackendConfig { export function loadMnemosyneConfig(settings: Settings, agentDir: string): MnemosyneBackendConfig { const configuredDbPath = settings.get("mnemosyne.dbPath"); const cwd = settings.getCwd(); - const bank = normalizeBank(settings.get("mnemosyne.bank"), cwd); + const scoping = settings.get("mnemosyne.scoping"); + const scope = resolveBankScope(settings.get("mnemosyne.bank"), cwd, scoping); const llmMode = settings.get("mnemosyne.llmMode"); return { dbPath: configuredDbPath ?? path.join(getMemoriesDir(agentDir), "mnemosyne", "mnemosyne.db"), - bank, + baseBank: scope.baseBank, + bank: scope.bank, + globalBank: scope.globalBank, + retainBank: scope.retainBank, + recallBanks: scope.recallBanks, + scoping, autoRecall: settings.get("mnemosyne.autoRecall"), autoRetain: settings.get("mnemosyne.autoRetain"), retainEveryNTurns: Math.max(1, Math.floor(settings.get("mnemosyne.retainEveryNTurns"))), @@ -65,9 +78,61 @@ export function loadMnemosyneConfig(settings: Settings, agentDir: string): Mnemo }; } -function normalizeBank(configured: string | undefined, cwd: string): string { +const DEFAULT_SHARED_BANK = "default"; + +interface MnemosyneBankScope { + baseBank: string; + bank: string; + globalBank: string; + retainBank: string; + recallBanks: readonly string[]; +} + +// Mnemosyne does not have built-in tag-filtered recall, so `per-project-tagged` +// maps to a project-local write bank plus a shared recall-visible bank. +function resolveBankScope(configured: string | undefined, cwd: string, scoping: MnemosyneScoping): MnemosyneBankScope { + const project = projectBank(configured, cwd); + const globalBank = sharedBank(configured); + switch (scoping) { + case "global": + return { + baseBank: globalBank, + bank: globalBank, + globalBank, + retainBank: globalBank, + recallBanks: [globalBank], + }; + case "per-project": + return { + baseBank: globalBank, + bank: project, + globalBank, + retainBank: project, + recallBanks: [project], + }; + case "per-project-tagged": + return { + baseBank: globalBank, + bank: project, + globalBank, + retainBank: project, + recallBanks: project === globalBank ? [project] : [project, globalBank], + }; + } +} + +function sharedBank(configured: string | undefined): string { const raw = configured?.trim(); - if (raw) return raw; + return raw || DEFAULT_SHARED_BANK; +} + +function projectBank(configured: string | undefined, cwd: string): string { + const project = normalizeProjectName(cwd); + const raw = configured?.trim(); + return raw ? `${raw}-${project}` : project; +} + +function normalizeProjectName(cwd: string): string { const base = path.basename(cwd) || "default"; return base.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "default"; } diff --git a/packages/coding-agent/src/mnemosyne/state.ts b/packages/coding-agent/src/mnemosyne/state.ts index 129df8cc7..3ec3f087b 100644 --- a/packages/coding-agent/src/mnemosyne/state.ts +++ b/packages/coding-agent/src/mnemosyne/state.ts @@ -9,7 +9,7 @@ import { } from "../hindsight/content"; import { extractMessages } from "../hindsight/transcript"; import type { AgentSession, AgentSessionEvent } from "../session/agent-session"; -import type { MnemosyneBackendConfig } from "./config"; +import type { MnemosyneBackendConfig, MnemosyneScoping } from "./config"; const kMnemosyneSessionState = Symbol("mnemosyne.sessionState"); @@ -17,6 +17,21 @@ interface AgentSessionWithMnemosyneState extends AgentSession { [kMnemosyneSessionState]?: MnemosyneSessionState; } +interface MnemosyneScopedMemory { + bank: string; + memory: Mnemosyne; +} + +interface MnemosyneScopedResources { + retain: MnemosyneScopedMemory; + recall: readonly MnemosyneScopedMemory[]; + owned: readonly Mnemosyne[]; + global?: MnemosyneScopedMemory; +} + +type MnemosyneRememberInput = Parameters[0]; +type MnemosyneRememberOptions = Parameters[1]; + export function getMnemosyneSessionState(session: AgentSession | undefined): MnemosyneSessionState | undefined { return session ? (session as AgentSessionWithMnemosyneState)[kMnemosyneSessionState] : undefined; } @@ -46,7 +61,9 @@ export class MnemosyneSessionState { readonly config: MnemosyneBackendConfig; readonly session: AgentSession; readonly memory: Mnemosyne; + readonly globalMemory?: Mnemosyne; readonly aliasOf?: MnemosyneSessionState; + private readonly scoped: MnemosyneScopedResources; lastRetainedTurn: number; hasRecalledForFirstTurn: boolean; lastRecallSnippet?: string; @@ -59,38 +76,88 @@ export class MnemosyneSessionState { this.aliasOf = options.aliasOf; this.lastRetainedTurn = options.lastRetainedTurn ?? 0; this.hasRecalledForFirstTurn = options.hasRecalledForFirstTurn ?? false; - const providerOptions = options.config.providerOptions as Record; - this.memory = - options.aliasOf?.memory ?? - new Mnemosyne({ - dbPath: options.config.dbPath, - bank: options.config.bank, - sessionId: options.config.bank, - authorId: "coding-agent", - authorType: "agent", - channelId: options.config.bank, - ...providerOptions, - } as ConstructorParameters[0]); + this.scoped = options.aliasOf?.scoped ?? createScopedResources(options.config); + this.memory = this.scoped.retain.memory; + this.globalMemory = this.scoped.global?.memory; } setSessionId(sessionId: string): void { this.sessionId = sessionId; } - async recallForContext(query: string): Promise { + getScopedRecallTargets(): readonly MnemosyneScopedMemory[] { + return this.scoped.recall; + } + + getScopedRetainTarget(): MnemosyneScopedMemory { + return this.scoped.retain; + } + + collectScopedRecallResults(query: string): RecallResult[] { + const merged: RecallResult[] = []; + const byId = new Map(); + const byContent = new Map(); + for (const target of this.scoped.recall) { + try { + const results = target.memory.recallEnhanced(query, this.config.recallLimit, { + includeFacts: true, + channelId: target.bank, + }); + for (const result of results) { + mergeRecallResult(merged, byId, byContent, result); + } + } catch (error) { + if (this.config.debug) { + logger.debug("Mnemosyne: scoped recall target failed", { + bank: target.bank, + error: String(error), + }); + } + } + } + merged.sort(compareRecallResults); + if (merged.length > this.config.recallLimit) merged.length = this.config.recallLimit; + return merged; + } + + recallResultsScoped(query: string): RecallResult[] { + return this.collectScopedRecallResults(query); + } + + formatScopedRecallContext( + results: readonly RecallResult[], + format: "bullet" | "json" = "bullet", + ): string | undefined { + if (results.length === 0) return undefined; + return this.memory.beam.formatContext(results, format); + } + + formatContextScoped(results: readonly RecallResult[], format: "bullet" | "json" = "bullet"): string { + return this.formatScopedRecallContext(results, format) ?? ""; + } + + rememberInScope(memory: MnemosyneRememberInput, options: MnemosyneRememberOptions = {}): string | undefined { try { - const results = this.memory.recallEnhanced(query, this.config.recallLimit, { - includeFacts: true, - channelId: this.config.bank, - }); - if (results.length === 0) return undefined; - return formatRecallBlock(results); + return this.scoped.retain.memory.remember(memory, options); } catch (error) { - if (this.config.debug) logger.debug("Mnemosyne: recall failed", { error: String(error) }); + logger.warn("Mnemosyne: retain failed", { + bank: this.scoped.retain.bank, + error: String(error), + }); return undefined; } } + rememberScoped(memory: MnemosyneRememberInput, options: MnemosyneRememberOptions = {}): string | undefined { + return this.rememberInScope(memory, options); + } + + async recallForContext(query: string): Promise { + const results = this.collectScopedRecallResults(query); + if (results.length === 0) return undefined; + return formatRecallBlock(results); + } + async beforeAgentStartPrompt(promptText: string): Promise { if (!this.config.autoRecall || this.hasRecalledForFirstTurn) return undefined; const latestPrompt = promptText.trim(); @@ -134,25 +201,21 @@ export class MnemosyneSessionState { async retainMessages(messages: Array<{ role: string; content: string }>, sourceId: string): Promise { const { transcript, messageCount } = prepareRetentionTranscript(messages, true); if (!transcript) return; - try { - this.memory.remember(transcript, { - source: "coding-agent-transcript", - importance: 0.65, - metadata: { - session_id: this.sessionId, - source_id: sourceId, - message_count: messageCount, - cwd: this.session.sessionManager.getCwd(), - }, - scope: "bank", - extract: true, - extractEntities: true, - veracity: "unknown", - memoryType: "episode", - }); - } catch (error) { - logger.warn("Mnemosyne: retain failed", { error: String(error) }); - } + this.rememberInScope(transcript, { + source: "coding-agent-transcript", + importance: 0.65, + metadata: { + session_id: this.sessionId, + source_id: sourceId, + message_count: messageCount, + cwd: this.session.sessionManager.getCwd(), + }, + scope: "bank", + extract: true, + extractEntities: true, + veracity: "unknown", + memoryType: "episode", + }); } attachSessionListeners(): void { @@ -187,10 +250,96 @@ export class MnemosyneSessionState { dispose(): void { this.unsubscribe?.(); this.unsubscribe = undefined; - if (!this.aliasOf) this.memory.close(); + if (!this.aliasOf) { + for (const memory of this.scoped.owned) memory.close(); + } } } +// `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: MnemosyneBackendConfig): MnemosyneScopedResources { + const banks = resolveScopedBanks(config); + const memories = new Map(); + const open = (bank: string): MnemosyneScopedMemory => { + const existing = memories.get(bank); + if (existing) return existing; + const scoped = { bank, memory: createMemory(config, bank) }; + memories.set(bank, scoped); + return scoped; + }; + const retain = open(banks.retainBank); + const recall = banks.recallBanks.map(open); + const global = banks.scoping === "per-project-tagged" ? open(banks.globalBank) : undefined; + return { + retain, + recall, + global, + owned: [...memories.values()].map(entry => entry.memory), + }; +} + +function resolveScopedBanks(config: MnemosyneBackendConfig): { + scoping: MnemosyneScoping; + globalBank: string; + retainBank: string; + recallBanks: readonly string[]; +} { + const scoping = config.scoping ?? "per-project"; + const retainBank = config.retainBank ?? config.bank; + const globalBank = config.globalBank ?? config.baseBank ?? config.bank; + const recallBanks = + config.recallBanks ?? (scoping === "per-project-tagged" ? uniqueBanks([retainBank, globalBank]) : [retainBank]); + return { scoping, globalBank, retainBank, recallBanks }; +} + +function uniqueBanks(banks: readonly string[]): readonly string[] { + return [...new Set(banks)]; +} + +function createMemory(config: MnemosyneBackendConfig, bank: string): Mnemosyne { + const providerOptions = config.providerOptions as Record; + return new Mnemosyne({ + dbPath: config.dbPath, + bank, + sessionId: bank, + authorId: "coding-agent", + authorType: "agent", + channelId: bank, + ...providerOptions, + } as ConstructorParameters[0]); +} + +function mergeRecallResult( + merged: RecallResult[], + byId: Map, + byContent: Map, + result: RecallResult, +): void { + const id = result.id ?? ""; + const existingIndex = (id.length > 0 ? byId.get(id) : undefined) ?? byContent.get(result.content); + if (existingIndex === undefined) { + const index = merged.push(result) - 1; + if (id.length > 0) byId.set(id, index); + byContent.set(result.content, index); + return; + } + const current = merged[existingIndex]; + if (compareRecallResults(result, current) < 0) { + merged[existingIndex] = result; + } + if (id.length > 0) byId.set(id, existingIndex); + byContent.set(result.content, existingIndex); +} + +function compareRecallResults(left: RecallResult, right: RecallResult): number { + return ( + (right.score ?? 0) - (left.score ?? 0) || + (right.timestamp ?? "").localeCompare(left.timestamp ?? "") || + left.content.localeCompare(right.content) + ); +} + function formatRecallBlock(results: RecallResult[]): string { const lines = results.map(result => { const source = result.source ? ` [${result.source}]` : ""; diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 30fb9efe5..5a8f1695c 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -631,6 +631,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise name !== "task"); } + // IRC is always available; the [COOP] prompt advertises it, so a restricted + // whitelist must still carry `irc` for the subagent to actually use it. + if (toolNames && !toolNames.includes("irc")) { + toolNames = [...toolNames, "irc"]; + } if (toolNames?.includes("exec")) { const allowEvalPy = settings.get("eval.py") ?? true; const allowEvalJs = settings.get("eval.js") ?? true; diff --git a/packages/coding-agent/src/tools/hindsight-recall.ts b/packages/coding-agent/src/tools/hindsight-recall.ts index 1cf9e3571..33c253849 100644 --- a/packages/coding-agent/src/tools/hindsight-recall.ts +++ b/packages/coding-agent/src/tools/hindsight-recall.ts @@ -38,17 +38,14 @@ export class HindsightRecallTool implements AgentTool = {}): MnemosyneBackendConfig { +function makeMnemosyneConfig( + overrides: (Partial & Record) | undefined = {}, +): MnemosyneBackendConfig { if (!tempDbPath) { const tempDir = path.join(tmpdir(), `mnemosyne-test-${Date.now()}`); mkdirSync(tempDir, { recursive: true }); @@ -132,21 +134,31 @@ function makeMnemosyneConfig(overrides: Partial = {}): M }; } -function registerMnemosyneState(config?: MnemosyneBackendConfig) { +interface RegisterMnemosyneStateOptions { + cwd?: string; + sessionId?: string; +} + +function registerMnemosyneState( + config?: MnemosyneBackendConfig, + options: RegisterMnemosyneStateOptions = {}, +): MnemosyneSessionState { const finalConfig = config ?? makeMnemosyneConfig(); + const sessionId = options.sessionId ?? TEST_SESSION_ID; registeredMnemosyneState = new MnemosyneSessionState({ - sessionId: TEST_SESSION_ID, + sessionId, config: finalConfig, session: { - sessionId: TEST_SESSION_ID, + sessionId, sessionManager: { getEntries: () => [], - getCwd: () => "/tmp", + getCwd: () => options.cwd ?? "/tmp", } as never, emitNotice: () => {}, getHindsightSessionState: () => undefined, } as never, }); + return registeredMnemosyneState; } describe("Hindsight tool factories", () => { @@ -365,6 +377,37 @@ describe("retain.execute (Mnemosyne backend)", () => { expect(text).toContain("fact three"); }); + it("isolates memories between projects when scoping is per-project", async () => { + const settings = Settings.isolated({ + "memory.backend": "mnemosyne", + "hindsight.scoping": "per-project", + }); + const config = makeMnemosyneConfig({ scoping: "per-project" }); + + registerMnemosyneState(config, { cwd: "/work/project-alpha" }); + await HindsightRetainTool.createIf(makeSession(settings))!.execute("call-mnemosyne-alpha-store", { + items: [{ content: "alpha uses tabs" }], + }); + + registeredMnemosyneState?.dispose(); + registerMnemosyneState(config, { cwd: "/work/project-beta" }); + const betaRecall = await HindsightRecallTool.createIf(makeSession(settings))!.execute( + "call-mnemosyne-beta-recall", + { + query: "tabs", + }, + ); + expect(betaRecall.content[0]).toEqual({ type: "text", text: "No relevant memories found." }); + + registeredMnemosyneState?.dispose(); + registerMnemosyneState(config, { cwd: "/work/project-alpha" }); + const alphaRecall = await HindsightRecallTool.createIf(makeSession(settings))!.execute( + "call-mnemosyne-alpha-recall", + { query: "tabs" }, + ); + expect((alphaRecall.content[0] as { text: string }).text).toContain("alpha uses tabs"); + }); + it("throws when no per-session Mnemosyne state is registered", async () => { const settings = Settings.isolated({ "memory.backend": "mnemosyne" }); const tool = HindsightRetainTool.createIf(makeSession(settings))!; @@ -491,6 +534,65 @@ describe("recall.execute (Mnemosyne backend)", () => { expect(text).toContain("the user prefers dark mode in their editor"); }); + it("shares memories across projects when scoping is global", async () => { + const settings = Settings.isolated({ + "memory.backend": "mnemosyne", + "hindsight.scoping": "global", + }); + const config = makeMnemosyneConfig({ scoping: "global" }); + + registerMnemosyneState(config, { cwd: "/work/project-alpha" }); + await HindsightRetainTool.createIf(makeSession(settings))!.execute("call-mnemosyne-global-store", { + items: [{ content: "global memory survives project switches" }], + }); + + registeredMnemosyneState?.dispose(); + registerMnemosyneState(config, { cwd: "/work/project-beta" }); + const result = await HindsightRecallTool.createIf(makeSession(settings))!.execute( + "call-mnemosyne-global-recall", + { query: "project switches" }, + ); + + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("global memory survives project switches"); + }); + + it("merges global and project-local memories on recall when scoping is per-project-tagged", async () => { + const settings = Settings.isolated({ + "memory.backend": "mnemosyne", + "hindsight.scoping": "per-project-tagged", + }); + + registerMnemosyneState(makeMnemosyneConfig({ scoping: "global" }), { cwd: "/work/project-alpha" }); + await HindsightRetainTool.createIf(makeSession(settings))!.execute("call-mnemosyne-tagged-global", { + items: [{ content: "the user likes concise CLI output" }], + }); + + registeredMnemosyneState?.dispose(); + registerMnemosyneState(makeMnemosyneConfig({ scoping: "per-project-tagged" }), { cwd: "/work/project-alpha" }); + await HindsightRetainTool.createIf(makeSession(settings))!.execute("call-mnemosyne-tagged-local", { + items: [{ content: "project alpha uses pnpm workspaces" }], + }); + + registeredMnemosyneState?.dispose(); + registerMnemosyneState(makeMnemosyneConfig({ scoping: "per-project-tagged" }), { cwd: "/work/project-beta" }); + await HindsightRetainTool.createIf(makeSession(settings))!.execute("call-mnemosyne-tagged-other", { + items: [{ content: "project beta deploys to staging first" }], + }); + + registeredMnemosyneState?.dispose(); + registerMnemosyneState(makeMnemosyneConfig({ scoping: "per-project-tagged" }), { cwd: "/work/project-alpha" }); + const result = await HindsightRecallTool.createIf(makeSession(settings))!.execute( + "call-mnemosyne-tagged-recall", + { query: "what should I know about this user and project alpha?" }, + ); + + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("the user likes concise CLI output"); + expect(text).toContain("project alpha uses pnpm workspaces"); + expect(text).not.toContain("project beta deploys to staging first"); + }); + it("throws when no per-session Mnemosyne state is registered", async () => { const settings = Settings.isolated({ "memory.backend": "mnemosyne" }); const tool = HindsightRecallTool.createIf(makeSession(settings))!; @@ -623,6 +725,34 @@ describe("reflect.execute (Mnemosyne backend)", () => { expect(text).toContain("Python"); }); + it("merges global and project-local memories on reflect when scoping is per-project-tagged", async () => { + const settings = Settings.isolated({ + "memory.backend": "mnemosyne", + "hindsight.scoping": "per-project-tagged", + }); + + registerMnemosyneState(makeMnemosyneConfig({ scoping: "global" }), { cwd: "/work/project-alpha" }); + await HindsightRetainTool.createIf(makeSession(settings))!.execute("call-mnemosyne-reflect-global", { + items: [{ content: "the user prefers concise summaries" }], + }); + + registeredMnemosyneState?.dispose(); + registerMnemosyneState(makeMnemosyneConfig({ scoping: "per-project-tagged" }), { cwd: "/work/project-alpha" }); + await HindsightRetainTool.createIf(makeSession(settings))!.execute("call-mnemosyne-reflect-local", { + items: [{ content: "project alpha uses turbo for task orchestration" }], + }); + + const result = await HindsightReflectTool.createIf(makeSession(settings))!.execute( + "call-mnemosyne-reflect-tagged", + { query: "what matters for this user working in project alpha?" }, + ); + + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("Based on recalled memories"); + expect(text).toContain("the user prefers concise summaries"); + expect(text).toContain("project alpha uses turbo for task orchestration"); + }); + it("throws when no per-session Mnemosyne state is registered", async () => { const settings = Settings.isolated({ "memory.backend": "mnemosyne" }); const tool = HindsightReflectTool.createIf(makeSession(settings))!; diff --git a/packages/coding-agent/test/tool-discovery/initial-tools.test.ts b/packages/coding-agent/test/tool-discovery/initial-tools.test.ts index 08edf7e8f..7b23bc4a2 100644 --- a/packages/coding-agent/test/tool-discovery/initial-tools.test.ts +++ b/packages/coding-agent/test/tool-discovery/initial-tools.test.ts @@ -26,7 +26,6 @@ const allToolsSettings = Settings.isolated({ "web_search.enabled": true, "browser.enabled": true, "checkpoint.enabled": true, - "irc.enabled": true, "recipe.enabled": true, "todo.enabled": true, "memory.backend": "hindsight", diff --git a/packages/mnemosyne/README.md b/packages/mnemosyne/README.md index 5b0f940ea..896d0f0e3 100644 --- a/packages/mnemosyne/README.md +++ b/packages/mnemosyne/README.md @@ -68,6 +68,18 @@ const dynamicLlm = new Mnemosyne({ }); ``` +### Banks and host scoping + +`Mnemosyne` itself exposes banks directly through constructor options such as `bank`; it does not hard-code coding-agent project scoping. + +The Oh My Pi coding-agent wrapper adds `mnemosyne.scoping` on top of those constructor options: + +- `global`: one shared bank +- `per-project`: isolated project memory +- `per-project-tagged`: project-local writes plus global recall visibility + +In `per-project-tagged`, the wrapper is responsible for combining project-local retention with global recall visibility. The package still just exposes banks plus constructor-level LLM and embedding options. + Common environment fallbacks: - `MNEMOSYNE_DATA_DIR` / `MNEMOSYNE_DB_PATH`: default storage location.