cortex-kg 0.1.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.
Files changed (74) hide show
  1. cortex/__init__.py +8 -0
  2. cortex/agents/__init__.py +0 -0
  3. cortex/agents/hooks.py +55 -0
  4. cortex/agents/writeback.py +58 -0
  5. cortex/api/__init__.py +0 -0
  6. cortex/api/rest.py +239 -0
  7. cortex/cli.py +238 -0
  8. cortex/config.py +80 -0
  9. cortex/context/__init__.py +0 -0
  10. cortex/context/generator.py +146 -0
  11. cortex/daemon.py +102 -0
  12. cortex/extractors/__init__.py +25 -0
  13. cortex/extractors/api_spec_extractor.py +76 -0
  14. cortex/extractors/base.py +63 -0
  15. cortex/extractors/code_extractor.py +255 -0
  16. cortex/extractors/db_schema_extractor.py +78 -0
  17. cortex/extractors/git_extractor.py +111 -0
  18. cortex/extractors/image_extractor.py +62 -0
  19. cortex/extractors/llm_relation_extractor.py +99 -0
  20. cortex/extractors/markdown_extractor.py +86 -0
  21. cortex/extractors/notes_extractor.py +101 -0
  22. cortex/extractors/pdf_extractor.py +59 -0
  23. cortex/ingestion/__init__.py +0 -0
  24. cortex/ingestion/content_hash.py +56 -0
  25. cortex/ingestion/pipeline.py +152 -0
  26. cortex/ingestion/resolver.py +131 -0
  27. cortex/mcp_server/__init__.py +0 -0
  28. cortex/mcp_server/server.py +187 -0
  29. cortex/models.py +169 -0
  30. cortex/offline/__init__.py +0 -0
  31. cortex/offline/local_llm.py +71 -0
  32. cortex/ontology/__init__.py +3 -0
  33. cortex/ontology/registry.py +171 -0
  34. cortex/plugins/__init__.py +0 -0
  35. cortex/plugins/examples/jira_like_plugin/plugin.py +51 -0
  36. cortex/plugins/interface.py +48 -0
  37. cortex/plugins/loader.py +106 -0
  38. cortex/replication/__init__.py +0 -0
  39. cortex/replication/crdt.py +95 -0
  40. cortex/retrieval/__init__.py +0 -0
  41. cortex/retrieval/embeddings.py +79 -0
  42. cortex/retrieval/fusion.py +47 -0
  43. cortex/retrieval/planner.py +49 -0
  44. cortex/retrieval/query_engine.py +158 -0
  45. cortex/retrieval/traversal.py +47 -0
  46. cortex/security/__init__.py +0 -0
  47. cortex/security/acl.py +90 -0
  48. cortex/security/audit.py +26 -0
  49. cortex/security/encryption.py +56 -0
  50. cortex/security/secrets_scanner.py +34 -0
  51. cortex/server_profile/__init__.py +0 -0
  52. cortex/server_profile/neo4j_store.py +200 -0
  53. cortex/setup/__init__.py +15 -0
  54. cortex/setup/generators.py +108 -0
  55. cortex/storage/__init__.py +12 -0
  56. cortex/storage/graph_store.py +322 -0
  57. cortex/storage/keyword_store.py +81 -0
  58. cortex/storage/provenance.py +233 -0
  59. cortex/storage/vector_store.py +77 -0
  60. cortex/sync/__init__.py +0 -0
  61. cortex/sync/change_feed.py +71 -0
  62. cortex/sync/job_queue.py +65 -0
  63. cortex/sync/watcher.py +92 -0
  64. cortex/versioning/__init__.py +0 -0
  65. cortex/versioning/bitemporal.py +82 -0
  66. cortex/workspace/__init__.py +0 -0
  67. cortex/workspace/federation.py +40 -0
  68. cortex/workspace/registry.py +98 -0
  69. cortex_kg-0.1.0.dist-info/METADATA +242 -0
  70. cortex_kg-0.1.0.dist-info/RECORD +74 -0
  71. cortex_kg-0.1.0.dist-info/WHEEL +5 -0
  72. cortex_kg-0.1.0.dist-info/entry_points.txt +2 -0
  73. cortex_kg-0.1.0.dist-info/licenses/LICENSE +21 -0
  74. cortex_kg-0.1.0.dist-info/top_level.txt +1 -0
