Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,11 @@ Hybrid retrieval over the memory store. Combines BM25, dense vector
ANN, optional scalar filtering, optional cross-encoder rerank, and
optional final LLM rerank. Returns ranked items grouped by kind.

> **Integrating a Chat Agent?** See the
> [Chat Agent Integration Guide](chat-agent-integration.md) for
> recommended patterns (on-demand search vs per-turn RAG) and an
> official tool schema.

#### Request body

| Field | Type | Required | Default | Constraints |
Expand Down
182 changes: 182 additions & 0 deletions docs/chat-agent-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# Chat Agent Integration Guide

Recommended patterns for integrating a Chat Agent (LLM-based assistant)
with EverOS persistent memory. Covers the write path (ingesting
conversations), the read path (recalling memories on demand), and an
official tool schema for function-calling agents.

## Architecture: On-Demand Search vs Per-Turn RAG

| Pattern | How it works | Trade-off |
|---|---|---|
| **Per-turn RAG** | Every turn, retrieve memories and inject into the LLM context window before generating a response. | Simple but pollutes context with irrelevant memories; burns tokens on every turn. |
| **On-demand search** | The agent decides *when* to recall by calling a memory search tool. | Token-efficient; closer to how human memory works (you don't recall everything every sentence). |

**Recommendation: on-demand search.** Keep short-term context (the last
*n* turns) in the LLM `messages` array as working memory. Long-term
memory is retrieved only when the agent determines it needs historical
context.

## Write Path

Ingest every conversation turn automatically. Do not wait for the agent
to decide what to remember.

```
POST /api/v1/memory/add
{
"session_id": "chat-abc123",
"messages": [
{
"sender_id": "user_42",
"role": "user",
"content": "I prefer dark mode for all my apps",
"timestamp": 1740564000000
}
]
}
```

When you need to trigger memory extraction immediately (e.g. end of
conversation), call flush:

```
POST /api/v1/memory/flush
{
"session_id": "chat-abc123"
}
```

Extraction also fires automatically when the buffer reaches a size
threshold. Calling flush is optional but useful when you want memories
available for search right away.

See [POST /api/v1/memory/add](api.md#post-apiv1memoryadd) for the
full request schema.

## Read Path

When the agent needs to recall past context, have it call the search
tool:

```
POST /api/v1/memory/search
{
"user_id": "user_42",
"query": "dark mode preferences",
"filters": {
"timestamp": {
"gte": 1740480000000,
"lt": 1740566400000
}
}
}
```

See [POST /api/v1/memory/search](api.md#post-apiv1memorysearch) for
the full request schema.

### Time-Range Filtering

For natural-language time references ("what we discussed yesterday about
X"), resolve the spoken time window to concrete `timestamp` bounds in
the `filters` field:

- Use Unix epoch milliseconds **or** ISO-8601 strings.
- `gte` / `lt` operators bracket the window.
- Timestamps reflect **when the conversation happened**, not when the
memory was extracted. If your extraction pipeline is async (flush-
based), propagate the original conversation timestamp.

```json
{
"filters": {
"AND": [
{"timestamp": {"gte": 1740480000000, "lt": 1740566400000}},
{"session_id": {"eq": "chat-abc123"}}
]
}
}
```

### Retrieval Methods

| Method | When to use |
|---|---|
| `hybrid` (default) | General-purpose — combines BM25 + vector search. Best starting point. |
| `keyword` | When the query is exact-match friendly (e.g. function names, error codes). |
| `vector` | When semantic similarity matters more than keyword overlap. |
| `agentic` | When you want the system to run multi-step retrieval with LLM sufficiency checks. |

## Official Tool Schema

The following OpenAI-compatible tool definition exposes memory search as
a function the agent can call. Fields align with the `/search` endpoint
documented in [api.md](api.md#post-apiv1memorysearch).

```json
{
"type": "function",
"function": {
"name": "memory_search",
"description": "Search the user's long-term memory for relevant past conversations, facts, and context. Use when the user references previous sessions, asks about past decisions, or when historical context would improve your response.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query — what to look for in past memories. Be specific."
},
"top_k": {
"type": "integer",
"default": 10,
"description": "Maximum number of results to return. -1 for server default."
},
"filters": {
"type": "object",
"description": "Optional filters for time range, session, or other dimensions.",
"properties": {
"timestamp": {
"type": "object",
"description": "Time range filter. Use gte/lt with Unix epoch ms or ISO-8601 strings.",
"properties": {
"gte": {"type": ["integer", "string"], "description": "Start of time range (inclusive)"},
"lt": {"type": ["integer", "string"], "description": "End of time range (exclusive)"}
}
},
"session_id": {
"type": "object",
"properties": {
"eq": {"type": "string", "description": "Filter to a specific session"}
}
}
}
}
},
"required": ["query"]
}
}
}
```

### MCP Tool Reference

For Claude Code and other MCP-compatible agents, a reference
implementation is available at
[`use-cases/claude-code-plugin/skills/memory-tools.md`](../use-cases/claude-code-plugin/skills/memory-tools.md).
That document describes the `evermem_search` tool and when to use it.

## Key Integration Points

1. **Write automatic, read agent-initiated.** Every turn goes through
`/add`; the agent calls `/search` only when it needs context.

2. **Session scoping.** Use `session_id` to group turns from one
conversation. The `/search` endpoint can filter by session.

3. **Owner scoping.** Pass `user_id` for user-facing agents or
`agent_id` for autonomous agents. Results never cross owner
boundaries.

4. **App / project scoping.** Use `app_id` and `project_id` to
isolate memories across different products or environments.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ specific thing (drain a queue, recover from a stuck row, etc.).
| Doc | Purpose |
|---|---|
| [cascade_runbook.md](cascade_runbook.md) | Cascade subsystem ops — drain queue, recover stuck rows |
| [chat-agent-integration.md](chat-agent-integration.md) | Chat Agent + EverOS integration guide — on-demand search, tool schema |

## Engineering / Internal

Expand Down
58 changes: 32 additions & 26 deletions src/everos/infra/persistence/sqlite/repos/md_change_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@

from ..sqlite_manager import get_session_factory
from ..tables import MdChangeState
from ..tables.md_change_state import ChangeKind, ChangeStatus, ChangeType


@dataclasses.dataclass(frozen=True)
Expand Down Expand Up @@ -82,8 +83,8 @@ async def upsert(
self,
md_path: str,
*,
kind: str,
change_type: str,
kind: ChangeKind | str,
change_type: ChangeType | str,
mtime: float,
) -> int:
"""Enqueue or re-enqueue ``md_path``; return the assigned LSN.
Expand Down Expand Up @@ -117,7 +118,7 @@ async def upsert(
first_seen_at=now,
last_changed_at=now,
lsn=new_lsn,
status="pending",
status=ChangeStatus.PENDING,
retryable=None,
last_attempt_at=None,
retry_count=0,
Expand All @@ -131,7 +132,7 @@ async def upsert(
"mtime": mtime,
"last_changed_at": now,
"lsn": new_lsn,
"status": "pending",
"status": ChangeStatus.PENDING,
"retryable": None,
"last_attempt_at": None,
"retry_count": 0,
Expand All @@ -143,7 +144,7 @@ async def upsert(
await s.commit()
return new_lsn

async def force_enqueue(self, md_path: str, kind: str) -> int:
async def force_enqueue(self, md_path: str, kind: ChangeKind | str) -> int:
"""`cascade sync --path` entry: re-enqueue regardless of status.

Semantically the same as :meth:`upsert` with ``change_type
Expand All @@ -153,7 +154,7 @@ async def force_enqueue(self, md_path: str, kind: str) -> int:
return await self.upsert(
md_path,
kind=kind,
change_type="modified",
change_type=ChangeType.MODIFIED,
mtime=0.0,
)

Expand All @@ -172,8 +173,8 @@ async def claim_one(self, md_path: str) -> MdChangeState | None:
result = await s.execute(
update(MdChangeState)
.where(MdChangeState.md_path == md_path)
.where(MdChangeState.status == "pending")
.values(status="processing", last_attempt_at=now)
.where(MdChangeState.status == ChangeStatus.PENDING)
.values(status=ChangeStatus.PROCESSING, last_attempt_at=now)
)
await s.commit()
if result.rowcount != 1:
Expand All @@ -197,7 +198,7 @@ async def claim_pending_batch(self, limit: int = 100) -> list[MdChangeState]:
(
await s.execute(
select(MdChangeState.md_path)
.where(MdChangeState.status == "pending")
.where(MdChangeState.status == ChangeStatus.PENDING)
.order_by(MdChangeState.lsn)
.limit(limit)
)
Expand All @@ -210,8 +211,8 @@ async def claim_pending_batch(self, limit: int = 100) -> list[MdChangeState]:
update_result = await s.execute(
update(MdChangeState)
.where(MdChangeState.md_path.in_(picks))
.where(MdChangeState.status == "pending")
.values(status="processing", last_attempt_at=now)
.where(MdChangeState.status == ChangeStatus.PENDING)
.values(status=ChangeStatus.PROCESSING, last_attempt_at=now)
)
await s.commit()
if update_result.rowcount == 0:
Expand All @@ -221,7 +222,7 @@ async def claim_pending_batch(self, limit: int = 100) -> list[MdChangeState]:
await s.execute(
select(MdChangeState)
.where(MdChangeState.md_path.in_(picks))
.where(MdChangeState.status == "processing")
.where(MdChangeState.status == ChangeStatus.PROCESSING)
.order_by(MdChangeState.lsn)
)
)
Expand All @@ -248,9 +249,9 @@ async def mark_done(self, md_path: str) -> None:
await s.execute(
update(MdChangeState)
.where(MdChangeState.md_path == md_path)
.where(MdChangeState.status == "processing")
.where(MdChangeState.status == ChangeStatus.PROCESSING)
.values(
status="done",
status=ChangeStatus.DONE,
last_attempt_at=now,
error=None,
retryable=None,
Expand Down Expand Up @@ -294,9 +295,9 @@ async def mark_failed(
await s.execute(
update(MdChangeState)
.where(MdChangeState.md_path == md_path)
.where(MdChangeState.status == "processing")
.where(MdChangeState.status == ChangeStatus.PROCESSING)
.values(
status="failed",
status=ChangeStatus.FAILED,
retryable=retryable,
last_attempt_at=now,
error=error,
Expand All @@ -319,8 +320,8 @@ async def recover_orphan_processing(self) -> int:
async with session_scope(self._factory) as s:
result = await s.execute(
update(MdChangeState)
.where(MdChangeState.status == "processing")
.values(status="pending", last_attempt_at=None)
.where(MdChangeState.status == ChangeStatus.PROCESSING)
.values(status=ChangeStatus.PENDING, last_attempt_at=None)
)
await s.commit()
return int(result.rowcount or 0)
Expand All @@ -338,7 +339,7 @@ async def list_failed(self) -> list[MdChangeState]:
(
await s.execute(
select(MdChangeState)
.where(MdChangeState.status == "failed")
.where(MdChangeState.status == ChangeStatus.FAILED)
.order_by(MdChangeState.lsn)
)
)
Expand All @@ -361,10 +362,10 @@ async def reset_retryable_to_pending(self) -> int:
async with session_scope(self._factory) as s:
result = await s.execute(
update(MdChangeState)
.where(MdChangeState.status == "failed")
.where(MdChangeState.status == ChangeStatus.FAILED)
.where(MdChangeState.retryable.is_(True))
.values(
status="pending",
status=ChangeStatus.PENDING,
retryable=None,
retry_count=0,
error=None,
Expand All @@ -378,17 +379,20 @@ async def queue_summary(self) -> QueueSummary:
"""Aggregate the table for the ``cascade status`` CLI."""
async with session_scope(self._factory) as s:
pending = await _count_where(
s, MdChangeState.status.in_(["pending", "processing"])
s,
MdChangeState.status.in_(
[ChangeStatus.PENDING, ChangeStatus.PROCESSING]
),
)
done = await _count_where(s, MdChangeState.status == "done")
done = await _count_where(s, MdChangeState.status == ChangeStatus.DONE)
failed_retryable = await _count_where(
s,
(MdChangeState.status == "failed")
(MdChangeState.status == ChangeStatus.FAILED)
& (MdChangeState.retryable.is_(True)),
)
failed_permanent = await _count_where(
s,
(MdChangeState.status == "failed")
(MdChangeState.status == ChangeStatus.FAILED)
& (MdChangeState.retryable.is_(False)),
)
max_lsn_stmt = select(func.coalesce(func.max(MdChangeState.lsn), 0))
Expand All @@ -397,7 +401,9 @@ async def queue_summary(self) -> QueueSummary:
(
await s.execute(
select(func.coalesce(func.max(MdChangeState.lsn), 0)).where(
MdChangeState.status.in_(["done", "failed"])
MdChangeState.status.in_(
[ChangeStatus.DONE, ChangeStatus.FAILED]
)
)
)
).scalar_one()
Expand Down
Loading
Loading