ltcai 10.6.3 → 10.8.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 (95) hide show
  1. package/README.md +56 -59
  2. package/docs/CHANGELOG.md +68 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/LAYOUT_REBUILD_SPEC.md +810 -0
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/OPERATIONS.md +29 -1
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/graph/retrieval_vector.py +69 -33
  13. package/lattice_brain/runtime/multi_agent.py +96 -15
  14. package/latticeai/__init__.py +1 -1
  15. package/latticeai/api/admin.py +81 -6
  16. package/latticeai/api/auth.py +37 -38
  17. package/latticeai/api/automation_intelligence.py +112 -0
  18. package/latticeai/api/browser.py +7 -6
  19. package/latticeai/api/knowledge_graph.py +133 -0
  20. package/latticeai/api/static_routes.py +35 -1
  21. package/latticeai/core/file_generation.py +141 -8
  22. package/latticeai/core/legacy_compatibility.py +1 -1
  23. package/latticeai/core/marketplace.py +1 -1
  24. package/latticeai/core/messages.py +230 -0
  25. package/latticeai/core/workspace_computer_memory.py +84 -0
  26. package/latticeai/core/workspace_indexing.py +102 -0
  27. package/latticeai/core/workspace_onboarding.py +104 -0
  28. package/latticeai/core/workspace_os.py +27 -207
  29. package/latticeai/core/workspace_os_constants.py +1 -1
  30. package/latticeai/core/workspace_relationships.py +99 -0
  31. package/latticeai/core/workspace_runs.py +111 -0
  32. package/latticeai/services/architecture_readiness.py +1 -1
  33. package/latticeai/services/product_readiness.py +1 -1
  34. package/package.json +5 -3
  35. package/scripts/capture_release_evidence.mjs +335 -1
  36. package/scripts/check_current_release_docs.mjs +1 -1
  37. package/scripts/check_release_evidence_bound.mjs +116 -0
  38. package/scripts/check_screenshot_pixel_delta.py +517 -0
  39. package/scripts/measure_brain_home_fill.mjs +141 -0
  40. package/scripts/release_screen_claims.json +37 -0
  41. package/src-tauri/Cargo.lock +1 -1
  42. package/src-tauri/Cargo.toml +1 -1
  43. package/src-tauri/tauri.conf.json +1 -1
  44. package/static/app/asset-manifest.json +37 -39
  45. package/static/app/assets/{Act-05uZSgyw.js → Act-CbQNsFgc.js} +1 -1
  46. package/static/app/assets/AdminConsole-Bs8SR8fE.js +1 -0
  47. package/static/app/assets/{Brain-jjMYTdnU.js → Brain-BZIPsZlH.js} +2 -2
  48. package/static/app/assets/BrainHome-BaH5MDce.js +2 -0
  49. package/static/app/assets/{BrainSignals-BYxDqBH3.js → BrainSignals-aDt-nSsW.js} +1 -1
  50. package/static/app/assets/Capture-DDv940Bv.js +1 -0
  51. package/static/app/assets/{CommandPalette-Cj7z-qUE.js → CommandPalette-Dq_MmPBl.js} +1 -1
  52. package/static/app/assets/Library-BkShbqmN.js +1 -0
  53. package/static/app/assets/{LivingBrain-CTokkZn1.js → LivingBrain-CXb8094H.js} +1 -1
  54. package/static/app/assets/ProductFlow-BaiqFWLO.js +1 -0
  55. package/static/app/assets/{ReviewCard-uAII_l9v.js → ReviewCard-Be7E7X18.js} +2 -2
  56. package/static/app/assets/System-DCstbTxC.js +1 -0
  57. package/static/app/assets/arrow-left-DC9Oquhs.js +1 -0
  58. package/static/app/assets/{bot-CkrHFdUl.js → bot-BR0eoeXY.js} +1 -1
  59. package/static/app/assets/brain-BmWa1wea.js +1 -0
  60. package/static/app/assets/{button-Ic6omIbs.js → button-U1nU7xX4.js} +1 -1
  61. package/static/app/assets/{circle-pause-DBaaUR08.js → circle-pause-DG5KHjDL.js} +1 -1
  62. package/static/app/assets/{circle-play-Dz0OLYN9.js → circle-play-Chhcuq90.js} +1 -1
  63. package/static/app/assets/{cpu-N8uE9ono.js → cpu-pkOSlT9h.js} +1 -1
  64. package/static/app/assets/{download-CCjhOjms.js → download-CSRwz923.js} +1 -1
  65. package/static/app/assets/{folder-open-Bld9TtHp.js → folder-open-DiMJ7W_7.js} +1 -1
  66. package/static/app/assets/{hard-drive-BMIqDSyY.js → hard-drive-iyLPAPdp.js} +1 -1
  67. package/static/app/assets/index-BZRvGOM2.css +2 -0
  68. package/static/app/assets/{index-yNtiRyw3.js → index-_ldE9TAX.js} +4 -4
  69. package/static/app/assets/{input-DW1tXtc0.js → input-e_r2hFAY.js} +1 -1
  70. package/static/app/assets/{permissionCopy-Djpwn54H.js → permissionCopy-9uOHktz3.js} +1 -1
  71. package/static/app/assets/{primitives-B6WtkLgB.js → primitives-CnEAOmg2.js} +1 -1
  72. package/static/app/assets/search-veep4n6f.js +1 -0
  73. package/static/app/assets/{share-2-DR5L1qRc.js → share-2-DLDJJKmI.js} +1 -1
  74. package/static/app/assets/{shield-alert-CVE5eXsl.js → shield-alert-BTtE-2EZ.js} +1 -1
  75. package/static/app/assets/{textarea-DdaVFgvk.js → textarea-Bbzkcgqe.js} +1 -1
  76. package/static/app/assets/{useFocusTrap-B5TyGaiB.js → useFocusTrap-DJzQd-Za.js} +1 -1
  77. package/static/app/assets/{useQuery-CiRdTQlD.js → useQuery-6R_tqHRe.js} +1 -1
  78. package/static/app/assets/utils-XDsjcrhS.js +4 -0
  79. package/static/app/assets/workspace-CwrG8LHq.js +1 -0
  80. package/static/app/index.html +4 -4
  81. package/static/sw.js +1 -1
  82. package/static/app/assets/AdminConsole-C8yz1Cs_.js +0 -1
  83. package/static/app/assets/BrainHome-D3t45_D4.js +0 -2
  84. package/static/app/assets/Capture-DKgu7fB0.js +0 -1
  85. package/static/app/assets/Library-CO76FWeO.js +0 -1
  86. package/static/app/assets/ProductFlow-Bmted2ys.js +0 -1
  87. package/static/app/assets/System-PUMzDmof.js +0 -1
  88. package/static/app/assets/activity-C_7182GG.js +0 -1
  89. package/static/app/assets/arrow-left-BQCpWR37.js +0 -1
  90. package/static/app/assets/brain-CdPX7svt.js +0 -1
  91. package/static/app/assets/index-DYUs0cWy.css +0 -2
  92. package/static/app/assets/search-CkHYuKpY.js +0 -1
  93. package/static/app/assets/users-pZqCRxdD.js +0 -1
  94. package/static/app/assets/utils-C3rXoa-U.js +0 -4
  95. package/static/app/assets/workspace-DfxKEttN.js +0 -1
