agentcache-core 0.9.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. agentcache/__init__.py +29 -0
  2. agentcache/app.py +312 -0
  3. agentcache/cli.py +346 -0
  4. agentcache/connect.py +724 -0
  5. agentcache/core/__init__.py +19 -0
  6. agentcache/core/audit_log.py +104 -0
  7. agentcache/core/config.py +51 -0
  8. agentcache/core/context_builder.py +209 -0
  9. agentcache/core/graph.py +120 -0
  10. agentcache/core/image_store.py +111 -0
  11. agentcache/core/infer.py +142 -0
  12. agentcache/core/kv_scopes.py +86 -0
  13. agentcache/core/lessons.py +242 -0
  14. agentcache/core/llm.py +596 -0
  15. agentcache/core/memory_store.py +207 -0
  16. agentcache/core/observation_store.py +576 -0
  17. agentcache/core/privacy.py +34 -0
  18. agentcache/core/project_profile.py +625 -0
  19. agentcache/core/search_service.py +444 -0
  20. agentcache/core/session_store.py +382 -0
  21. agentcache/core/slots.py +504 -0
  22. agentcache/db.py +359 -0
  23. agentcache/import_data.py +86 -0
  24. agentcache/legacy.py +94 -0
  25. agentcache/mcp_stdio.py +141 -0
  26. agentcache/py.typed +0 -0
  27. agentcache/replay_import.py +680 -0
  28. agentcache/routes/__init__.py +29 -0
  29. agentcache/routes/_deps.py +46 -0
  30. agentcache/routes/auth.py +68 -0
  31. agentcache/routes/graph.py +81 -0
  32. agentcache/routes/health.py +149 -0
  33. agentcache/routes/mcp.py +614 -0
  34. agentcache/routes/memories.py +116 -0
  35. agentcache/routes/migration.py +32 -0
  36. agentcache/routes/observations.py +253 -0
  37. agentcache/routes/search.py +80 -0
  38. agentcache/search.py +935 -0
  39. agentcache/storage/__init__.py +29 -0
  40. agentcache/storage/images.py +102 -0
  41. agentcache/storage/paths.py +116 -0
  42. agentcache/storage/scopes.py +9 -0
  43. agentcache/viewer/favicon.svg +1 -0
  44. agentcache/viewer/index.html +4235 -0
  45. agentcache/viewer_helpers.py +61 -0
  46. agentcache/workers.py +192 -0
  47. agentcache_core-0.9.9.dist-info/METADATA +194 -0
  48. agentcache_core-0.9.9.dist-info/RECORD +52 -0
  49. agentcache_core-0.9.9.dist-info/WHEEL +5 -0
  50. agentcache_core-0.9.9.dist-info/entry_points.txt +2 -0
  51. agentcache_core-0.9.9.dist-info/licenses/LICENSE +190 -0
  52. agentcache_core-0.9.9.dist-info/top_level.txt +1 -0
