Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **INTENTION entry type**: Goals with constraints, evaluated when conditions align. New lifecycle: pending → fired → completed/snoozed/cancelled.
- **`remind` tool**: Create intentions with optional `deliver_at` timestamp, constraints, urgency. Time-based triggers fire automatically in the briefing.
- **`get_intentions` tool**: Query intentions by state, source, tags. Supports list mode.
- **`update_intention` tool**: Transition intention state (fire, complete, snooze, cancel) with optional reason. Changelog tracked.
- **Briefing integration**: Collator evaluates pending intentions — surfaces `fired_intentions` when `deliver_at` has passed. Summary includes intention count. Evaluation field tracks `intentions_pending` and `intentions_fired`.
- 17 new tests (230 total)

## [0.7.0] - 2026-03-23

### Added
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ src/mcp_awareness/

**Key design decisions**:
- Briefing is computed on-demand per read (not background task)
- Seven entry types: status, alert, pattern, suppression, context, preference, note
- Eight entry types: status, alert, pattern, suppression, context, preference, note, intention
- One status entry per source (upsert), alerts keyed by source + alert_id, preferences upsert by key + scope
- Notes support optional content payload with MIME content_type
- update_entry works on knowledge types only (note/pattern/context/preference); status/alert/suppression are immutable. Changes tracked in changelog array
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 + 23 tools + 5 prompts
- Full MCP API: 6 resources + 26 tools + 5 prompts
- Read tool mirrors for tools-only clients
- User-defined custom prompts from store entries with `{{var}}` templates
- Streamable HTTP + stdio transports
Expand All @@ -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
- 213 tests (all against real Postgres), strict type checking, CI pipeline with coverage, QA gate
- 230 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
Expand Down
20 changes: 19 additions & 1 deletion docs/data-dictionary.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ All data in mcp-awareness is stored in a single `entries` table using a common e
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
| `id` | TEXT | No | Primary key. UUID v4, generated via `uuid.uuid4()`. |
| `type` | TEXT | No | Entry type. One of: `status`, `alert`, `pattern`, `suppression`, `context`, `preference`, `note`. |
| `type` | TEXT | No | Entry type. One of: `status`, `alert`, `pattern`, `suppression`, `context`, `preference`, `note`, `intention`. |
| `source` | TEXT | No | Origin identifier. Describes the subject, not the owner (e.g., `"personal"`, `"synology-nas"`, `"mcp-awareness-project"`). |
| `created` | TIMESTAMPTZ | No | UTC timestamp. Set once when the entry is first created. |
| `updated` | TIMESTAMPTZ | No | UTC timestamp. Updated on every upsert or `update_entry` call. |
Expand Down Expand Up @@ -84,6 +84,24 @@ Written by agents via `remember`. Permanent unless explicitly deleted. The defau
| `learned_from` | string | No | Platform that recorded this. Default: `"conversation"`. |
| `changelog` | array | No | Change history. Populated automatically by `update_entry`. Each element: `{"updated": "<timestamp>", "changed": {"<field>": "<old_value>"}}`. |

### `intention` — Goals with conditions

Written by agents via `remind`. Intentions have a lifecycle: they start `pending`, fire when conditions are met (currently time-based via `deliver_at`), and complete when the user acts on them. The collator evaluates pending intentions during briefing generation.

**`data` fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `goal` | string | Yes | What outcome is desired (e.g., "pick up milk", "tell Mom about insurance"). |
| `state` | string | Yes | Lifecycle state: `pending`, `fired`, `completed`, `snoozed`, `cancelled`. |
| `deliver_at` | string | No | ISO 8601 timestamp — when to surface this intention. Required for time-based triggers. |
| `constraints` | string | No | Preferences or requirements (e.g., "organic, budget-conscious"). |
| `urgency` | string | No | `"low"`, `"normal"`, or `"high"`. Default: `"normal"`. |
| `recurrence` | string | No | Reserved for future use. Currently only one-shot (`null`) is supported. |
| `state_reason` | string | No | Explanation for the current state (e.g., "completed at Mariano's", "not today"). |
| `learned_from` | string | No | Platform that created this. Default: `"conversation"`. |
| `changelog` | array | No | State transition history. Each element: `{"updated": "<timestamp>", "changed": {"state": "<old_state>"}}`. |

### `suppression` — Alert suppressions

Written by agents via `suppress_alert`. Time-limited — always has an `expires` timestamp. Suppressions filter alerts out of the briefing. Critical alerts can break through warning-level suppressions via escalation override.
Expand Down
4 changes: 2 additions & 2 deletions docs/deployment-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.
- **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 26 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.

- **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.
- **26 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"])`).
Expand Down
32 changes: 32 additions & 0 deletions src/mcp_awareness/collator.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ def compose_summary(briefing: dict[str, Any]) -> str:
if upcoming:
parts.append(f"{len(upcoming)} upcoming item{'s' if len(upcoming) != 1 else ''}")