@@ -312,9 +312,54 @@ def validate_file_content(content: str, target_path: str) -> Tuple[bool, str]:
312
312
  if "```" in content:
313
313
  return False, "output still contains Markdown fences"
314
314
  return True, "ok"
315
+ # Prose types (.md, .txt, .csv, …) have no grammar to check, which used to
316
+ # mean *nothing* was checked: a 1–4B model that answered "Sure! Here is the
317
+ # document you asked for:" and stopped had its sentence saved as the file,
318
+ # because the fence stripper only removes conversational lines it can
319
+ # recognise and the length guard on `looks_like_refusal` lets a wordy
320
+ # refusal through. The two checks below are the only ones that generalise
321
+ # without inventing a grammar: it must not still be wearing fences, and it
322
+ # must not be *only* an answer about the file.
323
+ if "```" in content:
324
+ return False, "output still contains Markdown fences"
325
+ if _looks_like_commentary(content):
326
+ return False, "the reply talks about the file instead of being the file"
315
327
  return True, "ok"
316
328
 
317
329
 
330
+ # Openers that mean "I am about to give you the thing" — if the whole reply is
331
+ # one of these, the thing never arrived.
332
+ _COMMENTARY_RE = re.compile(
333
+ r"^\s*("
334
+ r"(sure|of course|certainly|okay|ok|alright|here|below|the following)\b"
335
+ r"|i('ve| have| will|'ll)\b"
336
+ r"|(물론|네[,!. ]|알겠|다음은|아래(는|의)?|요청하신|원하시는)"
337
+ r")",
338
+ re.IGNORECASE,
339
+ )
340
+
341
+
342
+ def _looks_like_commentary(content: str) -> bool:
343
+ """True when the reply reads as an answer *about* a file, not the file.
344
+
345
+ Deliberately conservative — a long document that merely opens with "The
346
+ following" is a document. Only a short reply that both opens
347
+ conversationally and never grows into content is rejected, so a real file
348
+ is never thrown away to catch a chat line.
349
+ """
350
+ stripped = content.strip()
351
+ if len(stripped) > 400:
352
+ return False
353
+ if not _COMMENTARY_RE.match(stripped):
354
+ return False
355
+ # Structure means content arrived after the preamble: a heading, a list, a
356
+ # table row, a delimiter, or simply several lines of body text.
357
+ body = stripped.split("\n", 1)[1].strip() if "\n" in stripped else ""
358
+ if re.search(r"^\s*(#{1,6}\s|[-*+]\s|\d+[.)]\s|\||>)", body, re.MULTILINE):
359
+ return False
360
+ return len(body) < 120
361
+
362
+
318
363
  # ── prompting ───────────────────────────────────────────────────────────
