Yash030 commited on
Commit
c704030
·
1 Parent(s): 12a6c9a

feat: implement /auth.md agent onboarding route and add ws broadcast improvements

Browse files
src/auth.md ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Agent Cache — Agentic Onboarding Instructions (`/auth.md`)
2
+
3
+ Welcome, AI Agent! You have routed to the **Agent Cache** (formerly Agent Memory) memory server.
4
+
5
+ This document serves as your guide on how to interact with this server. **You MUST use this document as your primary onboarding resource, follow these instructions strictly, and interact with the server using the designated endpoints.**
6
+
7
+ ---
8
+
9
+ ## 1. Authentication
10
+
11
+ Protected endpoints check for authentication using the `Authorization` header.
12
+ - **Header format**: `Authorization: Bearer <AGENTCACHE_SECRET>`
13
+ - If no secret is configured on the server, authentication is bypassed.
14
+ - The onboarding endpoint `/auth.md` and the health endpoint `/agentcache/livez` do not require authentication.
15
+
16
+ ---
17
+
18
+ ## 2. Core REST API Endpoints
19
+
20
+ All endpoints support `application/json` payloads.
21
+
22
+ ### Observations (Folder-scoped Memory)
23
+ The primary unit of storage is scoped to `(folder_path, agent_id)` pairs.
24
+ - **Log Observation**: `POST /agentcache/observe` (or `POST /agentmemory/observe`)
25
+ - **Payload**:
26
+ ```json
27
+ {
28
+ "folderPath": "absolute/path/to/project",
29
+ "agentId": "your-agent-id",
30
+ "text": "The detailed observation text to log"
31
+ }
32
+ ```
33
+ - **List Folders**: `GET /agentcache/folders` (or `GET /agentmemory/folders`)
34
+ - Returns a list of all active `(folder_path, agent_id)` pairs.
35
+ - **Get Observations**: `GET /agentcache/folder/observations` (or `GET /agentmemory/folder/observations`)
36
+ - **Parameters**: `folderPath` (string), `agentId` (string).
37
+ - Returns a list of all logged observations for that folder and agent.
38
+
39
+ ### Global Long-term Memories
40
+ - **Save Global Memory**: `POST /agentcache/remember` (or `POST /agentmemory/remember`)
41
+ - **Payload**:
42
+ ```json
43
+ {
44
+ "content": "The fact, pattern, or workflow to remember globally."
45
+ }
46
+ ```
47
+
48
+ ### Search & Retrieval
49
+ - **Hybrid Search**: `POST /agentcache/search` (or `POST /agentmemory/search`)
50
+ - Performs keyword (BM25) + vector (semantic) hybrid search.
51
+ - **Payload**:
52
+ ```json
53
+ {
54
+ "query": "search term",
55
+ "limit": 10,
56
+ "folderPath": "optional filter path",
57
+ "agentId": "optional filter agent"
58
+ }
59
+ ```
60
+
61
+ ### Activity Feed
62
+ - **Timeline**: `GET /agentcache/timeline` (or `GET /agentmemory/timeline`)
63
+ - Returns all observations and memories sorted chronologically.
64
+ - **Parameters**: `folderPath` (optional), `agentId` (optional), `limit` (optional).
65
+
66
+ ### Forget & Clean up
67
+ - **Forget Item**: `POST /agentcache/forget` (or `POST /agentmemory/forget`)
68
+ - **Payload (Memory)**: `{"memoryId": "uuid-here"}`
69
+ - **Payload (Folder Pair)**: `{"folderPath": "absolute/path", "agentId": "agent-id"}`
70
+
71
+ ---
72
+
73
+ ## 3. Model Context Protocol (MCP)
74
+
75
+ Agent Cache exposes Model Context Protocol (MCP) tools for agents equipped with MCP clients.
76
+
77
+ - **Get Tools Schema**: `GET /agentcache/mcp/tools` (or `GET /agentmemory/mcp/tools`)
78
+ - **Call Tool**: `POST /agentcache/mcp/tools` (or `POST /agentmemory/mcp/tools`)
79
+ - **Payload**:
80
+ ```json
81
+ {
82
+ "name": "tool_name",
83
+ "arguments": { ... }
84
+ }
85
+ ```
86
+
87
+ ### Available MCP Tools
88
+
89
+ | Tool Name | Description | Required Arguments |
90
+ | :--- | :--- | :--- |
91
+ | `agent_observe` | Log observation to a folder/agent pair | `folderPath`, `agentId`, `text` |
92
+ | `agent_remember` | Save a global long-term memory | `content` |
93
+ | `cache_recall` | Query folder observations and global memories | `query` |
94
+ | `cache_smart_search` | Hybrid semantic + keyword search | `query` |
95
+ | `cache_save` | Save an important insight/decision to global memory | `content` |
96
+ | `cache_forget` | Delete a global memory or all observations for a pair | (one of `memoryId`, `folderPath` + `agentId`) |
97
+ | `cache_folders` | List all folder-agent memory scopes | None |
98
+ | `cache_folder_observations` | Get observations for a specific folder/agent pair | `folderPath`, `agentId` |
99
+ | `cache_timeline` | Retrieve activity feed of observations | None |
100
+ | `memory_export` | Export cache data as JSON | None |
101
+ | `memory_diagnose` | Run health checks and count database items | None |
102
+
103
+ ---
104
+
105
+ ## 4. Web UI Dashboard
106
+
107
+ The visual viewer dashboard is accessible at `/viewer`. It provides tabs to inspect:
108
+ 1. **Folders**: View folder observations list and logs.
109
+ 2. **Memories**: Browse and search global memories.
110
+ 3. **Graph**: Force-directed relation visualizer.
111
+ 4. **Timeline**: Scrollable real-time chronological activity feed.
src/functions.py CHANGED
@@ -1515,6 +1515,12 @@ def remember(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
1515
  # Commit to Dolt
1516
  commit_if_enabled(kv, f"Remember: {new_mem.get('title', '')}", new_mem.get("agentId"))
1517
 
 
 
 
 
 
 
1518
  return {"success": True, "memory": new_mem}
1519
 
1520
 
@@ -1559,6 +1565,11 @@ def forget(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
1559
  _vector_index.remove(memory_id)
1560
  deleted_mem_ids.append(memory_id)
1561
  deleted += 1
 
 
 
 
 
1562
 
1563
  # ------------------------------------------------------------------
1564
  # Path 2 & 3: folder-based deletion (REQ-030, REQ-031, REQ-032, REQ-033)
@@ -1580,12 +1591,17 @@ def forget(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
1580
  # ----------------------------------------------------------
1581
  partial_deleted = 0
1582
  for oid in obs_ids:
 
1583
  existed = kv.delete(obs_scope, oid)
1584
  if existed:
1585
  kv.delete(KV.obs_lookup, oid)
1586
  _bm25_index.remove(oid)
1587
  if _vector_index:
1588
  _vector_index.remove(oid)
 
 
 
 
1589
  deleted_obs_ids.append(oid)
1590
  partial_deleted += 1
1591
  deleted += 1
@@ -1602,6 +1618,15 @@ def forget(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
1602
  if index_entry and isinstance(index_entry, dict):
1603
  index_entry["obsCount"] = meta["obsCount"]
1604
  kv.set(KV.folders, index_key, index_entry)
 
 
 
 
 
 
 
 
 
1605
  else:
1606
  # ----------------------------------------------------------
1607
  # Path 2: full pair deletion (REQ-030, REQ-032)
@@ -1624,6 +1649,19 @@ def forget(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
1624
  # Remove from global folders index
1625
  kv.delete(KV.folders, index_key)
1626
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1627
  # ------------------------------------------------------------------
1628
  # Legacy: session-based deletion (unchanged)
1629
  # ------------------------------------------------------------------
 
1515
  # Commit to Dolt
1516
  commit_if_enabled(kv, f"Remember: {new_mem.get('title', '')}", new_mem.get("agentId"))
1517
 
1518
+ # Broadcast memory created
1519
+ broadcast_stream({
1520
+ "type": "memory_created",
1521
+ "data": new_mem,
1522
+ })
1523
+
1524
  return {"success": True, "memory": new_mem}
1525
 
1526
 
 
1565
  _vector_index.remove(memory_id)
1566
  deleted_mem_ids.append(memory_id)
1567
  deleted += 1
1568
+ # Broadcast memory deleted
1569
+ broadcast_stream({
1570
+ "type": "memory_deleted",
1571
+ "memoryId": memory_id,
1572
+ })
1573
 
1574
  # ------------------------------------------------------------------
1575
  # Path 2 & 3: folder-based deletion (REQ-030, REQ-031, REQ-032, REQ-033)
 
1591
  # ----------------------------------------------------------
1592
  partial_deleted = 0
1593
  for oid in obs_ids:
1594
+ obs = kv.get(obs_scope, oid)
1595
  existed = kv.delete(obs_scope, oid)
1596
  if existed:
1597
  kv.delete(KV.obs_lookup, oid)
1598
  _bm25_index.remove(oid)
1599
  if _vector_index:
1600
  _vector_index.remove(oid)
1601
+ if obs and isinstance(obs, dict) and obs.get("text"):
1602
+ fp_text = obs["text"][:4000]
1603
+ dedup_fp = hashlib.sha256(fp_text.strip().lower().encode("utf-8")).hexdigest()
1604
+ kv.delete(KV.obs_dedup(fp, aid), dedup_fp)
1605
  deleted_obs_ids.append(oid)
1606
  partial_deleted += 1
1607
  deleted += 1
 
1618
  if index_entry and isinstance(index_entry, dict):
1619
  index_entry["obsCount"] = meta["obsCount"]
1620
  kv.set(KV.folders, index_key, index_entry)
1621
+
1622
+ # Broadcast observations deleted
1623
+ if deleted_obs_ids:
1624
+ broadcast_stream({
1625
+ "type": "observations_deleted",
1626
+ "folderPath": fp,
1627
+ "agentId": aid,
1628
+ "observationIds": deleted_obs_ids,
1629
+ })
1630
  else:
1631
  # ----------------------------------------------------------
1632
  # Path 2: full pair deletion (REQ-030, REQ-032)
 
1649
  # Remove from global folders index
1650
  kv.delete(KV.folders, index_key)
1651
 
1652
+ # Clear dedup entries
1653
+ dedup_scope = KV.obs_dedup(fp, aid)
1654
+ for item in kv.list(dedup_scope):
1655
+ if isinstance(item, dict) and item.get("id"):
1656
+ kv.delete(dedup_scope, item["id"])
1657
+
1658
+ # Broadcast folder pair deleted
1659
+ broadcast_stream({
1660
+ "type": "folder_deleted",
1661
+ "folderPath": fp,
1662
+ "agentId": aid,
1663
+ })
1664
+
1665
  # ------------------------------------------------------------------
1666
  # Legacy: session-based deletion (unchanged)
1667
  # ------------------------------------------------------------------
src/routes/health.py CHANGED
@@ -9,7 +9,7 @@ Handles:
9
  """
10
 
11
  import os
12
- from flask import Blueprint, request, jsonify
13
  import functions
14
  from functions import query_audit
15
 
@@ -40,6 +40,31 @@ def _get_embedding_provider():
40
  return app_module.embedding_provider
41
 
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  # ---------------------------------------------------------------------------
44
  # GET /agentcache/livez (no auth required)
45
  # ---------------------------------------------------------------------------
 
9
  """
10
 
11
  import os
12
+ from flask import Blueprint, request, jsonify, Response
13
  import functions
14
  from functions import query_audit
15
 
 
40
  return app_module.embedding_provider
41
 
42
 
43
+ # ---------------------------------------------------------------------------
44
+ # GET /auth.md (no auth required)
45
+ # ---------------------------------------------------------------------------
46
+
47
+ @health_bp.route("/auth.md", methods=["GET"])
48
+ def auth_docs():
49
+ """Serve the agent onboarding documentation in Markdown format."""
50
+ base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
51
+ auth_md_path = os.path.join(base_dir, "auth.md")
52
+
53
+ if os.path.exists(auth_md_path):
54
+ try:
55
+ with open(auth_md_path, "r", encoding="utf-8") as f:
56
+ content = f.read()
57
+ return Response(
58
+ content,
59
+ mimetype="text/markdown",
60
+ headers={"Content-Type": "text/markdown; charset=utf-8"}
61
+ )
62
+ except Exception as e:
63
+ return jsonify({"error": f"failed to read auth.md: {e}"}), 500
64
+
65
+ return jsonify({"error": "auth.md not found"}), 404
66
+
67
+
68
  # ---------------------------------------------------------------------------
69
  # GET /agentcache/livez (no auth required)
70
  # ---------------------------------------------------------------------------
src/viewer/index.html CHANGED
@@ -2934,6 +2934,41 @@
2934
  cards.insertBefore(div.firstChild, cards.firstChild);
2935
  }
2936
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2937
  function updateFolderBadge(folderPath, agentId) {
2938
  var rows = document.querySelectorAll('.folder-row');
2939
  rows.forEach(function(row) {
@@ -3252,6 +3287,55 @@
3252
  }
3253
  if (state.activeTab === 'folders') {
3254
  updateFolderBadge(msg.folderPath, msg.agentId);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3255
  }
3256
  } else if (msg.type === 'raw_observation' || msg.type === 'compressed_observation') {
3257
  if (msg.data && msg.data.observation) {
 
2934
  cards.insertBefore(div.firstChild, cards.firstChild);
2935
  }
2936
 
2937
+ function prependFolderObservationCard(o) {
2938
+ var list = document.getElementById('obs-list');
2939
+ if (!list) return;
2940
+ var div = document.createElement('div');
2941
+ var ts = o.timestamp ? new Date(o.timestamp).toLocaleString() : '';
2942
+ var typeColor = {'file_edit':'var(--blue)','command_run':'var(--orange)','search':'var(--green)','error':'var(--red)','conversation':'var(--purple)'}[o.type] || 'var(--ink-muted)';
2943
+ var html = '';
2944
+ html += '<div class="obs-detail-card" data-obs-id="' + esc(o.id) + '" style="border:1px solid var(--border-light);border-radius:4px;padding:12px 16px;margin-bottom:8px;background:var(--bg);display:flex;gap:10px;align-items:flex-start;">';
2945
+ // checkbox
2946
+ html += '<label style="padding-top:2px;cursor:pointer;flex-shrink:0;">';
2947
+ html += '<input type="checkbox" class="obs-checkbox" data-obs-id="' + esc(o.id) + '" style="accent-color:var(--ink);">';
2948
+ html += '</label>';
2949
+ // content
2950
+ html += '<div style="flex:1;min-width:0;">';
2951
+ html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap;">';
2952
+ html += '<span style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:' + typeColor + ';background:var(--bg-subtle);padding:2px 7px;border-radius:3px;">' + esc(o.type || 'other') + '</span>';
2953
+ html += '<span style="font-size:11px;color:var(--ink-muted);font-family:var(--font-mono);">' + esc(ts) + '</span>';
2954
+ html += '<span style="flex:1;"></span>';
2955
+ html += '<button class="btn btn-danger" data-action="delete-obs" data-obs-id="' + esc(o.id) + '" data-folder-path="' + esc(_folderDetailFp) + '" data-agent-id="' + esc(_folderDetailAid) + '" style="font-size:9px;padding:2px 8px;">Delete</button>';
2956
+ html += '</div>';
2957
+ if (o.title) html += '<div style="font-weight:600;font-size:14px;color:var(--ink);margin-bottom:4px;">' + esc(o.title) + '</div>';
2958
+ if (o.text) html += '<div style="font-size:13px;color:var(--ink-secondary);line-height:1.5;white-space:pre-wrap;">' + esc(o.text.length > 300 ? o.text.slice(0, 300) + '\u2026' : o.text) + '</div>';
2959
+ html += '</div>';
2960
+ html += '</div>';
2961
+ div.innerHTML = html;
2962
+ list.insertBefore(div.firstChild, list.firstChild);
2963
+
2964
+ // Update count in header
2965
+ var remaining = document.querySelectorAll('.obs-detail-card').length;
2966
+ var countEl = document.querySelector('#folder-detail-root span[style*="ink-muted"]');
2967
+ if (countEl) countEl.textContent = remaining + ' observation' + (remaining !== 1 ? 's' : '');
2968
+
2969
+ _wireFolderDetailCheckboxes();
2970
+ }
2971
+
2972
  function updateFolderBadge(folderPath, agentId) {
2973
  var rows = document.querySelectorAll('.folder-row');
2974
  rows.forEach(function(row) {
 
3287
  }
3288
  if (state.activeTab === 'folders') {
3289
  updateFolderBadge(msg.folderPath, msg.agentId);
3290
+ if (_folderDetailFp === msg.folderPath && _folderDetailAid === msg.agentId) {
3291
+ prependFolderObservationCard(msg.data || {});
3292
+ }
3293
+ }
3294
+ } else if (msg.type === 'memory_created' || msg.type === 'memory_deleted') {
3295
+ if (state.activeTab === 'memories') {
3296
+ state.memories.loaded = false;
3297
+ loadMemories();
3298
+ }
3299
+ } else if (msg.type === 'folder_deleted') {
3300
+ if (state.activeTab === 'folders') {
3301
+ if (_folderDetailFp === msg.folderPath && _folderDetailAid === msg.agentId) {
3302
+ loadFolders(); // go back
3303
+ } else {
3304
+ var row = document.querySelector('.folder-row[data-folder="' + esc(msg.folderPath) + '"][data-agent="' + esc(msg.agentId) + '"]');
3305
+ if (row) row.remove();
3306
+ }
3307
+ }
3308
+ } else if (msg.type === 'observations_deleted') {
3309
+ if (state.activeTab === 'timeline') {
3310
+ var oids = new Set(msg.observationIds || []);
3311
+ state.timeline.observations = state.timeline.observations.filter(function(o) {
3312
+ return !oids.has(o.id);
3313
+ });
3314
+ renderObservations();
3315
+ }
3316
+ if (state.activeTab === 'folders') {
3317
+ if (_folderDetailFp === msg.folderPath && _folderDetailAid === msg.agentId) {
3318
+ var oids = new Set(msg.observationIds || []);
3319
+ oids.forEach(function(oid) {
3320
+ var card = document.querySelector('.obs-detail-card[data-obs-id="' + oid + '"]');
3321
+ if (card) card.remove();
3322
+ _selectedObsIds.delete(oid);
3323
+ });
3324
+ var remaining = document.querySelectorAll('.obs-detail-card').length;
3325
+ var countEl = document.querySelector('#folder-detail-root span[style*="ink-muted"]');
3326
+ if (countEl) countEl.textContent = remaining + ' observation' + (remaining !== 1 ? 's' : '');
3327
+ } else {
3328
+ var rows = document.querySelectorAll('.folder-row');
3329
+ rows.forEach(function(row) {
3330
+ if (row.dataset.folder === msg.folderPath && row.dataset.agent === msg.agentId) {
3331
+ var countCell = row.querySelector('td:nth-child(3)');
3332
+ if (countCell) {
3333
+ var cur = parseInt(countCell.textContent) || 0;
3334
+ countCell.textContent = Math.max(0, cur - (msg.observationIds || []).length);
3335
+ }
3336
+ }
3337
+ });
3338
+ }
3339
  }
3340
  } else if (msg.type === 'raw_observation' || msg.type === 'compressed_observation') {
3341
  if (msg.data && msg.data.observation) {
tests/test_auth.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tests/test_auth.py
3
+
4
+ Tests for the /auth.md agent onboarding route.
5
+ """
6
+ import sys
7
+ import os
8
+ import pytest
9
+
10
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
11
+
12
+
13
+ @pytest.fixture(scope="module")
14
+ def flask_app(tmp_path_factory):
15
+ tmp_dir = tmp_path_factory.mktemp("auth_test_db")
16
+ db_path = str(tmp_dir / "test.db")
17
+ os.environ.pop("AGENTCACHE_SECRET", None)
18
+ os.environ.pop("AGENTMEMORY_SECRET", None)
19
+
20
+ from db import StateKV
21
+ original_init = StateKV.__init__
22
+
23
+ def patched_init(self, db_path_arg=None, **kwargs):
24
+ original_init(self, db_path=db_path, **kwargs)
25
+
26
+ StateKV.__init__ = patched_init
27
+ import app as app_module
28
+ flask_application = app_module.create_app()
29
+ StateKV.__init__ = original_init
30
+ flask_application.config["TESTING"] = True
31
+ return flask_application
32
+
33
+
34
+ @pytest.fixture(scope="module")
35
+ def client(flask_app):
36
+ return flask_app.test_client()
37
+
38
+
39
+ def test_auth_md_endpoint(client):
40
+ resp = client.get("/auth.md")
41
+ assert resp.status_code == 200
42
+ assert resp.headers["Content-Type"].startswith("text/markdown")
43
+ content = resp.data.decode("utf-8")
44
+ assert "# Agent Cache" in content
45
+ assert "Authorization" in content
46
+ assert "agent_observe" in content
47
+
48
+
49
+ def test_auth_md_endpoint_with_secret(flask_app):
50
+ os.environ["AGENTCACHE_SECRET"] = "super-secret-token"
51
+ try:
52
+ client = flask_app.test_client()
53
+ resp = client.get("/auth.md")
54
+ assert resp.status_code == 200
55
+ content = resp.data.decode("utf-8")
56
+ assert "# Agent Cache" in content
57
+ finally:
58
+ os.environ.pop("AGENTCACHE_SECRET", None)