fired = briefing.get("fired_intentions", [])
if fired:
parts.append(f"{len(fired)} intention{'s' if len(fired) != 1 else ''} ready")

return ". ".join(parts) + "." if parts else f"All clear across {total} sources."


Expand All @@ -225,6 +229,11 @@ def compose_mention(briefing: dict[str, Any]) -> str:
if summary:
parts.append(summary)

for intention in briefing.get("fired_intentions", []):
goal = intention.get("goal", "")
if goal:
parts.append(f"INTENTION: {goal}")

return " ".join(parts)


Expand Down Expand Up @@ -314,12 +323,35 @@ def generate_briefing(store: Store) -> dict[str, Any]:
briefing["sources"][source] = source_entry

briefing["active_suppressions"] = store.count_active_suppressions()

# Evaluate time-based intentions — fire pending intentions whose deliver_at has passed
fired_intentions = store.get_fired_intentions()
if fired_intentions:
briefing["fired_intentions"] = [
{
"id": i.id,
"goal": i.data.get("goal", i.data.get("description", "")),
"source": i.source,
"tags": i.tags,
}
for i in fired_intentions
]
briefing["attention_needed"] = True

# Count pending intentions, excluding those already fired (avoid double-counting)
all_pending = store.get_intentions(state="pending")
fired_ids = {i.id for i in fired_intentions}
pending_not_fired = [i for i in all_pending if i.id not in fired_ids]
briefing["pending_intentions"] = len(pending_not_fired)

briefing["evaluation"] = {
"alerts_checked": eval_alerts_checked,
"suppressed": eval_suppressed,
"pattern_matched": eval_pattern_matched,
"stale_sources": eval_stale_sources,
"surfaced": briefing["active_alerts"],
"intentions_pending": len(pending_not_fired),
"intentions_fired": len(fired_intentions),
}
briefing["summary"] = compose_summary(briefing)

Expand Down
76 changes: 76 additions & 0 deletions src/mcp_awareness/postgres_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,82 @@ def get_read_counts(self, entry_ids: list[str]) -> dict[str, dict[str, Any]]:
}
return result

# ------------------------------------------------------------------
# Intentions
# ------------------------------------------------------------------

def get_intentions(
self,
state: str | None = None,
source: str | None = None,
tags: list[str] | None = None,
limit: int | None = None,
) -> list[Entry]:
"""Get intention entries, optionally filtered by state, source, or tags."""
clauses = ["type = %s"]
params: list[Any] = [EntryType.INTENTION.value]
if state:
clauses.append("data->>'state' = %s")
params.append(state)
if source:
clauses.append("source = %s")
params.append(source)
if tags:
for t in tags:
clauses.append("tags @> %s::jsonb")
params.append(json.dumps([t]))
where = " AND ".join(clauses)
entries = self._query_entries(where, tuple(params))
if limit:
entries = entries[:limit]
return entries

def update_intention_state(
self, entry_id: str, new_state: str, reason: str | None = None
) -> Entry | None:
"""Transition an intention to a new state. Appends to changelog."""
entry = self.get_entry_by_id(entry_id)
if entry is None or entry.type != EntryType.INTENTION:
return None
old_state = entry.data.get("state", "pending")
old_reason = entry.data.get("state_reason")
now = now_utc()
# Update state
entry.data["state"] = new_state
if reason:
entry.data["state_reason"] = reason
# Changelog — capture previous values
changelog = entry.data.setdefault("changelog", [])
changed: dict[str, Any] = {"state": old_state}
if old_reason is not None:
changed["state_reason"] = old_reason
changelog.append({"updated": to_iso(now), "changed": changed})
entry.updated = now
with self._conn.cursor() as cur:
cur.execute(
"UPDATE entries SET updated = %s, data = %s::jsonb WHERE id = %s",
(now, json.dumps(entry.data), entry.id),
)
self._conn.commit()
return entry

def get_fired_intentions(self) -> list[Entry]:
"""Get intentions whose deliver_at has passed and state is still pending.

These are ready to be surfaced to the user. The caller (collator or
server tool) is responsible for transitioning them to 'fired'.
"""
now = now_utc()
entries = self._query_entries(
"type = %s AND data->>'state' = %s",
(EntryType.INTENTION.value, "pending"),
)
return [
e
for e in entries
if e.data.get("deliver_at") and ensure_dt(e.data["deliver_at"]) <= now
]

def clear(self) -> None:
with self._conn.cursor() as cur:
cur.execute("DELETE FROM reads")
Expand Down
8 changes: 8 additions & 0 deletions src/mcp_awareness/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ class EntryType(str, Enum):
CONTEXT = "context"
PREFERENCE = "preference"
NOTE = "note"
INTENTION = "intention"


