ltcai 4.6.0 → 4.7.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 (37) hide show
  1. package/README.md +145 -194
  2. package/docs/CHANGELOG.md +139 -1
  3. package/docs/PRODUCT_DIRECTION_REVIEW.md +88 -0
  4. package/docs/V4_6_0_LIVING_BRAIN_EXPERIENCE_REPORT.md +33 -19
  5. package/docs/V4_6_1_RELEASE_REFRESH_REPORT.md +42 -0
  6. package/docs/V4_7_0_ADMIN_SEPARATION_REPORT.md +42 -0
  7. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +20 -18
  8. package/frontend/src/App.tsx +1098 -171
  9. package/frontend/src/api/client.ts +2 -0
  10. package/frontend/src/components/BrainConversation.tsx +10 -2
  11. package/frontend/src/components/LivingBrain.tsx +197 -106
  12. package/frontend/src/components/ProductFlow.tsx +210 -129
  13. package/frontend/src/styles.css +1946 -36
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/archive.py +86 -13
  16. package/lattice_brain/portability.py +82 -14
  17. package/lattice_brain/runtime/multi_agent.py +1 -1
  18. package/latticeai/__init__.py +1 -1
  19. package/latticeai/api/admin.py +30 -4
  20. package/latticeai/api/chat.py +25 -11
  21. package/latticeai/app_factory.py +8 -2
  22. package/latticeai/core/audit.py +3 -2
  23. package/latticeai/core/marketplace.py +1 -1
  24. package/latticeai/core/workspace_os.py +1 -1
  25. package/package.json +1 -1
  26. package/scripts/launch-pts-grok.sh +56 -0
  27. package/src-tauri/Cargo.lock +1 -1
  28. package/src-tauri/Cargo.toml +1 -1
  29. package/src-tauri/tauri.conf.json +1 -1
  30. package/static/app/asset-manifest.json +5 -5
  31. package/static/app/assets/index-DFmuiJ6t.css +2 -0
  32. package/static/app/assets/index-DwX3rNfA.js +16 -0
  33. package/static/app/assets/index-DwX3rNfA.js.map +1 -0
  34. package/static/app/index.html +2 -2
  35. package/static/app/assets/index-By-G-Kay.css +0 -2
  36. package/static/app/assets/index-CJx6WuQH.js +0 -336
  37. package/static/app/assets/index-CJx6WuQH.js.map +0 -1
@@ -26,7 +26,7 @@ from .storage import (
26
26
  storage_from_env,
27
27
  )
28
28
 
29
- __version__ = "4.6.0"
29
+ __version__ = "4.7.0"
30
30
 
