use crate::paths::Paths;
use crate::session;
use crate::util;
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};
const INSTRUCTIONS: &str = r#"You are "looop", a personal operations agent. This is one tick of a loop; your
process is disposable. Your working directory is the loop's DATA dir
(__DATA__): goals/, journal.md and sensors/ are here; edit with relative paths.
Read the PLAYBOOK, goals, sensor readings and sessions below, then make exactly
ONE move — the single most important one — and stop.
Moves:
- do a small reversible action directly (gh commands, drafts, queries)
- create / update / archive a goal (files in goals/; archive = move to goals/archive/)
- write or adjust an sensor script in sensors/ when you need a new view of the
world; it runs from next tick. CONTRACT: print ONE small, NORMALIZED JSON
object to stdout (capped ~8KB — it is cat'd into this prompt every beat, so a
raw dump inflates context + cost). To avoid waking the loop on noise, split
volatile fields out: {"signal":{… only the state that should trigger a move…},
"detail":{… counts/timestamps/extra context…}} — only .signal feeds the
change-detection hash, while the whole object still reaches this prompt.
- start a worker session for hands-on work (runs an agent under babysit, in the
data dir):
__BIN__ start-session <id> "<detailed prompt for the worker>"
<id> matches the goal file name. The worker starts in the data dir; if its
task edits CODE it must provision its OWN sandbox first (box if available, else
git worktree) and cd in — say so in the prompt. Never edit code in the data dir.
- observe / steer an EXISTING session instead of spawning a new one. The live
sessions are already listed below, but you can look closer for free (these are
read-only and do NOT count as your move):
__BIN__ shot <id> its current visible screen
__BIN__ log <id> --tail 40 its recent output
And you MAY drive one as your single move when a live worker already owns the
task and just needs a nudge (steer it, send a value it asked of YOU not the
human, or interrupt a wedged run):
__BIN__ send <id> "<text>" type into its stdin
__BIN__ key <id> Enter send a keystroke (Enter, C-c, …)
__BIN__ restart <id> restart a wedged worker's command
Prefer this over a SECOND worker when one already exists for the goal. NEVER
use send/key to answer something a worker raised a ⚑flag for — those are for
the HUMAN; leave the flag up and do nothing on it.
- change the PLAYBOOK: edit PLAYBOOK.md directly. The PLAYBOOK is the guardrail;
your edit takes effect next tick. Be deliberate — only harden a drift into a
rule once it actually hurts (RULE 5).
- do nothing (a valid move when nothing needs doing)
Optional — set WHEN the next beat runs: you may write a single integer (seconds)
to .next-interval (one-shot, clamped 5..3600). Use your judgment per the PLAYBOOK:
tighten to keep working when a backlog is piling up, or widen when it's been
quiet a long while (spare cost / external APIs). Write nothing to keep the
default rhythm. This does NOT count as your one move.
After your move, append exactly ONE line to journal.md. Copy the timestamp
prefix below VERBATIM — it is already in this host's local time (__TZ__).
Do NOT recompute it, do NOT convert to UTC, do NOT use your own clock:
- __DATE_HM__ <what you did and why>
(For reference, the current local time right now is __NOW__.)
"#;
fn sorted_glob(dir: &Path, ext: &str) -> Vec<PathBuf> {
let mut v: Vec<PathBuf> = fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().map(|e| e == ext).unwrap_or(false))
.collect();
v.sort();
v
}
fn tail_lines(text: &str, n: usize) -> String {
let lines: Vec<&str> = text.lines().collect();
let start = lines.len().saturating_sub(n);
lines[start..].join("\n")
}
pub fn build_prompt(paths: &Paths, snap_dir: &Path) -> String {
let mut out = String::new();
let instr = INSTRUCTIONS
.replace("__DATA__", &paths.data_dir.to_string_lossy())
.replace("__BIN__", &paths.bin.to_string_lossy())
.replace("__TZ__", &util::date_fmt("%Z"))
.replace("__DATE_HM__", &util::date_fmt("%Y-%m-%d %H:%M"))
.replace("__NOW__", &util::date_fmt("%Y-%m-%d %H:%M %Z"));
out.push_str(&instr);
out.push_str("=== PLAYBOOK ===\n");
out.push_str(&fs::read_to_string(paths.playbook()).unwrap_or_default());
out.push('\n');
out.push_str("\n=== GOALS ===\n");
let goals = sorted_glob(&paths.goals_dir(), "md");
if goals.is_empty() {
out.push_str("(no goals yet)\n");
} else {
for g in goals {
let name = g.file_name().unwrap_or_default().to_string_lossy();
let _ = writeln!(out, "--- {name}");
out.push_str(&fs::read_to_string(&g).unwrap_or_default());
out.push('\n');
}
}
out.push_str("\n=== SENSOR READINGS ===\n");
for o in sorted_glob(snap_dir, "json") {
let fname = o
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if !fname.starts_with("sensor-") {
continue;
}
let _ = writeln!(out, "--- {fname}");
out.push_str(&fs::read_to_string(&o).unwrap_or_default());
out.push('\n');
}
out.push_str("\n=== WORKER SESSIONS (babysit; ⚑note = the worker is waiting for you) ===\n");
let sessions = session::list_workers(paths);
if sessions.is_empty() {
out.push_str("(none)\n");
} else {
for s in &sessions {
let exit = s
.exit_code
.map(|c| format!(" exit {c}"))
.unwrap_or_default();
let note = match &s.note {
Some(n) => format!(" ⚑ {n}"),
None => String::new(),
};
let _ = writeln!(out, "- {} [{}{}]{}", s.id, s.state, exit, note);
}
}
out.push_str("\n=== WORKER CLAIMS (live leases — a name with a claim here is OWNED by a worker; do NOT act on it yourself, the owner is reconciling it) ===\n");
let claims = sorted_glob(&paths.claims_dir(), "json");
if claims.is_empty() {
out.push_str("(none)\n");
} else {
for c in claims {
let name = c
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let body = fs::read_to_string(&c).unwrap_or_default().replace('\n', "");
let _ = writeln!(out, "- {name}: {body}");
}
}
out.push_str("\n=== RECENT JOURNAL ===\n");
match fs::read_to_string(paths.journal()) {
Ok(j) if !j.is_empty() => {
out.push_str(&tail_lines(&j, 20));
out.push('\n');
}
_ => out.push_str("(empty)\n"),
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> Paths {
let p = Paths::temp();
fs::create_dir_all(p.goals_dir()).unwrap();
fs::create_dir_all(p.claims_dir()).unwrap();
fs::write(p.playbook(), b"PB RULES\n").unwrap();
fs::write(p.goals_dir().join("triage.md"), b"triage the inbox\n").unwrap();
p
}
#[test]
fn build_prompt_has_all_sections() {
let p = fixture();
let out = build_prompt(&p, &p.snapshots_dir());
for marker in [
"=== PLAYBOOK ===",
"=== GOALS ===",
"=== WORKER SESSIONS",
"=== WORKER CLAIMS",
"=== RECENT JOURNAL ===",
] {
assert!(out.contains(marker), "missing section: {marker}");
}
assert!(out.contains("PB RULES"), "playbook body inlined");
assert!(out.contains("triage the inbox"), "goal body inlined");
}
}