Docs

Read a bundle

Reading a .chit takes four steps.

  1. Unzip it and parse manifest.json. Check format is chitmonk-bundle and version is one you know (1).
  2. For each entry in meetings, read the file at its path and compare its SHA-256 with the manifest's.
  3. Replay its events in order, skipping any that an EventUndone names. The rules.
  4. Label speakers honestly. A confirmed name is a name. A guess is a guess.

How Chitmonk labels a line

Every export Chitmonk writes uses the same four labels, and a reader should too:

The line Label
A person confirmed who spoke Maya
A person marked it as nobody on the list Unassigned
Not confirmed, but the app has a guess Maya?
Not confirmed, no guess Unknown speaker

A reader in Python

No packages to install. project() is the whole of the replay rules.

"""Read a Chitmonk .chit bundle: who said what, what is owed, what was decided.

Standard library only.    python3 read_chit.py meetings.chit
"""
import hashlib
import json
import sys
import zipfile


def project(events):
    """Replay one meeting's event log into the meeting as it stands now."""
    undone = {e["targetId"] for e in events if e["type"] == "EventUndone"}
    m = {"title": "", "people": {}, "order": [], "lines": [], "actions": [], "decisions": [], "notes": []}
    for e in events:
        if e["id"] in undone:
            continue
        t = e["type"]
        items = m["actions"] if t.startswith("Action") else m["decisions"]
        if t in ("MeetingCreated", "MeetingRenamed"):
            m["title"] = e["title"]
        elif t == "ParticipantAdded":
            m["people"][e["participantId"]] = {"name": e["name"], "pronouns": e.get("pronouns"), "removed": False}
            m["order"].append(e["participantId"])
        elif t == "ParticipantUpdated":
            p = m["people"][e["participantId"]]
            p["name"] = e.get("name") or p["name"]
            if "pronouns" in e:
                p["pronouns"] = e["pronouns"]
        elif t == "ParticipantReordered":
            rank = {pid: i for i, pid in enumerate(e["order"])}
            m["order"].sort(key=lambda pid: rank.get(pid, 10**9))
        elif t == "ParticipantRemoved":
            m["people"][e["participantId"]]["removed"] = True
        elif t == "SegmentCommitted":
            who = e.get("speakerId")
            m["lines"].append({
                "id": e["lineId"], "startMs": e["startMs"], "text": e["text"],
                "speakerId": who, "confirmed": who is not None,
                "suggestedSpeakerId": None if who is not None else e.get("suggestedSpeakerId"),
            })
        elif t == "SpeakerAssigned":
            ids = set(e["lineIds"])
            for line in m["lines"]:
                if line["id"] in ids:
                    line.update(speakerId=e["participantId"], confirmed=True, suggestedSpeakerId=None)
            for item in m["actions"] + m["decisions"]:
                if item["ownerFromSpeaker"] and item["lineId"] in ids:
                    item["ownerId"] = e["participantId"]
        elif t == "TranscriptLineEdited":
            for line in m["lines"]:
                if line["id"] == e["lineId"]:
                    line["text"] = e["text"]
        elif t == "TranscriptLineDeleted":
            m["lines"] = [line for line in m["lines"] if line["id"] != e["lineId"]]
        elif t in ("ActionSuggested", "DecisionSuggested"):
            items.append({
                "id": e["itemId"], "lineId": e.get("lineId"), "text": e["text"], "ownerId": e.get("ownerId"),
                "ownerFromSpeaker": bool(e.get("ownerFromSpeaker")), "dueText": e.get("dueText"), "dueDate": e.get("dueDate"), "accepted": False,
            })
        elif t in ("ActionEdited", "DecisionEdited"):
            for item in items:
                if item["id"] == e["itemId"]:
                    if e.get("text") is not None:
                        item["text"] = e["text"]
                    if "ownerId" in e:  # a person chose the owner, so it stops following the speaker
                        item["ownerId"], item["ownerFromSpeaker"] = e["ownerId"], False
                    for key in ("dueText", "dueDate"):
                        if key in e:
                            item[key] = e[key]
        elif t in ("ActionAccepted", "DecisionAccepted"):
            for item in items:
                if item["id"] == e["itemId"]:
                    item["accepted"] = True
        elif t in ("ActionDeleted", "DecisionDeleted"):
            items[:] = [item for item in items if item["id"] != e["itemId"]]
        elif t == "NoteAdded":
            m["notes"].append({"id": e["noteId"], "lineId": e.get("lineId"), "text": e["text"]})
        elif t == "NoteEdited":
            for note in m["notes"]:
                if note["id"] == e["noteId"]:
                    note["text"] = e["text"]
        elif t == "NoteDeleted":
            m["notes"] = [note for note in m["notes"] if note["id"] != e["noteId"]]
    return m


