delimit-cli 4.1.44 → 4.1.47
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.
- package/CHANGELOG.md +6 -0
- package/bin/delimit-cli.js +365 -30
- package/bin/delimit-setup.js +100 -64
- package/gateway/ai/activate_helpers.py +253 -7
- package/gateway/ai/backends/gateway_core.py +236 -13
- package/gateway/ai/backends/repo_bridge.py +80 -16
- package/gateway/ai/backends/tools_infra.py +49 -32
- package/gateway/ai/checksums.sha256 +6 -0
- package/gateway/ai/continuity.py +462 -0
- package/gateway/ai/deliberation.pyi +53 -0
- package/gateway/ai/governance.pyi +32 -0
- package/gateway/ai/governance_hardening.py +569 -0
- package/gateway/ai/inbox_daemon_runner.py +217 -0
- package/gateway/ai/ledger_manager.py +40 -0
- package/gateway/ai/license.py +104 -3
- package/gateway/ai/license_core.py +177 -36
- package/gateway/ai/license_core.pyi +50 -0
- package/gateway/ai/loop_engine.py +786 -22
- package/gateway/ai/reddit_scanner.py +150 -5
- package/gateway/ai/server.py +254 -19
- package/gateway/ai/swarm.py +86 -0
- package/gateway/ai/swarm_infra.py +656 -0
- package/gateway/ai/tweet_corpus_schema.sql +76 -0
- package/gateway/core/diff_engine_v2.py +6 -2
- package/gateway/core/generator_drift.py +242 -0
- package/gateway/core/json_schema_diff.py +375 -0
- package/gateway/core/openapi_version.py +124 -0
- package/gateway/core/spec_detector.py +47 -7
- package/gateway/core/spec_health.py +5 -2
- package/lib/cross-model-hooks.js +4 -12
- package/package.json +8 -1
- package/scripts/sync-gateway.sh +13 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Standalone runner for the Delimit inbox polling daemon.
|
|
4
|
+
|
|
5
|
+
Designed for use with systemd or manual invocation. Adds:
|
|
6
|
+
- Structured logging with timestamps
|
|
7
|
+
- Graceful SIGTERM handling for clean systemd stop
|
|
8
|
+
- PID file to prevent duplicate instances
|
|
9
|
+
- Startup validation of required configuration
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
# Via systemd (see deploy/inbox-daemon.service)
|
|
13
|
+
systemctl start delimit-inbox-daemon
|
|
14
|
+
|
|
15
|
+
# Manual foreground run
|
|
16
|
+
python3 ai/inbox_daemon_runner.py
|
|
17
|
+
|
|
18
|
+
# Single poll cycle (for testing)
|
|
19
|
+
python3 ai/inbox_daemon_runner.py --once
|
|
20
|
+
|
|
21
|
+
Environment variables:
|
|
22
|
+
DELIMIT_SMTP_PASS Required. IMAP/SMTP password.
|
|
23
|
+
DELIMIT_INBOX_POLL_INTERVAL Poll interval in seconds (default: 300).
|
|
24
|
+
DELIMIT_HOME Delimit config directory (default: ~/.delimit).
|
|
25
|
+
PYTHONPATH Must include the gateway root for ai.* imports.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import signal
|
|
31
|
+
import sys
|
|
32
|
+
import time
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
# Ensure the gateway root is on sys.path so ai.* imports work
|
|
37
|
+
_gateway_root = Path(__file__).resolve().parent.parent
|
|
38
|
+
if str(_gateway_root) not in sys.path:
|
|
39
|
+
sys.path.insert(0, str(_gateway_root))
|
|
40
|
+
|
|
41
|
+
# PID file to prevent duplicate instances
|
|
42
|
+
PID_DIR = Path(os.environ.get("DELIMIT_HOME", Path.home() / ".delimit"))
|
|
43
|
+
PID_FILE = PID_DIR / "inbox-daemon.pid"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _setup_logging() -> logging.Logger:
|
|
47
|
+
"""Configure structured logging for journald and console."""
|
|
48
|
+
log_format = "%(asctime)s [%(name)s] %(levelname)s: %(message)s"
|
|
49
|
+
logging.basicConfig(
|
|
50
|
+
level=logging.INFO,
|
|
51
|
+
format=log_format,
|
|
52
|
+
stream=sys.stdout,
|
|
53
|
+
)
|
|
54
|
+
# Suppress noisy libraries
|
|
55
|
+
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
|
56
|
+
logging.getLogger("imaplib").setLevel(logging.WARNING)
|
|
57
|
+
return logging.getLogger("delimit.inbox_daemon_runner")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _write_pid() -> None:
|
|
61
|
+
"""Write PID file. Check for stale processes first."""
|
|
62
|
+
PID_DIR.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
|
|
64
|
+
if PID_FILE.exists():
|
|
65
|
+
try:
|
|
66
|
+
old_pid = int(PID_FILE.read_text().strip())
|
|
67
|
+
# Check if the old process is still running
|
|
68
|
+
os.kill(old_pid, 0)
|
|
69
|
+
# Process exists -- abort to prevent duplicates
|
|
70
|
+
print(
|
|
71
|
+
f"ERROR: Another inbox daemon is running (PID {old_pid}). "
|
|
72
|
+
f"Remove {PID_FILE} if stale.",
|
|
73
|
+
file=sys.stderr,
|
|
74
|
+
)
|
|
75
|
+
sys.exit(1)
|
|
76
|
+
except (ValueError, ProcessLookupError, PermissionError):
|
|
77
|
+
# Stale PID file -- safe to overwrite
|
|
78
|
+
pass
|
|
79
|
+
except OSError:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
PID_FILE.write_text(str(os.getpid()))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _remove_pid() -> None:
|
|
86
|
+
"""Remove PID file on clean shutdown."""
|
|
87
|
+
try:
|
|
88
|
+
if PID_FILE.exists():
|
|
89
|
+
current_pid = PID_FILE.read_text().strip()
|
|
90
|
+
if current_pid == str(os.getpid()):
|
|
91
|
+
PID_FILE.unlink()
|
|
92
|
+
except OSError:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _validate_config(logger: logging.Logger) -> bool:
|
|
97
|
+
"""Validate required configuration before starting the daemon."""
|
|
98
|
+
ok = True
|
|
99
|
+
|
|
100
|
+
if not os.environ.get("DELIMIT_SMTP_PASS"):
|
|
101
|
+
# Check if the notify module can load credentials from config
|
|
102
|
+
try:
|
|
103
|
+
from ai.notify import _load_smtp_account, IMAP_USER
|
|
104
|
+
if IMAP_USER:
|
|
105
|
+
account = _load_smtp_account(IMAP_USER)
|
|
106
|
+
if account and (account.get("pass") or account.get("password")):
|
|
107
|
+
logger.info("SMTP credentials loaded from config for %s", IMAP_USER)
|
|
108
|
+
else:
|
|
109
|
+
logger.error(
|
|
110
|
+
"DELIMIT_SMTP_PASS not set and no credentials found in config for %s",
|
|
111
|
+
IMAP_USER,
|
|
112
|
+
)
|
|
113
|
+
ok = False
|
|
114
|
+
else:
|
|
115
|
+
logger.error("DELIMIT_SMTP_PASS not set and IMAP_USER not configured")
|
|
116
|
+
ok = False
|
|
117
|
+
except ImportError:
|
|
118
|
+
logger.error("DELIMIT_SMTP_PASS not set and ai.notify module not importable")
|
|
119
|
+
ok = False
|
|
120
|
+
else:
|
|
121
|
+
logger.info("SMTP credentials provided via environment")
|
|
122
|
+
|
|
123
|
+
return ok
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def main() -> None:
|
|
127
|
+
import argparse
|
|
128
|
+
|
|
129
|
+
parser = argparse.ArgumentParser(
|
|
130
|
+
description="Delimit inbox daemon runner -- persistent email governance polling",
|
|
131
|
+
)
|
|
132
|
+
parser.add_argument(
|
|
133
|
+
"--once",
|
|
134
|
+
action="store_true",
|
|
135
|
+
help="Run a single poll cycle and exit",
|
|
136
|
+
)
|
|
137
|
+
parser.add_argument(
|
|
138
|
+
"--interval",
|
|
139
|
+
type=int,
|
|
140
|
+
default=None,
|
|
141
|
+
help="Override poll interval in seconds",
|
|
142
|
+
)
|
|
143
|
+
args = parser.parse_args()
|
|
144
|
+
|
|
145
|
+
logger = _setup_logging()
|
|
146
|
+
logger.info(
|
|
147
|
+
"Delimit inbox daemon runner starting (PID %d, Python %s)",
|
|
148
|
+
os.getpid(),
|
|
149
|
+
sys.version.split()[0],
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Validate config before doing anything else
|
|
153
|
+
if not _validate_config(logger):
|
|
154
|
+
logger.error("Configuration validation failed. Exiting.")
|
|
155
|
+
sys.exit(1)
|
|
156
|
+
|
|
157
|
+
# Import the daemon module (after PYTHONPATH is set up)
|
|
158
|
+
from ai.inbox_daemon import (
|
|
159
|
+
_daemon_state,
|
|
160
|
+
_daemon_loop,
|
|
161
|
+
poll_once,
|
|
162
|
+
POLL_INTERVAL,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Override poll interval if requested
|
|
166
|
+
if args.interval is not None:
|
|
167
|
+
import ai.inbox_daemon
|
|
168
|
+
ai.inbox_daemon.POLL_INTERVAL = args.interval
|
|
169
|
+
logger.info("Poll interval overridden to %d seconds", args.interval)
|
|
170
|
+
|
|
171
|
+
# Single-shot mode
|
|
172
|
+
if args.once:
|
|
173
|
+
logger.info("Running single poll cycle (--once mode)")
|
|
174
|
+
result = poll_once()
|
|
175
|
+
if "error" in result:
|
|
176
|
+
logger.error("Poll failed: %s", result["error"])
|
|
177
|
+
sys.exit(1)
|
|
178
|
+
logger.info(
|
|
179
|
+
"Poll complete: %d processed, %d forwarded",
|
|
180
|
+
result.get("processed", 0),
|
|
181
|
+
result.get("forwarded", 0),
|
|
182
|
+
)
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
# Write PID file (only for long-running mode)
|
|
186
|
+
_write_pid()
|
|
187
|
+
|
|
188
|
+
# Graceful shutdown handler
|
|
189
|
+
def _handle_signal(signum, frame):
|
|
190
|
+
sig_name = signal.Signals(signum).name
|
|
191
|
+
logger.info("Received %s -- initiating graceful shutdown", sig_name)
|
|
192
|
+
_daemon_state._stop_event.set()
|
|
193
|
+
|
|
194
|
+
signal.signal(signal.SIGTERM, _handle_signal)
|
|
195
|
+
signal.signal(signal.SIGINT, _handle_signal)
|
|
196
|
+
|
|
197
|
+
# Start the daemon loop (blocks until stop event)
|
|
198
|
+
logger.info(
|
|
199
|
+
"Inbox daemon entering main loop (poll interval: %ds)",
|
|
200
|
+
ai.inbox_daemon.POLL_INTERVAL,
|
|
201
|
+
)
|
|
202
|
+
_daemon_state.running = True
|
|
203
|
+
_daemon_state._stop_event.clear()
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
_daemon_loop()
|
|
207
|
+
except Exception as e:
|
|
208
|
+
logger.critical("Daemon loop crashed: %s", e, exc_info=True)
|
|
209
|
+
sys.exit(1)
|
|
210
|
+
finally:
|
|
211
|
+
_daemon_state.running = False
|
|
212
|
+
_remove_pid()
|
|
213
|
+
logger.info("Inbox daemon runner exiting cleanly")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
main()
|
|
@@ -94,6 +94,40 @@ def _register_venture(info: Dict[str, str]):
|
|
|
94
94
|
CENTRAL_LEDGER_DIR = Path.home() / ".delimit" / "ledger"
|
|
95
95
|
|
|
96
96
|
|
|
97
|
+
def _detect_model() -> str:
|
|
98
|
+
"""Auto-detect which AI model is running this session.
|
|
99
|
+
|
|
100
|
+
Checks environment variables set by various AI coding assistants:
|
|
101
|
+
- CLAUDE_MODEL / CLAUDE_CODE_MODEL: Claude Code
|
|
102
|
+
- CODEX_MODEL: OpenAI Codex CLI
|
|
103
|
+
- GEMINI_MODEL: Gemini CLI
|
|
104
|
+
- MCP_CLIENT_NAME: Generic MCP client identifier
|
|
105
|
+
Falls back to "unknown" if none are set.
|
|
106
|
+
"""
|
|
107
|
+
# Claude Code
|
|
108
|
+
for var in ("CLAUDE_MODEL", "CLAUDE_CODE_MODEL"):
|
|
109
|
+
val = os.environ.get(var)
|
|
110
|
+
if val:
|
|
111
|
+
return val
|
|
112
|
+
|
|
113
|
+
# OpenAI Codex
|
|
114
|
+
val = os.environ.get("CODEX_MODEL")
|
|
115
|
+
if val:
|
|
116
|
+
return val
|
|
117
|
+
|
|
118
|
+
# Gemini
|
|
119
|
+
val = os.environ.get("GEMINI_MODEL")
|
|
120
|
+
if val:
|
|
121
|
+
return val
|
|
122
|
+
|
|
123
|
+
# Generic MCP client
|
|
124
|
+
val = os.environ.get("MCP_CLIENT_NAME")
|
|
125
|
+
if val:
|
|
126
|
+
return val
|
|
127
|
+
|
|
128
|
+
return "unknown"
|
|
129
|
+
|
|
130
|
+
|
|
97
131
|
def _project_ledger_dir(project_path: str = ".") -> Path:
|
|
98
132
|
"""Get the ledger directory — ALWAYS uses central ~/.delimit/ledger/.
|
|
99
133
|
|
|
@@ -161,6 +195,7 @@ def add_item(
|
|
|
161
195
|
context: str = "",
|
|
162
196
|
tools_needed: Optional[List[str]] = None,
|
|
163
197
|
estimated_complexity: str = "",
|
|
198
|
+
worked_by: str = "",
|
|
164
199
|
) -> Dict[str, Any]:
|
|
165
200
|
"""Add a new item to the project's strategy or operational ledger.
|
|
166
201
|
|
|
@@ -191,6 +226,7 @@ def add_item(
|
|
|
191
226
|
"venture": venture["name"],
|
|
192
227
|
"status": "open",
|
|
193
228
|
"tags": tags or [],
|
|
229
|
+
"worked_by": worked_by or _detect_model(),
|
|
194
230
|
}
|
|
195
231
|
# LED-189: Optional acceptance criteria
|
|
196
232
|
if acceptance_criteria:
|
|
@@ -244,6 +280,7 @@ def update_item(
|
|
|
244
280
|
blocked_by: Optional[str] = None,
|
|
245
281
|
blocks: Optional[str] = None,
|
|
246
282
|
project_path: str = ".",
|
|
283
|
+
worked_by: str = "",
|
|
247
284
|
) -> Dict[str, Any]:
|
|
248
285
|
"""Update an existing ledger item's fields."""
|
|
249
286
|
_ensure(project_path)
|
|
@@ -281,6 +318,7 @@ def update_item(
|
|
|
281
318
|
"id": item_id,
|
|
282
319
|
"type": "update",
|
|
283
320
|
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
321
|
+
"worked_by": worked_by or _detect_model(),
|
|
284
322
|
}
|
|
285
323
|
if status:
|
|
286
324
|
update["status"] = status
|
|
@@ -348,6 +386,8 @@ def list_items(
|
|
|
348
386
|
state[item_id]["last_note"] = item["note"]
|
|
349
387
|
if "priority" in item:
|
|
350
388
|
state[item_id]["priority"] = item["priority"]
|
|
389
|
+
if "worked_by" in item:
|
|
390
|
+
state[item_id]["last_worked_by"] = item["worked_by"]
|
|
351
391
|
state[item_id]["updated_at"] = item.get("updated_at")
|
|
352
392
|
else:
|
|
353
393
|
state[item_id] = {**item}
|
package/gateway/ai/license.py
CHANGED
|
@@ -14,6 +14,9 @@ try:
|
|
|
14
14
|
check_premium as is_premium,
|
|
15
15
|
gate_tool as require_premium,
|
|
16
16
|
activate as activate_license,
|
|
17
|
+
needs_revalidation,
|
|
18
|
+
revalidate_license,
|
|
19
|
+
is_license_valid,
|
|
17
20
|
PRO_TOOLS as _CORE_PRO_TOOLS,
|
|
18
21
|
FREE_TRIAL_LIMITS,
|
|
19
22
|
)
|
|
@@ -78,17 +81,114 @@ except ImportError:
|
|
|
78
81
|
})
|
|
79
82
|
FREE_TRIAL_LIMITS = {"delimit_deliberate": 3}
|
|
80
83
|
|
|
84
|
+
REVALIDATION_INTERVAL = 30 * 86400 # 30 days
|
|
85
|
+
GRACE_PERIOD = 7 * 86400
|
|
86
|
+
HARD_BLOCK = 14 * 86400
|
|
87
|
+
|
|
81
88
|
def get_license() -> dict:
|
|
82
89
|
if not LICENSE_FILE.exists():
|
|
83
90
|
return {"tier": "free", "valid": True}
|
|
84
91
|
try:
|
|
85
|
-
|
|
92
|
+
data = json.loads(LICENSE_FILE.read_text())
|
|
93
|
+
if data.get("expires_at") and data["expires_at"] < time.time():
|
|
94
|
+
return {"tier": "free", "valid": True, "expired": True}
|
|
95
|
+
if data.get("tier") in ("pro", "enterprise") and data.get("valid"):
|
|
96
|
+
if needs_revalidation(data):
|
|
97
|
+
result = revalidate_license(data)
|
|
98
|
+
data = result["updated_data"]
|
|
99
|
+
if result["status"] == "expired":
|
|
100
|
+
return {"tier": "free", "valid": True, "revoked": True,
|
|
101
|
+
"reason": result.get("reason", "License expired.")}
|
|
102
|
+
return data
|
|
86
103
|
except Exception:
|
|
87
104
|
return {"tier": "free", "valid": True}
|
|
88
105
|
|
|
106
|
+
def needs_revalidation(data: dict) -> bool:
|
|
107
|
+
if data.get("tier") not in ("pro", "enterprise"):
|
|
108
|
+
return False
|
|
109
|
+
last_validated = data.get("last_validated_at", data.get("activated_at", 0))
|
|
110
|
+
if last_validated == 0:
|
|
111
|
+
return True
|
|
112
|
+
return (time.time() - last_validated) > REVALIDATION_INTERVAL
|
|
113
|
+
|
|
114
|
+
def revalidate_license(data: dict) -> dict:
|
|
115
|
+
import hashlib
|
|
116
|
+
import urllib.request
|
|
117
|
+
key = data.get("key", "")
|
|
118
|
+
if not key or key.startswith("JAMSONS"):
|
|
119
|
+
data["last_validated_at"] = time.time()
|
|
120
|
+
data["validation_status"] = "current"
|
|
121
|
+
_write_license(data)
|
|
122
|
+
return {"status": "valid", "updated_data": data}
|
|
123
|
+
|
|
124
|
+
last_validated = data.get("last_validated_at", data.get("activated_at", 0))
|
|
125
|
+
elapsed = time.time() - last_validated
|
|
126
|
+
machine_hash = data.get("machine_hash", hashlib.sha256(str(Path.home()).encode()).hexdigest()[:16])
|
|
127
|
+
|
|
128
|
+
api_valid = None
|
|
129
|
+
try:
|
|
130
|
+
req_data = json.dumps({"license_key": key, "instance_name": machine_hash}).encode()
|
|
131
|
+
req = urllib.request.Request(
|
|
132
|
+
"https://api.lemonsqueezy.com/v1/licenses/validate",
|
|
133
|
+
data=req_data,
|
|
134
|
+
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|
135
|
+
method="POST",
|
|
136
|
+
)
|
|
137
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
138
|
+
result = json.loads(resp.read())
|
|
139
|
+
api_valid = result.get("valid", False)
|
|
140
|
+
except Exception:
|
|
141
|
+
api_valid = None
|
|
142
|
+
|
|
143
|
+
if api_valid is True:
|
|
144
|
+
data["last_validated_at"] = time.time()
|
|
145
|
+
data["validation_status"] = "current"
|
|
146
|
+
data.pop("grace_days_remaining", None)
|
|
147
|
+
_write_license(data)
|
|
148
|
+
return {"status": "valid", "updated_data": data}
|
|
149
|
+
|
|
150
|
+
if elapsed > REVALIDATION_INTERVAL + HARD_BLOCK:
|
|
151
|
+
data["validation_status"] = "expired"
|
|
152
|
+
data["valid"] = False
|
|
153
|
+
_write_license(data)
|
|
154
|
+
return {"status": "expired", "updated_data": data,
|
|
155
|
+
"reason": "License expired — no successful re-validation in 44 days."}
|
|
156
|
+
|
|
157
|
+
if elapsed > REVALIDATION_INTERVAL + GRACE_PERIOD:
|
|
158
|
+
days_left = max(0, int((REVALIDATION_INTERVAL + HARD_BLOCK - elapsed) / 86400))
|
|
159
|
+
data["validation_status"] = "grace_period"
|
|
160
|
+
data["grace_days_remaining"] = days_left
|
|
161
|
+
_write_license(data)
|
|
162
|
+
return {"status": "grace", "updated_data": data, "grace_days_remaining": days_left}
|
|
163
|
+
|
|
164
|
+
data["validation_status"] = "revalidation_pending"
|
|
165
|
+
_write_license(data)
|
|
166
|
+
return {"status": "grace", "updated_data": data}
|
|
167
|
+
|
|
168
|
+
def is_license_valid(data: dict) -> bool:
|
|
169
|
+
if data.get("tier") not in ("pro", "enterprise"):
|
|
170
|
+
return False
|
|
171
|
+
if not data.get("valid", False):
|
|
172
|
+
return False
|
|
173
|
+
key = data.get("key", "")
|
|
174
|
+
if key.startswith("JAMSONS"):
|
|
175
|
+
return True
|
|
176
|
+
last_validated = data.get("last_validated_at", data.get("activated_at", 0))
|
|
177
|
+
if last_validated == 0:
|
|
178
|
+
return True
|
|
179
|
+
elapsed = time.time() - last_validated
|
|
180
|
+
return elapsed <= (REVALIDATION_INTERVAL + HARD_BLOCK)
|
|
181
|
+
|
|
182
|
+
def _write_license(data: dict) -> None:
|
|
183
|
+
try:
|
|
184
|
+
LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
LICENSE_FILE.write_text(json.dumps(data, indent=2))
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
|
|
89
189
|
def is_premium() -> bool:
|
|
90
190
|
lic = get_license()
|
|
91
|
-
return
|
|
191
|
+
return is_license_valid(lic)
|
|
92
192
|
|
|
93
193
|
def require_premium(tool_name: str) -> dict | None:
|
|
94
194
|
full_name = tool_name if tool_name.startswith("delimit_") else f"delimit_{tool_name}"
|
|
@@ -121,7 +221,8 @@ except ImportError:
|
|
|
121
221
|
# Store key for offline validation
|
|
122
222
|
license_data = {
|
|
123
223
|
"key": key, "tier": "pro", "valid": True,
|
|
124
|
-
"activated_at": time.time(), "
|
|
224
|
+
"activated_at": time.time(), "last_validated_at": time.time(),
|
|
225
|
+
"validated_via": "offline_fallback",
|
|
125
226
|
}
|
|
126
227
|
LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
127
228
|
LICENSE_FILE.write_text(json.dumps(license_data, indent=2))
|
|
@@ -45,8 +45,162 @@ FREE_TRIAL_LIMITS = {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
def needs_revalidation(data: dict) -> bool:
|
|
49
|
+
"""Check if a license needs re-validation (30+ days since last check).
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
data: License data dict (from license.json).
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
True if 30+ days have elapsed since last_validated_at (or activated_at
|
|
56
|
+
as fallback). Also returns True if neither timestamp exists (legacy
|
|
57
|
+
license.json files without last_validated_at).
|
|
58
|
+
"""
|
|
59
|
+
if data.get("tier") not in ("pro", "enterprise"):
|
|
60
|
+
return False
|
|
61
|
+
last_validated = data.get("last_validated_at", data.get("activated_at", 0))
|
|
62
|
+
if last_validated == 0:
|
|
63
|
+
return True # Legacy file — treat as needing validation
|
|
64
|
+
return (time.time() - last_validated) > REVALIDATION_INTERVAL
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def revalidate_license(data: dict) -> dict:
|
|
68
|
+
"""Re-validate a license against Lemon Squeezy.
|
|
69
|
+
|
|
70
|
+
Privacy-preserving: only sends license_key and instance_name (machine hash).
|
|
71
|
+
Non-blocking: network failures return offline grace status, never crash.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
data: License data dict (must contain 'key').
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Dict with 'status' key:
|
|
78
|
+
- "valid": API confirmed license is active, last_validated_at updated
|
|
79
|
+
- "grace": API unreachable or returned invalid, but within grace period
|
|
80
|
+
- "expired": beyond grace + hard block cutoff, Pro tools should be blocked
|
|
81
|
+
Also includes 'updated_data' with the (possibly modified) license data.
|
|
82
|
+
"""
|
|
83
|
+
key = data.get("key", "")
|
|
84
|
+
# Internal/founder keys always pass
|
|
85
|
+
if not key or key.startswith("JAMSONS"):
|
|
86
|
+
data["last_validated_at"] = time.time()
|
|
87
|
+
data["validation_status"] = "current"
|
|
88
|
+
_write_license(data)
|
|
89
|
+
return {"status": "valid", "updated_data": data}
|
|
90
|
+
|
|
91
|
+
last_validated = data.get("last_validated_at", data.get("activated_at", 0))
|
|
92
|
+
elapsed = time.time() - last_validated
|
|
93
|
+
|
|
94
|
+
# Try API call
|
|
95
|
+
api_valid = _call_lemon_squeezy(data)
|
|
96
|
+
|
|
97
|
+
if api_valid is True:
|
|
98
|
+
data["last_validated_at"] = time.time()
|
|
99
|
+
data["validation_status"] = "current"
|
|
100
|
+
data.pop("grace_days_remaining", None)
|
|
101
|
+
_write_license(data)
|
|
102
|
+
return {"status": "valid", "updated_data": data}
|
|
103
|
+
|
|
104
|
+
# API said invalid or was unreachable — check grace/expiry windows
|
|
105
|
+
if elapsed > REVALIDATION_INTERVAL + HARD_BLOCK:
|
|
106
|
+
data["validation_status"] = "expired"
|
|
107
|
+
data["valid"] = False
|
|
108
|
+
_write_license(data)
|
|
109
|
+
return {
|
|
110
|
+
"status": "expired",
|
|
111
|
+
"updated_data": data,
|
|
112
|
+
"reason": "License expired — no successful re-validation in 44 days. Renew at https://delimit.ai/pricing",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if elapsed > REVALIDATION_INTERVAL + GRACE_PERIOD:
|
|
116
|
+
days_left = max(0, int((REVALIDATION_INTERVAL + HARD_BLOCK - elapsed) / 86400))
|
|
117
|
+
data["validation_status"] = "grace_period"
|
|
118
|
+
data["grace_days_remaining"] = days_left
|
|
119
|
+
_write_license(data)
|
|
120
|
+
return {
|
|
121
|
+
"status": "grace",
|
|
122
|
+
"updated_data": data,
|
|
123
|
+
"grace_days_remaining": days_left,
|
|
124
|
+
"message": f"License re-validation failed. {days_left} days until Pro features are disabled.",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# Within first 7 days after revalidation interval — soft pending
|
|
128
|
+
data["validation_status"] = "revalidation_pending"
|
|
129
|
+
_write_license(data)
|
|
130
|
+
return {"status": "grace", "updated_data": data}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def is_license_valid(data: dict) -> bool:
|
|
134
|
+
"""Check if a license is currently valid for Pro tool access.
|
|
135
|
+
|
|
136
|
+
Returns True if:
|
|
137
|
+
- last_validated_at is within 30 days (current), OR
|
|
138
|
+
- last_validated_at is within 37 days (30 + 7 grace), OR
|
|
139
|
+
- last_validated_at is within 44 days (30 + 14 hard cutoff)
|
|
140
|
+
Returns False if beyond 44 days with no successful re-validation.
|
|
141
|
+
|
|
142
|
+
Backwards compatible: missing last_validated_at falls back to activated_at,
|
|
143
|
+
and missing both returns False (triggers re-validation).
|
|
144
|
+
"""
|
|
145
|
+
if data.get("tier") not in ("pro", "enterprise"):
|
|
146
|
+
return False
|
|
147
|
+
if not data.get("valid", False):
|
|
148
|
+
return False
|
|
149
|
+
# Internal/founder keys always valid
|
|
150
|
+
key = data.get("key", "")
|
|
151
|
+
if key.startswith("JAMSONS"):
|
|
152
|
+
return True
|
|
153
|
+
last_validated = data.get("last_validated_at", data.get("activated_at", 0))
|
|
154
|
+
if last_validated == 0:
|
|
155
|
+
return True # Legacy — allow access but needs_revalidation will trigger check
|
|
156
|
+
elapsed = time.time() - last_validated
|
|
157
|
+
return elapsed <= (REVALIDATION_INTERVAL + HARD_BLOCK)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _write_license(data: dict) -> None:
|
|
161
|
+
"""Persist license data to disk."""
|
|
162
|
+
try:
|
|
163
|
+
LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
LICENSE_FILE.write_text(json.dumps(data, indent=2))
|
|
165
|
+
except Exception:
|
|
166
|
+
pass # Non-blocking — don't crash on disk errors
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _call_lemon_squeezy(data: dict) -> bool | None:
|
|
170
|
+
"""Call Lemon Squeezy validation API. Privacy-preserving.
|
|
171
|
+
|
|
172
|
+
Only sends license_key and instance_name (machine hash).
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
True if valid, False if invalid, None if unreachable.
|
|
176
|
+
"""
|
|
177
|
+
key = data.get("key", "")
|
|
178
|
+
machine_hash = data.get("machine_hash", hashlib.sha256(str(Path.home()).encode()).hexdigest()[:16])
|
|
179
|
+
try:
|
|
180
|
+
import urllib.request
|
|
181
|
+
req_data = json.dumps({
|
|
182
|
+
"license_key": key,
|
|
183
|
+
"instance_name": machine_hash,
|
|
184
|
+
}).encode()
|
|
185
|
+
req = urllib.request.Request(
|
|
186
|
+
LS_VALIDATE_URL, data=req_data,
|
|
187
|
+
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|
188
|
+
method="POST",
|
|
189
|
+
)
|
|
190
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
191
|
+
result = json.loads(resp.read())
|
|
192
|
+
return result.get("valid", False)
|
|
193
|
+
except Exception:
|
|
194
|
+
return None # Unreachable — caller should use grace period
|
|
195
|
+
|
|
196
|
+
|
|
48
197
|
def load_license() -> dict:
|
|
49
|
-
"""Load and validate license with re-validation.
|
|
198
|
+
"""Load and validate license with periodic re-validation.
|
|
199
|
+
|
|
200
|
+
Re-validates against Lemon Squeezy every 30 days. On failure, provides
|
|
201
|
+
a 7-day grace period followed by a 7-day warning period. After 44 days
|
|
202
|
+
without successful re-validation, Pro tools are blocked.
|
|
203
|
+
"""
|
|
50
204
|
if not LICENSE_FILE.exists():
|
|
51
205
|
return {"tier": "free", "valid": True}
|
|
52
206
|
try:
|
|
@@ -55,33 +209,25 @@ def load_license() -> dict:
|
|
|
55
209
|
return {"tier": "free", "valid": True, "expired": True}
|
|
56
210
|
|
|
57
211
|
if data.get("tier") in ("pro", "enterprise") and data.get("valid"):
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
revalidated = _revalidate(data)
|
|
63
|
-
if revalidated.get("valid"):
|
|
64
|
-
data["last_validated_at"] = time.time()
|
|
65
|
-
data["validation_status"] = "current"
|
|
66
|
-
LICENSE_FILE.write_text(json.dumps(data, indent=2))
|
|
67
|
-
elif elapsed > REVALIDATION_INTERVAL + HARD_BLOCK:
|
|
212
|
+
if needs_revalidation(data):
|
|
213
|
+
result = revalidate_license(data)
|
|
214
|
+
data = result["updated_data"]
|
|
215
|
+
if result["status"] == "expired":
|
|
68
216
|
return {"tier": "free", "valid": True, "revoked": True,
|
|
69
|
-
"reason": "License expired. Renew at https://delimit.ai/pricing"}
|
|
70
|
-
elif elapsed > REVALIDATION_INTERVAL + GRACE_PERIOD:
|
|
71
|
-
data["validation_status"] = "grace_period"
|
|
72
|
-
days_left = int((REVALIDATION_INTERVAL + HARD_BLOCK - elapsed) / 86400)
|
|
73
|
-
data["grace_days_remaining"] = days_left
|
|
74
|
-
else:
|
|
75
|
-
data["validation_status"] = "revalidation_pending"
|
|
217
|
+
"reason": result.get("reason", "License expired. Renew at https://delimit.ai/pricing")}
|
|
76
218
|
return data
|
|
77
219
|
except Exception:
|
|
78
220
|
return {"tier": "free", "valid": True}
|
|
79
221
|
|
|
80
222
|
|
|
81
223
|
def check_premium() -> bool:
|
|
82
|
-
"""Check if user has a valid premium license.
|
|
224
|
+
"""Check if user has a valid premium license.
|
|
225
|
+
|
|
226
|
+
Uses load_license() which triggers re-validation if needed, then
|
|
227
|
+
checks is_license_valid() on the result.
|
|
228
|
+
"""
|
|
83
229
|
lic = load_license()
|
|
84
|
-
return
|
|
230
|
+
return is_license_valid(lic)
|
|
85
231
|
|
|
86
232
|
|
|
87
233
|
def gate_tool(tool_name: str) -> dict | None:
|
|
@@ -164,23 +310,18 @@ def activate(key: str) -> dict:
|
|
|
164
310
|
|
|
165
311
|
|
|
166
312
|
def _revalidate(data: dict) -> dict:
|
|
167
|
-
"""Re-validate against Lemon Squeezy.
|
|
168
|
-
|
|
169
|
-
|
|
313
|
+
"""Re-validate against Lemon Squeezy (legacy wrapper).
|
|
314
|
+
|
|
315
|
+
Deprecated: use revalidate_license() for the full status/grace workflow.
|
|
316
|
+
Kept for backwards compatibility with any external callers.
|
|
317
|
+
"""
|
|
318
|
+
result = _call_lemon_squeezy(data)
|
|
319
|
+
if result is True:
|
|
170
320
|
return {"valid": True}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
LS_VALIDATE_URL, data=req_data,
|
|
176
|
-
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|
177
|
-
method="POST",
|
|
178
|
-
)
|
|
179
|
-
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
180
|
-
result = json.loads(resp.read())
|
|
181
|
-
return {"valid": result.get("valid", False)}
|
|
182
|
-
except Exception:
|
|
183
|
-
return {"valid": True, "offline": True}
|
|
321
|
+
if result is False:
|
|
322
|
+
return {"valid": False}
|
|
323
|
+
# None = unreachable — grant offline grace
|
|
324
|
+
return {"valid": True, "offline": True}
|
|
184
325
|
|
|
185
326
|
|
|
186
327
|
def _get_monthly_usage(tool_name: str) -> int:
|