31
31
  __all__ = [
32
32
  "AgentRuntime",
@@ -50,6 +50,10 @@ def _now() -> str:
50
50
  return datetime.now(timezone.utc).isoformat()
51
51
 
52
52
 
53
+ def _stamp() -> str:
54
+ return _now().replace(":", "").replace("-", "").replace(".", "")[:15]
55
+
56
+
53
57
  def _derive_key(passphrase: str, salt: bytes) -> bytes:
54
58
  if not passphrase:
55
59
  raise ValueError("A passphrase is required for encrypted .latticebrain archives.")
@@ -93,6 +97,78 @@ def _assert_safe_member(name: str) -> PurePosixPath:
93
97
  return path
94
98
 
95
99
 
100
+ def _pre_restore_backup_dir(anchor: Path) -> Path:
101
+ backup_dir = anchor.parent / f"{anchor.name}.pre-restore-{_stamp()}"
102
+ index = 1
103
+ while backup_dir.exists():
104
+ backup_dir = anchor.parent / f"{anchor.name}.pre-restore-{_stamp()}-{index}"
105
+ index += 1
106
+ backup_dir.mkdir(parents=True)
107
+ return backup_dir
108
+
109
+
110
+ def _sqlite_siblings(db_path: Path) -> tuple[Path, Path, Path]:
111
+ return (db_path, Path(str(db_path) + "-wal"), Path(str(db_path) + "-shm"))
112
+
113
+
114
+ def _restore_sibling(path: Path, backup: Path) -> None:
115
+ if backup.exists():
116
+ shutil.copy2(backup, path)
117
+ elif path.exists():
118
+ path.unlink()
119
+
120
+
121
+ def _replace_sqlite_atomically(src: Path, dest: Path, backup_dir: Path) -> None:
122
+ dest.parent.mkdir(parents=True, exist_ok=True)
123
+ tmp = dest.parent / f".{dest.name}.restore-{_stamp()}-{os.getpid()}.tmp"
124
+ shutil.copyfile(src, tmp)
125
+ backups: dict[Path, Path] = {}
126
+ try:
127
+ for sibling in _sqlite_siblings(dest):
128
+ if sibling.exists():
129
+ backup = backup_dir / sibling.name
130
+ shutil.copy2(sibling, backup)
131
+ backups[sibling] = backup
132
+ for sibling in _sqlite_siblings(dest)[1:]:
133
+ if sibling.exists():
134
+ sibling.unlink()
135
+ os.replace(tmp, dest)
136
+ except Exception:
137
+ if tmp.exists():
138
+ tmp.unlink()
139
+ for sibling in _sqlite_siblings(dest):
140
+ _restore_sibling(sibling, backups.get(sibling, backup_dir / sibling.name))
141
+ raise
142
+
143
+
144
+ def _rollback_sqlite_from_backup(dest: Path, backup_dir: Path) -> None:
145
+ for sibling in _sqlite_siblings(dest):
146
+ _restore_sibling(sibling, backup_dir / sibling.name)
147
+
148
+
149
+ def _replace_tree_with_backup(src: Optional[Path], dest: Path, backup_dir: Path) -> None:
150
+ dest.parent.mkdir(parents=True, exist_ok=True)
151
+ staged = dest.parent / f".{dest.name}.restore-{_stamp()}-{os.getpid()}"
152
+ backup = backup_dir / dest.name
153
+ if src and src.exists():
154
+ shutil.copytree(src, staged)
155
+ else:
156
+ staged.mkdir(parents=True)
157
+ try:
158
+ if dest.exists():
159
+ shutil.copytree(dest, backup)
160
+ shutil.rmtree(dest)
161
+ os.replace(staged, dest)
162
+ except Exception:
163
+ if staged.exists():
164
+ shutil.rmtree(staged)
165
+ if dest.exists():
166
+ shutil.rmtree(dest)
167
+ if backup.exists():
168
+ shutil.copytree(backup, dest)
169
+ raise
170
+
171
+
96
172
  @dataclass(frozen=True)
97
173
  class BrainArchivePaths:
98
174
  db_path: Path
@@ -397,19 +473,15 @@ class EncryptedBrainArchive:
397
473
  db_src = out / "knowledge_graph.sqlite"
398
474
  if not db_src.exists():
399
475
  raise ValueError("Archive payload is missing knowledge_graph.sqlite.")
400
- target.db_path.parent.mkdir(parents=True, exist_ok=True)
401
- for sibling in (target.db_path, Path(str(target.db_path) + "-wal"), Path(str(target.db_path) + "-shm")):
402
- if sibling.exists():
403
- sibling.unlink()
404
- shutil.copyfile(db_src, target.db_path)
405
- blobs_src = out / "blobs"
406
- if target.blob_dir:
407
- if target.blob_dir.exists():
408
- shutil.rmtree(target.blob_dir)
409
- if blobs_src.exists():
410
- shutil.copytree(blobs_src, target.blob_dir)
411
- else:
412
- target.blob_dir.mkdir(parents=True, exist_ok=True)
476
+ backup_dir = _pre_restore_backup_dir(target.db_path)
477
+ try:
478
+ _replace_sqlite_atomically(db_src, target.db_path, backup_dir)
479
+ blobs_src = out / "blobs"
480
+ if target.blob_dir:
481
+ _replace_tree_with_backup(blobs_src if blobs_src.exists() else None, target.blob_dir, backup_dir)
482
+ except Exception:
483
+ _rollback_sqlite_from_backup(target.db_path, backup_dir)
484
+ raise
413
485
  if target.data_dir:
414
486
  data_src = out / "data"
415
487
  exports_src = out / "workspace_exports"
@@ -439,6 +511,7 @@ class EncryptedBrainArchive:
439
511
  "path": str(target.db_path),
440
512
  "encrypted": True,
441
513
  "verified": True,
514
+ "pre_restore_backup": str(backup_dir),
442
515
  "manifest": manifest,
443
516
  }
