Skip to content

Commit 948d32e

Browse files
committed
fix: use UUID for entity IDs, fix overdue date calculation
Replace slug-based ID generation with crypto.randomUUID() for tasks, notes, and skills. Existing slug IDs remain valid — no migration needed. Fix due date display bug where tasks due today showed as "Overdue 361d" by normalizing both dates to midnight before comparison.
1 parent 75115c0 commit 948d32e

15 files changed

Lines changed: 413 additions & 331 deletions

src/graphs/knowledge-types.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@ export function createKnowledgeGraph(): KnowledgeGraph {
3030
});
3131
}
3232

33+
import { randomUUID } from 'crypto';
34+
3335
/**
34-
* Generate a slug ID from a title: lowercase, spaces → hyphens, strip non-alphanumeric.
35-
* Dedup with ::2, ::3, etc. if the ID already exists in the graph.
36+
* Generate a UUID v4 entity ID.
37+
* @deprecated Use generateId() for new entities. Legacy slug-based IDs remain valid.
3638
*/
3739
export function slugify(title: string, graph: { hasNode(id: string): boolean }): string {
3840
const base = title
@@ -51,3 +53,8 @@ export function slugify(title: string, graph: { hasNode(id: string): boolean }):
5153
while (graph.hasNode(`${base}::${n}`)) n++;
5254
return `${base}::${n}`;
5355
}
56+
57+
/** Generate a UUID v4 entity ID. */
58+
export function generateId(): string {
59+
return randomUUID();
60+
}

