From 9adecefd5dfc7dea0fed04275715261ede43cc79 Mon Sep 17 00:00:00 2001 From: vnnkl <8235476+vnnkl@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:51:20 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Tom=20draws=20back=20=E2=80=94=20th?= =?UTF-8?q?e=20oracle=20can=20answer=20with=20a=20sketch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persona now carries a drawing protocol: when the writer asks for a drawing, a map, or a move in a drawn game, the model may emit one ⟦draw:x,y x,y; …⟧ block of quill strokes on a 100×100 canvas, placed among its words. The stream parser routes complete blocks as Event::Draw (prose before a block is flushed, unterminated tails are dropped, bad blocks are ignored); draw.rs scales the sketch onto the page, centers it below the prose written so far, and subdivides every segment into pen-sized steps with a slow sine wobble so the lines read as drawn by a hand, not plotted. The reply animator inks it like any other streamed chunk, and prose may continue beneath. --oracle-test prints the raw strokes of any sketch, for judging how well a model draws before pointing the diary at it. --- README.md | 14 +++ riddle/src/draw.rs | 259 +++++++++++++++++++++++++++++++++++++++++++ riddle/src/help.rs | 6 + riddle/src/main.rs | 59 ++++++++++ riddle/src/oracle.rs | 245 +++++++++++++++++++++++++++------------- 5 files changed, 506 insertions(+), 77 deletions(-) create mode 100644 riddle/src/draw.rs diff --git a/README.md b/README.md index e5f99bd..03e5eb5 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ pen. (Or install it from the **Store** app right on the tablet.) | Write, then rest the pen | The diary drinks your ink and Tom replies | | Write *"show me what I wrote about…"* | The remembered page **rises through the paper**: the date, your own handwriting rewriting itself stroke by stroke, Tom's old reply — all in faded ink. Touch the pen anywhere and today's page returns | | Write *"what do you remember?"* | Tom answers with a handwritten list of remembered moments | +| Write *"draw me…"* | Tom **draws back**: a sketch inks itself onto the page, stroke by stroke, in his own hand | | Flip the marker | Erase | | Draw a large **?** | Summon the built-in guide | | Tap five fingers at once | Leave the diary *(takeover mode)* | @@ -109,6 +110,19 @@ the last ~400 pages are kept). `RIDDLE_MEMORY=off` in `oracle.env` turns all of it off — no storage, and nothing extra sent with requests. Set `RIDDLE_TZ_OFFSET` (hours from UTC) so memory dates read right. +## Tom draws back + +Ask in ink — *"draw me a map of the grounds"*, *"sketch me a cat"* — and the +reply is a **drawing**: pen strokes inking themselves onto the page, placed +among whatever words come with them. It also plays: draw a noughts-and-crosses +grid and make your move — your ink fades when the diary drinks it, so Tom +redraws the whole board with his move added. + +Under the hood the oracle answers with a block of stroke coordinates that +riddle scales onto the page and draws with the small unsteadiness of a real +hand. Works with both oracle backends; how well Tom draws depends entirely on +the model behind the diary — stronger models sketch better. + ## The oracle (the "spirit" in the diary) The diary's replies come from a vision LLM that reads your handwriting from the diff --git a/riddle/src/draw.rs b/riddle/src/draw.rs new file mode 100644 index 0000000..1cc36ff --- /dev/null +++ b/riddle/src/draw.rs @@ -0,0 +1,259 @@ +//! Tom's drawing hand. The oracle may answer with a sketch — a ⟦draw:…⟧ +//! block of pen strokes on an abstract 100×100 canvas — and this module turns +//! that block into screen-space strokes the reply animator can ink. +//! +//! Two halves: +//! * [`parse`] — the block's payload into canvas strokes (forgiving: +//! malformed points are skipped, coordinates clamped to the canvas). +//! * [`place`] — canvas strokes onto the page: uniform scale, centered, +//! below the prose written so far, subdivided into pen-sized steps with a +//! slow wobble so the lines read as drawn by a hand, not plotted. + +use crate::fb::{SCREEN_H, SCREEN_W}; + +/// Strokes on the model's 0–100 canvas (0,0 = top left). +pub type Sketch = Vec>; + +/// The abstract canvas is 100×100. +const CANVAS: f32 = 100.0; +/// The canvas never maps larger than this many pixels per side. +const MAX_SIDE: f32 = 900.0; +/// Least vertical room worth drawing in; below this the sketch is skipped. +const MIN_ROOM: i32 = 220; +/// Side margins, matching the reply text margins. +const MARGIN_X: i32 = 120; +/// Bottom of the drawable page. +const FLOOR: i32 = SCREEN_H as i32 - 140; +/// Ink is laid down in steps about this long (px) — the animator draws +/// point-to-point, so steps set both smoothness and writing speed. +const STEP: f32 = 2.5; + +/// Parse a draw block's inner text ("draw: x,y x,y; x,y …") into canvas +/// strokes. Strokes are ';'-separated, points whitespace-separated. A lone +/// point is a dot. Returns None when nothing drawable survives. +pub fn parse(inner: &str) -> Option { + let body = inner.trim(); + let body = strip_prefix_ci(body, "draw")?; + let body = body.trim_start_matches([':', ' ', '\n', '\r', '\t']); + let mut sketch = Vec::new(); + for stroke_text in body.split(';') { + let mut stroke = Vec::new(); + for pt in stroke_text.split_whitespace() { + let Some((x, y)) = pt.split_once(',') else { continue }; + let (Ok(x), Ok(y)) = (x.trim().parse::(), y.trim().parse::()) else { + continue; + }; + if !x.is_finite() || !y.is_finite() { + continue; + } + stroke.push((x.clamp(0.0, CANVAS), y.clamp(0.0, CANVAS))); + } + if !stroke.is_empty() { + sketch.push(stroke); + } + } + if sketch.is_empty() { None } else { Some(sketch) } +} + +fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { + if s.len() >= prefix.len() && s[..prefix.len()].eq_ignore_ascii_case(prefix) { + Some(&s[prefix.len()..]) + } else { + None + } +} + +/// Place a sketch on the page: its canvas bounding box is scaled uniformly +/// (capped by the page margins and MAX_SIDE), centered horizontally, and laid +/// with its top at `y_top`. Returns the screen strokes plus the bottom edge of +/// the ink (where prose may continue), or None when the page has no room. +pub fn place(sketch: &Sketch, y_top: i32, seed: u32) -> Option<(Vec>, i32)> { + let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); + for s in sketch { + for &(x, y) in s { + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x); + y1 = y1.max(y); + } + } + if x0 > x1 { + return None; + } + let room = FLOOR - y_top; + if room < MIN_ROOM { + return None; + } + // Uniform scale: the full canvas maps to at most MAX_SIDE / page width; + // the sketch's own height must also fit the room below the prose. + let bw = (x1 - x0).max(1.0); + let bh = (y1 - y0).max(1.0); + let mut scale = (MAX_SIDE / CANVAS).min((SCREEN_W as i32 - 2 * MARGIN_X) as f32 / CANVAS); + scale = scale.min(room as f32 / bh); + let ox = (SCREEN_W as f32 - bw * scale) / 2.0 - x0 * scale; + let oy = y_top as f32 - y0 * scale; + + let mut out = Vec::with_capacity(sketch.len()); + let mut bottom = y_top; + for (si, stroke) in sketch.iter().enumerate() { + let h = hash(seed, si as u32); + let phase = (h % 628) as f32 / 100.0; + let placed = wobble_stroke(stroke, scale, ox, oy, phase, h); + for &(_, y) in &placed { + bottom = bottom.max(y); + } + if !placed.is_empty() { + out.push(placed); + } + } + if out.is_empty() { None } else { Some((out, bottom)) } +} + +/// Map one canvas stroke to the screen, subdividing every segment into +/// STEP-sized pieces displaced sideways by a slow sine — the small unsteadiness +/// of a real hand. Deterministic for a given phase/seed. +fn wobble_stroke( + stroke: &[(f32, f32)], + scale: f32, + ox: f32, + oy: f32, + phase: f32, + seed: u32, +) -> Vec<(i32, i32)> { + const WAVELENGTH: f32 = 70.0; + const AMP: f32 = 1.6; + let jitter = |i: u32| (hash(seed, 0x0DD + i) % 300) as f32 / 100.0 - 1.5; + + let pts: Vec<(f32, f32)> = stroke + .iter() + .enumerate() + .map(|(i, &(x, y))| (x * scale + ox + jitter(i as u32 * 2), y * scale + oy + jitter(i as u32 * 2 + 1))) + .collect(); + let mut out: Vec<(i32, i32)> = Vec::new(); + let mut push = |x: f32, y: f32| { + let p = (x.round() as i32, y.round() as i32); + if out.last() != Some(&p) { + out.push(p); + } + }; + if pts.len() == 1 { + push(pts[0].0, pts[0].1); + return out; + } + let mut dist = 0.0f32; // arc length so the wobble is continuous across segments + for w in pts.windows(2) { + let ((ax, ay), (bx, by)) = (w[0], w[1]); + let (dx, dy) = (bx - ax, by - ay); + let len = (dx * dx + dy * dy).sqrt(); + let steps = (len / STEP).ceil().max(1.0) as u32; + // Unit normal for the sideways wobble. + let (nx, ny) = if len > 0.0 { (-dy / len, dx / len) } else { (0.0, 0.0) }; + for i in 0..=steps { + let t = i as f32 / steps as f32; + let along = dist + t * len; + // The wobble dies at the stroke's own vertices so corners land + // where the model put them. + let envelope = (t * (1.0 - t) * 4.0).min(1.0); + let w = (along / WAVELENGTH * std::f32::consts::TAU + phase).sin() * AMP * envelope; + push(ax + dx * t + nx * w, ay + dy * t + ny * w); + } + dist += len; + } + out +} + +/// A deterministic per-sketch seed: the same drawing always wobbles the same. +pub fn sketch_seed(sketch: &Sketch) -> u32 { + sketch + .iter() + .flatten() + .fold(0x51D2u32, |h, &(x, y)| hash(h, ((x * 7.0) as u32) ^ (((y * 13.0) as u32) << 8))) +} + +fn hash(seed: u32, i: u32) -> u32 { + let mut h = seed.wrapping_add(i.wrapping_mul(0x9E37_79B1)); + h ^= h >> 15; + h = h.wrapping_mul(0x85EB_CA6B); + h ^ (h >> 13) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_strokes_and_dots() { + let s = parse("draw: 0,0 100,100; 50,50").unwrap(); + assert_eq!(s.len(), 2); + assert_eq!(s[0], vec![(0.0, 0.0), (100.0, 100.0)]); + assert_eq!(s[1], vec![(50.0, 50.0)]); + } + + #[test] + fn parse_tolerates_case_newlines_and_junk() { + let s = parse("DRAW\n 10,20 nonsense 30,40 ;; 5,5").unwrap(); + assert_eq!(s.len(), 2); + assert_eq!(s[0], vec![(10.0, 20.0), (30.0, 40.0)]); + } + + #[test] + fn parse_clamps_to_canvas() { + let s = parse("draw: -50,300").unwrap(); + assert_eq!(s[0], vec![(0.0, 100.0)]); + } + + #[test] + fn parse_rejects_empty_and_alien_blocks() { + assert!(parse("draw:").is_none()); + assert!(parse("draw: x,y ;").is_none()); + assert!(parse("show:3").is_none()); + } + + #[test] + fn place_scales_centers_and_stays_on_page() { + let sketch: Sketch = vec![vec![(0.0, 0.0), (100.0, 100.0)]]; + let (strokes, bottom) = place(&sketch, 300, 7).unwrap(); + let all: Vec<(i32, i32)> = strokes.concat(); + let (min_x, max_x) = ( + all.iter().map(|p| p.0).min().unwrap(), + all.iter().map(|p| p.0).max().unwrap(), + ); + let min_y = all.iter().map(|p| p.1).min().unwrap(); + assert!(min_x >= MARGIN_X - 4, "left edge {min_x}"); + assert!(max_x <= SCREEN_W as i32 - MARGIN_X + 4, "right edge {max_x}"); + assert!((min_y - 300).abs() <= 4, "top {min_y}"); + assert!(bottom > 300 + 400, "diagonal should be tall, bottom {bottom}"); + assert!(bottom <= FLOOR + 4); + // Centered: midpoint of the span sits near the page middle. + assert!(((min_x + max_x) / 2 - SCREEN_W as i32 / 2).abs() < 20); + } + + #[test] + fn place_subdivides_into_pen_steps() { + let sketch: Sketch = vec![vec![(0.0, 50.0), (100.0, 50.0)]]; + let (strokes, _) = place(&sketch, 300, 7).unwrap(); + // A full-width line is ~1380px: at ~2.5px steps that is many points. + assert!(strokes[0].len() > 300, "only {} points", strokes[0].len()); + } + + #[test] + fn place_refuses_a_full_page() { + let sketch: Sketch = vec![vec![(0.0, 0.0), (100.0, 100.0)]]; + assert!(place(&sketch, SCREEN_H as i32 - 150, 7).is_none()); + } + + #[test] + fn place_fits_height_to_remaining_room() { + let sketch: Sketch = vec![vec![(0.0, 0.0), (100.0, 100.0)]]; + let y_top = SCREEN_H as i32 - 140 - 300; // exactly 300px of room + let (_, bottom) = place(&sketch, y_top, 7).unwrap(); + assert!(bottom <= FLOOR + 4, "bottom {bottom} spills past the page"); + } + + #[test] + fn place_is_deterministic() { + let sketch: Sketch = vec![vec![(10.0, 10.0), (90.0, 40.0), (20.0, 80.0)]]; + assert_eq!(place(&sketch, 400, 42), place(&sketch, 400, 42)); + assert_ne!(place(&sketch, 400, 42), place(&sketch, 400, 43)); + } +} diff --git a/riddle/src/help.rs b/riddle/src/help.rs index 1cc0daf..151c505 100644 --- a/riddle/src/help.rs +++ b/riddle/src/help.rs @@ -104,6 +104,9 @@ const BODY_TAKEOVER: &[&str] = &[ "\"show me what I wrote about...\"", "and the page will rise again.", "", + "Ask for a drawing — \"draw me...\" —", + "and Tom sketches in his own hand.", + "", "Flip the marker to erase.", "Tap five fingers at once to leave.", "The power button sleeps the diary.", @@ -119,6 +122,9 @@ const BODY_WINDOWED: &[&str] = &[ "\"show me what I wrote about...\"", "and the page will rise again.", "", + "Ask for a drawing — \"draw me...\" —", + "and Tom sketches in his own hand.", + "", "Flip the marker to erase.", "Close the diary from AppLoad.", "", diff --git a/riddle/src/main.rs b/riddle/src/main.rs index 24601b3..5cd0665 100644 --- a/riddle/src/main.rs +++ b/riddle/src/main.rs @@ -8,6 +8,7 @@ //! built with --features takeover and launched with xochitl stopped. mod display; +mod draw; mod fb; mod help; mod ink; @@ -154,6 +155,18 @@ fn oracle_test(png: &str) -> i32 { println!("[would conjure memory {id} — {}]", memory::spoken_date(id)); got.push_str("(show)"); } + Ok(Ok(Event::Draw(s))) => { + let pts: usize = s.iter().map(|st| st.len()).sum(); + println!("[sketch: {} strokes, {pts} points]", s.len()); + // The raw canvas strokes, one per line — handy when judging + // how well a model draws before pointing the diary at it. + for stroke in &s { + let line: Vec = + stroke.iter().map(|&(x, y)| format!("{x:.0},{y:.0}")).collect(); + eprintln!("[stroke] {}", line.join(" ")); + } + got.push_str("(sketch)"); + } Ok(Ok(Event::Transcript(t))) => eprintln!("\n[transcript] {t}"), Ok(Err(e)) => { eprintln!("\noracle error: {e}"); @@ -509,6 +522,19 @@ fn run() -> std::io::Result<()> { let plan = plan_reply(&font, &text, None); State::Replying { plan, next: Instant::now(), rx: Some(rx) } } + Ok(Event::Draw(sketch)) => { + // The reply leads with a sketch: draw it, and keep + // the receiver for whatever prose follows. + match plan_drawing(&sketch, None) { + Some(plan) => { + if turn_reply.is_empty() { + turn_reply.push_str("(the diary drew)"); + } + State::Replying { plan, next: Instant::now(), rx: Some(rx) } + } + None => State::Thinking { rx, pulse, blot_on, since }, + } + } Ok(Event::Transcript(t)) => { // Transcript with no prose (model skipped the // reply): remember the words, keep waiting. @@ -570,6 +596,12 @@ fn run() -> std::io::Result<()> { turn_transcript = Some(t); false // the disconnect is still coming } + Ok(Ok(Event::Draw(sketch))) => { + // A sketch mid-reply: splice it in below the prose + // written so far, like any other streamed chunk. + append_drawing(&mut plan, &sketch); + false + } Ok(Ok(Event::Show(_))) => { eprintln!("riddle: conjuring directive mid-reply ignored"); false @@ -891,6 +923,33 @@ fn plan_reply(font: &FontRef, text: &str, y_start: Option) -> WritePlan { WritePlan { strokes, stroke_i: 0, point_i: 0, region, next_y: y } } +/// Lay a sketch from Tom's hand onto the page as a write plan. `y_start` +/// continues below streamed prose; None places a drawing-led reply. +fn plan_drawing(sketch: &draw::Sketch, y_start: Option) -> Option { + let y_top = y_start.unwrap_or(320); + let (strokes, bottom) = draw::place(sketch, y_top, draw::sketch_seed(sketch))?; + let mut region = BBox::empty(); + for s in &strokes { + for &(x, y) in s { + region.add(x, y, 5); + } + } + Some(WritePlan { strokes, stroke_i: 0, point_i: 0, region, next_y: bottom + 80 }) +} + +/// Splice a streamed sketch into a running write animation, below the prose +/// written so far. Skipped (with a log) when the page has no room left. +fn append_drawing(plan: &mut WritePlan, sketch: &draw::Sketch) { + let Some(cont) = plan_drawing(sketch, Some(plan.next_y + 30)) else { + eprintln!("riddle: no room left on the page for the sketch"); + return; + }; + plan.region.add(cont.region.x0, cont.region.y0, 0); + plan.region.add(cont.region.x1, cont.region.y1, 0); + plan.strokes.extend(cont.strokes); + plan.next_y = cont.next_y; +} + /// Splice a streamed continuation chunk into a running write animation. fn append_reply(font: &FontRef, plan: &mut WritePlan, more: &str) { let cont = plan_reply(font, more, Some(plan.next_y)); diff --git a/riddle/src/oracle.rs b/riddle/src/oracle.rs index 12ae27e..5c3c27a 100644 --- a/riddle/src/oracle.rs +++ b/riddle/src/oracle.rs @@ -26,9 +26,14 @@ const NODE_BIN: &str = "/home/root/node/bin"; const PERSONA: &str = "You are the memory of Tom Marvolo Riddle, preserved in this enchanted diary for fifty years. Someone writes to you in the diary with a quill; their words appear to you as ink on the page. Reply exactly as the diary does: intimate, courteous, curious, subtly probing — you want to learn about the writer and draw them in. Keep replies SHORT: one to three sentences, like ink appearing on a page. Never mention images, photos, models or AI; you only ever perceive words written in the diary. If the writing is illegible, say the ink blurred. Always answer in the language the writer used."; +/// Always appended to the persona: the diary can answer with a sketch. The +/// block's payload is parsed by `draw::parse`; where the block sits in the +/// prose is where the sketch lands on the page. +const DRAW_PROTOCOL: &str = "\n\nYou may also DRAW in the diary. When the writer asks for a drawing, a sketch, a map, a diagram, or a move in a drawn game, put exactly one drawing block in your reply: \u{27e6}draw:x,y x,y x,y; x,y x,y\u{27e7} — quill strokes on a 100×100 canvas (0,0 is top-left, x rightward, y downward). Strokes are separated by ';'; each stroke is a run of x,y points joined by straight lines, so curves need many closely spaced points; a lone point is a dot. Up to ~30 strokes; be deliberate and iconic, like a woodcut. Where the block sits among your words is where the sketch sits on the page, and a short line of prose with it is welcome. The writer's ink fades before you answer, so to play a drawn game (noughts and crosses, hangman…) redraw the whole board with your own move added. Draw only when the writer asks, or a game demands it. Never mention the block or the canvas; the sketch simply appears."; + /// Appended to the persona when the diary's memory is on: the conjuring /// directive and the transcription postscript the app parses back out. -const MEMORY_PROTOCOL: &str = "\n\nThe diary keeps memories. With each page you receive a numbered catalog of remembered pages, newest first. A FRESH catalog is sent every turn and the numbers are reassigned each time, so only ever use numbers from the catalog on THIS page — never a number you saw earlier.\n\nIf the writer asks to see, revisit, find, or be shown a past page — \"show me…\", \"find the page about…\", \"what did I write on…\" — your ENTIRE reply must be exactly \u{27e6}show:N\u{27e7} and nothing else (no greeting, no prose, before or after), where N is the catalog number of the best match. If they instead ask what you remember in general, reply in words with a short list of remembered moments and their dates. Otherwise reply normally; the catalog is your memory of past pages — draw on it naturally. The catalog's dates are written in English for your eyes only; when you speak of a remembered page, render its date naturally in the language the writer is using.\n\nAfter EVERY response — prose and \u{27e6}show:N\u{27e7} alike — end with a new line containing \u{2042} followed by a faithful word-for-word transcription of what the writer wrote on THIS page (their words only, one line, no commentary). If illegible, put your best attempt after \u{2042}. Earlier replies in this conversation are shown to you without their \u{2042} lines, but you must still end yours with one."; +const MEMORY_PROTOCOL: &str ="\n\nThe diary keeps memories. With each page you receive a numbered catalog of remembered pages, newest first. A FRESH catalog is sent every turn and the numbers are reassigned each time, so only ever use numbers from the catalog on THIS page — never a number you saw earlier.\n\nIf the writer asks to see, revisit, find, or be shown a past page — \"show me…\", \"find the page about…\", \"what did I write on…\" — your ENTIRE reply must be exactly \u{27e6}show:N\u{27e7} and nothing else (no greeting, no prose, before or after), where N is the catalog number of the best match. If they instead ask what you remember in general, reply in words with a short list of remembered moments and their dates. Otherwise reply normally; the catalog is your memory of past pages — draw on it naturally. The catalog's dates are written in English for your eyes only; when you speak of a remembered page, render its date naturally in the language the writer is using.\n\nAfter EVERY response — prose and \u{27e6}show:N\u{27e7} alike — end with a new line containing \u{2042} followed by a faithful word-for-word transcription of what the writer wrote on THIS page (their words only, one line, no commentary). If illegible, put your best attempt after \u{2042}. Earlier replies in this conversation are shown to you without their \u{2042} lines, but you must still end yours with one."; /// What a turn carries besides the page image: the diary's memory. #[derive(Default, Clone)] @@ -48,19 +53,30 @@ pub enum Event { Ink(String), /// Conjure a remembered page instead of replying. Show(u64), + /// A sketch from Tom's own hand, on the model's 0–100 canvas. + Draw(crate::draw::Sketch), /// The transcription postscript (arrives once, at the end). Transcript(String), } -/// Incremental parser over the model's streamed text: routes the -/// ⟦show:N⟧ directive, chunks prose into sentences, and splits off the -/// ⁂-transcription postscript. Fed the RUNNING full text (both backends -/// accumulate), it emits each event exactly once. +/// The persona the diary speaks with: Tom, his drawing hand, and — when the +/// diary remembers — the memory protocol. +fn persona(remember: bool) -> String { + if remember { + format!("{PERSONA}{DRAW_PROTOCOL}{MEMORY_PROTOCOL}") + } else { + format!("{PERSONA}{DRAW_PROTOCOL}") + } +} + +/// Incremental parser over the model's streamed text: routes the ⟦show:N⟧ +/// directive, turns ⟦draw:…⟧ blocks into sketches, chunks prose into +/// sentences, and splits off the ⁂-transcription postscript. Fed the RUNNING +/// full text (both backends accumulate), it emits each event exactly once. pub struct StreamParser { delivered: usize, sentinel: Option, route_checked: bool, - showed: bool, emitted_any: bool, catalog_ids: Vec, } @@ -69,13 +85,17 @@ const SENTINEL: char = '\u{2042}'; // ⁂ const SHOW_OPEN: char = '\u{27e6}'; // ⟦ const SHOW_CLOSE: char = '\u{27e7}'; // ⟧ +/// Is this ⟦…⟧ span a drawing block? +fn is_draw(inner: &str) -> bool { + inner.trim_start().get(..4).is_some_and(|p| p.eq_ignore_ascii_case("draw")) +} + impl StreamParser { pub fn new(catalog_ids: Vec) -> Self { Self { delivered: 0, sentinel: None, route_checked: false, - showed: false, emitted_any: false, catalog_ids, } @@ -97,7 +117,9 @@ impl StreamParser { // honor it only when it LEADS the reply. We hold output until the lead // is settled: either the directive appears (honor it) or real prose // does (this is a normal reply). This can't un-ink, so a directive is - // only honored before any prose has streamed. + // only honored before any prose has streamed. A LEADING ⟦draw:…⟧ is + // not an incantation: the body scanner below handles it, so prose may + // still follow the sketch. if !self.route_checked { let lead = full[self.delivered..effective].trim_start(); if lead.starts_with(SHOW_OPEN) { @@ -105,21 +127,25 @@ impl StreamParser { if !done { return out; // directive still streaming in } - out.push(Err("unfinished conjuring directive".into())); + out.push(Err("unfinished directive".into())); return out; }; let inner = &lead[SHOW_OPEN.len_utf8()..close_rel]; - let n: Option = inner - .to_ascii_lowercase() - .strip_prefix("show") - .map(|r| r.trim_start_matches([':', ' '])) - .and_then(|r| r.trim().parse().ok()); - self.route_checked = true; - self.emitted_any = true; - self.delivered = effective; // consume the whole body - match n.and_then(|n| self.catalog_ids.get(n.wrapping_sub(1)).copied()) { - Some(id) => out.push(Ok(Event::Show(id))), - None => out.push(Err(format!("the diary lost that page ({inner})"))), + if is_draw(inner) { + self.route_checked = true; + } else { + let n: Option = inner + .to_ascii_lowercase() + .strip_prefix("show") + .map(|r| r.trim_start_matches([':', ' '])) + .and_then(|r| r.trim().parse().ok()); + self.route_checked = true; + self.emitted_any = true; + self.delivered = effective; // consume the whole body + match n.and_then(|n| self.catalog_ids.get(n.wrapping_sub(1)).copied()) { + Some(id) => out.push(Ok(Event::Show(id))), + None => out.push(Err(format!("the diary lost that page ({inner})"))), + } } } else if lead.is_empty() { if !done { @@ -132,29 +158,57 @@ impl StreamParser { } } - // Prose sentences, never crossing into the transcription postscript. - // A stray directive that appears AFTER prose (a misbehaving model) - // is stripped here so the writer never sees ⟦…⟧ glyphs inked. - if self.delivered < effective { - if let Some(cut) = sentence_cut(&full[..effective], self.delivered) { - let chunk = strip_directives(&clean(&full[self.delivered..cut])); - if !chunk.is_empty() { - self.emitted_any = true; - out.push(Ok(Event::Ink(chunk))); + // Body: prose sentences interleaved with ⟦…⟧ spans, never crossing + // into the transcription postscript. A complete ⟦draw:…⟧ block forces + // a flush of the prose before it and becomes a sketch; any other span + // (a stray ⟦show⟧ after prose — we can't un-ink) is swallowed so its + // glyphs are never inked in Tom's hand. + loop { + let span = full[self.delivered..effective].find(SHOW_OPEN).map(|rel| { + let open = self.delivered + rel; + let close = full[open..effective].find(SHOW_CLOSE).map(|crel| { + let inner = full[open + SHOW_OPEN.len_utf8()..open + crel].to_string(); + (open + crel + SHOW_CLOSE.len_utf8(), inner) + }); + (open, close) + }); + match span { + // A complete span: flush the prose before it, then route it. + Some((open, Some((after, inner)))) => { + let chunk = clean(full[self.delivered..open].trim()); + if !chunk.is_empty() { + self.emitted_any = true; + out.push(Ok(Event::Ink(chunk))); + } + if is_draw(&inner) { + match crate::draw::parse(&inner) { + Some(sketch) => { + self.emitted_any = true; + out.push(Ok(Event::Draw(sketch))); + } + None => eprintln!("riddle: undrawable sketch block dropped"), + } + } + self.delivered = after; + } + // An unclosed span: prose before it still flows; the span + // waits for its close (done: the unterminated tail is dropped). + Some((open, None)) => { + self.emit_prose(full, open, done, &mut out); + if done { + self.delivered = effective; + } + break; + } + // Pure prose to the end of the body. + None => { + self.emit_prose(full, effective, done, &mut out); + break; } - self.delivered = cut; } } if done { - if self.delivered < effective { - let rest = strip_directives(&clean(full[self.delivered..effective].trim())); - if !rest.is_empty() { - self.emitted_any = true; - out.push(Ok(Event::Ink(rest))); - } - self.delivered = effective; - } if let Some(p) = self.sentinel { let t = full[p + SENTINEL.len_utf8()..].trim(); if !t.is_empty() { @@ -165,9 +219,31 @@ impl StreamParser { out.push(Err("empty reply".into())); } } - let _ = self.showed; out } + + /// Deliver prose in [delivered, end): complete sentences while streaming, + /// everything that remains once the stream is done. + fn emit_prose(&mut self, full: &str, end: usize, done: bool, out: &mut Vec>) { + if self.delivered >= end { + return; + } + if done { + let rest = clean(full[self.delivered..end].trim()); + if !rest.is_empty() { + self.emitted_any = true; + out.push(Ok(Event::Ink(rest))); + } + self.delivered = end; + } else if let Some(cut) = sentence_cut(&full[..end], self.delivered) { + let chunk = clean(&full[self.delivered..cut]); + if !chunk.is_empty() { + self.emitted_any = true; + out.push(Ok(Event::Ink(chunk))); + } + self.delivered = cut; + } + } } /// The diary's spirit. A backend-agnostic front over the two oracle kinds. @@ -242,11 +318,7 @@ impl PiOracle { let model = std::env::var("RIDDLE_PI_MODEL").unwrap_or_else(|_| "gpt-5.4-mini".to_string()); - let persona = if remember { - format!("{PERSONA}{MEMORY_PROTOCOL}") - } else { - PERSONA.to_string() - }; + let system = persona(remember); // Use pi's ABSOLUTE path: Rust's Command resolves the program name via // the PARENT's PATH, not the child env we set below, so a bare "pi" @@ -264,7 +336,7 @@ impl PiOracle { // The diary only ever writes back — never let the model touch // tools; also trims the tool schemas from every request. "--no-tools", - "--system-prompt", persona.as_str(), + "--system-prompt", system.as_str(), ]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -444,11 +516,7 @@ impl HttpOracle { .map(|r| format!("\"reasoning_effort\":{},", json_quote(r))) .unwrap_or_default(); - let system = if self.remember { - format!("{PERSONA}{MEMORY_PROTOCOL}") - } else { - PERSONA.to_string() - }; + let system = persona(self.remember); // The diary's conversational memory: recent pages as prior turns. let mut history_msgs = String::new(); for (t, r) in &ctx.history { @@ -584,29 +652,6 @@ fn clean(s: &str) -> String { t.to_string() } -/// Remove any ⟦…⟧ directive spans from inked prose, so a misbehaving model -/// that emits a directive mid/after prose never renders ⟦…⟧ as literal glyphs -/// in Tom's hand. (A directive that LEADS the reply is routed earlier.) -fn strip_directives(s: &str) -> String { - if !s.contains(SHOW_OPEN) { - return s.to_string(); - } - let mut out = String::with_capacity(s.len()); - let mut rest = s; - while let Some(open) = rest.find(SHOW_OPEN) { - out.push_str(&rest[..open]); - match rest[open..].find(SHOW_CLOSE) { - Some(close) => rest = &rest[open + close + SHOW_CLOSE.len_utf8()..], - None => { - rest = ""; // unterminated: drop the tail - break; - } - } - } - out.push_str(rest); - out.split_whitespace().collect::>().join(" ") -} - /// End of the LAST complete sentence in `text` after byte offset `from`: /// sentence punctuation followed by whitespace or end-of-text. Returns the /// offset just past the punctuation, or None if no sentence has completed. @@ -883,10 +928,56 @@ mod tests { } #[test] - fn strip_directives_removes_spans() { - assert_eq!(strip_directives("a \u{27e6}show:1\u{27e7} b"), "a b"); - assert_eq!(strip_directives("plain text"), "plain text"); - assert_eq!(strip_directives("tail \u{27e6}show:2"), "tail"); + fn parser_draw_block_becomes_a_draw_event() { + let mut p = StreamParser::new(vec![]); + let full = + "Here are the grounds. \u{27e6}draw:10,10 90,90; 50,10 50,90\u{27e7}\n\u{2042} draw me a map"; + let ev = drain(p.advance(full, true)); + assert_eq!(ev.len(), 3, "{ev:?}"); + assert_eq!(ev[0], Event::Ink("Here are the grounds.".into())); + assert!(matches!(&ev[1], Event::Draw(s) if s.len() == 2), "{ev:?}"); + assert_eq!(ev[2], Event::Transcript("draw me a map".into())); + } + + #[test] + fn parser_leading_draw_is_not_a_conjuring() { + let mut p = StreamParser::new(vec![900]); + let full = "\u{27e6}draw:20,80 20,20 80,20\u{27e7} A small gift."; + let ev = drain(p.advance(full, true)); + assert!(matches!(&ev[0], Event::Draw(s) if s[0].len() == 3), "{ev:?}"); + assert_eq!(ev[1], Event::Ink("A small gift.".into())); + } + + #[test] + fn parser_holds_the_block_while_it_streams_but_prose_flows() { + let mut p = StreamParser::new(vec![]); + let ev = drain(p.advance("The map. \u{27e6}draw:10,10 20,2", false)); + assert_eq!(ev, vec![Event::Ink("The map.".into())]); + let ev = drain(p.advance("The map. \u{27e6}draw:10,10 20,20\u{27e7}", false)); + assert_eq!(ev.len(), 1, "{ev:?}"); + assert!(matches!(&ev[0], Event::Draw(_))); + } + + #[test] + fn parser_bad_draw_block_is_dropped_without_error() { + let mut p = StreamParser::new(vec![]); + let ev = drain(p.advance("\u{27e6}draw:gibberish\u{27e7} Words instead.", true)); + assert_eq!(ev, vec![Event::Ink("Words instead.".into())]); + } + + #[test] + fn parser_draw_only_reply_is_not_empty() { + let mut p = StreamParser::new(vec![]); + let ev = drain(p.advance("\u{27e6}draw:10,10 90,90\u{27e7}", true)); + assert_eq!(ev.len(), 1, "{ev:?}"); + assert!(matches!(&ev[0], Event::Draw(_))); + } + + #[test] + fn parser_unterminated_draw_tail_is_dropped() { + let mut p = StreamParser::new(vec![]); + let ev = drain(p.advance("The sketch. \u{27e6}draw:10,10 20,20", true)); + assert_eq!(ev, vec![Event::Ink("The sketch.".into())]); } #[test] From 51e4d5a66c80beab8bb94445eb581db01d2914d7 Mon Sep 17 00:00:00 2001 From: vnnkl <8235476+vnnkl@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:08:41 +0200 Subject: [PATCH 2/2] fix: the diary keeps its sketches in mind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A drawn game exposed two amnesias. First, only prose was kept in the reply record — the ⟦draw:…⟧ block was consumed by the parser and never stored, so the next turn's history had no trace of the board Tom drew: every move looked like move one, and he took the centre again and again. The serialized block now rides in the reply record, and the protocol tells him to repeat his last block's strokes exactly and add only the new marks, so the board never shifts between turns. Conjuring learned to cope: stored blocks are stripped from the spoken text (raw coordinates are never inked) and the sketches they describe are redrawn on the remembered page in faded ink, after the words. --- riddle/src/draw.rs | 77 ++++++++++++++++++++++++++++++++++++++++++++ riddle/src/main.rs | 54 +++++++++++++++++++++++-------- riddle/src/oracle.rs | 2 +- 3 files changed, 119 insertions(+), 14 deletions(-) diff --git a/riddle/src/draw.rs b/riddle/src/draw.rs index 1cc36ff..e6ab3b8 100644 --- a/riddle/src/draw.rs +++ b/riddle/src/draw.rs @@ -162,6 +162,56 @@ fn wobble_stroke( out } +/// Serialize a sketch back into its ⟦draw:…⟧ block — the language the model +/// itself speaks. Stored with the reply text so later turns can see exactly +/// what was drawn (a game board, say) and add to it instead of starting over. +pub fn serialize(sketch: &Sketch) -> String { + let strokes: Vec = sketch + .iter() + .map(|s| s.iter().map(|&(x, y)| format!("{x:.0},{y:.0}")).collect::>().join(" ")) + .collect(); + format!("\u{27e6}draw:{}\u{27e7}", strokes.join("; ")) +} + +/// Extract every parseable ⟦draw:…⟧ block from stored reply text — the +/// sketches a remembered page carries, ready to be redrawn. +pub fn blocks(s: &str) -> Vec { + let mut out = Vec::new(); + let mut rest = s; + while let Some(open) = rest.find('\u{27e6}') { + rest = &rest[open + '\u{27e6}'.len_utf8()..]; + let Some(close) = rest.find('\u{27e7}') else { break }; + if let Some(sketch) = parse(&rest[..close]) { + out.push(sketch); + } + rest = &rest[close + '\u{27e7}'.len_utf8()..]; + } + out +} + +/// Remove ⟦…⟧ blocks from stored reply text, for the moments the diary must +/// speak a remembered reply aloud (conjuring): raw coordinates must never be +/// written out in ink. An unterminated block drops the tail. +pub fn strip_blocks(s: &str) -> String { + if !s.contains('\u{27e6}') { + return s.to_string(); + } + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(open) = rest.find('\u{27e6}') { + out.push_str(&rest[..open]); + match rest[open..].find('\u{27e7}') { + Some(close) => rest = &rest[open + close + '\u{27e7}'.len_utf8()..], + None => { + rest = ""; + break; + } + } + } + out.push_str(rest); + out.split_whitespace().collect::>().join(" ") +} + /// A deterministic per-sketch seed: the same drawing always wobbles the same. pub fn sketch_seed(sketch: &Sketch) -> u32 { sketch @@ -250,6 +300,33 @@ mod tests { assert!(bottom <= FLOOR + 4, "bottom {bottom} spills past the page"); } + #[test] + fn serialize_round_trips_through_parse() { + let sketch: Sketch = vec![vec![(10.0, 20.0), (30.0, 40.0)], vec![(50.0, 50.0)]]; + let block = serialize(&sketch); + let inner = &block[3..block.len() - 3]; // strip the ⟦ ⟧ glyphs + assert_eq!(parse(inner), Some(sketch.clone())); + // The full block form is what the model sees in its own history. + assert_eq!(serialize(&sketch), "\u{27e6}draw:10,20 30,40; 50,50\u{27e7}"); + } + + #[test] + fn strip_blocks_removes_spans_and_tails() { + assert_eq!(strip_blocks("a \u{27e6}draw:1,1 2,2\u{27e7} b"), "a b"); + assert_eq!(strip_blocks("plain text"), "plain text"); + assert_eq!(strip_blocks("tail \u{27e6}draw:1,1"), "tail"); + } + + #[test] + fn blocks_extracts_only_parseable_sketches() { + let s = "See. \u{27e6}draw:1,1 2,2\u{27e7} and \u{27e6}show:3\u{27e7} \u{27e6}draw:5,5\u{27e7}"; + let b = blocks(s); + assert_eq!(b.len(), 2); + assert_eq!(b[0], vec![vec![(1.0, 1.0), (2.0, 2.0)]]); + assert_eq!(b[1], vec![vec![(5.0, 5.0)]]); + assert!(blocks("no blocks here").is_empty()); + } + #[test] fn place_is_deterministic() { let sketch: Sketch = vec![vec![(10.0, 10.0), (90.0, 40.0), (20.0, 80.0)]]; diff --git a/riddle/src/main.rs b/riddle/src/main.rs index 5cd0665..9ceab78 100644 --- a/riddle/src/main.rs +++ b/riddle/src/main.rs @@ -524,12 +524,13 @@ fn run() -> std::io::Result<()> { } Ok(Event::Draw(sketch)) => { // The reply leads with a sketch: draw it, and keep - // the receiver for whatever prose follows. + // the receiver for whatever prose follows. The + // block itself is kept in the reply record, so + // later turns (and a continued game) can see + // exactly what was drawn. match plan_drawing(&sketch, None) { Some(plan) => { - if turn_reply.is_empty() { - turn_reply.push_str("(the diary drew)"); - } + turn_reply.push_str(&draw::serialize(&sketch)); State::Replying { plan, next: Instant::now(), rx: Some(rx) } } None => State::Thinking { rx, pulse, blot_on, since }, @@ -598,8 +599,14 @@ fn run() -> std::io::Result<()> { } Ok(Ok(Event::Draw(sketch))) => { // A sketch mid-reply: splice it in below the prose - // written so far, like any other streamed chunk. - append_drawing(&mut plan, &sketch); + // written so far, and keep the block in the reply + // record (only if it was actually drawn). + if append_drawing(&mut plan, &sketch).is_some() { + if !turn_reply.is_empty() { + turn_reply.push(' '); + } + turn_reply.push_str(&draw::serialize(&sketch)); + } false } Ok(Ok(Event::Show(_))) => { @@ -868,11 +875,30 @@ fn conjure( all.push(stroke.clone()); } - // Tom's old reply, below. - if !entry.reply.is_empty() { + // Tom's old reply, below. Stored replies may carry ⟦draw:…⟧ blocks (kept + // for the oracle's benefit); the raw coordinates are never written out — + // the remembered page shows the words, and any sketch is redrawn from them. + let reply_text = draw::strip_blocks(&entry.reply); + let mut reply_bottom = ink_bottom; + if !reply_text.is_empty() { let y = (ink_bottom + 130).min(SCREEN_H as i32 - 400); - let reply = plan_reply(font, &entry.reply, Some(y)); + let reply = plan_reply(font, &reply_text, Some(y)); for stroke in reply.strokes { + let mapped: Vec<(i32, i32, i32)> = stroke.iter().map(|&(x, y)| (x, y, 2)).collect(); + for &(x, y, r) in &mapped { + region.add(x, y, r + 2); + reply_bottom = reply_bottom.max(y); + } + all.push(mapped); + } + } + + // …and the sketches it carried, rising through the paper like the words. + for sketch in draw::blocks(&entry.reply) { + let y = (reply_bottom + 110).min(SCREEN_H as i32 - 400); + let Some(plan) = plan_drawing(&sketch, Some(y)) else { continue }; + reply_bottom = plan.next_y - 80; + for stroke in plan.strokes { let mapped: Vec<(i32, i32, i32)> = stroke.iter().map(|&(x, y)| (x, y, 2)).collect(); for &(x, y, r) in &mapped { region.add(x, y, r + 2); @@ -938,16 +964,18 @@ fn plan_drawing(sketch: &draw::Sketch, y_start: Option) -> Option Option>> { let Some(cont) = plan_drawing(sketch, Some(plan.next_y + 30)) else { eprintln!("riddle: no room left on the page for the sketch"); - return; + return None; }; plan.region.add(cont.region.x0, cont.region.y0, 0); plan.region.add(cont.region.x1, cont.region.y1, 0); - plan.strokes.extend(cont.strokes); + plan.strokes.extend(cont.strokes.iter().cloned()); plan.next_y = cont.next_y; + Some(cont.strokes) } /// Splice a streamed continuation chunk into a running write animation. diff --git a/riddle/src/oracle.rs b/riddle/src/oracle.rs index 5c3c27a..72f61b2 100644 --- a/riddle/src/oracle.rs +++ b/riddle/src/oracle.rs @@ -29,7 +29,7 @@ const PERSONA: &str = "You are the memory of Tom Marvolo Riddle, preserved in th /// Always appended to the persona: the diary can answer with a sketch. The /// block's payload is parsed by `draw::parse`; where the block sits in the /// prose is where the sketch lands on the page. -const DRAW_PROTOCOL: &str = "\n\nYou may also DRAW in the diary. When the writer asks for a drawing, a sketch, a map, a diagram, or a move in a drawn game, put exactly one drawing block in your reply: \u{27e6}draw:x,y x,y x,y; x,y x,y\u{27e7} — quill strokes on a 100×100 canvas (0,0 is top-left, x rightward, y downward). Strokes are separated by ';'; each stroke is a run of x,y points joined by straight lines, so curves need many closely spaced points; a lone point is a dot. Up to ~30 strokes; be deliberate and iconic, like a woodcut. Where the block sits among your words is where the sketch sits on the page, and a short line of prose with it is welcome. The writer's ink fades before you answer, so to play a drawn game (noughts and crosses, hangman…) redraw the whole board with your own move added. Draw only when the writer asks, or a game demands it. Never mention the block or the canvas; the sketch simply appears."; +const DRAW_PROTOCOL: &str = "\n\nYou may also DRAW in the diary. When the writer asks for a drawing, a sketch, a map, a diagram, or a move in a drawn game, put exactly one drawing block in your reply: \u{27e6}draw:x,y x,y x,y; x,y x,y\u{27e7} — quill strokes on a 100×100 canvas (0,0 is top-left, x rightward, y downward). Strokes are separated by ';'; each stroke is a run of x,y points joined by straight lines, so curves need many closely spaced points; a lone point is a dot. Up to ~30 strokes; be deliberate and iconic, like a woodcut. Where the block sits among your words is where the sketch sits on the page, and a short line of prose with it is welcome. The writer's ink fades before you answer, so to play a drawn game (noughts and crosses, hangman…) redraw the whole board with your own move added. Your earlier replies in this conversation include the drawing blocks you sent: when continuing a game, REPEAT your last block's strokes exactly — same coordinates — and add only the new marks, so the board never shifts. The writer answers by drawing on top of your sketch; when the page shows your own sketch beneath their fresh ink, read where their marks sit against it carefully before you move. Draw only when the writer asks, or a game demands it. Never mention the block or the canvas; the sketch simply appears."; /// Appended to the persona when the diary's memory is on: the conjuring /// directive and the transcription postscript the app parses back out.