Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions src/main/presenter/memoryPresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import type {
import type {
MemoryArchiveCandidateLifecyclePreview,
MemoryHealthDto,
MemoryLifecycle
MemoryLifecycle,
MemoryUpdateResult
} from '@shared/contracts/routes/memory.routes'
import type {
MemoryExtractionInput,
Expand Down Expand Up @@ -151,7 +152,7 @@ export class MemoryPresenter implements MemoryRuntimePort {
scheduleConsolidation: (agentId) => this.maintenance.scheduleConsolidation(agentId)
})

this.management = new ManagementService(this.runtime, {
this.management = new ManagementService(this.runtime, this.rows, {
deleteVectorsForDeletedMemory: (agentId, memoryIds, embedding) =>
this.vectorStore.deleteVectorsForMemoryIdsOpening(agentId, memoryIds, {
embeddingModel: embedding.embeddingModel,
Expand Down Expand Up @@ -288,6 +289,18 @@ export class MemoryPresenter implements MemoryRuntimePort {
return this.writeCoordinator.addUserMemory(agentId, input, sessionId)
}

updateMemory(
agentId: string,
memoryId: string,
patch: {
content?: string
category?: string | null
importance?: number
}
): MemoryUpdateResult {
return this.management.updateMemory(agentId, memoryId, patch)
}

async buildInjection(agentId: string, query: string): Promise<MemoryInjectionResult | null> {
return this.retrieval.buildInjection(agentId, query)
}
Expand Down
222 changes: 218 additions & 4 deletions src/main/presenter/memoryPresenter/services/managementService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import {
createEmptyMemoryHealth,
type MemoryArchiveCandidateLifecyclePreview,
type MemoryHealthDto,
type MemoryLifecycle
type MemoryLifecycle,
type MemoryUpdateResult
} from '@shared/contracts/routes/memory.routes'
import { isAgentMemoryCategory } from '@shared/types/agent-memory'
import { isAgentMemoryCategory, type AgentMemoryCategory } from '@shared/types/agent-memory'
import { ARCHIVE_AGE_MS, ARCHIVE_DECAY_THRESHOLD } from '../core/lifecycle'
import { deriveLifecycle, type DeriveLifecycleOptions } from '../core/lifecycle'
import { resolveRetrieval } from '../core/scoring'
Expand All @@ -17,8 +18,9 @@ import {
MEMORY_HEALTH_RECENT_FAILURES_LIMIT,
MEMORY_HEALTH_TOP_ACCESSED_LIMIT
} from '../runtimeConstants'
import type { AgentMemoryRow, MemoryStatus } from '../types'
import type { AgentMemoryRow, MemoryStatus, NormalizedMemoryCandidate } from '../types'
import { embeddingFingerprint, type MemoryRuntimeContext } from '../context'
import { MemoryRowMutations, type ManualEditFieldFlags } from './rowMutations'

function toHealthTopAccessedItem(
row: AgentMemoryRow
Expand All @@ -40,11 +42,67 @@ function isInternalMemoryKind(row: AgentMemoryRow): boolean {
return row.kind === 'persona' || row.kind === 'working'
}

function hasOwn<T extends object, K extends PropertyKey>(
object: T,
key: K
): object is T & Record<K, unknown> {
return Object.prototype.hasOwnProperty.call(object, key)
}

function clampImportance(value: number): number {
return Math.min(1, Math.max(0, value))
}

function parseSourceEntryIds(raw: string | null): number[] | null {
if (!raw) return null
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return null
const ids = parsed.filter((value): value is number => Number.isInteger(value) && value >= 0)
return ids.length === parsed.length ? ids : null
} catch {
return null
}
}

function isEditableUserKind(row: AgentMemoryRow): row is AgentMemoryRow & {
kind: 'episodic' | 'semantic'
} {
return row.kind === 'episodic' || row.kind === 'semantic'
}

function isEditableUserMemory(
agentId: string,
row: AgentMemoryRow | undefined
): row is AgentMemoryRow & { kind: 'episodic' | 'semantic' } {
return (
!!row &&
row.agent_id === agentId &&
row.superseded_by === null &&
row.status !== 'archived' &&
row.status !== 'conflicted' &&
row.conflict_state !== 'challenged' &&
isEditableUserKind(row) &&
!isInternalMemoryKind(row)
)
}

function isConflictParticipant(row: AgentMemoryRow | undefined): boolean {
return !!row && (row.status === 'conflicted' || row.conflict_state === 'challenged')
}

function mapContentSuppressedReason(reason: string): 'conflict' | 'duplicate' | 'suppressed' {
if (reason === 'conflict') return 'conflict'
if (reason === 'duplicate') return 'duplicate'
return 'suppressed'
}

type DeleteVectorResult = 'deleted' | 'skipped' | 'unusable'

export class ManagementService {
constructor(
private readonly ctx: MemoryRuntimeContext,
private readonly rows: MemoryRowMutations,
private readonly ports: {
deleteVectorsForDeletedMemory: (
agentId: string,
Expand Down Expand Up @@ -294,6 +352,147 @@ export class ManagementService {
}
}

updateMemory(
agentId: string,
memoryId: string,
patch: {
content?: string
category?: string | null
importance?: number
}
): MemoryUpdateResult {
if (this.ctx.isDisposed) return { action: 'noop' }
this.ctx.assertSafeAgentId(agentId)
if (!this.ctx.canWriteAgentMemory(agentId)) return { action: 'noop' }
const row = this.ctx.deps.repository.getById(memoryId)
if (!isEditableUserMemory(agentId, row)) {
return { action: 'noop', reason: isConflictParticipant(row) ? 'conflict' : 'not-editable' }
}

const hasContent = hasOwn(patch, 'content')
const nextContent = hasContent ? String(patch.content ?? '').trim() : row.content
if (hasContent && !nextContent) return { action: 'noop', reason: 'empty' }

const metadataPatch: { category?: AgentMemoryCategory | null; importance?: number } = {}
if (hasOwn(patch, 'category')) {
metadataPatch.category = isAgentMemoryCategory(patch.category) ? patch.category : null
}
if (hasOwn(patch, 'importance') && typeof patch.importance === 'number') {
metadataPatch.importance = clampImportance(patch.importance)
}

const contentChanged = hasContent && nextContent !== row.content.trim()
if (contentChanged) {
return this.updateMemoryContent(agentId, row, nextContent, metadataPatch)
}

const nextMetadata: { category?: string | null; importance?: number } = {}
if (hasOwn(metadataPatch, 'category') && metadataPatch.category !== row.category) {
nextMetadata.category = metadataPatch.category
}
if (
hasOwn(metadataPatch, 'importance') &&
typeof metadataPatch.importance === 'number' &&
metadataPatch.importance !== row.importance
) {
nextMetadata.importance = metadataPatch.importance
}
if (!hasOwn(nextMetadata, 'category') && !hasOwn(nextMetadata, 'importance')) {
return { action: 'noop', memoryId: row.id }
}

this.ctx.deps.repository.runInTransaction(() => {
this.ctx.deps.repository.updateUserMetadata(row.id, nextMetadata)
this.ctx.writeAudit(agentId, {
eventType: 'memory/manual_edit',
actorType: 'user',
status: 'completed',
inputRefs: { memoryId: row.id },
outputRefs: { action: 'updated', memoryId: row.id }
})
})
this.ports.syncWorkingMemoryAfterMutation(agentId)
this.ctx.emitChanged(agentId, 'manual-edit', { memoryId: row.id })
return { action: 'updated', memoryId: row.id }
}

private updateMemoryContent(
agentId: string,
row: AgentMemoryRow & { kind: 'episodic' | 'semantic' },
content: string,
metadataPatch: { category?: AgentMemoryCategory | null; importance?: number }
): MemoryUpdateResult {
const currentCategory = isAgentMemoryCategory(row.category) ? row.category : null
const categoryProvided = hasOwn(metadataPatch, 'category')
const importanceProvided =
hasOwn(metadataPatch, 'importance') && typeof metadataPatch.importance === 'number'
const nextCategory = categoryProvided ? (metadataPatch.category ?? null) : currentCategory
const nextImportance = importanceProvided
? (metadataPatch.importance as number)
: row.importance

const candidate: NormalizedMemoryCandidate = {
kind: row.kind,
category: nextCategory,
content,
importance: nextImportance
}
const providedFields: ManualEditFieldFlags = {
category: categoryProvided,
importance: importanceProvided
}

// Wrapped in one transaction so the row mutation and its audit event commit atomically, same as
// the metadata-only path below — a suppressed/noop outcome writes neither.
const result = this.ctx.deps.repository.runInTransaction((): MemoryUpdateResult => {
const update = this.rows.applyManualContentEdit(
agentId,
row,
candidate,
content,
Date.now(),
{
agentId,
sourceSession: row.source_session,
userScope: row.user_scope,
sourceEntryIds: parseSourceEntryIds(row.source_entry_ids)
},
providedFields
)
if (update.action === 'suppressed') {
return { action: 'noop', reason: mapContentSuppressedReason(update.reason) }
}

const memoryId = update.id
const outcome: MemoryUpdateResult =
update.action === 'folded'
? { action: 'folded', memoryId, supersededId: row.id }
: update.action === 'superseded'
? { action: 'superseded', memoryId, supersededId: update.supersededId }
: { action: 'updated', memoryId }

this.ctx.writeAudit(agentId, {
eventType: 'memory/manual_edit',
actorType: 'user',
status: 'completed',
inputRefs: { memoryId: row.id },
outputRefs: outcome
})
return outcome
})

if (result.action === 'noop') return result

this.ports.syncWorkingMemoryAfterMutation(agentId)
this.ctx.emitChanged(agentId, 'manual-edit', { memoryId: result.memoryId })
if (result.action !== 'folded') {
void this.ports.triggerEmbedding(agentId).catch((error) => {
logger.warn(`[Memory] background embedding failed: ${String(error)}`)
})
}
return result
}

async deleteMemory(agentId: string, memoryId: string): Promise<boolean> {
if (this.ctx.isDisposed) return false
this.ctx.assertSafeAgentId(agentId)
Expand Down Expand Up @@ -341,13 +540,28 @@ export class ManagementService {
getStatus(agentId: string): MemoryStatus {
this.ctx.assertSafeAgentId(agentId)
if (!this.ctx.isManagedAgent(agentId)) {
return { total: 0, pendingEmbedding: 0, hasPersona: false }
return {
total: 0,
pendingEmbedding: 0,
hasPersona: false,
activeMemoryCount: 0,
archivedMemoryCount: 0,
conflictCount: 0,
personaDraftCount: 0,
personaVersionCount: 0
}
}
const counts = this.ctx.deps.repository.countStatusView(agentId)
const personaCounts = this.ctx.deps.repository.getPersonaCounts(agentId)
return {
total: counts.total,
pendingEmbedding: counts.pendingEmbedding,
hasPersona: this.ctx.deps.repository.getActivePersona(agentId) !== undefined,
activeMemoryCount: counts.activeMemoryCount,
archivedMemoryCount: counts.archivedMemoryCount,
conflictCount: this.ctx.deps.repository.countConflictPairs(agentId),
personaDraftCount: personaCounts.draft,
personaVersionCount: personaCounts.total,
reindexing: this.ports.isReindexing(agentId)
}
}
Expand Down
Loading