@smyslenny/agent-memory 5.0.2 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.1.0 (2026-03-20)
4
+
5
+ ### ✨ Features
6
+
7
+ #### Archive on Eviction (淘汰归档)
8
+
9
+ - Memories evicted by governance are now **archived** to `memory_archive` instead
10
+ of permanently deleted. Only memories with `vitality ≥ 0.1` are archived;
11
+ lower-vitality memories (decayed noise) are still directly deleted.
12
+ - New schema v8: adds `memory_archive` table (migration `v7 → v8` runs
13
+ automatically on startup).
14
+ - New core functions: `archiveMemory()`, `restoreMemory()`, `listArchivedMemories()`,
15
+ `purgeArchive()`.
16
+ - New MCP tool **`archive`**: `list` / `restore` / `purge` actions for managing
17
+ archived memories.
18
+ - `GovernResult` now includes `archived` (count of memories actually written to
19
+ the archive table) and `evictedByType` breakdown.
20
+
21
+ #### Tiered Capacity (分层容量)
22
+
23
+ - Governance now enforces **per-type capacity limits** before the global cap.
24
+ Defaults: `identity: unlimited`, `emotion: 50`, `knowledge: 250`, `event: 50`,
25
+ `total: 350`.
26
+ - Configurable via environment variables: `AGENT_MEMORY_MAX_IDENTITY`,
27
+ `AGENT_MEMORY_MAX_EMOTION`, `AGENT_MEMORY_MAX_KNOWLEDGE`,
28
+ `AGENT_MEMORY_MAX_EVENT`, `AGENT_MEMORY_MAX_MEMORIES`.
29
+ - `status` MCP tool now returns a `capacity` object showing per-type counts and
30
+ limits.
31
+ - Identity memories (P0) are **never evicted** unless an explicit
32
+ `AGENT_MEMORY_MAX_IDENTITY` is set.
33
+
34
+ ### ♻️ Notes
35
+
36
+ - Tidy phase (`runTidy`) still deletes low-vitality memories directly — no
37
+ archiving. Only govern-phase evictions (capacity-based) go to the archive.
38
+ - All new parameters have defaults; upgrading from 5.0.x requires no config
39
+ changes. Schema migration is automatic.
40
+
3
41
  ## 5.0.1 (2026-03-20)
4
42
 
5
43
  ### 🐛 Fixes
@@ -18,7 +56,7 @@
18
56
  v5 is a major feature release that adds six intelligence capabilities to the
19
57
  memory layer. All features are backward-compatible with v4 workflows.
20
58
 
21
- Design document: `docs/design/0018-v5-memory-intelligence.md`
59
+ Design document: see the v5 feature table in [README.md](README.md).
22
60
 
23
61
  #### F1: Memory Links (记忆关联)
24
62
 
package/README.md CHANGED
@@ -22,7 +22,7 @@ AgentMemory is a SQLite-first memory layer for AI agents. It lets an agent:
22
22
  - **maintain** them over time with `reflect`, `reindex`, and feedback signals
23
23
  - **integrate** through **CLI**, **MCP stdio**, or **HTTP/SSE**
24
24
 
25
- Current release: **`5.0.1`**.
25
+ Current release: **`5.0.2`**.
26
26
 
27
27
  Without an embedding provider, AgentMemory still works in **BM25-only mode**.
28
28
  With one configured, it adds **hybrid recall** and **semantic dedup**.
@@ -100,6 +100,11 @@ function migrateDatabase(db, from, to) {
100
100
  v = 7;
101
101
  continue;
102
102
  }
103
+ if (v === 7) {
104
+ migrateV7ToV8(db);
105
+ v = 8;
106
+ continue;
107
+ }
103
108
  throw new Error(`Unsupported schema migration path: v${from} \u2192 v${to} (stuck at v${v})`);
104
109
  }
105
110
  }
