@@ -703,6 +703,130 @@ class MemoryService extends Base {
703703 }
704704 }
705705
706+ /**
707+ * @summary Merges a generated `miniSummary` into an existing `AGENT_MEMORY` node without changing tenant ownership.
708+ *
709+ * Backfill runs as daemon/system work, usually without a request-bound tenant. Plain
710+ * {@link GraphService#upsertNode} is intentionally RLS-aware and may not see private
711+ * tenant rows from that context, so it can treat an existing tenant-owned memory as a
712+ * new node. The backfill path must instead update the full persisted node JSON in
713+ * place, preserving `userId`, `agentIdentity`, `sessionId`, timestamps, and every
714+ * other property while adding only `miniSummary`.
715+ *
716+ * @param {Object } options
717+ * @param {String } options.id Existing `AGENT_MEMORY` node id.
718+ * @param {String } options.miniSummary Generated compact summary to merge.
719+ * @returns {Boolean } `true` when the node was updated, `false` when the row is missing.
720+ * @private
721+ */
722+ updateMemoryMiniSummary ( { id, miniSummary} ) {
723+ const graph = GraphService . db ,
724+ sqlite = graph ?. storage ?. db ;
725+
726+ if ( ! sqlite ) {
727+ return false ;
728+ }
729+
730+ const row = sqlite . prepare ( 'SELECT data FROM Nodes WHERE id = ? LIMIT 1' ) . get ( id ) ;
731+ if ( ! row ?. data ) {
732+ return false ;
733+ }
734+
735+ const existing = JSON . parse ( row . data ) ,
736+ properties = { ...( existing . properties || { } ) , miniSummary} ,
737+ nodeData = {
738+ id : existing . id || id ,
739+ label : existing . label || 'AGENT_MEMORY' ,
740+ properties
741+ } ;
742+
743+ graph . storage . addNodes ( [ nodeData ] ) ;
744+
745+ return true ;
746+ }
747+
748+ /**
749+ * @summary Backfills compact per-turn summaries for existing `AGENT_MEMORY` graph rows.
750+ *
751+ * Mirrors the inline {@link addMemory} enrichment path for pre-existing memories and for
752+ * turns written while the summarizer was unavailable. The scan is graph-first and
753+ * most-recent-first; Chroma is only joined by the selected node ids to fetch that memory's own
754+ * prompt/response. Updates merge `miniSummary` into the same graph node through a
755+ * tenant-preserving storage-layer merge, preserving tenant attribution (`userId`, `agentIdentity`)
756+ * and every other property already present on the row.
757+ *
758+ * Fail-soft by construction: model/provider failures leave the row unmodified so a later batch
759+ * can retry it. A failure for one row never aborts the batch.
760+ *
761+ * @param {Object } [options]
762+ * @param {Number } [options.limit] Maximum rows to process. Defaults to
763+ * `aiConfig.summarizationBatchLimit`.
764+ * @param {Function } [options.buildMiniSummary] Optional summarizer seam for deterministic tests.
765+ * @returns {Promise<{processed: Number, updated: Number, deferred: Number, missingContent: Number}> }
766+ */
767+ async backfillMiniSummaries ( { limit, buildMiniSummary} = { } ) {
768+ const sqlite = GraphService . db ?. storage ?. db ;
769+ if ( ! sqlite ) {
770+ return { processed : 0 , updated : 0 , deferred : 0 , missingContent : 0 } ;
771+ }
772+
773+ const defaultLimit = Number ( aiConfig . summarizationBatchLimit ) || 50 ;
774+ const numericLimit = Number ( limit ) || defaultLimit ;
775+ const boundedLimit = Math . max ( 1 , Math . min ( numericLimit , defaultLimit ) ) ;
776+ const summarize = buildMiniSummary || ( options => this . buildMiniSummary ( options ) ) ;
777+
778+ const rows = sqlite . prepare ( `
779+ SELECT memory.id AS id,
780+ json_extract(memory.data, '$.properties.timestamp') AS timestamp
781+ FROM Nodes memory
782+ WHERE json_extract(memory.data, '$.label') = 'AGENT_MEMORY'
783+ AND json_extract(memory.data, '$.properties.miniSummary') IS NULL
784+ ORDER BY json_extract(memory.data, '$.properties.timestamp') DESC, memory.id DESC
785+ LIMIT ?
786+ ` ) . all ( boundedLimit ) ;
787+
788+ if ( rows . length === 0 ) {
789+ return { processed : 0 , updated : 0 , deferred : 0 , missingContent : 0 } ;
790+ }
791+
792+ const collection = await StorageRouter . getMemoryCollection ( ) ;
793+ const fetched = await collection . get ( { ids : rows . map ( row => row . id ) , include : [ 'metadatas' ] } ) ;
794+ const byId = new Map ( ( fetched . ids || [ ] ) . map ( ( id , index ) => [ id , fetched . metadatas ?. [ index ] || { } ] ) ) ;
795+
796+ let updated = 0 , deferred = 0 , missingContent = 0 ;
797+
798+ for ( const row of rows ) {
799+ const metadata = byId . get ( row . id ) ;
800+ if ( ! metadata || ( ! metadata . prompt && ! metadata . response ) ) {
801+ missingContent ++ ;
802+ continue ;
803+ }
804+
805+ try {
806+ const miniSummary = await summarize ( {
807+ prompt : metadata . prompt ,
808+ response : metadata . response
809+ } ) ;
810+
811+ if ( ! miniSummary ) {
812+ deferred ++ ;
813+ continue ;
814+ }
815+
816+ if ( this . updateMemoryMiniSummary ( { id : row . id , miniSummary} ) ) {
817+ updated ++ ;
818+ } else {
819+ missingContent ++ ;
820+ }
821+ } catch ( error ) {
822+ logger . warn ( `[MemoryService] miniSummary backfill deferred for ${ row . id } (fail-soft): ${ error . message } ` ) ;
823+ deferred ++ ;
824+ }
825+ }
826+
827+ return { processed : rows . length , updated, deferred, missingContent} ;
828+ }
829+
706830 /**
707831 * Executes a semantic search against the memory collection.
708832 * @param {Object } options
0 commit comments