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.
- sessionmemory/__init__.py +1 -0
- sessionmemory/cli.py +62 -0
- sessionmemory/commands/__init__.py +1 -0
- sessionmemory/commands/_common.py +207 -0
- sessionmemory/commands/delete.py +71 -0
- sessionmemory/commands/doctor.py +30 -0
- sessionmemory/commands/export.py +61 -0
- sessionmemory/commands/init.py +88 -0
- sessionmemory/commands/inject.py +25 -0
- sessionmemory/commands/log.py +64 -0
- sessionmemory/commands/new.py +102 -0
- sessionmemory/commands/project.py +321 -0
- sessionmemory/commands/reindex.py +46 -0
- sessionmemory/commands/search.py +83 -0
- sessionmemory/lib/__init__.py +1 -0
- sessionmemory/lib/atomic.py +71 -0
- sessionmemory/lib/bootstrap.py +138 -0
- sessionmemory/lib/config.py +102 -0
- sessionmemory/lib/doctor.py +186 -0
- sessionmemory/lib/embed.py +123 -0
- sessionmemory/lib/export.py +25 -0
- sessionmemory/lib/field.py +181 -0
- sessionmemory/lib/fieldindex.py +214 -0
- sessionmemory/lib/frontmatter.py +163 -0
- sessionmemory/lib/gitinfo.py +177 -0
- sessionmemory/lib/ids.py +101 -0
- sessionmemory/lib/inject.py +97 -0
- sessionmemory/lib/log.py +73 -0
- sessionmemory/lib/paths.py +77 -0
- sessionmemory/lib/registry.py +269 -0
- sessionmemory/lib/resolve.py +75 -0
- sessionmemory-0.2.0.dist-info/METADATA +250 -0
- sessionmemory-0.2.0.dist-info/RECORD +35 -0
- sessionmemory-0.2.0.dist-info/WHEEL +4 -0
- sessionmemory-0.2.0.dist-info/entry_points.txt +3 -0
|
@@ -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)])
|