@@ -187,6 +192,8 @@ function inferSchemaVersion(db) {
187
192
  const hasFeedbackEvents = tableExists(db, "feedback_events");
188
193
  const hasEmotionTag = tableHasColumn(db, "memories", "emotion_tag");
189
194
  const hasProvenance = tableHasColumn(db, "memories", "source_session") && tableHasColumn(db, "memories", "source_context") && tableHasColumn(db, "memories", "observed_at");
195
+ const hasMemoryArchive = tableExists(db, "memory_archive");
196
+ if (hasAgentScopedPaths && hasAgentScopedLinks && hasV4Embeddings && hasMaintenanceJobs && hasFeedbackEvents && hasEmotionTag && hasProvenance && hasMemoryArchive) return 8;
190
197
  if (hasAgentScopedPaths && hasAgentScopedLinks && hasV4Embeddings && hasMaintenanceJobs && hasFeedbackEvents && hasEmotionTag && hasProvenance) return 7;
191
198
  if (hasAgentScopedPaths && hasAgentScopedLinks && hasV4Embeddings && hasMaintenanceJobs && hasFeedbackEvents && hasEmotionTag) return 6;
192
199
  if (hasAgentScopedPaths && hasAgentScopedLinks && hasV4Embeddings && hasMaintenanceJobs && hasFeedbackEvents) return 5;
@@ -228,6 +235,11 @@ function ensureIndexes(db) {
228
235
  db.exec("CREATE INDEX IF NOT EXISTS idx_feedback_events_agent_source ON feedback_events(agent_id, source, created_at DESC);");
229
236
  }
230
237
  }
238
+ if (tableExists(db, "memory_archive")) {
239
+ db.exec("CREATE INDEX IF NOT EXISTS idx_memory_archive_agent ON memory_archive(agent_id);");
240
+ db.exec("CREATE INDEX IF NOT EXISTS idx_memory_archive_type ON memory_archive(type);");
241
+ db.exec("CREATE INDEX IF NOT EXISTS idx_memory_archive_archived_at ON memory_archive(archived_at);");
242
+ }
231
243
  }
232
244
  function ensureFeedbackEventSchema(db) {
233
245
  if (!tableExists(db, "feedback_events")) return;
@@ -403,11 +415,55 @@ function migrateV6ToV7(db) {
403
415
  throw e;
404
416
  }
405
417
  }
418
+ function migrateV7ToV8(db) {
419
+ if (tableExists(db, "memory_archive")) {
420
+ db.prepare("INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('version', ?)").run(String(8));
421
+ return;
422
+ }
423
+ try {
424
+ db.exec("BEGIN");
425
+ db.exec(`
426
+ CREATE TABLE IF NOT EXISTS memory_archive (
427
+ id TEXT PRIMARY KEY,
428
+ content TEXT NOT NULL,
429
+ type TEXT NOT NULL,
430
+ priority INTEGER NOT NULL,
431
+ emotion_val REAL NOT NULL DEFAULT 0.0,
432
+ vitality REAL NOT NULL DEFAULT 0.0,
433
+ stability REAL NOT NULL DEFAULT 1.0,
434
+ access_count INTEGER NOT NULL DEFAULT 0,
435
+ last_accessed TEXT,
436
+ created_at TEXT NOT NULL,
437
+ updated_at TEXT NOT NULL,
438
+ archived_at TEXT NOT NULL,
439
+ archive_reason TEXT NOT NULL DEFAULT 'eviction',
440
+ source TEXT,
441
+ agent_id TEXT NOT NULL DEFAULT 'default',
442
+ hash TEXT,
443
+ emotion_tag TEXT,
444
+ source_session TEXT,
445
+ source_context TEXT,
446
+ observed_at TEXT
447
+ );
448
+ `);
449
+ db.exec("CREATE INDEX IF NOT EXISTS idx_memory_archive_agent ON memory_archive(agent_id);");
450
+ db.exec("CREATE INDEX IF NOT EXISTS idx_memory_archive_type ON memory_archive(type);");
451
+ db.exec("CREATE INDEX IF NOT EXISTS idx_memory_archive_archived_at ON memory_archive(archived_at);");
452
+ db.prepare("INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('version', ?)").run(String(8));
453
+ db.exec("COMMIT");
454
+ } catch (e) {
455
+ try {
456
+ db.exec("ROLLBACK");
457
+ } catch {
458
+ }
459
+ throw e;
460
+ }
461
+ }
406
462
  var SCHEMA_VERSION, SCHEMA_SQL;
