faculties 0.21.0

An office suite for AI agents: kanban, wiki, files, messaging, and a Lissajous-backed viewer — all persisted in a TribleSpace pile.
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
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
//! Tailored memory CLI grammar and host-only input/cache UX.
use super::operations::{parse_tai_timestamp, parse_time_range};
use super::{ChurnOptions, Memory};
use crate::memory_cover::{CoverOpts, CoverReport, DEFAULT_SIM_THRESHOLD};
use crate::out::Out;
use anyhow::{anyhow, bail, Context, Result};
use clap::{CommandFactory, Parser};
use hifitime::Epoch;
use std::path::{Path, PathBuf};

#[derive(Parser)]
#[command(
    version = crate::GIT_VERSION,
    name = "memory",
    about = "Show compacted context chunks (drill down by narrowing the time range).\n\n\
             Subcommands:\n  \
             memory <from>..<to>              — show best summary covering a time range\n  \
             memory meta <from>..<to>         — show structural metadata for a time range\n  \
             memory context [<budget>] [--chars N] [--chunk-overhead N] [--about <query>] [--filter <query>] [--remove <query>] [--sim-threshold <f>] — density-shaped, deliberately lossy recollection over journaled time in greedy SPACE order, fit to a CHARACTER budget (bare <budget>, --chars N, or the --tokens N alias all count CHARACTERS — there is no token estimate). A continuous logarithmic-age gradient maps each candidate length to an ideal temporal slot and chooses the actual range whose endpoints match best; wide old arcs emerge from that shape rather than a special coverage rule, so gaps, overlap, wobbling temporal centres, and unsampled detail are valid while the pile remains lossless. --chunk-overhead charges N additional character-equivalents per selected chunk for consumer framing/tokenization without changing stored summaries or rendered text; --about chooses the recollection most relevant to <query> by MEANING only when multiple memories have exactly the same temporal coverage (semantic, via `memory embed`; otherwise exact lexical BM25 is rebuilt automatically) and never changes the temporal sampler; --filter <query> keeps ONLY chunks whose positive similarity to <query> exceeds --sim-threshold (default 0.55); --remove <query> is the anti-filter — drops chunks whose similarity EXCEEDS the threshold (negate in the retrieval, NOT the query text; do not phrase a negation). They compose. NOTE: gating is chunk-level — a surviving COARSE memory's pre-written summary may still mention removed material. Unembedded wordless images are kept (fail-open) with a stderr warning.\n  \
             memory cover start [--chars N] [--chunk-chars M] [--session KEY] — generate the context cover (exactly `memory context --chars N`; N=400000) and store it for cursor-chunked reading in ~M-char chunks (M=20000); state lives in `${XDG_CACHE_HOME:-~/.cache}/faculties/cover/<KEY>/`, NOT the pile\n  \
             memory cover continue [--session KEY] — print the next stored chunk and advance the cursor; the final chunk ends with `COVER COMPLETE K/K`\n  \
             memory cover status [--session KEY]  — one line: complete=<true|false> loaded=<i>/<K> chars=<X>/<Y>; exit 0 when complete, 1 when not (hook-friendly)\n  \
             memory cover reset [--session KEY]   — rewind the cursor to 0 (does NOT regenerate the stored cover)\n  \
             memory density [<grain>]        — inspect where the stored hierarchy is bushy, balanced, or coarse (a journal diagnostic; it does not drive recollection)\n  \
             memory search <query>           — exact lexical (BM25) search rebuilt from the frozen Memory view\n  \
             memory similar <query>           — semantic search: nearest chunks by MEANING in the shared nomic space (build/refresh with `memory embed`) [needs --features local-embed]\n  \
             memory lens [<theme>]            — thematic lenses beside the spine: list them, or print a theme's narratives (create with `create --lens <theme>`)\n  \
             memory list [<grain>]            — show chunk time-ranges only: containment outline, or one zoom layer (no content)\n  \
             memory check <grain>             — report coverage gaps at a coarseness level (chunks of width <= grain)\n  \
             memory churn [--chars N] [--steps K] [--step-units U] — replay density-shaped recollection over the pile's own observation history (K diagnostic steps back, default 160): per step the fill, previous rendered prefix surviving, and any unselected stretch longer than one quarter of LIFE\n  \
             memory create [<range>] <summary> — create a memory chunk\n  \
             memory respan <id> <from>..<to>  — the same memory over corrected time coordinates: a new chunk with the identical text supersedes the old one, which stands aside from the cover and stays readable by id\n  \
             memory respan-instants [--dry-run] — give every zero-length memory the span its own text names, or a moment ending at its stamp; turn inverted ranges forward; one commit\n  \
             memory respan-seams [--dry-run]    — close one-second and one-minute seams between arcs written with rounded edges (an hour or wider): a coordinate correction, one commit\n  \
             memory image <when> <image-path> — create a WORDLESS image memory at a time-coordinate (embed with `memory embed`; ranks in `memory similar` beside text) [needs --features local-embed to embed]\n  \
             memory consolidate start <ts> | <ts> <summary> | stop — write chunks from an advancing edge ($PERSONA cursor)\n  \
             memory replay start <grain> [<from>] | [<count>] | stop — stream the memory at a zoom level ($PERSONA cursor)\n  \
             memory provenance <chunk-id>     — list cognition + archive events overlapping the chunk's time range\n\n\
             Time format: YYYY-MM-DDTHH:MM:SS..YYYY-MM-DDTHH:MM:SS (TAI)\n\
             Hex id prefixes also accepted as fallback."
)]
pub struct Cli {
    /// Path to the pile file to use.
    #[arg(long, env = "PILE")]
    pub pile: PathBuf,
    /// Existing durable signing-key file. Reads and writes never create it.
    #[arg(long, env = "TRIBLESPACE_KEY")]
    pub key: Option<PathBuf>,
    /// One or more time ranges / id prefixes to show, or `turn <turn-id>`, or `create [<from>..<to>] <summary>`.
    #[arg(value_name = "ID", trailing_var_arg = true, allow_hyphen_values = true)]
    pub ids: Vec<String>,
}

