droste-memory 1.0.0a0__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 (33) hide show
  1. droste_memory-1.0.0a0/CONTRIBUTING.md +39 -0
  2. droste_memory-1.0.0a0/LICENSE +21 -0
  3. droste_memory-1.0.0a0/MANIFEST.in +4 -0
  4. droste_memory-1.0.0a0/PKG-INFO +21 -0
  5. droste_memory-1.0.0a0/README.md +148 -0
  6. droste_memory-1.0.0a0/core/__init__.py +6 -0
  7. droste_memory-1.0.0a0/core/droste_cli.py +409 -0
  8. droste_memory-1.0.0a0/core/droste_engine.py +1067 -0
  9. droste_memory-1.0.0a0/core/droste_ingester.py +2742 -0
  10. droste_memory-1.0.0a0/core/droste_watcher.py +147 -0
  11. droste_memory-1.0.0a0/core/embedding_projector.py +279 -0
  12. droste_memory-1.0.0a0/core/treesitter_extract.py +391 -0
  13. droste_memory-1.0.0a0/droste_memory.egg-info/PKG-INFO +21 -0
  14. droste_memory-1.0.0a0/droste_memory.egg-info/SOURCES.txt +31 -0
  15. droste_memory-1.0.0a0/droste_memory.egg-info/dependency_links.txt +1 -0
  16. droste_memory-1.0.0a0/droste_memory.egg-info/entry_points.txt +2 -0
  17. droste_memory-1.0.0a0/droste_memory.egg-info/requires.txt +16 -0
  18. droste_memory-1.0.0a0/droste_memory.egg-info/top_level.txt +1 -0
  19. droste_memory-1.0.0a0/pyproject.toml +40 -0
  20. droste_memory-1.0.0a0/requirements.txt +18 -0
  21. droste_memory-1.0.0a0/setup.cfg +4 -0
  22. droste_memory-1.0.0a0/tests/test_core_invariants.py +197 -0
  23. droste_memory-1.0.0a0/tests/test_shards_race.py +218 -0
  24. droste_memory-1.0.0a0/visualizer/__init__.py +1 -0
  25. droste_memory-1.0.0a0/visualizer/app.py +240 -0
  26. droste_memory-1.0.0a0/visualizer/cockpit.html +1511 -0
  27. droste_memory-1.0.0a0/visualizer/context.json +81 -0
  28. droste_memory-1.0.0a0/visualizer/demo_graph.json +1 -0
  29. droste_memory-1.0.0a0/visualizer/export_graph.py +174 -0
  30. droste_memory-1.0.0a0/visualizer/graph.json +1 -0
  31. droste_memory-1.0.0a0/visualizer/server.py +75 -0
  32. droste_memory-1.0.0a0/visualizer/status.json +1 -0
  33. droste_memory-1.0.0a0/visualizer/templates/index.html +1164 -0
