lifenote 0.1.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.
@@ -0,0 +1,218 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+ # Temporary file for partial code execution
204
+ tempCodeRunnerFile.py
205
+
206
+ # Ruff stuff:
207
+ .ruff_cache/
208
+
209
+ # PyPI configuration file
210
+ .pypirc
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Streamlit
218
+ .streamlit/secrets.toml
lifenote-0.1.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel W. Chen
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.
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: lifenote
3
+ Version: 0.1.1
4
+ Summary: Bring your private journal into your AI agents — Claude Code, Cursor, Codex. MCP-compatible.
5
+ Project-URL: Homepage, https://www.mylifenote.ai
6
+ Project-URL: Source, https://github.com/dandanbang/lifenote-cli
7
+ Project-URL: Issues, https://github.com/dandanbang/lifenote-cli/issues
8
+ Author-email: "Daniel W. Chen" <daniel@mylifenote.ai>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-agents,claude-code,cursor,journaling,mcp
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Utilities
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: filelock>=3.12
18
+ Requires-Dist: httpx>=0.27
19
+ Requires-Dist: keyring>=24.0
20
+ Requires-Dist: mcp>=1.0
21
+ Requires-Dist: rich>=13.0
22
+ Requires-Dist: typer>=0.12
23
+ Description-Content-Type: text/markdown
24
+
25
+ # lifenote
26
+
27
+ Bring your private [Life Note](https://www.mylifenote.ai) journal into your AI agents — Claude Code, Claude Desktop, Cursor, Codex, anything that speaks MCP.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pipx install lifenote # recommended
33
+ # or
34
+ pip install lifenote
35
+ ```
36
+
37
+ ## Sign in
38
+
39
+ ```bash
40
+ lifenote auth login
41
+ ```
42
+
43
+ Opens your browser, you click **Allow**, you're done. Your token is stored in your OS keychain (macOS Keychain / Windows Credential Manager / Linux libsecret) — never in plain files.
44
+
45
+ For CI / Docker / headless agents, set `LIFENOTE_API_TOKEN` in the environment instead.
46
+
47
+ ## Use it from the terminal
48
+
49
+ ```bash
50
+ lifenote search "anxiety about launch"
51
+ lifenote entries --since 7d
52
+ lifenote today
53
+ lifenote week
54
+ lifenote conversations list
55
+ lifenote conversations show 574
56
+ ```
57
+
58
+ Add `--json` to any command for piping into agents:
59
+
60
+ ```bash
61
+ lifenote search "burnout" --json | claude "summarize the patterns"
62
+ ```
63
+
64
+ ## Use it from your AI agent (MCP)
65
+
66
+ ### Claude Desktop
67
+
68
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
69
+
70
+ ```json
71
+ {
72
+ "mcpServers": {
73
+ "lifenote": {
74
+ "command": "lifenote",
75
+ "args": ["mcp"]
76
+ }
77
+ }
78
+ }
79
+ ```
80
+
81
+ Restart Claude Desktop. Your journal is now a tool Claude can reach for on its own.
82
+
83
+ ### Claude.ai web (Connectors)
84
+
85
+ Settings → Connectors → Add → paste `https://mcp.mylifenote.ai/api/agent/v1/mcp`. Approve the OAuth screen.
86
+
87
+ ### Cursor / Codex / other MCP clients
88
+
89
+ Same `command: lifenote, args: [mcp]` pattern. Or point them at the HTTP endpoint directly.
90
+
91
+ ## Bundled Claude Code skill
92
+
93
+ Drop a brand-aligned skill into your `~/.claude/skills/` so Claude Code knows when to reach for Life Note vs. when not to:
94
+
95
+ ```bash
96
+ lifenote skills install
97
+ ```
98
+
99
+ ## What the agent can read
100
+
101
+ Two scopes today, both read-only:
102
+
103
+ - `journal:read` — your written entries + AI-generated analysis (themes, feelings)
104
+ - `chat:read` — your past conversations with mentors (Marcus Aurelius, etc.)
105
+
106
+ It **cannot** write to your journal, change your settings, see payment info, or access other users' data.
107
+
108
+ You can revoke any agent anytime at <https://www.mylifenote.ai/settings/agents>.
109
+
110
+ ## Configuration
111
+
112
+ Environment variables (all optional):
113
+
114
+ - `LIFENOTE_API_TOKEN` — bypass keychain (essential for CI / Docker)
115
+ - `LIFENOTE_BASE_URL` — point at staging / dev (default: `https://www.mylifenote.ai`)
116
+
117
+ ## License
118
+
119
+ MIT. Built by [Daniel W. Chen](https://github.com/dandanbang).
@@ -0,0 +1,95 @@
1
+ # lifenote
2
+
3
+ Bring your private [Life Note](https://www.mylifenote.ai) journal into your AI agents — Claude Code, Claude Desktop, Cursor, Codex, anything that speaks MCP.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pipx install lifenote # recommended
9
+ # or
10
+ pip install lifenote
11
+ ```
12
+
13
+ ## Sign in
14
+
15
+ ```bash
16
+ lifenote auth login
17
+ ```
18
+
19
+ Opens your browser, you click **Allow**, you're done. Your token is stored in your OS keychain (macOS Keychain / Windows Credential Manager / Linux libsecret) — never in plain files.
20
+
21
+ For CI / Docker / headless agents, set `LIFENOTE_API_TOKEN` in the environment instead.
22
+
23
+ ## Use it from the terminal
24
+
25
+ ```bash
26
+ lifenote search "anxiety about launch"
27
+ lifenote entries --since 7d
28
+ lifenote today
29
+ lifenote week
30
+ lifenote conversations list
31
+ lifenote conversations show 574
32
+ ```
33
+
34
+ Add `--json` to any command for piping into agents:
35
+
36
+ ```bash
37
+ lifenote search "burnout" --json | claude "summarize the patterns"
38
+ ```
39
+
40
+ ## Use it from your AI agent (MCP)
41
+
42
+ ### Claude Desktop
43
+
44
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
45
+
46
+ ```json
47
+ {
48
+ "mcpServers": {
49
+ "lifenote": {
50
+ "command": "lifenote",
51
+ "args": ["mcp"]
52
+ }
53
+ }
54
+ }
55
+ ```
56
+
57
+ Restart Claude Desktop. Your journal is now a tool Claude can reach for on its own.
58
+
59
+ ### Claude.ai web (Connectors)
60
+
61
+ Settings → Connectors → Add → paste `https://mcp.mylifenote.ai/api/agent/v1/mcp`. Approve the OAuth screen.
62
+
63
+ ### Cursor / Codex / other MCP clients
64
+
65
+ Same `command: lifenote, args: [mcp]` pattern. Or point them at the HTTP endpoint directly.
66
+
67
+ ## Bundled Claude Code skill
68
+
69
+ Drop a brand-aligned skill into your `~/.claude/skills/` so Claude Code knows when to reach for Life Note vs. when not to:
70
+
71
+ ```bash
72
+ lifenote skills install
73
+ ```
74
+
75
+ ## What the agent can read
76
+
77
+ Two scopes today, both read-only:
78
+
79
+ - `journal:read` — your written entries + AI-generated analysis (themes, feelings)
80
+ - `chat:read` — your past conversations with mentors (Marcus Aurelius, etc.)
81
+
82
+ It **cannot** write to your journal, change your settings, see payment info, or access other users' data.
83
+
84
+ You can revoke any agent anytime at <https://www.mylifenote.ai/settings/agents>.
85
+
86
+ ## Configuration
87
+
88
+ Environment variables (all optional):
89
+
90
+ - `LIFENOTE_API_TOKEN` — bypass keychain (essential for CI / Docker)
91
+ - `LIFENOTE_BASE_URL` — point at staging / dev (default: `https://www.mylifenote.ai`)
92
+
93
+ ## License
94
+
95
+ MIT. Built by [Daniel W. Chen](https://github.com/dandanbang).
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "lifenote"
7
+ version = "0.1.1"
8
+ description = "Bring your private journal into your AI agents — Claude Code, Cursor, Codex. MCP-compatible."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Daniel W. Chen", email = "daniel@mylifenote.ai" }]
12
+ requires-python = ">=3.10"
13
+ keywords = ["journaling", "mcp", "ai-agents", "claude-code", "cursor"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Topic :: Utilities",
19
+ ]
20
+ dependencies = [
21
+ "typer>=0.12",
22
+ "httpx>=0.27",
23
+ "keyring>=24.0",
24
+ "rich>=13.0",
25
+ "mcp>=1.0",
26
+ "filelock>=3.12", # cross-platform refresh-token lock (Codex round 4)
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://www.mylifenote.ai"
31
+ Source = "https://github.com/dandanbang/lifenote-cli"
32
+ Issues = "https://github.com/dandanbang/lifenote-cli/issues"
33
+
34
+ [project.scripts]
35
+ lifenote = "lifenote.cli:app"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/lifenote"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
@@ -0,0 +1,43 @@
1
+ """Thin httpx wrapper for /api/agent/v1/* — handles auth header + JSON."""
2
+ import httpx
3
+
4
+ from .auth import get_token
5
+ from .config import base_url
6
+
7
+
8
+ class APIError(Exception):
9
+ def __init__(self, status: int, body):
10
+ self.status = status
11
+ self.body = body
12
+ super().__init__(f"{status}: {body}")
13
+
14
+
15
+ def _client(timeout: float = 30.0) -> httpx.Client:
16
+ token = get_token()
17
+ headers = {"Authorization": f"Bearer {token}"} if token else {}
18
+ headers["User-Agent"] = "lifenote-cli/0.1.0"
19
+ return httpx.Client(base_url=base_url(), headers=headers, timeout=timeout)
20
+
21
+
22
+ def get(path: str, params: dict | None = None) -> dict:
23
+ with _client() as c:
24
+ r = c.get(path, params=params or {})
25
+ if r.status_code != 200:
26
+ try:
27
+ body = r.json()
28
+ except Exception:
29
+ body = r.text
30
+ raise APIError(r.status_code, body)
31
+ return r.json()
32
+
33
+
34
+ def delete(path: str) -> dict:
35
+ with _client() as c:
36
+ r = c.delete(path)
37
+ if r.status_code not in (200, 204):
38
+ try:
39
+ body = r.json()
40
+ except Exception:
41
+ body = r.text
42
+ raise APIError(r.status_code, body)
43
+ return {} if r.status_code == 204 else r.json()
@@ -0,0 +1,272 @@
1
+ """
2
+ Token storage + device-flow login + automatic refresh.
3
+
4
+ Storage precedence (read order):
5
+ 1. LIFENOTE_API_TOKEN env var (for CI / Docker / headless agents)
6
+ 2. OS keychain (macOS Keychain, Windows Cred Manager, Linux libsecret)
7
+ stores a JSON blob with {access, refresh, expires_at} so the CLI can
8
+ transparently refresh access tokens before they expire (Codex P2 #5).
9
+
10
+ Env-var path is essential: keyring fails or is unavailable in many headless
11
+ environments (CI runners, Docker without secrets manager, sandboxed agents).
12
+ Env-var mode skips refresh — the operator is expected to rotate it.
13
+ """
14
+ import json
15
+ import os
16
+ import socket
17
+ import time
18
+ import webbrowser
19
+ from datetime import datetime, timedelta, timezone
20
+
21
+ import httpx
22
+
23
+ from .config import (
24
+ ENV_TOKEN_VAR, KEYRING_SERVICE, KEYRING_USERNAME, base_url,
25
+ )
26
+
27
+ # Refresh access token when it has fewer than this many seconds left
28
+ REFRESH_LEEWAY_SECONDS = 120
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Storage
33
+ # ---------------------------------------------------------------------------
34
+
35
+ def _now_iso() -> str:
36
+ return datetime.now(timezone.utc).isoformat()
37
+
38
+
39
+ def _read_keyring_blob() -> dict | None:
40
+ try:
41
+ import keyring
42
+ raw = keyring.get_password(KEYRING_SERVICE, KEYRING_USERNAME)
43
+ except Exception:
44
+ return None
45
+ if not raw:
46
+ return None
47
+ try:
48
+ blob = json.loads(raw)
49
+ if isinstance(blob, dict) and 'access' in blob:
50
+ return blob
51
+ # Legacy: pre-refresh-rotation versions stored just the access token
52
+ return {'access': raw, 'refresh': None, 'expires_at': None}
53
+ except (ValueError, TypeError):
54
+ return {'access': raw, 'refresh': None, 'expires_at': None}
55
+
56
+
57
+ def _write_keyring_blob(blob: dict) -> None:
58
+ import keyring
59
+ keyring.set_password(KEYRING_SERVICE, KEYRING_USERNAME, json.dumps(blob))
60
+
61
+
62
+ def store_credentials(access: str, refresh: str | None, expires_in: int | None) -> None:
63
+ """Save access + refresh + computed absolute expiry."""
64
+ expires_at = None
65
+ if expires_in:
66
+ expires_at = (datetime.now(timezone.utc) + timedelta(seconds=expires_in)).isoformat()
67
+ _write_keyring_blob({'access': access, 'refresh': refresh, 'expires_at': expires_at})
68
+
69
+
70
+ def get_token() -> str | None:
71
+ """
72
+ Env var first, keyring second. If the keyring blob has a refresh token
73
+ and the access is near expiry, transparently refreshes (with a
74
+ cross-process file lock so two CLIs/MCP-proxies running concurrently
75
+ don't both rotate the same refresh token and trigger family-burn —
76
+ Codex round 3 P2 #4).
77
+
78
+ Returns the access token or None.
79
+ """
80
+ env = os.environ.get(ENV_TOKEN_VAR)
81
+ if env:
82
+ return env.strip()
83
+ blob = _read_keyring_blob()
84
+ if not blob:
85
+ return None
86
+ access = blob.get('access')
87
+ refresh = blob.get('refresh')
88
+ expires_at = blob.get('expires_at')
89
+ if access and refresh and expires_at and _is_near_expiry(expires_at):
90
+ rotated = _refresh_with_lock(refresh)
91
+ if rotated:
92
+ return rotated
93
+ return access
94
+
95
+
96
+ def _is_near_expiry(expires_at_iso: str) -> bool:
97
+ try:
98
+ exp = datetime.fromisoformat(expires_at_iso)
99
+ except ValueError:
100
+ return True
101
+ return (exp - datetime.now(timezone.utc)).total_seconds() < REFRESH_LEEWAY_SECONDS
102
+
103
+
104
+ def _lock_path() -> str:
105
+ """Per-user file lock for refresh — under XDG runtime dir if possible."""
106
+ import tempfile
107
+ base = os.environ.get('XDG_RUNTIME_DIR') or tempfile.gettempdir()
108
+ return os.path.join(base, 'lifenote-cli-refresh.lock')
109
+
110
+
111
+ def _refresh_with_lock(refresh_plain: str) -> str | None:
112
+ """
113
+ Acquire a cross-platform file lock around the refresh exchange (filelock
114
+ handles Windows, macOS, Linux). While holding the lock we re-read the
115
+ keyring; if another process already rotated, return its newly-stored
116
+ access without burning the family.
117
+
118
+ Codex round 5 P3: fail closed if filelock is missing — racing refresh
119
+ would burn the family on concurrent processes. filelock is a declared
120
+ dependency, so missing it means a broken install; tell the user to
121
+ reinstall rather than silently downgrading the guarantee.
122
+ """
123
+ try:
124
+ from filelock import FileLock, Timeout
125
+ except ImportError:
126
+ import sys as _sys
127
+ _sys.stderr.write(
128
+ 'lifenote: filelock dependency is missing — refresh would race '
129
+ 'across processes and burn your session. Reinstall the CLI '
130
+ '(pipx reinstall lifenote) and try again.\n'
131
+ )
132
+ return None
133
+
134
+ lock = FileLock(_lock_path() + '.flock', timeout=30)
135
+ try:
136
+ with lock:
137
+ latest = _read_keyring_blob() or {}
138
+ latest_refresh = latest.get('refresh')
139
+ latest_access = latest.get('access')
140
+ latest_expires = latest.get('expires_at')
141
+ if latest_refresh != refresh_plain and latest_access and latest_expires:
142
+ if not _is_near_expiry(latest_expires):
143
+ return latest_access # someone else just refreshed — use it
144
+ return _try_refresh(latest_refresh or refresh_plain)
145
+ except Timeout:
146
+ # Another process is taking too long; don't burn the family
147
+ return None
148
+
149
+
150
+ def _try_refresh(refresh_plain: str) -> str | None:
151
+ """
152
+ Exchange refresh token for a new pair via /api/agent/v1/oauth/token.
153
+ On reuse-detected (invalid_grant), clear stored creds — the user must
154
+ re-login. Returns new access token on success, None on failure.
155
+
156
+ Caller is responsible for serialization (use _refresh_with_lock above
157
+ when running from a long-lived process like mcp_proxy).
158
+ """
159
+ try:
160
+ with httpx.Client(timeout=15.0) as c:
161
+ r = c.post(
162
+ f'{base_url()}/api/agent/v1/oauth/token',
163
+ data={'grant_type': 'refresh_token', 'refresh_token': refresh_plain},
164
+ )
165
+ if r.status_code != 200:
166
+ # invalid_grant could mean: expired, revoked, OR reuse detected
167
+ # (family burned). In any case, the stored creds are dead.
168
+ clear_token()
169
+ return None
170
+ body = r.json()
171
+ store_credentials(
172
+ access=body['access_token'],
173
+ refresh=body.get('refresh_token'),
174
+ expires_in=body.get('expires_in'),
175
+ )
176
+ return body['access_token']
177
+ except Exception:
178
+ return None
179
+
180
+
181
+ def set_token(token: str) -> None:
182
+ """
183
+ Legacy API — store just an access token (no refresh, no expiry). Kept
184
+ for backward-compat with old-CLI users; new logins go through
185
+ store_credentials() instead.
186
+ """
187
+ _write_keyring_blob({'access': token, 'refresh': None, 'expires_at': None})
188
+
189
+
190
+ def clear_token() -> bool:
191
+ """Remove from keyring. Env var (if set) is left alone."""
192
+ try:
193
+ import keyring
194
+ keyring.delete_password(KEYRING_SERVICE, KEYRING_USERNAME)
195
+ return True
196
+ except Exception:
197
+ return False
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # Device flow
202
+ # ---------------------------------------------------------------------------
203
+
204
+ def _agent_name() -> str:
205
+ """Friendly default name shown in the approval UI. User-overridable."""
206
+ host = socket.gethostname().split(".")[0]
207
+ return f"lifenote CLI on {host}"
208
+
209
+
210
+ def device_login(*, scopes: list[str] | None = None,
211
+ name: str | None = None,
212
+ open_browser: bool = True) -> dict:
213
+ """
214
+ Run the device-authorization flow. Blocks until the user approves (or
215
+ until the device code expires). Returns the token response dict on
216
+ success; raises RuntimeError otherwise.
217
+ """
218
+ scopes = scopes or ["journal:read", "chat:read"]
219
+ name = name or _agent_name()
220
+
221
+ # 1. Request device code
222
+ with httpx.Client(timeout=30.0) as client:
223
+ r = client.post(
224
+ f"{base_url()}/api/agent/v1/device/code",
225
+ json={"name": name, "scopes": scopes, "client_kind": "cli"},
226
+ )
227
+ if r.status_code != 200:
228
+ raise RuntimeError(f"device/code failed: {r.status_code} {r.text}")
229
+ body = r.json()
230
+
231
+ user_code = body["user_code"]
232
+ verify = body.get("verification_uri_complete") or body["verification_uri"]
233
+ interval = body.get("interval", 5)
234
+ expires_in = body.get("expires_in", 600)
235
+ device_code = body["device_code"]
236
+
237
+ print()
238
+ print(f" Open this in your browser: {verify}")
239
+ print(f" Or visit {body['verification_uri']} and enter: {user_code}")
240
+ print()
241
+
242
+ if open_browser:
243
+ try:
244
+ webbrowser.open(verify)
245
+ except Exception:
246
+ pass
247
+
248
+ # 2. Poll for token
249
+ deadline = time.time() + expires_in
250
+ with httpx.Client(timeout=30.0) as client:
251
+ while time.time() < deadline:
252
+ time.sleep(interval)
253
+ r = client.post(
254
+ f"{base_url()}/api/agent/v1/device/token",
255
+ json={"device_code": device_code},
256
+ )
257
+ if r.status_code == 200:
258
+ return r.json()
259
+ try:
260
+ err = r.json().get("error")
261
+ except Exception:
262
+ err = None
263
+ if err == "authorization_pending":
264
+ continue
265
+ if err == "slow_down":
266
+ interval += 2
267
+ continue
268
+ if err in ("access_denied", "expired_token"):
269
+ raise RuntimeError(f"login {err}")
270
+ # Other errors (invalid_grant etc.) — bail
271
+ raise RuntimeError(f"login failed: {r.status_code} {r.text}")
272
+ raise RuntimeError("login timed out — please run `lifenote auth login` again")
@@ -0,0 +1,227 @@
1
+ """
2
+ lifenote CLI — typer entry point.
3
+
4
+ Commands:
5
+ auth login | logout | whoami
6
+ search <query>
7
+ entries [--since] [--limit]
8
+ today | week
9
+ conversations list | show <id>
10
+ mcp # stdio MCP server
11
+ skills install # bundled Claude Code skill
12
+ """
13
+ import sys
14
+
15
+ import typer
16
+
17
+ from . import __version__
18
+ from .api import APIError, get
19
+ from .auth import device_login, store_credentials, clear_token, get_token
20
+ from .output import console, emit
21
+
22
+ app = typer.Typer(
23
+ add_completion=False,
24
+ no_args_is_help=True,
25
+ help="Bring your private journal into your AI agents.",
26
+ rich_markup_mode="rich",
27
+ )
28
+
29
+ auth_app = typer.Typer(no_args_is_help=True, help="Sign in, sign out, who am I.")
30
+ conv_app = typer.Typer(no_args_is_help=True, help="Past mentor conversations.")
31
+ skills_app = typer.Typer(no_args_is_help=True, help="Bundled agent skills.")
32
+ app.add_typer(auth_app, name="auth")
33
+ app.add_typer(conv_app, name="conversations")
34
+ app.add_typer(skills_app, name="skills")
35
+
36
+
37
+ def _handle_api_error(e: APIError) -> None:
38
+ if e.status == 401:
39
+ console.print("[red]Not signed in.[/] Run [bold]lifenote auth login[/].")
40
+ elif e.status == 403:
41
+ console.print(f"[red]Permission denied:[/] {e.body}")
42
+ elif e.status == 429:
43
+ console.print(f"[yellow]Rate limited:[/] {e.body}")
44
+ else:
45
+ console.print(f"[red]API error {e.status}:[/] {e.body}")
46
+ raise typer.Exit(1)
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # auth
51
+ # ---------------------------------------------------------------------------
52
+
53
+ @auth_app.command("login")
54
+ def auth_login(
55
+ name: str | None = typer.Option(None, help="Name shown in the approval UI."),
56
+ no_browser: bool = typer.Option(False, help="Don't open the browser automatically."),
57
+ ):
58
+ """Sign in via the device flow."""
59
+ try:
60
+ result = device_login(name=name, open_browser=not no_browser)
61
+ except RuntimeError as e:
62
+ console.print(f"[red]Login failed:[/] {e}")
63
+ raise typer.Exit(1)
64
+ # Store full credentials so the CLI can transparently refresh when the
65
+ # 1-hour access token nears expiry. (Codex P2 #5)
66
+ store_credentials(
67
+ access=result["access_token"],
68
+ refresh=result.get("refresh_token"),
69
+ expires_in=result.get("expires_in"),
70
+ )
71
+ console.print(f"[green]Signed in.[/] Scopes: {result.get('scope', '?')}")
72
+
73
+
74
+ @auth_app.command("logout")
75
+ def auth_logout():
76
+ """Forget the stored token (does not revoke it server-side)."""
77
+ if clear_token():
78
+ console.print("[green]Signed out.[/] To revoke server-side, visit Settings → Connected Agents.")
79
+ else:
80
+ console.print("[dim]No stored token.[/]")
81
+
82
+
83
+ @auth_app.command("whoami")
84
+ def auth_whoami(json: bool = typer.Option(False, "--json")):
85
+ """Show who you're signed in as."""
86
+ if not get_token():
87
+ console.print("[red]Not signed in.[/] Run [bold]lifenote auth login[/].")
88
+ raise typer.Exit(1)
89
+ try:
90
+ emit(get("/api/agent/v1/whoami"), json)
91
+ except APIError as e:
92
+ _handle_api_error(e)
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # search / entries
97
+ # ---------------------------------------------------------------------------
98
+
99
+ @app.command()
100
+ def search(
101
+ query: str = typer.Argument(..., help="Search term (≥2 chars)."),
102
+ since: str | None = typer.Option(None, help="Time bound: 7d / 30d / YYYY-MM-DD"),
103
+ limit: int = typer.Option(20, help="Max results (1–50)."),
104
+ json: bool = typer.Option(False, "--json"),
105
+ ):
106
+ """Search journal entries, mentor responses, and conversations."""
107
+ params = {"q": query, "limit": limit}
108
+ if since:
109
+ params["since"] = since
110
+ try:
111
+ emit(get("/api/agent/v1/search", params=params), json)
112
+ except APIError as e:
113
+ _handle_api_error(e)
114
+
115
+
116
+ @app.command()
117
+ def entries(
118
+ since: str | None = typer.Option(None, help="Time bound: 7d / 30d / YYYY-MM-DD"),
119
+ limit: int = typer.Option(20, help="Max entries (1–100)."),
120
+ json: bool = typer.Option(False, "--json"),
121
+ ):
122
+ """List recent journal entries."""
123
+ params: dict = {"limit": limit}
124
+ if since:
125
+ params["since"] = since
126
+ try:
127
+ emit(get("/api/agent/v1/entries", params=params), json)
128
+ except APIError as e:
129
+ _handle_api_error(e)
130
+
131
+
132
+ @app.command()
133
+ def today(json: bool = typer.Option(False, "--json")):
134
+ """Today's journal entries (shortcut for entries --since 1d)."""
135
+ try:
136
+ emit(get("/api/agent/v1/entries", params={"since": "1d", "limit": 50}), json)
137
+ except APIError as e:
138
+ _handle_api_error(e)
139
+
140
+
141
+ @app.command()
142
+ def week(json: bool = typer.Option(False, "--json")):
143
+ """The last 7 days of entries."""
144
+ try:
145
+ emit(get("/api/agent/v1/entries", params={"since": "7d", "limit": 100}), json)
146
+ except APIError as e:
147
+ _handle_api_error(e)
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # conversations
152
+ # ---------------------------------------------------------------------------
153
+
154
+ @conv_app.command("list")
155
+ def conv_list(
156
+ since: str | None = typer.Option(None),
157
+ limit: int = typer.Option(20),
158
+ json: bool = typer.Option(False, "--json"),
159
+ ):
160
+ """List past mentor conversations."""
161
+ params: dict = {"limit": limit}
162
+ if since:
163
+ params["since"] = since
164
+ try:
165
+ emit(get("/api/agent/v1/conversations", params=params), json)
166
+ except APIError as e:
167
+ _handle_api_error(e)
168
+
169
+
170
+ @conv_app.command("show")
171
+ def conv_show(
172
+ conversation_id: int = typer.Argument(...),
173
+ json: bool = typer.Option(False, "--json"),
174
+ ):
175
+ """Show one conversation's full message thread."""
176
+ try:
177
+ emit(get(f"/api/agent/v1/conversations/{conversation_id}"), json)
178
+ except APIError as e:
179
+ _handle_api_error(e)
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # mcp — stdio MCP server proxying to the HTTP MCP endpoint
184
+ # ---------------------------------------------------------------------------
185
+
186
+ @app.command()
187
+ def mcp():
188
+ """
189
+ Run as a stdio MCP server. Reads JSON-RPC from stdin, writes to stdout.
190
+ Proxies every request to /api/agent/v1/mcp using the stored token.
191
+
192
+ Configure in Claude Desktop's claude_desktop_config.json:
193
+ { "mcpServers": { "lifenote": { "command": "lifenote", "args": ["mcp"] } } }
194
+ """
195
+ from .mcp_proxy import run as run_mcp
196
+ if not get_token():
197
+ # MCP clients see stderr in their logs
198
+ sys.stderr.write("lifenote: not signed in. Run `lifenote auth login` first.\n")
199
+ sys.exit(1)
200
+ run_mcp()
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # skills install
205
+ # ---------------------------------------------------------------------------
206
+
207
+ @skills_app.command("install")
208
+ def skills_install(
209
+ target: str = typer.Option(
210
+ "~/.claude/skills",
211
+ help="Where to drop the lifenote.md skill.",
212
+ ),
213
+ ):
214
+ """Install the bundled Claude Code skill into ~/.claude/skills/."""
215
+ from .skill import install as install_skill
216
+ path = install_skill(target)
217
+ console.print(f"[green]Installed[/] {path}")
218
+
219
+
220
+ @app.command()
221
+ def version():
222
+ """Print the CLI version."""
223
+ console.print(f"lifenote {__version__}")
224
+
225
+
226
+ if __name__ == "__main__":
227
+ app()
@@ -0,0 +1,12 @@
1
+ """Runtime config — base URL, env-var token fallback, keyring service name."""
2
+ import os
3
+
4
+ DEFAULT_BASE_URL = "https://www.mylifenote.ai"
5
+ KEYRING_SERVICE = "mylifenote"
6
+ KEYRING_USERNAME = "agent-token"
7
+ ENV_TOKEN_VAR = "LIFENOTE_API_TOKEN"
8
+ ENV_BASE_URL_VAR = "LIFENOTE_BASE_URL"
9
+
10
+
11
+ def base_url() -> str:
12
+ return os.environ.get(ENV_BASE_URL_VAR, DEFAULT_BASE_URL).rstrip("/")
@@ -0,0 +1,106 @@
1
+ """
2
+ stdio MCP server that proxies to the HTTP MCP endpoint.
3
+
4
+ Reads JSON-RPC requests from stdin (one per line), forwards each to
5
+ POST /api/agent/v1/mcp with the user's Bearer token, writes the response
6
+ back to stdout. Lives as `lifenote mcp` so MCP clients (Claude Desktop,
7
+ Cursor) can spawn the binary without dealing with HTTP+auth themselves.
8
+
9
+ Long-running correctness (Codex round 2 P2 #6 + #7):
10
+ - 202 No Content → notification; emit nothing per JSON-RPC spec
11
+ - 401 → refresh access token from keyring (refresh path) and retry once;
12
+ if refresh fails, emit JSON-RPC error and ask user to re-login
13
+ - The proxy holds a single session id across requests
14
+ """
15
+ import json
16
+ import sys
17
+
18
+ import httpx
19
+
20
+ from .auth import get_token, _refresh_with_lock, _read_keyring_blob
21
+ from .config import base_url
22
+
23
+
24
+ def run() -> None:
25
+ if not get_token():
26
+ sys.stderr.write("lifenote mcp: no token. Run `lifenote auth login` first.\n")
27
+ sys.exit(1)
28
+
29
+ session_id: str | None = None
30
+ url = f"{base_url()}/api/agent/v1/mcp"
31
+
32
+ def headers_with_token() -> dict:
33
+ return {
34
+ "Authorization": f"Bearer {get_token() or ''}",
35
+ "Content-Type": "application/json",
36
+ "User-Agent": "lifenote-cli/0.1.1 (mcp_proxy)",
37
+ }
38
+
39
+ def post_once(line: str, sid: str | None) -> httpx.Response:
40
+ h = headers_with_token()
41
+ if sid:
42
+ h["Mcp-Session-Id"] = sid
43
+ return _http_client.post(url, headers=h, content=line)
44
+
45
+ def refresh_or_die() -> bool:
46
+ # Cross-process lock + keyring re-read so concurrent proxies don't
47
+ # both rotate and burn the family (Codex round 3 P2 #4)
48
+ blob = _read_keyring_blob()
49
+ if not blob or not blob.get('refresh'):
50
+ return False
51
+ return _refresh_with_lock(blob['refresh']) is not None
52
+
53
+ with httpx.Client(timeout=120.0) as _http_client:
54
+ for line in sys.stdin:
55
+ line = line.strip()
56
+ if not line:
57
+ continue
58
+ try:
59
+ req = json.loads(line)
60
+ except json.JSONDecodeError:
61
+ _emit({"jsonrpc": "2.0", "id": None,
62
+ "error": {"code": -32700, "message": "Parse error"}})
63
+ continue
64
+
65
+ try:
66
+ r = post_once(line, session_id)
67
+ # 401 → token expired; refresh once and retry
68
+ if r.status_code == 401:
69
+ if refresh_or_die():
70
+ # Session may have been bound to the old token; drop it
71
+ # so we re-initialize cleanly.
72
+ session_id = None
73
+ r = post_once(line, None)
74
+ else:
75
+ _emit({"jsonrpc": "2.0", "id": req.get("id"),
76
+ "error": {"code": -32001,
77
+ "message": "lifenote: token expired and refresh failed; run `lifenote auth login`"}})
78
+ continue
79
+ except httpx.HTTPError as e:
80
+ _emit({"jsonrpc": "2.0", "id": req.get("id"),
81
+ "error": {"code": -32603, "message": f"transport error: {e}"}})
82
+ continue
83
+
84
+ # Capture the session id on initialize so subsequent calls echo it
85
+ if req.get("method") == "initialize":
86
+ sid = r.headers.get("Mcp-Session-Id")
87
+ if sid:
88
+ session_id = sid
89
+
90
+ # Notifications: server returns 202 No Content; spec says emit
91
+ # nothing back to the MCP client (Codex round 2 P2 #6).
92
+ if r.status_code == 202 or not r.content:
93
+ continue
94
+
95
+ try:
96
+ _emit(r.json())
97
+ except ValueError:
98
+ _emit({"jsonrpc": "2.0", "id": req.get("id"),
99
+ "error": {"code": -32603,
100
+ "message": f"server returned non-JSON: {r.text[:200]}"}})
101
+
102
+
103
+ def _emit(obj) -> None:
104
+ sys.stdout.write(json.dumps(obj, ensure_ascii=False))
105
+ sys.stdout.write("\n")
106
+ sys.stdout.flush()
@@ -0,0 +1,97 @@
1
+ """Rendering: human-readable for the terminal, --json for piping into agents."""
2
+ import json as _json
3
+
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ console = Console()
8
+
9
+
10
+ def emit(payload, json_mode: bool):
11
+ if json_mode:
12
+ # Stable, agent-pipe-friendly JSON (no rich formatting)
13
+ print(_json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=False))
14
+ return
15
+ # Otherwise dispatch by shape
16
+ if isinstance(payload, dict) and "entries" in payload:
17
+ _print_entries(payload)
18
+ elif isinstance(payload, dict) and "results" in payload:
19
+ _print_search(payload)
20
+ elif isinstance(payload, dict) and "conversations" in payload:
21
+ _print_conversations(payload)
22
+ elif isinstance(payload, dict) and "messages" in payload:
23
+ _print_conversation(payload)
24
+ elif isinstance(payload, dict) and "tokens" in payload:
25
+ _print_tokens(payload)
26
+ else:
27
+ console.print(payload)
28
+
29
+
30
+ def _print_entries(p):
31
+ if p.get("count", 0) == 0:
32
+ console.print("[dim]No entries.[/]")
33
+ return
34
+ for e in p["entries"]:
35
+ head = f"[bold]{e['date']}[/]"
36
+ if e.get("emoji"):
37
+ head += f" {e['emoji']}"
38
+ console.print(head)
39
+ if e.get("preview"):
40
+ console.print(f" [dim]{e['preview']}[/]")
41
+ console.print()
42
+
43
+
44
+ def _print_search(p):
45
+ console.print(f"[dim]{p['count']} hits for[/] [bold]{p['query']}[/] "
46
+ f"[dim](scanned {p['scanned']})[/]\n")
47
+ if not p["results"]:
48
+ console.print("[dim]No matches.[/]")
49
+ return
50
+ for r in p["results"]:
51
+ tag = r["source"].replace("_", " ")
52
+ date = r.get("date") or ""
53
+ console.print(f"[bold cyan]{tag}[/] [dim]{date}[/]")
54
+ console.print(f" {r['snippet']}")
55
+ console.print()
56
+
57
+
58
+ def _print_conversations(p):
59
+ if p.get("count", 0) == 0:
60
+ console.print("[dim]No conversations.[/]")
61
+ return
62
+ t = Table(show_header=True, header_style="bold", box=None)
63
+ t.add_column("ID")
64
+ t.add_column("Mentor")
65
+ t.add_column("Last")
66
+ t.add_column("Title")
67
+ for c in p["conversations"]:
68
+ t.add_row(str(c["id"]), c.get("mentor") or "-",
69
+ (c.get("last_message_at") or "")[:10],
70
+ (c.get("title") or "(no title)")[:60])
71
+ console.print(t)
72
+
73
+
74
+ def _print_conversation(p):
75
+ console.print(f"[bold]{p.get('mentor') or 'Conversation'}[/] — {p.get('title') or ''}")
76
+ console.print()
77
+ for m in p["messages"]:
78
+ who = "[cyan]you[/]" if m["sender"] == "user" else f"[magenta]{p.get('mentor','mentor')}[/]"
79
+ console.print(f"{who} [dim]{(m.get('timestamp') or '')[:16]}[/]")
80
+ console.print(f" {m.get('content') or '[dim](no content)[/]'}")
81
+ console.print()
82
+
83
+
84
+ def _print_tokens(p):
85
+ t = Table(show_header=True, header_style="bold", box=None)
86
+ t.add_column("ID")
87
+ t.add_column("Name")
88
+ t.add_column("Kind")
89
+ t.add_column("Last used")
90
+ t.add_column("Status")
91
+ for tok in p["tokens"]:
92
+ t.add_row(
93
+ str(tok["id"]), tok["name"], tok["client_kind"],
94
+ (tok.get("last_used_at") or "never")[:16],
95
+ "[green]active[/]" if tok["active"] else "[red]revoked[/]",
96
+ )
97
+ console.print(t)
@@ -0,0 +1,70 @@
1
+ """Bundled Claude Code skill — drops a markdown file into ~/.claude/skills/."""
2
+ import os
3
+
4
+ SKILL_NAME = "lifenote.md"
5
+
6
+ SKILL_BODY = """---
7
+ name: lifenote
8
+ description: |
9
+ Use Life Note as the user's reflective memory layer. Search their journal
10
+ entries, past mentor conversations, and emotional themes — to ground
11
+ responses in who the user actually is, not what you'd guess.
12
+ ---
13
+
14
+ # Life Note
15
+
16
+ The user keeps a private journal at https://www.mylifenote.ai. They've connected
17
+ it to you so you can read from it as part of your responses.
18
+
19
+ ## When to reach for Life Note (proactively, without being asked)
20
+
21
+ - The user mentions a feeling ("I'm anxious about X", "I've been frustrated lately")
22
+ → search the journal for that feeling word + topic
23
+ - The user references a person, project, or recurring theme by name
24
+ → search for the name; if hits, read at least one entry
25
+ - The user asks "why am I…" or "what's been on my mind lately"
26
+ → list recent entries (last 7 days)
27
+ - The user references "what [mentor] said" or any past mentor chat
28
+ → list conversations and read the relevant one
29
+
30
+ ## When NOT to reach for Life Note
31
+
32
+ - Coding / debugging questions
33
+ - General factual queries
34
+ - Anything where the user is acting in a professional or task-execution mode,
35
+ not a reflective one
36
+
37
+ ## How to use what you find
38
+
39
+ - **Paraphrase, don't quote verbatim.** The user wrote those words for themselves,
40
+ not for you to recite back. Reference what they said in your own voice.
41
+ - **Cite the date casually**: "you wrote on Tuesday that you were…" — not
42
+ "Per journal entry id 39938 dated 2026-05-06…"
43
+ - **If search returns nothing, say so plainly.** Don't fabricate continuity.
44
+ - **Don't summarize the whole journal.** Pull what's relevant to this exact
45
+ moment in the conversation. The user has hundreds of entries.
46
+
47
+ ## Boundaries
48
+
49
+ - You can READ. You cannot write to the journal in this version.
50
+ - The journal contains emotionally significant material. If recent entries
51
+ reference self-harm, severe distress, or crisis, prioritize a caring,
52
+ grounded response and the appropriate hotlines — don't analyze.
53
+
54
+ ## Tools available (via the lifenote MCP server)
55
+
56
+ - `search_entries(query, since?, limit?)` — keyword search across entries
57
+ - `list_recent_entries(since?, limit?)` — most recent with previews
58
+ - `get_entry(entry_id)` — full text + analysis + mentor responses
59
+ - `list_conversations(since?, limit?)` — past mentor chats
60
+ - `get_conversation(conversation_id)` — full message thread
61
+ """
62
+
63
+
64
+ def install(target_dir: str) -> str:
65
+ target_dir = os.path.expanduser(target_dir)
66
+ os.makedirs(target_dir, exist_ok=True)
67
+ path = os.path.join(target_dir, SKILL_NAME)
68
+ with open(path, "w", encoding="utf-8") as f:
69
+ f.write(SKILL_BODY)
70
+ return path