Skip to content

Commit c510121

Browse files
feat(memory-primitive): Phase 1 — doctor framework + entrypoint integration
Adds the memory contract + doctor primitive per ADR-036, with no provider adapters yet (Phase 2 adds hindsight on top of this). Python package (lib/python/agentic_memory/): - contract.py: MemoryContract dataclass + namespace validation + sanitization - doctor.py: 8 Check classes covering env_contract, namespace shape, provider known, adapter exists, config_json valid, backend DNS, backend /health, and delegated provider_specific. CLI surface (--json, --verbose, --fix, --provider/namespace/url overrides). Exit 0 or 1 — no warning tier. - tests/: 53 unit tests covering every check, every contract field, the CLI surface, and the runner. All passing under stdlib pytest. - pyproject.toml declares console_scripts entry agentic-memory-doctor. Workspace integration: - providers/workspaces/claude-cli/memory/doctor: bash entry that exec's the Python module via /opt/venv's python. - entrypoint.sh: new sections 5.6 (adapter init) and 5.7 (doctor preflight). Both no-op when AGENTIC_MEMORY_PROVIDER is unset. When the provider IS set: 5.6 sources /opt/agentic/memory/<p>/ init.sh; 5.7 runs the doctor with --json appended to /var/agentic/memory-doctor/<YYYY-MM-DD>.jsonl, hard-fails the container on non-zero exit. - Dockerfile: COPY memory/ /opt/agentic/memory/, create the audit dir at /var/agentic/memory-doctor/, set permissions on init.sh and doctor.sh files for all providers. Build system: - scripts/build-provider.py: adds stage_memory() function and agentic_memory to required_packages. Both wheels now built and staged into the image at /tmp/packages/*.whl for uv pip install. Verified via `uv run scripts/build-provider.py claude-cli --stage-only`: both wheels build cleanly, memory/ adapter directory copied to build context, no errors. Phase 2 (hindsight adapter) adds providers/workspaces/claude-cli/ memory/hindsight/{init.sh,doctor.sh}. Phase 3 wires this into agentic-domain-runner.
1 parent 4213184 commit c510121

13 files changed

Lines changed: 1616 additions & 2 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# agentic-memory
2+
3+
Memory primitive contract + doctor for `agentic-workspace-claude-cli`.
4+
Implements [ADR-036](../../../docs/adrs/036-memory-primitive-and-doctor.md)
5+
and the matching [design spec](../../../docs/superpowers/specs/2026-05-13-memory-primitive-and-doctor-design.md).
6+
7+
## Contract
8+
9+
Three required env vars from the host:
10+
11+
- `AGENTIC_MEMORY_PROVIDER` — provider name (`hindsight`, `lossless-claw`, `none`, …)
12+
- `AGENTIC_MEMORY_NAMESPACE` — host-supplied scope identifier
13+
- `AGENTIC_MEMORY_URL` — base URL of provider backend, reachable from container
14+
15+
Three optional:
16+
17+
- `AGENTIC_MEMORY_NAMESPACE_KIND``task` | `domain` | `workflow` | `user` | `session` | `project` | `custom`
18+
- `AGENTIC_MEMORY_AUTH` — provider-specific token
19+
- `AGENTIC_MEMORY_CONFIG_JSON` — adapter-specific config (JSON, escape hatch)
20+
21+
Setting `AGENTIC_MEMORY_PROVIDER` is the user's opt-in to memory; opting in is
22+
opting into hard-fail on misconfiguration. There is no soft-fail mode.
23+
24+
## Doctor
25+
26+
CLI at `/opt/agentic/memory/doctor` (also available as `agentic-memory-doctor`
27+
on the Python path).
28+
29+
```sh
30+
agentic-memory-doctor # full preflight; pretty output, exit 0 or 1
31+
agentic-memory-doctor --json # JSON to stdout, pretty to stderr
32+
agentic-memory-doctor --fix --apply # auto-correct client-side issues
33+
agentic-memory-doctor --verbose # extra diagnostics
34+
agentic-memory-doctor --provider hindsight # override provider for testing
35+
```
36+
37+
Exit codes:
38+
39+
- `0` — all checks pass
40+
- `1` — one or more checks failed
41+
42+
## Standard checks
43+
44+
1. `env_contract` — required env vars set
45+
2. `namespace_well_formed` — namespace matches `[a-zA-Z0-9._:-]+`
46+
3. `provider_known` — provider exists under `/opt/agentic/memory/`
47+
4. `adapter_exists``<provider>/init.sh` is an executable file
48+
5. `config_json_valid``AGENTIC_MEMORY_CONFIG_JSON` parses (when set)
49+
6. `backend_dns``AGENTIC_MEMORY_URL` hostname resolves
50+
7. `backend_health``GET <url>/health` returns 200
51+
8. `provider_specific` — delegated to `<provider>/doctor.sh`
52+
53+
## Audit log
54+
55+
When the doctor is invoked from the workspace entrypoint (section 5.7), the
56+
JSON output is appended to `/var/agentic/memory-doctor/YYYY-MM-DD.jsonl`. The
57+
orchestrator is expected to bind-mount this directory from the host so logs
58+
survive container teardown.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""agentic-memory: memory primitive contract + doctor for agentic-primitives workspaces.
2+
3+
See ADR-036 and 2026-05-13-memory-primitive-and-doctor-design.md.
4+
5+
This module is intentionally lazy — submodules are imported on demand to
6+
avoid the `RuntimeWarning: found in sys.modules` issue when running
7+
`python -m agentic_memory.doctor`.
8+
"""
9+
10+
__version__ = "0.1.0"
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Memory contract — env-var parsing and validation.
2+
3+
The memory contract is six env vars (three required, three optional). This
4+
module parses them into a `MemoryContract` dataclass and validates the
5+
namespace shape.
6+
7+
See spec: docs/superpowers/specs/2026-05-13-memory-primitive-and-doctor-design.md
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import json
13+
import os
14+
import re
15+
from dataclasses import dataclass, field
16+
from enum import Enum
17+
18+
19+
NAMESPACE_PATTERN = re.compile(r"^[a-zA-Z0-9._:-]+$")
20+
"""Allowed characters in AGENTIC_MEMORY_NAMESPACE — letters, digits, dot,
21+
underscore, colon, hyphen. No spaces, no slashes, no shell metacharacters."""
22+
23+
24+
class NamespaceKind(str, Enum):
25+
"""Semantic hint about what an AGENTIC_MEMORY_NAMESPACE represents.
26+
27+
Adapters MAY use this to influence bank-id prefixing, log labels, etc.
28+
Adapters MUST NOT change isolation semantics based on this value —
29+
namespace isolation is always per `AGENTIC_MEMORY_NAMESPACE` regardless
30+
of kind.
31+
"""
32+
33+
TASK = "task"
34+
DOMAIN = "domain"
35+
WORKFLOW = "workflow"
36+
USER = "user"
37+
SESSION = "session"
38+
PROJECT = "project"
39+
CUSTOM = "custom"
40+
41+
@classmethod
42+
def parse(cls, value: str | None) -> "NamespaceKind":
43+
if not value:
44+
return cls.TASK
45+
try:
46+
return cls(value.lower())
47+
except ValueError:
48+
return cls.CUSTOM
49+
50+
51+
@dataclass(frozen=True)
52+
class MemoryContract:
53+
"""Parsed AGENTIC_MEMORY_* env vars.
54+
55+
Use `MemoryContract.from_env()` to construct. The contract is intentionally
56+
immutable — once parsed, it's a value object that can be passed around.
57+
Adapters that need to mutate downstream state should produce new env vars
58+
in the process's environment, not rewrite this object.
59+
"""
60+
61+
provider: str
62+
namespace: str
63+
url: str | None
64+
namespace_kind: NamespaceKind = NamespaceKind.TASK
65+
auth: str | None = None
66+
config_json: str | None = None
67+
config_dict: dict | None = field(default=None, compare=False)
68+
69+
@classmethod
70+
def from_env(cls, env: dict[str, str] | None = None) -> "MemoryContract | None":
71+
"""Parse contract from env vars. Returns None if AGENTIC_MEMORY_PROVIDER
72+
is unset or set to 'none' — i.e. the contract has not been opted into.
73+
74+
Does NOT validate completeness — that's the doctor's job. This just
75+
produces the parsed value; missing required vars surface as empty
76+
strings / None and the doctor reports them.
77+
"""
78+
e = env if env is not None else os.environ
79+
80+
provider = e.get("AGENTIC_MEMORY_PROVIDER", "").strip()
81+
if not provider or provider.lower() == "none":
82+
return None
83+
84+
config_json = e.get("AGENTIC_MEMORY_CONFIG_JSON")
85+
config_dict: dict | None = None
86+
if config_json:
87+
try:
88+
parsed = json.loads(config_json)
89+
if isinstance(parsed, dict):
90+
config_dict = parsed
91+
except (json.JSONDecodeError, TypeError):
92+
config_dict = None # doctor's `config_json_valid` check will catch this
93+
94+
return cls(
95+
provider=provider,
96+
namespace=e.get("AGENTIC_MEMORY_NAMESPACE", "").strip(),
97+
url=e.get("AGENTIC_MEMORY_URL", "").strip() or None,
98+
namespace_kind=NamespaceKind.parse(e.get("AGENTIC_MEMORY_NAMESPACE_KIND")),
99+
auth=e.get("AGENTIC_MEMORY_AUTH") or None,
100+
config_json=config_json,
101+
config_dict=config_dict,
102+
)
103+
104+
105+
def is_namespace_well_formed(namespace: str) -> bool:
106+
"""True if the namespace string contains only allowed characters and is
107+
non-empty. See NAMESPACE_PATTERN."""
108+
return bool(namespace) and bool(NAMESPACE_PATTERN.match(namespace))
109+
110+
111+
def sanitize_namespace(namespace: str) -> str:
112+
"""Replace any disallowed characters with hyphens, collapse runs, strip
113+
leading/trailing hyphens. Returns 'unnamed' if the result is empty."""
114+
cleaned = re.sub(r"[^a-zA-Z0-9._:-]+", "-", namespace).strip("-")
115+
return cleaned or "unnamed"

0 commit comments

Comments
 (0)