Efren Yevale Varela 4 日 前
親
コミット
12e3d3649a

+ 9 - 1
.gitignore

@@ -5,7 +5,15 @@
 .codegraph
 .eggs/
 .omo/
-.opencode/
+# .opencode/ is editor/agent tooling. Only the session-summary skill is repo tooling we ship;
+# everything else there (node_modules, local agent state) stays untracked.
+.opencode/*
+!.opencode/skills/
+.opencode/skills/*
+!.opencode/skills/session-summary/
+# The ASR glossary accumulates client vocabulary from real sessions: private user state, like
+# questions.json. It is never committed; a fresh clone starts without one.
+.opencode/skills/session-summary/references/glossary.md
 .pytest_cache/
 .ruff_cache/
 .venv/

+ 373 - 0
.opencode/skills/session-summary/SKILL.md

@@ -0,0 +1,373 @@
+---
+name: session-summary
+description: Reconstructs a recorded conversation from a transcription session directory (transcripts/<session-id>/ with transcripts.jsonl, brief.md, audio/) and writes English and Spanish summaries (session-summary.svg and session-summary.es.svg, plus size-optimized PNG exports of each) into that directory, correcting ASR mishearings, language mis-detection, speaker misattribution and overclaiming, then rewrites the triage brief.md with the same corrections and adds what it learned to its ASR glossary. Use when asked to summarize, reconstruct, visualize or fix the brief of a transcripts/ session folder, or to create session-summary.svg / session-summary.es.svg.
+---
+
+# Session summary from a transcription directory
+
+Input: a session directory such as `transcripts/2026-10-05-15-01-08/`. All outputs are written
+into that same session directory, never elsewhere. Both language versions are always produced:
+
+1. `session-summary.content.json`: the reconstructed content, in English (source of the SVG).
+2. `session-summary.svg`: the one-page visual reconstruction, in English.
+3. `session-summary.es.content.json`: the same content translated to Spanish.
+4. `session-summary.es.svg`: the visual reconstruction, in Spanish.
+5. `session-summary.png` and `session-summary.es.png`: size-optimized PNG exports of the two
+   SVGs (written by `render.py` in Step 3, checked in Step 4).
+6. `brief.md`: corrected in place.
+
+The run is not complete until both SVGs and both PNGs exist in the session directory and pass
+Step 4.
+
+Run every command from the repository root. Bundled files live in
+`.opencode/skills/session-summary/`:
+
+- `references/glossary.md`: domain terms and known ASR mishearings. Read it first; update it
+  with what this session taught you in Step 6. This is the only file outside the session
+  directory that a run may change.
+- `scripts/render.py`: renders the SVG from the content JSON plus `transcripts.jsonl` and
+  exports the matching PNG next to it. It computes the transcription-quality metrics, wraps text, lays out any number of topic cards
+  and sizes the canvas. Never hand-write the SVG and never type in metrics yourself.
+
+## Hard rules
+
+- Never modify `transcripts.jsonl` or `audio/`. They are the evidence.
+- Write the content JSONs, the brief and the glossary only with the Write/Edit tools. The SVGs
+  and the PNGs are both written by `render.py` (PNGs via Inkscape/ImageMagick). Never use shell
+  redirection, `sed`, `tee`, `cat >` or similar to produce or change files.
+- Every claim in the SVG and brief must be traceable to a segment (`seg_id`). If you cannot
+  point at a segment, do not write the claim.
+- Paraphrase, do not invent. When a token cannot be resolved, say so in the footer and add it
+  to the glossary's Unresolved list; never make up a meaning for it.
+- The final SVGs and PNGs always live in the session directory. `render.py` keeps its
+  intermediate raster in a private temp dir and deletes it itself; never leave the only copy of
+  a deliverable in `/tmp`.
+- **Single-session scope.** Work only on the session directory the user named. Never list,
+  glob, grep, open or compare any other directory under `transcripts/`: not earlier sessions
+  for "context", not sibling sessions for speaker or term hints, not their SVGs, briefs or
+  JSONL. Do not run `ls transcripts/` or any pattern that expands across sessions. Everything
+  you need is in the named directory plus the allowed non-session files below. If the evidence
+  is not enough, say so in the footer and the report instead of looking elsewhere.
+  - Allowed outside the session directory: this skill's `references/glossary.md` and
+    `scripts/render.py`, and `speakers/speakers.json` (name mapping only). Never read other
+    repositories; the glossary is the only vocabulary source.
+  - Prior sessions' knowledge reaches you only through the glossary. Do not re-derive it from
+    old transcripts.
+
+## The brief is low-trust input
+
+`brief.md` is generated live by a small, fast LLM during the session. Expect low accuracy.
+
+- **Trust order:** `transcripts.jsonl` is the evidence; `brief.md` is only a list of possible
+  leads. Never copy a fact from the brief into the SVG or the corrected brief without finding
+  its segment. Do not let its framing anchor your reconstruction: reconstruct from the JSONL
+  first, then read the brief.
+- **Use the brief to check coverage:** after reconstructing, go through the brief. For anything
+  it mentions that you did not capture, look for it in the transcript, and keep it only if a
+  segment supports it.
+- **Known small-model failure patterns** (check every one, in the brief and in your own output):
+  - sections and sentences cut off mid-word (e.g. "Open Ques");
+  - the same fact repeated with slightly different wording;
+  - "Unknown participant" for lines the JSONL attributes to a named speaker;
+  - ASR mishearings copied literally ("Good One", "dedo del hígado");
+  - noise from mis-detected languages reported as statements ("said in Cyrillic");
+  - hedged statements turned into "approved" decisions or "confirmed" opportunities;
+  - reversed reasoning (who pays whom, buyer vs seller);
+  - worked examples and on-screen steps dropped;
+  - acknowledgments and greetings logged as facts.
+- **Calibrate the size of the rewrite:** expect to rewrite most of the brief, not patch it. If
+  your corrected brief still reuses most of the original wording, you probably anchored on it.
+  Go back to the JSONL.
+
+## Step 1: Load the evidence
+
+Dump the segments in a compact, readable form (read-only; output to the terminal only):
+
+```bash
+uv run python -c "
+import json, sys
+for l in open(sys.argv[1]):
+    d = json.loads(l)
+    print(d['seg_id'], d['ts'][11:19], d['lang'], round(d['lang_prob'], 2),
+          d['speaker_status'], d.get('speaker_score'), '|', d['speaker'], '|', d['text'].strip())
+" transcripts/<session-id>/transcripts.jsonl
+```
+
+JSONL fields that matter: `seg_id`, `ts`, `lang`, `lang_prob`, `text`, `speaker`
+(a display name; `"Name?"` means uncertain, `"?"` means unknown), `speaker_id`,
+`speaker_status` (`matched` / `uncertain` / `unknown`), `speaker_score`.
+
+Also read:
+
+- The speaker store `speakers/speakers.json` (`speakers.<id>.name`) to map the JSONL's
+  `speaker_id`s to names. It holds everyone ever enrolled on this machine, so it is **not** the
+  attendee list.
+- Any participant list or name corrections the user supplies; these override everything else.
+
+Build the attendee list from evidence: people who speak in the JSONL, plus people the
+conversation shows were present or expected (greeted, addressed, thanked, or "missing"), plus
+the user's list. Silent and absent attendees go in the Participants block with that status;
+never list an enrolled speaker who has no link to this session.
+
+If the session is long, read it in full anyway. Do not summarize from the first half.
+
+## Step 2: Reconstruct, correcting these error classes
+
+Work through the transcript in order and build a list of topics, each with its supporting
+`seg_id`s. While doing so, fix every instance of the following. This list was distilled from
+real failures; check all of them, not just the first one you notice.
+
+1. **ASR mishearings of domain terms.** Normalize them using `references/glossary.md`
+   (e.g. "Good One" → HUD-1, "long charges" → loan charges, "impulse" → impounds,
+   "DIP" → deed, "CELERB" → seller, "Bayer" → buyer). Infer new mappings only when the
+   context makes them unambiguous, and list every normalization in the Transcription quality
+   block's `corrections`. Keep a running
+   list of every new mapping, noise segment and unresolved token you find; Step 6 writes it
+   into the glossary.
+2. **Language mis-detection.** Segments tagged with an unexpected `lang` (pt, de, ru, ja, id,
+   ...) or in an odd script are usually misrecognized Spanish/English. Reinterpret them from
+   context (e.g. "caixas de close" was "Charges section"), or drop them as noise. Never
+   report them as statements made "in Portuguese/Cyrillic/Japanese".
+3. **Speaker attribution.** Trust `matched`. Check `uncertain` (`"Name?"`) against the
+   context (who was addressed, who answers). Resolve `unknown` (`"?"`) only when the context
+   is clear. Never write "Unknown participant" for a line the JSONL attributes to someone.
+   Resolve name aliases against the attendee list and the glossary (e.g. "Efraín", "Fren" →
+   Efren), and spot
+   compound mishearings ("Efraín Reyes" was two people).
+4. **Negation and possessive misreads.** Re-read short emphatic lines in context:
+   "No debería ser un problema mío" meant "it shouldn't be a problem", not "not my problem".
+5. **Proposal vs agreement vs decision.** Label a statement as agreed only if someone else
+   explicitly accepts it. A suggestion followed by an objection is a proposal plus an
+   objection, not a "fallback agreed on the call". Limits stated by one person are "stated",
+   not "agreed".
+6. **Direction of reasoning.** Payer vs payee, buyer vs seller, debit vs credit, cause vs
+   effect: re-check every sentence that has a direction. Do not invert it.
+7. **Context attribution.** Keep separate what a participant experienced on this call and
+   what they report from someone else (e.g. the client). Do not move one into the other.
+8. **Overclaiming and editorializing.** No verdict titles ("the design was right after all").
+   No "no development needed" when a dependency remains (undocumented tags, missing access).
+   Use "can", "once X", "to corroborate" where the speaker hedged.
+9. **Roles.** Take people's roles from what they say about themselves ("desde Customer
+   Support"), not from who shares the screen or talks the most.
+10. **Unresolvable tokens.** Nicknames, slang and garbled words: resolve them when standard
+    ("capa 8" = layer 8 = user error); otherwise mark them as unresolved. Never turn them into
+    entities such as "the standing channel".
+11. **Omissions.** Make sure the concrete, practical moments survive: worked examples,
+    on-screen walk-throughs, exact steps, numbers and item references. These are the most
+    useful parts and the ones small models drop first.
+12. **Duplicates and redundancy.** Each fact appears once, in the card where it belongs.
+
+Only after the reconstruction is complete, read `brief.md` and run the coverage check from
+"The brief is low-trust input". Record where the brief disagrees with the audio for the footer.
+
+## Step 3: Build the SVG
+
+Write `session-summary.content.json` in the session directory (Write tool), then render it.
+The page layout is fixed and generated by the script:
+
+- **Header:** eyebrow, title, and an auto-generated date line, plus three stats computed from
+  the JSONL (segments, session length, speed versus real time).
+- **Main-point band:** headline, status label, pill, body.
+- **Left sidebar, always present:**
+  - *Session focus*: the 2–5 things the session set out to do or ended up doing.
+  - *Participants and roles*: the attendee list from Step 1, including silent or absent
+    attendees, each with a role inferred from the dialogue.
+  - *Transcription quality*: language mix bar, session length, reconstructed speech, pause
+    cuts, low-confidence segments (`lang_prob` < 0.8), speaker identification rate,
+    uncertain/unknown speakers, mis-detected languages, median latency (all computed), plus
+    the list of name and term corrections you made.
+- **Topic cards, unlimited:** one card per distinct topic, in conversation order. Do not merge
+  unrelated topics to save space and do not pad with filler cards. Cards fill the main column
+  beside the sidebar, then continue below it in two-column rows (an odd last card spans the
+  full width). The usual arc is case/problem → answer → blockers → scope → settled → to
+  build → external context, but follow the conversation.
+- **Signals block, always present** (full width, after the cards): *Opportunities* and
+  *Blockers / Risks* panels are always drawn (an empty list renders "None identified in this
+  session"); *Open questions* is drawn only when the list is non-empty. These carry the same
+  items as the corrected `brief.md` sections of the same names (Step 5), each traced to a
+  segment. Do not duplicate them as topic cards; cards hold the narrative, this block holds
+  the forward-looking summary.
+- **Next steps** (optional): action / owner / when table with a closing note.
+- **Footer:** method notes, one string per paragraph.
+
+Content JSON schema (all text is plain; the script escapes XML and wraps lines):
+
+```json
+{
+  "lang": "en",
+  "expected_langs": ["es", "en"],
+  "title": "≤ ~65 chars; the script shrinks it if longer",
+  "eyebrow": "optional; defaults to SESSION RECONSTRUCTION",
+  "description": "one-paragraph <desc> for accessibility",
+  "main_point": {"headline": "...", "status": "SHORT STATUS", "pill": "TIMELINE · OWNER",
+                 "body": "two or three sentences"},
+  "focus": [{"label": "...", "detail": "...", "tone": "warn (optional: amber + italic)"}],
+  "participants": [{"name": "Full Name", "role": "role, from the dialogue",
+                    "status": "speaks | silent | absent", "initial": "optional"}],
+  "participants_note": "optional italic note (who never spoke, who was missing)",
+  "corrections": ["«heard» → meant · «heard» → meant", "..."],
+  "topics": [{
+    "accent": "amber | teal | red | violet | green | blue | rose | slate",
+    "eyebrow": "TOPIC · ANGLE", "title": "...", "subtitle": "optional",
+    "sections": [{"label": "...", "tone": "limit (optional: red bullets)",
+                  "bullets": ["...", "..."]}],
+    "chips": ["optional short tags, e.g. variants"],
+    "notes": ["optional closing lines"]
+  }],
+  "opportunities": ["REQUIRED key: what the conversation shows could be gained or enabled; [] if none"],
+  "blockers": ["REQUIRED key: blockers, risks, limits and dependencies; [] if none"],
+  "open_questions": ["optional: real unresolved items; omit or [] when there are none"],
+  "next_steps": {"rows": [{"action": "...", "owner": "...", "when": "..."}],
+                 "note": "optional"},
+  "footer": ["Speaker-ID mapping and how uncertain/unknown lines were attributed ...",
+             "Mis-detected language segments: reinterpreted or dropped ...",
+             "Unresolved tokens ...", "Where the brief disagreed with the audio ...",
+             "Wording is paraphrase, not verbatim."]
+}
+```
+
+Where method information goes, so nothing is duplicated:
+
+- **`corrections`** (Transcription quality block): every name and term normalization.
+- **`participants` / `participants_note`**: who attended, spoke, stayed silent or was missing.
+- **`footer`**: speaker-ID mapping, mis-detected-language segments, unresolved tokens, brief
+  discrepancies, and the paraphrase notice. Segment count, span and metrics are already in the
+  header and sidebar, so do not repeat them.
+- **Cards**: content only, never method notes.
+
+Signals writing rules: `opportunities`, `blockers` and `open_questions` are plain strings, one
+idea each, hedged exactly as the speaker hedged (an idea floated is an opportunity "to
+explore", never a confirmed one). Every item needs a supporting segment. `blockers` must
+include limits and unmet dependencies (missing access, undocumented tags), not only stated
+problems. Only list an open question the conversation actually left unresolved. In the Spanish
+version these are the same items, same order, translated.
+
+Card writing rules: give each card 1–4 labeled sections with 1–3 bullets each, one idea per
+bullet. Use `"tone": "limit"` for blockers and limits. Keep the claim strength the speaker
+used.
+
+**Spanish version (always required):** write `session-summary.es.content.json` with the same
+structure and the same facts, `"lang": "es"` (sidebar labels, metric names and dates switch
+automatically), and every string translated except the team's own working terms (product and
+domain terms they say in English, such as closing disclosure, settlement statement, charges).
+Keep their idioms where natural. The two versions must match: same topics, same order, same
+claims, nothing added or dropped in translation.
+
+Render both into the session directory. `render.py` writes the SVG, checks that it is
+well-formed XML, and exports the PNG beside it (same stem, `.png`) in the same run:
+
+```bash
+S=transcripts/<session-id>
+R=.opencode/skills/session-summary/scripts/render.py
+uv run python $R $S $S/session-summary.content.json    $S/session-summary.svg
+uv run python $R $S $S/session-summary.es.content.json $S/session-summary.es.svg
+```
+
+Each run prints `wrote <svg>` and `wrote <png> (<n> KB)`. If it prints an `error:` line or exits
+non-zero (missing `inkscape`/`magick`, failed export), the run has failed: fix it or report it.
+Never substitute a manual or ad-hoc export. `--no-png` skips the PNG and is only for draft
+re-renders; the final render must not use it.
+
+## Step 4: Verify both SVGs and PNGs
+
+The PNG is the shareable copy of the SVG: full 1680 px width, palette-quantized to 64 colors
+without dithering, metadata stripped (`render.py` owns this recipe). The flat-color design
+makes this visually lossless for text and cards while cutting the file to roughly a quarter of
+Inkscape's raw export (about 1.1 MB → 0.27 MB on a typical session). Do not downscale the
+width (it saves little and blurs the small text) and do not use dithering (it bloats the
+file). The raw Inkscape export lives in a private temp dir that `render.py` always deletes.
+
+Confirm all four files exist in the session directory and that each PNG is roughly 0.2–0.4 MB
+(about 1 MB means the quantization did not run):
+
+```bash
+ls -l $S/session-summary.svg $S/session-summary.png $S/session-summary.es.svg $S/session-summary.es.png
+```
+
+Then open each final PNG with the Read tool and inspect it. Wrapping is automatic, so look for
+content problems instead: a card that reads as two topics (split it), a near-empty card (merge
+it into its neighbor or add the missing substance), a wrong accent or tone, and long unbroken
+tokens that still run past an edge. Confirm the Opportunities and Blockers / Risks panels are
+visible (and Open questions, if the session has any), and that `render.py` printed no "no
+'opportunities'/'blockers' key" warning. If you edit the JSON, re-run the two render commands
+once; the PNGs are regenerated with the SVGs.
+
+## Step 5: Correct brief.md
+
+Rewrite `brief.md` in place, keeping its section format exactly. The layout is below; copy the
+header wording from the original file (the triage code looks for the `Prior Details:` marker):
+
+```
+Summary:
+<one paragraph>
+Opportunities:
+- ...
+Blockers/Risks:
+- ...
+Decisions:
+- ...
+Action Items:
+- <Owner>: ...
+Open Questions:
+- ...
+
+Prior Details:
+- <chronological facts>
+```
+
+Apply all twelve error classes from Step 2. In addition:
+
+- Complete truncated sections and sentences (small models cut off mid-word, e.g. "Open Ques").
+- Remove duplicate facts and filler ("X acknowledged with 'Thank you'", greetings, noise lines).
+- Keep `Prior Details:` chronological, one fact per line, with named speakers.
+- `Decisions:` contains only what was actually agreed; `Open Questions:` lists real
+  unresolved items from the conversation.
+- The brief and the SVGs must agree with each other: `Opportunities:`, `Blockers/Risks:` and
+  `Open Questions:` hold the same items as the SVGs' `opportunities`, `blockers` and
+  `open_questions` (the brief in its own language, the SVGs in both). Write them once, here
+  and in the content JSON, from the same reconstruction.
+- Keep the brief in the language it was generated in (English or Spanish; it follows the
+  language of the conversation). Do not translate it. Keep the section headers exactly as they
+  appear in the original file, since the triage code looks for `Prior Details:`.
+
+## Step 6: Update the glossary
+
+Write everything new from Step 2's running list into
+`.opencode/skills/session-summary/references/glossary.md`. Edit it with the Edit tool and put
+each item in the table where it belongs, not in a pile at the end:
+
+- **New mishearing:** add a row to the matching Part 1 table (Documents and settlement types;
+  Charges and sections; Letters and charge codes spoken aloud; Slang, tools and people). If the
+  "means" side is a domain term, check its canonical spelling against Part 2 of the glossary
+  only; do not look in other repositories. Mark it "(likely)" when the context strongly suggests
+  it but does not prove it.
+- **New spelling of a known term:** add the variant to the existing row instead of a new row.
+- **New noise pattern:** add a row to "Language mis-detection noise" only if it shows something
+  the table does not already cover (a new language tag, or a fragment that had to be
+  reinterpreted rather than dropped).
+- **Unresolved token:** add it to "Unresolved (do not invent meanings)" with the session ID and
+  segment.
+- **Resolved token:** if this session resolves something listed under "Unresolved", move it to
+  Part 1 and cite the segment that resolved it.
+- **New canonical vocabulary:** if the conversation used a real product term that is missing
+  from Part 2 and this session's transcript makes its spelling and meaning unambiguous, add it
+  to the right Part 2 section. If it cannot be confirmed from the transcript, list it under
+  "Unresolved" instead.
+
+Rules:
+
+- Only add what this session's transcript supports. Cite it in the row as
+  `(<session-id>, seg <n>)`. Never add guesses without "(likely)", and never add a meaning for
+  an unresolved token.
+- Do not duplicate: search the glossary for the heard form and for the meaning first.
+- Do not rewrite or delete existing entries. If one now looks wrong, add a note under it
+  explaining the conflict, with the segment.
+- Update the Part 1 "Sources:" line with this session ID.
+- If nothing new was found, say so in the report; do not add filler.
+
+## Step 7: Report
+
+Finish with a short report: the output files (both SVGs, both PNGs with their sizes, both
+content JSONs and the brief, all in the session directory), the corrections made grouped by
+error class (one line each), anything left unresolved, the glossary changes (rows added, moved
+or annotated), and the verification result (XML parse plus render check).

+ 552 - 0
.opencode/skills/session-summary/scripts/render.py

@@ -0,0 +1,552 @@
+"""Render a session-summary SVG from a content JSON and the session's transcripts.jsonl.
+
+Usage: uv run python render.py <session_dir> <content.json> <out.svg> [--no-png]
+
+Also exports <out>.png next to the SVG (Inkscape + ImageMagick, 64-color quantized); pass
+--no-png to skip. A missing tool or failed export exits non-zero.
+
+Layout: header, main-point band, a fixed left sidebar (session focus, participants and roles,
+transcription quality), then any number of topic cards. Topic cards fill the main column next
+to the sidebar, then continue below it in two-column rows. A full-width signals block follows
+(opportunities and blockers/risks always, open questions when any), then an optional next-steps
+table and the footer. Text is wrapped and the canvas height is computed, so nothing is clipped.
+Transcription-quality metrics are computed from the JSONL, never typed in by hand.
+"""
+
+import json
+import shutil
+import statistics
+import subprocess
+import sys
+import tempfile
+from collections import Counter
+from datetime import datetime, timedelta
+from pathlib import Path
+from xml.dom import minidom
+from xml.sax.saxutils import escape
+
+W      = 1680
+M      = 48
+GAP    = 24
+PAD    = 28
+SIDE_W = 468
+MAIN_X = M + SIDE_W + 32
+MAIN_W = W - M - MAIN_X
+FULL_W = W - 2 * M
+HALF_W = (FULL_W - GAP) // 2
+FONT   = "'Inter','Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif"
+RED    = "#B91C1C"
+
+ACCENTS = {
+    "amber": "#B45309", "teal": "#0D9488", "red": "#B91C1C", "violet": "#7C3AED",
+    "green": "#15803D", "blue": "#2563EB", "rose": "#BE123C", "slate": "#475569",
+}
+PEOPLE_COLORS = ["#7C3AED", "#2563EB", "#B45309", "#0F766E", "#15803D", "#BE123C", "#0369A1"]
+BAR_COLORS    = ["#2563EB", "#60A5FA", "#93C5FD"]
+
+L = {
+    "en": {
+        "eyebrow": "SESSION RECONSTRUCTION", "segments": "transcribed segments",
+        "length": "session length", "rt": "faster than real time", "main": "MAIN POINT",
+        "focus": "SESSION FOCUS", "people": "PARTICIPANTS AND ROLES",
+        "quality": "TRANSCRIPTION QUALITY", "next": "NEXT STEPS", "action": "ACTION",
+        "owner": "OWNER", "when": "WHEN", "q_length": "Session length",
+        "q_voiced": "Reconstructed speech", "q_pause": "Pause cuts",
+        "q_low": "Low-confidence segments (p < 0.8)", "q_spk": "Speaker identified by the model",
+        "q_unc": "Uncertain / unknown speaker", "q_mis": "Mis-detected languages",
+        "q_lat": "Median latency per segment", "corr": "Names and terms corrected",
+        "others": "Others", "none": "none",
+        "opps": "OPPORTUNITIES", "risks": "BLOCKERS / RISKS", "qs": "OPEN QUESTIONS",
+        "none_id": "None identified in this session",
+        "langs": {"es": "Spanish", "en": "English"},
+        "months": "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec",
+    },
+    "es": {
+        "eyebrow": "RECONSTRUCCIÓN DE SESIÓN", "segments": "segmentos transcritos",
+        "length": "de sesión", "rt": "más rápido que tiempo real", "main": "PUNTO PRINCIPAL",
+        "focus": "ENFOQUE DE LA SESIÓN", "people": "PARTICIPANTES Y ROLES",
+        "quality": "CALIDAD DE LA TRANSCRIPCIÓN", "next": "PRÓXIMOS PASOS",
+        "action": "ACCIÓN", "owner": "RESPONSABLE", "when": "CUÁNDO",
+        "q_length": "Duración de la sesión", "q_voiced": "Voz reconstruida",
+        "q_pause": "Cortes por pausa", "q_low": "Segmentos con confianza baja (p < 0.8)",
+        "q_spk": "Voz identificada por el modelo", "q_unc": "Hablante dudoso / desconocido",
+        "q_mis": "Idiomas mal detectados", "q_lat": "Latencia mediana por segmento",
+        "corr": "Nombres y términos corregidos", "others": "Otros", "none": "ninguno",
+        "opps": "OPORTUNIDADES", "risks": "BLOQUEOS / RIESGOS", "qs": "PREGUNTAS ABIERTAS",
+        "none_id": "Ninguno identificado en esta sesión",
+        "langs": {"es": "Español", "en": "Inglés"},
+        "months": "ene feb mar abr may jun jul ago sep oct nov dic",
+    },
+}
+
+
+def char_w(c):
+    if c in "ilI.,:;'|!·":
+        return 0.26
+    if c in "fjrt()[]-":
+        return 0.34
+    if c == " ":
+        return 0.28
+    if c in "mwMW—":
+        return 0.86
+    if c.isdigit():
+        return 0.56
+    if c.isupper():
+        return 0.68
+    return 0.53
+
+
+def text_w(s, size, bold=False, spacing=0.0):
+    w = sum(char_w(c) for c in s) * size * 1.05
+    return (w * 1.07 if bold else w) + spacing * len(s)
+
+
+def wrap(s, size, width, bold=False, spacing=0.0):
+    lines, cur = [], ""
+    for word in str(s).split():
+        trial = f"{cur} {word}" if cur else word
+        if not cur or text_w(trial, size, bold, spacing) <= width:
+            cur = trial
+        else:
+            lines.append(cur)
+            cur = word
+    return lines + [cur] if cur else lines or [""]
+
+
+def txt(x, y, s, size, fill, bold=False, anchor=None, spacing=None, italic=False):
+    attrs = f'x="{x:.0f}" y="{y:.0f}" font-size="{size}" fill="{fill}"'
+    if bold:
+        attrs += ' font-weight="700"'
+    if anchor:
+        attrs += f' text-anchor="{anchor}"'
+    if spacing:
+        attrs += f' letter-spacing="{spacing}"'
+    if italic:
+        attrs += ' font-style="italic"'
+    return f"<text {attrs}>{escape(str(s))}</text>"
+
+
+def para(out, x, y, s, size, width, lh, fill, **style):
+    """Draw wrapped text with the first baseline at y; return the last baseline."""
+    lines = wrap(s, size, width, style.get("bold", False), style.get("spacing") or 0.0)
+    for i, line in enumerate(lines):
+        out.append(txt(x, y + i * lh, line, size, fill, **style))
+    return y + (len(lines) - 1) * lh
+
+
+def box(x, y, w, h, fill="#FFFFFF", stroke="#DCE2EC", rx=12):
+    return (f'<rect x="{x:.0f}" y="{y:.0f}" width="{w:.0f}" height="{h:.0f}" rx="{rx}" '
+            f'fill="{fill}" stroke="{stroke}"/>')
+
+
+def hline(x1, y, x2, color="#EDF1F7"):
+    return f'<line x1="{x1:.0f}" y1="{y:.0f}" x2="{x2:.0f}" y2="{y:.0f}" stroke="{color}"/>'
+
+
+def fmt_dur(sec):
+    sec = int(round(sec))
+    h, rem = divmod(sec, 3600)
+    m, s = divmod(rem, 60)
+    return f"{h} h {m:02d} min {s:02d} s" if h else f"{m} min {s:02d} s"
+
+
+def metrics(session_dir, expected):
+    path = Path(session_dir) / "transcripts.jsonl"
+    segs = [json.loads(line) for line in path.open(encoding="utf-8") if line.strip()]
+    t0 = datetime.fromisoformat(segs[0]["ts"])
+    t1 = datetime.fromisoformat(segs[-1]["ts"]) + timedelta(seconds=segs[-1].get("duration_s") or 0)
+    voiced = sum(s.get("duration_s") or 0 for s in segs)
+    tx = [s["transcribe_s"] for s in segs if s.get("transcribe_s")]
+    status = Counter(s.get("speaker_status") for s in segs)
+    langs = Counter(s.get("lang") or "?" for s in segs)
+    return {
+        "n": len(segs), "t0": t0, "t1": t1, "length": (t1 - t0).total_seconds(),
+        "voiced": voiced, "pause": sum(1 for s in segs if s.get("end_reason") == "pause"),
+        "low": sum(1 for s in segs if (s.get("lang_prob") or 0) < 0.8),
+        "matched": status["matched"], "uncertain": status["uncertain"],
+        "unknown": status["unknown"], "langs": langs,
+        "misdetected": [code for code, _ in langs.most_common() if code not in expected],
+        "latency": statistics.median(tx) if tx else 0.0,
+        "rt": voiced / sum(tx) if tx else 0.0,
+    }
+
+
+def panel_title(out, x, y, w, title):
+    out.append(txt(x + PAD, y + 34, title, 13, "#5A6878", bold=True, spacing=2.2))
+    out.append(hline(x + PAD, y + 50, x + w - PAD))
+    return y + 50
+
+
+def render_focus(x, y, w, items, lang):
+    out = []
+    cur = panel_title(out, x, y, w, L[lang]["focus"])
+    inner = w - 2 * PAD
+    for i, item in enumerate(items):
+        base = cur + (28 if i == 0 else 30)
+        color = ACCENTS["amber"] if item.get("tone") == "warn" else ACCENTS["blue"]
+        out.append(f'<rect x="{x + PAD}" y="{base - 9:.0f}" width="8" height="8" rx="2" '
+                   f'fill="{color}"/>')
+        cur = para(out, x + PAD + 20, base, item["label"], 14.5, inner - 20, 19, "#0F172A",
+                   bold=True)
+        if item.get("detail"):
+            cur = para(out, x + PAD + 20, cur + 20, item["detail"], 12, inner - 20, 16,
+                       "#5A6878", italic=item.get("tone") == "warn")
+    h = cur + 26 - y
+    return [box(x, y, w, h)] + out, h
+
+
+def render_people(x, y, w, people, note, lang):
+    out = []
+    cur = panel_title(out, x, y, w, L[lang]["people"])
+    inner = w - 2 * PAD
+    for i, p in enumerate(people):
+        base = cur + (32 if i == 0 else 30)
+        speaks = p.get("status", "speaks") == "speaks"
+        color = PEOPLE_COLORS[i % len(PEOPLE_COLORS)] if speaks else "#94A3B8"
+        initial = p.get("initial") or p["name"][:1].upper()
+        out.append(f'<circle cx="{x + PAD + 14}" cy="{base - 5:.0f}" r="14" fill="{color}"/>')
+        out.append(txt(x + PAD + 14, base - 1, initial, 12, "#FFFFFF", bold=True,
+                       anchor="middle"))
+        cur = para(out, x + PAD + 40, base, p["name"], 14.5, inner - 40, 18, "#0F172A",
+                   bold=True)
+        if p.get("role"):
+            cur = para(out, x + PAD + 40, cur + 18, p["role"], 12, inner - 40, 16, "#5A6878")
+    if note:
+        out.append(hline(x + PAD, cur + 16, x + w - PAD, "#F3F6FA"))
+        cur = para(out, x + PAD, cur + 38, note, 12, inner, 16, "#5A6878", italic=True)
+    h = cur + 26 - y
+    return [box(x, y, w, h)] + out, h
+
+
+def render_quality(x, y, w, mt, expected, corrections, lang):
+    t = L[lang]
+    out = []
+    cur = panel_title(out, x, y, w, t["quality"])
+    inner = w - 2 * PAD
+    n = mt["n"]
+    parts = [(t["langs"].get(c, c.upper()), mt["langs"][c]) for c in expected if mt["langs"][c]]
+    other = n - sum(v for _, v in parts)
+    if other:
+        parts.append((t["others"], other))
+    bar_y, bx = cur + 20, x + PAD
+    out.append(f'<clipPath id="langbar"><rect x="{bx}" y="{bar_y:.0f}" width="{inner}" '
+               f'height="18" rx="9"/></clipPath><g clip-path="url(#langbar)">')
+    legend = []
+    for i, (name, count) in enumerate(parts):
+        color = "#CBD5E1" if name == t["others"] else BAR_COLORS[min(i, len(BAR_COLORS) - 1)]
+        seg_w = inner * count / n
+        out.append(f'<rect x="{bx:.1f}" y="{bar_y:.0f}" width="{seg_w + 0.5:.1f}" height="18" '
+                   f'fill="{color}"/>')
+        bx += seg_w
+        legend.append((f"{name} {count} ({round(100 * count / n)} %)", color))
+    out.append("</g>")
+    lx, ly = x + PAD, bar_y + 42
+    for label, color in legend:
+        lw = text_w(label, 12) + 30
+        if lx + lw > x + w - PAD:
+            lx, ly = x + PAD, ly + 20
+        out.append(f'<circle cx="{lx + 4:.0f}" cy="{ly - 4:.0f}" r="4" fill="{color}"/>')
+        out.append(txt(lx + 14, ly, label, 12, "#5A6878"))
+        lx += lw
+    cur = ly + 14
+    out.append(hline(x + PAD, cur, x + w - PAD))
+    pct = lambda k: f"{mt[k]} ({round(100 * mt[k] / n)} %)"  # noqa: E731
+    rows = [
+        (t["q_length"], fmt_dur(mt["length"])),
+        (t["q_voiced"], fmt_dur(mt["voiced"])),
+        (t["q_pause"], f"{mt['pause']} / {n}"),
+        (t["q_low"], pct("low")),
+        (t["q_spk"], f"{round(100 * mt['matched'] / n)} % ({mt['matched']} / {n})"),
+        (t["q_unc"], f"{mt['uncertain']} / {mt['unknown']}"),
+        (t["q_mis"], " · ".join(mt["misdetected"][:8]) or t["none"]),
+        (t["q_lat"], f"{mt['latency']:.2f} s"),
+    ]
+    for i, (label, value) in enumerate(rows):
+        base = cur + 26
+        out.append(txt(x + PAD, base, label, 12, "#5A6878"))
+        out.append(txt(x + w - PAD, base, value, 14.5, "#0F172A", bold=True, anchor="end"))
+        cur = base + 10
+        if i < len(rows) - 1:
+            out.append(hline(x + PAD, cur, x + w - PAD, "#F3F6FA"))
+    if corrections:
+        top, body = cur + 18, []
+        last = para(body, x + PAD + 16, top + 22, t["corr"], 12, inner - 32, 16, "#334155",
+                    bold=True)
+        for line in corrections:
+            last = para(body, x + PAD + 16, last + 18, line, 12, inner - 32, 16, "#5A6878")
+        bh = last + 16 - top
+        out.append(box(x + PAD, top, inner, bh, fill="#F8FAFC", stroke="none", rx=8))
+        out.append(f'<rect x="{x + PAD}" y="{top:.0f}" width="3" height="{bh:.0f}" rx="1.5" '
+                   f'fill="#94A3B8"/>')
+        out.extend(body)
+        cur = top + bh
+    h = cur + 26 - y
+    return [box(x, y, w, h)] + out, h
+
+
+def render_card(x, y, w, c, min_h=0):
+    acc = ACCENTS.get(c.get("accent", "blue"), ACCENTS["blue"])
+    px, inner, out = x + PAD, w - 2 * PAD, []
+    cur = para(out, px, y + 32, c["eyebrow"].upper(), 10, inner, 14, acc, bold=True,
+               spacing=1.7)
+    cur = para(out, px, cur + 27, c["title"], 19, inner, 24, "#0F172A", bold=True)
+    if c.get("subtitle"):
+        cur = para(out, px, cur + 22, c["subtitle"], 12, inner, 16, "#64748B")
+    cur += 14
+    out.append(hline(px, cur, px + inner, "#EEF2F6"))
+    for section in c.get("sections", []):
+        cur = para(out, px, cur + 22, section["label"].upper(), 9.5, inner, 13, "#94A3B8",
+                   bold=True, spacing=1.3)
+        dot = RED if section.get("tone") == "limit" else acc
+        for bullet in section.get("bullets", []):
+            base = cur + 21
+            out.append(f'<circle cx="{px + 5}" cy="{base - 5:.0f}" r="2.4" fill="{dot}"/>')
+            cur = para(out, px + 16, base, bullet, 12.5, inner - 16, 17, "#334155")
+        cur += 4
+    if c.get("chips"):
+        cx, top = px, cur + 12
+        for chip in c["chips"]:
+            cw = text_w(chip, 11, bold=True) + 28
+            if cx + cw > px + inner:
+                cx, top = px, top + 36
+            out.append(box(cx, top, cw, 28, fill="#F5F3FF", stroke="#DDD6FE", rx=6))
+            out.append(txt(cx + cw / 2, top + 18, chip, 11, acc, bold=True, anchor="middle"))
+            cx += cw + 10
+        cur = top + 28
+    for i, note in enumerate(c.get("notes", [])):
+        cur = para(out, px, cur + (26 if i == 0 else 17), note, 11.5, inner, 16, "#64748B")
+    h = max(cur + 24 - y, min_h)
+    head = [box(x, y, w, h, stroke="#E2E8F0"),
+            f'<rect x="{x}" y="{y:.0f}" width="{w}" height="4" rx="2" fill="{acc}"/>']
+    return head + out, h
+
+
+def render_signals(x, y, w, content, lang):
+    """Opportunities, blockers/risks and (when any) open questions as side-by-side panels."""
+    t = L[lang]
+    cols = [("opps", content.get("opportunities") or [], ACCENTS["green"]),
+            ("risks", content.get("blockers") or [], RED)]
+    if content.get("open_questions"):
+        cols.append(("qs", content["open_questions"], ACCENTS["violet"]))
+    col_w = (w - GAP * (len(cols) - 1)) // len(cols)
+    panels, bottom = [], 0
+    for i, (key, items, acc) in enumerate(cols):
+        cx, out = x + i * (col_w + GAP), []
+        px, inner = cx + PAD, col_w - 2 * PAD
+        out.append(txt(px, y + 34, t[key], 13, acc, bold=True, spacing=2.2))
+        out.append(hline(px, y + 50, px + inner, "#EEF2F6"))
+        cur = y + 50
+        if not items:
+            cur = para(out, px, cur + 26, t["none_id"], 12.5, inner, 17, "#94A3B8", italic=True)
+        for item in items:
+            base = cur + (26 if cur == y + 50 else 21)
+            out.append(f'<circle cx="{px + 5}" cy="{base - 5:.0f}" r="2.4" fill="{acc}"/>')
+            cur = para(out, px + 16, base, item, 12.5, inner - 16, 17, "#334155")
+        panels.append((cx, col_w, acc, out))
+        bottom = max(bottom, cur)
+    h = bottom + 24 - y
+    elems = []
+    for cx, cw, acc, out in panels:
+        elems += [box(cx, y, cw, h, stroke="#E2E8F0"),
+                  f'<rect x="{cx}" y="{y:.0f}" width="{cw}" height="4" rx="2" fill="{acc}"/>']
+        elems += out
+    return elems, h
+
+
+def render_next(x, y, w, nxt, lang):
+    t = L[lang]
+    px, inner, out = x + PAD, w - 2 * PAD, []
+    out.append(txt(px, y + 34, t["next"], 13, "#5A6878", bold=True, spacing=2.2))
+    c_owner, c_when = px + inner * 0.60, px + inner * 0.80
+    for label, cx in ((t["action"], px), (t["owner"], c_owner), (t["when"], c_when)):
+        out.append(txt(cx, y + 66, label, 11.5, "#64748B", bold=True, spacing=1.6))
+    cur = y + 76
+    out.append(hline(px, cur, px + inner, "#DCE2EC"))
+    for row in nxt.get("rows", []):
+        base = cur + 26
+        last = para(out, px, base, row["action"], 13.5, inner * 0.58, 18, "#1E293B")
+        last = max(last, para(out, c_owner, base, row.get("owner", ""), 13.5, inner * 0.19,
+                              18, "#1E293B"))
+        last = max(last, para(out, c_when, base, row.get("when", ""), 12.5, inner * 0.20,
+                              18, "#2563EB", bold=True))
+        cur = last + 14
+        out.append(hline(px, cur, px + inner, "#F3F6FA"))
+    if nxt.get("note"):
+        cur = para(out, px, cur + 24, nxt["note"], 12, inner, 16, "#5A6878", italic=True)
+    h = cur + 24 - y
+    return [box(x, y, w, h)] + out, h
+
+
+def render(session_dir, content):
+    lang = content.get("lang", "en")
+    t = L[lang]
+    expected = content.get("expected_langs", ["es", "en"])
+    mt = metrics(session_dir, expected)
+    body = []
+
+    # Header
+    t0, t1 = mt["t0"], mt["t1"]
+    month = t["months"].split()[t0.month - 1]
+    date_line = (f"{t0.day} {month} {t0.year} · {t0:%H:%M} – {t1:%H:%M} · "
+                 f"{mt['n']} {t['segments']}")
+    title_size = 32
+    while text_w(content["title"], title_size, bold=True) > 1030 and title_size > 22:
+        title_size -= 1
+    hours, minutes = divmod(int(mt["length"] // 60), 60)
+    stats = [(str(mt["n"]), t["segments"]),
+             (f"{hours}h {minutes:02d}m" if hours else f"{minutes}m", t["length"]),
+             (f"{mt['rt']:.1f}×", t["rt"])]
+    body += ['<rect x="0" y="0" width="1680" height="160" fill="#0B1220"/>',
+             '<rect x="0" y="158" width="1680" height="4" fill="#2563EB"/>',
+             '<rect x="48" y="40" width="6" height="24" rx="3" fill="#3B82F6"/>',
+             txt(68, 58, content.get("eyebrow", t["eyebrow"]).upper(), 13, "#94A3B8",
+                 bold=True, spacing=2.2),
+             txt(48, 110, content["title"], title_size, "#F8FAFC", bold=True),
+             txt(48, 140, date_line, 15, "#A8B6CA"),
+             hline(1120, 48, 1632, "#22304A")]
+    for i, (value, label) in enumerate(stats):
+        body.append(txt(1120 + 180 * i, 94, value, 30, "#F8FAFC", bold=True))
+        body.append(txt(1120 + 180 * i, 116, label, 12, "#8FA0B6"))
+
+    # Main point band
+    y = 184
+    mp = content["main_point"]
+    pill = mp.get("pill", "")
+    pill_w = text_w(pill, 9.5, True, 1.1) + 32 if pill else 0
+    status_w = text_w(mp.get("status", ""), 11, True)
+    head_w = FULL_W - 2 * PAD - 154 - status_w - pill_w - 48
+    band = []
+    cur = para(band, M + PAD + 154, y + 38, mp["headline"], 21, head_w, 26, "#1F2937",
+               bold=True)
+    right = M + FULL_W - PAD
+    if pill:
+        band.append(box(right - pill_w, y + 22, pill_w, 26, stroke="#EFD6A4", rx=13))
+        band.append(txt(right - pill_w / 2, y + 39, pill, 9.5, "#B45309", bold=True,
+                        anchor="middle", spacing=1.1))
+    if mp.get("status"):
+        band.append(txt(right - pill_w - 20, y + 38, mp["status"], 11, "#B45309", bold=True,
+                        anchor="end"))
+    cur = para(band, M + PAD, cur + 32, mp.get("body", ""), 12.5, FULL_W - 2 * PAD, 20,
+               "#475569")
+    band_h = cur + 22 - y
+    body += [box(M, y, FULL_W, band_h, fill="#FDF6E9", stroke="#EFD6A4"),
+             f'<rect x="{M}" y="{y}" width="5" height="{band_h:.0f}" rx="2.5" fill="#B45309"/>',
+             f'<rect x="{M + PAD}" y="{y + 20}" width="140" height="24" rx="12" '
+             f'fill="#B45309"/>',
+             txt(M + PAD + 70, y + 36, t["main"], 9.5, "#FFFFFF", bold=True, anchor="middle",
+                 spacing=1.2)] + band
+    y += band_h + GAP
+
+    # Sidebar
+    side_y = y
+    blocks = [
+        lambda yy: render_focus(M, yy, SIDE_W, content.get("focus", []), lang),
+        lambda yy: render_people(M, yy, SIDE_W, content.get("participants", []),
+                                 content.get("participants_note"), lang),
+        lambda yy: render_quality(M, yy, SIDE_W, mt, expected,
+                                  content.get("corrections", []), lang),
+    ]
+    for block in blocks:
+        elems, h = block(side_y)
+        body += elems
+        side_y += h + GAP
+
+    # Topic cards: main column beside the sidebar, then two-column rows below it
+    topics = list(content.get("topics", []))
+    main_y = y
+    while topics and main_y < side_y - GAP:
+        elems, h = render_card(MAIN_X, main_y, MAIN_W, topics.pop(0))
+        body += elems
+        main_y += h + GAP
+    y = max(side_y, main_y)
+    while topics:
+        if len(topics) == 1:
+            elems, h = render_card(M, y, FULL_W, topics.pop(0))
+            body += elems
+        else:
+            left, right_c = topics.pop(0), topics.pop(0)
+            h = max(render_card(M, y, HALF_W, left)[1],
+                    render_card(M + HALF_W + GAP, y, HALF_W, right_c)[1])
+            body += render_card(M, y, HALF_W, left, h)[0]
+            body += render_card(M + HALF_W + GAP, y, HALF_W, right_c, h)[0]
+        y += h + GAP
+
+    for key in ("opportunities", "blockers"):
+        if key not in content:
+            print(f"warning: content JSON has no '{key}' key; the panel will read "
+                  f"'{t['none_id']}'", file=sys.stderr)
+    elems, h = render_signals(M, y, FULL_W, content, lang)
+    body += elems
+    y += h + GAP
+
+    if content.get("next_steps"):
+        elems, h = render_next(M, y, FULL_W, content["next_steps"], lang)
+        body += elems
+        y += h + GAP
+
+    # Footer
+    body.append(hline(M, y, M + FULL_W, "#CBD5E1"))
+    cur = y + 2
+    for line in content.get("footer", []):
+        cur = para(body, M, cur + 18, line, 10.5, FULL_W, 15, "#94A3B8")
+    height = int(cur + 28)
+
+    desc = content.get("description", content["main_point"]["headline"])
+    head = ['<?xml version="1.0" encoding="UTF-8"?>',
+            f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{height}" '
+            f'viewBox="0 0 {W} {height}" font-family="{FONT}" xml:lang="{lang}" role="img" '
+            f'aria-labelledby="ttl dsc">',
+            f'<title id="ttl">{escape(content["title"])}</title>',
+            f'<desc id="dsc">{escape(desc)}</desc>',
+            f'<rect width="{W}" height="{height}" fill="#EEF2F7"/>']
+    return "\n".join(head + body + ["</svg>", ""])
+
+
+def export_png(svg_path):
+    """Export svg_path to a size-optimized PNG next to it (same stem, .png).
+
+    Inkscape rasterizes at full width, then ImageMagick quantizes to 64 colors without
+    dithering and strips metadata (~1.1 MB -> ~0.27 MB). The raw export lives in a private
+    temp dir that is always deleted; only the final PNG is kept, beside the SVG.
+    """
+    svg = Path(svg_path)
+    png = svg.with_suffix(".png")
+    for tool in ("inkscape", "magick"):
+        if shutil.which(tool) is None:
+            sys.exit(f"error: '{tool}' not found on PATH; cannot export {png}")
+    try:
+        minidom.parse(str(svg))
+    except Exception as exc:
+        sys.exit(f"error: {svg} is not well-formed XML: {exc}")
+    with tempfile.TemporaryDirectory(prefix="session-summary-") as tmp:
+        raw = Path(tmp) / "full.png"
+        steps = [
+            ["inkscape", str(svg), "--export-type=png", f"--export-filename={raw}"],
+            ["magick", str(raw), "-strip", "-colors", "64", "-dither", "None",
+             "-define", "png:compression-level=9", "-define", "png:compression-strategy=1",
+             str(png)],
+        ]
+        for cmd in steps:
+            res = subprocess.run(cmd, capture_output=True, text=True)
+            if res.returncode != 0:
+                sys.exit(f"error: {cmd[0]} failed ({res.returncode}): {res.stderr.strip()}")
+    if not png.is_file():
+        sys.exit(f"error: export finished but {png} was not created")
+    print(f"wrote {png} ({png.stat().st_size / 1024:.0f} KB)")
+
+
+def main():
+    args = [a for a in sys.argv[1:] if a != "--no-png"]
+    no_png = len(args) != len(sys.argv) - 1
+    if len(args) != 3:
+        sys.exit("usage: render.py <session_dir> <content.json> <out.svg> [--no-png]")
+    session_dir, content_path, out_path = args
+    content = json.loads(Path(content_path).read_text(encoding="utf-8"))
+    Path(out_path).write_text(render(session_dir, content), encoding="utf-8")
+    print(f"wrote {out_path}")
+    if not no_png:
+        export_png(out_path)
+
+
+if __name__ == "__main__":
+    main()

+ 23 - 1
AGENTS.md

@@ -88,6 +88,26 @@ publish-only.
 
 `stt-daemon` with no subcommand runs the daemon (legacy `--check` / `--list-sources` still work).
 
+## Post-conference reconstruction (session-summary)
+
+After a session, the `session-summary` skill turns `transcripts/<session>/` into the files worth
+sharing. It is an **agent run**, not a process: no console script, no socket, no effect on
+supervisor spawn counts. Full reference: `docs/SESSION-SUMMARY.md`.
+
+- Evidence order: `transcripts.jsonl` is the truth; `brief.md` is low-trust input that the skill
+  rewrites. Never modify `transcripts.jsonl` or `audio/`, and work on one session directory only
+  (no cross-session reads; shared knowledge lives in the skill's glossary, which is gitignored).
+- Outputs, all inside the session directory: `session-summary.svg`, `session-summary.png`,
+  `session-summary.content.json`, the `.es.` trio, and a corrected `brief.md`. A run is done only
+  when both SVGs and both PNGs exist; a ~1 MB PNG means quantization did not run.
+- `scripts/render.py <session> <content.json> <out.svg> [--no-png]` renders and validates the SVG
+  and exports the PNG via **Inkscape + ImageMagick**, both required on `PATH`. `--no-png` is for
+  drafts only — never hand-write or hand-export the SVG.
+- `.opencode/` is gitignored except the `session-summary` skill, which is tracked repo tooling
+  (see the progressive un-ignore in `.gitignore`) — with one exception inside it:
+  `references/glossary.md` is private client vocabulary and stays ignored. Nothing the skill
+  writes is committed: session directories stay ignored with `transcripts/`.
+
 ## Config
 
 - Loaded from `config.toml` in the **current working directory** only (no env var, no XDG lookup);
@@ -150,7 +170,9 @@ publish-only.
 ## Conventions & gotchas
 
 - Never commit gitignored runtime output: `config.toml`, `transcripts/`, `audio.json`, `audio/`,
-  `troubleshoot/` (audit/debug/triage), `brief/`, `speakers/` (biometric), `models/`, `.omo/`.
+  `troubleshoot/` (audit/debug/triage), `brief/`, `speakers/` (biometric), `models/`, `.omo/`,
+  `questions.json`, and `.opencode/skills/session-summary/references/glossary.md` (client
+  vocabulary).
 - New assistant tools: register in `assistant/tools.py`. Model-supplied arguments are JSON-parsed and
   schema-validated before running; set `side_effect` and `requires_confirmation` for anything
   non-read-only.

+ 2 - 0
README.md

@@ -11,6 +11,7 @@ An always-on speech-to-text daemon that listens to a PulseAudio/PipeWire source,
 - **Output** to stdout (human-readable) and/or JSONL files
 - **Run non-blocking** with worker threads so audio keeps flowing during transcription
 - **Act on speech** through the separate `stt-assistant` process (modes, commands, chat, tools, audit)
+- **Reconstruct** a finished session with the `session-summary` skill: English and Spanish SVG/PNG summaries plus a corrected brief, all inside the session directory
 
 ### Architecture
 
@@ -158,6 +159,7 @@ See [docs/CLI.md](docs/CLI.md) for every subcommand and [docs/CONFIGURATION.md](
 - [docs/CLI.md](docs/CLI.md) — `stt-daemon` subcommands and common overrides.
 - [docs/ASSISTANT.md](docs/ASSISTANT.md) — the assistant layer: modes, command flow, LLM, tools, audit.
 - [docs/TRIAGE.md](docs/TRIAGE.md) — the triage panel: Laya lane, the living brief, and questions file.
+- [docs/SESSION-SUMMARY.md](docs/SESSION-SUMMARY.md) — the post-conference stage: reconstructs a session into SVG/PNG summaries and a corrected brief.
 - [docs/SPEAKERS.md](docs/SPEAKERS.md) — speaker identification: enrollment, thresholds, store, and commands.
 - [docs/OUTPUT.md](docs/OUTPUT.md) — stdout, JSONL, and WAV output formats and fields.
 - [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) — common errors and how to fix them.

+ 143 - 0
docs/SESSION-SUMMARY.md

@@ -0,0 +1,143 @@
+# Session summary (post-conference reconstruction)
+
+`session-summary` is an agent skill that turns a finished recording into the files you actually
+share. It is the post-conference stage of the pipeline: the daemon and the assistant produce
+evidence during the meeting, this skill reconstructs it afterwards.
+
+It is **not** a daemon process, a console script, or a socket client. It runs inside an agent
+session (OpenCode), driven by
+[`.opencode/skills/session-summary/SKILL.md`](../.opencode/skills/session-summary/SKILL.md).
+
+## Inputs
+
+Everything comes from one session directory under `transcripts/` (gitignored):
+
+| Input | Role |
+|---|---|
+| `transcripts.jsonl` | **The evidence.** Read-only. `seg_id`, `ts`, `lang`, `lang_prob`, `text`, `speaker`, `speaker_id`, `speaker_status`, `speaker_score` |
+| `audio/*.wav` | Optional corroboration (only written when `[output].save_audio = true`) |
+| `brief.md` | **Low-trust input.** A list of possible leads, never a source of facts |
+| `speakers/speakers.json` | Name mapping only (`speakers.<id>.name`); not an attendee list |
+| `questions.json` | Not read by this skill |
+
+Rules that shape every run:
+
+- A run touches exactly **one** session directory. It must not glob, list, or compare other
+  sessions: cross-session knowledge only reaches it through the glossary.
+- `transcripts.jsonl` and `audio/` are never modified.
+- Every claim in the output must be traceable to a `seg_id`. If it cannot be, it is not written.
+- Paraphrase, never invent. An unresolvable token is reported as unresolved, not guessed.
+
+## Outputs
+
+Every run writes these files into the same session directory, and nothing elsewhere:
+
+| File | What it is |
+|---|---|
+| `session-summary.svg` | One-page visual reconstruction, English |
+| `session-summary.png` | Shareable export of the SVG (1680 px, 64-color, ~0.27 MB) |
+| `session-summary.content.json` | The content the English SVG was rendered from |
+| `session-summary.es.svg` | Spanish reconstruction — same topics, same order, same claims |
+| `session-summary.es.png` | Shareable export of the Spanish SVG |
+| `session-summary.es.content.json` | Spanish content JSON |
+| `brief.md` | Rewritten in place, keeping the `Summary:` … `Prior Details:` layout |
+
+The run is **not complete** until both SVGs and both PNGs exist and have passed verification.
+A PNG around 1 MB means the quantization step did not run.
+
+The page layout is fixed by the renderer: header with computed stats, a main-point band, a
+sidebar (session focus, participants and roles, transcription quality), any number of topic
+cards, an Opportunities / Blockers-Risks / Open-questions block, optional next steps, and a
+footer of method notes.
+
+## How to run it
+
+Invoke the skill from an agent session and name the session directory, for example "summarize
+`transcripts/2026-10-30-13-07-19`" or "fix the brief of that session". The skill drives the
+whole flow: load the evidence, reconstruct, write the content JSONs, render, verify, correct the
+brief, then update the glossary.
+
+Rendering is a separate script; the skill calls it, and you can call it directly for a
+re-render after editing a content JSON:
+
+```bash
+S=transcripts/<session-id>
+R=.opencode/skills/session-summary/scripts/render.py
+uv run python $R $S $S/session-summary.content.json    $S/session-summary.svg
+uv run python $R $S $S/session-summary.es.content.json $S/session-summary.es.svg
+```
+
+| Flag | Meaning |
+|---|---|
+| `<session_dir>` | Directory holding `transcripts.jsonl`; stats are computed from it |
+| `<content.json>` | Content produced by the run |
+| `<out.svg>` | Destination; the matching `.png` is written beside it |
+| `--no-png` | Skip the PNG. Draft re-renders only; a final render must not use it |
+
+`render.py` writes the SVG, validates that it parses as XML, then exports the PNG with
+**Inkscape** and quantizes it with **ImageMagick** (`magick`). Both must be on `PATH`; a missing
+binary makes the run fail loudly instead of leaving a silent fallback. It prints
+`wrote <svg>` and `wrote <png> (<n> KB)`, and warns when the content JSON lacks an
+`opportunities` or `blockers` key.
+
+## The brief is corrected, not trusted
+
+`brief.md` is written live by a small, fast model, so the skill treats it as unreliable and
+rewrites it after reconstructing from the JSONL. The error classes it checks come from real
+failures and map directly onto known live-mode behaviour:
+
+- sections and sentences cut off mid-word (`…Open Ques`) — the same failure the character cap
+  used to cause before the brief was made non-destructive;
+- the same fact repeated in slightly different wording;
+- `Unknown participant` for lines the JSONL attributes to a named speaker;
+- ASR mishearings copied literally;
+- mis-detected languages reported as statements ("said in Cyrillic");
+- hedged statements promoted to "approved" decisions or "confirmed" opportunities;
+- reversed direction (buyer vs seller, who pays whom);
+- worked examples, on-screen steps and numbers dropped;
+- acknowledgements logged as facts.
+
+Name and term corrections are applied through the glossary (for example "Good One" → HUD-1).
+
+## The glossary is the only shared state
+
+`.opencode/skills/session-summary/references/glossary.md` is the sole file outside the session
+directory a run may modify. It holds domain terms and known ASR mishearings, and every new row
+cites its evidence as `(<session-id>, seg <n>)`. That is deliberate: it is how knowledge
+survives across sessions without a run ever reading another session's evidence.
+
+It is **gitignored private state**, like `questions.json`: it accumulates client vocabulary from
+real recordings, so it never enters the repository. A fresh clone has no glossary, and the first
+run creates one from what it observes. Losing it costs accumulated corrections, not data.
+
+## Version control
+
+`.opencode/` is gitignored (it holds `node_modules` and local agent state). The skill directory
+is the exception — it is tracked repo tooling, minus its glossary. The rule lives in
+`.gitignore` as a progressive un-ignore, with the glossary re-ignored last:
+
+```gitignore
+.opencode/*
+!.opencode/skills/
+.opencode/skills/*
+!.opencode/skills/session-summary/
+.opencode/skills/session-summary/references/glossary.md
+```
+
+Two things stay untracked: the glossary (private client vocabulary, see above) and everything the
+skill writes. The outputs — the session directory and its SVG/PNG/JSON — are gitignored with
+`transcripts/`, and the corrected `brief.md` lives inside that directory. Nothing this skill
+writes is ever committed.
+
+## Troubleshooting
+
+| Symptom | Cause |
+|---|---|
+| `error: inkscape not found` / `magick` missing | Install both; the PNG step has no fallback |
+| `PNG is ~1 MB` | Quantization skipped; re-run without `--no-png` |
+| `no 'opportunities' key` warning | The content JSON is missing a required block; add it and re-render |
+| SVG opens clipped or overlapping | Do not hand-write the SVG; fix the content JSON and re-render |
+| Run reports unresolved tokens | Expected when the audio is genuinely ambiguous; they belong in the glossary |
+
+See also [OUTPUT.md](OUTPUT.md) for the JSONL fields the reconstruction depends on, and
+[TRIAGE.md](TRIAGE.md) for how `brief.md` is produced live.