plugmem-cli 0.9.0

plugmem command-line interface — bitemporal memory and hybrid retrieval over one local database.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
//! The command-line surface: the [`Cli`] parser and its [`Command`]s
//! (clap derive). Kept apart from the execution logic so the long help
//! text lives in one place.

use std::path::PathBuf;

use clap::{Parser, Subcommand, ValueEnum};
use plugmem_host::MaintenanceMode;

/// `plugmem` — bitemporal memory and hybrid retrieval in a local database.
#[derive(Parser)]
#[command(
    name = "plugmem-cli",
    version,
    about = "Bitemporal memory for local-first applications and agents — remember, recall, revise, forget over one local database.",
    long_about = LONG_ABOUT,
    after_help = AFTER_HELP,
    disable_help_subcommand = true,
)]
pub(crate) struct Cli {
    /// Database path (default: the platform data path, or $PLUGMEM_DB). With a
    /// workspace configured this also takes a bare memory *name* — `work` is a
    /// name, `./work.plugmem` is a path.
    #[arg(long, global = true, value_name = "PATH|NAME")]
    pub(crate) db: Option<String>,

    /// Directory of named memories (default: $PLUGMEM_WORKSPACE, else
    /// [workspace].dir). Unset means one database addressed by path, which is
    /// the default and needs nothing configured.
    #[arg(long, global = true, value_name = "DIR")]
    pub(crate) workspace: Option<PathBuf>,

    /// Config file (default: $PLUGMEM_CONFIG, else
    /// $XDG_CONFIG_HOME/plugmem/config.toml). Sections: [database],
    /// [engine], [embedder], [maintenance].
    #[arg(long, global = true, value_name = "PATH")]
    pub(crate) config: Option<PathBuf>,

    /// Machine-readable JSON output instead of the human report.
    #[arg(long, global = true)]
    pub(crate) json: bool,

    #[command(subcommand)]
    pub(crate) command: Command,
}

/// The `--help` long description.
const LONG_ABOUT: &str = "\
A local memory an agent talks to in four verbs — remember / recall / revise / forget — \
plus link / unlink / show / stats / maintain / checkpoint / export / import, integrity: verify / \
scrub / recover, and an interactive `repl` (one open handle, host speed). Recall fuses lexical \
(BM25), vector, entity-graph and temporal evidence into one \
ranked, token-budgeted block. One local database uses a manifest, immutable snapshot \
generations, a journal and a lock; point --db at its manifest path (default: the platform data \
path, or $PLUGMEM_DB). Human output by default, --json for \
tooling. Exit code: 0 ok, 1 not found / database locked, 2 usage / runtime error / \
corruption.";

/// The footer, aimed at an agent that reached the binary without the skill.
const AFTER_HELP: &str = "\
FOR AI AGENTS: you'll get markedly better results with the matching `plugmem` skill loaded \
— it carries the workflow, the remember/recall loop and the examples this binary expects. \
Check that its version matches `plugmem-cli --version`; the skill is attached to every \
release at https://github.com/m62624/plugmem/releases";

