Skip to main content

lean_ctx_activity/
model.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The activity event: what one agent session did, in host-neutral terms.
3
4use serde::{Deserialize, Serialize};
5
6/// Longest message text kept (user or agent message, permission prompt,
7/// error), in bytes; longer text is cut at a char boundary.
8pub const MAX_TEXT_BYTES: usize = 2_000;
9/// Longest target kept (path, redacted shell command, URL, query), in bytes.
10pub const MAX_TARGET_BYTES: usize = 512;
11/// Longest label kept (session id, agent, tool name), in bytes.
12pub const MAX_LABEL_BYTES: usize = 128;
13/// Longest project root kept, in bytes.
14pub const MAX_ROOT_BYTES: usize = 1_024;
15
16/// What happened.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum Kind {
20    /// The person's message to the agent (`text`).
21    UserMessage,
22    /// The agent's reply (`text`, bounded).
23    AgentMessage,
24    /// A tool call that is neither a file read/write nor a shell command.
25    ToolCall,
26    /// A file read (`target` = path).
27    FileRead,
28    /// A file write or edit (`target` = path).
29    FileWrite,
30    /// A shell command (`target` = the redacted command).
31    Shell,
32    /// A permission prompt (`outcome` asked | allowed | denied).
33    Permission,
34    /// A turn began (the person submitted a prompt).
35    TurnStart,
36    /// A turn ended (the agent stopped).
37    TurnEnd,
38    /// The agent or the host reported an error (`text`).
39    Error,
40}
41
42impl Kind {
43    pub const ALL: [Kind; 10] = [
44        Kind::UserMessage,
45        Kind::AgentMessage,
46        Kind::ToolCall,
47        Kind::FileRead,
48        Kind::FileWrite,
49        Kind::Shell,
50        Kind::Permission,
51        Kind::TurnStart,
52        Kind::TurnEnd,
53        Kind::Error,
54    ];
55
56    pub fn as_str(self) -> &'static str {
57        match self {
58            Kind::UserMessage => "user_message",
59            Kind::AgentMessage => "agent_message",
60            Kind::ToolCall => "tool_call",
61            Kind::FileRead => "file_read",
62            Kind::FileWrite => "file_write",
63            Kind::Shell => "shell",
64            Kind::Permission => "permission",
65            Kind::TurnStart => "turn_start",
66            Kind::TurnEnd => "turn_end",
67            Kind::Error => "error",
68        }
69    }
70
71    /// The kind named `name` (its contract spelling).
72    pub fn parse(name: &str) -> Option<Kind> {
73        Kind::ALL.into_iter().find(|kind| kind.as_str() == name)
74    }
75}
76
77/// Who reported the event.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum Source {
81    /// A host hook (`lean-ctx hook observe`).
82    Hook,
83    /// A LeanCTX MCP tool call.
84    Mcp,
85    /// An ACP client callback (Hive Desktop driving the agent).
86    Acp,
87}
88
89/// How a tool call, shell command or permission prompt ended.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum Outcome {
93    Ok,
94    Error,
95    Denied,
96    Cancelled,
97    /// A permission prompt was shown.
98    Asked,
99    /// A permission prompt was answered with yes.
100    Allowed,
101}
102
103/// What a producer reports; the store adds `seq` and `at`, redacts and bounds.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct Draft {
106    pub session_id: String,
107    pub agent: String,
108    pub project_root: String,
109    pub kind: Kind,
110    pub source: Source,
111    pub tool: Option<String>,
112    pub target: Option<String>,
113    pub outcome: Option<Outcome>,
114    pub text: Option<String>,
115}
116
117impl Draft {
118    pub fn new(
119        session_id: impl Into<String>,
120        agent: impl Into<String>,
121        project_root: impl Into<String>,
122        kind: Kind,
123        source: Source,
124    ) -> Self {
125        Self {
126            session_id: session_id.into(),
127            agent: agent.into(),
128            project_root: project_root.into(),
129            kind,
130            source,
131            tool: None,
132            target: None,
133            outcome: None,
134            text: None,
135        }
136    }
137
138    pub fn tool(mut self, tool: impl Into<String>) -> Self {
139        self.tool = Some(tool.into());
140        self
141    }
142
143    pub fn target(mut self, target: impl Into<String>) -> Self {
144        self.target = Some(target.into());
145        self
146    }
147
148    pub fn outcome(mut self, outcome: Outcome) -> Self {
149        self.outcome = Some(outcome);
150        self
151    }
152
153    pub fn text(mut self, text: impl Into<String>) -> Self {
154        self.text = Some(text.into());
155        self
156    }
157}
158
159/// One stored event: one line of a session's log, and one item of
160/// `activity.list` (Local Read v1).
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct Event {
163    /// 1-based, increasing within the session (gaps after a trim).
164    pub seq: u64,
165    /// RFC 3339 UTC with milliseconds.
166    pub at: String,
167    pub session_id: String,
168    pub agent: String,
169    pub project_root: String,
170    pub kind: Kind,
171    pub source: Source,
172    pub tool: Option<String>,
173    pub target: Option<String>,
174    pub outcome: Option<Outcome>,
175    pub text: Option<String>,
176    /// Whether `text` or `target` was cut to its bound.
177    pub truncated: bool,
178}
179
180/// `text` cut to `max` bytes at a char boundary; whether it was cut.
181pub(crate) fn bound(text: &str, max: usize) -> (String, bool) {
182    if text.len() <= max {
183        return (text.to_owned(), false);
184    }
185    let mut end = max;
186    while end > 0 && !text.is_char_boundary(end) {
187        end -= 1;
188    }
189    (format!("{}…", &text[..end]), true)
190}
191
192/// Empty or whitespace-only text counts as absent.
193pub(crate) fn present(value: Option<String>) -> Option<String> {
194    value.filter(|text| !text.trim().is_empty())
195}