diff --git a/.github/workflows/automation.yml b/.github/workflows/automation.yml index 7569c74b459da1d12045a2d7df17658dc3061890..0c11422b8a6fdf177498fe5246a3cd4d81297c2b 100644 --- a/.github/workflows/automation.yml +++ b/.github/workflows/automation.yml @@ -1,26 +1,13 @@ -name: Test Automation Pipeline +name: Test & Publish Pipeline on: push: branches: [main] - paths-ignore: - - "README.md" - - "CHANGELOG.md" - - "AGENTS.md" - - "ROADMAP.md" - - "**/*.md" + tags: + - 'v*' pull_request: branches: [main] - paths-ignore: - - "README.md" - - "CHANGELOG.md" - - "AGENTS.md" - - "ROADMAP.md" - - "**/*.md" workflow_dispatch: - schedule: - # Run every Sunday at midnight to check for dependency updates and slow/regression bugs - - cron: '0 0 * * 0' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -55,7 +42,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.10", "3.11", "3.12"] steps: @@ -68,7 +55,7 @@ jobs: python-version: ${{ matrix.python-version }} cache: 'pip' - - name: Install Dependencies + - name: Install Package & Dev Dependencies run: | python -m pip install --upgrade pip pip install -e ".[dev]" @@ -79,7 +66,7 @@ jobs: - name: Run Hypothesis Property-Based Tests run: pytest tests/test_properties.py -v --tb=short - build-verification: + build-and-verify: name: Build & Package Verification needs: automated-testing runs-on: ubuntu-latest @@ -92,12 +79,15 @@ jobs: with: python-version: "3.11" - - name: Install Build Tool - run: pip install build + - name: Install Build and Twine + run: pip install build twine - name: Build Package (wheel & sdist) run: python -m build + - name: Verify Package Metadata + run: twine check dist/* + - name: Upload Build Artifacts uses: actions/upload-artifact@v4 with: @@ -105,24 +95,22 @@ jobs: path: dist/ retention-days: 5 - deploy-to-huggingface: - name: Deploy to Hugging Face Spaces - needs: [lint-and-format, automated-testing, build-verification] - if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' + publish-to-pypi: + name: Publish to PyPI + needs: build-and-verify + # Only run on pushes of version tags (e.g. v0.9.8) to the main branch + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest + permissions: + id-token: write steps: - - name: Checkout Code - uses: actions/checkout@v4 + - name: Download Build Artifacts + uses: actions/download-artifact@v4 with: - fetch-depth: 0 - lfs: true - - - name: Push to Hugging Face Space - env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} - run: | - git config --global user.email "bot@github.com" - git config --global user.name "GitHub Action Bot" - git remote add hf https://Yash030:$HF_TOKEN@huggingface.co/spaces/Yash030/agentmemory-python - git push --force hf main:main + name: agentcache-dist + path: dist/ + - name: Publish Package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + print-hash: true diff --git a/Dockerfile b/Dockerfile index 1874efbfc36b989904aa6e66f6fb42c63aa0fc23..ced9208f924b3a99451ef6734c613504c0564d98 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,14 +15,11 @@ RUN useradd -m -u 1000 user # Set up workdir WORKDIR /app -# Copy python dependencies and install -COPY requirements.txt /app/requirements.txt -RUN pip install --no-cache-dir -r /app/requirements.txt - -# Copy application files -COPY --chown=user:user src /app/src -COPY --chown=user:user start.sh /app/start.sh -COPY --chown=user:user sync.py /app/sync.py +# Copy all application files first to support package installation +COPY --chown=user:user . /app + +# Install the package directly in non-editable mode for production deployment +RUN pip install --no-cache-dir /app # Give permissions RUN chmod +x /app/start.sh && chown -R user:user /app /home/user diff --git a/examples/antigravity_sync.py b/examples/antigravity_sync.py index ca3027cf3e9ca677511915512585f3960ede741a..8216dfdbcf36f527939080435ad9906a09d25c11 100644 --- a/examples/antigravity_sync.py +++ b/examples/antigravity_sync.py @@ -146,7 +146,7 @@ def perform_antigravity_sync_local(args): current_prompt = p_text.strip() current_timestamp = step.get("created_at") elif step_type == "PLANNER_RESPONSE" and current_prompt: - ts = current_timestamp or step.get("created_at") or datetime.datetime.utcnow().isoformat() + "Z" + ts = current_timestamp or step.get("created_at") or datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") turns.append({"prompt": current_prompt, "response": step.get("content", ""), "timestamp": ts}) current_prompt = None current_timestamp = None diff --git a/plugin/scripts/notification.py b/plugin/scripts/notification.py index 19ce4cbf667e958751616d401a060d014f6a4dab..eb73025e365a971ca492093aab5465615d88f8e1 100644 --- a/plugin/scripts/notification.py +++ b/plugin/scripts/notification.py @@ -2,7 +2,7 @@ import sys import json import time -from datetime import datetime +from datetime import datetime, timezone from hook_utils import resolve_project, is_sdk_child, api_call_bg def main(): @@ -28,7 +28,7 @@ def main(): "sessionId": session_id, "project": resolve_project(data.get("cwd")), "cwd": data.get("cwd") or "", - "timestamp": datetime.utcnow().isoformat() + "Z", + "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "data": { "notification_type": notification_type, "title": data.get("title"), diff --git a/plugin/scripts/post_tool_failure.py b/plugin/scripts/post_tool_failure.py index 892e282396e038551b3f4e477000164c2d49d454..2fc7da71cec7fb4db05a00d1807f824cf786dbe4 100644 --- a/plugin/scripts/post_tool_failure.py +++ b/plugin/scripts/post_tool_failure.py @@ -2,7 +2,7 @@ import sys import json import time -from datetime import datetime +from datetime import datetime, timezone from hook_utils import resolve_project, is_sdk_child, api_call_bg def main(): @@ -34,7 +34,7 @@ def main(): "sessionId": session_id, "project": resolve_project(data.get("cwd")), "cwd": data.get("cwd") or "", - "timestamp": datetime.utcnow().isoformat() + "Z", + "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "data": { "tool_name": tool_name, "tool_input": limit_str(tool_input), diff --git a/plugin/scripts/post_tool_use.py b/plugin/scripts/post_tool_use.py index 2b1b776d2e8b8b4f09d8348591165a0d40103a0f..8d522ec2e5359ee181416a9baf1d4bf461f239bc 100644 --- a/plugin/scripts/post_tool_use.py +++ b/plugin/scripts/post_tool_use.py @@ -2,7 +2,7 @@ import sys import json import time -from datetime import datetime +from datetime import datetime, timezone from hook_utils import resolve_project, is_sdk_child, api_call_bg def is_base64_image(val): @@ -80,7 +80,7 @@ def main(): "sessionId": session_id, "project": resolve_project(data.get("cwd")), "cwd": data.get("cwd") or "", - "timestamp": datetime.utcnow().isoformat() + "Z", + "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "data": { "tool_name": tool_name, "tool_input": tool_input, diff --git a/plugin/scripts/prompt_submit.py b/plugin/scripts/prompt_submit.py index 5f17ce3aa94249fbb8655c2122abb0b6562aa62d..282488a6e1abda25310fe61cd09bedbac20e08d0 100644 --- a/plugin/scripts/prompt_submit.py +++ b/plugin/scripts/prompt_submit.py @@ -42,7 +42,7 @@ def main(): "sessionId": session_id, "project": project, "cwd": cwd, - "timestamp": datetime.utcnow().isoformat() + "Z", + "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "data": { "prompt": prompt } diff --git a/plugin/scripts/subagent_start.py b/plugin/scripts/subagent_start.py index 5f5cdbef5bb2999089a94d340bffe9a8e09944ee..f1fffa5c0686b71c5c9f6be6ed6a911db41c6bea 100644 --- a/plugin/scripts/subagent_start.py +++ b/plugin/scripts/subagent_start.py @@ -2,7 +2,7 @@ import sys import json import time -from datetime import datetime +from datetime import datetime, timezone from hook_utils import resolve_project, is_sdk_child, api_call_bg def main(): @@ -26,7 +26,7 @@ def main(): "sessionId": session_id, "project": resolve_project(data.get("cwd")), "cwd": data.get("cwd") or "", - "timestamp": datetime.utcnow().isoformat() + "Z", + "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "data": { "agent_id": agent_id, "agent_type": agent_type diff --git a/plugin/scripts/subagent_stop.py b/plugin/scripts/subagent_stop.py index e221565a05b1aaba97f2c9bdbae50818395940c6..57da7c41f85c00218e80b5378e34faca4fba54a1 100644 --- a/plugin/scripts/subagent_stop.py +++ b/plugin/scripts/subagent_stop.py @@ -2,7 +2,7 @@ import sys import json import time -from datetime import datetime +from datetime import datetime, timezone from hook_utils import resolve_project, is_sdk_child, api_call_bg def main(): @@ -32,7 +32,7 @@ def main(): "sessionId": session_id, "project": resolve_project(data.get("cwd")), "cwd": data.get("cwd") or "", - "timestamp": datetime.utcnow().isoformat() + "Z", + "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "data": { "agent_id": agent_id, "agent_type": agent_type, diff --git a/plugin/scripts/task_completed.py b/plugin/scripts/task_completed.py index 23eac7e53bdd26ca6e3781b7c695fb83be182b5a..50c15e6964c3474946ee5472b77766546a8b7c45 100644 --- a/plugin/scripts/task_completed.py +++ b/plugin/scripts/task_completed.py @@ -2,7 +2,7 @@ import sys import json import time -from datetime import datetime +from datetime import datetime, timezone from hook_utils import resolve_project, is_sdk_child, api_call_bg def main(): @@ -29,7 +29,7 @@ def main(): "sessionId": session_id, "project": resolve_project(data.get("cwd")), "cwd": data.get("cwd") or "", - "timestamp": datetime.utcnow().isoformat() + "Z", + "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "data": { "task_id": data.get("task_id"), "task_subject": data.get("task_subject"), diff --git a/pyproject.toml b/pyproject.toml index 1b9e442b654eca98b45603634161204b0b87a65b..2271a8402eecd7daa2155d006f29ee1d6880d9a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,8 +7,26 @@ name = "agentcache" version = "0.9.8" description = "A Python REST + WebSocket + MCP cache server for AI agents, backed by SQLite" readme = "README.md" +authors = [ + { name = "Yash Wanjarwadkar", email = "yashwanjarwadkar@gmail.com" } +] +maintainers = [ + { name = "Yash Wanjarwadkar", email = "yashwanjarwadkar@gmail.com" } +] license = { text = "MIT" } requires-python = ">=3.10" +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Software Development :: Libraries :: Python Modules", +] dependencies = [ "flask>=3.0.0", "flask-sock>=0.7.0", @@ -22,21 +40,37 @@ dependencies = [ dev = [ "pytest>=8.0.0", "hypothesis>=6.100.0", + "ruff>=0.3.0", + "twine>=5.0.0", ] local-embeddings = [ "sentence-transformers>=2.7.0", ] +[project.urls] +Homepage = "https://github.com/Yash030/agentcache-python" +Repository = "https://github.com/Yash030/agentcache-python.git" +"Bug Tracker" = "https://github.com/Yash030/agentcache-python/issues" +Documentation = "https://github.com/Yash030/agentcache-python#readme" + [project.scripts] -agentcache = "src.cli:main" +agentcache = "agentcache.cli:main" [tool.setuptools.packages.find] -where = ["."] -include = ["src*"] +where = ["src"] +include = ["agentcache*"] [tool.setuptools.package-data] -"src.viewer" = ["*.html", "*.svg", "*.js", "*.css"] +"agentcache.viewer" = ["*.html", "*.svg", "*.js", "*.css"] [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"] + +[tool.ruff] +line-length = 88 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] +ignore = ["E501"] diff --git a/requirements.txt b/requirements.txt index f8f0d7b0f7fba8e0cb4454a210784041957d9ee1..d30e9245060f3348e4e2c1faf7495334b793cf83 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1 @@ -flask>=3.0.0 -flask-sock>=0.7.0 -requests>=2.31.0 -websockets>=12.0 -python-dateutil>=2.8.2 -huggingface_hub>=0.20.0 - -# dev -pytest==8.3.5 -hypothesis==6.131.15 +-e .[dev,local-embeddings] diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/src/agentcache.egg-info/PKG-INFO b/src/agentcache.egg-info/PKG-INFO new file mode 100644 index 0000000000000000000000000000000000000000..533ac5fa157ae60c2b7be3e1f8c6fea82548d1c5 --- /dev/null +++ b/src/agentcache.egg-info/PKG-INFO @@ -0,0 +1,520 @@ +Metadata-Version: 2.1 +Name: agentcache +Version: 0.9.8 +Summary: A Python REST + WebSocket + MCP cache server for AI agents, backed by SQLite +Author-email: Yash Wanjarwadkar +Maintainer-email: Yash Wanjarwadkar +License: MIT +Project-URL: Homepage, https://github.com/Yash030/agentcache-python +Project-URL: Repository, https://github.com/Yash030/agentcache-python.git +Project-URL: Bug Tracker, https://github.com/Yash030/agentcache-python/issues +Project-URL: Documentation, https://github.com/Yash030/agentcache-python#readme +Classifier: Development Status :: 4 - Beta +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: MIT License +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence +Classifier: Topic :: Software Development :: Libraries :: Python Modules +Requires-Python: >=3.10 +Description-Content-Type: text/markdown +License-File: LICENSE +Requires-Dist: flask>=3.0.0 +Requires-Dist: flask-sock>=0.7.0 +Requires-Dist: requests>=2.31.0 +Requires-Dist: python-dateutil>=2.8.2 +Requires-Dist: huggingface_hub>=0.20.0 +Requires-Dist: websockets>=12.0 +Provides-Extra: dev +Requires-Dist: pytest>=8.0.0; extra == "dev" +Requires-Dist: hypothesis>=6.100.0; extra == "dev" +Requires-Dist: ruff>=0.3.0; extra == "dev" +Requires-Dist: twine>=5.0.0; extra == "dev" +Provides-Extra: local-embeddings +Requires-Dist: sentence-transformers>=2.7.0; extra == "local-embeddings" + +--- +title: AgentCache Python +emoji: 🧠 +colorFrom: blue +colorTo: indigo +sdk: docker +pinned: false +--- + +

agentcache-python

+ +

+ Persistent memory for AI coding agents β€” pure Python, zero external databases.
+ Works with Claude Code, Cursor, Cline, Windsurf, Gemini CLI, and any MCP client. +

+ +

+ Python 3.10+ + SQLite WAL + Flask 3.0 + MCP Compatible + HuggingFace Space +

+ +

+ Quick Start • + Features • + MCP • + API • + Config • + Deploy • + Viewer • + Architecture +