pub fn run() -> Result<()> {
    let cli = Cli::parse();
    let exit_code = crate::cli::with_output("memory", |out| execute(cli, out))?;
    if exit_code != 0 {
        std::process::exit(exit_code);
    }
    Ok(())
}

/// A nonzero successful value is the hook-friendly incomplete-cover status.
/// Output routing is finalized before run() applies that process exit code.
pub fn execute(cli: Cli, out: &mut Out<'_>) -> Result<i32> {
    if cli.ids.is_empty() {
        return out
            .line(Cli::command().render_long_help().to_string())
            .map(|()| 0);
    }
    let memory = Memory::new(cli.pile, cli.key);
    let args = &cli.ids[1..];
    let one = |usage: &str| -> Result<&str> {
        if args.len() != 1 {
            bail!("{usage}");
        }
        Ok(&args[0])
    };
    let dry_run = |usage: &str| -> Result<bool> {
        if args.iter().any(|arg| arg != "--dry-run") {
            bail!("{usage}");
        }
        Ok(!args.is_empty())
    };
    match cli.ids[0].as_str() {
        "create" => cmd_create(&memory, args, out),
        "respan" => cmd_respan(&memory, args, out),
        "respan-instants" => {
            memory.respan_instants(dry_run("usage: memory respan-instants [--dry-run]")?, out)
        }
        "respan-seams" => {
            memory.respan_seams(dry_run("usage: memory respan-seams [--dry-run]")?, out)
        }
        "image" => cmd_image(&memory, args, out),
        "meta" => memory.meta(one("usage: memory meta <id or range>")?, out),
        "provenance" => memory.provenance(one("usage: memory provenance <id>")?, out),
        "turn" => memory.turn(one("usage: memory turn <turn-id>")?, out),
        "consolidate" => cmd_consolidate(&memory, args, out),
        "replay" => cmd_replay(&memory, args, out),
        "search" => memory.search(&literal_or_input(args, "query")?, out),
        "similar" => memory.similar(&literal_or_input(args, "query")?, out),
        "embed" => memory.embed(out),
        "context" => out.text(report_text(memory.context(&context_options(args)?)?)),
        "cover" => return super::cli_cover::execute(&memory, args, out),
        "lens" => memory.lens(args.first().map(String::as_str), out),
        "list" => memory.list(args.first().map(String::as_str), out),
        "check" => memory.check(one("usage: memory check <grain>")?, out),
        "density" => memory.density(args.first().map(String::as_str), out),
        "churn" => memory.churn(&churn_options(args)?, out),
        _ => memory.show_many(&cli.ids, out),
    }?;
    Ok(0)
}

pub(super) fn report_text(report: CoverReport) -> String {
    for diagnostic in report.diagnostics {
        eprintln!("{diagnostic}");
    }
    report.text
}

