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,269 @@
|
|
|
1
|
+
"""The map from a checkout on disk to a project slug in the vault.
|
|
2
|
+
|
|
3
|
+
Remotes are the primary key because they survive a repository being moved or cloned
|
|
4
|
+
again, which a filesystem path does not. Normalization strips everything that varies
|
|
5
|
+
between equivalent spellings of one URL: scheme, credentials, port, trailing `.git`,
|
|
6
|
+
and case. The port strip is anchored to the host segment (before the first `/`) so a
|
|
7
|
+
path that happens to contain a colon followed by digits is never mistaken for one.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import tomllib
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
import tomli_w
|
|
19
|
+
|
|
20
|
+
from sessionmemory.lib import atomic, ids
|
|
21
|
+
from sessionmemory.lib.paths import SYSTEM_DIR
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Iterable, Mapping
|
|
25
|
+
|
|
26
|
+
REGISTRY_FILE = "registry.toml"
|
|
27
|
+
|
|
28
|
+
_SCHEME = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE)
|
|
29
|
+
_SCP_LIKE = re.compile(r"^(?:[^@/]+@)?([^:/]+):(.+)$")
|
|
30
|
+
_CREDENTIALS = re.compile(r"^[^@/]+@")
|
|
31
|
+
_PORT = re.compile(r":\d+$")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RegistryError(ValueError):
|
|
35
|
+
"""Raised when registry.toml cannot be read, whether from a syntax error or a malformed shape.
|
|
36
|
+
|
|
37
|
+
A single exception for both failure modes lets callers report a hand-edited file's
|
|
38
|
+
problems to the user without risking that a genuine bug elsewhere (which would
|
|
39
|
+
raise some other exception type) gets misreported as "your file is broken".
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Project:
|
|
45
|
+
"""One registered project."""
|
|
46
|
+
|
|
47
|
+
slug: str
|
|
48
|
+
remotes: tuple[str, ...]
|
|
49
|
+
root: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def normalize_remote(url: str) -> str:
|
|
53
|
+
"""Reduce a git remote URL to a stable comparison key.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
url (str): A remote URL in any of git's supported spellings.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
str: The normalized `host/path` key, lowercased and without a `.git` suffix.
|
|
60
|
+
"""
|
|
61
|
+
candidate = url.strip()
|
|
62
|
+
|
|
63
|
+
if _SCHEME.match(candidate):
|
|
64
|
+
candidate = _SCHEME.sub("", candidate, count=1)
|
|
65
|
+
else:
|
|
66
|
+
scp = _SCP_LIKE.match(candidate)
|
|
67
|
+
if scp:
|
|
68
|
+
candidate = f"{scp.group(1)}/{scp.group(2)}"
|
|
69
|
+
|
|
70
|
+
candidate = _CREDENTIALS.sub("", candidate, count=1)
|
|
71
|
+
|
|
72
|
+
# Strip a port only from the host segment, so a path that happens to
|
|
73
|
+
# contain ":digits" past the first slash is never mistaken for one.
|
|
74
|
+
host, sep, rest = candidate.partition("/")
|
|
75
|
+
host = _PORT.sub("", host, count=1)
|
|
76
|
+
candidate = f"{host}{sep}{rest}"
|
|
77
|
+
|
|
78
|
+
candidate = candidate.removesuffix(".git").strip("/")
|
|
79
|
+
|
|
80
|
+
return candidate.lower()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _string_tuple(entry: dict[str, object], key: str, slug: str) -> tuple[str, ...]:
|
|
84
|
+
"""Read an optional list-of-strings field from a raw registry entry.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
entry (dict[str, object]): The raw table for one registry entry.
|
|
88
|
+
key (str): The field name to read.
|
|
89
|
+
slug (str): The entry's project slug, for error messages.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
tuple[str, ...]: The field's values, or an empty tuple when absent.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
RegistryError: If the field is present but is not a list of strings.
|
|
96
|
+
"""
|
|
97
|
+
value = entry.get(key, [])
|
|
98
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
99
|
+
msg = f"registry entry {slug!r} has a malformed {key!r} field"
|
|
100
|
+
raise RegistryError(msg)
|
|
101
|
+
return tuple(value)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load(vault: Path) -> dict[str, Project]:
|
|
105
|
+
"""Read the registry.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
vault (Path): The vault root.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
dict[str, Project]: Projects keyed by slug. Empty when the file is absent.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
RegistryError: If the file is not valid TOML, the top-level `projects` table
|
|
115
|
+
is malformed, a registry entry is not a table, or one of its fields has
|
|
116
|
+
the wrong type.
|
|
117
|
+
"""
|
|
118
|
+
path = vault / SYSTEM_DIR / REGISTRY_FILE
|
|
119
|
+
if not path.is_file():
|
|
120
|
+
return {}
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
124
|
+
except tomllib.TOMLDecodeError as error:
|
|
125
|
+
msg = f"registry is not valid TOML: {error}"
|
|
126
|
+
raise RegistryError(msg) from error
|
|
127
|
+
|
|
128
|
+
raw_projects = raw.get("projects", {})
|
|
129
|
+
if not isinstance(raw_projects, dict):
|
|
130
|
+
msg = f"registry 'projects' must be a table, got {type(raw_projects).__name__}"
|
|
131
|
+
raise RegistryError(msg)
|
|
132
|
+
|
|
133
|
+
projects: dict[str, Project] = {}
|
|
134
|
+
for slug, entry in raw_projects.items():
|
|
135
|
+
if not isinstance(entry, dict):
|
|
136
|
+
msg = f"registry entry {slug!r} must be a table, got {type(entry).__name__}"
|
|
137
|
+
raise RegistryError(msg)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
slug_shaped = slug == ids.slugify(slug)
|
|
141
|
+
except ValueError:
|
|
142
|
+
slug_shaped = False
|
|
143
|
+
if not slug_shaped:
|
|
144
|
+
# A slug names a directory under projects/, so one that survives no
|
|
145
|
+
# slugification, or slugifies to something else, could otherwise walk
|
|
146
|
+
# a project's files outside the vault.
|
|
147
|
+
msg = f"registry entry {slug!r} is not a valid project slug"
|
|
148
|
+
raise RegistryError(msg)
|
|
149
|
+
|
|
150
|
+
root = entry.get("root", "")
|
|
151
|
+
if not isinstance(root, str):
|
|
152
|
+
msg = f"registry entry {slug!r} has a malformed 'root' field"
|
|
153
|
+
raise RegistryError(msg)
|
|
154
|
+
|
|
155
|
+
projects[slug] = Project(
|
|
156
|
+
slug=slug,
|
|
157
|
+
remotes=_string_tuple(entry, "remotes", slug),
|
|
158
|
+
root=root,
|
|
159
|
+
)
|
|
160
|
+
return projects
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def save(vault: Path, projects: Mapping[str, Project]) -> None:
|
|
164
|
+
"""Write the registry, replacing whatever was there.
|
|
165
|
+
|
|
166
|
+
The write is atomic. A slug is permanent once notes carry it, and this file is the
|
|
167
|
+
only record of which slug a project's existing notes were filed under, so a write
|
|
168
|
+
interrupted partway through has to leave the previous mapping intact.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
vault (Path): The vault root.
|
|
172
|
+
projects (Mapping[str, Project]): Projects keyed by slug.
|
|
173
|
+
"""
|
|
174
|
+
path = vault / SYSTEM_DIR / REGISTRY_FILE
|
|
175
|
+
|
|
176
|
+
document = {
|
|
177
|
+
"projects": {
|
|
178
|
+
slug: {
|
|
179
|
+
"remotes": list(project.remotes),
|
|
180
|
+
"root": project.root,
|
|
181
|
+
}
|
|
182
|
+
for slug, project in sorted(projects.items())
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
atomic.write_text(path, tomli_w.dumps(document))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def find_by_remote(projects: Mapping[str, Project], remotes: Iterable[str]) -> Project | None:
|
|
189
|
+
"""Return the project sharing any remote with `remotes`.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
projects (Mapping[str, Project]): The loaded registry.
|
|
193
|
+
remotes (Iterable[str]): Normalized remote keys from the current checkout.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Project | None: The matching project, or None.
|
|
197
|
+
"""
|
|
198
|
+
wanted = set(remotes)
|
|
199
|
+
for project in projects.values():
|
|
200
|
+
if wanted & set(project.remotes):
|
|
201
|
+
return project
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def find_by_root(projects: Mapping[str, Project], root: str) -> Project | None:
|
|
206
|
+
"""Return the project whose recorded repository root equals `root`.
|
|
207
|
+
|
|
208
|
+
Both sides are resolved before comparison, so a symlinked path (`/tmp` on macOS,
|
|
209
|
+
for instance) matches a registry entry regardless of which spelling either side
|
|
210
|
+
happens to use. A stored root that is not absolute is skipped rather than resolved,
|
|
211
|
+
since resolving it would measure it from wherever the process happens to be running,
|
|
212
|
+
which would make the answer depend on the caller's working directory.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
projects (Mapping[str, Project]): The loaded registry.
|
|
216
|
+
root (str): An absolute repository root.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Project | None: The matching project, or None.
|
|
220
|
+
"""
|
|
221
|
+
if not root:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
target = Path(root).resolve()
|
|
225
|
+
for project in projects.values():
|
|
226
|
+
if not project.root or not Path(project.root).is_absolute():
|
|
227
|
+
continue
|
|
228
|
+
if Path(project.root).resolve() == target:
|
|
229
|
+
return project
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def find_by_path_prefix(projects: Mapping[str, Project], path: str) -> Project | None:
|
|
234
|
+
"""Return the registered project containing `path`, preferring the deepest root.
|
|
235
|
+
|
|
236
|
+
This is what resolves a directory anywhere inside a project whose root is the only
|
|
237
|
+
key it has, such as a project that is not a git repository. It is a broad key, and
|
|
238
|
+
`resolve` documents the narrow conditions under which it applies one. Both sides are
|
|
239
|
+
resolved before comparison for the same reason `find_by_root` does it, and a stored
|
|
240
|
+
root that is not absolute is skipped rather than resolved, since resolving it would
|
|
241
|
+
measure it from wherever the process happens to be running. The deepest matching root
|
|
242
|
+
wins, so a project nested inside another resolves as the inner one. Ties, which only a
|
|
243
|
+
hand-edited registry can produce, break on slug order to keep the answer stable across
|
|
244
|
+
runs.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
projects (Mapping[str, Project]): The loaded registry.
|
|
248
|
+
path (str): An absolute directory.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
Project | None: The innermost project containing `path`, or None.
|
|
252
|
+
"""
|
|
253
|
+
if not path:
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
target = Path(path).resolve()
|
|
257
|
+
best: Project | None = None
|
|
258
|
+
best_depth = -1
|
|
259
|
+
for project in sorted(projects.values(), key=lambda candidate: candidate.slug):
|
|
260
|
+
if not project.root or not Path(project.root).is_absolute():
|
|
261
|
+
continue
|
|
262
|
+
root = Path(project.root).resolve()
|
|
263
|
+
if not target.is_relative_to(root):
|
|
264
|
+
continue
|
|
265
|
+
depth = len(root.parts)
|
|
266
|
+
if depth > best_depth:
|
|
267
|
+
best = project
|
|
268
|
+
best_depth = depth
|
|
269
|
+
return best
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Answer "which project am I in".
|
|
2
|
+
|
|
3
|
+
Three keys are tried in order and the first hit wins: the normalized git remote, the
|
|
4
|
+
repository root, then a longest-prefix match of `cwd` against every registered root.
|
|
5
|
+
|
|
6
|
+
The third key applies only where git has positively ruled out a repository. A git
|
|
7
|
+
repository is its own project boundary, so an unregistered one is unregistered rather
|
|
8
|
+
than absorbed by whichever directory happens to contain it. Letting the path walk run
|
|
9
|
+
inside a repository would file a fresh checkout's notes under its parent directory's
|
|
10
|
+
project with nothing reported, and a slug is permanent once notes carry it. The same
|
|
11
|
+
reasoning bars the walk when git could not answer, since an unanswered question is not
|
|
12
|
+
an answer of "no repository", and when the repository is bare, since it has no working
|
|
13
|
+
tree for a working directory to belong to.
|
|
14
|
+
|
|
15
|
+
A worktree always resolves to its parent project, because the repository root comes from
|
|
16
|
+
`--git-common-dir`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
from sessionmemory.lib import registry
|
|
25
|
+
from sessionmemory.lib.gitinfo import git_context
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Resolution:
|
|
33
|
+
"""Which project a working directory belongs to."""
|
|
34
|
+
|
|
35
|
+
slug: str | None
|
|
36
|
+
registered: bool
|
|
37
|
+
repo_root: Path | None
|
|
38
|
+
is_worktree: bool
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve(vault: Path, cwd: Path) -> Resolution:
|
|
42
|
+
"""Map a working directory to a registered project.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
vault (Path): The vault root.
|
|
46
|
+
cwd (Path): The directory to resolve.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Resolution: The match, or an unregistered result carrying what git did know.
|
|
50
|
+
"""
|
|
51
|
+
context = git_context(cwd)
|
|
52
|
+
projects = registry.load(vault)
|
|
53
|
+
|
|
54
|
+
found = registry.find_by_remote(projects, context.remotes)
|
|
55
|
+
|
|
56
|
+
if found is None and context.repo_root is not None:
|
|
57
|
+
found = registry.find_by_root(projects, str(context.repo_root))
|
|
58
|
+
|
|
59
|
+
if found is None and context.repo_root is None and context.git_answered and not context.is_bare:
|
|
60
|
+
found = registry.find_by_path_prefix(projects, str(cwd.resolve()))
|
|
61
|
+
|
|
62
|
+
if found is None:
|
|
63
|
+
return Resolution(
|
|
64
|
+
slug=None,
|
|
65
|
+
registered=False,
|
|
66
|
+
repo_root=context.repo_root,
|
|
67
|
+
is_worktree=context.is_worktree,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return Resolution(
|
|
71
|
+
slug=found.slug,
|
|
72
|
+
registered=True,
|
|
73
|
+
repo_root=context.repo_root,
|
|
74
|
+
is_worktree=context.is_worktree,
|
|
75
|
+
)
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: sessionmemory
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Durable memory for coding agents, one folder of searchable pages per project.
|
|
5
|
+
Author: Nathaniel Landau
|
|
6
|
+
Author-email: Nathaniel Landau <github@natelandau.com>
|
|
7
|
+
Requires-Dist: fastembed>=0.8.0
|
|
8
|
+
Requires-Dist: nclutils>=3.4.4
|
|
9
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
10
|
+
Requires-Dist: sqlite-vec>=0.1.9
|
|
11
|
+
Requires-Dist: tomli-w>=1.2.0
|
|
12
|
+
Requires-Dist: typer>=0.27.2
|
|
13
|
+
Requires-Python: >=3.13, <3.15
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# sessionmemory
|
|
17
|
+
|
|
18
|
+
Durable memory for coding agents, one folder of searchable pages per project.
|
|
19
|
+
|
|
20
|
+
An agent starts every session knowing nothing about the last one. Yesterday's session
|
|
21
|
+
worked around a trap, rejected the obvious approach for a reason, and found the one flag
|
|
22
|
+
that makes a library behave. All of it ends with the session, and the next one works it
|
|
23
|
+
out again from nothing. `sessionmemory` keeps what a project learned and hands it back
|
|
24
|
+
when the next session starts. Each project keeps its own memory, and no page is ever
|
|
25
|
+
shared between projects.
|
|
26
|
+
|
|
27
|
+
| Part | What it is |
|
|
28
|
+
| -------------------------- | ------------------------------------------------------------------------------ |
|
|
29
|
+
| The vault | Your knowledge as markdown pages, one folder per project |
|
|
30
|
+
| The `sessionmemory` plugin | Claude Code hooks that feed a session at its start and record it at its end |
|
|
31
|
+
| The `sessionmemory` CLI | Searches pages by meaning and creates them. Everything else is a file you edit |
|
|
32
|
+
|
|
33
|
+
The pages follow the memoryfield format by Cal Paterson, described in
|
|
34
|
+
[his article](https://calpaterson.com/memoryfields.html) and defined in
|
|
35
|
+
[the memoryfield spec](https://github.com/calpaterson/memoryfield-spec). Each project's
|
|
36
|
+
`learnings/` folder is one field in that format: a flat directory of markdown pages beside one
|
|
37
|
+
vector index file. You can export that folder, share it, or read it with any tool that
|
|
38
|
+
speaks the format.
|
|
39
|
+
|
|
40
|
+
## Requirements
|
|
41
|
+
|
|
42
|
+
- Python 3.13 or 3.14
|
|
43
|
+
- [uv](https://docs.astral.sh/uv/)
|
|
44
|
+
- git. The vault is a git repository, and a project is registered by its git remote.
|
|
45
|
+
- Claude Code, to run the plugin. The CLI works on its own without it.
|
|
46
|
+
|
|
47
|
+
The first search downloads the `nomic-embed-text-v1.5` embedding model, about 520MB, and
|
|
48
|
+
caches it under `~/.cache/sessionmemory/models`. Nothing else touches the network.
|
|
49
|
+
|
|
50
|
+
## Install the CLI
|
|
51
|
+
|
|
52
|
+
Clone the repository and install its dependencies. Keep the clone: it is the source the
|
|
53
|
+
plugin installs from, and it is where you pull updates.
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
git clone https://github.com/natelandau/sessionmemory ~/repos/sessionmemory
|
|
57
|
+
cd ~/repos/sessionmemory
|
|
58
|
+
uv sync
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
To put a `sessionmemory` command on your `PATH`, install the package as a tool:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
uv tool install .
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Without that step, run the CLI as `uv run sessionmemory` from the clone, or through the
|
|
68
|
+
`bin/sessionmemory` shim, which works from any directory.
|
|
69
|
+
|
|
70
|
+
## Create a vault
|
|
71
|
+
|
|
72
|
+
The vault is its own directory. Make it a git repository of its own, so your pages and
|
|
73
|
+
this code do not share a history.
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
mkdir -p ~/repos/my-vault
|
|
77
|
+
cd ~/repos/my-vault
|
|
78
|
+
git init
|
|
79
|
+
export SESSIONMEMORY_VAULT=~/repos/my-vault
|
|
80
|
+
sessionmemory init
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Put the `export` in your shell profile, and give it an absolute path. Every directory the
|
|
84
|
+
CLI prints is built from that value.
|
|
85
|
+
|
|
86
|
+
`sessionmemory init` writes the three files a vault needs. A marker in `_system/vault.toml`
|
|
87
|
+
identifies the directory as a vault. A `.gitignore` keeps the derived index out of your
|
|
88
|
+
history. A README explains the layout to whoever opens the vault later. `sessionmemory init`
|
|
89
|
+
never overwrites a file, so you can run it again safely.
|
|
90
|
+
|
|
91
|
+
Until a directory holds that marker, every command refuses to touch it. The refusal
|
|
92
|
+
protects you. If `SESSIONMEMORY_VAULT` points at your home directory by mistake, the
|
|
93
|
+
first page written scatters a `projects/` tree into it.
|
|
94
|
+
|
|
95
|
+
Nothing commits the vault on a timer. The plugin commits it when a session starts and
|
|
96
|
+
again when a session ends, so a page reaches git within the session that wrote it.
|
|
97
|
+
Pushing that history to a remote stays yours to do.
|
|
98
|
+
|
|
99
|
+
> **Note:** To bring an existing directory of notes under the CLI, run
|
|
100
|
+
> `sessionmemory init --force ~/repos/my-vault` once. `--force` means only that the
|
|
101
|
+
> directory already has contents. Nothing existing is overwritten.
|
|
102
|
+
|
|
103
|
+
## Register a project
|
|
104
|
+
|
|
105
|
+
A project gets memory when its repository is registered. Registration is the only
|
|
106
|
+
decision, and it happens once, from inside the repository:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
cd ~/repos/invoice-api
|
|
110
|
+
sessionmemory project --register --cwd .
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
✓ registered 'invoice-api'
|
|
115
|
+
└─ root: ~/repos/invoice-api
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`--register` reads the git remote and the repository root, and derives the slug from
|
|
119
|
+
them. There is nothing else to choose: no tags, no scope, no note type. The slug is
|
|
120
|
+
permanent once pages carry it, so an unregistered directory is told to run this command
|
|
121
|
+
rather than registered for you.
|
|
122
|
+
|
|
123
|
+
## Search and write pages
|
|
124
|
+
|
|
125
|
+
The CLI does two things. It finds pages by meaning, and it creates pages. Reading and
|
|
126
|
+
editing a page is a job for your editor or your agent's own tools.
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
sessionmemory search "why does the same stripe event arrive twice" --limit 2 --cwd .
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
~/repos/my-vault/projects/invoice-api/learnings/stripe-retries-a-webhook-for-72-hours-so-the-handler-must-be-idempotent.md
|
|
134
|
+
Stripe retries a webhook for 72 hours, so the handler must be idempotent
|
|
135
|
+
Stripe redelivers an unacknowledged webhook for up to 72 hours, so the handler records the event id and ignores a repeat.
|
|
136
|
+
|
|
137
|
+
~/repos/my-vault/projects/invoice-api/learnings/the-nightly-reconciliation-job-must-start-after-the-02-00-bank-feed.md
|
|
138
|
+
The nightly reconciliation job must start after the 02:00 bank feed
|
|
139
|
+
The bank feed lands at 02:00 UTC; a reconciliation run before it reports every open invoice as unpaid.
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
A result is a path, a title, and a summary. A paraphrase finds the page, because search
|
|
143
|
+
ranks by meaning and not by words in common. A query that nothing answers returns no
|
|
144
|
+
results rather than the nearest pages. Pass `--read` to print every hit in full.
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
sessionmemory new learning \
|
|
148
|
+
--title "Stripe retries a webhook for 72 hours, so the handler must be idempotent" \
|
|
149
|
+
--summary "Stripe redelivers an unacknowledged webhook for up to 72 hours, so the handler records the event id and ignores a repeat." \
|
|
150
|
+
--cwd .
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
✓ created stripe-retries-a-webhook-for-72-hours-so-the-handler-must-be-idempotent.md
|
|
155
|
+
└─ ~/repos/my-vault/projects/invoice-api/learnings/stripe-retries-a-webhook-for-72-hours-so-the-handler-must-be-idempotent.md
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The vault path is shortened to `~` here; the command prints absolute paths.
|
|
159
|
+
|
|
160
|
+
The command writes the frontmatter and prints the path. Write the body into that file,
|
|
161
|
+
or pass it with `--body-file`. The title is what every future session sees at its start,
|
|
162
|
+
and the summary is what a search result shows. Both state the fact and not the topic.
|
|
163
|
+
|
|
164
|
+
## Install the Claude Code plugin
|
|
165
|
+
|
|
166
|
+
Add the clone as a marketplace, then install the plugin from it:
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
/plugin marketplace add ~/repos/sessionmemory
|
|
170
|
+
/plugin install sessionmemory@sessionmemory
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The GitHub shorthand `natelandau/sessionmemory` works as a marketplace source too.
|
|
174
|
+
Either way, Claude Code copies the plugin into its own cache and runs the hooks from that
|
|
175
|
+
copy, not from your clone. After you pull changes into the clone, run
|
|
176
|
+
`/plugin update sessionmemory@sessionmemory` to refresh the copy.
|
|
177
|
+
|
|
178
|
+
A hook does not start from an interactive shell. A session launched from a GUI or an IDE
|
|
179
|
+
cannot see a root exported in `.zshrc`, so record the vault root in
|
|
180
|
+
`~/.claude/sessionmemory.toml` as well:
|
|
181
|
+
|
|
182
|
+
```toml
|
|
183
|
+
[vault]
|
|
184
|
+
root = "~/repos/my-vault"
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
From then on, a session that starts in a registered repository receives that project's
|
|
188
|
+
memory. A session that ends or compacts hands its transcript to a background pass, which
|
|
189
|
+
records what was worth keeping.
|
|
190
|
+
|
|
191
|
+
## What a session sees
|
|
192
|
+
|
|
193
|
+
`sessionmemory inject` prints the block a session starts with. This is the block for a
|
|
194
|
+
project holding four learnings, one spec, one plan, and two open backlog items:
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
## Using this vault
|
|
198
|
+
|
|
199
|
+
Durable memory for this project lives in a vault of markdown pages. Below is the
|
|
200
|
+
list of what it already knows; each title is one `sessionmemory search` away.
|
|
201
|
+
|
|
202
|
+
- A title below matches what you are doing: `sessionmemory search "<words>"` returns
|
|
203
|
+
the page's path, and you Read it. Search before assuming nothing was written down.
|
|
204
|
+
- Past sessions: `sessionmemory search "<words>" --logs`. Open work: read `backlog.md`
|
|
205
|
+
in the project's vault folder (`sessionmemory project --json` prints every path).
|
|
206
|
+
- Something worth keeping past this session: `sessionmemory new learning --title "..."
|
|
207
|
+
--summary "..." --cwd .` creates the page and prints the path to write prose into.
|
|
208
|
+
Keep a page under 8KB; more detail is another page.
|
|
209
|
+
- Specs and plans: `sessionmemory new spec|plan --title "..." --cwd .` creates the file.
|
|
210
|
+
Edit `backlog.md`, specs, and plans directly; the CLI only creates pages.
|
|
211
|
+
|
|
212
|
+
## What this project knows
|
|
213
|
+
|
|
214
|
+
- Invoice numbers come from a Postgres sequence, never from max(id) plus one
|
|
215
|
+
- pytest-asyncio needs asyncio_mode = auto or every async test is skipped
|
|
216
|
+
- Stripe retries a webhook for 72 hours, so the handler must be idempotent
|
|
217
|
+
- The nightly reconciliation job must start after the 02:00 bank feed
|
|
218
|
+
|
|
219
|
+
## Open work
|
|
220
|
+
|
|
221
|
+
2 open backlog items
|
|
222
|
+
spec: Export invoices as UBL 2.1 XML
|
|
223
|
+
plan: Move PDF rendering to a worker queue
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
A page body never enters that block, so its cost grows with the number of pages and not
|
|
227
|
+
with their length. The titles say what exists. `sessionmemory search` returns what they say.
|
|
228
|
+
|
|
229
|
+
## Documentation
|
|
230
|
+
|
|
231
|
+
| Page | What it covers |
|
|
232
|
+
| ---------------------------------------- | ------------------------------------------------------------ |
|
|
233
|
+
| [Concepts](docs/concepts.md) | Pages, fields, the index, and the layout of a vault |
|
|
234
|
+
| [CLI reference](docs/cli.md) | Every command, its options, and its output |
|
|
235
|
+
| [The Claude Code plugin](docs/plugin.md) | The hooks, the sweep, every setting, and the slash commands |
|
|
236
|
+
| [Vault health](docs/vault-health.md) | What `sessionmemory doctor` reports, and what to do about it |
|
|
237
|
+
|
|
238
|
+
## Development
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
uv sync # install dependencies
|
|
242
|
+
uv run duty lint # ruff, ty, typos, yamllint, shellcheck, prek
|
|
243
|
+
uv run duty test # pytest with coverage
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
`CLAUDE.md` records the conventions this project holds itself to.
|
|
247
|
+
|
|
248
|
+
## License
|
|
249
|
+
|
|
250
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
sessionmemory/__init__.py,sha256=lZ5NrhvNUTYw1L7hV1Shym8B7sRAjC82U-W7N1UCXxc,26
|
|
2
|
+
sessionmemory/cli.py,sha256=AjhwVYRtLu8DKhdrdyzQ2KovAfirfTpkhDefpYNMVco,2215
|
|
3
|
+
sessionmemory/commands/__init__.py,sha256=Zgb48cR6suqCxLYng_XewSdeDg_l2Siv3FPk4F1zKL8,35
|
|
4
|
+
sessionmemory/commands/_common.py,sha256=8_tH03x-wWoOyH5bZxR1P3SIEnLZsXtGQ1NTHxqwm-s,7410
|
|
5
|
+
sessionmemory/commands/delete.py,sha256=0zNYhrPHQ785ruWlkyqBJW5TJr3ne9TKj4bKNfFHY3Q,2644
|
|
6
|
+
sessionmemory/commands/doctor.py,sha256=drrdiNQsYp0EfRhSWlbY6OfiDyNRYVkTgHUB4yfINIA,1008
|
|
7
|
+
sessionmemory/commands/export.py,sha256=QKG4mDc-8Tqcrx7b190MIlU0JGZ5o45smPQzkDVgL8I,1886
|
|
8
|
+
sessionmemory/commands/init.py,sha256=HcCfQKpQTA1Z5qFTjrN_l5ZZWNIfHaH5uNKmDOjb5pk,3057
|
|
9
|
+
sessionmemory/commands/inject.py,sha256=FrGuPWADg-2DEI675ZaJFU8bBxbVbvKtAcVDCZ8Hy5I,989
|
|
10
|
+
sessionmemory/commands/log.py,sha256=koYZm0mJ2GGcDj5qlqpJUfw1H095b3RHDMF9BC9UkKQ,2062
|
|
11
|
+
sessionmemory/commands/new.py,sha256=--UZhCi3FSK9kNNYWt4ax4ELUbArJbqNIv5-dFaq5g8,3106
|
|
12
|
+
sessionmemory/commands/project.py,sha256=Hvy-fwupZm6iAcEaDzdqS5tCE7XPAViEKpR6rJmYsEI,11232
|
|
13
|
+
sessionmemory/commands/reindex.py,sha256=jf8Ftp9TBfZrMmz_bRVcsXgZWj1GQgM3WVx7AapSmlU,1483
|
|
14
|
+
sessionmemory/commands/search.py,sha256=7mp1Hb8X_zr05Qp_1plpgox8Ut-rQSA7EGqkCMpTVYI,2757
|
|
15
|
+
sessionmemory/lib/__init__.py,sha256=IBmOlf3iTUn0K5FixO7tFYFcj0Som-1Kr2P0qIn9tDw,34
|
|
16
|
+
sessionmemory/lib/atomic.py,sha256=BEwmZOfCWgKHmlLj7TT9H9u1g_itpLo5ptq7HYY9U4M,2707
|
|
17
|
+
sessionmemory/lib/bootstrap.py,sha256=tENxX0QskhA3E79C9IwYgJU8iPvga8UHqgfXUCkhxr0,4847
|
|
18
|
+
sessionmemory/lib/config.py,sha256=sqJn2lmCy3HeKuVijINIMqx9tVZ_8G1D92REFy41y-Y,3088
|
|
19
|
+
sessionmemory/lib/doctor.py,sha256=gYc0-qsGAhLM44os3fxbT1vKCXFh00pnH-m6EtC4Ruk,6634
|
|
20
|
+
sessionmemory/lib/embed.py,sha256=V3fcSg2VUxVKuK0efH75EuE1lMQQBK7uA5LDzkbWHnE,4392
|
|
21
|
+
sessionmemory/lib/export.py,sha256=wIYglaUM7zlPllDf3r5hwk_-YawmrtdwiObYvtP0JkQ,864
|
|
22
|
+
sessionmemory/lib/field.py,sha256=iRPc70VNrhmwjPdxL73UThKFSDiaGMUUzLvQs31zSuk,5828
|
|
23
|
+
sessionmemory/lib/fieldindex.py,sha256=yRrMQEWWu3wtwEsKKQMb3QI0Xs37-4whtdAXV7vVVtQ,7166
|
|
24
|
+
sessionmemory/lib/frontmatter.py,sha256=-9PFyLu1hboNMlBu525iWGuAvRwj0BvaEWCVcqBCNBk,5524
|
|
25
|
+
sessionmemory/lib/gitinfo.py,sha256=FzIavwIQzgd6aXlIbu5zQz488LuD5IUns0cXOR02HZc,7152
|
|
26
|
+
sessionmemory/lib/ids.py,sha256=3HBUw9vwFa9B0h5ktAbFzw7wCKMP5-h_J3Ys_heLW_E,3561
|
|
27
|
+
sessionmemory/lib/inject.py,sha256=YcjEeD29hNLqn8qpNjIC6NQfvzRjdgu0KZsJNS-v5dw,3634
|
|
28
|
+
sessionmemory/lib/log.py,sha256=32zNrSvtEtEln1gSJYwRW2ZOfAg7uQGXmL53Y6z55jA,2190
|
|
29
|
+
sessionmemory/lib/paths.py,sha256=xdct1Vp-K2LVxzWxzsNrfkMSZqV_I8_OtHzkfll5zpE,2400
|
|
30
|
+
sessionmemory/lib/registry.py,sha256=itOfFGSqSliJWsZhC3zEpPEBTH_hP83dvMKXjFIZjjY,9427
|
|
31
|
+
sessionmemory/lib/resolve.py,sha256=LEbRQGaB7TIdL6xDrmMVLIU6v7RDInIAxaPexXi7bfY,2501
|
|
32
|
+
sessionmemory-0.2.0.dist-info/WHEEL,sha256=y6e-a5KI2W-qDAJKfh9Xr81bin8NgUdfWgz3VSXXpe4,80
|
|
33
|
+
sessionmemory-0.2.0.dist-info/entry_points.txt,sha256=Y-AoMbxG6rKjzZlclFrAE1hXi-oh4cE4iQ6CgzQQezI,58
|
|
34
|
+
sessionmemory-0.2.0.dist-info/METADATA,sha256=eXCnzbIS1LqbD6kPHR8-KNWucqWZsPGsT8jIRkRx250,10696
|
|
35
|
+
sessionmemory-0.2.0.dist-info/RECORD,,
|