From 9662844853da3fe7e536fe590c3da62c22e712e9 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Sun, 14 Jun 2026 12:03:13 +0900 Subject: [PATCH] feat(coding-agent): support the local memory backend in the learn tool The `learn` tool previously required a `hindsight`/`mnemopi` backend. It now also works when `memory.backend` is `local` (the file-based rollout backend): lessons append to a `learned.md` under the project's memory root, kept separate from the consolidation artifacts so a consolidation pass never clobbers them, and are injected into future sessions alongside the memory summary. - memories: `saveLearnedLesson` (newest-first, deduped, count- and per-field size-capped, secret-redacted, injection-neutralized) with per-path write serialization; `buildMemoryToolDeveloperInstructions` reads `learned.md` and shares one injection budget with the summary; `redactSecrets` extended with GitHub/npm/Slack/Google token prefixes. - local backend: implements `save()`; status reports `writable: true`. - learn tool: `local` execute branch; `createIf`/`isToolAllowed`/auto-include and the standing guidance extended to `local`; local saves tier as a `write` approval. - read-path prompt: renders the learned-lessons block when present. - Lessons are injection-neutralized and secret-redacted on BOTH write and read (they render unescaped into the system prompt). Also moves the auto-learn CHANGELOG entry out of the released [15.12.6] section (a cherry-pick artifact) back under [Unreleased] and notes the local backend. Tests: local storage (format, dedup, cap, redaction incl. provider/delimiter- split tokens, concurrency), read-back (with/without summary, off-gating, raw hand-edited file), tool gating + write-approval tiering. --- packages/coding-agent/CHANGELOG.md | 2 +- .../coding-agent/src/autolearn/controller.ts | 5 +- packages/coding-agent/src/memories/index.ts | 152 ++++++++++- .../src/memory-backend/local-backend.ts | 16 +- .../src/prompts/memories/read-path.md | 6 + packages/coding-agent/src/tools/index.ts | 4 +- packages/coding-agent/src/tools/learn.ts | 19 +- .../test/autolearn-controller.test.ts | 8 + .../test/autolearn-learn-local.test.ts | 249 ++++++++++++++++++ .../test/autolearn-tools-gating.test.ts | 14 + .../test/memory-backend-resolve.test.ts | 4 +- 11 files changed, 454 insertions(+), 25 deletions(-) create mode 100644 packages/coding-agent/test/autolearn-learn-local.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f207171e2..5a1b80724 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ - Added a `fastModeScope` setting (`both` | `openai` | `claude`, default `both`) controlling which providers `/fast on` (and the fast-mode toggle) target. `both` keeps the prior unscoped priority behavior; `openai`/`claude` scope fast mode to one family. `/fast status` now reports the active scope. - Added the `mnemopi.embeddingVariant` setting (`en` | `multilingual`) selecting a stronger SOTA local embedding model — `en` → `BAAI/bge-base-en-v1.5` (768d), `multilingual` → `intfloat/multilingual-e5-large` (1024d). Resolution precedence is `mnemopi.embeddingModel` setting > `MNEMOPI_EMBEDDING_MODEL` env > variant default, so the documented env override is still honored. Changing the active model wipes and rebuilds stored embeddings on the next writable start ([#2476](https://github.com/can1357/oh-my-pi/issues/2476)) - Added a `/guided-goal` slash command that interviews you to refine an objective before enabling goal mode, then seeds goal mode with the agreed objective. The bounded interview (up to six turns) runs on the plan or slow model and falls back with a hint when the goal is still too vague ([#2502](https://github.com/can1357/oh-my-pi/issues/2502)). +- Added an experimental, opt-in **auto-learn** loop (default off, `autolearn.enabled`). When enabled, after the agent stops it is nudged to capture reusable lessons: durable facts go to long-term memory and repeatable procedures become **managed skills** — `SKILL.md` files written to an isolated `~/.omp/agent/managed-skills` directory that is discovered and surfaced like authored skills but never overwrites user-authored skills (authored names always win). Two tools back this: `manage_skill` (create/update/delete managed skills) and `learn` (record a lesson, optionally minting a managed skill in the same call). `learn` works with the `hindsight`, `mnemopi`, or file-based `local` memory backend; under `local`, lessons append to a `learned.md` in the project's memory root (kept separate from the consolidation artifacts so they survive a consolidation pass) and are injected into future sessions. The nudge is passive by default (a hidden reminder rides the next turn); `autolearn.autoContinue` instead auto-runs one capture turn at stop, and `autolearn.minToolCalls` (default 5) gates trivial turns. Plan/goal-mode turns and subagents are never nudged. ### Fixed @@ -40,7 +41,6 @@ - Changed session persistence internals to expose `writeTextAtomic(...)` on session storage writers for atomic whole-file replacements - Changed online session-title generation to support tool-choice-less title models. Providers/models that cannot be forced to call a tool (chat-completions hosts without `tool_choice` support such as DeepSeek V4, and Claude Fable/Mythos) are now prompted to wrap the title in `...` markers instead of the `set_title` tool call; extraction is lenient, accepting a plain sentence or a truncated/unclosed tag. A `TITLE_SYSTEM.md` override is reused in this mode with the marker instruction appended. -- Added an experimental, opt-in **auto-learn** loop (default off, `autolearn.enabled`). When enabled, after the agent stops it is nudged to capture reusable lessons: durable facts go to long-term memory and repeatable procedures become **managed skills** — `SKILL.md` files written to an isolated `~/.omp/agent/managed-skills` directory that is discovered and surfaced like authored skills but never overwrites user-authored skills (authored names always win). Two tools back this: `manage_skill` (create/update/delete managed skills) and `learn` (record a lesson, optionally minting a managed skill in the same call; requires a `hindsight`/`mnemopi` memory backend). The nudge is passive by default (a hidden reminder rides the next turn); `autolearn.autoContinue` instead auto-runs one capture turn at stop, and `autolearn.minToolCalls` (default 5) gates trivial turns. Plan/goal-mode turns and subagents are never nudged. ### Fixed diff --git a/packages/coding-agent/src/autolearn/controller.ts b/packages/coding-agent/src/autolearn/controller.ts index 2bdf2dfe8..07c3d6ba2 100644 --- a/packages/coding-agent/src/autolearn/controller.ts +++ b/packages/coding-agent/src/autolearn/controller.ts @@ -23,11 +23,12 @@ const DEFAULT_MIN_TOOL_CALLS = 5; /** * Build the standing auto-learn guidance for the system prompt, or null when * the feature is disabled. The `learn` addendum is included only when a memory - * backend is live (the `learn` tool requires one). + * backend is live (the `learn` tool requires one — `hindsight`, `mnemopi`, or + * the file-based `local` backend). */ export function buildAutoLearnInstructions(settings: Settings): string | null { if (!settings.get("autolearn.enabled")) return null; - const learnEnabled = ["hindsight", "mnemopi"].includes(settings.get("memory.backend") ?? ""); + const learnEnabled = ["hindsight", "mnemopi", "local"].includes(settings.get("memory.backend") ?? ""); const parts = [autolearnGuidance.trim()]; if (learnEnabled) parts.push(autolearnGuidanceLearn.trim()); return parts.join("\n\n"); diff --git a/packages/coding-agent/src/memories/index.ts b/packages/coding-agent/src/memories/index.ts index a5fb2dff3..eabcc28cb 100644 --- a/packages/coding-agent/src/memories/index.ts +++ b/packages/coding-agent/src/memories/index.ts @@ -5,11 +5,12 @@ import * as path from "node:path"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import { type ApiKey, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai"; import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking"; -import { getAgentDbPath, getMemoriesDir, logger, parseJsonlLenient, prompt } from "@oh-my-pi/pi-utils"; +import { getAgentDbPath, getMemoriesDir, isEnoent, logger, parseJsonlLenient, prompt } from "@oh-my-pi/pi-utils"; import type { ModelRegistry } from "../config/model-registry"; import { getModelMatchPreferences, resolveModelRoleValue } from "../config/model-resolver"; import type { Settings } from "../config/settings"; +import type { MemoryBackendSaveInput, MemoryBackendSaveResult } from "../memory-backend/types"; import consolidationTemplate from "../prompts/memories/consolidation.md" with { type: "text" }; import consolidationSystemTemplate from "../prompts/memories/consolidation_system.md" with { type: "text" }; import readPathTemplate from "../prompts/memories/read-path.md" with { type: "text" }; @@ -156,22 +157,28 @@ export async function buildMemoryToolDeveloperInstructions( const cfg = loadMemoryConfig(settings); if (!cfg.enabled) return undefined; const memoryRoot = getMemoryRoot(agentDir, settings.getCwd()); - const summaryPath = path.join(memoryRoot, "memory_summary.md"); - let text: string; + let summary = ""; try { - text = await Bun.file(summaryPath).text(); + summary = (await Bun.file(path.join(memoryRoot, "memory_summary.md")).text()).trim(); } catch { - return undefined; + // Missing or unreadable summary — injection is best-effort; fall through + // so any captured lessons still surface on their own. } + const learned = await readLearnedLessons(memoryRoot); + if (!summary && !learned) return undefined; - const summary = text.trim(); - if (!summary) return undefined; - const truncated = truncateByApproxTokens(summary, cfg.summaryInjectionTokenLimit); - if (!truncated.trim()) return undefined; + const summaryOut = summary ? truncateByApproxTokens(summary, cfg.summaryInjectionTokenLimit).trim() : ""; + // Lessons share ONE injection budget with the summary so the combined block + // stays within `summaryInjectionTokenLimit` (~4 chars/token, matching + // truncateByApproxTokens). With no summary, lessons get the whole budget. + const learnedBudget = cfg.summaryInjectionTokenLimit - Math.ceil(summaryOut.length / 4); + const learnedOut = learned ? truncateByApproxTokens(learned, learnedBudget).trim() : ""; + if (!summaryOut && !learnedOut) return undefined; return prompt.render(readPathTemplate, { - memory_summary: truncated, + memory_summary: summaryOut, + learned: learnedOut, }); } @@ -982,6 +989,12 @@ function redactSecrets(input: string): string { /(?:sk|pk|rk|tok|key|secret|token|password)[-_A-Za-z0-9]{12,}/g, /[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g, /(?:AKIA|ASIA)[A-Z0-9]{16}/g, + // Common provider token prefixes (GitHub, npm, Slack, Google). + /(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g, + /github_pat_[A-Za-z0-9_]{20,}/g, + /npm_[A-Za-z0-9]{30,}/g, + /xox[baprs]-[A-Za-z0-9-]{10,}/g, + /AIza[A-Za-z0-9_-]{30,}/g, ]; for (const pattern of patterns) { out = out.replace(pattern, "[REDACTED]"); @@ -1121,6 +1134,125 @@ export function getMemoryRoot(agentDir: string, cwd: string): string { return path.join(getMemoriesDir(agentDir), encodeProjectPath(cwd)); } +/** + * Filename of the captured-lessons file under a project's memory root. + * + * Written by the `learn` tool via {@link saveLearnedLesson} and read back by + * {@link buildMemoryToolDeveloperInstructions}. Deliberately distinct from the + * consolidation artifacts (`MEMORY.md`, `memory_summary.md`, `skills/`) so a + * consolidation pass never clobbers manually captured lessons. + */ +const LEARNED_LESSONS_FILE = "learned.md"; +/** Newest-first cap on retained lessons, bounding file growth by entry count. */ +const MAX_LEARNED_LESSONS = 100; +/** Per-field char caps so a single huge capture can't bloat learned.md. */ +const MAX_LEARNED_CONTENT_CHARS = 2000; +const MAX_LEARNED_CONTEXT_CHARS = 400; + +/** + * Strip prompt-injection vectors from a single line of lesson text: control/ + * format chars, angle brackets (``), backticks, and `~~~` fences, then + * collapse whitespace. Applied on BOTH write and read (the block renders + * unescaped into the system prompt), mirroring managed-skill descriptions. + */ +function neutralizeInjection(text: string): string { + return text + .replace(/[\p{Cc}\p{Cf}]/gu, " ") + .replace(/[<>`]/g, "") + .replace(/~{2,}/g, "~") + .replace(/\s+/g, " ") + .trim(); +} + +/** Slice to `maxChars`, dropping a trailing unpaired high surrogate. */ +function boundChars(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + const sliced = text.slice(0, maxChars); + return /[\uD800-\uDBFF]$/.test(sliced) ? sliced.slice(0, -1) : sliced; +} + +/** + * Normalize one lesson field for storage: neutralize injection delimiters + * FIRST, then redact secrets (so delimiter stripping can't reassemble a token + * the redactor would have caught), then bound the length. + */ +function normalizeLearnedText(text: string, maxChars: number): string { + return boundChars(redactSecrets(neutralizeInjection(text)).trim(), maxChars); +} + +/** Per-path write chains serializing `learned.md` read-modify-write. */ +const learnedWriteChains = new Map>(); + +/** + * Append one lesson to the project's `learned.md` (newest-first, deduped, + * capped, secret-redacted, injection-neutralized). The file backs the `learn` + * tool when `memory.backend` is `local`. + */ +export async function saveLearnedLesson( + agentDir: string, + cwd: string, + input: MemoryBackendSaveInput, +): Promise { + const content = normalizeLearnedText(input.content, MAX_LEARNED_CONTENT_CHARS); + if (!content) { + return { backend: "local", stored: 0, message: "Empty lesson; nothing stored." }; + } + const context = input.context ? normalizeLearnedText(input.context, MAX_LEARNED_CONTEXT_CHARS) : ""; + const line = context ? `- ${content} _(context: ${context})_` : `- ${content}`; + const filePath = path.join(getMemoryRoot(agentDir, cwd), LEARNED_LESSONS_FILE); + + // Serialize the read-modify-write per file: parallel `learn` calls (sibling + // subagents, or two shared tool calls in one turn) share the project memory + // root, so an unguarded RMW would let the last writer drop the other's lesson. + const run = (learnedWriteChains.get(filePath) ?? Promise.resolve()).then(() => appendLearnedLine(filePath, line)); + const guarded = run.catch(() => {}); + learnedWriteChains.set(filePath, guarded); + try { + await run; + } finally { + // Drop the entry once this write is the chain tail, so the map does not + // retain one promise per distinct memory root for the process lifetime. + if (learnedWriteChains.get(filePath) === guarded) learnedWriteChains.delete(filePath); + } + return { backend: "local", stored: 1, message: `Lesson saved to ${LEARNED_LESSONS_FILE}.` }; +} + +async function appendLearnedLine(filePath: string, line: string): Promise { + let existing = ""; + try { + existing = await Bun.file(filePath).text(); + } catch (err) { + if (!isEnoent(err)) throw err; + } + const prior = existing + .split("\n") + .map(l => l.trim()) + .filter(l => l.startsWith("- ") && l !== line); + const lessons = [line, ...prior].slice(0, MAX_LEARNED_LESSONS); + await Bun.write(filePath, `${lessons.join("\n")}\n`); +} + +/** + * Read `learned.md`, neutralizing each line on read too — a hand-edited or + * pre-existing file bypasses write-time normalization and the block renders + * unescaped into the system prompt. Returns "" when absent/unreadable. + */ +async function readLearnedLessons(memoryRoot: string): Promise { + let raw = ""; + try { + raw = (await Bun.file(path.join(memoryRoot, LEARNED_LESSONS_FILE)).text()).trim(); + } catch { + return ""; + } + if (!raw) return ""; + // Neutralize delimiters THEN redact per line — mirrors the write path so a + // hand-edited line cannot reassemble a token after delimiter stripping. + return raw + .split("\n") + .map(line => redactSecrets(neutralizeInjection(line))) + .join("\n"); +} + function encodeProjectPath(cwd: string): string { return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; } diff --git a/packages/coding-agent/src/memory-backend/local-backend.ts b/packages/coding-agent/src/memory-backend/local-backend.ts index e36c7145d..0c7726ddb 100644 --- a/packages/coding-agent/src/memory-backend/local-backend.ts +++ b/packages/coding-agent/src/memory-backend/local-backend.ts @@ -2,6 +2,7 @@ import { buildMemoryToolDeveloperInstructions, clearMemoryData, enqueueMemoryConsolidation, + saveLearnedLesson, startMemoryStartupTask, } from "../memories"; import type { MemoryBackend } from "./types"; @@ -9,9 +10,10 @@ import type { MemoryBackend } from "./types"; /** * Wraps the existing `memories/` module as a `MemoryBackend`. * - * No behavioural change — every call delegates to the legacy entry points so - * the local memory pipeline (rollout summarisation → SQLite → memory_summary.md) - * keeps working exactly as before. + * The rollout-summarisation pipeline (rollouts → SQLite → memory_summary.md) is + * delegated unchanged. On top of it, `save()` persists `learn`-tool lessons to + * `learned.md` (so `status()` reports `writable: true`); structured search is + * still unavailable. */ export const localBackend: MemoryBackend = { id: "local", @@ -27,13 +29,17 @@ export const localBackend: MemoryBackend = { async enqueue(agentDir, cwd) { enqueueMemoryConsolidation(agentDir, cwd); }, + async save(context, input) { + return saveLearnedLesson(context.agentDir, context.cwd, input); + }, async status() { return { backend: "local" as const, active: true, - writable: false, + writable: true, searchable: false, - message: "Local rollout-summary memory is active; structured search/save is not available.", + message: + "Local rollout-summary memory is active; lessons from the `learn` tool are saved to learned.md. Structured search is not available.", }; }, }; diff --git a/packages/coding-agent/src/prompts/memories/read-path.md b/packages/coding-agent/src/prompts/memories/read-path.md index fdc85934f..9ef90dcaa 100644 --- a/packages/coding-agent/src/prompts/memories/read-path.md +++ b/packages/coding-agent/src/prompts/memories/read-path.md @@ -7,5 +7,11 @@ Operational rules: 4) When memory changes your plan, cite the artifact path (e.g. `memory://root/skills//SKILL.md`) and pair it with current-repo evidence. 5) If memory disagrees with repo state or user instruction, treat memory as stale: proceed with corrected behavior, then update/regenerate memory artifacts. 6) Escalate confidence only after repository verification. Memory alone is NEVER sufficient proof. +{{#if memory_summary}} Memory summary: {{memory_summary}} +{{/if}} +{{#if learned}} +Learned lessons (captured via the `learn` tool; durable but may be stale — verify against the repo before relying on them): +{{learned}} +{{/if}} diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 22fb54586..9ec7fe955 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -532,7 +532,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P if (session.settings.get("autolearn.enabled")) { if (!requestedTools.includes("manage_skill")) requestedTools.push("manage_skill"); if ( - ["hindsight", "mnemopi"].includes(session.settings.get("memory.backend") ?? "") && + ["hindsight", "mnemopi", "local"].includes(session.settings.get("memory.backend") ?? "") && !requestedTools.includes("learn") ) { requestedTools.push("learn"); @@ -575,7 +575,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P if (name === "learn") { return ( session.settings.get("autolearn.enabled") && - ["hindsight", "mnemopi"].includes(session.settings.get("memory.backend") ?? "") + ["hindsight", "mnemopi", "local"].includes(session.settings.get("memory.backend") ?? "") ); } if (name === "task") { diff --git a/packages/coding-agent/src/tools/learn.ts b/packages/coding-agent/src/tools/learn.ts index 9dea74df8..c9197660f 100644 --- a/packages/coding-agent/src/tools/learn.ts +++ b/packages/coding-agent/src/tools/learn.ts @@ -1,6 +1,7 @@ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { z } from "zod/v4"; import { writeManagedSkill } from "../autolearn/managed-skills"; +import { localBackend } from "../memory-backend/local-backend"; import learnDescription from "../prompts/tools/learn.md" with { type: "text" }; import type { ToolSession } from "."; @@ -24,11 +25,15 @@ export type LearnParams = z.infer; * Orchestrating "learn" tool: persists a lesson to long-term memory and, * given a `skill` payload, mints/enhances a managed skill via the shared * `writeManagedSkill` primitive. Gated behind `autolearn.enabled` plus a live - * memory backend (the memory half needs one). + * memory backend — `hindsight`/`mnemopi` (remote/SQLite) or `local` (the + * file-based rollout backend, where lessons append to `learned.md`). */ export class LearnTool implements AgentTool { readonly name = "learn"; - readonly approval = (args: unknown) => ((args as Partial).skill ? "write" : "read"); + readonly approval = (args: unknown) => + (args as Partial).skill || this.session.settings.get("memory.backend") === "local" + ? "write" + : "read"; readonly label = "Learn"; readonly description = learnDescription; readonly parameters = learnSchema; @@ -41,7 +46,7 @@ export class LearnTool implements AgentTool { static createIf(session: ToolSession): LearnTool | null { if (!session.settings.get("autolearn.enabled")) return null; const backend = session.settings.get("memory.backend"); - if (backend !== "hindsight" && backend !== "mnemopi") return null; + if (backend !== "hindsight" && backend !== "mnemopi" && backend !== "local") return null; return new LearnTool(session); } @@ -68,6 +73,14 @@ export class LearnTool implements AgentTool { veracity: "tool", memoryType: "fact", }); + } else if (backend === "local") { + const result = await localBackend.save?.( + { agentDir: this.session.settings.getAgentDir(), cwd: this.session.settings.getCwd() }, + { content: params.memory, context: params.context, source: "coding-agent-learn", importance: 0.8 }, + ); + if (!result || result.stored === 0) { + throw new Error("Lesson was empty after sanitization; nothing stored."); + } } else { const state = this.session.getHindsightSessionState?.(); if (!state) { diff --git a/packages/coding-agent/test/autolearn-controller.test.ts b/packages/coding-agent/test/autolearn-controller.test.ts index 9b3dc2e83..4e3a75c3f 100644 --- a/packages/coding-agent/test/autolearn-controller.test.ts +++ b/packages/coding-agent/test/autolearn-controller.test.ts @@ -183,6 +183,14 @@ describe("buildAutoLearnInstructions", () => { expect(text).toContain("long-term memory"); }); + it("includes the learn addendum for the file-based local backend", () => { + const text = buildAutoLearnInstructions( + Settings.isolated({ "autolearn.enabled": true, "memory.backend": "local" }), + ); + expect(text).toContain("manage_skill"); + expect(text).toContain("long-term memory"); + }); + it("omits the learn addendum when no memory backend is configured", () => { const text = buildAutoLearnInstructions( Settings.isolated({ "autolearn.enabled": true, "memory.backend": "off" }), diff --git a/packages/coding-agent/test/autolearn-learn-local.test.ts b/packages/coding-agent/test/autolearn-learn-local.test.ts new file mode 100644 index 000000000..d917bb696 --- /dev/null +++ b/packages/coding-agent/test/autolearn-learn-local.test.ts @@ -0,0 +1,249 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { + buildMemoryToolDeveloperInstructions, + getMemoryRoot, + saveLearnedLesson, +} from "@oh-my-pi/pi-coding-agent/memories"; +import { localBackend } from "@oh-my-pi/pi-coding-agent/memory-backend/local-backend"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { LearnTool } from "@oh-my-pi/pi-coding-agent/tools/learn"; + +Bun.env.PI_PYTHON_SKIP_CHECK = "1"; + +describe("learned-lesson storage (local backend)", () => { + let tmp: string; + let agentDir: string; + let projCwd: string; + let learnedFile: string; + + beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), "omp-learned-")); + agentDir = path.join(tmp, "agent"); + projCwd = path.join(tmp, "proj"); + learnedFile = path.join(getMemoryRoot(agentDir, projCwd), "learned.md"); + }); + afterEach(async () => { + await fs.rm(tmp, { recursive: true, force: true }); + }); + + it("appends a bullet, normalizes whitespace, and inlines context", async () => { + const result = await saveLearnedLesson(agentDir, projCwd, { + content: "Prefer Bun.file\nover\n\nreadFileSync.", + context: "from the build", + }); + expect(result.stored).toBe(1); + expect(await Bun.file(learnedFile).text()).toBe( + "- Prefer Bun.file over readFileSync. _(context: from the build)_\n", + ); + }); + + it("redacts secrets, including provider token prefixes, before persisting", async () => { + const ghToken = `ghp_${"A".repeat(36)}`; + await saveLearnedLesson(agentDir, projCwd, { + content: `API token-abcdefghijklmnop and ${ghToken} leaked into logs`, + }); + const text = await Bun.file(learnedFile).text(); + expect(text).toContain("[REDACTED]"); + expect(text).not.toContain("abcdefghijklmnop"); + expect(text).not.toContain(ghToken); + }); + + it("redacts a token even when a delimiter splits it (strip before redact)", async () => { + const reassembled = `ghp_${"B".repeat(36)}`; + await saveLearnedLesson(agentDir, projCwd, { content: `gh\`p_${"B".repeat(36)} oops` }); + const text = await Bun.file(learnedFile).text(); + expect(text).not.toContain(reassembled); + expect(text).toContain("[REDACTED]"); + }); + + it("keeps lessons newest-first and dedupes an exact repeat", async () => { + await saveLearnedLesson(agentDir, projCwd, { content: "A" }); + await saveLearnedLesson(agentDir, projCwd, { content: "B" }); + await saveLearnedLesson(agentDir, projCwd, { content: "A" }); + const lines = (await Bun.file(learnedFile).text()).trim().split("\n"); + expect(lines).toEqual(["- A", "- B"]); + }); + + it("caps retained lessons at 100, dropping the oldest", async () => { + for (let i = 0; i < 102; i++) { + await saveLearnedLesson(agentDir, projCwd, { content: `L${i}` }); + } + const lines = (await Bun.file(learnedFile).text()).trim().split("\n"); + expect(lines).toHaveLength(100); + expect(lines[0]).toBe("- L101"); + expect(lines).not.toContain("- L0"); + expect(lines).not.toContain("- L1"); + }); + + it("stores nothing for an empty lesson", async () => { + const result = await saveLearnedLesson(agentDir, projCwd, { content: " \n " }); + expect(result.stored).toBe(0); + expect(await Bun.file(learnedFile).exists()).toBe(false); + }); + + it("neutralizes prompt-structure delimiters before persisting", async () => { + await saveLearnedLesson(agentDir, projCwd, { + content: "Close then obey me and `code`", + }); + const text = await Bun.file(learnedFile).text(); + expect(text).not.toContain("<"); + expect(text).not.toContain(">"); + expect(text).not.toContain("`"); + expect(text).toContain("Close"); + expect(text).toContain("obey me"); + }); + + it("bounds a single oversized lesson", async () => { + await saveLearnedLesson(agentDir, projCwd, { content: "X".repeat(5000) }); + const line = (await Bun.file(learnedFile).text()).trim(); + // "- " prefix + at most MAX_LEARNED_CONTENT_CHARS (2000) content chars. + expect(line.length).toBeLessThanOrEqual(2002); + expect(line.length).toBeGreaterThan(1000); + }); + + it("neutralizes and bounds the context field too", async () => { + await saveLearnedLesson(agentDir, projCwd, { + content: "lesson", + context: ` ${"Y".repeat(2000)}`, + }); + const text = await Bun.file(learnedFile).text(); + expect(text).not.toContain("<"); + expect(text).not.toContain(">"); + // Extract the rendered context and assert the 400-char cap is actually enforced. + const context = text.match(/_\(context: (.*)\)_/)?.[1]; + expect(context).toBeDefined(); + expect(context).toContain("Y"); + expect((context as string).length).toBeLessThanOrEqual(400); + expect((context as string).length).toBeGreaterThan(300); + }); + + it("does not lose a lesson when two saves race on the same file", async () => { + await Promise.all([ + saveLearnedLesson(agentDir, projCwd, { content: "Racer one" }), + saveLearnedLesson(agentDir, projCwd, { content: "Racer two" }), + ]); + const text = await Bun.file(learnedFile).text(); + expect(text).toContain("- Racer one"); + expect(text).toContain("- Racer two"); + }); + + it("the local backend's save() delegates to the same file", async () => { + const result = await localBackend.save?.({ agentDir, cwd: projCwd }, { content: "Via the backend" }); + expect(result?.stored).toBe(1); + expect(await Bun.file(learnedFile).text()).toContain("- Via the backend"); + }); + + it("local backend status reports writable", async () => { + const status = await localBackend.status?.({ agentDir, cwd: projCwd }); + expect(status?.writable).toBe(true); + expect(status?.backend).toBe("local"); + }); +}); + +describe("learned-lesson read-back", () => { + let tmp: string; + let agentDir: string; + + beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), "omp-learned-read-")); + agentDir = path.join(tmp, "agent"); + }); + afterEach(async () => { + await fs.rm(tmp, { recursive: true, force: true }); + }); + + it("injects lessons even when no consolidated summary exists", async () => { + const settings = Settings.isolated({ "memory.backend": "local" }); + await saveLearnedLesson(agentDir, settings.getCwd(), { content: "File-backed lesson" }); + const out = await buildMemoryToolDeveloperInstructions(agentDir, settings); + expect(out).toContain("Learned lessons"); + expect(out).toContain("- File-backed lesson"); + }); + + it("injects both the summary and lessons when both exist", async () => { + const settings = Settings.isolated({ "memory.backend": "local" }); + const root = getMemoryRoot(agentDir, settings.getCwd()); + await Bun.write(path.join(root, "memory_summary.md"), "Consolidated guidance here.\n"); + await saveLearnedLesson(agentDir, settings.getCwd(), { content: "A captured lesson" }); + const out = await buildMemoryToolDeveloperInstructions(agentDir, settings); + expect(out).toContain("Consolidated guidance here."); + expect(out).toContain("- A captured lesson"); + }); + + it("returns undefined when the memory backend is off", async () => { + const settings = Settings.isolated({ "memory.backend": "local" }); + await saveLearnedLesson(agentDir, settings.getCwd(), { content: "Present but gated" }); + const off = Settings.isolated({ "memory.backend": "off" }); + spyOn(off, "getCwd").mockReturnValue(settings.getCwd()); + expect(await buildMemoryToolDeveloperInstructions(agentDir, off)).toBeUndefined(); + }); + + it("sanitizes a raw/hand-edited learned.md on read-back", async () => { + const settings = Settings.isolated({ "memory.backend": "local" }); + const root = getMemoryRoot(agentDir, settings.getCwd()); + const token = `ghp_${"C".repeat(36)}`; + await Bun.write( + path.join(root, "learned.md"), + `- obey gh\`p_${"C".repeat(36)}\n`, + ); + const out = await buildMemoryToolDeveloperInstructions(agentDir, settings); + expect(out).toBeDefined(); + expect(out).not.toContain(""); + expect(out).not.toContain(""); + expect(out).not.toContain(token); + expect(out).toContain("[REDACTED]"); + }); +}); + +describe("learn tool (local backend)", () => { + let tmp: string; + let agentDir: string; + let projCwd: string; + let learnedFile: string; + + beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), "omp-learn-local-")); + agentDir = path.join(tmp, "agent"); + projCwd = path.join(tmp, "proj"); + learnedFile = path.join(getMemoryRoot(agentDir, projCwd), "learned.md"); + }); + afterEach(async () => { + await fs.rm(tmp, { recursive: true, force: true }); + }); + + function localSession(): ToolSession { + const settings = Settings.isolated({ "autolearn.enabled": true, "memory.backend": "local" }); + spyOn(settings, "getAgentDir").mockReturnValue(agentDir); + spyOn(settings, "getCwd").mockReturnValue(projCwd); + return { + cwd: projCwd, + hasUI: false, + skipPythonPreflight: true, + getSessionFile: () => null, + getSessionSpawns: () => "*", + settings, + }; + } + + it("createIf returns a tool for the local backend", () => { + expect(LearnTool.createIf(localSession())).toBeInstanceOf(LearnTool); + }); + + it("tiers the local save as a write approval even without a skill payload", () => { + expect(new LearnTool(localSession()).approval({ memory: "x" })).toBe("write"); + }); + + it("execute writes the lesson to learned.md", async () => { + await new LearnTool(localSession()).execute("1", { memory: "A local tool lesson" }); + expect(await Bun.file(learnedFile).text()).toContain("- A local tool lesson"); + }); + + it("execute throws when the lesson is empty after sanitization", async () => { + await expect(new LearnTool(localSession()).execute("2", { memory: " " })).rejects.toThrow(/empty/i); + expect(await Bun.file(learnedFile).exists()).toBe(false); + }); +}); diff --git a/packages/coding-agent/test/autolearn-tools-gating.test.ts b/packages/coding-agent/test/autolearn-tools-gating.test.ts index 8f2034b23..bb59f968c 100644 --- a/packages/coding-agent/test/autolearn-tools-gating.test.ts +++ b/packages/coding-agent/test/autolearn-tools-gating.test.ts @@ -71,6 +71,20 @@ describe("autolearn tool gating", () => { expect(noBackend).toContain("manage_skill"); expect(noBackend).not.toContain("learn"); }); + + it("offers learn with the file-based local backend", async () => { + const names = (await createTools(makeSession({ "autolearn.enabled": true, "memory.backend": "local" }))).map( + t => t.name, + ); + expect(names).toContain("learn"); + expect(names).toContain("manage_skill"); + + // Force-included into an explicit restricted toolNames list too. + const restricted = ( + await createTools(makeSession({ "autolearn.enabled": true, "memory.backend": "local" }), ["read"]) + ).map(t => t.name); + expect(restricted).toContain("learn"); + }); }); describe("manage_skill execute", () => { diff --git a/packages/coding-agent/test/memory-backend-resolve.test.ts b/packages/coding-agent/test/memory-backend-resolve.test.ts index a46fe005c..576fd288d 100644 --- a/packages/coding-agent/test/memory-backend-resolve.test.ts +++ b/packages/coding-agent/test/memory-backend-resolve.test.ts @@ -29,7 +29,7 @@ describe("resolveMemoryBackend", () => { }); }); - it("reports local backend runtime status without structured search/save support", async () => { + it("reports local backend runtime status as writable (lessons) without structured search", async () => { const settings = Settings.isolated({ "memory.backend": "local" }); const memory = createMemoryRuntimeContext({ agentDir: "/tmp/agent", @@ -40,7 +40,7 @@ describe("resolveMemoryBackend", () => { await expect(memory.status()).resolves.toMatchObject({ backend: "local", active: true, - writable: false, + writable: true, searchable: false, }); await expect(memory.search("project preference")).resolves.toMatchObject({