Skip to content
Open
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
2 changes: 2 additions & 0 deletions shepherd/packages/contexts/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ mcp = "shepherd_contexts.mcp:MCPServerContext"
database = "shepherd_contexts.database:DatabaseContext"
kvstore = "shepherd_contexts.kvstore:KVStoreContext"
appstore = "shepherd_contexts.appstore:AppStoreContext"
memory = "shepherd_contexts.memory:MemoryContext"

[project.entry-points."shepherd.effects"]
workspace = "shepherd_contexts.workspace.effects"
Expand All @@ -47,6 +48,7 @@ kvstore = "shepherd_contexts.kvstore.effects"
mcp = "shepherd_contexts.mcp.effects"
appstore = "shepherd_contexts.appstore.effects"
database = "shepherd_contexts.database.effects"
memory = "shepherd_contexts.memory.effects"

[dependency-groups]
test = [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Advisory memory context for Shepherd.

Surfaces cross-run memory into a task's system prompt as a *logged* effect
(:class:`MemoryRecalled`), so what influenced a run is auditable in the trace.
Memory is advisory-only — it never enters the effect-replay fold or justifies a
release. Backends are pluggable via the :class:`MemoryBackend` protocol;
:class:`InMemoryBackend` is the deterministic, dependency-free default.

Concrete backends that talk to an external memory substrate live out-of-tree:
implement :class:`MemoryBackend` and pass an instance to
``MemoryContext.create(...)``.

Quick Start
-----------
from shepherd_contexts.memory import (
InMemoryBackend,
MemoryContext,
MemoryHint,
)

backend = InMemoryBackend([MemoryHint(title="auth", content="use setup-token")])
memory = MemoryContext.create(backend, query="claude auth", project="shepherd")

The write path is out-of-band: at settlement (select/discard) or on TaskFailed,
build observations from the trace via :func:`observations_from_effects` and
persist them through ``backend.save(...)``.
"""

from __future__ import annotations

from shepherd_contexts.memory.backend import (
InMemoryBackend,
MemoryBackend,
)
from shepherd_contexts.memory.context import MemoryContext
from shepherd_contexts.memory.effects import MemoryRecalled
from shepherd_contexts.memory.types import MemoryHint, MemoryObservation
from shepherd_contexts.memory.write import observations_from_effects

__all__ = [
"InMemoryBackend",
"MemoryBackend",
"MemoryContext",
"MemoryHint",
"MemoryObservation",
"MemoryRecalled",
"observations_from_effects",
]
103 changes: 103 additions & 0 deletions shepherd/packages/contexts/src/shepherd_contexts/memory/backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Pluggable memory backends for :class:`~shepherd_contexts.memory.context.MemoryContext`.

The :class:`MemoryBackend` protocol is the seam: Shepherd knows how to *surface*
and *log* recalled memory; a backend decides where it comes from.
``InMemoryBackend`` is the deterministic default (no external dependencies,
ideal for tests).

This module ships only the generic, dependency-free SPI and a reference
in-memory backend. Concrete backends that talk to an external memory substrate
(e.g. a durable cross-session store) are provided out-of-tree — implement the
:class:`MemoryBackend` protocol and pass an instance to
``MemoryContext.create(...)``.
"""
from __future__ import annotations

from typing import TYPE_CHECKING, Protocol, runtime_checkable

if TYPE_CHECKING:
from .types import MemoryHint, MemoryObservation


@runtime_checkable
class MemoryBackend(Protocol):
"""Read/write SPI for an advisory memory substrate."""

name: str

def recall(
self,
query: str,
*,
project: str | None = None,
n: int = 5,
) -> list[MemoryHint]:
"""Return up to ``n`` advisory hints relevant to ``query``.

Must be total: never raise. A backend that cannot answer returns ``[]``.
"""
...

def save(self, observation: MemoryObservation) -> str | None:
"""Persist a memory-worthy observation; return its id, or ``None``.

Must be total: never raise. Out-of-band only — callers must have already
settled the run (select/release/discard) or confirmed a TaskFailed.
"""
...


class InMemoryBackend:
"""Deterministic in-process backend. The default; ideal for tests.

``recall`` does naive case-insensitive substring matching of the query
against hint title+content, returning the top-``n`` matches (stable order).
``save`` appends to the in-process store and returns a synthetic id.
"""

def __init__(self, hints: list[MemoryHint] | None = None) -> None:
self._hints: list[MemoryHint] = list(hints or [])
self._saved: list[MemoryObservation] = []
self._counter = 0

@property
def name(self) -> str:
return "memory"

def recall(
self,
query: str,
*,
project: str | None = None,
n: int = 5,
) -> list[MemoryHint]:
del project # the naive backend does not partition by project
if not query.strip():
# No query: surface the most recent hints (recency-ish, stable).
return list(self._hints[-n:])
needle = query.lower()
terms = needle.split()
scored: list[tuple[int, int, MemoryHint]] = []
for idx, hint in enumerate(self._hints):
hay = (hint.title + " " + hint.content).lower()
hits = sum(1 for term in terms if term in hay)
if hits:
scored.append((hits, -idx, hint)) # more hits first, then earlier
scored.sort(key=lambda t: (t[0], t[1]), reverse=True)
return [h for _, _, h in scored[:n]]

def save(self, observation: MemoryObservation) -> str | None:
self._counter += 1
obs_id = f"inmem-{self._counter}"
self._saved.append(observation)
return obs_id

# Test/diagnostics helpers -------------------------------------------------

@property
def saved(self) -> list[MemoryObservation]:
"""Observations written via ``save`` (test inspection)."""
return list(self._saved)


__all__ = ["InMemoryBackend", "MemoryBackend"]
Loading