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,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
|