def clock(ms):
    s = int(ms / 1000 + 0.5)
    return f"{s // 3600}:{s % 3600 // 60:02}:{s % 60:02}" if s >= 3600 else f"{s // 60}:{s % 60:02}"


def name(m, pid):
    return m["people"][pid]["name"] if pid in m["people"] else None


def speaker(m, line):
    """A confirmed name, or a guess marked with "?". A guess is never shown as a fact."""
    if line["confirmed"]:
        return name(m, line["speakerId"]) or "Unassigned"
    guess = name(m, line["suggestedSpeakerId"])
    return f"{guess}?" if guess else "Unknown speaker"


def item_line(m, item):
    due = item["dueDate"] or item["dueText"]
    about = [name(m, item["ownerId"]), f"due {due}" if due else None, None if item["accepted"] else "suggested"]
    about = [a for a in about if a]
    return f"{item['text']} ({', '.join(about)})" if about else item["text"]


def read(path):
    """Every meeting in the bundle, checked against the manifest's SHA-256 and replayed."""
    with zipfile.ZipFile(path) as z:
        manifest = json.loads(z.read("manifest.json"))
        if manifest.get("format") != "chitmonk-bundle" or manifest.get("version", 0) > 1:
            raise ValueError("not a .chit bundle this script can read")
        for entry in manifest["meetings"]:
            data = z.read(entry["path"])
            if hashlib.sha256(data).hexdigest() != entry["sha256"]:
                raise ValueError(f"{entry['path']} does not match its checksum")
            yield project(json.loads(data)["events"])


if __name__ == "__main__":
    for m in read(sys.argv[1]):
        print(m["title"])
        people = [m["people"][pid] for pid in m["order"] if not m["people"][pid]["removed"]]
        print("People: " + ", ".join(f"{p['name']} ({p['pronouns']})" if p["pronouns"] else p["name"] for p in people))
        print()
        for line in m["lines"]:
            print(f"[{clock(line['startMs'])}] {speaker(m, line)}: {line['text']}")
        for title, items in (("Action items", m["actions"]), ("Decisions", m["decisions"])):
            if items:
                print(f"\n{title}")
                for item in items:
                    print(f"- {item_line(m, item)}")
        if m["notes"]:
            starts = {line["id"]: line["startMs"] for line in m["lines"]}
            print("\nNotes")
            for note in m["notes"]:
                at = f"[{clock(starts[note['lineId']])}] " if note["lineId"] in starts else ""
                print(f"- {at}{note['text']}")

Run it on the sample bundle:

$ python3 read_chit.py sample.chit
Launch review
People: Maya, Leo, Sam (they/them)

[0:01] Maya: Okay, let us get started. Thanks everyone for joining the launch review.
[0:07] Leo: Sounds good. The checkout tests passed last night, so the build is ready.
[0:13] Maya: Great. I will send the revised deck by Friday.
[0:17] Sam: We decided to launch on Tuesday.
[0:21] Leo?: Sam, can you update the release notes before the launch?
[0:26] Unknown speaker: Yes, that works for me.

Action items
- I will send the revised deck by Friday. (Maya, due by Friday)
- Sam, can you update the release notes before the launch? (Sam, suggested)

Decisions
- We decided to launch on Tuesday. (suggested)

Notes
- [0:13] Ask Leo for the old deck

Line three shows the edit (the friday first heard is gone), line four shows the undo (it is Sam's, not Leo's), and line five is still a guess because nobody confirmed it.

If you only need to read it

A .chit is for tools. If a person or a model just needs to read the meeting, the Markdown or plain text export is the same content already laid out.