@@ -0,0 +1,39 @@
1
+ # Contributing to Droste
2
+
3
+ Thanks for helping build the causal-memory layer for AI agents. 🌌
4
+
5
+ ## Dev setup
6
+
7
+ ```bash
8
+ git clone <your fork>
9
+ cd droste-memory
10
+ pip install -e ".[dev]"
11
+ pytest # should be green before you start
12
+ ```
13
+
14
+ ## Ground rules
15
+
16
+ - **Tests stay green.** `pytest` runs the deterministic regression suite
17
+ (`tests/`). Add a test for any behaviour change to the engine, ingester, or
18
+ packer. The suite forces the deterministic hash embedding backend, so it runs
19
+ offline with no model download.
20
+ - **`eval/` is for benchmarks, `tests/` is for invariants.** Don't mix them.
21
+ - **Keep the zero-config moat.** New required deps are a big deal — prefer
22
+ optional extras. `fastembed` (no torch) and `tree-sitter-language-pack` are the
23
+ only heavy runtime deps and both degrade gracefully if missing.
24
+ - **Never commit user data.** `droste_memory_db.json`, `.droste/`,
25
+ `visualizer/graph.json` and `status.json` are gitignored — they can contain a
26
+ user's source. Only `visualizer/demo_graph.json` (Droste indexing itself) is
27
+ public.
28
+
29
+ ## Good first issues
30
+
31
+ - New language extractor / edge rules in `core/treesitter_extract.py`.
32
+ - More cross-language bridges in `core/droste_ingester.py`
33
+ (`_build_dependency_links`) — e.g. ORM table refs, GraphQL, gRPC.
34
+ - Visualizer polish in `visualizer/cockpit.html`.
35
+
36
+ ## PRs
37
+
38
+ Small, focused, with a one-line rationale and a test. CI runs `pytest` on
39
+ Linux across Python 3.10–3.12.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Droste-Memory authors
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,4 @@
1
+ include README.md LICENSE CONTRIBUTING.md requirements.txt
2
+ recursive-include visualizer *.html *.py *.json
3
+ prune visualizer/graph.json
4
+ prune visualizer/status.json
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: droste-memory
3
+ Version: 1.0.0a0
4
+ Summary: Local hybrid structural and semantic graph memory engine.
5
+ Requires-Python: >=3.10
6
+ License-File: LICENSE
7
+ Requires-Dist: mcp>=1.2.0
8
+ Requires-Dist: numpy>=1.24.0
9
+ Requires-Dist: scikit-learn>=1.3.0
10
+ Requires-Dist: fastembed>=0.8.0
11
+ Requires-Dist: truststore>=0.10.0
12
+ Requires-Dist: pydantic>=2.0.0
13
+ Requires-Dist: fastapi>=0.110.0
14
+ Requires-Dist: uvicorn[standard]>=0.27.0
15
+ Requires-Dist: tree-sitter>=0.23.0
16
+ Requires-Dist: tree-sitter-language-pack>=1.9.0
17
+ Provides-Extra: heavy-embed
18
+ Requires-Dist: sentence-transformers>=2.7.0; extra == "heavy-embed"
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=7.0; extra == "dev"
21
+ Dynamic: license-file
@@ -0,0 +1,148 @@
1
+ <div align="center">
2
+
3
+ # 🌌 Droste
4
+
5
+ ### See your codebase as a living galaxy — and give your AI agent causal memory of it.
6
+
7
+ Droste indexes any repo into a **fractal, zoomable map of its symbols**, wires them
8
+ together with their **real call / import / DB edges across languages**, and serves
9
+ an AI agent the *causal* slice of code it actually needs — not just keyword matches.
10
+
11
+ **Local-first · zero-config · polyglot · MCP-native**
12
+
13
+ <!-- ░░░ HERO ░░░ replace with a 10s GIF: galaxy → zoom into a folder → click a
14
+ symbol → a cross-language wormhole lights up. See docs/assets/README.md. -->
15
+ ![Droste fractal code galaxy](docs/assets/hero.gif)
16
+
17
+ [Quickstart](#-quickstart) · [Why it's different](#-why-its-different) · [How it works](#-how-it-works) · [MCP](#-use-it-from-claude--cursor-mcp) · [Benchmarks](#-benchmarks)
18
+
19
+ </div>
20
+
21
+ ---
22
+
23
+ ## ⚡ Quickstart
24
+
25
+ ```bash
26
+ pip install -e . # or: pip install droste-memory (once on PyPI)
27
+ droste index . # index the current repo
28
+ droste view # open the fractal galaxy in your browser ✨
29
+ ```
30
+
31
+ Three commands. `droste view` opens a full-screen, 60fps zoomable map of your
32
+ code — scroll to dive from the project star into folder orbits, down to the
33
+ individual functions, with the causal edges glowing between them.
34
+
35
+ Need it for an agent instead of your eyes?
36
+
37
+ ```bash
38
+ droste context "checkout flow" --budget 1500 # causal context slice for an LLM
39
+ ```
40
+
41
+ ---
42
+
43
+ ## ✨ Why it's different
44
+
45
+ Most "code context" tools rank by **keyword** (ctags / ripgrep / repo-maps) or by
46
+ **embedding cosine** (vector-RAG). Both can only return what *resembles* your query.
47
+ A caller that shares no tokens — or a database function in a different language — is
48
+ invisible to them, yet it's exactly what you need to understand or change the code.
49
+
50
+ Droste's edge is the **causal graph**:
51
+
52
+ - 🧭 **Causal wormholes.** Real `syntax_dependency` edges (calls, imports,
53
+ inheritance) in both directions — Droste hands the agent the *callers and
54
+ callees*, ordered, within a token budget.
55
+ - 🌍 **Cross-language bridges.** This is the part nobody else does well: Droste
56
+ links **across languages** — app code → **SQL** functions/tables (`.rpc('x')`,
57
+ `.from('table')`), → **edge functions**, and same-name handlers between any two
58
+ languages. Your Dart/TS/Python frontend and your database stop being two
59
+ separate worlds on the map.
60
+ - 🪐 **A map you actually want to look at.** The fractal galaxy isn't a gimmick —
61
+ it's how you *see* coupling, risk hotspots, and the blast radius of a change.
62
+ - 🔌 **Zero-config & local.** No cloud, no account, no API key. `fastembed` (ONNX,
63
+ no torch) gives real semantics; a deterministic fallback keeps it runnable
64
+ anywhere.
65
+
66
+ **Polyglot:** Python (AST) + tree-sitter for **Dart, TypeScript/JavaScript, Go,
67
+ Rust, Java, C#, C/C++, Kotlin, Swift, Ruby, PHP, SQL** — symbols *and* edges.
68
+
69
+ > **Honest scope:** Droste's measured advantage is **structural / causal**
70
+ > retrieval. On pure semantic "concept" queries it's competitive with a vector
71
+ > baseline, not a leap. Cross-language bridges are strongest where the target is
72
+ > actually defined in the indexed repo (e.g. SQL schema in your migrations).
73
+
74
+ ---
75
+
76
+ ## 📊 Benchmarks
77
+
78
+ Self-supervised eval (gold = the true caller/callee set from the AST), equal
79
+ retrieval breadth *k*, **real** embeddings, across Python + Dart repos
80
+ (`eval/comparative_eval.py`):
81
+
82
+ | structural retrieval | **Droste** | vector-RAG core | lexical core |
83
+ | --- | --- | --- | --- |
84
+ | neighbour-recall | **0.94** | 0.18 | 0.42 |
85
+ | nDCG@k | **0.65** | 0.10 | 0.29 |
86
+
87
+ …plus hundreds of true causal neighbours that **both** baselines structurally miss.
88
+ This is a *retrieval-method* comparison (the cores of vector-RAG and lexical search),
89
+ not a head-to-head against the finished products that wrap them.
90
+
91
+ ---
92
+
93
+ ## 🔍 How it works
94
+
95
+ - **Causal graph.** Each definition is parsed (Python `ast`; tree-sitter for the
96
+ rest) into the names it calls / imports / inherits → first-class
97
+ `syntax_dependency` edges. Cross-language edges add DB calls (`.rpc`, `.from`,
98
+ `.functions.invoke`) and string-literal name matches across languages.
99
+ - **Hybrid seed.** A query is matched by a normalized blend of lexical score and
100
+ semantic cosine (fastembed `bge-small-en-v1.5`, 384-dim), then the graph expands
101
+ the seed bidirectionally (callees *and* callers).
102
+ - **Token packer.** Results fit a budget with LOD-demotion (full → contract →
103
+ skeleton) and a hard guardrail that never cuts a line of code mid-token.
104
+ - **Sharded persistence.** One shard per file under `.droste/`, blake2b
105
+ dirty-tracking so a re-index rewrites only what changed; atomic writes + meta
106
+ written last → crash-safe (it self-heals on the next run).
107
+
108
+ ---
109
+
110
+ ## 🔌 Use it from Claude / Cursor (MCP)
111
+
112
+ Droste is a drop-in **MCP server** — your agent calls it as primary code memory
113
+ instead of blind file reads.
114
+
115
+ ```jsonc
116
+ {
117
+ "mcpServers": {
118
+ "droste": { "command": "python", "args": ["/abs/path/to/droste-memory/server.py"] }
119
+ }
120
+ }
121
+ ```
122
+
123
+ Key tools: `droste_index_project`, `droste_get_context`, `droste_status`.
124
+
125
+ ---
126
+
127
+ ## 🧪 Development
128
+
129
+ ```bash
130
+ pip install -e ".[dev]"
131
+ pytest # deterministic regression suite (tests/)
132
+ python eval/comparative_eval.py # retrieval benchmark vs lexical & vector cores
133
+ ```
134
+
135
+ `tests/` = invariants + concurrency (round-trip, dirty-oracle, packer guardrail,
136
+ cross-process shard race). `eval/` = performance/quality benchmarks.
137
+
138
+ ---
139
+
140
+ ## 🗺️ Status
141
+
142
+ **v1.0.0a0 (alpha).** Engine, polyglot + cross-language graph, CLI, fractal
143
+ visualizer and MCP server are working and tested. Packaging/distribution are
144
+ maturing — issues and PRs welcome (`CONTRIBUTING.md`).
145
+
146
+ ## License
147
+
148
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,6 @@
1
+ """Core package for Droste-Memory."""
2
+
3
+ from .droste_engine import DrosteConceptEngine, DrosteNode
4
+ from .droste_ingester import DrosteProjectIngester
5
+
6
+ __all__ = ["DrosteConceptEngine", "DrosteNode", "DrosteProjectIngester"]
@@ -0,0 +1,409 @@
1
+ """Command line interface for Droste-Memory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any
13
+ from urllib import error, request
14
+
15
+
16
+ ROOT = Path(__file__).resolve().parents[1]
17
+ if str(ROOT) not in sys.path:
18
+ sys.path.insert(0, str(ROOT))
19
+ LOCAL_PACKAGES = ROOT / ".python-packages"
20
+ if LOCAL_PACKAGES.exists():
21
+ sys.path.insert(0, str(LOCAL_PACKAGES))
22
+
23
+ from core.droste_engine import DEFAULT_DB_PATH, DrosteConceptEngine
24
+ from core.droste_ingester import DrosteProjectIngester, droste_zoom_query
25
+
26
+
27
+ VERSION = "v1.0-Alpha-Sharded"
28
+ VISUALIZER_CAMERA_URL = "http://127.0.0.1:5000/api/camera"
29
+
30
+ RESET = "\033[0m"
31
+ GREEN = "\033[92m"
32
+ CYAN = "\033[96m"
33
+ WHITE = "\033[97m"
34
+ DIM = "\033[2m"
35
+
36
+
37
+ RADIAL_ART = r"""
38
+ .-----------------------.
39
+ .----' | '----.
40
+ .---' .-----+-----. '---.
41
+ .' .----' | '----. '.
42
+ / .---' .---+---. '---. \
43
+ / .-' .-' | '-. '-. \
44
+ | .' .-' .---+---. '-. '. |
45
+ | / .' .' | '. '. \ |
46
+ | | | | .--+--. | | | |
47
+ | --+--------+-----+---+ @ +---+-----+--------+-- |
48
+ | | | | '--+--' | | | |
49
+ | \ '. '. | .' .' / |
50
+ | '. '-. '---+---' .-' .' |
51
+ \ '-. '-. | .-' .-' /
52
+ \ '---. '---+---' .---' /
53
+ '. '----. | .----' .'
54
+ '---. '-----+-----' .---'
55
+ '----. | .----'
56
+ '-----------------------'
57
+ """
58
+
59
+
60
+ def _wants_color(enabled: bool | None = None) -> bool:
61
+ if enabled is not None:
62
+ return enabled
63
+ if os.environ.get("NO_COLOR"):
64
+ return False
65
+ return sys.stdout.isatty()
66
+
67
+
68
+ def _paint(text: str, color: str, enabled: bool) -> str:
69
+ return f"{color}{text}{RESET}" if enabled else text
70
+
71
+
72
+ def print_splash(color: bool | None = None) -> None:
73
+ enabled = _wants_color(color)
74
+ art_lines = RADIAL_ART.strip("\n").splitlines()
75
+ for index, line in enumerate(art_lines):
76
+ print(_paint(line, CYAN if index % 2 == 0 else GREEN, enabled))
77
+ print()
78
+ print(_paint("DROSTE-MEMORY // RIGID FRACTAL RADIAL LAYOUT", WHITE, enabled))
79
+ print(_paint(f"Local Graph Engine {VERSION}", WHITE, enabled))
80
+ print()
81
+ print(_paint("Commands", CYAN, enabled))
82
+ print(" droste index <path> [--reset]")
83
+ print(" droste status")
84
+ print(" droste zoom <symbol_name>")
85
+ print(" droste context [query] --budget 1500")
86
+ print()
87
+ print(_paint("Fast path: droste context hub_core --budget 1000 | clip", DIM, enabled))
88
+
89
+
90
+ def print_compact_header(color: bool | None = None) -> None:
91
+ enabled = _wants_color(color)
92
+ print(_paint(f"DROSTE-MEMORY {VERSION}", WHITE, enabled))
93
+ print(_paint("Rigid Fractal Radial Layout", CYAN, enabled))
94
+
95
+
96
+ def _human_bytes(size: int) -> str:
97
+ units = ("B", "KB", "MB", "GB", "TB")
98
+ value = float(size)
99
+ for unit in units:
100
+ if value < 1024 or unit == units[-1]:
101
+ if unit == "B":
102
+ return f"{int(value)} {unit}"
103
+ return f"{value:.2f} {unit}"
104
+ value /= 1024
105
+ return f"{size} B"
106
+
107
+
108
+ def _load_meta(db_path: Path) -> dict[str, Any]:
109
+ if not db_path.exists():
110
+ return {"storage": "missing", "error": None}
111
+ try:
112
+ return json.loads(db_path.read_text(encoding="utf-8"))
113
+ except json.JSONDecodeError as exc:
114
+ return {"storage": "corrupt", "error": str(exc)}
115
+ except OSError as exc:
116
+ return {"storage": "unreadable", "error": str(exc)}
117
+
118
+
119
+ def _storage_label(db_path: Path) -> str:
120
+ meta = _load_meta(db_path)
121
+ storage = meta.get("storage")
122
+ if storage == "sharded":
123
+ return "Sharded"
124
+ if storage in {"missing", "corrupt", "unreadable"}:
125
+ return str(storage).title()
126
+ if isinstance(meta.get("nodes"), list) and meta.get("nodes"):
127
+ return "Legacy inline (migrates on next graph save)"
128
+ return "Inline empty"
129
+
130
+
131
+ def _shard_stats(db_path: Path) -> dict[str, Any]:
132
+ shard_dir = db_path.parent / ".droste" / "nodes"
133
+ if not shard_dir.exists():
134
+ return {"path": str(shard_dir), "count": 0, "bytes": 0}
135
+ files = [path for path in shard_dir.glob("*.json") if path.is_file()]
136
+ return {
137
+ "path": str(shard_dir),
138
+ "count": len(files),
139
+ "bytes": sum(path.stat().st_size for path in files),
140
+ }
141
+
142
+
143
+ def _engine(db_path: Path | None = None) -> DrosteConceptEngine:
144
+ return DrosteConceptEngine(db_path=db_path or DEFAULT_DB_PATH)
145
+
146
+
147
+ def command_status(args: argparse.Namespace) -> int:
148
+ engine = _engine(args.db)
149
+ migration = engine.ensure_sharded_storage()
150
+ nodes = engine.all_nodes()
151
+ links = engine.all_links()
152
+ symbol_count = sum(1 for node in nodes if node.node_type == "symbol")
153
+ syntax_link_count = sum(1 for link in links if link.type == "syntax_dependency")
154
+ shard_stats = _shard_stats(engine.db_path)
155
+
156
+ payload = {
157
+ "storage": _storage_label(engine.db_path),
158
+ "database": str(engine.db_path),
159
+ "node_count": len(nodes),
160
+ "symbol_count": symbol_count,
161
+ "link_count": len(links),
162
+ "syntax_link_count": syntax_link_count,
163
+ "shard_dir": shard_stats["path"],
164
+ "shard_count": shard_stats["count"],
165
+ "shard_bytes": shard_stats["bytes"],
166
+ "migration": migration,
167
+ }
168
+ if args.json:
169
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
170
+ return 0
171
+
172
+ print_compact_header()
173
+ print("status")
174
+ print(f" migration: {migration['status']}")
175
+ print(f" storage: {payload['storage']}")
176
+ print(f" database: {payload['database']}")
177
+ print(f" live nodes: {payload['node_count']}")
178
+ print(f" symbols: {payload['symbol_count']}")
179
+ print(f" links: {payload['link_count']}")
180
+ print(f" syntax links: {payload['syntax_link_count']}")
181
+ print(f" shard dir: {payload['shard_dir']}")
182
+ print(f" shard files: {payload['shard_count']}")
183
+ print(f" shard cache: {_human_bytes(int(payload['shard_bytes']))}")
184
+ return 0
185
+
186
+
187
+ def _post_visualizer_camera(camera: dict[str, Any], url: str) -> tuple[bool, str]:
188
+ payload = {
189
+ "x": camera.get("x", 0.0),
190
+ "y": camera.get("y", 0.0),
191
+ "zoom_level": camera.get("zoom", 1.0),
192
+ }
193
+ body = json.dumps(payload).encode("utf-8")
194
+ req = request.Request(
195
+ url,
196
+ data=body,
197
+ headers={"Content-Type": "application/json"},
198
+ method="POST",
199
+ )
200
+ try:
201
+ with request.urlopen(req, timeout=1.5) as response:
202
+ return True, f"HTTP {response.status}"
203
+ except (OSError, error.URLError, error.HTTPError) as exc:
204
+ return False, str(exc)
205
+
206
+
207
+ def _open_editor(source_path: str | None, line_start: int | None) -> tuple[bool, str]:
208
+ if not source_path:
209
+ return False, "no source_path on focused node"
210
+ line = max(1, int(line_start or 1))
211
+ target = f"{source_path}:{line}"
212
+ code = shutil.which("code")
213
+ if code:
214
+ subprocess.Popen([code, "--goto", target])
215
+ return True, f"code --goto {target}"
216
+ return False, target
217
+
218
+
219
+ def command_zoom(args: argparse.Namespace) -> int:
220
+ engine = _engine(args.db)
221
+ result = droste_zoom_query(args.symbol_name, engine=engine)
222
+ if result.get("status") != "focused":
223
+ print(f"not found: {args.symbol_name}", file=sys.stderr)
224
+ return 2
225
+
226
+ node = result["focused_node"]
227
+ camera = result["camera"]
228
+ print(f"focused: {node['title']}")
229
+ print(f" node: {node['id']}")
230
+ print(f" type: {node['node_type']}")
231
+ print(f" source: {node.get('source_path') or '(none)'}")
232
+ print(f" line: {node.get('line_start') or '(none)'}")
233
+ print(f" camera: x={camera['x']:.4f} y={camera['y']:.4f} zoom={camera['zoom']:.2f}")
234
+
235
+ if not args.no_visualizer:
236
+ ok, message = _post_visualizer_camera(camera, args.visualizer_url)
237
+ status = "sent" if ok else "not sent"
238
+ print(f" visualizer: {status} ({message})")
239
+
240
+ if not args.no_open:
241
+ opened, message = _open_editor(node.get("source_path"), node.get("line_start"))
242
+ status = "opened" if opened else "fallback"
243
+ print(f" editor: {status} ({message})")
244
+ return 0
245
+
246
+
247
+ def command_context(args: argparse.Namespace) -> int:
248
+ engine = _engine(args.db)
249
+ ingester = DrosteProjectIngester(engine)
250
+ result = ingester.get_context(args.query, budget=args.budget)
251
+ if args.json:
252
+ print(json.dumps(result, indent=2, ensure_ascii=False))
253
+ return 0
254
+ print(result.get("compiled_context", ""))
255
+ return 0
256
+
257
+
258
+ def command_index(args: argparse.Namespace) -> int:
259
+ from core import treesitter_extract
260
+
261
+ path = Path(args.path).expanduser()
262
+ if not path.is_dir():
263
+ print(f"not a directory: {path}", file=sys.stderr)
264
+ return 2
265
+
266
+ engine = _engine(args.db)
267
+ ingester = DrosteProjectIngester(engine)
268
+ result = ingester.index_project(
269
+ str(path),
270
+ reset=args.reset,
271
+ max_files=args.max_files,
272
+ max_symbols=args.max_symbols,
273
+ )
274
+ if args.json:
275
+ print(json.dumps(result, indent=2, ensure_ascii=False))
276
+ return 0
277
+
278
+ stats = result["stats"]
279
+ ts_on = result.get("treesitter_available", treesitter_extract.available())
280
+ print_compact_header()
281
+ print(f"indexed {path}")
282
+ print(f" files: {stats['file_count']}")
283
+ print(f" symbols: {stats['symbol_count']}")
284
+ print(f" links: {stats['link_count']}")
285
+ print(f" syntax edges: {result.get('syntax_dependency_links', 0)}")
286
+ print(f" reused files: {result.get('reused_files', 0)}")
287
+ print(f" embeddings: {engine.projector.backend}")
288
+ if ts_on:
289
+ print(" tree-sitter: on (polyglot call-graph active)")
290
+ else:
291
+ print(" tree-sitter: OFF — non-Python files degrade to symbols "
292
+ "without causal edges")
293
+ print(" fix: pip install tree-sitter-language-pack",
294
+ file=sys.stderr)
295
+ return 0
296
+
297
+
298
+ def command_view(args: argparse.Namespace) -> int:
299
+ """Export the live graph for the chosen root and open the fractal cockpit —
300
+ the one-command 'wow': index, then `droste view`."""
301
+ import http.server
302
+ import importlib.util
303
+ import socketserver
304
+ import threading
305
+ import webbrowser
306
+
307
+ vis_dir = ROOT / "visualizer"
308
+ spec = importlib.util.spec_from_file_location(
309
+ "droste_export_graph", str(vis_dir / "export_graph.py"))
310
+ exporter = importlib.util.module_from_spec(spec)
311
+ spec.loader.exec_module(exporter)
312
+ try:
313
+ counts = exporter.export(args.root, vis_dir / "graph.json")
314
+ except SystemExit as exc:
315
+ print(exc, file=sys.stderr)
316
+ return 2
317
+
318
+ engine = _engine(args.db)
319
+ nodes = engine.all_nodes()
320
+ links = engine.all_links()
321
+ (vis_dir / "status.json").write_text(json.dumps({
322
+ "node_count": len(nodes),
323
+ "symbol_count": sum(1 for n in nodes if n.node_type == "symbol"),
324
+ "link_count": len(links),
325
+ "syntax_link_count": sum(1 for l in links if l.type == "syntax_dependency"),
326
+ "counts": counts,
327
+ }, ensure_ascii=False), encoding="utf-8")
328
+
329
+ def handler(*a, **k):
330
+ return http.server.SimpleHTTPRequestHandler(*a, directory=str(vis_dir), **k)
331
+
332
+ socketserver.TCPServer.allow_reuse_address = True
333
+ httpd = socketserver.TCPServer(("127.0.0.1", args.port), handler)
334
+ url = f"http://127.0.0.1:{args.port}/cockpit.html"
335
+ print_compact_header()
336
+ print(f"view -> {url}")
337
+ print(f" project: {counts['symbol']} symbols / {counts['edge']} causal edges "
338
+ f"across {counts['file']} files")
339
+ print(" Ctrl+C to stop")
340
+ if not args.no_open:
341
+ threading.Timer(0.6, lambda: webbrowser.open(url)).start()
342
+ try:
343
+ httpd.serve_forever()
344
+ except KeyboardInterrupt:
345
+ print("\nstopped")
346
+ finally:
347
+ httpd.server_close()
348
+ return 0
349
+
350
+
351
+ def build_parser() -> argparse.ArgumentParser:
352
+ parser = argparse.ArgumentParser(
353
+ prog="droste",
354
+ description="Droste-Memory cyberpunk graph CLI.",
355
+ )
356
+ parser.add_argument("--db", type=Path, default=DEFAULT_DB_PATH, help=argparse.SUPPRESS)
357
+ parser.add_argument("--version", action="version", version=f"droste {VERSION}")
358
+ sub = parser.add_subparsers(dest="command")
359
+
360
+ index = sub.add_parser("index", help="Index a project into the local graph.")
361
+ index.add_argument("path", help="Project root directory to index.")
362
+ index.add_argument("--reset", action="store_true",
363
+ help="Wipe this root's prior nodes before indexing.")
364
+ index.add_argument("--max-files", type=int, default=2000)
365
+ index.add_argument("--max-symbols", type=int, default=20000)
366
+ index.add_argument("--json", action="store_true", help="Emit the full index result JSON.")
367
+ index.set_defaults(func=command_index)
368
+
369
+ status = sub.add_parser("status", help="Show local graph health.")
370
+ status.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
371
+ status.set_defaults(func=command_status)
372
+
373
+ zoom = sub.add_parser("zoom", help="Focus a symbol in editor and visualizer.")
374
+ zoom.add_argument("symbol_name")
375
+ zoom.add_argument("--no-open", action="store_true", help="Do not launch the editor.")
376
+ zoom.add_argument(
377
+ "--no-visualizer",
378
+ action="store_true",
379
+ help="Do not POST camera coordinates to the local visualizer.",
380
+ )
381
+ zoom.add_argument("--visualizer-url", default=VISUALIZER_CAMERA_URL)
382
+ zoom.set_defaults(func=command_zoom)
383
+
384
+ context = sub.add_parser("context", help="Emit compressed LLM context.")
385
+ context.add_argument("query", nargs="?", default="project")
386
+ context.add_argument("--budget", type=int, default=1500)
387
+ context.add_argument("--json", action="store_true", help="Emit full context payload JSON.")
388
+ context.set_defaults(func=command_context)
389
+
390
+ view = sub.add_parser("view", help="Open the fractal visualizer on the live graph.")
391
+ view.add_argument("--root", default=None, help="Indexed root to view (default: most recent).")
392
+ view.add_argument("--port", type=int, default=7878)
393
+ view.add_argument("--no-open", action="store_true", help="Serve without opening a browser.")
394
+ view.set_defaults(func=command_view)
395
+
396
+ return parser
397
+
398
+
399
+ def main(argv: list[str] | None = None) -> int:
400
+ parser = build_parser()
401
+ args = parser.parse_args(argv)
402
+ if not args.command:
403
+ print_splash()
404
+ return 0
405
+ return int(args.func(args))
406
+
407
+
408
+ if __name__ == "__main__":
409
+ raise SystemExit(main())