- {{ t('settings.deepchatAgents.memoryManager.health.pipeline') }}
+
+
+ {{ t('settings.deepchatAgents.memoryManager.health.pipeline') }}
+
+
import { computed, defineComponent, h } from 'vue'
import { useI18n } from 'vue-i18n'
+import { Icon } from '@iconify/vue'
import { Badge } from '@shadcn/components/ui/badge'
+import { Button } from '@shadcn/components/ui/button'
import type {
MemoryArchiveCandidateLifecyclePreview,
MemoryHealthDto,
@@ -284,14 +308,20 @@ const props = withDefaults(
archiveCandidateLifecyclePreview?: MemoryArchiveCandidateLifecyclePreview | null
archiveCandidateLifecyclePreviewLoading?: boolean
archiveCandidateLifecyclePreviewError?: string | null
+ reindexing?: boolean
}>(),
{
archiveCandidateLifecyclePreview: null,
archiveCandidateLifecyclePreviewLoading: false,
- archiveCandidateLifecyclePreviewError: null
+ archiveCandidateLifecyclePreviewError: null,
+ reindexing: false
}
)
+const emit = defineEmits<{
+ reindex: []
+}>()
+
const { t, locale } = useI18n()
const dash = '—'
diff --git a/src/renderer/settings/components/MemoryManagerPanel.vue b/src/renderer/settings/components/MemoryManagerPanel.vue
index d2de9df85..0b1d5d187 100644
--- a/src/renderer/settings/components/MemoryManagerPanel.vue
+++ b/src/renderer/settings/components/MemoryManagerPanel.vue
@@ -393,6 +393,8 @@
:archive-candidate-lifecycle-preview="archiveCandidateLifecyclePreview"
:archive-candidate-lifecycle-preview-loading="archiveCandidateLifecyclePreviewLoading"
:archive-candidate-lifecycle-preview-error="archiveCandidateLifecyclePreviewError"
+ :reindexing="isReindexing"
+ @reindex="handleReindex"
/>
@@ -785,6 +787,7 @@ const auditEvents = ref([])
const viewManifests = ref([])
const status = ref(null)
const health = ref(null)
+const reindexPendingAgentId = ref(null)
const healthDirty = ref(true)
const archiveCandidateLifecyclePreview = ref(null)
const archiveCandidateLifecyclePreviewLoading = ref(false)
@@ -905,6 +908,16 @@ function markHealthDirty(): void {
archiveCandidateLifecyclePreviewLoading.value = false
}
+const isReindexing = computed(
+ () => reindexPendingAgentId.value === props.agentId || status.value?.reindexing === true
+)
+
+function settleReindexPending(agentId: string): void {
+ if (reindexPendingAgentId.value === agentId && status.value?.reindexing !== true) {
+ reindexPendingAgentId.value = null
+ }
+}
+
function refreshHealthIfActive(): void {
if (activeTab.value === 'health') void ensureHealthFresh()
}
@@ -1389,9 +1402,36 @@ async function handleRestore(memoryId: string): Promise {
}
}
+async function handleReindex(): Promise {
+ if (!props.agentId || isReindexing.value) return
+ const agentId = props.agentId
+ reindexPendingAgentId.value = agentId
+ try {
+ const result = await memoryClient.reindex(agentId)
+ if (props.agentId === agentId && status.value) {
+ status.value = { ...status.value, reindexing: result.started || status.value.reindexing }
+ }
+ if (!result.started && reindexPendingAgentId.value === agentId) {
+ reindexPendingAgentId.value = null
+ }
+ if (props.agentId === agentId) {
+ await Promise.all([refresh(), refreshHealth(agentId)])
+ settleReindexPending(agentId)
+ }
+ } catch (e) {
+ if (reindexPendingAgentId.value === agentId) {
+ reindexPendingAgentId.value = null
+ }
+ notifyActionFailed(e)
+ }
+}
+
watch(
() => props.agentId,
- () => {
+ (agentId) => {
+ if (reindexPendingAgentId.value !== agentId) {
+ reindexPendingAgentId.value = null
+ }
activeTab.value = 'memories'
categoryFilter.value = 'all'
markHealthDirty()
@@ -1412,7 +1452,14 @@ onMounted(() => {
if (payload.agentId === props.agentId) {
memoryUpdateVersion += 1
markHealthDirtyFromEvent(payload.reason)
- void refresh()
+ const refreshTask = refresh()
+ if (payload.reason === 'reindex') {
+ void refreshTask.then(() => {
+ settleReindexPending(payload.agentId)
+ })
+ } else {
+ void refreshTask
+ }
}
})
})
diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json
index e97c57f52..8fdf48907 100644
--- a/src/renderer/src/i18n/da-DK/settings.json
+++ b/src/renderer/src/i18n/da-DK/settings.json
@@ -2645,6 +2645,8 @@
"byCategory": "Efter kategori",
"byStatus": "Efter status",
"pipeline": "Pipeline",
+ "reindex": "Genindekser",
+ "reindexing": "Genindekserer",
"quality": "Kvalitet",
"topAccessed": "Mest brugt",
"noTopAccessed": "Ingen brugte minder endnu.",
diff --git a/src/renderer/src/i18n/de-DE/settings.json b/src/renderer/src/i18n/de-DE/settings.json
index a5bda0d86..4eb856a92 100644
--- a/src/renderer/src/i18n/de-DE/settings.json
+++ b/src/renderer/src/i18n/de-DE/settings.json
@@ -250,6 +250,8 @@
"byCategory": "Nach Kategorie",
"byStatus": "Nach Status",
"pipeline": "Pipeline",
+ "reindex": "Neu indizieren",
+ "reindexing": "Indizierung läuft",
"quality": "Qualität",
"topAccessed": "Am häufigsten genutzt",
"noTopAccessed": "Noch keine genutzten Erinnerungen.",
diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json
index 6e96a6d55..f676fb1a5 100644
--- a/src/renderer/src/i18n/en-US/settings.json
+++ b/src/renderer/src/i18n/en-US/settings.json
@@ -247,6 +247,8 @@
"byCategory": "By category",
"byStatus": "By status",
"pipeline": "Pipeline",
+ "reindex": "Reindex",
+ "reindexing": "Reindexing",
"quality": "Quality",
"topAccessed": "Top accessed",
"noTopAccessed": "No accessed memories yet.",
diff --git a/src/renderer/src/i18n/es-ES/settings.json b/src/renderer/src/i18n/es-ES/settings.json
index 494dc2ae9..b4b51dadc 100644
--- a/src/renderer/src/i18n/es-ES/settings.json
+++ b/src/renderer/src/i18n/es-ES/settings.json
@@ -250,6 +250,8 @@
"byCategory": "Por categoría",
"byStatus": "Por estado",
"pipeline": "Canalización",
+ "reindex": "Reindexar",
+ "reindexing": "Reindexando",
"quality": "Calidad",
"topAccessed": "Más usadas",
"noTopAccessed": "Aún no hay memorias usadas.",
diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json
index 0d2ccd3ba..8bd18b96b 100644
--- a/src/renderer/src/i18n/fa-IR/settings.json
+++ b/src/renderer/src/i18n/fa-IR/settings.json
@@ -2645,6 +2645,8 @@
"byCategory": "بر اساس دسته",
"byStatus": "بر اساس وضعیت",
"pipeline": "خط پردازش",
+ "reindex": "بازنمایهسازی",
+ "reindexing": "در حال بازنمایهسازی",
"quality": "کیفیت",
"topAccessed": "بیشترین استفاده",
"noTopAccessed": "هنوز حافظه استفادهشدهای نیست.",
diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json
index c971a686a..e7e50f8a4 100644
--- a/src/renderer/src/i18n/fr-FR/settings.json
+++ b/src/renderer/src/i18n/fr-FR/settings.json
@@ -2645,6 +2645,8 @@
"byCategory": "Par catégorie",
"byStatus": "Par statut",
"pipeline": "Pipeline",
+ "reindex": "Réindexer",
+ "reindexing": "Réindexation en cours",
"quality": "Qualité",
"topAccessed": "Les plus consultées",
"noTopAccessed": "Aucune mémoire consultée pour le moment.",
diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json
index 4798ce422..9f3ca45d1 100644
--- a/src/renderer/src/i18n/he-IL/settings.json
+++ b/src/renderer/src/i18n/he-IL/settings.json
@@ -2645,6 +2645,8 @@
"byCategory": "לפי קטגוריה",
"byStatus": "לפי סטטוס",
"pipeline": "צינור עיבוד",
+ "reindex": "בנה אינדקס מחדש",
+ "reindexing": "בונה אינדקס מחדש",
"quality": "איכות",
"topAccessed": "הכי נגישים",
"noTopAccessed": "אין עדיין זיכרונות שנעשה בהם שימוש.",
diff --git a/src/renderer/src/i18n/id-ID/settings.json b/src/renderer/src/i18n/id-ID/settings.json
index 5511cd7e1..9e94b4186 100644
--- a/src/renderer/src/i18n/id-ID/settings.json
+++ b/src/renderer/src/i18n/id-ID/settings.json
@@ -250,6 +250,8 @@
"byCategory": "Menurut kategori",
"byStatus": "Menurut status",
"pipeline": "Pipeline",
+ "reindex": "Indeks ulang",
+ "reindexing": "Mengindeks ulang",
"quality": "Kualitas",
"topAccessed": "Paling sering diakses",
"noTopAccessed": "Belum ada memori yang diakses.",
diff --git a/src/renderer/src/i18n/it-IT/settings.json b/src/renderer/src/i18n/it-IT/settings.json
index 6d78e0d54..04f4b2da0 100644
--- a/src/renderer/src/i18n/it-IT/settings.json
+++ b/src/renderer/src/i18n/it-IT/settings.json
@@ -250,6 +250,8 @@
"byCategory": "Per categoria",
"byStatus": "Per stato",
"pipeline": "Pipeline",
+ "reindex": "Reindicizza",
+ "reindexing": "Reindicizzazione",
"quality": "Qualità",
"topAccessed": "Più consultate",
"noTopAccessed": "Nessuna memoria consultata finora.",
diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json
index 6f2a7aa2c..1f47baa61 100644
--- a/src/renderer/src/i18n/ja-JP/settings.json
+++ b/src/renderer/src/i18n/ja-JP/settings.json
@@ -2645,6 +2645,8 @@
"byCategory": "カテゴリ別",
"byStatus": "状態別",
"pipeline": "パイプライン",
+ "reindex": "再インデックス",
+ "reindexing": "再インデックス中",
"quality": "品質",
"topAccessed": "アクセス上位",
"noTopAccessed": "アクセスされたメモリーはまだありません。",
diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json
index 66c327657..6e32ded8c 100644
--- a/src/renderer/src/i18n/ko-KR/settings.json
+++ b/src/renderer/src/i18n/ko-KR/settings.json
@@ -2645,6 +2645,8 @@
"byCategory": "카테고리별",
"byStatus": "상태별",
"pipeline": "파이프라인",
+ "reindex": "다시 색인",
+ "reindexing": "다시 색인 중",
"quality": "품질",
"topAccessed": "최다 사용",
"noTopAccessed": "아직 사용된 메모리가 없습니다.",
diff --git a/src/renderer/src/i18n/ms-MY/settings.json b/src/renderer/src/i18n/ms-MY/settings.json
index 4fd423df6..7538d7abf 100644
--- a/src/renderer/src/i18n/ms-MY/settings.json
+++ b/src/renderer/src/i18n/ms-MY/settings.json
@@ -250,6 +250,8 @@
"byCategory": "Mengikut kategori",
"byStatus": "Mengikut status",
"pipeline": "Saluran",
+ "reindex": "Indeks semula",
+ "reindexing": "Sedang mengindeks semula",
"quality": "Kualiti",
"topAccessed": "Paling kerap diakses",
"noTopAccessed": "Belum ada memori yang diakses.",
diff --git a/src/renderer/src/i18n/pl-PL/settings.json b/src/renderer/src/i18n/pl-PL/settings.json
index 0f7ed20a1..674962c58 100644
--- a/src/renderer/src/i18n/pl-PL/settings.json
+++ b/src/renderer/src/i18n/pl-PL/settings.json
@@ -250,6 +250,8 @@
"byCategory": "Według kategorii",
"byStatus": "Według statusu",
"pipeline": "Pipeline",
+ "reindex": "Przeindeksuj",
+ "reindexing": "Ponowne indeksowanie",
"quality": "Jakość",
"topAccessed": "Najczęściej używane",
"noTopAccessed": "Brak użytych wspomnień.",
diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json
index 745a6aa60..9cea20818 100644
--- a/src/renderer/src/i18n/pt-BR/settings.json
+++ b/src/renderer/src/i18n/pt-BR/settings.json
@@ -2645,6 +2645,8 @@
"byCategory": "Por categoria",
"byStatus": "Por status",
"pipeline": "Pipeline",
+ "reindex": "Reindexar",
+ "reindexing": "Reindexando",
"quality": "Qualidade",
"topAccessed": "Mais acessadas",
"noTopAccessed": "Nenhuma memória acessada ainda.",
diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json
index 17f49d7ef..1d02249d0 100644
--- a/src/renderer/src/i18n/ru-RU/settings.json
+++ b/src/renderer/src/i18n/ru-RU/settings.json
@@ -2645,6 +2645,8 @@
"byCategory": "По категории",
"byStatus": "По статусу",
"pipeline": "Конвейер",
+ "reindex": "Переиндексировать",
+ "reindexing": "Переиндексация",
"quality": "Качество",
"topAccessed": "Чаще всего использовались",
"noTopAccessed": "Использованных воспоминаний пока нет.",
diff --git a/src/renderer/src/i18n/tr-TR/settings.json b/src/renderer/src/i18n/tr-TR/settings.json
index 4c4762a9e..0da4cdb15 100644
--- a/src/renderer/src/i18n/tr-TR/settings.json
+++ b/src/renderer/src/i18n/tr-TR/settings.json
@@ -250,6 +250,8 @@
"byCategory": "Kategoriye göre",
"byStatus": "Duruma göre",
"pipeline": "İş hattı",
+ "reindex": "Yeniden indeksle",
+ "reindexing": "Yeniden indeksleniyor",
"quality": "Kalite",
"topAccessed": "En çok erişilen",
"noTopAccessed": "Henüz erişilmiş bellek yok.",
diff --git a/src/renderer/src/i18n/vi-VN/settings.json b/src/renderer/src/i18n/vi-VN/settings.json
index eaf2eccf8..33f2185c3 100644
--- a/src/renderer/src/i18n/vi-VN/settings.json
+++ b/src/renderer/src/i18n/vi-VN/settings.json
@@ -250,6 +250,8 @@
"byCategory": "Theo danh mục",
"byStatus": "Theo trạng thái",
"pipeline": "Pipeline",
+ "reindex": "Lập chỉ mục lại",
+ "reindexing": "Đang lập chỉ mục lại",
"quality": "Chất lượng",
"topAccessed": "Truy cập nhiều nhất",
"noTopAccessed": "Chưa có bộ nhớ nào được truy cập.",
diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json
index 6f9fcb0d0..58768bcc9 100644
--- a/src/renderer/src/i18n/zh-CN/settings.json
+++ b/src/renderer/src/i18n/zh-CN/settings.json
@@ -247,6 +247,8 @@
"byCategory": "按分类",
"byStatus": "按状态",
"pipeline": "管线",
+ "reindex": "重建索引",
+ "reindexing": "重建中",
"quality": "质量",
"topAccessed": "访问最多",
"noTopAccessed": "暂无被访问过的记忆。",
diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json
index 399dd139a..15eecad2b 100644
--- a/src/renderer/src/i18n/zh-HK/settings.json
+++ b/src/renderer/src/i18n/zh-HK/settings.json
@@ -2645,6 +2645,8 @@
"byCategory": "按分類",
"byStatus": "按狀態",
"pipeline": "管線",
+ "reindex": "重建索引",
+ "reindexing": "重建中",
"quality": "質素",
"topAccessed": "存取最多",
"noTopAccessed": "暫無被存取過的記憶。",
diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json
index 5ec9c5865..33ebf791a 100644
--- a/src/renderer/src/i18n/zh-TW/settings.json
+++ b/src/renderer/src/i18n/zh-TW/settings.json
@@ -2645,6 +2645,8 @@
"byCategory": "依分類",
"byStatus": "依狀態",
"pipeline": "管線",
+ "reindex": "重建索引",
+ "reindexing": "重建中",
"quality": "品質",
"topAccessed": "存取最多",
"noTopAccessed": "尚無被存取過的記憶。",
diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts
index f32bc0ac9..5a9398fb1 100644
--- a/src/shared/contracts/routes.ts
+++ b/src/shared/contracts/routes.ts
@@ -46,6 +46,7 @@ import {
memoryListRoute,
memoryListViewManifestsRoute,
memoryRejectPersonaDraftRoute,
+ memoryReindexRoute,
memoryResolveConflictRoute,
memoryRestoreRoute,
memoryRollbackPersonaRoute,
@@ -873,6 +874,7 @@ const DEEPCHAT_ROUTE_CATALOG_PART_5 = {
[memoryGetByIdsRoute.name]: memoryGetByIdsRoute,
[memoryGetStatusRoute.name]: memoryGetStatusRoute,
[memoryGetHealthRoute.name]: memoryGetHealthRoute,
+ [memoryReindexRoute.name]: memoryReindexRoute,
[memoryGetLifecycleRoute.name]: memoryGetLifecycleRoute,
[memoryGetArchiveCandidateLifecyclePreviewRoute.name]:
memoryGetArchiveCandidateLifecyclePreviewRoute,
diff --git a/src/shared/contracts/routes/memory.routes.ts b/src/shared/contracts/routes/memory.routes.ts
index c65269a37..1d051c76a 100644
--- a/src/shared/contracts/routes/memory.routes.ts
+++ b/src/shared/contracts/routes/memory.routes.ts
@@ -331,6 +331,12 @@ export const memoryGetHealthRoute = defineRouteContract({
output: z.object({ health: MemoryHealthSchema })
})
+export const memoryReindexRoute = defineRouteContract({
+ name: 'memory.reindex',
+ input: z.object({ agentId: AgentIdSchema }),
+ output: z.object({ started: z.boolean() })
+})
+
export const memoryGetLifecycleRoute = defineRouteContract({
name: 'memory.getLifecycle',
input: z.object({ agentId: AgentIdSchema, memoryId: z.string().min(1) }),
diff --git a/test/main/presenter/agentMemoryTable.test.ts b/test/main/presenter/agentMemoryTable.test.ts
index c4ee7f1e6..b35c74c07 100644
--- a/test/main/presenter/agentMemoryTable.test.ts
+++ b/test/main/presenter/agentMemoryTable.test.ts
@@ -831,6 +831,81 @@ describeIfSqlite('AgentMemoryTable', () => {
}
})
+ it('listWorkingCandidates pages by the stable working-blob ordering cursor', () => {
+ const db = new DatabaseCtor(':memory:')
+ try {
+ const table = new AgentMemoryTableCtor(db)
+ table.createTable()
+ table.insert({
+ id: 'm1',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'first',
+ importance: 0.4,
+ createdAt: 1000
+ })
+ table.insert({
+ id: 'm2',
+ agentId: 'a',
+ kind: 'episodic',
+ content: 'second',
+ importance: 0.8,
+ createdAt: 2000
+ })
+ table.insert({
+ id: 'm3',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'third',
+ importance: 0.9,
+ createdAt: 3000
+ })
+ table.insert({
+ id: 'm4',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'fourth',
+ importance: 0.9,
+ createdAt: 4000
+ })
+ table.recordAccess('m3', 5000)
+ table.recordAccess('m4', 5000)
+ table.recordAccess('m4', 6000)
+ const archived = table.insert({
+ id: 'archived',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'archived',
+ importance: 1,
+ createdAt: 9000
+ })
+ table.archive(archived.id)
+ table.insert({ id: 'working', agentId: 'a', kind: 'working', content: 'working' })
+ const superseded = table.insert({
+ id: 'superseded',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'superseded',
+ importance: 1,
+ createdAt: 8000
+ })
+ table.markSuperseded(superseded.id, 'm4')
+
+ const firstPage = table.listWorkingCandidates('a', 2)
+ expect(firstPage.map((row) => row.id)).toEqual(['m4', 'm3'])
+ const cursorRow = firstPage[1]
+ const secondPage = table.listWorkingCandidates('a', 2, {
+ importance: cursorRow.importance,
+ accessCount: cursorRow.access_count,
+ createdAt: cursorRow.created_at,
+ id: cursorRow.id
+ })
+ expect(secondPage.map((row) => row.id)).toEqual(['m2', 'm1'])
+ } finally {
+ db.close()
+ }
+ })
+
it('lists archive candidate lifecycle projections without content payloads', () => {
const db = new DatabaseCtor(':memory:')
try {
@@ -1213,6 +1288,29 @@ describeIfSqlite('AgentMemoryTable FTS5 + migration', () => {
}
})
+ it('requeueForEmbedding supports a bounded id cursor for fair error retry', () => {
+ const db = new DatabaseCtor(':memory:')
+ try {
+ const table = new AgentMemoryTableCtor(db)
+ table.createTable()
+ for (const id of ['err-01', 'err-02', 'err-03']) {
+ table.insert({ id, agentId: 'a', kind: 'semantic', content: id, status: 'error' })
+ }
+
+ expect(table.listEmbeddingStatusIds('a', ['error'], 2, 'err-01')).toEqual([
+ 'err-02',
+ 'err-03'
+ ])
+ expect(table.requeueForEmbedding('a', ['error'], 1, 'err-01')).toBe(1)
+
+ expect(table.getById('err-01')?.status).toBe('error')
+ expect(table.getById('err-02')?.status).toBe('pending_embedding')
+ expect(table.getById('err-03')?.status).toBe('error')
+ } finally {
+ db.close()
+ }
+ })
+
it('listPendingEmbedding never returns persona rows even if one is marked pending', () => {
const db = new DatabaseCtor(':memory:')
try {
@@ -1382,6 +1480,9 @@ describeIfSqlite('AgentMemoryTable FTS5 + migration', () => {
expect(
table.filterPrunableVectorRefs('a', ['active', 'archived', 'superseded'], 3, 'p:m')
).toEqual(['archived', 'superseded'])
+ expect(table.filterPrunableVectorRefs('a', ['missing-row'], 3, 'p:m')).toEqual([
+ 'missing-row'
+ ])
expect(
table.clearPrunableEmbeddingRefs('a', ['active', 'archived', 'superseded'], 3, 'p:m')
).toBe(2)
@@ -1820,6 +1921,122 @@ describeIfSqlite('AgentMemoryTable FTS5 + migration', () => {
}
})
+ it('agent memory audit hasForgetEvent honors restore ordering', () => {
+ const db = new DatabaseCtor(':memory:')
+ try {
+ const table = new AgentMemoryAuditTableCtor(db)
+ table.createTable()
+ table.insert({
+ id: 'forget-1',
+ agentId: 'a',
+ eventType: 'memory/forget',
+ actorType: 'runtime',
+ status: 'completed',
+ inputRefs: { memoryId: 'm1' },
+ outputRefs: { memoryId: 'm1' },
+ createdAt: 100
+ })
+ expect(table.hasForgetEvent('a', 'm1')).toBe(true)
+
+ table.insert({
+ id: 'restore-1',
+ agentId: 'a',
+ eventType: 'memory/restore',
+ actorType: 'user',
+ status: 'completed',
+ inputRefs: { memoryId: 'm1' },
+ outputRefs: { memoryId: 'm1' },
+ createdAt: 200
+ })
+ expect(table.hasForgetEvent('a', 'm1')).toBe(false)
+
+ table.insert({
+ id: 'archive-1',
+ agentId: 'a',
+ eventType: 'memory/archive',
+ actorType: 'user',
+ status: 'completed',
+ inputRefs: { memoryId: 'm1' },
+ outputRefs: { memoryId: 'm1' },
+ createdAt: 300
+ })
+ expect(table.hasForgetEvent('a', 'm1')).toBe(true)
+
+ table.insert({
+ id: 'restore-failed',
+ agentId: 'a',
+ eventType: 'memory/restore',
+ actorType: 'user',
+ status: 'failed',
+ inputRefs: { memoryId: 'm1' },
+ outputRefs: { memoryId: 'm1' },
+ createdAt: 400
+ })
+ expect(table.hasForgetEvent('a', 'm1')).toBe(true)
+ expect(table.hasForgetEvent('a', 'other')).toBe(false)
+ } finally {
+ db.close()
+ }
+ })
+
+ it('agent memory audit hasForgetEvent does not miss older memory refs behind newer events', () => {
+ const db = new DatabaseCtor(':memory:')
+ try {
+ const table = new AgentMemoryAuditTableCtor(db)
+ table.createTable()
+ table.insert({
+ id: 'old-forget',
+ agentId: 'a',
+ eventType: 'memory/forget',
+ actorType: 'runtime',
+ status: 'completed',
+ inputRefs: { memoryId: 'm-old' },
+ outputRefs: { memoryId: 'm-old' },
+ createdAt: 1
+ })
+ for (let index = 0; index < 205; index += 1) {
+ table.insert({
+ id: `newer-other-${index}`,
+ agentId: 'a',
+ eventType: 'memory/restore',
+ actorType: 'user',
+ status: 'completed',
+ inputRefs: { memoryId: `other-${index}` },
+ outputRefs: { memoryId: `other-${index}` },
+ createdAt: 1000 + index
+ })
+ }
+
+ expect(table.hasForgetEvent('a', 'm-old')).toBe(true)
+
+ table.insert({
+ id: 'new-restore',
+ agentId: 'a',
+ eventType: 'memory/restore',
+ actorType: 'user',
+ status: 'completed',
+ inputRefs: { memoryId: 'm-old' },
+ outputRefs: { memoryId: 'm-old' },
+ createdAt: 2000
+ })
+ expect(table.hasForgetEvent('a', 'm-old')).toBe(false)
+
+ table.insert({
+ id: 'new-archive',
+ agentId: 'a',
+ eventType: 'memory/archive',
+ actorType: 'user',
+ status: 'completed',
+ inputRefs: { memoryId: 'm-old' },
+ outputRefs: { memoryId: 'm-old' },
+ createdAt: 2001
+ })
+ expect(table.hasForgetEvent('a', 'm-old')).toBe(true)
+ } finally {
+ db.close()
+ }
+ })
+
it('agent memory audit computes bounded health status counts and recent failures', () => {
const db = new DatabaseCtor(':memory:')
try {
diff --git a/test/main/presenter/fakes/memoryFakes.ts b/test/main/presenter/fakes/memoryFakes.ts
index 412ffb1bf..70de655e7 100644
--- a/test/main/presenter/fakes/memoryFakes.ts
+++ b/test/main/presenter/fakes/memoryFakes.ts
@@ -15,6 +15,7 @@ import type {
AgentMemoryInsertInput,
AgentMemoryLifecycleRow,
AgentMemoryRow,
+ AgentMemoryWorkingCandidateCursor,
IMemoryVectorStore,
MemoryAuditListOptions,
MemoryAuditRepositoryPort,
@@ -46,6 +47,7 @@ function toLifecycleRow(row: AgentMemoryRow): AgentMemoryLifecycleRow {
// exercise the presenter without a native database.
export class FakeRepository implements MemoryRepositoryPort {
rows = new Map()
+ transactionCalls = 0
insert(input: AgentMemoryInsertInput): AgentMemoryRow {
if (input.provenanceKey) {
@@ -267,9 +269,26 @@ export class FakeRepository implements MemoryRepositoryPort {
return true
}
- requeueForEmbedding(agentId: string, statuses: AgentMemoryRow['status'][]) {
+ requeueForEmbedding(
+ agentId: string,
+ statuses: AgentMemoryRow['status'][],
+ limit?: number,
+ afterId?: string | null
+ ) {
let changed = 0
- for (const row of this.rows.values()) {
+ const candidates = [...this.rows.values()]
+ .filter(
+ (row) =>
+ row.agent_id === agentId &&
+ !row.superseded_by &&
+ row.kind !== 'persona' &&
+ row.kind !== 'working' &&
+ statuses.includes(row.status) &&
+ (!afterId || row.id > afterId)
+ )
+ .sort((a, b) => a.id.localeCompare(b.id))
+ .slice(0, limit === undefined ? undefined : Math.max(0, Math.floor(limit)))
+ for (const row of candidates) {
if (
row.agent_id !== agentId ||
row.superseded_by ||
@@ -287,6 +306,27 @@ export class FakeRepository implements MemoryRepositoryPort {
return changed
}
+ listEmbeddingStatusIds(
+ agentId: string,
+ statuses: AgentMemoryRow['status'][],
+ limit: number,
+ afterId?: string | null
+ ) {
+ return [...this.rows.values()]
+ .filter(
+ (row) =>
+ row.agent_id === agentId &&
+ !row.superseded_by &&
+ row.kind !== 'persona' &&
+ row.kind !== 'working' &&
+ statuses.includes(row.status) &&
+ (!afterId || row.id > afterId)
+ )
+ .sort((a, b) => a.id.localeCompare(b.id))
+ .slice(0, Math.max(0, Math.floor(limit)))
+ .map((row) => row.id)
+ }
+
markSuperseded(id: string, supersededBy: string | null) {
const row = this.rows.get(id)
if (row) row.superseded_by = supersededBy
@@ -314,6 +354,23 @@ export class FakeRepository implements MemoryRepositoryPort {
}
}
+ refreshDecayScoresForAgent(agentId: string, now: number, halfLifeMs: number) {
+ for (const row of this.listByAgent(agentId)) {
+ if (row.kind === 'persona') continue
+ const anchor = row.last_accessed ?? row.created_at
+ const age = Math.max(0, now - anchor)
+ const importance = Math.min(1, Math.max(0, row.importance))
+ row.decay_score = Math.pow(0.5, age / (halfLifeMs * (1 + importance)))
+ }
+ }
+
+ stampConsolidationForAgent(agentId: string, at: number) {
+ for (const row of this.listByAgent(agentId)) {
+ if (row.kind === 'persona') continue
+ row.last_consolidated_at = at
+ }
+ }
+
updateContent(
id: string,
content: string,
@@ -531,6 +588,67 @@ export class FakeRepository implements MemoryRepositoryPort {
return this.listByAgent(agentId, { includeSuperseded: true }).length
}
+ countStatusView(agentId: string) {
+ const rows = [...this.rows.values()].filter(
+ (row) =>
+ row.agent_id === agentId &&
+ row.status !== 'archived' &&
+ row.status !== 'conflicted' &&
+ row.kind !== 'working'
+ )
+ return {
+ total: rows.length,
+ pendingEmbedding: rows.filter((row) => row.status === 'pending_embedding').length
+ }
+ }
+
+ runInTransaction(fn: () => T): T {
+ this.transactionCalls += 1
+ const snapshot = new Map([...this.rows.entries()].map(([id, row]) => [id, { ...row }]))
+ try {
+ return fn()
+ } catch (error) {
+ this.rows = snapshot
+ throw error
+ }
+ }
+
+ listWorkingCandidates(agentId: string, limit: number, after?: AgentMemoryWorkingCandidateCursor) {
+ const isAfterCursor = (row: AgentMemoryRow): boolean => {
+ if (!after) return true
+ return (
+ row.importance < after.importance ||
+ (row.importance === after.importance && row.access_count < after.accessCount) ||
+ (row.importance === after.importance &&
+ row.access_count === after.accessCount &&
+ row.created_at < after.createdAt) ||
+ (row.importance === after.importance &&
+ row.access_count === after.accessCount &&
+ row.created_at === after.createdAt &&
+ row.id < after.id)
+ )
+ }
+
+ return [...this.rows.values()]
+ .filter(
+ (row) =>
+ row.agent_id === agentId &&
+ row.superseded_by === null &&
+ row.status !== 'archived' &&
+ row.status !== 'conflicted' &&
+ (row.kind === 'semantic' || row.kind === 'reflection' || row.kind === 'episodic') &&
+ isAfterCursor(row)
+ )
+ .sort(
+ (a, b) =>
+ b.importance - a.importance ||
+ b.access_count - a.access_count ||
+ b.created_at - a.created_at ||
+ b.id.localeCompare(a.id)
+ )
+ .slice(0, Math.max(0, Math.floor(limit)))
+ }
+
hasActiveMemory(agentId: string) {
return [...this.rows.values()].some(
(row) => row.agent_id === agentId && row.status !== 'archived'
@@ -631,9 +749,10 @@ export class FakeRepository implements MemoryRepositoryPort {
embeddingModel: string
) {
const uniqueIds = [...new Set(ids.filter((id) => id.trim()))]
- return uniqueIds.filter((id) =>
- this.isPrunableVectorRow(agentId, this.rows.get(id), embeddingDim, embeddingModel)
- )
+ return uniqueIds.filter((id) => {
+ const row = this.rows.get(id)
+ return !row || this.isPrunableVectorRow(agentId, row, embeddingDim, embeddingModel)
+ })
}
clearPrunableEmbeddingRefs(
@@ -714,6 +833,26 @@ export class FakeAuditRepository implements MemoryAuditRepositoryPort {
return latest
}
+ hasForgetEvent(agentId: string, memoryId: string): boolean {
+ const rows = this.rows
+ .filter((row) => {
+ if (row.agent_id !== agentId || row.status !== 'completed') return false
+ return (
+ (row.event_type === 'memory/forget' && row.actor_type === 'runtime') ||
+ (row.event_type === 'memory/archive' && row.actor_type === 'user') ||
+ row.event_type === 'memory/restore'
+ )
+ })
+ .sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))
+ for (const row of rows) {
+ const input = JSON.parse(row.input_refs_json) as Record
+ const output = JSON.parse(row.output_refs_json) as Record
+ if (input.memoryId !== memoryId && output.memoryId !== memoryId) continue
+ return row.event_type !== 'memory/restore'
+ }
+ return false
+ }
+
getHealthAuditStats(
agentId: string,
scanLimit: number,
@@ -773,6 +912,13 @@ export class FakeVectorStore implements IMemoryVectorStore {
for (const id of memoryIds) this.vectors.delete(id)
}
+ async listMemoryIds(afterId: string | null, limit: number) {
+ return [...this.vectors.keys()]
+ .filter((id) => afterId === null || id > afterId)
+ .sort((a, b) => a.localeCompare(b))
+ .slice(0, Math.max(0, Math.floor(limit)))
+ }
+
async close() {
this.closeCount += 1
}
diff --git a/test/main/presenter/memoryExtraction.test.ts b/test/main/presenter/memoryExtraction.test.ts
index 25704fcf8..4ae8a3fc2 100644
--- a/test/main/presenter/memoryExtraction.test.ts
+++ b/test/main/presenter/memoryExtraction.test.ts
@@ -158,8 +158,10 @@ describe('MemoryPresenter.extractAndStore', () => {
query: async () => [],
queryByMemoryId: async () => [],
deleteByMemoryIds: async () => {},
+ listMemoryIds: async () => [],
clear: async () => {},
- close: async () => {}
+ close: async () => {},
+ isUsable: () => true
})
})
@@ -213,8 +215,10 @@ describe('MemoryPresenter.extractAndStore', () => {
query: async () => [],
queryByMemoryId: async () => [],
deleteByMemoryIds: async () => {},
+ listMemoryIds: async () => [],
clear: async () => {},
- close: async () => {}
+ close: async () => {},
+ isUsable: () => true
})
})
@@ -249,8 +253,10 @@ describe('MemoryPresenter.extractAndStore', () => {
query: async () => [],
queryByMemoryId: async () => [],
deleteByMemoryIds: async () => {},
+ listMemoryIds: async () => [],
clear: async () => {},
- close: async () => {}
+ close: async () => {},
+ isUsable: () => true
})
})
@@ -303,6 +309,7 @@ describe('MemoryPresenter.extractAndStore triage gate, cheap model, lineage', ()
query: async () => [],
queryByMemoryId: async () => [],
deleteByMemoryIds: async () => {},
+ listMemoryIds: async () => [],
close: async () => {},
isUsable: () => true
}),
@@ -436,6 +443,7 @@ describe('MemoryPresenter.maybeReflect cheap model', () => {
query: async () => [],
queryByMemoryId: async () => [],
deleteByMemoryIds: async () => {},
+ listMemoryIds: async () => [],
close: async () => {},
isUsable: () => true
}),
@@ -558,7 +566,14 @@ function makeFakeRepo() {
last_accessed: null,
access_count: 0,
decay_score: null,
- source_entry_ids: input.sourceEntryIds?.length ? JSON.stringify(input.sourceEntryIds) : null
+ source_entry_ids: input.sourceEntryIds?.length
+ ? JSON.stringify(input.sourceEntryIds)
+ : null,
+ confidence: null,
+ last_consolidated_at: null,
+ conflict_state: null,
+ conflict_with: input.conflictWith ?? null,
+ persona_state: input.personaState ?? null
}
rows.set(row.id, row)
return row
@@ -583,10 +598,69 @@ function makeFakeRepo() {
[...rows.values()]
.filter((r) => r.status === 'pending_embedding' && (!agentId || r.agent_id === agentId))
.slice(0, limit),
+ updatePendingEmbeddingStatus: (
+ agentId: string,
+ id: string,
+ status: string,
+ embedding?: {
+ embeddingId?: string | null
+ embeddingDim?: number | null
+ embeddingModel?: string | null
+ }
+ ) => {
+ const r = rows.get(id)
+ if (!r || r.agent_id !== agentId || r.status !== 'pending_embedding') return false
+ r.status = status
+ r.embedding_id = embedding?.embeddingId ?? null
+ r.embedding_dim = embedding?.embeddingDim ?? null
+ r.embedding_model = embedding?.embeddingModel ?? null
+ return true
+ },
updateStatus: (id: string, status: string) => {
const r = rows.get(id)
if (r) r.status = status
},
+ requeueForEmbedding: (
+ agentId: string,
+ statuses: string[],
+ limit?: number,
+ afterId?: string | null
+ ) => {
+ const candidates = [...rows.values()]
+ .filter(
+ (r) =>
+ r.agent_id === agentId &&
+ !r.superseded_by &&
+ statuses.includes(r.status) &&
+ (!afterId || r.id > afterId)
+ )
+ .sort((a, b) => a.id.localeCompare(b.id))
+ .slice(0, limit === undefined ? undefined : Math.max(0, Math.floor(limit)))
+ for (const r of candidates) {
+ r.status = 'pending_embedding'
+ r.embedding_id = null
+ r.embedding_dim = null
+ r.embedding_model = null
+ }
+ return candidates.length
+ },
+ listEmbeddingStatusIds: (
+ agentId: string,
+ statuses: string[],
+ limit: number,
+ afterId?: string | null
+ ) =>
+ [...rows.values()]
+ .filter(
+ (r) =>
+ r.agent_id === agentId &&
+ !r.superseded_by &&
+ statuses.includes(r.status) &&
+ (!afterId || r.id > afterId)
+ )
+ .sort((a, b) => a.id.localeCompare(b.id))
+ .slice(0, Math.max(0, Math.floor(limit)))
+ .map((row) => row.id),
updateContent: (
id: string,
content: string,
@@ -603,6 +677,53 @@ function makeFakeRepo() {
},
markSuperseded: () => {},
recordAccess: () => {},
+ recordAccessBatch: () => {},
+ setPersonaState: () => {},
+ setAnchor: () => {},
+ getDraftPersona: () => undefined,
+ setConfidence: () => {},
+ setImportance: () => {},
+ markConflict: () => {},
+ setConflictWith: () => {},
+ setLastConsolidatedAt: () => {},
+ getLastConsolidatedAt: () => null,
+ getCurrentEmbeddingDimension: () => null,
+ getHealthStats: () => ({
+ totalRows: 0,
+ byKind: { semantic: 0, episodic: 0, reflection: 0, persona: 0, working: 0 },
+ byCategory: {
+ user_preference: 0,
+ project_fact: 0,
+ task_outcome: 0,
+ heuristic: 0,
+ anti_pattern: 0,
+ uncategorized: 0
+ },
+ byStatus: {
+ pending_embedding: 0,
+ embedded: 0,
+ fts_only: 0,
+ error: 0,
+ archived: 0,
+ conflicted: 0
+ },
+ neverAccessed: 0,
+ importanceAvg: null,
+ importanceMedian: null,
+ confidenceAvg: null,
+ conflicted: 0,
+ challenged: 0
+ }),
+ hasStaleEmbeddings: () => false,
+ countStaleEmbeddings: () => 0,
+ archive: (id: string) => {
+ const r = rows.get(id)
+ if (r) r.status = 'archived'
+ },
+ listArchiveCandidates: () => [],
+ listArchiveCandidateLifecycleRows: () => [],
+ countArchiveCandidates: () => 0,
+ listTopAccessed: () => [],
delete: (id: string) => rows.delete(id),
clearByAgent: (agentId: string) => {
let n = 0
@@ -611,7 +732,67 @@ function makeFakeRepo() {
},
countByAgent: (agentId: string) =>
[...rows.values()].filter((r) => r.agent_id === agentId).length,
+ countStatusView: (agentId: string) => {
+ const view = [...rows.values()].filter(
+ (r) => r.agent_id === agentId && r.status !== 'archived' && r.status !== 'conflicted'
+ )
+ return {
+ total: view.length,
+ pendingEmbedding: view.filter((r) => r.status === 'pending_embedding').length
+ }
+ },
+ hasActiveMemory: () => false,
+ listAgentIdsWithMemories: () => [],
+ runInTransaction: (fn: () => T): T => {
+ const snapshot = new Map([...rows.entries()].map(([id, row]) => [id, { ...row }]))
+ try {
+ return fn()
+ } catch (error) {
+ rows.clear()
+ for (const [id, row] of snapshot) rows.set(id, row)
+ throw error
+ }
+ },
+ listWorkingCandidates: (
+ agentId: string,
+ limit: number,
+ after?: { importance: number; accessCount: number; createdAt: number; id: string }
+ ) =>
+ [...rows.values()]
+ .filter((r) => {
+ if (
+ r.agent_id !== agentId ||
+ r.superseded_by !== null ||
+ r.status === 'archived' ||
+ r.status === 'conflicted' ||
+ !['semantic', 'reflection', 'episodic'].includes(r.kind)
+ ) {
+ return false
+ }
+ if (!after) return true
+ return (
+ r.importance < after.importance ||
+ (r.importance === after.importance && r.access_count < after.accessCount) ||
+ (r.importance === after.importance &&
+ r.access_count === after.accessCount &&
+ r.created_at < after.createdAt) ||
+ (r.importance === after.importance &&
+ r.access_count === after.accessCount &&
+ r.created_at === after.createdAt &&
+ r.id < after.id)
+ )
+ })
+ .sort(
+ (a, b) =>
+ b.importance - a.importance ||
+ b.access_count - a.access_count ||
+ b.created_at - a.created_at ||
+ b.id.localeCompare(a.id)
+ )
+ .slice(0, Math.max(0, Math.floor(limit))),
listConsolidationScanRows: () => [],
+ refreshDecayScoresForAgent: () => {},
+ stampConsolidationForAgent: () => {},
repairInternalKindStatuses: () => 0,
listPrunableVectorRefs: () => [],
filterPrunableVectorRefs: () => [],
diff --git a/test/main/presenter/memoryPresenter.test.ts b/test/main/presenter/memoryPresenter.test.ts
index 35359833d..3223afbdb 100644
--- a/test/main/presenter/memoryPresenter.test.ts
+++ b/test/main/presenter/memoryPresenter.test.ts
@@ -494,6 +494,36 @@ describe('working-memory L1 (T5)', () => {
expect(working?.content).not.toContain('x'.repeat(2000))
})
+ it('continues past the first oversized working candidate page', () => {
+ const { presenter, repo } = makePresenter(enabledConfig)
+ const listWorkingCandidatesSpy = vi.spyOn(repo, 'listWorkingCandidates')
+ for (let index = 0; index < 64; index += 1) {
+ repo.insert({
+ id: `long-${String(index).padStart(3, '0')}`,
+ agentId: 'deepchat',
+ kind: 'semantic',
+ content: `oversized ${index} ${'x'.repeat(2000)}`,
+ importance: 1,
+ createdAt: 10_000 - index
+ })
+ }
+ repo.insert({
+ id: 'short-after-page',
+ agentId: 'deepchat',
+ kind: 'semantic',
+ content: 'short fact after long candidates',
+ importance: 0.9,
+ createdAt: 1
+ })
+
+ presenter.refreshWorkingMemory('deepchat')
+
+ const working = [...repo.rows.values()].find((row) => row.kind === 'working')
+ expect(working?.content).toContain('short fact after long candidates')
+ expect(listWorkingCandidatesSpy.mock.calls.length).toBeGreaterThan(1)
+ expect(listWorkingCandidatesSpy.mock.calls[1][2]).toMatchObject({ id: 'long-063' })
+ })
+
it('falls back to recall when no working blob exists', async () => {
const { presenter, repo } = makePresenter(enabledConfig)
repo.insert({
@@ -595,22 +625,16 @@ describe('working-memory L1 (T5)', () => {
it('coalesces concurrent cold-start misses into a single refresh', async () => {
const { presenter, repo } = makePresenter(enabledConfig)
- const listByAgentSpy = vi.spyOn(repo, 'listByAgent')
+ const listWorkingCandidatesSpy = vi.spyOn(repo, 'listWorkingCandidates')
// Two opens race before either refresh microtask runs; the in-flight flag collapses them to one.
await Promise.all([
presenter.buildInjection('deepchat', 'q'),
presenter.buildInjection('deepchat', 'q')
])
await flushMicrotasks()
- const workingRefreshScans = listByAgentSpy.mock.calls.filter(([agentId, options]) => {
- const kinds = options?.kinds ?? []
- return (
- agentId === 'deepchat' &&
- kinds.includes('semantic') &&
- kinds.includes('reflection') &&
- kinds.includes('episodic')
- )
- })
+ const workingRefreshScans = listWorkingCandidatesSpy.mock.calls.filter(
+ ([agentId]) => agentId === 'deepchat'
+ )
expect(workingRefreshScans).toHaveLength(1)
})
@@ -2173,7 +2197,7 @@ describe('MemoryPresenter management', () => {
resolveCreate = () => resolve(store)
})
)
- const newContent = 'user likes redis preference'
+ const newContent = 'postgres preference'
const embeddingCalls: Array<{ texts: string[]; rowExists: boolean }> = []
const getEmbeddings = vi.fn(async (_p: string, _m: string, texts: string[]) => {
embeddingCalls.push({
@@ -2905,6 +2929,7 @@ describe('MemoryPresenter.processPendingEmbeddings (batch + fairness)', () => {
query: async () => [],
queryByMemoryId: async () => [],
deleteByMemoryIds: async () => {},
+ listMemoryIds: async () => [],
close: async () => {},
isUsable: () => true
}
@@ -2941,6 +2966,7 @@ describe('MemoryPresenter.processPendingEmbeddings (batch + fairness)', () => {
query: async () => [],
queryByMemoryId: async () => [],
deleteByMemoryIds: async () => {},
+ listMemoryIds: async () => [],
close: async () => {},
isUsable: () => true
}
@@ -2984,6 +3010,7 @@ describe('MemoryPresenter.processPendingEmbeddings (batch + fairness)', () => {
query: async () => [],
queryByMemoryId: async () => [],
deleteByMemoryIds: async () => {},
+ listMemoryIds: async () => [],
close: async () => {},
isUsable: () => false
}
@@ -3113,6 +3140,124 @@ describe('MemoryPresenter.processPendingEmbeddings (batch + fairness)', () => {
expect(repo.getById('m1')?.status).toBe('embedded')
expect(repo.getById('m2')?.status).toBe('embedded')
})
+
+ it('does not issue an empty error requeue when there are no error rows', async () => {
+ const { presenter, repo } = makePresenter(enabledConfig)
+ const requeueSpy = vi.spyOn(repo, 'requeueForEmbedding')
+
+ await presenter.processPendingEmbeddings('a')
+
+ expect(requeueSpy).not.toHaveBeenCalled()
+ })
+
+ it('cools down error retry even when a bounded requeue races to zero changes', async () => {
+ const { presenter, repo } = makePresenter(enabledConfig)
+ repo.insert({
+ id: 'err-01',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'retry race',
+ status: 'error'
+ })
+ const requeueSpy = vi.spyOn(repo, 'requeueForEmbedding')
+ requeueSpy.mockReturnValueOnce(0)
+ const embeddingState = (
+ presenter as unknown as {
+ embedding: {
+ getMutableRuntimeStateForTests(): {
+ errorRetryAt: Map
+ errorRetryAfterId: Map
+ }
+ }
+ }
+ ).embedding.getMutableRuntimeStateForTests()
+
+ await presenter.processPendingEmbeddings('a')
+
+ expect(requeueSpy).toHaveBeenCalledTimes(1)
+ expect(embeddingState.errorRetryAt.has('a')).toBe(true)
+ expect(embeddingState.errorRetryAfterId.get('a')).toBe('err-01')
+
+ await presenter.processPendingEmbeddings('a')
+
+ expect(requeueSpy).toHaveBeenCalledTimes(1)
+ })
+
+ it('advances error retry fairly instead of starving rows after the first batch', async () => {
+ const { presenter, repo } = makePresenter(enabledConfig)
+ for (let index = 0; index < 51; index += 1) {
+ repo.insert({
+ id: `err-${String(index).padStart(2, '0')}`,
+ agentId: 'a',
+ kind: 'semantic',
+ content: `retry ${index}`,
+ status: 'error'
+ })
+ }
+ const embeddingState = (
+ presenter as unknown as {
+ embedding: {
+ getMutableRuntimeStateForTests(): {
+ errorRetryAt: Map
+ errorRetryAfterId: Map
+ }
+ }
+ }
+ ).embedding.getMutableRuntimeStateForTests()
+
+ await presenter.processPendingEmbeddings('a')
+ expect(repo.getById('err-49')?.status).toBe('embedded')
+ expect(repo.getById('err-50')?.status).toBe('error')
+ expect(embeddingState.errorRetryAfterId.get('a')).toBe('err-49')
+
+ embeddingState.errorRetryAt.set('a', 0)
+ await presenter.processPendingEmbeddings('a')
+
+ expect(repo.getById('err-50')?.status).toBe('embedded')
+ })
+
+ it('sets an error retry cooldown when embedding vectors have mismatched dimensions', async () => {
+ const repo = new FakeRepository()
+ const presenter = new MemoryPresenter({
+ repository: repo,
+ resolveAgentConfig: () => enabledConfig,
+ getEmbeddings: async () => [
+ [1, 2, 3, 4],
+ [1, 2]
+ ],
+ createVectorStore: async () => new FakeVectorStore(),
+ resetVectorStore: async () => undefined
+ })
+ repo.insert({
+ id: 'ok',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'ok',
+ status: 'pending_embedding'
+ })
+ repo.insert({
+ id: 'bad',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'bad',
+ status: 'pending_embedding'
+ })
+ const embeddingState = (
+ presenter as unknown as {
+ embedding: {
+ getMutableRuntimeStateForTests(): {
+ errorRetryAt: Map
+ }
+ }
+ }
+ ).embedding.getMutableRuntimeStateForTests()
+
+ await presenter.processPendingEmbeddings('a')
+
+ expect(repo.getById('ok')?.status).toBe('embedded')
+ expect(repo.getById('bad')?.status).toBe('error')
+ expect(embeddingState.errorRetryAt.has('a')).toBe(true)
+ })
})
describe('MemoryPresenter change events (onMemoryChanged)', () => {
@@ -3154,6 +3299,14 @@ describe('MemoryPresenter change events (onMemoryChanged)', () => {
expect(onMemoryChanged).not.toHaveBeenCalled()
})
+ it('emits "reindex" when a manual reindex returns early with no rows', async () => {
+ const { presenter, onMemoryChanged } = makeWithSpy()
+
+ await presenter.reindexEmbeddings('a')
+
+ expect(onMemoryChanged).toHaveBeenCalledWith('a', 'reindex')
+ })
+
it('emits persona reasons through the draft / approve / rollback lifecycle', async () => {
const { presenter, onMemoryChanged } = makeWithSpy()
const v1 = presenter.evolvePersona('a', 'v1', null)
@@ -3646,6 +3799,104 @@ describe('writeMemoriesSync insert error classification (C2, AC-2.2)', () => {
})
describe('MemoryPresenter embedding reindex (T5, AC-3.x)', () => {
+ it('deletes orphan vectors in bounded batches during warm reconcile', async () => {
+ const { presenter, store } = makePresenter(enabledConfig)
+ for (let index = 0; index < 601; index += 1) {
+ store.vectors.set(`orphan-${String(index).padStart(4, '0')}`, textToVector('orphan redis'))
+ }
+ const deleteSpy = vi.spyOn(store, 'deleteByMemoryIds')
+
+ await presenter.recall('a', 'redis')
+ await waitForMemoryCondition(() => store.vectors.size === 0, 'orphan vectors were not deleted')
+
+ expect(deleteSpy).toHaveBeenCalledTimes(2)
+ expect(deleteSpy.mock.calls.map(([ids]) => ids.length)).toEqual([512, 89])
+ expect(deleteSpy.mock.calls.every(([ids]) => ids.length <= 512)).toBe(true)
+ })
+
+ it('keeps vector recall ready and cools down after orphan reconcile delete failure', async () => {
+ const { presenter, store } = makePresenter(enabledConfig)
+ store.vectors.set('orphan-0001', textToVector('orphan redis'))
+ const deleteSpy = vi.spyOn(store, 'deleteByMemoryIds')
+ deleteSpy.mockRejectedValueOnce(new Error('delete failed'))
+ const embeddingState = (
+ presenter as unknown as {
+ embedding: {
+ getMutableRuntimeStateForTests(): {
+ orphanVectorReconcileRetryAt: Map
+ }
+ }
+ }
+ ).embedding.getMutableRuntimeStateForTests()
+
+ await presenter.recall('a', 'redis')
+ await waitForMemoryCondition(() => deleteSpy.mock.calls.length === 1)
+ expect(store.vectors.has('orphan-0001')).toBe(true)
+ expect(
+ (presenter as unknown as { vectorStoreReady: Map }).vectorStoreReady.has('a')
+ ).toBe(true)
+
+ ;(
+ presenter as unknown as { clearVectorStoreReady(agentId: string): void }
+ ).clearVectorStoreReady('a')
+ await presenter.recall('a', 'redis')
+ await flushMicrotasks()
+ expect(deleteSpy).toHaveBeenCalledTimes(1)
+
+ for (const key of embeddingState.orphanVectorReconcileRetryAt.keys()) {
+ embeddingState.orphanVectorReconcileRetryAt.set(key, 0)
+ }
+ ;(
+ presenter as unknown as { clearVectorStoreReady(agentId: string): void }
+ ).clearVectorStoreReady('a')
+ await presenter.recall('a', 'redis')
+ await waitForMemoryCondition(
+ () => deleteSpy.mock.calls.length >= 2 && !store.vectors.has('orphan-0001'),
+ 'orphan reconcile did not retry after failure'
+ )
+ })
+
+ it('cleanupDeletedAgentResources waits for in-flight orphan reconcile before clearing marks', async () => {
+ const { presenter, store } = makePresenter(enabledConfig)
+ let releaseList!: () => void
+ vi.spyOn(store, 'listMemoryIds').mockImplementationOnce(
+ async () =>
+ new Promise((resolve) => {
+ releaseList = () => resolve([])
+ })
+ )
+ const embeddingState = (
+ presenter as unknown as {
+ embedding: {
+ getMutableRuntimeStateForTests(): {
+ orphanVectorReconciles: Map>
+ orphanVectorReconciled: Set
+ }
+ }
+ }
+ ).embedding.getMutableRuntimeStateForTests()
+
+ await presenter.recall('a', 'redis')
+ await waitForMemoryCondition(
+ () => embeddingState.orphanVectorReconciles.size === 1 && typeof releaseList === 'function',
+ 'orphan reconcile did not enter in-flight tracking'
+ )
+
+ let cleanupDone = false
+ const cleanup = presenter.cleanupDeletedAgentResources('a').then(() => {
+ cleanupDone = true
+ })
+ await flushMicrotasks()
+ expect(cleanupDone).toBe(false)
+
+ releaseList()
+ await cleanup
+
+ expect(cleanupDone).toBe(true)
+ expect(embeddingState.orphanVectorReconciles.size).toBe(0)
+ expect(embeddingState.orphanVectorReconciled.size).toBe(0)
+ })
+
it('reindexEmbeddings re-queues, rebuilds the store, and re-embeds with the new fingerprint', async () => {
const repo = new FakeRepository()
let config: DeepChatAgentConfig = {
@@ -3938,6 +4189,7 @@ describe('MemoryPresenter embedding reindex (T5, AC-3.x)', () => {
query: async () => [],
queryByMemoryId: async () => [],
deleteByMemoryIds: async () => {},
+ listMemoryIds: async () => [],
close: async () => {},
isUsable: () => false
}
@@ -5643,9 +5895,10 @@ describe('MemoryPresenter lifecycle revival (SDD-8)', () => {
expect(recalled.some((m) => m.id === id)).toBe(true)
})
- it('re-stating a superseded preference revives it and retires the contradicting head (AC-1.2)', async () => {
+ it('re-stating a superseded preference can revive it after a SUPERSEDE decision (AC-1.2)', async () => {
const generateText = routedLLM({
- extraction: '[{"kind":"semantic","content":"user likes redis","importance":0.8}]'
+ extraction: '[{"kind":"semantic","content":"user likes redis","importance":0.8}]',
+ decision: '{"decision":"SUPERSEDE","targetIndex":0,"mergedContent":"user likes redis"}'
})
const { presenter, repo } = makeLLMPresenter(generateText)
const aId = await seedEmbedded(presenter, 'user likes redis')
@@ -5667,6 +5920,88 @@ describe('MemoryPresenter lifecycle revival (SDD-8)', () => {
expect(recalled.some((m) => m.id === bId)).toBe(false)
})
+ it('ADD fallback revives a superseded provenance owner instead of dropping the fact', async () => {
+ const generateText = routedLLM({
+ extraction: '[{"kind":"semantic","content":"user likes redis","importance":0.8}]',
+ decision: '{"decision":"ADD","targetIndex":null,"mergedContent":null}'
+ })
+ const { presenter, repo } = makeLLMPresenter(generateText)
+ const ownerId = await seedEmbedded(presenter, 'user likes redis')
+ const headId = await seedEmbedded(presenter, 'user dislikes redis')
+ repo.markSuperseded(ownerId, headId)
+
+ const result = await presenter.extractAndStore({
+ agentId: 'a',
+ spanText: 'User: I like redis again',
+ model: { providerId: 'main', modelId: 'main' }
+ })
+
+ if (!result.ok) throw new Error('expected ok')
+ expect(result.createdIds).toHaveLength(0)
+ expect(repo.getById(ownerId)).toMatchObject({
+ status: 'pending_embedding',
+ superseded_by: null
+ })
+ expect(repo.getById(headId)?.superseded_by).toBe(ownerId)
+ })
+
+ it('CHALLENGE fallback revives a superseded provenance owner and retires the head', async () => {
+ const generateText = routedLLM({
+ extraction: '[{"kind":"semantic","content":"user likes redis","importance":0.8}]',
+ decision: '{"decision":"CHALLENGE","targetIndex":0,"mergedContent":null}'
+ })
+ const { presenter, repo } = makeLLMPresenter(generateText)
+ const ownerId = await seedEmbedded(presenter, 'user likes redis')
+ const headId = await seedEmbedded(presenter, 'user dislikes redis')
+ repo.markSuperseded(ownerId, headId)
+
+ const result = await presenter.extractAndStore({
+ agentId: 'a',
+ spanText: 'User: I like redis again',
+ model: { providerId: 'main', modelId: 'main' }
+ })
+
+ if (!result.ok) throw new Error('expected ok')
+ expect(result.createdIds).toHaveLength(0)
+ expect(repo.getById(ownerId)?.superseded_by).toBeNull()
+ expect(repo.getById(headId)?.superseded_by).toBe(ownerId)
+ expect(repo.listByAgent('a', { statuses: ['conflicted'], includeSuperseded: true })).toEqual([])
+ })
+
+ it('CHALLENGE fallback does not fold a target invalidated before the collision path', async () => {
+ const repo = new FakeRepository()
+ let headId = ''
+ const generateText = vi.fn(async (_p: string, _m: string, prompt: string) => {
+ if (prompt.includes('KEEP or SKIP')) return 'KEEP'
+ if (prompt.includes('JSON array')) {
+ return '[{"kind":"semantic","content":"user likes redis","importance":0.8}]'
+ }
+ if (prompt.includes('Choose exactly ONE decision')) {
+ if (headId) repo.archive(headId, Date.now())
+ return '{"decision":"CHALLENGE","targetIndex":0,"mergedContent":null}'
+ }
+ return ''
+ })
+ const { presenter } = makeLLMPresenter(generateText, embeddingConfig, repo)
+ const ownerId = await seedEmbedded(presenter, 'user likes redis')
+ headId = await seedEmbedded(presenter, 'user dislikes redis')
+ repo.markSuperseded(ownerId, headId)
+
+ const result = await presenter.extractAndStore({
+ agentId: 'a',
+ spanText: 'User: I like redis again',
+ model: { providerId: 'main', modelId: 'main' }
+ })
+
+ if (!result.ok) throw new Error('expected ok')
+ expect(repo.getById(ownerId)?.superseded_by).toBeNull()
+ expect(repo.getById(headId)).toMatchObject({
+ status: 'archived',
+ superseded_by: null
+ })
+ expect(repo.listByAgent('a', { statuses: ['conflicted'], includeSuperseded: true })).toEqual([])
+ })
+
it('SUPERSEDE whose merged wording collides with an archived row revives it and folds the target in (AC-1.4)', async () => {
const generateText = routedLLM({
extraction: '[{"kind":"semantic","content":"user now hates redis","importance":0.8}]',
@@ -5815,6 +6150,157 @@ describe('MemoryPresenter lifecycle revival (SDD-8)', () => {
expect(repo.listByAgent('a')[0].id).toBe(ownerId)
})
+ it('rolls back an archived provenance revive when the fold transaction fails', async () => {
+ const generateText = routedLLM({
+ extraction: '[{"kind":"semantic","content":"user enjoys redis","importance":0.8}]',
+ decision: '{"decision":"UPDATE","targetIndex":0,"mergedContent":"user prefers vue"}'
+ })
+ const { presenter, repo } = makeLLMPresenter(generateText)
+ const ownerId = await seedEmbedded(presenter, 'user prefers vue')
+ const targetId = await seedEmbedded(presenter, 'user likes redis')
+ repo.archive(ownerId, 1)
+ const markSuperseded = vi.spyOn(repo, 'markSuperseded')
+ markSuperseded.mockImplementationOnce(() => {
+ throw new Error('fold failed')
+ })
+
+ const result = await presenter.extractAndStore({
+ agentId: 'a',
+ spanText: 'User: I enjoy redis',
+ model: { providerId: 'main', modelId: 'main' }
+ })
+
+ expect(result.ok).toBe(false)
+ expect(repo.getById(ownerId)?.status).toBe('archived')
+ expect(repo.getById(targetId)?.superseded_by).toBeNull()
+ })
+
+ it('an UPDATE whose merged content collides with a superseded row revives the owner', async () => {
+ const generateText = routedLLM({
+ extraction: '[{"kind":"semantic","content":"user enjoys redis","importance":0.8}]',
+ decision: '{"decision":"UPDATE","targetIndex":0,"mergedContent":"user prefers vue"}'
+ })
+ const { presenter, repo } = makeLLMPresenter(generateText)
+ const targetId = await seedEmbedded(presenter, 'user likes redis')
+ const ownerId = await seedEmbedded(presenter, 'user prefers vue')
+ const headId = await seedEmbedded(presenter, 'user prefers react')
+ repo.markSuperseded(ownerId, headId)
+
+ const result = await presenter.extractAndStore({
+ agentId: 'a',
+ spanText: 'User: I enjoy redis',
+ model: { providerId: 'main', modelId: 'main' }
+ })
+
+ if (!result.ok) throw new Error('expected ok')
+ expect(result.createdIds).toHaveLength(0)
+ expect(repo.getById(ownerId)).toMatchObject({
+ status: 'pending_embedding',
+ superseded_by: null
+ })
+ expect(repo.getById(headId)?.superseded_by).toBe(ownerId)
+ expect(repo.getById(targetId)?.superseded_by).toBe(ownerId)
+ })
+
+ it('an UPDATE collision with user-forgotten provenance is a true noop', async () => {
+ const generateText = routedLLM({
+ extraction: '[{"kind":"semantic","content":"user enjoys redis","importance":0.8}]',
+ decision: '{"decision":"UPDATE","targetIndex":0,"mergedContent":"user prefers vue"}'
+ })
+ const { presenter, repo } = makeLLMPresenter(generateText)
+ const ownerId = await seedEmbedded(presenter, 'user prefers vue')
+ const targetId = await seedEmbedded(presenter, 'user likes redis')
+ expect(await presenter.archiveUserMemory('a', ownerId)).toBe(true)
+ const targetBefore = repo.getById(targetId)!
+ const beforeSnapshot = {
+ content: targetBefore.content,
+ provenanceKey: targetBefore.provenance_key,
+ confidence: targetBefore.confidence
+ }
+
+ const result = await presenter.extractAndStore({
+ agentId: 'a',
+ spanText: 'User: I enjoy redis',
+ model: { providerId: 'main', modelId: 'main' }
+ })
+
+ if (!result.ok) throw new Error('expected ok')
+ const targetAfter = repo.getById(targetId)!
+ expect(result.createdIds).toHaveLength(0)
+ expect(repo.getById(ownerId)?.status).toBe('archived')
+ expect(targetAfter.content).toBe(beforeSnapshot.content)
+ expect(targetAfter.provenance_key).toBe(beforeSnapshot.provenanceKey)
+ expect(targetAfter.status).toBe('embedded')
+ expect(targetAfter.confidence).toBe(beforeSnapshot.confidence)
+ expect(targetAfter.superseded_by).toBeNull()
+ })
+
+ it('restore audit offsets a prior forget so a decay-archived memory can be learned again', async () => {
+ vi.useFakeTimers()
+ try {
+ const generateText = routedLLM({
+ extraction: '[{"kind":"semantic","content":"user likes redis","importance":0.8}]'
+ })
+ const { presenter, repo, auditRepo } = makeLLMPresenter(generateText)
+ vi.setSystemTime(1000)
+ const id = await seedEmbedded(presenter, 'user likes redis')
+ vi.setSystemTime(2000)
+ expect(await presenter.forgetMemory('a', id)).toBe(true)
+ vi.setSystemTime(3000)
+ expect(presenter.restoreMemory('a', id)).toBe(true)
+ expect(auditRepo.hasForgetEvent('a', id)).toBe(false)
+ vi.setSystemTime(4000)
+ repo.archive(id, Date.now())
+
+ vi.setSystemTime(5000)
+ const result = await presenter.extractAndStore({
+ agentId: 'a',
+ spanText: 'User: I like redis',
+ model: { providerId: 'main', modelId: 'main' }
+ })
+
+ if (!result.ok) throw new Error('expected ok')
+ expect(result.createdIds).toHaveLength(0)
+ expect(repo.getById(id)?.status).toBe('pending_embedding')
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('a forget after restore remains the latest provenance tombstone', async () => {
+ vi.useFakeTimers()
+ try {
+ const generateText = routedLLM({
+ extraction: '[{"kind":"semantic","content":"user likes redis","importance":0.8}]'
+ })
+ const { presenter, repo, auditRepo } = makeLLMPresenter(generateText)
+ vi.setSystemTime(1000)
+ const id = await seedEmbedded(presenter, 'user likes redis')
+ vi.setSystemTime(2000)
+ expect(await presenter.forgetMemory('a', id)).toBe(true)
+ vi.setSystemTime(3000)
+ expect(presenter.restoreMemory('a', id)).toBe(true)
+ vi.setSystemTime(4000)
+ repo.archive(id, Date.now())
+ vi.setSystemTime(5000)
+ expect(await presenter.forgetMemory('a', id)).toBe(true)
+ expect(auditRepo.hasForgetEvent('a', id)).toBe(true)
+
+ vi.setSystemTime(6000)
+ const result = await presenter.extractAndStore({
+ agentId: 'a',
+ spanText: 'User: I like redis',
+ model: { providerId: 'main', modelId: 'main' }
+ })
+
+ if (!result.ok) throw new Error('expected ok')
+ expect(result.createdIds).toHaveLength(0)
+ expect(repo.getById(id)?.status).toBe('archived')
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
it('a consolidation pass interrupted by dispose writes nothing to the repository (AC-3.1)', async () => {
let resolveLLM = (): void => {}
const llmGate = new Promise((resolve) => {
@@ -6161,6 +6647,7 @@ describe('MemoryPresenter lifecycle revival (SDD-8)', () => {
}),
queryByMemoryId: async () => [],
deleteByMemoryIds: async () => {},
+ listMemoryIds: async () => [],
close: async () => {},
isUsable: () => true
}
@@ -6246,6 +6733,131 @@ describe('MemoryPresenter lifecycle revival (SDD-8)', () => {
expect(deleteSpy).not.toHaveBeenCalled() // no write against the store dispose just closed
})
+ it('deletes vectors from the row snapshot embedding store even after config changes', async () => {
+ const repo = new FakeRepository()
+ const store = new FakeVectorStore()
+ const createVectorStore = vi.fn(async () => store)
+ const presenter = new MemoryPresenter({
+ repository: repo,
+ resolveAgentConfig: () => ({
+ memoryEnabled: true,
+ memoryEmbedding: { providerId: 'p', modelId: 'current' }
+ }),
+ getEmbeddings: async (_p, _m, texts) => texts.map((text) => textToVector(text)),
+ createVectorStore,
+ resetVectorStore: async () => undefined
+ })
+ repo.insert({ id: 'm1', agentId: 'a', kind: 'semantic', content: 'redis fact' })
+ repo.updateStatus('m1', 'embedded', {
+ embeddingId: 'm1',
+ embeddingDim: 4,
+ embeddingModel: 'p:legacy'
+ })
+ store.vectors.set('m1', textToVector('redis fact'))
+ const deleteSpy = vi.spyOn(store, 'deleteByMemoryIds')
+
+ expect(await presenter.deleteMemory('a', 'm1')).toBe(true)
+
+ expect(createVectorStore).toHaveBeenCalledWith('a', { providerId: 'p', modelId: 'legacy' }, 4)
+ expect(deleteSpy).toHaveBeenCalledWith(['m1'])
+ expect(repo.getById('m1')).toBeUndefined()
+ })
+
+ it('keeps the row snapshot embedding model when delete must infer the dimension', async () => {
+ const repo = new FakeRepository()
+ const store = new FakeVectorStore()
+ const createVectorStore = vi.fn(async () => store)
+ const presenter = new MemoryPresenter({
+ repository: repo,
+ resolveAgentConfig: () => ({
+ memoryEnabled: true,
+ memoryEmbedding: { providerId: 'p', modelId: 'current' }
+ }),
+ getEmbeddings: async (_p, _m, texts) => texts.map((text) => textToVector(text)),
+ createVectorStore,
+ resetVectorStore: async () => undefined
+ })
+ repo.insert({ id: 'legacy-ref', agentId: 'a', kind: 'semantic', content: 'legacy ref' })
+ repo.updateStatus('legacy-ref', 'embedded', {
+ embeddingId: 'legacy-ref',
+ embeddingDim: 4,
+ embeddingModel: 'p:legacy'
+ })
+ repo.insert({ id: 'current-ref', agentId: 'a', kind: 'semantic', content: 'current ref' })
+ repo.updateStatus('current-ref', 'embedded', {
+ embeddingId: 'current-ref',
+ embeddingDim: 6,
+ embeddingModel: 'p:current'
+ })
+ repo.insert({ id: 'm1', agentId: 'a', kind: 'semantic', content: 'redis fact' })
+ repo.updateStatus('m1', 'embedded', {
+ embeddingId: 'm1',
+ embeddingDim: null,
+ embeddingModel: 'p:legacy'
+ })
+ store.vectors.set('m1', textToVector('redis fact'))
+ const deleteSpy = vi.spyOn(store, 'deleteByMemoryIds')
+
+ expect(await presenter.deleteMemory('a', 'm1')).toBe(true)
+
+ expect(createVectorStore).toHaveBeenCalledWith('a', { providerId: 'p', modelId: 'legacy' }, 4)
+ expect(deleteSpy).toHaveBeenCalledWith(['m1'])
+ })
+
+ it('does not fail the SQLite delete when opening the vector store fails', async () => {
+ const repo = new FakeRepository()
+ const presenter = new MemoryPresenter({
+ repository: repo,
+ resolveAgentConfig: () => enabledConfig,
+ getEmbeddings: async (_p, _m, texts) => texts.map((text) => textToVector(text)),
+ createVectorStore: async () => {
+ throw new Error('open failed')
+ },
+ resetVectorStore: async () => undefined
+ })
+ repo.insert({ id: 'm1', agentId: 'a', kind: 'semantic', content: 'redis fact' })
+ repo.updateStatus('m1', 'embedded', {
+ embeddingId: 'm1',
+ embeddingDim: 4,
+ embeddingModel: 'p:m'
+ })
+
+ await expect(presenter.deleteMemory('a', 'm1')).resolves.toBe(true)
+ expect(repo.getById('m1')).toBeUndefined()
+ })
+
+ it('schedules a forced reindex when delete finds an unusable vector store', async () => {
+ const repo = new FakeRepository()
+ const unusableStore: IMemoryVectorStore = {
+ upsert: async () => {},
+ query: async () => [],
+ queryByMemoryId: async () => [],
+ deleteByMemoryIds: async () => {},
+ listMemoryIds: async () => [],
+ close: async () => {},
+ isUsable: () => false
+ }
+ const presenter = new MemoryPresenter({
+ repository: repo,
+ resolveAgentConfig: () => enabledConfig,
+ getEmbeddings: async (_p, _m, texts) => texts.map((text) => textToVector(text)),
+ createVectorStore: async () => unusableStore,
+ resetVectorStore: async () => undefined
+ })
+ repo.insert({ id: 'm1', agentId: 'a', kind: 'semantic', content: 'redis fact' })
+ repo.updateStatus('m1', 'embedded', {
+ embeddingId: 'm1',
+ embeddingDim: 4,
+ embeddingModel: 'p:m'
+ })
+ const reindexSpy = vi.spyOn(presenter, 'reindexEmbeddings').mockResolvedValue()
+
+ expect(await presenter.deleteMemory('a', 'm1')).toBe(true)
+
+ expect(reindexSpy).toHaveBeenCalledWith('a', true)
+ expect(repo.getById('m1')).toBeUndefined()
+ })
+
it('dispose waits for an in-flight vector delete before closing the store (AC-3.11)', async () => {
const { presenter, repo, store } = makeLLMPresenter(routedLLM({}))
const id = await seedEmbedded(presenter, 'user likes redis')
diff --git a/test/main/presenter/memoryVectorStore.test.ts b/test/main/presenter/memoryVectorStore.test.ts
index 6b333d582..13631add1 100644
--- a/test/main/presenter/memoryVectorStore.test.ts
+++ b/test/main/presenter/memoryVectorStore.test.ts
@@ -35,6 +35,12 @@ interface QueryableStore {
): Promise>
}
+interface ListableStore {
+ connection: { runAndReadAll: ReturnType }
+ vectorTable: string
+ listMemoryIds(afterId: string | null, limit: number): Promise
+}
+
function makeStore(onRun: (sql: string) => void = () => {}) {
const calls: string[] = []
const connection = {
@@ -127,6 +133,38 @@ describe('MemoryVectorStore.queryByMemoryId', () => {
})
})
+describe('MemoryVectorStore.listMemoryIds', () => {
+ it('uses keyset pagination and a bounded limit', async () => {
+ const connection = {
+ runAndReadAll: vi.fn(async () => ({
+ getRowObjectsJson: () => [{ memory_id: 'm2' }, { memory_id: 'm3' }]
+ }))
+ }
+ const store = Object.create(MemoryVectorStore.prototype) as ListableStore
+ store.connection = connection
+ store.vectorTable = 'memory_vector'
+
+ await expect(store.listMemoryIds('m1', 2)).resolves.toEqual(['m2', 'm3'])
+
+ expect(connection.runAndReadAll).toHaveBeenCalledWith(
+ expect.stringContaining('memory_id > ?'),
+ ['m1', 2]
+ )
+ })
+
+ it('returns early for a zero limit', async () => {
+ const connection = {
+ runAndReadAll: vi.fn()
+ }
+ const store = Object.create(MemoryVectorStore.prototype) as ListableStore
+ store.connection = connection
+ store.vectorTable = 'memory_vector'
+
+ await expect(store.listMemoryIds(null, 0)).resolves.toEqual([])
+ expect(connection.runAndReadAll).not.toHaveBeenCalled()
+ })
+})
+
interface EmbeddingMeta {
provider: string
model: string
diff --git a/test/main/routes/dispatcher.test.ts b/test/main/routes/dispatcher.test.ts
index 0620b20ee..df4f1c2ce 100644
--- a/test/main/routes/dispatcher.test.ts
+++ b/test/main/routes/dispatcher.test.ts
@@ -2127,6 +2127,72 @@ describe('dispatchDeepchatRoute', () => {
expect(archiveUserMemory).toHaveBeenCalledWith('deepchat', 'm1')
})
+ it('dispatches memory.reindex only when a new managed task can start', async () => {
+ const { runtime, configPresenter } = createRuntime()
+ const canReindex = vi.fn(() => true)
+ const isReindexing = vi.fn(() => false)
+ const reindexEmbeddings = vi.fn().mockResolvedValue(undefined)
+ ;(runtime as any).memoryPresenter = { canReindex, isReindexing, reindexEmbeddings }
+
+ await expect(
+ dispatchDeepchatRoute(
+ runtime,
+ 'memory.reindex',
+ { agentId: 'other' },
+ { webContentsId: 42, windowId: 7 }
+ )
+ ).resolves.toEqual({ started: false })
+ expect(canReindex).not.toHaveBeenCalled()
+ expect(reindexEmbeddings).not.toHaveBeenCalled()
+
+ canReindex.mockReturnValueOnce(false)
+ await expect(
+ dispatchDeepchatRoute(
+ runtime,
+ 'memory.reindex',
+ { agentId: 'deepchat' },
+ { webContentsId: 42, windowId: 7 }
+ )
+ ).resolves.toEqual({ started: false })
+ expect(canReindex).toHaveBeenCalledWith('deepchat')
+ expect(reindexEmbeddings).not.toHaveBeenCalled()
+
+ canReindex.mockReturnValue(true)
+ isReindexing.mockReturnValueOnce(true)
+ await expect(
+ dispatchDeepchatRoute(
+ runtime,
+ 'memory.reindex',
+ { agentId: 'deepchat' },
+ { webContentsId: 42, windowId: 7 }
+ )
+ ).resolves.toEqual({ started: false })
+ expect(reindexEmbeddings).toHaveBeenCalledTimes(1)
+ expect(reindexEmbeddings).toHaveBeenLastCalledWith('deepchat', true)
+
+ isReindexing.mockReturnValueOnce(false)
+ await expect(
+ dispatchDeepchatRoute(
+ runtime,
+ 'memory.reindex',
+ { agentId: 'deepchat' },
+ { webContentsId: 42, windowId: 7 }
+ )
+ ).resolves.toEqual({ started: true })
+ expect(reindexEmbeddings).toHaveBeenCalledTimes(2)
+
+ vi.mocked(configPresenter.getAgentType).mockResolvedValueOnce('acp')
+ await expect(
+ dispatchDeepchatRoute(
+ runtime,
+ 'memory.reindex',
+ { agentId: 'deepchat' },
+ { webContentsId: 42, windowId: 7 }
+ )
+ ).resolves.toEqual({ started: false })
+ expect(reindexEmbeddings).toHaveBeenCalledTimes(2)
+ })
+
it('returns no memory view manifests for missing or non-DeepChat agents', async () => {
const { runtime, configPresenter } = createRuntime()
const listMemoryViewManifestAnchorsByAgent = vi.fn()
diff --git a/test/main/routes/memoryDto.test.ts b/test/main/routes/memoryDto.test.ts
index 9be698ad9..5a068c270 100644
--- a/test/main/routes/memoryDto.test.ts
+++ b/test/main/routes/memoryDto.test.ts
@@ -10,6 +10,7 @@ import {
memoryGetHealthRoute,
memoryGetLifecycleRoute,
memoryListRoute,
+ memoryReindexRoute,
memoryRestoreRoute,
memorySearchRoute
} from '@shared/contracts/routes'
@@ -185,6 +186,16 @@ describe('memory.restore route contract round-trip', () => {
})
})
+describe('memory.reindex route contract', () => {
+ it('round-trips the reindex request and started response', () => {
+ expect(memoryReindexRoute.input.parse({ agentId: 'deepchat-abc123' })).toEqual({
+ agentId: 'deepchat-abc123'
+ })
+ expect(memoryReindexRoute.output.parse({ started: true })).toEqual({ started: true })
+ expect(memoryReindexRoute.output.parse({ started: false })).toEqual({ started: false })
+ })
+})
+
describe('memory.getHealth route contract', () => {
it('round-trips the empty health DTO and a populated bounded preview', () => {
expect(memoryGetHealthRoute.input.parse({ agentId: 'deepchat' })).toEqual({
diff --git a/test/renderer/api/clients.test.ts b/test/renderer/api/clients.test.ts
index ce740e633..0eeee1a54 100644
--- a/test/renderer/api/clients.test.ts
+++ b/test/renderer/api/clients.test.ts
@@ -1007,6 +1007,8 @@ describe('renderer api clients', () => {
}
case 'memory.archive':
return { ok: true }
+ case 'memory.reindex':
+ return { started: true }
case 'memory.getHealth':
return {
health: {
@@ -1417,6 +1419,7 @@ describe('renderer api clients', () => {
await memoryClient.add('agent-1', { content: 'plain note' })
const selectedMemories = await memoryClient.getByIds('agent-1', ['mem-archived', 'mem-added'])
const archived = await memoryClient.archive('agent-1', 'mem-added')
+ const reindex = await memoryClient.reindex('agent-1')
const health = await memoryClient.getHealth('agent-1')
const lifecycle = await memoryClient.getLifecycle('agent-1', 'mem-1')
const archiveCandidatePreview =
@@ -1485,15 +1488,17 @@ describe('renderer api clients', () => {
memoryId: 'mem-added'
})
expect(archived).toBe(true)
- expect(bridge.invoke).toHaveBeenNthCalledWith(17, 'memory.getHealth', { agentId: 'agent-1' })
+ expect(bridge.invoke).toHaveBeenNthCalledWith(17, 'memory.reindex', { agentId: 'agent-1' })
+ expect(reindex.started).toBe(true)
+ expect(bridge.invoke).toHaveBeenNthCalledWith(18, 'memory.getHealth', { agentId: 'agent-1' })
expect(health.totalRows).toBe(1)
- expect(bridge.invoke).toHaveBeenNthCalledWith(18, 'memory.getLifecycle', {
+ expect(bridge.invoke).toHaveBeenNthCalledWith(19, 'memory.getLifecycle', {
agentId: 'agent-1',
memoryId: 'mem-1'
})
expect(lifecycle?.memoryId).toBe('mem-1')
expect(bridge.invoke).toHaveBeenNthCalledWith(
- 19,
+ 20,
'memory.getArchiveCandidateLifecyclePreview',
{
agentId: 'agent-1'
diff --git a/test/renderer/components/MemoryHealthSection.test.ts b/test/renderer/components/MemoryHealthSection.test.ts
index e4b37d07a..9f8f68615 100644
--- a/test/renderer/components/MemoryHealthSection.test.ts
+++ b/test/renderer/components/MemoryHealthSection.test.ts
@@ -23,6 +23,21 @@ vi.mock('@shadcn/components/ui/badge', () => ({
}
}))
+vi.mock('@shadcn/components/ui/button', () => ({
+ Button: {
+ name: 'Button',
+ template: ''
+ }
+}))
+
+vi.mock('@iconify/vue', () => ({
+ addCollection: vi.fn(),
+ Icon: {
+ name: 'Icon',
+ template: ''
+ }
+}))
+
interface SettingsJson {
deepchatAgents?: {
memoryManager?: {
@@ -150,6 +165,7 @@ function mountSection(props: {
archiveCandidateLifecyclePreview?: MemoryArchiveCandidateLifecyclePreview | null
archiveCandidateLifecyclePreviewLoading?: boolean
archiveCandidateLifecyclePreviewError?: string | null
+ reindexing?: boolean
}) {
return mount(MemoryHealthSection, {
props: {
@@ -158,7 +174,8 @@ function mountSection(props: {
error: props.error ?? null,
archiveCandidateLifecyclePreview: props.archiveCandidateLifecyclePreview,
archiveCandidateLifecyclePreviewLoading: props.archiveCandidateLifecyclePreviewLoading,
- archiveCandidateLifecyclePreviewError: props.archiveCandidateLifecyclePreviewError
+ archiveCandidateLifecyclePreviewError: props.archiveCandidateLifecyclePreviewError ?? null,
+ reindexing: props.reindexing ?? false
}
})
}
@@ -206,7 +223,22 @@ describe('MemoryHealthSection', () => {
expect(wrapper.text()).toContain('memory/maintenance_llm')
expect(wrapper.text()).toContain('model unavailable')
expect(wrapper.text()).toContain('—')
- expect(wrapper.find('button').exists()).toBe(false)
+ expect(wrapper.find('button').text()).toContain(
+ 'settings.deepchatAgents.memoryManager.health.reindex'
+ )
+ })
+
+ it('emits reindex and renders the in-progress label', async () => {
+ const ready = mountSection({ health: loadedHealth })
+ await ready.find('button').trigger('click')
+ expect(ready.emitted('reindex')).toHaveLength(1)
+ expect(ready.find('button').attributes('aria-busy')).toBeUndefined()
+
+ const running = mountSection({ health: loadedHealth, reindexing: true })
+ const button = running.find('button')
+ expect(button.text()).toContain('settings.deepchatAgents.memoryManager.health.reindexing')
+ expect(button.attributes('disabled')).toBeDefined()
+ expect(button.attributes('aria-busy')).toBe('true')
})
it('renders archive candidate lifecycle preview states without memory content', () => {
diff --git a/test/renderer/components/MemoryManagerDialog.test.ts b/test/renderer/components/MemoryManagerDialog.test.ts
index 511094daf..3ae5bdc3b 100644
--- a/test/renderer/components/MemoryManagerDialog.test.ts
+++ b/test/renderer/components/MemoryManagerDialog.test.ts
@@ -243,6 +243,7 @@ async function setup(
manifestPromise?: Promise
auditReject?: boolean
manifestReject?: boolean
+ reindexPromise?: Promise<{ started: boolean }>
} = {}
) {
vi.resetModules()
@@ -314,6 +315,9 @@ async function setup(
rejectPersonaDraft: vi.fn().mockResolvedValue(overrides.reject ?? true),
setPersonaAnchor: vi.fn().mockResolvedValue(overrides.anchor ?? true),
resolveConflict: vi.fn().mockResolvedValue(true),
+ reindex: overrides.reindexPromise
+ ? vi.fn().mockReturnValue(overrides.reindexPromise)
+ : vi.fn().mockResolvedValue({ started: true }),
onUpdated: vi.fn().mockImplementation((listener) => {
updateListener = listener
return dispose
@@ -390,8 +394,11 @@ async function setup(
memoryClient,
toast,
dispose,
- emitMemoryUpdated: (reason: MemoryUpdatedPayload['reason'] = 'extract') =>
- updateListener?.({ agentId: 'a', reason, version: 1 })
+ emitMemoryUpdated: (
+ reason: MemoryUpdatedPayload['reason'] = 'extract',
+ agentId = 'a',
+ version = 1
+ ) => updateListener?.({ agentId, reason, version })
}
}
@@ -445,6 +452,14 @@ async function deactivateHealthTab(
await clickTab(wrapper, 'memories')
}
+function findReindexButton(wrapper: Awaited>['wrapper']) {
+ const button = wrapper
+ .findAllComponents(ButtonStub)
+ .find((item) => item.text().includes('settings.deepchatAgents.memoryManager.health.reindex'))
+ if (!button) throw new Error('Missing reindex button')
+ return button
+}
+
function findSelectByText(wrapper: Awaited>['wrapper'], text: string) {
const select = wrapper
.findAllComponents({ name: 'Select' })
@@ -979,6 +994,8 @@ describe('MemoryManagerDialog memory health', () => {
})
}))
vi.doMock('@shadcn/components/ui/badge', () => ({ Badge: passStub('Badge') }))
+ vi.doMock('@shadcn/components/ui/button', () => ({ Button: passStub('Button') }))
+ vi.doMock('@iconify/vue', () => ({ addCollection: vi.fn(), Icon: passStub('Icon') }))
const MemoryHealthSection = (
await import('../../../src/renderer/settings/components/MemoryHealthSection.vue')
).default
@@ -996,7 +1013,7 @@ describe('MemoryManagerDialog memory health', () => {
expect(wrapper.text()).toContain('memory/maintenance_llm')
expect(wrapper.text()).toContain('model unavailable')
expect(wrapper.text()).toContain('—')
- expect(wrapper.find('button').exists()).toBe(false)
+ expect(wrapper.text()).toContain('settings.deepchatAgents.memoryManager.health.reindex')
})
it('renders zero as a valid last accessed timestamp', async () => {
@@ -1042,6 +1059,112 @@ describe('MemoryManagerDialog memory health', () => {
expect(memoryClient.getArchiveCandidateLifecyclePreview).toHaveBeenCalledTimes(1)
})
+ it('does not carry local reindex pending state across agents', async () => {
+ const reindex = deferred<{ started: boolean }>()
+ const { wrapper, memoryClient } = await setup({ reindexPromise: reindex.promise })
+
+ await activateHealthTab(wrapper)
+ await findReindexButton(wrapper).trigger('click')
+ await nextTick()
+
+ expect(memoryClient.reindex).toHaveBeenCalledWith('a')
+ expect(findReindexButton(wrapper).text()).toContain(
+ 'settings.deepchatAgents.memoryManager.health.reindexing'
+ )
+
+ await wrapper.setProps({ agentId: 'b' })
+ await flushPromises()
+ await activateHealthTab(wrapper)
+
+ expect(memoryClient.getStatus).toHaveBeenLastCalledWith('b')
+ expect(findReindexButton(wrapper).text()).toContain(
+ 'settings.deepchatAgents.memoryManager.health.reindex'
+ )
+ expect(findReindexButton(wrapper).text()).not.toContain(
+ 'settings.deepchatAgents.memoryManager.health.reindexing'
+ )
+
+ reindex.resolve({ started: true })
+ await flushPromises()
+ })
+
+ it('settles local reindex pending from matching-agent reindex status refreshes only', async () => {
+ const reindex = deferred<{ started: boolean }>()
+ const { wrapper, memoryClient, emitMemoryUpdated } = await setup({
+ reindexPromise: reindex.promise
+ })
+
+ await activateHealthTab(wrapper)
+ await findReindexButton(wrapper).trigger('click')
+ await nextTick()
+ expect(findReindexButton(wrapper).text()).toContain(
+ 'settings.deepchatAgents.memoryManager.health.reindexing'
+ )
+
+ emitMemoryUpdated('reindex', 'b')
+ await flushPromises()
+ expect(findReindexButton(wrapper).text()).toContain(
+ 'settings.deepchatAgents.memoryManager.health.reindexing'
+ )
+
+ memoryClient.getStatus.mockResolvedValueOnce({ ...status, reindexing: true })
+ emitMemoryUpdated('reindex', 'a')
+ await flushPromises()
+ expect(findReindexButton(wrapper).text()).toContain(
+ 'settings.deepchatAgents.memoryManager.health.reindexing'
+ )
+
+ memoryClient.getStatus.mockResolvedValueOnce(status)
+ emitMemoryUpdated('reindex', 'a')
+ await flushPromises()
+ expect(findReindexButton(wrapper).text()).toContain(
+ 'settings.deepchatAgents.memoryManager.health.reindex'
+ )
+
+ reindex.resolve({ started: true })
+ await flushPromises()
+ })
+
+ it('clears local reindex pending when the request returns started=false', async () => {
+ const { wrapper, memoryClient } = await setup({
+ reindexPromise: Promise.resolve({ started: false })
+ })
+
+ await activateHealthTab(wrapper)
+ await findReindexButton(wrapper).trigger('click')
+ await flushPromises()
+
+ expect(memoryClient.reindex).toHaveBeenCalledWith('a')
+ expect(findReindexButton(wrapper).text()).toContain(
+ 'settings.deepchatAgents.memoryManager.health.reindex'
+ )
+ expect(findReindexButton(wrapper).text()).not.toContain(
+ 'settings.deepchatAgents.memoryManager.health.reindexing'
+ )
+ })
+
+ it('clears local reindex pending after a started request refreshes to reindexing=false', async () => {
+ const reindex = deferred<{ started: boolean }>()
+ const { wrapper } = await setup({ reindexPromise: reindex.promise })
+
+ await activateHealthTab(wrapper)
+ await findReindexButton(wrapper).trigger('click')
+ await nextTick()
+ expect(findReindexButton(wrapper).text()).toContain(
+ 'settings.deepchatAgents.memoryManager.health.reindexing'
+ )
+
+ reindex.resolve({ started: true })
+ await flushPromises()
+
+ expect(findReindexButton(wrapper).text()).toContain(
+ 'settings.deepchatAgents.memoryManager.health.reindex'
+ )
+ expect(findReindexButton(wrapper).text()).not.toContain(
+ 'settings.deepchatAgents.memoryManager.health.reindexing'
+ )
+ })
+
it('clears the Health badge after an inactive memory update until health is refreshed', async () => {
const loadedHealth = { ...health, totalRows: 987 }
const refreshedHealth = { ...health, totalRows: 654 }