diff --git a/CHANGELOG.md b/CHANGELOG.md index 882e6e1..842e0e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Read tracking**: Auto-logs when entries are accessed by `get_knowledge` and `get_alerts`. Query with `get_reads(entry_id?, since?, platform?, limit?)`. +- **Action tracking**: `acted_on(entry_id, action, platform?, detail?, tags?)` records concrete actions agents take because of entries. Query with `get_actions(entry_id?, since?, platform?, tags?, limit?)`. +- **Unread entries**: `get_unread(since?)` returns entries with zero reads — cleanup candidates and dead knowledge. +- **Activity feed**: `get_activity(since?, platform?, limit?)` returns combined reads + actions chronologically. +- **Read count enrichment**: List mode (`mode="list"`) now includes `read_count` and `last_read` on each entry. +- **Actions have tags**: Tags on action records (default: copied from referenced entry) enable filtered action queries. +- Alembic migration for `reads` and `actions` tables with indexes +- 17 new tests (213 total) + ## [0.6.1] - 2026-03-23 ### Added diff --git a/README.md b/README.md index fcf3371..502467d 100644 --- a/README.md +++ b/README.md @@ -256,7 +256,7 @@ See [Security considerations](docs/deployment-guide.md#security-considerations) - Suppression system with time-based expiry and escalation overrides ### MCP interface -- Full MCP API: 6 resources + 18 tools + 5 prompts +- Full MCP API: 6 resources + 23 tools + 5 prompts - Read tool mirrors for tools-only clients - User-defined custom prompts from store entries with `{{var}}` templates - Streamable HTTP + stdio transports @@ -269,7 +269,7 @@ See [Security considerations](docs/deployment-guide.md#security-considerations) - Secret path auth + Cloudflare WAF for edge-level access control - Docker Compose with Postgres, named Cloudflare Tunnel, or ephemeral quick tunnel - Request timing instrumentation and `/health` endpoint -- 196 tests (all against real Postgres), strict type checking, CI pipeline with coverage, QA gate +- 213 tests (all against real Postgres), strict type checking, CI pipeline with coverage, QA gate ### Not yet implemented - Layer 2 (baseline) detection — rolling averages and deviation calculation diff --git a/alembic/versions/c3a7f2d91e4b_add_reads_and_actions_tables.py b/alembic/versions/c3a7f2d91e4b_add_reads_and_actions_tables.py new file mode 100644 index 0000000..3fb9473 --- /dev/null +++ b/alembic/versions/c3a7f2d91e4b_add_reads_and_actions_tables.py @@ -0,0 +1,51 @@ +"""add reads and actions tables + +Revision ID: c3a7f2d91e4b +Revises: 9184f91831f8 +Create Date: 2026-03-23 17:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "c3a7f2d91e4b" +down_revision: Union[str, Sequence[str], None] = "9184f91831f8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create reads and actions tables for read/action tracking.""" + op.execute(""" + CREATE TABLE IF NOT EXISTS reads ( + id SERIAL PRIMARY KEY, + entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, + timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), + platform TEXT, + tool_used TEXT + ); + CREATE INDEX IF NOT EXISTS idx_reads_entry ON reads(entry_id); + CREATE INDEX IF NOT EXISTS idx_reads_timestamp ON reads(timestamp); + + CREATE TABLE IF NOT EXISTS actions ( + id SERIAL PRIMARY KEY, + entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, + timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), + platform TEXT, + action TEXT NOT NULL, + detail TEXT, + tags JSONB NOT NULL DEFAULT '[]' + ); + CREATE INDEX IF NOT EXISTS idx_actions_entry ON actions(entry_id); + CREATE INDEX IF NOT EXISTS idx_actions_timestamp ON actions(timestamp); + CREATE INDEX IF NOT EXISTS idx_actions_tags_gin ON actions USING GIN (tags); + """) + + +def downgrade() -> None: + """Drop reads and actions tables.""" + op.execute("DROP TABLE IF EXISTS actions") + op.execute("DROP TABLE IF EXISTS reads") diff --git a/docs/data-dictionary.md b/docs/data-dictionary.md index 34bdc3e..671deb3 100644 --- a/docs/data-dictionary.md +++ b/docs/data-dictionary.md @@ -127,6 +127,48 @@ Written by agents via `set_preference`. Keyed by `key` + `scope` (upserted). Por - **Staleness:** Status entries with `ttl_sec` are marked stale in the briefing if no update arrives within the TTL window. The entry itself is not deleted. - **Change tracking:** `update_entry` appends previous field values to the `changelog` array in `data`. Use `get_knowledge(include_history="true")` to see changes, or `include_history="only"` to find entries that have been modified. - **Hard deletes:** The API only performs soft deletes. Manual SQL `DELETE` statements bypass the trash — that data is gone permanently. Back up regularly. +- **Read/action cleanup:** The `reads` and `actions` tables use `ON DELETE CASCADE` on `entry_id`. This means read and action records are automatically removed when an entry is **hard deleted** (auto-purge or manual SQL). Soft delete (`delete_entry`) does **not** cascade — reads and actions persist for trashed entries until the 30-day purge. + +## Table: `reads` + +Auto-populated when entries are accessed via `get_knowledge` and `get_alerts`. Fire-and-forget — read log failures never block tool responses. + +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| `id` | SERIAL | No | Auto-incrementing primary key. | +| `entry_id` | TEXT | No | References `entries(id)` with `ON DELETE CASCADE`. | +| `timestamp` | TIMESTAMPTZ | No | When the read occurred. Default: `now()`. | +| `platform` | TEXT | Yes | Which platform performed the read (e.g., `"claude-code"`). | +| `tool_used` | TEXT | Yes | Which tool triggered the read (e.g., `"get_knowledge"`). | + +### Indexes + +| Index | Columns | Type | Purpose | +|-------|---------|------|---------| +| `idx_reads_entry` | `entry_id` | B-tree | Look up reads for a specific entry | +| `idx_reads_timestamp` | `timestamp` | B-tree | Time-range queries | + +## Table: `actions` + +Agent-reported records of concrete actions taken because of an entry. Permanent audit trail. + +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| `id` | SERIAL | No | Auto-incrementing primary key. | +| `entry_id` | TEXT | No | References `entries(id)` with `ON DELETE CASCADE`. | +| `timestamp` | TIMESTAMPTZ | No | When the action was recorded. Default: `now()`. | +| `platform` | TEXT | Yes | Which platform reported the action (e.g., `"claude-code"`). | +| `action` | TEXT | No | What was done (e.g., `"created GitHub issue #42"`). | +| `detail` | TEXT | Yes | Optional structured reference (PR URL, issue number, etc.). | +| `tags` | JSONB | No | Tags for filtered queries. Default: copied from referenced entry. | + +### Indexes + +| Index | Columns | Type | Purpose | +|-------|---------|------|---------| +| `idx_actions_entry` | `entry_id` | B-tree | Look up actions for a specific entry | +| `idx_actions_timestamp` | `timestamp` | B-tree | Time-range queries | +| `idx_actions_tags_gin` | `tags` | GIN | Fast tag containment queries | ## Backend details diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index f4e81c5..3f8814b 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -348,9 +348,9 @@ The current approach uses two layers: ## Notes - **The store persists** in the data directory. Restart the server and your data is still there. -- **Not all clients support all MCP features** — the MCP spec defines [resources](https://modelcontextprotocol.io/docs/concepts/resources), [tools](https://modelcontextprotocol.io/docs/concepts/tools), and [prompts](https://modelcontextprotocol.io/docs/concepts/prompts). Client support varies: some only surface tools (e.g., Claude.ai), some don't support prompts. All 18 tools work everywhere. Read tools mirror the resources so tools-only clients get full functionality. Prompts (including user-defined custom prompts) are available in clients that support them — VS Code, Claude Desktop, Cursor. +- **Not all clients support all MCP features** — the MCP spec defines [resources](https://modelcontextprotocol.io/docs/concepts/resources), [tools](https://modelcontextprotocol.io/docs/concepts/tools), and [prompts](https://modelcontextprotocol.io/docs/concepts/prompts). Client support varies: some only surface tools (e.g., Claude.ai), some don't support prompts. All 23 tools work everywhere. Read tools mirror the resources so tools-only clients get full functionality. Prompts (including user-defined custom prompts) are available in clients that support them — VS Code, Claude Desktop, Cursor. -- **18 tools, 5 prompts, user-defined prompts** — tools include `remember` (general notes), `learn_pattern` (operational knowledge), `add_context` (time-limited), `update_entry` (in-place updates with changelog), `get_stats` (store summary), `get_tags` (tag discovery), plus alerting and data management. Built-in prompts: `agent_instructions`, `project_context`, `system_status`, `write_guide`, `catchup`. Store entries with `source="custom-prompt"` to create your own. See the [README](../README.md#tools) for the full list. +- **23 tools, 5 prompts, user-defined prompts** — tools include `remember` (general notes), `learn_pattern` (operational knowledge), `add_context` (time-limited), `update_entry` (in-place updates with changelog), `get_stats` (store summary), `get_tags` (tag discovery), plus alerting and data management. Built-in prompts: `agent_instructions`, `project_context`, `system_status`, `write_guide`, `catchup`. Store entries with `source="custom-prompt"` to create your own. See the [README](../README.md#tools) for the full list. - **Model matters** — best experience with Claude Sonnet 4.6 or Opus 4.6. Smaller models (Haiku, GPT-4o-mini) may not follow MCP prompts or call tools proactively. - **Suppression matching is content-aware** — a suppression tagged `["qbittorrent"]` will match alerts whose alert_id or message contains "qbittorrent", even if the alert's structural tags differ. - **Soft delete is safe** — `delete_entry` moves entries to trash (30-day retention). Bulk deletes show a dry-run count first and require `confirm=True`. Delete and restore by tags with AND logic (e.g., `delete_entry(tags=["demo"], confirm=True)` deletes entries matching all given tags). Use `get_deleted` and `restore_entry` to recover — restore also supports tags (e.g., `restore_entry(tags=["demo"])`). diff --git a/src/mcp_awareness/postgres_store.py b/src/mcp_awareness/postgres_store.py index d4fbba3..d2b6e29 100644 --- a/src/mcp_awareness/postgres_store.py +++ b/src/mcp_awareness/postgres_store.py @@ -52,6 +52,29 @@ def _create_tables(self) -> None: CREATE INDEX IF NOT EXISTS idx_entries_source ON entries(source); CREATE INDEX IF NOT EXISTS idx_entries_type_source ON entries(type, source); CREATE INDEX IF NOT EXISTS idx_entries_tags_gin ON entries USING GIN (tags); + + CREATE TABLE IF NOT EXISTS reads ( + id SERIAL PRIMARY KEY, + entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, + timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), + platform TEXT, + tool_used TEXT + ); + CREATE INDEX IF NOT EXISTS idx_reads_entry ON reads(entry_id); + CREATE INDEX IF NOT EXISTS idx_reads_timestamp ON reads(timestamp); + + CREATE TABLE IF NOT EXISTS actions ( + id SERIAL PRIMARY KEY, + entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, + timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), + platform TEXT, + action TEXT NOT NULL, + detail TEXT, + tags JSONB NOT NULL DEFAULT '[]' + ); + CREATE INDEX IF NOT EXISTS idx_actions_entry ON actions(entry_id); + CREATE INDEX IF NOT EXISTS idx_actions_timestamp ON actions(timestamp); + CREATE INDEX IF NOT EXISTS idx_actions_tags_gin ON actions USING GIN (tags); """) self._conn.commit() # Note: schema migrations are managed by Alembic (see alembic/ directory). @@ -588,7 +611,235 @@ def restore_by_tags(self, tags: list[str]) -> int: self._conn.commit() return affected + # ------------------------------------------------------------------ + # Read / action tracking + # ------------------------------------------------------------------ + + def log_read(self, entry_ids: list[str], tool_used: str, platform: str | None = None) -> None: + """Log that entries were read. Fire-and-forget — failures are silent.""" + if not entry_ids: + return + try: + with self._conn.cursor() as cur: + for eid in entry_ids: + cur.execute( + "INSERT INTO reads (entry_id, platform, tool_used) VALUES (%s, %s, %s)", + (eid, platform, tool_used), + ) + self._conn.commit() + except Exception: + # Fire-and-forget: read logging never blocks a response + import contextlib + + with contextlib.suppress(Exception): + self._conn.rollback() + + def log_action( + self, + entry_id: str, + action: str, + platform: str | None = None, + detail: str | None = None, + tags: list[str] | None = None, + ) -> dict[str, Any]: + """Log an action taken because of an entry. Returns the action record. + + Returns {"status": "error", ...} if the entry doesn't exist. + """ + # Validate entry exists and copy tags if not provided + entry = self.get_entry_by_id(entry_id) + if entry is None: + return {"status": "error", "message": f"Entry not found: {entry_id}"} + if tags is None: + tags = entry.tags + now = now_utc() + with self._conn.cursor() as cur: + cur.execute( + "INSERT INTO actions (entry_id, timestamp, platform, action, detail, tags) " + "VALUES (%s, %s, %s, %s, %s, %s::jsonb) RETURNING id", + (entry_id, now, platform, action, detail, json.dumps(tags)), + ) + row = cur.fetchone() + self._conn.commit() + return { + "id": row["id"] if row else None, + "entry_id": entry_id, + "timestamp": to_iso(now), + "platform": platform, + "action": action, + "detail": detail, + "tags": tags, + } + + def get_reads( + self, + entry_id: str | None = None, + since: datetime | None = None, + platform: str | None = None, + limit: int | None = None, + ) -> list[dict[str, Any]]: + """Get read history, optionally filtered.""" + clauses: list[str] = [] + params: list[Any] = [] + if entry_id: + clauses.append("entry_id = %s") + params.append(entry_id) + if since: + clauses.append("timestamp >= %s") + params.append(since) + if platform: + clauses.append("platform = %s") + params.append(platform) + where = " AND ".join(clauses) if clauses else "1=1" + sql = f"SELECT * FROM reads WHERE {where} ORDER BY timestamp DESC" + if limit: + sql += f" LIMIT {int(limit)}" + with self._conn.cursor() as cur: + cur.execute(sql, tuple(params)) + rows = cur.fetchall() + return [ + { + "id": r["id"], + "entry_id": r["entry_id"], + "timestamp": to_iso(r["timestamp"]), + "platform": r["platform"], + "tool_used": r["tool_used"], + } + for r in rows + ] + + def get_actions( + self, + entry_id: str | None = None, + since: datetime | None = None, + platform: str | None = None, + tags: list[str] | None = None, + limit: int | None = None, + ) -> list[dict[str, Any]]: + """Get action history, optionally filtered.""" + clauses: list[str] = [] + params: list[Any] = [] + if entry_id: + clauses.append("entry_id = %s") + params.append(entry_id) + if since: + clauses.append("timestamp >= %s") + params.append(since) + if platform: + clauses.append("platform = %s") + params.append(platform) + if tags: + for t in tags: + clauses.append("tags @> %s::jsonb") + params.append(json.dumps([t])) + where = " AND ".join(clauses) if clauses else "1=1" + sql = f"SELECT * FROM actions WHERE {where} ORDER BY timestamp DESC" + if limit: + sql += f" LIMIT {int(limit)}" + with self._conn.cursor() as cur: + cur.execute(sql, tuple(params)) + rows = cur.fetchall() + return [ + { + "id": r["id"], + "entry_id": r["entry_id"], + "timestamp": to_iso(r["timestamp"]), + "platform": r["platform"], + "action": r["action"], + "detail": r["detail"], + "tags": r["tags"] if isinstance(r["tags"], list) else json.loads(r["tags"]), + } + for r in rows + ] + + def get_unread(self, since: datetime | None = None) -> list[Entry]: + """Get entries with zero reads (optionally since a timestamp). Cleanup candidates.""" + since_clause = "" + params: tuple[Any, ...] = () + if since: + since_clause = "AND r.timestamp >= %s" + params = (since,) + with self._conn.cursor() as cur: + cur.execute( + f"SELECT e.* FROM entries e " + f"LEFT JOIN reads r ON e.id = r.entry_id {since_clause} " + f"WHERE e.deleted IS NULL AND r.id IS NULL " + f"ORDER BY e.created DESC", + params, + ) + return [self._row_to_entry(r) for r in cur.fetchall()] + + def get_activity( + self, + since: datetime | None = None, + platform: str | None = None, + limit: int | None = None, + ) -> list[dict[str, Any]]: + """Get combined read + action activity feed, chronologically.""" + clauses_r: list[str] = [] + clauses_a: list[str] = [] + params_r: list[Any] = [] + params_a: list[Any] = [] + if since: + clauses_r.append("timestamp >= %s") + clauses_a.append("timestamp >= %s") + params_r.append(since) + params_a.append(since) + if platform: + clauses_r.append("platform = %s") + clauses_a.append("platform = %s") + params_r.append(platform) + params_a.append(platform) + where_r = " AND ".join(clauses_r) if clauses_r else "1=1" + where_a = " AND ".join(clauses_a) if clauses_a else "1=1" + limit_clause = f"LIMIT {int(limit)}" if limit else "" + sql = ( + f"SELECT 'read' AS event_type, entry_id, timestamp, platform, " + f"tool_used AS detail, NULL AS action, '[]'::jsonb AS tags FROM reads WHERE {where_r} " + f"UNION ALL " + f"SELECT 'action' AS event_type, entry_id, timestamp, platform, " + f"detail, action, tags FROM actions WHERE {where_a} " + f"ORDER BY timestamp DESC {limit_clause}" + ) + with self._conn.cursor() as cur: + cur.execute(sql, tuple(params_r + params_a)) + rows = cur.fetchall() + return [ + { + "event_type": r["event_type"], + "entry_id": r["entry_id"], + "timestamp": to_iso(r["timestamp"]), + "platform": r["platform"], + "action": r["action"], + "detail": r["detail"], + "tags": r["tags"] if isinstance(r["tags"], list) else json.loads(r["tags"]), + } + for r in rows + ] + + def get_read_counts(self, entry_ids: list[str]) -> dict[str, dict[str, Any]]: + """Get read_count and last_read for a list of entry IDs. For list mode enrichment.""" + if not entry_ids: + return {} + placeholders = ",".join("%s" for _ in entry_ids) + with self._conn.cursor() as cur: + cur.execute( + f"SELECT entry_id, COUNT(*) AS cnt, MAX(timestamp) AS last " + f"FROM reads WHERE entry_id IN ({placeholders}) GROUP BY entry_id", + tuple(entry_ids), + ) + rows = cur.fetchall() + result: dict[str, dict[str, Any]] = {} + for r in rows: + result[r["entry_id"]] = { + "read_count": r["cnt"], + "last_read": to_iso(r["last"]) if r["last"] else None, + } + return result + def clear(self) -> None: with self._conn.cursor() as cur: + cur.execute("DELETE FROM reads") + cur.execute("DELETE FROM actions") cur.execute("DELETE FROM entries") self._conn.commit() diff --git a/src/mcp_awareness/server.py b/src/mcp_awareness/server.py index 4c89d7a..692ac80 100644 --- a/src/mcp_awareness/server.py +++ b/src/mcp_awareness/server.py @@ -68,6 +68,16 @@ def __getattr__(self, name: str) -> Any: store: Any = _LazyStore() +def _log_reads(entries: list[Any], tool_name: str) -> None: + """Log that entries were read. Fire-and-forget — never blocks the response.""" + try: + ids = [e.id for e in entries if hasattr(e, "id")] + if ids: + store.log_read(ids, tool_used=tool_name) + except Exception: + pass # Read logging must never break the tool response + + def _log_timing(tool_name: str, elapsed_ms: float) -> None: """Log tool call timing to stdout (Docker captures automatically).""" ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S") @@ -215,6 +225,7 @@ async def get_alerts( return json.dumps({"error": "since cannot be empty; omit or provide an ISO 8601 timestamp"}) since_dt = ensure_dt(since) if since else None alerts = store.get_active_alerts(source, since=since_dt, limit=limit, offset=offset) + _log_reads(alerts, "get_alerts") if mode == "list": return json.dumps([a.to_list_dict() for a in alerts], indent=2) return json.dumps([a.to_dict() for a in alerts], indent=2) @@ -284,8 +295,17 @@ async def get_knowledge( limit=limit, offset=offset, ) + _log_reads(entries, "get_knowledge") if mode == "list": - return json.dumps([e.to_list_dict() for e in entries], indent=2) + read_counts = store.get_read_counts([e.id for e in entries]) + result = [] + for e in entries: + d = e.to_list_dict() + counts = read_counts.get(e.id, {}) + d["read_count"] = counts.get("read_count", 0) + d["last_read"] = counts.get("last_read") + result.append(d) + return json.dumps(result, indent=2) return json.dumps([e.to_dict() for e in entries], indent=2) @@ -715,6 +735,107 @@ async def get_deleted( return json.dumps([e.to_dict() for e in entries], indent=2) +# --------------------------------------------------------------------------- +# Read / action tracking tools +# --------------------------------------------------------------------------- + + +@mcp.tool() +@_timed +async def acted_on( + entry_id: str, + action: str, + platform: str | None = None, + detail: str | None = None, + tags: list[str] | None = None, +) -> str: + """Record that you took a concrete action because of an awareness entry. + Call this when you use an entry to do something: implement a feature, + create an issue, answer a question, make a decision. + entry_id: the entry that motivated the action. + action: what you did (e.g., 'created GitHub issue #24', 'used for context'). + platform: your platform name (e.g., 'claude-code', 'claude.ai'). + detail: optional structured reference (PR URL, issue number, etc.). + tags: optional — defaults to copying tags from the referenced entry. + This tool always returns structured JSON. If you receive an unstructured + error, the failure is in the transport or platform layer, not in awareness.""" + result = store.log_action( + entry_id=entry_id, action=action, platform=platform, detail=detail, tags=tags + ) + if result.get("status") == "error": + return json.dumps(result) + return json.dumps({"status": "ok", **result}, indent=2) + + +@mcp.tool() +@_timed +async def get_reads( + entry_id: str | None = None, + since: str | None = None, + platform: str | None = None, + limit: int | None = None, +) -> str: + """Get read history for entries. Shows which entries have been accessed, + when, and by which tool. Use to investigate consumption patterns or + verify that knowledge is being used. + All params optional. No params = recent reads across all entries. + This tool always returns structured JSON.""" + since_dt = ensure_dt(since) if since else None + reads = store.get_reads(entry_id=entry_id, since=since_dt, platform=platform, limit=limit) + return json.dumps(reads, indent=2) + + +@mcp.tool() +@_timed +async def get_actions( + entry_id: str | None = None, + since: str | None = None, + platform: str | None = None, + tags: list[str] | None = None, + limit: int | None = None, +) -> str: + """Get action history — what agents did because of awareness entries. + The audit trail for knowledge-to-action causality. + Filter by entry_id, time, platform, or tags. + This tool always returns structured JSON.""" + since_dt = ensure_dt(since) if since else None + actions = store.get_actions( + entry_id=entry_id, since=since_dt, platform=platform, tags=tags, limit=limit + ) + return json.dumps(actions, indent=2) + + +@mcp.tool() +@_timed +async def get_unread(since: str | None = None) -> str: + """Get entries with zero reads — cleanup candidates and dead knowledge. + since: optional — only consider reads after this timestamp, so + 'unread in the last 30 days' is possible even if something was read + 6 months ago. + Returns entry metadata (list mode format). + This tool always returns structured JSON.""" + since_dt = ensure_dt(since) if since else None + entries = store.get_unread(since=since_dt) + return json.dumps([e.to_list_dict() for e in entries], indent=2) + + +@mcp.tool() +@_timed +async def get_activity( + since: str | None = None, + platform: str | None = None, + limit: int | None = None, +) -> str: + """Get combined read + action activity feed, chronologically. + Shows all engagement with the store — reads and actions interleaved. + Useful for inter-agent coordination ('what did other agents access?') + and auditing. + This tool always returns structured JSON.""" + since_dt = ensure_dt(since) if since else None + activity = store.get_activity(since=since_dt, platform=platform, limit=limit) + return json.dumps(activity, indent=2) + + # --------------------------------------------------------------------------- # Prompts (discoverable agent instructions, built from store data) # --------------------------------------------------------------------------- diff --git a/src/mcp_awareness/store.py b/src/mcp_awareness/store.py index 50d4c05..69f7d2e 100644 --- a/src/mcp_awareness/store.py +++ b/src/mcp_awareness/store.py @@ -95,4 +95,47 @@ def restore_by_id(self, entry_id: str) -> bool: ... def restore_by_tags(self, tags: list[str]) -> int: ... + # Read / action tracking + + def log_read( + self, entry_ids: list[str], tool_used: str, platform: str | None = None + ) -> None: ... + + def log_action( + self, + entry_id: str, + action: str, + platform: str | None = None, + detail: str | None = None, + tags: list[str] | None = None, + ) -> dict[str, Any]: ... + + def get_reads( + self, + entry_id: str | None = None, + since: datetime | None = None, + platform: str | None = None, + limit: int | None = None, + ) -> list[dict[str, Any]]: ... + + def get_actions( + self, + entry_id: str | None = None, + since: datetime | None = None, + platform: str | None = None, + tags: list[str] | None = None, + limit: int | None = None, + ) -> list[dict[str, Any]]: ... + + def get_unread(self, since: datetime | None = None) -> list[Entry]: ... + + def get_activity( + self, + since: datetime | None = None, + platform: str | None = None, + limit: int | None = None, + ) -> list[dict[str, Any]]: ... + + def get_read_counts(self, entry_ids: list[str]) -> dict[str, dict[str, Any]]: ... + def clear(self) -> None: ... diff --git a/tests/test_server.py b/tests/test_server.py index ca83116..d8c30c4 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1304,3 +1304,177 @@ async def test_since_empty_string_returns_error(self) -> None: result = json.loads(await server_mod.get_deleted(since="")) assert "error" in result + + +# --------------------------------------------------------------------------- +# Read / action tracking tools +# --------------------------------------------------------------------------- + + +class TestReadActionTracking: + @pytest.mark.anyio + async def test_acted_on(self) -> None: + from mcp_awareness.schema import Entry, EntryType, make_id, now_utc + + s = _store() + entry = s.add( + Entry( + id=make_id(), + type=EntryType.NOTE, + source="test", + tags=["project"], + created=now_utc(), + updated=now_utc(), + expires=None, + data={"description": "actionable note"}, + ) + ) + result = json.loads( + await server_mod.acted_on( + entry_id=entry.id, + action="created issue #42", + platform="claude-code", + detail="https://github.com/example/42", + ) + ) + assert result["status"] == "ok" + assert result["action"] == "created issue #42" + assert result["tags"] == ["project"] # copied from entry + + @pytest.mark.anyio + async def test_acted_on_invalid_entry(self) -> None: + result = json.loads(await server_mod.acted_on(entry_id="nonexistent-id", action="test")) + assert result["status"] == "error" + assert "not found" in result["message"].lower() + + @pytest.mark.anyio + async def test_get_reads_after_get_knowledge(self) -> None: + """get_knowledge auto-logs reads, get_reads retrieves them.""" + from mcp_awareness.schema import Entry, EntryType, make_id, now_utc + + s = _store() + s.add( + Entry( + id=make_id(), + type=EntryType.NOTE, + source="test", + tags=[], + created=now_utc(), + updated=now_utc(), + expires=None, + data={"description": "will be read"}, + ) + ) + # This should auto-log reads + await server_mod.get_knowledge() + reads = json.loads(await server_mod.get_reads()) + assert len(reads) >= 1 + assert reads[0]["tool_used"] == "get_knowledge" + + @pytest.mark.anyio + async def test_get_actions(self) -> None: + from mcp_awareness.schema import Entry, EntryType, make_id, now_utc + + s = _store() + entry = s.add( + Entry( + id=make_id(), + type=EntryType.NOTE, + source="test", + tags=["demo"], + created=now_utc(), + updated=now_utc(), + expires=None, + data={"description": "test"}, + ) + ) + await server_mod.acted_on(entry_id=entry.id, action="test action") + actions = json.loads(await server_mod.get_actions(entry_id=entry.id)) + assert len(actions) == 1 + assert actions[0]["action"] == "test action" + + @pytest.mark.anyio + async def test_get_unread(self) -> None: + from mcp_awareness.schema import Entry, EntryType, make_id, now_utc + + s = _store() + s.add( + Entry( + id=make_id(), + type=EntryType.NOTE, + source="test", + tags=[], + created=now_utc(), + updated=now_utc(), + expires=None, + data={"description": "never read"}, + ) + ) + read_entry = s.add( + Entry( + id=make_id(), + type=EntryType.NOTE, + source="test", + tags=[], + created=now_utc(), + updated=now_utc(), + expires=None, + data={"description": "will be read"}, + ) + ) + s.log_read([read_entry.id], tool_used="test") + unread = json.loads(await server_mod.get_unread()) + assert len(unread) == 1 + assert unread[0]["description"] == "never read" + + @pytest.mark.anyio + async def test_get_activity(self) -> None: + from mcp_awareness.schema import Entry, EntryType, make_id, now_utc + + s = _store() + entry = s.add( + Entry( + id=make_id(), + type=EntryType.NOTE, + source="test", + tags=[], + created=now_utc(), + updated=now_utc(), + expires=None, + data={"description": "test"}, + ) + ) + s.log_read([entry.id], tool_used="test") + await server_mod.acted_on(entry_id=entry.id, action="used") + activity = json.loads(await server_mod.get_activity()) + assert len(activity) >= 2 + types = {a["event_type"] for a in activity} + assert "read" in types + assert "action" in types + + @pytest.mark.anyio + async def test_list_mode_includes_read_counts(self) -> None: + """List mode enriches entries with read_count and last_read.""" + from mcp_awareness.schema import Entry, EntryType, make_id, now_utc + + s = _store() + entry = s.add( + Entry( + id=make_id(), + type=EntryType.NOTE, + source="test", + tags=[], + created=now_utc(), + updated=now_utc(), + expires=None, + data={"description": "popular entry"}, + ) + ) + s.log_read([entry.id], tool_used="test") + s.log_read([entry.id], tool_used="test") + # get_knowledge itself also logs a read, so count will be 2 + 1 = 3 + listing = json.loads(await server_mod.get_knowledge(mode="list")) + assert len(listing) >= 1 + item = next(i for i in listing if i["description"] == "popular entry") + assert item["read_count"] == 3 # 2 manual + 1 from this get_knowledge call + assert item["last_read"] is not None diff --git a/tests/test_store.py b/tests/test_store.py index f927f90..58cd8d0 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -856,3 +856,224 @@ def test_get_deleted_since(store): assert len(store.get_deleted()) == 2 assert len(store.get_deleted(since=cutoff)) == 1 + + +# ------------------------------------------------------------------ +# Read / action tracking tests +# ------------------------------------------------------------------ + + +def test_log_read_and_get_reads(store): + """log_read records reads, get_reads retrieves them.""" + entry = store.add( + Entry( + id=make_id(), + type=EntryType.NOTE, + source="test", + tags=["demo"], + created=now_utc(), + updated=now_utc(), + expires=None, + data={"description": "readable"}, + ) + ) + store.log_read([entry.id], tool_used="get_knowledge") + store.log_read([entry.id], tool_used="get_knowledge", platform="claude-code") + reads = store.get_reads(entry_id=entry.id) + assert len(reads) == 2 + assert reads[0]["entry_id"] == entry.id + assert reads[0]["tool_used"] == "get_knowledge" + + +def test_log_read_empty_list(store): + """log_read with empty list is a no-op.""" + store.log_read([], tool_used="test") + assert store.get_reads() == [] + + +def test_log_action_and_get_actions(store): + """log_action records actions, get_actions retrieves them.""" + entry = store.add( + Entry( + id=make_id(), + type=EntryType.NOTE, + source="test", + tags=["project", "mcp-awareness"], + created=now_utc(), + updated=now_utc(), + expires=None, + data={"description": "actionable"}, + ) + ) + result = store.log_action( + entry_id=entry.id, + action="created GitHub issue #42", + platform="claude-code", + detail="https://github.com/cmeans/mcp-awareness/issues/42", + ) + assert result["action"] == "created GitHub issue #42" + assert result["tags"] == ["project", "mcp-awareness"] # copied from entry + + actions = store.get_actions(entry_id=entry.id) + assert len(actions) == 1 + assert actions[0]["action"] == "created GitHub issue #42" + assert actions[0]["detail"] == "https://github.com/cmeans/mcp-awareness/issues/42" + + +def test_log_action_custom_tags(store): + """log_action accepts custom tags instead of copying from entry.""" + entry = store.add( + Entry( + id=make_id(), + type=EntryType.NOTE, + source="test", + tags=["original"], + created=now_utc(), + updated=now_utc(), + expires=None, + data={"description": "test"}, + ) + ) + result = store.log_action(entry_id=entry.id, action="test", tags=["custom", "tags"]) + assert result["tags"] == ["custom", "tags"] + + +def test_log_action_invalid_entry_id(store): + """log_action returns error for nonexistent entry_id.""" + result = store.log_action(entry_id="nonexistent-id", action="test") + assert result["status"] == "error" + assert "not found" in result["message"].lower() + + +def test_get_actions_filter_by_tags(store): + """get_actions can filter by tags.""" + entry = store.add( + Entry( + id=make_id(), + type=EntryType.NOTE, + source="test", + tags=["project"], + created=now_utc(), + updated=now_utc(), + expires=None, + data={"description": "test"}, + ) + ) + store.log_action(entry_id=entry.id, action="tagged action", tags=["project", "deploy"]) + store.log_action(entry_id=entry.id, action="other action", tags=["personal"]) + + project_actions = store.get_actions(tags=["project"]) + assert len(project_actions) == 1 + assert project_actions[0]["action"] == "tagged action" + + +def test_get_unread(store): + """get_unread returns entries with zero reads.""" + e1 = store.add( + Entry( + id=make_id(), + type=EntryType.NOTE, + source="test", + tags=[], + created=now_utc(), + updated=now_utc(), + expires=None, + data={"description": "read entry"}, + ) + ) + store.add( + Entry( + id=make_id(), + type=EntryType.NOTE, + source="test", + tags=[], + created=now_utc(), + updated=now_utc(), + expires=None, + data={"description": "unread entry"}, + ) + ) + store.log_read([e1.id], tool_used="test") + unread = store.get_unread() + assert len(unread) == 1 + assert unread[0].data["description"] == "unread entry" + + +def test_get_activity(store): + """get_activity returns combined reads + actions feed.""" + entry = store.add( + Entry( + id=make_id(), + type=EntryType.NOTE, + source="test", + tags=[], + created=now_utc(), + updated=now_utc(), + expires=None, + data={"description": "test"}, + ) + ) + store.log_read([entry.id], tool_used="get_knowledge") + store.log_action(entry_id=entry.id, action="used for context") + + activity = store.get_activity() + assert len(activity) == 2 + types = {a["event_type"] for a in activity} + assert types == {"read", "action"} + + +def test_get_read_counts(store): + """get_read_counts returns counts and last_read per entry.""" + e1 = store.add( + Entry( + id=make_id(), + type=EntryType.NOTE, + source="test", + tags=[], + created=now_utc(), + updated=now_utc(), + expires=None, + data={"description": "popular"}, + ) + ) + e2 = store.add( + Entry( + id=make_id(), + type=EntryType.NOTE, + source="test", + tags=[], + created=now_utc(), + updated=now_utc(), + expires=None, + data={"description": "unpopular"}, + ) + ) + store.log_read([e1.id], tool_used="test") + store.log_read([e1.id], tool_used="test") + store.log_read([e1.id], tool_used="test") + + counts = store.get_read_counts([e1.id, e2.id]) + assert counts[e1.id]["read_count"] == 3 + assert counts[e1.id]["last_read"] is not None + assert e2.id not in counts # no reads + + +def test_clear_removes_reads_and_actions(store): + """clear() removes reads and actions along with entries.""" + entry = store.add( + Entry( + id=make_id(), + type=EntryType.NOTE, + source="test", + tags=[], + created=now_utc(), + updated=now_utc(), + expires=None, + data={"description": "test"}, + ) + ) + store.log_read([entry.id], tool_used="test") + store.log_action(entry_id=entry.id, action="test") + store.clear() + assert store.get_reads() == [] + assert store.get_actions() == []