fn literal_or_input(args: &[String], label: &str) -> Result<String> {
    match args {
        [single] => crate::text_arg(single, label),
        [] => bail!("{label} is required"),
        _ => Ok(args.join(" ")),
    }
}

fn cmd_create(memory: &Memory, args: &[String], out: &mut Out<'_>) -> Result<()> {
    // A help flag must never be minted as memory content — print usage and stop
    // before any parsing, so `memory create --help` explains itself instead of
    // storing the literal "--help" as a chunk.
    if args.iter().any(|a| a == "--help" || a == "-h") {
        out.line(format!(
            "usage: memory create [--lens <theme>] [<from>..<to>] <summary...>\n\
             \n\
             Create a memory chunk and store it in the pile.\n\
             An optional time range as the first argument grounds the memory in\n\
             that period; without it, defaults to now. --lens <theme> files the\n\
             chunk as a thematic memory kept out of the chronological spine."
        ))?;
        return Ok(());
    }
    if args.is_empty() {
        bail!(
            "usage: memory create [<from>..<to>] <summary...>\n\
             \n\
             Create a memory chunk and store it in the pile.\n\
             An optional time range as the first argument grounds the\n\
             memory in that period. Without it, defaults to now.\n\
             References in prose: (memory:<from>..<to>) is a soft temporal\n\
             address, resolved by range query at read time, no fact minted.\n\
             [why it matters](memory:<hex>) is a hard reference to an exact\n\
             chunk, extracted into a queryable ctx::reference fact. Neither\n\
             affects the span or the hierarchy: containment relates chunks,\n\
             and the explicit range argument always wins."
        );
    }

    // Pull an optional `--lens <theme>` out first: a lens chunk is a thematic
    // memory (e.g. "us", "becoming-self"), kept OUT of the chronological spine.
    let mut lens: Option<String> = None;
    let mut filtered: Vec<String> = Vec::new();
    {
        let mut i = 0;
        while i < args.len() {
            if args[i] == "--lens" && i + 1 < args.len() {
                lens = Some(args[i + 1].clone());
                i += 2;
            } else {
                filtered.push(args[i].clone());
                i += 1;
            }
        }
    }
    let args = &filtered[..];
    if args.is_empty() {
        bail!(
            "summary text is required: memory create [--lens <theme>] [<from>..<to>] <summary...>"
        );
    }

    // If the first argument looks like a time range, parse it.
    let mut explicit_range: Option<(Epoch, Epoch)> = None;
    let summary_start_idx;
    // A first argument shaped like a range IS the range: if it does not parse,
    // that is an error to fix, never a summary to store. (The old fallthrough
    // stored `2026-09-05T13:20:00Z..2026-09-05T13:33:00Z` as prose, three
    // times in one day.)
    let looks_like_range = |t: &str| {
        t.contains("..") && t.len() >= 10 && t.as_bytes()[4] == b'-' && t.as_bytes()[7] == b'-'
    };
    if looks_like_range(&args[0]) {
        explicit_range = Some(parse_time_range(&args[0]).with_context(|| {
            format!(
                "the first argument looks like a time range but does not parse: {}",
                args[0]
            )
        })?);
        summary_start_idx = 1;
    } else {
        summary_start_idx = 0;
    }

    // A single summary token may reference a file or stdin via the shared `@`
    // convention (`@-`, `@path`, `@@literal`); multiple tokens are a literal
    // space-joined summary, so every existing inline usage is unchanged. This
    // closes the footgun where `@- <<HEREDOC` silently stored the string "@-".
    let summary_tokens = &args[summary_start_idx..];
    let summary_text: String = if summary_tokens.len() == 1 {
        crate::text_arg(&summary_tokens[0], "summary")?
    } else {
        summary_tokens.join(" ")
    };
    if summary_text.is_empty() {
        bail!("summary text is required: memory create [<from>..<to>] <summary...>");
    }

    memory
        .create(&summary_text, explicit_range, lens.as_deref())?
        .emit(out)
}

fn cmd_respan(memory: &Memory, args: &[String], out: &mut Out<'_>) -> Result<()> {
    if args.len() != 2 || args.iter().any(|a| a == "--help" || a == "-h") {
        bail!(
            "usage: memory respan <id> <from>..<to>\n\
             \n\
             Write the same memory over corrected time coordinates: a new\n\
             chunk with the identical text supersedes <id>, the cover shows\n\
             the new range, and the old chunk stands aside but stays in the\n\
             journal, readable by id. Only the coordinates move; to say\n\
             something different, write another memory."
        );
    }
    memory
        .respan(&args[0], parse_time_range(&args[1])?)?
        .emit(out)
}

