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::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        write!(out, "- #{} {}", task.id, task.title)?;
68        for tag in &task.tags {
69            write!(out, " #{tag}")?;
70        }
71        if let Some(running) = task.running_for(now) {
72            write!(out, " · running {}", fmt_clock(running))?;
73        }
74        if let Some(note) = state_note(task) {
75            write!(out, " · {note}")?;
76        }
77        out.push('\n');
78        match task.asked() {
79            Some(Asked::Waiting(question)) => excerpt(out, "question", question, now)?,
80            Some(Asked::Answered(answer)) => excerpt(out, "answer", answer, now)?,
81            None if matches!(task.status, Status::Doing | Status::Parked) && task.is_task() => {
82                if let Some(comment) = task.comments.last() {
83                    excerpt(out, "last comment", comment, now)?;
84                }
85            }
86            None => {}
87        }
88    }
89    if let Some(more) = tasks.len().checked_sub(limit).filter(|&n| n > 0) {
90        writeln!(out, "- … {more} more: `kb search` finds them")?;
91    }
92    out.push('\n');
93    Ok(())
94}
95
96/// `  last comment (2h ago): first line…`, under a task, for `comment` and what it is.
97fn excerpt(out: &mut String, what: &str, comment: &Comment, now: chrono::DateTime<chrono::Local>) -> std::fmt::Result {
98    let first = comment.body.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or_default();
99    let mut excerpt: String = first.chars().take(EXCERPT_CHARS).collect();
100    if excerpt.len() < first.len() || comment.body.trim() != first {
101        excerpt.push('…');
102    }
103    writeln!(out, "  {what} ({} ago): {excerpt}", fmt_age(now - comment.created))
104}
105
106/// What the application is about (or that it isn't described yet) and, for one of several
107/// repositories, this one's role (or that it isn't described yet) and where the others are: an agent knows the project without exploring it, and can reach the
108/// other services' tasks with `--project`.
109fn write_project(out: &mut String, project: &ProjectInfo) -> std::fmt::Result {
110    writeln!(out, "## Project {}\n", project.name)?;
111    // Every session starts from the description, so an agent that finds none is told, in one line, to
112    // write it before anything else: asked only to "keep it up to date", agents left it empty.
113    if project.description.is_empty() {
114        writeln!(
115            out,
116            concat!(
117                "Not described yet. Before other work, describe the application from the README and manifests: \
118                 `kb project describe -` (",
119                crate::projects::description_scope!(),
120                ").\n"
121            )
122        )?;
123    } else {
124        writeln!(out, "{}\n", project.description)?;
125    }
126    if project.siblings.is_empty() {
127        if !project.notes.is_empty() {
128            writeln!(out, "{}\n", project.notes)?;
129        }
130        return Ok(());
131    }
132    writeln!(out, "This is its `{}` repository.", project.repo)?;
133    // Only a project of several repositories has roles to tell apart, and work that crosses between them:
134    // said here rather than in the section every repository's agents read.
135    if project.notes.is_empty() {
136        writeln!(out, "\nIts role isn't described yet: `kb project notes -` (what it owns, exposes and consumes).")?;
137    } else {
138        writeln!(out, "\n{}", project.notes)?;
139    }
140    writeln!(
141        out,
142        "\nIts other repositories. Work one of them needs, or a problem found in it, is a task there, not a note \
143         here: `kb --project {0}/NAME add TITLE`, then `kb link ID NAME#ID` relates it to the task here. \
144         `kb --project {0}/NAME search WORDS` finds its decisions and notes.\n",
145        project.name
146    )?;
147    for (name, dir) in &project.siblings {
148        let root = dir.parent().unwrap_or(dir);
149        writeln!(out, "- {name}: {}", root.display())?;
150    }
151    out.push('\n');
152    Ok(())
153}