Yash030 commited on
Commit
ac98b25
·
1 Parent(s): 5586336

Raplh 2 in process

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .scratch/arch-improvements/issues/01-appcontext-expand.md +0 -16
  2. .scratch/arch-improvements/issues/02-privacy-module.md +0 -17
  3. .scratch/arch-improvements/issues/03-auth-middleware.md +0 -16
  4. .scratch/arch-improvements/issues/04-mcp-tool-registry.md +0 -17
  5. .scratch/arch-improvements/issues/05-split-observations-memories.md +0 -23
  6. .scratch/arch-improvements/issues/06-split-indexing-retrieval.md +0 -24
  7. .scratch/arch-improvements/issues/07-split-consolidation-slots-lessons.md +0 -25
  8. .scratch/arch-improvements/issues/08-delete-functions-shim.md +0 -23
  9. .scratch/arch-improvements/issues/09-typed-observations-table-expand.md +0 -19
  10. .scratch/arch-improvements/issues/10-typed-observations-reads-contract.md +0 -18
  11. .scratch/arch-improvements/issues/11-basevectorindex-protocol.md +0 -17
  12. src/agentcache.egg-info/PKG-INFO +2 -484
  13. src/agentcache.egg-info/SOURCES.txt +6 -1
  14. src/agentcache/__init__.py +6 -9
  15. src/agentcache/app.py +36 -22
  16. src/agentcache/cache/__init__.py +3 -9
  17. src/agentcache/cache/context.py +1 -1
  18. src/agentcache/cache/graph.py +1 -1
  19. src/agentcache/cache/health.py +1 -1
  20. src/agentcache/cache/observe.py +1 -1
  21. src/agentcache/cache/remember.py +1 -1
  22. src/agentcache/cache/timeline.py +1 -1
  23. src/agentcache/cli.py +4 -3
  24. src/agentcache/core/__init__.py +7 -2
  25. src/agentcache/core/kv_scopes.py +0 -2
  26. src/agentcache/core/observation_store.py +18 -15
  27. src/agentcache/core/search_service.py +17 -6
  28. src/agentcache/functions.py +0 -0
  29. src/agentcache/legacy.py +12 -13
  30. src/agentcache/replay_import.py +4 -24
  31. src/agentcache/routes/__init__.py +7 -12
  32. src/agentcache/routes/graph.py +1 -1
  33. src/agentcache/routes/health.py +1 -1
  34. src/agentcache/routes/mcp.py +10 -5
  35. src/agentcache/routes/memories.py +1 -4
  36. src/agentcache/routes/migration.py +1 -1
  37. src/agentcache/routes/observations.py +251 -267
  38. src/agentcache/routes/search.py +34 -8
  39. src/agentcache/search.py +1 -0
  40. src/agentcache/storage/scopes.py +3 -72
  41. src/agentcache/workers.py +39 -14
  42. tests/__init__.py +0 -2
  43. tests/conftest.py +1 -0
  44. tests/test_api.py +0 -294
  45. tests/test_auth.py +20 -7
  46. tests/test_auto_forget.py +0 -139
  47. tests/test_cli_context.py +0 -70
  48. tests/test_context.py +0 -204
  49. tests/test_debounce.py +0 -166
  50. tests/test_folder_graph_build.py +0 -432
.scratch/arch-improvements/issues/01-appcontext-expand.md DELETED
@@ -1,16 +0,0 @@
1
- # 01 — Introduce `AppContext` dataclass (Expand)
2
-
3
- **Blocked by:** None — can start immediately
4
- **Status:** ready-for-agent
5
-
6
- ## What to build
7
-
8
- Introduce a single `AppContext` dataclass that packages all runtime state currently scattered as module-level globals in `functions.py` (`_bm25_index`, `_vector_index`, `_embedding_provider`, `_hybrid_search`, `_stream_broadcaster`, and the `kv` reference). Construct one `AppContext` instance inside `create_app()` and thread it through to every function that needs it. The existing globals stay in place as a compatibility shim — no callers are changed in this ticket. Zero behaviour change; this is the expand step that makes the god-module split (tickets 05–08) possible.
9
-
10
- ## Acceptance criteria
11
-
12
- - [ ] `AppContext` dataclass exists in a new `context.py` module (or equivalent) with typed fields for `kv`, `bm25`, `vector`, `embedder`, and `broadcast`
13
- - [ ] `create_app()` constructs one `AppContext` and stores it on the Flask app (e.g. `app.ctx`)
14
- - [ ] The five existing `set_*` functions in `functions.py` remain working — globals are still set alongside the new `AppContext`
15
- - [ ] All existing tests pass with no changes
16
- - [ ] No route handler or worker is changed in this ticket
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.scratch/arch-improvements/issues/02-privacy-module.md DELETED
@@ -1,17 +0,0 @@
1
- # 02 — Extract `privacy.py` deep module
2
-
3
- **Blocked by:** None — can start immediately
4
- **Status:** ready-for-agent
5
-
6
- ## What to build
7
-
8
- Extract `strip_private_data()` from `functions.py` into a dedicated `privacy.py` module. The new module exposes a single `scrub(text: str, patterns=DEFAULT_PATTERNS) -> str` interface. The regex pattern list is promoted to a public, documented constant (`DEFAULT_PATTERNS`) and is extensible via an env var (`AGENTCACHE_REDACT_PATTERNS`). `functions.py` calls `privacy.scrub()` internally — all existing callers continue working without change. First unit tests for Privacy Scrub are added in this ticket, targeting `scrub()` directly.
9
-
10
- ## Acceptance criteria
11
-
12
- - [ ] `privacy.py` exists with a `scrub(text, patterns=DEFAULT_PATTERNS)` function
13
- - [ ] `DEFAULT_PATTERNS` is a documented list of regex strings — visible and auditable
14
- - [ ] An env var `AGENTCACHE_REDACT_PATTERNS` (comma-separated regex strings) appends additional patterns at startup
15
- - [ ] `functions.py` no longer contains the scrubbing regex list — it imports and calls `privacy.scrub()`
16
- - [ ] Unit tests cover: API key pattern, bearer token pattern, custom pattern via argument, no false positives on safe text
17
- - [ ] All existing tests pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.scratch/arch-improvements/issues/03-auth-middleware.md DELETED
@@ -1,16 +0,0 @@
1
- # 03 — Centralise auth middleware
2
-
3
- **Blocked by:** None — can start immediately
4
- **Status:** ready-for-agent
5
-
6
- ## What to build
7
-
8
- Replace the copy-pasted `_check_auth()` function that currently lives independently in every route blueprint (`mcp.py`, `observations.py`, `memories.py`, `health.py`, and others) with a single `require_auth` decorator defined in a new `auth.py` module. Every route that previously called `_check_auth()` at the top of its handler switches to the `@require_auth` decorator. The auth logic (timing-safe `hmac.compare_digest` Bearer token check) is identical — this is purely a deduplication. RBAC can be added in one place in a future ticket.
9
-
10
- ## Acceptance criteria
11
-
12
- - [ ] `auth.py` exists with a `require_auth(f)` decorator that performs the timing-safe Bearer token check
13
- - [ ] Every route blueprint imports and uses `@require_auth` — no blueprint defines its own `_check_auth`
14
- - [ ] A request with no secret configured passes through (existing behaviour preserved)
15
- - [ ] A request with a wrong token still gets a `401` response
16
- - [ ] All existing tests pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.scratch/arch-improvements/issues/04-mcp-tool-registry.md DELETED
@@ -1,17 +0,0 @@
1
- # 04 — MCP tool registry (replace elif chain)
2
-
3
- **Blocked by:** 03 — centralise auth middleware
4
- **Status:** ready-for-agent
5
-
6
- ## What to build
7
-
8
- Replace the 300-line `if/elif` dispatch chain in `routes/mcp.py` with a `@register("tool_name")` decorator registry. Each of the 30+ MCP tool handlers becomes a standalone function decorated with `@register`. The dispatcher becomes a two-liner: look up the tool name in the registry dict and call the handler. Auth is handled by the shared `@require_auth` decorator from ticket 03. The tool schema list (`get_mcp_tools_schemas`) stays as-is. No change to the MCP wire format or agent behaviour.
9
-
10
- ## Acceptance criteria
11
-
12
- - [ ] A `_tools: dict[str, Callable]` registry exists and a `@register(name)` decorator populates it
13
- - [ ] Every MCP tool handler is a standalone function — not an inline block inside a giant if/elif
14
- - [ ] The POST `/mcp/tools` handler body is ≤ 20 lines (lookup + call + error handling)
15
- - [ ] Each tool handler function is independently importable and callable in a test without starting Flask
16
- - [ ] Adding a new tool requires only adding a new decorated function — no editing of existing dispatch code
17
- - [ ] All existing MCP tool calls produce the same responses as before
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.scratch/arch-improvements/issues/05-split-observations-memories.md DELETED
@@ -1,23 +0,0 @@
1
- # 05 — Split god module batch 1: `observations.py` + `memories.py`
2
-
3
- **Blocked by:** 01 — AppContext dataclass (Expand)
4
- **Status:** ready-for-agent
5
-
6
- ## What to build
7
-
8
- Move the first two major domain areas out of `functions.py` into focused modules, using the domain vocabulary from `UBIQUITOUS_LANGUAGE.md`.
9
-
10
- **`observations.py`** receives: `folder_observe()`, `observe()` (legacy), `dedup_folder_observations()`, `build_synthetic_compression()`, `infer_type()`, `extract_files()`, `extract_image()`, image store helpers (`save_image_to_disk`, `delete_image`, `touch_image`, `is_managed_image_path`), and `normalize_folder_path()` / `validate_agent_id()`.
11
-
12
- **`memories.py`** receives: `remember()`, `forget()`, memory versioning logic, and `jaccard_similarity()`.
13
-
14
- `functions.py` re-exports everything from these two modules so all existing callers continue to work unchanged. CI must stay green. This is the first batch of the expand phase of the god-module split.
15
-
16
- ## Acceptance criteria
17
-
18
- - [ ] `observations.py` and `memories.py` exist as standalone modules
19
- - [ ] All moved functions accept `AppContext` (from ticket 01) instead of reaching into module-level globals, while also accepting the legacy `kv: StateKV` signature for backward compat
20
- - [ ] `functions.py` re-exports every moved symbol — no call site outside `functions.py` needs to change
21
- - [ ] Existing route blueprints and workers continue to import from `functions` without modification
22
- - [ ] All existing tests pass
23
- - [ ] No logic is changed — this is a pure relocation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.scratch/arch-improvements/issues/06-split-indexing-retrieval.md DELETED
@@ -1,24 +0,0 @@
1
- # 06 — Split god module batch 2: `indexing.py` + `retrieval.py`
2
-
3
- **Blocked by:** 05 — split batch 1 (observations + memories)
4
- **Status:** ready-for-agent
5
-
6
- ## What to build
7
-
8
- Move the second major domain area out of `functions.py`.
9
-
10
- **`indexing.py`** receives: `IndexPersistence`, `rebuild_index()`, `backfill_obs_lookup_if_needed()`, `vector_index_add_guarded()`, `clip_embed_input()`, and all index-related helpers. This module owns the Index Rebuild lifecycle (see `UBIQUITOUS_LANGUAGE.md`).
11
-
12
- **`retrieval.py`** receives: `folder_search()`, `folder_timeline()`, `compile_context()`, and `export_data()`. This module owns the Recall, Smart Search, Folder Search, Timeline, and Context Compilation operations.
13
-
14
- `functions.py` continues to re-export everything. CI stays green.
15
-
16
- ## Acceptance criteria
17
-
18
- - [ ] `indexing.py` and `retrieval.py` exist as standalone modules
19
- - [ ] All moved functions use `AppContext` from ticket 01 rather than reaching into globals directly
20
- - [ ] `functions.py` re-exports every moved symbol unchanged
21
- - [ ] `IndexPersistence` is importable from `indexing` in `workers.py` without changing any other worker code
22
- - [ ] `folder_search()` and `compile_context()` are callable from a test by constructing an `AppContext` with an in-memory KV and a fresh `SearchIndex` — no Flask app required
23
- - [ ] All existing tests pass
24
- - [ ] No logic is changed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.scratch/arch-improvements/issues/07-split-consolidation-slots-lessons.md DELETED
@@ -1,25 +0,0 @@
1
- # 07 — Split god module batch 3: `consolidation.py` + `slots.py` + `lessons.py`
2
-
3
- **Blocked by:** 06 — split batch 2 (indexing + retrieval)
4
- **Status:** ready-for-agent
5
-
6
- ## What to build
7
-
8
- Move the final major domain areas out of `functions.py`.
9
-
10
- **`consolidation.py`** receives: `consolidate()`, `auto_forget()`, `folder_graph_build()`, and all LLM-call helpers used exclusively by the Consolidation pipeline. This module owns the Consolidation lifecycle (Working Memory → Episodic → Semantic → Procedural).
11
-
12
- **`slots.py`** receives: all Slot CRUD functions (`get_slots`, `set_slot`, `delete_slot`, `reflect_slot`, `append_slot`, etc.).
13
-
14
- **`lessons.py`** receives: all Lesson CRUD functions (`get_lessons`, `save_lesson`, `search_lessons`, `strengthen_lesson`, lesson decay logic).
15
-
16
- `functions.py` re-exports everything. After this ticket, `functions.py` is a pure shim — no domain logic remains in it.
17
-
18
- ## Acceptance criteria
19
-
20
- - [ ] `consolidation.py`, `slots.py`, and `lessons.py` exist as standalone modules
21
- - [ ] `functions.py` contains no domain logic — only re-export statements
22
- - [ ] All moved functions use `AppContext` from ticket 01
23
- - [ ] `auto_forget()` is callable from a test without importing `functions` directly
24
- - [ ] All existing tests pass
25
- - [ ] No logic is changed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.scratch/arch-improvements/issues/08-delete-functions-shim.md DELETED
@@ -1,23 +0,0 @@
1
- # 08 — Contract: delete `functions.py` shim, migrate all callers to direct imports
2
-
3
- **Blocked by:** 05, 06, 07 — all three split batches must be complete
4
- **Status:** ready-for-agent
5
-
6
- ## What to build
7
-
8
- Delete `functions.py` entirely. Update every import across `routes/`, `workers.py`, `app.py`, and the test suite to point directly to the focused module that owns the symbol. This is the contract step of the expand–contract sequence. After this ticket the domain vocabulary from `UBIQUITOUS_LANGUAGE.md` is directly reflected in the module names — there is no god module.
9
-
10
- Import mapping (non-exhaustive):
11
- - `from .functions import folder_observe` → `from .observations import folder_observe`
12
- - `from .functions import remember, forget` → `from .memories import remember, forget`
13
- - `from .functions import folder_search, compile_context` → `from .retrieval import folder_search, compile_context`
14
- - `from .functions import IndexPersistence, rebuild_index` → `from .indexing import IndexPersistence, rebuild_index`
15
- - `from .functions import consolidate, auto_forget` → `from .consolidation import consolidate, auto_forget`
16
-
17
- ## Acceptance criteria
18
-
19
- - [ ] `functions.py` does not exist in the repository
20
- - [ ] `rg "from .functions import\|from agentcache.functions import\|import functions"` returns zero results in `src/`
21
- - [ ] All existing tests pass with imports updated
22
- - [ ] No test imports from `functions` — each test imports from the specific module it exercises
23
- - [ ] CI is green
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.scratch/arch-improvements/issues/09-typed-observations-table-expand.md DELETED
@@ -1,19 +0,0 @@
1
- # 09 — Add typed `observations` table to SQLite (Expand)
2
-
3
- **Blocked by:** 05 — observations module must exist to own this migration
4
- **Status:** ready-for-agent
5
-
6
- ## What to build
7
-
8
- Add a typed `observations` table to the SQLite schema with columns for `folder`, `agent`, `timestamp`, `type`, `importance`, and `text`. When a new Observation is ingested via `folder_observe()`, write it to both the new typed table and the existing `kv_store` scope (dual-write). Reads still use `kv_store` — zero behaviour change for any query path. This is the expand step that makes SQL-level filtering possible without breaking anything.
9
-
10
- The new table enables: `WHERE folder = ? AND agent = ? AND timestamp > ?` and `ORDER BY importance DESC LIMIT N` entirely at the SQLite layer — no Python-side filtering needed.
11
-
12
- ## Acceptance criteria
13
-
14
- - [ ] `observations(id TEXT PK, folder TEXT, agent TEXT, timestamp TEXT, type TEXT, importance INTEGER, text TEXT)` table exists in the DB schema with indexes on `(folder, agent, timestamp)` and `(importance)`
15
- - [ ] `folder_observe()` dual-writes: one row to `observations`, one entry to `kv_store` (existing path)
16
- - [ ] The DB migration runs automatically on startup if the table does not exist (no manual step)
17
- - [ ] All reads continue to use `kv_store` — no query is changed in this ticket
18
- - [ ] All existing tests pass
19
- - [ ] A DB integrity test verifies that for each `kv_store` Observation entry written, a matching row exists in `observations`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.scratch/arch-improvements/issues/10-typed-observations-reads-contract.md DELETED
@@ -1,18 +0,0 @@
1
- # 10 — Migrate Observation reads to typed table (Contract)
2
-
3
- **Blocked by:** 09 — typed observations table (Expand) must be complete
4
- **Status:** ready-for-agent
5
-
6
- ## What to build
7
-
8
- Switch `folder_search`, `folder_timeline`, `compile_context`, and `auto_forget` to read Observations from the typed `observations` table using SQL-level filtering, replacing the current pattern of `kv.list(scope)` + Python-side filtering. Stop dual-writing new Observations to `kv_store` — the typed table is now the single source of truth. A one-time migration backfills any existing `kv_store` Observation entries into the typed table for users upgrading from a previous version.
9
-
10
- ## Acceptance criteria
11
-
12
- - [ ] `folder_timeline()` issues a single `SELECT … WHERE folder=? AND agent=? ORDER BY timestamp DESC LIMIT ?` — no Python-side filtering loop
13
- - [ ] `folder_search()` hydrates candidates from the typed table rather than `kv.list(scope)` for the Observation load step
14
- - [ ] `compile_context()` fetches recent Observations using `ORDER BY importance DESC, timestamp DESC LIMIT ?` at the SQL layer
15
- - [ ] New `folder_observe()` calls write only to the typed table (no `kv_store` Observation scope write)
16
- - [ ] A backfill function runs once on startup to migrate existing `kv_store` Observations into the typed table
17
- - [ ] A benchmark test (or manual note in the PR) shows query latency improvement at ≥ 10k Observations
18
- - [ ] All existing tests pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.scratch/arch-improvements/issues/11-basevectorindex-protocol.md DELETED
@@ -1,17 +0,0 @@
1
- # 11 — `BaseVectorIndex` protocol + `InMemoryVectorIndex` adapter
2
-
3
- **Blocked by:** 06 — indexing module must exist before its interface is formalised
4
- **Status:** ready-for-agent
5
-
6
- ## What to build
7
-
8
- Define a `BaseVectorIndex` Protocol (or ABC) in `indexing.py` with three methods: `add(obs_id, session_id, embedding)`, `remove(obs_id)`, and `search(query_embedding, limit) -> list`. Rename the current flat-list implementation to `InMemoryVectorIndex` and have it implement `BaseVectorIndex`. Update `AppContext.vector` to be typed as `BaseVectorIndex`. Behaviour is completely unchanged — this ticket only cuts the adapter seam so future backends (HNSW, Qdrant, Chroma) can be swapped in without touching `retrieval.py` or `consolidation.py`.
9
-
10
- ## Acceptance criteria
11
-
12
- - [ ] `BaseVectorIndex` Protocol exists in `indexing.py` with `add`, `remove`, and `search` as the only required methods
13
- - [ ] `InMemoryVectorIndex` implements `BaseVectorIndex` and passes a `isinstance(idx, BaseVectorIndex)` check
14
- - [ ] `AppContext.vector` is typed as `BaseVectorIndex | None`
15
- - [ ] A test constructs a minimal stub that implements `BaseVectorIndex` and passes it to `folder_search()` via `AppContext` — confirming the seam is real and injectable
16
- - [ ] `VectorIndex` (old name) remains as an alias for `InMemoryVectorIndex` for one release to avoid breaking any external imports
17
- - [ ] All existing tests pass with no behaviour change
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/agentcache.egg-info/PKG-INFO CHANGED
@@ -1,4 +1,4 @@
1
- Metadata-Version: 2.1
2
  Name: agentcache
3
  Version: 0.9.8
4
  Summary: A Python REST + WebSocket + MCP cache server for AI agents, backed by SQLite
@@ -35,486 +35,4 @@ Requires-Dist: ruff>=0.3.0; extra == "dev"
35
  Requires-Dist: twine>=5.0.0; extra == "dev"
36
  Provides-Extra: local-embeddings
37
  Requires-Dist: sentence-transformers>=2.7.0; extra == "local-embeddings"