444
517
 
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
 
16
16
  import hashlib
17
17
  import json
18
+ import os
18
19
  import shutil
19
20
  import tempfile
20
21
  import zipfile
@@ -57,6 +58,78 @@ def _safe_zip_names(names) -> None:
57
58
  raise ValueError(f"Backup archive contains unsafe path: {name}")
58
59
 
59
60
 
61
+ def _pre_restore_backup_dir(anchor: Path) -> Path:
62
+ backup_dir = anchor.parent / f"{anchor.name}.pre-restore-{_stamp()}"
63
+ index = 1
64
+ while backup_dir.exists():
65
+ backup_dir = anchor.parent / f"{anchor.name}.pre-restore-{_stamp()}-{index}"
66
+ index += 1
67
+ backup_dir.mkdir(parents=True)
68
+ return backup_dir
69
+
70
+
71
+ def _sqlite_siblings(db_path: Path) -> tuple[Path, Path, Path]:
72
+ return (db_path, Path(str(db_path) + "-wal"), Path(str(db_path) + "-shm"))
73
+
74
+
75
+ def _restore_sibling(path: Path, backup: Path) -> None:
76
+ if backup.exists():
77
+ shutil.copy2(backup, path)
78
+ elif path.exists():
79
+ path.unlink()
80
+
81
+
82
+ def _replace_sqlite_atomically(src: Path, dest: Path, backup_dir: Path) -> None:
83
+ dest.parent.mkdir(parents=True, exist_ok=True)
84
+ tmp = dest.parent / f".{dest.name}.restore-{_stamp()}-{os.getpid()}.tmp"
85
+ shutil.copyfile(src, tmp)
86
+ backups: dict[Path, Path] = {}
87
+ try:
88
+ for sibling in _sqlite_siblings(dest):
89
+ if sibling.exists():
90
+ backup = backup_dir / sibling.name
91
+ shutil.copy2(sibling, backup)
92
+ backups[sibling] = backup
93
+ for sibling in _sqlite_siblings(dest)[1:]:
94
+ if sibling.exists():
95
+ sibling.unlink()
96
+ os.replace(tmp, dest)
97
+ except Exception:
98
+ if tmp.exists():
99
+ tmp.unlink()
100
+ for sibling in _sqlite_siblings(dest):
101
+ _restore_sibling(sibling, backups.get(sibling, backup_dir / sibling.name))
102
+ raise
103
+
104
+
105
+ def _rollback_sqlite_from_backup(dest: Path, backup_dir: Path) -> None:
106
+ for sibling in _sqlite_siblings(dest):
107
+ _restore_sibling(sibling, backup_dir / sibling.name)
108
+
109
+
110
+ def _replace_tree_with_backup(src: Optional[Path], dest: Path, backup_dir: Path) -> None:
111
+ dest.parent.mkdir(parents=True, exist_ok=True)
112
+ staged = dest.parent / f".{dest.name}.restore-{_stamp()}-{os.getpid()}"
113
+ backup = backup_dir / dest.name
114
+ if src and src.exists():
115
+ shutil.copytree(src, staged)
116
+ else:
117
+ staged.mkdir(parents=True)
118
+ try:
119
+ if dest.exists():
120
+ shutil.copytree(dest, backup)
121
+ shutil.rmtree(dest)
122
+ os.replace(staged, dest)
123
+ except Exception:
124
+ if staged.exists():
125
+ shutil.rmtree(staged)
126
+ if dest.exists():
127
+ shutil.rmtree(dest)
128
+ if backup.exists():
129
+ shutil.copytree(backup, dest)
130
+ raise
131
+
132
+
60
133
  class KGPortabilityService:
61
134
  def __init__(self, *, knowledge_graph: Any, data_dir, enable_graph: bool = True, device_identity: Any = None) -> None:
62
135
  self._kg = knowledge_graph
@@ -204,24 +277,19 @@ class KGPortabilityService:
204
277
  }
205
278
  db_dest = Path(self._kg.db_path)
