remaind 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. remaind/__init__.py +3 -0
  2. remaind/__main__.py +10 -0
  3. remaind/_artifacts.py +53 -0
  4. remaind/_atomic.py +91 -0
  5. remaind/_compaction.py +59 -0
  6. remaind/_compactor.py +158 -0
  7. remaind/_configs.py +21 -0
  8. remaind/_db.py +327 -0
  9. remaind/_events.py +217 -0
  10. remaind/_handover.py +32 -0
  11. remaind/_handover_writer.py +41 -0
  12. remaind/_jsonl.py +32 -0
  13. remaind/_paths.py +109 -0
  14. remaind/_redaction.py +83 -0
  15. remaind/_resume_gate.py +125 -0
  16. remaind/_resume_packet.py +284 -0
  17. remaind/_rollback.py +86 -0
  18. remaind/_schemas.py +39 -0
  19. remaind/_state_writer.py +80 -0
  20. remaind/_templates/CONTRACT.md +113 -0
  21. remaind/_templates/README.md +46 -0
  22. remaind/_templates/active/handover.md +26 -0
  23. remaind/_templates/schemas/event.schema.json +118 -0
  24. remaind/_templates/schemas/memory.schema.json +76 -0
  25. remaind/_templates/schemas/redaction.yaml +29 -0
  26. remaind/_templates/schemas/state.schema.json +102 -0
  27. remaind/_templates/schemas/thresholds.yaml +30 -0
  28. remaind/_templates/schemas/tools.yaml +14 -0
  29. remaind/_templates/schemas/validation.schema.json +39 -0
  30. remaind/_thresholds.py +74 -0
  31. remaind/_timestamps.py +24 -0
  32. remaind/_tokens.py +43 -0
  33. remaind/_ulid.py +60 -0
  34. remaind/_validator.py +165 -0
  35. remaind/cli.py +248 -0
  36. remaind/commands/__init__.py +1 -0
  37. remaind/commands/compact.py +177 -0
  38. remaind/commands/init.py +181 -0
  39. remaind/commands/resume.py +86 -0
  40. remaind/commands/rollback.py +143 -0
  41. remaind/commands/status.py +130 -0
  42. remaind/commands/validate.py +193 -0
  43. remaind/migrations/__init__.py +23 -0
  44. remaind/migrations/adapter.py +60 -0
  45. remaind/migrations/protocol.py +43 -0
  46. remaind/migrations/runner.py +60 -0
  47. remaind-0.1.0.dist-info/METADATA +174 -0
  48. remaind-0.1.0.dist-info/RECORD +51 -0
  49. remaind-0.1.0.dist-info/WHEEL +4 -0
  50. remaind-0.1.0.dist-info/entry_points.txt +2 -0
  51. remaind-0.1.0.dist-info/licenses/LICENSE +21 -0
