cortexdb-mcp 0.7.4__tar.gz → 0.7.5__tar.gz
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.
- {cortexdb_mcp-0.7.4 → cortexdb_mcp-0.7.5}/PKG-INFO +2 -2
- {cortexdb_mcp-0.7.4 → cortexdb_mcp-0.7.5}/README.md +1 -1
- {cortexdb_mcp-0.7.4 → cortexdb_mcp-0.7.5}/cortexdb_mcp/check_call.py +31 -4
- cortexdb_mcp-0.7.5/cortexdb_mcp/config.py +278 -0
- {cortexdb_mcp-0.7.4 → cortexdb_mcp-0.7.5}/cortexdb_mcp/render.py +8 -4
- {cortexdb_mcp-0.7.4 → cortexdb_mcp-0.7.5}/cortexdb_mcp/server.py +1859 -1702
- {cortexdb_mcp-0.7.4 → cortexdb_mcp-0.7.5}/pyproject.toml +1 -1
- {cortexdb_mcp-0.7.4 → cortexdb_mcp-0.7.5}/tests/test_check_call.py +65 -3
- {cortexdb_mcp-0.7.4 → cortexdb_mcp-0.7.5}/tests/test_server.py +607 -9
- cortexdb_mcp-0.7.4/cortexdb_mcp/config.py +0 -154
- {cortexdb_mcp-0.7.4 → cortexdb_mcp-0.7.5}/.gitignore +0 -0
- {cortexdb_mcp-0.7.4 → cortexdb_mcp-0.7.5}/Dockerfile +0 -0
- {cortexdb_mcp-0.7.4 → cortexdb_mcp-0.7.5}/cortexdb_mcp/__init__.py +0 -0
- {cortexdb_mcp-0.7.4 → cortexdb_mcp-0.7.5}/cortexdb_mcp/__main__.py +0 -0
- {cortexdb_mcp-0.7.4 → cortexdb_mcp-0.7.5}/cortexdb_mcp/api.py +0 -0
- {cortexdb_mcp-0.7.4 → cortexdb_mcp-0.7.5}/cortexdb_mcp/insights.py +0 -0
- {cortexdb_mcp-0.7.4 → cortexdb_mcp-0.7.5}/tests/__init__.py +0 -0
- {cortexdb_mcp-0.7.4 → cortexdb_mcp-0.7.5}/tests/test_insights.py +0 -0
- {cortexdb_mcp-0.7.4 → cortexdb_mcp-0.7.5}/tests/test_integration.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cortexdb-mcp
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.5
|
|
4
4
|
Summary: MCP Server for CortexDB — expose memory operations to AI agents
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -41,7 +41,7 @@ cortexdb-mcp --tool-profile code
|
|
|
41
41
|
This materially reduces tool-schema context while retaining natural-language
|
|
42
42
|
retrieval, typed traversal, prefix symbol discovery, and directory inventory.
|
|
43
43
|
|
|
44
|
-
That's it. On first launch the server hits `POST /v1/auth/signup`, mints a free-tier PASETO token + scope for itself, and caches them under `~/.config/cortexdb-mcp/state.json` (Linux/macOS) or `%APPDATA%\cortexdb-mcp\state.json` (Windows). Re-launches reuse the cached identity until the token expires (7 days).
|
|
44
|
+
That's it. On first launch the server hits `POST /v1/auth/signup`, mints a free-tier PASETO token + scope for itself, and caches them under `~/.config/cortexdb-mcp/state.json` (Linux/macOS) or `%APPDATA%\cortexdb-mcp\state.json` (Windows). Re-launches reuse the cached identity until the token expires (7 days); the cached `expires_at` is checked at load, and a token the server rejects with 401 is discarded and re-minted automatically, so no hand-deletion of `state.json` is needed. Signup runs against any configured `CORTEXDB_URL`, self-hosted included; a deployment with no minter answers 503 and the server carries on unauthenticated.
|
|
45
45
|
|
|
46
46
|
To target a custom deployment or pre-existing identity, set any of:
|
|
47
47
|
|
|
@@ -24,7 +24,7 @@ cortexdb-mcp --tool-profile code
|
|
|
24
24
|
This materially reduces tool-schema context while retaining natural-language
|
|
25
25
|
retrieval, typed traversal, prefix symbol discovery, and directory inventory.
|
|
26
26
|
|
|
27
|
-
That's it. On first launch the server hits `POST /v1/auth/signup`, mints a free-tier PASETO token + scope for itself, and caches them under `~/.config/cortexdb-mcp/state.json` (Linux/macOS) or `%APPDATA%\cortexdb-mcp\state.json` (Windows). Re-launches reuse the cached identity until the token expires (7 days).
|
|
27
|
+
That's it. On first launch the server hits `POST /v1/auth/signup`, mints a free-tier PASETO token + scope for itself, and caches them under `~/.config/cortexdb-mcp/state.json` (Linux/macOS) or `%APPDATA%\cortexdb-mcp\state.json` (Windows). Re-launches reuse the cached identity until the token expires (7 days); the cached `expires_at` is checked at load, and a token the server rejects with 401 is discarded and re-minted automatically, so no hand-deletion of `state.json` is needed. Signup runs against any configured `CORTEXDB_URL`, self-hosted included; a deployment with no minter answers 503 and the server carries on unauthenticated.
|
|
28
28
|
|
|
29
29
|
To target a custom deployment or pre-existing identity, set any of:
|
|
30
30
|
|
|
@@ -76,6 +76,10 @@ PATH_ALIASES = {
|
|
|
76
76
|
"/recall": "/v1/recall",
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
#: The fields of `ForgetRequest.selector` on the wire. They are NESTED under
|
|
80
|
+
#: `selector`; the route rejects them at the top level with 422 INVALID_BODY.
|
|
81
|
+
_SELECTOR_KEYS = ("memory_ids", "about_subject", "about_entity", "predicate")
|
|
82
|
+
|
|
79
83
|
#: SDK methods, mirroring the guidance the SDKs themselves now raise.
|
|
80
84
|
SDK_METHODS = {
|
|
81
85
|
"experience", "experience_bulk", "recall", "answer", "compose", "forget",
|
|
@@ -142,6 +146,7 @@ def check_http(method: str, path: str, body: dict[str, Any] | None = None) -> Ve
|
|
|
142
146
|
path = path.split("?", 1)[0]
|
|
143
147
|
path = "/" + path.strip("/") if path else ""
|
|
144
148
|
call = f"{method} {path}"
|
|
149
|
+
has_body = bool(body)
|
|
145
150
|
body = body or {}
|
|
146
151
|
|
|
147
152
|
if (method, path) not in ROUTES:
|
|
@@ -181,14 +186,36 @@ def check_http(method: str, path: str, body: dict[str, Any] | None = None) -> Ve
|
|
|
181
186
|
problems.extend(_check_scope(body["scope"]))
|
|
182
187
|
# The one cross-field rule the server enforces and agents keep hitting.
|
|
183
188
|
if path.startswith("/v1/forget"):
|
|
184
|
-
|
|
185
|
-
|
|
189
|
+
# Selectors live UNDER `selector` on the wire (ForgetRequest.selector),
|
|
190
|
+
# never at the top level. Reading only the top level meant this rule
|
|
191
|
+
# never fired for a real body -- which is how the MCP server's own
|
|
192
|
+
# memory_delete shipped a body the validator called valid and the
|
|
193
|
+
# server refused with 400 on every call (M1).
|
|
194
|
+
selector = body.get("selector")
|
|
195
|
+
selector = selector if isinstance(selector, dict) else {}
|
|
196
|
+
selectors = [k for k in _SELECTOR_KEYS if selector.get(k)]
|
|
197
|
+
misplaced = [k for k in _SELECTOR_KEYS if body.get(k)]
|
|
198
|
+
if misplaced:
|
|
199
|
+
problems.append(
|
|
200
|
+
f"{', '.join(misplaced)} must live under `selector`, not at the "
|
|
201
|
+
f"top level. The route rejects unknown top-level fields with "
|
|
202
|
+
f"422 INVALID_BODY (expected: scope, layers, selector, cascade, "
|
|
203
|
+
f"confirm_all, audit_note/reason, from_preview_id)."
|
|
204
|
+
)
|
|
186
205
|
if selectors and body.get("confirm_all"):
|
|
187
206
|
problems.append(
|
|
188
207
|
f"selector ({', '.join(selectors)}) combined with confirm_all=true is "
|
|
189
208
|
f"refused (AMBIGUOUS_SELECTOR_CONFIRM_ALL). confirm_all authorizes a "
|
|
190
|
-
f"SCOPE-WIDE erase; a selector narrows it.
|
|
191
|
-
f"the
|
|
209
|
+
f"SCOPE-WIDE erase; a selector narrows it. Drop confirm_all to delete "
|
|
210
|
+
f"exactly the selected memories, or drop the selector for a "
|
|
211
|
+
f"scope-wide forget."
|
|
212
|
+
)
|
|
213
|
+
if has_body and not selectors and not misplaced and not body.get("confirm_all"):
|
|
214
|
+
problems.append(
|
|
215
|
+
"no selector and no confirm_all: the route refuses an empty "
|
|
216
|
+
"selector without confirmation (422 "
|
|
217
|
+
"EMPTY_SELECTOR_WITHOUT_CONFIRMATION). Name what to delete "
|
|
218
|
+
"under `selector`, or set confirm_all=true for the whole scope."
|
|
192
219
|
)
|
|
193
220
|
notes = [n for n in [spec.get("notes")] if n]
|
|
194
221
|
return Verdict(valid=not problems, call=call, problems=problems, notes=notes)
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Configuration management for the CortexDB MCP server.
|
|
2
|
+
|
|
3
|
+
Reads settings from environment variables with sensible defaults.
|
|
4
|
+
|
|
5
|
+
The MCP server targets the v1 API surface by default
|
|
6
|
+
(``https://api-v1.cortexdb.ai``). Three auth paths are supported:
|
|
7
|
+
|
|
8
|
+
1. **Anonymous one-click** — leave ``CORTEXDB_API_KEY`` unset and the server
|
|
9
|
+
will call ``POST /v1/auth/signup`` on first launch (against ANY configured
|
|
10
|
+
URL, self-hosted included), caching the resulting PASETO token, actor,
|
|
11
|
+
scope and ``expires_at`` under ``~/.config/cortexdb-mcp/state.json``.
|
|
12
|
+
Re-launches reuse the cached token until it expires: a lapsed cache is
|
|
13
|
+
treated as absent, and a token the server rejects with 401 is discarded and
|
|
14
|
+
re-minted once, so a stale ``state.json`` never has to be deleted by hand.
|
|
15
|
+
|
|
16
|
+
2. **Bring your own PASETO** — set ``CORTEXDB_API_KEY`` to a PASETO ``v4.public.*``
|
|
17
|
+
token. The server still needs ``CORTEXDB_ACTOR`` and ``CORTEXDB_SCOPE`` so
|
|
18
|
+
it can send the matching ``X-Cortex-Actor`` header.
|
|
19
|
+
|
|
20
|
+
3. **Static operator key** — set ``CORTEXDB_API_KEY`` to a deployment's
|
|
21
|
+
``CORTEX_API_KEY``. Such a key does not expire, and is never discarded or
|
|
22
|
+
re-minted on a 401; the error surfaces to the caller instead. (The older
|
|
23
|
+
``cx_live_`` / Turso customer-key path is retired server-side.)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
#: Refresh a cached anonymous token this long before its stated expiry, so a
|
|
35
|
+
#: call started just under the wire doesn't 401 mid-flight.
|
|
36
|
+
EXPIRY_SKEW_SECONDS = 300
|
|
37
|
+
|
|
38
|
+
#: A token whose remaining life exceeds this is treated as non-expiring.
|
|
39
|
+
#: Auth-disabled / static-API-key deployments synthesize ``exp = i64::MAX / 2``
|
|
40
|
+
#: (~1.46e11 years), which rendered as a literal 4611686016638011224-second
|
|
41
|
+
#: countdown. Anything past a century is a sentinel, not a deadline.
|
|
42
|
+
NON_EXPIRING_THRESHOLD_SECONDS = 100 * 365 * 24 * 3600
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_expiry(value: str | None) -> datetime | None:
|
|
46
|
+
"""Parse an RFC3339 expiry into an aware UTC datetime, or None.
|
|
47
|
+
|
|
48
|
+
Returns None for absent, empty, or unparseable input — the server sends an
|
|
49
|
+
EMPTY ``x-cortex-token-expires-at`` for non-expiring credentials, because
|
|
50
|
+
its sentinel ``exp`` overflows ``DateTime::from_timestamp``.
|
|
51
|
+
"""
|
|
52
|
+
if not value:
|
|
53
|
+
return None
|
|
54
|
+
text = value.strip()
|
|
55
|
+
if not text:
|
|
56
|
+
return None
|
|
57
|
+
if text.endswith(("Z", "z")):
|
|
58
|
+
text = text[:-1] + "+00:00"
|
|
59
|
+
try:
|
|
60
|
+
parsed = datetime.fromisoformat(text)
|
|
61
|
+
except ValueError:
|
|
62
|
+
return None
|
|
63
|
+
if parsed.tzinfo is None:
|
|
64
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
65
|
+
return parsed.astimezone(timezone.utc)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def expiry_is_sentinel(seconds: float) -> bool:
|
|
69
|
+
"""Is this ``expires_in`` a non-expiry sentinel rather than a deadline?"""
|
|
70
|
+
return seconds >= NON_EXPIRING_THRESHOLD_SECONDS
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _state_path() -> Path:
|
|
74
|
+
"""Where the cached anonymous-signup token lives.
|
|
75
|
+
|
|
76
|
+
Uses XDG_CONFIG_HOME on Linux/macOS, %APPDATA% on Windows, with a
|
|
77
|
+
sensible fallback for both.
|
|
78
|
+
"""
|
|
79
|
+
if os.name == "nt":
|
|
80
|
+
base = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")))
|
|
81
|
+
else:
|
|
82
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
|
|
83
|
+
return base / "cortexdb-mcp" / "state.json"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class CortexMCPConfig:
|
|
88
|
+
"""Configuration for connecting to a CortexDB instance.
|
|
89
|
+
|
|
90
|
+
Attributes:
|
|
91
|
+
url: Base URL of the CortexDB HTTP API.
|
|
92
|
+
api_key: PASETO bearer token (or legacy cx_live_ key in v0 mode).
|
|
93
|
+
actor: ActorId sent as the X-Cortex-Actor header. Required for v1.
|
|
94
|
+
scope: Default scope path for write/recall calls. Required for v1.
|
|
95
|
+
tenant_id: Legacy tenant id for v0 calls. Defaults to None.
|
|
96
|
+
expires_at: RFC3339 expiry of ``api_key`` when the mint reported one.
|
|
97
|
+
anonymous: Whether ``api_key`` was minted by anonymous signup.
|
|
98
|
+
timeout: HTTP request timeout in seconds.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
url: str = "https://api-v1.cortexdb.ai"
|
|
102
|
+
api_key: str | None = None
|
|
103
|
+
actor: str | None = None
|
|
104
|
+
scope: str | None = None
|
|
105
|
+
tenant_id: str | None = None
|
|
106
|
+
# RFC3339 expiry of ``api_key``, when known (anonymous signup reports one).
|
|
107
|
+
# None means "unknown / does not expire", never "already expired".
|
|
108
|
+
expires_at: str | None = None
|
|
109
|
+
# True when ``api_key`` came from an anonymous signup (this process or a
|
|
110
|
+
# cached one), so the server may discard and re-mint it on rejection. An
|
|
111
|
+
# operator-supplied CORTEXDB_API_KEY is never discarded — we surface the
|
|
112
|
+
# 401 instead of silently swapping identities underneath them.
|
|
113
|
+
anonymous: bool = False
|
|
114
|
+
# Default public-recall reach. "holistic" = the scope + authorized ancestors
|
|
115
|
+
# + authorized descendants, never siblings (the server default); "descend"
|
|
116
|
+
# = the scope + authorized descendants; "granular" = exact scope.
|
|
117
|
+
view: str = "holistic"
|
|
118
|
+
timeout: float = 30.0
|
|
119
|
+
|
|
120
|
+
# Set by save_state() / from_env() when an anonymous signup happens; used
|
|
121
|
+
# by the server to indicate whether persistence is enabled. Kept off the
|
|
122
|
+
# dataclass init signature so tests can construct configs directly.
|
|
123
|
+
state_file: Path = field(default_factory=_state_path)
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def from_env(cls) -> "CortexMCPConfig":
|
|
127
|
+
"""Build configuration from environment variables.
|
|
128
|
+
|
|
129
|
+
Environment variables
|
|
130
|
+
---------------------
|
|
131
|
+
CORTEXDB_URL -- API base URL (default ``https://api-v1.cortexdb.ai``).
|
|
132
|
+
CORTEXDB_API_KEY -- PASETO token (preferred) or legacy v0 key.
|
|
133
|
+
CORTEXDB_ACTOR -- ActorId for X-Cortex-Actor (e.g. ``user:alice``).
|
|
134
|
+
CORTEXDB_SCOPE -- Default scope path for tool calls.
|
|
135
|
+
CORTEXDB_VIEW -- Default public-recall reach: ``holistic``
|
|
136
|
+
(scope + authorized ancestors + authorized
|
|
137
|
+
descendants, never siblings), ``descend``
|
|
138
|
+
(scope + authorized descendants), or ``granular``.
|
|
139
|
+
CORTEXDB_TENANT_ID -- Legacy tenant id (v0 callers only).
|
|
140
|
+
CORTEXDB_TIMEOUT -- Request timeout in seconds (default ``30.0``).
|
|
141
|
+
|
|
142
|
+
When CORTEXDB_API_KEY is unset, the server will look for a cached
|
|
143
|
+
state file written by a previous anonymous signup; failing that,
|
|
144
|
+
the next outgoing request triggers a fresh ``/v1/auth/signup``.
|
|
145
|
+
"""
|
|
146
|
+
url = os.environ.get("CORTEXDB_URL", cls.url)
|
|
147
|
+
cfg = cls(
|
|
148
|
+
url=url,
|
|
149
|
+
api_key=os.environ.get("CORTEXDB_API_KEY"),
|
|
150
|
+
actor=os.environ.get("CORTEXDB_ACTOR"),
|
|
151
|
+
scope=os.environ.get("CORTEXDB_SCOPE"),
|
|
152
|
+
tenant_id=os.environ.get("CORTEXDB_TENANT_ID"),
|
|
153
|
+
view=os.environ.get("CORTEXDB_VIEW", cls.view),
|
|
154
|
+
timeout=float(os.environ.get("CORTEXDB_TIMEOUT", str(cls.timeout))),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# If env didn't provide credentials, hydrate from the on-disk cache
|
|
158
|
+
# (anonymous signups from prior MCP-server launches). The cache only
|
|
159
|
+
# applies when CORTEXDB_API_KEY is unset, so an explicit env key
|
|
160
|
+
# always wins. We call `_state_path()` here (vs. reading
|
|
161
|
+
# cfg.state_file) so tests can monkeypatch the module-level
|
|
162
|
+
# function and influence resolution.
|
|
163
|
+
cfg.state_file = _state_path()
|
|
164
|
+
if cfg.api_key is None:
|
|
165
|
+
cached = _load_state(cfg.state_file)
|
|
166
|
+
# M4: an expired cached token used to be reused until every call
|
|
167
|
+
# 401'd, with "delete state.json" as the only recovery. Treat a
|
|
168
|
+
# lapsed (or nearly lapsed) cache as absent so the next request
|
|
169
|
+
# mints a fresh identity instead.
|
|
170
|
+
if cached and cached_token_is_usable(cached):
|
|
171
|
+
cfg.api_key = cached.get("token")
|
|
172
|
+
cfg.actor = cfg.actor or cached.get("actor")
|
|
173
|
+
cfg.scope = cfg.scope or cached.get("scope")
|
|
174
|
+
cfg.expires_at = cached.get("expires_at")
|
|
175
|
+
cfg.anonymous = True
|
|
176
|
+
return cfg
|
|
177
|
+
|
|
178
|
+
def token_is_expiring(self, *, now: datetime | None = None) -> bool:
|
|
179
|
+
"""Is ``api_key`` past (or within the skew window of) its expiry?
|
|
180
|
+
|
|
181
|
+
False when no expiry is known — an unknown expiry is not a lapsed one.
|
|
182
|
+
"""
|
|
183
|
+
return not expiry_is_future(self.expires_at, now=now)
|
|
184
|
+
|
|
185
|
+
def adopt_signup(
|
|
186
|
+
self, token: str, actor: str, scope: str, expires_at: str | None
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Install a freshly minted anonymous identity and cache it."""
|
|
189
|
+
self.api_key = token
|
|
190
|
+
self.actor = actor
|
|
191
|
+
self.scope = scope
|
|
192
|
+
self.expires_at = expires_at
|
|
193
|
+
self.anonymous = True
|
|
194
|
+
self.save_state(token, actor, scope, expires_at)
|
|
195
|
+
|
|
196
|
+
def forget_credentials(self) -> None:
|
|
197
|
+
"""Drop an anonymous identity the server rejected, in memory and on
|
|
198
|
+
disk, so the next request signs up again rather than replaying a dead
|
|
199
|
+
token. A no-op for operator-supplied keys: we never discard those."""
|
|
200
|
+
if not self.anonymous:
|
|
201
|
+
return
|
|
202
|
+
self.api_key = None
|
|
203
|
+
self.expires_at = None
|
|
204
|
+
self.anonymous = False
|
|
205
|
+
try:
|
|
206
|
+
self.state_file.unlink(missing_ok=True)
|
|
207
|
+
except OSError:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
def save_state(
|
|
211
|
+
self, token: str, actor: str, scope: str, expires_at: str | None = None
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Persist an anonymous-signup outcome so subsequent launches reuse
|
|
214
|
+
the same identity. Writes are best-effort — failures (perm denied,
|
|
215
|
+
full disk, etc.) are silently swallowed because the server is still
|
|
216
|
+
functional with an in-memory token."""
|
|
217
|
+
try:
|
|
218
|
+
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
self.state_file.write_text(
|
|
220
|
+
json.dumps(
|
|
221
|
+
{
|
|
222
|
+
"token": token,
|
|
223
|
+
"actor": actor,
|
|
224
|
+
"scope": scope,
|
|
225
|
+
# M4: without this the cache had no way to know the
|
|
226
|
+
# token had lapsed.
|
|
227
|
+
**({"expires_at": expires_at} if expires_at else {}),
|
|
228
|
+
},
|
|
229
|
+
indent=2,
|
|
230
|
+
),
|
|
231
|
+
encoding="utf-8",
|
|
232
|
+
)
|
|
233
|
+
# Best-effort tighten permissions on the secret-bearing file.
|
|
234
|
+
try:
|
|
235
|
+
os.chmod(self.state_file, 0o600)
|
|
236
|
+
except OSError:
|
|
237
|
+
pass
|
|
238
|
+
except OSError:
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def expiry_is_future(expires_at: str | None, *, now: datetime | None = None) -> bool:
|
|
243
|
+
"""Does ``expires_at`` leave more than the skew window of useful life?
|
|
244
|
+
|
|
245
|
+
An absent / unparseable expiry counts as usable: it means "unknown", and a
|
|
246
|
+
non-expiring credential reports exactly that. Only a parseable expiry that
|
|
247
|
+
has (nearly) arrived is a refusal.
|
|
248
|
+
"""
|
|
249
|
+
deadline = parse_expiry(expires_at)
|
|
250
|
+
if deadline is None:
|
|
251
|
+
return True
|
|
252
|
+
moment = now or datetime.now(timezone.utc)
|
|
253
|
+
if moment.tzinfo is None:
|
|
254
|
+
moment = moment.replace(tzinfo=timezone.utc)
|
|
255
|
+
return (deadline - moment).total_seconds() > EXPIRY_SKEW_SECONDS
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def cached_token_is_usable(
|
|
259
|
+
cached: dict[str, str], *, now: datetime | None = None
|
|
260
|
+
) -> bool:
|
|
261
|
+
"""Is a cached signup state still worth replaying?"""
|
|
262
|
+
if not cached.get("token"):
|
|
263
|
+
return False
|
|
264
|
+
return expiry_is_future(cached.get("expires_at"), now=now)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _load_state(path: Path) -> dict[str, str] | None:
|
|
268
|
+
"""Read a cached signup state. Returns None on any error — the caller
|
|
269
|
+
falls back to env-only config."""
|
|
270
|
+
try:
|
|
271
|
+
if not path.exists():
|
|
272
|
+
return None
|
|
273
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
274
|
+
if isinstance(data, dict) and "token" in data:
|
|
275
|
+
return {k: str(v) for k, v in data.items() if isinstance(v, str)}
|
|
276
|
+
except (OSError, json.JSONDecodeError):
|
|
277
|
+
pass
|
|
278
|
+
return None
|
|
@@ -4,10 +4,14 @@ Single source of truth for projecting the v1 wire shapes into readable text,
|
|
|
4
4
|
shared by the MCP tools (``server.py``) and the insights engine
|
|
5
5
|
(``insights.py``).
|
|
6
6
|
|
|
7
|
-
Ground truth (verified against a live ``/v1/recall``): the
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
Ground truth (re-verified against a live ``/v1/recall`` on v0.9.10): the
|
|
8
|
+
StratifiedPack renders recalled *events* into ``context_block`` AND returns
|
|
9
|
+
them structurally in ``layers.events`` (with ``context.labels`` and
|
|
10
|
+
``context.observed_at``) — an older note here claimed that layer was always
|
|
11
|
+
empty on the synthesized-recall path, which is no longer true and cost
|
|
12
|
+
``advanced_search`` its label filters. ``layers`` also carries the derived
|
|
13
|
+
``facts`` / ``beliefs`` / ``episodes``. ``memories_from_context`` remains the
|
|
14
|
+
fallback for packs that carry a context block and no events layer. A v1 Fact serializes its
|
|
11
15
|
triple FLAT — ``subject`` / ``predicate`` / ``object`` are top-level keys, with
|
|
12
16
|
no ``triple`` wrapper — and ``subject`` / ``object`` are tagged ``TypedValue``
|
|
13
17
|
objects: ``{"type":"entity","id":...,"name":...}`` or
|