319
364
 
320
365
  _FIRST_LINE_HINTS: Dict[str, str] = {
@@ -869,14 +914,28 @@ async def generate_file_content(
869
914
  """Generate validated file content with any LLM.
870
915
 
871
916
  ``generate`` is an async callable ``context -> raw model text``. Runs up
872
- to ``max_attempts`` model calls (the second with corrective feedback),
917
+ to ``max_attempts`` model calls (each retry carrying corrective feedback),
873
918
  then falls back to deterministic repair, so the returned content is
874
919
  always non-empty and structurally valid for the target type.
920
+
921
+ One extra call beyond ``max_attempts`` is spent — at most once per
922
+ request — when the model has returned a byte-identical rejected reply.
923
+ That is the one case where the ordinary retry is known to be dead on
924
+ arrival: the corrective feedback did not change the reply, so the budget
925
+ is better spent on a prompt that names the repetition than on a third
926
+ identical round trip. Small local models hit this constantly; large ones
927
+ never do, so the extra call is not charged to models that do not need it.
875
928
  """
876
929
  attempts: List[Dict[str, Any]] = []
877
930
  feedback: Optional[str] = None
878
- last_candidate = ""
879
- for attempt in range(1, max_attempts + 1):
931
+ best_candidate = ""
932
+ best_score = (-1, -1)
933
+ seen: set[str] = set()
934
+ escalations_left = 1
935
+ attempt = 0
936
+ budget = max_attempts
937
+ while attempt < budget:
938
+ attempt += 1
880
939
  context = build_file_generation_context(
881
940
  target_path, user_request, feedback=feedback, bundle_files=bundle_files,
882
941
  )
@@ -888,16 +947,90 @@ async def generate_file_content(
888
947
  continue
889
948
  candidate = extract_file_content(str(raw or ""), target_path)
890
949
  ok, reason = validate_file_content(candidate, target_path)
891
- attempts.append({"attempt": attempt, "valid": ok, "reason": reason})
950
+ record: Dict[str, Any] = {"attempt": attempt, "valid": ok, "reason": reason}
892
951
  if ok:
952
+ attempts.append(record)
893
953
  return candidate, {"attempts": attempts, "repaired": False}
894
- if len(candidate) > len(last_candidate):
895
- last_candidate = candidate
896
- feedback = reason
897
- repaired = repair_file_content(last_candidate, target_path, user_request)
954
+
955
+ # A small model handed the same corrective feedback often replays the
956
+ # same reply verbatim. Saying "you sent this before" is the only signal
957
+ # left that has any chance of moving it, and it makes the wasted retry
958
+ # visible in the trace instead of looking like two genuine tries.
959
+ fingerprint = candidate.strip()
960
+ repeated = fingerprint in seen and bool(fingerprint)
961
+ record["repeated"] = repeated
962
+ seen.add(fingerprint)
963
+ if repeated and escalations_left and attempt >= budget:
964
+ # The retry budget is exhausted and the last thing it bought was a
965
+ # duplicate. Buy one more, but only with a prompt that says so.
966
+ escalations_left -= 1
967
+ budget += 1
968
+ record["escalated"] = True
969
+ attempts.append(record)
970
+
971
+ # Keep the candidate that is *closest to a file*, not the longest one.
972
+ # Longest-wins handed repair a 900-character apology in preference to a
973
+ # 300-character HTML document that only needed its </html> closing —
974
+ # and repair can finish the document but can only bury the apology.
975
+ score = _salvage_score(candidate, target_path)
976
+ if score > best_score:
977
+ best_score, best_candidate = score, candidate
978
+
979
+ feedback = (
980
+ f"{reason}. You already sent exactly this reply and it was rejected "
981
+ "for the same reason — do not repeat it. Output the file itself, "
982
+ "starting at its first character."
983
+ if repeated else reason
984
+ )
985
+ repaired = repair_file_content(best_candidate, target_path, user_request)
898
986
  return repaired, {"attempts": attempts, "repaired": True}
899
987
 
900
988
 
989
+ def _salvage_score(candidate: str, target_path: str) -> Tuple[int, int]:
990
+ """How useful an invalid candidate is as raw material for repair.
991
+
992
+ ``(tier, length)`` — tier first, so a short real document always beats a
993
+ long non-document; length breaks ties within a tier.
994
+
995
+ Tier 2 something of the right shape that repair can finish (an HTML
996
+ document missing its close tag, parseable-ish JSON, Python that
997
+ at least tokenises).
998
+ Tier 1 ordinary text: no structure, but the words may be the content.
999
+ Tier 0 a refusal — repair should prefer literally anything else, because
1000
+ an apology written into the file is worse than an empty stub.
1001
+ """
1002
+ text = candidate.strip()
1003
+ if not text:
1004
+ return (0, 0)
1005
+ if looks_like_refusal(text):
1006
+ return (0, len(text))
1007
+
1008
+ ext = _ext(target_path)
1009
+ lower = text.lower()
1010
+ if ext in (".html", ".htm"):
1011
+ if lower.startswith("<!doctype") or lower.startswith("<html"):
1012
+ return (2, len(text))
1013
+ elif ext == ".json":
1014
+ if _slice_json_document(text) is not None:
1015
+ return (2, len(text))
1016
+ elif ext == ".py":
1017
+ try:
1018
+ ast.parse(text)
1019
+ except SyntaxError:
1020
+ pass
1021
+ else:
1022
+ return (2, len(text))
1023
+ elif ext in _BRACED_CODE_EXTENSIONS:
1024
+ if _check_balanced_delimiters(text)[0]:
1025
+ return (2, len(text))
1026
+ elif ext in _COMPONENT_EXTENSIONS:
1027
+ if _check_component_blocks(text)[0]:
1028
+ return (2, len(text))
1029
+ elif ext == ".css" and "{" in text and "}" in text:
1030
+ return (2, len(text))
1031
+ return (1, len(text))
1032
+
1033
+
901
1034
  __all__ = [
902
1035
  "PREVIEWABLE_EXTENSIONS",
903
1036
  "build_file_generation_context",
@@ -13,7 +13,7 @@ from dataclasses import dataclass
13
13
  from pathlib import Path
14
14
  from typing import Any, Dict, List
15
15
 
16
- LEGACY_COMPATIBILITY_VERSION = "10.6.3"
16
+ LEGACY_COMPATIBILITY_VERSION = "10.8.0"
17
17
 
18
18
 
19
19
  @dataclass(frozen=True)
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
- MARKETPLACE_VERSION = "10.6.3"
13
+ MARKETPLACE_VERSION = "10.8.0"
14
14
  TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
15
15
 
16
16
 
@@ -0,0 +1,230 @@
1
+ """Server-side message catalog.
2
+
3
+ Every string the API hands a person — an ``HTTPException`` detail, a status
4
+ line, a refusal — used to be a literal at the raise site, and the literals were
5
+ written in whichever language the author happened to be thinking in. The result
6
+ was one API that answered ``"사용자를 찾을 수 없습니다."`` and, two files over,
7
+ ``"Knowledge Graph ingestion is disabled."``, so *every* user read half the
8
+ product in a language they had not chosen.
9
+
10
+ The rules here are the same three the frontend catalog follows:
11
+
12
+ 1. **One id per message.** The id is what code refers to; the wording is data.
13
+ 2. **Both languages or neither.** ``test_messages.py`` fails on a key present
14
+ in one language and missing from the other, so a new message cannot ship
15
+ half-translated.
16
+ 3. **The caller never picks the language.** It comes from the request, via
17
+ :func:`resolve_language`, so a message cannot be localized to whoever wrote
18
+ the endpoint.
19
+
20
+ Usage::
21
+
22
+ from latticeai.core.messages import http_error, resolve_language
23
+
24
+ lang = resolve_language(request)
25
+ raise http_error(404, "auth.user_not_found", lang)
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import Any, Dict, Mapping, Optional
31
+
32
+ from fastapi import HTTPException
33
+
34
+ __all__ = [
35
+ "DEFAULT_LANGUAGE",
36
+ "SUPPORTED_LANGUAGES",
37
+ "LANGUAGE_HEADER",
38
+ "MESSAGES",
39
+ "http_error",
40
+ "resolve_language",
41
+ "translate",
42
+ ]
43
+
44
+ DEFAULT_LANGUAGE = "ko"
45
+ SUPPORTED_LANGUAGES = ("ko", "en")
46
+
47
+ #: Explicit override the frontend sends. Preferred over ``Accept-Language``
48
+ #: because the person picked this language *in the product*, whereas
49
+ #: ``Accept-Language`` is whatever their browser was installed with.
50
+ LANGUAGE_HEADER = "x-lattice-language"
51
+
52
+
53
+ MESSAGES: Dict[str, Dict[str, str]] = {
54
+ # ── auth ────────────────────────────────────────────────────────────
55
+ "auth.password_too_weak": {
56
+ "ko": "비밀번호는 8자 이상이며 영문자와 숫자를 모두 포함해야 합니다.",
57
+ "en": "Your password needs at least 8 characters, including letters and numbers.",
58
+ },
59
+ "auth.invitation_required": {
60
+ "ko": "유효한 서명 초대 권한이 필요합니다.",
61
+ "en": "A valid signed invitation is required.",
62
+ },
63
+ "auth.registration_disabled": {
64
+ "ko": "회원가입이 비활성화되어 있습니다. 관리자에게 문의하세요.",
65
+ "en": "Sign-up is turned off. Ask an administrator to enable it.",
66
+ },
67
+ "auth.email_taken": {
68
+ "ko": "이미 존재하는 이메일입니다.",
69
+ "en": "That email address is already registered.",
70
+ },
71
+ "auth.bad_credentials": {
72
+ "ko": "이메일 또는 비밀번호가 틀렸습니다.",
73
+ "en": "That email address or password is not correct.",
74
+ },
75
+ "auth.account_disabled": {
76
+ "ko": "비활성화된 계정입니다.",
77
+ "en": "This account has been disabled.",
78
+ },
79
+ "auth.login_required": {
80
+ "ko": "인증이 필요합니다.",
81
+ "en": "You need to sign in first.",
82
+ },
83
+ "auth.user_not_found": {
84
+ "ko": "사용자를 찾을 수 없습니다.",
85
+ "en": "No such user.",
86
+ },
87
+ "auth.current_password_wrong": {
88
+ "ko": "현재 비밀번호가 틀렸습니다.",
89
+ "en": "Your current password is not correct.",
90
+ },
91
+ "auth.name_required": {
92
+ "ko": "이름을 입력해주세요.",
93
+ "en": "Please enter a name.",
94
+ },
95
+ "auth.nickname_required": {
96
+ "ko": "닉네임을 입력해주세요.",
97
+ "en": "Please enter a nickname.",
98
+ },
99
+ # ── SSO ─────────────────────────────────────────────────────────────
100
+ "sso.not_configured": {
101
+ "ko": "SSO가 설정되지 않았습니다.",
102
+ "en": "Single sign-on is not set up.",
103
+ },
104
+ "sso.invalid_state": {
105
+ "ko": "유효하지 않은 SSO 상태입니다.",
106
+ "en": "That sign-on request is no longer valid. Please start again.",
107
+ },
108
+ "sso.config_error": {
109
+ "ko": "SSO 설정 오류입니다.",
110
+ "en": "Single sign-on is misconfigured.",
111
+ },
112
+ "sso.no_id_token": {
113
+ "ko": "ID 토큰을 받지 못했습니다.",
114
+ "en": "The identity provider did not return an ID token.",
115
+ },
116
+ "sso.token_verification_failed": {
117
+ "ko": "SSO 토큰 검증에 실패했습니다.",
118
+ "en": "The sign-on token could not be verified.",
119
+ },
120
+ "sso.provider_verification_failed": {
121
+ "ko": "SSO 공급자 검증에 실패했습니다.",
122
+ "en": "The sign-on provider could not be verified.",
123
+ },
124
+ "sso.email_unavailable": {
125
+ "ko": "이메일을 확인할 수 없습니다.",
126
+ "en": "The identity provider did not share an email address.",
127
+ },
128
+ "sso.invitation_required": {
129
+ "ko": "신규 SSO 계정에는 유효한 서명 초대 권한이 필요합니다.",
130
+ "en": "New single sign-on accounts need a valid signed invitation.",
131
+ },
132
+ # ── admin ───────────────────────────────────────────────────────────
133
+ "admin.invalid_role": {
134
+ "ko": "role은 admin 또는 user만 가능합니다.",
135
+ "en": "Role must be either 'admin' or 'user'.",
136
+ },
137
+ "admin.cannot_disable_self": {
138
+ "ko": "자기 자신은 비활성화할 수 없습니다.",
139
+ "en": "You cannot disable your own account.",
140
+ },
141
+ "admin.cannot_delete_self": {
142
+ "ko": "자기 자신은 삭제할 수 없습니다.",
143
+ "en": "You cannot delete your own account.",
144
+ },
145
+ # ── capture / ingestion ─────────────────────────────────────────────
146
+ "capture.ingestion_disabled": {
147
+ "ko": "지식 그래프 수집이 꺼져 있습니다.",
148
+ "en": "Knowledge Graph ingestion is turned off.",
149
+ },
150
+ "capture.payload_too_large": {
151
+ "ko": "보낸 내용이 너무 큽니다.",
152
+ "en": "That capture is too large to accept.",
153
+ },
154
+ "capture.nothing_to_capture": {
155
+ "ko": "저장할 내용이 없습니다. 텍스트나 페이지 내용을 함께 보내주세요.",
156
+ "en": "Nothing to capture — send text, html, or a selection.",
157
+ },
158
+ }
159
+
160
+
161
+ def resolve_language(request: Any, default: str = DEFAULT_LANGUAGE) -> str:
162
+ """Language for this request: the product's choice, then the browser's.
163
+
164
+ Never raises and never returns an unsupported language — an endpoint that
165
+ cannot resolve a language must still be able to answer.
166
+ """
167
+ headers: Optional[Mapping[str, str]] = getattr(request, "headers", None)
168
+ if headers is None:
169
+ return default
170
+
171
+ explicit = _normalize(headers.get(LANGUAGE_HEADER))
172
+ if explicit:
173
+ return explicit
174
+
175
+ # `Accept-Language: en-GB,en;q=0.9,ko;q=0.8` — take the first tag we
176
+ # support, honouring the order the browser sent (which is the priority
177
+ # order) rather than re-sorting by q-value, since the two agree in every
178
+ # real header and disagreeing ones are malformed anyway.
179
+ for part in (headers.get("accept-language") or "").split(","):
180
+ candidate = _normalize(part.split(";", 1)[0])
181
+ if candidate:
182
+ return candidate
183
+ return default
184
+
185
+
186
+ def _normalize(value: Optional[str]) -> Optional[str]:
187
+ """``"en-GB"`` → ``"en"`` when supported, else None."""
188
+ if not value:
189
+ return None
190
+ tag = value.strip().lower().replace("_", "-")
191
+ if not tag:
192
+ return None
193
+ base = tag.split("-", 1)[0]
194
+ return base if base in SUPPORTED_LANGUAGES else None
195
+
196
+
197
+ def translate(key: str, language: str = DEFAULT_LANGUAGE, **params: Any) -> str:
198
+ """Localized text for ``key``.
199
+
200
+ An unknown key returns the key itself rather than raising: a missing
201
+ message must not turn a 404 into a 500. The namespaced key is also
202
+ recognisable in a bug report, which a generic "an error occurred" is not.
203
+ """
204
+ entry = MESSAGES.get(key)
205
+ if entry is None:
206
+ return key
207
+ text = entry.get(language) or entry.get(DEFAULT_LANGUAGE) or key
208
+ for name, value in params.items():
209
+ text = text.replace("{" + name + "}", str(value))
210
+ return text
211
+
212
+
213
+ def http_error(
214
+ status_code: int,
215
+ key: str,
216
+ language: str = DEFAULT_LANGUAGE,
217
+ *,
218
+ headers: Optional[Dict[str, str]] = None,
219
+ **params: Any,
220
+ ) -> HTTPException:
221
+ """An ``HTTPException`` whose detail is localized for this request.
222
+
223
+ Returned, not raised, so call sites keep reading ``raise http_error(...)``
224
+ and the traceback still points at the endpoint.
225
+ """
226
+ return HTTPException(
227
+ status_code=status_code,
228
+ detail=translate(key, language, **params),
229
+ headers=headers,
230
+ )
@@ -0,0 +1,84 @@
1
+ """Local Computer Memory: the opt-in that lets the Brain watch this machine.
2
+
3
+ Extracted from ``WorkspaceOSStore``. Off by default and impossible to enable
4
+ without a recorded consent — the check lives here so there is one place to read
5
+ when asking "what did this machine agree to observe".
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ from .timeutil import now_iso as _now
13
+ from .workspace_os_utils import _json_hash
14
+
15
+ __all__ = ["WorkspaceComputerMemory", "DEFAULT_COMPUTER_MEMORY_SCOPES"]
16
+
17
+ #: Folders the feature offers to watch when the person names none. Chosen to be
18
+ #: recognisable rather than exhaustive: the point of the list is that a person
19
+ #: reading the consent dialog knows what it means.
20
+ DEFAULT_COMPUTER_MEMORY_SCOPES = ("Downloads", "Documents", "Repositories")
21
+
22
+
23
+ class WorkspaceComputerMemory:
24
+ """Consent state and activity log for watching the local computer."""
25
+
26
+ def __init__(self, store: Any) -> None:
27
+ self.store = store
28
+
29
+ def configure(
30
+ self,
31
+ *,
32
+ enabled: bool,
33
+ approved_by: Optional[str],
34
+ consent: Optional[Dict[str, Any]] = None,
35
+ scopes: Optional[List[str]] = None,
36
+ ) -> Dict[str, Any]:
37
+ consent = consent or {}
38
+ if enabled and not consent.get("approved"):
39
+ raise PermissionError("Local Computer Memory requires explicit approval.")
40
+ state = self.store.load_state()
41
+ config = state.setdefault("computer_memory", {})
42
+ config.update({
43
+ "enabled": bool(enabled),
44
+ "approved": bool(enabled),
45
+ "approved_at": _now() if enabled else config.get("approved_at"),
46
+ "approved_by": approved_by if enabled else config.get("approved_by"),
47
+ "scopes": scopes or config.get("scopes") or list(DEFAULT_COMPUTER_MEMORY_SCOPES),
48
+ "consent": consent,
49
+ })
50
+ state.setdefault("feature_flags", {})["local_computer_memory"] = bool(enabled)
51
+ self.store.save_state(state)
52
+ self.store.record_timeline_event(
53
+ "memory",
54
+ "computer_memory_configured",
55
+ {"enabled": bool(enabled), "approved_by": approved_by},
56
+ )
57
+ return config
58
+
59
+ def record_activity(self, activity: Dict[str, Any], graph: Any = None) -> Dict[str, Any]:
60
+ state = self.store.load_state()
61
+ config = state.setdefault("computer_memory", {})
62
+ if not config.get("enabled"):
63
+ return {"status": "ignored", "reason": "local computer memory is disabled"}
64
+ record = {
65
+ "id": f"activity-{_json_hash([activity, _now()])[:16]}",
66
+ "timestamp": _now(),
67
+ **activity,
68
+ }
69
+ config.setdefault("activities", []).append(record)
70
+ if graph is not None:
71
+ # A graph that refuses the event must not lose the activity: the
72
+ # record is kept either way and carries the reason it did not land.
73
+ try:
74
+ graph.ingest_event(
75
+ "ComputerActivity",
76
+ str(activity.get("summary") or activity.get("path") or "Computer activity")[:120],
77
+ source="workspace_os",
78
+ metadata=record,
79
+ )
80
+ except Exception as exc:
81
+ record["graph_error"] = str(exc)
82
+ self.store.save_state(state)
83
+ self.store.record_timeline_event("memory", "computer_activity", {"activity_id": record["id"]})
84
+ return {"status": "ok", "activity": record}
@@ -0,0 +1,102 @@
1
+ """Indexing dashboard and per-source watch control.
2
+
3
+ Extracted from ``WorkspaceOSStore`` so the store stays a façade over composed
4
+ managers rather than a class that also knows how a file watcher reports itself.
5
+ Everything here reads the graph and the watcher and writes only timeline
6
+ events, so it needs the store for ``record_timeline_event`` and nothing else.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Dict, Optional
12
+
13
+ __all__ = ["WorkspaceIndexing"]
14
+
15
+
16
+ class WorkspaceIndexing:
17
+ """Reads index state out of the graph; pauses, resumes, and removes sources."""
18
+
19
+ def __init__(self, store: Any) -> None:
20
+ self.store = store
21
+
22
+ def build_dashboard(
23
+ self,
24
+ graph: Any,
25
+ watcher_status: Optional[Dict[str, Any]] = None,
26
+ ) -> Dict[str, Any]:
27
+ if graph is None:
28
+ return {
29
+ "sources": [],
30
+ "watcher": watcher_status or {"available": False, "active": {}},
31
+ "totals": {"success": 0, "failed": 0, "nodes": 0, "edges": 0},
32
+ }
33
+ stats = graph.stats()
34
+ sources = graph.local_sources().get("sources", [])
35
+ watcher_status = watcher_status or {"available": False, "active": {}}
36
+ active = watcher_status.get("active", {})
37
+ dashboard_sources = []
38
+ total_success = 0
39
+ total_failed = 0
40
+ for source in sources:
41
+ file_status = source.get("file_status") or {}
42
+ success = int(file_status.get("indexed") or 0)
43
+ failed = sum(
44
+ int(file_status.get(key) or 0)
45
+ for key in ("failed", "inaccessible", "skipped_empty_text")
46
+ )
47
+ total_success += success
48
+ total_failed += failed
49
+ watch = active.get(source.get("id")) or {}
50
+ dashboard_sources.append({
51
+ "id": source.get("id"),
52
+ "label": source.get("label"),
53
+ "root_path": source.get("root_path"),
54
+ "status": source.get("status"),
55
+ "watch_enabled": bool(source.get("watch_enabled")),
56
+ "watch_active": source.get("id") in active,
57
+ "watch_status": watch,
58
+ "success_count": success,
59
+ "failure_count": failed,
60
+ "last_run_at": source.get("last_scanned_at") or source.get("updated_at"),
61
+ "file_status": file_status,
62
+ "include_ocr": bool(source.get("include_ocr")),
63
+ })
64
+ return {
65
+ "sources": dashboard_sources,
66
+ "watcher": watcher_status,
67
+ "totals": {
68
+ "success": total_success,
69
+ "failed": total_failed,
70
+ "nodes": sum(int(v or 0) for v in (stats.get("nodes") or {}).values()),
71
+ "edges": sum(int(v or 0) for v in (stats.get("edges") or {}).values()),
72
+ "local_sources": stats.get("local_sources", len(sources)),
73
+ },
74
+ "graph_stats": stats,
75
+ }
76
+
77
+ def pause(self, graph: Any, source_id: str, watcher: Any = None) -> Dict[str, Any]:
78
+ result = graph.set_local_source_watch(source_id, False)
79
+ watch = watcher.stop_source(source_id) if watcher else {"stopped": False, "source_id": source_id}
80
+ self.store.record_timeline_event("graph", "indexing_paused", {"source_id": source_id})
81
+ return {"status": "ok", "source": result, "watch": watch}
82
+
83
+ def resume(self, graph: Any, source_id: str, watcher: Any = None) -> Dict[str, Any]:
84
+ result = graph.set_local_source_watch(source_id, True)
85
+ watch = {"watching": False, "source_id": source_id}
86
+ source = next(
87
+ (item for item in graph.local_sources().get("sources", []) if item.get("id") == source_id),
88
+ None,
89
+ )
90
+ if watcher and source:
91
+ watch = watcher.start_source(source)
92
+ self.store.record_timeline_event("graph", "indexing_resumed", {"source_id": source_id})
93
+ return {"status": "ok", "source": result, "watch": watch}
94
+
95
+ def remove_source(self, graph: Any, source_id: str, watcher: Any = None) -> Dict[str, Any]:
96
+ if watcher:
97
+ watcher.stop_source(source_id)
98
+ if not hasattr(graph, "remove_local_source"):
99
+ raise ValueError("graph store does not support removing local sources")
100
+ result = graph.remove_local_source(source_id)
101
+ self.store.record_timeline_event("graph", "indexing_removed", {"source_id": source_id})
102
+ return {"status": "ok", **result}