remaind/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Remaind — durable context for long-running agents."""
2
+
3
+ __version__ = "0.1.0"
remaind/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Allow `python -m remaind ...` to invoke the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
remaind/_artifacts.py ADDED
@@ -0,0 +1,53 @@
1
+ """Content-addressed artifact store under ``.context/artifacts/``.
2
+
3
+ Used by the event writer when a single payload exceeds the inline byte
4
+ threshold (text) or is binary. Filenames are ``{sha256}.{ext}`` so identical
5
+ content deduplicates automatically and the on-disk name is verifiable from
6
+ the recorded ``content_hash``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class StoredArtifact:
18
+ path: Path
19
+ sha256: str
20
+ bytes: int
21
+
22
+
23
+ class ArtifactStore:
24
+ def __init__(self, root: Path) -> None:
25
+ self._root = root
26
+
27
+ @property
28
+ def root(self) -> Path:
29
+ return self._root
30
+
31
+ def _ensure_root(self) -> None:
32
+ self._root.mkdir(parents=True, exist_ok=True)
33
+
34
+ def write_text(self, text: str) -> StoredArtifact:
35
+ data = text.encode("utf-8")
36
+ return self._write(data, suffix="txt")
37
+
38
+ def write_bytes(self, data: bytes) -> StoredArtifact:
39
+ if not isinstance(data, (bytes, bytearray)):
40
+ raise TypeError(
41
+ f"write_bytes expected bytes-like, got {type(data).__name__}"
42
+ )
43
+ return self._write(bytes(data), suffix="bin")
44
+
45
+ def _write(self, data: bytes, *, suffix: str) -> StoredArtifact:
46
+ digest = hashlib.sha256(data).hexdigest()
47
+ self._ensure_root()
48
+ path = self._root / f"{digest}.{suffix}"
49
+ if not path.exists():
50
+ tmp = path.with_suffix(path.suffix + ".tmp")
51
+ tmp.write_bytes(data)
52
+ tmp.replace(path)
53
+ return StoredArtifact(path=path, sha256=digest, bytes=len(data))
remaind/_atomic.py ADDED
@@ -0,0 +1,91 @@
1
+ """Atomic file writer with optional history snapshots.
2
+
3
+ Implements the contract from `CONTRACT.md` §18:
4
+
5
+ 1. If the target exists and ``history_dir`` is supplied, copy the current
6
+ contents to ``history_dir/<microsecond-timestamp><ext>`` and fsync.
7
+ 2. Write the new contents to ``<target>.tmp`` + flush + fsync.
8
+ 3. Rename ``<target>.tmp`` over ``<target>``.
9
+ 4. fsync the parent directory where supported.
10
+
11
+ Failures clean up the temp file before re-raising. The original target is
12
+ never partially overwritten — either the new content is fully in place or
13
+ the prior version is still on disk.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from pathlib import Path
20
+
21
+ from ._timestamps import utc_now_filename_safe_us
22
+
23
+
24
+ def _fsync_dir(dirpath: Path) -> None:
25
+ """fsync ``dirpath`` if the platform supports directory fsync."""
26
+ try:
27
+ fd = os.open(str(dirpath), os.O_RDONLY)
28
+ except OSError:
29
+ return
30
+ try:
31
+ os.fsync(fd)
32
+ except OSError:
33
+ # Best-effort on platforms without directory fsync (e.g. Windows).
34
+ pass
35
+ finally:
36
+ os.close(fd)
37
+
38
+
39
+ def atomic_write_text(
40
+ target: Path,
41
+ content: str,
42
+ *,
43
+ history_dir: Path | None = None,
44
+ encoding: str = "utf-8",
45
+ ) -> Path | None:
46
+ """Atomically write ``content`` to ``target``.
47
+
48
+ When ``target`` already exists and ``history_dir`` is given, the current
49
+ contents are copied to a timestamped file inside ``history_dir`` before
50
+ the new write proceeds. Returns the history path if one was created,
51
+ else ``None``.
52
+ """
53
+ if not isinstance(content, str):
54
+ raise TypeError(
55
+ f"atomic_write_text requires str content, got {type(content).__name__}"
56
+ )
57
+
58
+ target = Path(target)
59
+ target.parent.mkdir(parents=True, exist_ok=True)
60
+
61
+ history_path: Path | None = None
62
+ if history_dir is not None and target.exists():
63
+ history_dir.mkdir(parents=True, exist_ok=True)
64
+ history_path = history_dir / f"{utc_now_filename_safe_us()}{target.suffix}"
65
+ # Read + write to copy the prior content so we capture the exact
66
+ # bytes that were on disk just before this update.
67
+ prior = target.read_bytes()
68
+ with open(history_path, "wb") as f:
69
+ f.write(prior)
70
+ f.flush()
71
+ os.fsync(f.fileno())
72
+ _fsync_dir(history_dir)
73
+
74
+ tmp = target.with_name(target.name + ".tmp")
75
+ try:
76
+ data = content.encode(encoding)
77
+ with open(tmp, "wb") as f:
78
+ f.write(data)
79
+ f.flush()
80
+ os.fsync(f.fileno())
81
+ os.replace(tmp, target)
82
+ _fsync_dir(target.parent)
83
+ except Exception:
84
+ if tmp.exists():
85
+ try:
86
+ tmp.unlink()
87
+ except OSError:
88
+ pass
89
+ raise
90
+
91
+ return history_path
remaind/_compaction.py ADDED
@@ -0,0 +1,59 @@
1
+ """Compaction-needed status surface.
2
+
3
+ Combines ``estimated_context_tokens`` with the active threshold bands to
4
+ answer two questions every caller cares about:
5
+
6
+ * should compaction happen at all? (``compaction_needed``)
7
+ * is it urgent — i.e. before the next substantial task step?
8
+ (``compaction_urgent``)
9
+
10
+ The four band recommendations come straight from CONTRACT.md §14.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ from ._paths import ContextPaths
20
+ from ._thresholds import Bands, band_for, compute_bands, load_default_profile
21
+
22
+ _RECOMMENDATIONS: dict[str, str] = {
23
+ "normal": "continue — no compaction needed",
24
+ "warning": "mark compaction pending",
25
+ "hard": "compact before the next substantial task step",
26
+ "emergency": "compact immediately before adding context-heavy work",
27
+ }
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class CompactionStatus:
32
+ estimated_tokens: int
33
+ band: str
34
+ bands: Bands
35
+ compaction_needed: bool
36
+ compaction_urgent: bool
37
+ recommendation: str = field(default="")
38
+
39
+
40
+ def compute_compaction_status(state: dict, bands: Bands) -> CompactionStatus:
41
+ """Pure: classify ``state`` against precomputed ``bands``."""
42
+ tokens = int(state.get("estimated_context_tokens", 0))
43
+ band = band_for(tokens, bands)
44
+ return CompactionStatus(
45
+ estimated_tokens=tokens,
46
+ band=band,
47
+ bands=bands,
48
+ compaction_needed=band != "normal",
49
+ compaction_urgent=band in ("hard", "emergency"),
50
+ recommendation=_RECOMMENDATIONS[band],
51
+ )
52
+
53
+
54
+ def compaction_status(base: Path) -> CompactionStatus:
55
+ """Read state + thresholds from ``base/.context`` and classify."""
56
+ paths = ContextPaths.at(Path(base))
57
+ state = json.loads(paths.state_json.read_text(encoding="utf-8"))
58
+ bands = compute_bands(load_default_profile(paths.thresholds_yaml))
59
+ return compute_compaction_status(state, bands)
remaind/_compactor.py ADDED
@@ -0,0 +1,158 @@
1
+ """Compaction pipeline primitives.
2
+
3
+ This phase delivers the **pipeline** for compaction: source event selection,
4
+ a candidate-generation interface, and a deterministic reference compactor.
5
+ The structured-validation gate that decides whether to accept a candidate
6
+ lands in the next phase.
7
+
8
+ The reference compactor is rule-based and intentionally cheap. It does the
9
+ minimum compaction work: preserves the existing state, rewrites the
10
+ ``## Compaction Notes`` section of the handover with the recent
11
+ high-importance events, and reports which event IDs it preserved. Real
12
+ LLM-driven compaction plugs in via the :class:`Compactor` Protocol later.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ from collections.abc import Iterable
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Protocol
23
+
24
+ from ._jsonl import iter_jsonl
25
+ from ._paths import ContextPaths
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class CompactionSources:
30
+ """The raw material a compactor needs to produce candidate state/handover."""
31
+
32
+ window_events: list[dict] = field(default_factory=list)
33
+ importance_high: list[dict] = field(default_factory=list)
34
+ importance_critical: list[dict] = field(default_factory=list)
35
+ current_state: dict = field(default_factory=dict)
36
+ current_handover: str = ""
37
+ last_compaction_event_id: str | None = None
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class CompactionCandidate:
42
+ """Result of running a compactor.
43
+
44
+ The candidate is just a proposal. A separate validation step (Phase 10)
45
+ decides whether to accept it.
46
+ """
47
+
48
+ new_state: dict
49
+ new_handover: str
50
+ summary: str
51
+ preserved_event_ids: list[str]
52
+ rationale: str
53
+
54
+
55
+ class Compactor(Protocol):
56
+ """Pluggable compactor surface for future LLM-driven plug-ins."""
57
+
58
+ def __call__(self, sources: CompactionSources) -> CompactionCandidate: ...
59
+
60
+
61
+ def select_sources(base: Path) -> CompactionSources:
62
+ """Read state + handover + post-anchor events from ``base/.context``."""
63
+ paths = ContextPaths.at(Path(base))
64
+ state = json.loads(paths.state_json.read_text(encoding="utf-8"))
65
+ handover = paths.handover_md.read_text(encoding="utf-8")
66
+ last_compaction = state.get("last_compaction_event_id")
67
+ last_validation = state.get("last_validation_event_id")
68
+ # A completed compaction cycle writes a compaction event followed by a
69
+ # validation event; both must be excluded from the next window. We use
70
+ # whichever pointer is later as the effective anchor (validation, when
71
+ # both are set, since it's written second).
72
+ anchor: str | None = max(
73
+ (x for x in (last_compaction, last_validation) if x is not None),
74
+ default=None,
75
+ )
76
+
77
+ window: list[dict] = []
78
+ for _lineno, obj, err in iter_jsonl(paths.events_jsonl):
79
+ if err is not None or obj is None:
80
+ continue
81
+ # ULIDs sort lexicographically by time; same length and prefix means
82
+ # a plain string compare is correct.
83
+ if anchor is not None and obj.get("id", "") <= anchor:
84
+ continue
85
+ window.append(obj)
86
+
87
+ high = [e for e in window if int(e.get("importance", 0)) >= 2]
88
+ critical = [e for e in window if int(e.get("importance", 0)) >= 3]
89
+ return CompactionSources(
90
+ window_events=window,
91
+ importance_high=high,
92
+ importance_critical=critical,
93
+ current_state=state,
94
+ current_handover=handover,
95
+ last_compaction_event_id=last_compaction,
96
+ )
97
+
98
+
99
+ _COMPACTION_NOTES_HEADING = "Compaction Notes"
100
+
101
+
102
+ def _render_compaction_notes(high: Iterable[dict]) -> str:
103
+ high = list(high)
104
+ if not high:
105
+ return "- No high-importance events to preserve.\n"
106
+ lines = [f"- Preserved {len(high)} high-importance event(s):"]
107
+ for e in high:
108
+ critical = int(e.get("importance", 0)) >= 3
109
+ marker = " [critical]" if critical else ""
110
+ lines.append(
111
+ f" - `{e['id']}` ({e['type']}, importance={e['importance']})"
112
+ f"{marker}: {e.get('summary', '').strip()}"
113
+ )
114
+ return "\n".join(lines) + "\n"
115
+
116
+
117
+ def _replace_or_append_h2_section(text: str, heading: str, body: str) -> str:
118
+ """Replace an existing H2 ``heading`` block, or append one at the end."""
119
+ pattern = re.compile(
120
+ rf"^##\s+{re.escape(heading)}\s*$.*?(?=^##\s+|\Z)",
121
+ re.MULTILINE | re.DOTALL,
122
+ )
123
+ new_section = f"## {heading}\n\n{body.rstrip()}\n\n"
124
+ if pattern.search(text):
125
+ return pattern.sub(new_section, text)
126
+ return text.rstrip() + "\n\n" + new_section
127
+
128
+
129
+ def default_compact(sources: CompactionSources) -> CompactionCandidate:
130
+ """Deterministic, rule-based reference compactor.
131
+
132
+ Keeps the active state intact and rewrites the handover's
133
+ ``## Compaction Notes`` section so the highest-importance events from
134
+ the window are explicitly summarized in the handover document.
135
+ """
136
+ new_state = dict(sources.current_state)
137
+ notes_body = _render_compaction_notes(sources.importance_high)
138
+ new_handover = _replace_or_append_h2_section(
139
+ sources.current_handover, _COMPACTION_NOTES_HEADING, notes_body
140
+ )
141
+
142
+ preserved_ids = [e["id"] for e in sources.importance_high if "id" in e]
143
+ summary = (
144
+ f"Preserved {len(preserved_ids)} high-importance event(s) "
145
+ f"from {len(sources.window_events)} window event(s)."
146
+ )
147
+ rationale = (
148
+ "default rule-based compactor: state preserved verbatim; handover "
149
+ "'## Compaction Notes' section rewritten to reflect the current "
150
+ "high-importance window."
151
+ )
152
+ return CompactionCandidate(
153
+ new_state=new_state,
154
+ new_handover=new_handover,
155
+ summary=summary,
156
+ preserved_event_ids=preserved_ids,
157
+ rationale=rationale,
158
+ )
remaind/_configs.py ADDED
@@ -0,0 +1,21 @@
1
+ """YAML config loaders for thresholds / redaction / tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+
11
+ def load_yaml_mapping(path: Path) -> dict[str, Any]:
12
+ """Parse ``path`` as YAML, requiring the top level to be a mapping."""
13
+ with path.open("r", encoding="utf-8") as f:
14
+ data = yaml.safe_load(f)
15
+ if data is None:
16
+ return {}
17
+ if not isinstance(data, dict):
18
+ raise ValueError(
19
+ f"{path}: top-level value must be a mapping, got {type(data).__name__}"
20
+ )
21
+ return data