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
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,30 @@ jobs:
fi
echo "dist/ OK — $(find dist -type f | wc -l) files produced"

test-python:
name: Python SDK tests (Python ${{ matrix.python }})
# Parsing-layer tests only — no server, no Postgres. Runs in seconds and
# guards the SDK against server response fields it does not yet declare.
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
# Floor and ceiling of the range pyproject.toml claims to support.
python: ["3.10", "3.13"]
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
cache: pip
cache-dependency-path: python/pyproject.toml
- run: pip install -e ".[dev]"
- run: pytest -q

test-security:
name: Security tests (Node ${{ matrix.node }})
runs-on: ubuntu-latest
Expand Down
3 changes: 2 additions & 1 deletion python/memforge/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from .types import (
AddResult, QueryResult, ConsolidateResult, ClearResult, AgentStats,
MemoryHealth, ResumeContext, FeedbackResult, SleepCycleResult,
ReflectionResult, MemoryHints,
ReflectionResult, MemoryHints, from_response,
)

__version__ = "0.1.0"
Expand All @@ -41,4 +41,5 @@
"FeedbackResult",
"SleepCycleResult",
"ReflectionResult",
"from_response",
]
22 changes: 11 additions & 11 deletions python/memforge/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from .types import (
AddResult, QueryResult, ConsolidateResult, ClearResult, AgentStats,
MemoryHealth, ResumeContext, FeedbackResult, SleepCycleResult,
ReflectionResult, MemoryHints,
ReflectionResult, MemoryHints, from_response,
)


