Skip to main content

kanbrain_core/api/
context.rs

1//! `kb context`: what an agent should know when it starts working, as short markdown.
2//! Titles and ids, plus the latest comment on tasks in progress so work can be picked up where it
3//! stopped; `show` gives the rest. Every section is capped, so it stays cheap however big the brain gets.
4
5use super::text::{head, state_note};
6use crate::brain::Brain;
7use crate::model::{Asked, Comment, Kind, Status, Task, fmt_age, fmt_clock, now};
8use crate::projects::ProjectInfo;
9use std::fmt::Write;
10
11/// Entries listed per section; the rest are counted, to keep the context short.
12const SECTION_LIMIT: usize = 20;
13/// Recently finished tasks listed, so an agent doesn't redo what was just done.
14const DONE_LIMIT: usize = 5;
15/// Longest excerpt of a latest comment, in characters.
16const EXCERPT_CHARS: usize = 120;
17
18impl Brain {
19    /// Active tasks by status, the latest done, then decisions and notes, newest first, each as
20    /// `- #id title #tag`.
21    pub fn context_markdown(&self) -> String {
22        let mut out = String::new();
23        self.write_context(&mut out).expect("writing to a String cannot fail");
24        out
25    }
26
27    /// Writes the context into `out`.
28    fn write_context(&self, out: &mut String) -> std::fmt::Result {
29        let scope = self.store.project().map_or_else(|| "central brain".to_string(), |p| format!("project {p}"));
30        writeln!(out, "# kanbrain · {scope}\n")?;
31        if let Some(project) = &self.project {
32            write_project(out, project)?;
33        }
34        // First, since the user answered them: the work to pick up before anything new.
35        let answered = self.newest(|t| matches!(t.asked(), Some(Asked::Answered(_))));
36        section(out, "Answered: pick these up first", &answered, SECTION_LIMIT)?;
37        for status in [Status::Doing, Status::Parked, Status::Todo] {
38            let tasks =
39                self.newest(|t| t.is_task() && t.status == status && !matches!(t.asked(), Some(Asked::Answered(_))));
40            section(out, &format!("Tasks: {}", status.as_str()), &tasks, SECTION_LIMIT)?;
41        }
42        let mut done = self.newest(|t| t.is_task() && t.status == Status::Done);
43        done.sort_by_key(|t| std::cmp::Reverse(t.status_since()));
44        section(out, "Tasks: done lately", &done, DONE_LIMIT)?;
45        section(out, "Decisions", &self.newest(|t| t.kind == Kind::Decision), SECTION_LIMIT)?;
46        section(out, "Notes", &self.newest(|t| t.kind == Kind::Note), SECTION_LIMIT)
47    }
48
49    /// Entries matching `keep`, newest first.
50    fn newest(&self, keep: impl Fn(&Task) -> bool) -> Vec<&Task> {
51        let mut tasks: Vec<&Task> = self.tasks.iter().filter(|t| keep(t)).collect();
52        tasks.sort_by_key(|t| std::cmp::Reverse(t.id));
53        tasks
54    }
55}
56
57/// A `## title` section listing up to `limit` entries; empty sections are left out. Tasks in doing
58/// or parked also show their latest comment, which usually says where the work stopped, or the
59/// question they wait on, or its answer.
60fn section(out: &mut String, title: &str, tasks: &[&Task], limit: usize) -> std::fmt::Result {
61    if tasks.is_empty() {
62        return Ok(());
63    }
64    writeln!(out, "## {title}\n")?;
65    let now = now();
66    for task in tasks.iter().take(limit) {
67        out.push_str("- ");
68        head(out, task)?;
69        if let Some(running) = task.running_for(now) {
70            write!(out, " · running {}", fmt_clock(running))?;
71        }
72        if let Some(note) = state_note(task) {
73            write!(out, " · {note}")?;
74        }
75        out.push('\n');
76        match task.asked() {
77            Some(Asked::Waiting(question)) => excerpt(out, "question", question, now)?,
78            Some(Asked::Answered(answer)) => excerpt(out, "answer", answer, now)?,
79            None if matches!(task.status, Status::Doing | Status::Parked) && task.is_task() => {
80                if let Some(comment) = task.comments.last() {
81                    excerpt(out, "last comment", comment, now)?;
82                }
83            }
84            None => {}
85        }
86    }
87    if let Some(more) = tasks.len().checked_sub(limit).filter(|&n| n > 0) {
88        writeln!(out, "- … {more} more: `kb search` finds them")?;
89    }
90    out.push('\n');
91    Ok(())
92}
93
94/// `  last comment (2h ago): first line…`, under a task, for `comment` and what it is.
95fn excerpt(out: &mut String, what: &str, comment: &Comment, now: chrono::DateTime<chrono::Local>) -> std::fmt::Result {
96    let first = comment.body.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or_default();
97    let mut excerpt: String = first.chars().take(EXCERPT_CHARS).collect();
98    if excerpt.len() < first.len() || comment.body.trim() != first {
99        excerpt.push('…');
100    }
101    writeln!(out, "  {what} ({} ago): {excerpt}", fmt_age(now - comment.created))
102}
103
104/// What the application is about (or that it isn't described yet) and, for one of several repositories, this one's role
105/// (or that it isn't described yet) and where the others are: an agent knows the project without exploring it, and can
106/// reach the other services' tasks with `--project`.
107fn write_project(out: &mut String, project: &ProjectInfo) -> std::fmt::Result {
108    writeln!(out, "## Project {}\n", project.name)?;
109    // Every session starts from the description, so an agent that finds none is told, in one line, to
110    // write it before anything else: asked only to "keep it up to date", agents left it empty.
111    if project.description.is_empty() {
112        writeln!(
113            out,
114            concat!(
115                "Not described yet. Before other work, describe the application from the README and manifests: \
116                 `kb project describe -` (",
117                crate::projects::description_scope!(),
118                ").\n"
119            )
120        )?;
121    } else {
122        writeln!(out, "{}\n", project.description)?;
123    }
124    if project.siblings.is_empty() {
125        if !project.notes.is_empty() {
126            writeln!(out, "{}\n", project.notes)?;
127        }
128        return Ok(());
129    }
130    writeln!(out, "This is its `{}` repository.", project.repo)?;
131    // Only a project of several repositories has roles to tell apart, and work that crosses between them:
132    // said here rather than in the section every repository's agents read.
133    if project.notes.is_empty() {
134        writeln!(out, "\nIts role isn't described yet: `kb project notes -` (what it owns, exposes and consumes).")?;
135    } else {
136        writeln!(out, "\n{}", project.notes)?;
137    }
138    writeln!(
139        out,
140        "\nIts other repositories. Work one of them needs, or a problem found in it, is a task there, not a note \
141         here: `kb --project {0}/NAME add TITLE`, then `kb link ID NAME#ID` relates it to the task here. \
142         `kb --project {0}/NAME search WORDS` finds its decisions and notes.\n",
143        project.name
144    )?;
145    for (name, dir) in &project.siblings {
146        let root = dir.parent().unwrap_or(dir);
147        writeln!(out, "- {name}: {}", root.display())?;
148    }
149    out.push('\n');
150    Ok(())
151}