ltcai 11.7.0 → 12.0.0

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.
Files changed (174) hide show
  1. package/README.md +100 -76
  2. package/docs/BENCHMARKS.md +9 -2
  3. package/docs/CHANGELOG.md +249 -0
  4. package/docs/CI_AND_RELEASE_GATES.md +126 -41
  5. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  6. package/docs/DEVELOPMENT.md +271 -103
  7. package/docs/ENTERPRISE.md +1 -1
  8. package/docs/LEGACY_COMPATIBILITY.md +10 -6
  9. package/docs/MULTI_AGENT_RUNTIME.md +4 -4
  10. package/docs/ONBOARDING.md +16 -4
  11. package/docs/OPERATIONS.md +14 -1
  12. package/docs/PERMISSION_MODE.md +14 -9
  13. package/docs/REALTIME_COLLABORATION.md +1 -1
  14. package/docs/ROADMAP.md +113 -0
  15. package/docs/TRUST_MODEL.md +28 -7
  16. package/docs/USABILITY_AUDIT.md +5 -0
  17. package/docs/WHY_LATTICE.md +13 -5
  18. package/docs/WORKFLOW_DESIGNER.md +2 -2
  19. package/docs/kg-schema.md +57 -7
  20. package/docs/mcp-tools.md +93 -82
  21. package/docs/security-model.md +6 -3
  22. package/lattice_brain/__init__.py +1 -1
  23. package/lattice_brain/graph/_kg_common/__init__.py +1 -54
  24. package/lattice_brain/graph/_kg_common/extraction.py +459 -105
  25. package/lattice_brain/graph/_kg_common/normalize.py +305 -0
  26. package/lattice_brain/graph/_kg_common/patterns.py +275 -0
  27. package/lattice_brain/graph/_kg_common/relations.py +12 -3
  28. package/lattice_brain/graph/_kg_common/sections.py +107 -0
  29. package/lattice_brain/graph/_kg_common/text.py +14 -450
  30. package/lattice_brain/graph/_kg_constants.py +7 -0
  31. package/lattice_brain/ingestion/__init__.py +6 -3
  32. package/lattice_brain/multimodal/__init__.py +9 -3
  33. package/latticeai/__init__.py +1 -1
  34. package/latticeai/api/agent_worker_seam.py +44 -1
  35. package/latticeai/api/models.py +18 -110
  36. package/latticeai/api/search.py +7 -30
  37. package/latticeai/api/worker_compute.py +127 -106
  38. package/latticeai/api/worker_seams.py +17 -2
  39. package/latticeai/core/embedding_providers/__init__.py +16 -0
  40. package/latticeai/core/embedding_providers/autodetect.py +302 -0
  41. package/latticeai/core/embedding_providers/base.py +25 -0
  42. package/latticeai/core/embedding_providers/profiles.py +44 -0
  43. package/latticeai/core/embedding_providers/text.py +74 -8
  44. package/latticeai/core/http_origin.py +3 -3
  45. package/latticeai/core/messages.py +0 -5
  46. package/latticeai/core/policy.py +1 -6
  47. package/latticeai/core/quiet.py +1 -20
  48. package/latticeai/core/security.py +29 -83
  49. package/latticeai/core/sessions.py +95 -4
  50. package/latticeai/core/users.py +0 -38
  51. package/latticeai/core/vector_index/__init__.py +61 -0
  52. package/latticeai/core/vector_index/hnsw.py +383 -0
  53. package/latticeai/core/vector_index/sidecar.py +329 -0
  54. package/latticeai/models/router/catalog.py +2 -2
  55. package/latticeai/models/router/generation.py +176 -30
  56. package/latticeai/models/router/loading.py +150 -9
  57. package/latticeai/runtime/access_runtime.py +7 -4
  58. package/latticeai/runtime/brain_runtime.py +43 -9
  59. package/latticeai/runtime/build_phases/features.py +8 -31
  60. package/latticeai/runtime/build_phases/foundation.py +7 -16
  61. package/latticeai/runtime/build_phases/web.py +3 -3
  62. package/latticeai/runtime/build_phases/worker_profile.py +29 -27
  63. package/latticeai/runtime/runtime_context.py +0 -2
  64. package/latticeai/services/architecture_readiness.py +18 -19
  65. package/latticeai/services/process_audit.py +1 -22
  66. package/latticeai/services/product_readiness.py +39 -12
  67. package/latticeai/services/search_service.py +7 -0
  68. package/latticeai/services/voice_capture.py +8 -28
  69. package/latticeai/tools/__init__.py +12 -47
  70. package/latticeai/tools/commands.py +9 -15
  71. package/latticeai/tools/documents.py +12 -0
  72. package/latticeai/tools/knowledge.py +0 -6
  73. package/latticeai/tools/markup.py +152 -0
  74. package/package.json +4 -5
  75. package/requirements.txt +0 -1
  76. package/scripts/check_current_release_docs.mjs +1 -1
  77. package/scripts/check_openapi_drift.mjs +3 -2
  78. package/scripts/check_server_i18n.mjs +5 -4
  79. package/scripts/compose_openapi.py +4 -1
  80. package/scripts/export_openapi.py +5 -4
  81. package/scripts/gen_worker_allowlist_fixture.py +2 -2
  82. package/scripts/openapi_route_families.json +19 -74
  83. package/scripts/publish_release.mjs +157 -0
  84. package/scripts/release_screen_claims.json +144 -28
  85. package/src-tauri/Cargo.lock +45 -10
  86. package/src-tauri/Cargo.toml +1 -1
  87. package/src-tauri/tauri.conf.json +1 -1
  88. package/static/app/asset-manifest.json +47 -41
  89. package/static/app/assets/Act-Cf1L2709.js +2 -0
  90. package/static/app/assets/AdminConsole-DPAbLTYV.js +1 -0
  91. package/static/app/assets/Brain-DqamGrj-.js +2 -0
  92. package/static/app/assets/BrainHome-MHe2_RYs.js +2 -0
  93. package/static/app/assets/BrainSignals-CQPPfyyH.js +1 -0
  94. package/static/app/assets/Capture-DGdIH_Zc.js +1 -0
  95. package/static/app/assets/Chronicle-C-UlCJoJ.js +1 -0
  96. package/static/app/assets/CommandPalette-WNT4EqUX.js +1 -0
  97. package/static/app/assets/DigitalBrainExplorer-CEBH5Cwc.js +321 -0
  98. package/static/app/assets/Library-C6xd1dlf.js +1 -0
  99. package/static/app/assets/LivingBrain-BEk-0ohw.js +1 -0
  100. package/static/app/assets/ProductFlow-CZLm5iXh.js +1 -0
  101. package/static/app/assets/QueryClientProvider-B3OjqSyJ.js +1 -0
  102. package/static/app/assets/{ReviewCard-HXRle3qq.js → ReviewCard-CEHG6evf.js} +2 -2
  103. package/static/app/assets/RunsListPanel-CLtEJSRW.js +1 -0
  104. package/static/app/assets/System-CAxwBUXw.js +1 -0
  105. package/static/app/assets/WorkflowGraph-Dj10RuGE.js +1 -0
  106. package/static/app/assets/WorkflowsPanel-Kyeh_LIT.js +2 -0
  107. package/static/app/assets/actHelpers-CtSmK9Dw.js +1 -0
  108. package/static/app/assets/arrow-left-CRl5EO4D.js +1 -0
  109. package/static/app/assets/{bot-Cn8bWRuq.js → bot-DhUGRel2.js} +1 -1
  110. package/static/app/assets/brain-CLkhHsHF.js +1 -0
  111. package/static/app/assets/button-CmaEqG1T.js +1 -0
  112. package/static/app/assets/circle-check-CFgejkOS.js +1 -0
  113. package/static/app/assets/{circle-pause-CmzC_apg.js → circle-pause-l96izbxj.js} +1 -1
  114. package/static/app/assets/{circle-play-D8mW2aQ7.js → circle-play-CrZa25_q.js} +1 -1
  115. package/static/app/assets/{cpu-DZcdd0PZ.js → cpu-BaXudqwl.js} +1 -1
  116. package/static/app/assets/{download-bv1KEPGQ.js → download-hCVFPiyc.js} +1 -1
  117. package/static/app/assets/{folder-open-d-Pip5gr.js → folder-open-CHL82Yp7.js} +1 -1
  118. package/static/app/assets/{hard-drive-D20iavUb.js → hard-drive-DDzET7lk.js} +1 -1
  119. package/static/app/assets/index-CB93CZWW.css +2 -0
  120. package/static/app/assets/index-D2H-wSl6.js +13 -0
  121. package/static/app/assets/input-Df1CAY_I.js +1 -0
  122. package/static/app/assets/jsx-runtime-bzQ4Vb5N.js +1 -0
  123. package/static/app/assets/{link-2-BPJOFlAy.js → link-2-xNnTIX1_.js} +1 -1
  124. package/static/app/assets/{permissionCopy-ChdJd493.js → permissionCopy-D3aWHco-.js} +1 -1
  125. package/static/app/assets/primitives-BioD2slS.js +1 -0
  126. package/static/app/assets/search-BzBw8YcW.js +1 -0
  127. package/static/app/assets/{share-2-YNX_NtMU.js → share-2-FkzGf8Df.js} +1 -1
  128. package/static/app/assets/{shield-alert-DuQ3zrVL.js → shield-alert-B3dwzik4.js} +1 -1
  129. package/static/app/assets/sourceMeta-DQSY_tah.js +1 -0
  130. package/static/app/assets/textarea-P8o6pvOP.js +1 -0
  131. package/static/app/assets/useFocusTrap-hswOIkXE.js +1 -0
  132. package/static/app/assets/useMutation-OJLrYSRA.js +1 -0
  133. package/static/app/assets/workspace-BCuk3Ku9.js +1 -0
  134. package/static/app/index.html +4 -4
  135. package/static/sw.js +1 -1
  136. package/lattice_brain/ingestion/pipeline.py +0 -108
  137. package/latticeai/api/local_files.py +0 -44
  138. package/latticeai/api/tools.py +0 -126
  139. package/latticeai/api/voice_capture.py +0 -32
  140. package/latticeai/core/agent_permission.py +0 -85
  141. package/scripts/agent_eval.py +0 -34
  142. package/scripts/brain_quality_eval.py +0 -37
  143. package/scripts/check_legacy_debt.mjs +0 -91
  144. package/scripts/check_python.py +0 -100
  145. package/scripts/chunking_parity_corpus.py +0 -449
  146. package/scripts/generate_agent_parity_fixtures.py +0 -771
  147. package/scripts/generate_chunking_parity_fixtures.py +0 -259
  148. package/static/app/assets/Act-BPcVAbOL.js +0 -1
  149. package/static/app/assets/AdminConsole-Bw1ATQL0.js +0 -1
  150. package/static/app/assets/Brain-CT92Kos0.js +0 -321
  151. package/static/app/assets/BrainHome-CFBkt1K_.js +0 -2
  152. package/static/app/assets/BrainSignals-ReLWF2H8.js +0 -1
  153. package/static/app/assets/Capture-BsTokYkk.js +0 -1
  154. package/static/app/assets/Chronicle-B6f0T9id.js +0 -1
  155. package/static/app/assets/CommandPalette-CuvjTv1u.js +0 -1
  156. package/static/app/assets/Library-BGJbG9Hd.js +0 -1
  157. package/static/app/assets/LivingBrain-DGYK_Jsa.js +0 -1
  158. package/static/app/assets/ProductFlow-DXBC6brE.js +0 -1
  159. package/static/app/assets/System-CMHSO9qM.js +0 -1
  160. package/static/app/assets/arrow-left-BfmkskWx.js +0 -1
  161. package/static/app/assets/brain-CQJberbE.js +0 -1
  162. package/static/app/assets/button-Ct9f2_oT.js +0 -1
  163. package/static/app/assets/circle-check-DruOxB-4.js +0 -1
  164. package/static/app/assets/index-D9x-kSNy.css +0 -2
  165. package/static/app/assets/index-Do83hDzJ.js +0 -10
  166. package/static/app/assets/input-BLXVNmj1.js +0 -1
  167. package/static/app/assets/primitives-Cv5tbZBY.js +0 -1
  168. package/static/app/assets/search-CT9aho2j.js +0 -1
  169. package/static/app/assets/textarea-DqwLnli4.js +0 -1
  170. package/static/app/assets/useFocusTrap-ZVI98jaW.js +0 -1
  171. package/static/app/assets/useMutation-CVC4qv_D.js +0 -1
  172. package/static/app/assets/useQuery-C7BeG4HU.js +0 -1
  173. package/static/app/assets/utils-CiFtIdZq.js +0 -4
  174. package/static/app/assets/workspace-DQz9vIId.js +0 -1
