返回 CodeWhale
native_memory.rs
根目录 / crates / tui / src / native_memory.rs
1 //! Structured replacement for the old Markdown-backed native memory store.
2 //! All existing entry points share root/store.sqlite3 with the Context Lens.
3 //! MEMORY.md paths are legacy anchors, NOT an alternative source of truth.
4 //! Capture is candidate-only. Human review is through authenticated Lens routes.
5 use anyhow::{Result, anyhow, bail};
6 use codewhale_memory::hooks::{self, Boundary, HookEvent, HookPolicy};
7 use codewhale_memory::{
8 Access, ByteCounter, ContextBudget, Draft, Evidence, Freshness, Memory, MemoryBackend, Recall,
9 Scope, Snapshot, SourceKind, Status, Store, policy, workspace,
10 };
11 use std::{
12 fs,
13 path::{Path, PathBuf},
14 process::Command,
15 };
16
17 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
18 pub enum MemoryScope {
19 Global,
20 Workspace,
21 }
22 #[derive(Debug, Clone, PartialEq, Eq)]
23 pub struct MemoryHit {
24 pub id: i64,
25 pub text: String,
26 pub source: PathBuf,
27 pub line_start: usize,
28 pub line_end: usize,
29 pub stale: bool,
30 }
31 #[derive(Debug, Clone)]
32 pub struct NativeMemoryStore {
33 root: PathBuf,
34 }
35 impl NativeMemoryStore {
36 pub fn new(root: impl Into<PathBuf>) -> Self {
37 Self { root: root.into() }
38 }
39 pub fn root(&self) -> &Path {
40 &self.root
41 }
42 pub fn global_path(&self) -> PathBuf {
43 self.root.join("global/MEMORY.md")
44 }
45 pub fn index_path(&self) -> PathBuf {
46 self.root.join("store.sqlite3")
47 }
48 pub fn from_global_path(path: &Path) -> Option<Self> {
49 if path.file_name()?.to_str()? != "MEMORY.md"
50 || path.parent()?.file_name()?.to_str()? != "global"
51 {
52 return None;
53 }
54 let root = path.parent()?.parent()?;
55 (root.file_name()?.to_str()? == "memory").then(|| Self::new(root))
56 }
57 pub fn from_memory_anchor(path: &Path) -> Self {
58 Self::from_global_path(path)
59 .unwrap_or_else(|| Self::new(path.parent().unwrap_or(Path::new(".")).join("memory")))
60 }
61 pub fn owner_scope() -> Scope {
62 Scope::user("local", "owner")
63 }
64 pub fn workspace_scope(id: &str) -> Result<Scope> {
65 safe_component(id)?;
66 Ok(Self::owner_scope().workspace(id))
67 }
68 pub fn workspace_id(workspace: &Path) -> Result<Option<String>> {
69 let out = Command::new("git")
70 .arg("-C")
71 .arg(workspace)
72 .args(["config", "--get", "remote.origin.url"])
73 .output()?;
74 if !out.status.success() {
75 return Ok(None);
76 }
77 let value = String::from_utf8_lossy(&out.stdout).trim().to_owned();
78 Ok(if value.is_empty() {
79 None
80 } else {
81 Some(policy::sha256(value.as_bytes()))
82 })
83 }
84 pub fn open_structured(&self) -> Result<Store> {
85 Ok(Store::open(self.index_path())?)
86 }
87 fn scopes(&self, id: Option<&str>) -> Result<Vec<Scope>> {
88 let mut scopes = vec![Self::owner_scope()];
89 if let Some(id) = id {
90 scopes.push(Self::workspace_scope(id)?);
91 }
92 Ok(scopes)
93 }
94 fn hit(
95 &self,
96 store: &Store,
97 access: &Access,
98 m: &Memory,
99 snapshot: &Snapshot,
100 ) -> Result<MemoryHit> {
101 // The scope's legacy anchor names the memory's origin for callers that
102 // label records by source path; it is an identifier, not a live file.
103 let source = match &m.draft.scope.workspace {
104 Some(id) => self.root.join("workspace").join(id).join("MEMORY.md"),
105 None => self.global_path(),
106 };
107 Ok(MemoryHit {
108 id: store.numeric_id(access, &m.id)?,
109 text: m.draft.body.clone(),
110 source,
111 line_start: 0,
112 line_end: 0,
113 stale: store.freshness(access, m, snapshot)? != Freshness::Current,
114 })
115 }
116 fn access_for(&self, id: Option<&str>) -> Result<Access> {
117 Ok(Access::operator(self.scopes(id)?)?)
118 }
119 pub fn remember(
120 &self,
121 scope: MemoryScope,
122 workspace_id: Option<&str>,
123 note: &str,
124 ) -> Result<MemoryHit> {
125 let selected = match scope {
126 MemoryScope::Global => Self::owner_scope(),
127 MemoryScope::Workspace => Self::workspace_scope(
128 workspace_id.ok_or_else(|| anyhow!("workspace scope requires an id"))?,
129 )?,
130 };
131 let access = Access::agent(vec![selected.clone()])?;
132 let mut store = self.open_structured()?;
133 let draft = Draft::note(
134 selected,
135 policy::excerpt(note, 80),
136 note,
137 Evidence {
138 kind: SourceKind::Agent,
139 uri: "codewhale:native-capture".into(),
140 locator: "Unreviewed capture through legacy native entry point".into(),
141 sha256: None,
142 observed_at: 0,
143 },
144 );
145 let key = format!("native-note:{}", policy::sha256(note.as_bytes()));
146 let r = store.capture(&access, &key, draft)?;
147 let mut hit = self.hit(&store, &access, &r.memory, &Snapshot::default())?;
148 hit.text = format!("Pending review in Context Lens: {}", hit.text);
149 Ok(hit)
150 }
151 /// Authenticated operator surfaces (`POST /v1/memory`, the Lens remember
152 /// action): the explicit request IS the review, so the capture is promoted
153 /// in the same call, exactly as `Action::Remember` does. Model-reachable
154 /// paths must keep using `remember`, which can only propose a candidate.
155 pub fn remember_reviewed(
156 &self,
157 scope: MemoryScope,
158 workspace_id: Option<&str>,
159 note: &str,
160 ) -> Result<MemoryHit> {
161 let selected = match scope {
162 MemoryScope::Global => Self::owner_scope(),
163 MemoryScope::Workspace => Self::workspace_scope(
164 workspace_id.ok_or_else(|| anyhow!("workspace scope requires an id"))?,
165 )?,
166 };
167 let access = Access::operator(vec![selected.clone()])?;
168 let mut store = self.open_structured()?;
169 let draft = Draft::note(
170 selected,
171 policy::excerpt(note, 80),
172 note,
173 Evidence {
174 kind: SourceKind::User,
175 uri: "codewhale:operator".into(),
176 locator: "Explicit remember through an authenticated host surface".into(),
177 sha256: None,
178 observed_at: 0,
179 },
180 );
181 let key = format!("operator-note:{}", policy::sha256(note.as_bytes()));
182 let r = store.capture(&access, &key, draft)?;
183 let memory = if r.memory.status == Status::Candidate {
184 store.approve(
185 &access,
186 &r.memory.id,
187 r.memory.revision,
188 None,
189 &Snapshot::default(),
190 )?
191 } else {
192 r.memory
193 };
194 self.hit(&store, &access, &memory, &Snapshot::default())
195 }
196 pub fn revise(
197 &self,
198 scope: MemoryScope,
199 workspace_id: Option<&str>,
200 from: &str,
201 to: &str,
202 evidence: &str,
203 ) -> Result<MemoryHit> {
204 let selected = match scope {
205 MemoryScope::Global => Self::owner_scope(),
206 MemoryScope::Workspace => Self::workspace_scope(
207 workspace_id.ok_or_else(|| anyhow!("workspace id required"))?,
208 )?,
209 };
210 let access = Access::agent(vec![selected.clone()])?;
211 let mut store = self.open_structured()?;
212 let entries = store.list(&access, None, 500)?;
213 let matches: Vec<_> = entries
214 .iter()
215 .filter(|m| m.status == Status::Active && m.draft.body.trim() == from.trim())
216 .collect();
217 if matches.len() != 1 {
218 bail!(
219 "correction must match exactly one active note on the bounded page; use Context Lens UUIDs"
220 );
221 }
222 let old = matches[0];
223 let mut draft = Draft::note(
224 selected,
225 policy::excerpt(to, 80),
226 to,
227 Evidence {
228 kind: SourceKind::Agent,
229 uri: format!("codewhale:memory:{}", old.id),
230 locator: policy::excerpt(evidence, 256),
231 sha256: None,
232 observed_at: 0,
233 },
234 );
235 draft.kind = old.draft.kind;
236 draft.key = old.draft.key.clone();
237 let request = format!(
238 "native-correction:{}:{}",
239 old.id,
240 policy::sha256(format!("{to}\0{evidence}").as_bytes())
241 );
242 let r = store.capture(&access, &request, draft)?;
243 let mut hit = self.hit(&store, &access, &r.memory, &Snapshot::default())?;
244 hit.text = format!(
245 "Correction candidate; original unchanged. Review in Context Lens: {}",
246 hit.text
247 );
248 Ok(hit)
249 }
250 // Retirement is a review operation: the Lens `forget` action owns it. No
251 // unreviewed delete entry point exists on this facade.
252 pub fn prompt_block(
253 &self,
254 workspace: &Path,
255 max_entries: usize,
256 max_chars: usize,
257 ) -> Result<Option<String>> {
258 let id = Self::workspace_id(workspace)?;
259 let access = self.access_for(id.as_deref())?;
260 let store = self.open_structured()?;
261 let snapshot = workspace::snapshot(workspace, store.dependency_paths(&access)?)?;
262 let (_report, packet) = store.context_packet(
263 &access,
264 &Recall {
265 limit: max_entries.clamp(1, 64),
266 snapshot,
267 ..Default::default()
268 },
269 &ByteCounter,
270 &ContextBudget {
271 max_units: max_chars,
272 max_bytes: max_chars,
273 max_entries,
274 },
275 )?;
276 Ok(if packet.selected.is_empty() {
277 None
278 } else {
279 Some(packet.text)
280 })
281 }
282 /// Session-scoped structured access for engine boundaries. Scopes are
283 /// owner + workspace (when the repository has an origin) + this session's
284 /// context scope, so receipts are attributable to the session that made
285 /// them and nothing else can write into them.
286 fn session_binding(
287 &self,
288 workspace: &Path,
289 session_id: &str,
290 ) -> Result<(Store, Access, Scope)> {
291 let id = Self::workspace_id(workspace)?;
292 let context_scope = match &id {
293 Some(id) => Self::workspace_scope(id)?,
294 None => Self::owner_scope(),
295 }
296 .session(session_id);
297 let mut scopes = vec![Self::owner_scope()];
298 if let Some(id) = &id {
299 scopes.push(Self::workspace_scope(id)?);
300 }
301 scopes.push(context_scope.clone());
302 let access = Access::operator(scopes)?;
303 Ok((self.open_structured()?, access, context_scope))
304 }
305 /// Like `prompt_block`, but the packet is prepared through the engine's
306 /// durable receipt path: `memory_contexts` records which bytes and memory
307 /// revisions were assembled, under this session's trace. The request key is
308 /// bound to the recall inputs, so an identical rebuild is idempotent while
309 /// changed inputs mint a new receipt. A receipt records preparation only —
310 /// append/dispatch acknowledgements remain the engine's to make.
311 ///
312 /// Receipt failure falls back to the same packet bytes without one: the
313 /// receipt is observability, never a precondition for shipping context.
314 pub fn prompt_block_traced(
315 &self,
316 workspace: &Path,
317 session_id: &str,
318 max_entries: usize,
319 max_chars: usize,
320 ) -> Result<Option<String>> {
321 if session_id.is_empty()
322 || session_id.len() > 128
323 || !session_id
324 .bytes()
325 .all(|b| b.is_ascii_alphanumeric() || b"_-.:".contains(&b))
326 {
327 return self.prompt_block(workspace, max_entries, max_chars);
328 }
329 let (store, access, context_scope) = self.session_binding(workspace, session_id)?;
330 let snapshot = workspace::snapshot(workspace, store.dependency_paths(&access)?)?;
331 let query = Recall {
332 limit: max_entries.clamp(1, 64),
333 snapshot,
334 ..Default::default()
335 };
336 let key_material = serde_json::to_vec(
337 &serde_json::json!({"q":query.query,"snapshot":query.snapshot,"limit":query.limit,
338 "graph":query.expand_graph,"vector_cap":query.vector_scan_limit,"budget":[max_chars,max_entries],"unit":"utf8_bytes"}),
339 )?;
340 let request_key = format!("session-prompt:{}", policy::sha256(&key_material));
341 let budget = ContextBudget {
342 max_units: max_chars,
343 max_bytes: max_chars,
344 max_entries,
345 };
346 match store.prepare_context(
347 &access,
348 &context_scope,
349 session_id,
350 &request_key,
351 &query,
352 &ByteCounter,
353 &budget,
354 ) {
355 Ok(prepared) => Ok(if prepared.packet.selected.is_empty() {
356 None
357 } else {
358 Some(prepared.packet.text)
359 }),
360 Err(_) => self.prompt_block(workspace, max_entries, max_chars),
361 }
362 }
363 /// Run the session-start boundary for `session_id`. The hook plan maps it
364 /// to ReconcilePendingOperations, realized here as detection — contexts
365 /// this session prepared but never saw dispatch-acknowledged — because the
366 /// store owns the receipts and only a host can replay them. Completion is
367 /// recorded durably, so a restarted session reconciles once.
368 /// Returns the number of interrupted contexts found.
369 pub fn session_start(&self, workspace: &Path, session_id: &str) -> Result<usize> {
370 if session_id.is_empty()
371 || session_id.len() > 128
372 || !session_id
373 .bytes()
374 .all(|b| b.is_ascii_alphanumeric() || b"_-.:".contains(&b))
375 {
376 bail!("invalid session id for memory reconcile");
377 }
378 let (store, access, context_scope) = self.session_binding(workspace, session_id)?;
379 let snapshot = workspace::snapshot(workspace, store.dependency_paths(&access)?)?;
380 let event = HookEvent {
381 id: format!("session-start:{session_id}"),
382 boundary: Boundary::SessionStart,
383 trace_id: session_id.to_owned(),
384 sequence: 0,
385 observed_at: store.timestamp(),
386 explicit_user_request: false,
387 success: None,
388 };
389 let plan = hooks::plan(
390 &HookPolicy {
391 enabled: true,
392 auto_candidates: false,
393 recall_on_task: true,
394 },
395 &event,
396 );
397 let mut interrupted = 0usize;
398 for intent in &plan.intents {
399 if intent == &hooks::Intent::ReconcilePendingOperations {
400 interrupted = store
401 .context_receipts(&access, Some(session_id), &snapshot)?
402 .iter()
403 .filter(|r| r.stage != "dispatched")
404 .count();
405 }
406 }
407 store.record_completed_hook(&access, &context_scope, &event)?;
408 Ok(interrupted)
409 }
410 pub fn search(&self, query: &str, limit: usize) -> Result<Vec<MemoryHit>> {
411 self.search_scoped(None, None, query, limit)
412 }
413 pub fn search_for_workspace(
414 &self,
415 workspace: &Path,
416 query: &str,
417 limit: usize,
418 ) -> Result<Vec<MemoryHit>> {
419 let id = Self::workspace_id(workspace)?;
420 self.search_scoped(id.as_deref(), Some(workspace), query, limit)
421 }
422 fn search_scoped(
423 &self,
424 id: Option<&str>,
425 root: Option<&Path>,
426 query: &str,
427 limit: usize,
428 ) -> Result<Vec<MemoryHit>> {
429 let access = self.access_for(id)?;
430 let store = self.open_structured()?;
431 let snapshot = match root {
432 Some(root) => workspace::snapshot(root, store.dependency_paths(&access)?)?,
433 None => Snapshot::default(),
434 };
435 let report = store.recall(
436 &access,
437 &Recall {
438 query: query.into(),
439 limit,
440 snapshot: snapshot.clone(),
441 ..Default::default()
442 },
443 )?;
444 report
445 .hits
446 .iter()
447 .map(|h| self.hit(&store, &access, &h.memory, &snapshot))
448 .collect()
449 }
450 pub fn get_for_workspace(&self, workspace: &Path, id: i64) -> Result<Option<MemoryHit>> {
451 let workspace_id = Self::workspace_id(workspace)?;
452 self.get_scoped(workspace_id.as_deref(), Some(workspace), id)
453 }
454 fn get_scoped(
455 &self,
456 workspace_id: Option<&str>,
457 root: Option<&Path>,
458 id: i64,
459 ) -> Result<Option<MemoryHit>> {
460 let access = self.access_for(workspace_id)?;
461 let store = self.open_structured()?;
462 let m = match store.get_numeric(&access, id) {
463 Ok(m) => m,
464 Err(codewhale_memory::Error::NotFound) => return Ok(None),
465 Err(e) => return Err(e.into()),
466 };
467 if !matches!(m.status, Status::Active | Status::Stale) {
468 return Ok(None);
469 }
470 let snapshot = match root {
471 Some(root) => workspace::snapshot(root, store.dependency_paths(&access)?)?,
472 None => Snapshot::default(),
473 };
474 Ok(Some(self.hit(&store, &access, &m, &snapshot)?))
475 }
476 pub fn list_all(
477 &self,
478 scope: Option<MemoryScope>,
479 workspace_id: Option<&str>,
480 limit: usize,
481 ) -> Result<Vec<MemoryHit>> {
482 let scopes = match scope {
483 None => self.scopes(workspace_id)?,
484 Some(MemoryScope::Global) => vec![Self::owner_scope()],
485 Some(MemoryScope::Workspace) => vec![Self::workspace_scope(
486 workspace_id.ok_or_else(|| anyhow!("workspace id required"))?,
487 )?],
488 };
489 let access = Access::operator(scopes)?;
490 let store = self.open_structured()?;
491 // Entry surfaces show only live memory; candidates and reviewed-off
492 // entries are visible in the Context Lens, not here.
493 store
494 .list(&access, None, limit)?
495 .iter()
496 .filter(|m| matches!(m.status, Status::Active | Status::Stale))
497 .map(|m| self.hit(&store, &access, m, &Snapshot::default()))
498 .collect()
499 }
500 pub fn import_legacy(&self, path: &Path) -> Result<bool> {
501 if !path.is_file() {
502 return Ok(false);
503 }
504 let meta = fs::symlink_metadata(path)?;
505 if meta.file_type().is_symlink() || meta.len() > 1024 * 1024 {
506 bail!("legacy import refused: symlink or size bound");
507 }
508 let text = fs::read_to_string(path)?;
509 let scope = Self::owner_scope();
510 let access = Access::operator(vec![scope.clone()])?;
511 let mut store = self.open_structured()?;
512 let report = codewhale_memory::import::markdown(
513 &mut store,
514 &access,
515 &scope,
516 "codewhale:legacy-memory",
517 &text,
518 )?;
519 if !report.rejected.is_empty() {
520 bail!(
521 "legacy import left {} rejected notes unchanged; inspect before retry",
522 report.rejected.len()
523 );
524 }
525 Ok(report.created > 0)
526 }
527 pub fn export(&self) -> Result<String> {
528 let store = self.open_structured()?;
529 let owner = Access::operator(vec![Self::owner_scope()])?;
530 let scopes = store.local_scope_inventory(&owner)?;
531 let mut output = String::from(
532 "# CodeWhale memory export\n\nNot an instruction file. This Markdown is not authoritative.\n",
533 );
534 for scope in scopes {
535 let access = Access::operator(vec![scope])?;
536 let mut after = None;
537 loop {
538 let batch = store.list(&access, after.as_deref(), 500)?;
539 if batch.is_empty() {
540 break;
541 }
542 for m in &batch {
543 output.push_str(&format!(
544 "\n## {} [{}] {}\n\n{}\n",
545 m.id,
546 m.status.as_str(),
547 m.draft.title,
548 m.draft.body
549 ));
550 }
551 after = batch.last().map(|m| m.id.clone());
552 }
553 }
554 Ok(output)
555 }
556 pub fn reindex(&self) -> Result<usize> {
557 let mut store = self.open_structured()?;
558 let access = Access::operator(vec![Self::owner_scope()])?;
559 store.reindex(&access)?;
560 let mut count = 0;
561 for scope in store.local_scope_inventory(&access)? {
562 let access = Access::operator(vec![scope])?;
563 let mut after = None;
564 loop {
565 let batch = store.list(&access, after.as_deref(), 500)?;
566 if batch.is_empty() {
567 break;
568 }
569 count += batch.len();
570 after = batch.last().map(|m| m.id.clone());
571 }
572 }
573 Ok(count)
574 }
575 pub fn delete_all(&self, scope: Option<MemoryScope>, workspace_id: Option<&str>) -> Result<()> {
576 let scopes = match scope {
577 Some(MemoryScope::Global) => vec![Self::owner_scope()],
578 Some(MemoryScope::Workspace) => vec![Self::workspace_scope(
579 workspace_id.ok_or_else(|| anyhow!("workspace id required"))?,
580 )?],
581 None => {
582 let store = self.open_structured()?;
583 let owner = Access::operator(vec![Self::owner_scope()])?;
584 let mut scopes = store.local_scope_inventory(&owner)?;
585 if scopes.len() >= 10000 {
586 bail!("Scope inventory reached its bound; delete narrower scopes explicitly");
587 }
588 if scopes.is_empty() {
589 scopes.push(Self::owner_scope());
590 }
591 scopes
592 }
593 };
594 let access = Access::operator(scopes)?;
595 let mut store = self.open_structured()?;
596 loop {
597 let batch = store.list(&access, None, 500)?;
598 if batch.is_empty() {
599 break;
600 }
601 for m in batch {
602 match store.get(&access, &m.id) {
603 Ok(current) => {
604 store.forget(&access, &current.id, current.revision)?;
605 }
606 Err(codewhale_memory::Error::NotFound) => {}
607 Err(e) => return Err(e.into()),
608 }
609 }
610 }
611 Ok(())
612 }
613 }
614 fn safe_component(value: &str) -> Result<()> {
615 if value.len() != 64
616 || !value
617 .bytes()
618 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
619 {
620 bail!("workspace identity must be a lowercase SHA-256 hash");
621 }
622 Ok(())
623 }
624
625 /// Compose the user-memory prompt block for the native store resolved from a
626 /// memory path. Single seam used by the engine, the TUI system-prompt
627 /// builder, and the context report so all three describe the same bytes.
628 /// Returns `None` when memory is disabled, the path is not a native
629 /// `memory/global/MEMORY.md` layout, or there is nothing worth injecting.
630 /// The block is a `codewhale.memory.context.v1` envelope: memory entries are
631 /// untrusted evidence, never instructions.
632 #[must_use]
633 pub fn native_prompt_block(enabled: bool, memory_path: &Path, workspace: &Path) -> Option<String> {
634 if !enabled {
635 return None;
636 }
637 NativeMemoryStore::from_global_path(memory_path)?
638 .prompt_block(workspace, 32, 12_000)
639 .ok()
640 .flatten()
641 }
642
643 /// The engine's session-boundary variant: identical bytes plus a durable
644 /// `prepared` context receipt under the session's trace, so the Context Lens
645 /// can show what was assembled for it. Diagnostics and previews keep using
646 /// `native_prompt_block` — a receipt is only meaningful when it names a real
647 /// session. The receipt never gates the packet.
648 #[must_use]
649 pub fn native_prompt_block_traced(
650 enabled: bool,
651 memory_path: &Path,
652 workspace: &Path,
653 session_id: &str,
654 ) -> Option<String> {
655 if !enabled {
656 return None;
657 }
658 NativeMemoryStore::from_global_path(memory_path)?
659 .prompt_block_traced(workspace, session_id, 32, 12_000)
660 .ok()
661 .flatten()
662 }
663
663 lines RUST