Skip to content
Merged
9 changes: 6 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/filesystem/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"@modelcontextprotocol/sdk": "^1.30.0",
"diff": "^8.0.3",
"glob": "^13.0.6",
"minimatch": "^10.0.1"
"minimatch": "^10.0.1",
"zod": "^4.0.0"
},
"devDependencies": {
"@types/diff": "^5.0.9",
Expand Down
6 changes: 3 additions & 3 deletions src/memory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,15 @@ Example:
- Remove entities and their relations
- Input: `entityNames` (string[])
- Cascading deletion of associated relations
- Silent operation if entity doesn't exist
- No error if an entity doesn't exist; the response reports which names were not found

- **delete_observations**
- Remove specific observations from entities
- Input: `deletions` (array of objects)
- Each object contains:
- `entityName` (string): Target entity
- `observations` (string[]): Observations to remove
- Silent operation if observation doesn't exist
- No error if an observation doesn't exist; the response reports how many were deleted

- **delete_relations**
- Remove specific relations from the graph
Expand All @@ -104,7 +104,7 @@ Example:
- `from` (string): Source entity name
- `to` (string): Target entity name
- `relationType` (string): Relationship type
- Silent operation if relation doesn't exist
- No error if a relation doesn't exist; the response reports how many were deleted

- **read_graph**
- Read the entire knowledge graph
Expand Down
99 changes: 99 additions & 0 deletions src/memory/__tests__/delete-reporting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { KnowledgeGraphManager, Entity, Relation } from '../index.js';

/**
* The delete tools stay silent when a target is absent, which the README
* documents. What they must not do is report a deletion that did not happen:
* an agent that mistypes a name is told its memory is clean while the data is
* still on disk, and nothing in the response contradicts that.
*/
describe('delete reporting', () => {
let manager: KnowledgeGraphManager;
let testFilePath: string;

const entities: Entity[] = [
{ name: 'Alice', entityType: 'person', observations: ['works at Acme Corp', 'likes tea'] },
{ name: 'Bob', entityType: 'person', observations: ['likes programming'] },
];
const relations: Relation[] = [{ from: 'Alice', to: 'Bob', relationType: 'works_with' }];

beforeEach(async () => {
testFilePath = path.join(
path.dirname(fileURLToPath(import.meta.url)),
`test-delete-reporting-${Date.now()}-${Math.random().toString(16).slice(2)}.jsonl`
);
manager = new KnowledgeGraphManager(testFilePath);
await manager.createEntities(entities);
await manager.createRelations(relations);
});

afterEach(async () => {
try {
await fs.unlink(testFilePath);
} catch {
// the file is gone already
}
});

describe('deleteEntities', () => {
it('reports which names matched and which did not', async () => {
const result = await manager.deleteEntities(['Alice', 'Alise']);
expect(result).toEqual({ deleted: ['Alice'], notFound: ['Alise'] });
});

it('reports nothing deleted when no name matches', async () => {
const result = await manager.deleteEntities(['Nobody']);
expect(result).toEqual({ deleted: [], notFound: ['Nobody'] });

const graph = await manager.readGraph();
expect(graph.entities).toHaveLength(2);
});

it('still deletes the entity and its relations', async () => {
await manager.deleteEntities(['Alice']);

const graph = await manager.readGraph();
expect(graph.entities.map(e => e.name)).toEqual(['Bob']);
expect(graph.relations).toHaveLength(0);
});
});

describe('deleteObservations', () => {
it('counts only the observations that were present', async () => {
const result = await manager.deleteObservations([
{ entityName: 'Alice', observations: ['likes tea', 'never said this'] },
]);
expect(result).toEqual({ deletedCount: 1, missingEntities: [] });
});

it('names an entity that does not exist', async () => {
const result = await manager.deleteObservations([
{ entityName: 'Carol', observations: ['anything'] },
]);
expect(result).toEqual({ deletedCount: 0, missingEntities: ['Carol'] });
});
});

describe('deleteRelations', () => {
it('counts only the relations that matched', async () => {
const result = await manager.deleteRelations([
{ from: 'Alice', to: 'Bob', relationType: 'works_with' },
{ from: 'Alice', to: 'Bob', relationType: 'never_existed' },
]);
expect(result).toEqual({ deletedCount: 1 });
});

it('reports nothing deleted when the relation type is wrong', async () => {
const result = await manager.deleteRelations([
{ from: 'Alice', to: 'Bob', relationType: 'manages' },
]);
expect(result).toEqual({ deletedCount: 0 });

const graph = await manager.readGraph();
expect(graph.relations).toHaveLength(1);
});
});
});
38 changes: 37 additions & 1 deletion src/memory/__tests__/file-path.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url';
import { ensureMemoryFilePath, defaultMemoryPath } from '../index.js';
import { ensureMemoryFilePath, defaultMemoryPath, expandHome } from '../index.js';

describe('ensureMemoryFilePath', () => {
const testDir = path.dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -72,6 +73,15 @@ describe('ensureMemoryFilePath', () => {
expect(path.isAbsolute(result)).toBe(true);
}
});

it('should expand a leading "~/" to the home directory', async () => {
process.env.MEMORY_FILE_PATH = '~/custom-memory.jsonl';

const result = await ensureMemoryFilePath();

expect(result).toBe(path.join(os.homedir(), 'custom-memory.jsonl'));
expect(path.isAbsolute(result)).toBe(true);
});
});

describe('without MEMORY_FILE_PATH environment variable', () => {
Expand Down Expand Up @@ -154,3 +164,29 @@ describe('ensureMemoryFilePath', () => {
});
});
});

describe('expandHome', () => {
it('expands a bare "~" to the home directory', () => {
expect(expandHome('~')).toBe(os.homedir());
});

it('expands a leading "~/" to the home directory', () => {
expect(expandHome('~/notes/memory.jsonl')).toBe(
path.join(os.homedir(), 'notes/memory.jsonl')
);
});

it('leaves a "~" not followed by a separator unchanged', () => {
expect(expandHome('~backup.jsonl')).toBe('~backup.jsonl');
});

it('leaves absolute paths unchanged', () => {
expect(expandHome('/var/data/memory.jsonl')).toBe('/var/data/memory.jsonl');
});

it('leaves relative paths unchanged', () => {
expect(expandHome(path.join('data', 'memory.jsonl'))).toBe(
path.join('data', 'memory.jsonl')
);
});
});
Loading