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.
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/PKG-INFO +86 -69
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/README.md +85 -68
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/pyproject.toml +2 -1
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/pyproject.toml.orig +2 -1
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/cli.py +17 -0
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/_common.py +14 -2
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/init.py +7 -7
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/log.py +7 -1
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/project.py +6 -0
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/config.py +41 -3
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/field.py +30 -2
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/inject.py +28 -13
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/log.py +33 -7
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/__init__.py +0 -0
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/__init__.py +0 -0
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/delete.py +0 -0
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/doctor.py +0 -0
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/export.py +0 -0
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/inject.py +0 -0
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/new.py +0 -0
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/reindex.py +0 -0
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/commands/search.py +0 -0
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/__init__.py +0 -0
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/atomic.py +0 -0
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/bootstrap.py +0 -0
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/doctor.py +0 -0
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/embed.py +0 -0
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/export.py +0 -0
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/fieldindex.py +0 -0
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/frontmatter.py +0 -0
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/gitinfo.py +0 -0
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/ids.py +0 -0
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/paths.py +0 -0
- {sessionmemory-0.2.0 → sessionmemory-0.3.1}/src/sessionmemory/lib/registry.py +0 -0
- {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.
|
|
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
|
-
-
|
|
43
|
-
|
|
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
|
|
51
|
+
## Install
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
cd ~/repos/sessionmemory
|
|
58
|
-
uv sync
|
|
61
|
+
uv tool install sessionmemory
|
|
59
62
|
```
|
|
60
63
|
|
|
61
|
-
To
|
|
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
|
-
```
|
|
64
|
-
|
|
70
|
+
```
|
|
71
|
+
/plugin marketplace add natelandau/sessionmemory
|
|
72
|
+
/plugin install sessionmemory@sessionmemory
|
|
65
73
|
```
|
|
66
74
|
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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.
|
|
106
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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.
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
-
|
|
210
|
-
|
|
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
|
-
-
|
|
28
|
-
|
|
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
|
|
36
|
+
## Install
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
cd ~/repos/sessionmemory
|
|
43
|
-
uv sync
|
|
46
|
+
uv tool install sessionmemory
|
|
44
47
|
```
|
|
45
48
|
|
|
46
|
-
To
|
|
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
|
-
```
|
|
49
|
-
|
|
55
|
+
```
|
|
56
|
+
/plugin marketplace add natelandau/sessionmemory
|
|
57
|
+
/plugin install sessionmemory@sessionmemory
|
|
50
58
|
```
|
|
51
59
|
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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.
|
|
91
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
-
|
|
195
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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=
|
|
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
|
|
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,
|
|
63
|
+
given, the configured root is read directly instead.
|
|
64
64
|
|
|
65
65
|
Raises:
|
|
66
|
-
Exit: When no directory is given and
|
|
67
|
-
a missing directory, or when the target directory holds
|
|
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=
|
|
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
|
|
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"{
|
|
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
|
|
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=
|
|
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.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
-
|
|
71
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|