droste-memory 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
core/__init__.py ADDED
@@ -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"]
core/droste_cli.py ADDED
@@ -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?web=1"
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())