Skip to content

Commit cd46cd4

Browse files
oratisclaude
andcommitted
feat(memory): path-scoped rules + project memory write-path (#-remember)
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/<slug>/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 `#<text>` 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) <noreply@anthropic.com>
1 parent a38e4a7 commit cd46cd4

5 files changed

Lines changed: 134 additions & 12 deletions

File tree

apps/cli/src/repl.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
findStyle,
2828
installToolSearch,
2929
loadMemory,
30+
rememberFact,
3031
loadOutputStyles,
3132
loadSettings,
3233
gateUntrustedSettings,
@@ -379,6 +380,20 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
379380

380381
if (!userInput.trim()) continue;
381382

383+
// `#<text>` — remember a fact to project memory (no agent turn).
384+
if (userInput.trim().startsWith('#')) {
385+
const fact = userInput.trim().slice(1).trim();
386+
if (fact) {
387+
try {
388+
const path = await rememberFact(ctx.cwd, fact, opts.home);
389+
output.write(` ✓ Remembered to ${path}\n\n`);
390+
} catch (e) {
391+
output.write(` ⚠ Could not save memory: ${(e as Error).message}\n\n`);
392+
}
393+
}
394+
continue;
395+
}
396+
382397
// Slash command?
383398
const match = commands.match(userInput);
384399
if (match) {

packages/core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ export {
139139
export {
140140
loadMemory,
141141
walkUpwards,
142+
rememberFact,
143+
projectMemoryPath,
144+
projectMemoryKey,
142145
type MemorySource,
143146
type LoadedMemory,
144147
type LoadMemoryOpts,

packages/core/src/memory/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
export {
66
loadMemory,
77
walkUpwards,
8+
rememberFact,
9+
projectMemoryPath,
10+
projectMemoryKey,
811
type MemorySource,
912
type LoadedMemory,
1013
type LoadMemoryOpts,

packages/core/src/memory/loader.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import { mkdtemp, rm } from 'node:fs/promises';
33
import { tmpdir } from 'node:os';
44
import { join } from 'node:path';
55
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6-
import { loadMemory, walkUpwards } from './loader.js';
6+
import {
7+
loadMemory,
8+
projectMemoryKey,
9+
projectMemoryPath,
10+
rememberFact,
11+
walkUpwards,
12+
} from './loader.js';
713

814
describe('loadMemory', () => {
915
let home: string;
@@ -142,3 +148,46 @@ describe('walkUpwards', () => {
142148
expect(dirs).toEqual(['/a']);
143149
});
144150
});
151+
152+
describe('path-scoped rules + project memory', () => {
153+
let home: string;
154+
let cwd: string;
155+
beforeEach(async () => {
156+
home = await mkdtemp(join(tmpdir(), 'dc-mem2-home-'));
157+
cwd = await mkdtemp(join(tmpdir(), 'dc-mem2-cwd-'));
158+
});
159+
afterEach(async () => {
160+
await rm(home, { recursive: true, force: true });
161+
await rm(cwd, { recursive: true, force: true });
162+
});
163+
164+
it("surfaces a rule's path-scope (globs) in its header + strips frontmatter", async () => {
165+
const rulesDir = join(cwd, '.deepcode', 'rules');
166+
await fs.mkdir(rulesDir, { recursive: true });
167+
await fs.writeFile(
168+
join(rulesDir, 'ts.md'),
169+
'---\nglobs:\n - "src/**/*.ts"\n - "test/**"\n---\nUse strict types.\n',
170+
);
171+
const mem = await loadMemory({ cwd, home });
172+
expect(mem.text).toMatch(/rule: ts\.md \(applies to: src\/\*\*\/\*\.ts, test\/\*\*\)/);
173+
expect(mem.text).toContain('Use strict types.');
174+
// the raw frontmatter fence should not leak into the memory text
175+
expect(mem.text).not.toContain('globs:');
176+
});
177+
178+
it('rememberFact appends to project memory, which loadMemory then reads back', async () => {
179+
expect(projectMemoryPath(home, cwd)).toBe(
180+
join(home, '.deepcode', 'projects', projectMemoryKey(cwd), 'memory', 'MEMORY.md'),
181+
);
182+
const p = await rememberFact(cwd, 'prefers tabs over spaces', home);
183+
expect(p).toBe(projectMemoryPath(home, cwd));
184+
await rememberFact(cwd, 'deploys on fridays', home);
185+
186+
const mem = await loadMemory({ cwd, home });
187+
expect(mem.text).toContain('project memory');
188+
expect(mem.text).toContain('prefers tabs over spaces');
189+
expect(mem.text).toContain('deploys on fridays');
190+
// header written once
191+
expect(mem.text.match(/# Project memory/g)?.length).toBe(1);
192+
});
193+
});

packages/core/src/memory/loader.ts

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,39 @@
1010
import { promises as fs } from 'node:fs';
1111
import { homedir } from 'node:os';
1212
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
13+
import { parseFrontmatter } from '../skills/frontmatter.js';
14+
15+
/** Slugify an absolute project path into a stable per-repo key (mirrors the
16+
* harness layout `~/.deepcode/projects/<key>/memory/MEMORY.md`). */
17+
export function projectMemoryKey(cwd: string): string {
18+
return resolve(cwd).replace(/[/\\]+/g, '-');
19+
}
20+
21+
/** Path to the agent/user-written project memory file for `cwd`. */
22+
export function projectMemoryPath(home: string, cwd: string): string {
23+
return join(home, '.deepcode', 'projects', projectMemoryKey(cwd), 'memory', 'MEMORY.md');
24+
}
25+
26+
/**
27+
* Append a remembered fact to the project memory file (the `#` store). Creates
28+
* the file with a header on first write. Returns the file path.
29+
*/
30+
export async function rememberFact(
31+
cwd: string,
32+
fact: string,
33+
home: string = homedir(),
34+
): Promise<string> {
35+
const path = projectMemoryPath(home, cwd);
36+
await fs.mkdir(dirname(path), { recursive: true });
37+
let header = '';
38+
try {
39+
await fs.access(path);
40+
} catch {
41+
header = `# Project memory\n\nFacts DeepCode should remember for this project.\n\n`;
42+
}
43+
await fs.appendFile(path, `${header}- ${fact.trim()}\n`, 'utf8');
44+
return path;
45+
}
1346

1447
export interface MemorySource {
1548
/** Where the content came from (label only — not for matching). */
@@ -53,17 +86,10 @@ export async function loadMemory(opts: LoadMemoryOpts): Promise<LoadedMemory> {
5386
const visited = new Set<string>();
5487
let bytes = 0;
5588

56-
const addFile = async (path: string, label: string, depth: number): Promise<void> => {
57-
const abs = resolve(path);
58-
if (visited.has(abs)) return; // cycle
59-
visited.add(abs);
60-
61-
const raw = await readMaybe(abs);
62-
if (raw === null) return;
63-
89+
// Process already-read content: expand @-imports, enforce the byte cap, push.
90+
const addRaw = async (abs: string, label: string, raw: string, depth: number): Promise<void> => {
6491
const expanded =
6592
depth < maxDepth ? await expandImports(raw, abs, depth + 1, addFile, unresolvedImports) : raw;
66-
6793
if (bytes + expanded.length > maxBytes) {
6894
const remaining = Math.max(0, maxBytes - bytes);
6995
const truncated = expanded.slice(0, remaining) + '\n... [truncated by memoryLoadCapKB]';
@@ -75,9 +101,21 @@ export async function loadMemory(opts: LoadMemoryOpts): Promise<LoadedMemory> {
75101
bytes += expanded.length;
76102
};
77103

104+
const addFile = async (path: string, label: string, depth: number): Promise<void> => {
105+
const abs = resolve(path);
106+
if (visited.has(abs)) return; // cycle
107+
visited.add(abs);
108+
const raw = await readMaybe(abs);
109+
if (raw === null) return;
110+
await addRaw(abs, label, raw, depth);
111+
};
112+
78113
// 1. ~/.deepcode/DEEPCODE.md (user-level)
79114
await addFile(join(home, '.deepcode', 'DEEPCODE.md'), 'user memory', 0);
80115

116+
// 1b. Agent/user-written project memory (the `#` remember store).
117+
await addFile(projectMemoryPath(home, opts.cwd), 'project memory', 0);
118+
81119
// 2. DEEPCODE.md walking from cwd → root, deepest first
82120
const upwards = walkUpwards(opts.cwd, home);
83121
// 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<LoadedMemory> {
88126
// 3. AGENTS.md (project root only — co-located with DEEPCODE.md)
89127
await addFile(join(opts.cwd, 'AGENTS.md'), 'AGENTS.md (cross-tool)', 0);
90128

91-
// 4. .deepcode/rules/*.md (path-scoped frontmatter — M3 loads all; gating M4)
129+
// 4. .deepcode/rules/*.md — path-scoped via frontmatter. A rule's `globs`
130+
// (or `applyTo`/`paths`) is surfaced in its header so the model applies it
131+
// only when editing matching files; the frontmatter block itself is stripped.
92132
const rulesDir = join(opts.cwd, '.deepcode', 'rules');
93133
try {
94134
const entries = await fs.readdir(rulesDir);
95135
for (const e of entries.sort()) {
96-
if (e.endsWith('.md')) await addFile(join(rulesDir, e), `rule: ${e}`, 0);
136+
if (!e.endsWith('.md')) continue;
137+
const rulePath = resolve(join(rulesDir, e));
138+
if (visited.has(rulePath)) continue;
139+
visited.add(rulePath);
140+
const raw = await readMaybe(rulePath);
141+
if (raw === null) continue;
142+
const { fields, body } = parseFrontmatter(raw);
143+
const globs = fields.globs ?? fields.applyTo ?? fields.paths;
144+
const scope =
145+
globs !== undefined
146+
? ` (applies to: ${Array.isArray(globs) ? globs.join(', ') : String(globs)})`
147+
: '';
148+
await addRaw(rulePath, `rule: ${e}${scope}`, body.trim() || raw, 0);
97149
}
98150
} catch (err) {
99151
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;

0 commit comments

Comments
 (0)