Federated Forge — Sub-Plan 01: Memoir Foundation¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the Forge Memoir library — a typed, append-only JSONL data layer where every entry is simultaneously a narrative beat for the visual novel and a labeled training tuple for the federated learning pipeline. This sub-plan ships the library only; host integration ships in plan-02.
Architecture: A new kourai_common.federation subpackage holds two modules: memoir_schema.py defines pydantic models for entries and the gameplay-rule split decision (which entries are shared_eligible vs. private_only); memoir.py provides a Memoir class with append-only writer and iterating reader, scoped to one ForgeSession. The library is pure — no host code, no agent code, no FL code touches it yet.
Tech Stack: Python 3.12, pydantic v2 (already transitively pulled via a2a-sdk; promoted to explicit dep in this plan), pytest 9, hatchling build backend.
Spec reference: docs/research/federated-forge/index.md — see the Forge Memoir section for the schema, the shared/personal split table for the gameplay rules, and Phase 1 of the phasing list for scope.
File Structure¶
Create:
shared/src/kourai_common/federation/__init__.py— empty subpackage markershared/src/kourai_common/federation/memoir_schema.py— pydantic models (MemoirEntry,PlayerResponse,TrainingLabel,SplitDecision) plus the puredecide_split()function applying the gameplay rules from the specshared/src/kourai_common/federation/memoir.py—Memoirreader/writer scoped to one ForgeSession, JSONL append-only on disktests/unit/test_memoir_schema.py— schema validation, split-decision rule coveragetests/unit/test_memoir.py— round-trip writer/reader, file-on-disk behavior, error handling
Modify:
shared/pyproject.toml— promote pydantic from transitive to explicit dep at>=2.0,<3.0
No host code is touched in this plan. Plan-02 will wire the CLI host to write Memoir entries; plan-03 covers GUI; plan-04 covers VN.
Task 1: Promote pydantic to an explicit dependency¶
Files:
-
Modify:
shared/pyproject.toml -
Step 1: Read current dependencies block
Run: head -25 shared/pyproject.toml
Expected output: [project] block with a dependencies = [...] list including a2a-sdk, litellm, httpx, mcp, opentelemetry-*, huggingface-hub. Pydantic is NOT listed (it's transitive via a2a-sdk).
- Step 2: Add pydantic to the dependencies list
In shared/pyproject.toml, the dependencies = [ list currently ends with "huggingface-hub>=1.5.0",. Insert this line just before the closing ]:
The full edited block should now end:
- Step 3: Re-resolve the lockfile
Run: uv sync
Expected output: Resolved N packages (lockfile updated; the install step is a no-op because pydantic is already present transitively at v2.12.5).
- Step 4: Confirm import still works
Run: uv run python -c "import pydantic; print(pydantic.VERSION)"
Expected output: 2.12.5 (or any 2.x).
- Step 5: Commit
git add shared/pyproject.toml uv.lock
git commit -m "deps(kourai-common): promote pydantic to explicit dep for Forge Memoir schema"
Task 2: Create the federation subpackage marker¶
Files:
-
Create:
shared/src/kourai_common/federation/__init__.py -
Step 1: Create the subpackage directory and empty
__init__.py
The file should be empty. Public API will be defined later when plan-02+ adds adapters and trainers; for now the library is internal.
- Step 2: Verify it imports
Run: uv run python -c "import kourai_common.federation; print('ok')"
Expected output: ok
- Step 3: Commit
git add shared/src/kourai_common/federation/__init__.py
git commit -m "feat(kourai-common): scaffold kourai_common.federation subpackage"
Task 3: Define the SplitDecision dataclass with failing tests¶
Files:
- Create:
tests/unit/test_memoir_schema.py -
Create:
shared/src/kourai_common/federation/memoir_schema.py -
Step 1: Write the failing test for SplitDecision
Create tests/unit/test_memoir_schema.py with this content:
"""Schema models and gameplay-rule split logic for the Forge Memoir."""
from __future__ import annotations
import pytest
from kourai_common.federation.memoir_schema import SplitDecision
class TestSplitDecision:
"""SplitDecision is a frozen pydantic model with two booleans and an
invariant that they cannot both be true."""
def test_shared_eligible_only(self):
d = SplitDecision(shared_eligible=True, private_only=False)
assert d.shared_eligible is True
assert d.private_only is False
def test_private_only_only(self):
d = SplitDecision(shared_eligible=False, private_only=True)
assert d.shared_eligible is False
assert d.private_only is True
def test_neither_set_is_valid(self):
# Used for entries that are still being constructed.
d = SplitDecision(shared_eligible=False, private_only=False)
assert d.shared_eligible is False
assert d.private_only is False
def test_both_true_is_rejected(self):
with pytest.raises(ValueError, match="cannot both be true"):
SplitDecision(shared_eligible=True, private_only=True)
- Step 2: Run the test to verify it fails
Run: uv run pytest tests/unit/test_memoir_schema.py -v
Expected: FAIL with ModuleNotFoundError: No module named 'kourai_common.federation.memoir_schema'
- Step 3: Implement SplitDecision
Create shared/src/kourai_common/federation/memoir_schema.py with this content:
"""Pydantic schemas for Forge Memoir entries.
Each Memoir entry has two faces — a narrative beat the visual novel can
replay, and a training tuple the federated-learning pipeline can consume.
The `SplitDecision` records which side of the council/bond split an entry
falls on. The `decide_split()` pure function applies the gameplay rules
from `docs/research/federated-forge/index.md`.
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, model_validator
class SplitDecision(BaseModel):
"""Whether a Memoir entry contributes to the federated council adapter,
stays in the local bond adapter only, or has not been decided yet.
Invariant: `shared_eligible` and `private_only` cannot both be true.
Both false is valid for entries still under construction.
"""
model_config = ConfigDict(frozen=True)
shared_eligible: bool = False
private_only: bool = False
@model_validator(mode="after")
def _at_most_one_true(self) -> SplitDecision:
if self.shared_eligible and self.private_only:
raise ValueError(
"shared_eligible and private_only cannot both be true"
)
return self
- Step 4: Run the test to verify it passes
Run: uv run pytest tests/unit/test_memoir_schema.py -v
Expected: 4 passed
- Step 5: Commit
git add tests/unit/test_memoir_schema.py shared/src/kourai_common/federation/memoir_schema.py
git commit -m "feat(memoir): add SplitDecision schema with at-most-one-true invariant"
Task 4: Define decide_split() with one test per gameplay rule row¶
Files:
- Modify:
tests/unit/test_memoir_schema.py - Modify:
shared/src/kourai_common/federation/memoir_schema.py
The spec's gameplay-rule table has these rows:
| Row | Source | Result |
|---|---|---|
| 1 | Specialist's proposed output | shared_eligible=True |
| 2 | Player's revised version (the diff) | private_only=True |
| 3 | Federating-agent interrupt | shared_eligible=True |
| 4 | Cupid or Puck interrupt | private_only=True |
| 5 | Inter-agent disagreement resolution | shared_eligible=True |
| 6 | Cupid scene of any kind | private_only=True |
| 7 | Puck tutorial / minigame / engagement | private_only=True |
| 8 | Affinity gain or loss | private_only=True |
| 9 | Player profile / memory_moments / relationship state | private_only=True |
| 10 | Raw forge transcript | private_only=True |
| 11 | Player's task description (free text) | private_only=True |
decide_split() is a pure function over a small enum of source types.
- Step 1: Add the failing tests for decide_split
Append to tests/unit/test_memoir_schema.py:
from kourai_common.federation.memoir_schema import (
EntrySource,
decide_split,
)
class TestDecideSplit:
"""Pure function applying the gameplay rules from the design spec."""
def test_specialist_proposed_output_is_shared(self):
d = decide_split(EntrySource.SPECIALIST_PROPOSED, agent="kallos")
assert d.shared_eligible is True
assert d.private_only is False
def test_player_revision_is_private(self):
d = decide_split(EntrySource.PLAYER_REVISION, agent="kallos")
assert d.shared_eligible is False
assert d.private_only is True
def test_federating_agent_interrupt_is_shared(self):
d = decide_split(EntrySource.AGENT_INTERRUPT, agent="aidos")
assert d.shared_eligible is True
assert d.private_only is False
def test_cupid_interrupt_is_private(self):
d = decide_split(EntrySource.AGENT_INTERRUPT, agent="cupid")
assert d.private_only is True
assert d.shared_eligible is False
def test_puck_interrupt_is_private(self):
d = decide_split(EntrySource.AGENT_INTERRUPT, agent="puck")
assert d.private_only is True
assert d.shared_eligible is False
def test_disagreement_resolution_is_shared(self):
d = decide_split(EntrySource.DISAGREEMENT_RESOLUTION, agent="hephaestus")
assert d.shared_eligible is True
assert d.private_only is False
def test_cupid_scene_is_private(self):
d = decide_split(EntrySource.CUPID_SCENE, agent="cupid")
assert d.private_only is True
def test_puck_engagement_is_private(self):
d = decide_split(EntrySource.PUCK_ENGAGEMENT, agent="puck")
assert d.private_only is True
def test_affinity_change_is_private(self):
d = decide_split(EntrySource.AFFINITY_CHANGE, agent="cupid")
assert d.private_only is True
def test_player_profile_is_private(self):
d = decide_split(EntrySource.PLAYER_PROFILE, agent=None)
assert d.private_only is True
def test_raw_transcript_is_private(self):
d = decide_split(EntrySource.RAW_TRANSCRIPT, agent=None)
assert d.private_only is True
def test_task_description_is_private(self):
d = decide_split(EntrySource.TASK_DESCRIPTION, agent=None)
assert d.private_only is True
def test_unknown_agent_for_interrupt_raises(self):
with pytest.raises(ValueError, match="unknown agent"):
decide_split(EntrySource.AGENT_INTERRUPT, agent="nobody")
- Step 2: Run the tests to verify they fail
Run: uv run pytest tests/unit/test_memoir_schema.py::TestDecideSplit -v
Expected: 13 failures with ImportError: cannot import name 'EntrySource'
- Step 3: Implement EntrySource and decide_split
Append to shared/src/kourai_common/federation/memoir_schema.py:
from enum import StrEnum
class EntrySource(StrEnum):
"""Where a Memoir entry came from. The split decision is keyed off this
enum and (for interrupts) the originating agent."""
SPECIALIST_PROPOSED = "specialist_proposed"
PLAYER_REVISION = "player_revision"
AGENT_INTERRUPT = "agent_interrupt"
DISAGREEMENT_RESOLUTION = "disagreement_resolution"
CUPID_SCENE = "cupid_scene"
PUCK_ENGAGEMENT = "puck_engagement"
AFFINITY_CHANGE = "affinity_change"
PLAYER_PROFILE = "player_profile"
RAW_TRANSCRIPT = "raw_transcript"
TASK_DESCRIPTION = "task_description"
# Agents that have a council adapter. Cupid and Puck are bond-only by
# construction (see spec, Goals/Non-goals section).
FEDERATING_AGENTS: frozenset[str] = frozenset(
{
"hephaestus",
"metis",
"techne",
"dokimasia",
"kallos",
"mneme",
"aidos",
"aletheia",
}
)
BOND_ONLY_AGENTS: frozenset[str] = frozenset({"cupid", "puck"})
ALL_AGENTS: frozenset[str] = FEDERATING_AGENTS | BOND_ONLY_AGENTS
def decide_split(source: EntrySource, *, agent: str | None) -> SplitDecision:
"""Apply the gameplay rules from the design spec.
Patterns leave the forge; instances do not. Federating agents'
proposed outputs and interrupts are shared-eligible; everything
keyed to the player or the relationship layer is private-only.
"""
if source in {
EntrySource.PLAYER_REVISION,
EntrySource.CUPID_SCENE,
EntrySource.PUCK_ENGAGEMENT,
EntrySource.AFFINITY_CHANGE,
EntrySource.PLAYER_PROFILE,
EntrySource.RAW_TRANSCRIPT,
EntrySource.TASK_DESCRIPTION,
}:
return SplitDecision(shared_eligible=False, private_only=True)
if source in {
EntrySource.SPECIALIST_PROPOSED,
EntrySource.DISAGREEMENT_RESOLUTION,
}:
return SplitDecision(shared_eligible=True, private_only=False)
if source is EntrySource.AGENT_INTERRUPT:
if agent is None or agent not in ALL_AGENTS:
raise ValueError(f"unknown agent {agent!r} for AGENT_INTERRUPT")
if agent in BOND_ONLY_AGENTS:
return SplitDecision(shared_eligible=False, private_only=True)
return SplitDecision(shared_eligible=True, private_only=False)
raise ValueError(f"unhandled EntrySource {source!r}")
- Step 4: Run the tests to verify they pass
Run: uv run pytest tests/unit/test_memoir_schema.py -v
Expected: 17 passed (4 from Task 3 + 13 from Task 4)
- Step 5: Commit
git add tests/unit/test_memoir_schema.py shared/src/kourai_common/federation/memoir_schema.py
git commit -m "feat(memoir): add decide_split() pure function applying gameplay-rule table"
Task 5: Define MemoirEntry with auto-decided split¶
Files:
- Modify:
tests/unit/test_memoir_schema.py - Modify:
shared/src/kourai_common/federation/memoir_schema.py
MemoirEntry is the wire/disk schema. Each entry knows its source, populates SplitDecision automatically, and carries the dual-face contract: narrative_beat for the VN replay, training_label for the FL pipeline.
This sub-plan only ships the pipeline_turn and agent_interrupt shapes; richer shapes (council_event, loyalty_beat) come in later sub-plans.
- Step 1: Add the failing tests for MemoirEntry
Append to tests/unit/test_memoir_schema.py:
from kourai_common.federation.memoir_schema import (
MemoirEntry,
PlayerResponse,
TrainingLabel,
)
class TestMemoirEntry:
"""The on-disk schema. Each entry carries narrative + training payloads
and auto-populates its SplitDecision from EntrySource + agent."""
def test_minimal_specialist_proposal_is_shared(self):
entry = MemoirEntry(
scene_id="session-1.turn-1",
agent="kallos",
source=EntrySource.SPECIALIST_PROPOSED,
agent_proposed="some style edit",
)
assert entry.split.shared_eligible is True
assert entry.split.private_only is False
def test_cupid_scene_is_private(self):
entry = MemoirEntry(
scene_id="session-1.scene-cupid-7",
agent="cupid",
source=EntrySource.CUPID_SCENE,
narrative_beat="cupid_late_night_check_in",
)
assert entry.split.private_only is True
def test_pipeline_turn_with_player_response(self):
entry = MemoirEntry(
scene_id="session-1.turn-2",
agent="kallos",
source=EntrySource.SPECIALIST_PROPOSED,
agent_proposed="lint fix",
player_response=PlayerResponse(
kind="modified", delta="player edits", felt="right"
),
training_label=TrainingLabel(
preference_pair=[
{"text": "lint fix", "score": 0},
{"text": "lint fix with player edits", "score": 1},
],
weight=1.0,
),
)
assert entry.player_response.kind == "modified"
assert entry.training_label.weight == 1.0
def test_unknown_agent_rejected(self):
with pytest.raises(ValueError, match="unknown agent"):
MemoirEntry(
scene_id="session-1.turn-3",
agent="nobody",
source=EntrySource.SPECIALIST_PROPOSED,
agent_proposed="x",
)
def test_round_trip_json(self):
original = MemoirEntry(
scene_id="session-1.turn-1",
agent="kallos",
source=EntrySource.SPECIALIST_PROPOSED,
agent_proposed="hello",
)
as_json = original.model_dump_json()
restored = MemoirEntry.model_validate_json(as_json)
assert restored == original
def test_optional_context_block_round_trips(self):
# Spec shows a `context` block with task_type, transcript_hash,
# preceding_agents. We accept any dict for forward compatibility;
# plan-02 will lock in the host-emitted shape.
original = MemoirEntry(
scene_id="session-1.turn-1",
agent="kallos",
source=EntrySource.SPECIALIST_PROPOSED,
agent_proposed="hello",
context={
"task_type": "style_review",
"transcript_hash": "sha256:abc",
"preceding_agents": ["techne"],
},
)
restored = MemoirEntry.model_validate_json(original.model_dump_json())
assert restored.context["task_type"] == "style_review"
assert restored.context["preceding_agents"] == ["techne"]
- Step 2: Run the tests to verify they fail
Run: uv run pytest tests/unit/test_memoir_schema.py::TestMemoirEntry -v
Expected: 5 failures with ImportError: cannot import name 'MemoirEntry'
- Step 3: Implement MemoirEntry, PlayerResponse, TrainingLabel
Append to shared/src/kourai_common/federation/memoir_schema.py:
from typing import Any, Literal
class PlayerResponse(BaseModel):
"""How the player responded to an agent's proposal.
`kind` is the high-level reaction; `delta` carries free-form payload
(a diff, a comment, etc); `felt` is the optional Likert-style affect
tag the host may choose to gather.
"""
model_config = ConfigDict(frozen=True)
kind: Literal["accepted", "modified", "rejected", "deferred"]
delta: str | None = None
felt: Literal["right", "off", "unsure"] | None = None
class TrainingLabel(BaseModel):
"""The FL-pipeline view of an entry. `preference_pair` is two scored
candidates the trainer can consume as a DPO pair; `weight` lets the
host emphasize or downweight specific entries (e.g. interrupted turns
weigh less than fully-completed ones).
"""
model_config = ConfigDict(frozen=True)
preference_pair: list[dict[str, Any]] | None = None
weight: float = 1.0
class MemoirEntry(BaseModel):
"""One Memoir entry on disk and on the wire.
Fields are split into:
- **identity / context** — `scene_id`, `agent`, `source`,
`narrative_beat`
- **narrative payload** — `agent_proposed`, `player_response`,
`affinity_delta`
- **training payload** — `training_label`
- **derived** — `split`, decided automatically from `source` + `agent`
via `decide_split()` if not provided
The dual-face contract: every entry can be replayed by the VN via
`narrative_beat`, AND consumed by the FL pipeline via `training_label`.
"""
model_config = ConfigDict(frozen=True)
scene_id: str
agent: str
source: EntrySource
context: dict[str, Any] | None = None
narrative_beat: str | None = None
agent_proposed: str | None = None
player_response: PlayerResponse | None = None
affinity_delta: float = 0.0
training_label: TrainingLabel | None = None
split: SplitDecision = SplitDecision()
@model_validator(mode="after")
def _populate_split(self) -> MemoirEntry:
if self.split.shared_eligible or self.split.private_only:
return self # caller provided one explicitly; trust them
decided = decide_split(self.source, agent=self.agent)
# Pydantic frozen models need object.__setattr__ to mutate after init.
object.__setattr__(self, "split", decided)
return self
- Step 4: Run the tests to verify they pass
Run: uv run pytest tests/unit/test_memoir_schema.py -v
Expected: 23 passed (17 from prior tasks + 6 new — including the optional-context round-trip)
- Step 5: Commit
git add tests/unit/test_memoir_schema.py shared/src/kourai_common/federation/memoir_schema.py
git commit -m "feat(memoir): add MemoirEntry, PlayerResponse, TrainingLabel with auto-split"
Task 6: Build the append-only Memoir writer¶
Files:
- Create:
tests/unit/test_memoir.py - Create:
shared/src/kourai_common/federation/memoir.py
The writer takes a directory path (typically forge_session.workdir), opens memoir.jsonl for appending, and writes one JSON-encoded entry per line. Append-only by contract; no concurrent-writer guarantees in this iteration (a single host owns one Memoir at a time).
- Step 1: Write the failing tests for Memoir.append
Create tests/unit/test_memoir.py with this content:
"""Memoir reader/writer scoped to one ForgeSession workdir."""
from __future__ import annotations
import pytest
from kourai_common.federation.memoir import Memoir, MemoirError
from kourai_common.federation.memoir_schema import (
EntrySource,
MemoirEntry,
)
class TestMemoirAppend:
"""Append-only writes produce one JSON-encoded line per entry."""
def test_append_creates_file(self, tmp_path):
memoir = Memoir(tmp_path)
entry = MemoirEntry(
scene_id="s1.t1",
agent="kallos",
source=EntrySource.SPECIALIST_PROPOSED,
agent_proposed="x",
)
memoir.append(entry)
assert (tmp_path / "memoir.jsonl").exists()
def test_append_writes_one_line_per_entry(self, tmp_path):
memoir = Memoir(tmp_path)
for i in range(3):
memoir.append(
MemoirEntry(
scene_id=f"s1.t{i}",
agent="kallos",
source=EntrySource.SPECIALIST_PROPOSED,
agent_proposed=f"x{i}",
)
)
contents = (tmp_path / "memoir.jsonl").read_text()
lines = contents.strip().split("\n")
assert len(lines) == 3
def test_append_does_not_truncate(self, tmp_path):
m1 = Memoir(tmp_path)
m1.append(
MemoirEntry(
scene_id="s1.t1",
agent="kallos",
source=EntrySource.SPECIALIST_PROPOSED,
agent_proposed="first",
)
)
m2 = Memoir(tmp_path)
m2.append(
MemoirEntry(
scene_id="s1.t2",
agent="kallos",
source=EntrySource.SPECIALIST_PROPOSED,
agent_proposed="second",
)
)
contents = (tmp_path / "memoir.jsonl").read_text()
assert "first" in contents
assert "second" in contents
def test_append_to_missing_directory_raises(self, tmp_path):
bogus = tmp_path / "does_not_exist"
memoir = Memoir(bogus)
with pytest.raises(MemoirError, match="not a directory"):
memoir.append(
MemoirEntry(
scene_id="s1.t1",
agent="kallos",
source=EntrySource.SPECIALIST_PROPOSED,
agent_proposed="x",
)
)
- Step 2: Run the tests to verify they fail
Run: uv run pytest tests/unit/test_memoir.py -v
Expected: 4 failures with ModuleNotFoundError: No module named 'kourai_common.federation.memoir'
- Step 3: Implement the Memoir class with append
Create shared/src/kourai_common/federation/memoir.py with this content:
"""Forge Memoir reader/writer.
The Memoir is the canonical record of one ForgeSession — a JSONL file
where each line is a `MemoirEntry`. Every entry has two faces: a
narrative beat the visual novel can replay, and a training tuple the
federated-learning pipeline can consume.
Append-only by contract. One host owns one Memoir at a time; no
cross-process coordination is provided in this iteration.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterator
from kourai_common.federation.memoir_schema import MemoirEntry
MEMOIR_FILENAME = "memoir.jsonl"
class MemoirError(Exception):
"""Raised on Memoir read/write failures."""
class Memoir:
"""Reader/writer for one ForgeSession's memoir.jsonl file."""
def __init__(self, workdir: Path) -> None:
self.workdir = Path(workdir)
self.path = self.workdir / MEMOIR_FILENAME
def append(self, entry: MemoirEntry) -> None:
if not self.workdir.is_dir():
raise MemoirError(f"workdir {self.workdir} is not a directory")
with self.path.open("a", encoding="utf-8") as f:
f.write(entry.model_dump_json())
f.write("\n")
- Step 4: Run the tests to verify they pass
Run: uv run pytest tests/unit/test_memoir.py -v
Expected: 4 passed
- Step 5: Commit
git add tests/unit/test_memoir.py shared/src/kourai_common/federation/memoir.py
git commit -m "feat(memoir): add append-only JSONL writer scoped to ForgeSession workdir"
Task 7: Build the iterating Memoir reader¶
Files:
- Modify:
tests/unit/test_memoir.py - Modify:
shared/src/kourai_common/federation/memoir.py
The reader iterates entries from disk in append order. Used by replay tooling, the bond trainer (next sub-plan), and the council trainer (later sub-plan).
- Step 1: Write the failing tests for Memoir.entries()
Append to tests/unit/test_memoir.py:
class TestMemoirRead:
"""`entries()` yields one MemoirEntry per JSONL line, preserving order."""
def test_empty_memoir_yields_nothing(self, tmp_path):
memoir = Memoir(tmp_path)
assert list(memoir.entries()) == []
def test_round_trip_preserves_order(self, tmp_path):
memoir = Memoir(tmp_path)
originals = [
MemoirEntry(
scene_id=f"s1.t{i}",
agent="kallos",
source=EntrySource.SPECIALIST_PROPOSED,
agent_proposed=f"x{i}",
)
for i in range(5)
]
for entry in originals:
memoir.append(entry)
restored = list(memoir.entries())
assert restored == originals
def test_round_trip_preserves_split(self, tmp_path):
memoir = Memoir(tmp_path)
cupid_entry = MemoirEntry(
scene_id="s1.cupid",
agent="cupid",
source=EntrySource.CUPID_SCENE,
narrative_beat="cupid_check_in",
)
kallos_entry = MemoirEntry(
scene_id="s1.kallos",
agent="kallos",
source=EntrySource.SPECIALIST_PROPOSED,
agent_proposed="lint fix",
)
memoir.append(cupid_entry)
memoir.append(kallos_entry)
restored = list(memoir.entries())
assert restored[0].split.private_only is True
assert restored[1].split.shared_eligible is True
def test_malformed_line_raises(self, tmp_path):
memoir = Memoir(tmp_path)
memoir.path.write_text("not valid json\n")
with pytest.raises(MemoirError, match="malformed"):
list(memoir.entries())
- Step 2: Run the tests to verify they fail
Run: uv run pytest tests/unit/test_memoir.py::TestMemoirRead -v
Expected: 4 failures with AttributeError: 'Memoir' object has no attribute 'entries'
- Step 3: Implement Memoir.entries()
Edit shared/src/kourai_common/federation/memoir.py. Add the import at the top (alongside the existing imports):
Then add this method to the Memoir class (after append):
def entries(self) -> Iterator[MemoirEntry]:
"""Yield each entry from the on-disk file in append order.
Returns an empty iterator if the file does not exist yet.
"""
from kourai_common.federation.memoir_schema import MemoirEntry
if not self.path.exists():
return
with self.path.open("r", encoding="utf-8") as f:
for line_number, line in enumerate(f, start=1):
stripped = line.strip()
if not stripped:
continue
try:
yield MemoirEntry.model_validate_json(stripped)
except (json.JSONDecodeError, ValueError) as e:
raise MemoirError(
f"malformed entry on line {line_number}: {e}"
) from e
- Step 4: Run the tests to verify they pass
Run: uv run pytest tests/unit/test_memoir.py -v
Expected: 8 passed (4 from Task 6 + 4 new)
- Step 5: Commit
git add tests/unit/test_memoir.py shared/src/kourai_common/federation/memoir.py
git commit -m "feat(memoir): add Memoir.entries() iterator over append-order JSONL"
Task 8: Run the full kourai-common unit suite to confirm no regressions¶
Files: none modified
- Step 1: Run the full unit test directory
Run: uv run pytest tests/unit -v
Expected: All previously-passing tests still pass; 23 new tests in test_memoir_schema.py; 8 new tests in test_memoir.py. Total new: 31. Pre-existing test count is preserved.
- Step 2: Run ruff and ty over the new files
Run: uv run ruff check shared/src/kourai_common/federation/ tests/unit/test_memoir.py tests/unit/test_memoir_schema.py
Expected: All checks passed! or empty output.
Run: uv run ty check shared/src/kourai_common/federation/
Expected: no diagnostics.
If either reports issues, fix them inline and re-run.
- Step 3: Confirm the federation subpackage is now properly importable
Run: uv run python -c "from kourai_common.federation.memoir import Memoir; from kourai_common.federation.memoir_schema import MemoirEntry, EntrySource, decide_split; print('ok')"
Expected: ok
- Step 4: No commit needed (this task is a verification gate)
What this plan ships¶
kourai_common.federation.memoir_schema—EntrySource,SplitDecision,PlayerResponse,TrainingLabel,MemoirEntry,decide_split,FEDERATING_AGENTS,BOND_ONLY_AGENTS,ALL_AGENTSkourai_common.federation.memoir—Memoir,MemoirError,MEMOIR_FILENAME- 30 new unit tests, all passing
- pydantic promoted to explicit dep
- Zero host or agent code changed
What's deferred to follow-up plans¶
- Plan-02 — CLI host integration (CLI emits Memoir entries on every agent turn and player response)
- Plan-03 — GUI host integration
- Plan-04 — VN host integration
- Plan-05 —
kourai-dev memoirinspection CLI command (replay tooling) - Plan-06 — interrupt entry shape and the concurrent interrupt channel itself
- Plan-07 — bond adapter scaffolding and local trainer (consumes the Memoir)
- Later plans — vFL LoRA-FAIR strategy, federation client, DP, Council scenes, Byzantine simulation, online preference learning ablation, evaluation pipeline
Each follow-up plan stands alone and has clear dependencies on this one (and on each other).