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,102 @@
1
+ """Commands that create a page or a document and print the path to write into."""
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, paths
18
+ from sessionmemory.lib.config import now
19
+
20
+ app = typer.Typer(no_args_is_help=True, help="Create a learning, spec, or plan.")
21
+
22
+ TITLE = typer.Option(..., "--title", help="The title.")
23
+ SUMMARY = typer.Option(..., "--summary", help="One sentence a search result shows.")
24
+ BODY = typer.Option("", "--body", help="Markdown body.")
25
+ BODY_FILE = typer.Option(None, "--body-file", help="Read the body from a file, or stdin for '-'.")
26
+ CWD = typer.Option(None, "--cwd", help="Directory to resolve the project from.")
27
+ JSON = typer.Option(False, "--json", help="Emit JSON instead of prose.") # noqa: FBT003
28
+
29
+
30
+ def _report(path: Path, *, as_json: bool, uuid: str | None = None) -> None:
31
+ if as_json:
32
+ payload: dict[str, str] = {"path": str(path), "title": field.read_page(path).title}
33
+ if uuid is not None:
34
+ payload["uuid"] = uuid
35
+ emit_json(payload)
36
+ return
37
+ pp.success(f"created {path.name}", details=[str(path)])
38
+
39
+
40
+ @app.command("learning")
41
+ def new_learning(
42
+ title: str = TITLE,
43
+ summary: str = SUMMARY,
44
+ body: str = BODY,
45
+ body_file: Path | None = BODY_FILE,
46
+ cwd: Path | None = CWD,
47
+ *,
48
+ as_json: bool = JSON,
49
+ ) -> None:
50
+ """Create a memory page in this project's learnings field."""
51
+ body = resolve_body(body, body_file)
52
+ vault = require_vault()
53
+ slug = require_project(vault, cwd)
54
+ try:
55
+ path = field.new_page(
56
+ paths.learnings_dir(vault, slug), title=title, summary=summary, body=body, now=now()
57
+ )
58
+ except field.PageError as error:
59
+ fail(str(error))
60
+ _report(path, as_json=as_json, uuid=field.read_page(path).uuid)
61
+
62
+
63
+ def _new_document(folder: Path, title: str, body: str, *, as_json: bool) -> None:
64
+ try:
65
+ path = field.new_document(folder, title=title, body=body, now=now())
66
+ except field.PageError as error:
67
+ fail(str(error))
68
+ _report(path, as_json=as_json)
69
+
70
+
71
+ @app.command("spec")
72
+ def new_spec(
73
+ title: str = TITLE,
74
+ body: str = BODY,
75
+ body_file: Path | None = BODY_FILE,
76
+ cwd: Path | None = CWD,
77
+ *,
78
+ as_json: bool = JSON,
79
+ ) -> None:
80
+ """Create a spec for this project."""
81
+ vault = require_vault()
82
+ slug = require_project(vault, cwd)
83
+ _new_document(
84
+ paths.specs_dir(vault, slug), title, resolve_body(body, body_file), as_json=as_json
85
+ )
86
+
87
+
88
+ @app.command("plan")
89
+ def new_plan(
90
+ title: str = TITLE,
91
+ body: str = BODY,
92
+ body_file: Path | None = BODY_FILE,
93
+ cwd: Path | None = CWD,
94
+ *,
95
+ as_json: bool = JSON,
96
+ ) -> None:
97
+ """Create a plan for this project."""
98
+ vault = require_vault()
99
+ slug = require_project(vault, cwd)
100
+ _new_document(
101
+ paths.plans_dir(vault, slug), title, resolve_body(body, body_file), as_json=as_json
102
+ )
@@ -0,0 +1,321 @@
1
+ """Commands that answer and record which project a directory belongs to."""
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
+ emit_json,
12
+ fail,
13
+ report_malformed_registry,
14
+ require_vault,
15
+ )
16
+ from sessionmemory.lib import paths as paths_lib
17
+ from sessionmemory.lib import registry
18
+ from sessionmemory.lib.gitinfo import git_context
19
+ from sessionmemory.lib.ids import slugify
20
+ from sessionmemory.lib.resolve import resolve as resolve_project
21
+
22
+ CWD_OPTION = typer.Option(None, "--cwd", help="Directory to resolve. Defaults to the shell's.")
23
+ JSON_OPTION = typer.Option(False, "--json", help="Emit JSON instead of prose.") # noqa: FBT003
24
+ SLUG_OPTION = typer.Option(None, "--slug", help="Override the derived slug.")
25
+ REGISTER_OPTION = typer.Option(
26
+ False, # noqa: FBT003
27
+ "--register",
28
+ help="Create this directory's registry entry.",
29
+ )
30
+
31
+
32
+ def _target(cwd: Path | None) -> Path:
33
+ """Return the directory to operate on.
34
+
35
+ Args:
36
+ cwd (Path | None): An explicit directory, or None for the current one.
37
+
38
+ Returns:
39
+ Path: The resolved directory.
40
+ """
41
+ return (cwd or Path.cwd()).resolve()
42
+
43
+
44
+ def _slug_from_remotes(remotes: tuple[str, ...]) -> str | None:
45
+ """Return the repository name from the first normalized remote.
46
+
47
+ The remote is preferred over the directory name because a checkout can be renamed
48
+ or cloned into a differently named directory while the remote stays put.
49
+
50
+ Args:
51
+ remotes (tuple[str, ...]): Normalized remote keys, as `host/owner/name`.
52
+
53
+ Returns:
54
+ str | None: The repository name, or None when there are no remotes.
55
+ """
56
+ for remote in remotes:
57
+ name = remote.rstrip("/").rsplit("/", 1)[-1]
58
+ if name:
59
+ return name
60
+ return None
61
+
62
+
63
+ def _derived_slug(remotes: tuple[str, ...], root: Path) -> str:
64
+ """Slugify the best name available for a project the caller did not name.
65
+
66
+ A derived slug has to satisfy the same rule an explicit `--slug` must already meet,
67
+ since either becomes a directory name under `projects/`. Explicit input is refused
68
+ rather than corrected because a caller must not silently get a slug it did not ask
69
+ for, but a remote or directory name has no caller to surprise, so it is slugified:
70
+ `My.Repo` becomes `my-repo`. A remote is preferred over the directory name because a
71
+ checkout can be renamed while the remote stays put, and the directory name is the
72
+ only source a project outside git has.
73
+
74
+ Args:
75
+ remotes (tuple[str, ...]): Normalized remote keys, as `host/owner/name`.
76
+ root (Path): The project root being registered.
77
+
78
+ Returns:
79
+ str: The slug to register under.
80
+
81
+ Raises:
82
+ Exit: When neither the remote nor the directory name yields a slug.
83
+ """
84
+ for candidate in (_slug_from_remotes(remotes), root.name):
85
+ if not candidate:
86
+ continue
87
+ try:
88
+ return slugify(candidate)
89
+ except ValueError:
90
+ continue
91
+
92
+ pp.error(
93
+ f"cannot derive a slug from {root.name or str(root)!r}",
94
+ details=["name it explicitly, for example: --slug my-project"],
95
+ )
96
+ raise typer.Exit(1)
97
+
98
+
99
+ def _require_slug_form(slug: str) -> None:
100
+ """Refuse a `--slug` that is not already the form the vault files projects under.
101
+
102
+ An explicit value is rejected rather than quietly corrected: a caller that asked
103
+ for one slug must not silently end up with another, and the slug becomes a
104
+ directory name under `projects/`, so anything outside the slug alphabet is either a
105
+ path escape or a name that will not match its own notes.
106
+
107
+ Args:
108
+ slug (str): The value passed to `--slug`.
109
+
110
+ Raises:
111
+ Exit: When the value is blank or is not its own slugification.
112
+ """
113
+ if not slug.strip():
114
+ pp.error("--slug must not be empty")
115
+ raise typer.Exit(1)
116
+
117
+ try:
118
+ normalized: str | None = slugify(slug)
119
+ except ValueError:
120
+ normalized = None
121
+
122
+ if normalized != slug:
123
+ pp.error(
124
+ "--slug must be lowercase letters, digits, and hyphens",
125
+ details=[f"try: --slug {normalized}"] if normalized else [],
126
+ )
127
+ raise typer.Exit(1)
128
+
129
+
130
+ def _entry_root(vault: Path, slug: str, fallback: Path) -> str:
131
+ """Return the root the registry records for `slug`.
132
+
133
+ A directory outside git resolves by longest path prefix, so the directory asked
134
+ about is routinely several levels below the one the entry was registered at, and
135
+ reporting the former would name a root the registry does not hold.
136
+
137
+ Args:
138
+ vault (Path): The vault root.
139
+ slug (str): The project the entry belongs to.
140
+ fallback (Path): The directory asked about, for an entry that has since gone.
141
+
142
+ Returns:
143
+ str: The recorded root.
144
+ """
145
+ entry = registry.load(vault).get(slug)
146
+ return entry.root if entry else str(fallback)
147
+
148
+
149
+ def _report(vault: Path, target: Path, action: str | None, *, as_json: bool) -> None:
150
+ """Print the entry as it now stands, and exit non-zero when there is none.
151
+
152
+ This is the command's only stdout in either mode, so a write is reported by showing
153
+ its result rather than by a line of its own.
154
+
155
+ Args:
156
+ vault (Path): The vault root.
157
+ target (Path): The directory to report on.
158
+ action (str | None): The verb naming a write that just happened, or None for a
159
+ plain view.
160
+ as_json (bool): Emit the payload instead of prose.
161
+
162
+ Raises:
163
+ Exit: With code 1 when the directory is not registered.
164
+ """
165
+ try:
166
+ result = resolve_project(vault, target)
167
+ except registry.RegistryError as error:
168
+ report_malformed_registry(vault, error)
169
+
170
+ project_paths = (
171
+ paths_lib.project_paths(vault, result.slug) if result.registered and result.slug else {}
172
+ )
173
+
174
+ payload = {
175
+ "slug": result.slug,
176
+ "registered": result.registered,
177
+ "repo_root": str(result.repo_root) if result.repo_root else None,
178
+ "is_worktree": result.is_worktree,
179
+ "project_dir": (
180
+ str(paths_lib.project_dir(vault, result.slug))
181
+ if result.registered and result.slug
182
+ else None
183
+ ),
184
+ "paths": project_paths,
185
+ }
186
+
187
+ if as_json:
188
+ emit_json(payload)
189
+ elif result.registered:
190
+ headline = f"{action} {result.slug!r}" if action else result.slug or ""
191
+ root = _entry_root(vault, result.slug, target) if result.slug else str(target)
192
+ pp.success(headline, details=[f"root: {root}"])
193
+ else:
194
+ pp.error(
195
+ f"{target} is not a registered project",
196
+ details=["run: sessionmemory project --register"],
197
+ )
198
+
199
+ if not result.registered:
200
+ raise typer.Exit(1)
201
+
202
+
203
+ def _register(
204
+ vault: Path,
205
+ target: Path,
206
+ slug: str | None,
207
+ ) -> None:
208
+ """Record this directory in the vault registry.
209
+
210
+ Args:
211
+ vault (Path): The vault root.
212
+ target (Path): The directory to register.
213
+ slug (str | None): An explicit slug, or None to derive one.
214
+
215
+ Raises:
216
+ Exit: When the target is not an existing directory, when git could not determine
217
+ whether it is a repository, when it is a bare repository, when it is already
218
+ registered, when its slug is already in use, or when no slug can be derived
219
+ from its remote or its directory name.
220
+ """
221
+ # A slug is permanent and its notes have to be moved by hand, so a mistyped --cwd is
222
+ # the one accident registration must not accept.
223
+ if not target.is_dir():
224
+ pp.error(
225
+ f"{target} is not a directory",
226
+ details=["--cwd takes a directory that exists"],
227
+ )
228
+ raise typer.Exit(1)
229
+
230
+ context = git_context(target)
231
+
232
+ # An entry recorded while git was unavailable stays wrong once git works again: a
233
+ # repository would be filed as a plain directory, with no remote and the wrong root.
234
+ if not context.git_answered:
235
+ pp.error(
236
+ f"cannot tell whether {target} is a git repository",
237
+ details=[
238
+ "git did not answer: it may be missing, or it may refuse this directory",
239
+ "fix that first, since a slug is permanent once notes carry it",
240
+ ],
241
+ )
242
+ raise typer.Exit(1)
243
+
244
+ # A bare repository has no working tree, so no working directory belongs to it and
245
+ # nothing filed under it could ever be resolved back.
246
+ if context.is_bare:
247
+ pp.error(
248
+ f"{target} is a bare repository",
249
+ details=["register a working checkout instead"],
250
+ )
251
+ raise typer.Exit(1)
252
+
253
+ # A directory outside git has no remote and no repository root, so the directory
254
+ # itself is the only key it can be registered and resolved under.
255
+ root = context.repo_root or target
256
+
257
+ if slug is not None:
258
+ _require_slug_form(slug)
259
+
260
+ try:
261
+ projects = registry.load(vault)
262
+ except registry.RegistryError as error:
263
+ report_malformed_registry(vault, error)
264
+
265
+ existing = registry.find_by_remote(projects, context.remotes) or registry.find_by_root(
266
+ projects, str(root)
267
+ )
268
+ if existing:
269
+ pp.error(f"already registered as {existing.slug!r}")
270
+ raise typer.Exit(1)
271
+
272
+ resolved_slug = slug if slug is not None else _derived_slug(context.remotes, root)
273
+ if resolved_slug in projects:
274
+ pp.error(f"slug {resolved_slug!r} is already in use; pass --slug")
275
+ raise typer.Exit(1)
276
+
277
+ # Read before the new entry is inserted, or it would match itself.
278
+ enclosing = registry.find_by_path_prefix(projects, str(root))
279
+
280
+ projects[resolved_slug] = registry.Project(
281
+ slug=resolved_slug,
282
+ remotes=context.remotes,
283
+ root=str(root),
284
+ )
285
+ registry.save(vault, projects)
286
+
287
+ # Registering inside another project is legitimate, but an accidental one looks
288
+ # exactly like a deliberate one, so the nesting is reported rather than assumed.
289
+ if enclosing is not None:
290
+ pp.warning(
291
+ f"{root} sits inside project {enclosing.slug!r}",
292
+ details=[
293
+ f"{resolved_slug!r} now wins for paths under {root}",
294
+ f"remove it from the registry if you meant to use {enclosing.slug!r}",
295
+ ],
296
+ )
297
+
298
+
299
+ def project_command(
300
+ cwd: Path | None = CWD_OPTION,
301
+ slug: str | None = SLUG_OPTION,
302
+ *,
303
+ register: bool = REGISTER_OPTION,
304
+ as_json: bool = JSON_OPTION,
305
+ ) -> None:
306
+ """Report this directory's project, or create its registry entry.
307
+
308
+ Raises:
309
+ Exit: When `--slug` is given without `--register`, when `--register` names a
310
+ directory that does not exist, is a bare repository, or is already
311
+ registered, or with code 1 when the directory has no project.
312
+ """
313
+ if slug is not None and not register:
314
+ fail("--slug applies only with --register", ["a slug is permanent once notes carry it"])
315
+ vault = require_vault()
316
+ target = _target(cwd)
317
+ action: str | None = None
318
+ if register:
319
+ _register(vault, target, slug)
320
+ action = "registered"
321
+ _report(vault, target, action, as_json=as_json)
@@ -0,0 +1,46 @@
1
+ """The `reindex` command: rebuild a project's field indexes from its pages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict
6
+ from pathlib import Path # noqa: TC003
7
+
8
+ import typer
9
+ from nclutils import pp
10
+
11
+ from sessionmemory.commands._common import (
12
+ build_embedder,
13
+ emit_json,
14
+ require_project,
15
+ require_vault,
16
+ )
17
+ from sessionmemory.lib import fieldindex, paths
18
+
19
+ CWD = typer.Option(None, "--cwd", help="Directory to resolve the project from.")
20
+ JSON = typer.Option(False, "--json", help="Emit JSON instead of prose.") # noqa: FBT003
21
+
22
+
23
+ def reindex_command(cwd: Path | None = CWD, *, as_json: bool = JSON) -> None:
24
+ """Bring this project's learnings and logs indexes up to date."""
25
+ vault = require_vault()
26
+ slug = require_project(vault, cwd)
27
+ embedder = build_embedder()
28
+ fields = {
29
+ "learnings": paths.learnings_dir(vault, slug),
30
+ "logs": paths.logs_dir(vault, slug),
31
+ }
32
+ if as_json:
33
+ emit_json(
34
+ {
35
+ name: asdict(fieldindex.refresh(directory, embedder))
36
+ for name, directory in fields.items()
37
+ }
38
+ )
39
+ return
40
+ with pp.step("reindexing") as step:
41
+ for name, directory in fields.items():
42
+ result = fieldindex.refresh(directory, embedder)
43
+ step.sub(
44
+ f"{name}: {result.added} added, {result.updated} updated, "
45
+ f"{result.removed} removed, {result.unchanged} unchanged"
46
+ )
@@ -0,0 +1,83 @@
1
+ """The `search` command: the one read the agent cannot do with its own tools."""
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
+ build_embedder,
12
+ emit_json,
13
+ emit_value,
14
+ fail,
15
+ require_project,
16
+ require_vault,
17
+ )
18
+ from sessionmemory.lib import fieldindex, paths
19
+
20
+ QUERY = typer.Argument(..., help="What to look for, in plain words.")
21
+ LOGS = typer.Option(False, "--logs", help="Search past session logs instead of learnings.") # noqa: FBT003
22
+ LIMIT = typer.Option(10, "--limit", help="Maximum number of results.")
23
+ MAX_DISTANCE = typer.Option(
24
+ fieldindex.DEFAULT_MAX_DISTANCE,
25
+ "--max-distance",
26
+ min=0.0,
27
+ max=2.0,
28
+ help="Farthest cosine distance that still counts as a hit.",
29
+ )
30
+ READ = typer.Option(False, "--read", help="Print each hit's whole file under its path.") # noqa: FBT003
31
+ CWD = typer.Option(None, "--cwd", help="Directory to resolve the project from.")
32
+ JSON = typer.Option(False, "--json", help="Emit JSON instead of prose.") # noqa: FBT003
33
+
34
+
35
+ def search_command(
36
+ query: str = QUERY,
37
+ *,
38
+ logs: bool = LOGS,
39
+ limit: int = LIMIT,
40
+ max_distance: float = MAX_DISTANCE,
41
+ read: bool = READ,
42
+ cwd: Path | None = CWD,
43
+ as_json: bool = JSON,
44
+ ) -> None:
45
+ """Find the pages nearest in meaning to the query, nearest first."""
46
+ if limit < 1:
47
+ fail("--limit must be at least 1")
48
+ vault = require_vault()
49
+ slug = require_project(vault, cwd)
50
+ directory = paths.logs_dir(vault, slug) if logs else paths.learnings_dir(vault, slug)
51
+ hits = fieldindex.search(
52
+ directory, build_embedder(), query, limit=limit, max_distance=max_distance
53
+ )
54
+
55
+ if as_json:
56
+ payload = []
57
+ for hit in hits:
58
+ entry: dict[str, object] = {
59
+ "path": str(hit.path),
60
+ "title": hit.title,
61
+ "summary": hit.summary,
62
+ "distance": hit.distance,
63
+ }
64
+ if read:
65
+ entry["content"] = _content(hit.path)
66
+ payload.append(entry)
67
+ emit_json(payload)
68
+ return
69
+ if not hits:
70
+ pp.info(
71
+ f"no results within distance {max_distance}; raise --max-distance to see farther pages"
72
+ )
73
+ return
74
+ # A path, a title, a summary, and a page are all things a caller copies or parses,
75
+ # so nothing here may be styled.
76
+ if read:
77
+ emit_value("\n\n".join(f"{hit.path}\n{_content(hit.path).rstrip()}" for hit in hits))
78
+ return
79
+ emit_value("\n\n".join(f"{hit.path}\n {hit.title}\n {hit.summary}" for hit in hits))
80
+
81
+
82
+ def _content(path: Path) -> str:
83
+ return path.read_bytes().decode("utf-8", errors="replace")
@@ -0,0 +1 @@
1
+ """Session memory CLI library."""
@@ -0,0 +1,71 @@
1
+ """Replace a file's contents in one step, or not at all.
2
+
3
+ Every file this CLI owns is the source of truth for something that cannot be rebuilt: a
4
+ note is knowledge no index holds a copy of, and the registry is the only record of which
5
+ slug a project's existing notes were filed under. A write interrupted by a crash, a full
6
+ disk, or a signal must therefore leave the previous contents in place rather than a
7
+ truncated file, so contents are written to a temporary file beside the target and renamed
8
+ over it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import tempfile
15
+ from pathlib import Path
16
+
17
+ DEFAULT_UMASK = 0o022
18
+
19
+
20
+ def write_text(path: Path, text: str) -> None:
21
+ """Write `text` to `path`, replacing it in a single rename.
22
+
23
+ Args:
24
+ path (Path): The destination file. Its parent directory is created if needed.
25
+ text (str): The complete contents to write.
26
+ """
27
+ path.parent.mkdir(parents=True, exist_ok=True)
28
+
29
+ handle, name = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
30
+ temporary = Path(name)
31
+ try:
32
+ with os.fdopen(handle, "w", encoding="utf-8") as file:
33
+ file.write(text)
34
+ # mkstemp creates the file mode 0600; match the umask default so a CLI-written
35
+ # file is indistinguishable from one made by hand, since Path.replace preserves
36
+ # the source file's mode across the rename.
37
+ umask = os.umask(DEFAULT_UMASK)
38
+ os.umask(umask)
39
+ temporary.chmod(0o666 & ~umask)
40
+ temporary.replace(path)
41
+ except BaseException:
42
+ temporary.unlink(missing_ok=True)
43
+ raise
44
+
45
+
46
+ def claim(path: Path) -> bool:
47
+ """Create `path` as an empty file, or report that someone else already has it.
48
+
49
+ Exclusive creation is the only way to take a filename that cannot lose a race. A
50
+ check followed by a write leaves a window in which another process takes the same
51
+ name, and `write_text` renames over whatever is there, so the loser's note is
52
+ destroyed with nothing reported. `Path.touch(exist_ok=False)` is `O_CREAT |
53
+ O_EXCL | O_WRONLY` under the hood, so this needs no lock file to clean up after a
54
+ crash.
55
+
56
+ On a case-insensitive filesystem, which is the default on macOS, this also refuses a
57
+ name differing only in case from an existing file. That is the wanted behavior:
58
+ those two names cannot coexist there.
59
+
60
+ Args:
61
+ path (Path): The file to claim. Its parent directory is created if needed.
62
+
63
+ Returns:
64
+ bool: True when this call created the file, False when it already existed.
65
+ """
66
+ path.parent.mkdir(parents=True, exist_ok=True)
67
+ try:
68
+ path.touch(exist_ok=False)
69
+ except FileExistsError:
70
+ return False
71
+ return True