src/graphs/knowledge.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from 'fs';
22
import path from 'path';
33
import type { KnowledgeGraph, KnowledgeNodeAttributes, KnowledgeEdgeAttributes, CrossGraphType } from '@/graphs/knowledge-types';
4-
import { createKnowledgeGraph, slugify } from '@/graphs/knowledge-types';
4+
import { createKnowledgeGraph, generateId } from '@/graphs/knowledge-types';
55
import type { DirectedGraph } from 'graphology';
66
import type { EmbedFns, GraphManagerContext, ExternalGraphs } from '@/graphs/manager-types';
77
import { resolveExternalGraph, VersionConflictError } from '@/graphs/manager-types';
@@ -102,7 +102,7 @@ export function createNote(
102102
embedding: number[],
103103
author = '',
104104
): string {
105-
const id = slugify(title, graph);
105+
const id = generateId();
106106
const now = Date.now();
107107
graph.addNode(id, {
108108
title,

src/graphs/skill.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import path from 'path';
33
import type { SkillGraph, SkillNodeAttributes, SkillEdgeAttributes, SkillCrossGraphType, SkillSource } from '@/graphs/skill-types';
44
import type { AttachmentMeta } from '@/graphs/attachment-types';
55
import { createSkillGraph } from '@/graphs/skill-types';
6-
import { slugify } from '@/graphs/knowledge-types';
6+
import { generateId } from '@/graphs/knowledge-types';
77
import type { DirectedGraph } from 'graphology';
88
import type { EmbedFns, GraphManagerContext, ExternalGraphs } from '@/graphs/manager-types';
99
import { resolveExternalGraph, VersionConflictError } from '@/graphs/manager-types';
@@ -116,7 +116,7 @@ export function createSkill(
116116
embedding: number[],
117117
author = '',
118118
): string {
119-
const id = slugify(title, graph);
119+
const id = generateId();
120120
const now = Date.now();
121121
graph.addNode(id, {
122122
title,

src/graphs/task.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import path from 'path';
33
import type { TaskGraph, TaskNodeAttributes, TaskEdgeAttributes, TaskCrossGraphType, TaskStatus, TaskPriority } from '@/graphs/task-types';
44
import type { AttachmentMeta } from '@/graphs/attachment-types';
55
import { createTaskGraph, PRIORITY_ORDER } from '@/graphs/task-types';
6-
import { slugify } from '@/graphs/knowledge-types';
6+
import { generateId } from '@/graphs/knowledge-types';
77
import type { DirectedGraph } from 'graphology';
88
import type { EmbedFns, GraphManagerContext, ExternalGraphs } from '@/graphs/manager-types';
99
import { resolveExternalGraph, VersionConflictError } from '@/graphs/manager-types';
@@ -113,7 +113,7 @@ export function createTask(
113113
author = '',
114114
assignee: string | null = null,
115115
): string {
116-
const id = slugify(title, graph);
116+
const id = generateId();
117117
const now = Date.now();
118118
graph.addNode(id, {
119119
title,

src/tests/knowledge-graph.test.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ describe('CRUD — Notes', () => {
5959
let id3: string;
6060

6161
describe('createNote', () => {
62-
it('returns slug id', () => {
62+
it('returns uuid id', () => {
6363
id1 = createNote(g, 'Auth uses JWT', 'The system authenticates via JWT tokens.', ['auth', 'security'], unitVec(0));
64-
expect(id1).toBe('auth-uses-jwt');
64+
expect(id1).toMatch(/^[0-9a-f]{8}-/);
6565
});
6666

6767
it('node exists', () => {
@@ -94,12 +94,12 @@ describe('CRUD — Notes', () => {
9494

9595
it('second note created', () => {
9696
id2 = createNote(g, 'Database is Postgres', 'We use PostgreSQL 15.', ['infra'], unitVec(1));
97-
expect(id2).toBe('database-is-postgres');
97+
expect(id2).toMatch(/^[0-9a-f]{8}-/);
9898
});
9999

100100
it('third note created', () => {
101101
id3 = createNote(g, 'Rate limiting', 'API has 100 req/min limit.', ['api'], unitVec(2));
102-
expect(id3).toBe('rate-limiting');
102+
expect(id3).toMatch(/^[0-9a-f]{8}-/);
103103
});
104104
});
105105

@@ -329,7 +329,7 @@ describe('searchKnowledge', () => {
329329

330330
it('exact match: auth note', () => {
331331
const hits = searchKnowledge(sg, unitVec(0), { topK: 1, bfsDepth: 0, minScore: 0.5 });
332-
expect(hits[0].id).toBe('auth-jwt');
332+
expect(hits[0].id).toBe(sn1);
333333
});
334334

335335
it('exact match: score 1.0', () => {
@@ -354,53 +354,53 @@ describe('searchKnowledge', () => {
354354

355355
it('BFS depth=1 includes seed', () => {
356356
const hits = searchKnowledge(sg, unitVec(0), { topK: 1, bfsDepth: 1 });
357-
expect(hits.map(h => h.id)).toContain('auth-jwt');
357+
expect(hits.map(h => h.id)).toContain(sn1);
358358
});
359359

360360
it('BFS depth=1 includes database via depends_on', () => {
361361
const hits = searchKnowledge(sg, unitVec(0), { topK: 1, bfsDepth: 1 });
362-
expect(hits.map(h => h.id)).toContain('database');
362+
expect(hits.map(h => h.id)).toContain(sn2);
363363
});
364364

365365
it('BFS depth=1 does NOT include rate-limit (depth 2)', () => {
366366
const hits = searchKnowledge(sg, unitVec(0), { topK: 1, bfsDepth: 1 });
367-
expect(hits.map(h => h.id)).not.toContain('api-rate-limit');
367+
expect(hits.map(h => h.id)).not.toContain(sn3);
368368
});
369369

370370
it('BFS depth=2 includes rate-limit', () => {
371371
const hits = searchKnowledge(sg, unitVec(0), { topK: 1, bfsDepth: 2, minScore: 0 });
372-
expect(hits.map(h => h.id)).toContain('api-rate-limit');
372+
expect(hits.map(h => h.id)).toContain(sn3);
373373
});
374374

375375
it('BFS score < seed score', () => {
376376
const hits = searchKnowledge(sg, unitVec(0), { topK: 1, bfsDepth: 1 });
377-
const seedScore = hits.find(h => h.id === 'auth-jwt')!.score;
378-
const bfsScore = hits.find(h => h.id === 'database')!.score;
377+
const seedScore = hits.find(h => h.id === sn1)!.score;
378+
const bfsScore = hits.find(h => h.id === sn2)!.score;
379379
expect(bfsScore).toBeLessThan(seedScore);
380380
});
381381

382382
it('BFS score = seed * 0.8', () => {
383383
const hits = searchKnowledge(sg, unitVec(0), { topK: 1, bfsDepth: 1 });
384-
const seedScore = hits.find(h => h.id === 'auth-jwt')!.score;
385-
const bfsScore = hits.find(h => h.id === 'database')!.score;
384+
const seedScore = hits.find(h => h.id === sn1)!.score;
385+
const bfsScore = hits.find(h => h.id === sn2)!.score;
386386
expect(Math.abs(bfsScore - seedScore * 0.8)).toBeLessThan(0.001);
387387
});
388388

389389
it('minScore=0.9 returns only seed', () => {
390390
const hits = searchKnowledge(sg, unitVec(0), { topK: 1, bfsDepth: 1, minScore: 0.9 });
391391
expect(hits).toHaveLength(1);
392-
expect(hits[0].id).toBe('auth-jwt');
392+
expect(hits[0].id).toBe(sn1);
393393
});
394394

395395
it('bfsDecay=1.0 keeps full score', () => {
396396
const hits = searchKnowledge(sg, unitVec(0), { topK: 1, bfsDepth: 1, bfsDecay: 1.0, minScore: 0.99 });
397-
expect(hits.some(h => h.id === 'database')).toBe(true);
397+
expect(hits.some(h => h.id === sn2)).toBe(true);
398398
});
399399

400400
it('bfsDecay=0.0 filters BFS nodes', () => {
401401
const hits = searchKnowledge(sg, unitVec(0), { topK: 1, bfsDepth: 1, bfsDecay: 0.0, minScore: 0.01 });
402402
expect(hits).toHaveLength(1);
403-
expect(hits[0].id).toBe('auth-jwt');
403+
expect(hits[0].id).toBe(sn1);
404404
});
405405

406406
it('maxResults=1 caps output', () => {
@@ -854,7 +854,7 @@ describe('persistence round-trip (knowledge)', () => {
854854

855855
// Verify a new note can be created via Manager on the loaded graph
856856
const n4 = await manager.createNote('New Note', 'Created after load', ['test']);
857-
expect(n4).toBe('new-note');
857+
expect(n4).toMatch(/^[0-9a-f]{8}-/);
858858
expect(listNotes(loaded)).toHaveLength(4);
859859
expect(manager.bm25Index.size).toBe(4);
860860
});

src/tests/mcp-file-index.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ describe('cross-graph relation to files', () => {
239239
tags: ['config'],
240240
}));
241241
noteId = res.noteId;
242-
expect(noteId).toBe('config-note');
242+
expect(noteId).toMatch(/^[0-9a-f]{8}-/);
243243
});
244244

245245
it('notes_create_link to file node', async () => {

src/tests/mcp-knowledge.test.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ describe('knowledge tools', () => {
6464
tags: ['auth', 'security'],
6565
}));
6666
expect(typeof note1.noteId).toBe('string');
67-
expect(note1.noteId).toBe('auth-jwt-knowledge');
67+
expect(note1.noteId).toMatch(/^[0-9a-f]{8}-/);
6868
});
6969

7070
it('notes_create: second note', async () => {
@@ -73,7 +73,7 @@ describe('knowledge tools', () => {
7373
content: 'We use PostgreSQL 15 for persistence.',
7474
tags: ['infra'],
7575
}));
76-
expect(note2.noteId).toBe('database-postgres');
76+
expect(note2.noteId).toMatch(/^[0-9a-f]{8}-/);
7777
});
7878

7979
it('notes_create: third note', async () => {
@@ -82,7 +82,7 @@ describe('knowledge tools', () => {
8282
content: 'API rate limited to 100 req/min.',
8383
tags: ['api'],
8484
}));
85-
expect(note3.noteId).toBe('rate-limit-api');
85+
expect(note3.noteId).toMatch(/^[0-9a-f]{8}-/);
8686
});
8787

8888
// ── notes_get ──
@@ -372,7 +372,7 @@ describe('cross-graph relation tools', () => {
372372
tags: ['cross'],
373373
}));
374374
noteId = res.noteId;
375-
expect(noteId).toBe('my-note-about-setup');
375+
expect(noteId).toMatch(/^[0-9a-f]{8}-/);
376376
});
377377

378378
it('notes_create_link to docs node', async () => {
@@ -486,6 +486,8 @@ describe('notes_find_linked', () => {
486486
const fFakeEmbed = createFakeEmbed([['note', 10]]);
487487
let fCtx: McpTestContext;
488488
let fCall: McpTestContext['call'];
489+
let fNoteAId: string;
490+
let fNoteBId: string;
489491

490492
beforeAll(async () => {
491493
// Add doc node
@@ -525,13 +527,15 @@ describe('notes_find_linked', () => {
525527
fCall = fCtx.call;
526528

527529
// Create two notes that link to the same doc node
528-
await fCall('notes_create', { title: 'Note A', content: 'First note', tags: ['a'] });
529-
await fCall('notes_create', { title: 'Note B', content: 'Second note', tags: ['b'] });
530+
const resA = json<{ noteId: string }>(await fCall('notes_create', { title: 'Note A', content: 'First note', tags: ['a'] }));
531+
const resB = json<{ noteId: string }>(await fCall('notes_create', { title: 'Note B', content: 'Second note', tags: ['b'] }));
530532
await fCall('notes_create', { title: 'Note C', content: 'Third note', tags: ['c'] });
533+
fNoteAId = resA.noteId;
534+
fNoteBId = resB.noteId;
531535

532-
await fCall('notes_create_link', { fromId: 'note-a', toId: 'api.md::Auth', kind: 'references', targetGraph: 'docs', projectId: 'test' });
533-
await fCall('notes_create_link', { fromId: 'note-b', toId: 'api.md::Auth', kind: 'documents', targetGraph: 'docs', projectId: 'test' });
534-
await fCall('notes_create_link', { fromId: 'note-a', toId: 'src/auth.ts::login', kind: 'depends_on', targetGraph: 'code', projectId: 'test' });
536+
await fCall('notes_create_link', { fromId: fNoteAId, toId: 'api.md::Auth', kind: 'references', targetGraph: 'docs', projectId: 'test' });
537+
await fCall('notes_create_link', { fromId: fNoteBId, toId: 'api.md::Auth', kind: 'documents', targetGraph: 'docs', projectId: 'test' });
538+
await fCall('notes_create_link', { fromId: fNoteAId, toId: 'src/auth.ts::login', kind: 'depends_on', targetGraph: 'code', projectId: 'test' });
535539
});
536540

537541
afterAll(async () => {
@@ -546,8 +550,8 @@ describe('notes_find_linked', () => {
546550
}));
547551
expect(results).toHaveLength(2);
548552
const ids = results.map(r => r.noteId);
549-
expect(ids).toContain('note-a');
550-
expect(ids).toContain('note-b');
553+
expect(ids).toContain(fNoteAId);
554+
expect(ids).toContain(fNoteBId);
551555
});
552556

553557
it('finds note linked to a code node', async () => {
@@ -557,7 +561,7 @@ describe('notes_find_linked', () => {
557561
projectId: 'test',
558562
}));
559563
expect(results).toHaveLength(1);
560-
expect(results[0].noteId).toBe('note-a');
564+
expect(results[0].noteId).toBe(fNoteAId);
561565
expect(results[0].kind).toBe('depends_on');
562566
expect(results[0].tags).toEqual(['a']);
563567
});
@@ -570,7 +574,7 @@ describe('notes_find_linked', () => {
570574
projectId: 'test',
571575
}));
572576
expect(results).toHaveLength(1);
573-
expect(results[0].noteId).toBe('note-a');
577+
expect(results[0].noteId).toBe(fNoteAId);
574578
});
575579

576580
it('returns message for unlinked target', async () => {

0 commit comments

Comments
 (0)