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.
- remaind/__init__.py +3 -0
- remaind/__main__.py +10 -0
- remaind/_artifacts.py +53 -0
- remaind/_atomic.py +91 -0
- remaind/_compaction.py +59 -0
- remaind/_compactor.py +158 -0
- remaind/_configs.py +21 -0
- remaind/_db.py +327 -0
- remaind/_events.py +217 -0
- remaind/_handover.py +32 -0
- remaind/_handover_writer.py +41 -0
- remaind/_jsonl.py +32 -0
- remaind/_paths.py +109 -0
- remaind/_redaction.py +83 -0
- remaind/_resume_gate.py +125 -0
- remaind/_resume_packet.py +284 -0
- remaind/_rollback.py +86 -0
- remaind/_schemas.py +39 -0
- remaind/_state_writer.py +80 -0
- remaind/_templates/CONTRACT.md +113 -0
- remaind/_templates/README.md +46 -0
- remaind/_templates/active/handover.md +26 -0
- remaind/_templates/schemas/event.schema.json +118 -0
- remaind/_templates/schemas/memory.schema.json +76 -0
- remaind/_templates/schemas/redaction.yaml +29 -0
- remaind/_templates/schemas/state.schema.json +102 -0
- remaind/_templates/schemas/thresholds.yaml +30 -0
- remaind/_templates/schemas/tools.yaml +14 -0
- remaind/_templates/schemas/validation.schema.json +39 -0
- remaind/_thresholds.py +74 -0
- remaind/_timestamps.py +24 -0
- remaind/_tokens.py +43 -0
- remaind/_ulid.py +60 -0
- remaind/_validator.py +165 -0
- remaind/cli.py +248 -0
- remaind/commands/__init__.py +1 -0
- remaind/commands/compact.py +177 -0
- remaind/commands/init.py +181 -0
- remaind/commands/resume.py +86 -0
- remaind/commands/rollback.py +143 -0
- remaind/commands/status.py +130 -0
- remaind/commands/validate.py +193 -0
- remaind/migrations/__init__.py +23 -0
- remaind/migrations/adapter.py +60 -0
- remaind/migrations/protocol.py +43 -0
- remaind/migrations/runner.py +60 -0
- remaind-0.1.0.dist-info/METADATA +174 -0
- remaind-0.1.0.dist-info/RECORD +51 -0
- remaind-0.1.0.dist-info/WHEEL +4 -0
- remaind-0.1.0.dist-info/entry_points.txt +2 -0
- remaind-0.1.0.dist-info/licenses/LICENSE +21 -0
remaind/__init__.py
ADDED
remaind/__main__.py
ADDED
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
|