agent-memory-os 0.2.4__tar.gz → 0.3.0__tar.gz

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.
Files changed (105) hide show
  1. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/PKG-INFO +24 -12
  2. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/README.md +23 -11
  3. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/pyproject.toml +1 -1
  4. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/src/agent_memory_os/cli.py +19 -0
  5. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/src/agent_memory_os/client.py +38 -1
  6. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/src/agent_memory_os/db.py +245 -28
  7. agent_memory_os-0.3.0/src/agent_memory_os/embedding.py +114 -0
  8. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/src/agent_memory_os/web_app.py +29 -0
  9. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/src/agent_memory_os/web_ui.py +53 -1
  10. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/tests/test_fts_triggers.py +3 -0
  11. agent_memory_os-0.3.0/tests/test_persistence_v03.py +170 -0
  12. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/.github/workflows/ci.yml +0 -0
  13. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/.github/workflows/release.yml +0 -0
  14. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/.gitignore +0 -0
  15. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/CHANGELOG.md +0 -0
  16. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/LICENSE +0 -0
  17. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/NOTICE +0 -0
  18. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/PROGRESS.md +0 -0
  19. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/PROJECT_STATUS.md +0 -0
  20. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/SPEC.md +0 -0
  21. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/assets/README.md +0 -0
  22. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/assets/agent-memory-os-logo-ai-v1.png +0 -0
  23. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/assets/agent-memory-os-logo-integrated-v2.png +0 -0
  24. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/assets/agent-memory-os-logo.png +0 -0
  25. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/assets/agent-memory-os-logo.svg +0 -0
  26. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/assets/agent-memory-os-wordmark.png +0 -0
  27. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/assets/agent-memory-os-wordmark.svg +0 -0
  28. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/assets/integrations/claude-code.svg +0 -0
  29. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/assets/integrations/codex.svg +0 -0
  30. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/assets/integrations/hermes-agent.svg +0 -0
  31. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/assets/integrations/openclaw.svg +0 -0
  32. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/assets/logo-header.png +0 -0
  33. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/assets/logo-icon-128.png +0 -0
  34. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/assets/logo-icon-512.png +0 -0
  35. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/AgentMemoryOS_README.md +0 -0
  36. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/CANARY_MONITORING_SCHEDULE.md +0 -0
  37. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/HISTORY.md +0 -0
  38. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/INSTALLATION.md +0 -0
  39. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/Memory_Resonance_Prototype.md +0 -0
  40. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/Memory_Resonance_Spec.md +0 -0
  41. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/PROJECT_STATUS.md +0 -0
  42. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/ROADMAP.md +0 -0
  43. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/Roadmap.md +0 -0
  44. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/Shadow_Mode_Timeline.md +0 -0
  45. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/Shadow_Mode_Verification_Report.md +0 -0
  46. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/adr/ADR-0001-turbovec-semantic-sidecar-positioning.md +0 -0
  47. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/blockers/current.md +0 -0
  48. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/evidence/20260609_120837-repo-audit.md +0 -0
  49. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/evidence/20260609_121749-activation-gate-verification.md +0 -0
  50. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/evidence/20260617_221742-webui-availability-triage.md +0 -0
  51. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/evidence/20260617_225724-webui-port-8000-correction.md +0 -0
  52. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/handoffs/20260609_120837-mizuki-to-neo-governance-fill.md +0 -0
  53. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/hermes-activation-gates.md +0 -0
  54. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/integrations/claude-code.md +0 -0
  55. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/integrations/codex.md +0 -0
  56. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/integrations/hermes-agent.md +0 -0
  57. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/integrations/openclaw.md +0 -0
  58. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/plans/20260605_100751-memory-decay-recency-v0.2.md +0 -0
  59. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/plans/20260605_114049-retrieval-foundation-v0.2.1.md +0 -0
  60. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/plans/20260605_143442-version-downgrade-verification.md +0 -0
  61. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/plans/20260608_174539-turbovec-semantic-provider-validation.md +0 -0
  62. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/plans/20260613_dco-technical-design.md +0 -0
  63. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/portfolio-status.md +0 -0
  64. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/project-status/current.md +0 -0
  65. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/reviews/.gitkeep +0 -0
  66. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/reviews/20260609_120837-neo-consistency-review.md +0 -0
  67. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/reviews/20260609_122032-mizuki-portfolio-maintenance.md +0 -0
  68. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/docs/stress-cases/case-01-noisy-truth.md +0 -0
  69. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/logo.png +0 -0
  70. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/scripts/verify_acl_identities.py +0 -0
  71. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/scripts/verify_downgrade_compatibility.py +0 -0
  72. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/src/agent_memory_os/__init__.py +0 -0
  73. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/src/agent_memory_os/cache.py +0 -0
  74. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/src/agent_memory_os/candidates.py +0 -0
  75. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/src/agent_memory_os/context_pack.py +0 -0
  76. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/src/agent_memory_os/golden_recall.py +0 -0
  77. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/src/agent_memory_os/hermes_importer.py +0 -0
  78. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/src/agent_memory_os/mcp_server.py +0 -0
  79. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/src/agent_memory_os/memory_resonance.py +0 -0
  80. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/src/agent_memory_os/providers/__init__.py +0 -0
  81. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/src/agent_memory_os/providers/turbovec.py +0 -0
  82. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/src/agent_memory_os/schema.py +0 -0
  83. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/src/agent_memory_os/scoring.py +0 -0
  84. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/src/agent_memory_os/shadow_mode.py +0 -0
  85. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/src/agent_memory_os/tokens.py +0 -0
  86. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/tests/test_acl_visibility.py +0 -0
  87. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/tests/test_cli.py +0 -0
  88. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/tests/test_cli_tools.py +0 -0
  89. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/tests/test_context_orchestration.py +0 -0
  90. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/tests/test_context_pack.py +0 -0
  91. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/tests/test_decay_scoring.py +0 -0
  92. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/tests/test_golden_recall.py +0 -0
  93. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/tests/test_hermes_importer.py +0 -0
  94. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/tests/test_memory_client.py +0 -0
  95. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/tests/test_memory_decay_recency.py +0 -0
  96. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/tests/test_memory_enhancements.py +0 -0
  97. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/tests/test_memory_links.py +0 -0
  98. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/tests/test_memory_resonance.py +0 -0
  99. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/tests/test_retrieval_foundation.py +0 -0
  100. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/tests/test_semantic_candidates.py +0 -0
  101. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/tests/test_shadow_mode.py +0 -0
  102. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/tests/test_truth_arbitration.py +0 -0
  103. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/tests/test_turbovec_provider.py +0 -0
  104. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/tests/test_verification_script.py +0 -0
  105. {agent_memory_os-0.2.4 → agent_memory_os-0.3.0}/tests/test_web_app.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-memory-os
