memstrata 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. memstrata-0.6.0/LICENSE +21 -0
  2. memstrata-0.6.0/PKG-INFO +182 -0
  3. memstrata-0.6.0/README.md +119 -0
  4. memstrata-0.6.0/memstrata/__init__.py +2 -0
  5. memstrata-0.6.0/memstrata/cli/__init__.py +0 -0
  6. memstrata-0.6.0/memstrata/cli/cd_hook.py +148 -0
  7. memstrata-0.6.0/memstrata/cli/ingest.py +432 -0
  8. memstrata-0.6.0/memstrata/cli/main.py +340 -0
  9. memstrata-0.6.0/memstrata/config/__init__.py +0 -0
  10. memstrata-0.6.0/memstrata/config/keychain.py +47 -0
  11. memstrata-0.6.0/memstrata/layer3/__init__.py +0 -0
  12. memstrata-0.6.0/memstrata/layer3/_db.py +638 -0
  13. memstrata-0.6.0/memstrata/layer3/api_server.py +2298 -0
  14. memstrata-0.6.0/memstrata/layer3/ingestion/__init__.py +115 -0
  15. memstrata-0.6.0/memstrata/layer3/ingestion/branch_switch.py +230 -0
  16. memstrata-0.6.0/memstrata/layer3/ingestion/chunker.py +351 -0
  17. memstrata-0.6.0/memstrata/layer3/ingestion/denylist.py +307 -0
  18. memstrata-0.6.0/memstrata/layer3/ingestion/lifecycle.py +312 -0
  19. memstrata-0.6.0/memstrata/layer3/ingestion/orchestrator.py +664 -0
  20. memstrata-0.6.0/memstrata/layer3/ingestion/progress.py +209 -0
  21. memstrata-0.6.0/memstrata/layer3/ingestion/resource_policy.py +297 -0
  22. memstrata-0.6.0/memstrata/layer3/ingestion/watcher.py +523 -0
  23. memstrata-0.6.0/memstrata/layer3/mcp_app.py +361 -0
  24. memstrata-0.6.0/memstrata/layer3/mcp_server.py +196 -0
  25. memstrata-0.6.0/memstrata/layer3/ollama_health.py +181 -0
  26. memstrata-0.6.0/memstrata/layer3/pricing/__init__.py +0 -0
  27. memstrata-0.6.0/memstrata/layer3/pricing/fx.py +147 -0
  28. memstrata-0.6.0/memstrata/layer3/pricing/lookup.py +166 -0
  29. memstrata-0.6.0/memstrata/layer3/pricing/openrouter_sync.py +174 -0
  30. memstrata-0.6.0/memstrata/layer3/pricing/pricing_matrix.json +78 -0
  31. memstrata-0.6.0/memstrata/layer3/retrieval.py +132 -0
  32. memstrata-0.6.0/memstrata/workers/__init__.py +0 -0
  33. memstrata-0.6.0/memstrata/workers/embedding_worker.py +301 -0
  34. memstrata-0.6.0/memstrata.egg-info/PKG-INFO +182 -0
  35. memstrata-0.6.0/memstrata.egg-info/SOURCES.txt +51 -0
  36. memstrata-0.6.0/memstrata.egg-info/dependency_links.txt +1 -0
  37. memstrata-0.6.0/memstrata.egg-info/entry_points.txt +2 -0
  38. memstrata-0.6.0/memstrata.egg-info/requires.txt +21 -0
  39. memstrata-0.6.0/memstrata.egg-info/top_level.txt +1 -0
  40. memstrata-0.6.0/pyproject.toml +108 -0
  41. memstrata-0.6.0/setup.cfg +4 -0
  42. memstrata-0.6.0/tests/test_api_server.py +1482 -0
  43. memstrata-0.6.0/tests/test_cd_hook_generation.py +92 -0
  44. memstrata-0.6.0/tests/test_cd_hook_idempotent.py +155 -0
  45. memstrata-0.6.0/tests/test_codebase_ingest.py +196 -0
  46. memstrata-0.6.0/tests/test_e2e_multi_session.py +625 -0
  47. memstrata-0.6.0/tests/test_keychain.py +142 -0
  48. memstrata-0.6.0/tests/test_mcp_server.py +317 -0
  49. memstrata-0.6.0/tests/test_ollama_health.py +268 -0
  50. memstrata-0.6.0/tests/test_phase_34_embedding.py +401 -0
  51. memstrata-0.6.0/tests/test_phase_34_retrieval.py +854 -0
  52. memstrata-0.6.0/tests/test_shutdown_endpoint.py +109 -0
  53. memstrata-0.6.0/tests/test_telemetry_edge_cases.py +499 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MemStrata Contributors
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,182 @@
1
+ Metadata-Version: 2.4
2
+ Name: memstrata
3
+ Version: 0.6.0
4
+ Summary: A local-first, verification-first context engine for AI workflows
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 MemStrata Contributors
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Project-URL: Homepage, https://memstrata.dev
28
+ Project-URL: Repository, https://github.com/yadu9989/MemStrata
29
+ Project-URL: Issues, https://github.com/yadu9989/MemStrata/issues
30
+ Keywords: llm,context,memory,mcp,local-first,ai-tools
31
+ Classifier: Development Status :: 4 - Beta
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Operating System :: OS Independent
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.10
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Topic :: Software Development
40
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
41
+ Requires-Python: >=3.10
42
+ Description-Content-Type: text/markdown
43
+ License-File: LICENSE
44
+ Requires-Dist: fastapi>=0.115
45
+ Requires-Dist: uvicorn[standard]>=0.32
46
+ Requires-Dist: httpx>=0.27
47
+ Requires-Dist: pydantic>=2.10
48
+ Requires-Dist: sqlite-vec>=0.1.6
49
+ Requires-Dist: tree-sitter>=0.22
50
+ Requires-Dist: tree-sitter-python>=0.21
51
+ Requires-Dist: tree-sitter-javascript>=0.21
52
+ Requires-Dist: tree-sitter-typescript>=0.21
53
+ Requires-Dist: mcp>=1.0
54
+ Requires-Dist: truststore>=0.9; python_version >= "3.10"
55
+ Requires-Dist: python-dotenv>=1.0
56
+ Provides-Extra: dev
57
+ Requires-Dist: pytest>=8; extra == "dev"
58
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
59
+ Requires-Dist: ruff>=0.6; extra == "dev"
60
+ Requires-Dist: mypy>=1.11; extra == "dev"
61
+ Requires-Dist: respx>=0.21; extra == "dev"
62
+ Dynamic: license-file
63
+
64
+ # MemStrata
65
+
66
+ **A local-first, verification-first context engine for AI workflows.**
67
+
68
+ MemStrata sits between you and every AI tool you use. It captures conversations
69
+ from the browser, indexes your codebase locally, and serves a context block to
70
+ whichever LLM you're talking to next. Your code stays on your machine. Your
71
+ conversation history stays on your machine. No telemetry leaves the box.
72
+
73
+ This repository is the **open-source core**: the local daemon, the chat-capture
74
+ browser extension, the MCP server, and the dashboard. It is MIT-licensed and
75
+ fully usable on its own.
76
+
77
+ The commercial Pro tier (token-budgeted context injection through a proxy
78
+ harness, money-back-guaranteed savings, IDE integration) lives in a separate,
79
+ private repository and consumes this package as a PyPI dependency. See
80
+ [memstrata.dev](https://memstrata.dev) if you want the paid product.
81
+
82
+ ---
83
+
84
+ ## What's in this repo
85
+
86
+ | Path | What it does |
87
+ |---|---|
88
+ | `memstrata/layer3/api_server.py` | The local daemon's FastAPI app — telemetry, dashboard, MCP routing |
89
+ | `memstrata/layer3/ingestion/` | File watcher, tree-sitter chunker, opt-in lifecycle, denylists |
90
+ | `memstrata/layer3/mcp_server.py`, `mcp_app.py` | MCP server (Anthropic-spec) for Claude Desktop / Cursor / etc. |
91
+ | `memstrata/layer3/_db.py` | SQLite schema with `sqlite-vec` for local vector search |
92
+ | `memstrata/layer3/retrieval.py` | Token-budgeted context retrieval against the local store |
93
+ | `memstrata/layer3/pricing/` | Live OpenRouter price sync + bundled static fallback for offline use |
94
+ | `memstrata/layer3/ollama_health.py` | Shared Ollama reachability probe (used by the dashboard) |
95
+ | `memstrata/workers/embedding_worker.py` | Background worker that embeds new turns into the vector store |
96
+ | `memstrata/cli/` | The `memstrata` CLI: `register`, `ingest`, the cd-hook generator |
97
+ | `memstrata/config/keychain.py` | OS keyring wrapper for storing per-provider API keys |
98
+ | `browser-extension/` | Chrome / Edge / Firefox extension that captures chat turns from every major LLM front-end |
99
+ | `migrations/` | SQL migrations |
100
+ | `shared/telemetry_schema.json` | The JSON schema for telemetry events (public contract) |
101
+
102
+ ---
103
+
104
+ ## Quickstart
105
+
106
+ ```bash
107
+ pip install memstrata
108
+ memstrata init # one-time interactive setup
109
+ memstrata api # start the daemon on 127.0.0.1:8000
110
+ ```
111
+
112
+ The daemon binds to `127.0.0.1:8000`. Open `http://127.0.0.1:8000/dashboard`
113
+ to see what's captured.
114
+
115
+ Install the browser extension from your browser's add-on store (see the
116
+ [browser-extension/](browser-extension/) directory for build instructions
117
+ if you want to load it unpacked).
118
+
119
+ ---
120
+
121
+ ## Architecture commitments
122
+
123
+ These aren't aspirational — they're enforced in the code and tested for in CI:
124
+
125
+ 1. **Localhost-only binding.** Every HTTP server in this repo hard-codes
126
+ `host="127.0.0.1"`. No `0.0.0.0`, no LAN exposure, no remote access.
127
+
128
+ 2. **No TLS interception.** The MCP server and the dashboard speak plain
129
+ HTTP on loopback. The browser extension talks directly to provider
130
+ APIs and to this daemon's loopback endpoint. There is no MITM proxy
131
+ in the open-source stack.
132
+
133
+ 3. **Local storage only.** All telemetry, all chat history, all vectors,
134
+ all API keys live in `~/.memstrata/` (or `$ML_DATA_DIR` if set).
135
+ Nothing is uploaded to a MemStrata-owned cloud service. There is no
136
+ such service.
137
+
138
+ 4. **Telemetry never includes user content.** The dashboard and the
139
+ MCP server expose your data back to you. Nothing is sent off-machine.
140
+
141
+ 5. **The Pro tier is structurally separate.** Pro code lives in a
142
+ different repository under a different license. This repo has
143
+ zero `import` statements that touch Pro code.
144
+
145
+ ---
146
+
147
+ ## Provider pricing (for the dashboard's savings calculator)
148
+
149
+ The dashboard's session-level savings columns compute against a price
150
+ table. By default the daemon syncs the table once per day from
151
+ [OpenRouter](https://openrouter.ai/api/v1/models). When the network is
152
+ down or OpenRouter is unreachable, the bundled
153
+ `memstrata/layer3/pricing/pricing_matrix.json` provides a static
154
+ fallback. The fallback covers the most common Claude, OpenAI, Gemini,
155
+ DeepSeek, xAI, and Mistral models. Prices in the fallback are
156
+ USD-per-million-tokens, last verified mid-2026.
157
+
158
+ ---
159
+
160
+ ## Contributing
161
+
162
+ See [CONTRIBUTING.md](CONTRIBUTING.md). The short version: open issues
163
+ first for non-trivial changes, run `pytest` before submitting, and keep
164
+ the architectural commitments above intact.
165
+
166
+ ## Security
167
+
168
+ See [SECURITY.md](SECURITY.md). Vulnerability reports go to
169
+ `security@memstrata.dev`, **not** GitHub issues.
170
+
171
+ ## License
172
+
173
+ [MIT](LICENSE). See `LICENSE` for the full text.
174
+
175
+ ## Related
176
+
177
+ - **Commercial Pro tier**: [memstrata.dev](https://memstrata.dev) — the
178
+ token-budgeting interception harness, the money-back guarantee, and
179
+ the IDE extension. Proprietary, paid, separate codebase.
180
+ - **Browser extension store listings**: shipped from the same source
181
+ tree in this repo; see [browser-extension/README.md](browser-extension/)
182
+ for build + sideload instructions.
@@ -0,0 +1,119 @@
1
+ # MemStrata
2
+
3
+ **A local-first, verification-first context engine for AI workflows.**
4
+
5
+ MemStrata sits between you and every AI tool you use. It captures conversations
6
+ from the browser, indexes your codebase locally, and serves a context block to
7
+ whichever LLM you're talking to next. Your code stays on your machine. Your
8
+ conversation history stays on your machine. No telemetry leaves the box.
9
+
10
+ This repository is the **open-source core**: the local daemon, the chat-capture
11
+ browser extension, the MCP server, and the dashboard. It is MIT-licensed and
12
+ fully usable on its own.
13
+
14
+ The commercial Pro tier (token-budgeted context injection through a proxy
15
+ harness, money-back-guaranteed savings, IDE integration) lives in a separate,
16
+ private repository and consumes this package as a PyPI dependency. See
17
+ [memstrata.dev](https://memstrata.dev) if you want the paid product.
18
+
19
+ ---
20
+
21
+ ## What's in this repo
22
+
23
+ | Path | What it does |
24
+ |---|---|
25
+ | `memstrata/layer3/api_server.py` | The local daemon's FastAPI app — telemetry, dashboard, MCP routing |
26
+ | `memstrata/layer3/ingestion/` | File watcher, tree-sitter chunker, opt-in lifecycle, denylists |
27
+ | `memstrata/layer3/mcp_server.py`, `mcp_app.py` | MCP server (Anthropic-spec) for Claude Desktop / Cursor / etc. |
28
+ | `memstrata/layer3/_db.py` | SQLite schema with `sqlite-vec` for local vector search |
29
+ | `memstrata/layer3/retrieval.py` | Token-budgeted context retrieval against the local store |
30
+ | `memstrata/layer3/pricing/` | Live OpenRouter price sync + bundled static fallback for offline use |
31
+ | `memstrata/layer3/ollama_health.py` | Shared Ollama reachability probe (used by the dashboard) |
32
+ | `memstrata/workers/embedding_worker.py` | Background worker that embeds new turns into the vector store |
33
+ | `memstrata/cli/` | The `memstrata` CLI: `register`, `ingest`, the cd-hook generator |
34
+ | `memstrata/config/keychain.py` | OS keyring wrapper for storing per-provider API keys |
35
+ | `browser-extension/` | Chrome / Edge / Firefox extension that captures chat turns from every major LLM front-end |
36
+ | `migrations/` | SQL migrations |
37
+ | `shared/telemetry_schema.json` | The JSON schema for telemetry events (public contract) |
38
+
39
+ ---
40
+
41
+ ## Quickstart
42
+
43
+ ```bash
44
+ pip install memstrata
45
+ memstrata init # one-time interactive setup
46
+ memstrata api # start the daemon on 127.0.0.1:8000
47
+ ```
48
+
49
+ The daemon binds to `127.0.0.1:8000`. Open `http://127.0.0.1:8000/dashboard`
50
+ to see what's captured.
51
+
52
+ Install the browser extension from your browser's add-on store (see the
53
+ [browser-extension/](browser-extension/) directory for build instructions
54
+ if you want to load it unpacked).
55
+
56
+ ---
57
+
58
+ ## Architecture commitments
59
+
60
+ These aren't aspirational — they're enforced in the code and tested for in CI:
61
+
62
+ 1. **Localhost-only binding.** Every HTTP server in this repo hard-codes
63
+ `host="127.0.0.1"`. No `0.0.0.0`, no LAN exposure, no remote access.
64
+
65
+ 2. **No TLS interception.** The MCP server and the dashboard speak plain
66
+ HTTP on loopback. The browser extension talks directly to provider
67
+ APIs and to this daemon's loopback endpoint. There is no MITM proxy
68
+ in the open-source stack.
69
+
70
+ 3. **Local storage only.** All telemetry, all chat history, all vectors,
71
+ all API keys live in `~/.memstrata/` (or `$ML_DATA_DIR` if set).
72
+ Nothing is uploaded to a MemStrata-owned cloud service. There is no
73
+ such service.
74
+
75
+ 4. **Telemetry never includes user content.** The dashboard and the
76
+ MCP server expose your data back to you. Nothing is sent off-machine.
77
+
78
+ 5. **The Pro tier is structurally separate.** Pro code lives in a
79
+ different repository under a different license. This repo has
80
+ zero `import` statements that touch Pro code.
81
+
82
+ ---
83
+
84
+ ## Provider pricing (for the dashboard's savings calculator)
85
+
86
+ The dashboard's session-level savings columns compute against a price
87
+ table. By default the daemon syncs the table once per day from
88
+ [OpenRouter](https://openrouter.ai/api/v1/models). When the network is
89
+ down or OpenRouter is unreachable, the bundled
90
+ `memstrata/layer3/pricing/pricing_matrix.json` provides a static
91
+ fallback. The fallback covers the most common Claude, OpenAI, Gemini,
92
+ DeepSeek, xAI, and Mistral models. Prices in the fallback are
93
+ USD-per-million-tokens, last verified mid-2026.
94
+
95
+ ---
96
+
97
+ ## Contributing
98
+
99
+ See [CONTRIBUTING.md](CONTRIBUTING.md). The short version: open issues
100
+ first for non-trivial changes, run `pytest` before submitting, and keep
101
+ the architectural commitments above intact.
102
+
103
+ ## Security
104
+
105
+ See [SECURITY.md](SECURITY.md). Vulnerability reports go to
106
+ `security@memstrata.dev`, **not** GitHub issues.
107
+
108
+ ## License
109
+
110
+ [MIT](LICENSE). See `LICENSE` for the full text.
111
+
112
+ ## Related
113
+
114
+ - **Commercial Pro tier**: [memstrata.dev](https://memstrata.dev) — the
115
+ token-budgeting interception harness, the money-back guarantee, and
116
+ the IDE extension. Proprietary, paid, separate codebase.
117
+ - **Browser extension store listings**: shipped from the same source
118
+ tree in this repo; see [browser-extension/README.md](browser-extension/)
119
+ for build + sideload instructions.
@@ -0,0 +1,2 @@
1
+ """MemStrata — MIT open-source context server for LLM-assisted coding."""
2
+ __version__ = "0.6.0"
File without changes
@@ -0,0 +1,148 @@
1
+ """
2
+ Shell cd-hook generation and idempotent installation.
3
+
4
+ Hook text and write/remove patterns taken verbatim from
5
+ v5_1_reference/critical_snippets.py §2. The idempotent marker pair
6
+ ensures repeated writes replace rather than duplicate the block.
7
+
8
+ Hard Rule 54: hooks only check for .git/ — no process scanning.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ _HOOK_MARKER_BEGIN = "# >>> memstrata cd-hook >>>"
18
+ _HOOK_MARKER_END = "# <<< memstrata cd-hook <<<"
19
+
20
+
21
+ def hook_for_shell(shell: str) -> str:
22
+ """
23
+ Generate the hook block for the given shell.
24
+
25
+ The returned string is delimited by _HOOK_MARKER_BEGIN / _HOOK_MARKER_END
26
+ so write_hook can replace it idempotently.
27
+ """
28
+ if shell == "zsh":
29
+ body = """
30
+ ml_cd_hook() {
31
+ if [ -d ".git" ] && command -v memstrata >/dev/null 2>&1; then
32
+ (memstrata register "$PWD" --quiet >/dev/null 2>&1 &)
33
+ fi
34
+ }
35
+ typeset -gaU chpwd_functions
36
+ chpwd_functions+=(ml_cd_hook)
37
+ """
38
+ elif shell == "bash":
39
+ body = """
40
+ ml_cd_hook() {
41
+ if [ -d ".git" ] && command -v memstrata >/dev/null 2>&1; then
42
+ (memstrata register "$PWD" --quiet >/dev/null 2>&1 &)
43
+ fi
44
+ }
45
+ PROMPT_COMMAND="ml_cd_hook;${PROMPT_COMMAND:-:}"
46
+ """
47
+ elif shell == "fish":
48
+ body = """
49
+ function ml_cd_hook --on-variable PWD
50
+ if test -d .git
51
+ if command -v memstrata >/dev/null 2>&1
52
+ memstrata register "$PWD" --quiet >/dev/null 2>&1 &
53
+ end
54
+ end
55
+ end
56
+ """
57
+ elif shell == "powershell":
58
+ body = """
59
+ $global:__MlOriginalPrompt = if (Test-Path Function:prompt) { Get-Item Function:prompt } else { $null }
60
+ function global:prompt {
61
+ if (Test-Path -PathType Container ".git") {
62
+ if (Get-Command memstrata -ErrorAction SilentlyContinue) {
63
+ Start-Job -ScriptBlock {
64
+ param($p) memstrata register $p --quiet
65
+ } -ArgumentList $PWD.Path | Out-Null
66
+ }
67
+ }
68
+ if ($global:__MlOriginalPrompt) { & $global:__MlOriginalPrompt }
69
+ else { "PS $($executionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) " }
70
+ }
71
+ """
72
+ else:
73
+ raise ValueError(f"unsupported shell: {shell!r}")
74
+
75
+ return f"\n{_HOOK_MARKER_BEGIN}\n{body.strip()}\n{_HOOK_MARKER_END}\n"
76
+
77
+
78
+ def write_hook(shell: str, config_path: Path) -> None:
79
+ """
80
+ Idempotently install the hook into config_path.
81
+
82
+ If the marker block is already present it is replaced in-place.
83
+ Otherwise the block is appended. A .ml-backup is created once on
84
+ the first write (never overwritten on subsequent writes).
85
+ """
86
+ backup = config_path.with_suffix(config_path.suffix + ".ml-backup")
87
+ if config_path.exists() and not backup.exists():
88
+ backup.write_text(config_path.read_text(encoding="utf-8"), encoding="utf-8")
89
+
90
+ existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
91
+ new_block = hook_for_shell(shell)
92
+
93
+ if _HOOK_MARKER_BEGIN in existing:
94
+ before, _, rest = existing.partition(_HOOK_MARKER_BEGIN)
95
+ _, _, after = rest.partition(_HOOK_MARKER_END)
96
+ after = after.lstrip("\n")
97
+ result = before.rstrip() + new_block + ("\n" + after if after else "")
98
+ else:
99
+ # new_block already starts with "\n", so rstrip() + new_block gives one separator.
100
+ result = existing.rstrip() + new_block
101
+
102
+ config_path.parent.mkdir(parents=True, exist_ok=True)
103
+ config_path.write_text(result, encoding="utf-8")
104
+
105
+
106
+ def remove_hook(config_path: Path) -> None:
107
+ """
108
+ Reverse write_hook. Strips the marker block from config_path in-place.
109
+ No-op if the file is missing or the block was never written.
110
+ """
111
+ if not config_path.exists():
112
+ return
113
+ text = config_path.read_text(encoding="utf-8")
114
+ if _HOOK_MARKER_BEGIN not in text:
115
+ return
116
+ before, _, rest = text.partition(_HOOK_MARKER_BEGIN)
117
+ _, _, after = rest.partition(_HOOK_MARKER_END)
118
+ config_path.write_text(before.rstrip() + "\n" + after.lstrip("\n"), encoding="utf-8")
119
+
120
+
121
+ def detect_shell() -> str | None:
122
+ """Best-effort shell detection from the environment."""
123
+ shell_env = os.environ.get("SHELL", "")
124
+ if "zsh" in shell_env:
125
+ return "zsh"
126
+ if "bash" in shell_env:
127
+ return "bash"
128
+ if "fish" in shell_env:
129
+ return "fish"
130
+ if os.environ.get("PSModulePath") and not shell_env:
131
+ return "powershell"
132
+ return None
133
+
134
+
135
+ def config_path_for_shell(shell: str) -> Path:
136
+ """Return the canonical config file path for the given shell."""
137
+ home = Path.home()
138
+ if shell == "zsh":
139
+ return home / ".zshrc"
140
+ if shell == "bash":
141
+ return home / ".bashrc"
142
+ if shell == "fish":
143
+ return home / ".config" / "fish" / "config.fish"
144
+ if shell == "powershell":
145
+ if sys.platform == "win32":
146
+ return home / "Documents" / "PowerShell" / "Microsoft.PowerShell_profile.ps1"
147
+ return home / ".config" / "powershell" / "Microsoft.PowerShell_profile.ps1"
148
+ raise ValueError(f"unsupported shell: {shell!r}")