#[derive(Subcommand)]
pub(crate) enum Command {
    /// Show detailed help for a topic without opening a database.
    Help {
        #[command(subcommand)]
        topic: HelpTopic,
    },
    /// Store a new fact; prints its id and any similar/conflicting facts.
    Remember {
        /// The fact text.
        text: String,
        /// Subject entity (created on first mention).
        #[arg(long)]
        entity: Option<String>,
        /// A tag; repeatable.
        #[arg(long = "tag", value_name = "TAG")]
        tags: Vec<String>,
        /// A typed edge `REL:ENTITY` from the subject; repeatable.
        #[arg(long = "link", value_name = "REL:ENTITY")]
        links: Vec<String>,
        /// A metadata entry `KEY=VALUE` (opaque to the engine — a URI, mime,
        /// external key); repeatable, last value wins per key.
        #[arg(long = "meta", value_name = "KEY=VALUE")]
        meta: Vec<String>,
        /// Validity start (unix millis); defaults to now.
        #[arg(long = "valid-from", value_name = "TS")]
        valid_from: Option<u64>,
        /// A precomputed embedding, comma-separated (`0.1,-0.2,…`). Its length
        /// must equal the configured `dim`. Given, it **replaces** the
        /// embedder: nothing is sent to the provider. This is the route for
        /// vectors you already have, or for a model that is not an
        /// OpenAI-shaped HTTP endpoint. Large ones come from a file:
        /// `--vector "$(cat vec.txt)"`.
        #[arg(long, value_name = "F32,…", value_delimiter = ',', num_args = 1..)]
        vector: Vec<f32>,
        /// Store only if the engine's bounded Jaccard/cosine detector finds no
        /// similar live fact for this entity, without a check/write race. A
        /// blocked call prints candidates and writes nothing.
        #[arg(long)]
        guarded: bool,
    },
    /// Retrieve a ranked, token-budgeted block; sources compose. Each line is
    /// `- [fN] text …`, where `N` is the fact's id — pass it to `forget`,
    /// `revise`, or `show` (e.g. `[f3]` → `forget 3`). `--json` carries the
    /// same id as a plain `"id"` field.
    Recall {
        /// Free-text query for the lexical/vector sources.
        query: Option<String>,
        /// Require this tag; repeatable (a fact must carry all).
        #[arg(long = "tag", value_name = "TAG")]
        tags: Vec<String>,
        /// Entity anchor for the graph source; repeatable.
        #[arg(long = "entity", value_name = "E")]
        entities: Vec<String>,
        /// "What was true then": validity instant (unix millis).
        #[arg(long = "as-of", value_name = "TS")]
        as_of: Option<u64>,
        /// recorded_at window `FROM TO` (unix millis) for the temporal source.
        #[arg(long, num_args = 2, value_names = ["FROM", "TO"])]
        range: Option<Vec<u64>>,
        /// Max facts (0 = engine default 8, ceiling 64).
        #[arg(short = 'k', long, default_value_t = 0)]
        k: usize,
        /// Include closed revisions (whole chains).
        #[arg(long)]
        closed: bool,
        /// Token budget of the rendered block (default 512). The block is the
        /// part that goes into a prompt, so this is the knob that decides how
        /// much of the context window recall is allowed to spend.
        #[arg(long = "token-budget", value_name = "N")]
        token_budget: Option<usize>,
        /// HNSW beam width for the vector source (default: `hnsw_ef_search`
        /// from the config). Higher is more accurate and slower. Ignored while
        /// the engine is still in the flat regime, below `flat_to_hnsw`.
        #[arg(long, value_name = "N")]
        ef: Option<usize>,
        /// How many edges the graph source may follow from an anchor entity
        /// (default: `graph_depth` from the config). `0` asks for the anchors'
        /// own facts and no neighbours. Use it when this
        /// particular question wants a wider or narrower net than the memory's
        /// usual one.
        #[arg(long = "graph-depth", value_name = "N")]
        graph_depth: Option<u32>,
        /// A precomputed query embedding, comma-separated. Given, it
        /// **replaces** the embedder for this query — nothing is sent to the
        /// provider — and its length must equal the configured `dim`.
        #[arg(long, value_name = "F32,…", value_delimiter = ',', num_args = 1..)]
        vector: Vec<f32>,
    },
    /// Supersede a fact: close the old one, record the successor.
    Revise {
        /// The fact id to revise.
        id: u32,
        /// The new fact text.
        text: String,
        #[arg(long)]
        entity: Option<String>,
        #[arg(long = "tag", value_name = "TAG")]
        tags: Vec<String>,
        #[arg(long = "link", value_name = "REL:ENTITY")]
        links: Vec<String>,
        #[arg(long = "meta", value_name = "KEY=VALUE")]
        meta: Vec<String>,
        #[arg(long = "valid-from", value_name = "TS")]
        valid_from: Option<u64>,
        /// A precomputed embedding, comma-separated (`0.1,-0.2,…`). Its length
        /// must equal the configured `dim`. Given, it **replaces** the
        /// embedder: nothing is sent to the provider. This is the route for
        /// vectors you already have, or for a model that is not an
        /// OpenAI-shaped HTTP endpoint. Large ones come from a file:
        /// `--vector "$(cat vec.txt)"`.
        #[arg(long, value_name = "F32,…", value_delimiter = ',', num_args = 1..)]
        vector: Vec<f32>,
    },
    /// Tombstone a fact (physically purged at the next `maintain`).
    Forget {
        /// The fact id to forget — the `N` from a `recall` line `[fN]`, a
        /// `show`, or a `remember`/`revise` confirmation.
        id: u32,
    },
    /// List current tags with counts in stable lexical order.
    Tags {
        /// Exact, case-sensitive prefix to scan from.
        #[arg(long)]
        prefix: Option<String>,
        /// Opaque cursor returned by the previous page.
        #[arg(long)]
        cursor: Option<String>,
        /// Page size (default 64, maximum 256).
        #[arg(long, default_value_t = 0)]
        limit: usize,
    },
    /// Remove a tag from every current fact while preserving fact history.
    RemoveTag {
        /// Tag to remove verbatim.
        tag: String,
    },
    /// Upsert a typed edge between two entities.
    Link {
        /// Source entity.
        src: String,
        /// Relation.
        rel: String,
        /// Destination entity.
        dst: String,
        /// The fact this edge follows from. Recorded on the edge and returned
        /// by graph recall, so a caller can answer "why is this edge here".
        #[arg(long, value_name = "FACT_ID")]
        provenance: Option<u32>,
    },
    /// Close the current typed edge between two entities.
    Unlink {
        /// Source entity.
        src: String,
        /// Relation.
        rel: String,
        /// Destination entity.
        dst: String,
    },
    /// Print one fact's full card (text, both time axes, state).
    Show {
        /// The fact id.
        id: u32,
    },
    /// Print engine size counters and identity.
    Stats,
    /// Run maintenance, or explicitly replace every stored embedding.
    Maintain {
        /// How much work to do. `auto` (the default) does only what is
        /// pending: purge tombstones, refresh a stale text index, and advance
        /// the vector graph within a bounded budget. `full` rebuilds
        /// everything and repacks the edge arenas — offline-grade work, and
        /// the only mode that reclaims edge-history page slack.
        #[arg(long, value_enum, default_value_t = MaintainMode::Auto)]
        mode: MaintainMode,
        /// Recompute every retained fact with the configured embedder and
        /// atomically replace the vector axis. Never implied by `--mode auto`.
        #[arg(long, conflicts_with = "mode")]
        reembed: bool,
        /// Maximum fact texts per provider request during `--reembed`.
        #[arg(long, value_name = "N", requires = "reembed")]
        batch_size: Option<usize>,
    },
    /// Flush the journal into a fresh snapshot now and clear it. Leaves the
    /// database checkpointed, so the read-only path (`scrub`, and any
    /// shared-lock open) can proceed without a dirty-journal `NeedsCheckpoint`.
    Checkpoint,
    /// Check the integrity an open defers: text UTF-8, metadata, vector↔fact
    /// consistency, and that the edge graph agrees with itself. Exit 2 on
    /// damage.
    Verify,
    /// Scrub the snapshot's byte-level container integrity (per-section and
    /// whole-file checksums), a slice at a time. Requires a checkpointed
    /// database (run `maintain` first if the journal is dirty). Exit 2 on
    /// the first damaged section.
    Scrub,
    /// Salvage a content-corrupt database into a fresh file: drop the facts
    /// that fail `verify`, compact the survivors, and write a clean copy to
    /// DST — the source (`--db`) is left untouched. Disk-first (bounded RAM).
    Recover {
        /// Destination file for the recovered image (must differ from `--db`).
        dst: PathBuf,
    },
    /// Dump the current memory as JSONL to stdout: every open fact, then every
    /// open edge, one per line, each tagged with `kind`.
    ///
    /// Streamed on both halves, so a large memory never has to fit in RAM.
    /// Facts carry text, entity, tags, metadata, `recorded_at` and
    /// `valid_from`; edges carry `src`/`rel`/`dst` and the `provenance` fact,
    /// referenced by its id in *this* database so an import can translate it.
    ///
    /// **Closed revisions and vectors are not in the format.** History does
    /// not survive a round trip, and vectors are recomputed on import if an
    /// embedder is configured. This is a portable knowledge dump, not a
    /// backup — to back a database up, copy its files.
    Export,
    /// Load a JSONL file written by `export`, re-remembering each fact and
    /// re-linking each edge.
    ///
    /// Ids and `recorded_at` are not preserved (a fresh database assigns its
    /// own); text, entity, tags, metadata, `valid_from`, edges and their
    /// provenance are. A line without a `kind` is read as a fact, so files
    /// written before edges were in the format still load.
    Import {
        /// The JSONL file to read.
        file: PathBuf,
        /// Facts per batch write: one embedder round-trip and one journal
        /// fsync per batch, instead of per fact. The file is streamed in
        /// batches of this size (bounded memory / bounded HTTP body). Default
        /// 128; also settable via `[maintenance].batch_size`. Flag > config.
        #[arg(long, value_name = "N")]
        batch: Option<usize>,
    },
    /// Interactive session for a person at a terminal: open the database once
    /// and run commands from stdin (one per line, same grammar as the
    /// subcommands), keeping the engine in memory for native (host) speed
    /// instead of reloading per command. NOT for a script or an agent — it
    /// reads until end-of-input, so a caller that cannot type into it waits
    /// forever; run one verb per invocation instead. Type
    /// `help` for the verb list, `exit`/`quit` (or EOF) to leave; the session
    /// checkpoints on exit. `scrub`/`recover` stay one-shot.
    Repl {
        /// Observe another process's database read-only (a shared, zero-copy
        /// mmap) instead of opening read-write. Only the read verbs run
        /// (recall/show/stats/export/verify), plus two cross-process freshness
        /// verbs: `refresh` (advance to the writer's latest checkpoint) and
        /// `generation` (the pinned snapshot number). Those two exist ONLY for
        /// this mode — a normal writer repl and every one-shot command already
        /// see the newest data (read-your-writes, or a fresh open), so they are
        /// neither needed nor offered there. Requires a checkpointed database
        /// and does not write (no checkpoint on exit).
        #[arg(long)]
        read_only: bool,
    },
    /// Manage a directory of named memories. Only useful once `[workspace].dir`
    /// (or `--workspace`) points somewhere; without one there is a single
    /// database and nothing here applies.
    Workspace {
        #[command(subcommand)]
        command: WorkspaceCommand,
    },
}