fn cmd_image(memory: &Memory, args: &[String], out: &mut Out<'_>) -> Result<()> {
    if args.len() != 2 {
        bail!(
            "usage: memory image <when> <image-path>\n\
             \n\
             Create a WORDLESS image memory chunk and store it in the pile.\n\
             <when> is a single TAI timestamp (YYYY-MM-DDTHH:MM:SS — a point\n\
             where start==end) or a `from..to` range. The image bytes are stored\n\
             as a blob; embed it into the shared nomic space with `memory embed`\n\
             (nomic-VISION-768), and it ranks in `memory similar` by meaning\n\
             beside text memories. Reference it from prose with [caption](memory:<hex>)."
        );
    }
    let when = &args[0];
    let image_path = Path::new(&args[1]);
    let range = if when.contains("..") {
        parse_time_range(when)?
    } else {
        let p = parse_tai_timestamp(when)?;
        (p, p)
    };
    let bytes = std::fs::read(image_path)
        .with_context(|| format!("read image {}", image_path.display()))?;
    if bytes.is_empty() {
        bail!("image file is empty: {}", image_path.display());
    }

    memory.image(&bytes, range)?.emit(out)?;
    out.line(format!(
        "({} image bytes stored; run `memory embed` to place it in the shared nomic space)",
        bytes.len()
    ))?;
    Ok(())
}

fn context_options(args: &[String]) -> Result<CoverOpts> {
    // Parse `[<budget>] [--chars N] [--about <query words...>]`. The budget is a
    // CHARACTER count: a bare number, `--chars N`, or (as an alias) `--tokens N`
    // all set it directly — there is no separate token estimate anymore (the old
    // estimate ran ~2× off the real token count, which was confusing; characters
    // are exact). `--about` chooses among recollections with the exact same
    // temporal coverage; it never changes the temporal sampler.
    let mut budget_chars: usize = 200_000;
    let mut chunk_overhead: usize = 0;
    let mut about: Option<String> = None;
    // `--filter <query>` (include-only) and `--remove <query>` (anti-filter) gate
    // ELIGIBILITY by positive similarity to their query; `--sim-threshold <f>` is
    // the shared cosine cutoff (default `DEFAULT_SIM_THRESHOLD`).
    let mut filter_q: Option<String> = None;
    let mut remove_q: Option<String> = None;
    let mut sim_threshold: f32 = DEFAULT_SIM_THRESHOLD;
    // `--chars N` (canonical) / `--tokens N` (alias) name the budget explicitly;
    // when present either wins over the bare positional (a backward-compatible
    // fallback — it also means characters).
    let mut chars_explicit = false;
    {
        // A value flag consumes the following args up to the NEXT recognized flag
        // (or end), so multi-word queries stay unquoted AND `--about`/`--filter`/
        // `--remove` compose in any order.
        let is_flag = |s: &str| {
            matches!(
                s,
                "--about"
                    | "--filter"
                    | "--remove"
                    | "--tokens"
                    | "--chars"
                    | "--chunk-overhead"
                    | "--sim-threshold"
            )
        };
        let mut i = 0;
        while i < args.len() {
            if matches!(args[i].as_str(), "--about" | "--filter" | "--remove") {
                let mut j = i + 1;
                while j < args.len() && !is_flag(&args[j]) {
                    j += 1;
                }
                let q = args[i + 1..j].join(" ");
                let q = (!q.trim().is_empty()).then_some(q);
                match args[i].as_str() {
                    "--about" => about = q,
                    "--filter" => filter_q = q,
                    _ => remove_q = q,
                }
                i = j;
                continue;
            }
            if args[i] == "--sim-threshold" {
                let raw = args.get(i + 1).ok_or_else(|| {
                    anyhow!("--sim-threshold needs a number in [0,1], e.g. `--sim-threshold 0.55`")
                })?;
                sim_threshold = raw
                    .parse()
                    .map_err(|_| anyhow!("--sim-threshold expects a float, got `{raw}`"))?;
                i += 2;
                continue;
            }
            if args[i] == "--chunk-overhead" {
                let raw = args.get(i + 1).ok_or_else(|| {
                    anyhow!(
                        "--chunk-overhead needs a non-negative integer, e.g. `--chunk-overhead 64`"
                    )
                })?;
                chunk_overhead = raw.parse().map_err(|_| {
                    anyhow!("--chunk-overhead expects a non-negative integer, got `{raw}`")
                })?;
                i += 2;
                continue;
            }
            // `--tokens N` is a backward-compatible ALIAS for `--chars N` (the
            // budget is characters now; there is no separate token path).
            if args[i] == "--tokens" || args[i] == "--chars" {
                let flag = args[i].as_str();
                let raw = args.get(i + 1).ok_or_else(|| {
                    anyhow!("{flag} needs a number, e.g. `memory context --chars 80000`")
                })?;
                budget_chars = raw
                    .parse()
                    .map_err(|_| anyhow!("{flag} expects a positive integer, got `{raw}`"))?;
                chars_explicit = true;
                i += 2;
                continue;
            }
            if !chars_explicit {
                if let Ok(n) = args[i].parse::<usize>() {
                    budget_chars = n;
                }
            }
            i += 1;
        }
    }

    Ok(CoverOpts {
        budget_chars,
        chunk_overhead,
        about,
        filter: filter_q,
        remove: remove_q,
        sim_threshold,
    })
}

