1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
//! `tm memory recall|remember|note|forget` — the no-MCP palace verbs (#8352).
//!
//! Why: a thin translation layer, the same shape `commands::memory` uses for
//! `import` — clap args in, one library call out, one report rendered. The verbs
//! themselves live in [`trusty_mpm::core::memory_verbs`] so they are testable
//! without a CLI, and so nothing about reaching the daemon is duplicated here.
//! What: [`run`] builds [`MemoryVerbOptions`], runs the verb, and prints either
//! the stable JSON envelope (`--json`) or a human summary. Returns `Err` — a
//! non-zero exit — when the palace could not be resolved, the socket could not
//! be derived, or the daemon did not answer. Exits
//! [`EXIT_SLOT_REFUSED`] (3) when a write was stored but its `--fact-key` slot
//! was refused (#9142). A forget the daemon did not report `deleted` is an
//! `Err` too (#9340), and so is a `--fact-key` that does not resolve to
//! exactly one live drawer.
//! Test: `cli_parses_memory_recall`, `cli_parses_memory_remember_with_tags`,
//! `cli_parses_memory_note`, `cli_parses_memory_forget` in `tests.rs`; the
//! socket behaviour is covered by
//! `tests/memory_verbs_socket.rs`.
use std::path::PathBuf;
use std::io::Write as _;
use anyhow::Context as _;
use serde_json::Value;
use trusty_mpm::core::memory_forget::{forget_failure, resolve_fact_key};
use trusty_mpm::core::memory_verbs::{
EXIT_SLOT_REFUSED, MemoryVerb, MemoryVerbOptions, MemoryVerbOutcome, run_verb, slot_refusal,
};
/// Longest slice of a recalled drawer printed per line.
///
/// Why: a recall hit can be a whole paragraph, and the human summary is an
/// index — `--json` is where the full text lives.
const SNIPPET_CHARS: usize = 160;
/// Run one no-MCP memory verb.
///
/// Why: keeps `commands::memory`'s dispatcher one arm per action.
/// What: see the module doc. A refused slot prints a warning (stderr under
/// `--json`, whose stdout is unchanged) and exits [`EXIT_SLOT_REFUSED`]; the
/// stored drawer is left as it is and nothing is retried. A forget answered
/// with anything but `deleted` prints the envelope under `--json`, no human
/// summary, and returns `Err` (#9340).
/// Test: `tests/memory_verbs_socket.rs`.
pub(crate) async fn run(
verb: MemoryVerb,
palace: Option<String>,
memory_socket: Option<PathBuf>,
json: bool,
) -> anyhow::Result<()> {
let opts = MemoryVerbOptions {
palace,
socket: memory_socket,
cwd: None,
};
let outcome = run_verb(&verb, &opts).await?;
// #9340: `not_found` is a successful daemon answer; it must not exit 0.
if let MemoryVerb::Forget { drawer_id } = &verb
&& let Some(reason) = forget_failure(drawer_id, outcome.palace.as_deref(), &outcome.result)
{
if json {
println!(
"{}",
serde_json::to_string_pretty(&outcome).context("serialise the memory envelope")?
);
}
anyhow::bail!(reason);
}
// #9142: a refused slot still stores the drawer; make that loud.
let refusal = slot_refusal(&verb, &outcome.result);
if json {
println!(
"{}",
serde_json::to_string_pretty(&outcome).context("serialise the memory envelope")?
);
if let Some(reason) = &refusal {
eprintln!("WARNING: slot refused: {reason}");
}
} else {
print_summary(&outcome, refusal.as_deref());
}
if refusal.is_some() {
std::io::stdout().flush().ok();
std::process::exit(EXIT_SLOT_REFUSED);
}
Ok(())
}
/// Run `tm memory forget --fact-key <key>`.
///
/// Why (#9340): an operator knows a slot (`pr:<n>/state`), not the UUID of the
/// drawer holding it; the supervisor ruled the CLI resolves one from the other.
/// What: [`resolve_fact_key`] lists the palace and picks the one live occupant,
/// refusing every unprovable case before anything is deleted. The forget then
/// goes through [`run`] as the id form, pinned to the palace and socket the
/// listing used, so its checks and output are the id form's own.
/// Not atomic: a write to the slot between the listing and the forget retires
/// the listed drawer, and the forget then removes that retired drawer.
/// Test: `forget_by_fact_key_forgets_the_one_listed_occupant`,
/// `forget_by_fact_key_with_no_match_fails_closed`.
pub(crate) async fn run_forget_fact_key(
fact_key: &str,
palace: Option<String>,
memory_socket: Option<PathBuf>,
json: bool,
) -> anyhow::Result<()> {
let opts = MemoryVerbOptions {
palace,
socket: memory_socket,
cwd: None,
};
let target = resolve_fact_key(fact_key, &opts).await?;
let verb = MemoryVerb::Forget {
drawer_id: target.drawer_id,
};
run(verb, Some(target.palace), Some(target.socket), json).await
}
/// Render the human summary.
///
/// Why: an operator running this by hand wants the hits, or the one line saying
/// what the write did — not the envelope. Nothing parses this; `--json` is the
/// machine surface.
/// What: a recall prints one line per hit plus a count; a write prints the
/// daemon's own `status`, its reason when it skipped, and the drawer id when it
/// stored. A palace index (the #6318 answer to a recall with no palace) prints
/// the hint the daemon supplied. A `refusal` adds a WARNING line (#9142).
/// Test: cosmetic; exercised through `tests/memory_verbs_socket.rs`.
fn print_summary(outcome: &MemoryVerbOutcome, refusal: Option<&str>) {
let palace = outcome.palace.as_deref().unwrap_or("(none resolved)");
match outcome.count {
Some(count) => {
let results = outcome
.result
.get("results")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
for hit in results {
let score = hit.get("score").and_then(Value::as_f64).unwrap_or(0.0);
let layer = hit.get("layer").and_then(Value::as_str).unwrap_or("-");
let content = hit.get("content").and_then(Value::as_str).unwrap_or("");
println!("[{score:.3}] {layer:<10} {}", snippet(content));
}
if let Some(hint) = outcome.result.get("hint").and_then(Value::as_str) {
println!("{hint}");
}
println!("\n{count} hit(s) from palace {palace}");
}
None => {
let status = outcome
.result
.get("status")
.and_then(Value::as_str)
.unwrap_or("stored");
let drawer = outcome
.result
.get("drawer_id")
.and_then(Value::as_str)
.unwrap_or("-");
match outcome.result.get("reason").and_then(Value::as_str) {
Some(reason) => println!("{status} → palace {palace}: {reason}"),
None => println!("{status} {drawer} → palace {palace}"),
}
if let Some(reason) = refusal {
println!("WARNING: slot refused: {reason} — drawer {drawer} was stored unslotted");
}
}
}
}
/// One line of `content`, capped at [`SNIPPET_CHARS`] characters.
fn snippet(content: &str) -> String {
let first = content.lines().next().unwrap_or("").trim();
if first.chars().count() <= SNIPPET_CHARS {
return first.to_string();
}
let cut: String = first.chars().take(SNIPPET_CHARS).collect();
format!("{cut}…")
}