/// The `workspace` subcommands.
#[derive(Subcommand)]
pub(crate) enum WorkspaceCommand {
    /// List every memory in the workspace, with its description when it has one.
    List,
    /// Find memories by what they are for — or by who owns them.
    Find {
        /// What the memory is for, in your own words. A person's name works too.
        query: String,
        /// Max results (default 8).
        #[arg(long, value_name = "N")]
        k: Option<usize>,
    },
    /// Say what a memory is for. Written into the memory itself and into the
    /// registry, so the registry can always be rebuilt from the memories.
    /// Creates the memory if it does not exist.
    Describe {
        /// The memory's name.
        name: String,
        /// What it is for.
        text: String,
        /// Tags to filter by (repeatable).
        #[arg(long = "tag", value_name = "TAG")]
        tags: Vec<String>,
        /// Who it belongs to.
        #[arg(long)]
        owner: Option<String>,
    },
    /// Label a memory archived. It stays where it is and stays openable.
    Archive {
        /// The memory's name.
        name: String,
    },
    /// Rebuild the registry from the memories' own descriptions.
    Reindex,
    /// Check the registry against the directory. Reports; never repairs.
    Verify,
    /// Print the shell line that points this terminal at a memory:
    /// `eval "$(plugmem-cli workspace use work)"`. It sets $PLUGMEM_DB in the
    /// shell you run it in — deliberately not a file on disk, so one window
    /// cannot silently redirect another.
    Use {
        /// The memory's name.
        name: String,
    },
}