cortex/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Cortex: a persistent, workspace-level Engineering Knowledge Graph.
2
+
3
+ Cortex models relationships between engineering knowledge (concepts, APIs,
4
+ services, decisions, people, documents, ...) rather than files. Knowledge is
5
+ owned by the workspace and outlives any single project.
6
+ """
7
+
8
+ __version__ = "0.1.0"
File without changes
cortex/agents/hooks.py ADDED
@@ -0,0 +1,55 @@
1
+ """Future AI agent integration (roadmap section 26): agents subscribe to
2
+ the change feed and react to new knowledge instead of polling.
3
+
4
+ `AgentHookRegistry` lets multiple independent agents/automations register
5
+ interest in specific event types (e.g. "run static analysis whenever a new
6
+ Service node appears") without any of them needing to know about each
7
+ other -- the same decoupling MCP gives you for *querying* the graph, but
8
+ for *reacting* to it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import threading
14
+ from typing import Callable
15
+
16
+ from cortex.sync.change_feed import ChangeFeed
17
+
18
+ HookCallback = Callable[[dict], None]
19
+
20
+
21
+ class AgentHookRegistry:
22
+ def __init__(self, daemon):
23
+ self._daemon = daemon
24
+ self._hooks: dict[str, list[HookCallback]] = {}
25
+ self._stop_event: threading.Event | None = None
26
+ self._thread: threading.Thread | None = None
27
+
28
+ def on(self, event_type: str, callback: HookCallback) -> None:
29
+ """Register `callback` for a specific change-feed event type, or
30
+ pass `"*"` to receive every event."""
31
+ self._hooks.setdefault(event_type, []).append(callback)
32
+
33
+ def dispatch(self, event: dict) -> None:
34
+ for callback in self._hooks.get(event.get("type", ""), []):
35
+ callback(event)
36
+ for callback in self._hooks.get("*", []):
37
+ callback(event)
38
+
39
+ def start(self) -> None:
40
+ if self._thread and self._thread.is_alive():
41
+ return
42
+ self._stop_event = threading.Event()
43
+ feed = ChangeFeed.instance(self._daemon)
44
+
45
+ def _run():
46
+ feed.watch(self.dispatch, self._stop_event)
47
+
48
+ self._thread = threading.Thread(target=_run, daemon=True)
49
+ self._thread.start()
50
+
51
+ def stop(self) -> None:
52
+ if self._stop_event:
53
+ self._stop_event.set()
54
+ if self._thread:
55
+ self._thread.join(timeout=2)
@@ -0,0 +1,58 @@
1
+ """Agent write-back API (roadmap section 26): "Agents both consume context
2
+ and contribute knowledge (write API with provenance = agent X)."
3
+
4
+ This is a typed convenience wrapper over the same primitives the MCP
5
+ `add_note`/`link` tools and the REST `/nodes`/`/edges` endpoints use --
6
+ every write is tagged with `actor` as provenance and audited, so an
7
+ agent's contributions are exactly as traceable as a human's or an
8
+ extractor's.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from cortex.models import Edge, Node, Provenance
14
+
15
+
16
+ class AgentWriteback:
17
+ def __init__(self, daemon, agent_id: str):
18
+ self._daemon = daemon
19
+ self.agent_id = agent_id
20
+
21
+ def record_observation(self, node_type: str, canonical_name: str, properties: dict | None = None,
22
+ confidence: float = 0.8) -> Node:
23
+ """An agent contributing a new fact it derived/observed (e.g. "this
24
+ function is a hot path" from a profiling run)."""
25
+ self._daemon.ontology.ensure_node_type(node_type, registered_by=f"agent:{self.agent_id}")
26
+ existing = self._daemon.graph.find_by_natural_key(node_type, canonical_name, self._daemon.workspace_id)
27
+ node = existing or Node(type=node_type, canonical_name=canonical_name, workspace_id=self._daemon.workspace_id)
28
+ if properties:
29
+ node.properties.update(properties)
30
+ node.confidence = max(node.confidence, confidence) if existing else confidence
31
+ self._daemon.graph.upsert_node(node)
32
+ self._daemon.keyword.upsert(node.id, node.type, node.workspace_id, node.canonical_name,
33
+ ",".join(node.aliases), str(node.properties))
34
+ self._daemon.provenance.record(node.id, "node", Provenance(
35
+ source_id=f"agent:{self.agent_id}", source_type="agent_writeback", extractor="agent.record_observation",
36
+ extractor_version="1", confidence=confidence,
37
+ ))
38
+ self._daemon.provenance.audit(self.agent_id, "record_observation", node.id, node_type)
39
+ self._publish("node_added", node_id=node.id)
40
+ return node
41
+
42
+ def link(self, edge_type: str, from_id: str, to_id: str, properties: dict | None = None,
43
+ confidence: float = 0.75) -> Edge:
44
+ self._daemon.ontology.ensure_edge_type(edge_type, registered_by=f"agent:{self.agent_id}")
45
+ edge = Edge(type=edge_type, from_id=from_id, to_id=to_id, properties=properties or {},
46
+ confidence=confidence)
47
+ self._daemon.graph.upsert_edge(edge)
48
+ self._daemon.provenance.record(edge.id, "edge", Provenance(
49
+ source_id=f"agent:{self.agent_id}", source_type="agent_writeback", extractor="agent.link",
50
+ extractor_version="1", confidence=confidence,
51
+ ))
52
+ self._daemon.provenance.audit(self.agent_id, "link", edge.id, edge_type)
53
+ self._publish("edge_added", edge_id=edge.id)
54
+ return edge
55
+
56
+ def _publish(self, event_type: str, **fields) -> None:
57
+ from cortex.sync.change_feed import ChangeFeed
58
+ ChangeFeed.instance(self._daemon).publish({"type": event_type, "actor": self.agent_id, **fields})
cortex/api/__init__.py ADDED
File without changes
cortex/api/rest.py ADDED
@@ -0,0 +1,239 @@
1
+ """Public REST API (roadmap sections 1, 4, 17).
2
+
3
+ This is the same capability surface exposed over MCP
4
+ (`cortex.mcp_server.server`), but as a versioned HTTP API so any tool that
5
+ isn't MCP-aware -- browsers, CI bots, custom scripts -- can still read from
6
+ and write to the graph. Node/edge IDs are the stable public contract
7
+ (section 17): they never change across re-extraction.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Optional
13
+
14
+ import asyncio
15
+ import json
16
+
17
+ from fastapi import Depends, FastAPI, HTTPException, Query
18
+ from pydantic import BaseModel
19
+ from sse_starlette.sse import EventSourceResponse
20
+
21
+ from cortex.config import DEFAULT_WORKSPACE
22
+ from cortex.daemon import CortexDaemon, get_daemon
23
+ from cortex.models import Edge, Node, Provenance
24
+ from cortex.sync.change_feed import ChangeFeed
25
+
26
+ app = FastAPI(
27
+ title="Cortex Engineering Knowledge Graph API",
28
+ version="v1",
29
+ description="Workspace-level knowledge graph API. See /docs for the full contract.",
30
+ )
31
+
32
+
33
+ def daemon_dep(workspace: str = Query(default=DEFAULT_WORKSPACE)) -> CortexDaemon:
34
+ return get_daemon(workspace)
35
+
36
+
37
+ class NodeIn(BaseModel):
38
+ type: str
39
+ canonical_name: str
40
+ properties: dict[str, Any] = {}
41
+ aliases: list[str] = []
42
+ confidence: float = 1.0
43
+ source_id: str = "api"
44
+ source_type: str = "manual"
45
+ actor: str = "api-client"
46
+
47
+
48
+ class EdgeIn(BaseModel):
49
+ type: str
50
+ from_id: str
51
+ to_id: str
52
+ properties: dict[str, Any] = {}
53
+ confidence: float = 1.0
54
+ directional: bool = True
55
+ source_id: str = "api"
56
+ source_type: str = "manual"
57
+ actor: str = "api-client"
58
+
59
+
60
+ class NodeOut(BaseModel):
61
+ id: str
62
+ type: str
63
+ canonical_name: str
64
+ aliases: list[str]
65
+ properties: dict[str, Any]
66
+ confidence: float
67
+ workspace_id: str
68
+ created_at: str
69
+ valid_from: str
70
+ valid_to: Optional[str] = None
71
+
72
+
73
+ def _node_out(n: Node) -> NodeOut:
74
+ return NodeOut(
75
+ id=n.id, type=n.type, canonical_name=n.canonical_name, aliases=n.aliases,
76
+ properties=n.properties, confidence=n.confidence, workspace_id=n.workspace_id,
77
+ created_at=n.created_at, valid_from=n.valid_from, valid_to=n.valid_to,
78
+ )
79
+
80
+
81
+ @app.get("/health")
82
+ def health() -> dict:
83
+ return {"status": "ok"}
84
+
85
+
86
+ @app.get("/info")
87
+ def info(daemon: CortexDaemon = Depends(daemon_dep)) -> dict:
88
+ return daemon.info()
89
+
90
+
91
+ @app.post("/nodes", response_model=NodeOut)
92
+ def create_node(body: NodeIn, daemon: CortexDaemon = Depends(daemon_dep)) -> NodeOut:
93
+ daemon.ontology.ensure_node_type(body.type, registered_by=f"api:{body.actor}")
94
+ existing = daemon.graph.find_by_natural_key(body.type, body.canonical_name, daemon.workspace_id)
95
+ node = existing or Node(
96
+ type=body.type, canonical_name=body.canonical_name, workspace_id=daemon.workspace_id,
97
+ )
98
+ node.properties.update(body.properties)
99
+ node.aliases = sorted(set(node.aliases) | set(body.aliases))
100
+ node.confidence = max(node.confidence, body.confidence)
101
+ daemon.graph.upsert_node(node)
102
+ daemon.provenance.record(node.id, "node", Provenance(
103
+ source_id=body.source_id, source_type=body.source_type, extractor="api",
104
+ extractor_version="1", confidence=body.confidence,
105
+ ))
106
+ daemon.keyword.upsert(node.id, node.type, node.workspace_id, node.canonical_name,
107
+ ",".join(node.aliases), str(node.properties))
108
+ daemon.provenance.audit(body.actor, "create_node", node.id, body.type)
109
+ return _node_out(node)
110
+
111
+
112
+ @app.get("/nodes/{node_id}", response_model=NodeOut)
113
+ def get_node(node_id: str, daemon: CortexDaemon = Depends(daemon_dep)) -> NodeOut:
114
+ node = daemon.graph.get_node(node_id)
115
+ if not node:
116
+ raise HTTPException(status_code=404, detail="node not found")
117
+ return _node_out(node)
118
+
119
+
120
+ @app.get("/nodes", response_model=list[NodeOut])
121
+ def list_nodes(type: Optional[str] = None, limit: int = 100,
122
+ daemon: CortexDaemon = Depends(daemon_dep)) -> list[NodeOut]:
123
+ nodes = daemon.graph.list_nodes(type_=type, workspace_id=daemon.workspace_id, limit=limit)
124
+ return [_node_out(n) for n in nodes]
125
+
126
+
127
+ @app.get("/nodes/{node_id}/provenance")
128
+ def node_provenance(node_id: str, daemon: CortexDaemon = Depends(daemon_dep)) -> list[dict]:
129
+ return [p.to_json() for p in daemon.provenance.for_entity(node_id)]
130
+
131
+
132
+ @app.post("/edges")
133
+ def create_edge(body: EdgeIn, daemon: CortexDaemon = Depends(daemon_dep)) -> dict:
134
+ daemon.ontology.ensure_edge_type(body.type, registered_by=f"api:{body.actor}")
135
+ edge = Edge(type=body.type, from_id=body.from_id, to_id=body.to_id,
136
+ properties=body.properties, confidence=body.confidence, directional=body.directional)
137
+ daemon.graph.upsert_edge(edge)
138
+ daemon.provenance.record(edge.id, "edge", Provenance(
139
+ source_id=body.source_id, source_type=body.source_type, extractor="api",
140
+ extractor_version="1", confidence=body.confidence,
141
+ ))
142
+ daemon.provenance.audit(body.actor, "create_edge", edge.id, body.type)
143
+ return {"id": edge.id, "type": edge.type, "from_id": edge.from_id, "to_id": edge.to_id}
144
+
145
+
146
+ @app.get("/nodes/{node_id}/neighbors")
147
+ def neighbors(node_id: str, direction: str = "both", edge_type: Optional[str] = None,
148
+ daemon: CortexDaemon = Depends(daemon_dep)) -> list[dict]:
149
+ edge_types = [edge_type] if edge_type else None
150
+ pairs = daemon.graph.neighbors(node_id, edge_types=edge_types, direction=direction)
151
+ return [{"edge_type": e.type, "edge_id": e.id, "node": _node_out(n).model_dump()} for e, n in pairs]
152
+
153
+
154
+ @app.get("/nodes/{node_id}/traverse")
155
+ def traverse(node_id: str, direction: str = "out", max_depth: int = 2,
156
+ edge_type: Optional[str] = None, daemon: CortexDaemon = Depends(daemon_dep)) -> dict:
157
+ edge_types = [edge_type] if edge_type else None
158
+ nodes, edges = daemon.graph.traverse(node_id, edge_types=edge_types, direction=direction,
159
+ max_depth=max_depth)
160
+ return {
161
+ "nodes": [_node_out(n).model_dump() for n in nodes],
162
+ "edges": [{"id": e.id, "type": e.type, "from_id": e.from_id, "to_id": e.to_id} for e in edges],
163
+ }
164
+
165
+
166
+ @app.get("/nodes/{from_id}/path/{to_id}")
167
+ def shortest_path(from_id: str, to_id: str, max_depth: int = 6,
168
+ daemon: CortexDaemon = Depends(daemon_dep)) -> dict:
169
+ path = daemon.graph.shortest_path(from_id, to_id, max_depth=max_depth)
170
+ return {"path": path}
171
+
172
+
173
+ @app.get("/search")
174
+ def search(q: str, k: int = 10, daemon: CortexDaemon = Depends(daemon_dep)) -> list[dict]:
175
+ results = daemon.query_engine.search(q, k=k)
176
+ return results
177
+
178
+
179
+ @app.get("/context")
180
+ def context(q: str, max_tokens: int = 2000, format: str = "markdown",
181
+ daemon: CortexDaemon = Depends(daemon_dep)) -> dict:
182
+ return daemon.context_generator.generate(q, max_tokens=max_tokens, fmt=format)
183
+
184
+
185
+ @app.get("/nodes/{node_id}/impact")
186
+ def impact(node_id: str, max_depth: int = 3, daemon: CortexDaemon = Depends(daemon_dep)) -> dict:
187
+ result = daemon.query_engine._traversal.explain_impact(node_id, max_depth=max_depth)
188
+ return {"root_id": result.root_id, "affected": [_node_out(n).model_dump() for n in result.affected_nodes]}
189
+
190
+
191
+ class FeedbackIn(BaseModel):
192
+ actor: str = "api-client"
193
+ verdict: str # "confirm" | "reject"
194
+
195
+
196
+ @app.post("/edges/{edge_id}/feedback")
197
+ def edge_feedback(edge_id: str, body: FeedbackIn, daemon: CortexDaemon = Depends(daemon_dep)) -> dict:
198
+ if body.verdict not in ("confirm", "reject"):
199
+ raise HTTPException(status_code=400, detail="verdict must be 'confirm' or 'reject'")
200
+ daemon.provenance.record_feedback(edge_id, body.actor, body.verdict)
201
+ return {"edge_id": edge_id, "verdict": body.verdict}
202
+
203
+
204
+ @app.get("/ontology/node-types")
205
+ def node_types(daemon: CortexDaemon = Depends(daemon_dep)) -> list[dict]:
206
+ return daemon.ontology.list_node_types()
207
+
208
+
209
+ @app.get("/ontology/edge-types")
210
+ def edge_types(daemon: CortexDaemon = Depends(daemon_dep)) -> list[dict]:
211
+ return daemon.ontology.list_edge_types()
212
+
213
+
214
+ @app.get("/change-feed/recent")
215
+ def change_feed_recent(daemon: CortexDaemon = Depends(daemon_dep)) -> list[dict]:
216
+ return ChangeFeed.instance(daemon).history()
217
+
218
+
219
+ @app.get("/change-feed/stream")
220
+ async def change_feed_stream(workspace: str = Query(default=DEFAULT_WORKSPACE)):
221
+ """Server-sent events: live subscription to graph changes (roadmap
222
+ section 10) -- any tool can react to new knowledge instead of polling.
223
+ """
224
+ daemon = get_daemon(workspace)
225
+ feed = ChangeFeed.instance(daemon)
226
+ q = feed.subscribe()
227
+
228
+ async def event_generator():
229
+ try:
230
+ while True:
231
+ try:
232
+ event = await asyncio.get_event_loop().run_in_executor(None, q.get, True, 1.0)
233
+ yield {"event": "change", "data": json.dumps(event, default=str)}
234
+ except Exception:
235
+ yield {"event": "heartbeat", "data": ""}
236
+ finally:
237
+ feed.unsubscribe(q)
238
+
239
+ return EventSourceResponse(event_generator())
cortex/cli.py ADDED
@@ -0,0 +1,238 @@
1
+ """`cortex` command-line entrypoint.
2
+
3
+ cortex init create/initialize a workspace
4
+ cortex info show graph/vector/job stats
5
+ cortex ingest <path> [--workspace] run the full ingestion pipeline over a directory
6
+ cortex watch <path> [--project] keep graph updated as files change
7
+ cortex search "<query>" hybrid search
8
+ cortex context "<query>" generate LLM-ready context
9
+ cortex setup cursor|claude|project print or write MCP / project integration files
10
+ cortex serve-api [--port] run the REST API (uvicorn)
11
+ cortex serve-mcp run the MCP server over stdio
12
+ cortex node get <id> fetch a single node
13
+ cortex node traverse <id> traverse from a node
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from cortex.config import DEFAULT_WORKSPACE
24
+
25
+
26
+ def cmd_init(args: argparse.Namespace) -> None:
27
+ from cortex.daemon import CortexDaemon
28
+
29
+ daemon = CortexDaemon(args.workspace)
30
+ print(json.dumps(daemon.info(), indent=2))
31
+
32
+
33
+ def cmd_info(args: argparse.Namespace) -> None:
34
+ from cortex.daemon import get_daemon
35
+
36
+ daemon = get_daemon(args.workspace)
37
+ print(json.dumps(daemon.info(), indent=2))
38
+
39
+
40
+ def cmd_ingest(args: argparse.Namespace) -> None:
41
+ from cortex.daemon import get_daemon
42
+ from cortex.ingestion.pipeline import IngestionPipeline
43
+
44
+ daemon = get_daemon(args.workspace)
45
+ pipeline = IngestionPipeline(daemon)
46
+ report = pipeline.run(args.path, project_name=args.project)
47
+ print(json.dumps(report, indent=2))
48
+
49
+
50
+ def cmd_search(args: argparse.Namespace) -> None:
51
+ from cortex.daemon import get_daemon
52
+
53
+ daemon = get_daemon(args.workspace)
54
+ results = daemon.query_engine.search(args.query, k=args.k)
55
+ print(json.dumps(results, indent=2, default=str))
56
+
57
+
58
+ def cmd_context(args: argparse.Namespace) -> None:
59
+ from cortex.daemon import get_daemon
60
+
61
+ daemon = get_daemon(args.workspace)
62
+ ctx = daemon.context_generator.generate(args.query, max_tokens=args.max_tokens, fmt=args.format)
63
+ if args.format == "markdown":
64
+ print(ctx["content"])
65
+ else:
66
+ print(json.dumps(ctx, indent=2, default=str))
67
+
68
+
69
+ def cmd_watch(args: argparse.Namespace) -> None:
70
+ from cortex.daemon import get_daemon
71
+ from cortex.sync.watcher import Watcher
72
+
73
+ daemon = get_daemon(args.workspace)
74
+ watcher = Watcher(daemon, args.path, project_name=args.project)
75
+ print(json.dumps({
76
+ "status": "watching",
77
+ "path": str(args.path),
78
+ "workspace": args.workspace,
79
+ "project": args.project,
80
+ }, indent=2))
81
+ watcher.start()
82
+ try:
83
+ import time
84
+ while True:
85
+ time.sleep(1)
86
+ except KeyboardInterrupt:
87
+ watcher.stop()
88
+ print("\nStopped watching.", file=sys.stderr)
89
+
90
+
91
+ def cmd_setup(args: argparse.Namespace) -> None:
92
+ from cortex.setup.generators import (
93
+ claude_desktop_config,
94
+ cursor_mcp_config,
95
+ vscode_mcp_config,
96
+ write_project_setup,
97
+ )
98
+
99
+ project_path = args.path or "."
100
+ if args.target == "cursor":
101
+ config = cursor_mcp_config(args.workspace, project_path)
102
+ elif args.target == "claude":
103
+ config = claude_desktop_config(args.workspace, project_path)
104
+ elif args.target == "vscode":
105
+ config = vscode_mcp_config(args.workspace, project_path)
106
+ elif args.target == "project":
107
+ if args.write:
108
+ written = write_project_setup(
109
+ Path(project_path),
110
+ workspace=args.workspace,
111
+ project_name=args.project or Path(project_path).resolve().name,
112
+ )
113
+ print(json.dumps({"written": written}, indent=2))
114
+ else:
115
+ print(json.dumps({
116
+ "mcp": cursor_mcp_config(args.workspace, project_path),
117
+ "rule_hint": "Run with --write to create .cursor/mcp.json and .cursor/rules/cortex.mdc",
118
+ }, indent=2))
119
+ return
120
+ else:
121
+ print(f"unknown setup target: {args.target}", file=sys.stderr)
122
+ sys.exit(1)
123
+
124
+ if args.write and args.target in ("cursor", "claude", "vscode"):
125
+ if args.target == "claude":
126
+ dest = Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
127
+ else:
128
+ dest = Path(project_path).resolve() / ".cursor" / "mcp.json"
129
+ dest.parent.mkdir(parents=True, exist_ok=True)
130
+ existing: dict = {}
131
+ if dest.exists():
132
+ existing = json.loads(dest.read_text(encoding="utf-8"))
133
+ existing.setdefault("mcpServers", {}).update(config["mcpServers"])
134
+ dest.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
135
+ print(json.dumps({"written": str(dest), "config": existing}, indent=2))
136
+ else:
137
+ print(json.dumps(config, indent=2))
138
+
139
+
140
+ def cmd_serve_api(args: argparse.Namespace) -> None:
141
+ import uvicorn
142
+
143
+ uvicorn.run("cortex.api.rest:app", host="0.0.0.0", port=args.port, reload=False)
144
+
145
+
146
+ def cmd_serve_mcp(args: argparse.Namespace) -> None:
147
+ from cortex.mcp_server.server import run_stdio
148
+
149
+ run_stdio(workspace=args.workspace)
150
+
151
+
152
+ def cmd_node_get(args: argparse.Namespace) -> None:
153
+ from cortex.daemon import get_daemon
154
+
155
+ daemon = get_daemon(args.workspace)
156
+ node = daemon.graph.get_node(args.id)
157
+ if not node:
158
+ print("not found", file=sys.stderr)
159
+ sys.exit(1)
160
+ print(json.dumps(node.to_row(), indent=2))
161
+
162
+
163
+ def cmd_node_traverse(args: argparse.Namespace) -> None:
164
+ from cortex.daemon import get_daemon
165
+
166
+ daemon = get_daemon(args.workspace)
167
+ nodes, edges = daemon.graph.traverse(args.id, max_depth=args.depth, direction=args.direction)
168
+ print(json.dumps({
169
+ "nodes": [n.to_row() for n in nodes],
170
+ "edges": [{"type": e.type, "from": e.from_id, "to": e.to_id} for e in edges],
171
+ }, indent=2))
172
+
173
+
174
+ def build_parser() -> argparse.ArgumentParser:
175
+ parser = argparse.ArgumentParser(prog="cortex", description="Engineering Knowledge Graph CLI")
176
+ parser.add_argument("--workspace", default=DEFAULT_WORKSPACE)
177
+ sub = parser.add_subparsers(dest="command", required=True)
178
+
179
+ sub.add_parser("init").set_defaults(func=cmd_init)
180
+
181
+ sub.add_parser("info").set_defaults(func=cmd_info)
182
+
183
+ p_ingest = sub.add_parser("ingest")
184
+ p_ingest.add_argument("path")
185
+ p_ingest.add_argument("--project", default=None)
186
+ p_ingest.set_defaults(func=cmd_ingest)
187
+
188
+ p_watch = sub.add_parser("watch", help="Watch a directory and re-ingest changed files")
189
+ p_watch.add_argument("path")
190
+ p_watch.add_argument("--project", default=None)
191
+ p_watch.set_defaults(func=cmd_watch)
192
+
193
+ p_search = sub.add_parser("search")
194
+ p_search.add_argument("query")
195
+ p_search.add_argument("--k", type=int, default=10)
196
+ p_search.set_defaults(func=cmd_search)
197
+
198
+ p_context = sub.add_parser("context")
199
+ p_context.add_argument("query")
200
+ p_context.add_argument("--max-tokens", type=int, default=2000, dest="max_tokens")
201
+ p_context.add_argument("--format", default="markdown", choices=["markdown", "json", "facts", "citations"])
202
+ p_context.set_defaults(func=cmd_context)
203
+
204
+ p_api = sub.add_parser("serve-api")
205
+ p_api.add_argument("--port", type=int, default=8420)
206
+ p_api.set_defaults(func=cmd_serve_api)
207
+
208
+ sub.add_parser("serve-mcp").set_defaults(func=cmd_serve_mcp)
209
+
210
+ p_setup = sub.add_parser("setup", help="Generate MCP configs or project integration files")
211
+ p_setup.add_argument("target", choices=["cursor", "claude", "vscode", "project"])
212
+ p_setup.add_argument("--path", default=".", help="Project directory (default: current dir)")
213
+ p_setup.add_argument("--project", default=None, help="Project name for rules / ingest")
214
+ p_setup.add_argument("--write", action="store_true", help="Write config files instead of printing JSON")
215
+ p_setup.set_defaults(func=cmd_setup)
216
+
217
+ p_node = sub.add_parser("node")
218
+ node_sub = p_node.add_subparsers(dest="node_command", required=True)
219
+ p_node_get = node_sub.add_parser("get")
220
+ p_node_get.add_argument("id")
221
+ p_node_get.set_defaults(func=cmd_node_get)
222
+ p_node_traverse = node_sub.add_parser("traverse")
223
+ p_node_traverse.add_argument("id")
224
+ p_node_traverse.add_argument("--depth", type=int, default=2)
225
+ p_node_traverse.add_argument("--direction", default="out", choices=["out", "in", "both"])
226
+ p_node_traverse.set_defaults(func=cmd_node_traverse)
227
+
228
+ return parser
229
+
230
+
231
+ def main(argv: list[str] | None = None) -> None:
232
+ parser = build_parser()
233
+ args = parser.parse_args(argv)
234
+ args.func(args)
235
+
236
+
237
+ if __name__ == "__main__":
238
+ main()
cortex/config.py ADDED
@@ -0,0 +1,80 @@
1
+ """Workspace-level configuration and the on-disk data layout.
2
+
3
+ Layout (see roadmap section 5 - Storage Architecture)::
4
+
5
+ ~/.cortex/<workspace_id>/
6
+ graph/ Kuzu embedded graph database
7
+ vectors/ LanceDB embedded vector database
8
+ index/ Keyword (FTS) index (SQLite FTS5)
9
+ meta/ Provenance, job queue, schema registry, ACLs (SQLite)
10
+ blobs/ Content-addressed cache of raw extracted chunks
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ CORTEX_HOME_ENV = "CORTEX_HOME"
20
+ DEFAULT_WORKSPACE = "default"
21
+
22
+
23
+ def cortex_home() -> Path:
24
+ """Root directory for all Cortex data, across all workspaces."""
25
+ override = os.environ.get(CORTEX_HOME_ENV)
26
+ base = Path(override) if override else Path.home() / ".cortex"
27
+ base.mkdir(parents=True, exist_ok=True)
28
+ return base
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class WorkspacePaths:
33
+ workspace_id: str
34
+ root: Path
35
+
36
+ @property
37
+ def graph_dir(self) -> Path:
38
+ """Kuzu wants to create the database file itself at this exact
39
+ path, so this is the *parent* directory that must pre-exist, not
40
+ the database path itself. See `graph_db_path`.
41
+ """
42
+ return self.root / "graph"
43
+
44
+ @property
45
+ def graph_db_path(self) -> Path:
46
+ return self.graph_dir / "db.kuzu"
47
+
48
+ @property
49
+ def vectors_dir(self) -> Path:
50
+ return self.root / "vectors"
51
+
52
+ @property
53
+ def index_dir(self) -> Path:
54
+ return self.root / "index"
55
+
56
+ @property
57
+ def meta_dir(self) -> Path:
58
+ return self.root / "meta"
59
+
60
+ @property
61
+ def blobs_dir(self) -> Path:
62
+ return self.root / "blobs"
63
+
64
+ @property
65
+ def meta_db_path(self) -> Path:
66
+ return self.meta_dir / "meta.sqlite3"
67
+
68
+ @property
69
+ def keyword_db_path(self) -> Path:
70
+ return self.index_dir / "keyword.sqlite3"
71
+
72
+ def ensure(self) -> "WorkspacePaths":
73
+ for d in (self.graph_dir, self.vectors_dir, self.index_dir, self.meta_dir, self.blobs_dir):
74
+ d.mkdir(parents=True, exist_ok=True)
75
+ return self
76
+
77
+
78
+ def workspace_paths(workspace_id: str = DEFAULT_WORKSPACE) -> WorkspacePaths:
79
+ root = cortex_home() / workspace_id
80
+ return WorkspacePaths(workspace_id=workspace_id, root=root).ensure()
File without changes