38
-
39
- ---
40
- title: AgentCache Python
41
- emoji: 🧠
42
- colorFrom: blue
43
- colorTo: indigo
44
- sdk: docker
45
- pinned: false
46
- ---
47
-
48
- <h1 align="center">agentcache-python</h1>
49
-
50
- <p align="center">
51
- <strong>Persistent memory for AI coding agents — pure Python, zero external databases.</strong><br/>
52
- Works with Claude Code, Cursor, Cline, Windsurf, Gemini CLI, and any MCP client.
53
- </p>
54
-
55
- <p align="center">
56
- <img src="https://img.shields.io/badge/Python-3.10%2B-3776AB?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.10+" />
57
- <img src="https://img.shields.io/badge/SQLite-WAL-003B57?style=for-the-badge&logo=sqlite&logoColor=white" alt="SQLite WAL" />
58
- <img src="https://img.shields.io/badge/Flask-3.0-000000?style=for-the-badge&logo=flask&logoColor=white" alt="Flask 3.0" />
59
- <img src="https://img.shields.io/badge/MCP-Compatible-6B21A8?style=for-the-badge" alt="MCP Compatible" />
60
- <img src="https://img.shields.io/badge/HuggingFace-Space-FF9D00?style=for-the-badge&logo=huggingface&logoColor=white" alt="HuggingFace Space" />
61
- </p>
62
-
63
- <p align="center">
64
- <a href="#quick-start">Quick Start</a> &bull;
65
- <a href="#features">Features</a> &bull;
66
- <a href="#mcp-integration">MCP</a> &bull;
67
- <a href="#api-reference">API</a> &bull;
68
- <a href="#configuration">Config</a> &bull;
69
- <a href="#deploy-to-huggingface">Deploy</a> &bull;
70
- <a href="#viewer">Viewer</a> &bull;
71
- <a href="#architecture">Architecture</a>
72
- </p>
73
-
74
- ---
75
-
76
- ## What Is This?
77
-
78
- **agentcache-python** is a Python reimplementation of the [agentcache](https://github.com/rohitg00/agentcache) persistent memory server. It exposes a REST API, WebSocket stream, and MCP tools endpoint that AI coding agents use to store and retrieve session observations, long-term memories, lessons, and pinned memory slots.
79
-
80
- Key differences from the Node.js original:
81
-
82
- - **No Node.js or iii-engine** — runs with plain `python src/app.py`
83
- - **SQLite instead of Dolt** — single file, WAL mode, instant startup
84
- - **HuggingFace Space ready** — deploys in one click, data synced to an HF dataset repo
85
- - **Same REST + MCP wire format** — drop-in for any agent already wired to agentcache
86
-
87
- Your agent captures every tool call, stores them as observations, compresses them into searchable memory, and injects the right context at the start of every new session — automatically.
88
-
89
- ---
90
-
91
- ## Quick Start
92
-
93
- ### Run locally
94
-
95
- ```bash
96
- # Clone
97
- git clone https://github.com/Yashwant00CR7/agentcache.git
98
- cd agentcache
99
-
100
- # Install dependencies (no build step)
101
- pip install -r requirements.txt
102
-
103
- # Start the server
104
- python src/app.py
105
- ```
106
-
107
- Server starts on **http://localhost:3111**. Open the viewer at http://localhost:3111/viewer.
108
-
109
- ### Verify it works
110
-
111
- ```bash
112
- # Health check
113
- curl http://localhost:3111/agentcache/livez
114
- # {"status": "ok"}
115
-
116
- # Save a memory
117
- curl -X POST http://localhost:3111/agentcache/remember \
118
- -H "Content-Type: application/json" \
119
- -d '{"content": "JWT auth uses jose middleware in src/middleware/auth.ts", "concepts": ["auth", "jwt"]}'
120
-
121
- # Recall it
122
- curl -X POST http://localhost:3111/agentcache/search \
123
- -H "Content-Type: application/json" \
124
- -d '{"query": "authentication middleware", "limit": 5}'
125
- ```
126
-
127
- ---
128
-
129
- ## Features
130
-
131
- | Feature | Status | Notes |
132
- |---------|--------|-------|
133
- | REST API — sessions, memories, observations | ✅ | Full surface |
134
- | WebSocket live stream | ✅ | `/stream/mem-live/viewer` |
135
- | MCP tools endpoint | ✅ | 31 tools |
136
- | Built-in HTML viewer | ✅ | Real-time dashboard at `/viewer` |
137
- | BM25 keyword search | ✅ | Always on, no API key needed |
138
- | Hybrid BM25 + vector search | ✅ | Requires `GEMINI_API_KEY` |
139
- | 4-tier memory consolidation | ⚙️ | `CONSOLIDATION_ENABLED=true` + LLM key |
140
- | Knowledge graph extraction | ⚙️ | `GRAPH_EXTRACTION_ENABLED=true` + LLM key |
141
- | LLM observation compression | ⚙️ | `AGENTCACHE_AUTO_COMPRESS=true` + LLM key |
142
- | Lessons with confidence decay | ✅ | Fingerprinted, auto-strengthen on repeat |
143
- | Memory slots (pinned context) | ✅ | CRUD + auto-reflect |
144
- | Session replay | ✅ | Full timeline in viewer |
145
- | Audit log | ✅ | Tracks every write with agent_id + timestamp |
146
- | HuggingFace Space deploy | ✅ | One-click, data synced to dataset repo |
147
- | Privacy filtering | ✅ | Strips API keys, tokens before storage |
148
-
149
- ### 4-Tier Memory Model
150
-
151
- Inspired by how human memory works — raw experience → compressed episodes → extracted facts → learned patterns.
152
-
153
- | Tier | What | When |
154
- |------|------|------|
155
- | **Working** | Raw observations from tool use | Every tool call |
156
- | **Episodic** | Compressed session summaries | Session end |
157
- | **Semantic** | Extracted facts and patterns | Consolidation |
158
- | **Procedural** | Workflows and decision patterns | Consolidation |
159
-
160
- ---
161
-
162
- ## MCP Integration
163
-
164
- Wire agentcache-python into your agent's MCP config. It speaks the same MCP protocol as the Node.js original.
165
-
166
- ### Most agents (Cursor, Claude Desktop, Cline, Windsurf)
167
-
168
- ```json
169
- {
170
- "mcpServers": {
171
- "agentcache": {
172
- "command": "npx",
173
- "args": ["-y", "@agentcache/mcp"],
174
- "env": {
175
- "AGENTCACHE_URL": "http://localhost:3111"
176
- }
177
- }
178
- }
179
- }
180
- ```
181
-
182
- ### Claude Code
183
-
184
- Paste this prompt and your agent will wire everything:
185
-
186
- ```
187
- Start agentcache-python: run `python src/app.py` from the agentcache-python directory.
188
- Then add this MCP server to ~/.claude.json under mcpServers:
189
- {
190
- "agentcache": {
191
- "command": "npx",
192
- "args": ["-y", "@agentcache/mcp"],
193
- "env": { "AGENTCACHE_URL": "http://localhost:3111" }
194
- }
195
- }
196
- Verify with: curl http://localhost:3111/agentcache/livez
197
- Open the viewer at: http://localhost:3111/viewer
198
- ```
199
-
200
- ### Available MCP Tools (31)
201
-
202
- | Tool | Description |
203
- |------|-------------|
204
- | `memory_save` | Save a long-term insight, decision, or pattern |
205
- | `memory_recall` | Search past observations by keyword |
206
- | `memory_smart_search` | Hybrid BM25 + vector semantic search |
207
- | `memory_sessions` | List recent sessions |
208
- | `memory_sessions_list` | Retrieve all memory sessions |
209
- | `memory_timeline` | Chronological observations for a session |
210
- | `memory_observations` | Observations for a session |
211
- | `memory_profile` | Per-project concept + file profile |
212
- | `memory_lessons` | List active lessons with confidence scores |
213
- | `memory_lesson_save` | Save a lesson (duplicate saves strengthen it) |
214
- | `memory_lesson_recall` | Search lessons by query |
215
- | `memory_lesson_search` | Search lessons by keywords |
216
- | `memory_consolidate` | Run 4-tier memory consolidation |
217
- | `memory_reflect` | Reflect on session, update context |
218
- | `memory_diagnose` | Health check across all subsystems |
219
- | `memory_forget` | Delete memory, session, or observations |
220
- | `memory_export` | Export all memory data as JSON |
221
- | `agent_observe` | Log agent execution observation |
222
- | `agent_remember` | Save agent cache to long-term storage |
223
- | `memory_antigravity_sync` | Sync Antigravity transcripts to memory |
224
- | `memory_antigravity_sync_all` | Master sync: transcript + crystallize + reflect |
225
- | `memory_slot_list` | List all pinned memory slots |
226
- | `memory_slot_get` | Retrieve a specific pinned memory slot |
227
- | `memory_slot_create` | Create/overwrite a pinned memory slot |
228
- | `memory_slot_append` | Append text content to a pinned memory slot |
229
- | `memory_slot_replace` | Replace pinned memory slot content |
230
- | `memory_slot_delete` | Delete a pinned memory slot |
231
- | `memory_action_create` | Create a new work item / action |
232
- | `memory_action_update` | Update fields of an existing action |
233
- | `memory_frontier` | Get active and pending actions sorted by priority |
234
- | `memory_crystallize` | Crystallize/summarize observations in a session |
235
-
236
- ---
237
-
238
- ## API Reference
239
-
240
- Base URL: `http://localhost:3111/agentcache`
241
-
242
- ### Health
243
-
244
- | Method | Path | Description |
245
- |--------|------|-------------|
246
- | `GET` | `/livez` | Liveness probe — no auth required |
247
-
248
- ### Sessions
249
-
250
- | Method | Path | Description |
251
- |--------|------|-------------|
252
- | `POST` | `/session/start` | Start a new session |
253
- | `POST` | `/session/end` | End a session |
254
- | `POST` | `/session/commit` | Commit session with summary |
255
- | `GET` | `/sessions` | List all sessions |
256
-
257
- ### Observations
258
-
259
- | Method | Path | Description |
260
- |--------|------|-------------|
261
- | `POST` | `/observe` | Ingest a hook event observation |
262
- | `POST` | `/agent/observe` | Simplified observe for direct agent use |
263
- | `GET` | `/observations` | List observations (`?session_id=`) |
264
- | `POST` | `/timeline` | Chronological observation window |
265
-
266
- ### Memories
267
-
268
- | Method | Path | Description |
269
- |--------|------|-------------|
270
- | `POST` | `/remember` | Save long-term memory |
271
- | `POST` | `/agent/remember` | Simplified remember |
272
- | `POST` | `/forget` | Delete memory / session / observations |
273
- | `POST` | `/search` | BM25 + vector search |
274
- | `POST` | `/context` | Compile context for a session + project |
275
- | `GET` | `/memories` | List memories (`?latest=true&limit=N`) |
276
- | `POST` | `/evolve` | Create a new memory version |
277
-
278
- ### Lessons
279
-
280
- | Method | Path | Description |
281
- |--------|------|-------------|
282
- | `GET` | `/lessons` | List lessons |
283
- | `POST` | `/lessons` | Create lesson |
284
- | `POST` | `/lessons/search` | Search lessons |
285
- | `POST` | `/lessons/strengthen` | Reinforce an existing lesson |
286
-
287
- ### Slots
288
-
289
- | Method | Path | Description |
290
- |--------|------|-------------|
291
- | `GET` | `/slots` | List all pinned slots |
292
- | `POST` | `/slot` | Create or update a slot |
293
- | `GET` | `/slot` | Get slot by name |
294
- | `DELETE` | `/slot` | Delete a slot |
295
- | `POST` | `/slot/reflect` | Auto-populate from session observations |
296
-
297
- ### Graph + Profile
298
-
299
- | Method | Path | Description |
300
- |--------|------|-------------|
301
- | `GET` | `/relations` | Knowledge graph edges |
302
- | `POST` | `/relations` | Add a relation |
303
- | `GET` | `/profile` | Project profile (top concepts, files) |
304
-
305
- ### Actions
306
-
307
- | Method | Path | Description |
308
- |--------|------|-------------|
309
- | `GET` | `/actions` | List actions |
310
- | `POST` | `/actions` | Create an action |
311
- | `PATCH` | `/actions/<id>` | Update action status / fields |
312
- | `GET` | `/frontier` | Pending actions sorted by priority |
313
- | `GET` | `/insights` | List insights |
314
-
315
- ### Replay
316
-
317
- | Method | Path | Description |
318
- |--------|------|-------------|
319
- | `GET` | `/replay/sessions` | Sessions list for replay tab |
320
- | `GET` | `/replay/load` | Full session + observations (`?sessionId=`) |
321
-
322
- ### MCP
323
-
324
- | Method | Path | Description |
325
- |--------|------|-------------|
326
- | `GET` | `/mcp/tools` | MCP tool schema list |
327
- | `POST` | `/mcp/tools` | MCP tool call dispatch |
328
-
329
- ---
330
-
331
- ## Configuration
332
-
333
- Create `~/.agentcache/.env` (no `export` prefix needed):
334
-
335
- ```env
336
- # Server port
337
- III_REST_PORT=3111
338
-
339
- # Vector search — enables Gemini 768-dim embeddings
340
- GEMINI_API_KEY=your-gemini-key
341
-
342
- # LLM for compression / consolidation / graph extraction
343
- # Any one of these enables LLM features:
344
- ANTHROPIC_API_KEY=your-anthropic-key
345
- # OPENAI_API_KEY=your-openai-key
346
- # GEMINI_API_KEY=your-key (same key as above works for both)
347
-
348
- # LLM-powered features (disabled by default — spend tokens)
349
- CONSOLIDATION_ENABLED=true
350
- GRAPH_EXTRACTION_ENABLED=true
351
- AGENTCACHE_AUTO_COMPRESS=true
352
-
353
- # Context injection limits
354
- TOKEN_BUDGET=2000
355
- MAX_OBS_PER_SESSION=500
356
-
357
- # Auth — set to require Bearer token on all endpoints
358
- AGENTCACHE_SECRET=your-secret
359
-
360
- # Agent scope isolation
361
- AGENT_ID=my-agent
362
- AGENTCACHE_AGENT_SCOPE=isolated # only see this agent's data
363
-
364
- # HuggingFace sync
365
- HF_TOKEN=your-hf-token
366
- AGENTCACHE_DATASET_REPO=username/agentcache-data
367
- ```
368
-
369
- ### Full Variable Reference
370
-
371
- | Variable | Default | Purpose |
372
- |----------|---------|---------|
373
- | `III_REST_PORT` / `PORT` | `3111` | API server port |
374
- | `GEMINI_API_KEY` / `GOOGLE_API_KEY` | — | Enables 768-dim vector search |
375
- | `AGENTCACHE_SECRET` | — | Bearer token auth on all endpoints |
376
- | `AGENT_ID` | — | Default agent ID for scope isolation |
377
- | `AGENTCACHE_AGENT_SCOPE=isolated` | — | Filters data to current `AGENT_ID` |
378
- | `MAX_OBS_PER_SESSION` | `500` | Hard cap on observations per session |
379
- | `TOKEN_BUDGET` | `2000` | Max tokens in compiled context |
380
- | `GRAPH_EXTRACTION_ENABLED` | `false` | Knowledge graph (needs LLM) |
381
- | `CONSOLIDATION_ENABLED` | `false` | Memory consolidation (needs LLM) |
382
- | `AGENTCACHE_AUTO_COMPRESS` | `false` | LLM observation compression |
383
-
384
- ---
385
-
386
- ## Viewer
387
-
388
- Built-in dashboard at **http://localhost:3111/viewer**.
389
-
390
- | Tab | What You See |
391
- |-----|-------------|
392
- | **Dashboard** | Session stats, memory counts, recent activity |
393
- | **Sessions** | Browse sessions, inspect observations |
394
- | **Memories** | Search, filter, and read long-term memories |
395
- | **Graph** | Project folder visualization — nodes = folders, edges = shared concepts or parent path |
396
- | **Timeline** | Per-session chronological observation view |
397
- | **Lessons** | Confidence-scored lessons with decay tracking |
398
- | **Slots** | Pinned memory slots editor |
399
- | **Replay** | Scrub through past sessions frame by frame |
400
-
401
- ---
402
-
403
- ## Deploy to HuggingFace
404
-
405
- This project is designed to run as a HuggingFace Space. Data is stored in an HF dataset repo and restored on every boot — so no persistent disk is needed.
406
-
407
- ### Setup
408
-
409
- 1. Fork this repo as a HuggingFace Space (SDK: Docker)
410
- 2. Create a dataset repo (e.g. `your-username/agentcache-data`)
411
- 3. Add Space secrets in the HF dashboard:
412
-
413
- | Secret | Value |
414
- |--------|-------|
415
- | `HF_TOKEN` | Your HF write token |
416
- | `AGENTCACHE_DATASET_REPO` | `your-username/agentcache-data` |
417
- | `AGENTCACHE_SECRET` | A random secret (optional but recommended) |
418
- | `GEMINI_API_KEY` | Gemini key (optional, enables vector search) |
419
-
420
- 4. The Space boots, restores `agentcache.db` from the dataset repo, and starts the server
421
-
422
- ### How sync works
423
-
424
- `sync.py` uses mtime fingerprinting — it only uploads when the database actually changed, so there are no unnecessary uploads during idle periods.
425
-
426
- ```bash
427
- # Manual backup
428
- python sync.py
429
-
430
- # Environment for sync
431
- HF_TOKEN=...
432
- AGENTCACHE_DATASET_REPO=username/agentcache-data
433
- ```
434
-
435
- ---
436
-
437
- ## Architecture
438
-
439
- ```
440
- agentcache-python/
441
- ├── src/
442
- │ ├── app.py Flask server — all endpoints, WebSocket broadcaster
443
- │ ├── db.py SQLite StateKV — WAL mode, audit_log table
444
- │ ├── functions.py Core logic — observe, remember, search, context
445
- │ ├── search.py BM25 + Gemini vector index + HybridSearch (RRF)
446
- │ └── viewer/
447
- │ └── index.html Single-file HTML dashboard (no build step)
448
- ├── sync.py HuggingFace dataset backup/restore
449
- ├── Dockerfile HF Space container
450
- ├── start.sh Boot script (restore → start server → start sync)
451
- └── requirements.txt 6 Python dependencies, no external DB required
452
- ```
453
-
454
- ### Database layout
455
-
456
- Two SQLite tables in `~/.agentcache/agentcache.db`:
457
-
458
- ```sql
459
- -- All data lives here, namespaced by scope
460
- kv_store (
461
- scope TEXT NOT NULL, -- e.g. "mem:sessions", "mem:obs:{session_id}"
462
- key TEXT NOT NULL,
463
- value TEXT NOT NULL, -- JSON-serialized
464
- PRIMARY KEY (scope, key)
465
- )
466
-
467
- -- Audit trail replaces Dolt git versioning
468
- audit_log (
469
- id INTEGER PRIMARY KEY AUTOINCREMENT,
470
- ts INTEGER NOT NULL, -- unix millis
471
- agent_id TEXT NOT NULL,
472
- message TEXT NOT NULL
473
- )
474
- ```
475
-
476
- ### Search pipeline
477
-
478
- ```
479
- Query
480
- → BM25 (always) — Porter-stemmed keyword matching
481
- → Vector (if Gemini key) — 768-dim cosine similarity
482
- → RRF fusion — Reciprocal Rank Fusion (k=60)
483
- → Session diversify — max 3 results per session
484
- → Return top-K
485
- ```
486
-
487
- ---
488
-
489
- ## vs Original agentcache
490
-
491
- | | agentcache (Node.js) | agentcache-python |
492
- |---|---|---|
493
- | Runtime | Node.js 20+ | Python 3.10+ |
494
- | Storage | Dolt SQL (git-versioned MySQL) | SQLite WAL (single file) |
495
- | Engine dependency | iii-engine (separate binary) | None — just Flask |
496
- | Embeddings | 6 providers + local `@xenova/transformers` | Gemini 768-dim |
497
- | MCP tools | 53 | 31 |
498
- | REST endpoints | 128 | ~50 |
499
- | Deploy | npm, Docker, fly.io, Railway, Render | Docker, HuggingFace Spaces |
500
- | Cold boot | ~7s (iii engine warm-up) | <2s |
501
- | Database size | ~232MB (417 Dolt chunk files) | ~20MB (single `.db` file) |
502
- | Setup | `npm install -g @agentcache/agentcache` | `pip install -r requirements.txt` |
503
-
504
- Choose the Python version for: simpler setup, HF Space deployment, single-file database, no Node.js, or Python ecosystem integration.
505
-
506
- Choose the Node.js version for: the full 53-tool MCP surface, iii-engine observability, production multi-agent deployments, or the full auto-hook suite.
507
-
508
- ---
509
-
510
- ## Contributing
511
-
512
- See [CONTRIBUTING.md](CONTRIBUTING.md). Issues and PRs welcome.
513
-
514
- Priority areas: test coverage, additional embedding providers, more agent hook scripts.
515
-
516
- ---
517
-
518
- ## License
519
-
520
- Apache-2.0 — see [LICENSE](LICENSE).
 
1
+ Metadata-Version: 2.4
2
  Name: agentcache
3
  Version: 0.9.8
4
  Summary: A Python REST + WebSocket + MCP cache server for AI agents, backed by SQLite
 
35
  Requires-Dist: twine>=5.0.0; extra == "dev"
36
  Provides-Extra: local-embeddings
37
  Requires-Dist: sentence-transformers>=2.7.0; extra == "local-embeddings"
38
+ Dynamic: license-file
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/agentcache.egg-info/SOURCES.txt CHANGED
@@ -1,5 +1,4 @@
1
  LICENSE
2
- README.md
3
  pyproject.toml
4
  src/agentcache/__init__.py
5
  src/agentcache/app.py
@@ -26,6 +25,9 @@ src/agentcache/cache/health.py
26
  src/agentcache/cache/observe.py
27
  src/agentcache/cache/remember.py
28
  src/agentcache/cache/timeline.py
 
 
 
29
  src/agentcache/routes/__init__.py
30
  src/agentcache/routes/graph.py
31
  src/agentcache/routes/health.py
@@ -42,6 +44,8 @@ src/agentcache/viewer/favicon.svg
42
  src/agentcache/viewer/index.html
43
  tests/test_api.py
44
  tests/test_auth.py
 
 
45
  tests/test_context.py
46
  tests/test_debounce.py
47
  tests/test_folder_graph_build.py
@@ -56,4 +60,5 @@ tests/test_properties.py
56
  tests/test_remember.py
57
  tests/test_route_regressions.py
58
  tests/test_search.py
 
59
  tests/test_timeline.py
 
1
  LICENSE
 
2
  pyproject.toml
3
  src/agentcache/__init__.py
4
  src/agentcache/app.py
 
25
  src/agentcache/cache/observe.py
26
  src/agentcache/cache/remember.py
27
  src/agentcache/cache/timeline.py
28
+ src/agentcache/core/__init__.py
29
+ src/agentcache/core/kv_scopes.py
30
+ src/agentcache/core/search_service.py
31
  src/agentcache/routes/__init__.py
32
  src/agentcache/routes/graph.py
33
  src/agentcache/routes/health.py
 
44
  src/agentcache/viewer/index.html
45
  tests/test_api.py
46
  tests/test_auth.py
47
+ tests/test_auto_forget.py
48
+ tests/test_cli_context.py
49
  tests/test_context.py
50
  tests/test_debounce.py
51
  tests/test_folder_graph_build.py
 
60
  tests/test_remember.py
61
  tests/test_route_regressions.py
62
  tests/test_search.py
63
+ tests/test_security.py
64
  tests/test_timeline.py
src/agentcache/__init__.py CHANGED
@@ -6,13 +6,10 @@ __version__ = "0.9.8"
6
 
7
  from .app import create_app
8
  from .connect import run_connect
 
9
  from .db import StateKV
10
- from .functions import (
11
  folder_graph_build,
12
- folder_observe,
13
- folder_search,
14
- folder_timeline,
15
- forget,
16
  health_check,
17
  remember,
18
  )
@@ -22,11 +19,11 @@ __all__ = [
22
  "create_app",
23
  "StateKV",
24
  "run_connect",
25
- "folder_observe",
26
- "folder_search",
27
- "folder_timeline",
 
28
  "folder_graph_build",
29
  "remember",
30
- "forget",
31
  "health_check",
32
  ]
 
6
 
7
  from .app import create_app
8
  from .connect import run_connect
9
+ from .core import KV, ObservationEvents, ObservationStore, SearchService
10
  from .db import StateKV
11
+ from .legacy import (
12
  folder_graph_build,
 
 
 
 
13
  health_check,
14
  remember,
15
  )
 
19
  "create_app",
20
  "StateKV",
21
  "run_connect",
22
+ "KV",
23
+ "ObservationStore",
24
+ "ObservationEvents",
25
+ "SearchService",
26
  "folder_graph_build",
27
  "remember",
 
28
  "health_check",
29
  ]