@@ -1,9 +1,27 @@
1
- """Password hashing, rate limiting, IP detection, file-magic validation."""
1
+ """Token hashing, secret redaction, and the worker's per-user rate limiter.
2
+
3
+ What is *not* here any more, and why: password hashing / verification, the
4
+ ``client_ip`` resolver with its per-IP login limiter, and the file-magic
5
+ extension check were all ported to ``lattice-auth`` / ``lattice-chat`` in
6
+ v11.6.0, when the front door and every write moved to Rust. They were left
7
+ behind as fully orphaned Python — no route, no service, no script called them
8
+ — and a second copy of an authentication rule that nothing enforces is worse
9
+ than no copy: it reads like a live guard.
10
+
11
+ What stays, stays because something still calls it:
12
+
13
+ * :func:`enforce_rate_limit` — charged by every ``/worker/*`` and ``/agent/*``
14
+ seam through ``_admit``.
15
+ * :func:`redact_secret_text` / :func:`redact_secrets` — and they additionally
16
+ generate the Rust redaction fixture via ``scripts/gen_redact_fixture.py``.
17
+ * the trusted-proxy allowlist — ``_peer_is_trusted_proxy`` is what
18
+ :mod:`latticeai.core.http_origin` (and through it the CSRF check) asks before
19
+ believing an ``X-Forwarded-Host``.
20
+ """
2
21
 