407
463
  var init_db = __esm({
408
464
  "src/core/db.ts"() {
409
465
  "use strict";
410
- SCHEMA_VERSION = 7;
466
+ SCHEMA_VERSION = 8;
411
467
  SCHEMA_SQL = `
412
468
  -- Memory entries
413
469
  CREATE TABLE IF NOT EXISTS memories (
@@ -513,6 +569,30 @@ CREATE TABLE IF NOT EXISTS schema_meta (
513
569
  value TEXT NOT NULL
514
570
  );
515
571
 
572
+ -- Memory archive (eviction archive)
573
+ CREATE TABLE IF NOT EXISTS memory_archive (
574
+ id TEXT PRIMARY KEY,
575
+ content TEXT NOT NULL,
576
+ type TEXT NOT NULL,
577
+ priority INTEGER NOT NULL,
578
+ emotion_val REAL NOT NULL DEFAULT 0.0,
579
+ vitality REAL NOT NULL DEFAULT 0.0,
580
+ stability REAL NOT NULL DEFAULT 1.0,
581
+ access_count INTEGER NOT NULL DEFAULT 0,
582
+ last_accessed TEXT,
583
+ created_at TEXT NOT NULL,
584
+ updated_at TEXT NOT NULL,
585
+ archived_at TEXT NOT NULL,
586
+ archive_reason TEXT NOT NULL DEFAULT 'eviction',
587
+ source TEXT,
588
+ agent_id TEXT NOT NULL DEFAULT 'default',
589
+ hash TEXT,
590
+ emotion_tag TEXT,
591
+ source_session TEXT,
592
+ source_context TEXT,
593
+ observed_at TEXT
594
+ );
595
+
516
596
  -- Indexes for common queries
517
597
  CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type);
518
598
  CREATE INDEX IF NOT EXISTS idx_memories_priority ON memories(priority);
@@ -523,6 +603,9 @@ CREATE INDEX IF NOT EXISTS idx_paths_memory ON paths(memory_id);
523
603
  CREATE INDEX IF NOT EXISTS idx_paths_domain ON paths(domain);
524
604
  CREATE INDEX IF NOT EXISTS idx_maintenance_jobs_phase_status ON maintenance_jobs(phase, status, started_at DESC);
525
605
  CREATE INDEX IF NOT EXISTS idx_feedback_events_memory ON feedback_events(memory_id, created_at DESC);
606
+ CREATE INDEX IF NOT EXISTS idx_memory_archive_agent ON memory_archive(agent_id);
607
+ CREATE INDEX IF NOT EXISTS idx_memory_archive_type ON memory_archive(type);
608
+ CREATE INDEX IF NOT EXISTS idx_memory_archive_archived_at ON memory_archive(archived_at);
526
609
  `;
527
610
  }
528
611
  });
@@ -1152,9 +1235,54 @@ function updateMemory(db, id, input) {
1152
1235
  }
1153
1236
  function deleteMemory(db, id) {
1154
1237
  db.prepare("DELETE FROM memories_fts WHERE id = ?").run(id);
1238
+ try {
1239
+ db.prepare("DELETE FROM embeddings WHERE memory_id = ?").run(id);
1240
+ } catch {
1241
+ }
1155
1242
  const result = db.prepare("DELETE FROM memories WHERE id = ?").run(id);
1156
1243
  return result.changes > 0;
1157
1244
  }
