sessionmemory 0.2.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.
@@ -0,0 +1 @@
1
+ """Session memory CLI."""
sessionmemory/cli.py ADDED
@@ -0,0 +1,62 @@
1
+ """Session memory CLI invoked as `sessionmemory` by a user."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from nclutils import pp
7
+
8
+ from sessionmemory.commands import delete as delete_commands
9
+ from sessionmemory.commands import doctor as doctor_commands
10
+ from sessionmemory.commands import export as export_commands
11
+ from sessionmemory.commands import init as init_commands
12
+ from sessionmemory.commands import inject as inject_commands
13
+ from sessionmemory.commands import log as log_commands
14
+ from sessionmemory.commands import new as new_commands
15
+ from sessionmemory.commands import project
16
+ from sessionmemory.commands import reindex as reindex_commands
17
+ from sessionmemory.commands import search as search_commands
18
+
19
+ app = typer.Typer(no_args_is_help=True, add_completion=False)
20
+
21
+
22
+ @app.callback()
23
+ def _root(
24
+ verbosity: int = typer.Option(
25
+ 0, "-v", "--verbose", count=True, help="Increase output verbosity. Repeat for more."
26
+ ),
27
+ ) -> None:
28
+ """Manage a project's memory in a vault of markdown pages."""
29
+ pp.configure(verbosity=verbosity)
30
+
31
+
32
+ app.command("delete", help="Delete files from the vault permanently.")(
33
+ delete_commands.delete_command
34
+ )
35
+ app.command("doctor", help="Report pages, projects, and indexes worth a look.")(
36
+ doctor_commands.doctor_command
37
+ )
38
+ app.command("export", help="Write this project's field as a .memoryfield.zip.")(
39
+ export_commands.export_command
40
+ )
41
+ app.command("init", help="Create the files a new vault needs.")(init_commands.init)
42
+ app.command("inject", help="Print what this project's session should start with.")(
43
+ inject_commands.inject_command
44
+ )
45
+ app.command("log", help="Record this session's work in one upserted page.")(
46
+ log_commands.log_command
47
+ )
48
+ app.command("project", help="Report this directory's project, or register it.")(
49
+ project.project_command
50
+ )
51
+ app.command("reindex", help="Rebuild this project's search indexes.")(
52
+ reindex_commands.reindex_command
53
+ )
54
+ app.command("search", help="Search this project's learnings by meaning, or its logs with --logs.")(
55
+ search_commands.search_command
56
+ )
57
+ app.add_typer(new_commands.app, name="new")
58
+
59
+
60
+ def main() -> None:
61
+ """Run the CLI. This is the console script entry point."""
62
+ app()
@@ -0,0 +1 @@
1
+ """Session memory CLI commands."""
@@ -0,0 +1,207 @@
1
+ """Helpers shared by every command module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, NoReturn
10
+
11
+ import typer
12
+ from nclutils import pp
13
+
14
+ from sessionmemory.lib import embed, registry
15
+ from sessionmemory.lib.bootstrap import is_empty
16
+ from sessionmemory.lib.config import VaultNotConfiguredError, is_initialized, vault_root
17
+ from sessionmemory.lib.paths import SYSTEM_DIR
18
+ from sessionmemory.lib.resolve import resolve as resolve_project
19
+
20
+ if TYPE_CHECKING:
21
+ from sessionmemory.lib.embed import Embedder
22
+
23
+ EMBEDDER_ENV_VAR = "SESSIONMEMORY_EMBEDDER"
24
+
25
+
26
+ def build_embedder() -> Embedder:
27
+ """Build the embedder commands use: the stub under test, the real model otherwise.
28
+
29
+ Read at call time so a test selects the stub with `monkeypatch.setenv`.
30
+ """
31
+ if os.environ.get(EMBEDDER_ENV_VAR) == "stub":
32
+ return embed.StubEmbedder()
33
+ return embed.default_embedder()
34
+
35
+
36
+ STDIN_SENTINEL = "-"
37
+
38
+
39
+ def resolve_body(body: str, body_file: Path | None) -> str:
40
+ """Return a note's markdown body, read from a file or stdin when one was named.
41
+
42
+ Shell-quoting a document of any real length into `--body` is impractical, so without
43
+ this a caller creates the note empty and then writes the file itself, which is the
44
+ one habit every skill in this project tells a caller not to form.
45
+
46
+ Args:
47
+ body (str): The `--body` value.
48
+ body_file (Path | None): The `--body-file` value, where `-` means stdin.
49
+
50
+ Returns:
51
+ str: The body to write.
52
+
53
+ Raises:
54
+ Exit: When both options carry a value, or the named file cannot be read.
55
+ """
56
+ if body_file is None:
57
+ return body
58
+
59
+ if body:
60
+ fail("--body and --body-file are mutually exclusive", ["pass one or the other"])
61
+
62
+ if str(body_file) == STDIN_SENTINEL:
63
+ return sys.stdin.read()
64
+
65
+ try:
66
+ return body_file.read_text(encoding="utf-8")
67
+ except OSError as error:
68
+ fail(f"cannot read {body_file}: {error.strerror or error}")
69
+ except UnicodeDecodeError:
70
+ fail(f"{body_file} is not valid UTF-8 text")
71
+
72
+
73
+ def emit_value(text: str) -> None:
74
+ """Print one machine readable value with nothing added to it.
75
+
76
+ Machine readable output is read by a caller rather than by a person, so an ANSI escape
77
+ in it is corruption rather than decoration: a styled path breaks the page path `vault
78
+ search` prints for a caller to copy, and a styled version string breaks the comparison
79
+ it exists for. Rich styles values by default and honors `FORCE_COLOR`, which real
80
+ shells export, so styling does not depend on stdout being a terminal and a caller
81
+ cannot opt out of it. Every value a caller consumes goes through here; prose for a
82
+ person keeps its styling and goes through `pp.success`, `pp.warning`, and `pp.error`.
83
+
84
+ `markup=False` keeps square brackets from being read as Rich tags, `emoji=False`
85
+ keeps `:word:` from being replaced by the emoji of that name, `highlight=False`
86
+ suppresses the automatic value coloring, and `soft_wrap=True` keeps a value longer
87
+ than the console width on one line.
88
+
89
+ Args:
90
+ text (str): The value to print.
91
+ """
92
+ pp.console().print(text, markup=False, emoji=False, highlight=False, soft_wrap=True)
93
+
94
+
95
+ def emit_json(payload: object) -> None:
96
+ """Print a machine readable payload as unstyled JSON.
97
+
98
+ `--json` exists so a caller can read a value out of the output instead of scraping
99
+ prose, which makes an escape sequence anywhere in the payload a parse error. Rich's
100
+ JSON printer syntax-highlights what it writes, so the payload is serialized here and
101
+ printed through `emit_value`, which documents why nothing may style it.
102
+
103
+ Args:
104
+ payload (object): Any JSON-serializable value.
105
+ """
106
+ emit_value(json.dumps(payload, indent=2))
107
+
108
+
109
+ def require_vault() -> Path:
110
+ """Return an initialized vault root, or exit with the command that fixes it.
111
+
112
+ Every command needs the vault location before it can do anything else, so this is
113
+ the one place that turns an unset, missing, or uninitialized `SESSIONMEMORY_VAULT`
114
+ into a clean message instead of a traceback or a note scattered into the wrong place.
115
+
116
+ Returns:
117
+ Path: The resolved, initialized vault directory.
118
+
119
+ Raises:
120
+ Exit: When the variable is unset, names a missing directory, or names a
121
+ directory that `sessionmemory init` has never touched.
122
+ """
123
+ try:
124
+ vault = vault_root()
125
+ except VaultNotConfiguredError as error:
126
+ pp.error(str(error), details=["export SESSIONMEMORY_VAULT=/path/to/your/vault"])
127
+ raise typer.Exit(1) from error
128
+
129
+ if not is_initialized(vault):
130
+ # An empty directory is the ordinary bootstrap case, and `sessionmemory init` is safe
131
+ # for a caller, human or agent, to run there. A non-empty directory holding no
132
+ # marker is different: initializing it is a one-time human decision about a
133
+ # specific path, never something to hand an agent as its next action, so that
134
+ # case is described rather than phrased as a `run:` instruction to follow.
135
+ if is_empty(vault):
136
+ pp.error(f"{vault} is not a vault", details=["run: sessionmemory init"])
137
+ raise typer.Exit(1)
138
+
139
+ pp.error(
140
+ f"{vault} is not an initialized vault",
141
+ details=[
142
+ "it holds files but no _system/vault.toml marker",
143
+ (
144
+ "if this is an existing vault, a human can initialize it once by "
145
+ "hand with: sessionmemory init --force"
146
+ ),
147
+ ],
148
+ )
149
+ raise typer.Exit(1)
150
+
151
+ return vault
152
+
153
+
154
+ def fail(message: str, details: list[str] | None = None) -> NoReturn:
155
+ """Report a failure and exit non-zero.
156
+
157
+ Args:
158
+ message (str): The error line.
159
+ details (list[str] | None): Follow-up lines.
160
+
161
+ Raises:
162
+ Exit: Always, with code 1.
163
+ """
164
+ pp.error(message, details=details if details is not None else [])
165
+ raise typer.Exit(1)
166
+
167
+
168
+ def report_malformed_registry(vault: Path, error: registry.RegistryError) -> NoReturn:
169
+ """Report a malformed registry.toml and exit non-zero.
170
+
171
+ Args:
172
+ vault (Path): The vault root.
173
+ error (registry.RegistryError): The error raised while loading the registry.
174
+
175
+ Raises:
176
+ Exit: Always, with code 1.
177
+ """
178
+ path = vault / SYSTEM_DIR / registry.REGISTRY_FILE
179
+ pp.error(f"{path} is malformed: {error}")
180
+ raise typer.Exit(1) from error
181
+
182
+
183
+ def require_project(vault: Path, cwd: Path | None = None) -> str:
184
+ """Return the current project's slug, or exit with instructions.
185
+
186
+ Args:
187
+ vault (Path): The vault root.
188
+ cwd (Path | None): The directory to resolve, or None for the shell's.
189
+
190
+ Returns:
191
+ str: The resolved project slug.
192
+
193
+ Raises:
194
+ Exit: When the working directory is not a registered project.
195
+ """
196
+ try:
197
+ result = resolve_project(vault, (cwd if cwd is not None else Path.cwd()).resolve())
198
+ except registry.RegistryError as error:
199
+ report_malformed_registry(vault, error)
200
+
201
+ if not result.registered or result.slug is None:
202
+ pp.error(
203
+ "this directory is not a registered project",
204
+ details=["run: sessionmemory project --register"],
205
+ )
206
+ raise typer.Exit(1)
207
+ return result.slug
@@ -0,0 +1,71 @@
1
+ """The `delete` command: remove pages and files from the vault permanently.
2
+
3
+ No confirmation and no dry run. The vault is a git repository, so a regretted delete is
4
+ recovered from history, and a page that is wrong or spent has no other end.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ import typer
13
+ from nclutils import pp
14
+
15
+ from sessionmemory.commands._common import build_embedder, emit_json, fail, require_vault
16
+ from sessionmemory.lib import fieldindex, paths
17
+
18
+ if TYPE_CHECKING:
19
+ from sessionmemory.lib.embed import Embedder
20
+
21
+ PATHS = typer.Argument(..., help="Files to delete.")
22
+ JSON = typer.Option(False, "--json", help="Emit JSON instead of prose.") # noqa: FBT003
23
+
24
+
25
+ def _check_vault_boundary(targets: list[Path], vault: Path) -> None:
26
+ """Verify all targets are inside the vault; fail if any is outside."""
27
+ for target in targets:
28
+ resolved = target.resolve()
29
+ if not resolved.is_relative_to(vault):
30
+ fail(f"{target} is outside the vault", ["nothing was deleted"])
31
+
32
+
33
+ def _delete_targets(targets: list[Path]) -> list[dict[str, object]]:
34
+ """Delete each target and forget its index row if applicable."""
35
+ embedder: Embedder | None = None
36
+ records: list[dict[str, object]] = []
37
+ for target in targets:
38
+ # Unlink the path as given, not its resolved target, so deleting a
39
+ # symlink removes the link rather than the page it points at.
40
+ absolute = target if target.is_absolute() else Path.cwd() / target
41
+ deleted = absolute.is_file()
42
+ if deleted:
43
+ absolute.unlink()
44
+ if absolute.parent.name in paths.FIELD_DIRS:
45
+ embedder = embedder or build_embedder()
46
+ fieldindex.forget(absolute.parent, embedder, absolute.name)
47
+ records.append({"path": str(target), "deleted": deleted})
48
+ return records
49
+
50
+
51
+ def delete_command(targets: list[Path] = PATHS, *, as_json: bool = JSON) -> None:
52
+ """Delete files from the vault, dropping a page's index row with it.
53
+
54
+ Raises:
55
+ Exit: With code 1 when a path is outside the vault or does not exist.
56
+ """
57
+ vault = require_vault()
58
+ _check_vault_boundary(targets, vault)
59
+ records = _delete_targets(targets)
60
+
61
+ missing = [record["path"] for record in records if not record["deleted"]]
62
+ if as_json:
63
+ emit_json(records)
64
+ else:
65
+ for record in records:
66
+ if record["deleted"]:
67
+ pp.success(f"deleted {record['path']}")
68
+ if missing:
69
+ if as_json:
70
+ raise typer.Exit(1)
71
+ fail(f"not found: {', '.join(str(path) for path in missing)}")
@@ -0,0 +1,30 @@
1
+ """The `doctor` command: report what is off, never fail over it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from nclutils import pp
7
+
8
+ from sessionmemory.commands._common import build_embedder, emit_json, require_vault
9
+ from sessionmemory.lib import doctor
10
+
11
+ JSON = typer.Option(False, "--json", help="Emit JSON instead of prose.") # noqa: FBT003
12
+
13
+
14
+ def doctor_command(*, as_json: bool = JSON) -> None:
15
+ """Report pages, projects, and indexes worth a look. Always exits 0."""
16
+ vault = require_vault()
17
+ findings = doctor.run(vault, build_embedder())
18
+ if as_json:
19
+ emit_json([{"check": f.check, "path": f.path, "message": f.message} for f in findings])
20
+ return
21
+ if not findings:
22
+ pp.success("nothing to report")
23
+ return
24
+ pp.info(
25
+ f"{len(findings)} suggestion{'s' if len(findings) != 1 else ''}",
26
+ details=[
27
+ f"{f.check}: {f.path}: {f.message}" if f.path else f"{f.check}: {f.message}"
28
+ for f in findings
29
+ ],
30
+ )
@@ -0,0 +1,61 @@
1
+ """The `export` command: write a project's field as a .memoryfield.zip."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from nclutils import pp
9
+
10
+ from sessionmemory.commands._common import (
11
+ build_embedder,
12
+ emit_json,
13
+ fail,
14
+ require_project,
15
+ require_vault,
16
+ )
17
+ from sessionmemory.lib import export, field, paths
18
+
19
+ CWD = typer.Option(None, "--cwd", help="Directory to resolve the project from.")
20
+ LOGS = typer.Option(False, "--logs", help="Export the logs field instead of learnings.") # noqa: FBT003
21
+ OUTPUT = typer.Option(
22
+ None,
23
+ "--output",
24
+ help="Where to write the zip. Defaults to <slug>.memoryfield.zip in the current directory.",
25
+ )
26
+ JSON = typer.Option(False, "--json", help="Emit JSON instead of prose.") # noqa: FBT003
27
+
28
+
29
+ def export_command(
30
+ cwd: Path | None = CWD,
31
+ *,
32
+ logs: bool = LOGS,
33
+ output: Path | None = OUTPUT,
34
+ as_json: bool = JSON,
35
+ ) -> None:
36
+ """Write this project's field as a .memoryfield.zip."""
37
+ vault = require_vault()
38
+ slug = require_project(vault, cwd)
39
+ embedder = build_embedder()
40
+
41
+ field_directory = paths.logs_dir(vault, slug) if logs else paths.learnings_dir(vault, slug)
42
+
43
+ if output is None:
44
+ suffix = "-logs.memoryfield.zip" if logs else ".memoryfield.zip"
45
+ output = Path.cwd() / f"{slug}{suffix}"
46
+
47
+ if not as_json and not field_directory.is_dir():
48
+ pp.warning(f"{field_directory} does not exist; the archive holds no pages")
49
+
50
+ try:
51
+ result = export.export_field(field_directory, embedder, output)
52
+ except OSError as error:
53
+ fail(f"cannot write {output}: {error}")
54
+ pages = len(field.iter_pages(field_directory))
55
+
56
+ if as_json:
57
+ emit_json({"path": str(result), "pages": pages})
58
+ return
59
+
60
+ label = "page" if pages == 1 else "pages"
61
+ pp.success(f"exported {pages} {label}", details=[str(result)])
@@ -0,0 +1,88 @@
1
+ """The `init` command that creates a new vault."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # Typer resolves annotations from the module's globals to build its CLI, so this
6
+ # cannot move into a TYPE_CHECKING block.
7
+ from pathlib import Path # noqa: TC003
8
+
9
+ import typer
10
+ from nclutils import pp
11
+
12
+ from sessionmemory.commands._common import emit_json
13
+ from sessionmemory.lib.bootstrap import InitResult, NotAVaultError, initialize
14
+ from sessionmemory.lib.config import VaultNotConfiguredError, vault_root
15
+
16
+ DIRECTORY_ARGUMENT = typer.Argument(
17
+ None, help="Where to create the vault. Defaults to SESSIONMEMORY_VAULT."
18
+ )
19
+ FORCE_OPTION = typer.Option(
20
+ False, # noqa: FBT003
21
+ "--force",
22
+ help="Initialize a directory that already has contents. Nothing existing is overwritten.",
23
+ )
24
+ JSON_OPTION = typer.Option(False, "--json", help="Emit JSON instead of prose.") # noqa: FBT003
25
+
26
+
27
+ def _report(result: InitResult, *, as_json: bool) -> None:
28
+ """Report what initialization did.
29
+
30
+ Args:
31
+ result (InitResult): What `bootstrap.initialize` created and found already there.
32
+ as_json (bool): Emit a machine readable payload instead of prose.
33
+ """
34
+ if as_json:
35
+ emit_json(
36
+ {
37
+ "vault": str(result.vault),
38
+ "created": list(result.created),
39
+ "existed": list(result.existed),
40
+ }
41
+ )
42
+ return
43
+
44
+ if result.created:
45
+ pp.success(f"initialized {result.vault}", details=list(result.created))
46
+ else:
47
+ pp.info(f"{result.vault} is already initialized; nothing to create")
48
+
49
+ if result.existed:
50
+ pp.info(f"already present: {', '.join(result.existed)}")
51
+
52
+
53
+ def init(
54
+ directory: Path | None = DIRECTORY_ARGUMENT,
55
+ *,
56
+ force: bool = FORCE_OPTION,
57
+ as_json: bool = JSON_OPTION,
58
+ ) -> None:
59
+ """Create the files a new vault needs.
60
+
61
+ A vault cannot be created through `require_vault`, since that helper refuses an
62
+ uninitialized directory and this command is what fixes that. When no directory is
63
+ given, `SESSIONMEMORY_VAULT` is read directly instead.
64
+
65
+ Raises:
66
+ Exit: When no directory is given and the environment variable is unset or names
67
+ a missing directory, or when the target directory holds unrelated files and
68
+ `--force` is not given.
69
+ """
70
+ if directory is not None:
71
+ # Every other reader resolves the vault path, so a relative or symlinked path
72
+ # recorded verbatim names a different directory than the one this created.
73
+ vault = directory.expanduser().resolve()
74
+ vault.mkdir(parents=True, exist_ok=True)
75
+ else:
76
+ try:
77
+ vault = vault_root()
78
+ except VaultNotConfiguredError as error:
79
+ pp.error(str(error), details=["export SESSIONMEMORY_VAULT=/path/to/your/vault"])
80
+ raise typer.Exit(1) from error
81
+
82
+ try:
83
+ result = initialize(vault, force=force)
84
+ except NotAVaultError as error:
85
+ pp.error(str(error))
86
+ raise typer.Exit(1) from error
87
+
88
+ _report(result, as_json=as_json)
@@ -0,0 +1,25 @@
1
+ """The `inject` command: what a project's session should start with."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path # noqa: TC003
6
+
7
+ import typer
8
+
9
+ from sessionmemory.commands._common import emit_json, emit_value, require_project, require_vault
10
+ from sessionmemory.lib import inject
11
+
12
+ CWD = typer.Option(None, "--cwd", help="Directory to resolve the project from.")
13
+ COMMAND = typer.Option("sessionmemory", "--command", help="How the guidance names this CLI.")
14
+ JSON = typer.Option(False, "--json", help="Emit JSON instead of prose.") # noqa: FBT003
15
+
16
+
17
+ def inject_command(cwd: Path | None = CWD, command: str = COMMAND, *, as_json: bool = JSON) -> None:
18
+ """Print what this project's session should start with."""
19
+ vault = require_vault()
20
+ slug = require_project(vault, cwd)
21
+ injection = inject.build(vault, slug)
22
+ if as_json:
23
+ emit_json(inject.payload(injection, command=command))
24
+ return
25
+ emit_value(inject.render(injection, command=command))
@@ -0,0 +1,64 @@
1
+ """The `log` command: record this session's work in one upserted page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path # noqa: TC003
6
+
7
+ import typer
8
+ from nclutils import pp
9
+
10
+ from sessionmemory.commands._common import (
11
+ emit_json,
12
+ fail,
13
+ require_project,
14
+ require_vault,
15
+ resolve_body,
16
+ )
17
+ from sessionmemory.lib import field, log
18
+ from sessionmemory.lib.config import now, today
19
+
20
+ SESSION_ID = typer.Option(..., "--session-id", help="This session's identifier.")
21
+ TITLE = typer.Option(..., "--title", help="The log's title.")
22
+ SUMMARY = typer.Option("", "--summary", help="One sentence a search result shows.")
23
+ BODY = typer.Option("", "--body", help="Markdown body. Replaces what is there.")
24
+ BODY_FILE = typer.Option(None, "--body-file", help="Read the body from a file, or stdin for '-'.")
25
+ CWD = typer.Option(None, "--cwd", help="Directory to resolve the project from.")
26
+ JSON = typer.Option(False, "--json", help="Emit JSON instead of prose.") # noqa: FBT003
27
+
28
+
29
+ def log_command(
30
+ session_id: str = SESSION_ID,
31
+ title: str = TITLE,
32
+ summary: str = SUMMARY,
33
+ body: str = BODY,
34
+ body_file: Path | None = BODY_FILE,
35
+ cwd: Path | None = CWD,
36
+ *,
37
+ as_json: bool = JSON,
38
+ ) -> None:
39
+ """Record this session's work in the one page that belongs to it.
40
+
41
+ Raises:
42
+ Exit: When the directory is not a registered project or the page cannot be written.
43
+ """
44
+ body = resolve_body(body, body_file)
45
+ vault = require_vault()
46
+ slug = require_project(vault, cwd)
47
+ try:
48
+ result = log.upsert_log(
49
+ vault,
50
+ slug=slug,
51
+ session_id=session_id,
52
+ title=title,
53
+ summary=summary,
54
+ body=body,
55
+ now=now(),
56
+ today=today(),
57
+ )
58
+ except field.PageError as error:
59
+ fail(str(error))
60
+ action = "created" if result.created else "updated"
61
+ if as_json:
62
+ emit_json({"path": str(result.path), "action": action})
63
+ return
64
+ pp.success(f"{action} {result.path.name}", details=[str(result.path)])