3
22
  import hashlib
4
23
  import ipaddress
5
24
  import re
6
- import secrets
7
25
  import threading
8
26
  import time
9
27
  from typing import Any, Dict, List
@@ -26,21 +44,6 @@ def sha256_hex(text: str) -> str:
26
44
  return hashlib.sha256(text.encode("utf-8")).hexdigest()
27
45
 
28
46
 
29
- def hash_password(password: str) -> str:
30
- salt = secrets.token_hex(16)
31
- key = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1)
32
- return f"{salt}:{key.hex()}"
33
-
34
-
35
- def verify_password(password: str, hashed: str) -> bool:
36
- try:
37
- salt, key_hex = hashed.split(":", 1)
38
- key = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1)
39
- return secrets.compare_digest(key.hex(), key_hex)
40
- except Exception:
41
- return False
42
-
43
-
44
47
  def host_is_loopback(host: str) -> bool:
45
48
  if host in {"localhost", "127.0.0.1", "::1"}:
46
49
  return True
@@ -50,15 +53,15 @@ def host_is_loopback(host: str) -> bool:
50
53
  return False
51
54
 
52
55
 
53
- # ── Trusted-proxy handling ────────────────────────────────────────────────────
54
- # ``client_ip`` is the key used for IP rate limiting (login / register) and for
55
- # audit logging. A forwarded header (``X-Forwarded-For`` / ``CF-Connecting-IP``)
56
- # is *client-controllable*, so honoring it unconditionally lets anyone spoof
57
- # their source IP and bypass per-IP rate limits. We therefore trust those headers
58
- # ONLY when the direct peer is a configured trusted proxy (e.g. the Cloudflare /
59
- # Vercel edge in front of the app). Default: no trusted proxies → use the peer
60
- # address, which is the safe, local-first behaviour.
61
- _FORWARDED_HEADERS = ("CF-Connecting-IP", "X-Forwarded-For")
56
+ # ── Trusted-proxy allowlist ──────────────────────────────────────────────────
57
+ # The per-IP login limiter this list was born for is ``lattice-auth``'s now, and
58
+ # ``client_ip`` went with it. The list itself is still load-bearing on this
59
+ # side: :func:`latticeai.core.http_origin.peer_may_forward` — and through it the
60
+ # CSRF front-door check — asks ``_peer_is_trusted_proxy`` whether an
61
+ # ``X-Forwarded-Host`` from this peer may be believed. Off loopback the answer
62
+ # is "only for an operator-listed proxy", which is what makes forging a front
63
+ # door from another machine impossible. ``configure_trusted_proxies`` is the one
64
+ # way that list is ever non-empty, so it stays with the guard it feeds.
62
65
  _trusted_proxies: List["ipaddress._BaseNetwork"] = []
