From cd46cd4b4082909fcd42d2ecc094839034031db6 Mon Sep 17 00:00:00 2001 From: oratis Date: Sun, 31 May 2026 23:27:04 +0800 Subject: [PATCH] feat(memory): path-scoped rules + project memory write-path (#-remember) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two §3.6a gaps from the audit: Path-scoped rules: `.deepcode/rules/*.md` were dumped flat. Now each rule's frontmatter `globs` (or `applyTo`/`paths`) is surfaced in its injected header ("rule: ts.md (applies to: src/**/*.ts)") so the model applies it only when editing matching files, and the frontmatter fence is stripped from the body. Agent/user memory write-path: there was a read path but nothing wrote it (the "# to remember" help was aspirational). - projectMemoryKey(cwd) / projectMemoryPath(home,cwd) → ~/.deepcode/projects//memory/MEMORY.md (mirrors the harness layout). - rememberFact(cwd, fact) appends a bullet (writes a header on first use). - loadMemory now READS that project-memory file too (step 1b). - REPL: a `#` line remembers the fact (no agent turn) — the documented shortcut, now real. Tests: +2 (rule glob annotation + frontmatter strip; rememberFact → loadMemory round-trip with single header). Core 608 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/repl.ts | 15 +++++ packages/core/src/index.ts | 3 + packages/core/src/memory/index.ts | 3 + packages/core/src/memory/loader.test.ts | 51 ++++++++++++++++- packages/core/src/memory/loader.ts | 74 +++++++++++++++++++++---- 5 files changed, 134 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index 2969bf5..3641b3c 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -27,6 +27,7 @@ import { findStyle, installToolSearch, loadMemory, + rememberFact, loadOutputStyles, loadSettings, gateUntrustedSettings, @@ -379,6 +380,20 @@ export async function startRepl(opts: ReplOpts): Promise { if (!userInput.trim()) continue; + // `#` — remember a fact to project memory (no agent turn). + if (userInput.trim().startsWith('#')) { + const fact = userInput.trim().slice(1).trim(); + if (fact) { + try { + const path = await rememberFact(ctx.cwd, fact, opts.home); + output.write(` ✓ Remembered to ${path}\n\n`); + } catch (e) { + output.write(` ⚠ Could not save memory: ${(e as Error).message}\n\n`); + } + } + continue; + } + // Slash command? const match = commands.match(userInput); if (match) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e086c19..8094737 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -139,6 +139,9 @@ export { export { loadMemory, walkUpwards, + rememberFact, + projectMemoryPath, + projectMemoryKey, type MemorySource, type LoadedMemory, type LoadMemoryOpts, diff --git a/packages/core/src/memory/index.ts b/packages/core/src/memory/index.ts index 8631bc6..636c5ac 100644 --- a/packages/core/src/memory/index.ts +++ b/packages/core/src/memory/index.ts @@ -5,6 +5,9 @@ export { loadMemory, walkUpwards, + rememberFact, + projectMemoryPath, + projectMemoryKey, type MemorySource, type LoadedMemory, type LoadMemoryOpts, diff --git a/packages/core/src/memory/loader.test.ts b/packages/core/src/memory/loader.test.ts index 45f2a3a..ac90ea6 100644 --- a/packages/core/src/memory/loader.test.ts +++ b/packages/core/src/memory/loader.test.ts @@ -3,7 +3,13 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { loadMemory, walkUpwards } from './loader.js'; +import { + loadMemory, + projectMemoryKey, + projectMemoryPath, + rememberFact, + walkUpwards, +} from './loader.js'; describe('loadMemory', () => { let home: string; @@ -142,3 +148,46 @@ describe('walkUpwards', () => { expect(dirs).toEqual(['/a']); }); }); + +describe('path-scoped rules + project memory', () => { + let home: string; + let cwd: string; + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'dc-mem2-home-')); + cwd = await mkdtemp(join(tmpdir(), 'dc-mem2-cwd-')); + }); + afterEach(async () => { + await rm(home, { recursive: true, force: true }); + await rm(cwd, { recursive: true, force: true }); + }); + + it("surfaces a rule's path-scope (globs) in its header + strips frontmatter", async () => { + const rulesDir = join(cwd, '.deepcode', 'rules'); + await fs.mkdir(rulesDir, { recursive: true }); + await fs.writeFile( + join(rulesDir, 'ts.md'), + '---\nglobs:\n - "src/**/*.ts"\n - "test/**"\n---\nUse strict types.\n', + ); + const mem = await loadMemory({ cwd, home }); + expect(mem.text).toMatch(/rule: ts\.md \(applies to: src\/\*\*\/\*\.ts, test\/\*\*\)/); + expect(mem.text).toContain('Use strict types.'); + // the raw frontmatter fence should not leak into the memory text + expect(mem.text).not.toContain('globs:'); + }); + + it('rememberFact appends to project memory, which loadMemory then reads back', async () => { + expect(projectMemoryPath(home, cwd)).toBe( + join(home, '.deepcode', 'projects', projectMemoryKey(cwd), 'memory', 'MEMORY.md'), + ); + const p = await rememberFact(cwd, 'prefers tabs over spaces', home); + expect(p).toBe(projectMemoryPath(home, cwd)); + await rememberFact(cwd, 'deploys on fridays', home); + + const mem = await loadMemory({ cwd, home }); + expect(mem.text).toContain('project memory'); + expect(mem.text).toContain('prefers tabs over spaces'); + expect(mem.text).toContain('deploys on fridays'); + // header written once + expect(mem.text.match(/# Project memory/g)?.length).toBe(1); + }); +}); diff --git a/packages/core/src/memory/loader.ts b/packages/core/src/memory/loader.ts index e3f7485..4e07c35 100644 --- a/packages/core/src/memory/loader.ts +++ b/packages/core/src/memory/loader.ts @@ -10,6 +10,39 @@ import { promises as fs } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, isAbsolute, join, resolve, sep } from 'node:path'; +import { parseFrontmatter } from '../skills/frontmatter.js'; + +/** Slugify an absolute project path into a stable per-repo key (mirrors the + * harness layout `~/.deepcode/projects//memory/MEMORY.md`). */ +export function projectMemoryKey(cwd: string): string { + return resolve(cwd).replace(/[/\\]+/g, '-'); +} + +/** Path to the agent/user-written project memory file for `cwd`. */ +export function projectMemoryPath(home: string, cwd: string): string { + return join(home, '.deepcode', 'projects', projectMemoryKey(cwd), 'memory', 'MEMORY.md'); +} + +/** + * Append a remembered fact to the project memory file (the `#` store). Creates + * the file with a header on first write. Returns the file path. + */ +export async function rememberFact( + cwd: string, + fact: string, + home: string = homedir(), +): Promise { + const path = projectMemoryPath(home, cwd); + await fs.mkdir(dirname(path), { recursive: true }); + let header = ''; + try { + await fs.access(path); + } catch { + header = `# Project memory\n\nFacts DeepCode should remember for this project.\n\n`; + } + await fs.appendFile(path, `${header}- ${fact.trim()}\n`, 'utf8'); + return path; +} export interface MemorySource { /** Where the content came from (label only — not for matching). */ @@ -53,17 +86,10 @@ export async function loadMemory(opts: LoadMemoryOpts): Promise { const visited = new Set(); let bytes = 0; - const addFile = async (path: string, label: string, depth: number): Promise => { - const abs = resolve(path); - if (visited.has(abs)) return; // cycle - visited.add(abs); - - const raw = await readMaybe(abs); - if (raw === null) return; - + // Process already-read content: expand @-imports, enforce the byte cap, push. + const addRaw = async (abs: string, label: string, raw: string, depth: number): Promise => { const expanded = depth < maxDepth ? await expandImports(raw, abs, depth + 1, addFile, unresolvedImports) : raw; - if (bytes + expanded.length > maxBytes) { const remaining = Math.max(0, maxBytes - bytes); const truncated = expanded.slice(0, remaining) + '\n... [truncated by memoryLoadCapKB]'; @@ -75,9 +101,21 @@ export async function loadMemory(opts: LoadMemoryOpts): Promise { bytes += expanded.length; }; + const addFile = async (path: string, label: string, depth: number): Promise => { + const abs = resolve(path); + if (visited.has(abs)) return; // cycle + visited.add(abs); + const raw = await readMaybe(abs); + if (raw === null) return; + await addRaw(abs, label, raw, depth); + }; + // 1. ~/.deepcode/DEEPCODE.md (user-level) await addFile(join(home, '.deepcode', 'DEEPCODE.md'), 'user memory', 0); + // 1b. Agent/user-written project memory (the `#` remember store). + await addFile(projectMemoryPath(home, opts.cwd), 'project memory', 0); + // 2. DEEPCODE.md walking from cwd → root, deepest first const upwards = walkUpwards(opts.cwd, home); // Reverse so root-most first, deepest last (later overrides via concat — Claude Code semantics) @@ -88,12 +126,26 @@ export async function loadMemory(opts: LoadMemoryOpts): Promise { // 3. AGENTS.md (project root only — co-located with DEEPCODE.md) await addFile(join(opts.cwd, 'AGENTS.md'), 'AGENTS.md (cross-tool)', 0); - // 4. .deepcode/rules/*.md (path-scoped frontmatter — M3 loads all; gating M4) + // 4. .deepcode/rules/*.md — path-scoped via frontmatter. A rule's `globs` + // (or `applyTo`/`paths`) is surfaced in its header so the model applies it + // only when editing matching files; the frontmatter block itself is stripped. const rulesDir = join(opts.cwd, '.deepcode', 'rules'); try { const entries = await fs.readdir(rulesDir); for (const e of entries.sort()) { - if (e.endsWith('.md')) await addFile(join(rulesDir, e), `rule: ${e}`, 0); + if (!e.endsWith('.md')) continue; + const rulePath = resolve(join(rulesDir, e)); + if (visited.has(rulePath)) continue; + visited.add(rulePath); + const raw = await readMaybe(rulePath); + if (raw === null) continue; + const { fields, body } = parseFrontmatter(raw); + const globs = fields.globs ?? fields.applyTo ?? fields.paths; + const scope = + globs !== undefined + ? ` (applies to: ${Array.isArray(globs) ? globs.join(', ') : String(globs)})` + : ''; + await addRaw(rulePath, `rule: ${e}${scope}`, body.trim() || raw, 0); } } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;