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}