63
66
 
64
67
 
@@ -96,46 +99,6 @@ def _peer_is_trusted_proxy(peer: str) -> bool:
96
99
  return any(addr in net for net in _trusted_proxies)
97
100
 
98
101
 
99
- def client_ip(request) -> str:
100
- peer = request.client.host if request.client else ""
101
- # Only a trusted proxy's forwarded headers are honoured; otherwise the
102
- # client-supplied header is ignored so per-IP rate limits cannot be spoofed.
103
- if _peer_is_trusted_proxy(peer):
104
- for header in _FORWARDED_HEADERS:
105
- val = request.headers.get(header)
106
- if val:
107
- candidate = val.split(",")[0].strip()
108
- try:
109
- ipaddress.ip_address(candidate)
110
- return candidate
111
- except ValueError:
112
- quiet()
113
- continue
114
- return peer or "unknown"
115
-
116
-
117
- _FILE_MAGIC: Dict[str, List[bytes]] = {
118
- ".pdf": [b"%PDF-"],
119
- ".docx": [b"PK\x03\x04"],
120
- ".xlsx": [b"PK\x03\x04"],
121
- ".pptx": [b"PK\x03\x04"],
122
- ".zip": [b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"],
123
- ".png": [b"\x89PNG\r\n\x1a\n"],
124
- ".jpg": [b"\xff\xd8\xff"],
125
- ".jpeg": [b"\xff\xd8\xff"],
126
- ".gif": [b"GIF87a", b"GIF89a"],
127
- }
128
-
129
-
130
- def bytes_match_extension(data: bytes, ext: str) -> bool:
131
- ext = (ext or "").lower()
132
- signatures = _FILE_MAGIC.get(ext)
133
- if not signatures:
134
- return True
135
- head = data[:16]
136
- return any(head.startswith(sig) for sig in signatures)
137
-
138
-
139
102
  SECRET_KEY_HINTS = (
140
103
  "api_key",
141
104
  "apikey",
@@ -209,23 +172,6 @@ def redact_secrets(value: Any) -> Any:
209
172
  return value
210
173
 
211
174
 
212
- # ── IP-based rate limiting (registration / login) ────────────────────────────
213
- _ip_rate_windows: dict = {}
214
- _ip_rate_lock = threading.Lock()
215
-
216
-
217
- def check_ip_rate_limit(ip: str, action: str, max_calls: int, window_secs: float) -> None:
218
- key = (ip, action)
219
- now = time.time()
220
- cutoff = now - window_secs
221
- with _ip_rate_lock:
222
- calls = [t for t in _ip_rate_windows.get(key, []) if t > cutoff]
223
- if len(calls) >= max_calls:
224
- raise HTTPException(status_code=429, detail="요청이 너무 많습니다. 잠시 후 다시 시도하세요.")
225
- calls.append(now)
226
- _ip_rate_windows[key] = calls
227
-
228
-
229
175
  # ── Per-user token-bucket rate limiting ──────────────────────────────────────
230
176
  _RATE_LIMITS = {
231
177
  "chat": (30, 0.5),
@@ -4,6 +4,21 @@ v4: bearer tokens are stored **hashed** (sha256) at rest — a process that can
4
4
  read ``sessions.json`` must not be able to hijack every session. Pre-v4 files
5
5
  holding raw tokens are migrated transparently on load (sessions survive the
6
6
  upgrade; the raw token never touches disk again).
7
+
8
+ 11.8.0: the in-memory map is a **cache of the file, not the file itself.**
9
+ Since v11.6.0 the writer is ``lattice-auth``: it creates the session and
10
+ appends it to ``sessions.json``, and this process only ever reads. Loading
11
+ once in ``__init__`` therefore made every login that happened after worker
12
+ boot invisible here — silently under ``trusted_local_owner`` (the anonymous
13
+ owner path answers first), and as a flat 401 under
14
+ ``LATTICEAI_REQUIRE_AUTH=true``, for a token that is sitting in the file.
15
+
16
+ A lookup that misses now re-reads the file before giving up. Two guards keep
17
+ that from turning a token-guessing burst into a disk-read burst: the re-read
18
+ is skipped when the file's ``mtime_ns``/``size`` are unchanged since the last
19
+ load, and — for a file that *is* changing — throttled to one parse per
20
+ ``SESSION_RELOAD_MIN_INTERVAL``. Both are checked under the same lock that
21
+ guards the map, so concurrent misses collapse into one read.
7
22
  """
8
23
 
9
24
  import json
@@ -19,6 +34,11 @@ from latticeai.core.security import sha256_hex
19
34
 
20
35
  SESSION_TTL = 60 * 60 * 24 # 24 hours
21
36
  SESSION_REFRESH_THRESHOLD = 60 * 15 # only persist if >15 min since last bump
37
+ #: Floor between two miss-triggered re-reads of ``sessions.json``. A wrong or
38
+ #: expired token is the common case for an unauthenticated probe, and each one
39
+ #: misses; without a floor a burst of them would be a burst of file reads.
40
+ #: Well under any human's retry, so a real login is still picked up at once.
41
+ SESSION_RELOAD_MIN_INTERVAL = 1.0
22
42
  _lock = threading.Lock()
23
43
 
24
44
  _HEX64 = frozenset("0123456789abcdef")
@@ -79,6 +99,19 @@ def persist_sessions(sessions: Dict[str, tuple], data_dir: Optional[Path] = None
79
99
  logging.warning("persist_sessions failed: %s", e)
80
100
 
81
101
 
102
+ def _sessions_stamp(data_dir: Optional[Path] = None) -> Optional[tuple]:
103
+ """``(mtime_ns, size)`` of ``sessions.json``, or ``None`` when unreadable.
104
+
105
+ ``None`` is *not* "unchanged": a file that has just appeared and one that
106
+ was just removed both need the map rebuilt, and both land here.
107
+ """
108
+ try:
109
+ stat = _sessions_file(data_dir).stat()
110
+ except OSError:
111
+ return None
112
+ return (stat.st_mtime_ns, stat.st_size)
113
+
114
+
82
115
  def _entry_subject(entry: tuple) -> Optional[str]:
83
116
  return entry[0] if entry else None
84
117
 
@@ -107,12 +140,63 @@ class SessionStore:
107
140
  self._ttl_seconds = int(ttl_seconds or SESSION_TTL)
108
141
  self._refresh_threshold_seconds = int(refresh_threshold_seconds or SESSION_REFRESH_THRESHOLD)
109
142
  self._sessions: Dict[str, tuple] = load_sessions(data_dir)
143
+ # Stamped *after* the load, so a pre-v4 file that ``load_sessions``
144
+ # rewrote on the way in is not mistaken for a foreign write.
145
+ self._loaded_stamp: Optional[tuple] = _sessions_stamp(data_dir)
146
+ # ``-inf`` and not "now": the first *changed* file must be free to
147
+ # load, or a login that lands in the same second as the worker's boot
148
+ # stays invisible for a second for no reason.
149
+ self._last_reload_at: float = float("-inf")
150
+
151
+ def _reload_if_stale(self, *, force: bool = False) -> bool:
152
+ """Re-read ``sessions.json`` when it has changed. Caller holds ``_lock``.
153
+
154
+ Returns whether the map was replaced. The two guards answer two
155
+ different costs, in the order that keeps both cheap:
156
+
157
+ * the **stamp** runs first and on every miss. It is one ``stat``, and
158
+ when the file has not moved — the overwhelming case, since a wrong
159
+ token is what an unauthenticated probe sends — that is the entire
160
+ cost. No parse, no allocation.
161
+ * the **interval** runs only once the file *has* moved, and bounds how
162
+ often a busy file is actually parsed. It never drops the change: the
163
+ stamp is recorded only when the load really happened, so the next
164
+ miss past the interval still sees the file as new.
165
+
166
+ ``force`` skips the interval only. It is for the two paths that are
167
+ about to *write* the file: a throttled read there would merge onto a
168
+ stale map and drop somebody else's session, and a login or a logout is
169
+ rare enough that the read costs nothing worth counting.
170
+ """
171
+ stamp = _sessions_stamp(self._data_dir)
172
+ if stamp == self._loaded_stamp:
173
+ return False
174
+ now = time.monotonic()
175
+ if not force and now - self._last_reload_at < SESSION_RELOAD_MIN_INTERVAL:
176
+ return False
177
+ self._last_reload_at = now
178
+ self._sessions = load_sessions(self._data_dir)
179
+ self._loaded_stamp = stamp
180
+ return True
181
+
182
+ def _persist(self) -> None:
183
+ """Write the map, then re-stamp. Caller holds ``_lock``.
184
+
185
+ Re-stamping matters: without it our own write looks like somebody
186
+ else's the next time a lookup misses, and the store re-reads the file
187
+ it just produced.
188
+ """
189
+ persist_sessions(self._sessions, self._data_dir)
190
+ self._loaded_stamp = _sessions_stamp(self._data_dir)
110
191
 
111
192
  def create(self, subject: str, *, email: Optional[str] = None) -> str:
112
193
  token = secrets.token_urlsafe(32)
113
194
  with _lock:
195
+ # Merge onto whatever is on disk rather than over it — this process
196
+ # is not the only writer.
197
+ self._reload_if_stale(force=True)
114
198
  self._sessions[_hash_token(token)] = (subject, time.time(), email or subject)
115
- persist_sessions(self._sessions, self._data_dir)
199
+ self._persist()
116
200
  return token
117
201
 
118
202
  def get_email(self, token: str) -> Optional[str]:
@@ -128,21 +212,28 @@ class SessionStore:
128
212
  key = _hash_token(token)
129
213
  with _lock:
130
214
  entry = self._sessions.get(key)
215
+ if entry is None and self._reload_if_stale():
216
+ # A miss is the only thing that can be wrong because the map is
217
+ # stale: an entry we *do* hold was really issued, and one we
218
+ # hold that the file no longer has is handled by the TTL and by
219
+ # ``active_session_email``'s account check on every request.
220
+ entry = self._sessions.get(key)
131
221
  if entry is None:
132
222
  return None
133
223
  created_at = _entry_created_at(entry)
134
224
  if now - created_at > self._ttl_seconds:
135
225
  self._sessions.pop(key, None)
136
- persist_sessions(self._sessions, self._data_dir)
226
+ self._persist()
137
227
  return None
138
228
  if now - created_at > self._refresh_threshold_seconds:
139
229
  refreshed = (_entry_subject(entry), now, _entry_email(entry))
140
230
  self._sessions[key] = refreshed
141
- persist_sessions(self._sessions, self._data_dir)
231
+ self._persist()
142
232
  return refreshed
143
233
  return entry
144
234
 
145
235
  def invalidate(self, token: str) -> None:
146
236
  with _lock:
237
+ self._reload_if_stale(force=True)
147
238
  self._sessions.pop(_hash_token(token), None)
148
- persist_sessions(self._sessions, self._data_dir)
239
+ self._persist()
@@ -4,9 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import shutil
7
- import sqlite3
8
7
  import uuid
9
- from contextlib import closing
10
8
  from pathlib import Path
11
9
  from typing import Any, Dict, Optional
12
10
 
@@ -83,11 +81,6 @@ def load_users_file(path: Path) -> Dict[str, Any]:
83
81
  return migrated
84
82
 
85
83
 
86
- def save_users_file(path: Path, users: Dict[str, Any]) -> None:
87
- migrated, _, _ = migrate_users(users)
88
- atomic_write_json(path, migrated)
89
-
90
-
91
84
  def user_id_for_email(users: Dict[str, Any], email: Optional[str]) -> Optional[str]:
92
85
  if not email:
93
86
  return None
@@ -98,34 +91,3 @@ def user_id_for_email(users: Dict[str, Any], email: Optional[str]) -> Optional[s
98
91
  if isinstance(user, dict):
99
92
  return user.get("id") or stable_user_id(normalized)
100
93
  return stable_user_id(normalized)
101
-
102
-
103
- def migrate_knowledge_graph_identity(db_path: Path, email_to_id: Dict[str, str]) -> int:
104
- """Rewrite KG owner/creator identity columns from email to stable UUIDs."""
105
- if not db_path.exists() or not email_to_id:
106
- return 0
107
- changed = 0
108
- with closing(sqlite3.connect(db_path)) as conn, conn:
109
- tables = {
110
- row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
111
- }
112
- for email, user_id in email_to_id.items():
113
- normalized = normalize_email(email)
114
- if "nodes_v2" in tables:
115
- cur = conn.execute("UPDATE nodes_v2 SET owner_id=? WHERE LOWER(owner_id)=?", (user_id, normalized))
116
- changed += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
117
- if "edges_v2" in tables:
118
- cur = conn.execute("UPDATE edges_v2 SET created_by=? WHERE LOWER(created_by)=?", (user_id, normalized))
119
- changed += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
120
- if "ingestion_provenance" in tables:
121
- cur = conn.execute("UPDATE ingestion_provenance SET owner=? WHERE LOWER(owner)=?", (user_id, normalized))
122
- changed += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
123
- if changed:
124
- conn.execute(
125
- "CREATE TABLE IF NOT EXISTS kg_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
126
- )
127
- conn.execute(
128
- "INSERT OR REPLACE INTO kg_meta(key, value) VALUES('identity_uuid_migrated_at', ?)",
129
- (_now(),),
130
- )
131
- return changed
@@ -0,0 +1,61 @@
1
+ """Search-side vector index backends (HNSW append + query).
2
+
3
+ ``POST /worker/vector/query`` serves this sidecar. ``lattice-retrieval``
4
+ asks for top-k candidates when ``LATTICEAI_VECTOR_INDEX=hnsw`` and
5
+ re-scores them exactly. Default env stays the brute scan.
6
+
7
+ This package is the Python HNSW sidecar: a disposable ``.hnsw`` graph
8
+ next to the brain database, with incremental ``add_items`` so a write no
9
+ longer rebuilds the whole graph.
10
+
11
+ Rebuild (not append) when:
12
+
13
+ * the embedder identity (``model_id`` / dim / space) changed
14
+ * any previously indexed id is absent (deletion). Tombstones exist in
15
+ hnswlib (``mark_deleted``) but a deletion-heavy store (more than
16
+ :data:`DELETE_REBUILD_RATIO` of the graph, or any delete when the
17
+ sidecar was loaded without source vectors) is cheaper to rebuild
18
+ than to fragment.
19
+ """
20
+
21
+ from .hnsw import (
22
+ DELETE_REBUILD_RATIO,
23
+ HNSW_BACKEND,
24
+ HNSW_META_SUFFIX,
25
+ HNSW_SUFFIX,
26
+ HNSWLIB_MODULE,
27
+ HnswIndex,
28
+ hnswlib_available,
29
+ load_hnswlib,
30
+ sidecar_paths,
31
+ )
32
+ from .sidecar import (
33
+ GRAPH_DB_NAME,
34
+ VECTOR_QUERY_K_CAP,
35
+ decode_f32le,
36
+ query_sidecar,
37
+ reset_sidecar_cache,
38
+ resolve_graph_db,
39
+ sidecar_fingerprint,
40
+ sidecar_freshness,
41
+ )
42
+
43
+ __all__ = [
44
+ "DELETE_REBUILD_RATIO",
45
+ "GRAPH_DB_NAME",
46
+ "HNSW_BACKEND",
47
+ "HNSW_META_SUFFIX",
48
+ "HNSW_SUFFIX",
49
+ "HNSWLIB_MODULE",
50
+ "HnswIndex",
51
+ "VECTOR_QUERY_K_CAP",
52
+ "decode_f32le",
53
+ "hnswlib_available",
54
+ "load_hnswlib",
55
+ "query_sidecar",
56
+ "reset_sidecar_cache",
57
+ "resolve_graph_db",
58
+ "sidecar_fingerprint",
59
+ "sidecar_freshness",
60
+ "sidecar_paths",
61
+ ]