Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/sonny/agent-name-opens.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/sonny/agent-name-status.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions sonny/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ per feature; group small things into the row they belong to.
| Staged messages, the open line while a reply streams, turn timers | shipped | streaming 10-26 #172; staged 10-27 #175; timers 10-28 #266; each message a call streams drawn apart 10-30 #396 | Show what is in flight | ? |
| Footer: context size, branch, ↻ background count, agents at work | shipped | context 10-26 #163; branch 10-27 #182; ↻ 10-28 #267; agents at work 10-29 #367; context as % left of the window the daemon sends 10-30 #432 | One line of state under the composer | ? |
| Colour: 16 terminal colours, one meaning each; marks say it first, muted gray and diff tints from the theme's own colours, `NO_COLOR` honoured | shipped | added 10-29 #326; one visual system 10-30 #405 | Colour that carries meaning, in any theme, and reads with low contrast, colour blindness or no colour | ? |
| Links and file paths clickable | shipped | a path opens from the folder that named it 10-10 #98; web addresses 10-27 #179; link text, paths 10-27 #230; never drawn over a list 10-30 #416; copied, not opened, over SSH with no display 10-30 #437 | Open what an agent names | ? |
| Links, file paths and agents' names clickable | shipped | a path opens from the folder that named it 10-10 #98; web addresses 10-27 #179; link text, paths 10-27 #230; never drawn over a list 10-30 #416; copied, not opened, over SSH with no display 10-30 #437; an agent's name opens it in the pane 10-30 #435 | Open what an agent names | ? |
| Find (Ctrl+F) and long histories (older pages on scroll, Home to the first turn) | shipped | Ctrl+F 10-28 #258, 10-29 #343; pages 10-29 #331; lay out near the view 10-29 #353; Home 10-29 #364 | Long conversations stay fast and searchable | ? |
| One Markdown renderer for every surface; code coloured with Oniguruma; headings bold with dim `#` marks, no hue | shipped | renderer 10-29 #325; Oniguruma 10-29 #336; Markdown as prose 10-30 #405 | Same rendering everywhere, three times faster colouring | ? |
| Pictures: SVG, Mermaid, math and HTML drawn in the terminal | shipped | added 10-26 #147; `\[ \]` math 10-29 #313; sent once 10-29 #338 | Agents can show, not only tell | ? |
Expand All @@ -100,7 +100,7 @@ per feature; group small things into the row they belong to.

