@@ -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