206
279
  blob_dest = Path(self._kg.blob_dir)
207
- db_dest.parent.mkdir(parents=True, exist_ok=True)
208
- # Drop the live DB + stale WAL/SHM siblings so the restored copy
209
- # is authoritative (no stale journal overlaying old pages).
210
- for sib in (db_dest, Path(str(db_dest) + "-wal"), Path(str(db_dest) + "-shm")):
211
- if sib.exists():
212
- sib.unlink()
213
- shutil.copyfile(db_src, db_dest)
214
- blob_src = tmp / "blobs"
215
- if blob_src.exists():
216
- if blob_dest.exists():
217
- shutil.rmtree(blob_dest)
218
- shutil.copytree(blob_src, blob_dest)
219
- else:
220
- blob_dest.mkdir(parents=True, exist_ok=True)
280
+ backup_dir = _pre_restore_backup_dir(db_dest)
281
+ try:
282
+ _replace_sqlite_atomically(db_src, db_dest, backup_dir)
283
+ blob_src = tmp / "blobs"
284
+ _replace_tree_with_backup(blob_src if blob_src.exists() else None, blob_dest, backup_dir)
285
+ except Exception:
286
+ _rollback_sqlite_from_backup(db_dest, backup_dir)
287
+ raise
221
288
  stats = self._kg.stats()
222
289
  return {
223
290
  "restored": True,
224
291
  "manifest": manifest,
292
+ "pre_restore_backup": str(backup_dir),
225
293
  "nodes": sum(stats.get("nodes", {}).values()),
226
294
  }
227
295
 
@@ -14,7 +14,7 @@ from datetime import datetime
14
14
  from typing import Any, Callable, Dict, List, Optional
15
15
 
16
16
 
17
- MULTI_AGENT_VERSION = "4.6.0"
17
+ MULTI_AGENT_VERSION = "4.7.0"
18
18
 
19
19
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
20
20
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "4.6.0"
3
+ __version__ = "4.7.0"
@@ -7,6 +7,8 @@ from typing import Callable, Dict, List, Optional
7
7
  from fastapi import APIRouter, HTTPException, Request
8
8
  from pydantic import BaseModel
9
9
 
10
+ from latticeai.core.workspace_os import DEFAULT_WORKSPACE_ID
11
+
10
12
 
11
13
  class AdminUserUpdate(BaseModel):
12
14
  role: Optional[str] = None
