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
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
//! Repo-memory commands, split out of the `commands` god-module: `memory` (doctor / rebind / list
//! / show) with its `*_bind_target` selector helpers, and `dream` (the deterministic
//! memory-maintenance worklist pass).
use rag_rat_core::{Config, OutputFormat};
use crate::cli::{DreamArgs, MemoryArgs, MemoryCommand};
use crate::commands::output_format;
use crate::open_index;
use crate::render::print_output;
/// Dream-mode worklist (#122): run the deterministic memory-maintenance pass (coverage gaps +
/// stale references), sync it into `dream_findings`, and render the open worklist. Writes ONLY to
/// `dream_findings` / the derived `memory_reality` + `memory_summaries` siblings — never mutates a
/// `repo_memories` row.
///
/// `--verify` turns on the dream v2 verification pass: the deterministic `memory_unverifiable`
/// findings always, and — when `[llm.dream] enabled = true` — the out-of-process model verdict
/// pass (writing `memory_reality` verdicts + `memory_divergence` findings). `--compact`
/// (independent of `--verify`) turns on the compaction pass: when the model is enabled it rewrites
/// un-summarized memories into `memory_summaries`. With neither flag the run is byte-identical to
/// the v1 deterministic worklist.
pub(crate) fn dream(config: &Config, args: &DreamArgs) -> anyhow::Result<()> {
// `dream` WRITES dream_findings — serialize with the watcher/index like every other write
// command (index/maintenance/oracle); WriteLock is reentrant so the open-time migrate is safe.
//
// ACCEPTED v1 POSTURE: with the model enabled, the verdict/compact passes run their whole
// budget (minutes of out-of-process network I/O) while HOLDING this lock, so the watcher and
// indexer block for the run's duration. That is acceptable because `dream --verify/--compact`
// is an EXPLICIT, human-invoked batch command run at most a few times a day — not a hot path.
// A finer-grained lock (release during model I/O) is deferred until that cadence proves wrong.
let lock_repo = rag_rat_core::locks::write_lock_repo_id(config);
let _lock = rag_rat_core::locks::WriteLock::acquire_blocking(&config.database, &lock_repo)?;
let db = open_index(config)?;
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
// REVIEW mode: `rag-rat dream <FINDING_ID> --accept|--dismiss|--reset` applies a human verdict
// to ONE finding and prints it — it does NOT run the worklist/model passes. The verdict is
// preserved across future runs (the sync refresh keeps accepted/dismissed).
if let Some(finding) = &args.finding {
let verdict = match (args.accept, args.dismiss, args.reset) {
(true, false, false) => rag_rat_core::dream::ReviewVerdict::Accept,
(false, true, false) => rag_rat_core::dream::ReviewVerdict::Dismiss,
(false, false, true) => rag_rat_core::dream::ReviewVerdict::Reset,
_ => anyhow::bail!(
"reviewing `{finding}` needs exactly one of --accept / --dismiss / --reset"
),
};
if args.verify || args.compact {
anyhow::bail!(
"--verify / --compact run the worklist; they can't combine with reviewing a \
finding"
);
}
let reviewed = db.review_dream_finding(finding, verdict, now_ms)?;
return print_output(&reviewed);
}
// A verdict flag with no finding id is a mistake (clap keeps the three verdicts mutually
// exclusive; this catches "flag but no id").
if args.accept || args.dismiss || args.reset {
anyhow::bail!("--accept / --dismiss / --reset need a <FINDING_ID> to review");
}
let opts = rag_rat_core::dream::DreamOptions {
now_ms,
limit: args.limit.unwrap_or(20) as usize,
verify: args.verify,
// `--all` also lists the human-reviewed (accepted/dismissed) findings, so you can see and
// `--reset` them; the default worklist is the open (needs-attention) set.
include_reviewed: args.all,
};
// The model passes run only when the operator asked for the matching flag AND enabled the model
// in config — the generative-model dependency stays strictly opt-in (#122). One client is built
// (out-of-process) and borrowed by whichever passes are active; with the model disabled, both
// are `None` and the run stays 100% deterministic.
let model_enabled = config.llm.dream.enabled;
// A model-pass flag with the model disabled would otherwise run silently as a
// deterministic-only pass; name the config key so the operator knows why no
// verdicts/summaries were written.
if (args.verify || args.compact) && !model_enabled {
eprintln!(
"note: --verify/--compact was requested but [llm.dream] enabled = false — running the \
deterministic passes only; set [llm.dream] enabled = true to run the model \
verdict/compaction pass."
);
}
let budget = args.max_memories.unwrap_or(20) as usize;
let remote = &config.llm.dream.remote;
// Build the model client when a model pass is asked-for AND enabled. For an EPHEMERAL
// `[llm.dream.remote]` (cookbook set), provision a GPU box first — but a ZERO-WORK GUARD skips
// that entirely when the churn-skip queues are already drained, so an idle `dream --verify`
// never cold-starts a paid box. `_provisioned` holds the box for the whole run; its `Drop`
// tears it down after the passes finish. Connect mode (or the local-Ollama default) builds
// directly.
let mut _provisioned = None;
let model = if model_enabled && (args.verify || args.compact) {
if remote.is_ephemeral() {
if db.dream_model_work_pending(
opts,
budget,
args.verify,
args.compact,
remote.model.trim(),
)? {
let (m, provisioned) = rag_rat_core::dream::provision_verdict_model(remote)?;
_provisioned = Some(provisioned);
Some(m)
} else {
eprintln!(
"note: no memories pending verification/compaction — skipping the ephemeral \
`[llm.dream.remote]` GPU box for this run."
);
None
}
} else {
Some(rag_rat_core::dream::HttpVerdictModel::from_config(remote)?)
}
} else {
None
};
let verdict_pass = (args.verify && model.is_some())
.then(|| rag_rat_core::dream::VerdictPass { model: model.as_ref().unwrap(), budget });
let compact_pass = (args.compact && model.is_some())
.then(|| rag_rat_core::dream::CompactPass { model: model.as_ref().unwrap(), budget });
let report = db.dream_run_with_passes(opts, verdict_pass, compact_pass)?;
print_output(&report)
}
// Each `memory rebind` target sets one anchor field and defaults the rest, so the call sites
// below state only what differs.
fn symbol_bind_target(
hit: &rag_rat_core::query::symbol::SymbolHit,
) -> rag_rat_core::query::memory::RepoMemoryBindTarget {
rag_rat_core::query::memory::RepoMemoryBindTarget {
symbol_id: Some(hit.symbol_id),
logical_symbol_id: hit.logical_symbol_id,
..Default::default()
}
}
fn path_bind_target(path: String) -> rag_rat_core::query::memory::RepoMemoryBindTarget {
rag_rat_core::query::memory::RepoMemoryBindTarget { path: Some(path), ..Default::default() }
}
fn dir_bind_target(dir: String) -> rag_rat_core::query::memory::RepoMemoryBindTarget {
rag_rat_core::query::memory::RepoMemoryBindTarget { dir: Some(dir), ..Default::default() }
}
fn chunk_bind_target(chunk_id: i64) -> rag_rat_core::query::memory::RepoMemoryBindTarget {
rag_rat_core::query::memory::RepoMemoryBindTarget {
chunk_id: Some(chunk_id),
..Default::default()
}
}
pub(crate) fn memory(config: &Config, args: &MemoryArgs) -> anyhow::Result<()> {
match &args.command {
MemoryCommand::Doctor => {
let db = open_index(config)?;
let entries = db.memory_doctor()?;
// Human-readable rebind suggestions by default; the global `--json` emits the
// structured doctor entries instead.
if output_format() == OutputFormat::Json {
print_output(&entries)?;
let any_gone = entries.iter().any(|e| e.anchor_status == "gone");
if any_gone {
anyhow::bail!("one or more memories have gone anchors");
}
return Ok(());
}
if entries.is_empty() {
eprintln!("All active memory anchors are current.");
return Ok(());
}
let mut any_gone = false;
for entry in &entries {
eprintln!("[{}] {} ({})", entry.anchor_status, entry.title, entry.memory_id);
eprintln!(" binding: {} {}", entry.binding_kind, entry.binding_id);
// `pending` (#492): the target is alive in another indexed scope (an in-flight
// worktree branch) — informational only. Never suggest rebind/mark-obsolete
// (that advice clobbered a valid forward anchor on the dogfood repo), and never
// fail the command over it.
if entry.anchor_status == "pending" {
eprintln!(
" -> in flight on another checkout (worktree overlay); no action needed \
— it re-anchors when that branch lands"
);
continue;
}
if entry.candidates.is_empty() {
if entry.anchor_status == "gone" {
eprintln!(
" -> code appears deleted; rag-rat memory mark-obsolete {}",
entry.memory_id
);
}
} else {
for candidate in &entry.candidates {
// Suggest --symbol-path (exact qualified-name match) rather than --symbol
// (substring): a fully-qualified candidate fed to --symbol would also hit
// longer siblings. Exact match plus cfg-group collapse makes this runnable.
eprintln!(
" rag-rat memory rebind {} --symbol-path {}",
entry.memory_id, candidate
);
}
}
if entry.anchor_status == "gone" {
any_gone = true;
}
}
if any_gone {
anyhow::bail!("one or more memories have gone anchors");
}
Ok(())
},
MemoryCommand::Rebind { memory_id, symbol, symbol_path, symbol_id, path, chunk, dir } => {
let db = open_index(config)?;
let bind = if symbol.is_some() || symbol_path.is_some() || symbol_id.is_some() {
let selector = rag_rat_core::query::symbol::SymbolSelector {
logical_symbol_id: None,
symbol_id: *symbol_id,
symbol_path: symbol_path.clone(),
symbol: symbol.clone(),
language: None,
allow_ambiguous: false,
limit: 10,
};
let label = symbol
.as_deref()
.or(symbol_path.as_deref())
.map(str::to_string)
.unwrap_or_else(|| format!("#{}", symbol_id.unwrap_or_default()));
match db.select_symbol_for_bind(&selector)? {
Ok(Some(hit)) => symbol_bind_target(&hit),
Ok(None) => anyhow::bail!("symbol `{label}` not found"),
Err(disambiguation) => anyhow::bail!(
"symbol `{label}` is ambiguous — disambiguate with one of:\n{}",
disambiguation
.candidates
.iter()
.map(|c| format!(
" --symbol-id {} ({} in {})",
c.symbol_id, c.qualified_name, c.path
))
.collect::<Vec<_>>()
.join("\n")
),
}
} else if let Some(path) = path {
path_bind_target(path.clone())
} else if let Some(chunk_id) = chunk {
chunk_bind_target(*chunk_id)
} else if let Some(dir) = dir {
dir_bind_target(dir.clone())
} else {
anyhow::bail!(
"memory rebind needs one of --symbol <name>, --symbol-path <path::name>, \
--symbol-id <id>, --path <path>, --chunk <id>, or --dir <dir>"
);
};
print_output(&db.memory_rebind(memory_id, bind)?)
},
MemoryCommand::List { kind } => {
let db = open_index(config)?;
let summaries = db.memory_list(kind.as_deref())?;
// The global `--json` emits the structured list (a caller parsing stdout gets JSON, not
// the human lines below).
if output_format() == OutputFormat::Json {
return print_output(&summaries);
}
if summaries.is_empty() {
eprintln!("No memories found.");
return Ok(());
}
for s in &summaries {
println!(
"{} [{}/{}] {} ({}:{})",
s.memory_id, s.kind, s.status, s.title, s.binding_kind, s.binding_id
);
}
Ok(())
},
MemoryCommand::Show { memory_id } => {
let db = open_index(config)?;
let Some(memory) = db.memory_get(memory_id)? else {
anyhow::bail!("memory `{memory_id}` not found");
};
// The global `--json` emits the structured memory instead of the human view below.
if output_format() == OutputFormat::Json {
return print_output(&memory);
}
println!("Title: {}", memory.title);
println!("Kind: {} / {} / {}", memory.kind, memory.status, memory.confidence);
println!();
println!("{}", memory.body);
if !memory.bindings.is_empty() {
println!();
println!("Bindings:");
for b in &memory.bindings {
println!(" {} {} [{}]", b.binding_kind, b.binding_id, b.anchor_status);
}
}
Ok(())
},
}
}