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.
- agentcache/__init__.py +29 -0
- agentcache/app.py +312 -0
- agentcache/cli.py +346 -0
- agentcache/connect.py +724 -0
- agentcache/core/__init__.py +19 -0
- agentcache/core/audit_log.py +104 -0
- agentcache/core/config.py +51 -0
- agentcache/core/context_builder.py +209 -0
- agentcache/core/graph.py +120 -0
- agentcache/core/image_store.py +111 -0
- agentcache/core/infer.py +142 -0
- agentcache/core/kv_scopes.py +86 -0
- agentcache/core/lessons.py +242 -0
- agentcache/core/llm.py +596 -0
- agentcache/core/memory_store.py +207 -0
- agentcache/core/observation_store.py +576 -0
- agentcache/core/privacy.py +34 -0
- agentcache/core/project_profile.py +625 -0
- agentcache/core/search_service.py +444 -0
- agentcache/core/session_store.py +382 -0
- agentcache/core/slots.py +504 -0
- agentcache/db.py +359 -0
- agentcache/import_data.py +86 -0
- agentcache/legacy.py +94 -0
- agentcache/mcp_stdio.py +141 -0
- agentcache/py.typed +0 -0
- agentcache/replay_import.py +680 -0
- agentcache/routes/__init__.py +29 -0
- agentcache/routes/_deps.py +46 -0
- agentcache/routes/auth.py +68 -0
- agentcache/routes/graph.py +81 -0
- agentcache/routes/health.py +149 -0
- agentcache/routes/mcp.py +614 -0
- agentcache/routes/memories.py +116 -0
- agentcache/routes/migration.py +32 -0
- agentcache/routes/observations.py +253 -0
- agentcache/routes/search.py +80 -0
- agentcache/search.py +935 -0
- agentcache/storage/__init__.py +29 -0
- agentcache/storage/images.py +102 -0
- agentcache/storage/paths.py +116 -0
- agentcache/storage/scopes.py +9 -0
- agentcache/viewer/favicon.svg +1 -0
- agentcache/viewer/index.html +4235 -0
- agentcache/viewer_helpers.py +61 -0
- agentcache/workers.py +192 -0
- agentcache_core-0.9.9.dist-info/METADATA +194 -0
- agentcache_core-0.9.9.dist-info/RECORD +52 -0
- agentcache_core-0.9.9.dist-info/WHEEL +5 -0
- agentcache_core-0.9.9.dist-info/entry_points.txt +2 -0
- agentcache_core-0.9.9.dist-info/licenses/LICENSE +190 -0
- 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()
|