1245
+ function archiveMemory(db, id, reason, opts) {
1246
+ const mem = getMemory(db, id);
1247
+ if (!mem) return false;
1248
+ const minVitality = opts?.minVitality ?? 0.1;
1249
+ if (mem.vitality < minVitality) {
1250
+ deleteMemory(db, id);
1251
+ return "deleted";
1252
+ }
1253
+ const archivedAt = now();
1254
+ const archiveReason = reason ?? "eviction";
1255
+ db.prepare(
1256
+ `INSERT OR REPLACE INTO memory_archive
1257
+ (id, content, type, priority, emotion_val, vitality, stability, access_count,
1258
+ last_accessed, created_at, updated_at, archived_at, archive_reason,
1259
+ source, agent_id, hash, emotion_tag, source_session, source_context, observed_at)
1260
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1261
+ ).run(
1262
+ mem.id,
1263
+ mem.content,
1264
+ mem.type,
1265
+ mem.priority,
1266
+ mem.emotion_val,
1267
+ mem.vitality,
1268
+ mem.stability,
1269
+ mem.access_count,
1270
+ mem.last_accessed,
1271
+ mem.created_at,
1272
+ mem.updated_at,
1273
+ archivedAt,
1274
+ archiveReason,
1275
+ mem.source,
1276
+ mem.agent_id,
1277
+ mem.hash,
1278
+ mem.emotion_tag,
1279
+ mem.source_session,
1280
+ mem.source_context,
1281
+ mem.observed_at
1282
+ );
1283
+ deleteMemory(db, id);
1284
+ return "archived";
1285
+ }
1158
1286
  function listMemories(db, opts) {
1159
1287
  const conditions = [];
1160
1288
  const params = [];
@@ -3003,13 +3131,31 @@ function rankEvictionCandidates(db, opts) {
3003
3131
  return left.memory.priority - right.memory.priority;
3004
3132
  });
3005
3133
  }
3134
+ function parseEnvInt(envKey) {
3135
+ const raw = process.env[envKey];
3136
+ if (raw === void 0 || raw === "") return null;
3137
+ const n = Number.parseInt(raw, 10);
3138
+ return Number.isFinite(n) && n > 0 ? n : null;
3139
+ }
3140
+ function getTieredCapacity(opts) {
3141
+ const envMax = parseEnvInt("AGENT_MEMORY_MAX_MEMORIES");
3142
+ return {
3143
+ identity: parseEnvInt("AGENT_MEMORY_MAX_IDENTITY"),
3144
+ // default: null (unlimited)
3145
+ emotion: parseEnvInt("AGENT_MEMORY_MAX_EMOTION") ?? 50,
3146
+ knowledge: parseEnvInt("AGENT_MEMORY_MAX_KNOWLEDGE") ?? 250,
3147
+ event: parseEnvInt("AGENT_MEMORY_MAX_EVENT") ?? 50,
3148
+ total: opts?.maxMemories ?? (envMax ?? 350)
3149
+ };
3150
+ }
3006
3151
  function runGovern(db, opts) {
3007
3152
  const agentId = opts?.agent_id;
3008
- const envMax = Number.parseInt(process.env.AGENT_MEMORY_MAX_MEMORIES ?? "", 10);
3009
- const maxMemories = opts?.maxMemories ?? (Number.isFinite(envMax) && envMax > 0 ? envMax : 200);
3153
+ const capacity = getTieredCapacity(opts);
3010
3154
  let orphanPaths = 0;
3011
3155
  let emptyMemories = 0;
3012
3156
  let evicted = 0;
3157
+ let archived = 0;
3158
+ const evictedByType = {};
3013
3159
  const transaction = db.transaction(() => {
3014
3160
  const pathResult = agentId ? db.prepare(
3015
3161
  `DELETE FROM paths
@@ -3019,17 +3165,48 @@ function runGovern(db, opts) {
3019
3165
  orphanPaths = pathResult.changes;
3020
3166
  const emptyResult = agentId ? db.prepare("DELETE FROM memories WHERE agent_id = ? AND TRIM(content) = ''").run(agentId) : db.prepare("DELETE FROM memories WHERE TRIM(content) = ''").run();
3021
3167
  emptyMemories = emptyResult.changes;
3168
+ const typeLimits = [
3169
+ { type: "identity", limit: capacity.identity },
3170
+ { type: "emotion", limit: capacity.emotion },
3171
+ { type: "knowledge", limit: capacity.knowledge },
3172
+ { type: "event", limit: capacity.event }
3173
+ ];
3174
+ const allCandidates = rankEvictionCandidates(db, { agent_id: agentId });
3175
+ const evictedIds = /* @__PURE__ */ new Set();
3176
+ for (const { type, limit } of typeLimits) {
3177
+ if (limit === null) continue;
3178
+ const typeCount = db.prepare(
3179
+ agentId ? "SELECT COUNT(*) as c FROM memories WHERE agent_id = ? AND type = ?" : "SELECT COUNT(*) as c FROM memories WHERE type = ?"
3180
+ ).get(...agentId ? [agentId, type] : [type]).c;
3181
+ const excess = Math.max(0, typeCount - limit);
3182
+ if (excess <= 0) continue;
3183
+ const typeCandidates = allCandidates.filter((c) => c.memory.type === type && !evictedIds.has(c.memory.id));
3184
+ const toEvict = typeCandidates.slice(0, excess);
3185
+ for (const candidate of toEvict) {
3186
+ const result = archiveMemory(db, candidate.memory.id, "eviction");
3187
+ evictedIds.add(candidate.memory.id);
3188
+ evicted += 1;
3189
+ if (result === "archived") archived += 1;
3190
+ evictedByType[type] = (evictedByType[type] ?? 0) + 1;
3191
+ }
3192
+ }
3022
3193
  const total = db.prepare(agentId ? "SELECT COUNT(*) as c FROM memories WHERE agent_id = ?" : "SELECT COUNT(*) as c FROM memories").get(...agentId ? [agentId] : []).c;
3023
- const excess = Math.max(0, total - maxMemories);
3024
- if (excess <= 0) return;
3025
- const candidates = rankEvictionCandidates(db, { agent_id: agentId }).slice(0, excess);
3026
- for (const candidate of candidates) {
3027
- deleteMemory(db, candidate.memory.id);
3028
- evicted += 1;
3194
+ const globalExcess = Math.max(0, total - capacity.total);
3195
+ if (globalExcess > 0) {
3196
+ const globalCandidates = allCandidates.filter((c) => !evictedIds.has(c.memory.id));
3197
+ const toEvict = globalCandidates.slice(0, globalExcess);
3198
+ for (const candidate of toEvict) {
3199
+ const result = archiveMemory(db, candidate.memory.id, "eviction");
3200
+ evictedIds.add(candidate.memory.id);
3201
+ evicted += 1;
3202
+ if (result === "archived") archived += 1;
3203
+ const t = candidate.memory.type;
3204
+ evictedByType[t] = (evictedByType[t] ?? 0) + 1;
3205
+ }
3029
3206
  }
3030
3207
  });
3031
3208
  transaction();
3032
- return { orphanPaths, emptyMemories, evicted };
3209
+ return { orphanPaths, emptyMemories, evicted, archived, evictedByType };
3033
3210
  }
3034
3211
 
3035
3212
  // src/sleep/jobs.ts
@@ -3272,12 +3449,21 @@ function getMemoryStatus(db, input) {
3272
3449
  const feedbackEvents = db.prepare(
3273
3450
  "SELECT COUNT(*) as c FROM feedback_events WHERE agent_id = ?"
3274
3451
  ).get(agentId);
3452
+ const tiered = getTieredCapacity();
3453
+ const capacity = {
3454
+ identity: { count: stats.by_type.identity ?? 0, limit: tiered.identity },
3455
+ emotion: { count: stats.by_type.emotion ?? 0, limit: tiered.emotion },
3456
+ knowledge: { count: stats.by_type.knowledge ?? 0, limit: tiered.knowledge },
3457
+ event: { count: stats.by_type.event ?? 0, limit: tiered.event },
3458
+ total: { count: stats.total, limit: tiered.total }
3459
+ };
3275
3460
  return {
3276
3461
  ...stats,
3277
3462
  paths: totalPaths.c,
3278
3463
  low_vitality: lowVitality.c,
3279
3464
  feedback_events: feedbackEvents.c,
3280
- agent_id: agentId
3465
+ agent_id: agentId,
3466
+ capacity
3281
3467
  };
3282
3468
  }
3283
3469