3
- Version: 0.2.4
3
+ Version: 0.3.0
4
4
  Summary: Local-first, MCP-native memory engine for AI agents with RAM cache and context-budgeted recall.
5
5
  Author: Bastet Agent Team
6
6
  License-Expression: Apache-2.0
@@ -140,21 +140,33 @@ AgentMemoryOS uses **two storage layers with strictly different authority**:
140
140
  ACL/expiry hard gates before its content can be used, and the index can be
141
141
  dropped and rebuilt at any time without touching the truth store.
142
142
 
143
- To activate semantic recall, supply an embedding function and wire the
144
- provider in:
143
+ Semantic recall works out of the box:
145
144
 
146
145
  ```python
147
- from agent_memory_os.providers.turbovec import TurbovecSemanticCandidateProvider
148
-
149
- provider = TurbovecSemanticCandidateProvider.from_vectors(
150
- vectors=embeddings, # one row per memory, any embedder
151
- external_id_by_memory_id=id_mapping, # stable memory_id -> uint64
152
- embed_query=embed, # str -> vector, same embedder
153
- )
154
- client = MemoryClient(home="~/.agent-memory", candidate_providers=[provider])
146
+ client = MemoryClient(home="~/.agent-memory", semantic="auto")
155
147
  ```
156
148
 
157
- `agent-memory doctor` confirms the turbovec backend is importable.
149
+ `semantic="auto"` wires in a self-syncing turbovec index over a built-in
150
+ deterministic hashing embedder (no model downloads; typo- and
151
+ morphology-tolerant lexical vectors). The index rebuilds itself whenever the
152
+ memories table changes and degrades silently to lexical + resonance recall
153
+ when the backend isn't installed. For deeper semantics, plug any embedding
154
+ model into `TurbovecSemanticCandidateProvider.from_vectors(...)` with your
155
+ own `embed_query`. `agent-memory doctor` confirms the backend is importable.
156
+
157
+ ## Memory lifecycle & retention
158
+
159
+ ```bash
160
+ agent-memory retention # archive expired + memories idle 4+ half-lives
161
+ agent-memory retention --half-lives 0 # expired only
162
+ agent-memory check # SQLite + FTS + link-graph integrity
163
+ ```
164
+
165
+ Archived memories leave recall entirely but stay restorable (Web UI → Tools →
166
+ Retention & archive, or `POST /api/archive/{id}/restore`). Pinned and
167
+ authority-track memories are never archived by decay. Databases self-migrate
168
+ through a versioned, forward-only migration table (`agent-memory check`
169
+ reports the schema version).
158
170
 
