Files
2026-07-01 17:56:31 +08:00

1233 lines
42 KiB
Python

"""File-based collaboration substrate (`comms`).
Provides the runtime mailbox for inter-role messaging. Each project/session gets a
`comms/` subtree under its workspace; inside it, `inbox/`, `handoffs/`
and `meetings/` are siblings, each holding per-role subdirectories.
Layout::
<workspace>/.opc-comms/<project_id>/<session_id>/
├── inbox/
│ ├── ceo/
│ │ ├── new/ # unread messages addressed to ceo
│ │ ├── seen/ # archived after broker / agent marks them read
│ │ └── outbox/ # mirror of messages ceo has sent (audit + dedupe)
│ ├── cto/
│ └── ...
├── meetings/
│ └── <meeting_id>/
│ ├── manifest.yaml
│ └── transcript.md
└── ../_shared/
└── team_memory/
└── TEAM_MEMORY.md
Design principles:
* Each message is its own file. There is no shared file the producer
and consumer have to lock — atomicity comes from `os.rename()` from
a per-role `.tmp/` directory into `new/`.
* Marking-as-read is `mv new/x.md seen/x.md`, also atomic.
* Blocking messages (`blocking: true` in frontmatter) carry a marker
the broker watches for; the reactivation scheduler treats them as
"stop sender, run receiver" instead of "let sender keep going".
* Convergence is *prompt-driven*, not enforced by hard counters: the
prompt rules tell agents to only reply when they need confirmation
/ changes, so the natural fixed point is "no new messages → no new
turns". A `followup_round` counter is recorded as anomaly telemetry
but never used as a hard cutoff.
This module is intentionally pure: it does no DB I/O, no async I/O,
no network. Callers (`company_mode`, `external_broker`, the engine
scheduler) wire it into the lifecycle.
"""
from __future__ import annotations
import contextlib
import json
import os
import re
import shutil
import tempfile
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from opc.layer4_tools.output_budget import clip_text
try:
import fcntl # POSIX only — used for transcript append locking
except Exception: # pragma: no cover - Windows fallback
fcntl = None # type: ignore
try:
import yaml # type: ignore
except Exception: # pragma: no cover - yaml is in core deps but stay defensive
yaml = None # type: ignore
# ──────────────────────────────────────────────────────────────────────
# Constants
# ──────────────────────────────────────────────────────────────────────
COMMS_ROOT_NAME = ".opc-comms"
INBOX_DIRNAME = "inbox"
MEETINGS_DIRNAME = "meetings"
SHARED_DIRNAME = "_shared"
TEAM_MEMORY_DIRNAME = "team_memory"
TEAM_MEMORY_ENTRYPOINT = "TEAM_MEMORY.md"
_TEAM_MEMORY_SCAFFOLD = (
"# Team Memory\n\n"
"Use this notebook for durable shared team state:\n"
"- Current conclusions\n"
"- Active risks\n"
"- Decisions\n"
"- Open questions\n"
"- Important constraints\n"
)
SCRATCHPAD_ENTRYPOINT = "scratchpad.md"
NEW_DIRNAME = "new"
SEEN_DIRNAME = "seen"
OUTBOX_DIRNAME = "outbox"
TMP_DIRNAME = ".tmp"
_FRONTMATTER_RE = re.compile(
r"^---\s*\n(?P<front>.*?)\n---\s*\n?(?P<body>.*)$",
re.DOTALL,
)
# Filename: <iso_ts>__<from>__<to>__<short_uuid>.md
_FILENAME_TEMPLATE = "{ts}__{from_role}__{to_role}__{uid}.md"
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
# ──────────────────────────────────────────────────────────────────────
# Dataclasses
# ──────────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class CommsLayout:
"""Resolved comms paths for a (workspace, project, session) triple.
All directories are absolute. The layout object is cheap to
construct and immutable; create one per (project, session) inside
each function that needs it rather than caching across requests.
"""
workspace_root: Path
project_id: str
session_id: str
root: Path
inbox_root: Path
meetings_root: Path
shared_root: Path
def role_inbox(self, role_id: str) -> Path:
return self.inbox_root / _safe(role_id)
def role_new_dir(self, role_id: str) -> Path:
return self.role_inbox(role_id) / NEW_DIRNAME
def role_seen_dir(self, role_id: str) -> Path:
return self.role_inbox(role_id) / SEEN_DIRNAME
def role_outbox_dir(self, role_id: str) -> Path:
return self.role_inbox(role_id) / OUTBOX_DIRNAME
def role_tmp_dir(self, role_id: str) -> Path:
return self.role_inbox(role_id) / TMP_DIRNAME
def meeting_dir(self, meeting_id: str) -> Path:
return self.meetings_root / _safe(meeting_id)
@property
def team_memory_root(self) -> Path:
return self.shared_root / TEAM_MEMORY_DIRNAME
@property
def team_memory_path(self) -> Path:
return self.team_memory_root / TEAM_MEMORY_ENTRYPOINT
@property
def scratchpad_path(self) -> Path:
return self.shared_root / SCRATCHPAD_ENTRYPOINT
@dataclass
class MessageHeader:
"""Lightweight metadata for a single message file.
Read from frontmatter only — body is loaded on demand. Used for
inbox listings and prompt injection (where we want titles + age,
not bodies).
"""
path: Path
message_id: str
from_role: str
to_role: str
subject: str
sent_at: str
blocking: bool = False
reply_to: str | None = None
priority: str = "normal"
tags: list[str] = field(default_factory=list)
raw_frontmatter: dict[str, Any] = field(default_factory=dict)
# ──────────────────────────────────────────────────────────────────────
# Layout resolution / bootstrap
# ──────────────────────────────────────────────────────────────────────
def resolve_layout(
workspace_root: str | Path,
project_id: str,
session_id: str,
) -> CommsLayout:
"""Compute the comms layout paths for a workspace + project + session.
Does NOT create directories — call `ensure_layout` for that.
"""
ws = Path(workspace_root).expanduser().resolve()
pid = _safe(project_id or "default")
sid = _safe(session_id or "default")
root = ws / COMMS_ROOT_NAME / pid / sid
return CommsLayout(
workspace_root=ws,
project_id=pid,
session_id=sid,
root=root,
inbox_root=root / INBOX_DIRNAME,
meetings_root=root / MEETINGS_DIRNAME,
# Shared memory is session-scoped so each runtime run keeps its own
# durable team notebook without bleeding conclusions across unrelated
# sessions in the same project.
shared_root=root / SHARED_DIRNAME,
)
def ensure_layout(
layout: CommsLayout,
roles: Iterable[str],
) -> None:
"""Create the directory tree for the given roles, idempotently.
Also (re)writes README.md. Safe to call on every work-item start —
pure mkdir + small file writes.
"""
layout.root.mkdir(parents=True, exist_ok=True)
layout.inbox_root.mkdir(parents=True, exist_ok=True)
layout.meetings_root.mkdir(parents=True, exist_ok=True)
layout.shared_root.mkdir(parents=True, exist_ok=True)
layout.team_memory_root.mkdir(parents=True, exist_ok=True)
for role in roles:
if not role:
continue
layout.role_new_dir(role).mkdir(parents=True, exist_ok=True)
layout.role_seen_dir(role).mkdir(parents=True, exist_ok=True)
layout.role_outbox_dir(role).mkdir(parents=True, exist_ok=True)
layout.role_tmp_dir(role).mkdir(parents=True, exist_ok=True)
_write_readme(layout)
_ensure_team_memory_entrypoint(layout)
# ──────────────────────────────────────────────────────────────────────
# Sending
# ──────────────────────────────────────────────────────────────────────
def send_message(
layout: CommsLayout,
*,
from_role: str,
to_role: str,
subject: str,
body: str,
blocking: bool = False,
reply_to: str | None = None,
priority: str = "normal",
tags: Iterable[str] | None = None,
idempotency_key: str | None = None,
message_id: str | None = None,
sent_at: str | None = None,
extra_frontmatter: dict[str, Any] | None = None,
) -> Path:
"""Atomically deliver a message to `to_role`'s inbox.
Writes to `<from_role outbox>/.tmp/<file>` first, fsync's, then
`os.rename()` into `<to_role inbox>/new/<file>`. Returns the
final path. Also leaves a copy in the sender's outbox for audit
and idempotency-key based deduplication.
`blocking=True` carries a marker the broker uses to decide
whether the sender's task should be parked until the receiver
produces a reply. The receiver-side prompt explains the semantics.
"""
from_role = _safe(from_role)
to_role = _safe(to_role)
if not from_role or not to_role:
raise ValueError("send_message requires both from_role and to_role")
# Make sure target dirs exist even if ensure_layout was never called
# for the target role yet (e.g. msg arrives before that work item starts).
layout.role_new_dir(to_role).mkdir(parents=True, exist_ok=True)
layout.role_outbox_dir(from_role).mkdir(parents=True, exist_ok=True)
layout.role_tmp_dir(from_role).mkdir(parents=True, exist_ok=True)
# Idempotency: if sender already produced a message with this
# key, return the receiver-side path (new/ if still unread, seen/
# if already archived) instead of writing a duplicate. We scan
# the sender's outbox (small per work item) for the key.
if idempotency_key:
existing_outbox = _find_in_outbox(layout, from_role, idempotency_key)
if existing_outbox is not None:
new_candidate = layout.role_new_dir(to_role) / existing_outbox.name
if new_candidate.exists():
return new_candidate
seen_candidate = layout.role_seen_dir(to_role) / existing_outbox.name
if seen_candidate.exists():
return seen_candidate
# Receiver-side file vanished — fall through and re-deliver.
ts = _utc_iso_compact()
short = uuid.uuid4().hex[:8]
resolved_message_id = str(message_id or f"{ts}__{from_role}__{short}").strip()
filename = _FILENAME_TEMPLATE.format(
ts=ts, from_role=from_role, to_role=to_role, uid=short
)
fm: dict[str, Any] = {
"message_id": resolved_message_id,
"from": from_role,
"to": to_role,
"subject": subject or "(no subject)",
"sent_at": str(sent_at or _utc_iso_human()).strip(),
"blocking": bool(blocking),
"priority": priority or "normal",
}
if reply_to:
fm["reply_to"] = str(reply_to)
if tags:
fm["tags"] = list(tags)
if idempotency_key:
fm["idempotency_key"] = str(idempotency_key)
if extra_frontmatter:
for k, v in extra_frontmatter.items():
if k not in fm:
fm[k] = v
payload = _render_message(fm, body)
# Write to sender-side .tmp first (atomic on same filesystem)
tmp_path = layout.role_tmp_dir(from_role) / filename
final_path = layout.role_new_dir(to_role) / filename
outbox_path = layout.role_outbox_dir(from_role) / filename
_atomic_write_text(tmp_path, payload)
# Mirror into sender outbox first so audit survives even if the
# rename to receiver fails.
shutil.copy2(tmp_path, outbox_path)
os.replace(tmp_path, final_path)
return final_path
# ──────────────────────────────────────────────────────────────────────
# Reading
# ──────────────────────────────────────────────────────────────────────
def list_unread(
layout: CommsLayout,
role_id: str,
*,
limit: int | None = None,
) -> list[MessageHeader]:
"""Return frontmatter headers of all messages in `role_id`'s `new/`.
Sorted oldest-first by filename (which begins with an ISO timestamp).
Cheap: only reads the frontmatter section of each file.
"""
role_id = _safe(role_id)
new_dir = layout.role_new_dir(role_id)
if not new_dir.is_dir():
return []
files = sorted(p for p in new_dir.iterdir() if p.is_file() and p.suffix == ".md")
if limit is not None:
files = files[:limit]
headers: list[MessageHeader] = []
for path in files:
try:
header = read_header(path)
except Exception:
continue
if header is not None:
headers.append(header)
return headers
def list_roles(layout: CommsLayout) -> list[str]:
"""Return every role directory currently present under inbox/."""
if not layout.inbox_root.is_dir():
return []
roles: list[str] = []
for child in sorted(layout.inbox_root.iterdir(), key=lambda item: item.name):
if child.is_dir():
roles.append(child.name)
return roles
def list_role_messages(
layout: CommsLayout,
role_id: str,
*,
include_new: bool = True,
include_seen: bool = True,
include_outbox: bool = False,
limit: int | None = None,
) -> list[MessageHeader]:
"""List all known message headers for one role across selected buckets."""
role = _safe(role_id)
buckets: list[Path] = []
if include_new:
buckets.append(layout.role_new_dir(role))
if include_seen:
buckets.append(layout.role_seen_dir(role))
if include_outbox:
buckets.append(layout.role_outbox_dir(role))
paths: list[Path] = []
for bucket in buckets:
if not bucket.is_dir():
continue
try:
paths.extend(
path for path in bucket.iterdir()
if path.is_file() and path.suffix == ".md"
)
except OSError:
continue
paths = sorted(paths, key=lambda item: item.name)
if limit is not None:
paths = paths[:limit]
headers: list[MessageHeader] = []
for path in paths:
header = read_header(path)
if header is not None:
headers.append(header)
return headers
def list_unread_blocking(
layout: CommsLayout,
role_id: str,
) -> list[MessageHeader]:
"""Subset of unread that have `blocking: true` set."""
return [h for h in list_unread(layout, role_id) if h.blocking]
def find_unresolved_blocking_outbox(
layout: CommsLayout,
sender_role: str,
) -> list[MessageHeader]:
"""List blocking messages `sender_role` has sent which still
lack a reply. We consider a blocking message resolved when the
sender's own inbox (new/ or seen/) contains some message whose
`reply_to` field matches the blocking message id.
Returned headers always come from the SENDER's outbox copy.
"""
sender_role = _safe(sender_role)
outbox = layout.role_outbox_dir(sender_role)
if not outbox.is_dir():
return []
blocking: list[MessageHeader] = []
for path in sorted(outbox.iterdir()):
if not path.is_file() or path.suffix != ".md":
continue
h = read_header(path)
if h is None or not h.blocking:
continue
if find_reply_to(layout, sender_role, h.message_id) is not None:
continue
blocking.append(h)
return blocking
def find_reply_to(
layout: CommsLayout,
sender_role: str,
message_id: str,
) -> MessageHeader | None:
"""Search the sender's inbox (new + seen) for any message whose
`reply_to` matches `message_id`. Returns the first match or None.
"""
sender_role = _safe(sender_role)
for sub in (NEW_DIRNAME, SEEN_DIRNAME):
d = layout.role_inbox(sender_role) / sub
if not d.is_dir():
continue
for path in sorted(d.iterdir()):
if not path.is_file() or path.suffix != ".md":
continue
h = read_header(path)
if h is None:
continue
if h.reply_to and str(h.reply_to) == str(message_id):
return h
return None
def has_unread(layout: CommsLayout, role_id: str) -> bool:
"""Cheap existence check — `os.scandir` only, no parsing."""
new_dir = layout.role_new_dir(_safe(role_id))
if not new_dir.is_dir():
return False
try:
with os.scandir(new_dir) as it:
return any(entry.is_file() and entry.name.endswith(".md") for entry in it)
except OSError:
return False
def read_header(path: Path) -> MessageHeader | None:
"""Parse frontmatter only (cheap). Body is not loaded."""
try:
with path.open("r", encoding="utf-8", errors="replace") as f:
chunk = f.read(8192)
except OSError:
return None
fm, _ = _split_frontmatter(chunk)
if fm is None:
return None
return _header_from_frontmatter(path, fm)
def read_message(path: Path) -> tuple[MessageHeader | None, str]:
"""Read full message: (header, body). Returns (None, "") on error."""
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return None, ""
fm, body = _split_frontmatter(text)
if fm is None:
return None, text
return _header_from_frontmatter(path, fm), body
def mark_seen(
layout: CommsLayout,
role_id: str,
paths: Iterable[Path],
) -> list[Path]:
"""Atomically move the given files from `new/` to `seen/`.
Returns the list of resulting paths in `seen/`. Files that no
longer exist (e.g. raced with another reader) are silently skipped.
"""
role_id = _safe(role_id)
seen_dir = layout.role_seen_dir(role_id)
seen_dir.mkdir(parents=True, exist_ok=True)
moved: list[Path] = []
for src in paths:
try:
if not src.exists():
continue
dst = seen_dir / src.name
os.replace(src, dst)
moved.append(dst)
except OSError:
continue
return moved
def mark_all_seen(layout: CommsLayout, role_id: str) -> list[Path]:
new_dir = layout.role_new_dir(_safe(role_id))
if not new_dir.is_dir():
return []
return mark_seen(layout, role_id, list(new_dir.iterdir()))
def read_team_memory(layout: CommsLayout) -> str:
path = layout.team_memory_path
if not path.exists():
_ensure_team_memory_entrypoint(layout)
try:
return path.read_text(encoding="utf-8")
except OSError:
return ""
def read_team_memory_digest(
layout: CommsLayout,
*,
max_chars: int = 1600,
) -> str:
payload = read_team_memory_digest_payload(layout, max_chars=max_chars)
digest = str(payload.get("digest", "") or "").strip()
if not digest:
return ""
meta_lines = [
f"team_memory_path: {payload.get('team_memory_path', '')}",
f"team_memory_truncated: {str(bool(payload.get('team_memory_truncated', False))).lower()}",
f"team_memory_omitted_chars: {int(payload.get('team_memory_omitted_chars', 0) or 0)}",
]
return "\n".join([*meta_lines, "", digest]).rstrip()
def _is_team_memory_scaffold_only(text: str) -> bool:
return "\n".join(str(text or "").strip().split()) == "\n".join(_TEAM_MEMORY_SCAFFOLD.strip().split())
def read_team_memory_digest_payload(
layout: CommsLayout,
*,
max_chars: int = 1600,
) -> dict[str, Any]:
text = read_team_memory(layout).strip()
path = layout.team_memory_path
if not text or _is_team_memory_scaffold_only(text):
return {
"digest": "",
"team_memory_path": str(path),
"team_memory_truncated": False,
"team_memory_omitted_chars": 0,
}
clip = clip_text(
text,
limit=max(1, int(max_chars or 1600)),
marker="team memory preview truncated",
)
return {
"digest": clip.text.rstrip(),
"team_memory_path": str(path),
"team_memory_truncated": clip.truncated,
"team_memory_omitted_chars": clip.omitted_chars,
"team_memory_original_chars": clip.original_chars,
}
# ──────────────────────────────────────────────────────────────────────
# Internals
# ──────────────────────────────────────────────────────────────────────
def _safe(name: str) -> str:
"""Filesystem-safe role/project/session/projection id."""
s = (name or "").strip()
if not s:
return ""
s = _SAFE_NAME_RE.sub("-", s)
return s.strip("-_") or "default"
def _utc_iso_compact() -> str:
# `2026-04-08T20-55-30Z` — sortable filename component, no `:` (Win-safe).
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")
def _utc_iso_human() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _atomic_write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(
prefix=path.name + ".",
suffix=".tmp",
dir=str(path.parent),
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(text)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_name, path)
except Exception:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
def _dump_yaml(data: dict[str, Any]) -> str:
if yaml is not None:
return yaml.safe_dump(
data, sort_keys=False, allow_unicode=True, default_flow_style=False
)
lines: list[str] = []
for k, v in data.items():
if isinstance(v, (list, tuple)):
lines.append(f"{k}:")
for item in v:
lines.append(f" - {item}")
elif isinstance(v, bool):
lines.append(f"{k}: {'true' if v else 'false'}")
elif v is None:
lines.append(f"{k}: null")
else:
lines.append(f"{k}: {v}")
return "\n".join(lines) + "\n"
def _render_message(frontmatter: dict[str, Any], body: str) -> str:
if yaml is not None:
front_text = yaml.safe_dump(
frontmatter,
sort_keys=False,
allow_unicode=True,
default_flow_style=False,
).strip()
else:
# Minimal YAML-ish renderer for primitive scalars
lines = []
for k, v in frontmatter.items():
if isinstance(v, (list, tuple)):
lines.append(f"{k}:")
for item in v:
lines.append(f" - {item}")
elif isinstance(v, bool):
lines.append(f"{k}: {'true' if v else 'false'}")
else:
lines.append(f"{k}: {v}")
front_text = "\n".join(lines)
body_text = (body or "").rstrip("\n")
return f"---\n{front_text}\n---\n\n{body_text}\n"
def _split_frontmatter(text: str) -> tuple[dict[str, Any] | None, str]:
match = _FRONTMATTER_RE.match(text or "")
if not match:
return None, text or ""
front_raw = match.group("front")
body = match.group("body") or ""
fm: dict[str, Any] = {}
if yaml is not None:
try:
loaded = yaml.safe_load(front_raw)
if isinstance(loaded, dict):
fm = loaded
except Exception:
fm = {}
if not fm:
for line in front_raw.splitlines():
if ":" in line and not line.startswith(" "):
k, _, v = line.partition(":")
fm[k.strip()] = v.strip()
return fm, body
def _header_from_frontmatter(path: Path, fm: dict[str, Any]) -> MessageHeader | None:
try:
return MessageHeader(
path=path,
message_id=str(fm.get("message_id") or path.stem),
from_role=str(fm.get("from") or ""),
to_role=str(fm.get("to") or ""),
subject=str(fm.get("subject") or "(no subject)"),
sent_at=str(fm.get("sent_at") or ""),
blocking=bool(fm.get("blocking") or False),
reply_to=(str(fm.get("reply_to")) if fm.get("reply_to") else None),
priority=str(fm.get("priority") or "normal"),
tags=list(fm.get("tags") or []),
raw_frontmatter=dict(fm),
)
except Exception:
return None
def _find_in_outbox(
layout: CommsLayout,
from_role: str,
idempotency_key: str,
) -> Path | None:
outbox = layout.role_outbox_dir(from_role)
if not outbox.is_dir():
return None
for path in outbox.iterdir():
if not path.is_file() or path.suffix != ".md":
continue
header = read_header(path)
if header is None:
continue
if str(header.raw_frontmatter.get("idempotency_key") or "") == str(idempotency_key):
return path
return None
def _ensure_team_memory_entrypoint(layout: CommsLayout) -> None:
path = layout.team_memory_path
if path.exists():
return
_atomic_write_text(path, _TEAM_MEMORY_SCAFFOLD)
# ──────────────────────────────────────────────────────────────────────
# Meetings: multi-party append-only transcripts
# ──────────────────────────────────────────────────────────────────────
@dataclass
class MeetingEntry:
"""A single line in a meeting transcript."""
entry_id: str
author: str
posted_at: str
content: str
raw: dict[str, Any] = field(default_factory=dict)
@dataclass
class MeetingState:
"""Cached view of `meetings/<id>/manifest.yaml` plus a transcript count.
`status` ∈ {"open", "closed"}.
"""
meeting_id: str
topic: str
organizer: str
participants: list[str]
status: str
opened_at: str
closed_at: str | None
decision: str | None
entry_count: int
manifest_path: Path
transcript_path: Path
def start_meeting(
layout: CommsLayout,
*,
meeting_id: str | None,
topic: str,
organizer: str,
participants: Iterable[str],
extra: dict[str, Any] | None = None,
) -> MeetingState:
"""Create a new meeting room.
Returns the freshly-created `MeetingState`. Idempotent only when
`meeting_id` is supplied AND already exists with matching topic;
otherwise a new id is generated.
"""
organizer = _safe(organizer)
participants_list = sorted({_safe(p) for p in participants if p})
if organizer and organizer not in participants_list:
participants_list.insert(0, organizer)
if meeting_id:
mid = _safe(meeting_id)
else:
mid = f"{_utc_iso_compact()}__{organizer or 'org'}__{uuid.uuid4().hex[:6]}"
mdir = layout.meeting_dir(mid)
mdir.mkdir(parents=True, exist_ok=True)
manifest_path = mdir / "manifest.yaml"
transcript_path = mdir / "transcript.md"
if manifest_path.exists():
# Re-load existing one rather than overwriting.
existing = read_meeting_state(layout, mid)
if existing is not None:
return existing
manifest: dict[str, Any] = {
"meeting_id": mid,
"topic": topic or "(no topic)",
"organizer": organizer,
"participants": participants_list,
"status": "open",
"opened_at": _utc_iso_human(),
"closed_at": None,
"decision": None,
}
if extra:
manifest.update({k: v for k, v in extra.items() if k not in manifest})
_atomic_write_text(manifest_path, _dump_yaml(manifest))
# Initialize transcript with a header.
header = (
f"# Meeting `{mid}` — {topic or '(no topic)'}\n"
f"\n"
f"Organizer: `{organizer}`. Participants: "
f"{', '.join(f'`{p}`' for p in participants_list)}.\n"
f"Opened at {manifest['opened_at']}.\n"
f"\n"
f"---\n"
)
if not transcript_path.exists():
_atomic_write_text(transcript_path, header)
state = read_meeting_state(layout, mid)
assert state is not None
return state
def append_to_transcript(
layout: CommsLayout,
*,
meeting_id: str,
author: str,
content: str,
) -> MeetingEntry:
"""Atomically append one line to a meeting transcript.
Uses `fcntl.flock(LOCK_EX)` on POSIX so concurrent participants
can append safely without losing entries. On Windows the call
falls back to a best-effort write — `os.O_APPEND` itself is
atomic for short writes on most filesystems anyway, but the
explicit lock makes the guarantee unconditional on POSIX.
Each entry is rendered as a markdown block with a frontmatter-ish
metadata header so the transcript is both human-readable and
machine-parseable by `read_transcript`.
"""
mid = _safe(meeting_id)
transcript_path = layout.meeting_dir(mid) / "transcript.md"
if not transcript_path.exists():
# Soft-bootstrap: if the meeting room exists but transcript was
# deleted, recreate. If neither exists, raise.
if not layout.meeting_dir(mid).is_dir():
raise FileNotFoundError(f"meeting {mid!r} not found at {transcript_path}")
_atomic_write_text(transcript_path, f"# Meeting `{mid}`\n\n---\n")
author = _safe(author) or "anonymous"
posted_at = _utc_iso_human()
entry_id = f"{_utc_iso_compact()}__{author}__{uuid.uuid4().hex[:6]}"
body = (content or "").rstrip()
# Render as one self-contained block. The leading machine-readable
# marker `<!-- entry: ... -->` makes parsing trivial.
block = (
f"\n<!-- entry: {entry_id} author: {author} posted_at: {posted_at} -->\n"
f"### `{author}` · {posted_at}\n"
f"\n"
f"{body}\n"
)
# Append with exclusive lock when fcntl is available.
fd = os.open(str(transcript_path), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o644)
try:
if fcntl is not None:
try:
fcntl.flock(fd, fcntl.LOCK_EX)
except OSError:
pass
try:
os.write(fd, block.encode("utf-8"))
os.fsync(fd)
finally:
if fcntl is not None:
with contextlib.suppress(OSError):
fcntl.flock(fd, fcntl.LOCK_UN)
finally:
os.close(fd)
return MeetingEntry(
entry_id=entry_id,
author=author,
posted_at=posted_at,
content=body,
)
def read_transcript(
layout: CommsLayout,
meeting_id: str,
) -> list[MeetingEntry]:
"""Parse a transcript back into a list of MeetingEntry objects.
Tolerant of hand edits — entries without the machine marker are
skipped (the file header itself is one such block).
"""
mid = _safe(meeting_id)
path = layout.meeting_dir(mid) / "transcript.md"
if not path.is_file():
return []
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return []
entries: list[MeetingEntry] = []
marker_re = re.compile(
r"<!--\s*entry:\s*(?P<eid>\S+)\s+author:\s*(?P<author>\S+)\s+posted_at:\s*(?P<posted>[^\s>]+)\s*-->"
)
matches = list(marker_re.finditer(text))
for i, m in enumerate(matches):
start = m.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
chunk = text[start:end]
# Strip the leading "### `author` · ts" line
lines = chunk.lstrip("\n").splitlines()
if lines and lines[0].startswith("### "):
lines = lines[1:]
body = "\n".join(lines).strip()
entries.append(
MeetingEntry(
entry_id=m.group("eid"),
author=m.group("author"),
posted_at=m.group("posted"),
content=body,
)
)
return entries
def read_meeting_state(
layout: CommsLayout,
meeting_id: str,
) -> MeetingState | None:
mid = _safe(meeting_id)
mdir = layout.meeting_dir(mid)
manifest_path = mdir / "manifest.yaml"
transcript_path = mdir / "transcript.md"
if not manifest_path.is_file():
return None
try:
text = manifest_path.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
if yaml is not None:
try:
data = yaml.safe_load(text) or {}
except Exception:
data = {}
else:
data = {}
if not isinstance(data, dict):
return None
return MeetingState(
meeting_id=str(data.get("meeting_id") or mid),
topic=str(data.get("topic") or ""),
organizer=str(data.get("organizer") or ""),
participants=list(data.get("participants") or []),
status=str(data.get("status") or "open"),
opened_at=str(data.get("opened_at") or ""),
closed_at=(str(data.get("closed_at")) if data.get("closed_at") else None),
decision=(str(data.get("decision")) if data.get("decision") else None),
entry_count=len(read_transcript(layout, mid)),
manifest_path=manifest_path,
transcript_path=transcript_path,
)
def list_active_meetings(
layout: CommsLayout,
*,
role_id: str | None = None,
) -> list[MeetingState]:
"""List meetings that are still `open`. If `role_id` is given,
only return meetings where that role is in `participants`.
"""
if not layout.meetings_root.is_dir():
return []
out: list[MeetingState] = []
role = _safe(role_id) if role_id else None
for child in sorted(layout.meetings_root.iterdir()):
if not child.is_dir():
continue
state = read_meeting_state(layout, child.name)
if state is None or state.status != "open":
continue
if role and role not in state.participants:
continue
out.append(state)
return out
def close_meeting(
layout: CommsLayout,
*,
meeting_id: str,
decision: str,
closed_by: str = "",
) -> MeetingState | None:
"""Mark a meeting as closed and write the final decision into the manifest.
Also appends a final transcript entry summarizing the decision so
the transcript is self-contained for downstream readers.
"""
mid = _safe(meeting_id)
state = read_meeting_state(layout, mid)
if state is None:
return None
if state.status == "closed":
return state
manifest = {
"meeting_id": state.meeting_id,
"topic": state.topic,
"organizer": state.organizer,
"participants": state.participants,
"status": "closed",
"opened_at": state.opened_at,
"closed_at": _utc_iso_human(),
"decision": decision or "",
}
_atomic_write_text(state.manifest_path, _dump_yaml(manifest))
append_to_transcript(
layout,
meeting_id=mid,
author=closed_by or state.organizer or "system",
content=f"**Meeting closed.** Decision: {decision or '(none recorded)'}",
)
return read_meeting_state(layout, mid)
def render_meetings_section(
layout: CommsLayout,
role_id: str,
*,
max_meetings: int = 6,
max_recent_entries: int = 3,
) -> str:
"""Markdown section for the prompt: meetings this role is currently in.
Renders one subsection per open meeting, with participants,
transcript path, and recent transcript tail. Returns "" when
there are no open meetings for this role — the "how to start a
meeting" command template lives in the ``collaboration-playbook``
skill, not in this per-turn prompt section.
"""
if not role_id:
return ""
role = _safe(role_id)
meetings = list_active_meetings(layout, role_id=role)[:max_meetings]
if not meetings:
return ""
lines: list[str] = ["### Meetings"]
for state in meetings:
lines.append("")
lines.append(
f"#### `{state.meeting_id}` — {state.topic} (status: {state.status})"
)
lines.append(
f"Participants: {', '.join('`'+p+'`' for p in state.participants)} "
f"· opened {state.opened_at} · {state.entry_count} entries"
)
lines.append(f"Transcript: `cat {state.transcript_path}`")
recent = read_transcript(layout, state.meeting_id)[-max_recent_entries:]
if recent:
lines.append("Recent:")
for e in recent:
lines.append(f"- `{e.author}` · {e.posted_at}: {e.content}")
return "\n".join(lines)
# ──────────────────────────────────────────────────────────────────────
# README (descriptive only - collaboration tools live behind opc-collab CLI)
# ──────────────────────────────────────────────────────────────────────
_README_TEMPLATE = """\
# OpenOPC Comms Layout
This directory is the file-based collaboration substrate for project
`{project_id}`, session `{session_id}`. It is OPC-managed runtime
state — you do not need to write into it directly.
## Layout
- `inbox/<role>/new/` — unread messages addressed to <role>
- `inbox/<role>/seen/` — messages OpenOPC has archived for <role>
- `inbox/<role>/outbox/` — copies of messages <role> has sent (audit)
- `handoffs/<projection_id>/` — formal work-item-level handoffs
- `meetings/<meeting_id>/` — multi-party meeting transcripts
## How agents collaborate
Mailbox delivery is **runtime-owned**. OpenOPC reads `.opc-comms`,
classifies inbox state, archives `new/ -> seen/`, and injects the
actionable mailbox snapshot into each agent's turn context.
Agents use collaboration tools only to express intent, such as:
- `send_dm(...)` / `reply_message(...)`
- `delegate_work(...)`
- `manager_board_read`
- `start_meeting(...)` / `respond_meeting(...)`
External agents receive a capability-sliced subset of these tools via
the `opc-collab` CLI. Debug/admin-only tools such as `read_inbox`,
`read_meeting`, and `list_colleagues` are not part of the normal
production surface.
## Reading the files manually (debug only)
ls inbox/<your_role>/new/
cat inbox/<your_role>/new/<message-file>.md
Direct file access is for debugging. Production runs do not require the
agent to call a mailbox-read tool; the runtime has already prepared the
actionable mailbox state for the turn.
"""
def _write_readme(layout: CommsLayout) -> None:
text = _README_TEMPLATE.format(
project_id=layout.project_id,
session_id=layout.session_id,
root=str(layout.root),
)
_atomic_write_text(layout.root / "README.md", text)
# ──────────────────────────────────────────────────────────────────────
# Prompt rendering helpers (used by context_assembler)
# ──────────────────────────────────────────────────────────────────────
def render_inbox_section(
layout: CommsLayout,
role_id: str,
*,
max_unread_listed: int = 12,
) -> str:
"""Build the per-turn comms section for the role's inbox.
This renders ONLY:
1. The role's mailbox scope.
2. Runtime events for this turn (currently-unread messages
addressed to this role) — surfaced as a runtime-owned
mailbox snapshot, not as an instruction to poll the inbox.
Collaboration actions are exposed through the runtime tool surface
or the opc-collab CLI as needed, but mailbox delivery itself remains
runtime-owned. The standing rules for when to send / when to reply /
how to use blocking semantics live in the collaboration skill.
Returns "" if ``role_id`` is empty or the role has neither
addresses worth surfacing nor unread messages.
"""
if not role_id:
return ""
role = _safe(role_id)
new_dir = layout.role_new_dir(role)
unread = list_unread(layout, role, limit=max_unread_listed) if new_dir.is_dir() else []
lines: list[str] = []
lines.append("### Mailbox")
lines.append(
f"Mailbox delivery for role `{role}` is runtime-owned; OpenOPC has already "
f"prepared the actionable snapshot for this turn. Use `reply_message` to "
f"answer, `inbox(action=\"ack\")` to acknowledge handled messages that need "
f"no reply, and other collaboration tools only when you need to delegate, "
f"release, roll up, or coordinate."
)
if unread:
lines.append("")
lines.append(
f"### Runtime-owned unread mailbox snapshot ({len(unread)})"
)
for h in unread:
tag = " [BLOCKING]" if h.blocking else ""
lines.append(
f"- {h.sent_at} — from `{h.from_role}` — {h.subject}{tag}"
)
return "\n".join(lines)