memory: Curator (Phase 4) — фоновая архивация старых неиспользуемых заметок

* :memory-api — MemoryStore.archiveStale(maxAge, maxUseCount, now): default
  имплементация через list + delete (бэкенды могут переопределить).
* :standalone — Curator: фоновая корутина на Dispatchers.IO, раз в сутки
  дёргает archiveStale(90d, 0). start()/stop(), runPass() — однократный
  прогон для тестов.
* :standalone/Main — Curator стартует автоматически если memory включён,
  stop() в shutdown hook.
* :standalone — публичный TestInMemoryMemoryStore вынесен из CompactionTest,
  переиспользуется в CuratorTest.
* README — раздел "Куратор памяти" с описанием семантики для md и vector
  бэкендов.

Tests: 288 total (+4 CuratorTest). Fatjar smoke-tested, curator стартует
на app boot, выводит `[Curator] started` в лог.

Defaults: interval=1d, maxAge=90d, maxUseCount=0. Override через
новые config-флаги отложен.
This commit is contained in:
2026-09-15 04:14:03 +03:00
parent 227d14b7e4
commit 9e12b22e85
7 changed files with 267 additions and 38 deletions
@@ -40,6 +40,38 @@ interface MemoryStore : AutoCloseable {
suspend fun delete(id: String): Boolean
suspend fun markUsed(id: String, at: Instant = Clock.System.now())
/**
* Архивирует заметки, которые:
* - не использовались дольше [maxAge] (считая от `now`);
* - имеют `useCount <= [maxUseCount]` (по умолчанию 0, т.е. только никогда
* не выданные в prefetch).
*
* Семантика архивации зависит от бэкенда:
* - `:memory-md` — переименовывает -файл с суффиксом `.archived.{ts}`;
* - `:memory-vector` — удаляет из SQLite и JVector (там данные
* пересоздаются из `agentik.db` при старте).
*
* Default-имплементация использует [list] + [delete]; бэкенды могут
* переопределить для более чистой семантики (особенно MD).
*
* @return количество архивированных заметок.
*/
suspend fun archiveStale(
maxAge: kotlin.time.Duration,
maxUseCount: Int = 0,
now: Instant = Clock.System.now(),
): Int {
val all = list(limit = Int.MAX_VALUE)
val cutoff = now - maxAge
var archived = 0
for (n in all) {
if (n.lastUsedAt < cutoff && n.useCount <= maxUseCount) {
if (delete(n.id)) archived++
}
}
return archived
}
fun events(): Flow<MemoryStoreEvent> = emptyFlow()
override fun close()