# Valid states for the INTENTION lifecycle
INTENTION_STATES = {"pending", "fired", "completed", "snoozed", "cancelled"}


SEVERITY_RANK = {
Expand All @@ -40,6 +45,9 @@ def now_utc() -> datetime:


def parse_iso(s: str) -> datetime:
# Python 3.10 doesn't support 'Z' suffix in fromisoformat — normalize to +00:00
if s.endswith("Z"):
s = s[:-1] + "+00:00"
return datetime.fromisoformat(s)


Expand Down
97 changes: 97 additions & 0 deletions src/mcp_awareness/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,103 @@ async def get_activity(
return json.dumps(activity, indent=2)


# ---------------------------------------------------------------------------
# Intention tools
# ---------------------------------------------------------------------------


@mcp.tool()
@_timed
async def remind(
goal: str,
source: str,
tags: list[str],
deliver_at: str | None = None,
constraints: str | None = None,
urgency: str = "normal",
recurrence: str | None = None,
learned_from: str = "conversation",
) -> str:
"""Create an intention — a goal to be evaluated when conditions align.
Unlike remember (permanent knowledge) or add_context (time-limited facts),
intentions have a lifecycle: they start pending, fire when conditions are met,
and complete when you act on them.
goal: what outcome is desired (e.g., 'pick up milk', 'tell Mom about insurance').
deliver_at: ISO 8601 timestamp — when to surface this intention. Required for
time-based triggers. Omit for intentions that will be triggered by other
conditions (location, events) in the future.
constraints: optional preferences or requirements (e.g., 'organic, budget-conscious').
urgency: 'low', 'normal', or 'high'. High-urgency intentions surface more prominently.
recurrence: reserved for future use. Currently only one-shot intentions are supported.
This tool always returns structured JSON."""
now = now_utc()
deliver_at_dt = ensure_dt(deliver_at) if deliver_at else None
entry = Entry(
id=make_id(),
type=EntryType.INTENTION,
source=source,
tags=tags,
created=now,
updated=now,
expires=None,
data={
"goal": goal,
"state": "pending",
"deliver_at": to_iso(deliver_at_dt) if deliver_at_dt else None,
"constraints": constraints,
"urgency": urgency,
"recurrence": recurrence,
"learned_from": learned_from,
},
)
store.add(entry)
return json.dumps({"status": "ok", "id": entry.id, "state": "pending"}, indent=2)


@mcp.tool()
@_timed
async def get_intentions(
state: str | None = None,
source: str | None = None,
tags: list[str] | None = None,
mode: str | None = None,
limit: int | None = None,
) -> str:
"""Get intentions, optionally filtered by state, source, or tags.
Valid states: 'pending', 'fired', 'completed', 'snoozed', 'cancelled'.
mode: omit for full entries, 'list' for metadata only.
This tool always returns structured JSON."""
entries = store.get_intentions(state=state, source=source, tags=tags, limit=limit)
if mode == "list":
return json.dumps([e.to_list_dict() for e in entries], indent=2)
return json.dumps([e.to_dict() for e in entries], indent=2)


@mcp.tool()
@_timed
async def update_intention(
entry_id: str,
state: str,
reason: str | None = None,
) -> str:
"""Transition an intention to a new state.
Valid states: 'fired', 'completed', 'snoozed', 'cancelled'.
reason: optional explanation (e.g., 'completed at Mariano\\'s', 'not today').
Use 'completed' when the goal was achieved, 'snoozed' to defer,
'cancelled' to permanently dismiss.
This tool always returns structured JSON."""
from .schema import INTENTION_STATES

if state not in INTENTION_STATES:
return json.dumps(
{"status": "error", "message": f"Invalid state: {state}. Valid: {INTENTION_STATES}"}
)
result = store.update_intention_state(entry_id, state, reason)
if result is None:
return json.dumps({"status": "error", "message": "Intention not found"})
return json.dumps({"status": "ok", "id": entry_id, "state": state, "reason": reason}, indent=2)


# ---------------------------------------------------------------------------
# Prompts (discoverable agent instructions, built from store data)
# ---------------------------------------------------------------------------
Expand Down
16 changes: 16 additions & 0 deletions src/mcp_awareness/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,20 @@ def get_activity(

def get_read_counts(self, entry_ids: list[str]) -> dict[str, dict[str, Any]]: ...

# Intentions

def get_intentions(
self,
state: str | None = None,
source: str | None = None,
tags: list[str] | None = None,
limit: int | None = None,
) -> list[Entry]: ...

def update_intention_state(
self, entry_id: str, new_state: str, reason: str | None = None
) -> Entry | None: ...

def get_fired_intentions(self) -> list[Entry]: ...

def clear(self) -> None: ...
Loading
Loading