From 12b90e20d806f822ed8f1e93c6375e80ff2e2164 Mon Sep 17 00:00:00 2001 From: ericwalisko Date: Fri, 4 Sep 2026 00:05:18 -0400 Subject: [PATCH 1/3] DX + accessibility: --plain, --ascii, completions, rich help, stdin assert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quality-of-life for editor/terminal users and a first-class accessibility pass. DX: - `--plain`: tab-separated, id-first, one record per line (recall/beliefs/read) — pipe to fzf/awk/cut. - `memware completions {bash,zsh,fish}` (new `[shell]` extra, via shtab). - Rich --help: per-command examples, an ENVIRONMENT/FILES/ACCESSIBILITY epilog, defaults shown; duplicate global flags hidden from subcommand help. - `memware assert -`: batch tab-separated subject/relation/value[/source] from stdin (# and blank lines skipped); pairs with `beliefs --plain | cut` for an $EDITOR round-trip. - XDG-aware home for fresh installs (existing ~/.memware never migrated); the store path follows it. Accessibility: - No colour is ever emitted (NO_COLOR honoured by construction); nothing relies on colour. - Default human output is labeled `field : value`, one per line, empty fields skipped — linear for screen readers. - `--ascii` / MEMWARE_ASCII, plus automatic fallback on a non-UTF-8 locale, swaps the … elision glyph for ASCII. - New memware.term module; index snippet/join use the ascii-aware marker. Docs: docs/editor-integration.md (emacs/vim/neovim/fzf recipes, no plugins), docs/accessibility.md, docs/memware.1 man page, README sections. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NnaQwZUSDRbVUNh8fsdksD --- README.md | 22 +++ docs/accessibility.md | 57 +++++++ docs/editor-integration.md | 158 +++++++++++++++++++ docs/memware.1 | 286 +++++++++++++++++++++++++++++++++++ pyproject.toml | 3 +- src/memware/cli.py | 301 ++++++++++++++++++++++++++++++++++--- src/memware/config.py | 21 ++- src/memware/index.py | 6 +- src/memware/store.py | 13 +- src/memware/term.py | 35 +++++ tests/test_cli.py | 62 ++++++++ tests/test_term.py | 20 +++ 12 files changed, 958 insertions(+), 26 deletions(-) create mode 100644 docs/accessibility.md create mode 100644 docs/editor-integration.md create mode 100644 docs/memware.1 create mode 100644 src/memware/term.py create mode 100644 tests/test_term.py diff --git a/README.md b/README.md index 4e1803b..9b0cb2d 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,28 @@ surface, and does the stale value stay hidden? It needs no model, so results are reproducible. The protocol for end-to-end comparisons — agent alone vs agent + memware — is in [docs/eval.md](docs/eval.md). +## Editor and shell integration + +memware ships no editor plugins — `--plain` (tab-separated, id-first) and `--json` are the +integration surface, and everything is a copy-paste recipe on top of them. Shell completions +come from `memware completions zsh|bash|fish` (needs the `[shell]` extra: +`uv tool install "memware[mcp,shell]"`). `--plain` pipes cleanly to `fzf`/`awk`/`cut`: + +```bash +memware recall "which port does the api use" --plain | fzf --delimiter='\t' --with-nth=10 +``` + +Emacs, Vim, Neovim, an `$EDITOR` bulk-edit round-trip, and completion install steps are in +[docs/editor-integration.md](docs/editor-integration.md). + +## Accessibility + +memware emits no colour at all (so `NO_COLOR` is honoured by construction), and no information +is ever carried by colour. Default output is screen-reader-friendly — labeled, one field per +line, blank line between records; `--plain` and `--json` are the stable machine formats; and +`--ascii` (auto-on in a non-UTF-8 locale) avoids glyphs a screen reader or terminal might +mangle. Full statement: [docs/accessibility.md](docs/accessibility.md). + ## Status Alpha. The schema may change before 1.0; the ledger semantics will not. diff --git a/docs/accessibility.md b/docs/accessibility.md new file mode 100644 index 0000000..5a637de --- /dev/null +++ b/docs/accessibility.md @@ -0,0 +1,57 @@ +# Accessibility + +memware is a command-line tool, so it is accessible by nature: keyboard-only, no mouse, no +GUI, and fully scriptable. Beyond that baseline, these are commitments about how it behaves. +They are CLI accessibility practices — not WCAG conformance, which is a standard for web UIs. + +## No colour, ever + +memware emits **no colour escape sequences at all**, under any flag, terminal, or theme. No +information is ever carried by colour, so output is legible under any palette, to colour-blind +users, and in monochrome terminals. `NO_COLOR` is honoured **by construction**: there is +nothing to disable, and nothing that could regress it. + +## Screen-reader-friendly default output + +The default human output for the record listers (`recall`, `beliefs`, `read`) is **labeled**: +one `field : value` per line, a blank line between records, and empty fields skipped. Nothing +is aligned into columns by eye. A screen reader reads it linearly and unambiguously — there is +no spatial layout to get lost in. + +Two machine formats are available when you want structure instead of prose: + +- `--plain` — tab-separated, one record per line, id-first, in a fixed column order; tabs and + newlines inside values collapse to spaces. Stable for piping to `cut`, `awk`, `fzf`, or a + braille display's filter. +- `--json` — machine-readable JSON for programmatic consumers. + +## ASCII fallback for glyphs + +The one non-ASCII glyph in normal output is the elision mark `…`. `--ascii` (or +`MEMWARE_ASCII=1`) replaces it with `...`, so it is not mispronounced by a screen reader or +mojibaked by a terminal that cannot render it. This also switches on **automatically** when the +locale is not UTF-8, so a stripped-down or remote environment gets ASCII without any flag. + +## stdout / stderr separation + +Data goes to **stdout**; diagnostics and progress go to **stderr**. Assistive tooling and +scripts can consume results without narration or warnings mixed in, and a screen reader driving +a pipeline hears only the data. + +## Documented, meaningful exit codes + +Exit status is `0` on success and `2` on a usage error (for example, `assert` missing its VALUE +without `-`, or `completions` when `shtab` is not installed). Automation and assistive wrappers +can branch on the status without parsing text. + +## Plain-text integration surfaces + +The MCP server's tool outputs and the prompt-time hook's injected context are plain text — the +same accessible, colourless, linear content, not a rendered widget. + +## Feedback + +If any output is hard to read with a screen reader, a braille display, or a non-UTF-8 terminal, +that is a bug. Please open an issue at +. Accessibility regressions are treated as bugs, not +enhancements. diff --git a/docs/editor-integration.md b/docs/editor-integration.md new file mode 100644 index 0000000..01c4644 --- /dev/null +++ b/docs/editor-integration.md @@ -0,0 +1,158 @@ +# Editor and terminal integration + +memware ships **no editor plugins**. It ships two machine-readable output modes — +`--plain` (tab-separated, one record per line, id-first) and `--json` — and those are the +integration surface. Everything below is a copy-paste recipe built on the CLI, so nothing +here goes stale against an editor's plugin API. See [Why no plugin](#why-no-plugin) at the end. + +The `--plain` column order for `recall` is fixed: + +```text +id kind score session when role subject relation source text +``` + +so `cut -f1` is the id, `cut -f4` is the session, and `cut -f10` is the text. The `read` +command takes a **session** id, so `cut -f4 | xargs memware read` reads a hit back in context. + +## Shell completions + +`memware completions {bash,zsh,fish}` prints a completion script (generated with `shtab`). +It needs the `[shell]` extra: + +```bash +uv tool install "memware[mcp,shell]" # or: pipx install "memware[shell]" / pip install "memware[shell]" +``` + +Without `shtab` the command exits 2. Install recipes: + +```bash +# zsh +memware completions zsh > ~/.zfunc/_memware +# then in ~/.zshrc: fpath+=~/.zfunc and autoload -Uz compinit && compinit + +# bash +memware completions bash | sudo tee /etc/bash_completion.d/memware +# or a user dir: memware completions bash > ~/.local/share/bash-completion/completions/memware + +# fish +memware completions fish > ~/.config/fish/completions/memware.fish +``` + +## fzf + +Pick a hit interactively and read its session back: + +```bash +memware recall "which port does the api use" --plain \ + | fzf --delimiter='\t' --with-nth=10 \ + | cut -f4 | xargs -r memware read +``` + +`--with-nth=10` shows only the text column in the picker; the full tab record is still on the +selected line, so `cut -f4` recovers the session for `read`. + +A reusable function that echoes the chosen id and text (drop it in `~/.zshrc` or +`~/.bashrc`), passing any recall args through: + +```bash +mw-recall() { + local hit + hit=$(memware recall "$@" --plain | fzf --delimiter='\t' --with-nth=10) || return + printf 'id\t%s\n' "$(printf '%s' "$hit" | cut -f1)" + printf 'text\t%s\n' "$(printf '%s' "$hit" | cut -f10)" +} +# mw-recall "api port" "gateway listen port" +``` + +## Emacs + +A small command that reads a query, offers the hits with `completing-read`, and inserts the +chosen record's text at point: + +```elisp +(defun memware-recall (queries) + "Recall from memware and insert the chosen record's text at point." + (interactive "sQueries: ") + (let* ((lines (split-string + (shell-command-to-string + (format "memware recall %s --plain" + (shell-quote-argument queries))) + "\n" t)) + (choice (completing-read "hit: " lines))) + ;; text is the 10th tab-separated field (0-based index 9) + (insert (nth 9 (split-string choice "\t"))))) +``` + +For a grep-style buffer, point `grep-command` at recall's plain output and use `M-x grep`; +you get the hits in a `compilation-mode` buffer you can navigate as text (the records are +id-first tab lines, not `file:line`, so there is nothing to jump to): + +```elisp +(setq grep-command "memware recall --plain ") +``` + +Consult users can wrap the same `memware recall … --plain` lines as a `consult--read` source +for live minibuffer recall. + +## Vim + +A user command that opens plain recall output in a scratch buffer: + +```vim +command! -nargs=+ MemwareRecall call s:MemwareRecall() +function! s:MemwareRecall(args) abort + let l:out = system('memware recall ' . shellescape(a:args) . ' --plain') + new + setlocal buftype=nofile bufhidden=wipe noswapfile + call setline(1, split(l:out, "\n")) +endfunction +" :MemwareRecall api port +``` + +With [fzf.vim](https://github.com/junegunn/fzf.vim), pick a hit in a fuzzy window: + +```vim +command! -nargs=+ MemwareFzf call fzf#run(fzf#wrap({ + \ 'source': 'memware recall ' . shellescape() . ' --plain', + \ 'options': ['--delimiter', "\t", '--with-nth', '10']})) +``` + +## Neovim + +The Lua equivalent, writing plain recall output into a scratch buffer: + +```lua +vim.api.nvim_create_user_command('MemwareRecall', function(opts) + local out = vim.fn.system({ 'memware', 'recall', opts.args, '--plain' }) + vim.cmd('new') + vim.bo.buftype = 'nofile' + vim.bo.bufhidden = 'wipe' + vim.bo.swapfile = false + vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.split(out, '\n')) +end, { nargs = '+' }) +-- :MemwareRecall api port +``` + +Telescope users can wrap `memware recall … --plain` as a custom finder over the same lines. + +## Bulk-edit facts through `$EDITOR` + +Dump the beliefs, edit them in your editor, and feed them back. `beliefs --plain` is id-first, +while `assert -` reads `subjectrelationvalue[source]`, so drop the id and the +audit columns with `cut` first — fields 2,3,4,9 are exactly subject, relation, value, source: + +```bash +memware beliefs --plain | cut -f2,3,4,9 > /tmp/f # -> subject relation value source +$EDITOR /tmp/f +memware assert - < /tmp/f +``` + +`assert -` skips blank lines and lines starting with `#`. This **asserts** every edited or +added row (each goes through the normal supersession rule); it does **not** delete — removing a +line from the file does not retract a belief. + +## Why no plugin + +The CLI is the stable contract. `--plain` and `--json` are the whole integration surface, so a +twenty-line recipe against them keeps working across editor releases in a way a plugin tracking +an editor's evolving API would not. There is nothing to update when your editor updates. diff --git a/docs/memware.1 b/docs/memware.1 new file mode 100644 index 0000000..aeb850d --- /dev/null +++ b/docs/memware.1 @@ -0,0 +1,286 @@ +.TH MEMWARE 1 "2026-09-03" "memware 0.3.0" "User Commands" +.SH NAME +memware \- local, model\-free memory for AI agents: transcript recall and a bi\-temporal belief ledger +.SH SYNOPSIS +.B memware +.RB [ \-\-json | \-\-plain ] +.RB [ \-\-ascii ] +.RB [ \-\-db +.IR FILE ] +.I command +.RI [ args ] +.PP +Global options may appear before or after the +.IR command . +.SH DESCRIPTION +memware is a single SQLite store with two tables. The append\-only +.B turns +index holds past prompts and answers, split into passages and searched with FTS5/BM25. The +bi\-temporal +.B belief +ledger records facts in which a new value for the same +.RI ( subject ", " relation ) +.B supersedes +the old one; recall only ever returns the currently valid belief, and history is kept for +audit. There is no daemon, no vector database, and no model call at capture or read time. +.PP +Recall is keyword search; the agent supplies the meaning by passing several phrasings, which +are fused by reciprocal rank. +.PP +Data is written to standard output; diagnostics go to standard error. memware emits no colour +under any circumstances. +.SH COMMANDS +.TP +.B init +Create the store. +.TP +.BI "sync " [PATH] +Catch up the configured transcript source (default +.IR ~/.claude/projects ) +into the index, or a given +.IR PATH . +.TP +.B backfill +One\-time index of the transcripts already on disk under +.IR ~/.claude/projects . +Idempotent. +.TP +.BI "recall " QUERY " ..." +Search turns and beliefs. Pass several phrasings, fused by reciprocal rank. Flags: +.BR \-k " (count)," +.BI "\-\-what " {all,turns,beliefs} , +.BR \-\-full ", " +.BR \-\-snippet\-tokens . +.TP +.B context +Emit the currently valid beliefs whose subject the prompt names, for prompt\-time injection. +Hook\-friendly. +.TP +.BI "assert " "SUBJECT RELATION VALUE" +Assert a fact; a new value supersedes the prior value for the same key. Flags: +.BR \-\-source ", " +.BR \-\-valid\-from ", " +.BR \-\-reliability ", " +.BR \-\-policy . +Batch mode +.B assert \- +reads +.IR subject relation value [ source ] +lines from standard input, skipping blank lines and lines beginning with +.RB \` # \(cq. +.TP +.BI "beliefs " "[SUBJECT [RELATION]]" +Show current beliefs, or the full history of one key. +.TP +.BI "read " SESSION +Read a session's turns back in context. Flags: +.BR \-\-around ", " +.BR \-\-window . +.TP +.B "review list|approve|reject|sync" +Manage contested supersessions through the review channel. +.TP +.B prune +Un\-index matching turns. Flags: +.BR \-\-glob ", " +.BR \-\-containing ", " +.BR \-\-turns\-containing . +.TP +.B stats +Print store statistics. +.TP +.B backup +Write a tiered snapshot (1/3/7/14\-day) plus a transcript mirror. Flags: +.BR \-\-dest ", " +.BR \-\-keep ", " +.BR \-\-if\-stale ", " +.BR \-\-quiet . +.TP +.B restore +Restore the store from a snapshot. Flags: +.BR \-\-from ", " +.BR \-\-latest . +.TP +.B setup +Guided one\-time setup: index existing sessions, pick a backup destination, take a first backup. +.TP +.BI "config " "KEY [VALUE]" +Get or set a configuration value. +.TP +.B nuke +Permanently delete all memware data. +.B \-\-confirm +requires typing the exact phrase +.RB \` "DELETE ALL MEMWARE DATA" \(cq. +.TP +.B "completions {bash,zsh,fish}" +Print a shell completion script (generated with +.IR shtab , +which requires the +.B [shell] +extra). Exits 2 if +.I shtab +is not installed. +.SH OPTIONS +Global flags, valid before or after the command: +.TP +.B \-\-json +Machine\-readable JSON output. +.TP +.B \-\-plain +Tab\-separated records, one per line, id\-first, in a fixed column order; tabs and newlines in +values collapse to spaces. For piping to +.BR fzf ", " awk ", " cut . +.TP +.B \-\-ascii +ASCII only \(em replaces the Unicode horizontal ellipsis elision glyph (U+2026) with three +ASCII dots +.RB (\` ... \(cq). +Also switches on automatically when the locale is not UTF\-8. +.TP +.BI "\-\-db " FILE +Store path (environment +.BR MEMWARE_DB ). +.TP +.B \-\-version +Print the version and exit. +.PP +The default human output (no +.BR \-\-json / \-\-plain ) +for the record listers +.RB ( recall ", " beliefs ", " read ) +is labeled: one +.RI \` "field : value" \(cq +per line, a blank line between records, empty fields skipped. +.SH ENVIRONMENT +.TP +.B MEMWARE_DB +Store path (same as +.BR \-\-db ). +.TP +.B MEMWARE_HOME +Config and store directory. XDG\-aware: uses +.B MEMWARE_HOME +if set, else +.I ~/.memware +if it already exists, else +.IR $XDG_DATA_HOME/memware , +else +.IR ~/.memware . +.TP +.B MEMWARE_ASCII +Set to +.B 1 +for ASCII output (same as +.BR \-\-ascii ). +.TP +.B MEMWARE_NO_CAPTURE +Set to +.B 1 +to never index the current session. +.TP +.B MEMWARE_IGNORE_MARKERS +Path to a file of content signatures; a transcript whose head contains one is never indexed. +.TP +.B NO_COLOR +Honoured by construction \(em memware emits no colour at all. +.SH FILES +.TP +.I /config.json +Configuration. +.TP +.I /ignore-markers.txt +Content signatures for transcripts that must never be indexed. +.PP +.I +is resolved as described for +.B MEMWARE_HOME +above. +.SH ACCESSIBILITY +memware never conveys information by colour and emits no colour escapes. Default output is +labeled, one field per line, for screen readers; +.B \-\-plain +and +.B \-\-json +are stable machine formats. Data goes to stdout and diagnostics to stderr. +.B \-\-ascii +(or a non\-UTF\-8 locale) avoids non\-ASCII glyphs. See +.I docs/accessibility.md +for the full statement. +.SH EXIT STATUS +.TP +.B 0 +Success. +.TP +.B 2 +Usage error (for example, +.B assert +missing +.I VALUE +without +.BR \- , +or +.B completions +when +.I shtab +is not installed). +.SH EXAMPLES +Catch up new transcripts into the index: +.PP +.RS +.EX +memware sync ~/.claude/projects +.EE +.RE +.PP +Assert a fact, superseding any prior value: +.PP +.RS +.EX +memware assert "api" "listens on port" "8443" \-\-source "session 3f2a, turn 41" +.EE +.RE +.PP +Recall a belief with several phrasings: +.PP +.RS +.EX +memware recall "which port does the api use" "api port" "8443" \-\-what beliefs +.EE +.RE +.PP +Pick a hit with fzf and read its session back: +.PP +.RS +.EX +memware recall "gateway port" \-\-plain | fzf \-\-delimiter='\et' \-\-with\-nth=10 \e + | cut \-f4 | xargs \-r memware read +.EE +.RE +.SH INSTALLATION +Read this page without installing it: +.PP +.RS +.EX +man \-l docs/memware.1 +.EE +.RE +.PP +Or install it by copying it into a +.I man1 +directory on your +.BR MANPATH , +for example +.IR ~/.local/share/man/man1/memware.1 , +then run +.BR "man memware" . +.SH SEE ALSO +.IR docs/design.md , +.IR docs/backup.md , +.IR docs/integrations.md , +.IR docs/keeping-memory-clean.md , +.IR docs/editor-integration.md , +.IR docs/accessibility.md . +.PP +Project home: +.I https://github.com/ericwalisko/memware diff --git a/pyproject.toml b/pyproject.toml index 6662284..4ae778f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,8 @@ dependencies = [] [project.optional-dependencies] mcp = ["mcp>=1.2"] -dev = ["pytest>=8", "pytest-cov>=5", "ruff==0.16.5", "mypy>=1.11", "pyyaml>=6"] +shell = ["shtab>=1.7"] # `memware completions` (bash/zsh/fish) +dev = ["pytest>=8", "pytest-cov>=5", "ruff==0.16.5", "mypy>=1.11", "pyyaml>=6", "shtab>=1.7"] [project.scripts] memware = "memware.cli:main" diff --git a/src/memware/cli.py b/src/memware/cli.py index fbf8cf2..e0751b8 100644 --- a/src/memware/cli.py +++ b/src/memware/cli.py @@ -5,8 +5,10 @@ import argparse import json +import os import sys from pathlib import Path +from typing import Any from memware import __version__ from memware.index import ( @@ -18,7 +20,81 @@ from memware.ingest import capture_disabled, prune_sources, prune_turns, sync_file, sync_tree from memware.ledger import Policy, approve, assert_belief, current, history, reject from memware.review import HttpReviewBackend, JsonlReviewBackend, open_reviews, sync_reviews -from memware.store import DEFAULT_DB, Store +from memware.store import Store + + +class _HelpFormatter(argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter): + """Show option defaults, and preserve the layout of examples in descriptions/epilogs.""" + + +# (key, label) column orders for the record-listing commands. Used for --plain (tab-separated, +# in this order) and for the default labeled view; keys absent from a row are skipped. +_RECALL_COLS = [ + ("id", "id"), + ("kind", "kind"), + ("score", "score"), + ("session", "session"), + ("ts", "when"), + ("role", "role"), + ("subject", "subject"), + ("relation", "relation"), + ("source", "source"), + ("text", "text"), +] +_BELIEF_COLS = [ + ("id", "id"), + ("subject", "subject"), + ("relation", "relation"), + ("value", "value"), + ("valid_from", "valid from"), + ("valid_to", "valid to"), + ("reliability", "reliability"), + ("status", "status"), + ("source", "source"), +] +_TURN_COLS = [("id", "id"), ("seq", "seq"), ("role", "role"), ("ts", "when"), ("text", "text")] + + +def _emit(a: argparse.Namespace, rows: object, columns: list[tuple[str, str]]) -> None: + """Emit records honouring --json / --plain / the default view. + + --json : indented JSON, the canonical shape for scripts that parse structure. + --plain : one record per line, tab-separated in ``columns`` order — pipe to fzf/awk/cut. + Tabs and newlines inside a value become spaces so each record stays on one line. + default : labeled, one field per line, a blank line between records — linear and + unambiguous for a screen reader, nothing aligned by eye, and no colour ever. + """ + if getattr(a, "json", False): + print(json.dumps(rows, indent=2, default=str)) + return + items: list[Any] = list(rows) if isinstance(rows, list) else [rows] + if getattr(a, "plain", False): + for row in items: + if isinstance(row, str): + print(row) + continue + d = dict(row) + cells = [ + "" if d.get(k) is None else str(d.get(k)).replace("\t", " ").replace("\n", " ") + for k, _ in columns + ] + print("\t".join(cells)) + return + width = max((len(lbl) for _, lbl in columns), default=0) + for i, row in enumerate(items): + if isinstance(row, str): + print(row) + continue + if i: + print() + d = dict(row) + for k, lbl in columns: + v = d.get(k) + if v is None or v == "": + continue + print(f"{lbl.rjust(width)} : " + str(v).replace(chr(10), " ")) + if not items: + print("(no matches)") def _hook_payload() -> dict[str, object]: @@ -176,7 +252,7 @@ def cmd_recall(a: argparse.Namespace) -> int: } for h in hits ] - _out(rows, a.json) + _emit(a, rows, _RECALL_COLS) return 0 @@ -212,6 +288,14 @@ def cmd_context(a: argparse.Namespace) -> int: def cmd_assert(a: argparse.Namespace) -> int: + if a.subject == "-": + return _assert_stdin(a) + if a.relation is None or a.value is None: + print( + "assert needs SUBJECT RELATION VALUE, or `-` to read TSV lines from stdin", + file=sys.stderr, + ) + return 2 with Store(a.db) as s: r = assert_belief( s, @@ -235,16 +319,59 @@ def cmd_assert(a: argparse.Namespace) -> int: return 0 +def _assert_stdin(a: argparse.Namespace) -> int: + """Batch-assert tab-separated ``subjectrelationvalue[source]`` lines from + stdin. Blank lines and lines starting with ``#`` are skipped. Pairs with ``beliefs --plain`` + so facts can round-trip through an editor or script: read them out, pipe them back in.""" + outcomes: list[dict[str, object]] = [] + with Store(a.db) as s: + for raw in sys.stdin: + line = raw.rstrip("\n") + if not line.strip() or line.lstrip().startswith("#"): + continue + parts = line.split("\t") + if len(parts) < 3: + print(f"skipped (need 3+ tab-separated fields): {line!r}", file=sys.stderr) + continue + source = parts[3] if len(parts) > 3 else a.source + r = assert_belief( + s, + parts[0], + parts[1], + parts[2], + valid_from=a.valid_from, + source=source, + reliability=a.reliability, + policy=Policy(a.policy), + ) + outcomes.append( + { + "subject": parts[0], + "relation": parts[1], + "value": parts[2], + "outcome": r.outcome.value, + "belief_id": r.belief_id, + } + ) + if getattr(a, "json", False): + print(json.dumps({"asserted": len(outcomes), "outcomes": outcomes}, indent=2, default=str)) + else: + for o in outcomes: + print(f"{o['outcome']}: {o['subject']} {o['relation']} = {o['value']}") + print(f"({len(outcomes)} asserted)") + return 0 + + def cmd_beliefs(a: argparse.Namespace) -> int: with Store(a.db) as s: rows = history(s, a.subject, a.relation) if a.relation else current(s, a.subject) - _out(rows, a.json) + _emit(a, rows, _BELIEF_COLS) return 0 def cmd_read(a: argparse.Namespace) -> int: with Store(a.db) as s: - _out(read_turns(s, a.session, around=a.around, window=a.window), a.json) + _emit(a, read_turns(s, a.session, around=a.around, window=a.window), _TURN_COLS) return 0 @@ -541,22 +668,98 @@ def cmd_nuke(a: argparse.Namespace) -> int: return 0 +_DESCRIPTION = ( + "Memory for AI agents that only remembers the latest truth — a local SQLite belief " + "ledger and transcript index. No daemon, no vector database, no model in the loop." +) + +_EPILOG = """\ +Examples: + memware backfill index the sessions already on disk (run once) + memware recall "which port" "api port" search; pass several phrasings, they are fused + memware recall "db url" --plain | fzf scriptable, tab-separated, one hit per line + memware beliefs api current beliefs about a subject + memware assert api "listens on" 8443 record a fact (supersedes the old value) + memware setup guided backup + first-run configuration + memware completions zsh > ~/.zfunc/_memware install shell completion + +Environment: + MEMWARE_DB store path (default: /memware.db) + MEMWARE_HOME config/store dir (default: ~/.memware, else $XDG_DATA_HOME/memware) + MEMWARE_ASCII=1 ASCII-only output (also on when the locale is not UTF-8); same as --ascii + MEMWARE_NO_CAPTURE=1 never index the current session + NO_COLOR honoured by construction — memware emits no colour at all + +Files: + /config.json configuration (see `memware config`) + /ignore-markers.txt content signatures never to index + +Accessibility: + No information is ever conveyed by colour. --plain gives tab-separated records for scripts + and screen readers; --ascii avoids non-ASCII glyphs. See docs/accessibility.md. + +See also: man memware · https://github.com/ericwalisko/memware +""" + + +def cmd_completions(a: argparse.Namespace) -> int: + """Print a shell completion script for bash/zsh/fish (generated from the parser by shtab).""" + try: + import shtab + except ImportError: + print( + "shell completions need shtab: pip install 'memware[shell]' (or: pip install shtab)", + file=sys.stderr, + ) + return 2 + print(shtab.complete(build_parser(), shell=a.shell)) + return 0 + + def build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser(prog="memware", description=__doc__) - p.add_argument("--db", default=str(DEFAULT_DB), help="SQLite file (env MEMWARE_DB)") - p.add_argument("--json", action="store_true", help="machine-readable output") + from memware.config import memware_home + + p = argparse.ArgumentParser( + prog="memware", description=_DESCRIPTION, epilog=_EPILOG, formatter_class=_HelpFormatter + ) + default_db = os.environ.get("MEMWARE_DB") or str(memware_home() / "memware.db") + p.add_argument("--db", default=default_db, metavar="FILE", help="SQLite store (env MEMWARE_DB)") + p.add_argument("--json", action="store_true", help="machine-readable JSON output") + p.add_argument( + "--plain", + action="store_true", + help="tab-separated records, one per line (scriptable; pipe to fzf/awk/cut)", + ) + p.add_argument( + "--ascii", + action="store_true", + help="ASCII only; no non-ASCII glyphs (screen readers, non-UTF-8 terminals)", + ) p.add_argument("--version", action="version", version=__version__) - sub = p.add_subparsers(dest="cmd", required=True) + sub = p.add_subparsers(dest="cmd", required=True, metavar="COMMAND") - def add(name: str, help: str) -> argparse.ArgumentParser: - sp = sub.add_parser(name, help=help) - sp.add_argument("--json", action="store_true", default=argparse.SUPPRESS) + def add(name: str, help: str, epilog: str | None = None) -> argparse.ArgumentParser: + sp = sub.add_parser( + name, help=help, description=help, epilog=epilog, formatter_class=_HelpFormatter + ) + for flag in ("--json", "--plain", "--ascii"): # global; documented on the top-level + sp.add_argument( + flag, action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS + ) return sp s = add("init", "create the database") s.set_defaults(fn=cmd_init) - s = add("sync", "index new turns from transcripts") + s = add( + "sync", + "index new turns from transcripts", + epilog=( + "Examples:\n" + " memware sync catch up the configured transcript source\n" + " memware sync ~/.claude/projects index a specific tree (idempotent)" + ), + ) s.add_argument("paths", nargs="*") s.add_argument("--harness", default="claude-code") s.add_argument("--from-hook", action="store_true") @@ -574,7 +777,11 @@ def add(name: str, help: str) -> argparse.ArgumentParser: ) s.set_defaults(fn=cmd_sync) - s = add("backfill", "one-time index of existing transcripts (run once on a new machine)") + s = add( + "backfill", + "one-time index of existing transcripts (run once on a new machine)", + epilog="Example:\n memware backfill index ~/.claude/projects once on a new machine", + ) s.add_argument( "root", nargs="?", @@ -585,7 +792,17 @@ def add(name: str, help: str) -> argparse.ArgumentParser: s.add_argument("--exclude", action="append", default=[], metavar="GLOB") s.set_defaults(fn=cmd_backfill) - s = add("recall", "search turns and beliefs; pass several phrasings to fuse them") + s = add( + "recall", + "search turns and beliefs; pass several phrasings to fuse them", + epilog=( + "Examples:\n" + ' memware recall "which port does the api use" "api port" 8443\n' + ' memware recall "auth flow" --what beliefs\n' + ' memware recall "auth flow" --plain | fzf pick a hit interactively\n' + ' memware recall "auth flow" --plain | cut -f1 just the ids' + ), + ) s.add_argument( "queries", nargs="+", @@ -605,10 +822,18 @@ def add(name: str, help: str) -> argparse.ArgumentParser: s.add_argument("--from-hook", action="store_true") s.set_defaults(fn=cmd_context) - s = add("assert", "record a belief; supersedes the previous value") - s.add_argument("subject") - s.add_argument("relation") - s.add_argument("value") + s = add( + "assert", + "record a belief; supersedes the previous value", + epilog=( + "Examples:\n" + ' memware assert api "listens on port" 8443 --source "session 3f2a"\n' + " printf 'api\\tlistens on port\\t8443\\n' | memware assert - (batch TSV from stdin)" + ), + ) + s.add_argument("subject", help="subject, or `-` to batch-read TSV lines from stdin") + s.add_argument("relation", nargs="?", help="relation (omit only when subject is `-`)") + s.add_argument("value", nargs="?", help="value (omit only when subject is `-`)") s.add_argument("--valid-from") s.add_argument("--source") s.add_argument("--reliability", type=float, default=0.5) @@ -617,12 +842,25 @@ def add(name: str, help: str) -> argparse.ArgumentParser: ) s.set_defaults(fn=cmd_assert) - s = add("beliefs", "current beliefs, or the history of one key") + s = add( + "beliefs", + "current beliefs, or the history of one key", + epilog=( + "Examples:\n" + " memware beliefs all current beliefs\n" + " memware beliefs api current beliefs about a subject\n" + ' memware beliefs api "listens on port" full history of one key' + ), + ) s.add_argument("subject", nargs="?") s.add_argument("relation", nargs="?") s.set_defaults(fn=cmd_beliefs) - s = add("read", "read a session's turns") + s = add( + "read", + "read a session's turns", + epilog="Example:\n memware read --around --window 5", + ) s.add_argument("session") s.add_argument("--around", type=int) s.add_argument("--window", type=int, default=5) @@ -656,7 +894,13 @@ def add(name: str, help: str) -> argparse.ArgumentParser: s.set_defaults(fn=cmd_stats) s = add( - "backup", "snapshot the store to a folder (Dropbox/iCloud/Drive/disk) with tiered retention" + "backup", + "snapshot the store to a folder (Dropbox/iCloud/Drive/disk) with tiered retention", + epilog=( + "Examples:\n" + " memware backup snapshot + transcript mirror to backup.dest\n" + " memware restore --latest after a wipe (never re-backfill)" + ), ) s.add_argument("--dest", metavar="DIR", help="destination (default: backup.dest from config)") s.add_argument( @@ -712,11 +956,26 @@ def add(name: str, help: str) -> argparse.ArgumentParser: help='must equal "DELETE ALL MEMWARE DATA" (else you are prompted)', ) s.set_defaults(fn=cmd_nuke) + + s = add( + "completions", + "print a shell completion script (bash/zsh/fish)", + epilog=( + "Examples:\n" + " memware completions zsh > ~/.zfunc/_memware\n" + " memware completions bash > ~/.local/share/bash-completion/completions/memware\n" + " memware completions fish > ~/.config/fish/completions/memware.fish" + ), + ) + s.add_argument("shell", choices=["bash", "zsh", "fish"]) + s.set_defaults(fn=cmd_completions) return p def main(argv: list[str] | None = None) -> int: a = build_parser().parse_args(argv) + if getattr(a, "ascii", False): + os.environ["MEMWARE_ASCII"] = "1" # honoured by memware.term for glyph fallback return int(a.fn(a)) diff --git a/src/memware/config.py b/src/memware/config.py index 3b5e8cb..bb4866b 100644 --- a/src/memware/config.py +++ b/src/memware/config.py @@ -13,7 +13,26 @@ def memware_home() -> Path: - return Path(os.environ.get("MEMWARE_HOME", "~/.memware")).expanduser() + """Directory for config, markers, and (by default) the store. + + Resolution, first match wins — so an existing install is never migrated and a new one + follows the XDG Base Directory spec: + + 1. ``$MEMWARE_HOME`` — explicit override. + 2. ``~/.memware`` — if it already exists (a pre-existing install stays put). + 3. ``$XDG_DATA_HOME/memware`` — a fresh install when XDG is configured. + 4. ``~/.memware`` — the default. + """ + env = os.environ.get("MEMWARE_HOME") + if env: + return Path(env).expanduser() + legacy = Path("~/.memware").expanduser() + if legacy.exists(): + return legacy + xdg = os.environ.get("XDG_DATA_HOME") + if xdg: + return Path(xdg).expanduser() / "memware" + return legacy def config_path() -> Path: diff --git a/src/memware/index.py b/src/memware/index.py index b615787..e65ab3f 100644 --- a/src/memware/index.py +++ b/src/memware/index.py @@ -30,6 +30,7 @@ from datetime import UTC, datetime from memware.store import Store, now_iso +from memware.term import ellipsis _TOKEN = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_\-./]{1,}") STOPWORDS = frozenset( @@ -156,7 +157,7 @@ def _join_passages(rows: list[sqlite3.Row]) -> str: previous: int | None = None for r in rows: if previous is not None and r["ord"] != previous + 1: - parts.append(ELLIPSIS) + parts.append(f" {ellipsis()} ") parts.append(r["passage_text"]) previous = int(r["ord"]) return "".join(parts) @@ -200,10 +201,11 @@ def search_turns( q = fts_query(query) if not q: return [] + mark = ellipsis() # our own constant (…/...); not user input rows = store.conn.execute( "SELECT p.id AS passage_id, p.turn_id, p.ord, p.start_char, p.text AS passage_text, " "t.session, t.ts, t.role, t.source, t.use_count, " - "-bm25(passage_fts) AS rel, snippet(passage_fts, 0, '', '', ' … ', ?) AS snip " + f"-bm25(passage_fts) AS rel, snippet(passage_fts, 0, '', '', ' {mark} ', ?) AS snip " "FROM passage_fts JOIN passage p ON p.id = passage_fts.rowid " "JOIN turn t ON t.id = p.turn_id " "WHERE passage_fts MATCH ? ORDER BY rel DESC LIMIT ?", diff --git a/src/memware/store.py b/src/memware/store.py index cfe6c1a..5e5b76b 100644 --- a/src/memware/store.py +++ b/src/memware/store.py @@ -102,7 +102,18 @@ ); """ -DEFAULT_DB = Path(os.environ.get("MEMWARE_DB", "~/.memware/memware.db")).expanduser() +def _default_db_path() -> Path: + """The store path: ``$MEMWARE_DB`` if set, else ``memware.db`` under the memware home + (which is XDG-aware for fresh installs; see :func:`memware.config.memware_home`).""" + env = os.environ.get("MEMWARE_DB") + if env: + return Path(env).expanduser() + from memware.config import memware_home + + return memware_home() / "memware.db" + + +DEFAULT_DB = _default_db_path() SCHEMA_VERSION = 2 """Bumped when an existing file needs work beyond ``CREATE ... IF NOT EXISTS``. diff --git a/src/memware/term.py b/src/memware/term.py new file mode 100644 index 0000000..c46e654 --- /dev/null +++ b/src/memware/term.py @@ -0,0 +1,35 @@ +"""Output policy for terminals, tuned for accessibility and scripting. + +Two principles: + +* **No information is ever carried by colour.** memware emits no colour escapes at + all, so it is legible in any palette, to colour-blind users, and under ``NO_COLOR`` + by construction — there is nothing to disable. +* **ASCII on request.** A screen reader or a non-UTF-8 terminal can mispronounce or + mangle glyphs like ``…``. ASCII mode swaps them for plain equivalents (``...``). + It turns on when ``MEMWARE_ASCII`` is set to a truthy value, or when the locale is + not UTF-8 (so legacy terminals degrade gracefully without any flag). +""" + +from __future__ import annotations + +import os + +_FALSEY = {"", "0", "false", "no", "off"} + + +def ascii_mode() -> bool: + """True when output should avoid non-ASCII glyphs.""" + val = os.environ.get("MEMWARE_ASCII") + if val is not None: + return val.lower() not in _FALSEY + loc = os.environ.get("LC_ALL") or os.environ.get("LC_CTYPE") or os.environ.get("LANG") or "" + if not loc: + return False # no locale info: assume a modern UTF-8 terminal + low = loc.lower() + return "utf-8" not in low and "utf8" not in low + + +def ellipsis() -> str: + """The elision marker: ``...`` in ASCII mode, ``…`` otherwise.""" + return "..." if ascii_mode() else "…" diff --git a/tests/test_cli.py b/tests/test_cli.py index 158e17c..0abc75e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -126,3 +126,65 @@ def test_bare_sync_catches_up_configured_transcript_src(tmp_path, capsys, monkey capsys.readouterr() assert main(["--db", db, "sync", "--json"]) == 0 # no path -> configured source assert json.loads(capsys.readouterr().out)["added"] == 1 + + +def test_plain_output_is_tab_separated_id_first(tmp_path, capsys): + db = str(tmp_path / "m.db") + main(["--db", db, "assert", "api", "listens on port", "8443"]) + capsys.readouterr() + main(["--db", db, "beliefs", "--plain"]) + cols = capsys.readouterr().out.strip().splitlines()[0].split("\t") + assert cols[0] == "1" and cols[1] == "api" and cols[3] == "8443" + + +def test_default_output_is_labeled_for_screen_readers(tmp_path, capsys): + db = str(tmp_path / "m.db") + main(["--db", db, "assert", "api", "listens on port", "8443"]) + capsys.readouterr() + main(["--db", db, "beliefs", "api"]) + out = capsys.readouterr().out + assert "subject : api" in out and "value : 8443" in out + + +def test_assert_batch_from_stdin(tmp_path, capsys, monkeypatch): + db = str(tmp_path / "m.db") + monkeypatch.setattr( + sys, "stdin", io.StringIO("api\tlistens on port\t8443\n# a comment\n\ndb\turl\tpg://x\n") + ) + assert main(["--db", db, "assert", "-", "--json"]) == 0 + assert json.loads(capsys.readouterr().out)["asserted"] == 2 + + +def test_assert_without_value_errors_cleanly(tmp_path, capsys): + db = str(tmp_path / "m.db") + assert main(["--db", db, "assert", "api", "listens on port"]) == 2 + assert "SUBJECT RELATION VALUE" in capsys.readouterr().err + + +def test_completions_emits_a_shell_script(capsys): + assert main(["completions", "zsh"]) == 0 + assert "#compdef memware" in capsys.readouterr().out + + +def test_help_shows_examples(capsys): + import contextlib + + with contextlib.suppress(SystemExit): + main(["recall", "--help"]) + out = capsys.readouterr().out + assert "Examples:" in out and "memware recall" in out + + +def test_memware_home_override_legacy_and_xdg(tmp_path, monkeypatch): + from memware.config import memware_home + + monkeypatch.setenv("MEMWARE_HOME", str(tmp_path / "explicit")) + assert memware_home() == tmp_path / "explicit" # explicit override wins + + monkeypatch.delenv("MEMWARE_HOME") + monkeypatch.setenv("HOME", str(tmp_path / "h")) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg")) + assert memware_home() == tmp_path / "xdg" / "memware" # fresh install -> XDG + + (tmp_path / "h" / ".memware").mkdir(parents=True) + assert memware_home() == tmp_path / "h" / ".memware" # existing legacy dir wins over XDG diff --git a/tests/test_term.py b/tests/test_term.py new file mode 100644 index 0000000..d15d793 --- /dev/null +++ b/tests/test_term.py @@ -0,0 +1,20 @@ +import memware.term as term + + +def test_ascii_mode_follows_env_and_locale(monkeypatch): + monkeypatch.delenv("MEMWARE_ASCII", raising=False) + monkeypatch.setenv("LANG", "en_US.UTF-8") + assert term.ascii_mode() is False + assert term.ellipsis() == "…" + + monkeypatch.setenv("MEMWARE_ASCII", "1") # explicit on + assert term.ascii_mode() is True + assert term.ellipsis() == "..." + + monkeypatch.setenv("MEMWARE_ASCII", "0") # explicit off wins over a non-UTF-8 locale + monkeypatch.setenv("LANG", "C") + assert term.ascii_mode() is False + + monkeypatch.delenv("MEMWARE_ASCII", raising=False) # no flag, non-UTF-8 locale -> ascii + monkeypatch.setenv("LANG", "C") + assert term.ascii_mode() is True From 5bdbed339f3fe779af711d55cd80d5b451d28d5d Mon Sep 17 00:00:00 2001 From: ericwalisko Date: Fri, 4 Sep 2026 00:06:42 -0400 Subject: [PATCH 2/3] Release 0.3.0: developer-experience + accessibility Bump package and both plugin manifests to 0.3.0 (per the drift guard), and record the release in the changelog. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NnaQwZUSDRbVUNh8fsdksD --- .claude-plugin/marketplace.json | 2 +- CHANGELOG.md | 25 +++++++++++++++++++ .../claude-code/.claude-plugin/plugin.json | 2 +- src/memware/__init__.py | 2 +- src/memware/store.py | 1 + 5 files changed, 29 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b522862..c87e139 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "memware", "source": "./integrations/claude-code", "description": "Session transcripts indexed for recall; a belief ledger that only remembers the latest truth. Hooks: SessionEnd/PreCompact sync, prompt-time belief context.", - "version": "0.2.6", + "version": "0.3.0", "author": { "name": "ericwalisko" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 9db867b..c90d156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.3.0] - 2026-09-04 + +### Added +- **`--plain` output** for `recall`, `beliefs`, and `read`: tab-separated, id-first, one + record per line (tabs/newlines in values collapse to spaces), for piping to fzf/awk/cut. +- **Shell completions**: `memware completions {bash,zsh,fish}` prints a completion script + (generated from the parser via shtab). New `memware[shell]` extra. +- **`memware assert -`**: batch-assert tab-separated `subject/relation/value[/source]` lines + from stdin (blank lines and `#` comments skipped). Pairs with `beliefs --plain | cut` for + an `$EDITOR` round-trip. +- **Richer `--help`**: per-command examples and an ENVIRONMENT/FILES/ACCESSIBILITY epilog, + option defaults shown; duplicate global flags hidden from subcommand help. +- **XDG Base Directory support** for fresh installs: the memware home resolves to + `$MEMWARE_HOME`, else an existing `~/.memware`, else `$XDG_DATA_HOME/memware`, else + `~/.memware` — so an existing install is never migrated. The store path follows it. +- Man page (`docs/memware.1`), `docs/editor-integration.md` (emacs/vim/neovim/fzf recipes, + no plugins), and `docs/accessibility.md`. + +### Changed +- **Accessibility**: memware emits no colour at all (so `NO_COLOR` is honoured by + construction and nothing depends on colour). The default human output is now labeled + (`field : value`, one per line, empty fields skipped) — linear for screen readers. New + `--ascii` flag and `MEMWARE_ASCII`, plus automatic fallback on a non-UTF-8 locale, replace + the `…` elision glyph with `...`. + ## [0.2.6] - 2026-09-03 ### Added diff --git a/integrations/claude-code/.claude-plugin/plugin.json b/integrations/claude-code/.claude-plugin/plugin.json index 24ce2a4..d4aac86 100644 --- a/integrations/claude-code/.claude-plugin/plugin.json +++ b/integrations/claude-code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memware", "description": "Session transcripts indexed for recall; a belief ledger that only remembers the latest truth.", - "version": "0.2.6", + "version": "0.3.0", "author": { "name": "ericwalisko" } diff --git a/src/memware/__init__.py b/src/memware/__init__.py index 0a6292c..555f171 100644 --- a/src/memware/__init__.py +++ b/src/memware/__init__.py @@ -22,4 +22,4 @@ "history", "reject", ] -__version__ = "0.2.6" +__version__ = "0.3.0" diff --git a/src/memware/store.py b/src/memware/store.py index 5e5b76b..1273c60 100644 --- a/src/memware/store.py +++ b/src/memware/store.py @@ -102,6 +102,7 @@ ); """ + def _default_db_path() -> Path: """The store path: ``$MEMWARE_DB`` if set, else ``memware.db`` under the memware home (which is XDG-aware for fresh installs; see :func:`memware.config.memware_home`).""" From 5fde55e68906b27ca0935f80a99d7be4d87ab9ee Mon Sep 17 00:00:00 2001 From: ericwalisko Date: Fri, 4 Sep 2026 00:08:50 -0400 Subject: [PATCH 3/3] test: isolate the locale in the ascii-mode test (macOS CI sets LC_CTYPE) macOS runners export LC_CTYPE=UTF-8, which correctly outranks the test's LANG=C (POSIX precedence LC_ALL > LC_CTYPE > LANG). Clear LC_ALL/LC_CTYPE so the locale under test is deterministic, and add a test that LC_ALL outranks LANG. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NnaQwZUSDRbVUNh8fsdksD --- tests/test_term.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/test_term.py b/tests/test_term.py index d15d793..e670148 100644 --- a/tests/test_term.py +++ b/tests/test_term.py @@ -1,8 +1,12 @@ import memware.term as term +_LOCALE_VARS = ("MEMWARE_ASCII", "LC_ALL", "LC_CTYPE", "LANG") + def test_ascii_mode_follows_env_and_locale(monkeypatch): - monkeypatch.delenv("MEMWARE_ASCII", raising=False) + for v in _LOCALE_VARS: # start from a known-clean locale (CI machines vary) + monkeypatch.delenv(v, raising=False) + monkeypatch.setenv("LANG", "en_US.UTF-8") assert term.ascii_mode() is False assert term.ellipsis() == "…" @@ -15,6 +19,18 @@ def test_ascii_mode_follows_env_and_locale(monkeypatch): monkeypatch.setenv("LANG", "C") assert term.ascii_mode() is False - monkeypatch.delenv("MEMWARE_ASCII", raising=False) # no flag, non-UTF-8 locale -> ascii + # No flag + a non-UTF-8 locale -> ascii. Clear LC_ALL/LC_CTYPE so LANG decides + # (POSIX precedence is LC_ALL > LC_CTYPE > LANG, which the code honours). + monkeypatch.delenv("MEMWARE_ASCII", raising=False) + monkeypatch.delenv("LC_ALL", raising=False) + monkeypatch.delenv("LC_CTYPE", raising=False) monkeypatch.setenv("LANG", "C") assert term.ascii_mode() is True + + +def test_lc_all_takes_precedence_over_lang(monkeypatch): + for v in _LOCALE_VARS: + monkeypatch.delenv(v, raising=False) + monkeypatch.setenv("LANG", "C") # would say ascii... + monkeypatch.setenv("LC_ALL", "en_US.UTF-8") # ...but LC_ALL wins + assert term.ascii_mode() is False