Skip to content

Commit cbd1fde

Browse files
committed
feat(memory): add scoped memory retrieval
1 parent 0458be8 commit cbd1fde

7 files changed

Lines changed: 419 additions & 24 deletions

File tree

src/cli-memory.ts

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
*/
44

55
import path from 'path';
6-
import type { Memory } from './types/index.js';
6+
import type { Memory, MemoryScope } from './types/index.js';
77
import { CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME } from './constants/codebase-context.js';
88
import {
99
appendMemoryFile,
10+
buildMemoryIdentityParts,
1011
readMemoriesFile,
1112
removeMemory,
1213
filterMemories,
14+
normalizeMemoryScope,
1315
withConfidence
1416
} from './memory/store.js';
1517

@@ -45,7 +47,7 @@ export async function handleMemoryCli(args: string[]): Promise<void> {
4547
const listUsage =
4648
'Usage: codebase-context memory list [--category <cat>] [--type <type>] [--query <text>] [--json]';
4749
const addUsage =
48-
'Usage: codebase-context memory add --type <type> --category <category> --memory <text> --reason <text> [--json]';
50+
'Usage: codebase-context memory add --type <type> --category <category> --memory <text> --reason <text> [--scope-kind global|file|symbol] [--scope-file <path>] [--scope-symbol <name>] [--json]';
4951
const removeUsage = 'Usage: codebase-context memory remove <id> [--json]';
5052

5153
const exitWithUsageError = (message: string, usage?: string): never => {
@@ -134,6 +136,13 @@ export async function handleMemoryCli(args: string[]): Promise<void> {
134136
const staleTag = m.stale ? ' [STALE]' : '';
135137
console.log(`[${m.id}] ${m.type}/${m.category}: ${m.memory}${staleTag}`);
136138
console.log(` Reason: ${m.reason}`);
139+
if (m.scope && m.scope.kind !== 'global') {
140+
if (m.scope.kind === 'file') {
141+
console.log(` Scope: file ${m.scope.file}`);
142+
} else {
143+
console.log(` Scope: symbol ${m.scope.file}#${m.scope.symbol}`);
144+
}
145+
}
137146
console.log(` Date: ${m.date} | Confidence: ${m.effectiveConfidence}`);
138147
console.log('');
139148
}
@@ -145,6 +154,9 @@ export async function handleMemoryCli(args: string[]): Promise<void> {
145154
let category: CliMemoryCategory | undefined;
146155
let memory: string | undefined;
147156
let reason: string | undefined;
157+
let scopeKind: MemoryScope['kind'] | undefined;
158+
let scopeFile: string | undefined;
159+
let scopeSymbol: string | undefined;
148160

149161
for (let i = 1; i < args.length; i++) {
150162
if (args[i] === '--type') {
@@ -197,6 +209,34 @@ export async function handleMemoryCli(args: string[]): Promise<void> {
197209
}
198210
reason = value;
199211
i++;
212+
} else if (args[i] === '--scope-kind') {
213+
const value = args[i + 1];
214+
if (!value || value.startsWith('--')) {
215+
exitWithUsageError('Error: --scope-kind requires a value.', addUsage);
216+
}
217+
if (value === 'global' || value === 'file' || value === 'symbol') {
218+
scopeKind = value;
219+
} else {
220+
exitWithUsageError(
221+
'Error: invalid --scope-kind. Allowed: global, file, symbol.',
222+
addUsage
223+
);
224+
}
225+
i++;
226+
} else if (args[i] === '--scope-file') {
227+
const value = args[i + 1];
228+
if (!value || value.startsWith('--')) {
229+
exitWithUsageError('Error: --scope-file requires a value.', addUsage);
230+
}
231+
scopeFile = value;
232+
i++;
233+
} else if (args[i] === '--scope-symbol') {
234+
const value = args[i + 1];
235+
if (!value || value.startsWith('--')) {
236+
exitWithUsageError('Error: --scope-symbol requires a value.', addUsage);
237+
}
238+
scopeSymbol = value;
239+
i++;
200240
} else if (args[i] === '--json') {
201241
// handled above
202242
}
@@ -210,9 +250,30 @@ export async function handleMemoryCli(args: string[]): Promise<void> {
210250
const requiredCategory = category;
211251
const requiredMemory = memory;
212252
const requiredReason = reason;
253+
const scope = normalizeMemoryScope({
254+
kind: scopeKind,
255+
file: scopeFile,
256+
symbol: scopeSymbol
257+
});
258+
259+
if (scopeKind === 'file' && !scope) {
260+
exitWithUsageError('Error: --scope-kind file requires --scope-file.', addUsage);
261+
}
262+
if (scopeKind === 'symbol' && !scope) {
263+
exitWithUsageError(
264+
'Error: --scope-kind symbol requires --scope-file and --scope-symbol.',
265+
addUsage
266+
);
267+
}
213268

214269
const crypto = await import('crypto');
215-
const hashContent = `${type}:${requiredCategory}:${requiredMemory}:${requiredReason}`;
270+
const hashContent = buildMemoryIdentityParts({
271+
type,
272+
category: requiredCategory,
273+
memory: requiredMemory,
274+
reason: requiredReason,
275+
scope
276+
});
216277
const hash = crypto.createHash('sha256').update(hashContent).digest('hex');
217278
const id = hash.substring(0, 12);
218279

@@ -222,7 +283,8 @@ export async function handleMemoryCli(args: string[]): Promise<void> {
222283
category: requiredCategory,
223284
memory: requiredMemory,
224285
reason: requiredReason,
225-
date: new Date().toISOString()
286+
date: new Date().toISOString(),
287+
...(scope && { scope })
226288
};
227289
const result = await appendMemoryFile(memoryPath, newMemory);
228290

src/memory/store.ts

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { promises as fs } from 'fs';
22
import path from 'path';
3-
import type { Memory, MemoryCategory, MemoryType } from '../types/index.js';
3+
import type { Memory, MemoryCategory, MemoryScope, MemoryType } from '../types/index.js';
44

55
type RawMemory = Partial<{
66
id: unknown;
@@ -11,6 +11,7 @@ type RawMemory = Partial<{
1111
reason: unknown;
1212
date: unknown;
1313
source: unknown;
14+
scope: unknown;
1415
}>;
1516

1617
export type MemoryFilters = {
@@ -23,6 +24,35 @@ function isRecord(value: unknown): value is Record<string, unknown> {
2324
return typeof value === 'object' && value !== null;
2425
}
2526

27+
function normalizePathLike(value: string): string {
28+
return value.replace(/\\/g, '/').replace(/^\.\//, '');
29+
}
30+
31+
export function normalizeMemoryScope(raw: unknown): MemoryScope | undefined {
32+
if (!isRecord(raw)) return undefined;
33+
const kind = raw.kind;
34+
if (kind === 'global') {
35+
return { kind };
36+
}
37+
if (kind === 'file' && typeof raw.file === 'string' && raw.file.trim()) {
38+
return { kind, file: normalizePathLike(raw.file.trim()) };
39+
}
40+
if (
41+
kind === 'symbol' &&
42+
typeof raw.file === 'string' &&
43+
raw.file.trim() &&
44+
typeof raw.symbol === 'string' &&
45+
raw.symbol.trim()
46+
) {
47+
return {
48+
kind,
49+
file: normalizePathLike(raw.file.trim()),
50+
symbol: raw.symbol.trim()
51+
};
52+
}
53+
return undefined;
54+
}
55+
2656
export function normalizeMemory(raw: unknown): Memory | null {
2757
if (!isRecord(raw)) return null;
2858
const m = raw as RawMemory;
@@ -42,7 +72,8 @@ export function normalizeMemory(raw: unknown): Memory | null {
4272
if (!id || !category || !memory || !reason || !date) return null;
4373

4474
const source = m.source === 'git' ? ('git' as const) : undefined;
45-
return { id, type, category, memory, reason, date, ...(source && { source }) };
75+
const scope = normalizeMemoryScope(m.scope);
76+
return { id, type, category, memory, reason, date, ...(source && { source }), ...(scope && { scope }) };
4677
}
4778

4879
export function normalizeMemories(raw: unknown): Memory[] {
@@ -104,7 +135,7 @@ export function filterMemories(memories: Memory[], filters: MemoryFilters): Memo
104135
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
105136
if (terms.length > 0) {
106137
filtered = filtered.filter((m) => {
107-
const haystack = `${m.memory} ${m.reason}`.toLowerCase();
138+
const haystack = `${m.memory} ${m.reason} ${formatMemoryScopeText(m.scope)}`.toLowerCase();
108139
return terms.some((t) => haystack.includes(t));
109140
});
110141
}
@@ -175,6 +206,30 @@ export function withConfidence(memories: Memory[], now?: Date): MemoryWithConfid
175206
}));
176207
}
177208

209+
export function formatMemoryScopeText(scope?: MemoryScope): string {
210+
if (!scope || scope.kind === 'global') return '';
211+
if (scope.kind === 'file') {
212+
return scope.file;
213+
}
214+
return `${scope.file} ${scope.symbol}`;
215+
}
216+
217+
export function buildMemoryIdentityParts(memory: {
218+
type: MemoryType;
219+
category: MemoryCategory;
220+
memory: string;
221+
reason: string;
222+
scope?: MemoryScope;
223+
}): string {
224+
const scopePart =
225+
!memory.scope || memory.scope.kind === 'global'
226+
? 'global'
227+
: memory.scope.kind === 'file'
228+
? `file:${normalizePathLike(memory.scope.file)}`
229+
: `symbol:${normalizePathLike(memory.scope.file)}:${memory.scope.symbol}`;
230+
return `${memory.type}:${memory.category}:${memory.memory}:${memory.reason}:${scopePart}`;
231+
}
232+
178233
export function applyUnfilteredLimit(
179234
memories: Memory[],
180235
filters: MemoryFilters,

src/tools/remember.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
22
import type { ToolContext, ToolResponse } from './types.js';
3-
import type { Memory, MemoryCategory, MemoryType } from '../types/index.js';
4-
import { appendMemoryFile } from '../memory/store.js';
3+
import type { Memory, MemoryCategory, MemoryScope, MemoryType } from '../types/index.js';
4+
import { appendMemoryFile, buildMemoryIdentityParts, normalizeMemoryScope } from '../memory/store.js';
55

66
export const definition: Tool = {
77
name: 'remember',
@@ -39,6 +39,23 @@ export const definition: Tool = {
3939
reason: {
4040
type: 'string',
4141
description: 'Why this matters or what breaks otherwise'
42+
},
43+
scope: {
44+
type: 'object',
45+
description:
46+
'Optional scope for this memory. Use { kind: "file", file } or { kind: "symbol", file, symbol }.',
47+
properties: {
48+
kind: {
49+
type: 'string',
50+
enum: ['global', 'file', 'symbol']
51+
},
52+
file: {
53+
type: 'string'
54+
},
55+
symbol: {
56+
type: 'string'
57+
}
58+
}
4259
}
4360
},
4461
required: ['type', 'category', 'memory', 'reason']
@@ -54,15 +71,17 @@ export async function handle(
5471
category: MemoryCategory;
5572
memory: string;
5673
reason: string;
74+
scope?: MemoryScope;
5775
};
5876

5977
const { type = 'decision', category, memory, reason } = args_typed;
78+
const scope = normalizeMemoryScope(args_typed.scope);
6079

6180
try {
6281
const crypto = await import('crypto');
6382
const memoryPath = ctx.paths.memory;
6483

65-
const hashContent = `${type}:${category}:${memory}:${reason}`;
84+
const hashContent = buildMemoryIdentityParts({ type, category, memory, reason, scope });
6685
const hash = crypto.createHash('sha256').update(hashContent).digest('hex');
6786
const id = hash.substring(0, 12);
6887

@@ -72,7 +91,8 @@ export async function handle(
7291
category,
7392
memory,
7493
reason,
75-
date: new Date().toISOString()
94+
date: new Date().toISOString(),
95+
...(scope && { scope })
7696
};
7797

7898
const result = await appendMemoryFile(memoryPath, newMemory);

0 commit comments

Comments
 (0)