159
171
  ## Backup & restore
160
172
 
@@ -109,21 +109,33 @@ AgentMemoryOS uses **two storage layers with strictly different authority**:
109
109
  ACL/expiry hard gates before its content can be used, and the index can be
110
110
  dropped and rebuilt at any time without touching the truth store.
111
111
 
112
- To activate semantic recall, supply an embedding function and wire the
113
- provider in:
112
+ Semantic recall works out of the box:
114
113
 
115
114
  ```python
116
- from agent_memory_os.providers.turbovec import TurbovecSemanticCandidateProvider
117
-
118
- provider = TurbovecSemanticCandidateProvider.from_vectors(
119
- vectors=embeddings, # one row per memory, any embedder
120
- external_id_by_memory_id=id_mapping, # stable memory_id -> uint64
121
- embed_query=embed, # str -> vector, same embedder
122
- )
123
- client = MemoryClient(home="~/.agent-memory", candidate_providers=[provider])
115
+ client = MemoryClient(home="~/.agent-memory", semantic="auto")
124
116
  ```
125
117
 
126
- `agent-memory doctor` confirms the turbovec backend is importable.
118
+ `semantic="auto"` wires in a self-syncing turbovec index over a built-in
119
+ deterministic hashing embedder (no model downloads; typo- and
120
+ morphology-tolerant lexical vectors). The index rebuilds itself whenever the
121
+ memories table changes and degrades silently to lexical + resonance recall
122
+ when the backend isn't installed. For deeper semantics, plug any embedding
123
+ model into `TurbovecSemanticCandidateProvider.from_vectors(...)` with your
124
+ own `embed_query`. `agent-memory doctor` confirms the backend is importable.
125
+
126
+ ## Memory lifecycle & retention
127
+
128
+ ```bash
129
+ agent-memory retention # archive expired + memories idle 4+ half-lives
130
+ agent-memory retention --half-lives 0 # expired only
131
+ agent-memory check # SQLite + FTS + link-graph integrity
132
+ ```
133
+
134
+ Archived memories leave recall entirely but stay restorable (Web UI → Tools →
135
+ Retention & archive, or `POST /api/archive/{id}/restore`). Pinned and
136
+ authority-track memories are never archived by decay. Databases self-migrate
137
+ through a versioned, forward-only migration table (`agent-memory check`
138
+ reports the schema version).
127
139
 
128
140
  ## Backup & restore
129
141
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agent-memory-os"
7
- version = "0.2.4"
7
+ version = "0.3.0"
8
8
  description = "Local-first, MCP-native memory engine for AI agents with RAM cache and context-budgeted recall."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -67,6 +67,14 @@ def build_parser() -> argparse.ArgumentParser:
67
67
  restore = sub.add_parser("restore", help="Restore the memory database from a backup file")
68
68
  restore.add_argument("src", help="Backup .db file to restore from")
69
69
  restore.add_argument("--force", action="store_true", help="Overwrite an existing database")
70
+
71
+ sub.add_parser("check", help="Run database integrity and invariant checks")
72
+
73
+ retention = sub.add_parser("retention", help="Archive expired and deeply-decayed memories")
74
+ retention.add_argument(
75
+ "--half-lives", type=float, default=None,
76
+ help="Also archive unpinned memories idle for N decay half-lives (default 4; 0 = expired only)",
77
+ )
70
78
  return p
71
79
 
72
80
 
@@ -229,6 +237,17 @@ def main(argv: list[str] | None = None) -> int:
229
237
 
230
238
  client = MemoryClient(home=args.home)
231
239
  try:
240
+ if args.command == "check":
241
+ report = client.integrity_check()
242
+ print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
243
+ return 0 if report["ok"] else 1
244
+ if args.command == "retention":
245
+ if args.half_lives is None:
246
+ result = client.run_retention()
247
+ else:
248
+ result = client.run_retention(decayed_half_lives=args.half_lives or None)
249
+ print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
250
+ return 0
232
251
  if args.command == "golden-recall":
233
252
  cases = load_golden_query_cases(args.cases)
234
253
  report = evaluate_golden_queries(
@@ -9,7 +9,7 @@ import json
9
9
  from .candidates import CandidateProvider
10
10
  from .cache import LRUCache
11
11
  from .context_pack import ContextPackReport, build_context_pack, build_context_pack_report
12
- from .db import MemoryStore
12
+ from .db import MemoryStore, RETENTION_MIN_HALF_LIVES
13
13
  from .schema import MemoryLink, MemoryRecord, RecallProfile, SearchResult
14
14
 
15
15
  class MemoryClient:
@@ -22,6 +22,7 @@ class MemoryClient:
22
22
  resonance_hops: int = 1,
23
23
  profile: RecallProfile | None = None,
24
24
  check_same_thread: bool = True,
25
+ semantic: str | None = None,
25
26
  ):