+ +--- + +## What Is This? + +**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. + +Key differences from the Node.js original: + +- **No Node.js or iii-engine** β€” runs with plain `python src/app.py` +- **SQLite instead of Dolt** β€” single file, WAL mode, instant startup +- **HuggingFace Space ready** β€” deploys in one click, data synced to an HF dataset repo +- **Same REST + MCP wire format** β€” drop-in for any agent already wired to agentcache + +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. + +--- + +## Quick Start + +### Run locally + +```bash +# Clone +git clone https://github.com/Yashwant00CR7/agentcache.git +cd agentcache + +# Install dependencies (no build step) +pip install -r requirements.txt + +# Start the server +python src/app.py +``` + +Server starts on **http://localhost:3111**. Open the viewer at http://localhost:3111/viewer. + +### Verify it works + +```bash +# Health check +curl http://localhost:3111/agentcache/livez +# {"status": "ok"} + +# Save a memory +curl -X POST http://localhost:3111/agentcache/remember \ + -H "Content-Type: application/json" \ + -d '{"content": "JWT auth uses jose middleware in src/middleware/auth.ts", "concepts": ["auth", "jwt"]}' + +# Recall it +curl -X POST http://localhost:3111/agentcache/search \ + -H "Content-Type: application/json" \ + -d '{"query": "authentication middleware", "limit": 5}' +``` + +--- + +## Features + +| Feature | Status | Notes | +|---------|--------|-------| +| REST API β€” sessions, memories, observations | βœ… | Full surface | +| WebSocket live stream | βœ… | `/stream/mem-live/viewer` | +| MCP tools endpoint | βœ… | 31 tools | +| Built-in HTML viewer | βœ… | Real-time dashboard at `/viewer` | +| BM25 keyword search | βœ… | Always on, no API key needed | +| Hybrid BM25 + vector search | βœ… | Requires `GEMINI_API_KEY` | +| 4-tier memory consolidation | βš™οΈ | `CONSOLIDATION_ENABLED=true` + LLM key | +| Knowledge graph extraction | βš™οΈ | `GRAPH_EXTRACTION_ENABLED=true` + LLM key | +| LLM observation compression | βš™οΈ | `AGENTCACHE_AUTO_COMPRESS=true` + LLM key | +| Lessons with confidence decay | βœ… | Fingerprinted, auto-strengthen on repeat | +| Memory slots (pinned context) | βœ… | CRUD + auto-reflect | +| Session replay | βœ… | Full timeline in viewer | +| Audit log | βœ… | Tracks every write with agent_id + timestamp | +| HuggingFace Space deploy | βœ… | One-click, data synced to dataset repo | +| Privacy filtering | βœ… | Strips API keys, tokens before storage | + +### 4-Tier Memory Model + +Inspired by how human memory works β€” raw experience β†’ compressed episodes β†’ extracted facts β†’ learned patterns. + +| Tier | What | When | +|------|------|------| +| **Working** | Raw observations from tool use | Every tool call | +| **Episodic** | Compressed session summaries | Session end | +| **Semantic** | Extracted facts and patterns | Consolidation | +| **Procedural** | Workflows and decision patterns | Consolidation | + +--- + +## MCP Integration + +Wire agentcache-python into your agent's MCP config. It speaks the same MCP protocol as the Node.js original. + +### Most agents (Cursor, Claude Desktop, Cline, Windsurf) + +```json +{ + "mcpServers": { + "agentcache": { + "command": "npx", + "args": ["-y", "@agentcache/mcp"], + "env": { + "AGENTCACHE_URL": "http://localhost:3111" + } + } + } +} +``` + +### Claude Code + +Paste this prompt and your agent will wire everything: + +``` +Start agentcache-python: run `python src/app.py` from the agentcache-python directory. +Then add this MCP server to ~/.claude.json under mcpServers: +{ + "agentcache": { + "command": "npx", + "args": ["-y", "@agentcache/mcp"], + "env": { "AGENTCACHE_URL": "http://localhost:3111" } + } +} +Verify with: curl http://localhost:3111/agentcache/livez +Open the viewer at: http://localhost:3111/viewer +``` + +### Available MCP Tools (31) + +| Tool | Description | +|------|-------------| +| `memory_save` | Save a long-term insight, decision, or pattern | +| `memory_recall` | Search past observations by keyword | +| `memory_smart_search` | Hybrid BM25 + vector semantic search | +| `memory_sessions` | List recent sessions | +| `memory_sessions_list` | Retrieve all memory sessions | +| `memory_timeline` | Chronological observations for a session | +| `memory_observations` | Observations for a session | +| `memory_profile` | Per-project concept + file profile | +| `memory_lessons` | List active lessons with confidence scores | +| `memory_lesson_save` | Save a lesson (duplicate saves strengthen it) | +| `memory_lesson_recall` | Search lessons by query | +| `memory_lesson_search` | Search lessons by keywords | +| `memory_consolidate` | Run 4-tier memory consolidation | +| `memory_reflect` | Reflect on session, update context | +| `memory_diagnose` | Health check across all subsystems | +| `memory_forget` | Delete memory, session, or observations | +| `memory_export` | Export all memory data as JSON | +| `agent_observe` | Log agent execution observation | +| `agent_remember` | Save agent cache to long-term storage | +| `memory_antigravity_sync` | Sync Antigravity transcripts to memory | +| `memory_antigravity_sync_all` | Master sync: transcript + crystallize + reflect | +| `memory_slot_list` | List all pinned memory slots | +| `memory_slot_get` | Retrieve a specific pinned memory slot | +| `memory_slot_create` | Create/overwrite a pinned memory slot | +| `memory_slot_append` | Append text content to a pinned memory slot | +| `memory_slot_replace` | Replace pinned memory slot content | +| `memory_slot_delete` | Delete a pinned memory slot | +| `memory_action_create` | Create a new work item / action | +| `memory_action_update` | Update fields of an existing action | +| `memory_frontier` | Get active and pending actions sorted by priority | +| `memory_crystallize` | Crystallize/summarize observations in a session | + +--- + +## API Reference + +Base URL: `http://localhost:3111/agentcache` + +### Health + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/livez` | Liveness probe β€” no auth required | + +### Sessions + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/session/start` | Start a new session | +| `POST` | `/session/end` | End a session | +| `POST` | `/session/commit` | Commit session with summary | +| `GET` | `/sessions` | List all sessions | + +### Observations + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/observe` | Ingest a hook event observation | +| `POST` | `/agent/observe` | Simplified observe for direct agent use | +| `GET` | `/observations` | List observations (`?session_id=`) | +| `POST` | `/timeline` | Chronological observation window | + +### Memories + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/remember` | Save long-term memory | +| `POST` | `/agent/remember` | Simplified remember | +| `POST` | `/forget` | Delete memory / session / observations | +| `POST` | `/search` | BM25 + vector search | +| `POST` | `/context` | Compile context for a session + project | +| `GET` | `/memories` | List memories (`?latest=true&limit=N`) | +| `POST` | `/evolve` | Create a new memory version | + +### Lessons + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/lessons` | List lessons | +| `POST` | `/lessons` | Create lesson | +| `POST` | `/lessons/search` | Search lessons | +| `POST` | `/lessons/strengthen` | Reinforce an existing lesson | + +### Slots + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/slots` | List all pinned slots | +| `POST` | `/slot` | Create or update a slot | +| `GET` | `/slot` | Get slot by name | +| `DELETE` | `/slot` | Delete a slot | +| `POST` | `/slot/reflect` | Auto-populate from session observations | + +### Graph + Profile + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/relations` | Knowledge graph edges | +| `POST` | `/relations` | Add a relation | +| `GET` | `/profile` | Project profile (top concepts, files) | + +### Actions + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/actions` | List actions | +| `POST` | `/actions` | Create an action | +| `PATCH` | `/actions/` | Update action status / fields | +| `GET` | `/frontier` | Pending actions sorted by priority | +| `GET` | `/insights` | List insights | + +### Replay + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/replay/sessions` | Sessions list for replay tab | +| `GET` | `/replay/load` | Full session + observations (`?sessionId=`) | + +### MCP + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/mcp/tools` | MCP tool schema list | +| `POST` | `/mcp/tools` | MCP tool call dispatch | + +--- + +## Configuration + +Create `~/.agentcache/.env` (no `export` prefix needed): + +```env +# Server port +III_REST_PORT=3111 + +# Vector search β€” enables Gemini 768-dim embeddings +GEMINI_API_KEY=your-gemini-key + +# LLM for compression / consolidation / graph extraction +# Any one of these enables LLM features: +ANTHROPIC_API_KEY=your-anthropic-key +# OPENAI_API_KEY=your-openai-key +# GEMINI_API_KEY=your-key (same key as above works for both) + +# LLM-powered features (disabled by default β€” spend tokens) +CONSOLIDATION_ENABLED=true +GRAPH_EXTRACTION_ENABLED=true +AGENTCACHE_AUTO_COMPRESS=true + +# Context injection limits +TOKEN_BUDGET=2000 +MAX_OBS_PER_SESSION=500 + +# Auth β€” set to require Bearer token on all endpoints +AGENTCACHE_SECRET=your-secret + +# Agent scope isolation +AGENT_ID=my-agent +AGENTCACHE_AGENT_SCOPE=isolated # only see this agent's data + +# HuggingFace sync +HF_TOKEN=your-hf-token +AGENTCACHE_DATASET_REPO=username/agentcache-data +``` + +### Full Variable Reference + +| Variable | Default | Purpose | +|----------|---------|---------| +| `III_REST_PORT` / `PORT` | `3111` | API server port | +| `GEMINI_API_KEY` / `GOOGLE_API_KEY` | β€” | Enables 768-dim vector search | +| `AGENTCACHE_SECRET` | β€” | Bearer token auth on all endpoints | +| `AGENT_ID` | β€” | Default agent ID for scope isolation | +| `AGENTCACHE_AGENT_SCOPE=isolated` | β€” | Filters data to current `AGENT_ID` | +| `MAX_OBS_PER_SESSION` | `500` | Hard cap on observations per session | +| `TOKEN_BUDGET` | `2000` | Max tokens in compiled context | +| `GRAPH_EXTRACTION_ENABLED` | `false` | Knowledge graph (needs LLM) | +| `CONSOLIDATION_ENABLED` | `false` | Memory consolidation (needs LLM) | +| `AGENTCACHE_AUTO_COMPRESS` | `false` | LLM observation compression | + +--- + +## Viewer + +Built-in dashboard at **http://localhost:3111/viewer**. + +| Tab | What You See | +|-----|-------------| +| **Dashboard** | Session stats, memory counts, recent activity | +| **Sessions** | Browse sessions, inspect observations | +| **Memories** | Search, filter, and read long-term memories | +| **Graph** | Project folder visualization β€” nodes = folders, edges = shared concepts or parent path | +| **Timeline** | Per-session chronological observation view | +| **Lessons** | Confidence-scored lessons with decay tracking | +| **Slots** | Pinned memory slots editor | +| **Replay** | Scrub through past sessions frame by frame | + +--- + +## Deploy to HuggingFace + +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. + +### Setup + +1. Fork this repo as a HuggingFace Space (SDK: Docker) +2. Create a dataset repo (e.g. `your-username/agentcache-data`) +3. Add Space secrets in the HF dashboard: + + | Secret | Value | + |--------|-------| + | `HF_TOKEN` | Your HF write token | + | `AGENTCACHE_DATASET_REPO` | `your-username/agentcache-data` | + | `AGENTCACHE_SECRET` | A random secret (optional but recommended) | + | `GEMINI_API_KEY` | Gemini key (optional, enables vector search) | + +4. The Space boots, restores `agentcache.db` from the dataset repo, and starts the server + +### How sync works + +`sync.py` uses mtime fingerprinting β€” it only uploads when the database actually changed, so there are no unnecessary uploads during idle periods. + +```bash +# Manual backup +python sync.py + +# Environment for sync +HF_TOKEN=... +AGENTCACHE_DATASET_REPO=username/agentcache-data +``` + +--- + +## Architecture + +``` +agentcache-python/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ app.py Flask server β€” all endpoints, WebSocket broadcaster +β”‚ β”œβ”€β”€ db.py SQLite StateKV β€” WAL mode, audit_log table +β”‚ β”œβ”€β”€ functions.py Core logic β€” observe, remember, search, context +β”‚ β”œβ”€β”€ search.py BM25 + Gemini vector index + HybridSearch (RRF) +β”‚ └── viewer/ +β”‚ └── index.html Single-file HTML dashboard (no build step) +β”œβ”€β”€ sync.py HuggingFace dataset backup/restore +β”œβ”€β”€ Dockerfile HF Space container +β”œβ”€β”€ start.sh Boot script (restore β†’ start server β†’ start sync) +└── requirements.txt 6 Python dependencies, no external DB required +``` + +### Database layout + +Two SQLite tables in `~/.agentcache/agentcache.db`: + +```sql +-- All data lives here, namespaced by scope +kv_store ( + scope TEXT NOT NULL, -- e.g. "mem:sessions", "mem:obs:{session_id}" + key TEXT NOT NULL, + value TEXT NOT NULL, -- JSON-serialized + PRIMARY KEY (scope, key) +) + +-- Audit trail replaces Dolt git versioning +audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, -- unix millis + agent_id TEXT NOT NULL, + message TEXT NOT NULL +) +``` + +### Search pipeline + +``` +Query + β†’ BM25 (always) β€” Porter-stemmed keyword matching + β†’ Vector (if Gemini key) β€” 768-dim cosine similarity + β†’ RRF fusion β€” Reciprocal Rank Fusion (k=60) + β†’ Session diversify β€” max 3 results per session + β†’ Return top-K +``` + +--- + +## vs Original agentcache + +| | agentcache (Node.js) | agentcache-python | +|---|---|---| +| Runtime | Node.js 20+ | Python 3.10+ | +| Storage | Dolt SQL (git-versioned MySQL) | SQLite WAL (single file) | +| Engine dependency | iii-engine (separate binary) | None β€” just Flask | +| Embeddings | 6 providers + local `@xenova/transformers` | Gemini 768-dim | +| MCP tools | 53 | 31 | +| REST endpoints | 128 | ~50 | +| Deploy | npm, Docker, fly.io, Railway, Render | Docker, HuggingFace Spaces | +| Cold boot | ~7s (iii engine warm-up) | <2s | +| Database size | ~232MB (417 Dolt chunk files) | ~20MB (single `.db` file) | +| Setup | `npm install -g @agentcache/agentcache` | `pip install -r requirements.txt` | + +Choose the Python version for: simpler setup, HF Space deployment, single-file database, no Node.js, or Python ecosystem integration. + +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. + +--- + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). Issues and PRs welcome. + +Priority areas: test coverage, additional embedding providers, more agent hook scripts. + +--- + +## License + +Apache-2.0 β€” see [LICENSE](LICENSE). diff --git a/src/agentcache.egg-info/SOURCES.txt b/src/agentcache.egg-info/SOURCES.txt new file mode 100644 index 0000000000000000000000000000000000000000..e7e9efe649956cad37f90b0dfb8f857e0fe1befc --- /dev/null +++ b/src/agentcache.egg-info/SOURCES.txt @@ -0,0 +1,59 @@ +LICENSE +README.md +pyproject.toml +src/agentcache/__init__.py +src/agentcache/app.py +src/agentcache/cli.py +src/agentcache/connect.py +src/agentcache/db.py +src/agentcache/functions.py +src/agentcache/import_data.py +src/agentcache/mcp_stdio.py +src/agentcache/replay_import.py +src/agentcache/search.py +src/agentcache/viewer_helpers.py +src/agentcache/workers.py +src/agentcache.egg-info/PKG-INFO +src/agentcache.egg-info/SOURCES.txt +src/agentcache.egg-info/dependency_links.txt +src/agentcache.egg-info/entry_points.txt +src/agentcache.egg-info/requires.txt +src/agentcache.egg-info/top_level.txt +src/agentcache/cache/__init__.py +src/agentcache/cache/context.py +src/agentcache/cache/graph.py +src/agentcache/cache/health.py +src/agentcache/cache/observe.py +src/agentcache/cache/remember.py +src/agentcache/cache/timeline.py +src/agentcache/routes/__init__.py +src/agentcache/routes/graph.py +src/agentcache/routes/health.py +src/agentcache/routes/mcp.py +src/agentcache/routes/memories.py +src/agentcache/routes/migration.py +src/agentcache/routes/observations.py +src/agentcache/routes/search.py +src/agentcache/storage/__init__.py +src/agentcache/storage/images.py +src/agentcache/storage/paths.py +src/agentcache/storage/scopes.py +src/agentcache/viewer/favicon.svg +src/agentcache/viewer/index.html +tests/test_api.py +tests/test_auth.py +tests/test_context.py +tests/test_debounce.py +tests/test_folder_graph_build.py +tests/test_folder_observe.py +tests/test_forget.py +tests/test_graph.py +tests/test_migration.py +tests/test_normalize.py +tests/test_obs_lookup.py +tests/test_observe_core.py +tests/test_properties.py +tests/test_remember.py +tests/test_route_regressions.py +tests/test_search.py +tests/test_timeline.py \ No newline at end of file diff --git a/src/agentcache.egg-info/dependency_links.txt b/src/agentcache.egg-info/dependency_links.txt new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/src/agentcache.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/agentcache.egg-info/entry_points.txt b/src/agentcache.egg-info/entry_points.txt new file mode 100644 index 0000000000000000000000000000000000000000..d9729b139b1347b840862d5a1be9296ccac9b7df --- /dev/null +++ b/src/agentcache.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +agentcache = agentcache.cli:main diff --git a/src/agentcache.egg-info/requires.txt b/src/agentcache.egg-info/requires.txt new file mode 100644 index 0000000000000000000000000000000000000000..6d78cc1ebd3b04bb57664854c3083322ddbdd8a0 --- /dev/null +++ b/src/agentcache.egg-info/requires.txt @@ -0,0 +1,15 @@ +flask>=3.0.0 +flask-sock>=0.7.0 +requests>=2.31.0 +python-dateutil>=2.8.2 +huggingface_hub>=0.20.0 +websockets>=12.0 + +[dev] +pytest>=8.0.0 +hypothesis>=6.100.0 +ruff>=0.3.0 +twine>=5.0.0 + +[local-embeddings] +sentence-transformers>=2.7.0 diff --git a/src/agentcache.egg-info/top_level.txt b/src/agentcache.egg-info/top_level.txt new file mode 100644 index 0000000000000000000000000000000000000000..33f2a5df0b0fd6c8cbeb76263f4965029b467fa3 --- /dev/null +++ b/src/agentcache.egg-info/top_level.txt @@ -0,0 +1 @@ +agentcache diff --git a/src/agentcache/__init__.py b/src/agentcache/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c932a8206065b071c3285a810a2f8d5c84db72d0 --- /dev/null +++ b/src/agentcache/__init__.py @@ -0,0 +1,32 @@ +""" +agentcache β€” A Python REST + WebSocket + MCP cache server for AI agents, backed by SQLite. +""" + +__version__ = "0.9.8" + +from .app import create_app +from .connect import run_connect +from .db import StateKV +from .functions import ( + folder_graph_build, + folder_observe, + folder_search, + folder_timeline, + forget, + health_check, + remember, +) + +__all__ = [ + "__version__", + "create_app", + "StateKV", + "run_connect", + "folder_observe", + "folder_search", + "folder_timeline", + "folder_graph_build", + "remember", + "forget", + "health_check", +] diff --git a/src/app.py b/src/agentcache/app.py similarity index 93% rename from src/app.py rename to src/agentcache/app.py index 11f4024c9c4a850f531991b4fafa64f7d6e9ba4f..bbcfca01c8d551ff0c0a9cddec35437b70cfcd5f 100644 --- a/src/app.py +++ b/src/agentcache/app.py @@ -1,280 +1,284 @@ -""" -agentmemory-python β€” Flask application factory. - -Entry point: create_app() returns a fully configured Flask app. -Run directly: python src/app.py -""" - -import os -import sys -import json -import hmac -from flask import Flask, request, send_from_directory -from flask_sock import Sock - -# Prevent double-import of app when run directly as __main__ -if __name__ == "__main__": - sys.modules["app"] = sys.modules["__main__"] - - -def _load_env() -> None: - env_path = os.path.join(os.path.expanduser("~"), ".agentcache", ".env") - if not os.path.exists(env_path): - env_path = os.path.join(os.path.expanduser("~"), ".agentmemory", ".env") - if not os.path.exists(env_path): - return - try: - with open(env_path, "r", encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - k, v = line.split("=", 1) - os.environ[k.strip()] = v.strip().strip('"').strip("'") - print(f"[config] Loaded environment from {env_path}") - except Exception as e: - print(f"[config] Error reading env file: {e}") - - -_load_env() - -# Module-level globals β€” set once by create_app(), read by blueprints via `import app` -kv = None -embedding_provider = None -persistence = None - - -def create_app() -> Flask: - """Create and return a fully configured Flask application.""" - global kv, embedding_provider, persistence - - # Check security credentials - if not os.getenv("AGENTCACHE_SECRET") and not os.getenv("AGENTMEMORY_SECRET"): - print( - "[security] WARNING: AGENTCACHE_SECRET/AGENTMEMORY_SECRET is not set! All API endpoints are publicly accessible without authentication." - ) - - import search as search_mod - import functions - from db import StateKV - from viewer_helpers import make_viewer_response - - # 1. DB - kv = StateKV() - - # 2. Embedding provider β€” auto-select by priority (D5.3): - # GEMINI_API_KEY β†’ OPENAI_API_KEY β†’ AGENTCACHE_LOCAL_EMBEDDING_MODEL β†’ BM25-only - api_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") - openai_key = os.getenv("OPENAI_API_KEY") - local_model = os.getenv("AGENTCACHE_LOCAL_EMBEDDING_MODEL") or os.getenv( - "AGENTMEMORY_LOCAL_EMBEDDING_MODEL" - ) - - if api_key: - try: - embedding_provider = search_mod.GeminiEmbeddingProvider(api_key) - functions.set_embedding_provider(embedding_provider) - print( - f"[search] Embedding provider active: gemini ({embedding_provider.dimensions} dims)" - ) - except Exception as e: - print(f"[search] Error initialising Gemini embedding provider: {e}") - elif openai_key: - try: - embedding_provider = search_mod.OpenAIEmbeddingProvider(openai_key) - functions.set_embedding_provider(embedding_provider) - print( - f"[search] Embedding provider active: openai ({embedding_provider.dimensions} dims)" - ) - except Exception as e: - print(f"[search] Error initialising OpenAI embedding provider: {e}") - elif local_model: - try: - embedding_provider = search_mod.SentenceTransformerProvider(local_model) - functions.set_embedding_provider(embedding_provider) - print( - f"[search] Embedding provider active: sentence-transformers/{local_model} ({embedding_provider.dimensions} dims)" - ) - except ImportError as e: - print(f"[search] sentence-transformers not installed: {e}") - except Exception as e: - print(f"[search] Error initialising SentenceTransformer provider: {e}") - else: - print("[search] No embedding API key found β€” running in BM25-only mode.") - - # 3. Index persistence β€” use embedding_provider variable set above - has_vector = embedding_provider is not None - persistence = functions.IndexPersistence( - kv, - functions._bm25_index, - functions._vector_index if has_vector else None, - ) - functions.set_index_persistence(persistence) - loaded = persistence.load() - print( - f"[persistence] Load results: BM25={loaded['bm25']}, Vector={loaded['vector']}" - ) - - # Backfill coordinate lookup index if missing/incomplete - try: - functions.backfill_obs_lookup_if_needed(kv) - except Exception as e: - print(f"[db] Warning backfilling obs_lookup: {e}") - - # 4. Flask app + blueprints - flask_app = Flask(__name__) - from werkzeug.middleware.proxy_fix import ProxyFix - - flask_app.wsgi_app = ProxyFix( - flask_app.wsgi_app, x_proto=1, x_host=1, x_port=1, x_prefix=1 - ) - from routes import register_blueprints - - register_blueprints(flask_app) - - # 5. WebSocket broadcaster - sock = Sock(flask_app) - _ws_clients: set = set() - - @sock.route("/stream/mem-live/viewer") - def stream_viewer(ws): - secret = os.getenv("AGENTCACHE_SECRET") or os.getenv("AGENTMEMORY_SECRET") - if secret: - token = request.args.get("token") or request.args.get("secret") - if not token or not hmac.compare_digest( - token.encode("utf-8"), secret.encode("utf-8") - ): - ws.close(1008) - return - _ws_clients.add(ws) - try: - while ws.receive() is not None: - pass - except Exception: - pass - finally: - _ws_clients.discard(ws) - - def _broadcast(payload: dict) -> None: - msg = json.dumps(payload) - for ws in list(_ws_clients): - try: - ws.send(msg) - except Exception: - _ws_clients.discard(ws) - - functions.set_stream_broadcaster(_broadcast) - - # 6. Viewer static routes - _base_dir = os.path.dirname(os.path.abspath(__file__)) - - @flask_app.route("/") - @flask_app.route("/viewer") - @flask_app.route("/agentcache/viewer") - @flask_app.route("/agentmemory/viewer") - def serve_viewer(): - try: - return make_viewer_response(_base_dir) - except Exception as e: - return f"Viewer not found: {e}", 404 - - @flask_app.route("/favicon.svg") - def serve_favicon(): - return send_from_directory(os.path.join(_base_dir, "viewer"), "favicon.svg") - - # 7. CORS after_request β€” D2.1: configurable via AGENTCACHE_CORS_ORIGINS env var - # Default allows localhost, 127.0.0.1, HuggingFace Spaces, vscode-webview://, chrome-extension:// - # Wildcard entries like "*.hf.space" match any subdomain via suffix check. - _default_cors = ( - "http://localhost,http://127.0.0.1," - "https://huggingface.co,https://*.hf.space," - "vscode-webview://*,chrome-extension://*" - ) - _cors_origins_raw = ( - os.getenv("AGENTCACHE_CORS_ORIGINS") - or os.getenv("AGENTMEMORY_CORS_ORIGINS") - or _default_cors - ) - - def _parse_cors_origins(raw: str): - """Return (exact_set, suffix_list) for efficient origin matching.""" - exact, suffixes = set(), [] - for entry in raw.split(","): - entry = entry.strip() - if not entry: - continue - if entry.startswith("*."): - # *.hf.space β†’ match anything ending with .hf.space - suffixes.append(entry[1:].lower()) # keep the leading dot: ".hf.space" - elif "*" in entry: - # generic prefix wildcard: strip trailing * and treat as prefix - suffixes.append(("prefix:", entry.rstrip("*").lower())) - else: - exact.add(entry.lower()) - return exact, suffixes - - _cors_exact, _cors_suffixes = _parse_cors_origins(_cors_origins_raw) - - def _origin_allowed(origin: str) -> bool: - lo = origin.lower() - if lo in _cors_exact: - return True - for s in _cors_suffixes: - if isinstance(s, tuple) and s[0] == "prefix:": - if lo.startswith(s[1]): - return True - elif lo.endswith(s): - return True - return False - - @flask_app.after_request - def _cors(response): - origin = request.headers.get("Origin") - if origin and _origin_allowed(origin): - response.headers["Access-Control-Allow-Origin"] = origin - response.headers["Access-Control-Allow-Credentials"] = "true" - response.headers.add( - "Access-Control-Allow-Headers", "Content-Type, Authorization" - ) - response.headers.add( - "Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS" - ) - return response - - # Handle CORS preflight OPTIONS requests globally - from flask import Response as _FlaskResponse - - @flask_app.before_request - def _handle_options(): - if request.method == "OPTIONS": - origin = request.headers.get("Origin", "") - if origin and _origin_allowed(origin): - resp = _FlaskResponse("", status=204) - resp.headers["Access-Control-Allow-Origin"] = origin - resp.headers["Access-Control-Allow-Credentials"] = "true" - resp.headers["Access-Control-Allow-Headers"] = ( - "Content-Type, Authorization" - ) - resp.headers["Access-Control-Allow-Methods"] = ( - "GET, POST, PUT, DELETE, OPTIONS" - ) - resp.headers["Access-Control-Max-Age"] = "86400" - return resp - - # 8. Background workers - from workers import start_background_workers - - start_background_workers(kv) - - return flask_app - - -def main() -> None: - flask_app = create_app() - port = int(os.getenv("III_REST_PORT", os.getenv("PORT", "3111"))) - print(f"[main] Starting Flask daemon on port {port}...") - flask_app.run(host="0.0.0.0", port=port, debug=False) - - -if __name__ == "__main__": - main() +""" +agentmemory-python β€” Flask application factory. + +Entry point: create_app() returns a fully configured Flask app. +Run directly: python src/app.py +""" + +import hmac +import json +import os +import sys + +from flask import Flask, request, send_from_directory +from flask_sock import Sock + +# Prevent double-import of app when run directly as __main__ +if __name__ == "__main__": + sys.modules["app"] = sys.modules["__main__"] + + +def _load_env() -> None: + env_path = os.path.join(os.path.expanduser("~"), ".agentcache", ".env") + if not os.path.exists(env_path): + env_path = os.path.join(os.path.expanduser("~"), ".agentmemory", ".env") + if not os.path.exists(env_path): + return + try: + with open(env_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + os.environ[k.strip()] = v.strip().strip('"').strip("'") + print(f"[config] Loaded environment from {env_path}") + except Exception as e: + print(f"[config] Error reading env file: {e}") + + +_load_env() + +# Module-level globals β€” set once by create_app(), read by blueprints via `import app` +kv = None +embedding_provider = None +persistence = None + + +def create_app() -> Flask: + """Create and return a fully configured Flask application.""" + global kv, embedding_provider, persistence + + # Check security credentials + if not os.getenv("AGENTCACHE_SECRET") and not os.getenv("AGENTMEMORY_SECRET"): + print( + "[security] WARNING: AGENTCACHE_SECRET/AGENTMEMORY_SECRET is not set! All API endpoints are publicly accessible without authentication." + ) + + from . import functions + from . import search as search_mod + from .db import StateKV + from .viewer_helpers import make_viewer_response + + # 1. DB + kv = StateKV() + + # 2. Embedding provider β€” auto-select by priority (D5.3): + # GEMINI_API_KEY β†’ OPENAI_API_KEY β†’ AGENTCACHE_LOCAL_EMBEDDING_MODEL β†’ BM25-only + api_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") + openai_key = os.getenv("OPENAI_API_KEY") + local_model = os.getenv("AGENTCACHE_LOCAL_EMBEDDING_MODEL") or os.getenv( + "AGENTMEMORY_LOCAL_EMBEDDING_MODEL" + ) + + if api_key: + try: + embedding_provider = search_mod.GeminiEmbeddingProvider(api_key) + functions.set_embedding_provider(embedding_provider) + print( + f"[search] Embedding provider active: gemini ({embedding_provider.dimensions} dims)" + ) + except Exception as e: + print(f"[search] Error initialising Gemini embedding provider: {e}") + elif openai_key: + try: + embedding_provider = search_mod.OpenAIEmbeddingProvider(openai_key) + functions.set_embedding_provider(embedding_provider) + print( + f"[search] Embedding provider active: openai ({embedding_provider.dimensions} dims)" + ) + except Exception as e: + print(f"[search] Error initialising OpenAI embedding provider: {e}") + elif local_model: + try: + embedding_provider = search_mod.SentenceTransformerProvider(local_model) + functions.set_embedding_provider(embedding_provider) + print( + f"[search] Embedding provider active: sentence-transformers/{local_model} ({embedding_provider.dimensions} dims)" + ) + except ImportError as e: + print(f"[search] sentence-transformers not installed: {e}") + except Exception as e: + print(f"[search] Error initialising SentenceTransformer provider: {e}") + else: + print("[search] No embedding API key found β€” running in BM25-only mode.") + + # 3. Index persistence β€” use embedding_provider variable set above + has_vector = embedding_provider is not None + persistence = functions.IndexPersistence( + kv, + functions._bm25_index, + functions._vector_index if has_vector else None, + ) + functions.set_index_persistence(persistence) + loaded = persistence.load() + print( + f"[persistence] Load results: BM25={loaded['bm25']}, Vector={loaded['vector']}" + ) + + # Backfill coordinate lookup index if missing/incomplete + try: + functions.backfill_obs_lookup_if_needed(kv) + except Exception as e: + print(f"[db] Warning backfilling obs_lookup: {e}") + + # 4. Flask app + blueprints + flask_app = Flask(__name__) + from werkzeug.middleware.proxy_fix import ProxyFix + + flask_app.wsgi_app = ProxyFix( + flask_app.wsgi_app, x_proto=1, x_host=1, x_port=1, x_prefix=1 + ) + from .routes import register_blueprints + + register_blueprints(flask_app) + + # 5. WebSocket broadcaster + sock = Sock(flask_app) + _ws_clients: set = set() + + @sock.route("/stream/mem-live/viewer") + def stream_viewer(ws): + secret = os.getenv("AGENTCACHE_SECRET") or os.getenv("AGENTMEMORY_SECRET") + if secret: + token = request.args.get("token") or request.args.get("secret") + if not token or not hmac.compare_digest( + token.encode("utf-8"), secret.encode("utf-8") + ): + ws.close(1008) + return + _ws_clients.add(ws) + try: + while ws.receive() is not None: + pass + except Exception: + pass + finally: + _ws_clients.discard(ws) + + def _broadcast(payload: dict) -> None: + msg = json.dumps(payload) + for ws in list(_ws_clients): + try: + ws.send(msg) + except Exception: + _ws_clients.discard(ws) + + functions.set_stream_broadcaster(_broadcast) + + # 6. Viewer static routes + from importlib.resources import files + + _viewer_resources = files("agentcache").joinpath("viewer") + _base_dir = str(_viewer_resources.parent) + + @flask_app.route("/") + @flask_app.route("/viewer") + @flask_app.route("/agentcache/viewer") + @flask_app.route("/agentmemory/viewer") + def serve_viewer(): + try: + return make_viewer_response(_base_dir) + except Exception as e: + return f"Viewer not found: {e}", 404 + + @flask_app.route("/favicon.svg") + def serve_favicon(): + return send_from_directory(str(_viewer_resources), "favicon.svg") + + # 7. CORS after_request β€” D2.1: configurable via AGENTCACHE_CORS_ORIGINS env var + # Default allows localhost, 127.0.0.1, HuggingFace Spaces, vscode-webview://, chrome-extension:// + # Wildcard entries like "*.hf.space" match any subdomain via suffix check. + _default_cors = ( + "http://localhost,http://127.0.0.1," + "https://huggingface.co,https://*.hf.space," + "vscode-webview://*,chrome-extension://*" + ) + _cors_origins_raw = ( + os.getenv("AGENTCACHE_CORS_ORIGINS") + or os.getenv("AGENTMEMORY_CORS_ORIGINS") + or _default_cors + ) + + def _parse_cors_origins(raw: str): + """Return (exact_set, suffix_list) for efficient origin matching.""" + exact, suffixes = set(), [] + for entry in raw.split(","): + entry = entry.strip() + if not entry: + continue + if entry.startswith("*."): + # *.hf.space β†’ match anything ending with .hf.space + suffixes.append(entry[1:].lower()) # keep the leading dot: ".hf.space" + elif "*" in entry: + # generic prefix wildcard: strip trailing * and treat as prefix + suffixes.append(("prefix:", entry.rstrip("*").lower())) + else: + exact.add(entry.lower()) + return exact, suffixes + + _cors_exact, _cors_suffixes = _parse_cors_origins(_cors_origins_raw) + + def _origin_allowed(origin: str) -> bool: + lo = origin.lower() + if lo in _cors_exact: + return True + for s in _cors_suffixes: + if isinstance(s, tuple) and s[0] == "prefix:": + if lo.startswith(s[1]): + return True + elif lo.endswith(s): + return True + return False + + @flask_app.after_request + def _cors(response): + origin = request.headers.get("Origin") + if origin and _origin_allowed(origin): + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Access-Control-Allow-Credentials"] = "true" + response.headers.add( + "Access-Control-Allow-Headers", "Content-Type, Authorization" + ) + response.headers.add( + "Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS" + ) + return response + + # Handle CORS preflight OPTIONS requests globally + from flask import Response as _FlaskResponse + + @flask_app.before_request + def _handle_options(): + if request.method == "OPTIONS": + origin = request.headers.get("Origin", "") + if origin and _origin_allowed(origin): + resp = _FlaskResponse("", status=204) + resp.headers["Access-Control-Allow-Origin"] = origin + resp.headers["Access-Control-Allow-Credentials"] = "true" + resp.headers["Access-Control-Allow-Headers"] = ( + "Content-Type, Authorization" + ) + resp.headers["Access-Control-Allow-Methods"] = ( + "GET, POST, PUT, DELETE, OPTIONS" + ) + resp.headers["Access-Control-Max-Age"] = "86400" + return resp + + # 8. Background workers + from .workers import start_background_workers + + start_background_workers(kv) + + return flask_app + + +def main() -> None: + flask_app = create_app() + port = int(os.getenv("III_REST_PORT", os.getenv("PORT", "3111"))) + print(f"[main] Starting Flask daemon on port {port}...") + flask_app.run(host="0.0.0.0", port=port, debug=False) + + +if __name__ == "__main__": + main() diff --git a/src/auth.md b/src/agentcache/auth.md similarity index 100% rename from src/auth.md rename to src/agentcache/auth.md diff --git a/src/cache/__init__.py b/src/agentcache/cache/__init__.py similarity index 91% rename from src/cache/__init__.py rename to src/agentcache/cache/__init__.py index e18b1c7b0a995f5c72dc812cf331574a9add483c..f943ba2922bf4aac9b16cc84cbb410fa5ac794da 100644 --- a/src/cache/__init__.py +++ b/src/agentcache/cache/__init__.py @@ -13,24 +13,23 @@ Compatibility shim: also re-exports everything from functions.py that callers may import from this package (A2.2). """ -from .observe import ( - folder_observe, - observe, - build_synthetic_compression, - strip_private_data, -) -from .remember import remember, forget, jaccard_similarity -from .context import context, export_data, rebuild_index -from .graph import folder_graph_build -from .timeline import folder_timeline, folder_search -from .health import health_check, auto_forget - # --------------------------------------------------------------------------- # Compatibility shim β€” delegate additional names to functions.py (A2.2) # Each name is imported lazily via a try/except so missing items don't break # the package import on partially-initialised environments. # --------------------------------------------------------------------------- -import functions as _fn # noqa: E402 (functions.py is on sys.path via src/) +from .. import functions as _fn # noqa: E402 +from .context import context, export_data, rebuild_index +from .graph import folder_graph_build +from .health import auto_forget, health_check +from .observe import ( + build_synthetic_compression, + folder_observe, + observe, + strip_private_data, +) +from .remember import forget, jaccard_similarity, remember +from .timeline import folder_search, folder_timeline KV = _fn.KV generate_id = _fn.generate_id diff --git a/src/cache/context.py b/src/agentcache/cache/context.py similarity index 94% rename from src/cache/context.py rename to src/agentcache/cache/context.py index b5beb239ef09309cc3d897c4c5aa89c049035bf4..323623ece93e6548b458fa85de258145aa0c178a 100644 --- a/src/cache/context.py +++ b/src/agentcache/cache/context.py @@ -11,8 +11,8 @@ from __future__ import annotations from typing import Any, Dict -from db import StateKV -import functions as _fn +from .. import functions as _fn +from ..db import StateKV def context(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]: diff --git a/src/cache/graph.py b/src/agentcache/cache/graph.py similarity index 87% rename from src/cache/graph.py rename to src/agentcache/cache/graph.py index ee42f40e350d71719792c2ebc08e4f1b31672532..525b5aa3acc4c2459c5db1dd5745337a9efafa97 100644 --- a/src/cache/graph.py +++ b/src/agentcache/cache/graph.py @@ -9,8 +9,8 @@ from __future__ import annotations from typing import Any, Dict -from db import StateKV -import functions as _fn +from .. import functions as _fn +from ..db import StateKV def folder_graph_build(kv: StateKV) -> Dict[str, Any]: diff --git a/src/cache/health.py b/src/agentcache/cache/health.py similarity index 91% rename from src/cache/health.py rename to src/agentcache/cache/health.py index 1068dc3785919a9fb618ccd169f50009cf356ed2..33b90f57e437917d1e758db0f4ea49c7c902da5b 100644 --- a/src/cache/health.py +++ b/src/agentcache/cache/health.py @@ -10,8 +10,8 @@ from __future__ import annotations from typing import Any, Dict -from db import StateKV -import functions as _fn +from .. import functions as _fn +from ..db import StateKV def health_check(kv: StateKV) -> Dict[str, Any]: diff --git a/src/cache/observe.py b/src/agentcache/cache/observe.py similarity index 89% rename from src/cache/observe.py rename to src/agentcache/cache/observe.py index ea8735a9291c44aa0575b13775c46ec73755ba5e..8eda41294eebe8f8383a5adb33c3d04dff372f65 100644 --- a/src/cache/observe.py +++ b/src/agentcache/cache/observe.py @@ -12,9 +12,8 @@ from __future__ import annotations from typing import Any, Dict -from db import StateKV -import functions as _fn # access module-level globals (_bm25_index, etc.) - +from .. import functions as _fn # access module-level globals (_bm25_index, etc.) +from ..db import StateKV # Re-export for backward compatibility strip_private_data = _fn.strip_private_data diff --git a/src/cache/remember.py b/src/agentcache/cache/remember.py similarity index 94% rename from src/cache/remember.py rename to src/agentcache/cache/remember.py index 5097915f9dab59bcc69f1f1c07a5d34da1c62919..cfc7fd0acede8b16e6869382bd8ecfc1f269a31c 100644 --- a/src/cache/remember.py +++ b/src/agentcache/cache/remember.py @@ -11,8 +11,8 @@ from __future__ import annotations from typing import Any, Dict -from db import StateKV -import functions as _fn +from .. import functions as _fn +from ..db import StateKV def remember(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]: diff --git a/src/cache/timeline.py b/src/agentcache/cache/timeline.py similarity index 94% rename from src/cache/timeline.py rename to src/agentcache/cache/timeline.py index c6f70bc67ad2dc86253025c7eccd5e358e88d068..f2af4cc754599b1c84a0170e6350603e2aee9488 100644 --- a/src/cache/timeline.py +++ b/src/agentcache/cache/timeline.py @@ -10,8 +10,8 @@ from __future__ import annotations from typing import Any, Dict, List, Optional -from db import StateKV -import functions as _fn +from .. import functions as _fn +from ..db import StateKV def folder_timeline( diff --git a/src/cli.py b/src/agentcache/cli.py similarity index 89% rename from src/cli.py rename to src/agentcache/cli.py index ff0d7d6954f052517b354adc76d4448117b63fa3..9019234916e714556728a53a72b21bfcc9918722 100644 --- a/src/cli.py +++ b/src/agentcache/cli.py @@ -11,14 +11,12 @@ Commands: import argparse import json import os -import sys def cmd_serve(args) -> None: """Start the Flask server.""" os.environ.setdefault("III_REST_PORT", str(args.port)) - sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - from app import create_app + from .app import create_app flask_app = create_app() print(f"[cli] Starting agentcache on {args.host}:{args.port}") @@ -27,9 +25,8 @@ def cmd_serve(args) -> None: def cmd_migrate(args) -> None: """Run session β†’ folder migration.""" - sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - from db import StateKV - from functions import migrate_sessions_to_folders + from .db import StateKV + from .functions import migrate_sessions_to_folders kv = StateKV() result = migrate_sessions_to_folders(kv, dry_run=args.dry_run) @@ -54,9 +51,8 @@ def cmd_migrate(args) -> None: def cmd_export(args) -> None: """Export all data as JSON.""" - sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - from db import StateKV - from functions import export_data + from .db import StateKV + from .functions import export_data kv = StateKV() data = export_data(kv, {}) @@ -78,8 +74,7 @@ def cmd_export(args) -> None: def cmd_connect(args) -> None: """Connect/wire MCP and hooks to client agents.""" - sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - from connect import run_connect + from .connect import run_connect run_connect(args) diff --git a/src/connect.py b/src/agentcache/connect.py similarity index 98% rename from src/connect.py rename to src/agentcache/connect.py index a06ca954a591c8a78d55ecf2fc41e725a622d1ef..e21fd2d3739b02de8ded92706123555793b09721 100644 --- a/src/connect.py +++ b/src/agentcache/connect.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 -import os -import sys +import argparse import json +import os import shutil -import argparse +import sys # Helper functions for connect module @@ -45,9 +45,9 @@ def backup_file(path, prefix, ext="json"): def get_plugin_root(): - # connect.py resides in src/, plugin/ is in the parent directory + # connect.py resides in src/agentcache/, plugin/ is in the parent of src/ src_dir = os.path.dirname(os.path.abspath(__file__)) - project_root = os.path.dirname(src_dir) + project_root = os.path.dirname(os.path.dirname(src_dir)) plugin_path = os.path.join(project_root, "plugin") if os.path.exists(os.path.join(plugin_path, "scripts")): return plugin_path @@ -368,14 +368,7 @@ class AntigravityAdapter: os.makedirs(gemini_mcp_dir, exist_ok=True) try: # Dynamic import to avoid circular dependency - try: - from routes.mcp import get_mcp_tools_schemas - except ImportError: - try: - from src.routes.mcp import get_mcp_tools_schemas - except ImportError: - sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - from routes.mcp import get_mcp_tools_schemas + from .routes.mcp import get_mcp_tools_schemas tools = get_mcp_tools_schemas() except Exception as e: diff --git a/src/db.py b/src/agentcache/db.py similarity index 99% rename from src/db.py rename to src/agentcache/db.py index 9a801322302503f52883ee543d6173e724726e6f..7c412e2ad4de1d33fb742e8c36f0b98a5b5f5f5b 100644 --- a/src/db.py +++ b/src/agentcache/db.py @@ -1,10 +1,10 @@ -import os +import atexit import json +import os import sqlite3 import threading import time -import atexit -from typing import Dict, Any, List, Optional, TypeVar +from typing import Any, Dict, List, Optional, TypeVar T = TypeVar("T") diff --git a/src/functions.py b/src/agentcache/functions.py similarity index 97% rename from src/functions.py rename to src/agentcache/functions.py index 114043a257306a1ac2b107cf17765edbc97e784d..c38f99322d928e618d3201f11212e7bdb2a54102 100644 --- a/src/functions.py +++ b/src/agentcache/functions.py @@ -1,14 +1,15 @@ +import datetime +import hashlib +import json import os import re +import threading import time import uuid -import json -import hashlib -import datetime -import threading -from typing import Dict, Any, List, Optional, Tuple, Set -from db import StateKV -from search import SearchIndex, VectorIndex, HybridSearch +from typing import Any, Dict, List, Optional, Set, Tuple + +from .db import StateKV +from .search import HybridSearch, SearchIndex, VectorIndex # ===================================================================== # Global Variables / Module State @@ -253,7 +254,9 @@ def auto_complete_old_active_sessions( ) -> int: sessions = kv.list(KV.sessions) count = 0 - now = datetime.datetime.utcnow().isoformat() + "Z" + now = ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) for s in sessions: if s.get("id") != current_session_id and s.get("status") == "active": if project and s.get("project") != project: @@ -337,7 +340,9 @@ def record_audit( ) -> Dict[str, Any]: entry = { "id": generate_id("aud"), - "timestamp": datetime.datetime.utcnow().isoformat() + "Z", + "timestamp": datetime.datetime.now(datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z"), "operation": operation, "userId": user_id, "functionId": function_id, @@ -1009,7 +1014,9 @@ def observe(kv: StateKV, payload: Dict[str, Any]) -> Dict[str, Any]: { "type": "set", "path": "updatedAt", - "value": datetime.datetime.utcnow().isoformat() + "Z", + "value": datetime.datetime.now(datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z"), }, { "type": "set", @@ -1035,7 +1042,11 @@ def observe(kv: StateKV, payload: Dict[str, Any]) -> Dict[str, Any]: trimmed_prompt = None if isinstance(raw.get("userPrompt"), str): trimmed_prompt = " ".join(raw["userPrompt"].split()).strip()[:200] - ts = datetime.datetime.utcnow().isoformat() + "Z" + ts = ( + datetime.datetime.now(datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ) new_sess = { "id": session_id, "project": project, @@ -1600,7 +1611,9 @@ def remember(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]: if project: project = project.strip() - now = datetime.datetime.utcnow().isoformat() + "Z" + now = ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) existing_memories = kv.list(KV.memories) superseded_id = None superseded_version = 1 @@ -1645,8 +1658,10 @@ def remember(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]: new_mem["project"] = project if ttl_days and isinstance(ttl_days, (int, float)) and ttl_days > 0: - forget_time = datetime.datetime.utcnow() + datetime.timedelta(days=ttl_days) - new_mem["forgetAfter"] = forget_time.isoformat() + "Z" + forget_time = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( + days=ttl_days + ) + new_mem["forgetAfter"] = forget_time.isoformat().replace("+00:00", "Z") if superseded_memory: superseded_memory["isLatest"] = False @@ -2164,7 +2179,9 @@ DEFAULT_SLOTS = [ def seed_defaults(kv: StateKV) -> None: - now = datetime.datetime.utcnow().isoformat() + "Z" + now = ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) for tmpl in DEFAULT_SLOTS: scope = tmpl["scope"] target = KV.globalSlots if scope == "global" else KV.slots @@ -2265,7 +2282,9 @@ def slot_create(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]: if existing: return {"success": False, "error": f"slot already exists in {scope} scope"} - now = datetime.datetime.utcnow().isoformat() + "Z" + now = ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) slot = { "label": label, "content": content, @@ -2327,7 +2346,9 @@ def slot_append( } slot["content"] = next_content - slot["updatedAt"] = datetime.datetime.utcnow().isoformat() + "Z" + slot["updatedAt"] = ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) kv.set(target_kv, label, slot) safe_audit( @@ -2375,7 +2396,9 @@ def slot_replace( before_len = len(slot.get("content") or "") slot["content"] = content - slot["updatedAt"] = datetime.datetime.utcnow().isoformat() + "Z" + slot["updatedAt"] = ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) kv.set(target_kv, label, slot) safe_audit( @@ -2455,7 +2478,9 @@ def slot_reflect(kv: StateKV, session_id: str, max_obs: int = 50) -> Dict[str, A files.add(f) applied = 0 - now = datetime.datetime.utcnow().isoformat() + "Z" + now = ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) if pending_lines: res = slot_get(kv, "pending_items", project) @@ -2558,7 +2583,9 @@ def slot_reflect(kv: StateKV, session_id: str, max_obs: int = 50) -> Dict[str, A def reinforce_lesson(lesson: Dict[str, Any]) -> None: - now = datetime.datetime.utcnow().isoformat() + "Z" + now = ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) lesson["reinforcements"] = lesson.get("reinforcements", 0) + 1 conf = lesson.get("confidence", 0.5) lesson["confidence"] = min(1.0, conf + 0.1 * (1 - conf)) @@ -2595,7 +2622,9 @@ def lesson_save(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]: if not isinstance(confidence, (int, float)) or confidence < 0 or confidence > 1: confidence = 0.5 - now = datetime.datetime.utcnow().isoformat() + "Z" + now = ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) lesson = { "id": fp, "content": content.strip(), @@ -2676,7 +2705,7 @@ def lesson_recall(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]: dt = dateutil.parser.parse(baseline) days = ( - datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc) + datetime.datetime.now(datetime.timezone.utc) - dt.replace(tzinfo=datetime.timezone.utc) ).total_seconds() / (3600 * 24) recency_boost = 1 / (1 + days * 0.01) @@ -2721,8 +2750,8 @@ def lesson_decay_sweep(kv: StateKV) -> Dict[str, Any]: all_lessons = kv.list(KV.lessons) decayed = 0 soft_deleted = 0 - now = datetime.datetime.utcnow() - timestamp = now.isoformat() + "Z" + now = datetime.datetime.now(datetime.timezone.utc) + timestamp = now.isoformat().replace("+00:00", "Z") for les in all_lessons: if les.get("deleted"): @@ -2733,10 +2762,9 @@ def lesson_decay_sweep(kv: StateKV) -> Dict[str, Any]: import dateutil.parser dt = dateutil.parser.parse(baseline_str) - weeks = ( - now.replace(tzinfo=datetime.timezone.utc) - - dt.replace(tzinfo=datetime.timezone.utc) - ).total_seconds() / (3600 * 24 * 7) + weeks = (now - dt.replace(tzinfo=datetime.timezone.utc)).total_seconds() / ( + 3600 * 24 * 7 + ) if weeks < 1.0: continue @@ -2911,7 +2939,9 @@ def create_session(kv: StateKV, session: Dict[str, Any]) -> Dict[str, Any]: def end_session(kv: StateKV, session_id: str) -> bool: - now = datetime.datetime.utcnow().isoformat() + "Z" + now = ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) kv.update( KV.sessions, session_id, @@ -2972,7 +3002,9 @@ def get_project_profile(kv: StateKV, project: str) -> Dict[str, Any]: "topFiles": [], "conventions": [], "commonErrors": [], - "updatedAt": datetime.datetime.utcnow().isoformat() + "Z", + "updatedAt": datetime.datetime.now(datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z"), } if not prof.get("topConcepts") and not prof.get("topFiles"): prof = build_project_profile(kv, project) @@ -2988,14 +3020,16 @@ def build_project_profile(kv: StateKV, project: str) -> Dict[str, Any]: "topFiles": [], "conventions": [], "commonErrors": [], - "updatedAt": datetime.datetime.utcnow().isoformat() + "Z", + "updatedAt": datetime.datetime.now(datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z"), } # Stored profile may lack topConcepts/topFiles β€” compute from observations + memories if empty if not prof.get("topConcepts") and not prof.get("topFiles"): - import re as _re import json as _j import os.path as _osp + import re as _re from collections import Counter sessions = kv.list(KV.sessions) @@ -3087,7 +3121,9 @@ def export_data(kv: StateKV, data: Optional[Dict[str, Any]] = None) -> Dict[str, if data is None: data = {} - exported_at = datetime.datetime.utcnow().isoformat() + "Z" + exported_at = ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) # ---- v2 folder-based export (primary path) ---- folder_pairs = kv.list(KV.folders) @@ -3226,7 +3262,9 @@ def migrate_sessions_to_folders(kv: StateKV, dry_run: bool = False) -> Dict[str, def set_project_profile( kv: StateKV, project: str, profile: Dict[str, Any] ) -> Dict[str, Any]: - profile["updatedAt"] = datetime.datetime.utcnow().isoformat() + "Z" + profile["updatedAt"] = ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) kv.set(KV.profiles, project, profile) # Commit to Dolt @@ -3245,7 +3283,9 @@ def add_relation(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]: "sourceId": data["sourceId"], "targetId": data["targetId"], "type": data["type"], - "createdAt": datetime.datetime.utcnow().isoformat() + "Z", + "createdAt": datetime.datetime.now(datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z"), } kv.set(KV.relations, rel["id"], rel) @@ -3273,7 +3313,9 @@ def evolve_memory(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]: existing["isLatest"] = False kv.set(KV.memories, existing["id"], existing) - now = datetime.datetime.utcnow().isoformat() + "Z" + now = ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) new_mem = dict(existing) new_mem["id"] = generate_id("mem") new_mem["content"] = new_content @@ -3319,8 +3361,7 @@ def evolve_memory(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]: def auto_forget(kv: StateKV, dry_run: bool = False) -> Dict[str, Any]: - now_dt = datetime.datetime.utcnow() - now_dt.isoformat() + "Z" + now_dt = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) evicted_memories = [] evicted_observations = [] @@ -3601,7 +3642,7 @@ def summarize(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]: return {"success": False, "error": "no_observations"} SUMMARY_SYSTEM = """You are a session summarization assistant. Your job is to read all raw tool executions and outcomes from a coding session and produce a high-fidelity summary. - + Output XML: Concise title summarizing the session @@ -3658,7 +3699,9 @@ def summarize(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]: final_summary = { "sessionId": session_id, "project": session.get("project"), - "createdAt": datetime.datetime.utcnow().isoformat() + "Z", + "createdAt": datetime.datetime.now(datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z"), "title": partial_summaries[0]["title"], "narrative": partial_summaries[0]["narrative"], "keyDecisions": partial_summaries[0]["keyDecisions"], @@ -3668,7 +3711,7 @@ def summarize(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]: } else: REDUCE_SYSTEM = """You are a session summarization reducer. Reduce multiple partial chunk summaries into a single final summary. - + Output XML: Concise final title summarizing the entire session @@ -3694,7 +3737,9 @@ def summarize(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]: final_summary = { "sessionId": session_id, "project": session.get("project"), - "createdAt": datetime.datetime.utcnow().isoformat() + "Z", + "createdAt": datetime.datetime.now(datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z"), "title": get_xml_tag(cleaned, "title") or partial_summaries[0]["title"], "narrative": get_xml_tag(cleaned, "narrative") or "", "keyDecisions": get_xml_children(cleaned, "decisions", "decision"), @@ -3772,7 +3817,7 @@ def consolidate( # Prompt templates CONSOLIDATION_SYSTEM = """You are a memory consolidation engine. Given a set of related observations from coding sessions, synthesize them into a single long-term memory. - + Output XML: pattern|preference|architecture|bug|workflow|fact @@ -3829,7 +3874,11 @@ def consolidate( concepts_list = get_xml_children(cleaned, "concepts", "concept") files_list = get_xml_children(cleaned, "files", "file") - now = datetime.datetime.utcnow().isoformat() + "Z" + now = ( + datetime.datetime.now(datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ) # Find existing memory with same title existing_match = None @@ -3934,12 +3983,12 @@ def consolidate( )[:20] SEMANTIC_MERGE_SYSTEM = """You are a memory consolidation engine. Given overlapping episodic memories (session summaries), extract stable factual knowledge. - + Output format (XML): Concise factual statement - + Rules: - Extract only facts that appear in 2+ episodes or are highly confident - Confidence reflects how well-supported the fact is across episodes @@ -3963,7 +4012,11 @@ def consolidate( ) existing_semantic = kv.list(KV.semantic) - now = datetime.datetime.utcnow().isoformat() + "Z" + now = ( + datetime.datetime.now(datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ) for conf_str, fact_text in fact_matches: fact_text = fact_text.strip() @@ -4018,7 +4071,7 @@ def consolidate( if len(patterns) >= 2: PROCEDURAL_EXTRACTION_SYSTEM = """You are a procedural memory extractor. Given repeated patterns and workflows observed across sessions, extract reusable procedures. - + Output format (XML): @@ -4026,7 +4079,7 @@ def consolidate( Step 2 description - + Rules: - Only extract procedures observed 2+ times - Steps should be concrete and actionable @@ -4051,7 +4104,11 @@ def consolidate( ) existing_procs = kv.list(KV.procedural) - now = datetime.datetime.utcnow().isoformat() + "Z" + now = ( + datetime.datetime.now(datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ) for name, trigger, steps_block in proc_matches: steps = [ diff --git a/src/import_data.py b/src/agentcache/import_data.py similarity index 95% rename from src/import_data.py rename to src/agentcache/import_data.py index fd7c0f899dc1ef0af3564b08af58bce59d407e4c..125793f37b53a986d9e9f6970bf74292114b8590 100644 --- a/src/import_data.py +++ b/src/agentcache/import_data.py @@ -1,11 +1,9 @@ -import os import json -import urllib.parse +import os import sys +import urllib.parse -# Append local path so we can import StateKV -sys.path.append(os.path.dirname(os.path.abspath(__file__))) -from db import StateKV +from .db import StateKV def import_old_data(old_db_path: str, kv: StateKV) -> bool: diff --git a/src/mcp_stdio.py b/src/agentcache/mcp_stdio.py similarity index 99% rename from src/mcp_stdio.py rename to src/agentcache/mcp_stdio.py index 372d25cc61b77f77c5afd3c9a7cbeaebc9ba07b4..1616f8e0aa1493e302fab0c10c5342e02c6b87c0 100644 --- a/src/mcp_stdio.py +++ b/src/agentcache/mcp_stdio.py @@ -9,9 +9,10 @@ Antigravity sync functions have been moved to examples/antigravity_sync.py (D1.3 All tool calls are proxied to the HTTP API. """ -import sys import json import os +import sys + import requests BASE = ( diff --git a/src/replay_import.py b/src/agentcache/replay_import.py similarity index 96% rename from src/replay_import.py rename to src/agentcache/replay_import.py index eac874915c609d180ec6a2d7d6af7ce5bb0919d1..cc02a77e8f9de03fa3e104409df7c07c785aeba0 100644 --- a/src/replay_import.py +++ b/src/agentcache/replay_import.py @@ -1,9 +1,9 @@ +import datetime +import hashlib +import json import os import re -import json -import hashlib -import datetime -from typing import List, Dict, Any, Tuple +from typing import Any, Dict, List, Tuple # Constants MAX_FILES_DEFAULT = 200 @@ -124,7 +124,9 @@ def parse_jsonl_text(text: str, fallback_session_id: str = None) -> Dict[str, An if entry.get("cwd") and not cwd: cwd = entry["cwd"] - ts = entry.get("timestamp") or datetime.datetime.utcnow().isoformat() + "Z" + ts = entry.get("timestamp") or datetime.datetime.now( + datetime.timezone.utc + ).isoformat().replace("+00:00", "Z") if not first_ts: first_ts = ts last_ts = ts @@ -196,7 +198,9 @@ def parse_jsonl_text(text: str, fallback_session_id: str = None) -> Dict[str, An if obs["sessionId"] == "imported": obs["sessionId"] = effective_session_id - now_iso = datetime.datetime.utcnow().isoformat() + "Z" + now_iso = ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) return { "sessionId": effective_session_id, "project": derive_project(cwd), @@ -215,11 +219,13 @@ def derive_crystal_and_lessons( compressed: List[Dict[str, Any]], first_prompt: str = None, ) -> None: - from functions import KV + from .functions import KV if not raw_obs: return - created_at = datetime.datetime.utcnow().isoformat() + "Z" + created_at = ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) files = set() tools = set() @@ -362,7 +368,7 @@ def find_jsonl_files(root: str, limit=200) -> Tuple[List[str], bool, int, bool]: def import_jsonl_data(kv, path: str = None, max_files: int = None) -> Dict[str, Any]: - from functions import KV, build_synthetic_compression, _bm25_index + from .functions import KV, _bm25_index, build_synthetic_compression default_root = os.path.expanduser(os.path.join("~", ".claude", "projects")) raw_path = path or default_root @@ -475,7 +481,7 @@ def import_jsonl_data(kv, path: str = None, max_files: int = None) -> Dict[str, } kv.set(KV.sessions, session["id"], session) - from functions import vector_index_add_guarded + from .functions import vector_index_add_guarded compressed = [] for obs in parsed["observations"]: @@ -506,7 +512,7 @@ def import_jsonl_data(kv, path: str = None, max_files: int = None) -> Dict[str, ) # Save the updated persistence state - import functions + from . import functions if functions._index_persistence: try: @@ -516,7 +522,7 @@ def import_jsonl_data(kv, path: str = None, max_files: int = None) -> Dict[str, # Audit trail try: - from functions import log_audit + from .functions import log_audit log_audit( kv, @@ -613,7 +619,11 @@ def estimate_duration_ms(event: Dict[str, Any]) -> int: def project_timeline(observations: List[Dict[str, Any]]) -> Dict[str, Any]: if not observations: - now = datetime.datetime.utcnow().isoformat() + "Z" + now = ( + datetime.datetime.now(datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ) return { "sessionId": "", "startedAt": now, diff --git a/src/routes/__init__.py b/src/agentcache/routes/__init__.py similarity index 100% rename from src/routes/__init__.py rename to src/agentcache/routes/__init__.py index e6d6e8dc8ddadba54ccd597158aad5711c42654d..5ee82169bfca7d3257f571e852a69792613257d7 100644 --- a/src/routes/__init__.py +++ b/src/agentcache/routes/__init__.py @@ -4,13 +4,13 @@ Flask blueprints for agentmemory-python. Import and register all blueprints via register_blueprints(app). """ -from .observations import observations_bp -from .memories import memories_bp -from .search import search_bp from .graph import graph_bp from .health import health_bp from .mcp import mcp_bp +from .memories import memories_bp from .migration import migration_bp +from .observations import observations_bp +from .search import search_bp def register_blueprints(app): diff --git a/src/routes/graph.py b/src/agentcache/routes/graph.py similarity index 96% rename from src/routes/graph.py rename to src/agentcache/routes/graph.py index 23cac4965c39a19586ec2258e9fc92c050b6e19c..ef5ad3740e8fd33797a04a3574cce29b939b9ba4 100644 --- a/src/routes/graph.py +++ b/src/agentcache/routes/graph.py @@ -9,8 +9,10 @@ Handles: """ import os -from flask import Blueprint, request, jsonify -import functions + +from flask import Blueprint, jsonify, request + +from .. import functions graph_bp = Blueprint("graph", __name__) @@ -31,7 +33,7 @@ def _check_auth(): def _get_kv(): - import app as app_module + from .. import app as app_module return app_module.kv diff --git a/src/routes/health.py b/src/agentcache/routes/health.py similarity index 96% rename from src/routes/health.py rename to src/agentcache/routes/health.py index 5314dc89edc562a10d65f7ac8d349d25bcf6e1dd..e4e12a0e364d6cf226a969ee72d93386f89e5819 100644 --- a/src/routes/health.py +++ b/src/agentcache/routes/health.py @@ -9,9 +9,11 @@ Handles: """ import os -from flask import Blueprint, request, jsonify, Response -import functions -from functions import query_audit + +from flask import Blueprint, Response, jsonify, request + +from .. import functions +from ..functions import query_audit health_bp = Blueprint("health", __name__) @@ -32,13 +34,13 @@ def _check_auth(): def _get_kv(): - import app as app_module + from .. import app as app_module return app_module.kv def _get_embedding_provider(): - import app as app_module + from .. import app as app_module return app_module.embedding_provider diff --git a/src/routes/mcp.py b/src/agentcache/routes/mcp.py similarity index 98% rename from src/routes/mcp.py rename to src/agentcache/routes/mcp.py index 5fc6a2696368df15b1c13814bc3270b6ef4c659a..8d92d130c8b828950a4bec35c49f79553ebc54c6 100644 --- a/src/routes/mcp.py +++ b/src/agentcache/routes/mcp.py @@ -6,12 +6,14 @@ Handles: POST /agentmemory/mcp/tools β€” dispatch a tool call """ -import os -import json import datetime -from flask import Blueprint, request, jsonify -import functions -from functions import KV +import json +import os + +from flask import Blueprint, jsonify, request + +from .. import functions +from ..functions import KV mcp_bp = Blueprint("mcp", __name__) @@ -32,13 +34,15 @@ def _check_auth(): def _get_kv(): - import app as app_module + from .. import app as app_module return app_module.kv def _datetime_now_iso() -> str: - return datetime.datetime.utcnow().isoformat() + "Z" + return ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) def _parse_mcp_list_arg(arg_val): diff --git a/src/routes/memories.py b/src/agentcache/routes/memories.py similarity index 96% rename from src/routes/memories.py rename to src/agentcache/routes/memories.py index 475a5d73bbc5d4ee10a6541e5fa41c324c476c58..2ccaac26a03e9282763cc04e2254dfc897a01eb6 100644 --- a/src/routes/memories.py +++ b/src/agentcache/routes/memories.py @@ -9,9 +9,11 @@ Handles: """ import os -from flask import Blueprint, request, jsonify -import functions -from functions import KV + +from flask import Blueprint, jsonify, request + +from .. import functions +from ..functions import KV memories_bp = Blueprint("memories", __name__) @@ -32,7 +34,7 @@ def _check_auth(): def _get_kv(): - import app as app_module + from .. import app as app_module return app_module.kv diff --git a/src/routes/migration.py b/src/agentcache/routes/migration.py similarity index 92% rename from src/routes/migration.py rename to src/agentcache/routes/migration.py index 461036280384855f7537c39d5ad8f789f278b642..04d021fd4eb65e1cbb06f82da7410831b9d70aa0 100644 --- a/src/routes/migration.py +++ b/src/agentcache/routes/migration.py @@ -6,8 +6,10 @@ Handles: """ import os -from flask import Blueprint, request, jsonify -import functions + +from flask import Blueprint, jsonify, request + +from .. import functions migration_bp = Blueprint("migration", __name__) @@ -28,7 +30,7 @@ def _check_auth(): def _get_kv(): - import app as app_module + from .. import app as app_module return app_module.kv diff --git a/src/routes/observations.py b/src/agentcache/routes/observations.py similarity index 97% rename from src/routes/observations.py rename to src/agentcache/routes/observations.py index 9b070eded0fa0bccbbf675aefce458fa513b5b09..36eda72d841346bc8961700b16b070faaccb185a 100644 --- a/src/routes/observations.py +++ b/src/agentcache/routes/observations.py @@ -8,17 +8,21 @@ Handles: GET /agentmemory/folders """ -import os import datetime -from flask import Blueprint, request, jsonify -import functions -from functions import KV +import os + +from flask import Blueprint, jsonify, request + +from .. import functions +from ..functions import KV observations_bp = Blueprint("observations", __name__) def _datetime_now_iso() -> str: - return datetime.datetime.utcnow().isoformat() + "Z" + return ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) def _check_auth(): @@ -39,7 +43,7 @@ def _check_auth(): def _get_kv(): """Retrieve the shared kv instance from the app module.""" - import app as app_module + from .. import app as app_module return app_module.kv diff --git a/src/routes/search.py b/src/agentcache/routes/search.py similarity index 95% rename from src/routes/search.py rename to src/agentcache/routes/search.py index 9f1eda2e1144c6419528355d2016224d192a8aa9..d0d5ddbd58864f32da86f8bd870be53cca0af967 100644 --- a/src/routes/search.py +++ b/src/agentcache/routes/search.py @@ -7,8 +7,10 @@ Handles: """ import os -from flask import Blueprint, request, jsonify -import functions + +from flask import Blueprint, jsonify, request + +from .. import functions search_bp = Blueprint("search", __name__) @@ -29,7 +31,7 @@ def _check_auth(): def _get_kv(): - import app as app_module + from .. import app as app_module return app_module.kv diff --git a/src/search.py b/src/agentcache/search.py similarity index 99% rename from src/search.py rename to src/agentcache/search.py index f24059a8a7cd946ed8844aac6978d087b668df72..6f40d3fbcb4c2de864526938fa560ed92f026fa2 100644 --- a/src/search.py +++ b/src/agentcache/search.py @@ -1,11 +1,11 @@ -import re -import math -import json -import base64 import array -import urllib.request +import base64 +import json +import math +import re import urllib.parse -from typing import Dict, Any, List, Optional, Tuple, Set +import urllib.request +from typing import Any, Dict, List, Optional, Set, Tuple # ===================================================================== # Custom Porter-like Stemmer (Ported from stemmer.ts) diff --git a/src/storage/__init__.py b/src/agentcache/storage/__init__.py similarity index 81% rename from src/storage/__init__.py rename to src/agentcache/storage/__init__.py index 94e4c4d52cb07f76c9886a3f517da955ae3e130b..2f44e0fb287482adf1380265518302d50047c77a 100644 --- a/src/storage/__init__.py +++ b/src/agentcache/storage/__init__.py @@ -12,9 +12,9 @@ These are compatibility copies β€” the originals in functions.py are kept intact for backward compatibility. """ +from .images import delete_image, is_managed_image_path, save_image_to_disk, touch_image +from .paths import fingerprint_id, generate_id, normalize_folder_path, validate_agent_id from .scopes import KV -from .paths import normalize_folder_path, validate_agent_id, generate_id, fingerprint_id -from .images import save_image_to_disk, delete_image, touch_image, is_managed_image_path __all__ = [ "KV", diff --git a/src/storage/images.py b/src/agentcache/storage/images.py similarity index 100% rename from src/storage/images.py rename to src/agentcache/storage/images.py index 7ccec0a92749d888fd1ce33b08cae3ad2078c7f0..8cc4574fe45d063f84ba33720f8bc15b85c979db 100644 --- a/src/storage/images.py +++ b/src/agentcache/storage/images.py @@ -4,8 +4,8 @@ src/storage/images.py β€” Image-on-disk storage helpers (A2.3). Copied from src/functions.py β€” do NOT delete the originals (backward compat). """ -import os import hashlib +import os from typing import Optional, Tuple IMAGES_DIR = os.path.join(os.path.expanduser("~"), ".agentmemory", "images") diff --git a/src/storage/paths.py b/src/agentcache/storage/paths.py similarity index 100% rename from src/storage/paths.py rename to src/agentcache/storage/paths.py index 0e2f0d1895566130cfe1a3cb0ccfe3455fb87606..5c5cd1f4a00ceb5945a7db5c30aef9cfa6fbf9c3 100644 --- a/src/storage/paths.py +++ b/src/agentcache/storage/paths.py @@ -4,10 +4,10 @@ src/storage/paths.py β€” Path normalisation and ID utilities (A2.3). Copied from src/functions.py β€” do NOT delete the originals (backward compat). """ +import hashlib import os import time import uuid -import hashlib # Maximum allowed length for folder paths and agent IDs. _MAX_PATH_LEN = 512 diff --git a/src/storage/scopes.py b/src/agentcache/storage/scopes.py similarity index 100% rename from src/storage/scopes.py rename to src/agentcache/storage/scopes.py diff --git a/src/viewer/favicon.svg b/src/agentcache/viewer/favicon.svg similarity index 100% rename from src/viewer/favicon.svg rename to src/agentcache/viewer/favicon.svg diff --git a/src/viewer/index.html b/src/agentcache/viewer/index.html similarity index 100% rename from src/viewer/index.html rename to src/agentcache/viewer/index.html diff --git a/src/viewer_helpers.py b/src/agentcache/viewer_helpers.py similarity index 99% rename from src/viewer_helpers.py rename to src/agentcache/viewer_helpers.py index a0b0f22a135f68404e4c42e978e10221deb7bb12..4b28b7c1d3e4a177d5f40031ba1682f77d93bb5b 100644 --- a/src/viewer_helpers.py +++ b/src/agentcache/viewer_helpers.py @@ -4,9 +4,10 @@ Viewer HTML rendering helpers. Separated from app.py so the factory stays under 150 lines. """ +import base64 import os import secrets -import base64 + from flask import make_response diff --git a/src/workers.py b/src/agentcache/workers.py similarity index 99% rename from src/workers.py rename to src/agentcache/workers.py index ffa95d7cc47564daca1523e5a762628992661290..a829ba60aa939da991748fc3df75b6cf91ef2c7e 100644 --- a/src/workers.py +++ b/src/agentcache/workers.py @@ -14,7 +14,7 @@ import sys import threading import time -import functions +from . import functions # Module-level shutdown flag β€” set by signal handlers _shutting_down = threading.Event() diff --git a/start.sh b/start.sh index 683a8b43db19599c2943136dd50209522d629499..86badf5c652bc0b7c61d02f0f8352b81a1f0a0dd 100644 --- a/start.sh +++ b/start.sh @@ -59,7 +59,7 @@ export III_REST_PORT=7860 # Start Flask application in the foreground echo "[start] Starting Flask application on port 7860..." -python3 src/app.py +python3 -m agentcache.cli serve --port 7860 # Perform a final backup on shutdown echo "[start] Flask application exited. Performing final backup..." diff --git a/sync.py b/sync.py index 4cb9af91fcd700483ef208b1d2488026c1e70266..2fd7b8fa6d88dad1ad7b8452c79c431e26d1893c 100644 --- a/sync.py +++ b/sync.py @@ -255,7 +255,7 @@ def backup(): # C4.1/C4.2: update state with new HWM and timestamp import datetime state["last_synced_audit_id"] = current_hwm - state["last_sync_at"] = datetime.datetime.utcnow().isoformat() + "Z" + state["last_sync_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") state["sync_status"] = "ok" _save_sync_state(state) diff --git a/tests/__init__.py b/tests/__init__.py index 65140f2e3919fe56b74572d1518910df4a331030..33a488174919151b1b4c43d5cc962c2d58f2d3ec 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1,2 @@ # tests package + diff --git a/tests/test_api.py b/tests/test_api.py index f6e53aeaf87a9db663a7c39376eb3085fb513a47..93ef05365a46296e3ef6e3ddd821855dabaa01cd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -4,16 +4,12 @@ tests/test_api.py β€” C3.1 Integration tests for REST endpoints using the Flask test client. """ -import sys -import os -import json import datetime +import json +import os import pytest -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) - - # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @@ -26,7 +22,7 @@ def flask_app(tmp_path_factory): os.environ.pop("AGENTCACHE_SECRET", None) os.environ.pop("AGENTMEMORY_SECRET", None) - from db import StateKV + from agentcache.db import StateKV original_init = StateKV.__init__ @@ -34,7 +30,7 @@ def flask_app(tmp_path_factory): original_init(self, db_path=db_path, **kwargs) StateKV.__init__ = patched_init - import app as app_module + import agentcache.app as app_module os.environ.pop("AGENTCACHE_SECRET", None) os.environ.pop("AGENTMEMORY_SECRET", None) @@ -50,7 +46,9 @@ def client(flask_app): def _now(): - return datetime.datetime.utcnow().isoformat() + "Z" + return ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) def _post(client, url, payload): diff --git a/tests/test_auth.py b/tests/test_auth.py index a7fcceda9f95d31952a2c3be560c4efebff76a03..c37881c5f2c1e413f23d28526ed6df14e4ed5207 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -4,11 +4,9 @@ tests/test_auth.py Tests for the /auth.md agent onboarding route. """ -import sys import os -import pytest -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) +import pytest @pytest.fixture(scope="module") @@ -18,7 +16,7 @@ def flask_app(tmp_path_factory): os.environ.pop("AGENTCACHE_SECRET", None) os.environ.pop("AGENTMEMORY_SECRET", None) - from db import StateKV + from agentcache.db import StateKV original_init = StateKV.__init__ @@ -26,7 +24,7 @@ def flask_app(tmp_path_factory): original_init(self, db_path=db_path, **kwargs) StateKV.__init__ = patched_init - import app as app_module + import agentcache.app as app_module flask_application = app_module.create_app() StateKV.__init__ = original_init diff --git a/tests/test_context.py b/tests/test_context.py index bad850fb4d1dfe9a6829739f59bab7803af3251b..dc879813cea97fd4f6c3aae3b7fbedb6d7f2ea48 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -4,29 +4,27 @@ tests/test_context.py β€” C1.4 Tests for context(), export_data(), and token budget enforcement. """ -import sys -import os import datetime +import os import pytest -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) - - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_kv(tmp_path): - from db import StateKV + from agentcache.db import StateKV os.environ.pop("AGENTCACHE_SECRET", None) return StateKV(db_path=str(tmp_path / "test.db")) def _now(): - return datetime.datetime.utcnow().isoformat() + "Z" + return ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) # --------------------------------------------------------------------------- @@ -37,7 +35,7 @@ def _now(): class TestContext: def test_empty_db_returns_minimal_context(self, tmp_path): """Empty DB should return a well-formed but empty context.""" - from functions import context + from agentcache.functions import context kv = _make_kv(tmp_path) result = context( @@ -56,14 +54,14 @@ class TestContext: assert result["tokens"] == 0 def test_raises_on_missing_session_id(self, tmp_path): - from functions import context + from agentcache.functions import context kv = _make_kv(tmp_path) with pytest.raises(ValueError): context(kv, {"project": "/home/user/proj"}) def test_raises_on_missing_project(self, tmp_path): - from functions import context + from agentcache.functions import context kv = _make_kv(tmp_path) with pytest.raises(ValueError): @@ -71,7 +69,7 @@ class TestContext: def test_respects_token_budget(self, tmp_path): """Context output tokens should not exceed the requested budget.""" - from functions import context, lesson_save + from agentcache.functions import context, lesson_save kv = _make_kv(tmp_path) project = "/home/user/budget-test" @@ -101,7 +99,7 @@ class TestContext: def test_context_includes_xml_wrapper(self, tmp_path): """Non-empty context should be wrapped in .""" - from functions import context, lesson_save + from agentcache.functions import context, lesson_save kv = _make_kv(tmp_path) project = "/home/user/xml-test" @@ -130,7 +128,7 @@ class TestContext: def test_token_budget_env_var_respected(self, tmp_path, monkeypatch): """TOKEN_BUDGET env var should be used when no budget param given.""" - from functions import context, lesson_save + from agentcache.functions import context, lesson_save kv = _make_kv(tmp_path) project = "/home/user/env-budget" @@ -158,7 +156,7 @@ class TestContext: class TestExportData: def test_export_returns_folders_and_memories(self, tmp_path): - from functions import folder_observe, remember, export_data + from agentcache.functions import export_data, folder_observe, remember kv = _make_kv(tmp_path) @@ -178,7 +176,7 @@ class TestExportData: assert "folders" in result or "observations" in result or "memories" in result def test_export_empty_db(self, tmp_path): - from functions import export_data + from agentcache.functions import export_data kv = _make_kv(tmp_path) result = export_data(kv, {}) @@ -193,12 +191,12 @@ class TestExportData: class TestEstimateTokens: def test_empty_string(self): - from functions import estimate_tokens + from agentcache.functions import estimate_tokens assert estimate_tokens("") == 0 def test_typical_text(self): - from functions import estimate_tokens + from agentcache.functions import estimate_tokens text = "hello world this is a test" * 10 tokens = estimate_tokens(text) diff --git a/tests/test_debounce.py b/tests/test_debounce.py index def7bc3bf765afae5b372b02ffad73bf9f15fef8..c9b3fddc2fd97955290e7bf4716eea37b77dd864 100644 --- a/tests/test_debounce.py +++ b/tests/test_debounce.py @@ -5,16 +5,12 @@ Tests: - flush() triggers immediate save without waiting for debounce timer. """ -import sys -import os import time import unittest.mock as mock -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) - -from db import StateKV -from search import SearchIndex, VectorIndex -from functions import IndexPersistence +from agentcache.db import StateKV +from agentcache.functions import IndexPersistence +from agentcache.search import SearchIndex, VectorIndex # Speed up debounce for tests FAST_DEBOUNCE = 0.05 diff --git a/tests/test_folder_graph_build.py b/tests/test_folder_graph_build.py index 769e63afa0632f87b9763b7acf237ccbcef839ee..5422ce5753f68c89cfc558ea2cf2dd832a401c71 100644 --- a/tests/test_folder_graph_build.py +++ b/tests/test_folder_graph_build.py @@ -1,15 +1,10 @@ """Unit tests for folderColor() and folder_graph_build() β€” REQ-023–REQ-028.""" -import os -import sys - import pytest -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) - -from db import StateKV -from functions import KV, folder_graph_build, folder_color as folderColor - +from agentcache.db import StateKV +from agentcache.functions import KV, folder_graph_build +from agentcache.functions import folder_color as folderColor # --------------------------------------------------------------------------- # Fixtures diff --git a/tests/test_folder_observe.py b/tests/test_folder_observe.py index be6bf485b2424e2ed80b70e1031e9829b476a1aa..8a87690811dfeeeea2c77d43287bc23b1f313137 100644 --- a/tests/test_folder_observe.py +++ b/tests/test_folder_observe.py @@ -1,13 +1,12 @@ """Unit tests for folder_observe (REQ-008, REQ-010, REQ-011, REQ-015).""" -import sys +import datetime import os + import pytest -import datetime -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) -from db import StateKV -from functions import folder_observe, KV +from agentcache.db import StateKV +from agentcache.functions import KV, folder_observe def make_kv(tmp_path): @@ -20,7 +19,9 @@ def base_payload(**overrides): "folderPath": "/home/user/projects/myapp", "agentId": "kiro", "text": "Edited src/app.py to add a new route", - "timestamp": datetime.datetime.utcnow().isoformat() + "Z", + "timestamp": datetime.datetime.now(datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z"), } payload.update(overrides) return payload diff --git a/tests/test_forget.py b/tests/test_forget.py index b80b0cf606e9f187d218e0d88243a1215b01d0d9..11e190a344f29fca58e00f1734b5451ce8740467 100644 --- a/tests/test_forget.py +++ b/tests/test_forget.py @@ -1,12 +1,10 @@ """Unit tests for forget() folder-based deletion (REQ-029–REQ-033).""" -import sys -import os import datetime +import os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) -from db import StateKV -from functions import folder_observe, forget, KV +from agentcache.db import StateKV +from agentcache.functions import KV, folder_observe, forget def make_kv(tmp_path): @@ -23,7 +21,9 @@ def add_obs(kv, folder="/home/user/proj", agent="kiro", n=1): "folderPath": folder, "agentId": agent, "text": f"observation {i}", - "timestamp": datetime.datetime.utcnow().isoformat() + "Z", + "timestamp": datetime.datetime.now(datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z"), }, ) ids.append(result["observationId"]) @@ -102,7 +102,7 @@ class TestForgetPartial: class TestForgetMemory: def test_delete_global_memory(self, tmp_path): kv = make_kv(tmp_path) - from functions import remember + from agentcache.functions import remember result = remember(kv, {"content": "Important insight", "type": "fact"}) mem_id = result["memory"]["id"] diff --git a/tests/test_graph.py b/tests/test_graph.py index 2c3762c859eaebf79b72366a2cd6b33171aa9f63..386df372936c06a432463da909ca7120e9694f69 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -1,12 +1,10 @@ """Unit tests for folder_graph_build (REQ-023–REQ-028).""" -import sys -import os import datetime +import os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) -from db import StateKV -from functions import folder_observe, folder_graph_build +from agentcache.db import StateKV +from agentcache.functions import folder_graph_build, folder_observe def make_kv(tmp_path): @@ -21,7 +19,9 @@ def add_obs(kv, folder, agent="kiro", text="obs"): "folderPath": folder, "agentId": agent, "text": text, - "timestamp": datetime.datetime.utcnow().isoformat() + "Z", + "timestamp": datetime.datetime.now(datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z"), }, ) diff --git a/tests/test_migration.py b/tests/test_migration.py index 78de8f84351aad6ab5caa4d5ad9195b41f92dbb5..31d84043e70cf9196fe9be33e382b7fe889046f9 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -1,12 +1,10 @@ """Unit tests for migrate_sessions_to_folders (REQ-058–REQ-062).""" -import sys -import os import datetime +import os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) -from db import StateKV -from functions import migrate_sessions_to_folders, KV +from agentcache.db import StateKV +from agentcache.functions import KV, migrate_sessions_to_folders def make_kv(tmp_path): @@ -18,7 +16,7 @@ def seed_session( kv, session_id="sess_001", cwd="/home/user/proj", agent="kiro", obs_count=3 ): """Seed a legacy session with observations into the old schema.""" - ts = datetime.datetime.utcnow().isoformat() + "Z" + ts = datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") session = { "id": session_id, "project": cwd, @@ -87,7 +85,9 @@ class TestMigrateActual: { "id": "obs_0:raw", "sessionId": "sess_001", - "timestamp": datetime.datetime.utcnow().isoformat() + "Z", + "timestamp": datetime.datetime.now(datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z"), }, ) result = migrate_sessions_to_folders(kv, dry_run=False) @@ -105,7 +105,11 @@ class TestMigrateActual: def test_unknown_fallback(self, tmp_path): kv = make_kv(tmp_path) # Session with no cwd or project - ts = datetime.datetime.utcnow().isoformat() + "Z" + ts = ( + datetime.datetime.now(datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ) kv.set( KV.sessions, "sess_no_path", diff --git a/tests/test_normalize.py b/tests/test_normalize.py index d90a262112e612568e09f51c5c9954ab784c9059..0927cbe8be49374012f6bd7e36c23cd2adbcce68 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -1,11 +1,8 @@ """Unit tests for normalize_folder_path (REQ-002, REQ-063, REQ-064, REQ-066).""" -import sys -import os import pytest -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) -from functions import normalize_folder_path +from agentcache.functions import normalize_folder_path class TestNormalizeFolderPath: diff --git a/tests/test_obs_lookup.py b/tests/test_obs_lookup.py index f96b7170c188c247de281aa06d500e223649b3e4..5288255bd18ff4e9a40a5506abaa7209b97313d3 100644 --- a/tests/test_obs_lookup.py +++ b/tests/test_obs_lookup.py @@ -1,18 +1,16 @@ """Unit tests for observation lookup index, backfill, and index sync validation.""" -import sys -import os import datetime +import os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) -from db import StateKV -import functions -from functions import ( +from agentcache import functions +from agentcache.db import StateKV +from agentcache.functions import ( + KV, + backfill_obs_lookup_if_needed, folder_observe, forget, - backfill_obs_lookup_if_needed, verify_index_sync_on_boot, - KV, ) @@ -26,7 +24,9 @@ def base_payload(folder="/home/user/proj", agent="kiro", text="Test observation" "folderPath": folder, "agentId": agent, "text": text, - "timestamp": datetime.datetime.utcnow().isoformat() + "Z", + "timestamp": datetime.datetime.now(datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z"), } diff --git a/tests/test_observe_core.py b/tests/test_observe_core.py index 08fe2ae191d53891f84a2932fa03d0f9d9c51f87..d1200545966988eaf6bf3c36a66d8ec3d7043d95 100644 --- a/tests/test_observe_core.py +++ b/tests/test_observe_core.py @@ -2,16 +2,12 @@ C1.1 β€” Unit tests for observe(), strip_private_data(), and folder_observe(). """ -import sys -import os import datetime -import pytest - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) -from db import StateKV -from functions import observe, folder_observe, strip_private_data, KV +import pytest +from agentcache.db import StateKV +from agentcache.functions import KV, folder_observe, observe, strip_private_data # --------------------------------------------------------------------------- # Helpers @@ -23,7 +19,9 @@ def make_kv(tmp_path): def _now() -> str: - return datetime.datetime.utcnow().isoformat() + "Z" + return ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) def valid_observe_payload(**overrides): diff --git a/tests/test_properties.py b/tests/test_properties.py index ac8527c4451afe6e2ad65d2fad929fd699ad798c..f8ea633168f695d54a1679e04d2f7ec6d84e178a 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -8,17 +8,14 @@ Note: Each property test creates a fresh isolated SQLite DB per hypothesis example using a shared counter, avoiding state accumulation between examples. """ -import sys -import os import datetime +import os import tempfile import pytest -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) - try: - from hypothesis import given, settings, assume, HealthCheck + from hypothesis import HealthCheck, assume, given, settings from hypothesis import strategies as st HYPOTHESIS_AVAILABLE = True @@ -39,7 +36,7 @@ _counter = [0] def _fresh_kv(): """Create a brand-new isolated StateKV in a temp directory.""" - from db import StateKV + from agentcache.db import StateKV os.environ.pop("AGENTCACHE_SECRET", None) _counter[0] += 1 @@ -48,7 +45,9 @@ def _fresh_kv(): def _now(): - return datetime.datetime.utcnow().isoformat() + "Z" + return ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) def _safe_path(): @@ -91,7 +90,7 @@ def _safe_text(): def test_property_1_pair_isolation(path1, agent1, path2, agent2, text): assume((path1, agent1) != (path2, agent2)) - from functions import folder_observe, KV + from agentcache.functions import KV, folder_observe kv = _fresh_kv() @@ -121,7 +120,7 @@ def test_property_1_pair_isolation(path1, agent1, path2, agent2, text): texts=st.lists(_safe_text(), min_size=1, max_size=10), ) def test_property_2_obs_count_consistency(path, agent, texts): - from functions import folder_observe, KV + from agentcache.functions import KV, folder_observe kv = _fresh_kv() @@ -152,7 +151,7 @@ def test_property_2_obs_count_consistency(path, agent, texts): text=_safe_text(), ) def test_property_3_index_coverage(path, agent, text): - from functions import folder_observe, KV + from agentcache.functions import KV, folder_observe kv = _fresh_kv() @@ -186,7 +185,7 @@ def test_property_3_index_coverage(path, agent, text): prefix=st.text(alphabet="abcdefghijklmnop", min_size=3, max_size=10), ) def test_property_4_privacy_invariant(path, agent, prefix): - from functions import folder_observe, KV + from agentcache.functions import KV, folder_observe kv = _fresh_kv() @@ -221,7 +220,7 @@ def test_property_4_privacy_invariant(path, agent, prefix): n=st.integers(min_value=2, max_value=8), ) def test_property_5_timeline_ordering(path, agent, n): - from functions import folder_observe, folder_timeline + from agentcache.functions import folder_observe, folder_timeline kv = _fresh_kv() @@ -256,7 +255,7 @@ def test_property_5_timeline_ordering(path, agent, n): texts=st.lists(_safe_text(), min_size=1, max_size=5), ) def test_property_6_forget_completeness(path, agent, texts): - from functions import folder_observe, forget, KV + from agentcache.functions import KV, folder_observe, forget kv = _fresh_kv() @@ -297,7 +296,7 @@ def test_property_6_forget_completeness(path, agent, texts): n_variants=st.integers(min_value=2, max_value=4), ) def test_property_7_memory_version_uniqueness(base_content, n_variants): - from functions import remember, KV + from agentcache.functions import KV, remember kv = _fresh_kv() @@ -341,7 +340,7 @@ def test_property_7_memory_version_uniqueness(base_content, n_variants): ).filter(lambda p: ".." not in p and p.strip("/") != ""), ) def test_property_8_path_normalization_idempotency(path): - from functions import normalize_folder_path + from agentcache.functions import normalize_folder_path try: normalized_once = normalize_folder_path(path) diff --git a/tests/test_remember.py b/tests/test_remember.py index fdeb04f5a7a233557026455e8a27e7573c59c09a..2337203e93855038c9c089d3b1adf8c3b5ac944b 100644 --- a/tests/test_remember.py +++ b/tests/test_remember.py @@ -4,29 +4,27 @@ tests/test_remember.py β€” C1.2 Tests for remember(), forget(), and jaccard_similarity(). """ -import sys -import os import datetime +import os import pytest -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) - - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_kv(tmp_path): - from db import StateKV + from agentcache.db import StateKV os.environ.pop("AGENTCACHE_SECRET", None) return StateKV(db_path=str(tmp_path / "test.db")) def _now(): - return datetime.datetime.utcnow().isoformat() + "Z" + return ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) # --------------------------------------------------------------------------- @@ -36,18 +34,18 @@ def _now(): class TestJaccardSimilarity: def test_identical_strings(self): - from functions import jaccard_similarity + from agentcache.functions import jaccard_similarity assert jaccard_similarity("hello world foo", "hello world foo") == 1.0 def test_completely_different(self): - from functions import jaccard_similarity + from agentcache.functions import jaccard_similarity score = jaccard_similarity("apple banana cherry", "xyz uvw qrs") assert score == 0.0 def test_partial_overlap(self): - from functions import jaccard_similarity + from agentcache.functions import jaccard_similarity score = jaccard_similarity( "authentication security token", "authentication bearer token" @@ -55,12 +53,12 @@ class TestJaccardSimilarity: assert 0.0 < score < 1.0 def test_empty_strings(self): - from functions import jaccard_similarity + from agentcache.functions import jaccard_similarity assert jaccard_similarity("", "") == 1.0 def test_high_similarity_above_threshold(self): - from functions import jaccard_similarity + from agentcache.functions import jaccard_similarity # These two should meet or exceed the 0.7 threshold used in remember() a = "Use parameterised queries to prevent SQL injection in all database calls" @@ -68,7 +66,7 @@ class TestJaccardSimilarity: assert jaccard_similarity(a, b) >= 0.7 def test_low_similarity_below_threshold(self): - from functions import jaccard_similarity + from agentcache.functions import jaccard_similarity a = "Configure Redis as the session cache backend" b = "Deploy the React frontend to Vercel using GitHub Actions CI" @@ -82,7 +80,7 @@ class TestJaccardSimilarity: class TestRemember: def test_creates_memory_with_is_latest_true(self, tmp_path): - from functions import remember + from agentcache.functions import remember kv = _make_kv(tmp_path) result = remember(kv, {"content": "Always use type hints in Python functions"}) @@ -93,7 +91,7 @@ class TestRemember: assert "Always use type hints" in mem["content"] def test_memory_has_required_fields(self, tmp_path): - from functions import remember + from agentcache.functions import remember kv = _make_kv(tmp_path) result = remember( @@ -112,7 +110,7 @@ class TestRemember: assert "design" in mem["concepts"] def test_supersedes_memory_with_high_jaccard_similarity(self, tmp_path): - from functions import remember, KV + from agentcache.functions import KV, remember kv = _make_kv(tmp_path) @@ -143,7 +141,7 @@ class TestRemember: assert second_mem.get("parentId") == first_id def test_independent_memory_with_low_jaccard_similarity(self, tmp_path): - from functions import remember, KV + from agentcache.functions import KV, remember kv = _make_kv(tmp_path) @@ -178,14 +176,14 @@ class TestRemember: assert len(all_mems) == 2 def test_remember_raises_on_empty_content(self, tmp_path): - from functions import remember + from agentcache.functions import remember kv = _make_kv(tmp_path) with pytest.raises(ValueError, match="content is required"): remember(kv, {"content": ""}) def test_remember_strips_private_data(self, tmp_path): - from functions import remember + from agentcache.functions import remember kv = _make_kv(tmp_path) result = remember( @@ -198,7 +196,7 @@ class TestRemember: assert "[REDACTED" in result["memory"]["content"] def test_remember_with_project_scoping(self, tmp_path): - from functions import remember + from agentcache.functions import remember kv = _make_kv(tmp_path) # Two very similar memories for different projects should not supersede each other @@ -228,7 +226,7 @@ class TestRemember: class TestForget: def test_forget_memory_by_id(self, tmp_path): - from functions import remember, forget, KV + from agentcache.functions import KV, forget, remember kv = _make_kv(tmp_path) result = remember(kv, {"content": "This memory will be forgotten"}) @@ -239,7 +237,7 @@ class TestForget: assert kv.get(KV.memories, mem_id) is None def test_forget_returns_zero_for_nonexistent_memory(self, tmp_path): - from functions import forget + from agentcache.functions import forget kv = _make_kv(tmp_path) result = forget(kv, {"memoryId": "mem_nonexistent_id"}) diff --git a/tests/test_route_regressions.py b/tests/test_route_regressions.py index 7c4cd94fe8497badc2a08c3d7838ce4019388ab8..6e241181a8ce264102d6ac77f9e3b228731cf4ac 100644 --- a/tests/test_route_regressions.py +++ b/tests/test_route_regressions.py @@ -9,15 +9,13 @@ Tests use the Flask test client β€” no running server required. The AGENTCACHE_SECRET env var is NOT set so all auth checks pass through. """ -import sys -import os -import json import datetime +import json +import os import pytest # Ensure src/ is on the path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) # --------------------------------------------------------------------------- @@ -43,11 +41,11 @@ def flask_app(tmp_path_factory): os.environ.pop("AGENTCACHE_SECRET", None) os.environ.pop("AGENTMEMORY_SECRET", None) - import app as app_module + import agentcache.app as app_module os.environ.pop("AGENTCACHE_SECRET", None) os.environ.pop("AGENTMEMORY_SECRET", None) - from db import StateKV + from agentcache.db import StateKV # Patch StateKV to use tmp db before create_app() initialises it original_init = StateKV.__init__ @@ -75,7 +73,9 @@ def client(flask_app): def _now_iso() -> str: - return datetime.datetime.utcnow().isoformat() + "Z" + return ( + datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ) def _post_json(client, url, payload): diff --git a/tests/test_search.py b/tests/test_search.py index 0447fcdda434bcd5bd3a356ddfbcb8441e68f5ee..4a4c0bdd3e7207566600ccc9115cda22a2154040 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -4,12 +4,6 @@ tests/test_search.py β€” C1.3 Tests for SearchIndex, HybridSearch, and synonym expansion. """ -import sys -import os - - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) - # --------------------------------------------------------------------------- # SearchIndex unit tests @@ -29,7 +23,7 @@ class TestSearchIndex: } def test_add_and_exact_match_returns_rank_one(self): - from search import SearchIndex + from agentcache.search import SearchIndex idx = SearchIndex() obs = self._make_obs("obs_001", "authentication middleware refactor") @@ -39,7 +33,7 @@ class TestSearchIndex: assert results[0]["obsId"] == "obs_001" def test_prefix_matching(self): - from search import SearchIndex + from agentcache.search import SearchIndex idx = SearchIndex() obs = self._make_obs("obs_002", "authentication token validation") @@ -49,7 +43,7 @@ class TestSearchIndex: def test_synonym_expansion_db_conn(self): """'db conn' should find document indexed with 'database connection'.""" - from search import SearchIndex + from agentcache.search import SearchIndex idx = SearchIndex() obs = self._make_obs( @@ -62,7 +56,7 @@ class TestSearchIndex: assert any(r["obsId"] == "obs_003" for r in results) def test_remove_document(self): - from search import SearchIndex + from agentcache.search import SearchIndex idx = SearchIndex() obs = self._make_obs("obs_004", "deploy kubernetes service mesh") @@ -72,14 +66,14 @@ class TestSearchIndex: assert not any(r["obsId"] == "obs_004" for r in results) def test_empty_index_returns_empty(self): - from search import SearchIndex + from agentcache.search import SearchIndex idx = SearchIndex() results = idx.search("anything") assert results == [] def test_multiple_docs_rank_order(self): - from search import SearchIndex + from agentcache.search import SearchIndex idx = SearchIndex() idx.add( @@ -102,7 +96,7 @@ class TestSearchIndex: assert results[0]["obsId"] == "obs_a" def test_size_property(self): - from search import SearchIndex + from agentcache.search import SearchIndex idx = SearchIndex() assert idx.size == 0 @@ -113,7 +107,7 @@ class TestSearchIndex: assert idx.size == 1 def test_clear(self): - from search import SearchIndex + from agentcache.search import SearchIndex idx = SearchIndex() idx.add(self._make_obs("x1", "something")) @@ -122,7 +116,7 @@ class TestSearchIndex: assert idx.search("something") == [] def test_dirty_flag_set_on_add(self): - from search import SearchIndex + from agentcache.search import SearchIndex idx = SearchIndex() assert idx._dirty is False @@ -130,7 +124,7 @@ class TestSearchIndex: assert idx._dirty is True def test_dirty_flag_set_on_remove(self): - from search import SearchIndex + from agentcache.search import SearchIndex idx = SearchIndex() idx.add(self._make_obs("x1", "test remove dirty")) @@ -139,7 +133,7 @@ class TestSearchIndex: assert idx._dirty is True def test_dirty_flag_reset_after_restore(self): - from search import SearchIndex + from agentcache.search import SearchIndex idx = SearchIndex() idx.add(self._make_obs("x1", "test restore")) @@ -149,7 +143,7 @@ class TestSearchIndex: assert idx2._dirty is False def test_has_method(self): - from search import SearchIndex + from agentcache.search import SearchIndex idx = SearchIndex() obs = self._make_obs("obs_has", "has method test") @@ -167,7 +161,7 @@ class TestSearchIndex: class TestVectorIndex: def test_dirty_flag_on_add(self): - from search import VectorIndex + from agentcache.search import VectorIndex vi = VectorIndex() assert vi._dirty is False @@ -175,7 +169,7 @@ class TestVectorIndex: assert vi._dirty is True def test_dirty_flag_on_remove(self): - from search import VectorIndex + from agentcache.search import VectorIndex vi = VectorIndex() vi.add("v1", "sess", [0.1, 0.2, 0.3]) @@ -184,7 +178,7 @@ class TestVectorIndex: assert vi._dirty is True def test_dirty_flag_reset_after_restore(self): - from search import VectorIndex + from agentcache.search import VectorIndex vi = VectorIndex() vi.add("v1", "sess", [0.1, 0.2, 0.3]) @@ -212,7 +206,7 @@ class TestHybridSearchBM25Only: } def test_hybrid_bm25_only_returns_same_results_as_search_index(self): - from search import SearchIndex, VectorIndex, HybridSearch + from agentcache.search import HybridSearch, SearchIndex, VectorIndex bm25 = SearchIndex() vector = VectorIndex() @@ -237,7 +231,7 @@ class TestHybridSearchBM25Only: assert bm25_ids[0] == hybrid_ids[0] def test_hybrid_returns_empty_for_no_matches(self): - from search import SearchIndex, VectorIndex, HybridSearch + from agentcache.search import HybridSearch, SearchIndex, VectorIndex bm25 = SearchIndex() hybrid = HybridSearch(bm25, VectorIndex(), None, None) @@ -251,7 +245,7 @@ class TestHybridSearchBM25Only: class TestSearchIndexSerialization: def test_roundtrip_preserves_search_results(self): - from search import SearchIndex + from agentcache.search import SearchIndex idx = SearchIndex() idx.add( diff --git a/tests/test_timeline.py b/tests/test_timeline.py index ef7eecb168a4ca987a27b857ddc62d2aae941805..f12514b37007cc600c01d1aad28ecb1f68bbbe17 100644 --- a/tests/test_timeline.py +++ b/tests/test_timeline.py @@ -1,12 +1,10 @@ """Unit tests for folder_timeline (REQ-020, REQ-021, REQ-022, REQ-071).""" -import sys -import os import datetime +import os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) -from db import StateKV -from functions import folder_observe, folder_timeline +from agentcache.db import StateKV +from agentcache.functions import folder_observe, folder_timeline def make_kv(tmp_path):