anona-memory 0.27.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
anona/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ from .client import (
2
+ AnonaClient,
3
+ AnonaError,
4
+ RateLimit,
5
+ ReasonWithReceipt,
6
+ RetrieveResults,
7
+ RetrieveWithReceipt,
8
+ )
9
+
10
+ # `Anona` is what the TypeScript package calls its client, and the two SDKs
11
+ # describing the same API under two different class names is a papercut every
12
+ # reader of both pays. The Python name stays canonical — it is what every
13
+ # existing import and every published example uses — and this is an alias, not
14
+ # a rename, so nothing breaks either way round.
15
+ Anona = AnonaClient
16
+
17
+ __all__ = [
18
+ "Anona",
19
+ "AnonaClient",
20
+ "AnonaError",
21
+ "RateLimit",
22
+ "ReasonWithReceipt",
23
+ "RetrieveResults",
24
+ "RetrieveWithReceipt",
25
+ ]
26
+ __version__ = "0.27.0"
@@ -0,0 +1,108 @@
1
+ """What a `record` response actually says happened.
2
+
3
+ The API answers `201` for a write it accepted, and acceptance is not storage:
4
+ extraction runs over the content and often produces nothing, which is a
5
+ success with an empty result rather than an error. Roughly 44% of short,
6
+ context-free statements come back that way. A caller that reads only the
7
+ status code reports those as stored, which is silent data loss dressed as
8
+ success.
9
+
10
+ **The verdict is read off the ids, not off `status`** — the same decision the
11
+ server makes, and for the server's own stated reason: the engine has no
12
+ failure shape, so a status field has been wrong about this before and an
13
+ `"stored"` that stored nothing is exactly the bug being guarded against. A
14
+ guard that trusted the status would depend on the server-side fix for it
15
+ being deployed everywhere, which is the one thing it cannot assume. `status:
16
+ "nothing_extracted"` and empty ids always coincide, so believing the ids
17
+ costs nothing and survives a deployment that predates the status.
18
+
19
+ What the server IS believed about is the **wording**: `message` accompanies
20
+ that status and carries measured advice about what to do, so it is shown
21
+ verbatim where present and only restated here when absent.
22
+
23
+ This lives on its own, outside `integrations.mcp`, for two reasons. That
24
+ module imports FastMCP, which ships in the optional `[mcp]` extra, so the CLI
25
+ cannot import from it without making an optional dependency mandatory. And
26
+ the question "did this write store anything" has exactly one right answer, so
27
+ the two surfaces that ask it must not each carry their own copy to drift.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ # Said only when the server said nothing itself. The server sets `message` on
33
+ # exactly this status and its wording is the one to show; this is for a
34
+ # deployment that predates that field, so that an older server still produces
35
+ # an explanation rather than a bare non-zero exit.
36
+ NOTHING_EXTRACTED_FALLBACK = (
37
+ "Nothing was stored in space '{space_id}': no facts could be extracted from "
38
+ "that content. Short, context-free statements often extract to nothing — "
39
+ "re-record it with a sentence of context (what the thing is, or why it "
40
+ "matters), which usually fixes it."
41
+ )
42
+
43
+ # Statuses that mean extraction has not run yet, so there is no outcome to
44
+ # report and no ids to name.
45
+ _QUEUED_STATUSES = ("processing", "pending", "queued")
46
+
47
+
48
+ def stored_ids(result: object) -> list[str]:
49
+ """The memory ids a write created, in the order the API returned them.
50
+
51
+ ``memory_ids`` is the full set: one item usually produces more than one
52
+ memory (the raw fact, plus any synthesis built on it), so the singular
53
+ ``memory_id`` could only ever name part of what happened. It is read as
54
+ the fallback for an older response that carries only the one.
55
+
56
+ Empty for a write that stored nothing **and** for one that is still
57
+ queued — the two are different outcomes, so ask :func:`is_queued` before
58
+ reading an empty list as nothing.
59
+ """
60
+ if not isinstance(result, dict):
61
+ return []
62
+ many = result.get("memory_ids")
63
+ if isinstance(many, list):
64
+ ids = [str(item) for item in many if item]
65
+ if ids:
66
+ return ids
67
+ one = result.get("memory_id")
68
+ return [str(one)] if one else []
69
+
70
+
71
+ def is_queued(result: object) -> bool:
72
+ """Whether the write was accepted for background extraction.
73
+
74
+ A queued write has not run extraction yet, so it has no ids and no outcome
75
+ to report. Reporting it as empty would be wrong in the other direction.
76
+ """
77
+ if not isinstance(result, dict):
78
+ return False
79
+ return bool(result.get("job_id")) or result.get("status") in _QUEUED_STATUSES
80
+
81
+
82
+ def stored_something(result: object) -> bool:
83
+ """Whether a record persisted a memory, read off the API response.
84
+
85
+ The ids decide. `status` is deliberately not consulted: see the module
86
+ docstring — a status field has been wrong about this, and the ids cannot
87
+ be. A non-dict is treated as stored: this is a guard against an unexpected
88
+ response shape, and inventing a failure the API did not report would be
89
+ worse than the thing it protects against.
90
+ """
91
+ if not isinstance(result, dict):
92
+ return True
93
+ return bool(stored_ids(result)) or is_queued(result)
94
+
95
+
96
+ def nothing_extracted_message(result: object, space_id: str) -> str:
97
+ """Why nothing was stored, in the server's own words where it gave them.
98
+
99
+ The server sets `message` on exactly this outcome and it says what to do.
100
+ Preferring it means the advice can be improved server-side without a
101
+ client release, and it keeps this package from carrying another copy of
102
+ wording the server already owns.
103
+ """
104
+ if isinstance(result, dict):
105
+ message = result.get("message")
106
+ if isinstance(message, str) and message.strip():
107
+ return message.strip()
108
+ return NOTHING_EXTRACTED_FALLBACK.format(space_id=space_id)
anona/cli/__init__.py ADDED
File without changes
anona/cli/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """`python -m anona.cli`: the same command as `anona-memory`."""
2
+
3
+ import sys
4
+
5
+ from .main import main
6
+
7
+ sys.exit(main())
@@ -0,0 +1,157 @@
1
+ """`anona-memory completion bash|zsh|fish`: a completion script generated from the parser.
2
+
3
+ Install:
4
+ bash anona-memory completion bash > ~/.local/share/bash-completion/completions/anona-memory
5
+ zsh anona-memory completion zsh > "${fpath[1]}/_anona-memory" (or eval it from ~/.zshrc)
6
+ fish anona-memory completion fish > ~/.config/fish/completions/anona-memory.fish
7
+
8
+ Generated, not hand-written, so a new verb or flag completes the moment it
9
+ exists. Commands and flags complete; flag values do not, except where a flag
10
+ has fixed choices (fish only) -- a filename is offered where nothing else
11
+ fits.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+
18
+ from .introspect import Command, tree
19
+
20
+
21
+ def _quote(text: str) -> str:
22
+ """Single-quoted for sh and fish: no expansion of any kind inside."""
23
+ return "'" + text.replace("'", "'\\''") + "'"
24
+
25
+
26
+ def _paths(root: Command) -> list[Command]:
27
+ return root.walk()
28
+
29
+
30
+ def _key(cmd: Command) -> str:
31
+ # The root's path is ["anona-memory"]; every other command's path starts at its own name.
32
+ return "" if cmd.path == ["anona-memory"] else " ".join(cmd.path)
33
+
34
+
35
+ def _flags(cmd: Command, root: Command) -> list[str]:
36
+ flags = [f for o in cmd.options for f in o.flags]
37
+ # Global flags are accepted after any command too.
38
+ if cmd is not root:
39
+ flags += [f for o in root.options for f in o.flags if f != "--version"]
40
+ flags.append("--help")
41
+ return flags
42
+
43
+
44
+ def bash(parser: argparse.ArgumentParser) -> str:
45
+ root = tree(parser)
46
+ children = []
47
+ options = []
48
+ for cmd in _paths(root):
49
+ key = _quote(_key(cmd))
50
+ if cmd.children:
51
+ children.append(f" {key}) echo {_quote(' '.join(c.path[-1] for c in cmd.children))};;")
52
+ options.append(f" {key}) echo {_quote(' '.join(_flags(cmd, root)))};;")
53
+ return "\n".join(
54
+ [
55
+ "# anona-memory completion for bash. Generated by `anona-memory completion bash`.",
56
+ "_anona_memory_children() {",
57
+ ' case "$1" in',
58
+ *children,
59
+ " esac",
60
+ "}",
61
+ "_anona_memory_options() {",
62
+ ' case "$1" in',
63
+ *options,
64
+ " esac",
65
+ "}",
66
+ "_anona_memory() {",
67
+ ' local cur="${COMP_WORDS[COMP_CWORD]}" path="" w i kids',
68
+ " for ((i=1; i<COMP_CWORD; i++)); do",
69
+ ' w="${COMP_WORDS[i]}"',
70
+ ' case "$w" in -*) continue;; esac',
71
+ ' kids=" $(_anona_memory_children "$path") "',
72
+ ' if [[ "$kids" == *" $w "* ]]; then path="${path:+$path }$w"; fi',
73
+ " done",
74
+ ' if [[ "$cur" == -* ]]; then',
75
+ ' COMPREPLY=( $(compgen -W "$(_anona_memory_options "$path")" -- "$cur") )',
76
+ " else",
77
+ ' kids="$(_anona_memory_children "$path")"',
78
+ ' if [[ -n "$kids" ]]; then',
79
+ ' COMPREPLY=( $(compgen -W "$kids" -- "$cur") )',
80
+ " else",
81
+ ' COMPREPLY=( $(compgen -f -- "$cur") )',
82
+ " fi",
83
+ " fi",
84
+ "}",
85
+ "complete -o default -F _anona_memory anona-memory",
86
+ "",
87
+ ]
88
+ )
89
+
90
+
91
+ def zsh(parser: argparse.ArgumentParser) -> str:
92
+ # zsh runs the bash function through bashcompinit, which ships with zsh.
93
+ # One generator for two shells means the two cannot disagree.
94
+ return "\n".join(
95
+ [
96
+ "#compdef anona-memory",
97
+ "# anona-memory completion for zsh. Generated by `anona-memory completion zsh`.",
98
+ "autoload -U +X bashcompinit && bashcompinit",
99
+ bash(parser),
100
+ ]
101
+ )
102
+
103
+
104
+ def fish(parser: argparse.ArgumentParser) -> str:
105
+ root = tree(parser)
106
+ lines = ["# anona-memory completion for fish. Generated by `anona-memory completion fish`.", "complete -c anona-memory -f"]
107
+ top = " ".join(c.path[0] for c in root.children)
108
+ for cmd in root.children:
109
+ lines.append(
110
+ f"complete -c anona-memory -n {_quote('not __fish_seen_subcommand_from ' + top)} "
111
+ f"-a {_quote(cmd.path[0])} -d {_quote(cmd.help[:80])}"
112
+ )
113
+ for opt in root.options:
114
+ lines.extend(_fish_option(opt, None))
115
+ for cmd in root.walk():
116
+ if cmd is root:
117
+ continue
118
+ cond = " ; and ".join(f"__fish_seen_subcommand_from {p}" for p in cmd.path)
119
+ if cmd.children:
120
+ kids = " ".join(c.path[-1] for c in cmd.children)
121
+ for child in cmd.children:
122
+ lines.append(
123
+ f"complete -c anona-memory -n {_quote(cond + ' ; and not __fish_seen_subcommand_from ' + kids)} "
124
+ f"-a {_quote(child.path[-1])} -d {_quote(child.help[:80])}"
125
+ )
126
+ for opt in cmd.options:
127
+ lines.extend(_fish_option(opt, cond))
128
+ if any(p.name in ("PATHS", "PATH") or "path" in p.help.lower() or "file" in p.help.lower() for p in cmd.positionals):
129
+ lines.append(f"complete -c anona-memory -n {_quote(cond)} -F")
130
+ return "\n".join(lines) + "\n"
131
+
132
+
133
+ def _fish_option(opt, cond: str | None) -> list[str]:
134
+ out = []
135
+ for flag in opt.flags:
136
+ if flag.startswith("--"):
137
+ spec = f"-l {_quote(flag[2:])}"
138
+ elif flag.startswith("-") and len(flag) == 2:
139
+ spec = f"-s {_quote(flag[1:])}"
140
+ else:
141
+ continue
142
+ line = "complete -c anona-memory"
143
+ if cond:
144
+ line += f" -n {_quote(cond)}"
145
+ line += f" {spec}"
146
+ if opt.takes_value:
147
+ line += " -r"
148
+ if opt.choices:
149
+ line += f" -a {_quote(' '.join(opt.choices))}"
150
+ if opt.help:
151
+ line += f" -d {_quote(opt.help[:80])}"
152
+ out.append(line)
153
+ return out
154
+
155
+
156
+ def script(parser: argparse.ArgumentParser, shell: str) -> str:
157
+ return {"bash": bash, "zsh": zsh, "fish": fish}[shell](parser)
@@ -0,0 +1,75 @@
1
+ """`~/.anona/config.env`, read as data.
2
+
3
+ The skill installer (`skills/install.sh`) writes this file for the agent skill
4
+ and for `anona-memory-mcp`: `NAME='value'` lines, single-quoted, with a quote inside a
5
+ value written as '\\''. It is meant to be sourceable by a shell, which is
6
+ exactly why it must never be sourced or executed here: a value is whatever the
7
+ installer was given, and a hand-edited line can hold `$(...)` or backticks.
8
+ So it is parsed the way the installer's own `read_config` parses it -- the
9
+ three names below and nothing else, quoted or unquoted, never expanded.
10
+
11
+ Separate from `credentials.json` (store.py): this holds an API key, which the
12
+ REST API accepts, and `anona-memory logout` never touches it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import stat
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ NAMES = ("ANONA_API_KEY", "ANONA_SPACE_ID", "ANONA_BASE_URL")
22
+
23
+
24
+ def path() -> Path:
25
+ # The active profile's (store.anona_dir): ~/.anona/config.env by default.
26
+ from . import store
27
+
28
+ return store.anona_dir() / "config.env"
29
+
30
+
31
+ def _unquote(value: str) -> str:
32
+ # The installer's `shq`: the whole value in single quotes, each embedded
33
+ # quote as '\''. Older installers wrote it unquoted, which is taken as is.
34
+ if len(value) >= 2 and value.startswith("'") and value.endswith("'"):
35
+ return value[1:-1].replace("'\\''", "'")
36
+ return value
37
+
38
+
39
+ def parse(text: str) -> dict[str, str]:
40
+ """The recognised names in `text`. The first assignment of a name wins,
41
+ as in the installer's reader; an empty value counts as absent."""
42
+ found: dict[str, str] = {}
43
+ for line in text.splitlines():
44
+ line = line.rstrip("\r")
45
+ if line.startswith("export "):
46
+ line = line[len("export "):]
47
+ name, sep, value = line.partition("=")
48
+ if not sep or name not in NAMES or name in found:
49
+ continue
50
+ value = _unquote(value)
51
+ if value:
52
+ found[name] = value
53
+ return found
54
+
55
+
56
+ def load() -> dict[str, str]:
57
+ """The recognised names in config.env, or {} when there is no file.
58
+
59
+ Warns on stderr -- naming the file, never a value -- when the file is
60
+ readable by anyone but its owner: it holds a key with full data-plane
61
+ reach. The installer writes it 600; this catches a hand-made copy.
62
+ """
63
+ p = path()
64
+ try:
65
+ text = p.read_text(encoding="utf-8", errors="replace")
66
+ mode = p.stat().st_mode
67
+ except OSError:
68
+ return {}
69
+ if mode & (stat.S_IRWXG | stat.S_IRWXO):
70
+ print(
71
+ f"anona-memory: warning: {p} is readable by other users; "
72
+ f"run `chmod 600 {p}`.",
73
+ file=sys.stderr,
74
+ )
75
+ return parse(text)
@@ -0,0 +1,77 @@
1
+ """Which API key `record`, `retrieve` and `reason` use, and where it came from.
2
+
3
+ Highest first: `--api-key`, then ANONA_API_KEY in the environment, then
4
+ ANONA_API_KEY in ~/.anona/config.env. With none of them the verbs fall back to
5
+ the command-line login (`anona-memory login --cli`), then to the stored
6
+ temporary-profile token; memory._token_and_space owns that half.
7
+
8
+ The MCP login is never one of these. It is MCP-scoped (`typ=mcp_access`) and
9
+ the REST API refuses it by design; MCP and the CLI's data verbs hold separate
10
+ credentials on purpose.
11
+
12
+ The source travels with the key because it decides where the key may be sent
13
+ (target.for_api_key) and is what `anona-memory status` names. The key itself is
14
+ never printed: `display()` is the only form that reaches output.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import re
21
+ from dataclasses import dataclass, field
22
+
23
+ _PREFIXES = ("anona_live_", "anona_test_")
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ApiKey:
28
+ key: str = field(repr=False)
29
+ source: str # "flag", "env" or "config.env"
30
+
31
+ def display(self) -> str:
32
+ """The key's prefix and a few characters, never enough to use it."""
33
+ for prefix in _PREFIXES:
34
+ if self.key.startswith(prefix):
35
+ rest = self.key[len(prefix):]
36
+ # A short key would give away a large share of itself.
37
+ return prefix + (rest[:4] if len(rest) >= 16 else "") + "…"
38
+ return (self.key[:4] if len(self.key) >= 24 else "") + "…"
39
+
40
+
41
+ def find_api_key(flag: str | None, config: dict[str, str]) -> ApiKey | None:
42
+ if flag:
43
+ return ApiKey(flag, "flag")
44
+ env = os.environ.get("ANONA_API_KEY")
45
+ if env:
46
+ return ApiKey(env, "env")
47
+ from_file = config.get("ANONA_API_KEY")
48
+ if from_file:
49
+ return ApiKey(from_file, "config.env")
50
+ return None
51
+
52
+
53
+ def find_base_url(flag: str | None, config: dict[str, str]) -> tuple[str | None, str | None]:
54
+ """(base URL, its source), or (None, None) when nothing names one."""
55
+ if flag:
56
+ return flag, "flag"
57
+ env = os.environ.get("ANONA_BASE_URL")
58
+ if env:
59
+ return env, "env"
60
+ from_file = config.get("ANONA_BASE_URL")
61
+ if from_file:
62
+ return from_file, "config.env"
63
+ return None, None
64
+
65
+
66
+ def find_space(flag: str | None, config: dict[str, str]) -> str | None:
67
+ return flag or os.environ.get("ANONA_SPACE_ID") or config.get("ANONA_SPACE_ID") or None
68
+
69
+
70
+ # Anything shaped like an Anona credential, wherever it turns up in text we
71
+ # did not write -- argparse echoes unrecognised arguments verbatim, so
72
+ # `anona-memory start --api-key <key>` would otherwise print the key back.
73
+ _CREDENTIAL = re.compile(r"\b(anona_(?:live|test|tmp)_)[A-Za-z0-9_\-]+")
74
+
75
+
76
+ def redact(text: str) -> str:
77
+ return _CREDENTIAL.sub(lambda m: m.group(1) + "…", text)