Skip to content

Commit 67f79e2

Browse files
committed
feat: re-embed user graphs (knowledge/tasks/skills/epics) on model change
When the embedding model changes for user-managed graphs, existing nodes cannot be cleared (user data). Instead, iterate all nodes and re-embed them with the new model. Covers workspace shared graphs and standalone project graphs. Called after model loading in both index and serve CLI commands.
1 parent 1c104c8 commit 67f79e2

2 files changed

Lines changed: 141 additions & 0 deletions

File tree

src/cli/index.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,14 @@ program
106106
await manager.probeDimensions(id);
107107
}
108108

109+
// Re-embed user graphs if embedding model changed
110+
for (const wsId of manager.listWorkspaces()) {
111+
await manager.checkWorkspaceFingerprints(wsId);
112+
}
113+
for (const id of ids) {
114+
await manager.checkProjectFingerprints(id);
115+
}
116+
109117
// Create indexers for all projects
110118
for (const id of ids) {
111119
manager.ensureIndexer(id);
@@ -280,6 +288,22 @@ program
280288
}
281289
}
282290

291+
// Re-embed user graphs if embedding model changed
292+
for (const wsId of manager.listWorkspaces()) {
293+
try {
294+
await manager.checkWorkspaceFingerprints(wsId);
295+
} catch (err: unknown) {
296+
log.error({ err, workspaceId: wsId }, 'Failed to check workspace embedding fingerprints');
297+
}
298+
}
299+
for (const id of projectIds) {
300+
try {
301+
await manager.checkProjectFingerprints(id);
302+
} catch (err: unknown) {
303+
log.error({ err, projectId: id }, 'Failed to check project embedding fingerprints');
304+
}
305+
}
306+
283307
// Three-phase sequential indexing: docs → files → code
284308
for (const id of projectIds) {
285309
try { manager.ensureIndexer(id); } catch (err: unknown) {

src/lib/project-manager.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,123 @@ export class ProjectManager extends EventEmitter {
357357
log.info({ project: id, dims }, 'Probed project embedding dimensions');
358358
}
359359

360+
/**
361+
* Check embedding fingerprints for workspace shared graphs (knowledge, tasks, skills, epics).
362+
* If the model changed, re-embed all existing nodes. Call after loadWorkspaceModels + probeWorkspaceDimensions.
363+
*/
364+
async checkWorkspaceFingerprints(id: string): Promise<void> {
365+
const ws = this.workspaces.get(id);
366+
if (!ws) throw new Error(`Workspace "${id}" not found`);
367+
368+
const gc = ws.config.graphConfigs;
369+
const store = ws.store;
370+
const scoped = ws.storeManager.scoped;
371+
const embedFn = (text: string) => embed(text, '', `${id}:knowledge`);
372+
373+
const graphs = ['knowledge', 'tasks', 'skills'] as const;
374+
for (const gn of graphs) {
375+
const fp = embeddingFingerprint(gc[gn].model);
376+
const metaKey = `model_fp:${id}:${gn}`;
377+
const stored = store.getMeta(metaKey);
378+
if (stored !== null && stored !== fp) {
379+
await this.reembedGraph(scoped, gn, embedFn);
380+
log.info({ workspace: id, graph: gn }, 'Model changed — re-embedded user graph');
381+
}
382+
store.setMeta(metaKey, fp);
383+
}
384+
// Epics use the tasks model — check separately
385+
const epicFp = embeddingFingerprint(gc.tasks.model);
386+
const epicMetaKey = `model_fp:${id}:epics`;
387+
const epicStored = store.getMeta(epicMetaKey);
388+
if (epicStored !== null && epicStored !== epicFp) {
389+
await this.reembedEpics(scoped, embedFn);
390+
log.info({ workspace: id, graph: 'epics' }, 'Model changed — re-embedded epics');
391+
}
392+
store.setMeta(epicMetaKey, epicFp);
393+
}
394+
395+
/**
396+
* Check embedding fingerprints for standalone project's user graphs.
397+
* Call after loadModels + probeDimensions.
398+
*/
399+
async checkProjectFingerprints(id: string): Promise<void> {
400+
const instance = this.projects.get(id);
401+
if (!instance) throw new Error(`Project "${id}" not found`);
402+
if (instance.workspaceId) return; // workspace projects handled by checkWorkspaceFingerprints
403+
404+
const gc = instance.config.graphConfigs;
405+
const store = instance.workspaceId
406+
? this.workspaces.get(instance.workspaceId)!.store
407+
: this.stores.get(id);
408+
if (!store) return;
409+
410+
const scoped = instance.storeManager.scoped;
411+
const embedFn = (text: string) => embed(text, '', `${id}:knowledge`);
412+
413+
const graphs = ['knowledge', 'tasks', 'skills'] as const;
414+
for (const gn of graphs) {
415+
if (!gc[gn].enabled) continue;
416+
const fp = embeddingFingerprint(gc[gn].model);
417+
const metaKey = `model_fp:${id}:${gn}`;
418+
const stored = store.getMeta(metaKey);
419+
if (stored !== null && stored !== fp) {
420+
await this.reembedGraph(scoped, gn, embedFn);
421+
log.info({ project: id, graph: gn }, 'Model changed — re-embedded user graph');
422+
}
423+
store.setMeta(metaKey, fp);
424+
}
425+
// Epics use knowledge model for standalone
426+
if (gc.knowledge.enabled) {
427+
const epicFp = embeddingFingerprint(gc.knowledge.model);
428+
const epicMetaKey = `model_fp:${id}:epics`;
429+
const epicStored = store.getMeta(epicMetaKey);
430+
if (epicStored !== null && epicStored !== epicFp) {
431+
await this.reembedEpics(scoped, embedFn);
432+
log.info({ project: id, graph: 'epics' }, 'Model changed — re-embedded epics');
433+
}
434+
store.setMeta(epicMetaKey, epicFp);
435+
}
436+
}
437+
438+
/** Re-embed all nodes in a user-managed graph (knowledge, tasks, or skills). */
439+
private async reembedGraph(
440+
scoped: ProjectScopedStore,
441+
graph: 'knowledge' | 'tasks' | 'skills',
442+
embedFn: (text: string) => Promise<number[]>,
443+
): Promise<void> {
444+
if (graph === 'knowledge') {
445+
const { results } = scoped.knowledge.list({ limit: 100_000 });
446+
for (const note of results) {
447+
const vec = await embedFn(`${note.title} ${note.content}`);
448+
scoped.knowledge.update(note.id, {}, vec);
449+
}
450+
} else if (graph === 'tasks') {
451+
const { results } = scoped.tasks.list({ limit: 100_000 });
452+
for (const task of results) {
453+
const vec = await embedFn(`${task.title} ${task.description}`);
454+
scoped.tasks.update(task.id, {}, vec);
455+
}
456+
} else {
457+
const { results } = scoped.skills.list({ limit: 100_000 });
458+
for (const skill of results) {
459+
const vec = await embedFn(`${skill.title} ${skill.description}`);
460+
scoped.skills.update(skill.id, {}, vec);
461+
}
462+
}
463+
}
464+
465+
/** Re-embed all epics. */
466+
private async reembedEpics(
467+
scoped: ProjectScopedStore,
468+
embedFn: (text: string) => Promise<number[]>,
469+
): Promise<void> {
470+
const { results } = scoped.epics.list({ limit: 100_000 });
471+
for (const epic of results) {
472+
const vec = await embedFn(`${epic.title} ${epic.description}`);
473+
scoped.epics.update(epic.id, {}, vec);
474+
}
475+
}
476+
360477
/**
361478
* Start indexing + watching for a project. Call after loadModels.
362479
* Uses three sequential phases: docs → files → code, then finalize.

0 commit comments

Comments
 (0)