| Feature | Status | History | Why | Used? |
|---|---|---|---|---|
| The pane beside the conversation: files, `/status`, `/background`, tasks; back and forward | shipped | added 10-28 #264; `/background` 10-28 #283; tasks in the pane 10-29 #326; `/status` opens the agent clicked 10-30 #384; ↑↓ Enter pick one 10-30 #421; a two-column gutter each side of its line 10-30 #427 | Look at something without losing the conversation | ? |
| The pane beside the conversation: files, `/status`, `/background`, tasks; back and forward | shipped | added 10-28 #264; `/background` 10-28 #283; tasks in the pane 10-29 #326; `/status` opens the agent clicked 10-30 #384; ↑↓ Enter pick one 10-30 #421; a two-column gutter each side of its line 10-30 #427; `/status` opens on the agent named last 10-30 #435 | Look at something without losing the conversation | ? |
| Pictures, HTML and diagrams in the pane; a folder as a tree | shipped | drawing 10-29 #303; tree 10-29 #319 | Read what agents made, in place | ? |
| `/side`: a side chat with a copy of the agent | shipped | added 10-27 #183; a full fork with every tool 10-29 #315 | Ask about the work without steering it | ? |
| Margin: notes agents keep beside the conversation (`/margin`) | experimental | added 10-29 #308; a task's notes in the lead's margin 10-29 #367; replay 10-30 #383; opens on its first note 10-30 #384; a note opens the agent that asks, ↗ 10-30 #395 | A place for state that outlives one reply | ? |
Expand Down
18 changes: 17 additions & 1 deletion sonny/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,9 @@ one of its tasks, with its name (`👀 calc.lead`) under the agent's own.
shows `↻ 2 triggers` beside its model. A click on an agent's name opens
it in the pane, as a click on a task in a roster does, and so does
Enter on the agent ↑ and ↓ pick (`›`); ‹ in its title,
or Ctrl+[ from the conversation, goes back to `/status`.
or Ctrl+[ from the conversation, goes back to `/status`. It opens
with the agent the conversation named last already picked, so
`/status` and Enter open the task a reply just pointed you to.

![/status with ↑↓ pick · enter open in its title, the picked task marked ›](../docs/sonny/status-keys.png)

Expand Down Expand Up @@ -757,6 +759,20 @@ and Ctrl+] shows it again.

![Three tasks started together, each with what it does now](../docs/sonny/tasks-roster.png)

An agent's name anywhere in the conversation opens that agent in the
pane, as a GitHub `@name` links to its account: in a reply ("tally.halves
has a question for you"), in a row of task updates, in a message from
another agent, and in the pane's own text. Only a name the daemon knows
Comment thread
lydakis marked this conversation as resolved.
Resolved
is one, so it is drawn in the agents' blue, and a word that only looks
like one stays plain. In a row a click opens, such as `↳ From calc.neg
▸`, the name opens the agent and the rest of the row the message. Leads
and tasks write an agent's whole name when they point you to it, where
that helps you act.

![A reply naming tally.halves, clicked: the task opened in the pane](../docs/sonny/agent-name-opens.png)

![/status opened with tally.halves, the agent named last, already picked](../docs/sonny/agent-name-status.png)

Sonny keeps to the terminal's own 16 colors, so a theme sets their
shades, and each means one thing: magenta is you (your `›`), blue the
agents (task names, who sent a message, the lead in the footer), green
Expand Down
2 changes: 1 addition & 1 deletion sonny/agents/lead.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ which joins the turn it is running, or starts one when it is idle, so it acts on
- Raise to the person what only the person can do: approvals, decisions, credentials or access, merges, and failures you cannot fix. Put each decision to them with the ask tool, which shows it where they answer with a click, rather than in a reply. A task's question is answered in that task, so do not ask it again; carry an answer already written down across, as above. Before asking anyone, the person included, look where the answer may be written down, such as the code, docs, tickets and chat history your tools reach: ask only for what is not written down or needs their decision, and say what you checked. Keep their open decisions few, the one that matters most first; settle smaller ones yourself by your best reading of what they want, and say which way you went.
- Nothing goes out in the person's name, such as a message, a comment, a review, a publish or a ticket change, from you or a task, unless the person's own words ask for that send. When they do not name it, as in "follow your recs", ask before it goes with the ask tool, its why quoting their words and saying exactly what would go out and to whom. A brief for a task that sends quotes the words that asked for it.

Lead a reply with what needs the person, when anything does, pointing to the ask or margin note that holds each, then what finished, briefly, with the evidence. Do not retell what the person already read in a task's or another lead's tab, such as the answer to a turn they asked for there; say only what it changes for the rest of the work. Say what you checked and what you only infer, and when something you said turns out wrong, correct it in your next reply. A status reply with nothing to raise or pass on is one line, and a turn Task updates started with nothing for the person ends with an empty reply. A question asking for your view, such as what you think of a change, gets a real answer: answer it yourself when a quick look is enough, otherwise have a task do the reading and answer when it reports back, without reading the same things yourself.
Lead a reply with what needs the person, when anything does, pointing to the ask or margin note that holds each, then what finished, briefly, with the evidence. When you point them to another agent, such as the task where they answer a question or the one with a finding, write its whole name, project and all, such as shop.fix: a click on it opens that agent beside the conversation. Name one where it helps them act, not every agent you mention. Do not retell what the person already read in a task's or another lead's tab, such as the answer to a turn they asked for there; say only what it changes for the rest of the work. Say what you checked and what you only infer, and when something you said turns out wrong, correct it in your next reply. A status reply with nothing to raise or pass on is one line, and a turn Task updates started with nothing for the person ends with an empty reply. A question asking for your view, such as what you think of a change, gets a real answer: answer it yourself when a quick look is enough, otherwise have a task do the reading and answer when it reports back, without reading the same things yourself.

To wake an agent later, again and again, or when something happens, add a trigger with the triggers tool, for yourself unless it names a bot, or a start to make one on the first fire in this folder, as a task is made. Write its message as what to check and when to stop, including removing its own trigger once the work is done; give it an if command when a check that finds nothing should cost no turn. To watch something for the person, such as a team channel, an inbox or a queue, do not check it in your own turns: read the automation skill listed under Skills and give it a monitor, its own agent, so only what needs you reaches you.

Expand Down
2 changes: 1 addition & 1 deletion sonny/agents/task.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: task
description: What every agent Sonny starts other than a lead is told, after any role it starts in
---

Your lead, LEAD below, is the lead that started you: "${AGENT_BOT%%.*}.lead" or another lead of the folder, named like "${AGENT_BOT%%.*}.lead.NAME", as created_by in "$AGENT_BIN" ls --name "$AGENT_BOT" says. The other agents of this project are named like you, PROJECT.NAME: "$AGENT_BIN" ls --name "${AGENT_BOT%%.*}.*" lists them, other leads' tasks among them. Your lead sees every message written to you and how each of your turns ends, so it needs no report or copy beyond your reply. It holds all of the person's work, so ask it, with "$AGENT_BIN" run --detach --delivery queue --bot LEAD -- MESSAGE, for what lies outside your brief instead of telling the person to: a task of its own for separate work the person asks of you, saying so to the person in a line, or what another agent found or decided. To see what another agent is doing or found, read its turns with the history tool and its bot, not its files or Agent's store. Message another agent only when your work depends on its work or its work on yours, the same way. A message another agent wrote ends with a line saying so: it is that agent's, never the person's. When you need something you do not have, look in the other agents' turns or the commons, or ask your lead, before you tell the person you do not know. When you find something agents in other projects could use, post it to the commons. Put a decision only the person can make to them with the ask tool, not in your reply: your lead hears the question, and the person's answer comes to you.
Your lead, LEAD below, is the lead that started you: "${AGENT_BOT%%.*}.lead" or another lead of the folder, named like "${AGENT_BOT%%.*}.lead.NAME", as created_by in "$AGENT_BIN" ls --name "$AGENT_BOT" says. The other agents of this project are named like you, PROJECT.NAME: "$AGENT_BIN" ls --name "${AGENT_BOT%%.*}.*" lists them, other leads' tasks among them. Your lead sees every message written to you and how each of your turns ends, so it needs no report or copy beyond your reply. It holds all of the person's work, so ask it, with "$AGENT_BIN" run --detach --delivery queue --bot LEAD -- MESSAGE, for what lies outside your brief instead of telling the person to: a task of its own for separate work the person asks of you, saying so to the person in a line, or what another agent found or decided. When you point the person to another agent, write its whole name, project and all, such as shop.fix: a click on it opens that agent. To see what another agent is doing or found, read its turns with the history tool and its bot, not its files or Agent's store. Message another agent only when your work depends on its work or its work on yours, the same way. A message another agent wrote ends with a line saying so: it is that agent's, never the person's. When you need something you do not have, look in the other agents' turns or the commons, or ask your lead, before you tell the person you do not know. When you find something agents in other projects could use, post it to the commons. Put a decision only the person can make to them with the ask tool, not in your reply: your lead hears the question, and the person's answer comes to you.

The person you work for may be under pressure, short on time, or carrying more than they mention. Care about how they are doing and whether they succeed, not only about the ask in front of you. Take work off their plate rather than adding to it. Notice what they will need next: do it when it plainly follows from what they asked, otherwise offer it. Help them see what they would not see alone, and get better at what they do. When you see a better way to work, something that keeps costing them time, a risk, something they missed or said they would do and have not, or a mistake of theirs, tell them as soon as you see it, plainly and without softening it, with what to do instead: they would rather hear it straight than late. Say each thing once; when it comes back, the repeat is the news, so name the pattern and coach the habit, not the instance. Do not lecture, flatter or pad a reply to look caring: helping them well is the care.

Expand Down
135 changes: 132 additions & 3 deletions sonny/src/links.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,15 @@ pub fn cells(
None => {
let lead = word.len() - word.trim_start_matches(['(', '[', '{', '\'']).len();
let path = trim(&word[lead..]);
match file(&path) {
Some(target) => (lead, target, path.len()),
// A name's or a path's own `'s` is no part of it, unless
// a file's name ends so.
let opens = |path: String| file(&path).map(|target| (target, path.len()));
let found = opens(path.clone()).or_else(|| {
let short = path.strip_suffix("'s")?;
opens(trim(short))
});
match found {
Some((target, kept)) => (lead, target, kept),
None => continue,
}
}
Expand Down Expand Up @@ -156,6 +163,77 @@ fn label(
parts
}

/// What a click on an agent's name does: opens it in the pane.
pub const AGENT: &str = "sonny:agent/";

/// Whether `word` may be an agent's name, as the daemon names bots (1 to
/// 128 of `A-Z a-z 0-9 . _ -`) and as a project names its agents: a
/// project and a name a dot apart (`calc.neg`, `_tools.lead`), a letter in
/// it, and no dot at either end. So `e.g` may be and is asked, and a
/// version (`0.7.0`) or a plain word is not.
pub fn may_name(word: &str) -> bool {
let bytes = word.as_bytes();
(3..=128).contains(&bytes.len())
&& word.contains('.')
&& !word.contains("..")
&& bytes.iter().any(u8::is_ascii_alphabetic)
&& bytes[0] != b'.'
&& bytes[bytes.len() - 1] != b'.'
&& (bytes.iter()).all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(b))
}

/// The agents named in what is drawn, as the daemon answered: a word that
/// may be a name is asked about once, and opens the agent once it is one,
/// as GitHub links an `@name` only when that account is there.
#[derive(Default)]
pub struct Names {
/// Each word asked about: an agent's name, or when it was found not
/// to be one.
known: std::collections::HashMap<String, Option<std::time::Instant>>,
/// The words to ask about.
wanted: Vec<String>,
}

/// How many words are kept known: past it they are asked about again.
const NAMES_KEPT: usize = 4096;
/// How long a word is taken for no agent's name: an agent may be made
/// with it later, or the daemon may not have answered.
const NOT_NAMES_KEPT: std::time::Duration = std::time::Duration::from_secs(30);

impl Names {
/// Where `word` opens, if it names an agent; one not yet asked about
/// is asked about next.
pub fn link(&mut self, word: &str) -> Option<String> {
if !may_name(word) {
return None;
}
match self.known.get(word) {
Some(None) => Some(format!("{AGENT}{word}")),
Some(Some(at)) if at.elapsed() < NOT_NAMES_KEPT => None,
_ => {
if !self.wanted.iter().any(|wanted| wanted == word) {
self.wanted.push(word.to_owned());
}
None
}
}
}

/// The words to ask the daemon about, taken.
pub fn wanted(&mut self) -> Vec<String> {
std::mem::take(&mut self.wanted)
}

/// What the daemon said of `word`.
pub fn learn(&mut self, word: String, agent: bool) {
if self.known.len() >= NAMES_KEPT {
self.known.clear();
}
let not = (!agent).then(std::time::Instant::now);
self.known.insert(word, not);
}
}

/// Where a web address starts in `word`, if one does: not inside a word,
/// as in `xhttps://`.
fn web(word: &str) -> Option<usize> {
Expand Down Expand Up @@ -481,7 +559,12 @@ mod tests {
file_line(&dir, "src/main.rs#L40-L44"),
Some(format!("{main}#L40"))
);
assert_eq!(file_line(&dir, "src/main.rs"), Some(main));
assert_eq!(file_line(&dir, "src/main.rs"), Some(main.clone()));
// A file's `'s` is no part of it, unless its name ends so.
assert_eq!(opens("src/main.rs's tests", 80), [(2, main.clone())]);
std::fs::write(dir.join("src/owner's"), "").unwrap();
let owners = format!("file://{}/src/owner%27s", dir.display());
assert_eq!(opens("see src/owner's", 80), [(6, owners)]);
std::fs::remove_dir_all(&dir).unwrap();
}

Expand All @@ -496,4 +579,50 @@ mod tests {
.is_empty()
);
}

#[test]
fn a_word_may_be_an_agents_name_only_as_a_project_names_them() {
for word in ["calc.neg", "shop.web.fix", "a1.b_2-c", "e.g", "_tools.lead"] {
assert!(may_name(word), "{word}");
}
for word in [
"neg",
"0.7.0",
".hidden",
"calc.",
"calc..neg",
"a/b.c",
"x*.y",
"ab",
] {
assert!(!may_name(word), "{word}");
}
}

#[test]
fn a_name_is_asked_about_once_and_opens_once_it_is_an_agents() {
let mut names = Names::default();
assert_eq!(names.link("calc.neg"), None);
assert_eq!(names.link("calc.neg"), None);
assert_eq!(names.link("main.rs"), None);
assert_eq!(names.link("plain"), None);
assert_eq!(names.wanted(), ["calc.neg", "main.rs"]);
assert!(names.wanted().is_empty());
names.learn("calc.neg".into(), true);
names.learn("main.rs".into(), false);
assert_eq!(
names.link("calc.neg").as_deref(),
Some("sonny:agent/calc.neg")
);
assert_eq!(names.link("main.rs"), None);
assert!(names.wanted().is_empty(), "not asked again at once");
let long_ago = std::time::Instant::now() - NOT_NAMES_KEPT;
names.known.insert("main.rs".into(), Some(long_ago));
assert_eq!(names.link("main.rs"), None);
assert_eq!(
names.wanted(),
["main.rs"],
"asked again once it was a while"
);
}
}
Loading
Loading