fn churn_options(args: &[String]) -> Result<ChurnOptions> {
    let mut budget: usize = 800_000;
    let mut steps: usize = 160;
    let mut step_units: i128 = 1;
    let mut i = 0;
    while i < args.len() {
        let flag = args[i].as_str();
        let value = |i: usize| -> Result<&String> {
            args.get(i + 1)
                .ok_or_else(|| anyhow!("{} needs a value", args[i]))
        };
        match flag {
            "--chars" => budget = value(i)?.parse().context("--chars expects a number")?,
            "--steps" => steps = value(i)?.parse().context("--steps expects a number")?,
            "--step-units" => {
                step_units = value(i)?.parse().context("--step-units expects a number")?
            }
            other => bail!(
                "unknown flag {other}; usage: memory churn [--chars N] [--steps K] [--step-units U]"
            ),
        }
        i += 2;
    }

    Ok(ChurnOptions {
        budget_chars: budget,
        steps,
        step_units,
    })
}

fn cmd_consolidate(memory: &Memory, args: &[String], out: &mut Out<'_>) -> Result<()> {
    let persona = comb_persona()?;
    match args.first().map(String::as_str) {
        Some("start") => {
            let raw = args
                .get(1)
                .context("usage: memory consolidate start <timestamp>")?;
            memory.consolidate_start(&persona, parse_tai_timestamp(raw)?)?;
            out.line(format!(
                "consolidation edge set to {raw} (persona {persona})"
            ))
        }
        Some("stop") => {
            memory.consolidate_stop(&persona)?;
            out.line(format!("consolidation stopped (persona {persona})"))
        }
        Some(until) => {
            memory
                .consolidate(
                    &persona,
                    parse_tai_timestamp(until)?,
                    &literal_or_input(&args[1..], "summary")?,
                )?
                .emit(out)?;
            out.line(format!("edge → {until}"))
        }
        None => bail!("usage: memory consolidate start <timestamp> | <timestamp> <summary> | stop"),
    }
}

fn cmd_replay(memory: &Memory, args: &[String], out: &mut Out<'_>) -> Result<()> {
    let persona = comb_persona()?;
    match args.first().map(String::as_str) {
        Some("start") => {
            let grain = args
                .get(1)
                .context("usage: memory replay start <grain> [<from>]")?;
            let from = args
                .get(2)
                .map(|raw| parse_tai_timestamp(raw))
                .transpose()?;
            memory.replay_start(&persona, grain, from)?;
            out.line(format!(
                "memory replay started at grain {grain} (persona {persona})"
            ))
        }
        Some("stop") => {
            memory.replay_stop(&persona)?;
            out.line(format!("memory replay stopped (persona {persona})"))
        }
        other => memory.replay(
            &persona,
            other
                .map(str::parse)
                .transpose()
                .context("expected a batch count")?
                .unwrap_or(5),
            out,
        ),
    }
}

fn comb_persona() -> Result<String> {
    std::env::var("PERSONA").map_err(|_| {
        anyhow!(
            "no persona: set $PERSONA.\n\
             Cursors are session bookkeeping — no agent is defaulted as \
             \"the\" rememberer; the memories themselves belong to the one \
             enduring memory stream and are never persona-scoped."
        )
    })
}