26
27
  home_path = Path(home or os.getenv("AGENT_MEMORY_HOME", "~/.agent-memory")).expanduser()
27
28
  self.home = home_path
@@ -31,6 +32,20 @@ class MemoryClient:
31
32
  resonance_hops=resonance_hops,
32
33
  check_same_thread=check_same_thread,
33
34
  )
35
+ self.semantic_enabled = False
36
+ if semantic == "auto":
37
+ # Out-of-the-box semantic recall: hashing embedder + self-syncing
38
+ # turbovec index. Degrades silently when the backend isn't
39
+ # installed — lexical/resonance recall still works.
40
+ from .providers.turbovec import semantic_backend_available
41
+
42
+ if semantic_backend_available():
43
+ from .embedding import AutoSemanticIndex
44
+
45
+ self.store.candidate_providers.append(AutoSemanticIndex(self.store))
46
+ self.semantic_enabled = True
47
+ elif semantic is not None:
48
+ raise ValueError('semantic must be "auto" or None')
34
49
  self.profile = profile
35
50
  self.cache: LRUCache[tuple, object] = LRUCache(max_items=cache_items)
36
51
  self._profile_cache: dict[str, RecallProfile | None] = {}
@@ -64,6 +79,28 @@ class MemoryClient:
64
79
  self.cache.clear()
65
80
  return result
66
81
 
82
+ def run_retention(
83
+ self, *, decayed_half_lives: float | None = RETENTION_MIN_HALF_LIVES
84
+ ) -> dict[str, int]:
85
+ """Archive expired (and optionally deeply-decayed) memories.
86
+
87
+ `decayed_half_lives=None` (or 0) archives expired memories only.
88
+ """
89
+ result = self.store.run_retention(decayed_half_lives=decayed_half_lives)
90
+ self.cache.clear()
91
+ return result
92
+
93
+ def list_archived(self, *, limit: int = 20, offset: int = 0) -> list[dict[str, object]]:
94
+ return self.store.list_archived(limit=limit, offset=offset)
95
+
96
+ def restore_archived(self, memory_id: str) -> MemoryRecord:
97
+ restored = self.store.restore_archived(memory_id)
98
+ self.cache.clear()
99
+ return restored
100
+
101
+ def integrity_check(self) -> dict[str, object]:
102
+ return self.store.integrity_check()
103
+
67
104
  def dashboard_stats(self) -> dict[str, object]:
68
105
  return self.store.dashboard_stats() | {"cache_items": len(self.cache)}
69
106
 
@@ -99,6 +99,73 @@ AUTO_LINK_LIMIT = 5
99
99
  CONSOLIDATION_MIN_CLUSTER_WEIGHT = 0.6
100
100
  CONSOLIDATION_MIN_ACTIVATIONS = 3
101
101
  CONSOLIDATION_MIN_CLUSTER_SIZE = 3
