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.
- droste_memory-1.0.0a0/CONTRIBUTING.md +39 -0
- droste_memory-1.0.0a0/LICENSE +21 -0
- droste_memory-1.0.0a0/MANIFEST.in +4 -0
- droste_memory-1.0.0a0/PKG-INFO +21 -0
- droste_memory-1.0.0a0/README.md +148 -0
- droste_memory-1.0.0a0/core/__init__.py +6 -0
- droste_memory-1.0.0a0/core/droste_cli.py +409 -0
- droste_memory-1.0.0a0/core/droste_engine.py +1067 -0
- droste_memory-1.0.0a0/core/droste_ingester.py +2742 -0
- droste_memory-1.0.0a0/core/droste_watcher.py +147 -0
- droste_memory-1.0.0a0/core/embedding_projector.py +279 -0
- droste_memory-1.0.0a0/core/treesitter_extract.py +391 -0
- droste_memory-1.0.0a0/droste_memory.egg-info/PKG-INFO +21 -0
- droste_memory-1.0.0a0/droste_memory.egg-info/SOURCES.txt +31 -0
- droste_memory-1.0.0a0/droste_memory.egg-info/dependency_links.txt +1 -0
- droste_memory-1.0.0a0/droste_memory.egg-info/entry_points.txt +2 -0
- droste_memory-1.0.0a0/droste_memory.egg-info/requires.txt +16 -0
- droste_memory-1.0.0a0/droste_memory.egg-info/top_level.txt +1 -0
- droste_memory-1.0.0a0/pyproject.toml +40 -0
- droste_memory-1.0.0a0/requirements.txt +18 -0
- droste_memory-1.0.0a0/setup.cfg +4 -0
- droste_memory-1.0.0a0/tests/test_core_invariants.py +197 -0
- droste_memory-1.0.0a0/tests/test_shards_race.py +218 -0
- droste_memory-1.0.0a0/visualizer/__init__.py +1 -0
- droste_memory-1.0.0a0/visualizer/app.py +240 -0
- droste_memory-1.0.0a0/visualizer/cockpit.html +1511 -0
- droste_memory-1.0.0a0/visualizer/context.json +81 -0
- droste_memory-1.0.0a0/visualizer/demo_graph.json +1 -0
- droste_memory-1.0.0a0/visualizer/export_graph.py +174 -0
- droste_memory-1.0.0a0/visualizer/graph.json +1 -0
- droste_memory-1.0.0a0/visualizer/server.py +75 -0
- droste_memory-1.0.0a0/visualizer/status.json +1 -0
- 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,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
|
+

|
|
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,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())
|