cognexdb 0.4.1__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.
- cognex/__init__.py +7 -0
- cognex/ai.py +170 -0
- cognex/auth.py +120 -0
- cognex/cli.py +92 -0
- cognex/crdt.py +187 -0
- cognex/database.py +368 -0
- cognex/engine.py +193 -0
- cognex/functions.py +38 -0
- cognex/importers.py +71 -0
- cognex/mcp.py +100 -0
- cognex/policies.py +57 -0
- cognex/sdk.py +195 -0
- cognex/server.py +338 -0
- cognex/sync.py +279 -0
- cognexdb-0.4.1.dist-info/METADATA +440 -0
- cognexdb-0.4.1.dist-info/RECORD +19 -0
- cognexdb-0.4.1.dist-info/WHEEL +5 -0
- cognexdb-0.4.1.dist-info/entry_points.txt +3 -0
- cognexdb-0.4.1.dist-info/top_level.txt +1 -0
cognex/__init__.py
ADDED
cognex/ai.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import io
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .database import FileDatabase
|
|
11
|
+
|
|
12
|
+
SAFE_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class AiPlan:
|
|
17
|
+
action: str
|
|
18
|
+
confidence: float
|
|
19
|
+
explanation: str
|
|
20
|
+
collection: str | None = None
|
|
21
|
+
query: str | None = None
|
|
22
|
+
sql: str | None = None
|
|
23
|
+
params: list[Any] | None = None
|
|
24
|
+
updates: dict[str, Any] | None = None
|
|
25
|
+
filters: dict[str, Any] | None = None
|
|
26
|
+
|
|
27
|
+
def as_dict(self) -> dict[str, Any]:
|
|
28
|
+
return {k: v for k, v in self.__dict__.items() if v is not None}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AiNativeEngine:
|
|
32
|
+
"""Deterministic AI-native command layer for local file-backed data.
|
|
33
|
+
|
|
34
|
+
This is intentionally provider-free: it turns common natural-language data
|
|
35
|
+
tasks into safe plans that can be inspected before execution, then runs them
|
|
36
|
+
against Cognex files/SQLite. LLMs can call it directly through REST, CLI,
|
|
37
|
+
SDK, or MCP without needing custom database glue code.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, root: str | Path):
|
|
41
|
+
self.root = Path(root).resolve()
|
|
42
|
+
self.db = FileDatabase(self.root)
|
|
43
|
+
self.db.init()
|
|
44
|
+
|
|
45
|
+
def plan(self, prompt: str, *, collection: str | None = None, csv_text: str | None = None) -> dict[str, Any]:
|
|
46
|
+
prompt = prompt.strip()
|
|
47
|
+
if not prompt:
|
|
48
|
+
raise ValueError("Prompt is required")
|
|
49
|
+
if csv_text or re.search(r"\b(create|import)\b.*\bcsv\b", prompt, re.I):
|
|
50
|
+
target = collection or self._collection_after_words(prompt, ["crm", "collection", "table"]) or "records"
|
|
51
|
+
return AiPlan("import_csv", 0.82, f"Import CSV rows into the {target} collection.", collection=target).as_dict()
|
|
52
|
+
update = self._plan_update(prompt, collection)
|
|
53
|
+
if update:
|
|
54
|
+
return update.as_dict()
|
|
55
|
+
query = self._plan_query(prompt, collection)
|
|
56
|
+
if query:
|
|
57
|
+
return query.as_dict()
|
|
58
|
+
term = self._quoted(prompt) or prompt
|
|
59
|
+
return AiPlan("search", 0.55, "Search across indexed records because no safer structured intent matched.", collection=collection, query=term).as_dict()
|
|
60
|
+
|
|
61
|
+
def run(self, prompt: str, *, collection: str | None = None, csv_text: str | None = None, dry_run: bool = False) -> dict[str, Any]:
|
|
62
|
+
plan = self.plan(prompt, collection=collection, csv_text=csv_text)
|
|
63
|
+
action = plan["action"]
|
|
64
|
+
if dry_run:
|
|
65
|
+
return {"plan": plan, "dry_run": True, "result": None}
|
|
66
|
+
if action == "search":
|
|
67
|
+
return {"plan": plan, "result": self._search(plan.get("query") or prompt, plan.get("collection"))}
|
|
68
|
+
if action == "query":
|
|
69
|
+
return {"plan": plan, "result": self.db.query(plan["sql"], plan.get("params") or [])}
|
|
70
|
+
if action == "update":
|
|
71
|
+
return {"plan": plan, "result": self._update(plan["collection"], plan.get("filters") or {}, plan.get("updates") or {})}
|
|
72
|
+
if action == "import_csv":
|
|
73
|
+
if not csv_text:
|
|
74
|
+
raise ValueError("csv_text is required for CSV imports")
|
|
75
|
+
return {"plan": plan, "result": self._import_csv(plan["collection"], csv_text)}
|
|
76
|
+
raise ValueError(f"Unsupported AI action: {action}")
|
|
77
|
+
|
|
78
|
+
def _plan_query(self, prompt: str, collection: str | None) -> AiPlan | None:
|
|
79
|
+
low = prompt.lower()
|
|
80
|
+
if not re.search(r"\b(find|show|list|get|search)\b", low):
|
|
81
|
+
return None
|
|
82
|
+
target = collection or self._collection_after_words(prompt, ["every", "all", "from", "in"]) or self._first_collection()
|
|
83
|
+
if not target or not SAFE_NAME_RE.match(target):
|
|
84
|
+
term = self._quoted(prompt) or self._term_after_words(prompt, ["bought", "contains", "about", "for"])
|
|
85
|
+
return AiPlan("search", 0.62, "Search all records for the requested term.", query=term or prompt)
|
|
86
|
+
field_filter = self._field_equals(prompt)
|
|
87
|
+
if field_filter:
|
|
88
|
+
field, value = field_filter
|
|
89
|
+
sql = f'SELECT * FROM "{target}" WHERE lower("{field}") = lower(?)'
|
|
90
|
+
return AiPlan("query", 0.86, f"Query {target} where {field} equals {value}.", collection=target, sql=sql, params=[value], filters={field: value})
|
|
91
|
+
term = self._quoted(prompt) or self._term_after_words(prompt, ["bought", "contains", "about", "for"])
|
|
92
|
+
if term:
|
|
93
|
+
return AiPlan("search", 0.74, f"Search {target} records for {term}.", collection=target, query=term)
|
|
94
|
+
return AiPlan("query", 0.7, f"List records from {target}.", collection=target, sql=f'SELECT * FROM "{target}"', params=[])
|
|
95
|
+
|
|
96
|
+
def _plan_update(self, prompt: str, collection: str | None) -> AiPlan | None:
|
|
97
|
+
# Supported safe shape: update customers where status = lead set status = active
|
|
98
|
+
match = re.search(
|
|
99
|
+
r"update\s+(?P<collection>[A-Za-z0-9_.-]+)?\s*where\s+(?P<filter>[A-Za-z0-9_.-]+)\s*=\s*(?P<value>[^\s]+)\s+set\s+(?P<field>[A-Za-z0-9_.-]+)\s*=\s*(?P<new>[^\s]+)",
|
|
100
|
+
prompt,
|
|
101
|
+
re.I,
|
|
102
|
+
)
|
|
103
|
+
if not match:
|
|
104
|
+
return None
|
|
105
|
+
target = collection or match.group("collection")
|
|
106
|
+
if not target or not SAFE_NAME_RE.match(target):
|
|
107
|
+
raise ValueError("Safe update requires a valid collection")
|
|
108
|
+
filters = {match.group("filter"): self._clean_value(match.group("value"))}
|
|
109
|
+
updates = {match.group("field"): self._clean_value(match.group("new"))}
|
|
110
|
+
return AiPlan("update", 0.9, f"Update {target} records matching {filters} with {updates}.", collection=target, filters=filters, updates=updates)
|
|
111
|
+
|
|
112
|
+
def _search(self, query: str, collection: str | None) -> list[dict[str, Any]]:
|
|
113
|
+
records = self.db.search(query, 50)
|
|
114
|
+
if collection:
|
|
115
|
+
records = [record for record in records if record["collection"] == collection]
|
|
116
|
+
return records
|
|
117
|
+
|
|
118
|
+
def _update(self, collection: str, filters: dict[str, Any], updates: dict[str, Any]) -> dict[str, Any]:
|
|
119
|
+
changed = []
|
|
120
|
+
for record in self.db.list_records(collection, 10000, 0):
|
|
121
|
+
data = record.get("data") or {}
|
|
122
|
+
if all(str(data.get(key)) == str(value) for key, value in filters.items()):
|
|
123
|
+
merged = dict(data)
|
|
124
|
+
merged.update(updates)
|
|
125
|
+
changed.append(self.db.upsert_record(collection, record["id"], merged, record.get("content", ""), record.get("format", "json"), record.get("revision")))
|
|
126
|
+
return {"updated": len(changed), "records": changed}
|
|
127
|
+
|
|
128
|
+
def _import_csv(self, collection: str, csv_text: str) -> dict[str, Any]:
|
|
129
|
+
if not SAFE_NAME_RE.match(collection):
|
|
130
|
+
raise ValueError("Invalid collection name")
|
|
131
|
+
rows = list(csv.DictReader(io.StringIO(csv_text)))
|
|
132
|
+
written = []
|
|
133
|
+
for index, row in enumerate(rows):
|
|
134
|
+
rid = str(row.get("id") or row.get("email") or row.get("name") or index).strip().replace(" ", "_")
|
|
135
|
+
rid = re.sub(r"[^A-Za-z0-9_.-]", "_", rid) or str(index)
|
|
136
|
+
written.append(self.db.upsert_record(collection, rid, dict(row), fmt="json"))
|
|
137
|
+
return {"imported": len(written), "records": written}
|
|
138
|
+
|
|
139
|
+
def _field_equals(self, prompt: str) -> tuple[str, str] | None:
|
|
140
|
+
match = re.search(r"\b([A-Za-z0-9_.-]+)\s*(?:=|is)\s*['\"]?([^'\".?!]+)['\"]?", prompt, re.I)
|
|
141
|
+
if match and SAFE_NAME_RE.match(match.group(1)):
|
|
142
|
+
return match.group(1), self._clean_value(match.group(2))
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
def _collection_after_words(self, prompt: str, words: list[str]) -> str | None:
|
|
146
|
+
for word in words:
|
|
147
|
+
match = re.search(rf"\b{re.escape(word)}\s+([A-Za-z0-9_.-]+)", prompt, re.I)
|
|
148
|
+
if match:
|
|
149
|
+
candidate = match.group(1).lower().strip(".,?!")
|
|
150
|
+
if candidate not in {"that", "with", "where", "from", "the", "a", "an"} and SAFE_NAME_RE.match(candidate):
|
|
151
|
+
return candidate
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
def _term_after_words(self, prompt: str, words: list[str]) -> str | None:
|
|
155
|
+
for word in words:
|
|
156
|
+
match = re.search(rf"\b{re.escape(word)}\s+['\"]?([^'\".?!]+)['\"]?", prompt, re.I)
|
|
157
|
+
if match:
|
|
158
|
+
return self._clean_value(match.group(1))
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
def _quoted(self, prompt: str) -> str | None:
|
|
162
|
+
match = re.search(r"['\"]([^'\"]+)['\"]", prompt)
|
|
163
|
+
return match.group(1).strip() if match else None
|
|
164
|
+
|
|
165
|
+
def _first_collection(self) -> str | None:
|
|
166
|
+
collections = self.db.collections()
|
|
167
|
+
return collections[0]["collection"] if collections else None
|
|
168
|
+
|
|
169
|
+
def _clean_value(self, value: str) -> str:
|
|
170
|
+
return value.strip().strip("'\".,?!")
|
cognex/auth.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuthError(RuntimeError):
|
|
15
|
+
"""Raised when local Cognex authentication fails."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LocalAuth:
|
|
19
|
+
"""File-backed local auth for single-node/local-first deployments.
|
|
20
|
+
|
|
21
|
+
Users and sessions are stored under `.cognex/auth.json`. Passwords are
|
|
22
|
+
salted PBKDF2 hashes and session tokens are stored hashed at rest.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, root: str | Path, session_ttl_seconds: int = 60 * 60 * 24 * 7):
|
|
26
|
+
self.root = Path(root).resolve()
|
|
27
|
+
self.path = self.root / ".cognex" / "auth.json"
|
|
28
|
+
self.session_ttl_seconds = session_ttl_seconds
|
|
29
|
+
|
|
30
|
+
def signup(self, email: str, password: str, role: str = "user") -> dict[str, Any]:
|
|
31
|
+
email = self._normalize_email(email)
|
|
32
|
+
if len(password) < 8:
|
|
33
|
+
raise AuthError("Password must be at least 8 characters")
|
|
34
|
+
state = self._load()
|
|
35
|
+
if email in state["users"]:
|
|
36
|
+
raise AuthError("User already exists")
|
|
37
|
+
now = time.time()
|
|
38
|
+
state["users"][email] = {
|
|
39
|
+
"email": email,
|
|
40
|
+
"role": role,
|
|
41
|
+
"password": self._hash_password(password),
|
|
42
|
+
"created_at": now,
|
|
43
|
+
}
|
|
44
|
+
token, session = self._new_session(email, role)
|
|
45
|
+
state["sessions"][session["token_hash"]] = session
|
|
46
|
+
self._save(state)
|
|
47
|
+
return {"user": self._public_user(state["users"][email]), "session": {"access_token": token, "expires_at": session["expires_at"]}}
|
|
48
|
+
|
|
49
|
+
def login(self, email: str, password: str) -> dict[str, Any]:
|
|
50
|
+
email = self._normalize_email(email)
|
|
51
|
+
state = self._load()
|
|
52
|
+
user = state["users"].get(email)
|
|
53
|
+
if not user or not self._verify_password(password, user["password"]):
|
|
54
|
+
raise AuthError("Invalid email or password")
|
|
55
|
+
token, session = self._new_session(email, user.get("role", "user"))
|
|
56
|
+
state["sessions"][session["token_hash"]] = session
|
|
57
|
+
self._save(state)
|
|
58
|
+
return {"user": self._public_user(user), "session": {"access_token": token, "expires_at": session["expires_at"]}}
|
|
59
|
+
|
|
60
|
+
def validate_token(self, token: str) -> dict[str, Any] | None:
|
|
61
|
+
if not token:
|
|
62
|
+
return None
|
|
63
|
+
state = self._load()
|
|
64
|
+
session = state["sessions"].get(self._token_hash(token))
|
|
65
|
+
if not session or session.get("expires_at", 0) < time.time():
|
|
66
|
+
return None
|
|
67
|
+
user = state["users"].get(session["email"])
|
|
68
|
+
return self._public_user(user) if user else None
|
|
69
|
+
|
|
70
|
+
def logout(self, token: str) -> bool:
|
|
71
|
+
state = self._load()
|
|
72
|
+
removed = state["sessions"].pop(self._token_hash(token), None) is not None
|
|
73
|
+
if removed:
|
|
74
|
+
self._save(state)
|
|
75
|
+
return removed
|
|
76
|
+
|
|
77
|
+
def _new_session(self, email: str, role: str) -> tuple[str, dict[str, Any]]:
|
|
78
|
+
token = secrets.token_urlsafe(32)
|
|
79
|
+
now = time.time()
|
|
80
|
+
return token, {"token_hash": self._token_hash(token), "email": email, "role": role, "created_at": now, "expires_at": now + self.session_ttl_seconds}
|
|
81
|
+
|
|
82
|
+
def _load(self) -> dict[str, Any]:
|
|
83
|
+
if not self.path.exists():
|
|
84
|
+
return {"users": {}, "sessions": {}}
|
|
85
|
+
return json.loads(self.path.read_text(encoding="utf-8"))
|
|
86
|
+
|
|
87
|
+
def _save(self, state: dict[str, Any]) -> None:
|
|
88
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
tmp = self.path.with_suffix(".tmp")
|
|
90
|
+
tmp.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
91
|
+
os.replace(tmp, self.path)
|
|
92
|
+
|
|
93
|
+
def _hash_password(self, password: str) -> str:
|
|
94
|
+
salt = secrets.token_bytes(16)
|
|
95
|
+
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 210_000)
|
|
96
|
+
return "pbkdf2_sha256$210000$" + base64.b64encode(salt).decode() + "$" + base64.b64encode(digest).decode()
|
|
97
|
+
|
|
98
|
+
def _verify_password(self, password: str, encoded: str) -> bool:
|
|
99
|
+
try:
|
|
100
|
+
scheme, iterations, salt_b64, digest_b64 = encoded.split("$", 3)
|
|
101
|
+
if scheme != "pbkdf2_sha256":
|
|
102
|
+
return False
|
|
103
|
+
salt = base64.b64decode(salt_b64)
|
|
104
|
+
expected = base64.b64decode(digest_b64)
|
|
105
|
+
actual = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, int(iterations))
|
|
106
|
+
return hmac.compare_digest(actual, expected)
|
|
107
|
+
except Exception:
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
def _token_hash(self, token: str) -> str:
|
|
111
|
+
return hashlib.sha256(token.encode()).hexdigest()
|
|
112
|
+
|
|
113
|
+
def _normalize_email(self, email: str) -> str:
|
|
114
|
+
email = (email or "").strip().lower()
|
|
115
|
+
if "@" not in email:
|
|
116
|
+
raise AuthError("Valid email is required")
|
|
117
|
+
return email
|
|
118
|
+
|
|
119
|
+
def _public_user(self, user: dict[str, Any]) -> dict[str, Any]:
|
|
120
|
+
return {"email": user["email"], "role": user.get("role", "user"), "created_at": user.get("created_at")}
|
cognex/cli.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .ai import AiNativeEngine
|
|
8
|
+
from .crdt import CrdtEngine
|
|
9
|
+
from .database import FileDatabase
|
|
10
|
+
from .functions import FunctionRunner
|
|
11
|
+
from .importers import Importer
|
|
12
|
+
from .engine import ProjectEngine
|
|
13
|
+
from .mcp import McpServer
|
|
14
|
+
from .server import serve
|
|
15
|
+
from .sync import SyncEngine
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main(argv: list[str] | None = None) -> int:
|
|
19
|
+
parser = argparse.ArgumentParser(prog="cognex", description="Local project intelligence engine for AI coding assistants.")
|
|
20
|
+
parser.add_argument("--root", default=".", help="Project root to index. Defaults to current directory.")
|
|
21
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
22
|
+
sub.add_parser("init", help="Create local Cognex state and ignore rules.")
|
|
23
|
+
sub.add_parser("index", help="Index the project.")
|
|
24
|
+
s = sub.add_parser("search", help="Search the project index."); s.add_argument("query"); s.add_argument("--limit", type=int, default=10)
|
|
25
|
+
c = sub.add_parser("context", help="Build AI-ready context for a prompt."); c.add_argument("prompt"); c.add_argument("--limit", type=int, default=8); c.add_argument("--max-chars", type=int, default=14000)
|
|
26
|
+
sub.add_parser("graph", help="Print the project graph as JSON.")
|
|
27
|
+
q = sub.add_parser("sql", help="Run read-only SQL against file collections."); q.add_argument("sql")
|
|
28
|
+
ai = sub.add_parser("ai", help="Plan or run an AI-native natural-language data task."); ai.add_argument("prompt"); ai.add_argument("--collection", default=None); ai.add_argument("--csv-file", default=None); ai.add_argument("--dry-run", action="store_true")
|
|
29
|
+
sub.add_parser("collections", help="List file database collections.")
|
|
30
|
+
u = sub.add_parser("upsert", help="Write a JSON/Markdown record to the file database."); u.add_argument("collection"); u.add_argument("id"); u.add_argument("--data", default="{}"); u.add_argument("--content", default=""); u.add_argument("--format", default="json", choices=["json", "md"]); u.add_argument("--expected-revision", default=None)
|
|
31
|
+
sub.add_parser("transactions", help="List recent local write transactions.")
|
|
32
|
+
sp = sub.add_parser("sync-push", help="Push local data files to a filesystem/Git sync remote."); sp.add_argument("remote"); sp.add_argument("--force", action="store_true"); sp.add_argument("--git", action="store_true"); sp.add_argument("--message", default="Cognex sync push"); sp.add_argument("--no-crdt", action="store_true")
|
|
33
|
+
sl = sub.add_parser("sync-pull", help="Pull data files from a filesystem/Git sync remote."); sl.add_argument("remote"); sl.add_argument("--force", action="store_true"); sl.add_argument("--git", action="store_true"); sl.add_argument("--no-crdt", action="store_true")
|
|
34
|
+
ss = sub.add_parser("sync-status", help="Show pending push/pull/conflict status for a sync remote."); ss.add_argument("remote")
|
|
35
|
+
cu = sub.add_parser("crdt-update", help="Apply CRDT field updates to a JSON record."); cu.add_argument("collection"); cu.add_argument("id"); cu.add_argument("--fields", default="{}"); cu.add_argument("--deletes", default="[]"); cu.add_argument("--actor", default=None)
|
|
36
|
+
ce = sub.add_parser("crdt-export", help="Export CRDT operations to a JSON file."); ce.add_argument("target"); ce.add_argument("--since", type=float, default=0.0)
|
|
37
|
+
ci = sub.add_parser("crdt-import", help="Import CRDT operations from a JSON file."); ci.add_argument("source")
|
|
38
|
+
imp = sub.add_parser("import", help="Import Supabase/Firebase exports."); imp.add_argument("provider", choices=["supabase", "firebase"]); imp.add_argument("source"); imp.add_argument("--collection", default=None)
|
|
39
|
+
fn = sub.add_parser("function", help="Run a local Cognex function from .cognex/functions."); fn.add_argument("name"); fn.add_argument("--data", default="{}")
|
|
40
|
+
api = sub.add_parser("serve", help="Run the production REST backend."); api.add_argument("--host", default="0.0.0.0"); api.add_argument("--port", type=int, default=8080); api.add_argument("--api-key", default=""); api.add_argument("--no-watch", action="store_true")
|
|
41
|
+
sub.add_parser("mcp", help="Run an MCP stdio server.")
|
|
42
|
+
args = parser.parse_args(argv)
|
|
43
|
+
root = Path(args.root)
|
|
44
|
+
engine = ProjectEngine(root)
|
|
45
|
+
|
|
46
|
+
if args.cmd == "init":
|
|
47
|
+
engine.init(); print(f"Initialized Cognex at {engine.state_dir}")
|
|
48
|
+
elif args.cmd == "index":
|
|
49
|
+
engine.load(); print(json.dumps(engine.index(), indent=2))
|
|
50
|
+
elif args.cmd == "search":
|
|
51
|
+
engine.load(); print(json.dumps([h.__dict__ for h in engine.search(args.query, args.limit)], indent=2))
|
|
52
|
+
elif args.cmd == "context":
|
|
53
|
+
engine.load(); print(engine.context(args.prompt, args.limit, args.max_chars))
|
|
54
|
+
elif args.cmd == "graph":
|
|
55
|
+
engine.load(); print(json.dumps(engine.graph(), indent=2))
|
|
56
|
+
elif args.cmd == "sql":
|
|
57
|
+
db = FileDatabase(root); print(json.dumps(db.query(args.sql), indent=2))
|
|
58
|
+
elif args.cmd == "ai":
|
|
59
|
+
csv_text = Path(args.csv_file).read_text(encoding="utf-8") if args.csv_file else None
|
|
60
|
+
print(json.dumps(AiNativeEngine(root).run(args.prompt, collection=args.collection, csv_text=csv_text, dry_run=args.dry_run), indent=2))
|
|
61
|
+
elif args.cmd == "collections":
|
|
62
|
+
db = FileDatabase(root); print(json.dumps(db.collections(), indent=2))
|
|
63
|
+
elif args.cmd == "import":
|
|
64
|
+
importer = Importer(root)
|
|
65
|
+
result = importer.import_supabase(args.source, args.collection) if args.provider == "supabase" else importer.import_firebase(args.source, args.collection)
|
|
66
|
+
print(json.dumps(result, indent=2))
|
|
67
|
+
elif args.cmd == "function":
|
|
68
|
+
print(json.dumps(FunctionRunner(root).run(args.name, json.loads(args.data)), indent=2))
|
|
69
|
+
elif args.cmd == "upsert":
|
|
70
|
+
db = FileDatabase(root); print(json.dumps(db.upsert_record(args.collection, args.id, json.loads(args.data), args.content, args.format, args.expected_revision), indent=2))
|
|
71
|
+
elif args.cmd == "transactions":
|
|
72
|
+
db = FileDatabase(root); print(json.dumps(db.transaction_log(), indent=2))
|
|
73
|
+
elif args.cmd == "sync-push":
|
|
74
|
+
print(json.dumps(SyncEngine(root).push(args.remote, args.force, args.git, args.message, not args.no_crdt).__dict__, indent=2))
|
|
75
|
+
elif args.cmd == "sync-pull":
|
|
76
|
+
print(json.dumps(SyncEngine(root).pull(args.remote, args.force, args.git, not args.no_crdt).__dict__, indent=2))
|
|
77
|
+
elif args.cmd == "sync-status":
|
|
78
|
+
print(json.dumps(SyncEngine(root).status(args.remote), indent=2))
|
|
79
|
+
elif args.cmd == "crdt-update":
|
|
80
|
+
print(json.dumps(CrdtEngine(root, args.actor).apply_update(args.collection, args.id, json.loads(args.fields), json.loads(args.deletes)), indent=2))
|
|
81
|
+
elif args.cmd == "crdt-export":
|
|
82
|
+
print(json.dumps(CrdtEngine(root).write_export(args.target, args.since), indent=2))
|
|
83
|
+
elif args.cmd == "crdt-import":
|
|
84
|
+
print(json.dumps(CrdtEngine(root).merge_files(args.source), indent=2))
|
|
85
|
+
elif args.cmd == "serve":
|
|
86
|
+
serve(root, args.host, args.port, args.api_key, not args.no_watch)
|
|
87
|
+
elif args.cmd == "mcp":
|
|
88
|
+
McpServer(root).serve()
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
raise SystemExit(main())
|
cognex/crdt.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .database import FileDatabase
|
|
12
|
+
|
|
13
|
+
DELETE = "__cognex_deleted__"
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class CrdtOp:
|
|
17
|
+
op_id: str
|
|
18
|
+
actor: str
|
|
19
|
+
seq: int
|
|
20
|
+
collection: str
|
|
21
|
+
id: str
|
|
22
|
+
field: str
|
|
23
|
+
value: Any
|
|
24
|
+
deleted: bool
|
|
25
|
+
ts: float
|
|
26
|
+
|
|
27
|
+
class CrdtEngine:
|
|
28
|
+
"""Operation-based LWW-register CRDT for JSON-style Cognex records.
|
|
29
|
+
|
|
30
|
+
It stores idempotent field operations, imports/exports operation logs, and
|
|
31
|
+
materializes the deterministic merged state back into human-readable files.
|
|
32
|
+
Different fields merge automatically. Same-field concurrent edits resolve by
|
|
33
|
+
a deterministic last-writer-wins tuple: (ts, actor, op_id).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, root: str | Path, actor: str | None = None):
|
|
37
|
+
self.root = Path(root).resolve()
|
|
38
|
+
self.db = FileDatabase(self.root)
|
|
39
|
+
self.db.init()
|
|
40
|
+
self.actor = actor or os.getenv("COGNEX_ACTOR") or self._load_actor()
|
|
41
|
+
self._create_schema()
|
|
42
|
+
|
|
43
|
+
def _create_schema(self) -> None:
|
|
44
|
+
db = self.db.connect()
|
|
45
|
+
db.executescript(
|
|
46
|
+
"""
|
|
47
|
+
CREATE TABLE IF NOT EXISTS crdt_ops (
|
|
48
|
+
op_id TEXT PRIMARY KEY,
|
|
49
|
+
actor TEXT NOT NULL,
|
|
50
|
+
seq INTEGER NOT NULL,
|
|
51
|
+
collection TEXT NOT NULL,
|
|
52
|
+
id TEXT NOT NULL,
|
|
53
|
+
field TEXT NOT NULL,
|
|
54
|
+
value_json TEXT NOT NULL,
|
|
55
|
+
deleted INTEGER NOT NULL,
|
|
56
|
+
ts REAL NOT NULL,
|
|
57
|
+
received_at REAL NOT NULL
|
|
58
|
+
);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_crdt_record ON crdt_ops(collection, id);
|
|
60
|
+
CREATE TABLE IF NOT EXISTS crdt_actor_state (
|
|
61
|
+
actor TEXT PRIMARY KEY,
|
|
62
|
+
seq INTEGER NOT NULL
|
|
63
|
+
);
|
|
64
|
+
"""
|
|
65
|
+
)
|
|
66
|
+
db.commit()
|
|
67
|
+
|
|
68
|
+
def _load_actor(self) -> str:
|
|
69
|
+
path = self.root / ".cognex" / "actor_id"
|
|
70
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
if path.exists():
|
|
72
|
+
return path.read_text(encoding="utf-8").strip()
|
|
73
|
+
actor = f"actor-{uuid.uuid4()}"
|
|
74
|
+
path.write_text(actor, encoding="utf-8")
|
|
75
|
+
return actor
|
|
76
|
+
|
|
77
|
+
def _next_seq(self) -> int:
|
|
78
|
+
db = self.db.connect()
|
|
79
|
+
row = db.execute("SELECT seq FROM crdt_actor_state WHERE actor=?", (self.actor,)).fetchone()
|
|
80
|
+
seq = int(row["seq"]) + 1 if row else 1
|
|
81
|
+
db.execute("INSERT INTO crdt_actor_state(actor,seq) VALUES(?,?) ON CONFLICT(actor) DO UPDATE SET seq=excluded.seq", (self.actor, seq))
|
|
82
|
+
db.commit()
|
|
83
|
+
return seq
|
|
84
|
+
|
|
85
|
+
def apply_update(self, collection: str, rid: str, fields: dict[str, Any], deletes: list[str] | None = None, actor: str | None = None) -> dict[str, Any]:
|
|
86
|
+
ops = []
|
|
87
|
+
actor_id = actor or self.actor
|
|
88
|
+
for field, value in fields.items():
|
|
89
|
+
ops.append(self._new_op(collection, rid, field, value, False, actor_id))
|
|
90
|
+
for field in deletes or []:
|
|
91
|
+
ops.append(self._new_op(collection, rid, field, None, True, actor_id))
|
|
92
|
+
imported = self.import_ops([asdict(op) for op in ops], materialize=False)
|
|
93
|
+
record = self.materialize_record(collection, rid)
|
|
94
|
+
return {"actor": actor_id, "applied": imported["imported"], "ops": [asdict(op) for op in ops], "record": record}
|
|
95
|
+
|
|
96
|
+
def delete_record(self, collection: str, rid: str, actor: str | None = None) -> dict[str, Any]:
|
|
97
|
+
return self.apply_update(collection, rid, {DELETE: True}, actor=actor)
|
|
98
|
+
|
|
99
|
+
def _new_op(self, collection: str, rid: str, field: str, value: Any, deleted: bool, actor: str) -> CrdtOp:
|
|
100
|
+
seq = self._next_seq() if actor == self.actor else int(time.time() * 1_000_000)
|
|
101
|
+
ts = time.time()
|
|
102
|
+
op_id = f"{actor}:{seq}:{uuid.uuid4().hex}"
|
|
103
|
+
return CrdtOp(op_id, actor, seq, collection, rid, field, value, deleted, ts)
|
|
104
|
+
|
|
105
|
+
def import_ops(self, ops: list[dict[str, Any]], materialize: bool = True) -> dict[str, Any]:
|
|
106
|
+
db = self.db.connect()
|
|
107
|
+
imported = 0
|
|
108
|
+
touched: set[tuple[str, str]] = set()
|
|
109
|
+
for raw in ops:
|
|
110
|
+
op = self._normalize(raw)
|
|
111
|
+
try:
|
|
112
|
+
db.execute(
|
|
113
|
+
"INSERT INTO crdt_ops(op_id,actor,seq,collection,id,field,value_json,deleted,ts,received_at) VALUES(?,?,?,?,?,?,?,?,?,?)",
|
|
114
|
+
(op.op_id, op.actor, op.seq, op.collection, op.id, op.field, json.dumps(op.value, sort_keys=True), int(op.deleted), op.ts, time.time()),
|
|
115
|
+
)
|
|
116
|
+
imported += 1
|
|
117
|
+
touched.add((op.collection, op.id))
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
if "UNIQUE" not in str(exc).upper():
|
|
120
|
+
raise
|
|
121
|
+
db.commit()
|
|
122
|
+
records = []
|
|
123
|
+
if materialize:
|
|
124
|
+
for collection, rid in sorted(touched):
|
|
125
|
+
records.append(self.materialize_record(collection, rid))
|
|
126
|
+
return {"imported": imported, "records": records}
|
|
127
|
+
|
|
128
|
+
def export_ops(self, since: float = 0.0, collection: str | None = None, rid: str | None = None) -> list[dict[str, Any]]:
|
|
129
|
+
sql = "SELECT * FROM crdt_ops WHERE ts >= ?"
|
|
130
|
+
params: list[Any] = [since]
|
|
131
|
+
if collection:
|
|
132
|
+
sql += " AND collection = ?"; params.append(collection)
|
|
133
|
+
if rid:
|
|
134
|
+
sql += " AND id = ?"; params.append(rid)
|
|
135
|
+
sql += " ORDER BY ts, actor, seq, op_id"
|
|
136
|
+
rows = self.db.connect().execute(sql, params).fetchall()
|
|
137
|
+
return [self._row_to_op(row) for row in rows]
|
|
138
|
+
|
|
139
|
+
def materialize_record(self, collection: str, rid: str) -> dict[str, Any]:
|
|
140
|
+
state = self.merged_state(collection, rid)
|
|
141
|
+
if state.get(DELETE) is True:
|
|
142
|
+
self.db.delete_record(collection, rid)
|
|
143
|
+
return {"collection": collection, "id": rid, "deleted": True, "data": {}}
|
|
144
|
+
record = self.db.upsert_record(collection, rid, state)
|
|
145
|
+
return record
|
|
146
|
+
|
|
147
|
+
def merged_state(self, collection: str, rid: str) -> dict[str, Any]:
|
|
148
|
+
rows = self.db.connect().execute("SELECT * FROM crdt_ops WHERE collection=? AND id=?", (collection, rid)).fetchall()
|
|
149
|
+
winners: dict[str, tuple[tuple[float, str, str], Any, bool]] = {}
|
|
150
|
+
for row in rows:
|
|
151
|
+
key = (float(row["ts"]), row["actor"], row["op_id"])
|
|
152
|
+
field = row["field"]
|
|
153
|
+
old = winners.get(field)
|
|
154
|
+
if old is None or key > old[0]:
|
|
155
|
+
winners[field] = (key, json.loads(row["value_json"]), bool(row["deleted"]))
|
|
156
|
+
data: dict[str, Any] = {}
|
|
157
|
+
for field, (_, value, deleted) in winners.items():
|
|
158
|
+
if not deleted:
|
|
159
|
+
data[field] = value
|
|
160
|
+
return data
|
|
161
|
+
|
|
162
|
+
def merge_files(self, source: str | Path) -> dict[str, Any]:
|
|
163
|
+
path = Path(source)
|
|
164
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
165
|
+
return self.import_ops(payload.get("ops", payload if isinstance(payload, list) else []))
|
|
166
|
+
|
|
167
|
+
def write_export(self, target: str | Path, since: float = 0.0) -> dict[str, Any]:
|
|
168
|
+
path = Path(target)
|
|
169
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
ops = self.export_ops(since)
|
|
171
|
+
payload = {"version": 1, "actor": self.actor, "exported_at": time.time(), "ops": ops}
|
|
172
|
+
path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
|
173
|
+
return {"target": str(path), "ops": len(ops)}
|
|
174
|
+
|
|
175
|
+
def _normalize(self, raw: dict[str, Any]) -> CrdtOp:
|
|
176
|
+
return CrdtOp(
|
|
177
|
+
op_id=str(raw["op_id"]), actor=str(raw["actor"]), seq=int(raw["seq"]),
|
|
178
|
+
collection=str(raw["collection"]), id=str(raw["id"]), field=str(raw["field"]),
|
|
179
|
+
value=raw.get("value"), deleted=bool(raw.get("deleted", False)), ts=float(raw["ts"]),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def _row_to_op(self, row) -> dict[str, Any]:
|
|
183
|
+
return {
|
|
184
|
+
"op_id": row["op_id"], "actor": row["actor"], "seq": row["seq"],
|
|
185
|
+
"collection": row["collection"], "id": row["id"], "field": row["field"],
|
|
186
|
+
"value": json.loads(row["value_json"]), "deleted": bool(row["deleted"]), "ts": row["ts"],
|
|
187
|
+
}
|