openplan-mcp 0.8.2__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.
- openplan/__init__.py +8 -0
- openplan/__main__.py +17 -0
- openplan/cli.py +264 -0
- openplan/config.py +67 -0
- openplan/core/__init__.py +0 -0
- openplan/core/activation.py +295 -0
- openplan/core/analytics.py +156 -0
- openplan/core/bandit.py +73 -0
- openplan/core/conditions.py +49 -0
- openplan/core/costs.py +92 -0
- openplan/core/embedding.py +359 -0
- openplan/core/errors.py +102 -0
- openplan/core/estimator.py +74 -0
- openplan/core/export.py +284 -0
- openplan/core/goals.py +55 -0
- openplan/core/graph.py +551 -0
- openplan/core/graph_ops.py +61 -0
- openplan/core/ids.py +45 -0
- openplan/core/insight_propagation.py +101 -0
- openplan/core/learning.py +152 -0
- openplan/core/learnings.py +76 -0
- openplan/core/maintenance.py +71 -0
- openplan/core/planner.py +506 -0
- openplan/core/planning.py +277 -0
- openplan/core/read.py +588 -0
- openplan/core/reasoning.py +57 -0
- openplan/core/recommend.py +318 -0
- openplan/core/resolve.py +76 -0
- openplan/core/retro.py +82 -0
- openplan/core/self_tune.py +181 -0
- openplan/core/simulate.py +83 -0
- openplan/core/state.py +272 -0
- openplan/core/telemetry.py +282 -0
- openplan/core/transaction.py +54 -0
- openplan/core/tree.py +194 -0
- openplan/core/utils.py +7 -0
- openplan/db/__init__.py +0 -0
- openplan/db/connection.py +16 -0
- openplan/db/schema.py +192 -0
- openplan/handler_utils.py +213 -0
- openplan/handlers/__init__.py +15 -0
- openplan/handlers/act_handler.py +251 -0
- openplan/handlers/complete_handler.py +121 -0
- openplan/handlers/export_handler.py +19 -0
- openplan/handlers/init_handler.py +20 -0
- openplan/handlers/recommend_handler.py +150 -0
- openplan/handlers/start_handler.py +20 -0
- openplan/server.py +315 -0
- openplan/tools/__init__.py +0 -0
- openplan/tools/definitions.py +126 -0
- openplan_mcp-0.8.2.dist-info/METADATA +116 -0
- openplan_mcp-0.8.2.dist-info/RECORD +56 -0
- openplan_mcp-0.8.2.dist-info/WHEEL +5 -0
- openplan_mcp-0.8.2.dist-info/entry_points.txt +2 -0
- openplan_mcp-0.8.2.dist-info/licenses/LICENSE +21 -0
- openplan_mcp-0.8.2.dist-info/top_level.txt +1 -0
openplan/__init__.py
ADDED
openplan/__main__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
if len(sys.argv) >= 2 and sys.argv[1] in ("auth", "subscribe", "status", "--help", "-h"):
|
|
8
|
+
from openplan.cli import main as cli_main
|
|
9
|
+
cli_main()
|
|
10
|
+
else:
|
|
11
|
+
from openplan.server import main as server_main
|
|
12
|
+
import anyio
|
|
13
|
+
anyio.run(server_main)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
main()
|
openplan/cli.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "openplan"
|
|
10
|
+
CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _load_credentials() -> dict[str, Any]:
|
|
14
|
+
if CREDENTIALS_FILE.exists():
|
|
15
|
+
try:
|
|
16
|
+
return json.loads(CREDENTIALS_FILE.read_text())
|
|
17
|
+
except (json.JSONDecodeError, OSError):
|
|
18
|
+
pass
|
|
19
|
+
return {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _save_credentials(data: dict[str, Any]) -> None:
|
|
23
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
CREDENTIALS_FILE.write_text(json.dumps(data, indent=2))
|
|
25
|
+
CREDENTIALS_FILE.chmod(0o600)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_api_key() -> str | None:
|
|
29
|
+
if key := os.environ.get("OPENPLAN_API_KEY"):
|
|
30
|
+
return key
|
|
31
|
+
creds = _load_credentials()
|
|
32
|
+
return creds.get("api_key")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cmd_auth_login(args: list[str]) -> None:
|
|
36
|
+
"""Authenticate with GitHub via device code flow."""
|
|
37
|
+
api_url = os.environ.get("OPENPLAN_API_URL", "https://api.openplan.cc")
|
|
38
|
+
|
|
39
|
+
print("Open the following URL in your browser and enter the code shown.")
|
|
40
|
+
print()
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
import httpx
|
|
44
|
+
resp = httpx.post(f"{api_url}/oauth/authorize", json={
|
|
45
|
+
"client_id": "openplan-cli",
|
|
46
|
+
"scope": "user:email",
|
|
47
|
+
}, timeout=10)
|
|
48
|
+
resp.raise_for_status()
|
|
49
|
+
data = resp.json()
|
|
50
|
+
except Exception as e:
|
|
51
|
+
print(f"Failed to start authentication: {e}")
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
|
|
54
|
+
user_code = data["user_code"]
|
|
55
|
+
verification_uri = data.get("verification_uri", "https://github.com/login/device")
|
|
56
|
+
device_code = data["device_code"]
|
|
57
|
+
interval = data.get("interval", 5)
|
|
58
|
+
|
|
59
|
+
print(f" {verification_uri}")
|
|
60
|
+
print(f" Enter code: {user_code}")
|
|
61
|
+
print()
|
|
62
|
+
|
|
63
|
+
import time
|
|
64
|
+
try:
|
|
65
|
+
import httpx
|
|
66
|
+
while True:
|
|
67
|
+
time.sleep(interval)
|
|
68
|
+
resp = httpx.post(f"{api_url}/oauth/token", json={
|
|
69
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
70
|
+
"device_code": device_code,
|
|
71
|
+
"client_id": "openplan-cli",
|
|
72
|
+
}, timeout=10)
|
|
73
|
+
token_data = resp.json()
|
|
74
|
+
if resp.status_code == 200:
|
|
75
|
+
access_token = token_data["access_token"]
|
|
76
|
+
refresh_token = token_data.get("refresh_token", "")
|
|
77
|
+
|
|
78
|
+
# Exchange access token for an API key
|
|
79
|
+
key_resp = httpx.post(f"{api_url}/api/keys", headers={
|
|
80
|
+
"Authorization": f"Bearer {access_token}",
|
|
81
|
+
}, json={"tier": "pro"}, timeout=10)
|
|
82
|
+
key_resp.raise_for_status()
|
|
83
|
+
api_key = key_resp.json()["api_key"]
|
|
84
|
+
|
|
85
|
+
_save_credentials({
|
|
86
|
+
"api_key": api_key,
|
|
87
|
+
"refresh_token": refresh_token,
|
|
88
|
+
"tier": "pro",
|
|
89
|
+
})
|
|
90
|
+
print("✓ Authentication complete")
|
|
91
|
+
print("✓ Pro tier enabled")
|
|
92
|
+
print(f" Config: {CREDENTIALS_FILE}")
|
|
93
|
+
return
|
|
94
|
+
elif token_data.get("error") == "authorization_pending":
|
|
95
|
+
continue
|
|
96
|
+
elif token_data.get("error") == "slow_down":
|
|
97
|
+
interval += 5
|
|
98
|
+
continue
|
|
99
|
+
elif token_data.get("error") == "access_denied":
|
|
100
|
+
print("Authentication cancelled.")
|
|
101
|
+
sys.exit(1)
|
|
102
|
+
elif token_data.get("error") == "expired_token":
|
|
103
|
+
print("Session expired. Try again.")
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
else:
|
|
106
|
+
print(f"Unexpected error: {token_data}")
|
|
107
|
+
sys.exit(1)
|
|
108
|
+
except KeyboardInterrupt:
|
|
109
|
+
print("\nCancelled.")
|
|
110
|
+
sys.exit(1)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cmd_auth_logout(args: list[str]) -> None:
|
|
114
|
+
"""Remove stored credentials."""
|
|
115
|
+
if CREDENTIALS_FILE.exists():
|
|
116
|
+
CREDENTIALS_FILE.unlink()
|
|
117
|
+
api_url = os.environ.get("OPENPLAN_API_URL", "https://api.openplan.cc")
|
|
118
|
+
key = _get_api_key()
|
|
119
|
+
if key:
|
|
120
|
+
try:
|
|
121
|
+
import httpx
|
|
122
|
+
httpx.post(f"{api_url}/api/keys/revoke", json={"api_key": key}, timeout=10)
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
print("✓ Credentials removed")
|
|
126
|
+
else:
|
|
127
|
+
print("No credentials found")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def cmd_auth_status(args: list[str]) -> None:
|
|
131
|
+
"""Show authentication status."""
|
|
132
|
+
creds = _load_credentials()
|
|
133
|
+
env_key = os.environ.get("OPENPLAN_API_KEY")
|
|
134
|
+
|
|
135
|
+
if env_key:
|
|
136
|
+
print("Authentication: API key from OPENPLAN_API_KEY env var")
|
|
137
|
+
elif creds.get("api_key"):
|
|
138
|
+
print("Authentication: stored credentials")
|
|
139
|
+
print(f" Tier: {creds.get('tier', 'unknown')}")
|
|
140
|
+
print(f" Config: {CREDENTIALS_FILE}")
|
|
141
|
+
else:
|
|
142
|
+
print("Authentication: none (free tier)")
|
|
143
|
+
print(" Run 'openplan auth login' to authenticate with GitHub")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def cmd_subscribe(args: list[str]) -> None:
|
|
147
|
+
"""Open Stripe Checkout for Pro subscription."""
|
|
148
|
+
api_key = _get_api_key()
|
|
149
|
+
if not api_key:
|
|
150
|
+
print("You need to authenticate first.")
|
|
151
|
+
print("Run: openplan auth login")
|
|
152
|
+
sys.exit(1)
|
|
153
|
+
|
|
154
|
+
api_url = os.environ.get("OPENPLAN_API_URL", "https://api.openplan.cc")
|
|
155
|
+
plan = args[0] if args else "pro"
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
import httpx
|
|
159
|
+
resp = httpx.post(f"{api_url}/checkout", json={
|
|
160
|
+
"plan": plan,
|
|
161
|
+
"api_key": api_key,
|
|
162
|
+
}, timeout=10)
|
|
163
|
+
resp.raise_for_status()
|
|
164
|
+
checkout_url = resp.json()["checkout_url"]
|
|
165
|
+
except Exception as e:
|
|
166
|
+
print(f"Failed to create checkout session: {e}")
|
|
167
|
+
sys.exit(1)
|
|
168
|
+
|
|
169
|
+
import webbrowser
|
|
170
|
+
webbrowser.open(checkout_url)
|
|
171
|
+
print(f"Opened browser for Stripe Checkout ({plan} plan)")
|
|
172
|
+
print("Complete payment in the browser.")
|
|
173
|
+
|
|
174
|
+
import time
|
|
175
|
+
deadline = time.time() + 300 # 5 minute timeout
|
|
176
|
+
poll_count = 0
|
|
177
|
+
try:
|
|
178
|
+
while time.time() < deadline:
|
|
179
|
+
time.sleep(3)
|
|
180
|
+
poll_count += 1
|
|
181
|
+
if poll_count % 5 == 0:
|
|
182
|
+
print(f" Waiting for payment... ({int(deadline - time.time())}s remaining)")
|
|
183
|
+
try:
|
|
184
|
+
resp = httpx.get(f"{api_url}/api/subscription/status", headers={
|
|
185
|
+
"Authorization": f"Bearer {api_key}",
|
|
186
|
+
}, timeout=10)
|
|
187
|
+
data = resp.json()
|
|
188
|
+
if data.get("status") == "active":
|
|
189
|
+
print("✓ Subscription active")
|
|
190
|
+
print(f" Tier: {data.get('tier', 'pro')}")
|
|
191
|
+
return
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
print("Timed out waiting for subscription. Complete payment at the Stripe page.")
|
|
195
|
+
except KeyboardInterrupt:
|
|
196
|
+
print("\nCancelled.")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def cmd_status(args: list[str]) -> None:
|
|
200
|
+
"""Show OpenPlan status and sync info."""
|
|
201
|
+
api_key = _get_api_key()
|
|
202
|
+
api_url = os.environ.get("OPENPLAN_API_URL", "https://api.openplan.cc")
|
|
203
|
+
|
|
204
|
+
creds = _load_credentials()
|
|
205
|
+
tier = creds.get("tier", "free") if not os.environ.get("OPENPLAN_API_KEY") else "pro (env)"
|
|
206
|
+
|
|
207
|
+
from openplan import VERSION
|
|
208
|
+
print(f"OpenPlan v{VERSION}")
|
|
209
|
+
print(f"Tier: {tier}")
|
|
210
|
+
print()
|
|
211
|
+
|
|
212
|
+
if api_key:
|
|
213
|
+
try:
|
|
214
|
+
import httpx
|
|
215
|
+
resp = httpx.get(f"{api_url}/api/keys/usage", headers={
|
|
216
|
+
"Authorization": f"Bearer {api_key}",
|
|
217
|
+
}, timeout=10)
|
|
218
|
+
data = resp.json()
|
|
219
|
+
print(f"Events synced: {data.get('event_count', '?')}")
|
|
220
|
+
print(f"Sync rate: {data.get('rate_limit', '?')}/min")
|
|
221
|
+
except Exception:
|
|
222
|
+
print("API: unreachable")
|
|
223
|
+
else:
|
|
224
|
+
print("Free tier (anonymous)")
|
|
225
|
+
print("Run 'openplan auth login' to enable Pro features")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def main() -> None:
|
|
229
|
+
import sys
|
|
230
|
+
if len(sys.argv) < 2 or sys.argv[1] in ("--help", "-h"):
|
|
231
|
+
print("OpenPlan — Waze for AI agents planning")
|
|
232
|
+
print()
|
|
233
|
+
print("Usage:")
|
|
234
|
+
print(" openplan Start MCP server")
|
|
235
|
+
print(" openplan auth login Authenticate with GitHub")
|
|
236
|
+
print(" openplan auth logout Remove credentials")
|
|
237
|
+
print(" openplan auth status Show authentication state")
|
|
238
|
+
print(" openplan subscribe [plan] Start Pro subscription")
|
|
239
|
+
print(" openplan status Show OpenPlan status")
|
|
240
|
+
print()
|
|
241
|
+
print("Docs: https://github.com/anomalyco/opencode")
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
cmd = sys.argv[1]
|
|
245
|
+
if cmd == "auth":
|
|
246
|
+
if len(sys.argv) < 3:
|
|
247
|
+
print("Usage: openplan auth <login|logout|status>")
|
|
248
|
+
return
|
|
249
|
+
sub = sys.argv[2]
|
|
250
|
+
if sub == "login":
|
|
251
|
+
cmd_auth_login(sys.argv[3:])
|
|
252
|
+
elif sub == "logout":
|
|
253
|
+
cmd_auth_logout(sys.argv[3:])
|
|
254
|
+
elif sub == "status":
|
|
255
|
+
cmd_auth_status(sys.argv[3:])
|
|
256
|
+
else:
|
|
257
|
+
print(f"Unknown auth command: {sub}")
|
|
258
|
+
elif cmd == "subscribe":
|
|
259
|
+
cmd_subscribe(sys.argv[2:])
|
|
260
|
+
elif cmd == "status":
|
|
261
|
+
cmd_status(sys.argv[2:])
|
|
262
|
+
else:
|
|
263
|
+
print(f"Unknown command: {cmd}")
|
|
264
|
+
sys.exit(1)
|
openplan/config.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
CONFIG_SCHEMA: dict[str, dict[str, Any]] = {
|
|
9
|
+
"db_path": {"type": str, "default": "openplan.db"},
|
|
10
|
+
"stale_days": {"type": int, "default": 2, "min": 1, "max": 365},
|
|
11
|
+
"wip_limit": {"type": int, "default": 20, "min": 1, "max": 1000},
|
|
12
|
+
"activation_threshold": {"type": float, "default": 0.5, "min": 0.0, "max": 1.0},
|
|
13
|
+
"plan_limit": {"type": int, "default": 10, "min": 1, "max": 100},
|
|
14
|
+
"expansion_limit": {"type": int, "default": 5, "min": 1, "max": 50},
|
|
15
|
+
"avg_edge_cost": {"type": float, "default": 5000.0, "min": 100, "max": 1000000},
|
|
16
|
+
"heuristic_scale": {"type": float, "default": 1.0, "min": 0.1, "max": 10.0},
|
|
17
|
+
"reverse_penalty": {"type": float, "default": 3.0, "min": 1.0, "max": 100.0},
|
|
18
|
+
"risk_adjustment": {"type": str, "default": "probability", "values": ["none", "probability", "variance"]},
|
|
19
|
+
"tune_interval": {"type": int, "default": 10, "min": 0, "max": 1000},
|
|
20
|
+
"activation_weights": {"type": dict, "default": {"in_degree": 0.33, "frontier": 0.24, "recency": 0.19, "boost": 0.09, "visit": 0.1, "novelty": 0.05}},
|
|
21
|
+
"learning": {"type": dict, "default": {}},
|
|
22
|
+
"embedding": {"type": dict, "default": {}},
|
|
23
|
+
"page_rank": {"type": dict, "default": {}},
|
|
24
|
+
"stale_branch_hours": {"type": int, "default": 24, "min": 1, "max": 720},
|
|
25
|
+
"recommend_weights": {"type": dict, "default": {}},
|
|
26
|
+
"maintenance_interval_minutes": {"type": int, "default": 5, "min": 1, "max": 1440},
|
|
27
|
+
"adaptive_weights": {"type": dict, "default": {}},
|
|
28
|
+
"insight_similarity_threshold": {"type": float, "default": 0.7, "min": 0.0, "max": 1.0},
|
|
29
|
+
"api_url": {"type": str, "default": ""},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _validate_config(raw: dict[str, Any]) -> dict[str, Any]:
|
|
34
|
+
validated: dict[str, Any] = {}
|
|
35
|
+
for key, value in raw.items():
|
|
36
|
+
spec = CONFIG_SCHEMA.get(key)
|
|
37
|
+
if not spec:
|
|
38
|
+
raise ValueError(f"Unknown config key: '{key}'")
|
|
39
|
+
expected_type = spec["type"]
|
|
40
|
+
if not isinstance(value, expected_type):
|
|
41
|
+
raise TypeError(f"Config '{key}' must be {expected_type.__name__}, got {type(value).__name__}")
|
|
42
|
+
if expected_type in (int, float) and "min" in spec and value < spec["min"]:
|
|
43
|
+
raise ValueError(f"Config '{key}' must be >= {spec['min']}, got {value}")
|
|
44
|
+
if expected_type in (int, float) and "max" in spec and value > spec["max"]:
|
|
45
|
+
raise ValueError(f"Config '{key}' must be <= {spec['max']}, got {value}")
|
|
46
|
+
if "values" in spec and value not in spec["values"]:
|
|
47
|
+
raise ValueError(f"Config '{key}' must be one of {spec['values']}, got '{value}'")
|
|
48
|
+
validated[key] = value
|
|
49
|
+
return validated
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_config(config_path: str | None = None) -> dict[str, Any]:
|
|
53
|
+
cfg: dict[str, Any] = {}
|
|
54
|
+
for key, spec in CONFIG_SCHEMA.items():
|
|
55
|
+
cfg[key] = copy.deepcopy(spec["default"])
|
|
56
|
+
cfg["db_path"] = os.environ.get("OPENPLAN_DB_PATH", cfg.get("db_path", "openplan.db"))
|
|
57
|
+
if config_path and os.path.exists(config_path):
|
|
58
|
+
with open(config_path) as f:
|
|
59
|
+
raw = json.load(f)
|
|
60
|
+
validated = _validate_config(raw)
|
|
61
|
+
for key in list(cfg.keys()):
|
|
62
|
+
if key in validated and isinstance(validated[key], dict) and isinstance(cfg[key], dict):
|
|
63
|
+
merged = dict(cfg[key])
|
|
64
|
+
merged.update(validated[key])
|
|
65
|
+
validated[key] = merged
|
|
66
|
+
cfg.update(validated)
|
|
67
|
+
return cfg
|
|
File without changes
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import threading
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ActivationContext:
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self.cache: dict[str, float] = {}
|
|
13
|
+
self.dirty: set[str] = set()
|
|
14
|
+
self.cache_order: list[str] = []
|
|
15
|
+
self.visiting: set[str] = set()
|
|
16
|
+
self.max_in_degree: int = 1
|
|
17
|
+
self.validation_counter: int = 0
|
|
18
|
+
self.VALIDATE_EVERY: int = 100
|
|
19
|
+
self.max_in_degree_initialized: bool = False
|
|
20
|
+
self.lock = threading.RLock()
|
|
21
|
+
|
|
22
|
+
def increment_max_in_degree(self, target_id: str, conn: sqlite3.Connection) -> None:
|
|
23
|
+
with self.lock:
|
|
24
|
+
cnt = conn.execute(
|
|
25
|
+
"SELECT COUNT(*) AS cnt FROM edges WHERE target_id = ?", (target_id,)
|
|
26
|
+
).fetchone()["cnt"]
|
|
27
|
+
if cnt > self.max_in_degree:
|
|
28
|
+
self.max_in_degree = cnt
|
|
29
|
+
self.validation_counter += 1
|
|
30
|
+
if self.validation_counter >= self.VALIDATE_EVERY:
|
|
31
|
+
self._validate_max_in_degree(conn)
|
|
32
|
+
|
|
33
|
+
def _validate_max_in_degree(self, conn: sqlite3.Connection) -> None:
|
|
34
|
+
row = conn.execute(
|
|
35
|
+
"SELECT COUNT(*) AS cnt FROM edges GROUP BY target_id ORDER BY cnt DESC LIMIT 1"
|
|
36
|
+
).fetchone()
|
|
37
|
+
self.max_in_degree = row["cnt"] if row else 1
|
|
38
|
+
self.validation_counter = 0
|
|
39
|
+
|
|
40
|
+
def _get_max_in_degree(self, conn: sqlite3.Connection) -> int:
|
|
41
|
+
if not self.max_in_degree_initialized:
|
|
42
|
+
self._validate_max_in_degree(conn)
|
|
43
|
+
self.max_in_degree_initialized = True
|
|
44
|
+
return self.max_in_degree
|
|
45
|
+
|
|
46
|
+
def _get_outgoing_count(self, state_id: str, conn: sqlite3.Connection) -> int:
|
|
47
|
+
row = conn.execute(
|
|
48
|
+
"SELECT COUNT(*) AS cnt FROM edges WHERE source_id = ?", (state_id,)
|
|
49
|
+
).fetchone()
|
|
50
|
+
return row["cnt"]
|
|
51
|
+
|
|
52
|
+
def _recompute_cache_order(self, conn: sqlite3.Connection) -> None:
|
|
53
|
+
self.cache_order = sorted(
|
|
54
|
+
self.dirty,
|
|
55
|
+
key=lambda s: (
|
|
56
|
+
self._get_outgoing_count(s, conn),
|
|
57
|
+
-conn.execute(
|
|
58
|
+
"SELECT COUNT(*) AS cnt FROM edges WHERE target_id = ?", (s,)
|
|
59
|
+
).fetchone()["cnt"],
|
|
60
|
+
s,
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def mark_dirty(self, state_id: str, conn: sqlite3.Connection) -> None:
|
|
65
|
+
with self.lock:
|
|
66
|
+
self.dirty.add(state_id)
|
|
67
|
+
preds = conn.execute(
|
|
68
|
+
"SELECT DISTINCT source_id FROM edges WHERE target_id = ?", (state_id,)
|
|
69
|
+
).fetchall()
|
|
70
|
+
for p in preds:
|
|
71
|
+
self.dirty.add(p["source_id"])
|
|
72
|
+
self._recompute_cache_order(conn)
|
|
73
|
+
|
|
74
|
+
def _compute_frontier_ratio(self, state_id: str, conn: sqlite3.Connection, config: dict[str, Any]) -> float:
|
|
75
|
+
threshold = config.get("activation_threshold", 0.5)
|
|
76
|
+
edges = conn.execute(
|
|
77
|
+
"SELECT target_id FROM edges WHERE source_id = ?", (state_id,)
|
|
78
|
+
).fetchall()
|
|
79
|
+
if not edges:
|
|
80
|
+
return 0.0
|
|
81
|
+
active = 0
|
|
82
|
+
for e in edges:
|
|
83
|
+
target_act = self.cache.get(e["target_id"], 0.0)
|
|
84
|
+
if target_act > threshold:
|
|
85
|
+
active += 1
|
|
86
|
+
return active / len(edges)
|
|
87
|
+
|
|
88
|
+
def _compute_in_degree_ratio(self, state_id: str, conn: sqlite3.Connection) -> float:
|
|
89
|
+
row = conn.execute(
|
|
90
|
+
"SELECT COUNT(*) AS cnt FROM edges WHERE target_id = ?", (state_id,)
|
|
91
|
+
).fetchone()
|
|
92
|
+
cnt = row["cnt"]
|
|
93
|
+
max_in = self._get_max_in_degree(conn)
|
|
94
|
+
return min(cnt / max_in, 1.0) if max_in > 0 else 0.0
|
|
95
|
+
|
|
96
|
+
def _compute_recency(self, state_id: str, conn: sqlite3.Connection, stale_days: int) -> float:
|
|
97
|
+
row = conn.execute(
|
|
98
|
+
"SELECT updated_at FROM nodes WHERE id = ?", (state_id,)
|
|
99
|
+
).fetchone()
|
|
100
|
+
if not row:
|
|
101
|
+
return 0.0
|
|
102
|
+
updated = row["updated_at"]
|
|
103
|
+
try:
|
|
104
|
+
updated_dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
|
|
105
|
+
except (ValueError, TypeError):
|
|
106
|
+
return 0.0
|
|
107
|
+
now = datetime.now(timezone.utc)
|
|
108
|
+
if updated_dt.tzinfo is None:
|
|
109
|
+
updated_dt = updated_dt.replace(tzinfo=timezone.utc)
|
|
110
|
+
delta = now - updated_dt
|
|
111
|
+
days = delta.total_seconds() / 86400.0
|
|
112
|
+
return 1.0 - min(days / stale_days, 1.0)
|
|
113
|
+
|
|
114
|
+
def _compute_agent_boost(self, state_id: str, conn: sqlite3.Connection) -> float:
|
|
115
|
+
row = conn.execute(
|
|
116
|
+
"SELECT props FROM nodes WHERE id = ?", (state_id,)
|
|
117
|
+
).fetchone()
|
|
118
|
+
if not row:
|
|
119
|
+
return 0.5
|
|
120
|
+
try:
|
|
121
|
+
props = json.loads(row["props"])
|
|
122
|
+
except (json.JSONDecodeError, TypeError):
|
|
123
|
+
return 0.5
|
|
124
|
+
if props.get("boost") is True:
|
|
125
|
+
boosted_at = props.get("boosted_at")
|
|
126
|
+
if boosted_at:
|
|
127
|
+
try:
|
|
128
|
+
bt = datetime.fromisoformat(boosted_at)
|
|
129
|
+
now = datetime.now(timezone.utc)
|
|
130
|
+
if bt.tzinfo is None:
|
|
131
|
+
bt = bt.replace(tzinfo=timezone.utc)
|
|
132
|
+
hours = (now - bt).total_seconds() / 3600.0
|
|
133
|
+
if hours > 24:
|
|
134
|
+
return 0.5
|
|
135
|
+
except (ValueError, TypeError):
|
|
136
|
+
pass
|
|
137
|
+
return 1.0
|
|
138
|
+
return 0.5
|
|
139
|
+
|
|
140
|
+
def _compute_visit_ratio(self, state_id: str, conn: sqlite3.Connection) -> float:
|
|
141
|
+
row = conn.execute("SELECT props FROM nodes WHERE id = ?", (state_id,)).fetchone()
|
|
142
|
+
if not row:
|
|
143
|
+
return 0.0
|
|
144
|
+
try:
|
|
145
|
+
props = json.loads(row["props"])
|
|
146
|
+
except (json.JSONDecodeError, TypeError):
|
|
147
|
+
return 0.0
|
|
148
|
+
my_visits = props.get("visit_count", 0)
|
|
149
|
+
if my_visits == 0:
|
|
150
|
+
return 0.0
|
|
151
|
+
project_row = conn.execute("SELECT project FROM nodes WHERE id = ?", (state_id,)).fetchone()
|
|
152
|
+
if not project_row:
|
|
153
|
+
return 0.0
|
|
154
|
+
max_row = conn.execute(
|
|
155
|
+
"SELECT MAX(json_extract(props, '$.visit_count')) AS max_visits FROM nodes WHERE project = ?",
|
|
156
|
+
(project_row["project"],),
|
|
157
|
+
).fetchone()
|
|
158
|
+
max_visits = max_row["max_visits"] if max_row and max_row["max_visits"] else 0
|
|
159
|
+
return min(my_visits / max_visits, 1.0) if max_visits > 0 else 0.0
|
|
160
|
+
|
|
161
|
+
def _compute_novelty_ratio(self, state_id: str, conn: sqlite3.Connection) -> float:
|
|
162
|
+
out_count = conn.execute(
|
|
163
|
+
"SELECT COUNT(*) AS cnt FROM edges WHERE source_id = ?", (state_id,)
|
|
164
|
+
).fetchone()["cnt"]
|
|
165
|
+
return 1.0 / (out_count + 1)
|
|
166
|
+
|
|
167
|
+
def _compute_activation(self, state_id: str, conn: sqlite3.Connection, config: dict[str, Any]) -> float:
|
|
168
|
+
import hashlib
|
|
169
|
+
weights = dict(config.get("activation_weights", {}))
|
|
170
|
+
w_row = conn.execute(
|
|
171
|
+
"SELECT value FROM meta WHERE key = 'self_tuning:weight_config'"
|
|
172
|
+
).fetchone()
|
|
173
|
+
if w_row:
|
|
174
|
+
try:
|
|
175
|
+
w_override = json.loads(w_row["value"])
|
|
176
|
+
if isinstance(w_override, dict):
|
|
177
|
+
weights = w_override
|
|
178
|
+
except (json.JSONDecodeError, TypeError):
|
|
179
|
+
pass
|
|
180
|
+
w_in = weights.get("in_degree", 0.35)
|
|
181
|
+
w_frontier = weights.get("frontier", 0.25)
|
|
182
|
+
w_recency = weights.get("recency", 0.2)
|
|
183
|
+
w_boost = weights.get("boost", 0.1)
|
|
184
|
+
w_visit = weights.get("visit", 0.0)
|
|
185
|
+
w_novelty = weights.get("novelty", 0.0)
|
|
186
|
+
in_degree_ratio = self._compute_in_degree_ratio(state_id, conn)
|
|
187
|
+
frontier_ratio = self._compute_frontier_ratio(state_id, conn, config)
|
|
188
|
+
stale_days = config.get("stale_days", 2)
|
|
189
|
+
recency = self._compute_recency(state_id, conn, stale_days)
|
|
190
|
+
agent_boost = self._compute_agent_boost(state_id, conn)
|
|
191
|
+
visit_ratio = self._compute_visit_ratio(state_id, conn)
|
|
192
|
+
novelty = self._compute_novelty_ratio(state_id, conn)
|
|
193
|
+
raw = w_in * in_degree_ratio + w_frontier * frontier_ratio + w_recency * recency + w_boost * agent_boost + w_visit * visit_ratio + w_novelty * novelty
|
|
194
|
+
digest = hashlib.sha256(state_id.encode()).digest()[:4]
|
|
195
|
+
tiebreaker = int.from_bytes(digest, "big") / 1e16
|
|
196
|
+
return raw + tiebreaker
|
|
197
|
+
|
|
198
|
+
def _precompute_targets(self, state_id: str, conn: sqlite3.Connection, config: dict[str, Any]) -> None:
|
|
199
|
+
stack = [(state_id, 0)]
|
|
200
|
+
order: list[str] = []
|
|
201
|
+
while stack:
|
|
202
|
+
sid, idx = stack[-1]
|
|
203
|
+
if idx == 0:
|
|
204
|
+
if sid in self.visiting:
|
|
205
|
+
stack.pop()
|
|
206
|
+
continue
|
|
207
|
+
self.visiting.add(sid)
|
|
208
|
+
order.append(sid)
|
|
209
|
+
targets = conn.execute(
|
|
210
|
+
"SELECT target_id FROM edges WHERE source_id = ?", (sid,)
|
|
211
|
+
).fetchall()
|
|
212
|
+
if idx < len(targets):
|
|
213
|
+
tid = targets[idx]["target_id"]
|
|
214
|
+
stack[-1] = (sid, idx + 1)
|
|
215
|
+
if tid not in self.cache and tid not in self.visiting:
|
|
216
|
+
stack.append((tid, 0))
|
|
217
|
+
else:
|
|
218
|
+
stack.pop()
|
|
219
|
+
self.visiting.discard(sid)
|
|
220
|
+
for sid in reversed(order):
|
|
221
|
+
if sid not in self.cache:
|
|
222
|
+
act = self._compute_activation(sid, conn, config)
|
|
223
|
+
self.cache[sid] = act
|
|
224
|
+
threshold = config.get("activation_threshold", 0.5)
|
|
225
|
+
frontier = 1 if self._is_frontier(sid, act, conn, threshold) else 0
|
|
226
|
+
conn.execute(
|
|
227
|
+
"UPDATE nodes SET activation = ?, frontier = ? WHERE id = ?",
|
|
228
|
+
(act, frontier, sid),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def _is_frontier(self, state_id: str, activation: float, conn: sqlite3.Connection, threshold: float) -> bool:
|
|
232
|
+
if activation <= threshold:
|
|
233
|
+
return False
|
|
234
|
+
cnt = conn.execute(
|
|
235
|
+
"SELECT COUNT(*) AS cnt FROM edges WHERE source_id = ?", (state_id,)
|
|
236
|
+
).fetchone()["cnt"]
|
|
237
|
+
return cnt > 0
|
|
238
|
+
|
|
239
|
+
def recompute_all_dirty(self, conn: sqlite3.Connection, config: dict[str, Any]) -> None:
|
|
240
|
+
with self.lock:
|
|
241
|
+
self._recompute_all_dirty_locked(conn, config)
|
|
242
|
+
|
|
243
|
+
def _recompute_all_dirty_locked(self, conn: sqlite3.Connection, config: dict[str, Any]) -> None:
|
|
244
|
+
threshold = config.get("activation_threshold", 0.5)
|
|
245
|
+
for sid in self.cache_order:
|
|
246
|
+
if sid in self.dirty:
|
|
247
|
+
act = self._compute_activation(sid, conn, config)
|
|
248
|
+
self.cache[sid] = act
|
|
249
|
+
self.dirty.discard(sid)
|
|
250
|
+
frontier = 1 if self._is_frontier(sid, act, conn, threshold) else 0
|
|
251
|
+
conn.execute(
|
|
252
|
+
"UPDATE nodes SET activation = ?, frontier = ? WHERE id = ?",
|
|
253
|
+
(act, frontier, sid),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def get_activation(self, state_id: str, conn: sqlite3.Connection, config: dict[str, Any]) -> float:
|
|
257
|
+
with self.lock:
|
|
258
|
+
if self.dirty:
|
|
259
|
+
self._recompute_all_dirty_locked(conn, config)
|
|
260
|
+
if state_id not in self.cache:
|
|
261
|
+
self._precompute_targets(state_id, conn, config)
|
|
262
|
+
return self.cache[state_id]
|
|
263
|
+
|
|
264
|
+
def reset(self) -> None:
|
|
265
|
+
with self.lock:
|
|
266
|
+
self.cache.clear()
|
|
267
|
+
self.dirty.clear()
|
|
268
|
+
self.cache_order.clear()
|
|
269
|
+
self.visiting.clear()
|
|
270
|
+
self.max_in_degree = 1
|
|
271
|
+
self.validation_counter = 0
|
|
272
|
+
self.max_in_degree_initialized = False
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
_default_ctx = ActivationContext()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def get_activation(state_id: str, conn: sqlite3.Connection, config: dict[str, Any], ctx: ActivationContext | None = None) -> float:
|
|
279
|
+
return (ctx or _default_ctx).get_activation(state_id, conn, config)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def mark_dirty(state_id: str, conn: sqlite3.Connection, ctx: ActivationContext | None = None) -> None:
|
|
283
|
+
(ctx or _default_ctx).mark_dirty(state_id, conn)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def recompute_all_dirty(conn: sqlite3.Connection, config: dict[str, Any], ctx: ActivationContext | None = None) -> None:
|
|
287
|
+
(ctx or _default_ctx).recompute_all_dirty(conn, config)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def increment_max_in_degree(target_id: str, conn: sqlite3.Connection, ctx: ActivationContext | None = None) -> None:
|
|
291
|
+
(ctx or _default_ctx).increment_max_in_degree(target_id, conn)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def reset_cache(ctx: ActivationContext | None = None) -> None:
|
|
295
|
+
(ctx or _default_ctx).reset()
|