Expand Down Expand Up @@ -112,7 +112,7 @@ async def add(
if session_id:
body["session_id"] = session_id
raw = await self._post(f"/memory/{agent_id}/add", body)
return AddResult(**{k: raw[k] for k in ("id", "agent_id", "created_at") if k in raw})
return from_response(AddResult, raw)

async def query(
self,
Expand Down Expand Up @@ -155,7 +155,7 @@ async def query(
if explain:
params["explain"] = "true"
raw = await self._get(f"/memory/{agent_id}/query", params)
return [QueryResult(**r) for r in raw] if isinstance(raw, list) else []
return [from_response(QueryResult, r) for r in raw] if isinstance(raw, list) else []

async def timeline(
self,
Expand Down Expand Up @@ -196,20 +196,20 @@ async def consolidate(
if target_namespace:
body["target_namespace"] = target_namespace
raw = await self._post(f"/memory/{agent_id}/consolidate", body)
return ConsolidateResult(**raw)
return from_response(ConsolidateResult, raw)

async def clear(self, agent_id: str) -> ClearResult:
"""Archive all hot+warm memory to cold tier."""
raw = await self._post(f"/memory/{agent_id}/clear")
return ClearResult(**raw)
return from_response(ClearResult, raw)

async def stats(self, agent_id: str, namespace: str | None = None) -> AgentStats:
"""Get memory tier statistics."""
params: dict[str, Any] = {}
if namespace:
params["namespace"] = namespace
raw = await self._get(f"/memory/{agent_id}/stats", params or None)
return AgentStats(**raw)
return from_response(AgentStats, raw)

# ── Knowledge Graph ──────────────────────────────────────────────────

Expand All @@ -235,7 +235,7 @@ async def reflect(
) -> ReflectionResult:
"""Trigger LLM reflection on recent memories."""
raw = await self._post(f"/memory/{agent_id}/reflect", {"trigger": trigger, "limit": limit})
return ReflectionResult(**raw)
return from_response(ReflectionResult, raw)

async def get_reflections(self, agent_id: str, limit: int = 10) -> list[dict[str, Any]]:
"""Retrieve stored reflections."""
Expand Down Expand Up @@ -276,12 +276,12 @@ async def sleep(
if include_reflection is not None:
body["includeReflection"] = include_reflection
raw = await self._post(f"/memory/{agent_id}/sleep", body)
return SleepCycleResult(**raw)
return from_response(SleepCycleResult, raw)

async def memory_health(self, agent_id: str) -> MemoryHealth:
"""Get memory health metrics."""
raw = await self._get(f"/memory/{agent_id}/health")
return MemoryHealth(**raw)
return from_response(MemoryHealth, raw)

# ── Epistemic Confidence Model (v3.9) ─────────────────────────────────

Expand Down Expand Up @@ -412,7 +412,7 @@ async def resume(self, agent_id: str, limit: int = 5, namespace: str | None = No
if namespace:
params["namespace"] = namespace
raw = await self._get(f"/memory/{agent_id}/resume", params)
return ResumeContext(**raw)
return from_response(ResumeContext, raw)

# ── Feedback ─────────────────────────────────────────────────────────

Expand All @@ -428,7 +428,7 @@ async def feedback(
if metadata:
body["metadata"] = metadata
raw = await self._post(f"/memory/{agent_id}/feedback", body)
return FeedbackResult(**raw)
return from_response(FeedbackResult, raw)

async def active_recall(self, agent_id: str, context: str, limit: int = 5) -> dict[str, Any]:
"""Proactively surface relevant memories for a context."""
Expand Down
9 changes: 9 additions & 0 deletions python/memforge/resilient.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ class ResilientMemForgeClient:

On failure, returns safe defaults (empty lists, None, zeroed stats)
and optionally calls an ``on_error`` callback.

Note that "failure" here includes *parse* failures, not just transport
ones: a response this SDK cannot deserialize is reported to ``on_error``
and then reads to the caller as an empty result, indistinguishable from
the server having no data. That masking is how issue #161 stayed hidden
— every query against a v3.8+ server returned ``[]`` rather than
raising. It is deliberately left as-is here; narrowing the caught
exceptions is a behavior change for every existing caller and belongs in
its own change.
"""

def __init__(
Expand Down
88 changes: 85 additions & 3 deletions python/memforge/types.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,82 @@
"""MemForge Python SDK — type definitions."""
"""MemForge Python SDK — type definitions.

The server grows its response payloads in minor releases: a memory row that
carried 8 keys in v3.7 carries 12 in v3.12. Two rules keep this SDK usable
across that drift, and both are load-bearing:

1. Every response dataclass is built through :func:`from_response`, which
drops keys the dataclass does not declare. Without it a single new server
field raises ``TypeError`` on *every* call to the affected endpoint —
which is exactly how issue #161 shipped.
2. Every field the server added after the dataclass was first written carries
a default, so an SDK running against an *older* server still parses. The
defaults are chosen to match what the server's omission means, not merely
to be falsy — see the per-field notes below.

Together these make dataclass and server independently upgradable in either
direction. Filtering alone would silently discard real data; adding fields
alone would leave the next server release to break callers again.
"""

from __future__ import annotations

import dataclasses
from dataclasses import dataclass, field
from typing import Any, Optional
from typing import Any, Mapping, Optional, TypeVar

T = TypeVar("T")


def from_response(cls: type[T], raw: Mapping[str, Any]) -> T:
"""Build a response dataclass from a server payload, ignoring unknown keys.

The server adds response fields in minor releases; an SDK that rejects
them breaks every caller on upgrade. Keys absent from ``raw`` fall back
to the dataclass default, which is how this SDK stays compatible with
servers older than itself.
"""
known = {f.name for f in dataclasses.fields(cls)} # type: ignore[arg-type]
return cls(**{k: v for k, v in raw.items() if k in known})


@dataclass
class AddResult:
id: int
agent_id: str
created_at: str
# Only emitted when the write collapsed onto an existing hot-tier row.
deduplicated: bool = False


@dataclass
class QueryResult:
id: int
content: str
summary: Optional[str]
metadata: dict[str, Any]
consolidated_at: str
time_start: Optional[str]
time_end: Optional[str]
rank: float

# `summary` is only populated under LLM consolidation, and shared-pool
# rows omit the key entirely, so it cannot be a required argument.
summary: Optional[str] = None

# v3.8 — sentiment/urgency/session_type merged from contributing hot rows.
# Absent means "no signals recorded", which is what an empty dict says.
context_signals: dict[str, Any] = field(default_factory=dict)

# v3.9 — calibrated uncertainty. None means the server did not report a
# status; it is not the same as any of the five status values.
epistemic_status: Optional[str] = None
# v3.9 — corroborating retrievals. The server's floor is 1, so 0 would be
# a lie; None means "not reported".
evidence_count: Optional[int] = None

# v3.10 — per-result rank factors, present only when query(explain=True).
# None distinguishes "not requested" from "requested, no factors".
explanation: Optional[list[dict[str, Any]]] = None


@dataclass
class ConsolidateResult:
Expand Down Expand Up @@ -54,6 +107,10 @@ class AgentStats:
last_consolidation: Optional[str]
last_seen: Optional[str]

# v3.4 — warm rows awaiting re-embedding under the current model. Omitted
# when embeddings are disabled, where the answer is "unknown", not zero.
stale_embedding_count: Optional[int] = None


@dataclass
class MemoryHealth:
Expand All @@ -68,6 +125,12 @@ class MemoryHealth:
retrieval_count_24h: int
contradiction_rate: float

# v2.6 — staleness and knowledge-gap tracking. Always sent by v2.6+
# servers; the zero defaults cover pre-v2.6 ones.
stale_memory_count: int = 0
avg_staleness: float = 0.0
knowledge_gap_count_7d: int = 0


@dataclass
class ResumeContext:
Expand Down Expand Up @@ -100,6 +163,25 @@ class SleepCycleResult:
tokens_used: int
duration_ms: int

# Counters the engine always emits. Zero is the correct reading both when
# the phase did no work and when the server predates the phase.
phase5b_cold_purged: int = 0
schemas_detected: int = 0
conflicts_resolved: int = 0
audit_records_archived: int = 0

# Counters the engine emits only when non-zero — it assigns each key
# post-hoc behind `if counter > 0`, so an omitted key means exactly 0.
capacity_evicted: int = 0
temporal_expired: int = 0
procedures_evolved: int = 0
embeddings_migrated: int = 0
embeddings_migration_backlog: int = 0
deprecated_decayed: int = 0
epistemic_promoted: int = 0 # v3.9 — Sleep Phase 5.12
causal_edges_updated: int = 0 # v3.10 — Sleep Phase 6.1
principles_extracted: int = 0 # v3.11 — Sleep Phase 5.11


@dataclass
class ReflectionResult:
Expand Down
Loading