102
+ RETENTION_MIN_HALF_LIVES = 4.0
103
+
104
+
105
+ def _migration_decay_columns(conn: sqlite3.Connection) -> None:
106
+ existing = {row["name"] for row in conn.execute("PRAGMA table_info(memories)")}
107
+ columns = {
108
+ "decay_policy": "TEXT NOT NULL DEFAULT 'exponential'",
109
+ "decay_half_life_days": "REAL NOT NULL DEFAULT 30.0",
110
+ "last_accessed_at": "TEXT",
111
+ "access_count": "INTEGER NOT NULL DEFAULT 0",
112
+ "pinned": "INTEGER NOT NULL DEFAULT 0",
113
+ }
114
+ for name, definition in columns.items():
115
+ if name not in existing:
116
+ conn.execute(f"ALTER TABLE memories ADD COLUMN {name} {definition}")
117
+
118
+
119
+ def _migration_fix_fts_triggers(conn: sqlite3.Connection) -> None:
120
+ # Legacy AFTER UPDATE/DELETE triggers used the FTS5 'delete' command,
121
+ # which is invalid on regular FTS5 tables and raised 'SQL logic error'
122
+ # on every update_content()/delete().
123
+ rows = conn.execute(
124
+ "SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND name IN ('memories_ad', 'memories_au')"
125
+ ).fetchall()
126
+ broken = [row["name"] for row in rows if "'delete'" in (row["sql"] or "")]
127
+ if not broken:
128
+ return
129
+ for name in broken:
130
+ conn.execute(f"DROP TRIGGER IF EXISTS {name}")
131
+ conn.executescript(SCHEMA)
132
+
133
+
134
+ def _migration_archive_table(conn: sqlite3.Connection) -> None:
135
+ conn.execute(
136
+ """
137
+ CREATE TABLE IF NOT EXISTS memories_archive (
138
+ id TEXT PRIMARY KEY,
139
+ owner TEXT NOT NULL,
140
+ scope TEXT NOT NULL,
141
+ type TEXT NOT NULL,
142
+ content TEXT NOT NULL,
143
+ summary TEXT NOT NULL,
144
+ tags TEXT NOT NULL DEFAULT '[]',
145
+ visibility TEXT NOT NULL DEFAULT '[]',
146
+ source TEXT NOT NULL DEFAULT '{}',
147
+ confidence REAL NOT NULL DEFAULT 0.8,
148
+ importance REAL NOT NULL DEFAULT 0.5,
149
+ created_at TEXT NOT NULL,
150
+ updated_at TEXT NOT NULL,
151
+ expires_at TEXT,
152
+ decay_policy TEXT NOT NULL DEFAULT 'exponential',
153
+ decay_half_life_days REAL NOT NULL DEFAULT 30.0,
154
+ last_accessed_at TEXT,
155
+ access_count INTEGER NOT NULL DEFAULT 0,
156
+ pinned INTEGER NOT NULL DEFAULT 0,
157
+ archived_at TEXT NOT NULL,
158
+ archive_reason TEXT NOT NULL
159
+ )
160
+ """
161
+ )
162
+
163
+
164
+ MIGRATIONS: list[tuple[int, str, object]] = [
165
+ (1, "decay and reinforcement columns", _migration_decay_columns),
166
+ (2, "repair FTS update/delete triggers", _migration_fix_fts_triggers),
167
+ (3, "cold archive table", _migration_archive_table),
168
+ ]
102
169
 
103
170
 
104
171
  class MemoryStore:
@@ -128,43 +195,70 @@ class MemoryStore:
128
195
  self.conn.execute("PRAGMA busy_timeout=5000")
129
196
  self.conn.execute("PRAGMA journal_mode=WAL")
130
197
  self.conn.executescript(SCHEMA)
131
- self._ensure_decay_columns()
132
- self._ensure_valid_fts_triggers()
198
+ self._run_migrations()
133
199
  self.conn.commit()
134
200
 
135
201
  def close(self) -> None:
136
202
  self.conn.close()
137
203
 