@@ -42,6 +44,7 @@ def create_admin_router(
42
44
  save_users: Callable,
43
45
  get_user_role: Callable,
44
46
  get_history: Callable,
47
+ get_audit_log: Callable,
45
48
  public_user: Callable,
46
49
  load_vpc_config: Callable,
47
50
  save_vpc_config: Callable,
@@ -60,10 +63,33 @@ def create_admin_router(
60
63
  ) -> APIRouter:
61
64
  router = APIRouter()
62
65
 
66
+ def _workspace_scope(request: Request) -> Optional[str]:
67
+ header = request.headers.get("X-Workspace-Id")
68
+ if header and header.strip():
69
+ return header.strip()
70
+ query = request.query_params.get("workspace_id")
71
+ return query.strip() if query and query.strip() else None
72
+
73
+ def _matches_scope(item: Dict[str, object], workspace_id: Optional[str]) -> bool:
74
+ if not workspace_id:
75
+ return True
76
+ item_scope = item.get("workspace_id")
77
+ if not item_scope and workspace_id == DEFAULT_WORKSPACE_ID:
78
+ return True
79
+ return str(item_scope or "") == str(workspace_id)
80
+
81
+ def _scoped_history(request: Request) -> List[Dict]:
82
+ scope = _workspace_scope(request)
83
+ return [item for item in get_history() if _matches_scope(item, scope)]
84
+
85
+ def _scoped_audit_log(request: Request) -> List[Dict]:
86
+ scope = _workspace_scope(request)
87
+ return [item for item in get_audit_log() if _matches_scope(item, scope)]
88
+
63
89
  @router.get("/admin/summary")
64
90
  async def admin_summary(request: Request):
65
91
  _, users = require_admin(request)
66
- history = get_history()
92
+ history = _scoped_history(request)
67
93
  user_msgs = [i for i in history if i.get("role") == "user"]
68
94
  asst_msgs = [i for i in history if i.get("role") == "assistant"]
69
95
  return {
@@ -79,7 +105,7 @@ def create_admin_router(
79
105
  @router.get("/admin/stats")
80
106
  async def admin_stats(request: Request):
81
107
  require_admin(request)
82
- history = get_history()
108
+ history = _scoped_history(request)
83
109
  daily: dict = defaultdict(lambda: {"user": 0, "assistant": 0})
84
110
  for item in history:
85
111
  ts = item.get("timestamp", "")
@@ -98,12 +124,12 @@ def create_admin_router(
98
124
  @router.get("/admin/sensitivity")
99
125
  async def admin_sensitivity(request: Request):
100
126
  require_admin(request)
101
- return build_sensitivity_report(get_history())
127
+ return build_sensitivity_report(_scoped_history(request))
102
128
 
103
129
  @router.get("/admin/audit")
104
130
  async def admin_audit(request: Request):
105
131
  _, users = require_admin(request)
106
- report = build_admin_audit_report(users)
132
+ report = build_admin_audit_report(users, _scoped_audit_log(request))
107
133
  try:
108
134
  report["graph"] = get_graph_stats() if enable_graph else {"disabled": True}
109
135
  except Exception as e:
@@ -52,6 +52,7 @@ class AgentRequest(BaseModel):
52
52
  temperature: float = 0.1
53
53
  user_email: Optional[str] = None
54
54
  user_nickname: Optional[str] = None
55
+ workspace_id: Optional[str] = None
55
56
  planning_model: Optional[str] = None
56
57
  executing_model: Optional[str] = None
57
58
  reviewing_model: Optional[str] = None
@@ -136,6 +137,13 @@ def format_network_status(info: Dict) -> str:
136
137
  lines.extend(["", note])
137
138
  return "\n".join(lines)
138
139
 
140
+ def workspace_scope_from_request(request: Request) -> Optional[str]:
141
+ header = request.headers.get("X-Workspace-Id")
142
+ if header and header.strip():
143
+ return header.strip()
144
+ query = request.query_params.get("workspace_id")
145
+ return query.strip() if query and query.strip() else None
146
+
139
147
  async def single_text_stream(text: str, model: str = "system") -> AsyncIterator[str]:
140
148
  yield f"data: {json.dumps({'chunk': text, 'model': model}, ensure_ascii=False)}\n\n"
141
149
  yield "data: [DONE]\n\n"
@@ -296,15 +304,20 @@ def create_chat_router(context: AppContext) -> APIRouter:
296
304
  )
297
305
  effective_email = req.user_email or current_user or None
298
306
  history_user = get_history_user(effective_email, req.user_nickname)
307
+ history_meta = {
308
+ "source": req.source or "web",
309
+ "conversation_id": req.conversation_id,
310
+ "workspace_id": workspace_scope_from_request(request),
311
+ }
299
312
 
300
313
  if is_network_status_request(req.message):
301
314
  history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
302
- save_to_history("user", history_message, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
315
+ save_to_history("user", history_message, **history_meta, **history_user)
303
316
  try:
304
317
  answer = format_network_status(network_status())
305
318
  except ToolError as exc:
306
319
  answer = f"네트워크 정보를 확인하지 못했습니다: {exc}"
307
- save_to_history("assistant", answer, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
320
+ save_to_history("assistant", answer, **history_meta, **history_user)
308
321
  notify_chat_message("user", req.message, req.source)
309
322
  notify_chat_message("assistant", answer, req.source)
310
323
  if req.stream:
@@ -362,8 +375,8 @@ def create_chat_router(context: AppContext) -> APIRouter:
362
375
 
363
376
  if is_current_url_request(req.message) and req.client_url:
364
377
  answer = f"현재 페이지 URL: {req.client_url}"
365
- save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
366
- save_to_history("assistant", answer, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
378
+ save_to_history("user", req.message, **history_meta, **history_user)
379
+ save_to_history("assistant", answer, **history_meta, **history_user)
367
380
  notify_chat_message("user", req.message, req.source)
368
381
  notify_chat_message("assistant", answer, req.source)
369
382
  if req.stream:
@@ -459,7 +472,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
459
472
  trace_seed["context_assembly"] = context_trace
460
473
 
461
474
  history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
462
- save_to_history("user", history_message, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
475
+ save_to_history("user", history_message, **history_meta, **history_user)
463
476
  notify_chat_message("user", req.message, req.source)
464
477
 
465
478
  if is_doc_gen and ENABLE_GRAPH and KNOWLEDGE_GRAPH:
@@ -488,7 +501,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
488
501
  yield f"data: {json.dumps({'text': footnote}, ensure_ascii=False)}\n\n"
489
502
  full_text += footnote
490
503
  session.update(graph_md, full_text, req.conversation_id)
491
- save_to_history("assistant", full_text, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
504
+ save_to_history("assistant", full_text, **history_meta, **history_user)
492
505
  trace_record = CHAT_SERVICE.record_trace(
493
506
  question=req.message,
494
507
  response=full_text,
@@ -513,7 +526,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
513
526
  if footnote:
514
527
  result += footnote
515
528
  session.update(graph_md, result, req.conversation_id)
516
- save_to_history("assistant", str(result), source=req.source or "web", conversation_id=req.conversation_id, **history_user)
529
+ save_to_history("assistant", str(result), **history_meta, **history_user)
517
530
  trace_record = CHAT_SERVICE.record_trace(
518
531
  question=req.message,
519
532
  response=str(result),
@@ -549,7 +562,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
549
562
 
550
563
  result = await router.generate(req.message, full_context, req.max_tokens, req.temperature, req.image_data)
551
564
 
552
- save_to_history("assistant", str(result), source=req.source or "web", conversation_id=req.conversation_id, **history_user)
565
+ save_to_history("assistant", str(result), **history_meta, **history_user)
553
566
  trace_record = CHAT_SERVICE.record_trace(
554
567
  question=req.message,
555
568
  response=str(result),
@@ -652,7 +665,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
652
665
  full_response += str(clean_chunk)
653
666
  yield f"data: {json.dumps({'chunk': clean_chunk, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
654
667
  history_user = get_history_user(req.user_email, req.user_nickname)
655
- save_to_history("assistant", full_response, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
668
+ save_to_history("assistant", full_response, **history_meta, **history_user)
656
669
  trace_record = CHAT_SERVICE.record_trace(
657
670
  question=req.message,
658
671
  response=full_response,
@@ -728,6 +741,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
728
741
  """
729
742
  current_user = require_user(request)
730
743
  enforce_rate_limit(current_user, "agent")
744
+ req.workspace_id = req.workspace_id or workspace_scope_from_request(request)
731
745
  if not router.current_model_id:
732
746
  raise HTTPException(status_code=400, detail="No model loaded. Call /models/load first.")
733
747
 
@@ -776,8 +790,8 @@ def create_chat_router(context: AppContext) -> APIRouter:
776
790
  asyncio.create_task(_AGENT_RUNTIME.memory_update(ctx, req, current_user))
777
791
 
778
792
  message = ctx.final_message or "작업을 완료했습니다."
779
- save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id)
780
- save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id)
793
+ save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
794
+ save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
781
795
  try:
782
796
  WORKSPACE_OS.record_agent_run(
783
797
  agent_id="agent:executor",
@@ -15,7 +15,7 @@ lazily via module ``__getattr__`` for backwards compatibility.
15
15
  from __future__ import annotations
16
16
 
17
17
  import threading
18
- from typing import TYPE_CHECKING, Any, Dict, Optional
18
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
19
19
 
20
20
  if TYPE_CHECKING: # imports for annotations only — keep module import light
21
21
  from fastapi import FastAPI
@@ -704,6 +704,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
704
704
  user_nickname: Optional[str] = None,
705
705
  source: Optional[str] = None,
706
706
  conversation_id: Optional[str] = None,
707
+ workspace_id: Optional[str] = None,
707
708
  ):
708
709
  try:
709
710
  message = redact_secret_text(message)
@@ -718,6 +719,8 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
718
719
  item["source"] = source
719
720
  if conversation_id:
720
721
  item["conversation_id"] = conversation_id
722
+ if workspace_id:
723
+ item["workspace_id"] = workspace_id
721
724
  sensitive = classify_sensitive_message(item, -1)
722
725
  append_audit_event(
723
726
  "chat_message",
@@ -726,6 +729,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
726
729
  user_nickname=user_nickname,
727
730
  source=source,
728
731
  conversation_id=conversation_id,
732
+ workspace_id=workspace_id,
729
733
  content_preview=sensitive.get("preview"),
730
734
  content_chars=len(message or ""),
731
735
  sensitivity=sensitive.get("sensitivity"),
@@ -1036,7 +1040,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
1036
1040
  return _build_sensitivity_report(history)
1037
1041
 
1038
1042
  # ── Admin audit report — delegated to latticeai.core.audit ───────────────────
1039
- def build_admin_audit_report(users: Dict) -> Dict:
1043
+ def build_admin_audit_report(users: Dict, audit_events: Optional[List[Dict]] = None) -> Dict:
1040
1044
  graph_stats = None
1041
1045
  try:
1042
1046
  if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
@@ -1047,6 +1051,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
1047
1051
  AUDIT_FILE, users,
1048
1052
  get_user_role=get_user_role,
1049
1053
  graph_stats=graph_stats,
1054
+ audit_events=audit_events,
1050
1055
  )
1051
1056
 
1052
1057
  router = LLMRouter()
@@ -1268,6 +1273,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
1268
1273
  require_admin=require_admin, require_user=require_user,
1269
1274
  load_users=load_users, save_users=save_users,
1270
1275
  get_user_role=get_user_role, get_history=get_history,
1276
+ get_audit_log=get_audit_log,
1271
1277
  public_user=public_user, load_vpc_config=load_vpc_config,
1272
1278
  save_vpc_config=save_vpc_config,
1273
1279
  build_admin_audit_report=build_admin_audit_report,
@@ -141,8 +141,9 @@ def build_admin_audit_report(
141
141
  *,
142
142
  get_user_role: Callable[[str, Optional[Dict]], str],
143
143
  graph_stats: Optional[Dict] = None,
144
+ audit_events: Optional[List[Dict]] = None,
144
145
  ) -> Dict:
145
- events = get_audit_log(audit_file)
146
+ events = audit_events if audit_events is not None else get_audit_log(audit_file)
146
147
 
147
148
  def _user_bucket(email: Optional[str], nickname: Optional[str] = None) -> Dict:
148
149
  user = users.get(email or "", {})
@@ -234,7 +235,7 @@ def build_admin_audit_report(
234
235
  def _public_audit_event(event: Dict) -> Dict:
235
236
  allowed = {
236
237
  "event_type", "timestamp", "role", "user_email", "user_nickname", "source",
237
- "conversation_id", "command", "scope", "target_email", "filename", "mime_type",
238
+ "conversation_id", "workspace_id", "command", "scope", "target_email", "filename", "mime_type",
238
239
  "ext", "bytes", "extracted_chars", "graph_node", "keep_last", "removed", "kept",
239
240
  "started_at", "sensitivity", "sensitive_labels", "content_preview", "content_chars",
240
241
  }
@@ -11,7 +11,7 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "4.6.0"
14
+ MARKETPLACE_VERSION = "4.7.0"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent")
16
16
 
17
17
 
@@ -19,7 +19,7 @@ from pathlib import Path
19
19
  from typing import Any, Callable, Dict, Iterable, List, Optional
20
20
 
21
21
 
22
- WORKSPACE_OS_VERSION = "4.6.0"
22
+ WORKSPACE_OS_VERSION = "4.7.0"
23
23
 
24
24
  # Workspace types separate single-user Personal workspaces from shared
25
25
  # Organization workspaces. Both keep the same local-first JSON store; the type
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "4.6.0",
3
+ "version": "4.7.0",
4
4
  "description": "Lattice AI — local-first Living Brain workspace (conversation, durable memory, hybrid search, agents, advanced graph exploration, portable encrypted brain archives)",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -0,0 +1,56 @@
1
+ #!/bin/zsh
2
+ # Launch a dedicated Grok TUI session wired to the "pts_grok" Discord bot.
3
+ # This lets you collaborate with pts_openclaw and pts_claudecode in the shared channel
4
+ # by having them @mention pts_grok (or you mentioning it).
5
+ #
6
+ # Prerequisites:
7
+ # 1. Create a brand new Discord Application + Bot in https://discord.com/developers/applications
8
+ # - Name the bot exactly "pts_grok" (for easy @mentions).
9
+ # - Enable "Message Content Intent" under Privileged Gateway Intents.
10
+ # - Generate / Reset the Bot token (copy it once).
11
+ # 2. Invite the new bot to your server using OAuth2 URL Generator (bot scope + View Channels,
12
+ # Send Messages, Send Messages in Threads, Read Message History, Attach Files, Add Reactions).
13
+ # 3. Put the token into ~/.grok/channels/discord-pts-grok/.env as DISCORD_BOT_TOKEN=...
14
+ # 4. After creating the bot, note its User ID (right-click the bot in Discord → Copy User ID, with Dev Mode on).
15
+ # Then add that ID to the other agents' botAllowFrom lists (and they will add yours).
16
+ #
17
+ # Usage:
18
+ # cd ~/Downloads/Lattice\ AI
19
+ # ./scripts/launch-pts-grok.sh
20
+ #
21
+ # In that new terminal session, the "discord" MCP tools will be bound to pts_grok.
22
+ # Mentioning pts_grok from pts_openclaw (or the human) in channel 1506662093309608026
23
+ # will be delivered here.
24
+
25
+ set -euo pipefail
26
+
27
+ export DISCORD_STATE_DIR="$HOME/.grok/channels/discord-pts-grok"
28
+
29
+ # Safety: ensure the secret file has sane permissions
30
+ chmod 600 "$DISCORD_STATE_DIR/.env" 2>/dev/null || true
31
+
32
+ # Make sure the token is present and looks real (not just empty or placeholder)
33
+ token_line=$(grep '^DISCORD_BOT_TOKEN=' "$DISCORD_STATE_DIR/.env" 2>/dev/null | tail -1 || true)
34
+ token_value="${token_line#DISCORD_BOT_TOKEN=}"
35
+
36
+ if [[ -z "$token_value" || ${#token_value} -lt 40 ]]; then
37
+ echo "ERROR: DISCORD_BOT_TOKEN is missing or too short in $DISCORD_STATE_DIR/.env"
38
+ echo "Edit it first with the real token from Discord Developer Portal → Your pts_grok bot → Reset Token."
39
+ echo "Example line: DISCORD_BOT_TOKEN=MTxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
40
+ exit 1
41
+ fi
42
+
43
+ # Quick sanity so we don't launch with the placeholder comment
44
+ if echo "$token_value" | grep -qi 'PUT_YOUR\|MASKED\|edit me'; then
45
+ echo "ERROR: The token in $DISCORD_STATE_DIR/.env still contains placeholder text."
46
+ echo "Replace it with the actual bot token."
47
+ exit 1
48
+ fi
49
+
50
+ cd "$(dirname "$0")/.."
51
+
52
+ echo "Launching Grok as pts_grok (state: $DISCORD_STATE_DIR)"
53
+ echo "Make sure the 'discord' MCP / plugin is enabled (use /mcps or /plugins in the TUI if needed)."
54
+ echo
55
+
56
+ exec "$HOME/.grok/bin/grok" "$@"
@@ -1654,7 +1654,7 @@ dependencies = [
1654
1654
 
1655
1655
  [[package]]
1656
1656
  name = "lattice-ai-desktop"
1657
- version = "4.6.0"
1657
+ version = "4.7.0"
1658
1658
  dependencies = [
1659
1659
  "plist",
1660
1660
  "serde",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "lattice-ai-desktop"
3
- version = "4.6.0"
3
+ version = "4.7.0"
4
4
  description = "Lattice AI Digital Brain desktop shell"
5
5
  authors = ["TaeSoo Park"]
6
6
  edition = "2021"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://schema.tauri.app/config/2",
3
3
  "productName": "Lattice AI",
4
- "version": "4.6.0",
4
+ "version": "4.7.0",
5
5
  "identifier": "ai.lattice.desktop",
6
6
  "build": {
7
7
  "beforeDevCommand": "npm run frontend:dev",