cli-mem 0.1.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.
- cli_mem-0.1.0.dist-info/METADATA +315 -0
- cli_mem-0.1.0.dist-info/RECORD +12 -0
- cli_mem-0.1.0.dist-info/WHEEL +4 -0
- cli_mem-0.1.0.dist-info/entry_points.txt +2 -0
- cli_mem-0.1.0.dist-info/licenses/LICENSE +21 -0
- mem/__init__.py +3 -0
- mem/capture.py +198 -0
- mem/cli.py +342 -0
- mem/models.py +83 -0
- mem/patterns.py +254 -0
- mem/search.py +171 -0
- mem/storage.py +313 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cli-mem
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Privacy-first CLI that turns shell history into searchable memory
|
|
5
|
+
Project-URL: GitHub, https://github.com/matinsaurralde/mem
|
|
6
|
+
Author: Matias Insaurralde
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: System :: Shells
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: click
|
|
20
|
+
Requires-Dist: pydantic
|
|
21
|
+
Requires-Dist: rich
|
|
22
|
+
Provides-Extra: ai
|
|
23
|
+
Requires-Dist: apple-fm-sdk; extra == 'ai'
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
<p align="center">
|
|
31
|
+
<h1 align="center">mem</h1>
|
|
32
|
+
<p align="center">
|
|
33
|
+
<strong>Your shell history, understood. Not just searched.</strong>
|
|
34
|
+
</p>
|
|
35
|
+
<p align="center">
|
|
36
|
+
A privacy-first CLI that turns your terminal history into an intelligent,<br>
|
|
37
|
+
searchable memory system — powered by on-device AI, with zero cloud dependencies.
|
|
38
|
+
</p>
|
|
39
|
+
<p align="center">
|
|
40
|
+
<a href="#installation"><img alt="macOS 26+" src="https://img.shields.io/badge/macOS-26%2B-blue?logo=apple&logoColor=white"></a>
|
|
41
|
+
<a href="#installation"><img alt="Python 3.10+" src="https://img.shields.io/badge/python-3.10%2B-3776AB?logo=python&logoColor=white"></a>
|
|
42
|
+
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-green"></a>
|
|
43
|
+
<a href="PHILOSOPHY.md"><img alt="Privacy: 100% on-device" src="https://img.shields.io/badge/privacy-100%25%20on--device-brightgreen"></a>
|
|
44
|
+
</p>
|
|
45
|
+
</p>
|
|
46
|
+
|
|
47
|
+
<p align="center">
|
|
48
|
+
<img src="assets/demo.gif" alt="mem demo" width="700">
|
|
49
|
+
</p>
|
|
50
|
+
|
|
51
|
+
<!-- TODO: Record a demo GIF showing: mem deploy → results from different repos -->
|
|
52
|
+
<!-- Use https://github.com/faressoft/terminalizer or asciinema + agg -->
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
Unlike `Ctrl+R`, mem knows *where* you are. The same query returns different results depending on your current git repository — because `kubectl apply` means something different in your infra repo than in your backend repo.
|
|
57
|
+
|
|
58
|
+
Unlike cloud-based history tools, **nothing ever leaves your machine**. Every command, pattern, and session stays in plain text files you can `cat`, `grep`, and `tail`.
|
|
59
|
+
|
|
60
|
+
## Features
|
|
61
|
+
|
|
62
|
+
- **Context-aware search** — results ranked by your current git repo, not just recency
|
|
63
|
+
- **AI pattern extraction** — learns that `kubectl get pods`, `kubectl get services`, `kubectl get deployments` are all `kubectl get <resource>`
|
|
64
|
+
- **100% on-device** — uses Apple Foundation Models locally. Zero network. Zero telemetry
|
|
65
|
+
- **Plain text storage** — everything in `~/.mem/` as JSONL files. Inspect with `cat`. Search with `grep`
|
|
66
|
+
- **Silent capture** — shell hook adds <5ms to prompt. You won't notice it
|
|
67
|
+
- **Session replay** — recall the exact sequence of commands from last Tuesday's debugging session
|
|
68
|
+
|
|
69
|
+
## Quick start
|
|
70
|
+
|
|
71
|
+
### 1. Install
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# Homebrew (recommended)
|
|
75
|
+
brew install matinsaurralde/tap/mem
|
|
76
|
+
|
|
77
|
+
# Quick install script
|
|
78
|
+
curl -fsSL https://raw.githubusercontent.com/matinsaurralde/mem/main/install.sh | bash
|
|
79
|
+
|
|
80
|
+
# pip / pipx
|
|
81
|
+
pipx install mem-cli
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 2. Activate
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
echo 'eval "$(mem init zsh)"' >> ~/.zshrc
|
|
88
|
+
source ~/.zshrc
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 3. Use your terminal
|
|
92
|
+
|
|
93
|
+
Every command is silently captured with full context — directory, git repo, exit code, duration.
|
|
94
|
+
|
|
95
|
+
### 4. Search
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
mem deploy
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
1 kubectl apply -f deployment.yaml infra 2h ago
|
|
103
|
+
2 docker compose up -d backend 1d ago
|
|
104
|
+
3 fly deploy api 3d ago
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
That's it. mem gets smarter the more you use it.
|
|
108
|
+
|
|
109
|
+
## Usage
|
|
110
|
+
|
|
111
|
+
### Search history
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
mem kubectl # search by keyword
|
|
115
|
+
mem "docker compose" # search by phrase
|
|
116
|
+
mem deploy -n 20 # show more results
|
|
117
|
+
mem deploy --json # machine-readable output
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### See patterns
|
|
121
|
+
|
|
122
|
+
After running `mem sync`, mem uses on-device AI to extract structural patterns from your history:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
mem kubectl --pattern
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
Patterns for "kubectl":
|
|
130
|
+
|
|
131
|
+
kubectl get <resource>
|
|
132
|
+
kubectl describe <resource> <name>
|
|
133
|
+
kubectl logs <pod> [--tail=<n>]
|
|
134
|
+
kubectl apply -f <file>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Recall sessions
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
mem session "api outage"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
+-----------------------------------------+
|
|
145
|
+
| Session: 2026-03-07 14:30 myapp |
|
|
146
|
+
|-----------------------------------------|
|
|
147
|
+
| 1 kubectl logs api-7f9b --tail=100 |
|
|
148
|
+
| 2 kubectl get pods -n production |
|
|
149
|
+
| 3 kubectl rollout restart deploy api |
|
|
150
|
+
| 4 curl -s localhost:8080/health |
|
|
151
|
+
+-----------------------------------------+
|
|
152
|
+
|
|
153
|
+
Replay a session? [number/n]: _
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### More commands
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
mem stats # top commands, repos, totals
|
|
160
|
+
mem sync # extract patterns + clean old data
|
|
161
|
+
mem forget "API_KEY=sk-..." # permanently delete sensitive commands
|
|
162
|
+
mem init zsh # print shell hook code
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## How it works
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
You type a command
|
|
169
|
+
│
|
|
170
|
+
▼
|
|
171
|
+
Shell hook (preexec/precmd)
|
|
172
|
+
│
|
|
173
|
+
▼
|
|
174
|
+
mem _capture ← runs in background, <5ms
|
|
175
|
+
│
|
|
176
|
+
▼
|
|
177
|
+
Append one JSON line to ~/.mem/repos/<repo>.jsonl
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
When you search, mem reads the JSONL file for your current repo and scores each command:
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
score = (frequency × 0.4) + (recency × 0.4) + (context × 0.2)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
- **Frequency** — how often you've run this exact command
|
|
187
|
+
- **Recency** — exponential decay with a 7-day half-life
|
|
188
|
+
- **Context** — 1.0 if same repo, 0.5 if same directory prefix, 0.0 otherwise
|
|
189
|
+
|
|
190
|
+
Pattern extraction uses [Apple Foundation Models](https://developer.apple.com/machine-learning/api/) running entirely on-device. No API keys, no cloud calls, no data exfiltration — just your Mac's neural engine.
|
|
191
|
+
|
|
192
|
+
## Storage
|
|
193
|
+
|
|
194
|
+
All data lives in `~/.mem/` as human-readable plain text:
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
~/.mem/
|
|
198
|
+
repos/
|
|
199
|
+
infra-k8s.jsonl # commands from this git repo
|
|
200
|
+
backend.jsonl
|
|
201
|
+
_global.jsonl # commands outside any repo
|
|
202
|
+
sessions/
|
|
203
|
+
2026-03-05.jsonl # grouped work sessions
|
|
204
|
+
patterns/
|
|
205
|
+
kubectl.json # AI-extracted patterns
|
|
206
|
+
docker.json
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Every file is inspectable:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
cat ~/.mem/repos/myapp.jsonl
|
|
213
|
+
tail -f ~/.mem/repos/myapp.jsonl # watch commands arrive in real-time
|
|
214
|
+
grep "docker" ~/.mem/repos/*.jsonl # search across all repos with grep
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Privacy
|
|
218
|
+
|
|
219
|
+
mem is built on a simple promise: **your shell history never leaves your machine**.
|
|
220
|
+
|
|
221
|
+
- Zero network requests — not even update checks
|
|
222
|
+
- Zero telemetry — no usage tracking, no analytics, no crash reports
|
|
223
|
+
- Zero cloud dependencies — works fully offline, always
|
|
224
|
+
- On-device AI only — Apple Foundation Models run on your Mac's neural engine
|
|
225
|
+
- Plain text storage — no proprietary formats, no encrypted blobs. You own your data
|
|
226
|
+
|
|
227
|
+
Read more in [PHILOSOPHY.md](PHILOSOPHY.md).
|
|
228
|
+
|
|
229
|
+
## Requirements
|
|
230
|
+
|
|
231
|
+
| Requirement | Version |
|
|
232
|
+
|-------------|---------|
|
|
233
|
+
| macOS | 26.0+ |
|
|
234
|
+
| Python | 3.10+ |
|
|
235
|
+
| Apple Intelligence | Enabled (for pattern extraction) |
|
|
236
|
+
|
|
237
|
+
> **Note:** mem works without Apple Intelligence — you just won't get AI-extracted patterns. Search, capture, and everything else works fine.
|
|
238
|
+
|
|
239
|
+
## Installation
|
|
240
|
+
|
|
241
|
+
### Homebrew
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
brew tap matinsaurralde/tap
|
|
245
|
+
brew install mem
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Quick install
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
curl -fsSL https://raw.githubusercontent.com/matinsaurralde/mem/main/install.sh | bash
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### pipx (recommended for Python users)
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
pipx install mem-cli
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### From source
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
git clone https://github.com/matinsaurralde/mem.git
|
|
264
|
+
cd mem
|
|
265
|
+
pip install -e ".[ai]" # with AI pattern extraction
|
|
266
|
+
pip install -e "." # without AI (search-only)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Shell setup
|
|
270
|
+
|
|
271
|
+
After installation, add the hook to your shell:
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
# zsh (v1)
|
|
275
|
+
echo 'eval "$(mem init zsh)"' >> ~/.zshrc
|
|
276
|
+
source ~/.zshrc
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Bash and fish support coming in v1.5.
|
|
280
|
+
|
|
281
|
+
## Data retention
|
|
282
|
+
|
|
283
|
+
mem never grows unbounded. Running `mem sync` automatically cleans old data:
|
|
284
|
+
|
|
285
|
+
| Data | Retention | Rationale |
|
|
286
|
+
|------|-----------|-----------|
|
|
287
|
+
| Commands | 90 days | High-volume, older ones rarely recalled |
|
|
288
|
+
| Sessions | 30 days | Useful for recent postmortems |
|
|
289
|
+
| Patterns | Forever | Small files, accumulated learning |
|
|
290
|
+
|
|
291
|
+
Override defaults: `mem sync --keep-commands 180 --keep-sessions 60`
|
|
292
|
+
|
|
293
|
+
## Uninstall
|
|
294
|
+
|
|
295
|
+
```bash
|
|
296
|
+
brew uninstall mem # or: pipx uninstall mem-cli
|
|
297
|
+
rm -rf ~/.mem # remove all captured data
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Remove the `eval "$(mem init zsh)"` line from your `~/.zshrc`.
|
|
301
|
+
|
|
302
|
+
## Contributing
|
|
303
|
+
|
|
304
|
+
Contributions are welcome. Please read the [PHILOSOPHY.md](PHILOSOPHY.md) first to understand the principles that guide this project.
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
git clone https://github.com/matinsaurralde/mem.git
|
|
308
|
+
cd mem
|
|
309
|
+
pip install -e ".[dev]"
|
|
310
|
+
pytest
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
## License
|
|
314
|
+
|
|
315
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
mem/__init__.py,sha256=ewEZCQcr5gM10n0oX2bYh0wXGchwtdehJzqYSSBggmg,69
|
|
2
|
+
mem/capture.py,sha256=rvO-x0axVLrZ3uozeIcoWRbTbZNy674mUCjntCpdLlY,6652
|
|
3
|
+
mem/cli.py,sha256=Po_QiDm46yXd1ntXSlVPt9KV89HCgBWhCii5GiXNukI,10591
|
|
4
|
+
mem/models.py,sha256=AXLCjOR02-MW50uw1XLjlQJhM5JWjG8XWol8kRs6gf4,2206
|
|
5
|
+
mem/patterns.py,sha256=NeGQGQDZomt1m93__-6U74jLgx3aRt0sJeILqZ1oErE,8203
|
|
6
|
+
mem/search.py,sha256=AItSepaTFG63fXuVIdjGpt8f4r1yC6rF_WzXmETGKpk,6005
|
|
7
|
+
mem/storage.py,sha256=4eXFtpRnraliUVh8nRmeg1aFtBFP4qfS-ooCB4heHFg,10750
|
|
8
|
+
cli_mem-0.1.0.dist-info/METADATA,sha256=Won8Vj6QbgmjeINboWJuI3Zy7Um9qWBJz7gA6tojA0o,9018
|
|
9
|
+
cli_mem-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
cli_mem-0.1.0.dist-info/entry_points.txt,sha256=KiuCWp4iI84ovCretwybDasM3_8g9YdK-nypnmeOIWw,36
|
|
11
|
+
cli_mem-0.1.0.dist-info/licenses/LICENSE,sha256=SeV7jm8yfSsZQjMq2fYTOK9FZQqR6chFVGhqjrxl9P8,1084
|
|
12
|
+
cli_mem-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matias Federico Insaurralde
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
mem/__init__.py
ADDED
mem/capture.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shell history capture module.
|
|
3
|
+
|
|
4
|
+
Handles command capture from shell hooks and session tracking.
|
|
5
|
+
The capture pipeline: shell hook -> mem _capture -> this module -> storage.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
from mem.models import CapturedCommand, SessionState, WorkSession
|
|
16
|
+
from mem import storage
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_git_repo(directory: str) -> str | None:
|
|
20
|
+
"""Detect the current git repository's root path.
|
|
21
|
+
|
|
22
|
+
Runs `git rev-parse --show-toplevel` to find the repo root
|
|
23
|
+
and returns the full absolute path (e.g., /Users/me/projects/myapp).
|
|
24
|
+
|
|
25
|
+
Uses the full path — not the basename — so repos with the same
|
|
26
|
+
folder name under different parents stay isolated (e.g.,
|
|
27
|
+
/work/client-a/api and /work/client-b/api are distinct).
|
|
28
|
+
|
|
29
|
+
Returns None if the directory is not inside a git repository.
|
|
30
|
+
|
|
31
|
+
Why subprocess over gitpython: zero dependencies. git is always
|
|
32
|
+
available on macOS, and we only need one command.
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
result = subprocess.run(
|
|
36
|
+
["git", "-C", directory, "rev-parse", "--show-toplevel"],
|
|
37
|
+
capture_output=True,
|
|
38
|
+
text=True,
|
|
39
|
+
timeout=5,
|
|
40
|
+
)
|
|
41
|
+
if result.returncode == 0:
|
|
42
|
+
return result.stdout.strip()
|
|
43
|
+
return None
|
|
44
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def capture_command(raw: str, directory: str, exit_code: int, duration_ms: int) -> None:
|
|
49
|
+
"""Capture a shell command with full context and persist it.
|
|
50
|
+
|
|
51
|
+
Called by the shell hook after every command execution.
|
|
52
|
+
Builds a CapturedCommand with the current timestamp and git repo,
|
|
53
|
+
then appends it to the appropriate JSONL file.
|
|
54
|
+
"""
|
|
55
|
+
repo = get_git_repo(directory)
|
|
56
|
+
cmd = CapturedCommand(
|
|
57
|
+
command=raw,
|
|
58
|
+
ts=int(time.time()),
|
|
59
|
+
dir=directory,
|
|
60
|
+
repo=repo,
|
|
61
|
+
exit_code=exit_code,
|
|
62
|
+
duration_ms=duration_ms,
|
|
63
|
+
)
|
|
64
|
+
storage.append_command(cmd)
|
|
65
|
+
|
|
66
|
+
# Update session tracking
|
|
67
|
+
try:
|
|
68
|
+
tracker = SessionTracker()
|
|
69
|
+
tracker.update(cmd)
|
|
70
|
+
except Exception:
|
|
71
|
+
pass # Session tracking failure should never block capture
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SessionTracker:
|
|
75
|
+
"""Tracks work sessions across shell commands.
|
|
76
|
+
|
|
77
|
+
A session is a coherent sequence of commands grouped by time
|
|
78
|
+
proximity and repository context. Session boundaries are detected
|
|
79
|
+
when:
|
|
80
|
+
|
|
81
|
+
1. More than 300 seconds (5 minutes) of idle time between commands
|
|
82
|
+
2. The user switches to a different git repository
|
|
83
|
+
|
|
84
|
+
Why 300 seconds: Five minutes is long enough that brief interruptions
|
|
85
|
+
(reading docs, bathroom breaks) don't split a session, but short
|
|
86
|
+
enough that genuine context switches are detected. This threshold
|
|
87
|
+
was chosen by observing that most developers maintain focus on a
|
|
88
|
+
single task for at least 5 minutes, and breaks longer than that
|
|
89
|
+
typically indicate a task switch.
|
|
90
|
+
|
|
91
|
+
State is persisted in ~/.mem/.session_state.json so sessions
|
|
92
|
+
survive shell restarts.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(self) -> None:
|
|
96
|
+
self._state_path = storage.MEM_DIR / ".session_state.json"
|
|
97
|
+
|
|
98
|
+
def _load_state(self) -> SessionState | None:
|
|
99
|
+
"""Load the current session state from disk."""
|
|
100
|
+
if not self._state_path.exists():
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
data = json.loads(self._state_path.read_text(encoding="utf-8"))
|
|
104
|
+
return SessionState(**data)
|
|
105
|
+
except Exception:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
def _save_state(self, state: SessionState) -> None:
|
|
109
|
+
"""Persist session state to disk."""
|
|
110
|
+
storage.ensure_dirs()
|
|
111
|
+
self._state_path.write_text(
|
|
112
|
+
state.model_dump_json(), encoding="utf-8"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def _clear_state(self) -> None:
|
|
116
|
+
"""Remove session state file."""
|
|
117
|
+
if self._state_path.exists():
|
|
118
|
+
self._state_path.unlink()
|
|
119
|
+
|
|
120
|
+
def update(self, cmd: CapturedCommand) -> None:
|
|
121
|
+
"""Process a new command and update session state.
|
|
122
|
+
|
|
123
|
+
Detects session boundaries and closes sessions when:
|
|
124
|
+
- More than 300 seconds have passed since the last command
|
|
125
|
+
- The git repo has changed
|
|
126
|
+
"""
|
|
127
|
+
state = self._load_state()
|
|
128
|
+
|
|
129
|
+
if state is None:
|
|
130
|
+
# Start a new session
|
|
131
|
+
new_state = SessionState(
|
|
132
|
+
session_id=uuid.uuid4().hex,
|
|
133
|
+
last_command_ts=cmd.ts,
|
|
134
|
+
last_repo=cmd.repo,
|
|
135
|
+
commands=[cmd.command],
|
|
136
|
+
)
|
|
137
|
+
self._save_state(new_state)
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
idle_time = cmd.ts - state.last_command_ts
|
|
141
|
+
repo_changed = cmd.repo != state.last_repo
|
|
142
|
+
|
|
143
|
+
# Session boundary: >300s idle OR repo change
|
|
144
|
+
if idle_time > 300 or repo_changed:
|
|
145
|
+
self._close_session(state)
|
|
146
|
+
# Start new session
|
|
147
|
+
new_state = SessionState(
|
|
148
|
+
session_id=uuid.uuid4().hex,
|
|
149
|
+
last_command_ts=cmd.ts,
|
|
150
|
+
last_repo=cmd.repo,
|
|
151
|
+
commands=[cmd.command],
|
|
152
|
+
)
|
|
153
|
+
self._save_state(new_state)
|
|
154
|
+
else:
|
|
155
|
+
# Continue current session
|
|
156
|
+
state.commands.append(cmd.command)
|
|
157
|
+
state.last_command_ts = cmd.ts
|
|
158
|
+
state.last_repo = cmd.repo
|
|
159
|
+
self._save_state(state)
|
|
160
|
+
|
|
161
|
+
def _close_session(self, state: SessionState) -> None:
|
|
162
|
+
"""Close and persist a completed session."""
|
|
163
|
+
if not state.commands:
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
# Generate summary — use first command as fallback when AI unavailable
|
|
167
|
+
summary = self._generate_summary(state.commands, state.last_repo)
|
|
168
|
+
|
|
169
|
+
session = WorkSession(
|
|
170
|
+
id=state.session_id,
|
|
171
|
+
summary=summary,
|
|
172
|
+
started_at=state.last_command_ts - (len(state.commands) * 10), # approximate
|
|
173
|
+
ended_at=state.last_command_ts,
|
|
174
|
+
dir="", # not tracked in state for simplicity
|
|
175
|
+
repo=state.last_repo,
|
|
176
|
+
commands=state.commands,
|
|
177
|
+
)
|
|
178
|
+
storage.append_session(session)
|
|
179
|
+
|
|
180
|
+
def _generate_summary(self, commands: list[str], repo: str | None) -> str:
|
|
181
|
+
"""Generate a session summary.
|
|
182
|
+
|
|
183
|
+
Uses Apple FM SDK if available, otherwise falls back to
|
|
184
|
+
using the first command as the summary.
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
import asyncio
|
|
188
|
+
from mem.patterns import generate_session_summary
|
|
189
|
+
result = asyncio.run(generate_session_summary(commands))
|
|
190
|
+
if result:
|
|
191
|
+
return result
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
# Fallback: first command + count
|
|
196
|
+
if len(commands) == 1:
|
|
197
|
+
return commands[0]
|
|
198
|
+
return f"{commands[0]} (+{len(commands) - 1} more commands)"
|