/// The `maintain --mode` values, mirroring [`MaintenanceMode`] one to one.
///
/// A separate enum so the command line owns its own spelling and help text;
/// the engine's variants are not a CLI contract.
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub(crate) enum MaintainMode {
    /// Only pending work: purge tombstones, refresh a stale text index, and
    /// advance the vector graph within a bounded budget. Cheap and safe to
    /// run often; a no-op when nothing is pending.
    Auto,
    /// Physically purge tombstoned facts and compact storage and indexes.
    Compact,
    /// Rebuild the text index by re-reading and re-tokenizing every fact.
    ReindexText,
    /// Build or advance the vector graph without compacting anything else.
    OptimizeVectors,
    /// Rebuild every rebuildable structure, fully optimize vectors, and
    /// repack the edge arenas. O(database) work; no history is ever dropped.
    Full,
}

impl From<MaintainMode> for MaintenanceMode {
    fn from(mode: MaintainMode) -> Self {
        match mode {
            MaintainMode::Auto => Self::Auto,
            MaintainMode::Compact => Self::Compact,
            MaintainMode::ReindexText => Self::ReindexText,
            MaintainMode::OptimizeVectors => Self::OptimizeVectors,
            MaintainMode::Full => Self::Full,
        }
    }
}

/// Detailed help topics that are intentionally separate from ordinary `--help`.
#[derive(Subcommand)]
pub(crate) enum HelpTopic {
    /// Explain config.toml discovery and every supported setting.
    Settings,
}

#[cfg(test)]
mod tests {
    use super::*;
    use clap::Parser;

    #[test]
    fn settings_help_is_an_explicit_topic() {
        let cli = Cli::try_parse_from(["plugmem-cli", "help", "settings"]).unwrap();
        assert!(matches!(
            cli.command,
            Command::Help {
                topic: HelpTopic::Settings
            }
        ));
    }

    #[test]
    fn reembed_is_explicit_and_owns_its_batch_bound() {
        let cli =
            Cli::try_parse_from(["plugmem-cli", "maintain", "--reembed", "--batch-size", "32"])
                .unwrap();
        assert!(matches!(
            cli.command,
            Command::Maintain {
                reembed: true,
                batch_size: Some(32),
                ..
            }
        ));
        assert!(Cli::try_parse_from(["plugmem-cli", "maintain", "--batch-size", "32"]).is_err());
        assert!(
            Cli::try_parse_from(["plugmem-cli", "maintain", "--reembed", "--mode", "full"])
                .is_err()
        );
    }
}