sessionmemory 0.2.0__tar.gz → 0.3.1__tar.gz

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.
Files changed (35) hide show
  1. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/PKG-INFO +86 -69
  2. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/README.md +85 -68
  3. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/pyproject.toml +2 -1
  4. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/pyproject.toml.orig +2 -1
  5. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/cli.py +17 -0
  6. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/_common.py +14 -2
  7. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/init.py +7 -7
  8. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/log.py +7 -1
  9. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/project.py +6 -0
  10. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/config.py +41 -3
  11. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/field.py +30 -2
  12. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/inject.py +28 -13
  13. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/log.py +33 -7
  14. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/__init__.py +0 -0
  15. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/__init__.py +0 -0
  16. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/delete.py +0 -0
  17. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/doctor.py +0 -0
  18. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/export.py +0 -0
  19. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/inject.py +0 -0
  20. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/new.py +0 -0
  21. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/reindex.py +0 -0
  22. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/search.py +0 -0
  23. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/__init__.py +0 -0
  24. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/atomic.py +0 -0
  25. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/bootstrap.py +0 -0
  26. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/doctor.py +0 -0
  27. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/embed.py +0 -0
  28. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/export.py +0 -0
  29. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/fieldindex.py +0 -0
  30. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/frontmatter.py +0 -0
  31. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/gitinfo.py +0 -0
  32. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/ids.py +0 -0
  33. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/paths.py +0 -0
  34. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/registry.py +0 -0
  35. {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/resolve.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sessionmemory
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Durable memory for coding agents, one folder of searchable pages per project.
5
5
  Author: Nathaniel Landau
6
6
  Author-email: Nathaniel Landau <github@natelandau.com>
@@ -39,58 +39,79 @@ speaks the format.
39
39
 
40
40
  ## Requirements
41
41
 
42
- - Python 3.13 or 3.14
43
- - [uv](https://docs.astral.sh/uv/)
42
+ - [uv](https://docs.astral.sh/uv/). It installs the CLI, and the plugin runs its hooks
43
+ through it.
44
+ - Python 3.13 or 3.14. `uv` downloads one when none is installed.
44
45
  - git. The vault is a git repository, and a project is registered by its git remote.
45
46
  - Claude Code, to run the plugin. The CLI works on its own without it.
46
47
 
47
48
  The first search downloads the `nomic-embed-text-v1.5` embedding model, about 520MB, and
48
49
  caches it under `~/.cache/sessionmemory/models`. Nothing else touches the network.
49
50
 
50
- ## Install the CLI
51
+ ## Install
51
52
 
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.
53
+ Installation is three steps: the CLI, the plugin, and a vault for them to share.
54
+
55
+ ### 1. Install the CLI
56
+
57
+ The CLI is published on PyPI. Install it as a tool, which puts `sessionmemory` on your
58
+ `PATH`:
54
59
 
55
60
  ```bash
56
- git clone https://github.com/natelandau/sessionmemory ~/repos/sessionmemory
57
- cd ~/repos/sessionmemory
58
- uv sync
61
+ uv tool install sessionmemory
59
62
  ```
60
63
 
61
- To put a `sessionmemory` command on your `PATH`, install the package as a tool:
64
+ To upgrade it later, run `uv tool upgrade sessionmemory`.
65
+
66
+ ### 2. Install the Claude Code plugin
67
+
68
+ In Claude Code, add this repository as a marketplace and install the plugin from it:
62
69
 
63
- ```bash
64
- uv tool install .
70
+ ```
71
+ /plugin marketplace add natelandau/sessionmemory
72
+ /plugin install sessionmemory@sessionmemory
65
73
  ```
66
74
 
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.
75
+ The hooks run the `sessionmemory` from step 1 when its version is at or past the
76
+ plugin's own. Keep the two in step: after `/plugin update sessionmemory@sessionmemory`,
77
+ run `uv tool upgrade sessionmemory` as well. A plugin newer than the tool falls back to
78
+ a copy of the CLI it carries, in a Python environment of its own that the first such
79
+ session builds.
69
80
 
70
- ## Create a vault
81
+ ### 3. Create a vault
71
82
 
72
83
  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.
84
+ this code do not share a history. Then initialize it:
74
85
 
75
86
  ```bash
76
87
  mkdir -p ~/repos/my-vault
77
88
  cd ~/repos/my-vault
78
89
  git init
79
- export SESSIONMEMORY_VAULT=~/repos/my-vault
80
- sessionmemory init
90
+ sessionmemory init ~/repos/my-vault
81
91
  ```
82
92
 
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
93
  `sessionmemory init` writes the three files a vault needs. A marker in `_system/vault.toml`
87
94
  identifies the directory as a vault. A `.gitignore` keeps the derived index out of your
88
95
  history. A README explains the layout to whoever opens the vault later. `sessionmemory init`
89
96
  never overwrites a file, so you can run it again safely.
90
97
 
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.
98
+ Then tell the CLI and the hooks where the vault is. Both read the same two places, in the
99
+ same order: the `SESSIONMEMORY_VAULT` environment variable, then `vault.root` in
100
+ `~/.claude/sessionmemory.toml`. Record the root in the file, since a session launched
101
+ from a GUI or an IDE does not read your shell profile:
102
+
103
+ ```toml
104
+ [vault]
105
+ root = "~/repos/my-vault"
106
+ ```
107
+
108
+ The variable wins when both are set, so exporting it in one shell points that shell at a
109
+ different vault without touching the file. The
110
+ [plugin documentation](docs/plugin.md#settings) lists every other key that file accepts.
111
+
112
+ Until a directory holds the marker that `sessionmemory init` writes, every command refuses
113
+ to touch it. The refusal protects you. If the root points at your home directory by
114
+ mistake, the first page written scatters a `projects/` tree into it.
94
115
 
95
116
  Nothing commits the vault on a timer. The plugin commits it when a session starts and
96
117
  again when a session ends, so a page reaches git within the session that wrote it.
@@ -102,8 +123,21 @@ Pushing that history to a remote stays yours to do.
102
123
 
103
124
  ## Register a project
104
125
 
105
- A project gets memory when its repository is registered. Registration is the only
106
- decision, and it happens once, from inside the repository:
126
+ A project gets memory when its repository is registered. With the plugin installed, the
127
+ first session you open in a git repository registers it. The session begins with one
128
+ line that names the slug:
129
+
130
+ ```
131
+ This repository was registered with the vault as project 'invoice-api'.
132
+ ```
133
+
134
+ The slug comes from the git remote, or from the directory name when the repository has
135
+ no remote. There is nothing else to choose: no tags, no scope, no note type.
136
+
137
+ Only a git repository is registered for you. A slug is permanent once pages carry it. A
138
+ session opened in your home directory or a scratch folder must not leave a project named
139
+ after it in the vault. To register a directory outside git, or to choose the slug
140
+ yourself, run the command once:
107
141
 
108
142
  ```bash
109
143
  cd ~/repos/invoice-api
@@ -115,10 +149,9 @@ sessionmemory project --register --cwd .
115
149
  └─ root: ~/repos/invoice-api
116
150
  ```
117
151
 
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.
152
+ From then on, a session that starts in a registered repository receives that project's
153
+ memory. A session that ends or compacts hands its transcript to a background pass, which
154
+ records what was worth keeping.
122
155
 
123
156
  ## Search and write pages
124
157
 
@@ -161,33 +194,6 @@ The command writes the frontmatter and prints the path. Write the body into that
161
194
  or pass it with `--body-file`. The title is what every future session sees at its start,
162
195
  and the summary is what a search result shows. Both state the fact and not the topic.
163
196
 
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
197
  ## What a session sees
192
198
 
193
199
  `sessionmemory inject` prints the block a session starts with. This is the block for a
@@ -196,18 +202,27 @@ project holding four learnings, one spec, one plan, and two open backlog items:
196
202
  ```
197
203
  ## Using this vault
198
204
 
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.
205
+ Durable memory for this project lives in a vault of markdown pages. Nothing below is
206
+ loaded for you: the titles are what the vault holds, and each is one `sessionmemory search`
207
+ away. The project's folder has `learnings/` and `logs/`, searched by meaning, beside
208
+ `specs/`, `plans/`, and `backlog.md`, which are ordinary files you Read and Edit.
209
+ `sessionmemory project --json` prints every path.
210
+
211
+ - Before assuming nothing was written down, search: `sessionmemory search "<words>"`
212
+ prints each hit's path, title, and summary, and `--read` prints every hit's whole
213
+ page in one call. A paraphrase still matches. No hits means nothing is recorded,
214
+ not that the query needs loosening.
215
+ - Past sessions, one page each: `sessionmemory search "<words>" --logs`.
216
+ - Open work: read `backlog.md`. An item is one line under a `## <kind>` heading
217
+ (feat, fix, refactor, perf, docs, test, build, ci), sized S, M, or L:
218
+ `- [ ] [S] <imperative description> - <YYYY-MM-DD> [#topic]`. Add, tick, or
219
+ delete lines directly. If the file is missing, create it with a `# Backlog` heading.
220
+ - Specs and plans: `sessionmemory new spec|plan --title "..." --cwd .` creates the file
221
+ and prints its path. Edit it directly after that.
222
+ - Learnings are captured at session end, not by you mid-session. When the user asks
223
+ to keep one now: `sessionmemory new learning --title "..." --summary "..." --cwd .`
224
+ creates the page and prints the path to write prose into. Title and summary state
225
+ the fact, not the topic. Keep a page under 8KB; more detail is another page.
211
226
 
212
227
  ## What this project knows
213
228
 
@@ -238,6 +253,8 @@ with their length. The titles say what exists. `sessionmemory search` returns wh
238
253
  ## Development
239
254
 
240
255
  ```bash
256
+ git clone https://github.com/natelandau/sessionmemory
257
+ cd sessionmemory
241
258
  uv sync # install dependencies
242
259
  uv run duty lint # ruff, ty, typos, yamllint, shellcheck, prek
243
260
  uv run duty test # pytest with coverage
@@ -24,58 +24,79 @@ speaks the format.
24
24
 
25
25
  ## Requirements
26
26
 
27
- - Python 3.13 or 3.14
28
- - [uv](https://docs.astral.sh/uv/)
27
+ - [uv](https://docs.astral.sh/uv/). It installs the CLI, and the plugin runs its hooks
28
+ through it.
29
+ - Python 3.13 or 3.14. `uv` downloads one when none is installed.
29
30
  - git. The vault is a git repository, and a project is registered by its git remote.
30
31
  - Claude Code, to run the plugin. The CLI works on its own without it.
31
32
 
32
33
  The first search downloads the `nomic-embed-text-v1.5` embedding model, about 520MB, and
33
34
  caches it under `~/.cache/sessionmemory/models`. Nothing else touches the network.
34
35
 
35
- ## Install the CLI
36
+ ## Install
36
37
 
37
- Clone the repository and install its dependencies. Keep the clone: it is the source the
38
- plugin installs from, and it is where you pull updates.
38
+ Installation is three steps: the CLI, the plugin, and a vault for them to share.
39
+
40
+ ### 1. Install the CLI
41
+
42
+ The CLI is published on PyPI. Install it as a tool, which puts `sessionmemory` on your
43
+ `PATH`:
39
44
 
40
45
  ```bash
41
- git clone https://github.com/natelandau/sessionmemory ~/repos/sessionmemory
42
- cd ~/repos/sessionmemory
43
- uv sync
46
+ uv tool install sessionmemory
44
47
  ```
45
48
 
46
- To put a `sessionmemory` command on your `PATH`, install the package as a tool:
49
+ To upgrade it later, run `uv tool upgrade sessionmemory`.
50
+
51
+ ### 2. Install the Claude Code plugin
52
+
53
+ In Claude Code, add this repository as a marketplace and install the plugin from it:
47
54
 
48
- ```bash
49
- uv tool install .
55
+ ```
56
+ /plugin marketplace add natelandau/sessionmemory
57
+ /plugin install sessionmemory@sessionmemory
50
58
  ```
51
59
 
52
- Without that step, run the CLI as `uv run sessionmemory` from the clone, or through the
53
- `bin/sessionmemory` shim, which works from any directory.
60
+ The hooks run the `sessionmemory` from step 1 when its version is at or past the
61
+ plugin's own. Keep the two in step: after `/plugin update sessionmemory@sessionmemory`,
62
+ run `uv tool upgrade sessionmemory` as well. A plugin newer than the tool falls back to
63
+ a copy of the CLI it carries, in a Python environment of its own that the first such
64
+ session builds.
54
65
 
55
- ## Create a vault
66
+ ### 3. Create a vault
56
67
 
57
68
  The vault is its own directory. Make it a git repository of its own, so your pages and
58
- this code do not share a history.
69
+ this code do not share a history. Then initialize it:
59
70
 
60
71
  ```bash
61
72
  mkdir -p ~/repos/my-vault
62
73
  cd ~/repos/my-vault
63
74
  git init
64
- export SESSIONMEMORY_VAULT=~/repos/my-vault
65
- sessionmemory init
75
+ sessionmemory init ~/repos/my-vault
66
76
  ```
67
77
 
68
- Put the `export` in your shell profile, and give it an absolute path. Every directory the
69
- CLI prints is built from that value.
70
-
71
78
  `sessionmemory init` writes the three files a vault needs. A marker in `_system/vault.toml`
72
79
  identifies the directory as a vault. A `.gitignore` keeps the derived index out of your
73
80
  history. A README explains the layout to whoever opens the vault later. `sessionmemory init`
74
81
  never overwrites a file, so you can run it again safely.
75
82
 
76
- Until a directory holds that marker, every command refuses to touch it. The refusal
77
- protects you. If `SESSIONMEMORY_VAULT` points at your home directory by mistake, the
78
- first page written scatters a `projects/` tree into it.
83
+ Then tell the CLI and the hooks where the vault is. Both read the same two places, in the
84
+ same order: the `SESSIONMEMORY_VAULT` environment variable, then `vault.root` in
85
+ `~/.claude/sessionmemory.toml`. Record the root in the file, since a session launched
86
+ from a GUI or an IDE does not read your shell profile:
87
+
88
+ ```toml
89
+ [vault]
90
+ root = "~/repos/my-vault"
91
+ ```
92
+
93
+ The variable wins when both are set, so exporting it in one shell points that shell at a
94
+ different vault without touching the file. The
95
+ [plugin documentation](docs/plugin.md#settings) lists every other key that file accepts.
96
+
97
+ Until a directory holds the marker that `sessionmemory init` writes, every command refuses
98
+ to touch it. The refusal protects you. If the root points at your home directory by
99
+ mistake, the first page written scatters a `projects/` tree into it.
79
100
 
80
101
  Nothing commits the vault on a timer. The plugin commits it when a session starts and
81
102
  again when a session ends, so a page reaches git within the session that wrote it.
@@ -87,8 +108,21 @@ Pushing that history to a remote stays yours to do.
87
108
 
88
109
  ## Register a project
89
110
 
90
- A project gets memory when its repository is registered. Registration is the only
91
- decision, and it happens once, from inside the repository:
111
+ A project gets memory when its repository is registered. With the plugin installed, the
112
+ first session you open in a git repository registers it. The session begins with one
113
+ line that names the slug:
114
+
115
+ ```
116
+ This repository was registered with the vault as project 'invoice-api'.
117
+ ```
118
+
119
+ The slug comes from the git remote, or from the directory name when the repository has
120
+ no remote. There is nothing else to choose: no tags, no scope, no note type.
121
+
122
+ Only a git repository is registered for you. A slug is permanent once pages carry it. A
123
+ session opened in your home directory or a scratch folder must not leave a project named
124
+ after it in the vault. To register a directory outside git, or to choose the slug
125
+ yourself, run the command once:
92
126
 
93
127
  ```bash
94
128
  cd ~/repos/invoice-api
@@ -100,10 +134,9 @@ sessionmemory project --register --cwd .
100
134
  └─ root: ~/repos/invoice-api
101
135
  ```
102
136
 
103
- `--register` reads the git remote and the repository root, and derives the slug from
104
- them. There is nothing else to choose: no tags, no scope, no note type. The slug is
105
- permanent once pages carry it, so an unregistered directory is told to run this command
106
- rather than registered for you.
137
+ From then on, a session that starts in a registered repository receives that project's
138
+ memory. A session that ends or compacts hands its transcript to a background pass, which
139
+ records what was worth keeping.
107
140
 
108
141
  ## Search and write pages
109
142
 
@@ -146,33 +179,6 @@ The command writes the frontmatter and prints the path. Write the body into that
146
179
  or pass it with `--body-file`. The title is what every future session sees at its start,
147
180
  and the summary is what a search result shows. Both state the fact and not the topic.
148
181
 
149
- ## Install the Claude Code plugin
150
-
151
- Add the clone as a marketplace, then install the plugin from it:
152
-
153
- ```
154
- /plugin marketplace add ~/repos/sessionmemory
155
- /plugin install sessionmemory@sessionmemory
156
- ```
157
-
158
- The GitHub shorthand `natelandau/sessionmemory` works as a marketplace source too.
159
- Either way, Claude Code copies the plugin into its own cache and runs the hooks from that
160
- copy, not from your clone. After you pull changes into the clone, run
161
- `/plugin update sessionmemory@sessionmemory` to refresh the copy.
162
-
163
- A hook does not start from an interactive shell. A session launched from a GUI or an IDE
164
- cannot see a root exported in `.zshrc`, so record the vault root in
165
- `~/.claude/sessionmemory.toml` as well:
166
-
167
- ```toml
168
- [vault]
169
- root = "~/repos/my-vault"
170
- ```
171
-
172
- From then on, a session that starts in a registered repository receives that project's
173
- memory. A session that ends or compacts hands its transcript to a background pass, which
174
- records what was worth keeping.
175
-
176
182
  ## What a session sees
177
183
 
178
184
  `sessionmemory inject` prints the block a session starts with. This is the block for a
@@ -181,18 +187,27 @@ project holding four learnings, one spec, one plan, and two open backlog items:
181
187
  ```
182
188
  ## Using this vault
183
189
 
184
- Durable memory for this project lives in a vault of markdown pages. Below is the
185
- list of what it already knows; each title is one `sessionmemory search` away.
186
-
187
- - A title below matches what you are doing: `sessionmemory search "<words>"` returns
188
- the page's path, and you Read it. Search before assuming nothing was written down.
189
- - Past sessions: `sessionmemory search "<words>" --logs`. Open work: read `backlog.md`
190
- in the project's vault folder (`sessionmemory project --json` prints every path).
191
- - Something worth keeping past this session: `sessionmemory new learning --title "..."
192
- --summary "..." --cwd .` creates the page and prints the path to write prose into.
193
- Keep a page under 8KB; more detail is another page.
194
- - Specs and plans: `sessionmemory new spec|plan --title "..." --cwd .` creates the file.
195
- Edit `backlog.md`, specs, and plans directly; the CLI only creates pages.
190
+ Durable memory for this project lives in a vault of markdown pages. Nothing below is
191
+ loaded for you: the titles are what the vault holds, and each is one `sessionmemory search`
192
+ away. The project's folder has `learnings/` and `logs/`, searched by meaning, beside
193
+ `specs/`, `plans/`, and `backlog.md`, which are ordinary files you Read and Edit.
194
+ `sessionmemory project --json` prints every path.
195
+
196
+ - Before assuming nothing was written down, search: `sessionmemory search "<words>"`
197
+ prints each hit's path, title, and summary, and `--read` prints every hit's whole
198
+ page in one call. A paraphrase still matches. No hits means nothing is recorded,
199
+ not that the query needs loosening.
200
+ - Past sessions, one page each: `sessionmemory search "<words>" --logs`.
201
+ - Open work: read `backlog.md`. An item is one line under a `## <kind>` heading
202
+ (feat, fix, refactor, perf, docs, test, build, ci), sized S, M, or L:
203
+ `- [ ] [S] <imperative description> - <YYYY-MM-DD> [#topic]`. Add, tick, or
204
+ delete lines directly. If the file is missing, create it with a `# Backlog` heading.
205
+ - Specs and plans: `sessionmemory new spec|plan --title "..." --cwd .` creates the file
206
+ and prints its path. Edit it directly after that.
207
+ - Learnings are captured at session end, not by you mid-session. When the user asks
208
+ to keep one now: `sessionmemory new learning --title "..." --summary "..." --cwd .`
209
+ creates the page and prints the path to write prose into. Title and summary state
210
+ the fact, not the topic. Keep a page under 8KB; more detail is another page.
196
211
 
197
212
  ## What this project knows
198
213
 
@@ -223,6 +238,8 @@ with their length. The titles say what exists. `sessionmemory search` returns wh
223
238
  ## Development
224
239
 
225
240
  ```bash
241
+ git clone https://github.com/natelandau/sessionmemory
242
+ cd sessionmemory
226
243
  uv sync # install dependencies
227
244
  uv run duty lint # ruff, ty, typos, yamllint, shellcheck, prek
228
245
  uv run duty test # pytest with coverage
@@ -11,7 +11,7 @@ description = "Durable memory for coding agents, one folder of searchable pages
11
11
  name = "sessionmemory"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.13,<3.15"
14
- version = "0.2.0"
14
+ version = "0.3.1"
15
15
 
16
16
  [[project.authors]]
17
17
  name = "Nathaniel Landau"
@@ -50,6 +50,7 @@ bump_message = "bump(release): v$current_version → v$new_version"
50
50
  changelog_merge_prerelease = true
51
51
  tag_format = "v$version"
52
52
  update_changelog_on_bump = true
53
+ version_files = [".claude-plugin/plugin.json:version"]
53
54
  version_provider = "uv"
54
55
 
55
56
  [tool.coverage.report]
@@ -12,7 +12,7 @@
12
12
  name = "sessionmemory"
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.13,<3.15"
15
- version = "0.2.0"
15
+ version = "0.3.1"
16
16
 
17
17
  [project.scripts]
18
18
  sessionmemory = "sessionmemory.cli:main"
@@ -47,6 +47,7 @@
47
47
  changelog_merge_prerelease = true
48
48
  tag_format = "v$version"
49
49
  update_changelog_on_bump = true
50
+ version_files = [".claude-plugin/plugin.json:version"]
50
51
  version_provider = "uv"
51
52
 
52
53
  [tool.coverage.report] # https://coverage.readthedocs.io/en/latest/config.html#report
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import importlib.metadata
6
+
5
7
  import typer
6
8
  from nclutils import pp
7
9
 
@@ -15,15 +17,30 @@ from sessionmemory.commands import new as new_commands
15
17
  from sessionmemory.commands import project
16
18
  from sessionmemory.commands import reindex as reindex_commands
17
19
  from sessionmemory.commands import search as search_commands
20
+ from sessionmemory.commands._common import emit_value
18
21
 
19
22
  app = typer.Typer(no_args_is_help=True, add_completion=False)
20
23
 
21
24
 
25
+ def _print_version(value: bool) -> None: # noqa: FBT001
26
+ """Print the installed version bare and exit, for a caller that compares it."""
27
+ if value:
28
+ emit_value(importlib.metadata.version("sessionmemory"))
29
+ raise typer.Exit
30
+
31
+
22
32
  @app.callback()
23
33
  def _root(
24
34
  verbosity: int = typer.Option(
25
35
  0, "-v", "--verbose", count=True, help="Increase output verbosity. Repeat for more."
26
36
  ),
37
+ version: bool = typer.Option( # noqa: ARG001, FBT001
38
+ False, # noqa: FBT003
39
+ "--version",
40
+ callback=_print_version,
41
+ is_eager=True,
42
+ help="Print the version and exit.",
43
+ ),
27
44
  ) -> None:
28
45
  """Manage a project's memory in a vault of markdown pages."""
29
46
  pp.configure(verbosity=verbosity)
@@ -13,7 +13,12 @@ from nclutils import pp
13
13
 
14
14
  from sessionmemory.lib import embed, registry
15
15
  from sessionmemory.lib.bootstrap import is_empty
16
- from sessionmemory.lib.config import VaultNotConfiguredError, is_initialized, vault_root
16
+ from sessionmemory.lib.config import (
17
+ CONFIG_FILE,
18
+ VaultNotConfiguredError,
19
+ is_initialized,
20
+ vault_root,
21
+ )
17
22
  from sessionmemory.lib.paths import SYSTEM_DIR
18
23
  from sessionmemory.lib.resolve import resolve as resolve_project
19
24
 
@@ -23,6 +28,13 @@ if TYPE_CHECKING:
23
28
  EMBEDDER_ENV_VAR = "SESSIONMEMORY_EMBEDDER"
24
29
 
25
30
 
31
+ # The two ways to name the vault, offered together whenever neither is set.
32
+ VAULT_FIX = [
33
+ "export SESSIONMEMORY_VAULT=/path/to/your/vault",
34
+ f"or set [vault] root in {CONFIG_FILE}",
35
+ ]
36
+
37
+
26
38
  def build_embedder() -> Embedder:
27
39
  """Build the embedder commands use: the stub under test, the real model otherwise.
28
40
 
@@ -123,7 +135,7 @@ def require_vault() -> Path:
123
135
  try:
124
136
  vault = vault_root()
125
137
  except VaultNotConfiguredError as error:
126
- pp.error(str(error), details=["export SESSIONMEMORY_VAULT=/path/to/your/vault"])
138
+ pp.error(str(error), details=VAULT_FIX)
127
139
  raise typer.Exit(1) from error
128
140
 
129
141
  if not is_initialized(vault):
@@ -9,12 +9,12 @@ from pathlib import Path # noqa: TC003
9
9
  import typer
10
10
  from nclutils import pp
11
11
 
12
- from sessionmemory.commands._common import emit_json
12
+ from sessionmemory.commands._common import VAULT_FIX, emit_json
13
13
  from sessionmemory.lib.bootstrap import InitResult, NotAVaultError, initialize
14
14
  from sessionmemory.lib.config import VaultNotConfiguredError, vault_root
15
15
 
16
16
  DIRECTORY_ARGUMENT = typer.Argument(
17
- None, help="Where to create the vault. Defaults to SESSIONMEMORY_VAULT."
17
+ None, help="Where to create the vault. Defaults to the configured vault root."
18
18
  )
19
19
  FORCE_OPTION = typer.Option(
20
20
  False, # noqa: FBT003
@@ -60,12 +60,12 @@ def init(
60
60
 
61
61
  A vault cannot be created through `require_vault`, since that helper refuses an
62
62
  uninitialized directory and this command is what fixes that. When no directory is
63
- given, `SESSIONMEMORY_VAULT` is read directly instead.
63
+ given, the configured root is read directly instead.
64
64
 
65
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.
66
+ Exit: When no directory is given and no vault root is configured or the
67
+ configured one names a missing directory, or when the target directory holds
68
+ unrelated files and `--force` is not given.
69
69
  """
70
70
  if directory is not None:
71
71
  # Every other reader resolves the vault path, so a relative or symlinked path
@@ -76,7 +76,7 @@ def init(
76
76
  try:
77
77
  vault = vault_root()
78
78
  except VaultNotConfiguredError as error:
79
- pp.error(str(error), details=["export SESSIONMEMORY_VAULT=/path/to/your/vault"])
79
+ pp.error(str(error), details=VAULT_FIX)
80
80
  raise typer.Exit(1) from error
81
81
 
82
82
  try:
@@ -22,16 +22,20 @@ TITLE = typer.Option(..., "--title", help="The log's title.")
22
22
  SUMMARY = typer.Option("", "--summary", help="One sentence a search result shows.")
23
23
  BODY = typer.Option("", "--body", help="Markdown body. Replaces what is there.")
24
24
  BODY_FILE = typer.Option(None, "--body-file", help="Read the body from a file, or stdin for '-'.")
25
+ TRANSCRIPT = typer.Option("", "--transcript", help="Path to this session's transcript on disk.")
26
+ URL = typer.Option("", "--url", help="Where this session can be opened online.")
25
27
  CWD = typer.Option(None, "--cwd", help="Directory to resolve the project from.")
26
28
  JSON = typer.Option(False, "--json", help="Emit JSON instead of prose.") # noqa: FBT003
27
29
 
28
30
 
29
- def log_command(
31
+ def log_command( # noqa: PLR0913, PLR0917
30
32
  session_id: str = SESSION_ID,
31
33
  title: str = TITLE,
32
34
  summary: str = SUMMARY,
33
35
  body: str = BODY,
34
36
  body_file: Path | None = BODY_FILE,
37
+ transcript: str = TRANSCRIPT,
38
+ url: str = URL,
35
39
  cwd: Path | None = CWD,
36
40
  *,
37
41
  as_json: bool = JSON,
@@ -54,6 +58,8 @@ def log_command(
54
58
  body=body,
55
59
  now=now(),
56
60
  today=today(),
61
+ transcript=transcript,
62
+ session_url=url,
57
63
  )
58
64
  except field.PageError as error:
59
65
  fail(str(error))
@@ -284,6 +284,12 @@ def _register(
284
284
  )
285
285
  registry.save(vault, projects)
286
286
 
287
+ # The folder is created here rather than on the first page write because the
288
+ # plugin's sweep runs inside it, and a project's first sweep is what writes its
289
+ # first page.
290
+ for field in paths_lib.FIELD_DIRS:
291
+ (paths_lib.project_dir(vault, resolved_slug) / field).mkdir(parents=True, exist_ok=True)
292
+
287
293
  # Registering inside another project is legitimate, but an accidental one looks
288
294
  # exactly like a deliberate one, so the nesting is reported rather than assumed.
289
295
  if enclosing is not None:
@@ -11,6 +11,9 @@ from sessionmemory.lib.paths import SYSTEM_DIR
11
11
 
12
12
  VAULT_ENV_VAR = "SESSIONMEMORY_VAULT"
13
13
 
14
+ # The plugin's settings file, read here only for `[vault] root`.
15
+ CONFIG_FILE = Path("~/.claude/sessionmemory.toml")
16
+
14
17
  VAULT_MARKER = "vault.toml"
15
18
 
16
19
 
@@ -19,7 +22,11 @@ class VaultNotConfiguredError(RuntimeError):
19
22
 
20
23
 
21
24
  def vault_root() -> Path:
22
- """Return the vault directory named by the environment.
25
+ """Return the vault directory named by the environment or the config file.
26
+
27
+ `SESSIONMEMORY_VAULT` wins. When it is unset, `[vault] root` in `CONFIG_FILE` is read
28
+ instead, so a plugin user who recorded the root there once has the CLI and the hooks
29
+ agree on where the vault is. The precedence is the hooks' own.
23
30
 
24
31
  The path is resolved, so every directory the CLI prints is absolute, such as the
25
32
  `project_dir` in `sessionmemory project --json`. A relative value would otherwise be
@@ -29,21 +36,52 @@ def vault_root() -> Path:
29
36
  Path: The resolved vault directory.
30
37
 
31
38
  Raises:
32
- VaultNotConfiguredError: If the variable is unset or names a missing directory.
39
+ VaultNotConfiguredError: If neither source names a vault, the config file cannot
40
+ be parsed, or the named directory is missing.
33
41
  """
34
42
  raw = os.environ.get(VAULT_ENV_VAR)
43
+ source = VAULT_ENV_VAR
44
+ if not raw:
45
+ raw = _configured_root()
46
+ source = str(CONFIG_FILE)
35
47
  if not raw:
36
48
  msg = f"{VAULT_ENV_VAR} is not set. Point it at your vault repository."
37
49
  raise VaultNotConfiguredError(msg)
38
50
 
39
51
  root = Path(raw).expanduser().resolve()
40
52
  if not root.is_dir():
41
- msg = f"{VAULT_ENV_VAR} is {root}, which does not exist or is not a directory."
53
+ msg = f"{source} names {root}, which does not exist or is not a directory."
42
54
  raise VaultNotConfiguredError(msg)
43
55
 
44
56
  return root
45
57
 
46
58
 
59
+ def _configured_root() -> str | None:
60
+ """Return `[vault] root` from the config file, or None when it names nothing.
61
+
62
+ A value that is not a string is treated as absent, as the hooks treat it. An
63
+ unparsable file raises rather than reading as absent, because a person at a keyboard
64
+ fixes a named parse error faster than a "not set" message that is not true.
65
+
66
+ Raises:
67
+ VaultNotConfiguredError: If the file exists but cannot be read or parsed.
68
+ """
69
+ path = CONFIG_FILE.expanduser()
70
+ try:
71
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
72
+ except FileNotFoundError:
73
+ return None
74
+ except (tomllib.TOMLDecodeError, OSError, UnicodeDecodeError) as error:
75
+ msg = f"{CONFIG_FILE} could not be read: {error}"
76
+ raise VaultNotConfiguredError(msg) from error
77
+
78
+ vault = data.get("vault")
79
+ if not isinstance(vault, dict):
80
+ return None
81
+ root = vault.get("root")
82
+ return root if isinstance(root, str) and root else None
83
+
84
+
47
85
  def today() -> str:
48
86
  """Return today's date as an ISO string.
49
87
 
@@ -15,7 +15,13 @@ from typing import TYPE_CHECKING, Any
15
15
 
16
16
  from sessionmemory.lib import atomic
17
17
  from sessionmemory.lib.frontmatter import FrontmatterError, parse, serialize
18
- from sessionmemory.lib.ids import id_candidates, slugify
18
+ from sessionmemory.lib.ids import (
19
+ DATE_PREFIX_WIDTH,
20
+ MAX_ID_LENGTH,
21
+ id_candidates,
22
+ slugify,
23
+ strip_date,
24
+ )
19
25
 
20
26
  if TYPE_CHECKING:
21
27
  from collections.abc import Mapping
@@ -176,6 +182,28 @@ def new_page(directory: Path, *, title: str, summary: str, body: str, now: str)
176
182
  def new_document(
177
183
  directory: Path, *, title: str, body: str, now: str, stem: str | None = None
178
184
  ) -> Path:
179
- """Create a spec, plan, or log: a titled, dated file that is not a memory page."""
185
+ """Create a spec, plan, or log: a titled, dated file that is not a memory page.
186
+
187
+ The filename leads with the creation date unless the caller fixes the stem, so a
188
+ directory listing reads in the order the documents were written.
189
+
190
+ Raises:
191
+ PageError: If the title yields no slug or no name is free.
192
+ """
193
+ if stem is None:
194
+ stem = dated_stem(title, now[: len("2026-01-01")])
180
195
  meta = {"title": title, "created": now, "updated": now}
181
196
  return _create(directory, meta, body, stem=stem)
197
+
198
+
199
+ def dated_stem(title: str, day: str) -> str:
200
+ """Return `<day>-<slug>`, with the slug shortened so the whole stem fits the id limit.
201
+
202
+ Raises:
203
+ PageError: If the title yields no slug.
204
+ """
205
+ try:
206
+ slug = slugify(strip_date(title, day), max_length=MAX_ID_LENGTH - DATE_PREFIX_WIDTH)
207
+ except ValueError as error:
208
+ raise PageError(str(error)) from error
209
+ return f"{day}-{slug}"
@@ -16,6 +16,7 @@ if TYPE_CHECKING:
16
16
  from pathlib import Path
17
17
 
18
18
  OPEN_ITEM = "- [ ]"
19
+ _LEADING_PUNCTUATION = "`'\"([{<*_~"
19
20
 
20
21
 
21
22
  @dataclass(frozen=True)
@@ -29,9 +30,14 @@ class Injection:
29
30
  plans: tuple[str, ...]
30
31
 
31
32
 
33
+ def _sort_key(title: str) -> str:
34
+ """Order by the first letter or digit, so a title opening with a code span sorts with its word."""
35
+ return title.lstrip(_LEADING_PUNCTUATION).casefold()
36
+
37
+
32
38
  def _titles(directory: Path) -> tuple[str, ...]:
33
39
  titles = [field.read_page(path).title or path.stem for path in field.iter_pages(directory)]
34
- return tuple(sorted(titles, key=str.casefold))
40
+ return tuple(sorted(titles, key=_sort_key))
35
41
 
36
42
 
37
43
  def _open_backlog(path: Path) -> int:
@@ -57,18 +63,27 @@ def build(vault: Path, slug: str) -> Injection:
57
63
 
58
64
  GUIDANCE = """## Using this vault
59
65
 
60
- Durable memory for this project lives in a vault of markdown pages. Below is the
61
- list of what it already knows; each title is one `{command} search` away.
62
-
63
- - A title below matches what you are doing: `{command} search "<words>"` returns
64
- the page's path, and you Read it. Search before assuming nothing was written down.
65
- - Past sessions: `{command} search "<words>" --logs`. Open work: read `backlog.md`
66
- in the project's vault folder (`{command} project --json` prints every path).
67
- - Something worth keeping past this session: `{command} new learning --title "..."
68
- --summary "..." --cwd .` creates the page and prints the path to write prose into.
69
- Keep a page under 8KB; more detail is another page.
70
- - Specs and plans: `{command} new spec|plan --title "..." --cwd .` creates the file.
71
- Edit `backlog.md`, specs, and plans directly; the CLI only creates pages."""
66
+ Durable memory for this project lives in a vault of markdown pages. Nothing below is
67
+ loaded for you: the titles are what the vault holds, and each is one `{command} search`
68
+ away. The project's folder has `learnings/` and `logs/`, searched by meaning, beside
69
+ `specs/`, `plans/`, and `backlog.md`, which are ordinary files you Read and Edit.
70
+ `{command} project --json` prints every path.
71
+
72
+ - Before assuming nothing was written down, search: `{command} search "<words>"`
73
+ prints each hit's path, title, and summary, and `--read` prints every hit's whole
74
+ page in one call. A paraphrase still matches. No hits means nothing is recorded,
75
+ not that the query needs loosening.
76
+ - Past sessions, one page each: `{command} search "<words>" --logs`.
77
+ - Open work: read `backlog.md`. An item is one line under a `## <kind>` heading
78
+ (feat, fix, refactor, perf, docs, test, build, ci), sized S, M, or L:
79
+ `- [ ] [S] <imperative description> - <YYYY-MM-DD> [#topic]`. Add, tick, or
80
+ delete lines directly. If the file is missing, create it with a `# Backlog` heading.
81
+ - Specs and plans: `{command} new spec|plan --title "..." --cwd .` creates the file
82
+ and prints its path. Edit it directly after that.
83
+ - Learnings are captured at session end, not by you mid-session. When the user asks
84
+ to keep one now: `{command} new learning --title "..." --summary "..." --cwd .`
85
+ creates the page and prints the path to write prose into. Title and summary state
86
+ the fact, not the topic. Keep a page under 8KB; more detail is another page."""
72
87
 
73
88
 
74
89
  def render(injection: Injection, *, command: str = "sessionmemory") -> str:
@@ -7,17 +7,21 @@ searched on request without diluting the learnings field.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import re
10
11
  import uuid
11
12
  from dataclasses import dataclass
12
13
  from typing import TYPE_CHECKING
13
14
 
14
15
  from sessionmemory.lib import field, paths
15
- from sessionmemory.lib.ids import slugify, strip_date
16
16
 
17
17
  if TYPE_CHECKING:
18
18
  from pathlib import Path
19
19
 
20
20
  SESSION_FIELD = "session_id"
21
+ TRANSCRIPT_FIELD = "transcript"
22
+ URL_FIELD = "session_url"
23
+
24
+ _ISO_DATE = re.compile(r"(?<!\d)\d{4}-\d{2}-\d{2}(?!\d)")
21
25
 
22
26
 
23
27
  @dataclass(frozen=True)
@@ -36,6 +40,17 @@ def find_session_log(vault: Path, slug: str, session_id: str) -> Path | None:
36
40
  return None
37
41
 
38
42
 
43
+ def log_date(title: str, today: str) -> str:
44
+ """The day a log is filed under: the first date its title names, else `today`.
45
+
46
+ A log records a session, and the sweep titles it for the moment the session began,
47
+ so a session that runs past midnight is swept on a day its title does not name.
48
+ Filing it under the title's date keeps the filename from carrying both.
49
+ """
50
+ match = _ISO_DATE.search(title)
51
+ return match.group(0) if match else today
52
+
53
+
39
54
  def upsert_log( # noqa: PLR0913
40
55
  vault: Path,
41
56
  *,
@@ -46,20 +61,30 @@ def upsert_log( # noqa: PLR0913
46
61
  body: str,
47
62
  now: str,
48
63
  today: str,
64
+ transcript: str = "",
65
+ session_url: str = "",
49
66
  ) -> Upserted:
50
- """Write this session's log, replacing the page it already has rather than adding one."""
67
+ """Write this session's log, replacing the page it already has rather than adding one.
68
+
69
+ `transcript` and `session_url` are written only when given, and an update never
70
+ clears one the page already carries, since a later sweep of the same session may
71
+ be handed less than the first one was.
72
+ """
73
+ source = {
74
+ key: value
75
+ for key, value in ((TRANSCRIPT_FIELD, transcript), (URL_FIELD, session_url))
76
+ if value
77
+ }
51
78
  existing = find_session_log(vault, slug, session_id)
52
79
  if existing is not None:
53
80
  page = field.read_page(existing)
54
81
  meta = dict(page.meta)
55
- meta.update({"title": title, "summary": summary, "updated": now})
82
+ meta.update({"title": title, "summary": summary, "updated": now, **source})
56
83
  field.write_page(existing, meta, body)
57
84
  return Upserted(path=existing, created=False)
58
85
 
59
- try:
60
- stem = f"{today}-{slugify(strip_date(title, today))}"
61
- except ValueError as error:
62
- raise field.PageError(str(error)) from error
86
+ day = log_date(title, today)
87
+ stem = field.dated_stem(title, day)
63
88
  path = field.claim_filename(paths.logs_dir(vault, slug), title, stem=stem)
64
89
  meta = {
65
90
  "title": title,
@@ -68,6 +93,7 @@ def upsert_log( # noqa: PLR0913
68
93
  "created": now,
69
94
  "updated": now,
70
95
  SESSION_FIELD: session_id,
96
+ **source,
71
97
  }
72
98
  field.write_page(path, meta, body)
73
99
  return Upserted(path=path, created=True)