ltcai 8.9.0 → 9.1.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 (208) hide show
  1. package/README.md +81 -58
  2. package/auto_setup.py +7 -904
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +221 -238
  6. package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
  7. package/docs/DEVELOPMENT.md +53 -14
  8. package/docs/LEGACY_COMPATIBILITY.md +4 -4
  9. package/docs/ONBOARDING.md +10 -2
  10. package/docs/OPERATIONS.md +8 -4
  11. package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
  12. package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
  13. package/docs/TRUST_MODEL.md +5 -2
  14. package/docs/WHY_LATTICE.md +15 -10
  15. package/docs/WORKFLOW_DESIGNER.md +22 -0
  16. package/docs/kg-schema.md +13 -2
  17. package/docs/mcp-tools.md +17 -6
  18. package/docs/privacy.md +19 -3
  19. package/docs/public-deploy.md +32 -3
  20. package/docs/security-model.md +46 -11
  21. package/lattice_brain/__init__.py +1 -1
  22. package/lattice_brain/archive.py +4 -14
  23. package/lattice_brain/context.py +66 -9
  24. package/lattice_brain/embeddings.py +38 -2
  25. package/lattice_brain/graph/_kg_common.py +27 -462
  26. package/lattice_brain/graph/_kg_constants.py +243 -0
  27. package/lattice_brain/graph/_kg_fsutil.py +293 -0
  28. package/lattice_brain/graph/discovery.py +0 -948
  29. package/lattice_brain/graph/discovery_index.py +1126 -0
  30. package/lattice_brain/graph/documents.py +44 -9
  31. package/lattice_brain/graph/ingest.py +47 -20
  32. package/lattice_brain/graph/provenance.py +13 -6
  33. package/lattice_brain/graph/retrieval.py +141 -610
  34. package/lattice_brain/graph/retrieval_docgen.py +243 -0
  35. package/lattice_brain/graph/retrieval_vector.py +460 -0
  36. package/lattice_brain/graph/store.py +6 -0
  37. package/lattice_brain/ingestion.py +69 -4
  38. package/lattice_brain/portability.py +29 -23
  39. package/lattice_brain/quality.py +98 -4
  40. package/lattice_brain/runtime/agent_runtime.py +169 -7
  41. package/lattice_brain/runtime/hooks.py +30 -13
  42. package/lattice_brain/runtime/multi_agent.py +27 -8
  43. package/lattice_brain/runtime/statuses.py +10 -0
  44. package/lattice_brain/utils.py +46 -0
  45. package/lattice_brain/workflow.py +1 -5
  46. package/latticeai/__init__.py +1 -1
  47. package/latticeai/api/admin.py +1 -1
  48. package/latticeai/api/agent_registry.py +10 -8
  49. package/latticeai/api/agents.py +22 -3
  50. package/latticeai/api/auth.py +78 -16
  51. package/latticeai/api/browser.py +303 -34
  52. package/latticeai/api/chat.py +355 -952
  53. package/latticeai/api/chat_agent_http.py +356 -0
  54. package/latticeai/api/chat_contracts.py +54 -0
  55. package/latticeai/api/chat_documents.py +256 -0
  56. package/latticeai/api/chat_helpers.py +250 -0
  57. package/latticeai/api/chat_history.py +99 -0
  58. package/latticeai/api/chat_intents.py +302 -0
  59. package/latticeai/api/chat_stream.py +115 -0
  60. package/latticeai/api/computer_use.py +211 -40
  61. package/latticeai/api/health.py +9 -3
  62. package/latticeai/api/hooks.py +17 -6
  63. package/latticeai/api/knowledge_graph.py +78 -14
  64. package/latticeai/api/local_files.py +7 -2
  65. package/latticeai/api/marketplace.py +11 -0
  66. package/latticeai/api/mcp.py +102 -23
  67. package/latticeai/api/models.py +72 -33
  68. package/latticeai/api/network.py +5 -5
  69. package/latticeai/api/permissions.py +70 -29
  70. package/latticeai/api/plugins.py +21 -7
  71. package/latticeai/api/portability.py +5 -5
  72. package/latticeai/api/realtime.py +33 -5
  73. package/latticeai/api/setup.py +2 -2
  74. package/latticeai/api/static_routes.py +123 -24
  75. package/latticeai/api/tools.py +97 -14
  76. package/latticeai/api/workflow_designer.py +37 -0
  77. package/latticeai/api/workspace.py +2 -1
  78. package/latticeai/app_factory.py +185 -405
  79. package/latticeai/core/agent.py +19 -9
  80. package/latticeai/core/agent_registry.py +1 -5
  81. package/latticeai/core/config.py +23 -3
  82. package/latticeai/core/context_builder.py +21 -3
  83. package/latticeai/core/invitations.py +1 -4
  84. package/latticeai/core/io_utils.py +45 -0
  85. package/latticeai/core/legacy_compatibility.py +24 -3
  86. package/latticeai/core/local_embeddings.py +2 -4
  87. package/latticeai/core/marketplace.py +33 -2
  88. package/latticeai/core/mcp_catalog.py +450 -0
  89. package/latticeai/core/mcp_registry.py +2 -441
  90. package/latticeai/core/plugins.py +15 -0
  91. package/latticeai/core/policy.py +1 -1
  92. package/latticeai/core/realtime.py +38 -15
  93. package/latticeai/core/sessions.py +7 -2
  94. package/latticeai/core/timeutil.py +10 -0
  95. package/latticeai/core/tool_registry.py +90 -18
  96. package/latticeai/core/users.py +5 -14
  97. package/latticeai/core/workspace_graph_trace.py +31 -5
  98. package/latticeai/core/workspace_memory.py +3 -1
  99. package/latticeai/core/workspace_os.py +18 -7
  100. package/latticeai/core/workspace_os_utils.py +2 -21
  101. package/latticeai/core/workspace_permissions.py +2 -1
  102. package/latticeai/core/workspace_plugins.py +1 -1
  103. package/latticeai/core/workspace_runs.py +14 -5
  104. package/latticeai/core/workspace_skills.py +1 -1
  105. package/latticeai/core/workspace_snapshots.py +2 -1
  106. package/latticeai/core/workspace_timeline.py +2 -1
  107. package/latticeai/integrations/telegram_bot.py +96 -40
  108. package/latticeai/models/model_providers.py +111 -0
  109. package/latticeai/models/router.py +189 -173
  110. package/latticeai/runtime/access_runtime.py +62 -4
  111. package/latticeai/runtime/audit_runtime.py +27 -16
  112. package/latticeai/runtime/automation_runtime.py +22 -7
  113. package/latticeai/runtime/brain_runtime.py +19 -7
  114. package/latticeai/runtime/chat_wiring.py +2 -0
  115. package/latticeai/runtime/config_runtime.py +58 -26
  116. package/latticeai/runtime/context_runtime.py +16 -4
  117. package/latticeai/runtime/history_runtime.py +163 -0
  118. package/latticeai/runtime/hooks_runtime.py +1 -1
  119. package/latticeai/runtime/model_wiring.py +33 -5
  120. package/latticeai/runtime/namespace_runtime.py +163 -0
  121. package/latticeai/runtime/network_config_runtime.py +56 -0
  122. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  123. package/latticeai/runtime/review_wiring.py +1 -1
  124. package/latticeai/runtime/router_registration.py +34 -1
  125. package/latticeai/runtime/security_runtime.py +110 -13
  126. package/latticeai/runtime/sso_config_runtime.py +128 -0
  127. package/latticeai/runtime/stages.py +27 -0
  128. package/latticeai/runtime/user_key_runtime.py +106 -0
  129. package/latticeai/server_app.py +5 -1
  130. package/latticeai/services/architecture_readiness.py +74 -5
  131. package/latticeai/services/brain_automation.py +3 -26
  132. package/latticeai/services/chat_service.py +205 -15
  133. package/latticeai/services/local_knowledge.py +423 -0
  134. package/latticeai/services/memory_service.py +268 -21
  135. package/latticeai/services/model_engines.py +48 -33
  136. package/latticeai/services/model_errors.py +17 -0
  137. package/latticeai/services/model_loading.py +28 -25
  138. package/latticeai/services/model_runtime.py +228 -162
  139. package/latticeai/services/p_reinforce.py +22 -3
  140. package/latticeai/services/platform_runtime.py +92 -24
  141. package/latticeai/services/product_readiness.py +19 -17
  142. package/latticeai/services/review_queue.py +76 -11
  143. package/latticeai/services/router_context.py +1 -0
  144. package/latticeai/services/run_executor.py +25 -9
  145. package/latticeai/services/search_service.py +203 -28
  146. package/latticeai/services/setup_detection.py +80 -0
  147. package/latticeai/services/tool_dispatch.py +28 -5
  148. package/latticeai/services/triggers.py +53 -4
  149. package/latticeai/services/upload_service.py +13 -2
  150. package/latticeai/setup/__init__.py +25 -0
  151. package/latticeai/setup/auto_setup.py +857 -0
  152. package/latticeai/setup/wizard.py +1264 -0
  153. package/latticeai/tools/__init__.py +278 -0
  154. package/{tools → latticeai/tools}/commands.py +67 -7
  155. package/{tools → latticeai/tools}/computer.py +1 -1
  156. package/{tools → latticeai/tools}/documents.py +1 -1
  157. package/{tools → latticeai/tools}/filesystem.py +3 -3
  158. package/latticeai/tools/knowledge.py +178 -0
  159. package/{tools → latticeai/tools}/local_files.py +7 -1
  160. package/{tools → latticeai/tools}/network.py +0 -1
  161. package/local_knowledge_api.py +4 -342
  162. package/package.json +22 -2
  163. package/scripts/brain_quality_eval.py +3 -1
  164. package/scripts/bump_version.py +3 -0
  165. package/scripts/capture_release_evidence.mjs +180 -0
  166. package/scripts/check_current_release_docs.mjs +142 -0
  167. package/scripts/check_i18n_literals.mjs +107 -31
  168. package/scripts/check_openapi_drift.mjs +56 -0
  169. package/scripts/export_openapi.py +67 -7
  170. package/scripts/i18n_literal_allowlist.json +22 -24
  171. package/scripts/run_integration_tests.mjs +72 -10
  172. package/scripts/validate_release_artifacts.py +49 -7
  173. package/scripts/wheel_smoke.py +5 -0
  174. package/setup_wizard.py +3 -1304
  175. package/src-tauri/Cargo.lock +1 -1
  176. package/src-tauri/Cargo.toml +1 -1
  177. package/src-tauri/tauri.conf.json +1 -1
  178. package/static/app/asset-manifest.json +11 -11
  179. package/static/app/assets/Act-Bzz0bUyW.js +1 -0
  180. package/static/app/assets/Brain-Dj2J20YA.js +321 -0
  181. package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
  182. package/static/app/assets/Library-B03FP1Yx.js +1 -0
  183. package/static/app/assets/System-80lHW0Ux.js +1 -0
  184. package/static/app/assets/index-BiMofBTM.js +17 -0
  185. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  186. package/static/app/assets/primitives-Q1A96_7v.js +1 -0
  187. package/static/app/assets/textarea-D13RtnTo.js +1 -0
  188. package/static/app/index.html +2 -2
  189. package/static/css/tokens.css +4 -2
  190. package/static/sw.js +1 -1
  191. package/tools/__init__.py +21 -269
  192. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  193. package/latticeai/runtime/app_context_runtime.py +0 -13
  194. package/latticeai/runtime/sso_runtime.py +0 -52
  195. package/latticeai/runtime/tail_wiring.py +0 -21
  196. package/scripts/com.pts.claudecode.discord.plist +0 -31
  197. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  198. package/scripts/start-pts-claudecode-discord.sh +0 -51
  199. package/static/app/assets/Act-fZokUnC0.js +0 -1
  200. package/static/app/assets/Brain-DtyuWubr.js +0 -321
  201. package/static/app/assets/Capture-D5KV3Cu7.js +0 -1
  202. package/static/app/assets/Library-C9kyFkSt.js +0 -1
  203. package/static/app/assets/System-VbChmX7r.js +0 -1
  204. package/static/app/assets/index-DCh5AoXt.css +0 -2
  205. package/static/app/assets/index-DPdcPoF0.js +0 -17
  206. package/static/app/assets/primitives-DFeanEV6.js +0 -1
  207. package/static/app/assets/textarea-CD8UNKIy.js +0 -1
  208. package/tools/knowledge.py +0 -95
