From 91c3b02f130b740265459fab239e0cd2fea0a218 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Wed, 8 Jul 2026 16:11:45 +0800 Subject: [PATCH 1/3] feat(memory): redesign settings ux --- src/main/presenter/memoryPresenter/index.ts | 17 +- .../services/managementService.ts | 222 ++- .../memoryPresenter/services/rowMutations.ts | 109 ++ src/main/presenter/memoryPresenter/types.ts | 24 +- .../sqlitePresenter/tables/agentMemory.ts | 89 +- src/main/routes/index.ts | 15 + src/renderer/api/MemoryClient.ts | 17 + .../components/MemoryConfigInlinePanel.vue | 720 +++++++ .../settings/components/MemoryConfigPanel.vue | 624 ------ .../components/MemoryDiagnosticsPanel.vue | 427 ++++ .../settings/components/MemoryEmptyState.vue | 49 + .../components/MemoryHealthSection.vue | 575 ------ .../settings/components/MemoryInboxBar.vue | 234 +++ .../settings/components/MemoryInlinePanel.vue | 640 ++++++ .../settings/components/MemoryListView.vue | 698 +++++++ .../components/MemoryManagerDialog.vue | 46 - .../components/MemoryManagerPanel.vue | 1473 -------------- .../components/MemoryPersonaPanel.vue | 197 ++ .../settings/components/MemorySettings.vue | 256 ++- .../components/memoryRedesignUtils.ts | 98 + src/renderer/src/i18n/da-DK/settings.json | 78 + src/renderer/src/i18n/de-DE/settings.json | 78 + src/renderer/src/i18n/en-US/settings.json | 78 + src/renderer/src/i18n/es-ES/settings.json | 78 + src/renderer/src/i18n/fa-IR/settings.json | 78 + src/renderer/src/i18n/fr-FR/settings.json | 78 + src/renderer/src/i18n/he-IL/settings.json | 78 + src/renderer/src/i18n/id-ID/settings.json | 78 + src/renderer/src/i18n/it-IT/settings.json | 78 + src/renderer/src/i18n/ja-JP/settings.json | 78 + src/renderer/src/i18n/ko-KR/settings.json | 78 + src/renderer/src/i18n/ms-MY/settings.json | 78 + src/renderer/src/i18n/pl-PL/settings.json | 78 + src/renderer/src/i18n/pt-BR/settings.json | 78 + src/renderer/src/i18n/ru-RU/settings.json | 78 + src/renderer/src/i18n/tr-TR/settings.json | 78 + src/renderer/src/i18n/vi-VN/settings.json | 78 + src/renderer/src/i18n/zh-CN/settings.json | 78 + src/renderer/src/i18n/zh-HK/settings.json | 78 + src/renderer/src/i18n/zh-TW/settings.json | 78 + src/shared/contracts/events/memory.events.ts | 1 + src/shared/contracts/routes.ts | 4 +- src/shared/contracts/routes/memory.routes.ts | 39 +- test/main/presenter/fakes/memoryFakes.ts | 66 +- test/main/presenter/memoryPresenter.test.ts | 7 +- test/main/presenter/memoryUpdate.test.ts | 421 ++++ test/main/routes/memoryDto.test.ts | 111 +- .../MemoryConfigInlinePanel.test.ts | 323 ++++ .../components/MemoryConfigPanel.test.ts | 197 -- .../components/MemoryDiagnosticsPanel.test.ts | 264 +++ .../components/MemoryHealthSection.test.ts | 380 ---- .../components/MemoryInlinePanel.test.ts | 360 ++++ .../components/MemoryListView.test.ts | 414 ++++ .../components/MemoryManagerDialog.test.ts | 1721 ----------------- .../components/MemorySettings.test.ts | 274 ++- 55 files changed, 7556 insertions(+), 5116 deletions(-) create mode 100644 src/renderer/settings/components/MemoryConfigInlinePanel.vue delete mode 100644 src/renderer/settings/components/MemoryConfigPanel.vue create mode 100644 src/renderer/settings/components/MemoryDiagnosticsPanel.vue create mode 100644 src/renderer/settings/components/MemoryEmptyState.vue delete mode 100644 src/renderer/settings/components/MemoryHealthSection.vue create mode 100644 src/renderer/settings/components/MemoryInboxBar.vue create mode 100644 src/renderer/settings/components/MemoryInlinePanel.vue create mode 100644 src/renderer/settings/components/MemoryListView.vue delete mode 100644 src/renderer/settings/components/MemoryManagerDialog.vue delete mode 100644 src/renderer/settings/components/MemoryManagerPanel.vue create mode 100644 src/renderer/settings/components/MemoryPersonaPanel.vue create mode 100644 src/renderer/settings/components/memoryRedesignUtils.ts create mode 100644 test/main/presenter/memoryUpdate.test.ts create mode 100644 test/renderer/components/MemoryConfigInlinePanel.test.ts delete mode 100644 test/renderer/components/MemoryConfigPanel.test.ts create mode 100644 test/renderer/components/MemoryDiagnosticsPanel.test.ts delete mode 100644 test/renderer/components/MemoryHealthSection.test.ts create mode 100644 test/renderer/components/MemoryInlinePanel.test.ts create mode 100644 test/renderer/components/MemoryListView.test.ts delete mode 100644 test/renderer/components/MemoryManagerDialog.test.ts diff --git a/src/main/presenter/memoryPresenter/index.ts b/src/main/presenter/memoryPresenter/index.ts index d1c3618a5..345eb8fa3 100644 --- a/src/main/presenter/memoryPresenter/index.ts +++ b/src/main/presenter/memoryPresenter/index.ts @@ -22,7 +22,8 @@ import type { import type { MemoryArchiveCandidateLifecyclePreview, MemoryHealthDto, - MemoryLifecycle + MemoryLifecycle, + MemoryUpdateResult } from '@shared/contracts/routes/memory.routes' import type { MemoryExtractionInput, @@ -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, @@ -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 { return this.retrieval.buildInjection(agentId, query) } diff --git a/src/main/presenter/memoryPresenter/services/managementService.ts b/src/main/presenter/memoryPresenter/services/managementService.ts index d861f6746..cae20c663 100644 --- a/src/main/presenter/memoryPresenter/services/managementService.ts +++ b/src/main/presenter/memoryPresenter/services/managementService.ts @@ -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' @@ -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 @@ -40,11 +42,67 @@ function isInternalMemoryKind(row: AgentMemoryRow): boolean { return row.kind === 'persona' || row.kind === 'working' } +function hasOwn( + object: T, + key: K +): object is T & Record { + 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, @@ -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 { if (this.ctx.isDisposed) return false this.ctx.assertSafeAgentId(agentId) @@ -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) } } diff --git a/src/main/presenter/memoryPresenter/services/rowMutations.ts b/src/main/presenter/memoryPresenter/services/rowMutations.ts index f41eb14c4..72760b360 100644 --- a/src/main/presenter/memoryPresenter/services/rowMutations.ts +++ b/src/main/presenter/memoryPresenter/services/rowMutations.ts @@ -24,8 +24,17 @@ export type ProvenanceHitResult = export type ContentUpdateResult = | { action: 'updated'; id: string } | { action: 'folded'; id: string } + | { action: 'superseded'; id: string; supersededId: string; created?: boolean } | { action: 'suppressed'; id: string; reason: string } +// Which metadata fields the caller's edit patch actually touched, so a fold only overwrites the +// surviving owner's fields the user explicitly set rather than whatever the edited row happened +// to carry (e.g. its own untouched category/importance). +export interface ManualEditFieldFlags { + category: boolean + importance: boolean +} + export class MemoryRowMutations { constructor(private readonly ctx: MemoryRuntimeContext) {} @@ -158,6 +167,106 @@ export class MemoryRowMutations { return { action: 'updated', id: row.id } } + applyManualContentEdit( + agentId: string, + row: AgentMemoryRow, + candidate: NormalizedMemoryCandidate, + content: string, + now: number, + options: WriteMemoriesOptions, + providedFields: ManualEditFieldFlags + ): ContentUpdateResult { + const newKey = buildMemoryProvenanceKey(agentId, row.kind, content) + const nextCategory = canCarryCategory(row.kind) ? candidate.category : undefined + + if (newKey !== row.provenance_key) { + const owner = this.ctx.deps.repository.getByProvenanceKey(agentId, newKey) + if (owner && owner.id !== row.id) { + return this.resolveManualEditFold(agentId, row, owner, candidate, providedFields) + } + } + + if (newKey === row.provenance_key) { + this.ctx.deps.repository.runInTransaction(() => { + this.ctx.deps.repository.updateContent(row.id, content, newKey, now, nextCategory) + this.ctx.deps.repository.updateUserMetadata(row.id, { + category: candidate.category, + importance: candidate.importance + }) + this.ctx.deps.repository.updateStatus(row.id, 'pending_embedding') + }) + return { action: 'updated', id: row.id } + } + + let newId: string | null = null + this.ctx.deps.repository.runInTransaction(() => { + newId = this.insertMemory(agentId, candidate, content, newKey, options) + if (newId) this.ctx.deps.repository.markSuperseded(row.id, newId) + }) + + if (!newId) { + const owner = this.ctx.deps.repository.getByProvenanceKey(agentId, newKey) + if (owner && owner.id !== row.id) { + return this.resolveManualEditFold(agentId, row, owner, candidate, providedFields) + } + return { action: 'suppressed', id: row.id, reason: 'insert-skipped' } + } + + return { action: 'superseded', id: newId, supersededId: row.id, created: true } + } + + // Shared by the primary provenance-hit branch and the UNIQUE-constraint recovery branch above so + // both resolve a manual edit into an existing owner with identical semantics: refuse conflict + // participants outright (M1), and only overwrite metadata fields the caller's patch actually + // provided (M3) rather than whatever the edited row's fully-resolved candidate happens to carry. + private resolveManualEditFold( + agentId: string, + row: AgentMemoryRow, + owner: AgentMemoryRow, + candidate: NormalizedMemoryCandidate, + providedFields: ManualEditFieldFlags + ): ContentUpdateResult { + if (owner.status === 'conflicted' || owner.conflict_state === 'challenged') { + return { action: 'suppressed', id: owner.id, reason: 'conflict' } + } + + const hit = this.handleProvenanceHit(agentId, owner, { allowDecisionForSuperseded: true }) + if (hit.action === 'noop' && hit.reason !== 'duplicate') { + return { action: 'suppressed', id: owner.id, reason: hit.reason } + } + + const isRetiredOwner = owner.status === 'archived' || owner.superseded_by !== null + const action: 'folded' | 'superseded' = isRetiredOwner ? 'superseded' : 'folded' + + this.ctx.deps.repository.runInTransaction(() => { + if (hit.action === 'absorbed') { + this.ctx.deps.repository.updateStatus(owner.id, 'pending_embedding') + } + if (hit.action === 'continue') { + this.reviveSupersededAfterDecision(agentId, owner) + } + const metadataPatch: { category?: string | null; importance?: number } = {} + if ( + providedFields.category && + canCarryCategory(owner.kind) && + owner.category !== candidate.category + ) { + metadataPatch.category = candidate.category + } + if (providedFields.importance && owner.importance !== candidate.importance) { + metadataPatch.importance = candidate.importance + } + if (Object.keys(metadataPatch).length > 0) { + this.ctx.deps.repository.updateUserMetadata(owner.id, metadataPatch) + } + this.ctx.deps.repository.markSuperseded(row.id, owner.id) + }) + + return action === 'folded' + ? { action: 'folded', id: owner.id } + : { action: 'superseded', id: owner.id, supersededId: row.id, created: false } + } + supersedeHead(agentId: string, row: AgentMemoryRow): AgentMemoryRow { let current = row const seen = new Set([row.id]) diff --git a/src/main/presenter/memoryPresenter/types.ts b/src/main/presenter/memoryPresenter/types.ts index 714807a54..4f76eb096 100644 --- a/src/main/presenter/memoryPresenter/types.ts +++ b/src/main/presenter/memoryPresenter/types.ts @@ -105,6 +105,13 @@ export interface MemoryRepositoryPort { at?: number, category?: string | null ): void + updateUserMetadata( + id: string, + patch: { + category?: string | null + importance?: number + } + ): void setConfidence(id: string, confidence: number): void setImportance(id: string, importance: number): void markConflict(id: string, state: AgentMemoryConflictState | null): void @@ -127,7 +134,16 @@ export interface MemoryRepositoryPort { delete(id: string): void clearByAgent(agentId: string): number countByAgent(agentId: string): number - countStatusView(agentId: string): { total: number; pendingEmbedding: number } + countStatusView(agentId: string): { + total: number + pendingEmbedding: number + activeMemoryCount: number + archivedMemoryCount: number + } + // Counts valid conflict pairs (challenger + its target); must mirror the pair-validity predicate + // in ConflictService.listConflicts exactly, or the two will silently drift. + countConflictPairs(agentId: string): number + getPersonaCounts(agentId: string): { total: number; draft: number } hasActiveMemory(agentId: string): boolean runInTransaction(fn: () => T): T listWorkingCandidates( @@ -341,6 +357,11 @@ export interface MemoryStatus { total: number pendingEmbedding: number hasPersona: boolean + activeMemoryCount: number + archivedMemoryCount: number + conflictCount: number + personaDraftCount: number + personaVersionCount: number // True while the agent's vectors are being rebuilt after an embedding model/dimension change. reindexing?: boolean } @@ -390,6 +411,7 @@ export type MemoryUpdateReason = | 'persona-approve' | 'persona-reject' | 'persona-rollback' + | 'manual-edit' | 'reindex' export interface MemoryUpdateContext { diff --git a/src/main/presenter/sqlitePresenter/tables/agentMemory.ts b/src/main/presenter/sqlitePresenter/tables/agentMemory.ts index e0500cd05..f90f64d00 100644 --- a/src/main/presenter/sqlitePresenter/tables/agentMemory.ts +++ b/src/main/presenter/sqlitePresenter/tables/agentMemory.ts @@ -1047,6 +1047,28 @@ export class AgentMemoryTable extends BaseTable { .run(content, provenanceKey, at, id) } + updateUserMetadata( + id: string, + patch: { + category?: string | null + importance?: number + } + ): void { + const sets: string[] = [] + const params: unknown[] = [] + if (Object.prototype.hasOwnProperty.call(patch, 'category')) { + sets.push('category = ?') + params.push(patch.category ?? null) + } + if (Object.prototype.hasOwnProperty.call(patch, 'importance')) { + sets.push('importance = ?') + params.push(patch.importance) + } + if (!sets.length) return + params.push(id) + this.db.prepare(`UPDATE agent_memory SET ${sets.join(', ')} WHERE id = ?`).run(...params) + } + // Confidence only ever rises: NULL seeds the first value, otherwise keep the larger. setConfidence(id: string, confidence: number): void { this.db @@ -1327,24 +1349,77 @@ export class AgentMemoryTable extends BaseTable { return row?.count ?? 0 } - countStatusView(agentId: string): { total: number; pendingEmbedding: number } { + countStatusView(agentId: string): { + total: number + pendingEmbedding: number + activeMemoryCount: number + archivedMemoryCount: number + } { const row = this.db .prepare( - `SELECT COUNT(*) AS total, + `SELECT + SUM(CASE WHEN status != 'archived' THEN 1 ELSE 0 END) AS activeMemoryCount, + SUM(CASE WHEN status = 'archived' THEN 1 ELSE 0 END) AS archivedMemoryCount, SUM(CASE WHEN status = 'pending_embedding' THEN 1 ELSE 0 END) AS pendingEmbedding FROM agent_memory WHERE agent_id = ? - AND status != 'archived' AND status != 'conflicted' - AND kind != 'working'` + AND superseded_by IS NULL + AND kind NOT IN ('persona', 'working')` ) - .get(agentId) as { total: number; pendingEmbedding: number | null } | undefined + .get(agentId) as + | { + activeMemoryCount: number | null + archivedMemoryCount: number | null + pendingEmbedding: number | null + } + | undefined + const activeMemoryCount = row?.activeMemoryCount ?? 0 + const archivedMemoryCount = row?.archivedMemoryCount ?? 0 return { - total: row?.total ?? 0, - pendingEmbedding: row?.pendingEmbedding ?? 0 + total: activeMemoryCount, + pendingEmbedding: row?.pendingEmbedding ?? 0, + activeMemoryCount, + archivedMemoryCount } } + // Mirrors the pair-validity predicate in ConflictService.listConflicts exactly (challenger is a + // live 'conflicted' row; its conflict_with target belongs to the same agent, is still + // 'challenged', and hasn't itself been superseded). Keep both in sync. + countConflictPairs(agentId: string): number { + const row = this.db + .prepare( + `SELECT COUNT(*) AS count + FROM agent_memory challenger + JOIN agent_memory target ON target.id = challenger.conflict_with + WHERE challenger.agent_id = ? + AND challenger.status = 'conflicted' + AND challenger.superseded_by IS NULL + AND target.agent_id = challenger.agent_id + AND target.conflict_state = 'challenged' + AND target.superseded_by IS NULL` + ) + .get(agentId) as { count: number } | undefined + return row?.count ?? 0 + } + + getPersonaCounts(agentId: string): { total: number; draft: number } { + const row = this.db + .prepare( + `SELECT + SUM(CASE + WHEN persona_state IS NULL OR persona_state IN ('active', 'superseded') THEN 1 + ELSE 0 + END) AS total, + SUM(CASE WHEN persona_state = 'draft' THEN 1 ELSE 0 END) AS draft + FROM agent_memory + WHERE agent_id = ? AND kind = 'persona'` + ) + .get(agentId) as { total: number | null; draft: number | null } | undefined + return { total: row?.total ?? 0, draft: row?.draft ?? 0 } + } + runInTransaction(fn: () => T): T { return this.db.transaction(fn)() } diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts index f3e82e770..1b23e1d94 100644 --- a/src/main/routes/index.ts +++ b/src/main/routes/index.ts @@ -111,6 +111,7 @@ import { memoryRollbackPersonaRoute, memorySearchRoute, memorySetPersonaAnchorRoute, + memoryUpdateRoute, dialogErrorRoute, dialogRespondRoute, deviceGetAppVersionRoute, @@ -2372,6 +2373,20 @@ export async function dispatchDeepchatRoute( return memoryAddRoute.output.parse({ result: toMemoryAddResultDto(outcome) }) } + case memoryUpdateRoute.name: { + const input = memoryUpdateRoute.input.parse(rawInput) + const agentType = await runtime.configPresenter.getAgentType(input.agentId) + if (agentType !== 'deepchat') { + return memoryUpdateRoute.output.parse({ result: { action: 'noop' } }) + } + const result = runtime.memoryPresenter.updateMemory( + input.agentId, + input.memoryId, + input.patch + ) + return memoryUpdateRoute.output.parse({ result }) + } + case memoryGetByIdsRoute.name: { const input = memoryGetByIdsRoute.input.parse(rawInput) const agentType = await runtime.configPresenter.getAgentType(input.agentId) diff --git a/src/renderer/api/MemoryClient.ts b/src/renderer/api/MemoryClient.ts index 6ef786615..9232b884d 100644 --- a/src/renderer/api/MemoryClient.ts +++ b/src/renderer/api/MemoryClient.ts @@ -24,6 +24,7 @@ import { memoryRollbackPersonaRoute, memorySearchRoute, memorySetPersonaAnchorRoute, + memoryUpdateRoute, type MemoryAddResult, type MemoryArchiveCandidateLifecyclePreview, type MemoryConflictItem, @@ -34,6 +35,7 @@ import { type MemorySearchResult, type MemorySourceSpan, type MemoryStatusDto, + type MemoryUpdateResult, type MemoryViewManifest } from '@shared/contracts/routes' import { memoryUpdatedEvent, type DeepchatEventPayload } from '@shared/contracts/events' @@ -65,6 +67,11 @@ type MemoryAddPayload = { importance?: number sessionId?: string } +type MemoryUpdateInput = { + content?: string + category?: AgentMemoryCategory | null + importance?: number +} export function createMemoryClient(bridge: DeepchatBridge = getDeepchatBridge()) { async function list(agentId: string): Promise { @@ -126,6 +133,15 @@ export function createMemoryClient(bridge: DeepchatBridge = getDeepchatBridge()) return result.result } + async function update( + agentId: string, + memoryId: string, + patch: MemoryUpdateInput + ): Promise { + const result = await bridge.invoke(memoryUpdateRoute.name, { agentId, memoryId, patch }) + return result.result + } + async function getByIds(agentId: string, memoryIds: string[]): Promise { const result = await bridge.invoke(memoryGetByIdsRoute.name, { agentId, memoryIds }) return result.memories @@ -257,6 +273,7 @@ export function createMemoryClient(bridge: DeepchatBridge = getDeepchatBridge()) getArchiveCandidateLifecyclePreview, search, add, + update, getByIds, listAuditEvents, listViewManifests, diff --git a/src/renderer/settings/components/MemoryConfigInlinePanel.vue b/src/renderer/settings/components/MemoryConfigInlinePanel.vue new file mode 100644 index 000000000..f60173a7d --- /dev/null +++ b/src/renderer/settings/components/MemoryConfigInlinePanel.vue @@ -0,0 +1,720 @@ + + + diff --git a/src/renderer/settings/components/MemoryConfigPanel.vue b/src/renderer/settings/components/MemoryConfigPanel.vue deleted file mode 100644 index 0bcb0c621..000000000 --- a/src/renderer/settings/components/MemoryConfigPanel.vue +++ /dev/null @@ -1,624 +0,0 @@ - - - diff --git a/src/renderer/settings/components/MemoryDiagnosticsPanel.vue b/src/renderer/settings/components/MemoryDiagnosticsPanel.vue new file mode 100644 index 000000000..c2c5de02f --- /dev/null +++ b/src/renderer/settings/components/MemoryDiagnosticsPanel.vue @@ -0,0 +1,427 @@ + + + diff --git a/src/renderer/settings/components/MemoryEmptyState.vue b/src/renderer/settings/components/MemoryEmptyState.vue new file mode 100644 index 000000000..cca8162b9 --- /dev/null +++ b/src/renderer/settings/components/MemoryEmptyState.vue @@ -0,0 +1,49 @@ + + + diff --git a/src/renderer/settings/components/MemoryHealthSection.vue b/src/renderer/settings/components/MemoryHealthSection.vue deleted file mode 100644 index 25e4c9ddb..000000000 --- a/src/renderer/settings/components/MemoryHealthSection.vue +++ /dev/null @@ -1,575 +0,0 @@ - - - diff --git a/src/renderer/settings/components/MemoryInboxBar.vue b/src/renderer/settings/components/MemoryInboxBar.vue new file mode 100644 index 000000000..4ad3b9a50 --- /dev/null +++ b/src/renderer/settings/components/MemoryInboxBar.vue @@ -0,0 +1,234 @@ + + + diff --git a/src/renderer/settings/components/MemoryInlinePanel.vue b/src/renderer/settings/components/MemoryInlinePanel.vue new file mode 100644 index 000000000..142c5d54e --- /dev/null +++ b/src/renderer/settings/components/MemoryInlinePanel.vue @@ -0,0 +1,640 @@ +