Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/cli/src/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
findStyle,
installToolSearch,
loadMemory,
rememberFact,
loadOutputStyles,
loadSettings,
gateUntrustedSettings,
Expand Down Expand Up @@ -379,6 +380,20 @@ export async function startRepl(opts: ReplOpts): Promise<number> {

if (!userInput.trim()) continue;

// `#<text>` — 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) {
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ export {
export {
loadMemory,
walkUpwards,
rememberFact,
projectMemoryPath,
projectMemoryKey,
type MemorySource,
type LoadedMemory,
type LoadMemoryOpts,
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/memory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
export {
loadMemory,
walkUpwards,
rememberFact,
projectMemoryPath,
projectMemoryKey,
type MemorySource,
type LoadedMemory,
type LoadMemoryOpts,
Expand Down
51 changes: 50 additions & 1 deletion packages/core/src/memory/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});
});
74 changes: 63 additions & 11 deletions packages/core/src/memory/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<key>/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<string> {
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). */
Expand Down Expand Up @@ -53,17 +86,10 @@ export async function loadMemory(opts: LoadMemoryOpts): Promise<LoadedMemory> {
const visited = new Set<string>();
let bytes = 0;

const addFile = async (path: string, label: string, depth: number): Promise<void> => {
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<void> => {
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]';
Expand All @@ -75,9 +101,21 @@ export async function loadMemory(opts: LoadMemoryOpts): Promise<LoadedMemory> {
bytes += expanded.length;
};

const addFile = async (path: string, label: string, depth: number): Promise<void> => {
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)
Expand All @@ -88,12 +126,26 @@ export async function loadMemory(opts: LoadMemoryOpts): Promise<LoadedMemory> {
// 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;
Expand Down
Loading