src/agentcache/app.py CHANGED
@@ -13,7 +13,7 @@ import sys
13
  from flask import Flask, request, send_from_directory
14
  from flask_sock import Sock
15
 
16
- from . import functions
17
 
18
  # Prevent double-import of app when run directly as __main__
19
  if __name__ == "__main__":
@@ -41,26 +41,30 @@ def _load_env() -> None:
41
 
42
  _load_env()
43
 
44
- # Module-level globals — set once by create_app(), read by blueprints via `import app`
45
  kv = None
46
  embedding_provider = None
47
- persistence = None
 
 
48
 
49
 
50
  def init_services() -> tuple:
51
- """Initialise database, embedding provider, and index persistence."""
52
- global kv, embedding_provider, persistence
53
  if kv is not None:
54
  return kv, embedding_provider, persistence
55
 
56
- from . import functions
57
  from . import search as search_mod
 
 
58
  from .db import StateKV
 
59
 
60
  # 1. DB
61
  kv = StateKV()
62
 
63
- # 2. Embedding provider — auto-select by priority (D5.3):
64
  # GEMINI_API_KEY → OPENAI_API_KEY → AGENTCACHE_LOCAL_EMBEDDING_MODEL → BM25-only
65
  api_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
66
  openai_key = os.getenv("OPENAI_API_KEY")
@@ -71,7 +75,6 @@ def init_services() -> tuple:
71
  if api_key:
72
  try:
73
  embedding_provider = search_mod.GeminiEmbeddingProvider(api_key)
74
- functions.set_embedding_provider(embedding_provider)
75
  print(
76
  f"[search] Embedding provider active: gemini ({embedding_provider.dimensions} dims)"
77
  )
@@ -80,7 +83,6 @@ def init_services() -> tuple:
80
  elif openai_key:
81
  try:
82
  embedding_provider = search_mod.OpenAIEmbeddingProvider(openai_key)
83
- functions.set_embedding_provider(embedding_provider)
84
  print(
85
  f"[search] Embedding provider active: openai ({embedding_provider.dimensions} dims)"
86
  )
@@ -89,7 +91,6 @@ def init_services() -> tuple:
89
  elif local_model:
90
  try:
91
  embedding_provider = search_mod.SentenceTransformerProvider(local_model)
92
- functions.set_embedding_provider(embedding_provider)
93
  print(
94
  f"[search] Embedding provider active: sentence-transformers/{local_model} ({embedding_provider.dimensions} dims)"
95
  )
@@ -100,22 +101,27 @@ def init_services() -> tuple:
100
  else:
101
  print("[search] No embedding API key found — running in BM25-only mode.")
102
 
103
- # 3. Index persistence use embedding_provider variable set above
104
- has_vector = embedding_provider is not None
105
- persistence = functions.IndexPersistence(
106
- kv,
107
- functions._bm25_index,
108
- functions._vector_index if has_vector else None,
109
  )
110
- functions.set_index_persistence(persistence)
111
- loaded = persistence.load()
 
112
  print(
113
  f"[persistence] Load results: BM25={loaded['bm25']}, Vector={loaded['vector']}"
114
  )
115
 
116
- # Backfill coordinate lookup index if missing/incomplete
 
 
 
117
  try:
118
- functions.backfill_obs_lookup_if_needed(kv)
 
119
  except Exception as e:
120
  print(f"[db] Warning backfilling obs_lookup: {e}")
121
 
@@ -136,6 +142,9 @@ def create_app() -> Flask:
136
 
137
  # 4. Flask app + blueprints
138
  flask_app = Flask(__name__)
 
 
 
139
  from werkzeug.middleware.proxy_fix import ProxyFix
140
 
141
  flask_app.wsgi_app = ProxyFix(
@@ -143,7 +152,9 @@ def create_app() -> Flask:
143
  )
144
  from .routes import register_blueprints
145
 
146
- register_blueprints(flask_app)
 
 
147
 
148
  # 5. WebSocket broadcaster
149
  sock = Sock(flask_app)
@@ -176,7 +187,10 @@ def create_app() -> Flask:
176
  except Exception:
177
  _ws_clients.discard(ws)
178
 
179
- functions.set_stream_broadcaster(_broadcast)
 
 
 
180
 
181
  # 6. Viewer static routes
182
  from importlib.resources import files
 
13
  from flask import Flask, request, send_from_directory
14
  from flask_sock import Sock
15
 
16
+ from . import legacy
17
 
18
  # Prevent double-import of app when run directly as __main__
19
  if __name__ == "__main__":
 
41
 
42
  _load_env()
43
 
44
+ # Module-level singletons — set once by init_services(), read by blueprints and workers.
45
  kv = None
46
  embedding_provider = None
47
+ persistence = None # kept for backward compat with workers; use search_service instead
48
+ search_service = None # SearchService instance
49
+ observation_store = None # ObservationStore instance
50
 
51
 
52
  def init_services() -> tuple:
53
+ """Initialise database, SearchService, ObservationStore, and legacy persistence shim."""
54
+ global kv, embedding_provider, persistence, search_service, observation_store
55
  if kv is not None:
56
  return kv, embedding_provider, persistence
57
 
 
58
  from . import search as search_mod
59
+ from .core.observation_store import ObservationEvents, ObservationStore
60
+ from .core.search_service import SearchService
61
  from .db import StateKV
62
+ from .search import SearchIndex, VectorIndex
63
 
64
  # 1. DB
65
  kv = StateKV()
66
 
67
+ # 2. Embedding provider — auto-select by priority:
68
  # GEMINI_API_KEY → OPENAI_API_KEY → AGENTCACHE_LOCAL_EMBEDDING_MODEL → BM25-only
69
  api_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
70
  openai_key = os.getenv("OPENAI_API_KEY")
 
75
  if api_key:
76
  try:
77
  embedding_provider = search_mod.GeminiEmbeddingProvider(api_key)
 
78
  print(
79
  f"[search] Embedding provider active: gemini ({embedding_provider.dimensions} dims)"
80
  )
 
83
  elif openai_key:
84
  try:
85
  embedding_provider = search_mod.OpenAIEmbeddingProvider(openai_key)
 
86
  print(
87
  f"[search] Embedding provider active: openai ({embedding_provider.dimensions} dims)"
88
  )
 
91
  elif local_model:
92
  try:
93
  embedding_provider = search_mod.SentenceTransformerProvider(local_model)
 
94
  print(
95
  f"[search] Embedding provider active: sentence-transformers/{local_model} ({embedding_provider.dimensions} dims)"
96
  )
 
101
  else:
102
  print("[search] No embedding API key found — running in BM25-only mode.")
103
 
104
+ # 3. Construct SearchService and ObservationStore with injected dependencies.
105
+ bm25 = SearchIndex()
106
+ vector = VectorIndex() if embedding_provider is not None else None
107
+ search_service = SearchService(bm25, vector, embedding_provider, kv)
108
+ observation_store = ObservationStore(
109
+ kv, search_service=search_service, events=ObservationEvents()
110
  )
111
+
112
+ # Load persisted indexes.
113
+ loaded = search_service.load_persisted()
114
  print(
115
  f"[persistence] Load results: BM25={loaded['bm25']}, Vector={loaded['vector']}"
116
  )
117
 
118
+ # Keep persistence reference for workers backward compat.
119
+ persistence = search_service._persistence
120
+
121
+ # Backfill coordinate lookup index if missing/incomplete.
122
  try:
123
+ if observation_store is not None:
124
+ observation_store.backfill_lookup()
125
  except Exception as e:
126
  print(f"[db] Warning backfilling obs_lookup: {e}")
127
 
 
142
 
143
  # 4. Flask app + blueprints
144
  flask_app = Flask(__name__)
145
+ flask_app.extensions["observation_store"] = observation_store
146
+ flask_app.extensions["search_service"] = search_service
147
+
148
  from werkzeug.middleware.proxy_fix import ProxyFix
149
 
150
  flask_app.wsgi_app = ProxyFix(
 
152
  )
153
  from .routes import register_blueprints
154
 
155
+ register_blueprints(
156
+ flask_app, observation_store=observation_store, search_service=search_service
157
+ )
158
 
159
  # 5. WebSocket broadcaster
160
  sock = Sock(flask_app)
 
187
  except Exception:
188
  _ws_clients.discard(ws)
189
 
190
+ legacy.set_stream_broadcaster(_broadcast)
191
+
192
+ if observation_store and observation_store.events:
193
+ observation_store.events.on_added.append(_broadcast)
194
 
195
  # 6. Viewer static routes
196
  from importlib.resources import files
src/agentcache/cache/__init__.py CHANGED
@@ -18,7 +18,8 @@ callers may import from this package (A2.2).
18
  # Each name is imported lazily via a try/except so missing items don't break
19
  # the package import on partially-initialised environments.
20
  # ---------------------------------------------------------------------------
21
- from .. import functions as _fn # noqa: E402
 
22
  from .context import context, export_data, rebuild_index
23
  from .graph import folder_graph_build
24
  from .health import auto_forget, health_check