@@ -22,15 +22,23 @@ Lattice AI는 **개인 AI 워크스페이스**로 설계되었습니다. 기본
22
22
 
23
23
  ### 세션
24
24
 
25
- - UUID 토큰, `~/.ltcai/sessions.json` 파일 저장
25
+ - `secrets.token_urlsafe`로 생성한 토큰은 SHA-256 해시만
26
+ `~/.ltcai/sessions.json`에 저장(레거시 평문 토큰은 로드 시 자동 마이그레이션)
26
27
  - TTL: 24시간 + sliding refresh (활동 시 자동 연장, 15분 단위 디스크 쓰기)
27
28
  - 쿠키: `HttpOnly; SameSite=Lax; Path=/`
29
+ - public/non-loopback에서는 `Secure`도 강제(HTTPS 필요)
28
30
  - 서버 재시작 후에도 유지 (파일 기반)
31
+ - 삭제되거나 비활성화된 계정의 기존 세션은 다음 요청에서 즉시 거부
32
+ - POSIX에서 데이터 디렉터리 `0700`, atomic JSON/세션 파일 `0600`
33
+ - 초대 게이트 쿠키는 설치별 secret으로 HMAC 서명되고 만료 시각을 포함함.
34
+ `authorized=true` 리터럴은 신뢰하지 않으며, 공용 기본 invite code도 없음
29
35
 