138
- def _ensure_decay_columns(self) -> None:
139
- existing = {row["name"] for row in self.conn.execute("PRAGMA table_info(memories)")}
140
- columns = {
141
- "decay_policy": "TEXT NOT NULL DEFAULT 'exponential'",
142
- "decay_half_life_days": "REAL NOT NULL DEFAULT 30.0",
143
- "last_accessed_at": "TEXT",
144
- "access_count": "INTEGER NOT NULL DEFAULT 0",
145
- "pinned": "INTEGER NOT NULL DEFAULT 0",
204
+ # ------------------------------------------------------------------ #
205
+ # Schema migrations: forward-only, versioned, recorded per database.
206
+ # Each migration must be idempotent (it may run once against databases
207
+ # created before the migrations table existed).
208
+ # ------------------------------------------------------------------ #
209
+
210
+ def _run_migrations(self) -> None:
211
+ self.conn.execute(
212
+ """
213
+ CREATE TABLE IF NOT EXISTS schema_migrations (
214
+ version INTEGER PRIMARY KEY,
215
+ description TEXT NOT NULL,
216
+ applied_at TEXT NOT NULL
217
+ )
218
+ """
219
+ )
220
+ applied = {
221
+ row["version"] for row in self.conn.execute("SELECT version FROM schema_migrations")
146
222
  }
147
- for name, definition in columns.items():
148
- if name not in existing:
149
- self.conn.execute(f"ALTER TABLE memories ADD COLUMN {name} {definition}")
223
+ for version, description, migrate in MIGRATIONS:
224
+ if version in applied:
225
+ continue
226
+ migrate(self.conn)
227
+ self.conn.execute(
228
+ "INSERT INTO schema_migrations(version, description, applied_at) VALUES (?, ?, ?)",
229
+ (version, description, utc_now()),
230
+ )
231
+
232
+ def schema_version(self) -> int:
233
+ row = self.conn.execute("SELECT MAX(version) FROM schema_migrations").fetchone()
234
+ return int(row[0] or 0)
150
235
 
151
- def _ensure_valid_fts_triggers(self) -> None:
152
- """Replace legacy FTS triggers that used the FTS5 'delete' command.
236
+ def integrity_check(self) -> dict[str, object]:
237
+ """Verify the database and the store's own invariants.
153
238
 
154
- The 'delete' command is only valid for external-content/contentless
155
- FTS5 tables; on this regular FTS5 table it raises 'SQL logic error' on
156
- every UPDATE/DELETE. Databases created before the fix keep the broken
157
- triggers because SCHEMA uses CREATE TRIGGER IF NOT EXISTS.
239
+ Returns per-check results; `ok` is the conjunction. Read-only.
158
240
  """
159
- rows = self.conn.execute(
160
- "SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND name IN ('memories_ad', 'memories_au')"
161
- ).fetchall()
162
- broken = [row["name"] for row in rows if "'delete'" in (row["sql"] or "")]
163
- if not broken:
164
- return
165
- for name in broken:
166
- self.conn.execute(f"DROP TRIGGER IF EXISTS {name}")
167
- self.conn.executescript(SCHEMA)
241
+ checks: dict[str, object] = {}
242
+ pragma = self.conn.execute("PRAGMA integrity_check").fetchone()[0]
243
+ checks["sqlite_integrity"] = pragma == "ok"
244
+ total = self.conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0]
245
+ indexed = self.conn.execute("SELECT COUNT(*) FROM memories_fts").fetchone()[0]
246
+ checks["fts_in_sync"] = indexed == total
247
+ checks["fts_rows"] = {"memories": int(total), "indexed": int(indexed)}
248
+ orphan_edges = self.conn.execute(
249
+ """
250
+ SELECT COUNT(*) FROM memory_links l
251
+ WHERE NOT EXISTS (SELECT 1 FROM memories m WHERE m.id = l.src_id)
252
+ OR NOT EXISTS (SELECT 1 FROM memories m WHERE m.id = l.dst_id)
253
+ """
254
+ ).fetchone()[0]
255
+ checks["orphan_links"] = int(orphan_edges)
256
+ checks["links_valid"] = orphan_edges == 0
257
+ checks["schema_version"] = self.schema_version()
258
+ checks["ok"] = bool(
259
+ checks["sqlite_integrity"] and checks["fts_in_sync"] and checks["links_valid"]
260
+ )
261
+ return checks
168
262
 
169
263
  def add(self, record: MemoryRecord) -> MemoryRecord:
170
264
  record.summary = record.normalized_summary()
@@ -422,6 +516,25 @@ class MemoryStore:
422
516
  ).fetchall()
423
517
  return [self._row_to_record(row) for row in rows]
424
518
 
519
+ def semantic_corpus(self) -> list[tuple[int, str, str]]:
520
+ """(rowid, memory_id, embeddable text) for every memory — the auto
521
+ semantic indexer's rebuild source. rowid doubles as the uint64
522
+ external id; full rebuilds make rowid reuse after deletes harmless."""
523
+ rows = self.conn.execute(
524
+ "SELECT rowid AS rid, id, content, summary, tags FROM memories"
525
+ ).fetchall()
526
+ return [
527
+ (int(row["rid"]), row["id"], f"{row['content']}\n{row['summary']}\n{row['tags']}")
528
+ for row in rows
529
+ ]
530
+
531
+ def semantic_signature(self) -> tuple:
532
+ """Cheap change signature used to decide when the auto index rebuilds."""
533
+ row = self.conn.execute(
534
+ "SELECT COUNT(*), COALESCE(MAX(rowid), 0), COALESCE(MAX(updated_at), '') FROM memories"
535
+ ).fetchone()
536
+ return (int(row[0]), int(row[1]), row[2])
537
+
425
538
  def graph_snapshot(
426
539
  self,
427
540
  *,
@@ -640,6 +753,108 @@ class MemoryStore:
640
753
  }
641
754
  return [memory_id for memory_id in ordered if memory_id in visible]
642
755
 
756
+ _MEMORY_COLUMNS = (
757
+ "id, owner, scope, type, content, summary, tags, visibility, source, "
758
+ "confidence, importance, created_at, updated_at, expires_at, "
759
+ "decay_policy, decay_half_life_days, last_accessed_at, access_count, pinned"
760
+ )
761
+
762
+ def run_retention(
763
+ self, *, decayed_half_lives: float | None = RETENTION_MIN_HALF_LIVES
764
+ ) -> dict[str, int]:
765
+ """Move expired (and optionally deeply-decayed) memories to cold archive.
766
+
767
+ Archived memories leave active recall entirely but stay restorable.
768
+ Pinned and authority-track records are never archived by decay; only a
769
+ hard expiry can retire them.
770
+ """
771
+ now = utc_now()
772
+ expired = self._archive_where(
773
+ "expires_at IS NOT NULL AND expires_at <= ?", [now], reason="expired"
774
+ )
775
+ decayed = 0
776
+ if decayed_half_lives and decayed_half_lives > 0:
777
+ decayed = self._archive_where(
778
+ """
779
+ pinned = 0 AND decay_policy != 'none'
780
+ AND NOT (COALESCE(json_extract(source, '$.permanence'), 0) = 1
781
+ AND COALESCE(json_extract(source, '$.weight'), 0) >= 10)
782
+ AND (julianday(?) - julianday(COALESCE(last_accessed_at, updated_at)))
783
+ > (? * decay_half_life_days)
784
+ """,
785
+ [now, float(decayed_half_lives)],
786
+ reason="decayed",
787
+ )
788
+ return {"archived_expired": expired, "archived_decayed": decayed}
789
+
790
+ def _archive_where(self, where_sql: str, params: list[object], *, reason: str) -> int:
791
+ rows = self.conn.execute(
792
+ f"SELECT id FROM memories WHERE {where_sql}", params
793
+ ).fetchall()
794
+ ids = [row["id"] for row in rows]
795
+ if not ids:
796
+ return 0
797
+ now = utc_now()
798
+ placeholders = ",".join("?" for _ in ids)
799
+ self.conn.execute(
800
+ f"""
801
+ INSERT OR REPLACE INTO memories_archive
802
+ SELECT {self._MEMORY_COLUMNS}, ?, ? FROM memories WHERE id IN ({placeholders})
803
+ """,
804
+ [now, reason, *ids],
805
+ )
806
+ self.conn.execute(
807
+ f"DELETE FROM memory_links WHERE src_id IN ({placeholders}) OR dst_id IN ({placeholders})",
808
+ [*ids, *ids],
809
+ )
810
+ self.conn.execute(f"DELETE FROM memories WHERE id IN ({placeholders})", ids)
811
+ self.conn.commit()
812
+ return len(ids)
813
+
814
+ def list_archived(self, *, limit: int = 20, offset: int = 0) -> list[dict[str, object]]:
815
+ rows = self.conn.execute(
816
+ "SELECT * FROM memories_archive ORDER BY archived_at DESC, rowid DESC LIMIT ? OFFSET ?",
817
+ (max(1, limit), max(0, offset)),
818
+ ).fetchall()
819
+ return [
820
+ {
821
+ "id": row["id"], "owner": row["owner"], "scope": row["scope"],
822
+ "type": row["type"], "content": row["content"], "summary": row["summary"],
823
+ "archived_at": row["archived_at"], "archive_reason": row["archive_reason"],
824
+ }
825
+ for row in rows
826
+ ]
827
+
828
+ def restore_archived(self, memory_id: str) -> MemoryRecord:
829
+ """Bring an archived memory back into active recall.
830
+
831
+ The original expiry is cleared — restoring an expired memory that
832
+ stays expired would be a no-op — and updated_at restarts the decay
833
+ clock, because a human decision to restore IS a relevance signal.
834
+ """
835
+ row = self.conn.execute(
836
+ "SELECT * FROM memories_archive WHERE id = ?", (memory_id,)
837
+ ).fetchone()
838
+ if row is None:
839
+ raise KeyError(memory_id)
840
+ now = utc_now()
841
+ self.conn.execute(
842
+ f"""
843
+ INSERT INTO memories({self._MEMORY_COLUMNS})
844
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
845
+ """,
846
+ (
847
+ row["id"], row["owner"], row["scope"], row["type"], row["content"],
848
+ row["summary"], row["tags"], row["visibility"], row["source"],
849
+ row["confidence"], row["importance"], row["created_at"], now,
850
+ None, row["decay_policy"], row["decay_half_life_days"],
851
+ row["last_accessed_at"], row["access_count"], row["pinned"],
852
+ ),
853
+ )
854
+ self.conn.execute("DELETE FROM memories_archive WHERE id = ?", (memory_id,))
855
+ self.conn.commit()
856
+ return self.get(memory_id)
857
+
643
858
  def purge_owner(self, owner: str) -> dict[str, int]:
644
859
  """Delete every memory owned by `owner`, plus all links touching them.
645
860
 
@@ -1519,7 +1734,9 @@ class MemoryStore:
1519
1734
  "stale_links": int(stale_links),
1520
1735
  "top_hubs": top_hubs,
1521
1736
  }