@@ -31,14 +32,10 @@ from .observe import (
31
  from .remember import forget, jaccard_similarity, remember
32
  from .timeline import folder_search, folder_timeline
33
 
34
- KV = _fn.KV
35
  generate_id = _fn.generate_id
36
  fingerprint_id = _fn.fingerprint_id
37
  normalize_folder_path = _fn.normalize_folder_path
38
  validate_agent_id = _fn.validate_agent_id
39
- IndexPersistence = _fn.IndexPersistence
40
- set_embedding_provider = _fn.set_embedding_provider
41
- set_index_persistence = _fn.set_index_persistence
42
  set_stream_broadcaster = _fn.set_stream_broadcaster
43
  get_agent_id = _fn.get_agent_id
44
  record_audit = _fn.record_audit
@@ -72,15 +69,12 @@ __all__ = [
72
  # health.py
73
  "health_check",
74
  "auto_forget",
75
- # functions.py shims (A2.2)
76
  "KV",
77
  "generate_id",
78
  "fingerprint_id",
79
  "normalize_folder_path",
80
  "validate_agent_id",
81
- "IndexPersistence",
82
- "set_embedding_provider",
83
- "set_index_persistence",
84
  "set_stream_broadcaster",
85
  "get_agent_id",
86
  "record_audit",
 
18
  # Each name is imported lazily via a try/except so missing items don't break
19
  # the package import on partially-initialised environments.
20
  # ---------------------------------------------------------------------------
21
+ from .. import legacy as _fn # noqa: E402
22
+ from ..core import KV
23
  from .context import context, export_data, rebuild_index
24
  from .graph import folder_graph_build
25
  from .health import auto_forget, health_check
 
32
  from .remember import forget, jaccard_similarity, remember
33
  from .timeline import folder_search, folder_timeline
34
 
 
35
  generate_id = _fn.generate_id
36
  fingerprint_id = _fn.fingerprint_id
37
  normalize_folder_path = _fn.normalize_folder_path
38
  validate_agent_id = _fn.validate_agent_id
 
 
 
39
  set_stream_broadcaster = _fn.set_stream_broadcaster
40
  get_agent_id = _fn.get_agent_id
41
  record_audit = _fn.record_audit
 
69
  # health.py
70
  "health_check",
71
  "auto_forget",
72
+ # legacy.py shims
73
  "KV",
74
  "generate_id",
75
  "fingerprint_id",
76
  "normalize_folder_path",
77
  "validate_agent_id",
 
 
 
78
  "set_stream_broadcaster",
79
  "get_agent_id",
80
  "record_audit",
src/agentcache/cache/context.py CHANGED
@@ -11,7 +11,7 @@ from __future__ import annotations
11
 
12
  from typing import Any, Dict
13
 
14
- from .. import functions as _fn
15
  from ..db import StateKV
16
 
17
 
 
11
 
12
  from typing import Any, Dict
13
 
14
+ from .. import legacy as _fn
15
  from ..db import StateKV
16
 
17
 
src/agentcache/cache/graph.py CHANGED
@@ -9,7 +9,7 @@ from __future__ import annotations
9
 
10
  from typing import Any, Dict
11
 
12
- from .. import functions as _fn
13
  from ..db import StateKV
14
 
15
 
 
9
 
10
  from typing import Any, Dict
11
 
12
+ from .. import legacy as _fn
13
  from ..db import StateKV
14
 
15
 
src/agentcache/cache/health.py CHANGED
@@ -10,7 +10,7 @@ from __future__ import annotations
10
 
11
  from typing import Any, Dict
12
 
13
- from .. import functions as _fn
14
  from ..db import StateKV
15
 
16
 
 
10
 
11
  from typing import Any, Dict
12
 
13
+ from .. import legacy as _fn
14
  from ..db import StateKV
15
 
16
 
src/agentcache/cache/observe.py CHANGED
@@ -12,7 +12,7 @@ from __future__ import annotations
12
 
13
  from typing import Any, Dict
14
 
15
- from .. import functions as _fn # access module-level globals (_bm25_index, etc.)
16
  from ..db import StateKV
17
 
18
  # Re-export for backward compatibility
 
12
 
13
  from typing import Any, Dict
14
 
15
+ from .. import legacy as _fn # access module-level globals (_bm25_index, etc.)
16
  from ..db import StateKV
17
 
18
  # Re-export for backward compatibility
src/agentcache/cache/remember.py CHANGED
@@ -11,7 +11,7 @@ from __future__ import annotations
11
 
12
  from typing import Any, Dict
13
 
14
- from .. import functions as _fn
15
  from ..db import StateKV
16
 
17
 
 
11
 
12
  from typing import Any, Dict
13
 
14
+ from .. import legacy as _fn
15
  from ..db import StateKV
16
 
17
 
src/agentcache/cache/timeline.py CHANGED
@@ -10,7 +10,7 @@ from __future__ import annotations
10
 
11
  from typing import Any, Dict, List, Optional
12
 
13
- from .. import functions as _fn
14
  from ..db import StateKV
15
 
16
 
 
10
 
11
  from typing import Any, Dict, List, Optional
12
 
13
+ from .. import legacy as _fn
14
  from ..db import StateKV
15
 
16
 
src/agentcache/cli.py CHANGED
@@ -28,7 +28,7 @@ def cmd_serve(args) -> None:
28
  def cmd_migrate(args) -> None:
29
  """Run session → folder migration."""
30
  from .db import StateKV
31
- from .functions import migrate_sessions_to_folders
32
 
33
  kv = StateKV()
34
  result = migrate_sessions_to_folders(kv, dry_run=args.dry_run)
@@ -54,7 +54,7 @@ def cmd_migrate(args) -> None:
54
  def cmd_export(args) -> None:
55
  """Export all data as JSON."""
56
  from .db import StateKV
57
- from .functions import export_data
58
 
59
  kv = StateKV()
60
  data = export_data(kv, {})
@@ -113,7 +113,8 @@ def cmd_context(args) -> None:
113
  import time
114
 
115
  from .app import init_services
116
- from .functions import KV, normalize_folder_path
 
117
 
118
  # 1. Resolve folder and agent
119
  cwd = os.getcwd()
 
28
  def cmd_migrate(args) -> None:
29
  """Run session → folder migration."""
30
  from .db import StateKV
31
+ from .legacy import migrate_sessions_to_folders
32
 
33
  kv = StateKV()
34
  result = migrate_sessions_to_folders(kv, dry_run=args.dry_run)
 
54
  def cmd_export(args) -> None:
55
  """Export all data as JSON."""
56
  from .db import StateKV
57
+ from .legacy import export_data
58
 
59
  kv = StateKV()
60
  data = export_data(kv, {})
 
113
  import time
114
 
115
  from .app import init_services
116
+ from .core import KV
117
+ from .core.observation_store import normalize_folder_path
118
 
119
  # 1. Resolve folder and agent
120
  cwd = os.getcwd()
src/agentcache/core/__init__.py CHANGED
@@ -10,5 +10,10 @@ from .kv_scopes import KV
10
  from .observation_store import ObservationEvents, ObservationStore
11
  from .search_service import IndexPersistence, SearchService
12
 
13
- __all__ = ["KV", "SearchService", "IndexPersistence", "ObservationStore", "ObservationEvents"]
14
-
 
 
 
 
 
 
10
  from .observation_store import ObservationEvents, ObservationStore
11
  from .search_service import IndexPersistence, SearchService
12
 
13
+ __all__ = [
14
+ "KV",
15
+ "SearchService",
16
+ "IndexPersistence",
17
+ "ObservationStore",
18
+ "ObservationEvents",
19
+ ]
src/agentcache/core/kv_scopes.py CHANGED
@@ -5,8 +5,6 @@ Single source of truth for every SQLite scope string used in the system.
5
  Import this module wherever a KV scope key is needed — routes, stores, workers.
6
  """
7
 
8
- from typing import Optional
9
-
10
 
11
  class KV:
12
  # ---- Folder memory scopes ----
 
5
  Import this module wherever a KV scope key is needed — routes, stores, workers.
6
  """
7
 
 
 
8
 
9
  class KV:
10
  # ---- Folder memory scopes ----
src/agentcache/core/observation_store.py CHANGED
@@ -104,8 +104,7 @@ class ObservationStore:
104
  folder_path = normalize_folder_path(folder_path_raw)
105
  agent_id = validate_agent_id(agent_id_raw)
106
 
107
- from ..legacy import strip_private_data, infer_type, extract_files
108
-
109
 
110
  safe_text = strip_private_data(text_raw)[:4000]
111
 
@@ -116,7 +115,11 @@ class ObservationStore:
116
  dedup_lock = self._get_dedup_lock(folder_path, agent_id)
117
  with dedup_lock:
118
  existing_dedup = self.kv.get(KV.obs_dedup(folder_path, agent_id), dedup_fp)
119
- if existing_dedup and isinstance(existing_dedup, dict) and existing_dedup.get("obsId"):
 
 
 
 
120
  return {"observationId": existing_dedup["obsId"], "deduplicated": True}
121
 
122
  max_obs = int(os.getenv("MAX_OBS_PER_FOLDER", "2000"))
@@ -267,7 +270,6 @@ class ObservationStore:
267
  total_kept = 0
268
 
269
  for pair in pairs:
270
-
271
  fp = pair["folderPath"]
272
  aid = pair["agentId"]
273
  all_obs = self.kv.list(KV.folder_obs(fp, aid))
@@ -292,7 +294,9 @@ class ObservationStore:
292
  duplicates.append(obs["id"])
293
 
294
  if duplicates:
295
- self.forget({"folderPath": fp, "agentId": aid, "observationIds": duplicates})
 
 
296
  total_removed += len(duplicates)
297
 
298
  total_kept += len(fingerprint_map)
@@ -305,7 +309,6 @@ class ObservationStore:
305
  {"obsId": obs["id"], "timestamp": obs.get("timestamp", "")},
306
  )
307
 
308
-
309
  return {
310
  "success": True,
311
  "deduplicated": total_removed,
@@ -323,7 +326,6 @@ class ObservationStore:
323
  deleted = 0
324
  deleted_mem_ids: List[str] = []
325
  deleted_obs_ids: List[str] = []
326
- deleted_session = False
327
 
328
  if memory_id:
329
  mem = self.kv.get(KV.memories, memory_id)
@@ -351,8 +353,6 @@ class ObservationStore:
351
  if "observationIds" in data and data["observationIds"] is not None:
352
  partial_deleted = 0
353
  for oid in obs_ids:
354
-
355
-
356
  obs = self.kv.get(obs_scope, oid)
357
  existed = self.kv.delete(obs_scope, oid)
358
  if existed:
@@ -385,7 +385,9 @@ class ObservationStore:
385
  try:
386
  cb(deleted_obs_ids)
387
  except Exception as ex:
388
- print(f"[observation_store] Error in on_deleted callback: {ex}")
 
 
389
  else:
390
  all_obs = self.kv.list(obs_scope)
391
  for obs in all_obs:
@@ -411,7 +413,9 @@ class ObservationStore:
411
  try:
412
  cb(fp, aid)
413
  except Exception as ex:
414
- print(f"[observation_store] Error in on_folder_deleted callback: {ex}")
 
 
415
 
416
  if session_id and obs_ids:
417
  for oid in obs_ids:
@@ -454,7 +458,6 @@ class ObservationStore:
454
  deleted += 1
455
  self.kv.delete(KV.sessions, session_id)
456
  self.kv.delete(KV.summaries, session_id)
457
- deleted_session = True
458
  deleted += 2
459
 
460
  if deleted > 0 and self.search_service:
@@ -474,7 +477,9 @@ class ObservationStore:
474
  index_entries = self.kv.list(KV.folders)
475
 
476
  if folder_path is not None:
477
- index_entries = [e for e in index_entries if e.get("folderPath") == folder_path]
 
 
478
 
479
  if agent_id is not None:
480
  index_entries = [e for e in index_entries if e.get("agentId") == agent_id]
@@ -568,5 +573,3 @@ class ObservationStore:
568
  self.search_service.schedule_persist()
569
 
570
  return total_indexed
571
-
572
-
 
104
  folder_path = normalize_folder_path(folder_path_raw)
105
  agent_id = validate_agent_id(agent_id_raw)
106
 
107
+ from ..legacy import extract_files, infer_type, strip_private_data
 
108
 
109
  safe_text = strip_private_data(text_raw)[:4000]
110
 
 
115
  dedup_lock = self._get_dedup_lock(folder_path, agent_id)
116
  with dedup_lock:
117
  existing_dedup = self.kv.get(KV.obs_dedup(folder_path, agent_id), dedup_fp)
118
+ if (
119
+ existing_dedup
120
+ and isinstance(existing_dedup, dict)
121
+ and existing_dedup.get("obsId")
122
+ ):
123
  return {"observationId": existing_dedup["obsId"], "deduplicated": True}
124
 
125
  max_obs = int(os.getenv("MAX_OBS_PER_FOLDER", "2000"))
 
270
  total_kept = 0
271
 
272
  for pair in pairs:
 
273
  fp = pair["folderPath"]
274
  aid = pair["agentId"]
275
  all_obs = self.kv.list(KV.folder_obs(fp, aid))
 
294
  duplicates.append(obs["id"])
295
 
296
  if duplicates:
297
+ self.forget(
298
+ {"folderPath": fp, "agentId": aid, "observationIds": duplicates}
299
+ )
300
  total_removed += len(duplicates)
301
 
302
  total_kept += len(fingerprint_map)
 
309
  {"obsId": obs["id"], "timestamp": obs.get("timestamp", "")},
310
  )
311
 
 
312
  return {
313
  "success": True,
314
  "deduplicated": total_removed,
 
326
  deleted = 0
327
  deleted_mem_ids: List[str] = []
328
  deleted_obs_ids: List[str] = []
 
329
 
330
  if memory_id:
331
  mem = self.kv.get(KV.memories, memory_id)
 
353
  if "observationIds" in data and data["observationIds"] is not None:
354
  partial_deleted = 0
355
  for oid in obs_ids:
 
 
356
  obs = self.kv.get(obs_scope, oid)
357
  existed = self.kv.delete(obs_scope, oid)
358
  if existed:
 
385
  try:
386
  cb(deleted_obs_ids)
387
  except Exception as ex:
388
+ print(
389
+ f"[observation_store] Error in on_deleted callback: {ex}"
390
+ )
391
  else:
392
  all_obs = self.kv.list(obs_scope)
393
  for obs in all_obs:
 
413
  try:
414
  cb(fp, aid)
415
  except Exception as ex:
416
+ print(
417
+ f"[observation_store] Error in on_folder_deleted callback: {ex}"
418
+ )
419
 
420
  if session_id and obs_ids:
421
  for oid in obs_ids:
 
458
  deleted += 1
459
  self.kv.delete(KV.sessions, session_id)
460
  self.kv.delete(KV.summaries, session_id)
 
461
  deleted += 2
462
 
463
  if deleted > 0 and self.search_service:
 
477
  index_entries = self.kv.list(KV.folders)
478
 
479
  if folder_path is not None:
480
+ index_entries = [
481
+ e for e in index_entries if e.get("folderPath") == folder_path
482
+ ]
483
 
484
  if agent_id is not None:
485
  index_entries = [e for e in index_entries if e.get("agentId") == agent_id]
 
573
  self.search_service.schedule_persist()
574
 
575
  return total_indexed
 
 
src/agentcache/core/search_service.py CHANGED
@@ -8,7 +8,6 @@ MemoryStore, routes) call this service instead of touching index globals.
8
  from __future__ import annotations
9
 
10
  import json
11
- import math
12
  import sqlite3
13
  import threading
14
  import time
@@ -24,7 +23,9 @@ class IndexPersistence:
24
 
25
  DEBOUNCE_SECONDS: float = 5.0
26
 
27
- def __init__(self, kv: Any, bm25: SearchIndex, vector: Optional[VectorIndex] = None):
 
 
28
  self.kv = kv
29
  self.bm25 = bm25
30
  self.vector = vector
@@ -149,14 +150,20 @@ class IndexPersistence:
149
  cursor.close()
150
  except sqlite3.OperationalError as ex:
151
  err_msg = str(ex).lower()
152
- if ("locked" in err_msg or "busy" in err_msg) and attempt < max_retries - 1:
 
 
153
  time.sleep(delay)
154
  delay *= 2
155
  continue
156
- print(f"[index persistence] error cleaning up obsolete shards: {ex}")
 
 
157
  break
158
  except Exception as ex:
159
- print(f"[index persistence] error cleaning up obsolete shards: {ex}")
 
 
160
  break
161
 
162
  if (
@@ -372,7 +379,11 @@ class SearchService:
372
  results.append(result)
373
  seen_ids.add(obs_id)
374
  # Lazy backfill lookup
375
- active_kv.set(KV.obs_lookup, obs_id, {"folderPath": fp, "agentId": aid})
 
 
 
 
376
  found = True
377
  break
378
  if found:
 
8
  from __future__ import annotations
9
 
10
  import json
 
11
  import sqlite3
12
  import threading
13
  import time
 
23
 
24
  DEBOUNCE_SECONDS: float = 5.0
25
 
26
+ def __init__(
27
+ self, kv: Any, bm25: SearchIndex, vector: Optional[VectorIndex] = None
28
+ ):
29
  self.kv = kv
30
  self.bm25 = bm25
31
  self.vector = vector
 
150
  cursor.close()
151
  except sqlite3.OperationalError as ex:
152
  err_msg = str(ex).lower()
153
+ if (
154
+ "locked" in err_msg or "busy" in err_msg
155
+ ) and attempt < max_retries - 1:
156
  time.sleep(delay)
157
  delay *= 2
158
  continue
159
+ print(
160
+ f"[index persistence] error cleaning up obsolete shards: {ex}"
161
+ )
162
  break
163
  except Exception as ex:
164
+ print(
165
+ f"[index persistence] error cleaning up obsolete shards: {ex}"
166
+ )
167
  break
168
 
169
  if (
 
379
  results.append(result)
380
  seen_ids.add(obs_id)
381
  # Lazy backfill lookup
382
+ active_kv.set(
383
+ KV.obs_lookup,
384
+ obs_id,
385
+ {"folderPath": fp, "agentId": aid},
386
+ )
387
  found = True
388
  break
389
  if found:
src/agentcache/functions.py DELETED
The diff for this file is too large to render. See raw diff
 
src/agentcache/legacy.py CHANGED
@@ -2,11 +2,9 @@
2
 
3
  import datetime
4
  import hashlib
5
-
6
  import json
7
  import os
8
  import re
9
- import sqlite3
10
  import threading
11
  import time
12
  import uuid
@@ -15,7 +13,6 @@ from typing import Any, Dict, List, Optional, Set, Tuple
15
  from .core.kv_scopes import KV # noqa: F401 re-exported for backward compat
16
  from .core.search_service import IndexPersistence # noqa: F401
17
  from .db import StateKV
18
- from .search import HybridSearch, SearchIndex, VectorIndex
19
 
20
  # =====================================================================
21
  # Global Variables / Module State
@@ -811,7 +808,8 @@ def observe(kv: StateKV, payload: Dict[str, Any]) -> Dict[str, Any]:
811
  if k in raw_for_synthetic:
812
  synthetic[k] = raw_for_synthetic[k]
813
  kv.set(KV.observations(session_id), obs_id, synthetic)
814
- if _search_service: _search_service.bm25.add(synthetic)
 
815
 
816
  comb_text = synthetic["title"] + " " + (synthetic.get("narrative") or "")
817
  vector_index_add_guarded(
@@ -854,9 +852,11 @@ def observe(kv: StateKV, payload: Dict[str, Any]) -> Dict[str, Any]:
854
 
855
  def _get_observation_store(kv: StateKV):
856
  from . import app as app_module
 
857
  if getattr(app_module, "observation_store", None) is not None:
858
  return app_module.observation_store
859
  from .core.observation_store import ObservationStore
 
860
  return ObservationStore(kv, search_service=_search_service)
861
 
862
 
@@ -876,7 +876,6 @@ def dedup_folder_observations(
876
  return store.dedup(folder_path_raw, agent_id_raw)
877
 
878
 
879
-
880
  # =====================================================================
881
  # Folder-Based Search (folder_search)
882
  # =====================================================================
@@ -918,7 +917,6 @@ def folder_timeline(
918
  )
919
 
920
 
921
-
922
  # =====================================================================
923
  # Memory System (Remember, Forget, Evolve)
924
  # =====================================================================
@@ -1052,7 +1050,6 @@ def forget(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
1052
  return store.forget(data)
1053
 
1054
 
1055
-
1056
  # =====================================================================
1057
  # Prompt Context Compilation System
1058
  # =====================================================================
@@ -1958,7 +1955,6 @@ def rebuild_index(kv: StateKV) -> int:
1958
  return store.rebuild_index()
1959
 
1960
 
1961
-
1962
  # =====================================================================
1963
  # Advanced Function Stubs / CRUD Operations
1964
  # =====================================================================
@@ -2540,7 +2536,8 @@ def auto_forget(kv: StateKV, dry_run: bool = False) -> Dict[str, Any]:
2540
  refs = kv.get(KV.imageRefs, ref) or 0
2541
  if refs > 0:
2542
  kv.set(KV.imageRefs, ref, refs - 1)
2543
- if _search_service: _search_service.remove(mem_id)
 
2544
 
2545
  # Commit evictions for session-based observations
2546
  for sid, obs_id in evicted_observations:
@@ -2578,7 +2575,8 @@ def auto_forget(kv: StateKV, dry_run: bool = False) -> Dict[str, Any]:
2578
  ).hexdigest()
2579
  kv.delete(KV.obs_dedup(fp, aid), dedup_fp)
2580
 
2581
- if _search_service: _search_service.remove(obs_id)
 
2582
 
2583
  pair_key = (fp, aid)
2584
  folder_deletes[pair_key] = folder_deletes.get(pair_key, 0) + 1
@@ -2729,7 +2727,6 @@ def health_check(kv: StateKV) -> Dict[str, Any]:
2729
  }
2730
 
2731
 
2732
-
2733
  def strip_xml_wrappers(raw: str) -> str:
2734
  if not raw:
2735
  return ""
@@ -3543,7 +3540,9 @@ def backfill_obs_lookup_if_needed(kv: StateKV) -> None:
3543
  store.backfill_lookup()
3544
 
3545
 
3546
- def verify_index_sync_on_boot(kv: StateKV, search_service: Optional[Any] = None) -> bool:
 
 
3547
  """Check if the search index size matches the database counts.
3548
  Returns True if in sync, False if a rebuild is needed.
3549
  """
@@ -3551,6 +3550,7 @@ def verify_index_sync_on_boot(kv: StateKV, search_service: Optional[Any] = None)
3551
  svc = search_service or _search_service
3552
  if svc is None:
3553
  from . import app as app_module
 
3554
  svc = getattr(app_module, "search_service", None)
3555
 
3556
  # 1. Total folder obs count
@@ -3577,4 +3577,3 @@ def verify_index_sync_on_boot(kv: StateKV, search_service: Optional[Any] = None)
3577
  except Exception as e:
3578
  print(f"[persistence] verify_index_sync_on_boot failed: {e}")
3579
  return False
3580
-
 
2
 
3
  import datetime
4
  import hashlib
 
5
  import json
6
  import os
7
  import re
 
8
  import threading
9
  import time
10
  import uuid
 
13
  from .core.kv_scopes import KV # noqa: F401 re-exported for backward compat
14
  from .core.search_service import IndexPersistence # noqa: F401
15
  from .db import StateKV
 
16
 
17
  # =====================================================================
18
  # Global Variables / Module State
 
808
  if k in raw_for_synthetic:
809
  synthetic[k] = raw_for_synthetic[k]
810
  kv.set(KV.observations(session_id), obs_id, synthetic)
811
+ if _search_service:
812
+ _search_service.bm25.add(synthetic)
813
 
814
  comb_text = synthetic["title"] + " " + (synthetic.get("narrative") or "")
815
  vector_index_add_guarded(
 
852
 
853
  def _get_observation_store(kv: StateKV):
854
  from . import app as app_module
855
+
856
  if getattr(app_module, "observation_store", None) is not None:
857
  return app_module.observation_store
858
  from .core.observation_store import ObservationStore
859
+
860
  return ObservationStore(kv, search_service=_search_service)
861
 
862
 
 
876
  return store.dedup(folder_path_raw, agent_id_raw)
877
 
878
 
 
879
  # =====================================================================
880
  # Folder-Based Search (folder_search)
881
  # =====================================================================
 
917
  )
918
 
919
 
 
920
  # =====================================================================
921
  # Memory System (Remember, Forget, Evolve)
922
  # =====================================================================
 
1050
  return store.forget(data)
1051
 
1052
 
 
1053
  # =====================================================================
1054
  # Prompt Context Compilation System
1055
  # =====================================================================
 
1955
  return store.rebuild_index()
1956
 
1957
 
 
1958
  # =====================================================================
1959
  # Advanced Function Stubs / CRUD Operations
1960
  # =====================================================================
 
2536
  refs = kv.get(KV.imageRefs, ref) or 0
2537
  if refs > 0:
2538
  kv.set(KV.imageRefs, ref, refs - 1)
2539
+ if _search_service:
2540
+ _search_service.remove(mem_id)
2541
 
2542
  # Commit evictions for session-based observations
2543
  for sid, obs_id in evicted_observations:
 
2575
  ).hexdigest()
2576
  kv.delete(KV.obs_dedup(fp, aid), dedup_fp)
2577
 
2578
+ if _search_service:
2579
+ _search_service.remove(obs_id)
2580
 
2581
  pair_key = (fp, aid)
2582
  folder_deletes[pair_key] = folder_deletes.get(pair_key, 0) + 1
 
2727
  }
2728
 
2729
 
 
2730
  def strip_xml_wrappers(raw: str) -> str:
2731
  if not raw:
2732
  return ""
 
3540
  store.backfill_lookup()
3541
 
3542
 
3543
+ def verify_index_sync_on_boot(
3544
+ kv: StateKV, search_service: Optional[Any] = None
3545
+ ) -> bool:
3546
  """Check if the search index size matches the database counts.
3547
  Returns True if in sync, False if a rebuild is needed.
3548
  """
 
3550
  svc = search_service or _search_service
3551
  if svc is None:
3552
  from . import app as app_module
3553
+
3554
  svc = getattr(app_module, "search_service", None)
3555
 
3556
  # 1. Total folder obs count
 
3577
  except Exception as e:
3578
  print(f"[persistence] verify_index_sync_on_boot failed: {e}")
3579
  return False
 
src/agentcache/replay_import.py CHANGED
@@ -219,7 +219,7 @@ def derive_crystal_and_lessons(
219
  compressed: List[Dict[str, Any]],
220
  first_prompt: str = None,
221
  ) -> None:
222
- from .functions import KV
223
 
224
  if not raw_obs:
225
  return
@@ -368,7 +368,8 @@ def find_jsonl_files(root: str, limit=200) -> Tuple[List[str], bool, int, bool]:
368
 
369
 
370
  def import_jsonl_data(kv, path: str = None, max_files: int = None) -> Dict[str, Any]:
371
- from .functions import KV, _bm25_index, build_synthetic_compression
 
372
 
373
  default_root = os.path.expanduser(os.path.join("~", ".claude", "projects"))
374
  raw_path = path or default_root
@@ -481,24 +482,12 @@ def import_jsonl_data(kv, path: str = None, max_files: int = None) -> Dict[str,
481
  }
482
  kv.set(KV.sessions, session["id"], session)
483
 
484
- from .functions import vector_index_add_guarded
485
-
486
  compressed = []
487
  for obs in parsed["observations"]:
488
  synthetic = build_synthetic_compression(obs)
489
  compressed.append(synthetic)
490
  kv.set(KV.observations(parsed["sessionId"]), obs["id"], synthetic)
491
 
492
- # Index
493
- _bm25_index.add(synthetic)
494
- comb_text = synthetic["title"] + " " + (synthetic.get("narrative") or "")
495
- vector_index_add_guarded(
496
- synthetic["id"],
497
- synthetic["sessionId"],
498
- comb_text,
499
- {"kind": "synthetic", "logId": synthetic["id"]},
500
- )
501
-
502
  observation_count += len(parsed["observations"])
503
  session_ids.append(parsed["sessionId"])
504
 
@@ -511,18 +500,9 @@ def import_jsonl_data(kv, path: str = None, max_files: int = None) -> Dict[str,
511
  first_prompt,
512
  )
513
 
514
- # Save the updated persistence state
515
- from . import functions
516
-
517
- if functions._index_persistence:
518
- try:
519
- functions._index_persistence.save()
520
- except Exception as e:
521
- print(f"[import-jsonl] Warning saving index persistence: {e}")
522
-
523
  # Audit trail
524
  try:
525
- from .functions import log_audit
526
 
527
  log_audit(
528
  kv,
 
219
  compressed: List[Dict[str, Any]],
220
  first_prompt: str = None,
221
  ) -> None:
222
+ from .core import KV
223
 
224
  if not raw_obs:
225
  return
 
368
 
369
 
370
  def import_jsonl_data(kv, path: str = None, max_files: int = None) -> Dict[str, Any]:
371
+ from .core import KV
372
+ from .legacy import build_synthetic_compression
373
 
374
  default_root = os.path.expanduser(os.path.join("~", ".claude", "projects"))
375
  raw_path = path or default_root
 
482
  }
483
  kv.set(KV.sessions, session["id"], session)
484
 
 
 
485
  compressed = []
486
  for obs in parsed["observations"]:
487
  synthetic = build_synthetic_compression(obs)
488
  compressed.append(synthetic)
489
  kv.set(KV.observations(parsed["sessionId"]), obs["id"], synthetic)
490
 
 
 
 
 
 
 
 
 
 
 
491
  observation_count += len(parsed["observations"])
492
  session_ids.append(parsed["sessionId"])
493
 
 
500
  first_prompt,
501
  )
502
 
 
 
 
 
 
 
 
 
 
503
  # Audit trail
504
  try:
505
+ from .legacy import log_audit
506
 
