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..e6ab3b8 --- /dev/null +++ b/riddle/src/draw.rs @@ -0,0 +1,336 @@ +//! 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 +} + +/// 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 + .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 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)]]; + 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..9ceab78 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,20 @@ 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. 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) => { + turn_reply.push_str(&draw::serialize(&sketch)); + 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 +597,18 @@ 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, 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(_))) => { eprintln!("riddle: conjuring directive mid-reply ignored"); false @@ -836,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); @@ -891,6 +949,35 @@ 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. Returns the placed screen strokes, or None (with a log) +/// when the page has no room left. +fn append_drawing(plan: &mut WritePlan, sketch: &draw::Sketch) -> 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 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.iter().cloned()); + plan.next_y = cont.next_y; + Some(cont.strokes) +} + /// 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..72f61b2 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. 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. -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]