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.
- cortex/__init__.py +8 -0
- cortex/agents/__init__.py +0 -0
- cortex/agents/hooks.py +55 -0
- cortex/agents/writeback.py +58 -0
- cortex/api/__init__.py +0 -0
- cortex/api/rest.py +239 -0
- cortex/cli.py +238 -0
- cortex/config.py +80 -0
- cortex/context/__init__.py +0 -0
- cortex/context/generator.py +146 -0
- cortex/daemon.py +102 -0
- cortex/extractors/__init__.py +25 -0
- cortex/extractors/api_spec_extractor.py +76 -0
- cortex/extractors/base.py +63 -0
- cortex/extractors/code_extractor.py +255 -0
- cortex/extractors/db_schema_extractor.py +78 -0
- cortex/extractors/git_extractor.py +111 -0
- cortex/extractors/image_extractor.py +62 -0
- cortex/extractors/llm_relation_extractor.py +99 -0
- cortex/extractors/markdown_extractor.py +86 -0
- cortex/extractors/notes_extractor.py +101 -0
- cortex/extractors/pdf_extractor.py +59 -0
- cortex/ingestion/__init__.py +0 -0
- cortex/ingestion/content_hash.py +56 -0
- cortex/ingestion/pipeline.py +152 -0
- cortex/ingestion/resolver.py +131 -0
- cortex/mcp_server/__init__.py +0 -0
- cortex/mcp_server/server.py +187 -0
- cortex/models.py +169 -0
- cortex/offline/__init__.py +0 -0
- cortex/offline/local_llm.py +71 -0
- cortex/ontology/__init__.py +3 -0
- cortex/ontology/registry.py +171 -0
- cortex/plugins/__init__.py +0 -0
- cortex/plugins/examples/jira_like_plugin/plugin.py +51 -0
- cortex/plugins/interface.py +48 -0
- cortex/plugins/loader.py +106 -0
- cortex/replication/__init__.py +0 -0
- cortex/replication/crdt.py +95 -0
- cortex/retrieval/__init__.py +0 -0
- cortex/retrieval/embeddings.py +79 -0
- cortex/retrieval/fusion.py +47 -0
- cortex/retrieval/planner.py +49 -0
- cortex/retrieval/query_engine.py +158 -0
- cortex/retrieval/traversal.py +47 -0
- cortex/security/__init__.py +0 -0
- cortex/security/acl.py +90 -0
- cortex/security/audit.py +26 -0
- cortex/security/encryption.py +56 -0
- cortex/security/secrets_scanner.py +34 -0
- cortex/server_profile/__init__.py +0 -0
- cortex/server_profile/neo4j_store.py +200 -0
- cortex/setup/__init__.py +15 -0
- cortex/setup/generators.py +108 -0
- cortex/storage/__init__.py +12 -0
- cortex/storage/graph_store.py +322 -0
- cortex/storage/keyword_store.py +81 -0
- cortex/storage/provenance.py +233 -0
- cortex/storage/vector_store.py +77 -0
- cortex/sync/__init__.py +0 -0
- cortex/sync/change_feed.py +71 -0
- cortex/sync/job_queue.py +65 -0
- cortex/sync/watcher.py +92 -0
- cortex/versioning/__init__.py +0 -0
- cortex/versioning/bitemporal.py +82 -0
- cortex/workspace/__init__.py +0 -0
- cortex/workspace/federation.py +40 -0
- cortex/workspace/registry.py +98 -0
- cortex_kg-0.1.0.dist-info/METADATA +242 -0
- cortex_kg-0.1.0.dist-info/RECORD +74 -0
- cortex_kg-0.1.0.dist-info/WHEEL +5 -0
- cortex_kg-0.1.0.dist-info/entry_points.txt +2 -0
- cortex_kg-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|