Skip to main content

newt_core/
conversation.rs

1//! Shared conversation types and free functions.
2//!
3//! Until step 17.1b this module also housed the original JSON-file
4//! `ConversationStore` (one pretty-printed record per conversation under
5//! `<root>/conversations/<workspace-uuid>/<id>.json`). That write path is
6//! gone: the SQLite store at [`crate::store::ConversationStore`] is the only
7//! backend, and it performs a one-time import of any legacy JSON tree on
8//! open (renaming it to `conversations.imported/` as a backup). What remains
9//! here is the storage-agnostic surface both the store and the TUI share:
10//! the record/summary/turn types, conversation-id minting, and the
11//! per-session plan paths (issue #220).
12
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicU64, Ordering};
16
17use serde::{Deserialize, Serialize};
18
19static CLOCK_TIEBREAKER: AtomicU64 = AtomicU64::new(0);
20
21/// One tool invocation recorded during a turn (Step 17.6, issue #246).
22///
23/// Serialized (as an array element) into the store's `turns.events` JSON
24/// column. The field names `tool` and `args_digest` are **load-bearing**:
25/// the FTS5 recall index derives its `tool_names` / `tool_args_digest`
26/// columns by extracting exactly `$.tool` and `$.args_digest` from each
27/// element (see `store.rs` — `events_extract_sql`, the 17.3 seam) — rename
28/// either and tool recall goes dark. Unknown extra fields are ignored on
29/// load, so the shape can grow additively without a migration.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct ToolEvent {
32    /// Tool name exactly as the model invoked it (`read_file`,
33    /// `run_command`, an MCP `server__tool`, or a hallucinated name —
34    /// recorded as called, ok = false tells the story).
35    pub tool: String,
36    /// Privacy-preserving argument digest: the argument *key names*
37    /// (searchable) plus a truncated BLAKE3 of the canonical args JSON
38    /// (correlatable). **Never raw argument values** — args carry file
39    /// contents and can carry secrets. Built by [`ToolEvent::from_call`].
40    pub args_digest: String,
41    /// Whether the tool result read as success. Best-effort: the loop's
42    /// tool results are plain strings, so this mirrors the `error:` /
43    /// `capability denied:` / `unknown tool` prefixes `execute_tool` emits.
44    pub ok: bool,
45    /// Wall-clock duration of the tool call in milliseconds. A display
46    /// claim only (§6): never an ordering key — order within the turn is
47    /// the events array position.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub duration_ms: Option<u64>,
50}
51
52impl ToolEvent {
53    /// Record one tool call. `args` are digested, never stored raw: the
54    /// digest is the object's key names (space-joined — useful FTS terms
55    /// that cannot leak values) plus `b3:` + the first 16 hex chars of
56    /// BLAKE3 over the canonical JSON, so two turns calling the same tool
57    /// with identical args correlate without exposing what the args were.
58    pub fn from_call(
59        tool: impl Into<String>,
60        args: &serde_json::Value,
61        ok: bool,
62        duration_ms: Option<u64>,
63    ) -> Self {
64        // serde_json's default map is ordered (BTreeMap), so `to_string`
65        // is canonical for a given parsed value and the digest is stable.
66        let hash = blake3::hash(args.to_string().as_bytes()).to_hex();
67        let short = &hash.as_str()[..16];
68        let keys = args
69            .as_object()
70            .map(|m| m.keys().cloned().collect::<Vec<_>>().join(" "))
71            .unwrap_or_default();
72        let args_digest = if keys.is_empty() {
73            format!("b3:{short}")
74        } else {
75            format!("{keys} b3:{short}")
76        };
77        Self {
78            tool: tool.into(),
79            args_digest,
80            ok,
81            duration_ms,
82        }
83    }
84}
85
86/// #717: a phantom tool/capability reach — a tool/notion the model grabbed for
87/// that is NOT a real tool (an alias or hallucination), or a real tool that
88/// returned empty-by-design (`state_get` for an unset key, `recall` with no
89/// matches). Distinct from [`ToolEvent`] (which stays for tool-recall): this is
90/// the alias-seam telemetry — *which* foreign notions weak local models reach
91/// for, so the seam is driven by data, not guesses.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct PhantomReach {
94    /// The literal tool/notion name the model emitted.
95    pub name_as_called: String,
96    /// How newt resolved the reach.
97    pub resolution: PhantomResolution,
98    /// Context features in effect when the reach happened (so we can see e.g.
99    /// `scheduled` was on but the model still reached for a phantom `plan`).
100    /// Empty when unknown.
101    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102    pub active_context_features: Vec<String>,
103}
104
105/// How a [`PhantomReach`] resolved (#717).
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case", tag = "kind", content = "detail")]
108pub enum PhantomResolution {
109    /// A foreign name rewritten to a real tool (the canonical name).
110    Rewrite(String),
111    /// A foreign name corrected with guidance (the message naming the right tool).
112    Correct(String),
113    /// An unknown name with no alias — a true phantom tool.
114    Unknown,
115    /// A real tool that misfired / returned empty-by-design (the reason).
116    RealToolMiss(String),
117    /// #479 (G4): a real tool reached while its surface is presence-gated OFF —
118    /// `crew`/`compose_roster` with the crew/team surface absent (the default,
119    /// since the runner is only built when the operator sets `NEWT_TEAM`). The
120    /// name stays real (in `ALL_TOOL_NAMES`, so `is_hallucination` is unchanged
121    /// and the ON path dispatches normally), so `classify_phantom_reach` never
122    /// flags it — yet a gated-off reach is exactly the delegation signal we want
123    /// to mine for the common OFF default. The string names the gated surface.
124    GatedOff(String),
125    /// A reach narrated in prose that never became a tool call (reserved; NOT
126    /// emitted in v1 — the narration scanner is a deferred follow-up).
127    NarratedNoCall,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct ConversationTurn {
132    pub user: String,
133    pub assistant: String,
134    /// Tool events recorded during the turn (17.6). Empty for pre-17.6
135    /// rows and for turns that called no tools.
136    #[serde(default, skip_serializing_if = "Vec::is_empty")]
137    pub events: Vec<ToolEvent>,
138    /// Phantom tool/capability reaches recorded during the turn (#717). Empty
139    /// for pre-#717 turns or turns with no phantom reach. Additive: `#[serde(default)]`.
140    #[serde(default, skip_serializing_if = "Vec::is_empty")]
141    pub phantom_reaches: Vec<PhantomReach>,
142    /// Backend-reported prompt tokens for the turn (largest single prompt
143    /// across the turn's rounds — see `chat_complete`'s Step 18.1 usage
144    /// semantics). `None` when the backend reported nothing: absence is
145    /// stored as absence, never an estimate (18.5 rehydrates from this).
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub tokens_in: Option<u32>,
148    /// Backend-reported completion tokens (summed across rounds). `None`
149    /// when the backend reported nothing.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub tokens_out: Option<u32>,
152}
153
154impl ConversationTurn {
155    pub fn new(user: impl Into<String>, assistant: impl Into<String>) -> Self {
156        Self {
157            user: user.into(),
158            assistant: assistant.into(),
159            events: Vec::new(),
160            phantom_reaches: Vec::new(),
161            tokens_in: None,
162            tokens_out: None,
163        }
164    }
165}
166
167/// A full conversation as loaded from the store. Also the on-disk shape of
168/// the retired JSON backend's records — kept serde-compatible so the 17.1b
169/// one-time import can parse legacy files with the exact semantics they were
170/// written under.
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172pub struct ConversationRecord {
173    pub id: String,
174    pub title: String,
175    pub workspace: String,
176    pub workspace_id: String,
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub persona: Option<String>,
179    #[serde(default)]
180    pub turns: Vec<ConversationTurn>,
181    /// The conversation's scratchpad `<state>` snapshot (#713, Step 26.4): the
182    /// structured working memory the model keeps via `state_set` / `state_get`.
183    /// Persisted at the conversation/record level so an interrupt + auto-resume
184    /// restores the `<state>` it already restores the transcript for — without
185    /// it, the first `state_get("current_task")` after a resume reads an empty
186    /// live store and the model blind-probes for a task that is simply gone.
187    /// Additive: `#[serde(default)]` keeps legacy records loadable, and an empty
188    /// map is skipped on serialize so it never bloats the on-disk file. **Working
189    /// memory, not provenance** — kept OUT of the §6 content chain (it is NOT a
190    /// turn field and never enters `canonical_encoding_v1`).
191    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
192    pub scratchpad: BTreeMap<String, String>,
193    /// The conversation's plan-ledger snapshot (#715, Step 26.6b): the ordered
194    /// `<plan>` steps with each one's done/active status. Persisted at the
195    /// conversation/record level — exactly like `scratchpad` — so an interrupt +
196    /// auto-resume re-hydrates the in-memory ledger instead of rebuilding it
197    /// empty (which loses the `<plan>` block and `plan_get` after a resume).
198    /// Additive: `#[serde(default)]` keeps legacy records loadable, and an empty
199    /// snapshot is skipped on serialize so it never bloats the on-disk file.
200    /// **Working memory, not provenance** — kept OUT of the §6 content chain (it
201    /// is NOT a turn field and never enters `canonical_encoding_v1`).
202    #[serde(default, skip_serializing_if = "crate::PlanSnapshot::is_empty")]
203    pub plan: crate::PlanSnapshot,
204    pub created_at_unix_nanos: u128,
205    pub updated_at_unix_nanos: u128,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct ConversationSummary {
210    pub id: String,
211    pub title: String,
212    pub persona: Option<String>,
213    pub turn_count: usize,
214    pub updated_at_unix_nanos: u128,
215}
216
217/// Mint a fresh conversation id: `{unix_nanos}-{uuid_v4}`.
218///
219/// Exposed so the TUI can pre-generate an id at session start — the same id
220/// keys both the durable conversation record and the per-session plan dir
221/// (issue #220) — and then hand it to
222/// [`crate::store::ConversationStore::create_with_id`]. Two concurrent newt
223/// processes mint distinct ids (distinct nanos + distinct UUIDs), so their
224/// plan files never collide.
225pub fn new_conversation_id() -> String {
226    format!("{}-{}", unix_nanos(), uuid::Uuid::new_v4())
227}
228
229/// Workspace-relative directory holding a conversation's per-session plan:
230/// `<scratch>/sessions/<conversation-id>` (default `.scratch/sessions/...`). Under
231/// the ephemeral scratch root (#844) so it stays out of `.newt/` tracked config;
232/// kept workspace-RELATIVE (via [`crate::scratch::scratch_workspace_subdir`]) so
233/// the file tools' workspace fence permits writing it — an absolute scratch
234/// relocation applies to crew scratch, not fenced session plans. See #220, #844.
235pub fn session_plan_dir(conversation_id: &str) -> PathBuf {
236    Path::new(&crate::scratch::scratch_workspace_subdir())
237        .join("sessions")
238        .join(conversation_id)
239}
240
241/// Workspace-relative per-session plan document path:
242/// `.scratch/sessions/<conversation-id>/plan.md`. This is the path the system
243/// prompt tells the model to use; it replaces the old fixed `.newt/plan.md`
244/// that collided when several newt instances ran in one repo. See #220, #844.
245pub fn session_plan_path(conversation_id: &str) -> PathBuf {
246    session_plan_dir(conversation_id).join("plan.md")
247}
248
249fn unix_nanos() -> u128 {
250    let base = std::time::SystemTime::now()
251        .duration_since(std::time::UNIX_EPOCH)
252        .map(|d| d.as_nanos())
253        .unwrap_or(0);
254    base + CLOCK_TIEBREAKER.fetch_add(1, Ordering::Relaxed) as u128
255}