30
36
  ### SSO (선택적)
31
37
 
32
38
  - Entra ID / Okta OIDC (`OIDC_DISCOVERY_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`)
33
39
  - 콜백 후 내부 세션 토큰으로 변환
40
+ - 초대 게이트 활성화 시 신규 SSO JIT 계정은 서명 초대 권한이 필요하며,
41
+ 해당 권한은 서버 측 일회용 state/nonce/PKCE 트랜잭션에 바인딩됨
34
42
  - 어드민 핸드오프: `sessionStorage` 1회 읽기 (URL 파라미터 노출 방지)
35
43
 
36
44
  ## API 키 보안
@@ -83,15 +91,27 @@ MAGIC_NUMBERS = {
83
91
  - **수집 라이프사이클**: 모든 수집은 `IngestionPipeline.ingest` → `dispatch_tool`
84
92
  를 거쳐 `pre_tool`/`post_tool` 훅이 발화됩니다. `pre_tool`이 차단하면 수집은
85
93
  정직하게 `status="blocked"`로 거부됩니다(권한 게이트·민감정보 가드 적용).
86
- - **웹 URL 읽기**(`/api/browser/read-url`): `http(s)` 스킴만 허용(파일/기타 스킴
87
- 거부), 12초 타임아웃, 4MB 응답 상한, HTML/text 컨텐츠 타입만 처리. 차단/로그인
88
- 필요 페이지는 5xx가 아닌 **422로 우아하게 실패**합니다. 로컬 런타임이 사용자가
89
- 명시한 URL만 가져옵니다(자동 크롤링 없음).
94
+ - **웹 URL 읽기**(`/api/browser/read-url`): `http(s)`만 허용하고 DNS의 모든 결과에서
95
+ loopback/private/link-local/multicast/reserved 주소를 거부합니다. 검증한 IP에 연결을
96
+ 고정해 DNS rebinding을 막고, redirect마다 재검증하며 환경 proxy를 사용하지
97
+ 않습니다. 12초 타임아웃, 스트리밍된 4MB 응답 상한, textual content type만
98
+ 처리합니다. 차단/로그인 필요 페이지는 5xx가 아닌 **422로 실패**합니다.
90
99
  - **브라우저 탭 수집**(`/api/browser/ingest-current-tab`): payload 정화(스크립트/
91
100
  스타일 제거) + 페이로드 크기 상한(413). Manifest V3 확장은 **`127.0.0.1`로만**
92
101
  전송하며 클라우드 엔드포인트가 없습니다.
93
- - **포터빌리티 권한**: 그래프 export/status 읽기는 로그인 사용자, **import /
94
- backup / restore 는 admin 전용**(`require_admin`). 그래프는 머신-전역 자원입니다.
102
+ - **포터빌리티 권한**: 상태 읽기는 로그인 사용자, 전체 그래프 export/provenance와
103
+ **import / backup / restore는 admin 전용**(`require_admin`)입니다. 그래프는
104
+ 머신-전역 자원입니다.
105
+ - **워크스페이스 컨텍스트**: MCP 그래프 호출, 메모리 recall, hybrid search,
106
+ garden-note 컨텍스트, realtime presence는 인증된 사용자와 활성/허용 workspace에
107
+ 바인딩됩니다. 에이전트·플러그인 registry 및 graph curation 변경은 admin
108
+ 전용이며, MCP 환경 변수 값은 API 응답에 포함되지 않습니다. MCP/플러그인
109
+ 실행은 local file/document 도구를 호출할 수 없고 전용 승인 토큰 경로를
110
+ 사용해야 합니다. Document RAG와 answer trace도 동일한 workspace 범위로
111
+ 검색·저장됩니다.
112
+ - **fail-closed KG 범위**: workspace projection 조회 실패 또는 scope를 알 수 없는
113
+ v2 node는 반환하지 않습니다. legacy-global 데이터는
114
+ `include_legacy_global=True`를 명시한 호환 경로에서만 읽습니다.
95
115
  - **복원 무결성**: 백업 아카이브는 `manifest.json`의 sha256과 대조 검증 후에만
96
116
  복원되며, 불일치 시 거부됩니다.
97
117
 
@@ -99,9 +119,10 @@ MAGIC_NUMBERS = {
99
119
 
100
120
  ### `run_command()` 위험 플래그 차단
101
121
 
102
- 다음 패턴이 포함된 명령 실행 거부:
103
- - `rm -rf`, `sudo`, `chmod 777`, `curl | bash`, `wget | sh`
104
- - `> /dev/sda`, `dd if=`, `mkfs`
122
+ 고정 allowlist(`pwd`, `ls`, `find`, `cat`, `head`, `tail`, `wc`, `rg`)와 정화된
123
+ 환경만 사용합니다. Python/Node/npm/npx/sed 같은 일반 인터프리터, 실행 파일 경로,
124
+ 절대 경로·`..` traversal·workspace 밖 symlink, `rg --pre`, `find -exec/-delete`
125
+ 등은 실행 전에 거부합니다.
105
126
 
106
127
  ### `edit_file()` 유일성 검증
107
128
 
@@ -119,6 +140,17 @@ MAGIC_NUMBERS = {
119
140
  - 평문 비밀번호 마이그레이션 이벤트: `password_migrated_from_plaintext`
120
141
  - `server.log` 파일에 모든 요청 기록
121
142
 
143
+ ## Telegram 및 권한 알림
144
+
145
+ - Telegram은 `LATTICEAI_TELEGRAM_ALLOWED_CHAT_IDS`에 지정된 chat의 메시지와
146
+ callback query만 처리하며, 허용 전에 chat을 등록하지 않습니다.
147
+ - 봇의 로컬 API 호출에는 `LATTICEAI_SERVER_SESSION_TOKEN` 전용 bearer가
148
+ 필요합니다. 봇은 `sessions.json`을 스캔하지 않습니다.
149
+ - 권한 요청 알림은 전체 승인 토큰 대신 8자 hint만 포함합니다. 선택적인
150
+ `LATTICEAI_PERMISSION_UI_URL`은 사람의 검토 페이지로 연결하지만 토큰을 URL에
151
+ 넣지 않습니다.
152
+ - permission queue는 atomic write와 POSIX `0600`을 사용합니다.
153
+
122
154
  ## 텔레메트리
123
155
 
124
156
  **없음.** 모든 데이터는 로컬에만 저장됩니다. 외부 서버로 어떠한 사용 데이터도 전송되지 않습니다.
@@ -128,11 +160,14 @@ MAGIC_NUMBERS = {
128
160
  ## 퍼블릭 배포 체크리스트
129
161
 
130
162
  - [ ] `LATTICEAI_MODE=public`
131
- - [ ] `LATTICEAI_INVITE_CODE` 비공개 값 설정
163
+ - [ ] 초대 온보딩을 사용할 때만 `LATTICEAI_INVITE_GATE_ENABLED=true`와 비공개
164
+ `LATTICEAI_INVITE_CODE` 설정(미설정 시 생성되는 설치별 secret 영구 보관)
132
165
  - [ ] HTTPS 리버스 프록시 (nginx/Caddy)
133
166
  - [ ] `LATTICEAI_ENABLE_GRAPH=false` (필요 시)
134
167
  - [ ] `/data` 영구 볼륨 마운트
135
168
  - [ ] `LATTICEAI_ALLOW_LOCAL_MODELS=false`
136
169
  - [ ] 방화벽에서 4825 포트 직접 노출 차단 (리버스 프록시 통해서만)
170
+ - [ ] Telegram 활성화 시 `LATTICEAI_TELEGRAM_ALLOWED_CHAT_IDS`와
171
+ `LATTICEAI_SERVER_SESSION_TOKEN` 설정
137
172
 
138
173
  자세한 내용: [public-deploy.md](public-deploy.md)
@@ -26,7 +26,7 @@ from .storage import (
26
26
  storage_from_env,
27
27
  )
28
28
 
29
- __version__ = "8.9.0"
29
+ __version__ = "9.1.0"
30
30
 
31
31
  __all__ = [
32
32
  "AgentRuntime",
@@ -9,7 +9,6 @@ restore on another machine without contacting a service.
9
9
  from __future__ import annotations
10
10
 
11
11
  import base64
12
- import hashlib
13
12
  import io
14
13
  import json
15
14
  import os
@@ -18,7 +17,6 @@ import sqlite3
18
17
  import tempfile
19
18
  import zipfile
20
19
  from dataclasses import dataclass
21
- from datetime import datetime, timezone
22
20
  from pathlib import Path, PurePosixPath
23
21
  from typing import Any, Dict, Iterable, List, Optional
24
22
 
@@ -27,6 +25,8 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
27
25
  from cryptography.exceptions import InvalidTag
28
26
  from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
29
27
 
28
+ from .utils import sha256_file as _sha256_file, utc_now_iso as _now
29
+
30
30
 
31
31
  ARCHIVE_FORMAT = "latticebrain.encrypted"
32
32
  ARCHIVE_VERSION = 2
@@ -47,10 +47,6 @@ PORTABLE_DATA_FILES = (
47
47
  PORTABLE_EXPORT_SUFFIXES = (".json", ".zip")
48
48
 
49
49
 
50
- def _now() -> str:
51
- return datetime.now(timezone.utc).isoformat()
52
-
53
-
54
50
  def _stamp() -> str:
55
51
  return _now().replace(":", "").replace("-", "").replace(".", "")[:15]
56
52
 
@@ -68,15 +64,9 @@ def _derive_key(passphrase: str, salt: bytes) -> bytes:
68
64
 
69
65
 
70
66
  def _sha256_bytes(data: bytes) -> str:
71
- return hashlib.sha256(data).hexdigest()
72
-
67
+ import hashlib
73
68
 
74
- def _sha256_file(path: Path) -> str:
75
- h = hashlib.sha256()
76
- with open(path, "rb") as fh:
77
- for block in iter(lambda: fh.read(65536), b""):
78
- h.update(block)
79
- return h.hexdigest()
69
+ return hashlib.sha256(data).hexdigest()
80
70
 
81
71
 
82
72
  def _safe_json(value: Any) -> Any:
@@ -13,6 +13,7 @@ design-review amendment T5).
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
+ import inspect
16
17
  import logging
17
18
  from dataclasses import dataclass, field
18
19
  from typing import Any, Callable, Dict, List, Optional
@@ -23,6 +24,46 @@ def approx_tokens(text: str) -> int:
23
24
  return max(0, (len(text or "") + 3) // 4)
24
25
 
25
26
 
27
+ def _call_context_seam(callback: Callable[..., Any], query: str, **context: Any) -> Any:
28
+ """Pass identity/scope only when a legacy seam declares support for it.
29
+
30
+ Signature inspection preserves old one-argument adapters without catching
31
+ a callback's own ``TypeError`` and retrying it with less restrictive scope.
32
+ If a callable cannot be inspected, the secure behavior is to pass every
33
+ context field and let the caller's failure isolation omit that section.
34
+ """
35
+ try:
36
+ parameters = inspect.signature(callback).parameters
37
+ except (TypeError, ValueError):
38
+ return callback(query, **context)
39
+ accepts_kwargs = any(
40
+ item.kind is inspect.Parameter.VAR_KEYWORD
41
+ for item in parameters.values()
42
+ )
43
+ supported = context if accepts_kwargs else {
44
+ key: value for key, value in context.items()
45
+ if key in parameters
46
+ }
47
+ return callback(query, **supported)
48
+
49
+
50
+ def _call_keyword_seam(callback: Callable[..., Any], **context: Any) -> Any:
51
+ """Invoke a keyword-only seam while preserving legacy narrow signatures."""
52
+ try:
53
+ parameters = inspect.signature(callback).parameters
54
+ except (TypeError, ValueError):
55
+ return callback(**context)
56
+ accepts_kwargs = any(
57
+ item.kind is inspect.Parameter.VAR_KEYWORD
58
+ for item in parameters.values()
59
+ )
60
+ supported = context if accepts_kwargs else {
61
+ key: value for key, value in context.items()
62
+ if key in parameters
63
+ }
64
+ return callback(**supported)
65
+
66
+
26
67
  @dataclass
27
68
  class ContextSection:
28
69
  name: str
@@ -112,11 +153,11 @@ class ContextAssembler:
112
153
  if self._memory_recall is not None:
113
154
  sections.append(self._memories_section(query, user_email, workspace_id, memory_limit))
114
155
  if self._hybrid_search is not None:
115
- sections.append(self._knowledge_section(query, knowledge_limit, user_email))
156
+ sections.append(self._knowledge_section(query, knowledge_limit, user_email, workspace_id))
116
157
  if self._notes_context is not None:
117
- sections.append(self._notes_section(query))
158
+ sections.append(self._notes_section(query, user_email, workspace_id))
118
159
  if self._recent_chat is not None:
119
- sections.append(self._recent_section(user_email, conversation_id))
160
+ sections.append(self._recent_section(user_email, conversation_id, workspace_id))
120
161
 
121
162
  sections = [s for s in sections if s.content.strip()]
122
163
  self._apply_budget(sections, budget)
@@ -141,9 +182,15 @@ class ContextAssembler:
141
182
  ],
142
183
  )
143
184
 
144
- def _knowledge_section(self, query, limit, user_email=None) -> ContextSection:
185
+ def _knowledge_section(self, query, limit, user_email=None, workspace_id=None) -> ContextSection:
145
186
  try:
146
- hybrid = self._hybrid_search(query, limit=limit, user_email=user_email)
187
+ hybrid = _call_context_seam(
188
+ self._hybrid_search,
189
+ query,
190
+ limit=limit,
191
+ user_email=user_email,
192
+ workspace_id=workspace_id,
193
+ )
147
194
  matches = hybrid.get("matches", [])[:limit]
148
195
  except Exception as exc:
149
196
  logging.debug("context: hybrid search failed: %s", exc)
@@ -166,9 +213,14 @@ class ContextAssembler:
166
213
  provenance=provenance,
167
214
  )
168
215
 
169
- def _notes_section(self, query) -> ContextSection:
216
+ def _notes_section(self, query, user_email=None, workspace_id=None) -> ContextSection:
170
217
  try:
171
- content = self._notes_context(query) or ""
218
+ content = _call_context_seam(
219
+ self._notes_context,
220
+ query,
221
+ user_email=user_email,
222
+ workspace_id=workspace_id,
223
+ ) or ""
172
224
  except Exception as exc:
173
225
  logging.debug("context: notes context failed: %s", exc)
174
226
  content = ""
@@ -179,9 +231,14 @@ class ContextAssembler:
179
231
  provenance=[{"source": "garden", "included": bool(content)}],
180
232
  )
181
233
 
182
- def _recent_section(self, user_email, conversation_id) -> ContextSection:
234
+ def _recent_section(self, user_email, conversation_id, workspace_id=None) -> ContextSection:
183
235
  try:
184
- content = self._recent_chat(user_email=user_email, conversation_id=conversation_id) or ""
236
+ content = _call_keyword_seam(
237
+ self._recent_chat,
238
+ user_email=user_email,
239
+ conversation_id=conversation_id,
240
+ workspace_id=workspace_id,
241
+ ) or ""
185
242
  except Exception as exc:
186
243
  logging.debug("context: recent chat failed: %s", exc)
187
244
  content = ""
@@ -8,7 +8,7 @@ import os
8
8
  import re
9
9
  import struct
10
10
  from dataclasses import dataclass
11
- from typing import Iterable, List
11
+ from typing import Any, Dict, Iterable, List, Optional
12
12
 
13
13
 
14
14
  DEFAULT_EMBEDDING_DIM = int(os.getenv("LATTICEAI_VECTOR_DIM", "384"))
@@ -87,4 +87,40 @@ class LocalEmbeddingModel:
87
87
  return list(struct.unpack(f"<{count}f", payload[: count * 4]))
88
88
 
89
89
 
90
- __all__ = ["DEFAULT_EMBEDDING_DIM", "EMBEDDING_MODEL_ID", "LocalEmbeddingModel", "embedding_model_id"]
90
+ # --- Large candidate #2 slice: multimodal (vision) stubs ---
91
+ # Schema already defines IMAGE / IMAGE_TEXT / CONTAINS_IMAGE.
92
+ # These stubs allow ingestion + retrieval paths to carry image signals without
93
+ # requiring heavy deps at core. Real impl can swap in local vision (e.g. via
94
+ # ollama vision or onnx CLIP) behind the same interface.
95
+ @dataclass(frozen=True)
96
+ class VisionStub:
97
+ """Offline vision describe + embed stubs for multimodal Brain.
98
+
99
+ describe: returns a short textual caption derived from metadata/filename.
100
+ embed: produces a vector from image meta (size+format) + optional caption hash.
101
+ Later: replace with real embedding model that accepts image bytes/path.
102
+ """
103
+ dim: int = DEFAULT_EMBEDDING_DIM
104
+
105
+ def describe(self, path: str | None = None, meta: Optional[Dict[str, Any]] = None) -> str:
106
+ meta = meta or {}
107
+ w = meta.get("width") or meta.get("w") or "?"
108
+ h = meta.get("height") or meta.get("h") or "?"
109
+ fmt = meta.get("format") or meta.get("ext") or "img"
110
+ name = (path or "").split("/")[-1] or "image"
111
+ # deterministic caption stub (no external call)
112
+ return f"Image {name} ({fmt} {w}x{h})"
113
+
114
+ def embed_image(self, path: str | None = None, meta: Optional[Dict[str, Any]] = None, caption: str = "") -> List[float]:
115
+ meta = meta or {}
116
+ basis = f"{path or ''}|{meta.get('width',0)}x{meta.get('height',0)}|{meta.get('format','')}|{caption[:120]}"
117
+ # reuse text embedder for determinism (image content hash would be better with real vision)
118
+ model = LocalEmbeddingModel(dim=self.dim)
119
+ return model.embed(basis)
120
+
121
+
122
+ def get_vision_embedder(dim: int | None = None) -> VisionStub:
123
+ return VisionStub(dim=dim or DEFAULT_EMBEDDING_DIM)
124
+
125
+
126
+ __all__ = ["DEFAULT_EMBEDDING_DIM", "EMBEDDING_MODEL_ID", "LocalEmbeddingModel", "embedding_model_id", "VisionStub", "get_vision_embedder"]