507
  log_audit(
508
  kv,
src/agentcache/routes/__init__.py CHANGED
@@ -4,31 +4,26 @@ Flask blueprints for agentmemory-python.
4
  Import and register all blueprints via register_blueprints(app).
5
  """
6
 
7
- from .graph import create_graph_bp
8
- from .health import create_health_bp
9
  from .mcp import mcp_bp
10
- from .memories import create_memories_bp
11
  from .migration import migration_bp
12
  from .observations import create_observations_bp, observations_bp
13
  from .search import search_bp
14
 
15
 
16
- def register_blueprints(app, observation_store=None, search_service=None, kv=None):
17
  """Register all route blueprints on a Flask application instance."""
18
  obs_bp = (
19
  create_observations_bp(observation_store)
20
  if observation_store
21
  else observations_bp
22
  )
23
- if kv is None and observation_store is not None:
24
- kv = observation_store.kv
25
  app.register_blueprint(obs_bp)
26
- app.register_blueprint(create_memories_bp(kv))
27
  app.register_blueprint(search_bp)
28
- app.register_blueprint(create_graph_bp(kv))
29
- app.register_blueprint(create_health_bp(kv))
30
  app.register_blueprint(mcp_bp)
31
  app.register_blueprint(migration_bp)
32
-
33
-
34
-
 
4
  Import and register all blueprints via register_blueprints(app).
5
  """
6
 
7
+ from .graph import graph_bp
8
+ from .health import health_bp
9
  from .mcp import mcp_bp
10
+ from .memories import memories_bp
11
  from .migration import migration_bp
12
  from .observations import create_observations_bp, observations_bp
13
  from .search import search_bp
14
 
15
 
16
+ def register_blueprints(app, observation_store=None, search_service=None):
17
  """Register all route blueprints on a Flask application instance."""
18
  obs_bp = (
19
  create_observations_bp(observation_store)
20
  if observation_store
21
  else observations_bp
22
  )
 
 
23
  app.register_blueprint(obs_bp)
24
+ app.register_blueprint(memories_bp)
25
  app.register_blueprint(search_bp)
26
+ app.register_blueprint(graph_bp)
27
+ app.register_blueprint(health_bp)
28
  app.register_blueprint(mcp_bp)
29
  app.register_blueprint(migration_bp)
 
 
 
src/agentcache/routes/graph.py CHANGED
@@ -22,6 +22,7 @@ def create_graph_bp(kv=None):
22
  if kv is not None:
23
  return kv
24
  from .. import app as app_module
 
25
  return app_module.kv
26
 
27
  # ------------------------------------------------------------------
@@ -81,5 +82,4 @@ def create_graph_bp(kv=None):
81
  return bp
82
 
83
 
84
-
85
  graph_bp = create_graph_bp(None)
 
22
  if kv is not None:
23
  return kv
24
  from .. import app as app_module
25
+
26
  return app_module.kv
27
 
28
  # ------------------------------------------------------------------
 
82
  return bp
83
 
84
 
 
85
  graph_bp = create_graph_bp(None)
src/agentcache/routes/health.py CHANGED
@@ -25,6 +25,7 @@ def create_health_bp(kv=None, embedding_provider=None):
25
  if kv is not None:
26
  return kv
27
  from .. import app as app_module
 
28
  return app_module.kv
29
 
30
  # ------------------------------------------------------------------
@@ -90,7 +91,6 @@ def create_health_bp(kv=None, embedding_provider=None):
90
  res = query_audit(_get_kv(), {"operation": op, "limit": limit})
91
  return jsonify({"entries": res, "success": True}), 200
92
 
93
-
94
  # ------------------------------------------------------------------
95
  # GET /agentcache/config/flags
96
  # ------------------------------------------------------------------
 
25
  if kv is not None:
26
  return kv
27
  from .. import app as app_module
28
+
29
  return app_module.kv
30
 
31
  # ------------------------------------------------------------------
 
91
  res = query_audit(_get_kv(), {"operation": op, "limit": limit})
92
  return jsonify({"entries": res, "success": True}), 200
93
 
 
94
  # ------------------------------------------------------------------
95
  # GET /agentcache/config/flags
96
  # ------------------------------------------------------------------
src/agentcache/routes/mcp.py CHANGED
@@ -15,7 +15,6 @@ from flask import Blueprint, jsonify, request
15
  from .. import legacy
16
  from ..core import KV
17
 
18
-
19
  mcp_bp = Blueprint("mcp", __name__)
20
 
21
 
@@ -52,7 +51,6 @@ def _get_observation_store():
52
  return app_module.observation_store
53
 
54
 
55
-
56
  def _datetime_now_iso() -> str:
57
  return (
58
  datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
@@ -401,7 +399,11 @@ def mcp_tools_call():
401
  search_svc = _get_search_service()
402
  if search_svc is not None:
403
  res = search_svc.search(
404
- query=q, limit=limit, folder_path=folder_path, agent_id=agent_id, kv=kv
 
 
 
 
405
  )
406
  else:
407
  res = []
@@ -440,7 +442,11 @@ def mcp_tools_call():
440
  search_svc = _get_search_service()
441
  if search_svc is not None:
442
  res = search_svc.search(
443
- query=q, limit=limit, folder_path=folder_path, agent_id=agent_id, kv=kv
 
 
 
 
444
  )
445
  else:
446
  res = []
@@ -475,7 +481,6 @@ def mcp_tools_call():
475
  res = {"success": False, "deleted": 0}
476
  text_out = json.dumps(res, indent=2)
477
 
478
-
479
  elif name in ("cache_export", "memory_export"):
480
  res = legacy.export_data(kv, {})
481
  text_out = json.dumps(res, indent=2)
 
15
  from .. import legacy
16
  from ..core import KV
17
 
 
18
  mcp_bp = Blueprint("mcp", __name__)
19
 
20
 
 
51
  return app_module.observation_store
52
 
53
 
 
54
  def _datetime_now_iso() -> str:
55
  return (
56
  datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
 
399
  search_svc = _get_search_service()
400
  if search_svc is not None:
401
  res = search_svc.search(
402
+ query=q,
403
+ limit=limit,
404
+ folder_path=folder_path,
405
+ agent_id=agent_id,
406
+ kv=kv,
407
  )
408
  else:
409
  res = []
 
442
  search_svc = _get_search_service()
443
  if search_svc is not None:
444
  res = search_svc.search(
445
+ query=q,
446
+ limit=limit,
447
+ folder_path=folder_path,
448
+ agent_id=agent_id,
449
+ kv=kv,
450
  )
451
  else:
452
  res = []
 
481
  res = {"success": False, "deleted": 0}
482
  text_out = json.dumps(res, indent=2)
483
 
 
484
  elif name in ("cache_export", "memory_export"):
485
  res = legacy.export_data(kv, {})
486
  text_out = json.dumps(res, indent=2)
src/agentcache/routes/memories.py CHANGED
@@ -23,6 +23,7 @@ def create_memories_bp(kv=None):
23
  if kv is not None:
24
  return kv
25
  from .. import app as app_module
 
26
  return app_module.kv
27
 
28
  # ------------------------------------------------------------------
@@ -40,9 +41,6 @@ def create_memories_bp(kv=None):
40
  except Exception as e:
41
  return jsonify({"error": str(e)}), 400
42
 
43
-
44
-
45
-
46
  # ------------------------------------------------------------------
47
  # POST /agentmemory/agent/remember
48
  # ------------------------------------------------------------------
@@ -113,7 +111,6 @@ def create_memories_bp(kv=None):
113
  res = functions.forget(_get_kv(), body)
114
  return jsonify(res), 200
115
  except Exception as e:
116
-
117
  return jsonify({"error": str(e)}), 400
118
 
119
  return bp
 
23
  if kv is not None:
24
  return kv
25
  from .. import app as app_module
26
+
27
  return app_module.kv
28
 
29
  # ------------------------------------------------------------------
 
41
  except Exception as e:
42
  return jsonify({"error": str(e)}), 400
43
 
 
 
 
44
  # ------------------------------------------------------------------
45
  # POST /agentmemory/agent/remember
46
  # ------------------------------------------------------------------
 
111
  res = functions.forget(_get_kv(), body)
112
  return jsonify(res), 200
113
  except Exception as e:
 
114
  return jsonify({"error": str(e)}), 400
115
 
116
  return bp
src/agentcache/routes/migration.py CHANGED
@@ -9,7 +9,7 @@ import os
9
 
10
  from flask import Blueprint, jsonify, request
11
 
12
- from .. import functions
13
 
14
  migration_bp = Blueprint("migration", __name__)
15
 
 
9
 
10
  from flask import Blueprint, jsonify, request
11
 
12
+ from .. import legacy as functions
13
 
14
  migration_bp = Blueprint("migration", __name__)
15
 
src/agentcache/routes/observations.py CHANGED
@@ -2,21 +2,21 @@
2
  Observation routes blueprint.
3
 
4
  Handles:
5
- POST /agentmemory/observe
6
- POST /agentmemory/agent/observe
7
- GET /agentmemory/folder/observations
8
- GET /agentmemory/folders
 
9
  """
10
 
11
  import datetime
12
  import os
 
13
 
14
  from flask import Blueprint, jsonify, request
15
 
16
- from .. import functions
17
- from ..functions import KV
18
-
19
- observations_bp = Blueprint("observations", __name__)
20
 
21
 
22
  def _datetime_now_iso() -> str:
@@ -41,271 +41,255 @@ def _check_auth():
41
  return None
42
 
43
 
44
- def _get_kv():
45
- """Retrieve the shared kv instance from the app module."""
46
- from .. import app as app_module
47
-
48
- return app_module.kv
49
-
50
-
51
- # ---------------------------------------------------------------------------
52
- # POST /agentmemory/observe (legacy raw hook endpoint + auto-compat shim)
53
- # ---------------------------------------------------------------------------
54
-
55
-
56
- @observations_bp.route("/agentcache/observe", methods=["POST"])
57
- @observations_bp.route("/agentmemory/observe", methods=["POST"])
58
- def api_observe():
59
- auth_err = _check_auth()
60
- if auth_err:
61
- return auth_err
62
-
63
- body = {}
64
- try:
65
- body = request.get_json(force=True) or {}
66
- folder_path = body.get("folderPath")
67
- agent_id = body.get("agentId")
68
- text = body.get("text") or body.get("content") or ""
69
-
70
- if not folder_path or not agent_id or not text:
71
- return jsonify({"error": "folderPath, agentId, and text are required"}), 400
72
-
73
- payload = {
74
- "folderPath": folder_path,
75
- "agentId": agent_id,
76
- "text": text,
77
- "timestamp": body.get("timestamp") or _datetime_now_iso(),
78
- "type": body.get("type"),
79
- "title": body.get("title"),
80
- "concepts": body.get("concepts"),
81
- "files": body.get("files"),
82
- "importance": body.get("importance"),
83
- }
84
- res = functions.folder_observe(_get_kv(), payload)
85
- return jsonify(res), 201
86
- except Exception as e:
87
- import traceback
88
-
89
- tb = traceback.format_exc()
90
- print(f"[observe] 400 — keys={list(body.keys())} {type(e).__name__}: {e}\n{tb}")
91
- return jsonify(
92
- {
93
- "error": str(e),
94
- "detail": type(e).__name__,
95
- "keys": list(body.keys()),
96
- "tb": tb,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  }
98
- ), 400
99
-
100
 
101
- # ---------------------------------------------------------------------------
102
- # POST /agentmemory/agent/observe
103
- # ---------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
 
 
 
 
 
 
105
 
106
- @observations_bp.route("/agentcache/agent/observe", methods=["POST"])
107
- @observations_bp.route("/agentmemory/agent/observe", methods=["POST"])
108
- def api_agent_observe():
109
- auth_err = _check_auth()
110
- if auth_err:
111
- return auth_err
112
 
113
- try:
114
  body = request.get_json(force=True) or {}
115
- folder_path = body.get("folderPath")
116
- agent_id = body.get("agentId")
117
- text = body.get("text") or body.get("content") or ""
118
-
119
- if not folder_path or not agent_id or not text:
120
- return jsonify({"error": "folderPath, agentId, and text are required"}), 400
121
-
122
- # sessionId accepted but ignored (folder-based model)
123
- timestamp = body.get("timestamp") or _datetime_now_iso()
124
-
125
- payload = {
126
- "folderPath": folder_path,
127
- "agentId": agent_id,
128
- "text": text,
129
- "timestamp": timestamp,
130
- "type": body.get("type"),
131
- "title": body.get("title"),
132
- "concepts": body.get("concepts"),
133
- "files": body.get("files"),
134
- "importance": body.get("importance"),
135
- }
136
-
137
- res = functions.folder_observe(_get_kv(), payload)
138
- return jsonify(res), 201
139
- except ValueError as e:
140
- import traceback
141
-
142
- print(f"[agent_observe] 400 ValueError — body keys: {list(body.keys())} — {e}")
143
- return jsonify({"error": str(e)}), 400
144
- except Exception as e:
145
- import traceback
146
-
147
- print(
148
- f"[agent_observe] 400 error — body keys: {list(body.keys())} — {type(e).__name__}: {e}"
149
  )
150
- print(traceback.format_exc())
151
- return jsonify({"error": str(e), "detail": type(e).__name__}), 400
152
-
153
-
154
- # ---------------------------------------------------------------------------
155
- # GET /agentmemory/folders
156
- # ---------------------------------------------------------------------------
157
-
158
 
159
- @observations_bp.route("/agentcache/folders", methods=["GET"])
160
- @observations_bp.route("/agentmemory/folders", methods=["GET"])
161
- def api_folders():
162
- auth_err = _check_auth()
163
- if auth_err:
164
- return auth_err
165
- folders = sorted(
166
- _get_kv().list(KV.folders),
167
- key=lambda x: x.get("lastUpdated", ""),
168
- reverse=True,
169
- )
170
- if functions.is_agent_scope_isolated():
171
- aid = functions.get_agent_id()
172
- if aid:
173
- folders = [f for f in folders if f.get("agentId") == aid]
174
- return jsonify({"folders": folders}), 200
175
-
176
-
177
- # ---------------------------------------------------------------------------
178
- # GET /agentmemory/folder/observations
179
- # ---------------------------------------------------------------------------
180
-
181
-
182
- @observations_bp.route("/agentcache/folder/observations", methods=["GET"])
183
- @observations_bp.route("/agentmemory/folder/observations", methods=["GET"])
184
- def api_folder_observations():
185
- auth_err = _check_auth()
186
- if auth_err:
187
- return auth_err
188
- fp = request.args.get("folderPath")
189
- aid = request.args.get("agentId")
190
- if not fp or not aid:
191
- return jsonify({"error": "folderPath and agentId are required"}), 400
192
- if functions.is_agent_scope_isolated():
193
- current_aid = functions.get_agent_id()
194
- if current_aid and aid != current_aid:
195
- return jsonify(
196
- {"error": "Unauthorized: Agent scope is isolated to another agent"}
197
- ), 403
198
- observations = sorted(
199
- _get_kv().list(KV.folder_obs(fp, aid)),
200
- key=lambda x: x.get("timestamp", ""),
201
- reverse=True,
202
- )
203
- return jsonify(
204
- {"observations": observations, "folderPath": fp, "agentId": aid}
205
- ), 200
206
-
207
-
208
- # ---------------------------------------------------------------------------
209
- # POST /agentmemory/session/start (legacy compat shim → 200 no-op)
210
- # ---------------------------------------------------------------------------
211
-
212
-
213
- @observations_bp.route("/agentcache/session/start", methods=["POST"])
214
- @observations_bp.route("/agentmemory/session/start", methods=["POST"])
215
- def api_session_start():
216
- """Legacy session/start — clients in the wild still call this.
217
- Return a synthetic session ID so callers don't error out.
218
- """
219
- auth_err = _check_auth()
220
- if auth_err:
221
- return auth_err
222
-
223
- import uuid
224
-
225
- body = request.get_json(force=True) or {}
226
- session_id = body.get("sessionId") or f"compat_{uuid.uuid4().hex[:16]}"
227
- return jsonify(
228
- {
229
- "sessionId": session_id,
230
- "status": "active",
231
- "message": "Session model migrated to folder-based. Use /agentmemory/agent/observe.",
232
- }
233
- ), 200
234
-
235
-
236
- # ---------------------------------------------------------------------------
237
- # POST /agentmemory/session/end (legacy compat shim → 200 no-op)
238
- # ---------------------------------------------------------------------------
239
-
240
-
241
- @observations_bp.route("/agentcache/session/end", methods=["POST"])
242
- @observations_bp.route("/agentmemory/session/end", methods=["POST"])
243
- def api_session_end():
244
- auth_err = _check_auth()
245
- if auth_err:
246
- return auth_err
247
- return jsonify(
248
- {"success": True, "message": "Session model is now folder-based."}
249
- ), 200
250
-
251
-
252
- # ---------------------------------------------------------------------------
253
- # GET /agentmemory/observations (legacy compat shim)
254
- # ---------------------------------------------------------------------------
255
-
256
-
257
- @observations_bp.route("/agentcache/folder/dedup", methods=["POST"])
258
- @observations_bp.route("/agentmemory/folder/dedup", methods=["POST"])
259
- def api_folder_dedup():
260
- """POST /agentmemory/folder/dedup — remove duplicate observations.
261
-
262
- Body (both optional):
263
- folderPath: str — deduplicate only this folder pair
264
- agentId: str — deduplicate only this agent
265
-
266
- If both are omitted all folder pairs are processed.
267
- Returns: {"success": bool, "deduplicated": int, "pairs_processed": int, "kept": int}
268
- """
269
- auth_err = _check_auth()
270
- if auth_err:
271
- return auth_err
272
- try:
273
- body = request.get_json(force=True) or {}
274
- folder_path = body.get("folderPath") or None
275
- agent_id = body.get("agentId") or None
276
- res = functions.dedup_folder_observations(_get_kv(), folder_path, agent_id)
277
- return jsonify(res), 200
278
- except Exception as e:
279
- return jsonify({"error": str(e)}), 400
280
-
281
-
282
- # ---------------------------------------------------------------------------
283
- # GET /agentmemory/observations (legacy compat shim)
284
- # ---------------------------------------------------------------------------
285
-
286
-
287
- @observations_bp.route("/agentcache/observations", methods=["GET"])
288
- @observations_bp.route("/agentmemory/observations", methods=["GET"])
289
- def api_observations_legacy():
290
- """Legacy /observations?sessionId=... shim.
291
- Reads from legacy KV scope if data exists, otherwise returns empty list.
292
- """
293
- auth_err = _check_auth()
294
- if auth_err:
295
- return auth_err
296
-
297
- session_id = request.args.get("sessionId", "")
298
- if not session_id:
299
- return jsonify({"observations": [], "sessionId": ""}), 200
300
-
301
- try:
302
- obs = sorted(
303
- _get_kv().list(functions.KV.observations(session_id)),
304
- key=lambda x: x.get("timestamp", ""),
305
- reverse=True,
306
  )
307
- return jsonify({"observations": obs, "sessionId": session_id}), 200
308
- except Exception as e:
309
- return jsonify(
310
- {"observations": [], "sessionId": session_id, "error": str(e)}
311
- ), 200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  Observation routes blueprint.
3
 
4
  Handles:
5
+ POST /agentcache/observe
6
+ POST /agentcache/agent/observe
7
+ GET /agentcache/folder/observations
8
+ GET /agentcache/folders
9
+ POST /agentcache/folder/dedup
10
  """
11
 
12
  import datetime
13
  import os
14
+ from typing import Optional
15
 
16
  from flask import Blueprint, jsonify, request
17
 
18
+ from ..core.kv_scopes import KV
19
+ from ..core.observation_store import ObservationStore
 
 
20
 
21
 
22
  def _datetime_now_iso() -> str:
 
41
  return None
42
 
43
 
44
+ def create_observations_bp(
45
+ observation_store: Optional[ObservationStore] = None,
46
+ ) -> Blueprint:
47
+ bp = Blueprint("observations", __name__)
48
+
49
+ def get_store() -> ObservationStore:
50
+ if observation_store is not None:
51
+ return observation_store
52
+ from flask import current_app
53
+
54
+ store = current_app.extensions.get("observation_store")
55
+ if store is None:
56
+ from .. import app as app_module
57
+
58
+ store = getattr(app_module, "observation_store", None)
59
+ if store is None:
60
+ raise RuntimeError("ObservationStore is not initialized")
61
+ return store
62
+
63
+ def get_kv():
64
+ return get_store().kv
65
+
66
+ @bp.route("/agentcache/observe", methods=["POST"])
67
+ @bp.route("/agentmemory/observe", methods=["POST"])
68
+ def api_observe():
69
+ auth_err = _check_auth()
70
+ if auth_err:
71
+ return auth_err
72
+
73
+ body = {}
74
+ try:
75
+ body = request.get_json(force=True) or {}
76
+ folder_path = body.get("folderPath")
77
+ agent_id = body.get("agentId")
78
+ text = body.get("text") or body.get("content") or ""
79
+
80
+ if not folder_path or not agent_id or not text:
81
+ return (
82
+ jsonify({"error": "folderPath, agentId, and text are required"}),
83
+ 400,
84
+ )
85
+
86
+ payload = {
87
+ "folderPath": folder_path,
88
+ "agentId": agent_id,
89
+ "text": text,
90
+ "timestamp": body.get("timestamp") or _datetime_now_iso(),
91
+ "type": body.get("type"),
92
+ "title": body.get("title"),
93
+ "concepts": body.get("concepts"),
94
+ "files": body.get("files"),
95
+ "importance": body.get("importance"),
96
+ }
97
+ res = get_store().ingest(payload)
98
+ return jsonify(res), 201
99
+ except Exception as e:
100
+ import traceback
101
+
102
+ tb = traceback.format_exc()
103
+ print(
104
+ f"[observe] 400 — keys={list(body.keys())} {type(e).__name__}: {e}\n{tb}"
105
+ )
106
+ return (
107
+ jsonify(
108
+ {
109
+ "error": str(e),
110
+ "detail": type(e).__name__,
111
+ "keys": list(body.keys()),
112
+ "tb": tb,
113
+ }
114
+ ),
115
+ 400,
116
+ )
117
+
118
+ @bp.route("/agentcache/agent/observe", methods=["POST"])
119
+ @bp.route("/agentmemory/agent/observe", methods=["POST"])
120
+ def api_agent_observe():
121
+ auth_err = _check_auth()
122
+ if auth_err:
123
+ return auth_err
124
+
125
+ try:
126
+ body = request.get_json(force=True) or {}
127
+ folder_path = body.get("folderPath")
128
+ agent_id = body.get("agentId")
129
+ text = body.get("text") or body.get("content") or ""
130
+
131
+ if not folder_path or not agent_id or not text:
132
+ return (
133
+ jsonify({"error": "folderPath, agentId, and text are required"}),
134
+ 400,
135
+ )
136
+
137
+ timestamp = body.get("timestamp") or _datetime_now_iso()
138
+
139
+ payload = {
140
+ "folderPath": folder_path,
141
+ "agentId": agent_id,
142
+ "text": text,
143
+ "timestamp": timestamp,
144
+ "type": body.get("type"),
145
+ "title": body.get("title"),
146
+ "concepts": body.get("concepts"),
147
+ "files": body.get("files"),
148
+ "importance": body.get("importance"),
149
  }
 
 
150
 
151
+ res = get_store().ingest(payload)
152
+ return jsonify(res), 201
153
+ except ValueError as e:
154
+ print(
155
+ f"[agent_observe] 400 ValueError — body keys: {list(body.keys())} — {e}"
156
+ )
157
+ return jsonify({"error": str(e)}), 400
158
+ except Exception as e:
159
+ import traceback
160
+
161
+ print(
162
+ f"[agent_observe] 400 error — body keys: {list(body.keys())} — {type(e).__name__}: {e}"
163
+ )
164
+ print(traceback.format_exc())
165
+ return jsonify({"error": str(e), "detail": type(e).__name__}), 400
166
+
167
+ @bp.route("/agentcache/folders", methods=["GET"])
168
+ @bp.route("/agentmemory/folders", methods=["GET"])
169
+ def api_folders():
170
+ auth_err = _check_auth()
171
+ if auth_err:
172
+ return auth_err
173
+ from .. import legacy
174
+
175
+ folders = sorted(
176
+ get_kv().list(KV.folders),
177
+ key=lambda x: x.get("lastUpdated", ""),
178
+ reverse=True,
179
+ )
180
+ if legacy.is_agent_scope_isolated():
181
+ aid = legacy.get_agent_id()
182
+ if aid:
183
+ folders = [f for f in folders if f.get("agentId") == aid]
184
+ return jsonify({"folders": folders}), 200
185
+
186
+ @bp.route("/agentcache/folder/observations", methods=["GET"])
187
+ @bp.route("/agentmemory/folder/observations", methods=["GET"])
188
+ def api_folder_observations():
189
+ auth_err = _check_auth()
190
+ if auth_err:
191
+ return auth_err
192
+ fp = request.args.get("folderPath")
193
+ aid = request.args.get("agentId")
194
+ if not fp or not aid:
195
+ return jsonify({"error": "folderPath and agentId are required"}), 400
196
+ from .. import legacy
197
+
198
+ if legacy.is_agent_scope_isolated():
199
+ current_aid = legacy.get_agent_id()
200
+
201
+ if current_aid and aid != current_aid:
202
+ return (
203
+ jsonify(
204
+ {
205
+ "error": "Unauthorized: Agent scope is isolated to another agent"
206
+ }
207
+ ),
208
+ 403,
209
+ )
210
+ observations = sorted(
211
+ get_kv().list(KV.folder_obs(fp, aid)),
212
+ key=lambda x: x.get("timestamp", ""),
213
+ reverse=True,
214
+ )
215
+ return (
216
+ jsonify({"observations": observations, "folderPath": fp, "agentId": aid}),
217
+ 200,
218
+ )
219
 
220
+ @bp.route("/agentcache/session/start", methods=["POST"])
221
+ @bp.route("/agentmemory/session/start", methods=["POST"])
222
+ def api_session_start():
223
+ auth_err = _check_auth()
224
+ if auth_err:
225
+ return auth_err
226
 
227
+ import uuid
 
 
 
 
 
228
 
 
229
  body = request.get_json(force=True) or {}
230
+ session_id = body.get("sessionId") or f"compat_{uuid.uuid4().hex[:16]}"
231
+ return (
232
+ jsonify(
233
+ {
234
+ "sessionId": session_id,
235
+ "status": "active",
236
+ "message": "Session model migrated to folder-based. Use /agentmemory/agent/observe.",
237
+ }
238
+ ),
239
+ 200,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  )
 
 
 
 
 
 
 
 
241
 
242
+ @bp.route("/agentcache/session/end", methods=["POST"])
243
+ @bp.route("/agentmemory/session/end", methods=["POST"])
244
+ def api_session_end():
245
+ auth_err = _check_auth()
246
+ if auth_err:
247
+ return auth_err
248
+ return (
249
+ jsonify({"success": True, "message": "Session model is now folder-based."}),
250
+ 200,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  )
252
+
253
+ @bp.route("/agentcache/folder/dedup", methods=["POST"])
254
+ @bp.route("/agentmemory/folder/dedup", methods=["POST"])
255
+ def api_folder_dedup():
256
+ auth_err = _check_auth()
257
+ if auth_err:
258
+ return auth_err
259
+ try:
260
+ body = request.get_json(force=True) or {}
261
+ folder_path = body.get("folderPath") or None
262
+ agent_id = body.get("agentId") or None
263
+ res = get_store().dedup(folder_path, agent_id)
264
+ return jsonify(res), 200
265
+ except Exception as e:
266
+ return jsonify({"error": str(e)}), 400
267
+
268
+ @bp.route("/agentcache/observations", methods=["GET"])
269
+ @bp.route("/agentmemory/observations", methods=["GET"])
270
+ def api_observations_legacy():
271
+ auth_err = _check_auth()
272
+ if auth_err:
273
+ return auth_err
274
+
275
+ session_id = request.args.get("sessionId", "")
276
+ if not session_id:
277
+ return jsonify({"observations": [], "sessionId": ""}), 200
278
+
279
+ try:
280
+ obs = sorted(
281
+ get_kv().list(KV.observations(session_id)),
282
+ key=lambda x: x.get("timestamp", ""),
283
+ reverse=True,
284
+ )
285
+ return jsonify({"observations": obs, "sessionId": session_id}), 200
286
+ except Exception as e:
287
+ return (
288
+ jsonify({"observations": [], "sessionId": session_id, "error": str(e)}),
289
+ 200,
290
+ )
291
+
292
+ return bp
293
+
294
+
295
+ observations_bp = create_observations_bp()
src/agentcache/routes/search.py CHANGED
@@ -10,8 +10,6 @@ import os
10
 
11
  from flask import Blueprint, jsonify, request
12
 
13
- from .. import functions
14
-
15
  search_bp = Blueprint("search", __name__)
16
 
17
 
@@ -36,6 +34,18 @@ def _get_kv():
36
  return app_module.kv
37
 
38
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  # ---------------------------------------------------------------------------
40
  # POST /agentcache/search
41
  # ---------------------------------------------------------------------------
@@ -57,9 +67,17 @@ def api_search():
57
  folder_path = body.get("folderPath")
58
  agent_id = body.get("agentId")
59
 
60
- res = functions.folder_search(
61
- _get_kv(), query, limit, folder_path=folder_path, agent_id=agent_id
62
- )
 
 
 
 
 
 
 
 
63
  return jsonify(res), 200
64
  except Exception as e:
65
  return jsonify({"error": str(e)}), 400
@@ -84,9 +102,17 @@ def api_timeline():
84
  limit = body.get("limit") or 100
85
  before = body.get("before")
86
  after = body.get("after")
87
- result = functions.folder_timeline(
88
- _get_kv(), limit, folder_path, agent_id, before, after
89
- )
 
 
 
 
 
 
 
 
90
  return jsonify({"observations": result}), 200
91
  except Exception as e:
92
  return jsonify({"error": str(e)}), 400
 
10
 
11
  from flask import Blueprint, jsonify, request
12
 
 
 
13
  search_bp = Blueprint("search", __name__)
14
 
15
 
 
34
  return app_module.kv
35
 
36
 
37
+ def _get_search_service():
38
+ from .. import app as app_module
39
+
40
+ return app_module.search_service
41
+
42
+
43
+ def _get_observation_store():
44
+ from .. import app as app_module
45
+
46
+ return app_module.observation_store
47
+
48
+
49
  # ---------------------------------------------------------------------------
50
  # POST /agentcache/search
51
  # ---------------------------------------------------------------------------
 
67
  folder_path = body.get("folderPath")
68
  agent_id = body.get("agentId")
69
 
70
+ search_svc = _get_search_service()
71
+ if search_svc is not None:
72
+ res = search_svc.search(
73
+ query=query,
74
+ limit=limit,
75
+ folder_path=folder_path,
76
+ agent_id=agent_id,
77
+ kv=_get_kv(),
78
+ )
79
+ else:
80
+ res = []
81
  return jsonify(res), 200
82
  except Exception as e:
83
  return jsonify({"error": str(e)}), 400
 
102
  limit = body.get("limit") or 100
103
  before = body.get("before")
104
  after = body.get("after")
105
+ obs_store = _get_observation_store()
106
+ if obs_store is not None:
107
+ result = obs_store.timeline(
108
+ limit=limit,
109
+ folder_path=folder_path,
110
+ agent_id=agent_id,
111
+ before=before,
112
+ after=after,
113
+ )
114
+ else:
115
+ result = []
116
  return jsonify({"observations": result}), 200
117
  except Exception as e:
118
  return jsonify({"error": str(e)}), 400
src/agentcache/search.py CHANGED
@@ -522,6 +522,7 @@ class SearchIndex:
522
  parts = [
523
  obs.get("title", ""),
524
  obs.get("subtitle", "") or "",
 
525
  obs.get("narrative", "") or "",
526
  " ".join(obs.get("facts", []) or []),
527
  " ".join(obs.get("concepts", []) or []),
 
522
  parts = [
523
  obs.get("title", ""),
524
  obs.get("subtitle", "") or "",
525
+ obs.get("text", "") or "",
526
  obs.get("narrative", "") or "",
527
  " ".join(obs.get("facts", []) or []),
528
  " ".join(obs.get("concepts", []) or []),
src/agentcache/storage/scopes.py CHANGED
@@ -1,78 +1,9 @@
1
  """
2
  src/storage/scopes.py — KV scope registry (A2.3).
3
 
4
- Copied from src/functions.py do NOT delete the original (backward compat).
5
- The KV class defines all storage scope keys used across agentcache-python.
6
  """
7
 
 
8
 
9
- class KV:
10
- # ---- Folder memory scopes (new) ----
11
-
12
- # Global index of all (folder_path, agent_id) pairs known to the system.
13
- # Key = "{safe_folder_path}:{agent_id}", value = FolderIndexEntry dict.
14
- folders = "mem:folders"
15
-
16
- @staticmethod
17
- def folder_obs(folder_path: str, agent_id: str) -> str:
18
- """Per-(folder, agent) observations scope.
19
- Key = obs_id, value = FolderObservation dict.
20
- """
21
- safe_path = folder_path.replace("\\", "/").strip("/")
22
- safe_agent = agent_id.strip()
23
- return f"mem:folder:{safe_path}:{safe_agent}"
24
-
25
- @staticmethod
26
- def folder_meta(folder_path: str, agent_id: str) -> str:
27
- """Per-(folder, agent) metadata scope.
28
- Key = "meta", value = FolderMeta dict (obsCount, lastUpdated, summary).
29
- """
30
- safe_path = folder_path.replace("\\", "/").strip("/")
31
- safe_agent = agent_id.strip()
32
- return f"mem:foldermeta:{safe_path}:{safe_agent}"
33
-
34
- @staticmethod
35
- def obs_dedup(folder_path: str, agent_id: str) -> str:
36
- """Deduplication index scope for (folder, agent) pairs.
37
- Key = SHA-256 fingerprint hex of normalized text.
38
- Value = {"obsId": str, "timestamp": str}
39
- """
40
- safe_path = folder_path.replace("\\", "/").strip("/")
41
- safe_agent = agent_id.strip()
42
- return f"mem:obs_dedup:{safe_path}:{safe_agent}"
43
-
44
- # ---- Global / shared scopes (kept) ----
45
-
46
- # Long-term memories — unchanged from previous implementation.
47
- memories = "mem:memories"
48
-
49
- # BM25 index shards — unchanged.
50
- bm25Index = "mem:index:bm25"
51
-
52
- # Audit log — unchanged.
53
- audit = "mem:audit"
54
-
55
- # Graph edges — repurposed for folder graph edges.
56
- relations = "mem:relations"
57
-
58
- # ---- Legacy scopes (read-only; kept for migration and backward compat) ----
59
-
60
- # Legacy session store — read by migrate_sessions_to_folders() and legacy observe().
61
- sessions = "mem:sessions"
62
-
63
- @staticmethod
64
- def observations(session_id: str) -> str:
65
- """Legacy per-session observations scope.
66
- Key = obs_id, value = raw/synthetic observation dict.
67
- Read by migrate_sessions_to_folders() and legacy observe().
68
- """
69
- return f"mem:obs:{session_id}"
70
-
71
- # Legacy summary / profile / slot / image-ref scopes retained for legacy code paths.
72
- summaries = "mem:summaries"
73
- profiles = "mem:profiles"
74
- slots = "mem:slots"
75
- imageRefs = "mem:image-refs"
76
-
77
- # Global (cross-project) pinned slots.
78
- globalSlots = "mem:global-slots"
 
1
  """
2
  src/storage/scopes.py — KV scope registry (A2.3).
3
 
4
+ Re-exports KV from core.kv_scopes for backward compatibility.
 
5
  """
6
 
7
+ from ..core.kv_scopes import KV
8
 
9
+ __all__ = ["KV"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/agentcache/workers.py CHANGED
@@ -14,14 +14,14 @@ import sys
14
  import threading
15
  import time
16
 
17
- from . import functions
18
 
19
  # Module-level shutdown flag — set by signal handlers
20
  _shutting_down = threading.Event()
21
 
22
- # Reference to the persistence object (set by start_background_workers)
23
  _persistence_ref = None
24
- # Reference to the kv object (set by start_background_workers)
 
25
  _kv_ref = None
26
 
27
 
@@ -30,7 +30,7 @@ def _shutdown_handler(signum, frame) -> None: # noqa: ARG001
30
 
31
  Steps:
32
  1. Set the global _shutting_down flag to stop background loops.
33
- 2. Flush the debounce timer and save the index synchronously.
34
  3. Run a WAL checkpoint via StateKV.teardown().
35
  4. Exit cleanly with code 0.
36
  """
@@ -39,9 +39,15 @@ def _shutdown_handler(signum, frame) -> None: # noqa: ARG001
39
 
40
  _shutting_down.set()
41
 
42
- # Flush in-flight persistence debounce timer and save immediately
43
- global _persistence_ref
44
- if _persistence_ref is not None:
 
 
 
 
 
 
45
  try:
46
  print("[workers] Flushing index persistence...")
47
  _persistence_ref.flush()
@@ -89,7 +95,7 @@ def _auto_forget_loop(kv) -> None:
89
  if kv.acquire_lock("auto_forget", lease_seconds=300):
90
  try:
91
  print("[scheduler] Running auto_forget sweep...")
92
- res = functions.auto_forget(kv, dry_run=False)
93
  print(f"[scheduler] auto_forget sweep completed: {res}")
94
  finally:
95
  kv.release_lock("auto_forget")
@@ -107,7 +113,13 @@ def _rebuild_index(kv) -> None:
107
  try:
108
  if kv.acquire_lock("index_rebuild", lease_seconds=600):
109
  try:
110
- count = functions.rebuild_index(kv)
 
 
 
 
 
 
111
  print(f"[persistence] Rebuild completed: indexed {count} items.")
112
  finally:
113
  kv.release_lock("index_rebuild")
@@ -127,21 +139,34 @@ def start_background_workers(kv, tasks=None) -> None:
127
  kv: Initialised StateKV instance.
128
  tasks: Optional list of tasks to run ("index", "forget"). Defaults to running both.
129
  """
130
- global _kv_ref, _persistence_ref
131
  _kv_ref = kv
132
 
133
- # Capture the persistence reference for the shutdown handler
134
- _persistence_ref = functions._index_persistence
 
 
 
 
 
 
 
 
 
 
135
 
136
  # Register graceful shutdown signal handlers (C5.1)
137
  _register_signal_handlers()
138
 
139
  if tasks is None or "index" in tasks:
140
  # Rebuild search index if empty or out of sync (Step 5)
141
- index_empty = functions._bm25_index.size == 0
 
142
  index_in_sync = True
143
  if not index_empty:
144
- index_in_sync = functions.verify_index_sync_on_boot(kv)
 
 
145
 
146
  if index_empty or not index_in_sync:
147
  reason = "empty" if index_empty else "out of sync"
 
14
  import threading
15
  import time
16
 
17
+ from . import legacy
18
 
19
  # Module-level shutdown flag — set by signal handlers
20
  _shutting_down = threading.Event()
21
 
 
22
  _persistence_ref = None
23
+ _search_svc_ref = None
24
+ _obs_store_ref = None
25
  _kv_ref = None
26
 
27
 
 
30
 
31
  Steps:
32
  1. Set the global _shutting_down flag to stop background loops.
33
+ 2. Flush the debounce timer and save the index synchronously via SearchService.flush_persist().
34
  3. Run a WAL checkpoint via StateKV.teardown().
35
  4. Exit cleanly with code 0.
36
  """
 
39
 
40
  _shutting_down.set()
41
 
42
+ global _search_svc_ref, _persistence_ref
43
+ if _search_svc_ref is not None:
44
+ try:
45
+ print("[workers] Flushing SearchService persistence...")
46
+ _search_svc_ref.flush_persist()
47
+ print("[workers] SearchService persistence flushed.")
48
+ except Exception as e:
49
+ print(f"[workers] Error flushing SearchService: {e}")
50
+ elif _persistence_ref is not None:
51
  try:
52
  print("[workers] Flushing index persistence...")
53
  _persistence_ref.flush()
 
95
  if kv.acquire_lock("auto_forget", lease_seconds=300):
96
  try:
97
  print("[scheduler] Running auto_forget sweep...")
98
+ res = legacy.auto_forget(kv, dry_run=False)
99
  print(f"[scheduler] auto_forget sweep completed: {res}")
100
  finally:
101
  kv.release_lock("auto_forget")
 
113
  try:
114
  if kv.acquire_lock("index_rebuild", lease_seconds=600):
115
  try:
116
+ from . import app as app_module
117
+
118
+ obs_store = getattr(app_module, "observation_store", None)
119
+ if obs_store is not None:
120
+ count = obs_store.rebuild_index()
121
+ else:
122
+ count = 0
123
  print(f"[persistence] Rebuild completed: indexed {count} items.")
124
  finally:
125
  kv.release_lock("index_rebuild")
 
139
  kv: Initialised StateKV instance.
140
  tasks: Optional list of tasks to run ("index", "forget"). Defaults to running both.
141
  """
142
+ global _kv_ref, _persistence_ref, _search_svc_ref, _obs_store_ref
143
  _kv_ref = kv
144
 
145
+ from . import app as app_module
146
+
147
+ search_svc = getattr(app_module, "search_service", None)
148
+ obs_store = getattr(app_module, "observation_store", None)
149
+
150
+ _search_svc_ref = search_svc
151
+ _obs_store_ref = obs_store
152
+
153
+ if search_svc is not None:
154
+ _persistence_ref = search_svc._persistence
155
+ else:
156
+ _persistence_ref = None
157
 
158
  # Register graceful shutdown signal handlers (C5.1)
159
  _register_signal_handlers()
160
 
161
  if tasks is None or "index" in tasks:
162
  # Rebuild search index if empty or out of sync (Step 5)
163
+ bm25_size = search_svc.bm25_size if search_svc is not None else 0
164
+ index_empty = bm25_size == 0
165
  index_in_sync = True
166
  if not index_empty:
167
+ index_in_sync = legacy.verify_index_sync_on_boot(
168
+ kv, search_service=search_svc
169
+ )
170
 
171
  if index_empty or not index_in_sync:
172
  reason = "empty" if index_empty else "out of sync"
tests/__init__.py CHANGED
@@ -1,2 +0,0 @@
1
- # tests package
2
-
 
 
 
tests/conftest.py CHANGED
@@ -3,6 +3,7 @@ Shared pytest fixtures for agentcache test suite.
3
  """
4
 
5
  import pytest
 
6
  import agentcache.app as app_mod
7
  from agentcache.app import create_app
8
  from agentcache.db import StateKV
 
3
  """
4
 
5
  import pytest
6
+
7
  import agentcache.app as app_mod
8
  from agentcache.app import create_app
9
  from agentcache.db import StateKV
tests/test_api.py DELETED
@@ -1,294 +0,0 @@
1
- """
2
- tests/test_api.py — C3.1
3
-
4
- Integration tests for REST endpoints using the Flask test client.
5
- """
6
-
7
- import datetime
8
- import json
9
- import os
10
-
11
- import pytest
12
-
13
- # ---------------------------------------------------------------------------
14
- # Fixtures
15
- # ---------------------------------------------------------------------------
16
-
17
-
18
- @pytest.fixture(scope="module")
19
- def flask_app(tmp_path_factory):
20
- tmp_dir = tmp_path_factory.mktemp("api_test_db")
21
- db_path = str(tmp_dir / "test.db")
22
- os.environ.pop("AGENTCACHE_SECRET", None)
23
- os.environ.pop("AGENTMEMORY_SECRET", None)
24
-
25
- from agentcache.db import StateKV
26
-
27
- original_init = StateKV.__init__
28
-
29
- def patched_init(self, db_path_arg=None, **kwargs):
30
- original_init(self, db_path=db_path, **kwargs)
31
-
32
- StateKV.__init__ = patched_init
33
- import agentcache.app as app_module
34
-
35
- os.environ.pop("AGENTCACHE_SECRET", None)
36
- os.environ.pop("AGENTMEMORY_SECRET", None)
37
- flask_application = app_module.create_app()
38
- StateKV.__init__ = original_init
39
- flask_application.config["TESTING"] = True
40
- return flask_application
41
-
42
-
43
- @pytest.fixture(scope="module")
44
- def client(flask_app):
45
- return flask_app.test_client()
46
-
47
-
48
- def _now():
49
- return (
50
- datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
51
- )
52
-
53
-
54
- def _post(client, url, payload):
55
- return client.post(url, data=json.dumps(payload), content_type="application/json")
56
-
57
-
58
- # ---------------------------------------------------------------------------
59
- # POST /agentcache/agent/observe
60
- # ---------------------------------------------------------------------------
61
-
62
-
63
- class TestAgentObserve:
64
- def test_valid_payload_returns_201(self, client):
65
- resp = _post(
66
- client,
67
- "/agentcache/agent/observe",
68
- {
69
- "folderPath": "/home/user/test-project",
70
- "agentId": "kiro",
71
- "text": "Implemented new authentication middleware",
72
- "timestamp": _now(),
73
- },
74
- )
75
- assert resp.status_code == 201
76
- data = resp.get_json()
77
- assert "observationId" in data
78
- assert data["observationId"].startswith("fobs_")
79
-
80
- def test_missing_folder_path_returns_400(self, client):
81
- resp = _post(
82
- client,
83
- "/agentcache/agent/observe",
84
- {
85
- "agentId": "kiro",
86
- "text": "Some work",
87
- "timestamp": _now(),
88
- },
89
- )
90
- assert resp.status_code == 400
91
-
92
- def test_missing_agent_id_returns_400(self, client):
93
- resp = _post(
94
- client,
95
- "/agentcache/agent/observe",
96
- {
97
- "folderPath": "/home/user/proj",
98
- "text": "Some work",
99
- "timestamp": _now(),
100
- },
101
- )
102
- assert resp.status_code == 400
103
-
104
- def test_missing_text_returns_400(self, client):
105
- resp = _post(
106
- client,
107
- "/agentcache/agent/observe",
108
- {
109
- "folderPath": "/home/user/proj",
110
- "agentId": "kiro",
111
- "timestamp": _now(),
112
- },
113
- )
114
- assert resp.status_code == 400
115
-
116
-
117
- # ---------------------------------------------------------------------------
118
- # POST /agentcache/search
119
- # ---------------------------------------------------------------------------
120
-
121
-
122
- class TestSearch:
123
- def test_search_with_query_returns_200(self, client):
124
- # Seed data first
125
- _post(
126
- client,
127
- "/agentcache/agent/observe",
128
- {
129
- "folderPath": "/home/user/search-proj",
130
- "agentId": "kiro",
131
- "text": "Refactored the authentication system",
132
- "timestamp": _now(),
133
- },
134
- )
135
- resp = _post(client, "/agentcache/search", {"query": "authentication"})
136
- assert resp.status_code == 200
137
- data = resp.get_json()
138
- assert isinstance(data, list) or isinstance(data, dict)
139
-
140
- def test_search_missing_query_returns_400(self, client):
141
- resp = _post(client, "/agentcache/search", {})
142
- assert resp.status_code == 400
143
-
144
- def test_search_empty_query_returns_400(self, client):
145
- resp = _post(client, "/agentcache/search", {"query": " "})
146
- assert resp.status_code == 400
147
-
148
-
149
- # ---------------------------------------------------------------------------
150
- # GET /agentcache/folders
151
- # ---------------------------------------------------------------------------
152
-
153
-
154
- class TestFolders:
155
- def test_get_folders_returns_200(self, client):
156
- # Ensure at least one folder exists from earlier tests
157
- _post(
158
- client,
159
- "/agentcache/agent/observe",
160
- {
161
- "folderPath": "/home/user/folders-check",
162
- "agentId": "kiro",
163
- "text": "Check folders endpoint",
164
- "timestamp": _now(),
165
- },
166
- )
167
- resp = client.get("/agentcache/folders")
168
- assert resp.status_code == 200
169
- data = resp.get_json()
170
- assert "folders" in data
171
- assert isinstance(data["folders"], list)
172
-
173
-
174
- # ---------------------------------------------------------------------------
175
- # GET /agentcache/health
176
- # ---------------------------------------------------------------------------
177
-
178
-
179
- class TestHealth:
180
- def test_health_returns_200(self, client):
181
- resp = client.get("/agentcache/health")
182
- assert resp.status_code == 200
183
- data = resp.get_json()
184
- assert "folderCount" in data
185
- assert "observationCount" in data
186
- assert "memoryCount" in data
187
-
188
- def test_health_status_ok(self, client):
189
- resp = client.get("/agentcache/health")
190
- data = resp.get_json()
191
- assert data.get("status") in ("ok", "degraded")
192
-
193
-
194
- # ---------------------------------------------------------------------------
195
- # GET /agentcache/livez
196
- # ---------------------------------------------------------------------------
197
-
198
-
199
- class TestLivez:
200
- def test_livez_returns_200_no_auth(self, client):
201
- resp = client.get("/agentcache/livez")
202
- assert resp.status_code == 200
203
- data = resp.get_json()
204
- assert data["status"] == "ok"
205
-
206
- def test_livez_open_with_secret_set(self, client):
207
- os.environ["AGENTCACHE_SECRET"] = "test-secret-123"
208
- try:
209
- resp = client.get("/agentcache/livez")
210
- assert resp.status_code == 200
211
- finally:
212
- del os.environ["AGENTCACHE_SECRET"]
213
-
214
-
215
- # ---------------------------------------------------------------------------
216
- # Authentication
217
- # ---------------------------------------------------------------------------
218
-
219
-
220
- class TestAuthentication:
221
- def test_protected_endpoint_returns_401_with_wrong_token(self, client):
222
- os.environ["AGENTCACHE_SECRET"] = "correct-secret"
223
- try:
224
- resp = client.get(
225
- "/agentcache/audit",
226
- headers={"Authorization": "Bearer wrong-token"},
227
- )
228
- assert resp.status_code == 401
229
- finally:
230
- del os.environ["AGENTCACHE_SECRET"]
231
-
232
- def test_protected_endpoint_passes_with_correct_token(self, client):
233
- secret = "my-test-secret-xyz"
234
- os.environ["AGENTCACHE_SECRET"] = secret
235
- try:
236
- resp = client.get(
237
- "/agentcache/audit",
238
- headers={"Authorization": f"Bearer {secret}"},
239
- )
240
- assert resp.status_code == 200
241
- finally:
242
- del os.environ["AGENTCACHE_SECRET"]
243
-
244
- def test_livez_always_open_regardless_of_secret(self, client):
245
- os.environ["AGENTCACHE_SECRET"] = "any-secret"
246
- try:
247
- resp = client.get("/agentcache/livez")
248
- assert resp.status_code == 200
249
- finally:
250
- del os.environ["AGENTCACHE_SECRET"]
251
-
252
-
253
- # ---------------------------------------------------------------------------
254
- # Additional endpoint smoke tests
255
- # ---------------------------------------------------------------------------
256
-
257
-
258
- class TestMemoriesEndpoint:
259
- def test_memories_list_returns_200(self, client):
260
- resp = client.get("/agentcache/memories")
261
- assert resp.status_code == 200
262
- data = resp.get_json()
263
- assert "memories" in data
264
-
265
- def test_remember_valid_payload_returns_201(self, client):
266
- resp = _post(
267
- client,
268
- "/agentcache/remember",
269
- {
270
- "content": "API test memory content",
271
- "type": "fact",
272
- },
273
- )
274
- assert resp.status_code == 201
275
-
276
- def test_forget_nonexistent_id(self, client):
277
- resp = _post(client, "/agentcache/forget", {"memoryId": "mem_nonexistent"})
278
- assert resp.status_code == 200
279
-
280
- def test_graph_endpoint_returns_200(self, client):
281
- resp = client.get("/agentcache/graph")
282
- assert resp.status_code == 200
283
- data = resp.get_json()
284
- assert "nodes" in data
285
- assert "edges" in data
286
-
287
- def test_mcp_tools_list_returns_200(self, client):
288
- resp = client.get("/agentcache/mcp/tools")
289
- assert resp.status_code == 200
290
- data = resp.get_json()
291
- assert "tools" in data
292
- tool_names = {t["name"] for t in data["tools"]}
293
- assert "agent_observe" in tool_names
294
- assert "cache_recall" in tool_names
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_auth.py CHANGED
@@ -3,8 +3,8 @@ Unit and integration tests for authentication and authorization.
3
  """
4
 
5
  from flask import Flask, jsonify
6
- from agentcache.routes.auth import require_auth, verify_token
7
 
 
8
 
9
  # ------------------------------------------------------------------------------
10
  # Unit Tests — verify_token & require_auth
@@ -85,7 +85,11 @@ def test_protected_routes_require_auth(authed_client):
85
  headers = {"Authorization": f"Bearer {secret}"}
86
 
87
  protected_endpoints = [
88
- ("POST", "/agentcache/observe", {"folderPath": "src/test", "agentId": "a1", "text": "test"}),
 
 
 
 
89
  ("POST", "/agentcache/remember", {"content": "test memory"}),
90
  ("POST", "/agentcache/search", {"query": "test"}),
91
  ("POST", "/agentcache/timeline", {}),
@@ -104,8 +108,12 @@ def test_protected_routes_require_auth(authed_client):
104
  res_unauth = client.get(path)
105
  res_auth = client.get(path, headers=headers)
106
 
107
- assert res_unauth.status_code == 401, f"{method} {path} should require auth (got {res_unauth.status_code})"
108
- assert res_auth.status_code in (200, 201), f"{method} {path} failed with valid auth (got {res_auth.status_code})"
 
 
 
 
109
 
110
 
111
  def test_unprotected_routes_accessible_without_auth(authed_client):
@@ -120,7 +128,9 @@ def test_unprotected_routes_accessible_without_auth(authed_client):
120
 
121
  for path in unprotected_paths:
122
  res = client.get(path)
123
- assert res.status_code == 200, f"Unprotected route {path} failed (got {res.status_code})"
 
 
124
 
125
 
126
  def test_wrong_token_on_any_blueprint_returns_401(authed_client):
@@ -129,7 +139,11 @@ def test_wrong_token_on_any_blueprint_returns_401(authed_client):
129
  bad_headers = {"Authorization": "Bearer wrong-token-value"}
130
 
131
  protected_endpoints = [
132
- ("POST", "/agentcache/observe", {"folderPath": "src/test", "agentId": "a1", "text": "test"}),
 
 
 
 
133
  ("POST", "/agentcache/remember", {"content": "test memory"}),
134
  ("POST", "/agentcache/search", {"query": "test"}),
135
  ("POST", "/agentcache/timeline", {}),
@@ -139,7 +153,6 @@ def test_wrong_token_on_any_blueprint_returns_401(authed_client):
139
  ("POST", "/agentcache/migrate", {}),
140
  ]
141
 
142
-
143
  for method, path, payload in protected_endpoints:
144
  if method == "POST":
145
  res = client.post(path, json=payload or {}, headers=bad_headers)
 
3
  """
4
 
5
  from flask import Flask, jsonify
 
6
 
7
+ from agentcache.routes.auth import require_auth, verify_token
8
 
9
  # ------------------------------------------------------------------------------
10
  # Unit Tests — verify_token & require_auth
 
85
  headers = {"Authorization": f"Bearer {secret}"}
86
 
87
  protected_endpoints = [
88
+ (
89
+ "POST",
90
+ "/agentcache/observe",
91
+ {"folderPath": "src/test", "agentId": "a1", "text": "test"},
92
+ ),
93
  ("POST", "/agentcache/remember", {"content": "test memory"}),
94
  ("POST", "/agentcache/search", {"query": "test"}),
95
  ("POST", "/agentcache/timeline", {}),
 
108
  res_unauth = client.get(path)
109
  res_auth = client.get(path, headers=headers)
110
 
111
+ assert res_unauth.status_code == 401, (
112
+ f"{method} {path} should require auth (got {res_unauth.status_code})"
113
+ )
114
+ assert res_auth.status_code in (200, 201), (
115
+ f"{method} {path} failed with valid auth (got {res_auth.status_code})"
116
+ )
117
 
118
 
119
  def test_unprotected_routes_accessible_without_auth(authed_client):
 
128
 
129
  for path in unprotected_paths:
130
  res = client.get(path)
131
+ assert res.status_code == 200, (
132
+ f"Unprotected route {path} failed (got {res.status_code})"
133
+ )
134
 
135
 
136
  def test_wrong_token_on_any_blueprint_returns_401(authed_client):
 
139
  bad_headers = {"Authorization": "Bearer wrong-token-value"}
140
 
141
  protected_endpoints = [
142
+ (
143
+ "POST",
144
+ "/agentcache/observe",
145
+ {"folderPath": "src/test", "agentId": "a1", "text": "test"},
146
+ ),
147
  ("POST", "/agentcache/remember", {"content": "test memory"}),
148
  ("POST", "/agentcache/search", {"query": "test"}),
149
  ("POST", "/agentcache/timeline", {}),
 
153
  ("POST", "/agentcache/migrate", {}),
154
  ]
155
 
 
156
  for method, path, payload in protected_endpoints:
157
  if method == "POST":
158
  res = client.post(path, json=payload or {}, headers=bad_headers)
tests/test_auto_forget.py DELETED
@@ -1,139 +0,0 @@
1
- """Unit tests for auto_forget() folder-based and memory-based eviction."""
2
-
3
- import datetime
4
- import os
5
-
6
- from agentcache.db import StateKV
7
- from agentcache.functions import KV, auto_forget, folder_observe, remember
8
-
9
-
10
- def make_kv(tmp_path):
11
- db_path = os.path.join(str(tmp_path), "test.db")
12
- return StateKV(db_path=db_path)
13
-
14
-
15
- def test_auto_forget_memories(tmp_path):
16
- kv = make_kv(tmp_path)
17
-
18
- # 1. Create a memory that expires in the past
19
- past_time = (
20
- (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1))
21
- .isoformat()
22
- .replace("+00:00", "Z")
23
- )
24
- res1 = remember(kv, {"content": "Stale memory", "forgetAfter": past_time})
25
- mem1_id = res1["memory"]["id"]
26
-
27
- # 2. Create a memory that expires in the future
28
- future_time = (
29
- (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=5))
30
- .isoformat()
31
- .replace("+00:00", "Z")
32
- )
33
- res2 = remember(kv, {"content": "Fresh memory", "forgetAfter": future_time})
34
- mem2_id = res2["memory"]["id"]
35
-
36
- # Run auto_forget
37
- results = auto_forget(kv, dry_run=False)
38
- assert len(results["evictedMemories"]) == 1
39
-
40
- # Verify mem1 is deleted, mem2 exists
41
- assert kv.get(KV.memories, mem1_id) is None
42
- assert kv.get(KV.memories, mem2_id) is not None
43
-
44
-
45
- def test_auto_forget_expired_folder_observations(tmp_path):
46
- kv = make_kv(tmp_path)
47
- folder = "/home/user/myproject"
48
- agent = "kiro"
49
-
50
- # 1. Create expired folder observation
51
- past_time = (
52
- (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1))
53
- .isoformat()
54
- .replace("+00:00", "Z")
55
- )
56
- res1 = folder_observe(
57
- kv,
58
- {
59
- "folderPath": folder,
60
- "agentId": agent,
61
- "text": "Stale observation",
62
- "timestamp": past_time,
63
- "forgetAfter": past_time,
64
- },
65
- )
66
- obs1_id = res1["observationId"]
67
-
68
- # 2. Create fresh folder observation
69
- future_time = (
70
- (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=1))
71
- .isoformat()
72
- .replace("+00:00", "Z")
73
- )
74
- res2 = folder_observe(
75
- kv,
76
- {
77
- "folderPath": folder,
78
- "agentId": agent,
79
- "text": "Fresh observation",
80
- "timestamp": past_time,
81
- "forgetAfter": future_time,
82
- },
83
- )
84
- obs2_id = res2["observationId"]
85
-
86
- # Run auto_forget
87
- results = auto_forget(kv, dry_run=False)
88
- assert len(results["evictedObservations"]) == 1
89
-
90
- # Verify eviction
91
- fp = "home/user/myproject"
92
- assert kv.get(KV.folder_obs(fp, agent), obs1_id) is None
93
- assert kv.get(KV.folder_obs(fp, agent), obs2_id) is not None
94
-
95
-
96
- def test_auto_forget_low_importance_stale_observations(tmp_path):
97
- kv = make_kv(tmp_path)
98
- folder = "/home/user/myproject"
99
- agent = "kiro"
100
-
101
- # 1. Create old low-importance folder observation (importance = 1, 200 days old)
102
- old_time = (
103
- (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=200))
104
- .isoformat()
105
- .replace("+00:00", "Z")
106
- )
107
- res1 = folder_observe(
108
- kv,
109
- {
110
- "folderPath": folder,
111
- "agentId": agent,
112
- "text": "Stale low value observation",
113
- "timestamp": old_time,
114
- "importance": 1,
115
- },
116
- )
117
- obs1_id = res1["observationId"]
118
-
119
- # 2. Create old high-importance folder observation (importance = 8, 200 days old)
120
- res2 = folder_observe(
121
- kv,
122
- {
123
- "folderPath": folder,
124
- "agentId": agent,
125
- "text": "Stale high value observation",
126
- "timestamp": old_time,
127
- "importance": 8,
128
- },
129
- )
130
- obs2_id = res2["observationId"]
131
-
132
- # Run auto_forget
133
- results = auto_forget(kv, dry_run=False)
134
- assert len(results["evictedObservations"]) == 1
135
-
136
- # Verify eviction
137
- fp = "home/user/myproject"
138
- assert kv.get(KV.folder_obs(fp, agent), obs1_id) is None
139
- assert kv.get(KV.folder_obs(fp, agent), obs2_id) is not None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_cli_context.py DELETED
@@ -1,70 +0,0 @@
1
- """Unit tests for the agentcache context CLI command."""
2
-
3
- import argparse
4
- import os
5
- from unittest.mock import patch
6
-
7
- from agentcache.cli import cmd_context
8
- from agentcache.db import StateKV
9
- from agentcache.functions import folder_observe, remember
10
-
11
-
12
- def make_kv(tmp_path):
13
- db_path = os.path.join(str(tmp_path), "test.db")
14
- return StateKV(db_path=db_path)
15
-
16
-
17
- def test_cli_context_generation(tmp_path):
18
- kv = make_kv(tmp_path)
19
-
20
- # 1. Add observations and memories
21
- folder = "/home/user/myproject"
22
- agent = "test-agent"
23
-
24
- folder_observe(
25
- kv,
26
- {
27
- "folderPath": folder,
28
- "agentId": agent,
29
- "text": "First observation",
30
- "timestamp": "2026-07-15T10:00:00Z",
31
- },
32
- )
33
-
34
- remember(
35
- kv, {"content": "A crucial project rule", "type": "fact", "agentId": agent}
36
- )
37
-
38
- remember(
39
- kv,
40
- {
41
- "content": "A project-wide memory",
42
- "type": "architecture",
43
- "project": "myproject",
44
- "agentId": "some-other-agent",
45
- },
46
- )
47
-
48
- output_file = os.path.join(str(tmp_path), "context.md")
49
- args = argparse.Namespace(agent=agent, output=output_file, watch=False)
50
-
51
- # Mock os.getcwd to match the project path and init_services to return our test db
52
- with (
53
- patch("os.getcwd", return_value=folder),
54
- patch("agentcache.app.init_services", return_value=(kv, None, None)),
55
- ):
56
- cmd_context(args)
57
-
58
- # 2. Verify file output
59
- assert os.path.exists(output_file)
60
- with open(output_file, "r", encoding="utf-8") as f:
61
- content = f.read()
62
-
63
- # Check that metadata and values are written
64
- assert "Agent Cache Context" in content
65
- assert "Project Metadata" in content
66
- assert "myproject" in content
67
- assert "test-agent" in content
68
- assert "First observation" in content
69
- assert "A crucial project rule" in content
70
- assert "A project-wide memory" in content
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_context.py DELETED
@@ -1,204 +0,0 @@
1
- """
2
- tests/test_context.py — C1.4
3
-
4
- Tests for context(), export_data(), and token budget enforcement.
5
- """
6
-
7
- import datetime
8
- import os
9
-
10
- import pytest
11
-
12
- # ---------------------------------------------------------------------------
13
- # Helpers
14
- # ---------------------------------------------------------------------------
15
-
16
-
17
- def _make_kv(tmp_path):
18
- from agentcache.db import StateKV
19
-
20
- os.environ.pop("AGENTCACHE_SECRET", None)
21
- return StateKV(db_path=str(tmp_path / "test.db"))
22
-
23
-
24
- def _now():
25
- return (
26
- datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
27
- )
28
-
29
-
30
- # ---------------------------------------------------------------------------
31
- # context() tests
32
- # ---------------------------------------------------------------------------
33
-
34
-
35
- class TestContext:
36
- def test_empty_db_returns_minimal_context(self, tmp_path):
37
- """Empty DB should return a well-formed but empty context."""
38
- from agentcache.functions import context
39
-
40
- kv = _make_kv(tmp_path)
41
- result = context(
42
- kv,
43
- {
44
- "sessionId": "sess_test_empty",
45
- "project": "/home/user/my-project",
46
- "budget": 2000,
47
- },
48
- )
49
- assert isinstance(result, dict)
50
- assert "context" in result
51
- assert "blocks" in result
52
- assert "tokens" in result
53
- assert result["blocks"] == 0
54
- assert result["tokens"] == 0
55
-
56
- def test_raises_on_missing_session_id(self, tmp_path):
57
- from agentcache.functions import context
58
-
59
- kv = _make_kv(tmp_path)
60
- with pytest.raises(ValueError):
61
- context(kv, {"project": "/home/user/proj"})
62
-
63
- def test_raises_on_missing_project(self, tmp_path):
64
- from agentcache.functions import context
65
-
66
- kv = _make_kv(tmp_path)
67
- with pytest.raises(ValueError):
68
- context(kv, {"sessionId": "sess_x"})
69
-
70
- def test_respects_token_budget(self, tmp_path):
71
- """Context output tokens should not exceed the requested budget."""
72
- from agentcache.functions import context, lesson_save
73
-
74
- kv = _make_kv(tmp_path)
75
- project = "/home/user/budget-test"
76
-
77
- # Add many lessons to push towards the budget
78
- for i in range(20):
79
- lesson_save(
80
- kv,
81
- {
82
- "content": f"Lesson {i}: " + ("x " * 100),
83
- "project": project,
84
- "confidence": 0.9,
85
- },
86
- )
87
-
88
- budget = 500
89
- result = context(
90
- kv,
91
- {
92
- "sessionId": "sess_budget",
93
- "project": project,
94
- "budget": budget,
95
- },
96
- )
97
- # Token estimate is len/3 — check that total tokens respects budget
98
- assert result["tokens"] <= budget + 50 # small headroom for header/footer
99
-
100
- def test_context_includes_xml_wrapper(self, tmp_path):
101
- """Non-empty context should be wrapped in <agentcache-context>."""
102
- from agentcache.functions import context, lesson_save
103
-
104
- kv = _make_kv(tmp_path)
105
- project = "/home/user/xml-test"
106
-
107
- lesson_save(
108
- kv,
109
- {
110
- "content": "Always validate user input before processing",
111
- "project": project,
112
- "confidence": 0.8,
113
- },
114
- )
115
-
116
- result = context(
117
- kv,
118
- {
119
- "sessionId": "sess_xml",
120
- "project": project,
121
- "budget": 2000,
122
- },
123
- )
124
-
125
- if result["blocks"] > 0:
126
- assert "<agentcache-context" in result["context"]
127
- assert "</agentcache-context>" in result["context"]
128
-
129
- def test_token_budget_env_var_respected(self, tmp_path, monkeypatch):
130
- """TOKEN_BUDGET env var should be used when no budget param given."""
131
- from agentcache.functions import context, lesson_save
132
-
133
- kv = _make_kv(tmp_path)
134
- project = "/home/user/env-budget"
135
- monkeypatch.setenv("TOKEN_BUDGET", "100")
136
-
137
- for i in range(10):
138
- lesson_save(
139
- kv,
140
- {
141
- "content": f"Important lesson {i}: " + ("word " * 50),
142
- "project": project,
143
- "confidence": 0.9,
144
- },
145
- )
146
-
147
- result = context(kv, {"sessionId": "sess_env_budget", "project": project})
148
- # Should use TOKEN_BUDGET=100 from env
149
- assert result["tokens"] <= 150 # with some headroom for XML wrapper
150
-
151
-
152
- # ---------------------------------------------------------------------------
153
- # export_data() tests
154
- # ---------------------------------------------------------------------------
155
-
156
-
157
- class TestExportData:
158
- def test_export_returns_folders_and_memories(self, tmp_path):
159
- from agentcache.functions import export_data, folder_observe, remember
160
-
161
- kv = _make_kv(tmp_path)
162
-
163
- folder_observe(
164
- kv,
165
- {
166
- "folderPath": "/home/user/export-test",
167
- "agentId": "kiro",
168
- "text": "Working on export feature",
169
- "timestamp": _now(),
170
- },
171
- )
172
- remember(kv, {"content": "Export data uses v2 format"})
173
-
174
- result = export_data(kv, {})
175
- assert isinstance(result, dict)
176
- assert "folders" in result or "observations" in result or "memories" in result
177
-
178
- def test_export_empty_db(self, tmp_path):
179
- from agentcache.functions import export_data
180
-
181
- kv = _make_kv(tmp_path)
182
- result = export_data(kv, {})
183
- assert isinstance(result, dict)
184
- # Should not crash on empty DB
185
-
186
-
187
- # ---------------------------------------------------------------------------
188
- # estimate_tokens()
189
- # ---------------------------------------------------------------------------
190
-
191
-
192
- class TestEstimateTokens:
193
- def test_empty_string(self):
194
- from agentcache.functions import estimate_tokens
195
-
196
- assert estimate_tokens("") == 0
197
-
198
- def test_typical_text(self):
199
- from agentcache.functions import estimate_tokens
200
-
201
- text = "hello world this is a test" * 10
202
- tokens = estimate_tokens(text)
203
- # Should be approximately len/3
204
- assert tokens == len(text) // 3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_debounce.py DELETED
@@ -1,166 +0,0 @@
1
- """A4.3 — Unit tests for IndexPersistence debounce behavior.
2
-
3
- Tests:
4
- - 100 rapid schedule_save() calls result in exactly 1 save() call.
5
- - flush() triggers immediate save without waiting for debounce timer.
6
- """
7
-
8
- import time
9
- import unittest.mock as mock
10
-
11
- from agentcache.db import StateKV
12
- from agentcache.functions import IndexPersistence
13
- from agentcache.search import SearchIndex, VectorIndex
14
-
15
- # Speed up debounce for tests
16
- FAST_DEBOUNCE = 0.05
17
-
18
-
19
- def make_kv(tmp_path):
20
- return StateKV(db_path=str(tmp_path / "test_debounce.db"))
21
-
22
-
23
- class TestDebounce:
24
- def test_100_rapid_calls_result_in_1_save(self, tmp_path):
25
- """100 rapid schedule_save() calls must fire exactly 1 save()."""
26
- kv = make_kv(tmp_path)
27
- bm25 = SearchIndex()
28
- vector = VectorIndex()
29
-
30
- persistence = IndexPersistence(kv, bm25, vector)
31
- persistence.DEBOUNCE_SECONDS = FAST_DEBOUNCE
32
-
33
- save_call_count = [0]
34
-
35
- original_save = persistence.save
36
-
37
- def counting_save():
38
- save_call_count[0] += 1
39
- original_save()
40
-
41
- with mock.patch.object(persistence, "save", side_effect=counting_save):
42
- for _ in range(100):
43
- persistence.schedule_save()
44
- # Wait for the debounce timer to fire (2× debounce window is plenty)
45
- time.sleep(FAST_DEBOUNCE * 4)
46
-
47
- assert save_call_count[0] == 1, (
48
- f"Expected exactly 1 save() call; got {save_call_count[0]}"
49
- )
50
-
51
- def test_rapid_calls_with_dirty_bm25(self, tmp_path):
52
- """schedule_save() fires exactly once even when BM25 is dirty."""
53
- kv = make_kv(tmp_path)
54
- bm25 = SearchIndex()
55
- vector = VectorIndex()
56
-
57
- # Add a doc so the index is dirty
58
- bm25.add(
59
- {
60
- "id": "obs_test1",
61
- "sessionId": "sess1",
62
- "title": "hello world",
63
- "facts": [],
64
- "concepts": [],
65
- "files": [],
66
- "type": "other",
67
- }
68
- )
69
-
70
- persistence = IndexPersistence(kv, bm25, vector)
71
- persistence.DEBOUNCE_SECONDS = FAST_DEBOUNCE
72
-
73
- save_call_count = [0]
74
- original_save = persistence.save
75
-
76
- def counting_save():
77
- save_call_count[0] += 1
78
- original_save()
79
-
80
- with mock.patch.object(persistence, "save", side_effect=counting_save):
81
- for _ in range(100):
82
- persistence.schedule_save()
83
- time.sleep(FAST_DEBOUNCE * 4)
84
-
85
- assert save_call_count[0] == 1
86
-
87
- def test_flush_triggers_immediate_save(self, tmp_path):
88
- """flush() must call save() immediately without waiting for the debounce timer."""
89
- kv = make_kv(tmp_path)
90
- bm25 = SearchIndex()
91
- vector = VectorIndex()
92
-
93
- persistence = IndexPersistence(kv, bm25, vector)
94
- persistence.DEBOUNCE_SECONDS = 60.0 # very long timer — flush must bypass it
95
-
96
- save_call_count = [0]
97
- original_save = persistence.save
98
-
99
- def counting_save():
100
- save_call_count[0] += 1
101
- original_save()
102
-
103
- with mock.patch.object(persistence, "save", side_effect=counting_save):
104
- persistence.schedule_save()
105
- # Timer is set but hasn't fired yet (60s window)
106
- assert save_call_count[0] == 0, "save() should not have been called yet"
107
-
108
- # flush() must cancel the timer and call save() synchronously
109
- persistence.flush()
110
-
111
- assert save_call_count[0] == 1, (
112
- f"flush() should trigger exactly 1 save(); got {save_call_count[0]}"
113
- )
114
-
115
- def test_flush_after_no_pending_save_is_safe(self, tmp_path):
116
- """flush() with no pending timer should still call save() once."""
117
- kv = make_kv(tmp_path)
118
- bm25 = SearchIndex()
119
- vector = VectorIndex()
120
-
121
- persistence = IndexPersistence(kv, bm25, vector)
122
-
123
- save_call_count = [0]
124
- original_save = persistence.save
125
-
126
- def counting_save():
127
- save_call_count[0] += 1
128
- original_save()
129
-
130
- with mock.patch.object(persistence, "save", side_effect=counting_save):
131
- persistence.flush()
132
-
133
- assert save_call_count[0] == 1
134
-
135
- def test_subsequent_schedule_after_fire_starts_new_timer(self, tmp_path):
136
- """Two bursts of saves separated by more than DEBOUNCE_SECONDS should fire 2 saves."""
137
- kv = make_kv(tmp_path)
138
- bm25 = SearchIndex()
139
- vector = VectorIndex()
140
-
141
- persistence = IndexPersistence(kv, bm25, vector)
142
- persistence.DEBOUNCE_SECONDS = FAST_DEBOUNCE
143
-
144
- save_call_count = [0]
145
- original_save = persistence.save
146
-
147
- def counting_save():
148
- save_call_count[0] += 1
149
- original_save()
150
-
151
- with mock.patch.object(persistence, "save", side_effect=counting_save):
152
- # First burst
153
- for _ in range(10):
154
- persistence.schedule_save()
155
- # Wait for first timer to fire
156
- time.sleep(FAST_DEBOUNCE * 4)
157
-
158
- # Second burst
159
- for _ in range(10):
160
- persistence.schedule_save()
161
- # Wait for second timer to fire
162
- time.sleep(FAST_DEBOUNCE * 4)
163
-
164
- assert save_call_count[0] == 2, (
165
- f"Expected 2 save() calls for two separate bursts; got {save_call_count[0]}"
166
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_folder_graph_build.py DELETED
@@ -1,432 +0,0 @@
1
- """Unit tests for folderColor() and folder_graph_build() — REQ-023–REQ-028."""
2
-
3
- import pytest
4
-
5
- from agentcache.db import StateKV
6
- from agentcache.functions import KV, folder_graph_build
7
- from agentcache.functions import folder_color as folderColor
8
-
9
- # ---------------------------------------------------------------------------
10
- # Fixtures
11
- # ---------------------------------------------------------------------------
12
-
13
-
14
- @pytest.fixture()
15
- def kv(tmp_path):
16
- """Return a fresh in-file StateKV backed by a temp SQLite database."""
17
- db_file = str(tmp_path / "test.db")
18
- return StateKV(db_path=db_file)
19
-
20
-
21
- def _write_pair(
22
- kv: StateKV,
23
- folder_path: str,
24
- agent_id: str,
25
- obs_texts: list = None,
26
- obs_count: int = None,
27
- ) -> None:
28
- """Insert a (folder_path, agent_id) entry into KV.folders and optionally write observations."""
29
- obs_texts = obs_texts or []
30
- count = obs_count if obs_count is not None else len(obs_texts)
31
-
32
- # Write folders index entry
33
- index_key = f"{folder_path}:{agent_id}"
34
- kv.set(
35
- KV.folders,
36
- index_key,
37
- {
38
- "folderPath": folder_path,
39
- "agentId": agent_id,
40
- "obsCount": count,
41
- "lastUpdated": "2025-01-15T12:00:00.000Z",
42
- },
43
- )
44
-
45
- # Write observation objects if text supplied
46
- for i, text in enumerate(obs_texts):
47
- obs_id = f"obs_{folder_path.replace('/', '_')}_{agent_id}_{i}"
48
- obs = {
49
- "id": obs_id,
50
- "folderPath": folder_path,
51
- "agentId": agent_id,
52
- "timestamp": "2025-01-15T12:00:00.000Z",
53
- "text": text,
54
- "type": "other",
55
- "title": f"title {i}",
56
- "concepts": [],
57
- "files": [],
58
- "importance": 5,
59
- }
60
- kv.set(KV.folder_obs(folder_path, agent_id), obs_id, obs)
61
-
62
-
63
- # ---------------------------------------------------------------------------
64
- # Tests — folderColor helper
65
- # ---------------------------------------------------------------------------
66
-
67
-
68
- def test_folder_color_returns_hsl_string():
69
- """folderColor should return a string matching hsl(...) format."""
70
- color = folderColor("projects/alpha")
71
- assert color.startswith("hsl(")
72
- assert color.endswith(")")
73
-
74
-
75
- def test_folder_color_deterministic():
76
- """Same path always returns the same color."""
77
- assert folderColor("projects/alpha") == folderColor("projects/alpha")
78
-
79
-
80
- def test_folder_color_different_paths_produce_different_colors():
81
- """Different paths should (almost always) produce different colors."""
82
- # Use very distinct paths to ensure hash difference
83
- assert folderColor("projects/alpha") != folderColor(
84
- "projects/omega-completely-different"
85
- )
86
-
87
-
88
- def test_folder_color_hsl_values_in_range():
89
- """HSL values should be within expected ranges."""
90
- color = folderColor("some/path")
91
- # Strip "hsl(" and ")" then parse
92
- inner = color[4:-1] # e.g. "200, 70%, 55%"
93
- parts = [p.strip().rstrip("%") for p in inner.split(",")]
94
- hue, sat, lig = int(parts[0]), int(parts[1]), int(parts[2])
95
- assert 0 <= hue < 360
96
- assert 55 <= sat <= 79 # 55 + (h % 25)
97
- assert 38 <= lig <= 51 # 38 + (h % 14)
98
-
99
-
100
- def test_folder_color_empty_string():
101
- """folderColor on empty string should not raise."""
102
- color = folderColor("")
103
- assert color.startswith("hsl(")
104
-
105
-
106
- # ---------------------------------------------------------------------------
107
- # Tests — empty KV returns empty graph (REQ-023)
108
- # ---------------------------------------------------------------------------
109
-
110
-
111
- def test_empty_kv_returns_empty_graph(kv):
112
- """Empty KV returns {nodes: [], edges: []}."""
113
- result = folder_graph_build(kv)
114
- assert result == {"nodes": [], "edges": []}
115
-
116
-
117
- # ---------------------------------------------------------------------------
118
- # Tests — node construction (REQ-023, REQ-024)
119
- # ---------------------------------------------------------------------------
120
-
121
-
122
- def test_one_node_per_unique_folder_path(kv):
123
- """Two agents in the same folder produce a single node (REQ-023)."""
124
- _write_pair(kv, "projects/alpha", "kiro", obs_count=3)
125
- _write_pair(kv, "projects/alpha", "claude", obs_count=2)
126
-
127
- result = folder_graph_build(kv)
128
- assert len(result["nodes"]) == 1
129
- node = result["nodes"][0]
130
- assert node["folderPath"] == "projects/alpha"
131
-
132
-
133
- def test_multiple_folders_produce_multiple_nodes(kv):
134
- """Each distinct folder_path produces exactly one node."""
135
- _write_pair(kv, "projects/alpha", "kiro")
136
- _write_pair(kv, "projects/beta", "kiro")
137
- _write_pair(kv, "projects/gamma", "claude")
138
-
139
- result = folder_graph_build(kv)
140
- folder_paths = {n["folderPath"] for n in result["nodes"]}
141
- assert folder_paths == {"projects/alpha", "projects/beta", "projects/gamma"}
142
-
143
-
144
- def test_node_fields_present(kv):
145
- """Each node contains all required fields (REQ-024)."""
146
- _write_pair(kv, "projects/alpha", "kiro", obs_count=5)
147
-
148
- result = folder_graph_build(kv)
149
- node = result["nodes"][0]
150
- assert "id" in node
151
- assert "label" in node
152
- assert "folderPath" in node
153
- assert "agentIds" in node
154
- assert "obsCount" in node
155
- assert "color" in node
156
-
157
-
158
- def test_node_id_equals_folder_path(kv):
159
- """Node id is the folderPath string."""
160
- _write_pair(kv, "projects/alpha", "kiro")
161
-
162
- result = folder_graph_build(kv)
163
- node = result["nodes"][0]
164
- assert node["id"] == "projects/alpha"
165
- assert node["folderPath"] == "projects/alpha"
166
-
167
-
168
- def test_node_label_is_basename(kv):
169
- """Node label is the last path component."""
170
- _write_pair(kv, "home/user/projects/myapp", "kiro")
171
-
172
- result = folder_graph_build(kv)
173
- node = result["nodes"][0]
174
- assert node["label"] == "myapp"
175
-
176
-
177
- def test_node_agent_ids_aggregated_and_sorted(kv):
178
- """agentIds is the sorted union of all agents for that folder."""
179
- _write_pair(kv, "projects/alpha", "zorro", obs_count=1)
180
- _write_pair(kv, "projects/alpha", "alice", obs_count=1)
181
- _write_pair(kv, "projects/alpha", "bob", obs_count=1)
182
-
183
- result = folder_graph_build(kv)
184
- node = result["nodes"][0]
185
- assert node["agentIds"] == ["alice", "bob", "zorro"]
186
-
187
-
188
- def test_node_obs_count_summed_across_agents(kv):
189
- """obsCount is the sum across all agents for that folder."""
190
- _write_pair(kv, "projects/alpha", "kiro", obs_count=4)
191
- _write_pair(kv, "projects/alpha", "claude", obs_count=6)
192
-
193
- result = folder_graph_build(kv)
194
- node = result["nodes"][0]
195
- assert node["obsCount"] == 10
196
-
197
-
198
- def test_node_color_is_hsl(kv):
199
- """Node color comes from folderColor and is an HSL string."""
200
- _write_pair(kv, "projects/alpha", "kiro")
201
-
202
- result = folder_graph_build(kv)
203
- node = result["nodes"][0]
204
- assert node["color"].startswith("hsl(")
205
- # Must match folderColor directly
206
- assert node["color"] == folderColor("projects/alpha")
207
-
208
-
209
- # ---------------------------------------------------------------------------
210
- # Tests — same-parent edges (REQ-025)
211
- # ---------------------------------------------------------------------------
212
-
213
-
214
- def test_same_parent_edge_created(kv):
215
- """Two folders with the same parent get a same-parent edge."""
216
- _write_pair(kv, "projects/alpha", "kiro")
217
- _write_pair(kv, "projects/beta", "kiro") # both under "projects"
218
-
219
- result = folder_graph_build(kv)
220
- same_parent_edges = [e for e in result["edges"] if e["type"] == "same-parent"]
221
- assert len(same_parent_edges) == 1
222
- edge = same_parent_edges[0]
223
- assert set([edge["source"], edge["target"]]) == {"projects/alpha", "projects/beta"}
224
-
225
-
226
- def test_no_same_parent_edge_for_different_parents(kv):
227
- """Folders with different parents do not get a same-parent edge."""
228
- _write_pair(kv, "projects/alpha", "kiro")
229
- _write_pair(kv, "work/beta", "kiro")
230
-
231
- result = folder_graph_build(kv)
232
- same_parent_edges = [e for e in result["edges"] if e["type"] == "same-parent"]
233
- assert same_parent_edges == []
234
-
235
-
236
- def test_same_parent_edge_only_for_sharing_pairs(kv):
237
- """Only pairs sharing a parent get same-parent edges; non-sharing pairs do not."""
238
- _write_pair(kv, "a/x", "kiro")
239
- _write_pair(kv, "a/y", "kiro") # shares parent "a" with a/x
240
- _write_pair(kv, "b/z", "kiro") # different parent "b"
241
-
242
- result = folder_graph_build(kv)
243
- same_parent_edges = [e for e in result["edges"] if e["type"] == "same-parent"]
244
- assert len(same_parent_edges) == 1
245
- edge = same_parent_edges[0]
246
- assert set([edge["source"], edge["target"]]) == {"a/x", "a/y"}
247
-
248
-
249
- # ---------------------------------------------------------------------------
250
- # Tests — cross-reference edges (REQ-026)
251
- # ---------------------------------------------------------------------------
252
-
253
-
254
- def test_cross_ref_edge_when_obs_mentions_other_folder(kv):
255
- """A cross-ref edge is created when folder A's obs text mentions folder B's path."""
256
- _write_pair(
257
- kv, "projects/alpha", "kiro", obs_texts=["I worked on projects/beta today"]
258
- )
259
- _write_pair(kv, "projects/beta", "kiro", obs_texts=["nothing"])
260
-
261
- result = folder_graph_build(kv)
262
- cross_edges = [e for e in result["edges"] if e["type"] == "cross-ref"]
263
- assert len(cross_edges) >= 1
264
- sources_targets = {(e["source"], e["target"]) for e in cross_edges}
265
- assert ("projects/alpha", "projects/beta") in sources_targets
266
-
267
-
268
- def test_no_cross_ref_edge_when_no_mention(kv):
269
- """No cross-ref edge when obs texts don't mention another folder path."""
270
- _write_pair(kv, "projects/alpha", "kiro", obs_texts=["Just some work here"])
271
- _write_pair(kv, "projects/beta", "kiro", obs_texts=["Unrelated content"])
272
-
273
- result = folder_graph_build(kv)
274
- cross_edges = [e for e in result["edges"] if e["type"] == "cross-ref"]
275
- assert cross_edges == []
276
-
277
-
278
- def test_cross_ref_edge_from_title_mention(kv):
279
- """Cross-ref edges are also detected via obs titles."""
280
- _write_pair(kv, "projects/alpha", "kiro", obs_texts=["some text"])
281
- # Manually insert obs with a title that mentions the other folder
282
- obs = {
283
- "id": "obs_special",
284
- "folderPath": "projects/alpha",
285
- "agentId": "kiro",
286
- "timestamp": "2025-01-15T12:00:00.000Z",
287
- "text": "normal text",
288
- "type": "other",
289
- "title": "work on projects/beta",
290
- "concepts": [],
291
- "files": [],
292
- "importance": 5,
293
- }
294
- kv.set(KV.folder_obs("projects/alpha", "kiro"), "obs_special", obs)
295
- _write_pair(kv, "projects/beta", "kiro", obs_texts=["nothing"])
296
-
297
- result = folder_graph_build(kv)
298
- cross_edges = [e for e in result["edges"] if e["type"] == "cross-ref"]
299
- sources = {e["source"] for e in cross_edges}
300
- assert "projects/alpha" in sources
301
-
302
-
303
- # ---------------------------------------------------------------------------
304
- # Tests — agent-shared edges (REQ-027)
305
- # ---------------------------------------------------------------------------
306
-
307
-
308
- def test_agent_shared_edge_created(kv):
309
- """Two folders with a common agent get an agent-shared edge."""
310
- _write_pair(kv, "projects/alpha", "kiro")
311
- _write_pair(kv, "projects/beta", "kiro") # same agent "kiro"
312
-
313
- result = folder_graph_build(kv)
314
- agent_edges = [e for e in result["edges"] if e["type"] == "agent-shared"]
315
- assert len(agent_edges) >= 1
316
- edge = agent_edges[0]
317
- assert set([edge["source"], edge["target"]]) == {"projects/alpha", "projects/beta"}
318
-
319
-
320
- def test_no_agent_shared_edge_when_no_common_agent(kv):
321
- """Folders with no common agents do not get an agent-shared edge."""
322
- _write_pair(kv, "projects/alpha", "kiro")
323
- _write_pair(kv, "projects/beta", "claude") # different agents
324
-
325
- result = folder_graph_build(kv)
326
- agent_edges = [e for e in result["edges"] if e["type"] == "agent-shared"]
327
- assert agent_edges == []
328
-
329
-
330
- def test_agent_shared_edge_with_partial_overlap(kv):
331
- """Two folders with one common agent among several agents still get an edge."""
332
- _write_pair(kv, "projects/alpha", "kiro")
333
- _write_pair(kv, "projects/alpha", "claude")
334
- _write_pair(kv, "projects/beta", "claude")
335
- _write_pair(kv, "projects/beta", "cursor")
336
-
337
- result = folder_graph_build(kv)
338
- agent_edges = [e for e in result["edges"] if e["type"] == "agent-shared"]
339
- endpoints = {frozenset([e["source"], e["target"]]) for e in agent_edges}
340
- assert frozenset({"projects/alpha", "projects/beta"}) in endpoints
341
-
342
-
343
- # ---------------------------------------------------------------------------
344
- # Tests — edge deduplication (REQ-028)
345
- # ---------------------------------------------------------------------------
346
-
347
-
348
- def test_no_duplicate_edges(kv):
349
- """No two edges share the same (source, target, type) pair."""
350
- _write_pair(kv, "projects/alpha", "kiro", obs_texts=["mentions projects/beta"])
351
- _write_pair(kv, "projects/beta", "kiro", obs_texts=["mentions projects/alpha"])
352
-
353
- result = folder_graph_build(kv)
354
- seen = set()
355
- for edge in result["edges"]:
356
- key = (frozenset([edge["source"], edge["target"]]), edge["type"])
357
- assert key not in seen, f"Duplicate edge: {edge}"
358
- seen.add(key)
359
-
360
-
361
- def test_ab_and_ba_treated_as_same_edge(kv):
362
- """(a, b, type) and (b, a, type) are considered the same edge."""
363
- # Both folders reference each other — should produce only one cross-ref edge
364
- _write_pair(
365
- kv, "projects/alpha", "kiro", obs_texts=["See also projects/beta for details"]
366
- )
367
- _write_pair(
368
- kv, "projects/beta", "kiro", obs_texts=["Related to projects/alpha work"]
369
- )
370
-
371
- result = folder_graph_build(kv)
372
- cross_edges = [e for e in result["edges"] if e["type"] == "cross-ref"]
373
- # Should be exactly 1 cross-ref edge (not 2)
374
- assert len(cross_edges) == 1
375
-
376
-
377
- def test_same_parent_and_agent_shared_are_separate_edge_types(kv):
378
- """same-parent and agent-shared edges between the same pair are both kept."""
379
- # Both folders share parent "projects" AND share agent "kiro"
380
- _write_pair(kv, "projects/alpha", "kiro")
381
- _write_pair(kv, "projects/beta", "kiro")
382
-
383
- result = folder_graph_build(kv)
384
- edge_types = {e["type"] for e in result["edges"]}
385
- # We expect both types to appear
386
- assert "same-parent" in edge_types
387
- assert "agent-shared" in edge_types
388
-
389
-
390
- # ---------------------------------------------------------------------------
391
- # Tests — return structure
392
- # ---------------------------------------------------------------------------
393
-
394
-
395
- def test_return_has_nodes_and_edges_keys(kv):
396
- """Result always has 'nodes' and 'edges' keys."""
397
- _write_pair(kv, "projects/alpha", "kiro")
398
- result = folder_graph_build(kv)
399
- assert "nodes" in result
400
- assert "edges" in result
401
-
402
-
403
- def test_edge_has_required_fields(kv):
404
- """Each edge has source, target, and type fields."""
405
- _write_pair(kv, "projects/alpha", "kiro")
406
- _write_pair(kv, "projects/beta", "kiro")
407
-
408
- result = folder_graph_build(kv)
409
- for edge in result["edges"]:
410
- assert "source" in edge
411
- assert "target" in edge
412
- assert "type" in edge
413
-
414
-
415
- def test_single_folder_produces_no_edges(kv):
416
- """A graph with only one folder produces no edges."""
417
- _write_pair(kv, "projects/alpha", "kiro")
418
-
419
- result = folder_graph_build(kv)
420
- assert len(result["nodes"]) == 1
421
- assert result["edges"] == []
422
-
423
-
424
- def test_edge_types_are_valid(kv):
425
- """All edge types are one of the three valid values."""
426
- _write_pair(kv, "projects/alpha", "kiro", obs_texts=["mentions projects/beta"])
427
- _write_pair(kv, "projects/beta", "kiro")
428
-
429
- result = folder_graph_build(kv)
430
- valid_types = {"same-parent", "cross-ref", "agent-shared"}
431
- for edge in result["edges"]:
432
- assert edge["type"] in valid_types