agentcache/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """
2
+ agentcache — A Python REST + WebSocket + MCP cache server for AI agents, backed by SQLite.
3
+ """
4
+
5
+ __version__ = "0.9.9"
6
+
7
+ from .app import create_app
8
+ from .connect import run_connect
9
+ from .core import KV, ObservationEvents, ObservationStore, SearchService
10
+ from .db import StateKV
11
+ from .legacy import (
12
+ folder_graph_build,
13
+ health_check,
14
+ remember,
15
+ )
16
+
17
+ __all__ = [
18
+ "__version__",
19
+ "create_app",
20
+ "StateKV",
21
+ "run_connect",
22
+ "KV",
23
+ "ObservationStore",
24
+ "ObservationEvents",
25
+ "SearchService",
26
+ "folder_graph_build",
27
+ "remember",
28
+ "health_check",
29
+ ]
agentcache/app.py ADDED
@@ -0,0 +1,312 @@
1
+ """
2
+ agentmemory-python — Flask application factory.
3
+
4
+ Entry point: create_app() returns a fully configured Flask app.
5
+ Run directly: python src/app.py
6
+ """
7
+
8
+ import hmac
9
+ import json
10
+ import os
11
+ import sys
12
+
13
+ from flask import Flask, request, send_from_directory
14
+ from flask_sock import Sock
15
+
16
+ from . import legacy
17
+
18
+ # Prevent double-import of app when run directly as __main__
19
+ if __name__ == "__main__":
20
+ sys.modules["app"] = sys.modules["__main__"]
21
+
22
+
23
+ def _load_env() -> None:
24
+ env_path = os.path.join(os.path.expanduser("~"), ".agentcache", ".env")
25
+ if not os.path.exists(env_path):
26
+ env_path = os.path.join(os.path.expanduser("~"), ".agentmemory", ".env")
27
+ if not os.path.exists(env_path):
28
+ return
29
+ try:
30
+ with open(env_path, "r", encoding="utf-8") as f:
31
+ for line in f:
32
+ line = line.strip()
33
+ if not line or line.startswith("#") or "=" not in line:
34
+ continue
35
+ k, v = line.split("=", 1)
36
+ os.environ[k.strip()] = v.strip().strip('"').strip("'")
37
+ print(f"[config] Loaded environment from {env_path}")
38
+ except Exception as e:
39
+ print(f"[config] Error reading env file: {e}")
40
+
41
+
42
+ _load_env()
43
+
44
+ # Module-level singletons — set once by init_services(), read by blueprints and workers.
45
+ kv = None
46
+ embedding_provider = None
47
+ persistence = None # kept for backward compat with workers; use search_service instead
48
+ search_service = None # SearchService instance
49
+ observation_store = None # ObservationStore instance
50
+
51
+
52
+ def init_services() -> tuple:
53
+ """Initialise database, SearchService, ObservationStore, and legacy persistence shim."""
54
+ global kv, embedding_provider, persistence, search_service, observation_store
55
+ if kv is not None:
56
+ return kv, embedding_provider, persistence
57
+
58
+ from . import search as search_mod
59
+ from .core.observation_store import ObservationEvents, ObservationStore
60
+ from .core.search_service import SearchService
61
+ from .db import StateKV
62
+ from .search import SearchIndex, VectorIndex
63
+
64
+ # 1. DB
65
+ kv = StateKV()
66
+
67
+ # 2. Embedding provider — auto-select by priority:
68
+ # GEMINI_API_KEY → OPENAI_API_KEY → AGENTCACHE_LOCAL_EMBEDDING_MODEL → BM25-only
69
+ api_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
70
+ openai_key = os.getenv("OPENAI_API_KEY")
71
+ local_model = os.getenv("AGENTCACHE_LOCAL_EMBEDDING_MODEL") or os.getenv(
72
+ "AGENTMEMORY_LOCAL_EMBEDDING_MODEL"
73
+ )
74
+
75
+ if api_key:
76
+ try:
77
+ embedding_provider = search_mod.GeminiEmbeddingProvider(api_key)
78
+ print(
79
+ f"[search] Embedding provider active: gemini ({embedding_provider.dimensions} dims)"
80
+ )
81
+ except Exception as e:
82
+ print(f"[search] Error initialising Gemini embedding provider: {e}")
83
+ elif openai_key:
84
+ try:
85
+ embedding_provider = search_mod.OpenAIEmbeddingProvider(openai_key)
86
+ print(
87
+ f"[search] Embedding provider active: openai ({embedding_provider.dimensions} dims)"
88
+ )
89
+ except Exception as e:
90
+ print(f"[search] Error initialising OpenAI embedding provider: {e}")
91
+ elif local_model:
92
+ try:
93
+ embedding_provider = search_mod.SentenceTransformerProvider(local_model)
94
+ print(
95
+ f"[search] Embedding provider active: sentence-transformers/{local_model} ({embedding_provider.dimensions} dims)"
96
+ )
97
+ except ImportError as e:
98
+ print(f"[search] sentence-transformers not installed: {e}")
99
+ except Exception as e:
100
+ print(f"[search] Error initialising SentenceTransformer provider: {e}")
101
+ else:
102
+ print("[search] No embedding API key found — running in BM25-only mode.")
103
+
104
+ # 3. Construct SearchService and ObservationStore with injected dependencies.
105
+ bm25 = SearchIndex()
106
+ vector = VectorIndex() if embedding_provider is not None else None
107
+ search_service = SearchService(bm25, vector, embedding_provider, kv)
108
+ observation_store = ObservationStore(
109
+ kv, search_service=search_service, events=ObservationEvents()
110
+ )
111
+
112
+ # Load persisted indexes.
113
+ loaded = search_service.load_persisted()
114
+ print(
115
+ f"[persistence] Load results: BM25={loaded['bm25']}, Vector={loaded['vector']}"
116
+ )
117
+
118
+ # Keep persistence reference for workers backward compat.
119
+ persistence = search_service._persistence
120
+
121
+ # Backfill coordinate lookup index if missing/incomplete.
122
+ try:
123
+ if observation_store is not None:
124
+ observation_store.backfill_lookup()
125
+ except Exception as e:
126
+ print(f"[db] Warning backfilling obs_lookup: {e}")
127
+
128
+ return kv, embedding_provider, persistence
129
+
130
+
131
+ def create_app() -> Flask:
132
+ """Create and return a fully configured Flask application."""
133
+ # Check security credentials
134
+ if not os.getenv("AGENTCACHE_SECRET") and not os.getenv("AGENTMEMORY_SECRET"):
135
+ print(
136
+ "[security] WARNING: AGENTCACHE_SECRET/AGENTMEMORY_SECRET is not set! All API endpoints are publicly accessible without authentication."
137
+ )
138
+
139
+ init_services()
140
+
141
+ from .viewer_helpers import make_viewer_response
142
+
143
+ # 4. Flask app + blueprints
144
+ flask_app = Flask(__name__)
145
+ flask_app.extensions["kv"] = kv
146
+ flask_app.extensions["observation_store"] = observation_store
147
+ flask_app.extensions["search_service"] = search_service
148
+
149
+ from werkzeug.middleware.proxy_fix import ProxyFix
150
+
151
+ flask_app.wsgi_app = ProxyFix(
152
+ flask_app.wsgi_app, x_proto=1, x_host=1, x_port=1, x_prefix=1
153
+ )
154
+ from .routes import register_blueprints
155
+
156
+ register_blueprints(
157
+ flask_app, observation_store=observation_store, search_service=search_service
158
+ )
159
+
160
+ # 5. WebSocket broadcaster
161
+ sock = Sock(flask_app)
162
+ _ws_clients: set = set()
163
+
164
+ @sock.route("/stream/mem-live/viewer")
165
+ def stream_viewer(ws):
166
+ secret = os.getenv("AGENTCACHE_SECRET") or os.getenv("AGENTMEMORY_SECRET")
167
+ if secret:
168
+ token = request.args.get("token") or request.args.get("secret")
169
+ if not token or not hmac.compare_digest(
170
+ token.encode("utf-8"), secret.encode("utf-8")
171
+ ):
172
+ ws.close(1008)
173
+ return
174
+ _ws_clients.add(ws)
175
+ try:
176
+ while ws.receive() is not None:
177
+ pass
178
+ except Exception:
179
+ pass
180
+ finally:
181
+ _ws_clients.discard(ws)
182
+
183
+ def _broadcast(payload: dict) -> None:
184
+ msg = json.dumps(payload)
185
+ for ws in list(_ws_clients):
186
+ try:
187
+ ws.send(msg)
188
+ except Exception:
189
+ _ws_clients.discard(ws)
190
+
191
+ legacy.set_stream_broadcaster(_broadcast)
192
+
193
+ if observation_store and observation_store.events:
194
+ observation_store.events.on_added.append(_broadcast)
195
+
196
+ # 6. Viewer static routes
197
+ from importlib.resources import files
198
+
199
+ _viewer_resources = files("agentcache").joinpath("viewer")
200
+ _base_dir = str(_viewer_resources.parent)
201
+
202
+ @flask_app.route("/")
203
+ @flask_app.route("/viewer")
204
+ @flask_app.route("/agentcache/viewer")
205
+ @flask_app.route("/agentmemory/viewer")
206
+ def serve_viewer():
207
+ try:
208
+ return make_viewer_response(_base_dir)
209
+ except Exception as e:
210
+ return f"Viewer not found: {e}", 404
211
+
212
+ @flask_app.route("/favicon.svg")
213
+ def serve_favicon():
214
+ return send_from_directory(str(_viewer_resources), "favicon.svg")
215
+
216
+ # 7. CORS after_request — D2.1: configurable via AGENTCACHE_CORS_ORIGINS env var
217
+ # Default allows localhost, 127.0.0.1, HuggingFace Spaces, vscode-webview://, chrome-extension://
218
+ # Wildcard entries like "*.hf.space" match any subdomain via suffix check.
219
+ _default_cors = (
220
+ "http://localhost,http://127.0.0.1,"
221
+ "https://huggingface.co,https://*.hf.space,"
222
+ "vscode-webview://*,chrome-extension://*"
223
+ )
224
+ _cors_origins_raw = (
225
+ os.getenv("AGENTCACHE_CORS_ORIGINS")
226
+ or os.getenv("AGENTMEMORY_CORS_ORIGINS")
227
+ or _default_cors
228
+ )
229
+
230
+ def _parse_cors_origins(raw: str):
231
+ """Return (exact_set, suffix_list) for efficient origin matching."""
232
+ exact, suffixes = set(), []
233
+ for entry in raw.split(","):
234
+ entry = entry.strip()
235
+ if not entry:
236
+ continue
237
+ if entry.startswith("*."):
238
+ # *.hf.space → match anything ending with .hf.space
239
+ suffixes.append(entry[1:].lower()) # keep the leading dot: ".hf.space"
240
+ elif "*" in entry:
241
+ # generic prefix wildcard: strip trailing * and treat as prefix
242
+ suffixes.append(("prefix:", entry.rstrip("*").lower()))
243
+ else:
244
+ exact.add(entry.lower())
245
+ return exact, suffixes
246
+
247
+ _cors_exact, _cors_suffixes = _parse_cors_origins(_cors_origins_raw)
248
+
249
+ def _origin_allowed(origin: str) -> bool:
250
+ lo = origin.lower()
251
+ if lo in _cors_exact:
252
+ return True
253
+ for s in _cors_suffixes:
254
+ if isinstance(s, tuple) and s[0] == "prefix:":
255
+ if lo.startswith(s[1]):
256
+ return True
257
+ elif lo.endswith(s):
258
+ return True
259
+ return False
260
+
261
+ @flask_app.after_request
262
+ def _cors(response):
263
+ origin = request.headers.get("Origin")
264
+ if origin and _origin_allowed(origin):
265
+ response.headers["Access-Control-Allow-Origin"] = origin
266
+ response.headers["Access-Control-Allow-Credentials"] = "true"
267
+ response.headers.add(
268
+ "Access-Control-Allow-Headers", "Content-Type, Authorization"
269
+ )
270
+ response.headers.add(
271
+ "Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"
272
+ )
273
+ return response
274
+
275
+ # Handle CORS preflight OPTIONS requests globally
276
+ from flask import Response as _FlaskResponse
277
+
278
+ @flask_app.before_request
279
+ def _handle_options():
280
+ if request.method == "OPTIONS":
281
+ origin = request.headers.get("Origin", "")
282
+ if origin and _origin_allowed(origin):
283
+ resp = _FlaskResponse("", status=204)
284
+ resp.headers["Access-Control-Allow-Origin"] = origin
285
+ resp.headers["Access-Control-Allow-Credentials"] = "true"
286
+ resp.headers["Access-Control-Allow-Headers"] = (
287
+ "Content-Type, Authorization"
288
+ )
289
+ resp.headers["Access-Control-Allow-Methods"] = (
290
+ "GET, POST, PUT, DELETE, OPTIONS"
291
+ )
292
+ resp.headers["Access-Control-Max-Age"] = "86400"
293
+ return resp
294
+
295
+ # 8. Background workers
296
+ if os.getenv("AGENTCACHE_DISABLE_WORKERS") != "true":
297
+ from .workers import start_background_workers
298
+
299
+ start_background_workers(kv)
300
+
301
+ return flask_app
302
+
303
+
304
+ def main() -> None:
305
+ flask_app = create_app()
306
+ port = int(os.getenv("III_REST_PORT", os.getenv("PORT", "3111")))
307
+ print(f"[main] Starting Flask daemon on port {port}...")
308
+ flask_app.run(host="0.0.0.0", port=port, debug=False) # nosec B104
309
+
310
+
311
+ if __name__ == "__main__":
312
+ main()
agentcache/cli.py ADDED
@@ -0,0 +1,346 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ agentcache CLI entrypoint.
4
+
5
+ Commands:
6
+ agentcache serve [--port PORT] [--host HOST]
7
+ agentcache migrate [--dry-run]
8
+ agentcache export [--output FILE]
9
+ """
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+
15
+
16
+ def cmd_serve(args) -> None:
17
+ """Start the Flask server."""
18
+ os.environ.setdefault("III_REST_PORT", str(args.port))
19
+ if getattr(args, "no_workers", False):
20
+ os.environ["AGENTCACHE_DISABLE_WORKERS"] = "true"
21
+ from .app import create_app
22
+
23
+ flask_app = create_app()
24
+ print(f"[cli] Starting agentcache on {args.host}:{args.port}")
25
+ flask_app.run(host=args.host, port=args.port, debug=False)
26
+
27
+
28
+ def cmd_migrate(args) -> None:
29
+ """Run session → folder migration."""
30
+ from .db import StateKV
31
+ from .legacy import migrate_sessions_to_folders
32
+
33
+ kv = StateKV()
34
+ result = migrate_sessions_to_folders(kv, dry_run=args.dry_run)
35
+ print(json.dumps(result, indent=2))
36
+ if args.dry_run:
37
+ print(
38
+ f"\n[cli] Dry run — no changes made. "
39
+ f"{result['migrated_sessions']} sessions, "
40
+ f"{result['migrated_observations']} observations would be migrated."
41
+ )
42
+ else:
43
+ print(
44
+ f"\n[cli] Migration complete: "
45
+ f"{result['migrated_sessions']} sessions, "
46
+ f"{result['migrated_observations']} observations migrated."
47
+ )
48
+ if result.get("errors"):
49
+ print(f"[cli] {len(result['errors'])} errors:")
50
+ for e in result["errors"]:
51
+ print(f" - {e}")
52
+
53
+
54
+ def cmd_export(args) -> None:
55
+ """Export all data as JSON."""
56
+ from .db import StateKV
57
+ from .legacy import export_data
58
+
59
+ kv = StateKV()
60
+ data = export_data(kv, {})
61
+ output = json.dumps(data, indent=2, ensure_ascii=False)
62
+ if args.output:
63
+ with open(args.output, "w", encoding="utf-8") as f:
64
+ f.write(output)
65
+ print(f"[cli] Exported to {args.output}")
66
+ total_obs = sum(
67
+ len(folder.get("observations", [])) for folder in data.get("folders", [])
68
+ )
69
+ print(
70
+ f"[cli] {len(data.get('folders', []))} folders, {total_obs} observations, "
71
+ f"{len(data.get('memories', []))} memories"
72
+ )
73
+ else:
74
+ print(output)
75
+
76
+
77
+ def cmd_connect(args) -> None:
78
+ """Connect/wire MCP and hooks to client agents."""
79
+ from .connect import run_connect
80
+
81
+ run_connect(args)
82
+
83
+
84
+ def cmd_worker(args) -> None:
85
+ """Run background worker tasks in a dedicated process."""
86
+ import time
87
+
88
+ tasks = [t.strip() for t in args.tasks.split(",")]
89
+ print(f"[worker] Starting worker process for tasks: {tasks}")
90
+
91
+ from .app import init_services
92
+
93
+ kv, _, _ = init_services()
94
+
95
+ from .workers import _shutting_down, start_background_workers
96
+
97
+ start_background_workers(kv, tasks=tasks)
98
+
99
+ try:
100
+ while not _shutting_down.is_set():
101
+ time.sleep(1)
102
+ except KeyboardInterrupt:
103
+ print("[worker] KeyboardInterrupt received. Initiating shutdown...")
104
+ import signal
105
+
106
+ from .workers import _shutdown_handler
107
+
108
+ _shutdown_handler(signal.SIGINT, None)
109
+
110
+
111
+ def cmd_context(args) -> None:
112
+ """Generate or watch .agentcache_context.md in the current folder."""
113
+ import time
114
+
115
+ from .app import init_services
116
+ from .core import KV
117
+ from .core.observation_store import normalize_folder_path
118
+
119
+ # 1. Resolve folder and agent
120
+ cwd = os.getcwd()
121
+ try:
122
+ folder_path = normalize_folder_path(cwd)
123
+ except Exception as e:
124
+ print(f"[cli] Failed to normalize current path: {e}")
125
+ return
126
+
127
+ agent_id = args.agent or os.getenv("AGENT_ID") or "agent"
128
+ output_file = args.output or ".agentcache_context.md"
129
+
130
+ print(f"[context] Target: {folder_path} (agent: {agent_id}) -> {output_file}")
131
+
132
+ # 2. Init DB services
133
+ kv, _, _ = init_services()
134
+
135
+ def generate_context() -> str:
136
+ # Fetch metadata
137
+ meta = kv.get(KV.folder_meta(folder_path, agent_id), "meta") or {}
138
+ obs_count = meta.get("obsCount", 0)
139
+ last_updated = meta.get("lastUpdated", "Never")
140
+
141
+ # Fetch recent observations (sorted desc by timestamp)
142
+ obs_list = kv.list(KV.folder_obs(folder_path, agent_id))
143
+ obs_list = sorted(obs_list, key=lambda x: x.get("timestamp", ""), reverse=True)
144
+ recent_obs = obs_list[:5]
145
+
146
+ # Fetch memories relevant to this agent or project (case-insensitive match)
147
+ folder_name = os.path.basename(cwd) or "Workspace"
148
+ project_name = folder_name.strip().lower()
149
+
150
+ memories = kv.list(KV.memories)
151
+ relevant_memories = []
152
+ for m in memories:
153
+ if m.get("isLatest") is False:
154
+ continue
155
+
156
+ match_agent = m.get("agentId") == agent_id
157
+ m_proj = m.get("project")
158
+ match_project = m_proj and m_proj.strip().lower() == project_name
159
+
160
+ if match_agent or match_project:
161
+ relevant_memories.append(m)
162
+
163
+ relevant_memories = sorted(
164
+ relevant_memories, key=lambda x: x.get("updatedAt", ""), reverse=True
165
+ )
166
+ recent_memories = relevant_memories[:5]
167
+
168
+ lines = [
169
+ "<!-- Generated by agentcache. DO NOT EDIT. -->",
170
+ f"# Agent Cache Context - {folder_name}",
171
+ "",
172
+ "## Project Metadata",
173
+ f"- **Path:** `{folder_path}`",
174
+ f"- **Agent ID:** `{agent_id}`",
175
+ f"- **Total Observations:** {obs_count}",
176
+ f"- **Last Updated:** {last_updated}",
177
+ "",
178
+ "## Pinned Memories & Lessons",
179
+ ]
180
+
181
+ if not recent_memories:
182
+ lines.append("No active long-term memories registered for this agent.")
183
+ else:
184
+ for mem in recent_memories:
185
+ m_type = mem.get("type", "fact").upper()
186
+ lines.append(f"### [{m_type}] {mem.get('title', 'Untitled')}")
187
+ lines.append(f"{mem.get('content', '')}")
188
+ lines.append("")
189
+
190
+ lines.extend(["", "## Recent Activity Timeline (Last 5 Observations)", ""])
191
+
192
+ if not recent_obs:
193
+ lines.append("No observations logged yet in this workspace.")
194
+ else:
195
+ for obs in recent_obs:
196
+ ts = obs.get("timestamp", "")
197
+ o_type = obs.get("type", "observation")
198
+ text = obs.get("text", "")
199
+ lines.append(f"- **[{ts}] ({o_type}):** {text.strip()}")
200
+
201
+ lines.append("")
202
+ return "\n".join(lines)
203
+
204
+ def write_context():
205
+ content = generate_context()
206
+ with open(output_file, "w", encoding="utf-8") as f:
207
+ f.write(content)
208
+ print(f"[context] Updated {output_file}")
209
+
210
+ # Initial write
211
+ write_context()
212
+
213
+ print(
214
+ "[context] Tip: Add the following instruction to your IDE's system prompt or .cursorrules to automatically load this cache context:"
215
+ )
216
+ print(
217
+ "--------------------------------------------------------------------------------"
218
+ )
219
+ print(
220
+ "Always load and inspect the local `.agentcache_context.md` file at the start of"
221
+ )
222
+ print(
223
+ "your session to retrieve the latest project state, lessons, and pinned memories."
224
+ )
225
+ print(
226
+ "--------------------------------------------------------------------------------"
227
+ )
228
+
229
+ if args.watch:
230
+ print("[context] Watching for database changes. Press Ctrl+C to stop...")
231
+ last_meta_state = None
232
+ try:
233
+ while True:
234
+ # Check meta state change
235
+ meta = kv.get(KV.folder_meta(folder_path, agent_id), "meta") or {}
236
+ meta_state = (meta.get("obsCount", 0), meta.get("lastUpdated", ""))
237
+ if meta_state != last_meta_state:
238
+ if last_meta_state is not None:
239
+ write_context()
240
+ last_meta_state = meta_state
241
+ time.sleep(2)
242
+ except KeyboardInterrupt:
243
+ print("[context] Watcher stopped.")
244
+
245
+
246
+ def main() -> None:
247
+ parser = argparse.ArgumentParser(
248
+ prog="agentcache",
249
+ description="agentcache — AI agent cache server",
250
+ )
251
+ subparsers = parser.add_subparsers(dest="command", required=True)
252
+
253
+ # serve
254
+ serve_parser = subparsers.add_parser("serve", help="Start the cache server")
255
+ serve_parser.add_argument(
256
+ "--port", type=int, default=int(os.getenv("III_REST_PORT", "3111"))
257
+ )
258
+ serve_parser.add_argument("--host", default="0.0.0.0") # nosec B104
259
+ serve_parser.add_argument(
260
+ "--no-workers", action="store_true", help="Disable background worker threads"
261
+ )
262
+
263
+ # worker
264
+ worker_parser = subparsers.add_parser(
265
+ "worker", help="Run background worker processes"
266
+ )
267
+ worker_parser.add_argument(
268
+ "--tasks",
269
+ default="index,forget",
270
+ help="Comma-separated tasks to run (index, forget)",
271
+ )
272
+
273
+ # migrate
274
+ migrate_parser = subparsers.add_parser(
275
+ "migrate", help="Migrate legacy session data to folder-based storage"
276
+ )
277
+ migrate_parser.add_argument(
278
+ "--dry-run", action="store_true", help="Preview without writing"
279
+ )
280
+
281
+ # export
282
+ export_parser = subparsers.add_parser("export", help="Export all data as JSON")
283
+ export_parser.add_argument(
284
+ "--output", "-o", help="Output file path (default: stdout)"
285
+ )
286
+
287
+ # context
288
+ context_parser = subparsers.add_parser(
289
+ "context", help="Generate or watch .agentcache_context.md local file"
290
+ )
291
+ context_parser.add_argument(
292
+ "--agent", help="Specify agent ID (defaults to AGENT_ID or 'agent')"
293
+ )
294
+ context_parser.add_argument(
295
+ "--output", "-o", help="Output file path (default: .agentcache_context.md)"
296
+ )
297
+ context_parser.add_argument(
298
+ "--watch", action="store_true", help="Watch for updates in real-time"
299
+ )
300
+
301
+ # connect
302
+ connect_parser = subparsers.add_parser(
303
+ "connect", help="Connect/wire MCP and hooks to client agents"
304
+ )
305
+ connect_parser.add_argument(
306
+ "agent",
307
+ nargs="?",
308
+ help="Specify target agent (antigravity, claude-code, kiro, etc.)",
309
+ )
310
+ connect_parser.add_argument(
311
+ "--with-hooks",
312
+ action="store_true",
313
+ help="Install global workspace hook execution blocks (Claude/Codex).",
314
+ )
315
+ connect_parser.add_argument(
316
+ "--dry-run",
317
+ action="store_true",
318
+ help="Log proposed configuration modifications without writing.",
319
+ )
320
+ connect_parser.add_argument(
321
+ "--force",
322
+ action="store_true",
323
+ help="Overwrite existing configuration settings.",
324
+ )
325
+ connect_parser.add_argument(
326
+ "--all", action="store_true", help="Attempt connection to all detected agents."
327
+ )
328
+
329
+ args = parser.parse_args()
330
+
331
+ if args.command == "serve":
332
+ cmd_serve(args)
333
+ elif args.command == "worker":
334
+ cmd_worker(args)
335
+ elif args.command == "migrate":
336
+ cmd_migrate(args)
337
+ elif args.command == "export":
338
+ cmd_export(args)
339
+ elif args.command == "context":
340
+ cmd_context(args)
341
+ elif args.command == "connect":
342
+ cmd_connect(args)
343
+
344
+
345
+ if __name__ == "__main__":
346
+ main()