1737
+ archived = self.conn.execute("SELECT COUNT(*) FROM memories_archive").fetchone()[0]
1522
1738
  return base | {
1739
+ "archived": int(archived),
1523
1740
  "graph_health": graph_health,
1524
1741
  "pinned": int(pinned),
1525
1742
  "expired": int(expired),
@@ -0,0 +1,114 @@
1
+ """Embedding pipeline: semantic recall that works out of the box.
2
+
3
+ `HashingEmbedder` is the dependency-free default — deterministic feature
4
+ hashing over words and character trigrams. It captures lexical similarity in
5
+ vector space (typo- and morphology-tolerant), not deep semantics; swap in any
6
+ real embedding model by passing a `str -> sequence[float]` callable.
7
+
8
+ `AutoSemanticIndex` keeps a turbovec index in sync with the memories table by
9
+ watching a cheap change signature and rebuilding lazily — the disposable-index
10
+ philosophy: the vector index is never authoritative and can always be rebuilt
11
+ from SQLite.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ import re
18
+ import zlib
19
+ from typing import Iterable, Sequence
20
+
21
+ from .candidates import Candidate
22
+
23
+ _WORD_RE = re.compile(r"\w+", re.UNICODE)
24
+
25
+
26
+ class HashingEmbedder:
27
+ """Deterministic feature-hashing embedder (no model, no downloads)."""
28
+
29
+ def __init__(self, dim: int = 256):
30
+ if dim < 16:
31
+ raise ValueError("dim must be >= 16")
32
+ self.dim = dim
33
+
34
+ def __call__(self, text: str) -> list[float]:
35
+ vector = [0.0] * self.dim
36
+ for feature in self._features(text):
37
+ digest = zlib.crc32(feature.encode("utf-8"))
38
+ index = digest % self.dim
39
+ sign = 1.0 if (digest >> 16) & 1 else -1.0
40
+ vector[index] += sign
41
+ norm = math.sqrt(sum(value * value for value in vector))
42
+ if norm == 0.0:
43
+ return vector
44
+ return [value / norm for value in vector]
45
+
46
+ @staticmethod
47
+ def _features(text: str) -> Iterable[str]:
48
+ words = _WORD_RE.findall(text.lower())
49
+ for word in words:
50
+ yield "w:" + word
51
+ padded = f"^{word}$"
52
+ for i in range(len(padded) - 2):
53
+ yield "t:" + padded[i : i + 3]
54
+
55
+
56
+ class AutoSemanticIndex:
57
+ """CandidateProvider that self-maintains a turbovec index over the store.
58
+
59
+ Rebuilds whenever the memories table changes (count / max rowid / max
60
+ updated_at signature), so no write-path coupling is needed and multiple
61
+ processes sharing the database each converge on their own fresh index.
62
+ Returns candidate ids only; the store's ACL/expiry hard gates still apply.
63
+ """
64
+
65
+ name = "turbovec-auto"
66
+
67
+ def __init__(self, store, embedder=None, *, dim: int = 256):
68
+ self._store = store
69
+ self._embedder = embedder or HashingEmbedder(dim)
70
+ self._provider = None
71
+ self._signature: tuple | None = None
72
+
73
+ def candidates(
74
+ self,
75
+ query: str,
76
+ *,
77
+ owner: str | None = None,
78
+ scope: str | None = None,
79
+ requester_agent_id: str | None = None,
80
+ requester_team_id: str | None = None,
81
+ limit: int = 10,
82
+ ) -> Sequence[Candidate]:
83
+ signature = self._store.semantic_signature()
84
+ if signature != self._signature:
85
+ self._rebuild(signature)
86
+ if self._provider is None:
87
+ return []
88
+ return list(
89
+ self._provider.candidates(
90
+ query,
91
+ owner=owner,
92
+ scope=scope,
93
+ requester_agent_id=requester_agent_id,
94
+ requester_team_id=requester_team_id,
95
+ limit=limit,
96
+ )
97
+ )
98
+
99
+ def _rebuild(self, signature: tuple) -> None:
100
+ from .providers.turbovec import TurbovecSemanticCandidateProvider
101
+
102
+ corpus = self._store.semantic_corpus()
103
+ if not corpus:
104
+ self._provider = None
105
+ self._signature = signature
106
+ return
107
+ vectors = [self._embedder(text) for _, _, text in corpus]
108
+ mapping = {memory_id: rowid for rowid, memory_id, _ in corpus}
109
+ self._provider = TurbovecSemanticCandidateProvider.from_vectors(
110
+ vectors=vectors,
111
+ external_id_by_memory_id=mapping,
112
+ embed_query=self._embedder,
113
+ )
114
+ self._signature = signature