Spaces:
Configuration error
Configuration error
Raplh 2 in process
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .scratch/arch-improvements/issues/01-appcontext-expand.md +0 -16
- .scratch/arch-improvements/issues/02-privacy-module.md +0 -17
- .scratch/arch-improvements/issues/03-auth-middleware.md +0 -16
- .scratch/arch-improvements/issues/04-mcp-tool-registry.md +0 -17
- .scratch/arch-improvements/issues/05-split-observations-memories.md +0 -23
- .scratch/arch-improvements/issues/06-split-indexing-retrieval.md +0 -24
- .scratch/arch-improvements/issues/07-split-consolidation-slots-lessons.md +0 -25
- .scratch/arch-improvements/issues/08-delete-functions-shim.md +0 -23
- .scratch/arch-improvements/issues/09-typed-observations-table-expand.md +0 -19
- .scratch/arch-improvements/issues/10-typed-observations-reads-contract.md +0 -18
- .scratch/arch-improvements/issues/11-basevectorindex-protocol.md +0 -17
- src/agentcache.egg-info/PKG-INFO +2 -484
- src/agentcache.egg-info/SOURCES.txt +6 -1
- src/agentcache/__init__.py +6 -9
- src/agentcache/app.py +36 -22
- src/agentcache/cache/__init__.py +3 -9
- src/agentcache/cache/context.py +1 -1
- src/agentcache/cache/graph.py +1 -1
- src/agentcache/cache/health.py +1 -1
- src/agentcache/cache/observe.py +1 -1
- src/agentcache/cache/remember.py +1 -1
- src/agentcache/cache/timeline.py +1 -1
- src/agentcache/cli.py +4 -3
- src/agentcache/core/__init__.py +7 -2
- src/agentcache/core/kv_scopes.py +0 -2
- src/agentcache/core/observation_store.py +18 -15
- src/agentcache/core/search_service.py +17 -6
- src/agentcache/functions.py +0 -0
- src/agentcache/legacy.py +12 -13
- src/agentcache/replay_import.py +4 -24
- src/agentcache/routes/__init__.py +7 -12
- src/agentcache/routes/graph.py +1 -1
- src/agentcache/routes/health.py +1 -1
- src/agentcache/routes/mcp.py +10 -5
- src/agentcache/routes/memories.py +1 -4
- src/agentcache/routes/migration.py +1 -1
- src/agentcache/routes/observations.py +251 -267
- src/agentcache/routes/search.py +34 -8
- src/agentcache/search.py +1 -0
- src/agentcache/storage/scopes.py +3 -72
- src/agentcache/workers.py +39 -14
- tests/__init__.py +0 -2
- tests/conftest.py +1 -0
- tests/test_api.py +0 -294
- tests/test_auth.py +20 -7
- tests/test_auto_forget.py +0 -139
- tests/test_cli_context.py +0 -70
- tests/test_context.py +0 -204
- tests/test_debounce.py +0 -166
- 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.
|
| 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> •
|
| 65 |
-
<a href="#features">Features</a> •
|
| 66 |
-
<a href="#mcp-integration">MCP</a> •
|
| 67 |
-
<a href="#api-reference">API</a> •
|
| 68 |
-
<a href="#configuration">Config</a> •
|
| 69 |
-
<a href="#deploy-to-huggingface">Deploy</a> •
|
| 70 |
-
<a href="#viewer">Viewer</a> •
|
| 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 .
|
| 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 |
-
"
|
| 26 |
-
"
|
| 27 |
-
"
|
|
|
|
| 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
|
| 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
|
| 45 |
kv = None
|
| 46 |
embedding_provider = None
|
| 47 |
-
persistence = None
|
|
|
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
def init_services() -> tuple:
|
| 51 |
-
"""Initialise database,
|
| 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
|
| 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.
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
)
|
| 110 |
-
|
| 111 |
-
|
|
|
|
| 112 |
print(
|
| 113 |
f"[persistence] Load results: BM25={loaded['bm25']}, Vector={loaded['vector']}"
|
| 114 |
)
|
| 115 |
|
| 116 |
-
#
|
|
|
|
|
|
|
|
|
|
| 117 |
try:
|
| 118 |
-
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 |
-
#
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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 .
|
| 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 .
|
| 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 .
|
|
|
|
| 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__ = [
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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 = [
|
|
|
|
|
|
|
| 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__(
|
|
|
|
|
|
|
| 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 (
|
|
|
|
|
|
|
| 153 |
time.sleep(delay)
|
| 154 |
delay *= 2
|
| 155 |
continue
|
| 156 |
-
print(
|
|
|
|
|
|
|
| 157 |
break
|
| 158 |
except Exception as ex:
|
| 159 |
-
print(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 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:
|
|
|
|
| 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:
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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 .
|
| 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 .
|
|
|
|
| 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 .
|
| 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
|
| 8 |
-
from .health import
|
| 9 |
from .mcp import mcp_bp
|
| 10 |
-
from .memories import
|
| 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 |
-
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(
|
| 27 |
app.register_blueprint(search_bp)
|
| 28 |
-
app.register_blueprint(
|
| 29 |
-
app.register_blueprint(
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 /
|
| 6 |
-
POST /
|
| 7 |
-
GET /
|
| 8 |
-
GET /
|
|
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
import datetime
|
| 12 |
import os
|
|
|
|
| 13 |
|
| 14 |
from flask import Blueprint, jsonify, request
|
| 15 |
|
| 16 |
-
from .. import
|
| 17 |
-
from ..
|
| 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
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
return
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
if
|
| 71 |
-
return
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
"
|
| 77 |
-
|
| 78 |
-
"
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
"
|
| 94 |
-
"
|
| 95 |
-
"
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
}
|
| 98 |
-
), 400
|
| 99 |
-
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
-
|
| 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 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 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 |
-
@
|
| 160 |
-
@
|
| 161 |
-
def
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 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 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 61 |
-
|
| 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 |
-
|
| 88 |
-
|
| 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 |
-
|
| 5 |
-
The KV class defines all storage scope keys used across agentcache-python.
|
| 6 |
"""
|
| 7 |
|
|
|
|
| 8 |
|
| 9 |
-
|
| 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
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 142 |
index_in_sync = True
|
| 143 |
if not index_empty:
|
| 144 |
-
index_in_sync =
|
|
|
|
|
|
|
| 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 |
-
(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
| 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 |
-
(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|