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
70 changes: 66 additions & 4 deletions src/cli-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
*/

import path from 'path';
import type { Memory } from './types/index.js';
import type { Memory, MemoryScope } from './types/index.js';
import { CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME } from './constants/codebase-context.js';
import {
appendMemoryFile,
buildMemoryIdentityParts,
readMemoriesFile,
removeMemory,
filterMemories,
normalizeMemoryScope,
withConfidence
} from './memory/store.js';

Expand Down Expand Up @@ -45,7 +47,7 @@ export async function handleMemoryCli(args: string[]): Promise<void> {
const listUsage =
'Usage: codebase-context memory list [--category <cat>] [--type <type>] [--query <text>] [--json]';
const addUsage =
'Usage: codebase-context memory add --type <type> --category <category> --memory <text> --reason <text> [--json]';
'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]';
const removeUsage = 'Usage: codebase-context memory remove <id> [--json]';

const exitWithUsageError = (message: string, usage?: string): never => {
Expand Down Expand Up @@ -134,6 +136,13 @@ export async function handleMemoryCli(args: string[]): Promise<void> {
const staleTag = m.stale ? ' [STALE]' : '';
console.log(`[${m.id}] ${m.type}/${m.category}: ${m.memory}${staleTag}`);
console.log(` Reason: ${m.reason}`);
if (m.scope && m.scope.kind !== 'global') {
if (m.scope.kind === 'file') {
console.log(` Scope: file ${m.scope.file}`);
} else {
console.log(` Scope: symbol ${m.scope.file}#${m.scope.symbol}`);
}
}
console.log(` Date: ${m.date} | Confidence: ${m.effectiveConfidence}`);
console.log('');
}
Expand All @@ -145,6 +154,9 @@ export async function handleMemoryCli(args: string[]): Promise<void> {
let category: CliMemoryCategory | undefined;
let memory: string | undefined;
let reason: string | undefined;
let scopeKind: MemoryScope['kind'] | undefined;
let scopeFile: string | undefined;
let scopeSymbol: string | undefined;

for (let i = 1; i < args.length; i++) {
if (args[i] === '--type') {
Expand Down Expand Up @@ -197,6 +209,34 @@ export async function handleMemoryCli(args: string[]): Promise<void> {
}
reason = value;
i++;
} else if (args[i] === '--scope-kind') {
const value = args[i + 1];
if (!value || value.startsWith('--')) {
exitWithUsageError('Error: --scope-kind requires a value.', addUsage);
}
if (value === 'global' || value === 'file' || value === 'symbol') {
scopeKind = value;
} else {
exitWithUsageError(
'Error: invalid --scope-kind. Allowed: global, file, symbol.',
addUsage
);
}
i++;
} else if (args[i] === '--scope-file') {
const value = args[i + 1];
if (!value || value.startsWith('--')) {
exitWithUsageError('Error: --scope-file requires a value.', addUsage);
}
scopeFile = value;
i++;
} else if (args[i] === '--scope-symbol') {
const value = args[i + 1];
if (!value || value.startsWith('--')) {
exitWithUsageError('Error: --scope-symbol requires a value.', addUsage);
}
scopeSymbol = value;
i++;
} else if (args[i] === '--json') {
// handled above
}
Expand All @@ -210,9 +250,30 @@ export async function handleMemoryCli(args: string[]): Promise<void> {
const requiredCategory = category;
const requiredMemory = memory;
const requiredReason = reason;
const scope = normalizeMemoryScope({
kind: scopeKind,
file: scopeFile,
symbol: scopeSymbol
});

if (scopeKind === 'file' && !scope) {
exitWithUsageError('Error: --scope-kind file requires --scope-file.', addUsage);
}
if (scopeKind === 'symbol' && !scope) {
exitWithUsageError(
'Error: --scope-kind symbol requires --scope-file and --scope-symbol.',
addUsage
);
}

const crypto = await import('crypto');
const hashContent = `${type}:${requiredCategory}:${requiredMemory}:${requiredReason}`;
const hashContent = buildMemoryIdentityParts({
type,
category: requiredCategory,
memory: requiredMemory,
reason: requiredReason,
scope
});
const hash = crypto.createHash('sha256').update(hashContent).digest('hex');
const id = hash.substring(0, 12);

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

Expand Down
61 changes: 58 additions & 3 deletions src/memory/store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { promises as fs } from 'fs';
import path from 'path';
import type { Memory, MemoryCategory, MemoryType } from '../types/index.js';
import type { Memory, MemoryCategory, MemoryScope, MemoryType } from '../types/index.js';

type RawMemory = Partial<{
id: unknown;
Expand All @@ -11,6 +11,7 @@ type RawMemory = Partial<{
reason: unknown;
date: unknown;
source: unknown;
scope: unknown;
}>;

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

function normalizePathLike(value: string): string {
return value.replace(/\\/g, '/').replace(/^\.\//, '');
}

export function normalizeMemoryScope(raw: unknown): MemoryScope | undefined {
if (!isRecord(raw)) return undefined;
const kind = raw.kind;
if (kind === 'global') {
return { kind };
}
if (kind === 'file' && typeof raw.file === 'string' && raw.file.trim()) {
return { kind, file: normalizePathLike(raw.file.trim()) };
}
if (
kind === 'symbol' &&
typeof raw.file === 'string' &&
raw.file.trim() &&
typeof raw.symbol === 'string' &&
raw.symbol.trim()
) {
return {
kind,
file: normalizePathLike(raw.file.trim()),
symbol: raw.symbol.trim()
};
}
return undefined;
}

export function normalizeMemory(raw: unknown): Memory | null {
if (!isRecord(raw)) return null;
const m = raw as RawMemory;
Expand All @@ -42,7 +72,8 @@ export function normalizeMemory(raw: unknown): Memory | null {
if (!id || !category || !memory || !reason || !date) return null;

const source = m.source === 'git' ? ('git' as const) : undefined;
return { id, type, category, memory, reason, date, ...(source && { source }) };
const scope = normalizeMemoryScope(m.scope);
return { id, type, category, memory, reason, date, ...(source && { source }), ...(scope && { scope }) };
}

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

export function formatMemoryScopeText(scope?: MemoryScope): string {
if (!scope || scope.kind === 'global') return '';
if (scope.kind === 'file') {
return scope.file;
}
return `${scope.file} ${scope.symbol}`;
}

export function buildMemoryIdentityParts(memory: {
type: MemoryType;
category: MemoryCategory;
memory: string;
reason: string;
scope?: MemoryScope;
}): string {
const scopePart =
!memory.scope || memory.scope.kind === 'global'
? 'global'
: memory.scope.kind === 'file'
? `file:${normalizePathLike(memory.scope.file)}`
: `symbol:${normalizePathLike(memory.scope.file)}:${memory.scope.symbol}`;
return `${memory.type}:${memory.category}:${memory.memory}:${memory.reason}:${scopePart}`;
}

export function applyUnfilteredLimit(
memories: Memory[],
filters: MemoryFilters,
Expand Down
28 changes: 24 additions & 4 deletions src/tools/remember.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
import type { ToolContext, ToolResponse } from './types.js';
import type { Memory, MemoryCategory, MemoryType } from '../types/index.js';
import { appendMemoryFile } from '../memory/store.js';
import type { Memory, MemoryCategory, MemoryScope, MemoryType } from '../types/index.js';
import { appendMemoryFile, buildMemoryIdentityParts, normalizeMemoryScope } from '../memory/store.js';

export const definition: Tool = {
name: 'remember',
Expand Down Expand Up @@ -39,6 +39,23 @@ export const definition: Tool = {
reason: {
type: 'string',
description: 'Why this matters or what breaks otherwise'
},
scope: {
type: 'object',
description:
'Optional scope for this memory. Use { kind: "file", file } or { kind: "symbol", file, symbol }.',
properties: {
kind: {
type: 'string',
enum: ['global', 'file', 'symbol']
},
file: {
type: 'string'
},
symbol: {
type: 'string'
}
}
}
},
required: ['type', 'category', 'memory', 'reason']
Expand All @@ -54,15 +71,17 @@ export async function handle(
category: MemoryCategory;
memory: string;
reason: string;
scope?: MemoryScope;
};

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

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

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

Expand All @@ -72,7 +91,8 @@ export async function handle(
category,
memory,
reason,
date: new Date().toISOString()
date: new Date().toISOString(),
...(scope && { scope })
};

const result = await appendMemoryFile(memoryPath, newMemory);
Expand Down
Loading
Loading