1#![doc = include_str!("../README.md")]
2mod cli;
17mod config;
18mod workspace;
19
20use std::collections::BTreeMap;
21use std::io::{self, BufRead, Write};
22use std::path::{Path, PathBuf};
23use std::process::ExitCode;
24use std::sync::Arc;
25use std::time::{SystemTime, UNIX_EPOCH};
26
27use clap::Parser;
28use plugmem_host::{
29 Database, Embedder, EmbedderGate, EmbedderState, ExportedFact, FactId, GuardedRememberOutcome,
30 HostError, LinkInput, MaintenanceMode, MaintenanceOptions, ReadOnlyDatabase, RecallQuery,
31 RecallResult, RememberInput, RememberOutcome, Settings, SharedEmbedder, Stats, TagPage,
32 TagQuery, UnlinkInput, VALID_TO_OPEN,
33};
34use serde_json::json;
35
36use crate::cli::{Cli, Command, HelpTopic, MaintainMode};
37use crate::config::read_batch_size;
38
39pub(crate) const ENV_DB: &str = "PLUGMEM_DB";
41pub(crate) const DEFAULT_DB: &str = "plugmem.db";
43
44#[derive(Debug)]
47pub(crate) enum CliError {
48 Host(HostError),
49 Usage(String),
50}
51
52impl From<HostError> for CliError {
53 fn from(e: HostError) -> Self {
54 CliError::Host(e)
55 }
56}
57
58pub(crate) fn now_ms() -> u64 {
60 SystemTime::now()
61 .duration_since(UNIX_EPOCH)
62 .map(|d| d.as_millis() as u64)
63 .unwrap_or(0)
64}
65
66pub fn run() -> ExitCode {
70 let stdout = io::stdout();
71 ExitCode::from(run_parsed(Cli::parse(), &mut stdout.lock()))
72}
73
74fn run_parsed(cli: Cli, out: &mut impl Write) -> u8 {
78 if let Command::Help { topic } = &cli.command {
79 return execute_help(topic, cli.json, out);
80 }
81
82 let table = match plugmem_host::read_config(cli.config.as_deref()) {
86 Ok(t) => t,
87 Err(e) => return report_err(&e.into()),
88 };
89 let cfg_batch_size = read_batch_size(table.as_ref());
90 let mut settings = match Settings::from_table(table.as_ref()) {
91 Ok(s) => s,
92 Err(e) => return report_err(&e.into()),
93 };
94 for warning in &settings.warnings {
98 eprintln!("plugmem: {warning}");
99 }
100 let root = workspace::resolve_root(cli.workspace.as_deref(), &settings);
105 if let Command::Workspace { command } = &cli.command {
106 return match workspace::execute(command, root, settings, cli.json, out) {
107 Ok(code) => code,
108 Err(e) => {
109 let _ = out.flush();
110 report_err(&e)
111 }
112 };
113 }
114 let path = resolve_db_path(
115 cli.db.as_deref(),
116 settings.database_path.as_deref(),
117 root.as_ref(),
118 );
119 if let Err(e) = workspace::ensure_dir(&path, root.as_ref()) {
120 return report_err(&e);
121 }
122
123 match &cli.command {
128 Command::Recover { dst } => return do_recover(&path, dst, &settings, cli.json, out),
129 Command::Scrub => return do_scrub(&path, &settings, cli.json, out),
130 Command::Repl { read_only: true } => {
135 return run_repl_ro(&path, settings, cli.json, io::stdin().lock(), out);
136 }
137 Command::Repl { read_only: false } => {
138 return run_repl(&path, settings, cli.json, io::stdin().lock(), out);
139 }
140 _ => {}
141 }
142
143 let readonly_ok = matches!(
151 &cli.command,
152 Command::Show { .. }
153 | Command::Stats
154 | Command::Tags { .. }
155 | Command::Export
156 | Command::Verify
157 | Command::Recall { .. }
158 );
159 if readonly_ok {
160 match Database::open_readonly(&path, settings.config.clone()) {
161 Ok(ro) => {
162 let gate = embedder_gate(&mut settings);
170 let recall_vector = match embed_recall_query(&gate, &cli.command) {
171 Ok(v) => v,
172 Err(e) => return report_err(&e),
173 };
174 let (recall_vector, recall_space) = split_embedded(recall_vector.as_ref());
178 return execute_ro(
179 &ro,
180 &cli.command,
181 recall_vector,
182 recall_space,
183 gate.state(),
184 cli.json,
185 out,
186 );
187 }
188 Err(HostError::Locked { path }) => return report_locked(&path),
189 Err(_) => {}
194 }
195 }
196
197 let db = match settings.open(&path) {
200 Ok(db) => db,
201 Err(HostError::Locked { path }) => return report_locked(&path),
202 Err(e) => return report_err(&CliError::Host(e)),
203 };
204 if let Command::Import { file, batch } = &cli.command {
207 let batch_size = batch
208 .or(cfg_batch_size.map(|n| n as usize))
209 .unwrap_or(DEFAULT_IMPORT_BATCH)
210 .max(1);
211 return match do_import(&db, now_ms(), file, batch_size, out) {
212 Ok(report) => {
213 if cli.json {
214 writeln!(
215 out,
216 "{}",
217 json!({ "imported": report.facts, "edges": report.edges })
218 )
219 .ok();
220 } else if report.edges == 0 {
221 writeln!(out, "imported {} facts", report.facts).ok();
222 } else {
223 writeln!(
224 out,
225 "imported {} facts and {} edges",
226 report.facts, report.edges
227 )
228 .ok();
229 }
230 0
231 }
232 Err(e) => {
233 let _ = out.flush();
234 report_err(&e)
235 }
236 };
237 }
238 match execute(&db, &cli.command, cli.json, now_ms(), out) {
239 Ok(code) => code,
240 Err(e) => {
241 let _ = out.flush();
242 report_err(&e)
243 }
244 }
245}
246
247const DEFAULT_IMPORT_BATCH: usize = 128;
250
251fn write_err(out: &mut impl Write, e: &CliError) {
258 let _ = match e {
259 CliError::Usage(msg) => writeln!(out, "plugmem: {msg}"),
260 CliError::Host(err) => {
261 writeln!(out, "plugmem: {err}").and_then(|()| match err.capacity_hint() {
262 Some(hint) => writeln!(out, "plugmem: {hint}"),
263 None => Ok(()),
264 })
265 }
266 };
267}
268
269fn report_err(e: &CliError) -> u8 {
271 write_err(&mut std::io::stderr(), e);
272 2
273}
274
275fn report_locked(path: &std::path::Path) -> u8 {
277 eprintln!(
278 "plugmem: database is locked by another process: {}",
279 path.display()
280 );
281 1
282}
283
284fn resolve_db_path(
291 flag: Option<&str>,
292 config_path: Option<&std::path::Path>,
293 root: Option<&PathBuf>,
294) -> PathBuf {
295 flag.map(|value| workspace::resolve_target(value, root))
296 .or_else(|| {
297 std::env::var_os(ENV_DB).map(|v| workspace::resolve_target(&v.to_string_lossy(), root))
298 })
299 .or_else(|| config_path.map(PathBuf::from))
300 .or_else(plugmem_host::default_database_path)
301 .unwrap_or_else(|| PathBuf::from(DEFAULT_DB))
302}
303
304fn execute_help(topic: &HelpTopic, json_output: bool, out: &mut impl Write) -> u8 {
307 match topic {
308 HelpTopic::Settings => {
309 if json_output {
310 let help = plugmem_host::settings_help();
311 let settings: Vec<_> = help
312 .docs()
313 .iter()
314 .map(|doc| {
315 json!({
316 "section": doc.section,
317 "key": doc.key,
318 "type": doc.value_type,
319 "default": doc.default,
320 "description": doc.description,
321 "scope": doc.scope.as_str(),
322 })
323 })
324 .collect();
325 let value = json!({
326 "topic": "settings",
327 "config_path_precedence": help.config_path_precedence(),
328 "default_config_path": plugmem_host::default_config_path()
329 .map(|path| path.display().to_string()),
330 "settings": settings,
331 });
332 writeln!(out, "{value}").ok();
333 } else {
334 write!(out, "{}", plugmem_host::settings_help().render_human()).ok();
335 }
336 0
337 }
338 }
339}
340
341fn execute_ro(
344 ro: &ReadOnlyDatabase,
345 cmd: &Command,
346 recall_vector: Option<&[f32]>,
347 recall_space: Option<&str>,
348 embedder: EmbedderState,
349 json: bool,
350 out: &mut impl Write,
351) -> u8 {
352 match cmd {
353 Command::Recall { .. } => {
354 match with_recall_query(cmd, now_ms(), recall_vector, |q| match recall_space {
355 Some(space) => ro.recall_in_space(q, space),
356 None => ro.recall(q),
357 }) {
358 Ok(res) => {
359 render_recall(&res, json, out);
360 0
361 }
362 Err(e) => report_err(&CliError::Host(e)),
363 }
364 }
365 Command::Show { id } => render_show(ro.get(FactId(*id)), *id, json, out),
366 Command::Stats => {
367 render_stats(&ro.stats(), embedder, json, out);
368 0
369 }
370 Command::Tags {
371 prefix,
372 cursor,
373 limit,
374 } => match ro.list_tags(TagQuery {
375 prefix: prefix.as_deref(),
376 cursor: cursor.as_deref(),
377 limit: *limit,
378 }) {
379 Ok(page) => {
380 render_tags(&page, json, out);
381 0
382 }
383 Err(e) => report_err(&CliError::Host(e)),
384 },
385 Command::Export => {
386 ro.export_each(|f| write_export_line(out, &f));
387 ro.export_edges_each(|src, rel, dst, fact| write_export_edge(out, src, rel, dst, fact));
388 0
389 }
390 Command::Verify => match ro.verify() {
392 Ok(()) => {
393 if json {
394 writeln!(out, "{}", json!({ "ok": true })).ok();
395 } else {
396 writeln!(out, "integrity ok").ok();
397 }
398 0
399 }
400 Err(e) => report_err(&CliError::Host(e)),
401 },
402 _ => unreachable!("execute_ro only receives read-only commands"),
403 }
404}
405
406fn execute(
410 db: &Database,
411 cmd: &Command,
412 json: bool,
413 now: u64,
414 out: &mut impl Write,
415) -> Result<u8, CliError> {
416 match cmd {
417 Command::Remember {
418 text,
419 entity,
420 tags,
421 links,
422 meta,
423 valid_from,
424 vector,
425 guarded,
426 } => {
427 if *guarded {
428 let outcome = do_guarded_remember(
429 db,
430 now,
431 text,
432 entity,
433 tags,
434 links,
435 meta,
436 *valid_from,
437 vector,
438 )?;
439 render_guarded_remember(&outcome, json, out);
440 } else {
441 let outcome = do_remember(
442 db,
443 now,
444 text,
445 entity,
446 tags,
447 links,
448 meta,
449 *valid_from,
450 vector,
451 None,
452 )?;
453 render_remember(&outcome, json, out);
454 }
455 Ok(0)
456 }
457 Command::Revise {
458 id,
459 text,
460 entity,
461 tags,
462 links,
463 meta,
464 valid_from,
465 vector,
466 } => {
467 let outcome = do_remember(
468 db,
469 now,
470 text,
471 entity,
472 tags,
473 links,
474 meta,
475 *valid_from,
476 vector,
477 Some(FactId(*id)),
478 )?;
479 render_remember(&outcome, json, out);
480 Ok(0)
481 }
482 Command::Recall { .. } => {
483 let res = with_recall_query(cmd, now, None, |q| db.recall(q))?;
484 render_recall(&res, json, out);
485 Ok(0)
486 }
487 Command::Forget { ids } => {
488 let fact_ids: Vec<FactId> = ids.iter().copied().map(FactId).collect();
489 let results = db.forget_many(now, &fact_ids)?;
490 if json {
491 if ids.len() == 1 {
492 writeln!(out, "{}", json!({ "id": ids[0], "forgotten": results[0] })).ok();
493 } else {
494 let items: Vec<_> = ids
495 .iter()
496 .zip(&results)
497 .map(|(id, fresh)| json!({ "id": id, "forgotten": fresh }))
498 .collect();
499 writeln!(out, "{}", json!(items)).ok();
500 }
501 } else {
502 for (id, fresh) in ids.iter().zip(&results) {
503 if *fresh {
504 writeln!(out, "forgot fact {id}").ok();
505 } else {
506 writeln!(out, "fact {id} was already gone").ok();
507 }
508 }
509 }
510 Ok(0)
511 }
512 Command::Tags {
513 prefix,
514 cursor,
515 limit,
516 } => {
517 let page = db.list_tags(TagQuery {
518 prefix: prefix.as_deref(),
519 cursor: cursor.as_deref(),
520 limit: *limit,
521 })?;
522 render_tags(&page, json, out);
523 Ok(0)
524 }
525 Command::RemoveTag { tag } => {
526 let report = db.remove_tag(now, tag)?;
527 if json {
528 writeln!(
529 out,
530 "{}",
531 json!({ "tag": tag, "affected": report.affected })
532 )
533 .ok();
534 } else {
535 writeln!(
536 out,
537 "removed tag {tag:?} from {} current facts",
538 report.affected
539 )
540 .ok();
541 }
542 Ok(0)
543 }
544 Command::Link {
545 src,
546 rel,
547 dst,
548 provenance,
549 } => {
550 db.link(LinkInput {
551 now,
552 src,
553 rel,
554 dst,
555 provenance: provenance.map(FactId),
556 })?;
557 if json {
558 writeln!(out, "{}", json!({ "src": src, "rel": rel, "dst": dst })).ok();
559 } else {
560 writeln!(out, "linked {src} -{rel}-> {dst}").ok();
561 }
562 Ok(0)
563 }
564 Command::Unlink { src, rel, dst } => {
565 let fresh = db.unlink(UnlinkInput { now, src, rel, dst })?;
566 if json {
567 writeln!(
568 out,
569 "{}",
570 json!({ "src": src, "rel": rel, "dst": dst, "unlinked": fresh })
571 )
572 .ok();
573 } else if fresh {
574 writeln!(out, "unlinked {src} -{rel}-> {dst}").ok();
575 } else {
576 writeln!(out, "edge {src} -{rel}-> {dst} was already absent").ok();
577 }
578 Ok(0)
579 }
580 Command::Show { id } => Ok(render_show(db.get(FactId(*id)), *id, json, out)),
581 Command::Stats => {
582 render_stats(&db.stats(), db.embedder_state(), json, out);
583 Ok(0)
584 }
585 Command::Export => {
586 db.export_each(|f| write_export_line(out, &f));
587 db.export_edges_each(|src, rel, dst, fact| write_export_edge(out, src, rel, dst, fact));
588 Ok(0)
589 }
590 Command::Maintain {
591 mode,
592 reembed,
593 batch_size,
594 } => {
595 if *reembed {
596 let report = db.reembed_with_batch(
597 now,
598 batch_size.unwrap_or(plugmem_host::DEFAULT_REEMBED_BATCH_SIZE),
599 )?;
600 if json {
601 writeln!(
602 out,
603 "{}",
604 json!({
605 "embedded": report.embedded,
606 "previous_dim": report.previous_dim,
607 "new_dim": report.new_dim,
608 "previous_space": report.previous_space,
609 "new_space": report.new_space,
610 "vector_bytes": report.vector_bytes,
611 "hnsw_indexed": report.hnsw_indexed,
612 })
613 )
614 .ok();
615 } else {
616 writeln!(
617 out,
618 "reembedded: {} facts, {} -> {} dimensions, {:?} -> {:?}, {} vector bytes, hnsw {}",
619 report.embedded,
620 report.previous_dim,
621 report.new_dim,
622 report.previous_space,
623 report.new_space,
624 report.vector_bytes,
625 report.hnsw_indexed,
626 )
627 .ok();
628 }
629 return Ok(0);
630 }
631 let report = db.maintain_with_options(now, maintenance_options(*mode))?;
632 if json {
633 writeln!(
634 out,
635 "{}",
636 json!({
637 "purged": report.purged,
638 "bytes_before": report.bytes_before,
639 "bytes_after": report.bytes_after,
640 "no_op": report.no_op,
641 "tombstones_before": report.tombstones_before,
642 "facts_before": report.facts_before,
643 "facts_after": report.facts_after,
644 "vectors_before": report.vectors_before,
645 "vectors_after": report.vectors_after,
646 "hnsw_indexed_before": report.hnsw_indexed_before,
647 "hnsw_indexed_after": report.hnsw_indexed_after,
648 "structural_compacted": report.structural_compacted,
649 "bm25_compacted": report.bm25_compacted,
650 "bm25_reindexed": report.bm25_reindexed,
651 "hnsw_rebuilt": report.hnsw_rebuilt,
652 "hnsw_remapped": report.hnsw_remapped,
653 "hnsw_inserted": report.hnsw_inserted,
654 "edges_compacted": report.edges_compacted,
655 "edges_before": report.edges_before,
656 "edge_versions_before": report.edge_versions_before,
657 })
658 )
659 .ok();
660 } else {
661 writeln!(
662 out,
663 "maintained: purged {}, {} -> {} bytes, hnsw +{}, bm25 {}{}{}",
664 report.purged,
665 report.bytes_before,
666 report.bytes_after,
667 report.hnsw_inserted,
668 if report.bm25_reindexed {
669 "reindexed"
670 } else if report.bm25_compacted {
671 "compacted"
672 } else {
673 "unchanged"
674 },
675 if report.edges_compacted {
676 ", edges repacked"
677 } else {
678 ""
679 },
680 if report.no_op { " (no-op)" } else { "" }
681 )
682 .ok();
683 }
684 Ok(0)
685 }
686 Command::Checkpoint => {
687 db.checkpoint(now)?;
688 if json {
689 writeln!(out, "{}", json!({ "ok": true })).ok();
690 } else {
691 writeln!(out, "checkpointed: journal flushed to snapshot").ok();
692 }
693 Ok(0)
694 }
695 Command::Verify => {
696 db.verify()?;
699 if json {
700 writeln!(out, "{}", json!({ "ok": true })).ok();
701 } else {
702 writeln!(out, "integrity ok").ok();
703 }
704 Ok(0)
705 }
706 Command::Scrub
708 | Command::Recover { .. }
709 | Command::Repl { .. }
710 | Command::Import { .. }
711 | Command::Workspace { .. }
712 | Command::Help { .. } => {
713 unreachable!("this command is dispatched before execute")
714 }
715 }
716}
717
718fn do_recover(src: &Path, dst: &Path, settings: &Settings, json: bool, out: &mut impl Write) -> u8 {
722 match Database::recover(src, dst, settings.config.clone(), now_ms()) {
723 Ok(r) => {
724 if json {
725 writeln!(
726 out,
727 "{}",
728 json!({
729 "kept": r.kept,
730 "dropped_text": r.dropped_text,
731 "dropped_vector": r.dropped_vector,
732 "dropped_metadata": r.dropped_metadata,
733 "dst": dst.display().to_string(),
734 })
735 )
736 .ok();
737 } else {
738 writeln!(
739 out,
740 "recovered to {}: kept {}, dropped {} text + {} vector + {} metadata",
741 dst.display(),
742 r.kept,
743 r.dropped_text,
744 r.dropped_vector,
745 r.dropped_metadata
746 )
747 .ok();
748 }
749 0
750 }
751 Err(HostError::Locked { path }) => report_locked(&path),
752 Err(e) => report_err(&CliError::Host(e)),
753 }
754}
755
756fn do_scrub(path: &Path, settings: &Settings, json: bool, out: &mut impl Write) -> u8 {
761 let ro = match Database::open_readonly(path, settings.config.clone()) {
762 Ok(ro) => ro,
763 Err(HostError::Locked { path }) => return report_locked(&path),
764 Err(e) => return report_err(&CliError::Host(e)),
765 };
766 let scrub = match ro.scrub() {
767 Ok(s) => s,
768 Err(e) => return report_err(&CliError::Host(e)),
769 };
770 let mut done = 0u64;
771 let mut total = 0u64;
772 for step in scrub {
773 match step {
774 Ok(p) => {
775 done = p.done_bytes;
776 total = p.total_bytes;
777 }
778 Err(e) => return report_err(&CliError::Host(e)),
779 }
780 }
781 if json {
782 writeln!(out, "{}", json!({ "ok": true, "bytes": done })).ok();
783 } else {
784 writeln!(out, "scrub ok: {done}/{total} bytes verified").ok();
785 }
786 0
787}
788
789#[derive(Parser)]
792#[command(
793 no_binary_name = true,
794 name = "plugmem",
795 disable_help_subcommand = true
796)]
797struct ReplLine {
798 #[command(subcommand)]
799 command: Command,
800}
801
802fn split_line(line: &str) -> Vec<String> {
806 let mut tokens = Vec::new();
807 let mut cur = String::new();
808 let mut quote: Option<char> = None;
809 let mut has = false;
810 for c in line.chars() {
811 match quote {
812 Some(q) => {
813 if c == q {
814 quote = None;
815 } else {
816 cur.push(c);
817 }
818 }
819 None if c == '"' || c == '\'' => {
820 quote = Some(c);
821 has = true;
822 }
823 None if c.is_whitespace() => {
824 if has {
825 tokens.push(std::mem::take(&mut cur));
826 has = false;
827 }
828 }
829 None => {
830 cur.push(c);
831 has = true;
832 }
833 }
834 }
835 if has {
836 tokens.push(cur);
837 }
838 tokens
839}
840
841fn run_repl(
847 path: &Path,
848 settings: Settings,
849 json: bool,
850 input: impl BufRead,
851 out: &mut impl Write,
852) -> u8 {
853 let db = match settings.open(path) {
854 Ok(db) => db,
855 Err(HostError::Locked { path }) => return report_locked(&path),
856 Err(e) => return report_err(&CliError::Host(e)),
857 };
858 eprintln!("plugmem repl — one open handle, host speed. `help` for verbs, `exit` to quit.");
859 eprint!("plugmem> ");
860 for line in input.lines() {
861 let Ok(line) = line else { break };
862 let line = line.trim();
863 if line.is_empty() {
864 eprint!("plugmem> ");
865 continue;
866 }
867 if line == "exit" || line == "quit" {
868 break;
869 } else if line == "help" {
870 writeln!(
871 out,
872 "verbs: remember recall revise forget tags remove-tag link unlink show stats maintain checkpoint \
873 verify export import (scrub/recover stay one-shot) exit"
874 )
875 .ok();
876 } else {
877 run_repl_line(&db, line, json, out);
878 }
879 eprint!("plugmem> ");
880 }
881 eprintln!();
882 match db.checkpoint(now_ms()) {
884 Ok(()) => 0,
885 Err(e) => report_err(&CliError::Host(e)),
886 }
887}
888
889fn run_repl_line(db: &Database, line: &str, json: bool, out: &mut impl Write) {
892 let cmd = match ReplLine::try_parse_from(split_line(line)) {
893 Ok(r) => r.command,
894 Err(e) => {
896 let _ = writeln!(out, "{e}");
897 return;
898 }
899 };
900 match &cmd {
901 Command::Repl { .. } => {
902 let _ = writeln!(out, "already in a repl session");
903 }
904 Command::Scrub | Command::Recover { .. } => {
905 let _ = writeln!(
906 out,
907 "scrub/recover are one-shot commands; run them outside the repl"
908 );
909 }
910 _ => {
911 if let Err(e) = execute(db, &cmd, json, now_ms(), out) {
912 write_err(out, &e);
913 }
914 }
915 }
916}
917
918fn run_repl_ro(
931 path: &Path,
932 mut settings: Settings,
933 json: bool,
934 input: impl BufRead,
935 out: &mut impl Write,
936) -> u8 {
937 let gate = embedder_gate(&mut settings);
942 let mut ro = match Database::open_readonly(path, settings.config.clone()) {
943 Ok(ro) => ro,
944 Err(HostError::Locked { path }) => return report_locked(&path),
945 Err(e) => return report_err(&CliError::Host(e)),
948 };
949 eprintln!(
950 "plugmem repl --read-only — observing generation {} of another process's writer. \
951 `help` for verbs, `refresh`/`generation` for cross-process freshness, `exit` to quit.",
952 ro.generation()
953 );
954 eprint!("plugmem(ro)> ");
955 for line in input.lines() {
956 let Ok(line) = line else { break };
957 let line = line.trim();
958 if line.is_empty() {
959 eprint!("plugmem(ro)> ");
960 continue;
961 }
962 match line {
963 "exit" | "quit" => break,
964 "help" => {
965 writeln!(
966 out,
967 "read verbs: recall show stats export verify \
968 freshness: generation refresh exit \
969 (writes and scrub/recover are refused in a read-only session)"
970 )
971 .ok();
972 }
973 "generation" => {
976 let g = ro.generation();
977 if json {
978 writeln!(out, "{}", json!({ "generation": g })).ok();
979 } else {
980 writeln!(out, "generation {g}").ok();
981 }
982 }
983 "refresh" => match ro.refresh() {
984 Ok(advanced) => {
985 let g = ro.generation();
986 if json {
987 writeln!(out, "{}", json!({ "advanced": advanced, "generation": g })).ok();
988 } else if advanced {
989 writeln!(out, "refreshed → generation {g}").ok();
990 } else {
991 writeln!(out, "already current → generation {g}").ok();
992 }
993 }
994 Err(e) => write_err(out, &CliError::Host(e)),
995 },
996 _ => run_repl_ro_line(&ro, &gate, line, json, out),
997 }
998 eprint!("plugmem(ro)> ");
999 }
1000 eprintln!();
1001 0
1003}
1004
1005fn run_repl_ro_line(
1008 ro: &ReadOnlyDatabase,
1009 gate: &EmbedderGate,
1010 line: &str,
1011 json: bool,
1012 out: &mut impl Write,
1013) {
1014 let cmd = match ReplLine::try_parse_from(split_line(line)) {
1015 Ok(r) => r.command,
1016 Err(e) => {
1017 let _ = writeln!(out, "{e}");
1018 return;
1019 }
1020 };
1021 let readable = matches!(
1022 &cmd,
1023 Command::Show { .. }
1024 | Command::Stats
1025 | Command::Tags { .. }
1026 | Command::Export
1027 | Command::Verify
1028 | Command::Recall { .. }
1029 );
1030 if !readable {
1031 let _ = writeln!(
1032 out,
1033 "read-only session: only recall/show/stats/tags/export/verify run \
1034 (plus refresh/generation); writes and one-shot commands need a writer handle"
1035 );
1036 return;
1037 }
1038 let recall_vector = match embed_recall_query(gate, &cmd) {
1041 Ok(v) => v,
1042 Err(e) => {
1043 write_err(out, &e);
1044 return;
1045 }
1046 };
1047 let (vector, space) = split_embedded(recall_vector.as_ref());
1048 let _ = execute_ro(ro, &cmd, vector, space, gate.state(), json, out);
1049}
1050
1051fn split_embedded(embedded: Option<&(Vec<f32>, String)>) -> (Option<&[f32]>, Option<&str>) {
1054 match embedded {
1055 Some((vector, space)) => (Some(vector.as_slice()), Some(space.as_str())),
1056 None => (None, None),
1057 }
1058}
1059
1060fn embedder_gate(settings: &mut Settings) -> EmbedderGate {
1072 let shared = settings.embedder.take().map(SharedEmbedder::new);
1073 if let Some(shared) = &shared {
1074 settings.embedder = Some(Box::new(shared.clone()));
1075 }
1076 EmbedderGate::new(
1077 shared.map(|shared| Arc::new(shared) as Arc<dyn Embedder>),
1078 settings.embed_error_policy,
1079 settings.embed_retry,
1080 )
1081}
1082
1083fn embed_recall_query(
1101 gate: &EmbedderGate,
1102 cmd: &Command,
1103) -> Result<Option<(Vec<f32>, String)>, CliError> {
1104 let Command::Recall {
1105 query: Some(text),
1106 vector,
1107 ..
1108 } = cmd
1109 else {
1110 return Ok(None);
1111 };
1112 if !vector.is_empty() {
1115 return Ok(None);
1116 }
1117 gate.embed_one(text.as_str(), |_| Ok(()))
1118 .map_err(CliError::Host)
1119}
1120
1121fn with_recall_query<R>(
1126 cmd: &Command,
1127 now: u64,
1128 override_vector: Option<&[f32]>,
1129 f: impl FnOnce(RecallQuery<'_>) -> R,
1130) -> R {
1131 let Command::Recall {
1132 query,
1133 tags,
1134 entities,
1135 as_of,
1136 range,
1137 k,
1138 closed,
1139 token_budget,
1140 ef,
1141 graph_depth,
1142 vector,
1143 } = cmd
1144 else {
1145 unreachable!("with_recall_query called on a non-recall command");
1146 };
1147 let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
1148 let ent_refs: Vec<&str> = entities.iter().map(String::as_str).collect();
1149 let range_pair = range.as_ref().map(|v| (v[0], v[1]));
1150 let explicit = (!vector.is_empty()).then_some(vector.as_slice());
1155 let q = RecallQuery {
1156 now,
1157 text: query.as_deref(),
1158 vector: explicit.or(override_vector),
1159 tags: &tag_refs,
1160 entities: &ent_refs,
1161 as_of: *as_of,
1162 range: range_pair,
1163 k: *k,
1164 token_budget: *token_budget,
1165 include_closed: *closed,
1166 ef: *ef,
1167 graph_depth: *graph_depth,
1168 };
1169 f(q)
1170}
1171
1172fn render_recall(res: &RecallResult, json: bool, out: &mut impl Write) {
1175 if json {
1176 let facts: Vec<_> = res
1177 .facts
1178 .iter()
1179 .map(|f| {
1180 json!({
1181 "id": f.id.0,
1182 "score": f.score,
1183 "sources": f.sources,
1184 "recorded_at": f.recorded_at,
1185 "valid_from": f.valid_from,
1186 "valid_to": open_or(f.valid_to),
1187 })
1188 })
1189 .collect();
1190 let edges: Vec<_> = res
1195 .edges
1196 .iter()
1197 .map(|e| {
1198 json!({
1199 "src": e.src.0,
1200 "rel": e.rel.0,
1201 "dst": e.dst.0,
1202 "provenance": (e.provenance != FactId::NONE).then_some(e.provenance.0),
1203 })
1204 })
1205 .collect();
1206 writeln!(
1207 out,
1208 "{}",
1209 json!({
1210 "facts": facts,
1211 "edges": edges,
1212 "rendered": res.rendered,
1213 "truncated": res.truncated,
1214 })
1215 )
1216 .ok();
1217 } else if res.rendered.is_empty() {
1218 writeln!(out, "(nothing recalled)").ok();
1219 } else {
1220 writeln!(out, "{}", res.rendered).ok();
1221 }
1222}
1223
1224fn render_show(
1226 fact: Option<plugmem_host::FactSnapshot>,
1227 id: u32,
1228 json: bool,
1229 out: &mut impl Write,
1230) -> u8 {
1231 let Some(fact) = fact else {
1232 if json {
1233 writeln!(out, "{}", json!({ "id": id, "found": false })).ok();
1234 } else {
1235 writeln!(out, "fact {id} not found").ok();
1236 }
1237 return 1;
1238 };
1239 let r = &fact.record;
1240 if json {
1241 writeln!(
1242 out,
1243 "{}",
1244 json!({
1245 "id": r.id.0,
1246 "text": fact.text,
1247 "recorded_at": r.recorded_at,
1248 "valid_from": r.valid_from,
1249 "valid_to": open_or(r.valid_to),
1250 "closed": r.is_closed(),
1251 "tombstone": r.is_tombstone(),
1252 "revises": (r.revises != FactId::NONE).then_some(r.revises.0),
1253 "metadata": fact.metadata,
1254 })
1255 )
1256 .ok();
1257 } else {
1258 writeln!(out, "fact {}", r.id.0).ok();
1259 writeln!(out, " text {}", fact.text).ok();
1260 writeln!(out, " recorded_at {}", r.recorded_at).ok();
1261 write!(out, " valid [{}, ", r.valid_from).ok();
1262 match r.valid_to {
1263 VALID_TO_OPEN => writeln!(out, "open)").ok(),
1264 to => writeln!(out, "{to})").ok(),
1265 };
1266 if r.revises != FactId::NONE {
1267 writeln!(out, " revises fact {}", r.revises.0).ok();
1268 }
1269 if !fact.metadata.is_empty() {
1270 let rendered = fact
1271 .metadata
1272 .iter()
1273 .map(|(k, v)| format!("{k}={v}"))
1274 .collect::<Vec<_>>()
1275 .join(", ");
1276 writeln!(out, " metadata {rendered}").ok();
1277 }
1278 if r.is_tombstone() {
1279 writeln!(out, " state tombstoned").ok();
1280 }
1281 }
1282 0
1283}
1284
1285fn maintenance_options(mode: MaintainMode) -> MaintenanceOptions {
1290 match MaintenanceMode::from(mode) {
1291 MaintenanceMode::Auto => MaintenanceOptions::auto(),
1292 MaintenanceMode::Full => MaintenanceOptions::full(),
1293 mode => MaintenanceOptions {
1294 mode,
1295 ..MaintenanceOptions::auto()
1296 },
1297 }
1298}
1299
1300fn render_stats(s: &Stats, embedder: EmbedderState, json: bool, out: &mut impl Write) {
1308 let embedder = match embedder {
1309 EmbedderState::Absent => "absent",
1310 EmbedderState::Active => "active",
1311 EmbedderState::Suspended { .. } => "suspended",
1312 };
1313 if json {
1314 writeln!(
1315 out,
1316 "{}",
1317 json!({
1318 "embedder": embedder,
1319 "facts": s.facts,
1320 "entities": s.entities,
1321 "terms": s.terms,
1322 "edges": s.edges,
1323 "edge_versions": s.edge_versions,
1324 "vectors": s.vectors,
1325 "hnsw_indexed": s.hnsw_indexed,
1326 "next_fact": s.next_fact,
1327 "next_entity": s.next_entity,
1328 "next_edge": s.next_edge,
1329 "pool_bytes": s.pool_bytes,
1330 "shards": {
1331 "facts": s.shards.facts,
1332 "entities": s.shards.entities,
1333 "edges": s.shards.edges,
1334 "temporal": s.shards.temporal,
1335 "postings": s.shards.postings,
1336 },
1337 })
1338 )
1339 .ok();
1340 } else {
1341 writeln!(out, "embedder {embedder}").ok();
1342 writeln!(out, "facts {}", s.facts).ok();
1343 writeln!(out, "entities {}", s.entities).ok();
1344 writeln!(out, "terms {}", s.terms).ok();
1345 writeln!(out, "edges {}", s.edges).ok();
1346 writeln!(out, "edge_vers {}", s.edge_versions).ok();
1347 writeln!(out, "vectors {}", s.vectors).ok();
1348 writeln!(out, "hnsw_idx {}", s.hnsw_indexed).ok();
1349 writeln!(out, "next_fact {}", s.next_fact).ok();
1350 writeln!(out, "next_edge {}", s.next_edge).ok();
1351 writeln!(out, "pool_bytes {}", s.pool_bytes).ok();
1352 writeln!(
1355 out,
1356 "shards facts {} entities {} edges {} temporal {} postings {}",
1357 s.shards.facts, s.shards.entities, s.shards.edges, s.shards.temporal, s.shards.postings,
1358 )
1359 .ok();
1360 }
1361}
1362
1363fn render_tags(page: &TagPage, json_output: bool, out: &mut impl Write) {
1366 if json_output {
1367 writeln!(
1368 out,
1369 "{}",
1370 json!({
1371 "items": page.items.iter().map(|item| json!({
1372 "name": item.name,
1373 "count": item.count,
1374 })).collect::<Vec<_>>(),
1375 "next_cursor": page.next_cursor,
1376 })
1377 )
1378 .ok();
1379 return;
1380 }
1381 for item in &page.items {
1382 writeln!(out, "{}\t{}", item.count, item.name).ok();
1383 }
1384 if let Some(cursor) = &page.next_cursor {
1385 writeln!(out, "next_cursor\t{cursor}").ok();
1386 }
1387}
1388
1389fn write_export_line(out: &mut impl Write, f: &ExportedFact) {
1396 writeln!(
1397 out,
1398 "{}",
1399 json!({
1400 "kind": "fact",
1401 "id": f.id,
1402 "text": f.text,
1403 "entity": f.entity,
1404 "tags": f.tags,
1405 "metadata": f.metadata,
1406 "recorded_at": f.recorded_at,
1407 "valid_from": f.valid_from,
1408 })
1409 )
1410 .ok();
1411}
1412
1413fn write_export_edge(out: &mut impl Write, src: &str, rel: &str, dst: &str, provenance: FactId) {
1417 writeln!(
1418 out,
1419 "{}",
1420 json!({
1421 "kind": "edge",
1422 "src": src,
1423 "rel": rel,
1424 "dst": dst,
1425 "provenance": (provenance != FactId::NONE).then_some(provenance.0),
1426 })
1427 )
1428 .ok();
1429}
1430
1431#[cfg(test)]
1434fn render_export(facts: &[ExportedFact], _json: bool, out: &mut impl Write) {
1435 for f in facts {
1436 write_export_line(out, f);
1437 }
1438}
1439
1440fn do_import(
1447 db: &Database,
1448 now: u64,
1449 file: &std::path::Path,
1450 batch_size: usize,
1451 _out: &mut impl Write,
1452) -> Result<ImportReport, CliError> {
1453 let f = std::fs::File::open(file)
1454 .map_err(|e| CliError::Usage(format!("reading {}: {e}", file.display())))?;
1455 let reader = io::BufReader::new(f);
1456 let mut count = 0usize;
1457 let mut batch: Vec<ParsedFact> = Vec::with_capacity(batch_size);
1458 let mut remap: BTreeMap<u32, FactId> = BTreeMap::new();
1462 let mut edges = 0usize;
1463
1464 for (i, line) in reader.lines().enumerate() {
1465 let line = line.map_err(|e| CliError::Usage(format!("line {}: {e}", i + 1)))?;
1466 let line = line.trim();
1467 if line.is_empty() {
1468 continue;
1469 }
1470 match parse_import_line(line, i + 1)? {
1471 ImportLine::Fact(fact) => {
1472 batch.push(fact);
1473 if batch.len() >= batch_size {
1474 count += flush_import_batch(db, now, &batch, &mut remap)?;
1475 batch.clear();
1476 }
1477 }
1478 ImportLine::Edge(edge) => {
1479 if !batch.is_empty() {
1484 count += flush_import_batch(db, now, &batch, &mut remap)?;
1485 batch.clear();
1486 }
1487 db.link(LinkInput {
1488 now,
1489 src: &edge.src,
1490 rel: &edge.rel,
1491 dst: &edge.dst,
1492 provenance: edge.provenance.and_then(|old| remap.get(&old).copied()),
1496 })?;
1497 edges += 1;
1498 }
1499 }
1500 }
1501 count += flush_import_batch(db, now, &batch, &mut remap)?;
1502 Ok(ImportReport {
1503 facts: count,
1504 edges,
1505 })
1506}
1507
1508#[derive(Debug, PartialEq, Eq)]
1512struct ImportReport {
1513 facts: usize,
1514 edges: usize,
1515}
1516
1517enum ImportLine {
1519 Fact(ParsedFact),
1520 Edge(ParsedEdge),
1521}
1522
1523struct ParsedEdge {
1526 src: String,
1527 rel: String,
1528 dst: String,
1529 provenance: Option<u32>,
1530}
1531
1532struct ParsedFact {
1536 id: Option<u32>,
1537 text: String,
1538 entity: Option<String>,
1539 tags: Vec<String>,
1540 metadata: Vec<(String, String)>,
1541 valid_from: Option<u64>,
1542}
1543
1544fn parse_import_line(line: &str, lineno: usize) -> Result<ImportLine, CliError> {
1549 let v: serde_json::Value =
1550 serde_json::from_str(line).map_err(|e| CliError::Usage(format!("line {lineno}: {e}")))?;
1551 match v["kind"].as_str() {
1552 None | Some("fact") => parse_import_fact(&v, lineno).map(ImportLine::Fact),
1553 Some("edge") => parse_import_edge(&v, lineno).map(ImportLine::Edge),
1554 Some(other) => Err(CliError::Usage(format!(
1555 "line {lineno}: unknown kind \"{other}\" (expected \"fact\" or \"edge\")"
1556 ))),
1557 }
1558}
1559
1560fn parse_import_edge(v: &serde_json::Value, lineno: usize) -> Result<ParsedEdge, CliError> {
1563 let field = |key: &str| -> Result<String, CliError> {
1564 v[key]
1565 .as_str()
1566 .map(String::from)
1567 .ok_or_else(|| CliError::Usage(format!("line {lineno}: edge missing string \"{key}\"")))
1568 };
1569 Ok(ParsedEdge {
1570 src: field("src")?,
1571 rel: field("rel")?,
1572 dst: field("dst")?,
1573 provenance: v["provenance"].as_u64().and_then(|n| u32::try_from(n).ok()),
1574 })
1575}
1576
1577fn parse_import_fact(v: &serde_json::Value, lineno: usize) -> Result<ParsedFact, CliError> {
1579 let text = v["text"]
1580 .as_str()
1581 .ok_or_else(|| CliError::Usage(format!("line {lineno}: missing string \"text\"")))?
1582 .to_string();
1583 let entity = v["entity"].as_str().map(String::from);
1584 let tags = v["tags"]
1585 .as_array()
1586 .map(|a| {
1587 a.iter()
1588 .filter_map(|t| t.as_str().map(String::from))
1589 .collect()
1590 })
1591 .unwrap_or_default();
1592 let metadata = v["metadata"]
1595 .as_object()
1596 .map(|m| {
1597 m.iter()
1598 .filter_map(|(k, val)| val.as_str().map(|s| (k.clone(), s.to_string())))
1599 .collect::<BTreeMap<_, _>>()
1600 .into_iter()
1601 .collect()
1602 })
1603 .unwrap_or_default();
1604 let valid_from = v["valid_from"].as_u64();
1605 Ok(ParsedFact {
1606 id: v["id"].as_u64().and_then(|n| u32::try_from(n).ok()),
1607 text,
1608 entity,
1609 tags,
1610 metadata,
1611 valid_from,
1612 })
1613}
1614
1615fn flush_import_batch(
1618 db: &Database,
1619 now: u64,
1620 batch: &[ParsedFact],
1621 remap: &mut BTreeMap<u32, FactId>,
1622) -> Result<usize, CliError> {
1623 if batch.is_empty() {
1624 return Ok(0);
1625 }
1626 let tag_refs: Vec<Vec<&str>> = batch
1629 .iter()
1630 .map(|p| p.tags.iter().map(String::as_str).collect())
1631 .collect();
1632 let meta_refs: Vec<Vec<(&str, &str)>> = batch
1633 .iter()
1634 .map(|p| {
1635 p.metadata
1636 .iter()
1637 .map(|(k, v)| (k.as_str(), v.as_str()))
1638 .collect()
1639 })
1640 .collect();
1641 let inputs: Vec<RememberInput> = batch
1642 .iter()
1643 .zip(&tag_refs)
1644 .zip(&meta_refs)
1645 .map(|((p, tags), meta)| RememberInput {
1646 entity: p.entity.as_deref(),
1647 tags,
1648 metadata: (!meta.is_empty()).then_some(meta.as_slice()),
1649 valid_from: p.valid_from,
1650 ..RememberInput::text(now, &p.text)
1651 })
1652 .collect();
1653 let outcomes = db.remember_many(inputs)?;
1656 for (parsed, outcome) in batch.iter().zip(&outcomes) {
1657 if let Some(old) = parsed.id {
1658 remap.insert(old, outcome.id);
1659 }
1660 }
1661 Ok(batch.len())
1662}
1663
1664#[allow(clippy::too_many_arguments)]
1666fn do_remember(
1667 db: &Database,
1668 now: u64,
1669 text: &str,
1670 entity: &Option<String>,
1671 tags: &[String],
1672 links: &[String],
1673 meta: &[String],
1674 valid_from: Option<u64>,
1675 vector: &[f32],
1676 revise: Option<FactId>,
1677) -> Result<RememberOutcome, CliError> {
1678 let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
1679 let link_pairs = parse_links(links)?;
1680 let link_refs: Vec<(&str, &str)> = link_pairs
1681 .iter()
1682 .map(|(r, e)| (r.as_str(), e.as_str()))
1683 .collect();
1684 let meta_map = parse_meta(meta)?;
1688 let meta_refs: Vec<(&str, &str)> = meta_map
1689 .iter()
1690 .map(|(k, v)| (k.as_str(), v.as_str()))
1691 .collect();
1692 let input = RememberInput {
1693 entity: entity.as_deref(),
1694 tags: &tag_refs,
1695 links: &link_refs,
1696 metadata: (!meta_refs.is_empty()).then_some(meta_refs.as_slice()),
1697 valid_from,
1698 vector: (!vector.is_empty()).then_some(vector),
1702 ..RememberInput::text(now, text)
1703 };
1704 match revise {
1705 Some(target) => Ok(db.revise(target, input)?),
1706 None => Ok(db.remember(input)?),
1707 }
1708}
1709
1710#[allow(clippy::too_many_arguments)]
1713fn do_guarded_remember(
1714 db: &Database,
1715 now: u64,
1716 text: &str,
1717 entity: &Option<String>,
1718 tags: &[String],
1719 links: &[String],
1720 meta: &[String],
1721 valid_from: Option<u64>,
1722 vector: &[f32],
1723) -> Result<GuardedRememberOutcome, CliError> {
1724 let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
1725 let link_pairs = parse_links(links)?;
1726 let link_refs: Vec<(&str, &str)> = link_pairs
1727 .iter()
1728 .map(|(relation, target)| (relation.as_str(), target.as_str()))
1729 .collect();
1730 let meta_map = parse_meta(meta)?;
1731 let meta_refs: Vec<(&str, &str)> = meta_map
1732 .iter()
1733 .map(|(key, value)| (key.as_str(), value.as_str()))
1734 .collect();
1735 Ok(db.remember_guarded(RememberInput {
1736 entity: entity.as_deref(),
1737 tags: &tag_refs,
1738 links: &link_refs,
1739 metadata: (!meta_refs.is_empty()).then_some(meta_refs.as_slice()),
1740 valid_from,
1741 vector: (!vector.is_empty()).then_some(vector),
1742 ..RememberInput::text(now, text)
1743 })?)
1744}
1745
1746fn parse_meta(meta: &[String]) -> Result<BTreeMap<String, String>, CliError> {
1749 let mut map = BTreeMap::new();
1750 for s in meta {
1751 let (k, v) = s
1752 .split_once('=')
1753 .filter(|(k, _)| !k.is_empty())
1754 .ok_or_else(|| CliError::Usage(format!("bad --meta `{s}` — expected KEY=VALUE")))?;
1755 map.insert(k.to_string(), v.to_string());
1756 }
1757 Ok(map)
1758}
1759
1760fn parse_links(links: &[String]) -> Result<Vec<(String, String)>, CliError> {
1762 links
1763 .iter()
1764 .map(|s| {
1765 s.split_once(':')
1766 .filter(|(r, e)| !r.is_empty() && !e.is_empty())
1767 .map(|(r, e)| (r.to_string(), e.to_string()))
1768 .ok_or_else(|| CliError::Usage(format!("bad --link `{s}` — expected REL:ENTITY")))
1769 })
1770 .collect()
1771}
1772
1773fn render_remember(outcome: &RememberOutcome, json: bool, out: &mut impl Write) {
1775 if json {
1776 let similar: Vec<_> = outcome
1777 .similar
1778 .iter()
1779 .map(|s| json!({ "id": s.id.0, "score": s.score, "reason": format!("{:?}", s.reason) }))
1780 .collect();
1781 writeln!(
1782 out,
1783 "{}",
1784 json!({
1785 "id": outcome.id.0,
1786 "entity": outcome.entity.map(|e| e.0),
1787 "similar": similar,
1788 })
1789 )
1790 .ok();
1791 } else {
1792 writeln!(out, "remembered fact {}", outcome.id.0).ok();
1793 for s in &outcome.similar {
1794 writeln!(
1795 out,
1796 " ~ similar to fact {} ({:?}, {:.2})",
1797 s.id.0, s.reason, s.score
1798 )
1799 .ok();
1800 }
1801 }
1802}
1803
1804fn render_guarded_remember(outcome: &GuardedRememberOutcome, json: bool, out: &mut impl Write) {
1805 match outcome {
1806 GuardedRememberOutcome::Stored { outcome, checked } => {
1807 if json {
1808 writeln!(
1809 out,
1810 "{}",
1811 json!({
1812 "status": "stored",
1813 "checked": checked,
1814 "outcome": {
1815 "id": outcome.id.0,
1816 "entity": outcome.entity.map(|entity| entity.0),
1817 "similar": [],
1818 }
1819 })
1820 )
1821 .ok();
1822 } else {
1823 writeln!(out, "remembered fact {}", outcome.id.0).ok();
1824 if !checked {
1827 writeln!(
1828 out,
1829 " ! stored WITHOUT a duplicate check: the check is scoped to \
1830 --entity, and none was given"
1831 )
1832 .ok();
1833 }
1834 }
1835 }
1836 GuardedRememberOutcome::Blocked { similar } => {
1837 if json {
1838 let similar: Vec<_> = similar
1839 .iter()
1840 .map(|item| {
1841 json!({
1842 "id": item.id.0,
1843 "score": item.score,
1844 "reason": format!("{:?}", item.reason),
1845 })
1846 })
1847 .collect();
1848 writeln!(
1849 out,
1850 "{}",
1851 json!({ "status": "blocked", "similar": similar })
1852 )
1853 .ok();
1854 } else {
1855 writeln!(out, "not remembered: similar facts require a decision").ok();
1856 for item in similar {
1857 writeln!(
1858 out,
1859 " ~ fact {} ({:?}, {:.2})",
1860 item.id.0, item.reason, item.score
1861 )
1862 .ok();
1863 }
1864 }
1865 }
1866 }
1867}
1868
1869fn open_or(valid_to: u64) -> Option<u64> {
1871 (valid_to != VALID_TO_OPEN).then_some(valid_to)
1872}
1873
1874#[cfg(test)]
1875mod tests {
1876 use plugmem_host::Config;
1877
1878 use super::*;
1879
1880 struct StubEmbedder;
1882 impl plugmem_host::Embedder for StubEmbedder {
1883 fn space_id(&self) -> &str {
1884 "cli-stub"
1885 }
1886
1887 fn dim(&self) -> usize {
1888 3
1889 }
1890 fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
1891 Ok(texts.iter().map(|_| vec![0.1, 0.2, 0.3]).collect())
1892 }
1893 }
1894
1895 fn recall_cmd(query: Option<&str>) -> Command {
1896 Command::Recall {
1897 query: query.map(str::to_owned),
1898 tags: vec![],
1899 entities: vec![],
1900 as_of: None,
1901 range: None,
1902 k: 0,
1903 closed: false,
1904 token_budget: None,
1905 ef: None,
1906 graph_depth: None,
1907 vector: Vec::new(),
1908 }
1909 }
1910
1911 fn settings_with(embedder: Option<Box<dyn plugmem_host::Embedder>>) -> Settings {
1912 Settings {
1913 database_path: None,
1914 config: Config::default(),
1915 embedder,
1916 embed_error_policy: plugmem_host::EmbedErrorPolicy::default(),
1917 embed_retry: plugmem_host::EmbedRetry::default(),
1918 snapshot_every_ops: None,
1919 snapshot_journal_bytes: None,
1920 maintain_every_forgets: None,
1921 fsync: None,
1922 workspace: plugmem_host::WorkspaceSettings {
1923 dir: None,
1924 limits: plugmem_host::WorkspaceLimits::default(),
1925 },
1926 warnings: Vec::new(),
1927 }
1928 }
1929
1930 fn gate_of(settings: &mut Settings) -> plugmem_host::EmbedderGate {
1933 embedder_gate(settings)
1934 }
1935
1936 #[test]
1937 fn the_gate_leaves_a_handle_behind_for_a_database_open() {
1938 let mut settings = settings_with(Some(Box::new(StubEmbedder)));
1943 let gate = gate_of(&mut settings);
1944 assert!(
1945 settings.embedder.is_some(),
1946 "a database opened after this would have no embedder"
1947 );
1948 assert_eq!(settings.embedder.as_ref().unwrap().space_id(), "cli-stub");
1949 assert_eq!(gate.provider().unwrap().space_id(), "cli-stub");
1952
1953 for _ in 0..3 {
1955 assert_eq!(
1956 embed_recall_query(&gate, &recall_cmd(Some("tokio"))).unwrap(),
1957 Some((vec![0.1, 0.2, 0.3], "cli-stub".to_string()))
1958 );
1959 }
1960 }
1961
1962 #[test]
1963 fn embed_recall_query_embeds_recall_text_only_when_an_embedder_is_set() {
1964 let mut with = settings_with(Some(Box::new(StubEmbedder)));
1966 let with = gate_of(&mut with);
1967 assert_eq!(
1968 embed_recall_query(&with, &recall_cmd(Some("tokio"))).unwrap(),
1969 Some((vec![0.1, 0.2, 0.3], "cli-stub".to_string()))
1970 );
1971
1972 let mut without = settings_with(None);
1974 let without = gate_of(&mut without);
1975 assert_eq!(
1976 embed_recall_query(&without, &recall_cmd(Some("tokio"))).unwrap(),
1977 None
1978 );
1979
1980 let mut with_empty = settings_with(Some(Box::new(StubEmbedder)));
1982 let with_empty = gate_of(&mut with_empty);
1983 assert_eq!(
1984 embed_recall_query(&with_empty, &recall_cmd(None)).unwrap(),
1985 None
1986 );
1987
1988 let mut with_stats = settings_with(Some(Box::new(StubEmbedder)));
1990 let with_stats = gate_of(&mut with_stats);
1991 assert_eq!(
1992 embed_recall_query(&with_stats, &Command::Stats).unwrap(),
1993 None
1994 );
1995 }
1996
1997 #[test]
1998 fn split_line_honors_quotes_and_whitespace() {
1999 assert_eq!(split_line("remember hello"), ["remember", "hello"]);
2000 assert_eq!(
2001 split_line(r#"remember "two words" --tag x"#),
2002 ["remember", "two words", "--tag", "x"]
2003 );
2004 assert_eq!(split_line(" recall 'a b' "), ["recall", "a b"]);
2005 assert_eq!(split_line(""), Vec::<String>::new());
2006 assert_eq!(split_line(r#"remember """#), ["remember", ""]);
2008 }
2009
2010 #[test]
2011 fn repl_runs_over_one_handle_and_checkpoints_on_exit() {
2012 let (db, tmp) = TempDb::open();
2013 let path = tmp.0.join("m.plugmem");
2014 drop(db); let settings = settings_with(None);
2017 let script = b"remember \"hello tokio world\"\nrecall tokio\nrevise 0 \"goodbye tokio\"\nbadcmd\nexit\n";
2019 let mut out = Vec::new();
2020 let code = run_repl(&path, settings, false, &script[..], &mut out);
2021 let text = String::from_utf8(out).unwrap();
2022
2023 assert_eq!(code, 0);
2024 assert!(text.contains("remembered fact 0"), "{text}");
2025 assert!(text.contains("tokio"), "{text}");
2026 assert!(text.contains("unrecognized subcommand"), "{text}");
2028
2029 let ro = Database::open_readonly(&path, Config::default()).unwrap();
2033 assert_eq!(ro.stats().facts, 2, "original + successor after the revise");
2034 }
2035
2036 #[test]
2037 fn read_only_repl_observes_a_writer_reports_freshness_and_refuses_writes() {
2038 let (db, tmp) = TempDb::open();
2039 let path = tmp.0.join("m.plugmem");
2040 let mut sink = Vec::new();
2043 execute(
2044 &db,
2045 &remember("seed fact tokio", None, &[]),
2046 false,
2047 1_000,
2048 &mut sink,
2049 )
2050 .unwrap();
2051 db.checkpoint(1_001).unwrap();
2052
2053 let settings = settings_with(None);
2054 let script = b"generation\nstats\nrefresh\nremember \"nope\"\nexit\n";
2056 let mut out = Vec::new();
2057 let code = run_repl_ro(&path, settings, false, &script[..], &mut out);
2058 let text = String::from_utf8(out).unwrap();
2059
2060 assert_eq!(code, 0);
2061 assert!(text.contains("generation 1"), "generation verb: {text}");
2062 assert!(text.contains("fact"), "stats ran: {text}");
2063 assert!(
2066 text.contains("already current → generation 1"),
2067 "refresh no-op: {text}"
2068 );
2069 assert!(text.contains("read-only session"), "write refused: {text}");
2071
2072 assert_eq!(db.stats().facts, 1);
2075 }
2076
2077 #[test]
2078 fn read_only_repl_refresh_advances_after_the_writer_checkpoints() {
2079 let (db, tmp) = TempDb::open();
2080 let path = tmp.0.join("m.plugmem");
2081 let mut sink = Vec::new();
2082 execute(&db, &remember("first", None, &[]), false, 1_000, &mut sink).unwrap();
2083 db.checkpoint(1_001).unwrap();
2084
2085 struct HookOnFirstRead<'a> {
2089 script: std::io::Cursor<&'a [u8]>,
2090 db: &'a Database,
2091 fired: bool,
2092 }
2093 impl std::io::Read for HookOnFirstRead<'_> {
2094 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2095 if !self.fired {
2096 self.fired = true;
2097 let mut s = Vec::new();
2100 execute(
2101 self.db,
2102 &remember("second", None, &[]),
2103 false,
2104 2_000,
2105 &mut s,
2106 )
2107 .unwrap();
2108 self.db.checkpoint(2_001).unwrap();
2109 }
2110 self.script.read(buf)
2111 }
2112 }
2113 let reader = std::io::BufReader::new(HookOnFirstRead {
2114 script: std::io::Cursor::new(b"refresh\nstats\nexit\n" as &[u8]),
2115 db: &db,
2116 fired: false,
2117 });
2118
2119 let mut out = Vec::new();
2120 let code = run_repl_ro(&path, settings_with(None), false, reader, &mut out);
2121 let text = String::from_utf8(out).unwrap();
2122
2123 assert_eq!(code, 0);
2124 assert!(text.contains("refreshed → generation 2"), "advance: {text}");
2126 assert!(text.contains("fact"), "stats after refresh: {text}");
2128 assert_eq!(db.stats().facts, 2);
2129 }
2130
2131 #[test]
2132 fn read_only_repl_freshness_verbs_emit_json() {
2133 let (db, tmp) = TempDb::open();
2134 let path = tmp.0.join("m.plugmem");
2135 let mut sink = Vec::new();
2136 execute(&db, &remember("j", None, &[]), false, 1_000, &mut sink).unwrap();
2137 db.checkpoint(1_001).unwrap();
2138
2139 let script = b"generation\nrefresh\nexit\n";
2140 let mut out = Vec::new();
2141 let code = run_repl_ro(&path, settings_with(None), true, &script[..], &mut out);
2142 let text = String::from_utf8(out).unwrap();
2143
2144 assert_eq!(code, 0);
2145 assert!(
2146 text.contains(r#""generation":1"#),
2147 "generation json: {text}"
2148 );
2149 assert!(text.contains(r#""advanced":false"#), "refresh json: {text}");
2150 }
2151
2152 struct TempDb(PathBuf);
2154 impl TempDb {
2155 fn open() -> (Database, Self) {
2156 let dir = std::env::temp_dir().join(format!(
2157 "plugmem-cli-{}-{}",
2158 std::process::id(),
2159 now_ms_unique()
2160 ));
2161 std::fs::create_dir_all(&dir).unwrap();
2162 let path = dir.join("m.plugmem");
2163 let (db, _) = Database::open(&path, Config::default()).unwrap();
2164 (db, TempDb(dir))
2165 }
2166 }
2167 impl Drop for TempDb {
2168 fn drop(&mut self) {
2169 let _ = std::fs::remove_dir_all(&self.0);
2170 }
2171 }
2172
2173 fn now_ms_unique() -> String {
2176 use std::sync::atomic::{AtomicU64, Ordering};
2177 static N: AtomicU64 = AtomicU64::new(0);
2178 format!("{}-{}", now_ms(), N.fetch_add(1, Ordering::Relaxed))
2179 }
2180
2181 fn run_cmd(db: &Database, cmd: &Command, json: bool, now: u64) -> (u8, String) {
2182 let mut buf = Vec::new();
2183 let code = execute(db, cmd, json, now, &mut buf).expect("execute");
2184 (code, String::from_utf8(buf).unwrap())
2185 }
2186
2187 fn remember(text: &str, entity: Option<&str>, tags: &[&str]) -> Command {
2188 Command::Remember {
2189 text: text.into(),
2190 entity: entity.map(Into::into),
2191 tags: tags.iter().map(|t| (*t).into()).collect(),
2192 links: Vec::new(),
2193 meta: Vec::new(),
2194 valid_from: None,
2195 vector: Vec::new(),
2196 guarded: false,
2197 }
2198 }
2199
2200 fn remember_with_meta(text: &str, meta: &[&str]) -> Command {
2201 Command::Remember {
2202 text: text.into(),
2203 entity: None,
2204 tags: Vec::new(),
2205 links: Vec::new(),
2206 meta: meta.iter().map(|m| (*m).into()).collect(),
2207 valid_from: None,
2208 vector: Vec::new(),
2209 guarded: false,
2210 }
2211 }
2212
2213 #[test]
2214 fn guarded_remember_flag_reports_blocked_without_a_write() {
2215 let (db, _temp) = TempDb::open();
2216 let mut first = remember("likes green tea every morning", Some("user"), &[]);
2217 let Command::Remember { guarded, .. } = &mut first else {
2218 unreachable!()
2219 };
2220 *guarded = true;
2221 let (_, stored) = run_cmd(&db, &first, true, 1_000);
2222 let stored: serde_json::Value = serde_json::from_str(&stored).unwrap();
2223 assert_eq!(stored["status"], "stored");
2224 assert_eq!(stored["outcome"]["id"], 0);
2225 assert_eq!(stored["checked"], true, "an entity was named");
2226
2227 let mut second = remember("likes green tea each morning", Some("user"), &[]);
2228 let Command::Remember { guarded, .. } = &mut second else {
2229 unreachable!()
2230 };
2231 *guarded = true;
2232 let (_, blocked) = run_cmd(&db, &second, true, 2_000);
2233 let blocked: serde_json::Value = serde_json::from_str(&blocked).unwrap();
2234 assert_eq!(blocked["status"], "blocked");
2235 assert_eq!(blocked["similar"][0]["id"], 0);
2236 assert_eq!(db.stats().facts, 1);
2237 }
2238
2239 #[test]
2247 fn guarded_without_an_entity_says_it_stored_unchecked() {
2248 let (db, _temp) = TempDb::open();
2249 let text = "the cache is disabled because it raced with the warmup task";
2250 for at in [1_000, 2_000, 3_000] {
2251 let mut cmd = remember(text, None, &[]);
2252 let Command::Remember { guarded, .. } = &mut cmd else {
2253 unreachable!()
2254 };
2255 *guarded = true;
2256 let (_, plain) = run_cmd(&db, &cmd, false, at);
2257 assert!(plain.contains("remembered fact"), "{plain}");
2258 assert!(plain.contains("WITHOUT a duplicate check"), "{plain}");
2259 }
2260 assert_eq!(db.stats().facts, 3, "identical facts, as remember would");
2261
2262 let mut cmd = remember(text, None, &[]);
2263 let Command::Remember { guarded, .. } = &mut cmd else {
2264 unreachable!()
2265 };
2266 *guarded = true;
2267 let (_, json) = run_cmd(&db, &cmd, true, 4_000);
2268 let json: serde_json::Value = serde_json::from_str(&json).unwrap();
2269 assert_eq!(json["status"], "stored");
2270 assert_eq!(json["checked"], false);
2271 }
2272
2273 #[test]
2274 fn meta_flag_renders_sorted_in_show_and_export_and_rejects_bad_input() {
2275 let (db, _t) = TempDb::open();
2276 let cmd = remember_with_meta("a scan", &["uri=s3://b/x", "page=2", "page=3"]);
2278 assert_eq!(run_cmd(&db, &cmd, false, 1_000).0, 0);
2279
2280 let (_, human) = run_cmd(&db, &Command::Show { id: 0 }, false, 2_000);
2282 assert!(
2283 human.contains("metadata page=3, uri=s3://b/x"),
2284 "{human}"
2285 );
2286 let (_, jshow) = run_cmd(&db, &Command::Show { id: 0 }, true, 2_000);
2288 let v: serde_json::Value = serde_json::from_str(&jshow).unwrap();
2289 assert_eq!(v["metadata"]["page"], "3");
2290 assert_eq!(v["metadata"]["uri"], "s3://b/x");
2291
2292 let (_, exp) = run_cmd(&db, &Command::Export, false, 2_000);
2294 let line: serde_json::Value = serde_json::from_str(exp.lines().next().unwrap()).unwrap();
2295 assert_eq!(line["metadata"]["uri"], "s3://b/x");
2296
2297 assert!(matches!(
2299 parse_meta(&["noequals".to_string()]),
2300 Err(CliError::Usage(_))
2301 ));
2302 assert!(parse_meta(&["=noKey".to_string()]).is_err());
2303 }
2304
2305 #[test]
2306 fn remember_then_recall_human_and_json() {
2307 let (db, _t) = TempDb::open();
2308 let (code, out) = run_cmd(
2309 &db,
2310 &remember("prefers tokio", Some("user"), &["pref"]),
2311 false,
2312 1_000,
2313 );
2314 assert_eq!(code, 0);
2315 assert!(out.starts_with("remembered fact 0"), "{out}");
2316
2317 let recall = Command::Recall {
2319 query: Some("tokio".into()),
2320 tags: Vec::new(),
2321 entities: Vec::new(),
2322 as_of: None,
2323 range: None,
2324 k: 0,
2325 closed: false,
2326 token_budget: None,
2327 ef: None,
2328 graph_depth: None,
2329 vector: Vec::new(),
2330 };
2331 let (code, out) = run_cmd(&db, &recall, false, 2_000);
2332 assert_eq!(code, 0);
2333 assert!(out.contains("tokio"), "{out}");
2334
2335 let (code, out) = run_cmd(&db, &recall, true, 2_000);
2337 assert_eq!(code, 0);
2338 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2339 assert!(!v["facts"].as_array().unwrap().is_empty(), "{out}");
2340 }
2341
2342 #[test]
2343 fn recall_empty_is_ok_with_a_note() {
2344 let (db, _t) = TempDb::open();
2345 let recall = Command::Recall {
2346 query: Some("nothing here".into()),
2347 tags: Vec::new(),
2348 entities: Vec::new(),
2349 as_of: None,
2350 range: None,
2351 k: 0,
2352 closed: false,
2353 token_budget: None,
2354 ef: None,
2355 graph_depth: None,
2356 vector: Vec::new(),
2357 };
2358 let (code, out) = run_cmd(&db, &recall, false, 1_000);
2359 assert_eq!(code, 0);
2360 assert!(out.contains("nothing recalled"), "{out}");
2361 }
2362
2363 #[test]
2364 fn revise_closes_the_predecessor_and_conflict_is_surfaced() {
2365 let (db, _t) = TempDb::open();
2366 run_cmd(
2367 &db,
2368 &remember("lives in Moscow", Some("user"), &[]),
2369 false,
2370 1_000,
2371 );
2372 let (_, out) = run_cmd(
2374 &db,
2375 &remember("lives in Moscow now", Some("user"), &[]),
2376 false,
2377 1_500,
2378 );
2379 assert!(out.contains("similar to fact"), "{out}");
2380
2381 let revise = Command::Revise {
2382 id: 0,
2383 text: "lives in Berlin".into(),
2384 entity: Some("user".into()),
2385 tags: Vec::new(),
2386 links: Vec::new(),
2387 meta: Vec::new(),
2388 valid_from: None,
2389 vector: Vec::new(),
2390 };
2391 let (code, out) = run_cmd(&db, &revise, false, 2_000);
2392 assert_eq!(code, 0);
2393 assert!(out.starts_with("remembered fact"), "{out}");
2394 }
2395
2396 #[test]
2397 fn show_found_and_missing() {
2398 let (db, _t) = TempDb::open();
2399 run_cmd(&db, &remember("a note", None, &[]), false, 1_000);
2400
2401 let (code, out) = run_cmd(&db, &Command::Show { id: 0 }, false, 2_000);
2402 assert_eq!(code, 0);
2403 assert!(
2404 out.contains("a note") && out.contains("recorded_at 1000"),
2405 "{out}"
2406 );
2407
2408 let (code, out) = run_cmd(&db, &Command::Show { id: 999 }, false, 2_000);
2409 assert_eq!(code, 1, "missing id is a soft miss");
2410 assert!(out.contains("not found"), "{out}");
2411
2412 let (_, out) = run_cmd(&db, &Command::Show { id: 0 }, true, 2_000);
2414 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2415 assert_eq!(v["text"], "a note");
2416 assert_eq!(v["valid_to"], serde_json::Value::Null); }
2418
2419 #[test]
2420 fn forget_then_maintain_purges() {
2421 let (db, _t) = TempDb::open();
2422 run_cmd(&db, &remember("temp", None, &[]), false, 1_000);
2423
2424 let (code, out) = run_cmd(&db, &Command::Forget { ids: vec![0] }, false, 2_000);
2425 assert_eq!(code, 0);
2426 assert!(out.contains("forgot fact 0"), "{out}");
2427 let (_, out) = run_cmd(&db, &Command::Forget { ids: vec![0] }, false, 2_100);
2429 assert!(out.contains("already gone"), "{out}");
2430
2431 let (code, out) = run_cmd(
2432 &db,
2433 &Command::Maintain {
2434 mode: MaintainMode::Auto,
2435 reembed: false,
2436 batch_size: None,
2437 },
2438 false,
2439 3_000,
2440 );
2441 assert_eq!(code, 0);
2442 assert!(out.contains("purged 1"), "{out}");
2443 }
2444
2445 #[test]
2446 fn forget_many_ids_one_line_per_id() {
2447 let (db, _t) = TempDb::open();
2448 run_cmd(&db, &remember("a", None, &[]), false, 1_000);
2449 run_cmd(&db, &remember("b", None, &[]), false, 1_100);
2450 run_cmd(&db, &remember("c", None, &[]), false, 1_200);
2451
2452 let (code, out) = run_cmd(&db, &Command::Forget { ids: vec![0, 1] }, false, 2_000);
2453 assert_eq!(code, 0);
2454 let lines: Vec<_> = out.lines().collect();
2455 assert_eq!(lines, vec!["forgot fact 0", "forgot fact 1"]);
2456
2457 let (code, out) = run_cmd(&db, &Command::Forget { ids: vec![0, 2] }, false, 2_100);
2459 assert_eq!(code, 0);
2460 let lines: Vec<_> = out.lines().collect();
2461 assert_eq!(lines, vec!["fact 0 was already gone", "forgot fact 2"]);
2462 }
2463
2464 #[test]
2465 fn forget_many_ids_json_is_an_array() {
2466 let (db, _t) = TempDb::open();
2467 run_cmd(&db, &remember("a", None, &[]), false, 1_000);
2468 run_cmd(&db, &remember("b", None, &[]), false, 1_100);
2469
2470 let (_, out) = run_cmd(&db, &Command::Forget { ids: vec![0, 1] }, true, 2_000);
2471 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2472 let arr = v.as_array().expect("json array for multi-id forget");
2473 assert_eq!(arr.len(), 2);
2474 assert_eq!(arr[0]["id"], 0);
2475 assert_eq!(arr[0]["forgotten"], true);
2476 assert_eq!(arr[1]["id"], 1);
2477 assert_eq!(arr[1]["forgotten"], true);
2478 }
2479
2480 #[test]
2481 fn explicit_reembed_has_human_and_json_reports() {
2482 let (plain, tmp) = TempDb::open();
2483 let path = tmp.0.join("m.plugmem");
2484 drop(plain);
2485 let mut config = Config::default();
2486 config.dim = 3;
2487 let (db, _) = Database::builder(config)
2488 .embedder(Box::new(StubEmbedder))
2489 .open(path)
2490 .unwrap();
2491 run_cmd(&db, &remember("vector fact", None, &["kept"]), false, 1);
2492
2493 let command = Command::Maintain {
2494 mode: MaintainMode::Auto,
2495 reembed: true,
2496 batch_size: Some(1),
2497 };
2498 let (code, human) = run_cmd(&db, &command, false, 2);
2499 assert_eq!(code, 0);
2500 assert!(human.contains("reembedded: 1 facts"));
2501 let (code, json) = run_cmd(&db, &command, true, 3);
2502 assert_eq!(code, 0);
2503 let report: serde_json::Value = serde_json::from_str(json.trim()).unwrap();
2504 assert_eq!(report["new_space"], "cli-stub");
2505 assert_eq!(report["embedded"], 1);
2506 }
2507
2508 #[test]
2509 fn readonly_cli_refuses_a_same_dimension_different_space() {
2510 let (plain, tmp) = TempDb::open();
2511 let path = tmp.0.join("m.plugmem");
2512 drop(plain);
2513 let mut config = Config::default();
2514 config.dim = 3;
2515 let (db, _) = Database::builder(config.clone())
2516 .embedder(Box::new(StubEmbedder))
2517 .open(&path)
2518 .unwrap();
2519 db.remember(RememberInput::text(1, "stored vector"))
2520 .unwrap();
2521 db.checkpoint(2).unwrap();
2522 let ro = Database::open_readonly(&path, config).unwrap();
2523 let cmd = recall_cmd(Some("stored"));
2524 let mut out = Vec::new();
2525 let code = execute_ro(
2526 &ro,
2527 &cmd,
2528 Some(&[0.1, 0.2, 0.3]),
2529 Some("other-model"),
2530 EmbedderState::Active,
2531 false,
2532 &mut out,
2533 );
2534 assert_eq!(code, 2);
2535 }
2536
2537 #[test]
2538 fn tags_pages_and_remove_tag_have_human_and_json_shapes() {
2539 let (db, _t) = TempDb::open();
2540 run_cmd(&db, &remember("one", None, &["drop", "keep"]), false, 1_000);
2541 run_cmd(&db, &remember("two", None, &["drop"]), false, 1_100);
2542
2543 let (code, out) = run_cmd(
2544 &db,
2545 &Command::Tags {
2546 prefix: None,
2547 cursor: None,
2548 limit: 1,
2549 },
2550 false,
2551 2_000,
2552 );
2553 assert_eq!(code, 0);
2554 assert!(out.starts_with("2\tdrop\n"), "{out}");
2555 assert!(out.contains("next_cursor\t"), "{out}");
2556
2557 let (_, out) = run_cmd(&db, &Command::RemoveTag { tag: "drop".into() }, true, 3_000);
2558 let removed: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2559 assert_eq!(removed["affected"], 2);
2560
2561 let (_, out) = run_cmd(
2562 &db,
2563 &Command::Tags {
2564 prefix: None,
2565 cursor: None,
2566 limit: 0,
2567 },
2568 true,
2569 4_000,
2570 );
2571 let page: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2572 assert_eq!(page["items"][0]["name"], "keep");
2573 assert_eq!(db.export().len(), 2, "facts remain current after retagging");
2574 }
2575
2576 #[test]
2577 fn link_and_stats_and_json() {
2578 let (db, _t) = TempDb::open();
2579 run_cmd(
2580 &db,
2581 &remember("uses tokio", Some("plugmem"), &[]),
2582 false,
2583 1_000,
2584 );
2585 let link = Command::Link {
2586 src: "plugmem".into(),
2587 rel: "depends_on".into(),
2588 dst: "tokio".into(),
2589 provenance: None,
2590 };
2591 let (code, out) = run_cmd(&db, &link, false, 2_000);
2592 assert_eq!(code, 0);
2593 assert!(out.contains("plugmem -depends_on-> tokio"), "{out}");
2594 let unlink = Command::Unlink {
2595 src: "plugmem".into(),
2596 rel: "depends_on".into(),
2597 dst: "tokio".into(),
2598 };
2599 let (code, out) = run_cmd(&db, &unlink, false, 2_500);
2600 assert_eq!(code, 0);
2601 assert!(
2602 out.contains("unlinked plugmem -depends_on-> tokio"),
2603 "{out}"
2604 );
2605
2606 let (code, out) = run_cmd(&db, &Command::Stats, true, 3_000);
2607 assert_eq!(code, 0);
2608 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2609 assert_eq!(v["facts"], 1);
2610 assert_eq!(v["edges"], 0);
2611 assert_eq!(v["edge_versions"], 1);
2612 }
2613
2614 #[test]
2615 fn bad_link_is_a_usage_error() {
2616 let (db, _t) = TempDb::open();
2617 let cmd = Command::Remember {
2618 text: "x".into(),
2619 entity: Some("user".into()),
2620 tags: Vec::new(),
2621 links: vec!["not-a-pair".into()],
2622 meta: Vec::new(),
2623 valid_from: None,
2624 vector: Vec::new(),
2625 guarded: false,
2626 };
2627 let mut buf = Vec::new();
2628 let err = execute(&db, &cmd, false, 1_000, &mut buf).unwrap_err();
2629 assert!(matches!(err, CliError::Usage(_)));
2630 }
2631
2632 #[test]
2633 fn as_of_time_travel_via_recall() {
2634 let (db, _t) = TempDb::open();
2635 run_cmd(
2636 &db,
2637 &remember("lives in Moscow", Some("user"), &[]),
2638 false,
2639 1_000,
2640 );
2641 let revise = Command::Revise {
2642 id: 0,
2643 text: "lives in Berlin".into(),
2644 entity: Some("user".into()),
2645 tags: Vec::new(),
2646 links: Vec::new(),
2647 meta: Vec::new(),
2648 valid_from: None,
2649 vector: Vec::new(),
2650 };
2651 run_cmd(&db, &revise, false, 2_000);
2652
2653 let as_of = Command::Recall {
2654 query: Some("lives".into()),
2655 tags: Vec::new(),
2656 entities: vec!["user".into()],
2657 as_of: Some(1_500),
2658 range: None,
2659 k: 0,
2660 closed: false,
2661 token_budget: None,
2662 ef: None,
2663 graph_depth: None,
2664 vector: Vec::new(),
2665 };
2666 let (_, out) = run_cmd(&db, &as_of, false, 3_000);
2667 assert!(out.contains("Moscow"), "as-of 1500 → Moscow: {out}");
2668 }
2669
2670 #[test]
2671 fn every_command_has_a_json_shape() {
2672 let (db, _t) = TempDb::open();
2673 let (_, out) = run_cmd(
2675 &db,
2676 &remember("uses tokio", Some("plugmem"), &["pref"]),
2677 true,
2678 1_000,
2679 );
2680 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2681 assert_eq!(v["id"], 0);
2682 assert!(v["similar"].is_array());
2683
2684 let revise = Command::Revise {
2686 id: 0,
2687 text: "uses tokio now".into(),
2688 entity: Some("plugmem".into()),
2689 tags: Vec::new(),
2690 links: Vec::new(),
2691 meta: Vec::new(),
2692 valid_from: None,
2693 vector: Vec::new(),
2694 };
2695 let (_, out) = run_cmd(&db, &revise, true, 1_500);
2696 assert!(serde_json::from_str::<serde_json::Value>(out.trim()).is_ok());
2697
2698 let link = Command::Link {
2700 src: "plugmem".into(),
2701 rel: "depends_on".into(),
2702 dst: "tokio".into(),
2703 provenance: None,
2704 };
2705 let (_, out) = run_cmd(&db, &link, true, 2_000);
2706 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2707 assert_eq!(v["rel"], "depends_on");
2708 let unlink = Command::Unlink {
2709 src: "plugmem".into(),
2710 rel: "depends_on".into(),
2711 dst: "tokio".into(),
2712 };
2713 let (_, out) = run_cmd(&db, &unlink, true, 2_100);
2714 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2715 assert_eq!(v["unlinked"], true);
2716
2717 let (_, out) = run_cmd(&db, &Command::Forget { ids: vec![1] }, true, 2_500);
2719 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2720 assert_eq!(v["forgotten"], true);
2721 let (_, out) = run_cmd(
2722 &db,
2723 &Command::Maintain {
2724 mode: MaintainMode::Auto,
2725 reembed: false,
2726 batch_size: None,
2727 },
2728 true,
2729 3_000,
2730 );
2731 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2732 assert!(v["purged"].as_u64().unwrap() >= 1);
2733
2734 let (code, out) = run_cmd(&db, &Command::Show { id: 999 }, true, 3_500);
2736 assert_eq!(code, 1);
2737 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2738 assert_eq!(v["found"], false);
2739
2740 let recall = Command::Recall {
2742 query: None,
2743 tags: Vec::new(),
2744 entities: vec!["plugmem".into()],
2745 as_of: None,
2746 range: Some(vec![0, 10_000]),
2747 k: 4,
2748 closed: true,
2749 token_budget: None,
2750 ef: None,
2751 graph_depth: None,
2752 vector: Vec::new(),
2753 };
2754 let (_, out) = run_cmd(&db, &recall, true, 4_000);
2755 assert!(serde_json::from_str::<serde_json::Value>(out.trim()).is_ok());
2756 }
2757
2758 #[test]
2759 fn graph_depth_is_a_per_call_flag_over_the_configured_default() {
2760 let (db, _t) = TempDb::open();
2763 for (i, (entity, next)) in [("a", "b"), ("b", "c"), ("c", "d"), ("d", "e")]
2764 .iter()
2765 .enumerate()
2766 {
2767 let cmd = Command::Remember {
2768 text: format!("fact on {entity}"),
2769 entity: Some((*entity).into()),
2770 tags: Vec::new(),
2771 links: vec![format!("leads_to:{next}")],
2772 meta: Vec::new(),
2773 valid_from: None,
2774 vector: Vec::new(),
2775 guarded: false,
2776 };
2777 run_cmd(&db, &cmd, false, 1_000 + i as u64);
2778 }
2779
2780 let reached = |depth: Option<u32>| {
2781 let recall = Command::Recall {
2782 query: None,
2783 tags: Vec::new(),
2784 entities: vec!["a".into()],
2785 as_of: None,
2786 range: None,
2787 k: 64,
2788 closed: false,
2789 token_budget: Some(4096),
2790 ef: None,
2791 graph_depth: depth,
2792 vector: Vec::new(),
2793 };
2794 let (_, out) = run_cmd(&db, &recall, false, 5_000);
2795 out.lines().filter(|l| l.starts_with("- [f")).count()
2796 };
2797
2798 assert_eq!(reached(None), 3, "the configured default is 2 hops");
2799 assert_eq!(reached(Some(0)), 1, "no expansion: the anchor's own fact");
2800 assert_eq!(reached(Some(1)), 2);
2801 assert_eq!(reached(Some(3)), 4);
2802 assert_eq!(reached(Some(99)), 4);
2805 assert_eq!(reached(Some(u32::MAX)), 4);
2806 }
2807
2808 #[test]
2809 fn stats_human_lists_the_counters() {
2810 let (db, _t) = TempDb::open();
2811 run_cmd(&db, &remember("a", None, &[]), false, 1_000);
2812 let (code, out) = run_cmd(&db, &Command::Stats, false, 2_000);
2813 assert_eq!(code, 0);
2814 assert!(out.contains("facts") && out.contains("pool_bytes"), "{out}");
2815 }
2816
2817 #[test]
2818 fn verify_command_renders_human_and_json() {
2819 let (db, _t) = TempDb::open();
2820 run_cmd(&db, &remember("clean", None, &[]), false, 1_000);
2821
2822 let (code, out) = run_cmd(&db, &Command::Verify, false, 2_000);
2823 assert_eq!(code, 0);
2824 assert_eq!(out.trim(), "integrity ok");
2825
2826 let (code, out) = run_cmd(&db, &Command::Verify, true, 2_100);
2827 assert_eq!(code, 0);
2828 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2829 assert_eq!(v["ok"], true);
2830 }
2831
2832 #[test]
2833 fn show_json_of_a_revised_predecessor_is_closed() {
2834 let (db, _t) = TempDb::open();
2835 run_cmd(&db, &remember("v1", Some("e"), &[]), false, 1_000);
2836 let revise = Command::Revise {
2837 id: 0,
2838 text: "v2".into(),
2839 entity: Some("e".into()),
2840 tags: Vec::new(),
2841 links: Vec::new(),
2842 meta: Vec::new(),
2843 valid_from: None,
2844 vector: Vec::new(),
2845 };
2846 run_cmd(&db, &revise, false, 2_000);
2847 let (_, out) = run_cmd(&db, &Command::Show { id: 1 }, false, 3_000);
2849 assert!(out.contains("revises fact 0"), "{out}");
2850 }
2851
2852 #[test]
2853 fn resolve_db_path_prefers_the_flag() {
2854 let p = "/tmp/explicit.plugmem";
2855 assert_eq!(resolve_db_path(Some(p), None, None), PathBuf::from(p));
2856 let configured = std::path::Path::new("/tmp/configured.plugmem");
2857 assert_eq!(
2858 resolve_db_path(None, Some(configured), None),
2859 PathBuf::from(configured)
2860 );
2861 let _ = resolve_db_path(None, None, None);
2864 }
2865
2866 #[test]
2867 fn a_bare_name_is_a_memory_only_when_a_workspace_is_configured() {
2868 let root = PathBuf::from("/srv/bot");
2869
2870 assert_eq!(
2873 resolve_db_path(Some("work"), None, None),
2874 PathBuf::from("work")
2875 );
2876
2877 assert_eq!(
2879 resolve_db_path(Some("work"), None, Some(&root)),
2880 PathBuf::from("/srv/bot/db/work.plugmem")
2881 );
2882 for path in ["./work", "work.plugmem", "/srv/other.plugmem", "../up"] {
2885 assert_eq!(
2886 resolve_db_path(Some(path), None, Some(&root)),
2887 PathBuf::from(path),
2888 "{path}"
2889 );
2890 }
2891
2892 let configured = std::path::Path::new("work");
2894 assert_eq!(
2895 resolve_db_path(None, Some(configured), Some(&root)),
2896 PathBuf::from("work")
2897 );
2898 }
2899
2900 #[test]
2901 fn settings_help_runs_without_opening_a_database() {
2902 let cli = Cli::try_parse_from(["plugmem-cli", "help", "settings"]).unwrap();
2903 let mut output = Vec::new();
2904 assert_eq!(run_parsed(cli, &mut output), 0);
2905 let output = String::from_utf8(output).unwrap();
2906 assert!(output.contains("plugmem settings"));
2907 assert!(output.contains("[database]"));
2908 assert!(output.contains("path (path string"));
2909
2910 let cli = Cli::try_parse_from(["plugmem-cli", "--json", "help", "settings"]).unwrap();
2911 let mut output = Vec::new();
2912 assert_eq!(run_parsed(cli, &mut output), 0);
2913 let output: serde_json::Value = serde_json::from_slice(&output).unwrap();
2914 assert_eq!(output["topic"], "settings");
2915 assert!(output["config_path_precedence"].is_array());
2916 assert!(output["settings"].as_array().unwrap().len() > 10);
2917 }
2918
2919 #[test]
2920 fn run_parsed_opens_runs_and_reports() {
2921 let dir = std::env::temp_dir().join(format!(
2922 "plugmem-run-{}-{}",
2923 std::process::id(),
2924 now_ms_unique()
2925 ));
2926 std::fs::create_dir_all(&dir).unwrap();
2927 let path = dir.join("m.plugmem");
2928 let cli = Cli {
2929 db: Some(path.display().to_string()),
2930 workspace: None,
2931 config: None,
2932 json: false,
2933 command: Command::Stats,
2934 };
2935 let mut buf = Vec::new();
2936 let code = run_parsed(cli, &mut buf);
2937 assert_eq!(code, 0);
2938 assert!(String::from_utf8(buf).unwrap().contains("facts"));
2939 let _ = std::fs::remove_dir_all(&dir);
2940 }
2941
2942 #[test]
2943 fn recover_and_scrub_render_json_and_human_shapes() {
2944 let (db, tmp) = TempDb::open();
2945 let path = tmp.0.join("m.plugmem");
2946 run_cmd(&db, &remember("recoverable fact", None, &[]), false, 1_000);
2947 run_cmd(&db, &Command::Checkpoint, false, 2_000);
2948 drop(db);
2949
2950 let settings = settings_with(None);
2951 let mut out = Vec::new();
2952 assert_eq!(do_scrub(&path, &settings, true, &mut out), 0);
2953 let scrub: serde_json::Value = serde_json::from_slice(&out).unwrap();
2954 assert_eq!(scrub["ok"], true);
2955 assert!(scrub["bytes"].as_u64().unwrap() > 0);
2956 let mut out = Vec::new();
2957 assert_eq!(do_scrub(&path, &settings, false, &mut out), 0);
2958 let out = String::from_utf8(out).unwrap();
2959 assert!(out.contains("scrub ok:"), "{out}");
2960
2961 let json_dst = tmp.0.join("copy-json.plugmem");
2962 let mut out = Vec::new();
2963 assert_eq!(do_recover(&path, &json_dst, &settings, true, &mut out), 0);
2964 let recover: serde_json::Value = serde_json::from_slice(&out).unwrap();
2965 assert_eq!(recover["kept"], 1);
2966 assert_eq!(recover["dropped_text"], 0);
2967 assert_eq!(recover["dst"], json_dst.display().to_string());
2968
2969 let human_dst = tmp.0.join("copy-human.plugmem");
2970 let mut out = Vec::new();
2971 assert_eq!(do_recover(&path, &human_dst, &settings, false, &mut out), 0);
2972 let out = String::from_utf8(out).unwrap();
2973 assert!(out.contains("recovered to"), "{out}");
2974 assert!(out.contains("kept 1"), "{out}");
2975 }
2976
2977 #[test]
2978 fn readonly_dispatcher_renders_every_read_shape() {
2979 let (db, tmp) = TempDb::open();
2980 let path = tmp.0.join("m.plugmem");
2981 run_cmd(
2982 &db,
2983 &remember("readonly tokio fact", Some("plugmem"), &["pref"]),
2984 false,
2985 1_000,
2986 );
2987 run_cmd(&db, &Command::Checkpoint, false, 2_000);
2988 let ro = Database::open_readonly(&path, Config::default()).unwrap();
2989
2990 let mut out = Vec::new();
2991 assert_eq!(
2992 execute_ro(
2993 &ro,
2994 &Command::Stats,
2995 None,
2996 None,
2997 EmbedderState::Absent,
2998 true,
2999 &mut out
3000 ),
3001 0
3002 );
3003 let stats: serde_json::Value = serde_json::from_slice(&out).unwrap();
3004 assert_eq!(stats["facts"], 1);
3005
3006 let mut out = Vec::new();
3007 assert_eq!(
3008 execute_ro(
3009 &ro,
3010 &Command::Show { id: 0 },
3011 None,
3012 None,
3013 EmbedderState::Absent,
3014 false,
3015 &mut out
3016 ),
3017 0
3018 );
3019 let text = String::from_utf8(out).unwrap();
3020 assert!(text.contains("readonly tokio fact"), "{text}");
3021
3022 let mut out = Vec::new();
3023 assert_eq!(
3024 execute_ro(
3025 &ro,
3026 &Command::Export,
3027 None,
3028 None,
3029 EmbedderState::Absent,
3030 false,
3031 &mut out
3032 ),
3033 0
3034 );
3035 let exported: serde_json::Value =
3036 serde_json::from_str(String::from_utf8(out).unwrap().lines().next().unwrap()).unwrap();
3037 assert_eq!(exported["text"], "readonly tokio fact");
3038
3039 let mut out = Vec::new();
3040 let recall = Command::Recall {
3041 query: Some("tokio".into()),
3042 tags: vec!["pref".into()],
3043 entities: vec!["plugmem".into()],
3044 as_of: None,
3045 range: None,
3046 k: 1,
3047 closed: false,
3048 token_budget: None,
3049 ef: None,
3050 graph_depth: None,
3051 vector: Vec::new(),
3052 };
3053 assert_eq!(
3054 execute_ro(
3055 &ro,
3056 &recall,
3057 None,
3058 None,
3059 EmbedderState::Absent,
3060 false,
3061 &mut out
3062 ),
3063 0
3064 );
3065 let text = String::from_utf8(out).unwrap();
3066 assert!(text.contains("tokio"), "{text}");
3067
3068 let mut out = Vec::new();
3069 assert_eq!(
3070 execute_ro(
3071 &ro,
3072 &Command::Verify,
3073 None,
3074 None,
3075 EmbedderState::Absent,
3076 true,
3077 &mut out
3078 ),
3079 0
3080 );
3081 let verify: serde_json::Value = serde_json::from_slice(&out).unwrap();
3082 assert_eq!(verify["ok"], true);
3083 }
3084
3085 #[test]
3086 fn run_parsed_on_a_locked_database_returns_one() {
3087 let (_held, dir) = {
3088 let dir = std::env::temp_dir().join(format!(
3089 "plugmem-lock-{}-{}",
3090 std::process::id(),
3091 now_ms_unique()
3092 ));
3093 std::fs::create_dir_all(&dir).unwrap();
3094 let path = dir.join("m.plugmem");
3095 (Database::open(&path, Config::default()).unwrap(), dir)
3096 };
3097 let cli = Cli {
3098 db: Some(dir.join("m.plugmem").display().to_string()),
3099 workspace: None,
3100 config: None,
3101 json: false,
3102 command: Command::Stats,
3103 };
3104 let mut buf = Vec::new();
3105 assert_eq!(run_parsed(cli, &mut buf), 1);
3106 let _ = std::fs::remove_dir_all(&dir);
3107 }
3108
3109 #[test]
3110 fn run_parsed_propagates_a_usage_error_as_two() {
3111 let dir = std::env::temp_dir().join(format!(
3112 "plugmem-usage-{}-{}",
3113 std::process::id(),
3114 now_ms_unique()
3115 ));
3116 std::fs::create_dir_all(&dir).unwrap();
3117 let cli = Cli {
3118 db: Some(dir.join("m.plugmem").display().to_string()),
3119 workspace: None,
3120 config: None,
3121 json: false,
3122 command: Command::Remember {
3123 text: "x".into(),
3124 entity: None,
3125 tags: Vec::new(),
3126 links: vec!["bad".into()],
3127 meta: Vec::new(),
3128 valid_from: None,
3129 vector: Vec::new(),
3130 guarded: false,
3131 },
3132 };
3133 let mut buf = Vec::new();
3134 assert_eq!(run_parsed(cli, &mut buf), 2);
3135 let _ = std::fs::remove_dir_all(&dir);
3136 }
3137
3138 struct Scratch(PathBuf);
3140 impl Scratch {
3141 fn new(tag: &str) -> Self {
3142 let dir = std::env::temp_dir().join(format!(
3143 "plugmem-cli-{tag}-{}-{}",
3144 std::process::id(),
3145 now_ms_unique()
3146 ));
3147 std::fs::create_dir_all(&dir).unwrap();
3148 Scratch(dir)
3149 }
3150 }
3151 impl Drop for Scratch {
3152 fn drop(&mut self) {
3153 let _ = std::fs::remove_dir_all(&self.0);
3154 }
3155 }
3156
3157 #[test]
3158 fn export_import_roundtrip_preserves_open_facts() {
3159 let (a, _ta) = TempDb::open();
3164 run_cmd(
3165 &a,
3166 &Command::Remember {
3167 text: "prefers tokio".into(),
3168 entity: Some("user".into()),
3169 tags: vec!["pref".into(), "lang".into()],
3170 links: Vec::new(),
3171 meta: vec!["uri=s3://b/x".into(), "src=chat".into()],
3172 valid_from: Some(500),
3173 vector: Vec::new(),
3174 guarded: false,
3175 },
3176 false,
3177 1_000,
3178 );
3179 run_cmd(
3180 &a,
3181 &remember("lives in Moscow", Some("user"), &[]),
3182 false,
3183 1_100,
3184 ); run_cmd(
3186 &a,
3187 &Command::Revise {
3188 id: 1,
3189 text: "lives in Berlin".into(),
3190 entity: Some("user".into()),
3191 tags: vec!["geo".into()],
3192 links: Vec::new(),
3193 meta: Vec::new(),
3194 valid_from: None,
3195 vector: Vec::new(),
3196 },
3197 false,
3198 1_200,
3199 ); run_cmd(&a, &remember("junk", None, &[]), false, 1_300); run_cmd(&a, &Command::Forget { ids: vec![3] }, false, 1_400); run_cmd(
3203 &a,
3204 &remember("uses rust", Some("plugmem"), &["lang"]),
3205 false,
3206 1_500,
3207 ); let mut dump = Vec::new();
3211 render_export(&a.export(), false, &mut dump);
3212 let scratch = Scratch::new("roundtrip");
3213 let file = scratch.0.join("dump.jsonl");
3214 std::fs::write(&file, &dump).unwrap();
3215
3216 let (b, _tb) = TempDb::open();
3218 let n = do_import(&b, 9_000, &file, 128, &mut Vec::new()).unwrap();
3219
3220 let key = |f: &ExportedFact| {
3222 let mut tags = f.tags.clone();
3223 tags.sort();
3224 (f.text.clone(), f.entity.clone(), tags, f.valid_from)
3225 };
3226 let mut ak: Vec<_> = a.export().iter().map(key).collect();
3227 let mut bk: Vec<_> = b.export().iter().map(key).collect();
3228 ak.sort();
3229 bk.sort();
3230 assert_eq!(n.facts, ak.len());
3231 assert_eq!(
3232 ak, bk,
3233 "roundtrip must preserve text/entity/tags/valid_from"
3234 );
3235
3236 let b_open = b.export();
3239 assert!(b_open.iter().any(|f| f.text == "prefers tokio"
3240 && f.valid_from == 500
3241 && f.entity.as_deref() == Some("user")
3242 && f.tags == vec!["pref".to_string(), "lang".to_string()]
3243 && f.metadata.get("uri").map(String::as_str) == Some("s3://b/x")
3244 && f.metadata.get("src").map(String::as_str) == Some("chat")));
3245 assert!(b_open.iter().any(|f| f.text == "lives in Berlin"));
3246 assert!(b_open.iter().any(|f| f.text == "uses rust"));
3247 assert!(!b_open.iter().any(|f| f.text.contains("Moscow")));
3248 assert!(!b_open.iter().any(|f| f.text == "junk"));
3249 }
3250
3251 #[test]
3252 fn export_command_emits_jsonl_regardless_of_json_flag() {
3253 let (db, _t) = TempDb::open();
3254 run_cmd(&db, &remember("a fact", Some("e"), &["t"]), false, 1_000);
3255 for json in [false, true] {
3256 let (code, out) = run_cmd(&db, &Command::Export, json, 2_000);
3257 assert_eq!(code, 0);
3258 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
3259 assert_eq!(v["text"], "a fact");
3260 assert_eq!(v["entity"], "e");
3261 assert_eq!(v["tags"][0], "t");
3262 }
3263 }
3264
3265 #[test]
3266 fn import_command_counts_and_rejects_bad_lines() {
3267 let (db, _t) = TempDb::open();
3268 let scratch = Scratch::new("import");
3269 let good = scratch.0.join("in.jsonl");
3270 std::fs::write(
3271 &good,
3272 "{\"text\":\"from jsonl\",\"entity\":\"user\",\"tags\":[\"x\"],\"valid_from\":42}\n\n{\"text\":\"second\"}\n",
3273 )
3274 .unwrap();
3275 let n = do_import(&db, 9_000, &good, 1, &mut Vec::new()).unwrap();
3277 assert_eq!(n.facts, 2, "both facts imported, blank line skipped");
3278
3279 let bad = scratch.0.join("bad.jsonl");
3280 std::fs::write(&bad, "not json at all\n").unwrap();
3281 let err = do_import(&db, 9_000, &bad, 128, &mut Vec::new()).unwrap_err();
3282 assert!(matches!(err, CliError::Usage(_)));
3283 }
3284
3285 #[test]
3286 fn import_batch_size_does_not_change_the_result() {
3287 let scratch = Scratch::new("import-batch");
3290 let file = scratch.0.join("facts.jsonl");
3291 let mut jsonl = String::new();
3292 for i in 0..5 {
3293 jsonl.push_str(&format!("{{\"text\":\"fact number {i}\"}}\n"));
3294 }
3295 std::fs::write(&file, &jsonl).unwrap();
3296
3297 let (a, _ta) = TempDb::open();
3298 let (b, _tb) = TempDb::open();
3299 let na = do_import(&a, 9_000, &file, 1, &mut Vec::new()).unwrap();
3300 let nb = do_import(&b, 9_000, &file, 100, &mut Vec::new()).unwrap();
3301
3302 assert_eq!(na.facts, 5);
3303 assert_eq!(nb.facts, 5);
3304 let texts = |db: &Database| {
3305 let mut t: Vec<_> = db.export().into_iter().map(|f| f.text).collect();
3306 t.sort();
3307 t
3308 };
3309 assert_eq!(texts(&a), texts(&b), "batch size must not change the facts");
3310 }
3311
3312 #[test]
3313 fn config_table_feeds_settings_and_the_cli_batch_size() {
3314 let scratch = Scratch::new("settings");
3318 let cfgfile = scratch.0.join("config.toml");
3319 std::fs::write(
3320 &cfgfile,
3321 "[engine]\ndim = 512\n[embedder]\nenabled = false\n\
3322 [maintenance]\nsnapshot_every_ops = 64\nbatch_size = 200\n",
3323 )
3324 .unwrap();
3325 let table = plugmem_host::read_config(Some(&cfgfile)).unwrap();
3326 let s = Settings::from_table(table.as_ref()).unwrap();
3327 assert_eq!(s.config.dim, 512);
3328 assert!(s.embedder.is_none());
3329 assert_eq!(s.snapshot_every_ops, Some(64));
3330 assert_eq!(read_batch_size(table.as_ref()), Some(200));
3331
3332 assert!(plugmem_host::read_config(Some(&scratch.0.join("nope.toml"))).is_err());
3334 }
3335
3336 #[test]
3337 fn checkpoint_command_flushes_the_journal_and_enables_the_readonly_path() {
3338 let scratch = Scratch::new("checkpoint-cmd");
3339 let path = scratch.0.join("m.plugmem");
3340
3341 let remember = Cli {
3343 db: Some(path.display().to_string()),
3344 workspace: None,
3345 config: None,
3346 json: false,
3347 command: Command::Remember {
3348 text: "hello tokio".into(),
3349 entity: None,
3350 tags: Vec::new(),
3351 links: Vec::new(),
3352 meta: Vec::new(),
3353 valid_from: None,
3354 vector: Vec::new(),
3355 guarded: false,
3356 },
3357 };
3358 assert_eq!(run_parsed(remember, &mut Vec::new()), 0);
3359
3360 let checkpoint = |json| Cli {
3362 db: Some(path.display().to_string()),
3363 workspace: None,
3364 config: None,
3365 json,
3366 command: Command::Checkpoint,
3367 };
3368 let mut buf = Vec::new();
3369 assert_eq!(run_parsed(checkpoint(false), &mut buf), 0);
3370 assert!(String::from_utf8(buf).unwrap().contains("checkpointed"));
3371
3372 let mut buf = Vec::new();
3374 assert_eq!(run_parsed(checkpoint(true), &mut buf), 0);
3375 let v: serde_json::Value =
3376 serde_json::from_str(String::from_utf8(buf).unwrap().trim()).unwrap();
3377 assert_eq!(v["ok"], true);
3378
3379 let scrub = Cli {
3382 db: Some(path.display().to_string()),
3383 workspace: None,
3384 config: None,
3385 json: false,
3386 command: Command::Scrub,
3387 };
3388 let mut buf = Vec::new();
3389 assert_eq!(run_parsed(scrub, &mut buf), 0);
3390 assert!(String::from_utf8(buf).unwrap().contains("scrub ok"));
3391 }
3392
3393 #[test]
3394 fn run_parsed_uses_the_readonly_path_after_a_checkpoint() {
3395 let scratch = Scratch::new("ro-route");
3396 let path = scratch.0.join("m.plugmem");
3397 {
3398 let (db, _) = Database::open(&path, Config::default()).unwrap();
3399 db.remember(RememberInput::text(1_000, "hello tokio"))
3400 .unwrap();
3401 db.checkpoint(2_000).unwrap(); }
3403 let cli = Cli {
3405 db: Some(path.display().to_string()),
3406 workspace: None,
3407 config: None,
3408 json: false,
3409 command: Command::Stats,
3410 };
3411 let mut buf = Vec::new();
3412 assert_eq!(run_parsed(cli, &mut buf), 0);
3413 assert!(String::from_utf8(buf).unwrap().contains("facts"));
3414
3415 let cli = Cli {
3417 db: Some(path.display().to_string()),
3418 workspace: None,
3419 config: None,
3420 json: false,
3421 command: Command::Recall {
3422 query: Some("tokio".into()),
3423 tags: Vec::new(),
3424 entities: Vec::new(),
3425 as_of: None,
3426 range: None,
3427 k: 0,
3428 closed: false,
3429 token_budget: None,
3430 ef: None,
3431 graph_depth: None,
3432 vector: Vec::new(),
3433 },
3434 };
3435 let mut buf = Vec::new();
3436 assert_eq!(run_parsed(cli, &mut buf), 0);
3437 assert!(String::from_utf8(buf).unwrap().contains("tokio"));
3438 }
3439}