ltcai 10.7.0 → 10.9.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 (97) hide show
  1. package/README.md +55 -40
  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/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/TRUST_MODEL.md +1 -1
  8. package/docs/WHY_LATTICE.md +1 -1
  9. package/docs/kg-schema.md +1 -1
  10. package/lattice_brain/__init__.py +1 -1
  11. package/lattice_brain/graph/retrieval_vector.py +69 -33
  12. package/lattice_brain/runtime/multi_agent.py +96 -15
  13. package/latticeai/__init__.py +1 -1
  14. package/latticeai/api/admin.py +9 -6
  15. package/latticeai/api/auth.py +37 -38
  16. package/latticeai/api/browser.py +7 -6
  17. package/latticeai/api/chat.py +9 -6
  18. package/latticeai/api/chat_agent_http.py +2 -1
  19. package/latticeai/api/chat_history.py +4 -2
  20. package/latticeai/api/chat_intents.py +14 -14
  21. package/latticeai/api/knowledge_graph.py +5 -4
  22. package/latticeai/api/local_files.py +25 -15
  23. package/latticeai/api/mcp.py +15 -11
  24. package/latticeai/api/memory.py +2 -1
  25. package/latticeai/api/models.py +39 -24
  26. package/latticeai/api/network_boundary.py +9 -4
  27. package/latticeai/api/portability.py +27 -19
  28. package/latticeai/api/project_sessions.py +7 -5
  29. package/latticeai/api/review_queue.py +12 -7
  30. package/latticeai/api/setup.py +10 -11
  31. package/latticeai/api/static_routes.py +62 -46
  32. package/latticeai/api/tools.py +7 -6
  33. package/latticeai/core/file_generation.py +141 -8
  34. package/latticeai/core/legacy_compatibility.py +1 -1
  35. package/latticeai/core/marketplace.py +1 -1
  36. package/latticeai/core/mcp_registry.py +20 -4
  37. package/latticeai/core/messages.py +452 -0
  38. package/latticeai/core/workspace_computer_memory.py +84 -0
  39. package/latticeai/core/workspace_indexing.py +102 -0
  40. package/latticeai/core/workspace_onboarding.py +104 -0
  41. package/latticeai/core/workspace_os.py +24 -207
  42. package/latticeai/core/workspace_os_constants.py +1 -1
  43. package/latticeai/core/workspace_relationships.py +99 -0
  44. package/latticeai/integrations/telegram_bot.py +20 -18
  45. package/latticeai/services/architecture_readiness.py +1 -1
  46. package/latticeai/services/model_loading.py +63 -27
  47. package/latticeai/services/product_readiness.py +1 -1
  48. package/package.json +3 -2
  49. package/scripts/check_current_release_docs.mjs +1 -1
  50. package/scripts/check_screenshot_pixel_delta.py +92 -10
  51. package/scripts/check_server_i18n.mjs +96 -0
  52. package/scripts/release_screen_claims.json +46 -0
  53. package/src-tauri/Cargo.lock +1 -1
  54. package/src-tauri/Cargo.toml +1 -1
  55. package/src-tauri/tauri.conf.json +1 -1
  56. package/static/app/asset-manifest.json +37 -37
  57. package/static/app/assets/{Act-DIbkoJqs.js → Act-CS9IeqUX.js} +1 -1
  58. package/static/app/assets/{AdminConsole-Cbi6Jre6.js → AdminConsole-3UkIEWGA.js} +1 -1
  59. package/static/app/assets/{Brain-BPfuXZ71.js → Brain-B22EmNqS.js} +1 -1
  60. package/static/app/assets/{BrainHome-87XaAS4V.js → BrainHome-CMDqgJF4.js} +2 -2
  61. package/static/app/assets/{BrainSignals-ExpcdiUi.js → BrainSignals-BeE8RJo3.js} +1 -1
  62. package/static/app/assets/{Capture-Bxe4WiVX.js → Capture-ZX9bQh68.js} +1 -1
  63. package/static/app/assets/{CommandPalette-B1PV5Yd4.js → CommandPalette-86m4FCcN.js} +1 -1
  64. package/static/app/assets/{Library-BRlJJmwZ.js → Library-Bhz5LUca.js} +1 -1
  65. package/static/app/assets/{LivingBrain-D61qG3WY.js → LivingBrain-BSa0wpFG.js} +1 -1
  66. package/static/app/assets/ProductFlow-IP4Q-5aQ.js +1 -0
  67. package/static/app/assets/{ReviewCard-BoiWv0Y1.js → ReviewCard-BepjSDpN.js} +1 -1
  68. package/static/app/assets/{System-Ci0HEtTJ.js → System-Bwx1h_jT.js} +1 -1
  69. package/static/app/assets/arrow-left-ig7AZU8B.js +1 -0
  70. package/static/app/assets/{bot-BaPt4PPn.js → bot-DQj0-LkM.js} +1 -1
  71. package/static/app/assets/{brain-BSkWaj3i.js → brain-D8OEwmVj.js} +1 -1
  72. package/static/app/assets/{button-CKwtO1O0.js → button-CmTknyAP.js} +1 -1
  73. package/static/app/assets/{circle-pause-CiUc5RRk.js → circle-pause-yTCWRziJ.js} +1 -1
  74. package/static/app/assets/{circle-play-BaTDxzR9.js → circle-play-Ccrva84R.js} +1 -1
  75. package/static/app/assets/{cpu-Dm1gHuP1.js → cpu-CbJqWTlS.js} +1 -1
  76. package/static/app/assets/{download-C32oGU-C.js → download-CkSzbzU-.js} +1 -1
  77. package/static/app/assets/{folder-open-Ch2jLVdD.js → folder-open-CKyjQ4PU.js} +1 -1
  78. package/static/app/assets/{hard-drive-BBjA5st8.js → hard-drive-DAzk9um0.js} +1 -1
  79. package/static/app/assets/index-BfD-jhA9.css +2 -0
  80. package/static/app/assets/{index-J6h01X78.js → index-CxOcwsHV.js} +3 -3
  81. package/static/app/assets/{input-y6ABdKWY.js → input-DcMETmZ7.js} +1 -1
  82. package/static/app/assets/{permissionCopy-BEtc0Ihd.js → permissionCopy-BVf13_25.js} +1 -1
  83. package/static/app/assets/{primitives-GSFXF2Pd.js → primitives-CQV9Q2YM.js} +1 -1
  84. package/static/app/assets/search-CXGASMMH.js +1 -0
  85. package/static/app/assets/{share-2-BgGq2A63.js → share-2-COWCHNZm.js} +1 -1
  86. package/static/app/assets/{shield-alert-BYA491cj.js → shield-alert-BDrvilyK.js} +1 -1
  87. package/static/app/assets/{textarea-BAcim3fs.js → textarea-rUmsc8cP.js} +1 -1
  88. package/static/app/assets/{useFocusTrap-qLg24jiv.js → useFocusTrap-Bi5UY_8v.js} +1 -1
  89. package/static/app/assets/{useQuery-xkAu9eDX.js → useQuery-C-AicB-3.js} +1 -1
  90. package/static/app/assets/{utils-CogdofAA.js → utils-BMwWg78e.js} +1 -1
  91. package/static/app/assets/{workspace-BMvfx9_R.js → workspace-Y93tls8P.js} +1 -1
  92. package/static/app/index.html +4 -4
  93. package/static/sw.js +1 -1
  94. package/static/app/assets/ProductFlow-BxjxI8Aa.js +0 -1
  95. package/static/app/assets/arrow-left-DArdBUxd.js +0 -1
  96. package/static/app/assets/index-BV7Yf6Eq.css +0 -2
  97. package/static/app/assets/search-CaIZKyo_.js +0 -1
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  [![CI Status](https://github.com/TaeSooPark-PTS/LatticeAI/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/TaeSooPark-PTS/LatticeAI/actions/workflows/ci.yml)
12
12
  [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
13
13
 
14
- ![v10.7.0 Living Brain walkthrough](output/release/v10.7.0/gifs/v10.7.0-living-brain-walkthrough.gif)
14
+ ![v10.9.0 Living Brain walkthrough](output/release/v10.9.0/gifs/v10.9.0-living-brain-walkthrough.gif)
15
15
 
16
16
  Chat, files, folders, notes, and web pages all flow into one durable knowledge
17
17
  graph on your computer. Any model — local MLX or cloud — can speak with that
@@ -24,10 +24,10 @@ memory. Nothing leaves your machine without explicit consent.
24
24
 
25
25
  | | |
26
26
  | --- | --- |
27
- | **Chat with a Brain that remembers** — every conversation grows durable, source-linked memory ![Brain Chat](output/release/v10.7.0/screenshots/04-brain-chat-home.png) | **See how knowledge connects** — a real relationship graph, not a file list ![Memory Graph](output/release/v10.7.0/screenshots/05-memory-graph.png) |
28
- | **Capture anything** — files, whole folders, notes, screenshots, web pages ![Capture](output/release/v10.7.0/screenshots/06-capture.png) | **Automate with review** — agent changes become proposals you approve first ![Review Center](output/release/v10.7.0/screenshots/12-review-center.png) |
29
- | **Pick a model in one click** — recommended local models for your hardware ![Recommended Models](output/release/v10.7.0/screenshots/02-recommended-models.png) | **Stay in control** — audit, roles, retention in a separate admin surface ![Admin Console](output/release/v10.7.0/screenshots/10-admin-console.png) |
30
- | **Watch a file become memory** — three named steps, not a pipeline diagram ![Material to memory](output/release/v10.7.0/screenshots/11-knowledge-journey.png) | **Say how much it may do alone** — one dial in plain words; dangerous actions stay blocked either way ![Settings](output/release/v10.7.0/screenshots/08-system.png) |
27
+ | **Chat with a Brain that remembers** — every conversation grows durable, source-linked memory ![Brain Chat](output/release/v10.9.0/screenshots/04-brain-chat-home.png) | **See how knowledge connects** — a real relationship graph, not a file list ![Memory Graph](output/release/v10.9.0/screenshots/05-memory-graph.png) |
28
+ | **Capture anything** — files, whole folders, notes, screenshots, web pages ![Capture](output/release/v10.9.0/screenshots/06-capture.png) | **Automate with review** — agent changes become proposals you approve first ![Review Center](output/release/v10.9.0/screenshots/12-review-center.png) |
29
+ | **Pick a model in one click** — recommended local models for your hardware ![Recommended Models](output/release/v10.9.0/screenshots/02-recommended-models.png) | **Stay in control** — audit, roles, retention in a separate admin surface ![Admin Console](output/release/v10.9.0/screenshots/10-admin-console.png) |
30
+ | **Watch a file become memory** — three named steps, not a pipeline diagram ![Material to memory](output/release/v10.9.0/screenshots/11-knowledge-journey.png) | **Say how much it may do alone** — one dial in plain words; dangerous actions stay blocked either way ![Settings](output/release/v10.9.0/screenshots/08-system.png) |
31
31
 
32
32
  ## Why Lattice AI
33
33
 
@@ -58,51 +58,64 @@ First-run flow — wake the Brain, pick the owner, load a recommended model:
58
58
 
59
59
  | | | |
60
60
  | --- | --- | --- |
61
- | ![Login](output/release/v10.7.0/screenshots/01-login.png) | ![Model install](output/release/v10.7.0/screenshots/03-install-load-progress.png) | ![Model library](output/release/v10.7.0/screenshots/07-model-library.png) |
61
+ | ![Login](output/release/v10.9.0/screenshots/01-login.png) | ![Model install](output/release/v10.9.0/screenshots/03-install-load-progress.png) | ![Model library](output/release/v10.9.0/screenshots/07-model-library.png) |
62
62
 
63
63
  Screenshot index and capture notes:
64
- [output/release/v10.7.0/SCREENSHOT_INDEX.md](output/release/v10.7.0/SCREENSHOT_INDEX.md)
64
+ [output/release/v10.9.0/SCREENSHOT_INDEX.md](output/release/v10.9.0/SCREENSHOT_INDEX.md)
65
65
 
66
66
  ## Current Release
67
67
 
68
- The current release is **10.7.0 — Plain Surface**:
69
-
70
- Every screen was rebuilt. Lattice AI is a personal memory system that runs on
71
- your own machine, and it had come to look like an operations console — dense
72
- grids of panels, developer vocabulary in the labels, and the thing you came to
73
- do buried under the things you rarely touch. 10.7.0 rearranges all twelve
74
- screens around what the person is actually there for, in plain language.
75
-
76
- **Nothing was removed to achieve it.** The 38 hash destinations in
77
- `frontend/src/routes.ts` are the same 38 as before the rebuild — none dropped,
78
- none added. `frontend/src/routes.test.ts` checks every one of them resolves to a
79
- real screen, and names the landing spot outright for the paths most likely to
80
- regress.
81
-
82
- - **Layout actually changed.** `scripts/check_screenshot_pixel_delta.py` compares
83
- all twelve captures against the previous release and fails when a screen only
84
- changed its wording. Multi-column grid classes across `frontend/src` fell from
85
- 22 occurrences to 6, 4 of them outside test assertions —
86
- `grep -rhoE 'grid-cols-[a-z0-9]+' frontend/src | wc -l` against the
87
- pre-rebuild tree and this one.
88
- - **Features still reachable.** Every destination was rearranged, not retired:
89
- 38 → 38. Copy keys have no such gate — 18 keys no screen rendered any more
90
- were removed by hand, each after checking the feature is reachable elsewhere.
91
- - **Evidence bound to this build.** `output/release/v10.7.0/` holds the twelve
68
+ The current release is **10.9.0 — Never Blocks**:
69
+
70
+ Lattice AI runs one event loop on your computer. Several things you could ask
71
+ it to do were being done *on* that loop, so while they ran the server could not
72
+ answer anything else. Downloading a model has a fifteen-minute timeout; that is
73
+ how long the whole product could be frozen by one click.
74
+
75
+ - **Nothing long runs on the event loop.** Model pulls, engine installs, MCP
76
+ package installs and the CPU/RAM probe behind the System screen all moved to
77
+ a worker thread. `/local/sysinfo` is the one most people met without knowing:
78
+ it is read by the System screen *and* by first-run analysis, so its ~2s
79
+ freeze landed exactly while an answer was streaming. Two things keep it
80
+ fixed — ruff's `ASYNC` blocking-call rules, and
81
+ `tests/unit/test_event_loop_not_blocked.py`, which runs a ticker coroutine
82
+ during the handler and asserts the loop kept getting control.
83
+ - **A focus ring you can see.** 10.8.0 added `border-color` to the transition
84
+ on the capture pills, so keyboard focus faded in over 150ms and at the
85
+ instant it landed the pill still looked idle. Focus rings may not ease.
86
+ - **The Brain stops thinking when it has answered.** A retrieval pulse parked a
87
+ 900ms timer that put the organism back into "thinking"; if the answer
88
+ finished inside that window the timer still fired, and the Brain sat visibly
89
+ working on a question it had already answered.
90
+ - **One language, further in.** Fourteen more routers moved to the message
91
+ catalog — chat, memory, graph, files, portability, review, models, tools,
92
+ MCP, setup. `models.py`, `mcp.py` and `tools.py` had been answering some
93
+ errors in Korean and others in English on the same screen. Two gates keep
94
+ them migrated, and a third fails if the two gates disagree.
95
+ - **The welcome screen fits.** It measured 770px against a 747px viewport, so
96
+ the line explaining what you are agreeing to sat under the fold. Nothing was
97
+ removed; the whitespace gave back the 25px.
98
+ - **The streaming answer path has tests.** `useBrainChat` was 12% covered
99
+ because asserting anything about a stream needs frames arriving over time.
100
+ `frontend/src/test/fakeChatStream.ts` is that harness; eleven cases now cover
101
+ the answer building up, the stop button, refusals, grounding badges and live
102
+ step frames.
103
+ - **Evidence bound to this build.** `output/release/v10.9.0/` holds the twelve
92
104
  capture screens, walkthrough gif/webm, and the asset-manifest / mock-server
93
- fingerprints that lint re-checks before merge.
94
- - **Exact artifact names only.** Publish paths list `dist/ltcai-10.7.0-*` and
95
- `ltcai-10.7.0.tgz` — never `dist/*`.
105
+ fingerprints that lint re-checks before merge. 10.9.0 claims no screen
106
+ redesign, and `scripts/release_screen_claims.json` says so explicitly.
107
+ - **Exact artifact names only.** Publish paths list `dist/ltcai-10.9.0-*` and
108
+ `ltcai-10.9.0.tgz` — never `dist/*`.
96
109
 
97
110
  Release notes: [RELEASE.md](RELEASE.md) · Full history: [docs/CHANGELOG.md](docs/CHANGELOG.md)
98
111
 
99
- Expected artifacts for 10.7.0 release must use exact filenames:
112
+ Expected artifacts for 10.9.0 release must use exact filenames:
100
113
 
101
- - `dist/ltcai-10.7.0-py3-none-any.whl`
102
- - `dist/ltcai-10.7.0.tar.gz`
103
- - `ltcai-10.7.0.tgz`
104
- - `dist/ltcai-10.7.0.vsix`
105
- - `src-tauri/target/release/bundle/dmg/Lattice AI_10.7.0_aarch64.dmg`
114
+ - `dist/ltcai-10.9.0-py3-none-any.whl`
115
+ - `dist/ltcai-10.9.0.tar.gz`
116
+ - `ltcai-10.9.0.tgz`
117
+ - `dist/ltcai-10.9.0.vsix`
118
+ - `src-tauri/target/release/bundle/dmg/Lattice AI_10.9.0_aarch64.dmg`
106
119
 
107
120
  Do not use wildcard artifact uploads. Package registry publishing remains owner-run.
108
121
 
@@ -137,6 +150,8 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for details and
137
150
 
138
151
  | Version | Theme |
139
152
  | --- | --- |
153
+ | 10.9.0 | Never Blocks |
154
+ | 10.8.0 | Within Reach |
140
155
  | 10.7.0 | Plain Surface |
141
156
  | 10.6.4 | Loud Limits |
142
157
  | 10.6.3 | Loud Limits |
package/docs/CHANGELOG.md CHANGED
@@ -4,6 +4,74 @@ The top entry is either the current unreleased main-branch work or the current
4
4
  release line. Older entries are historical and may describe behavior as it
5
5
  existed at that release.
6
6
 
7
+ ## [10.9.0] - 2026-08-05
8
+
9
+ ### Fixed
10
+ - 모델 내려받기(`ollama pull`, Hugging Face), 엔진 설치, MCP `pip`/`npm` 설치,
11
+ `/local/sysinfo` 호스트 측정이 `async def` 안에서 그대로 실행되어 실행 중
12
+ 서버 전체가 다른 요청을 처리하지 못하던 문제 — 전부 `asyncio.to_thread`로
13
+ 이관. 최악의 경우 15분(다운로드 타임아웃)간 채팅·`/health`·UI가 모두 멈췄습니다.
14
+ - Telegram 브리지의 사진/문서 업로드가 파일을 이벤트 루프에서 읽던 문제.
15
+ - 캡처 필의 키보드 포커스 테두리가 150ms에 걸쳐 서서히 나타나, 포커스가 닿는
16
+ 순간에는 여전히 idle 색이던 문제(`home-simple.css` transition에서
17
+ `border-color` 제거).
18
+ - 답변이 끝난 뒤에도 Brain 유기체가 "생각 중"에 머물던 문제 — 회상 펄스가
19
+ 건 900ms 타이머가 스트림 종료 시 취소되지 않았습니다.
20
+ - 같은 틱에 두 번 전송될 때 `stopStreaming`이 중단 핸들을 잃던 문제.
21
+ - 환영 화면이 747px 뷰포트에서 770px이라 마지막 안내 문장이 접힌 부분 아래에
22
+ 있던 문제(`@media (max-height: 820px)` 단계 추가).
23
+
24
+ ### Added
25
+ - `tests/unit/test_event_loop_not_blocked.py` — 핸들러 실행 중 티커 코루틴을
26
+ 돌려 이벤트 루프가 실제로 제어권을 받았는지 측정하는 회귀 테스트.
27
+ - `frontend/src/test/fakeChatStream.ts` — 프레임이 시간에 걸쳐 도착하는 가짜
28
+ SSE 하네스. `useBrainChat.test.tsx` 11개 시나리오.
29
+ - `scripts/check_server_i18n.mjs` — 이관 완료 선언된 라우터에서 문자열 리터럴
30
+ detail을 금지하는 lint 게이트(`npm run lint`에 편입).
31
+ - `tests/visual/v3.spec.js` — 환영 화면 fold 테스트, 캡처 필 포커스 테두리.
32
+ - ruff `ASYNC210/220/221/222/230/251` 규칙.
33
+
34
+ ### Changed
35
+ - 서버 메시지 카탈로그를 14개 라우터로 확대(chat, chat_history, chat_intents,
36
+ memory, knowledge_graph, local_files, portability, review_queue,
37
+ project_sessions, network_boundary, models, tools, mcp, setup) — 총 17개.
38
+ `MIGRATED_ROUTERS`와 lint 게이트 목록이 어긋나면 테스트가 실패합니다.
39
+ - `latticeai/services/model_loading.py`: 엔진 준비/다운로드 블록을
40
+ `_prepare_model_sources()`로 추출(워커 스레드에서 실행 가능한 동기 함수).
41
+ - `latticeai/api/static_routes.py`: 호스트 측정을 `_probe_host_capacity()`로
42
+ 추출.
43
+
44
+ ## [10.8.0] - 2026-08-04
45
+
46
+ ### Fixed
47
+ - 온보딩 login/recommend/install 3개 화면에서 주 작업이 접힌 부분 아래에서
48
+ 시작하던 문제 — 히어로 브레인을 단계별 크기(`data-scale`)로 분리.
49
+ - 설치 화면이 번역 키 원문(`flow.install.stage.idle`)을 사용자에게 출력하던
50
+ 문제 — `t()`가 `defaultValue` 옵션을 읽지 않았고, 옵션 자체가 보간 값으로
51
+ 새어 들어가고 있었습니다.
52
+ - 추천 모델 화면이 셸 중앙에서 왼쪽으로 치우쳐 있던 문제(`margin-inline`).
53
+ - Brain 대화 홈의 추천 칩이 4글자를 담은 채 952px로 늘어나던 문제 —
54
+ `@media (max-height)` 블록이 `display:flex`만 재선언하고 `flex-direction`을
55
+ 다시 쓰지 않아 기본 규칙의 `column` 축을 물려받고 있었습니다.
56
+ - 브라우저 확장 팝업이 한 창 안에서 한국어와 영어를 섞어 보여주던 문제.
57
+
58
+ ### Added
59
+ - `latticeai/core/messages.py` — 서버 사이드 메시지 카탈로그(ko/en)와
60
+ 요청 기반 언어 결정(`X-Lattice-Language` → `Accept-Language`).
61
+ - `frontend/src/styles/mediaQueryOverride.test.ts` — 미디어 쿼리가 `display`를
62
+ 재선언하면서 축을 다시 쓰지 않는 부분 오버라이드 가드.
63
+ - 신규 테스트 스위트: `App.tsx`, `AdminConsole.tsx`, `lib/folderPicker.ts`,
64
+ `api/client.ts` 확장, 멀티에이전트 JSON 복구, 증분 인덱스 재빌드.
65
+
66
+ ### Changed
67
+ - 파일 생성이 실패 응답 중 "가장 긴" 것이 아니라 "가장 파일에 가까운" 것을
68
+ 복구에 넘깁니다. 동일 응답 반복 시 1회 한정 추가 시도.
69
+ - 산문 파일 타입(`.md`/`.txt`/…)에도 검증 추가 — 파일에 *대한* 답변은 파일이
70
+ 아닙니다.
71
+ - 멀티에이전트 계획 파서: 균형 스팬 스캔 + `<think>` 제거 + trailing comma 복구.
72
+ - 증분 벡터 재빌드가 스트리밍 + 단일 해시맵 조회로 전환.
73
+ - `latticeai/core/workspace_os.py` 1128 → 945줄.
74
+
7
75
  ## [10.7.0] - 2026-08-04
8
76
 
9
77
  ### Changed
@@ -1,6 +1,6 @@
1
1
  # Community And Plugins
2
2
 
3
- Current release: **10.7.0 — Plain Surface**.
3
+ Current release: **10.9.0 — Never Blocks**.
4
4
 
5
5
  LatticeAI defines the path from a strong local-first framework (8.4.0
6
6
  action-aware baseline, 8.5.0 registry+DI hardening, 8.6.0 capture/navigation
@@ -3,7 +3,7 @@
3
3
  > **Status: canonical** — current contributor guidance, kept in sync with the
4
4
  > current release.
5
5
 
6
- Current release: **10.7.0 — Plain Surface**.
6
+ Current release: **10.9.0 — Never Blocks**.
7
7
 
8
8
  This document is for contributors working on the local-first Digital Brain
9
9
  codebase. Product positioning and quick start stay in `README.md`; release
@@ -1,6 +1,6 @@
1
1
  # Lattice AI Onboarding
2
2
 
3
- Current release: **10.7.0 — Plain Surface**.
3
+ Current release: **10.9.0 — Never Blocks**.
4
4
 
5
5
  The first-run goal is a five-minute path from "I opened the app" to "my Brain
6
6
  has a source, a question, and proof." This page is the product contract behind
@@ -1,4 +1,4 @@
1
- # Lattice AI — Operations Guide (v10.7.0)
1
+ # Lattice AI — Operations Guide (v10.9.0)
2
2
 
3
3
  > **Status: canonical** — kept in sync with the current release. Storage layout
4
4
  > below reflects the SQLite live Brain store and workspace scoping, not the
@@ -1,6 +1,6 @@
1
1
  # Lattice AI Trust Model
2
2
 
3
- Current release: **10.7.0 — Plain Surface**.
3
+ Current release: **10.9.0 — Never Blocks**.
4
4
 
5
5
  Lattice AI is local-first, explicit about external communication, and honest
6
6
  when a capability is unavailable.
@@ -1,6 +1,6 @@
1
1
  # Why Lattice AI Exists
2
2
 
3
- Current release: **10.7.0 — Plain Surface**.
3
+ Current release: **10.9.0 — Never Blocks**.
4
4
 
5
5
  **Lattice AI is a local-first Digital Brain that keeps your knowledge durable
6
6
  across any AI model.**
package/docs/kg-schema.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Knowledge Graph Schema
2
2
 
3
- Current release: **10.7.0 — Plain Surface**.
3
+ Current release: **10.9.0 — Never Blocks**.
4
4
 
5
5
  명세 출처: `lattice_ai_full_spec.pptx` 슬라이드 20·21·22
6
6
  구현: `lattice_brain/graph/schema.py`
@@ -26,7 +26,7 @@ from .storage import (
26
26
  storage_from_env,
27
27
  )
28
28
 
29
- __version__ = "10.7.0"
29
+ __version__ = "10.9.0"
30
30
 
31
31
  __all__ = [
32
32
  "AgentRuntime",
@@ -136,14 +136,45 @@ class KnowledgeGraphVectorMixin(_Core):
136
136
  )
137
137
  return {"current": current, "recorded": recorded, "stale_embedder": stale}
138
138
 
139
+ def _vector_text_hashes(self, conn: sqlite3.Connection) -> Dict[str, str]:
140
+ """``item_id -> text_hash`` for rows already embedded by *this* embedder.
141
+
142
+ The incremental rebuild's job is mostly deciding what it does *not*
143
+ have to do, and it used to ask that question with one ``SELECT`` per
144
+ candidate item — a round trip per node and per chunk on every run,
145
+ almost all of which answer "unchanged". One query returning two short
146
+ columns replaces all of them.
147
+
148
+ Rows written by a different embedder are left out, so they compare as
149
+ missing and get re-embedded, which is what an embedder swap requires.
150
+ """
151
+ return {
152
+ row["item_id"]: row["text_hash"]
153
+ for row in conn.execute(
154
+ """
155
+ SELECT item_id, text_hash
156
+ FROM vector_embeddings
157
+ WHERE embedding_model=? AND embedding_dim=?
158
+ """,
159
+ (self._embedding_model.model_id, self._embedding_model.dim),
160
+ ).fetchall()
161
+ }
162
+
139
163
  def _iter_vector_source_items(
140
164
  self,
141
165
  conn: sqlite3.Connection,
142
166
  *,
143
167
  include_nodes: bool = True,
144
168
  include_chunks: bool = True,
145
- ) -> List[Dict[str, Any]]:
146
- items: List[Dict[str, Any]] = []
169
+ ) -> Iterator[Dict[str, Any]]:
170
+ """Stream the graph's embeddable text, one item at a time.
171
+
172
+ Yields rather than returns a list. Every caller consumes this exactly
173
+ once in a ``for``, and building the list first meant a rebuild held
174
+ the full text of every node and chunk in memory simultaneously — the
175
+ one shape guaranteed to fail on precisely the large graph that most
176
+ needs the index.
177
+ """
147
178
  if include_nodes:
148
179
  for row in conn.execute(
149
180
  """
@@ -160,15 +191,13 @@ class KnowledgeGraphVectorMixin(_Core):
160
191
  metadata=metadata,
161
192
  )
162
193
  if text:
163
- items.append(
164
- {
165
- "item_id": row["id"],
166
- "item_type": "node",
167
- "source_node": row["id"],
168
- "text": text,
169
- "metadata": {"node_type": row["type"], **metadata},
170
- }
171
- )
194
+ yield {
195
+ "item_id": row["id"],
196
+ "item_type": "node",
197
+ "source_node": row["id"],
198
+ "text": text,
199
+ "metadata": {"node_type": row["type"], **metadata},
200
+ }
172
201
  if include_chunks:
173
202
  for row in conn.execute(
174
203
  """
@@ -181,19 +210,16 @@ class KnowledgeGraphVectorMixin(_Core):
181
210
  metadata = _safe_loads(row["metadata_json"])
182
211
  text = _clean_text(row["text"] or "")
183
212
  if text:
184
- items.append(
185
- {
186
- "item_id": row["id"],
187
- "item_type": "chunk",
188
- "source_node": row["id"],
189
- "text": text,
190
- "metadata": {
191
- **metadata,
192
- "parent_source_node": row["parent_source_node"],
193
- },
194
- }
195
- )
196
- return items
213
+ yield {
214
+ "item_id": row["id"],
215
+ "item_type": "chunk",
216
+ "source_node": row["id"],
217
+ "text": text,
218
+ "metadata": {
219
+ **metadata,
220
+ "parent_source_node": row["parent_source_node"],
221
+ },
222
+ }
197
223
 
198
224
  def index_node_incremental(self, node_id: str) -> Dict[str, Any]:
199
225
  """Embed/index only ``node_id`` and its chunks (incremental sync).
@@ -345,15 +371,22 @@ class KnowledgeGraphVectorMixin(_Core):
345
371
  conn.execute(
346
372
  f"DELETE FROM vector_embeddings WHERE item_type IN ({','.join(filters)})"
347
373
  )
348
- items = self._iter_vector_source_items(
374
+ # After a full wipe nothing is current by definition, so the
375
+ # prefetch would only be a wasted scan of a table we just
376
+ # emptied. Incremental is where it pays: it turns "one SELECT
377
+ # per item, nearly all of which say unchanged" into one query.
378
+ known = {} if full else self._vector_text_hashes(conn)
379
+ total = indexed = skipped = 0
380
+ for item in self._iter_vector_source_items(
349
381
  conn,
350
382
  include_nodes=include_nodes,
351
383
  include_chunks=include_chunks,
352
- )
353
- indexed = skipped = 0
354
- for item in items:
355
- changed = self._upsert_vector_item(conn, **item)
356
- if changed:
384
+ ):
385
+ total += 1
386
+ if known.get(item["item_id"]) == _sha256_text(_clean_text(item["text"])):
387
+ skipped += 1
388
+ continue
389
+ if self._upsert_vector_item(conn, **item):
357
390
  indexed += 1
358
391
  else:
359
392
  skipped += 1
@@ -367,7 +400,7 @@ class KnowledgeGraphVectorMixin(_Core):
367
400
  """,
368
401
  (
369
402
  _now(),
370
- len(items),
403
+ total,
371
404
  indexed,
372
405
  skipped,
373
406
  _json(
@@ -390,7 +423,7 @@ class KnowledgeGraphVectorMixin(_Core):
390
423
  "status": "completed",
391
424
  "operation_id": op_id,
392
425
  "full": bool(full),
393
- "items_total": len(items),
426
+ "items_total": total,
394
427
  "items_indexed": indexed,
395
428
  "items_skipped": skipped,
396
429
  "duration_ms": duration_ms,
@@ -442,7 +475,10 @@ class KnowledgeGraphVectorMixin(_Core):
442
475
  "SELECT item_type, COUNT(*) AS count FROM vector_embeddings GROUP BY item_type"
443
476
  )
444
477
  }
445
- source_items = self._iter_vector_source_items(conn)
478
+ # Materialised on purpose, unlike the rebuild path: status walks
479
+ # this set twice (once for ids, once to classify each item) and
480
+ # reports a count, none of which a one-shot iterator can serve.
481
+ source_items = list(self._iter_vector_source_items(conn))
446
482
  vector_rows = {
447
483
  row["item_id"]: row
448
484
  for row in conn.execute(
@@ -41,13 +41,15 @@ orchestrator for the product ``/agents`` surface.
41
41
 
42
42
  from __future__ import annotations
43
43
 
44
+ import json
45
+ import re
44
46
  from dataclasses import dataclass, field
45
47
  from typing import Any, Callable, Dict, List, Optional
46
48
 
47
49
  from ..utils import now_iso as _now
48
50
  from .contracts import multi_agent_contract
49
51
 
50
- MULTI_AGENT_VERSION = "10.7.0"
52
+ MULTI_AGENT_VERSION = "10.9.0"
51
53
 
52
54
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
53
55
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -448,23 +450,102 @@ def default_role_runner(
448
450
  return runner
449
451
 
450
452
 
453
+ def _json_object_candidates(text: str) -> List[str]:
454
+ """Every balanced ``{...}`` span in ``text``, outermost first.
455
+
456
+ ``find("{")`` to ``rfind("}")`` is one span, and it is the wrong one
457
+ whenever the model wrote anything after its object — a closing remark, a
458
+ second example, a stray ``}`` in prose — because the slice then contains
459
+ the object *and* the noise, and parses as neither. Scanning for balance
460
+ yields the actual objects, in the order they appear.
461
+
462
+ String literals are tracked so a brace inside ``"a } b"`` does not close a
463
+ span early.
464
+ """
465
+ spans: List[str] = []
466
+ depth = 0
467
+ start = -1
468
+ in_string = False
469
+ escaped = False
470
+ for index, char in enumerate(text):
471
+ if in_string:
472
+ if escaped:
473
+ escaped = False
474
+ elif char == "\\":
475
+ escaped = True
476
+ elif char == '"':
477
+ in_string = False
478
+ continue
479
+ if char == '"':
480
+ in_string = True
481
+ elif char == "{":
482
+ if depth == 0:
483
+ start = index
484
+ depth += 1
485
+ elif char == "}":
486
+ if depth:
487
+ depth -= 1
488
+ if depth == 0 and start >= 0:
489
+ spans.append(text[start : index + 1])
490
+ return spans
491
+
492
+
493
+ #: A comma directly before a closing brace/bracket. Small local models emit
494
+ #: these constantly — it is the single most common reason an otherwise correct
495
+ #: plan is thrown away.
496
+ _TRAILING_COMMA_RE = re.compile(r",(\s*[}\]])")
497
+
498
+ #: Reasoning models wrap their scratchpad in these, and the scratchpad is full
499
+ #: of braces. Removed before anything else so it cannot poison a span.
500
+ _THINK_BLOCK_RE = re.compile(
501
+ r"<(think|thinking|reasoning|reflection)>.*?</\1>",
502
+ re.DOTALL | re.IGNORECASE,
503
+ )
504
+ _THINK_OPEN_RE = re.compile(r"<(think|thinking|reasoning)>.*\Z", re.DOTALL | re.IGNORECASE)
505
+
506
+
451
507
  def _extract_json_object(raw: str) -> Dict[str, Any]:
452
- """Parse one JSON object out of an LLM response (fences/prose tolerated)."""
453
- import json as _json
454
- import re as _re
508
+ """Parse one JSON object out of an LLM response (fences/prose tolerated).
455
509
 
510
+ Recovery only — never invention. Every strategy here returns something the
511
+ model actually wrote; when none of them parse, this still raises and the
512
+ run fails loudly with the raw output preserved, exactly as before. The
513
+ point is that a 1–4B model's plan should not be discarded over a trailing
514
+ comma or a farewell sentence.
515
+ """
456
516
  text = str(raw or "").strip()
457
- fenced = _re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=_re.DOTALL)
458
- if fenced:
459
- text = fenced.group(1)
460
- elif not text.startswith("{"):
461
- start, end = text.find("{"), text.rfind("}")
462
- if start >= 0 and end > start:
463
- text = text[start : end + 1]
464
- parsed = _json.loads(text)
465
- if not isinstance(parsed, dict):
466
- raise ValueError("model returned JSON that is not an object")
467
- return parsed
517
+ text = _THINK_BLOCK_RE.sub("", text)
518
+ text = _THINK_OPEN_RE.sub("", text).strip()
519
+
520
+ candidates: List[str] = []
521
+ for fenced in re.findall(r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=re.DOTALL):
522
+ candidates.append(fenced)
523
+ candidates.extend(_json_object_candidates(text))
524
+ if text.startswith("{"):
525
+ candidates.append(text)
526
+
527
+ last_error: Optional[Exception] = None
528
+ empty_fallback: Optional[Dict[str, Any]] = None
529
+ for candidate in candidates:
530
+ for attempt in (candidate, _TRAILING_COMMA_RE.sub(r"\1", candidate)):
531
+ try:
532
+ parsed = json.loads(attempt)
533
+ except ValueError as exc:
534
+ last_error = exc
535
+ continue
536
+ if not isinstance(parsed, dict):
537
+ last_error = ValueError("model returned JSON that is not an object")
538
+ continue
539
+ if parsed:
540
+ return parsed
541
+ # A prose aside like "I considered using { } notation" is a
542
+ # syntactically valid empty object, and taking the first thing that
543
+ # parses handed that back as the plan. An empty object is only ever
544
+ # the answer when nothing else in the reply is one.
545
+ empty_fallback = parsed
546
+ if empty_fallback is not None:
547
+ return empty_fallback
548
+ raise last_error or ValueError("model returned no JSON object")
468
549
 
469
550
 
470
551
  def llm_role_runner(
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "10.7.0"
3
+ __version__ = "10.9.0"
@@ -5,9 +5,10 @@ from collections import defaultdict
5
5
  from datetime import datetime, timedelta
6
6
  from typing import Any, Callable, Dict, List, Optional
7
7
 
8
- from fastapi import APIRouter, HTTPException, Query, Request
8
+ from fastapi import APIRouter, Query, Request
9
9
  from pydantic import BaseModel
10
10
 
11
+ from latticeai.core.messages import http_error, resolve_language
11
12
  from latticeai.core.workspace_os import DEFAULT_WORKSPACE_ID
12
13
 
13
14
 
@@ -391,17 +392,18 @@ def create_admin_router(
391
392
 
392
393
  @router.patch("/admin/users/{email:path}")
393
394
  async def admin_update_user(email: str, req: AdminUserUpdate, request: Request):
395
+ lang = resolve_language(request)
394
396
  admin_email, users = require_admin(request)
395
397
  if email not in users:
396
- raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
398
+ raise http_error(404, "auth.user_not_found", lang)
397
399
  before = public_user(email, users[email], users)
398
400
  if req.role is not None:
399
401
  if req.role not in {"admin", "user"}:
400
- raise HTTPException(status_code=400, detail="role은 admin 또는 user만 가능합니다.")
402
+ raise http_error(400, "admin.invalid_role", lang)
401
403
  users[email]["role"] = req.role
402
404
  if req.disabled is not None:
403
405
  if email == admin_email and req.disabled:
404
- raise HTTPException(status_code=400, detail="자기 자신은 비활성화할 수 없습니다.")
406
+ raise http_error(400, "admin.cannot_disable_self", lang)
405
407
  users[email]["disabled"] = req.disabled
406
408
  save_users(users)
407
409
  after = public_user(email, users[email], users)
@@ -410,11 +412,12 @@ def create_admin_router(
410
412
 
411
413
  @router.delete("/admin/users/{email:path}")
412
414
  async def admin_delete_user(email: str, request: Request):
415
+ lang = resolve_language(request)
413
416
  admin_email, users = require_admin(request)
414
417
  if email == admin_email:
415
- raise HTTPException(status_code=400, detail="자기 자신은 삭제할 수 없습니다.")
418
+ raise http_error(400, "admin.cannot_delete_self", lang)
416
419
  if email not in users:
417
- raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
420
+ raise http_error(404, "auth.user_not_found", lang)
418
421
  deleted = public_user(email, users[email], users)
419
422
  append_audit_event("user_delete", user_email=admin_email, target_email=email, deleted_user=deleted)
420
423
  del users[email]