ltcai 4.6.1 → 4.7.2

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 (34) hide show
  1. package/README.md +74 -40
  2. package/docs/CHANGELOG.md +141 -0
  3. package/docs/PRODUCT_DIRECTION_REVIEW.md +88 -0
  4. package/docs/V4_7_0_ADMIN_SEPARATION_REPORT.md +42 -0
  5. package/docs/V4_7_1_ADMIN_OPERATIONS_REPORT.md +49 -0
  6. package/docs/V4_7_2_INTUITIVE_BRAIN_UX_REPORT.md +62 -0
  7. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +22 -19
  8. package/frontend/src/App.tsx +627 -8
  9. package/frontend/src/api/client.ts +11 -1
  10. package/frontend/src/components/ProductFlow.tsx +106 -51
  11. package/frontend/src/pages/System.tsx +1 -1
  12. package/frontend/src/styles.css +905 -81
  13. package/lattice_brain/__init__.py +1 -1
  14. package/lattice_brain/archive.py +86 -13
  15. package/lattice_brain/portability.py +82 -14
  16. package/lattice_brain/runtime/multi_agent.py +1 -1
  17. package/latticeai/__init__.py +1 -1
  18. package/latticeai/api/admin.py +141 -6
  19. package/latticeai/api/chat.py +35 -13
  20. package/latticeai/app_factory.py +8 -4
  21. package/latticeai/core/audit.py +3 -2
  22. package/latticeai/core/marketplace.py +1 -1
  23. package/latticeai/core/workspace_os.py +1 -1
  24. package/package.json +2 -1
  25. package/src-tauri/Cargo.lock +1 -1
  26. package/src-tauri/Cargo.toml +1 -1
  27. package/src-tauri/tauri.conf.json +1 -1
  28. package/static/app/asset-manifest.json +5 -5
  29. package/static/app/assets/index-DdAB4yfa.js +16 -0
  30. package/static/app/assets/index-DdAB4yfa.js.map +1 -0
  31. package/static/app/assets/{index-7U86v70r.css → index-KlQ04wVv.css} +1 -1
  32. package/static/app/index.html +2 -2
  33. package/static/app/assets/index-D1jAPQws.js +0 -16
  34. package/static/app/assets/index-D1jAPQws.js.map +0 -1
@@ -26,7 +26,7 @@ from .storage import (
26
26
  storage_from_env,
27
27
  )
28
28
 
29
- __version__ = "4.6.1"
29
+ __version__ = "4.7.2"
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.1"
17
+ MULTI_AGENT_VERSION = "4.7.2"
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.1"
3
+ __version__ = "4.7.2"
@@ -2,11 +2,14 @@
2
2
 
3
3
  import logging
4
4
  from collections import defaultdict
5
+ from datetime import datetime, timedelta
5
6
  from typing import Callable, Dict, List, Optional
6
7
 
7
- from fastapi import APIRouter, HTTPException, Request
8
+ from fastapi import APIRouter, HTTPException, Query, Request
8
9
  from pydantic import BaseModel
9
10
 
11
+ from latticeai.core.workspace_os import DEFAULT_WORKSPACE_ID
12
+
10
13
 
11
14
  class AdminUserUpdate(BaseModel):
12
15
  role: Optional[str] = None
@@ -42,6 +45,7 @@ def create_admin_router(
42
45
  save_users: Callable,
43
46
  get_user_role: Callable,
44
47
  get_history: Callable,
48
+ get_audit_log: Callable,
45
49
  public_user: Callable,
46
50
  load_vpc_config: Callable,
47
51
  save_vpc_config: Callable,
@@ -60,10 +64,89 @@ def create_admin_router(
60
64
  ) -> APIRouter:
61
65
  router = APIRouter()
62
66
 
67
+ def _workspace_scope(request: Request) -> Optional[str]:
68
+ header = request.headers.get("X-Workspace-Id")
69
+ if header and header.strip():
70
+ return header.strip()
71
+ query = request.query_params.get("workspace_id")
72
+ return query.strip() if query and query.strip() else None
73
+
74
+ def _matches_scope(item: Dict[str, object], workspace_id: Optional[str]) -> bool:
75
+ if not workspace_id:
76
+ return True
77
+ item_scope = item.get("workspace_id")
78
+ if not item_scope and workspace_id == DEFAULT_WORKSPACE_ID:
79
+ return True
80
+ return str(item_scope or "") == str(workspace_id)
81
+
82
+ def _scoped_history(request: Request) -> List[Dict]:
83
+ scope = _workspace_scope(request)
84
+ return [item for item in get_history() if _matches_scope(item, scope)]
85
+
86
+ def _scoped_audit_log(request: Request) -> List[Dict]:
87
+ scope = _workspace_scope(request)
88
+ return [item for item in get_audit_log() if _matches_scope(item, scope)]
89
+
90
+ def _filter_audit_log(
91
+ events: List[Dict],
92
+ *,
93
+ q: Optional[str] = None,
94
+ actor: Optional[str] = None,
95
+ action: Optional[str] = None,
96
+ severity: Optional[str] = None,
97
+ limit: int = 50,
98
+ ) -> List[Dict]:
99
+ needle = (q or "").strip().lower()
100
+ actor_filter = (actor or "").strip().lower()
101
+ action_filter = (action or "").strip().lower()
102
+ severity_filter = (severity or "").strip().lower()
103
+
104
+ def matches(event: Dict) -> bool:
105
+ public = _event_public_text(event)
106
+ if needle and needle not in public:
107
+ return False
108
+ if actor_filter and actor_filter not in str(event.get("user_email") or event.get("actor") or "").lower():
109
+ return False
110
+ event_action = str(event.get("event_type") or event.get("action") or "").lower()
111
+ if action_filter and action_filter not in event_action:
112
+ return False
113
+ event_severity = str(event.get("severity") or event.get("sev") or "").lower()
114
+ if severity_filter and event_severity != severity_filter:
115
+ return False
116
+ return True
117
+
118
+ capped_limit = max(1, min(int(limit or 50), 250))
119
+ return [event for event in events if matches(event)][-capped_limit:]
120
+
121
+ def _event_public_text(event: Dict) -> str:
122
+ parts = [
123
+ event.get("event_type"),
124
+ event.get("action"),
125
+ event.get("user_email"),
126
+ event.get("actor"),
127
+ event.get("target"),
128
+ event.get("target_email"),
129
+ event.get("workspace_id"),
130
+ event.get("severity"),
131
+ event.get("sev"),
132
+ ]
133
+ return " ".join(str(part).lower() for part in parts if part is not None)
134
+
135
+ def _parse_timestamp(value: object) -> Optional[datetime]:
136
+ if not value:
137
+ return None
138
+ try:
139
+ parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
140
+ if parsed.tzinfo is not None:
141
+ return parsed.astimezone().replace(tzinfo=None)
142
+ return parsed
143
+ except ValueError:
144
+ return None
145
+
63
146
  @router.get("/admin/summary")
64
147
  async def admin_summary(request: Request):
65
148
  _, users = require_admin(request)
66
- history = get_history()
149
+ history = _scoped_history(request)
67
150
  user_msgs = [i for i in history if i.get("role") == "user"]
68
151
  asst_msgs = [i for i in history if i.get("role") == "assistant"]
69
152
  return {
@@ -79,7 +162,7 @@ def create_admin_router(
79
162
  @router.get("/admin/stats")
80
163
  async def admin_stats(request: Request):
81
164
  require_admin(request)
82
- history = get_history()
165
+ history = _scoped_history(request)
83
166
  daily: dict = defaultdict(lambda: {"user": 0, "assistant": 0})
84
167
  for item in history:
85
168
  ts = item.get("timestamp", "")
@@ -98,12 +181,37 @@ def create_admin_router(
98
181
  @router.get("/admin/sensitivity")
99
182
  async def admin_sensitivity(request: Request):
100
183
  require_admin(request)
101
- return build_sensitivity_report(get_history())
184
+ return build_sensitivity_report(_scoped_history(request))
102
185
 
103
186
  @router.get("/admin/audit")
104
- async def admin_audit(request: Request):
187
+ async def admin_audit(
188
+ request: Request,
189
+ q: Optional[str] = Query(None),
190
+ actor: Optional[str] = Query(None),
191
+ action: Optional[str] = Query(None),
192
+ severity: Optional[str] = Query(None),
193
+ limit: int = Query(50, ge=1, le=250),
194
+ ):
105
195
  _, users = require_admin(request)
106
- report = build_admin_audit_report(users)
196
+ scoped_events = _scoped_audit_log(request)
197
+ filtered_events = _filter_audit_log(
198
+ scoped_events,
199
+ q=q,
200
+ actor=actor,
201
+ action=action,
202
+ severity=severity,
203
+ limit=limit,
204
+ )
205
+ report = build_admin_audit_report(users, filtered_events)
206
+ report["filters"] = {
207
+ "q": q or "",
208
+ "actor": actor or "",
209
+ "action": action or "",
210
+ "severity": severity or "",
211
+ "limit": limit,
212
+ "matched_events": len(filtered_events),
213
+ "scoped_events": len(scoped_events),
214
+ }
107
215
  try:
108
216
  report["graph"] = get_graph_stats() if enable_graph else {"disabled": True}
109
217
  except Exception as e:
@@ -153,9 +261,36 @@ def create_admin_router(
153
261
  {"id": "invite_gate", "label": "Invite gate",
154
262
  "value": "Required for new accounts" if invite_gate_enabled else "Open registration",
155
263
  "enforced": bool(invite_gate_enabled)},
264
+ {"id": "log_retention", "label": "Log retention",
265
+ "value": "90 day local audit window with manual export before pruning", "enforced": True},
156
266
  ]
157
267
  }
158
268
 
269
+ @router.get("/admin/log-retention")
270
+ async def admin_log_retention(request: Request):
271
+ require_admin(request)
272
+ events = _scoped_audit_log(request)
273
+ retention_days = 90
274
+ cutoff = datetime.now() - timedelta(days=retention_days)
275
+ retained = 0
276
+ prune_candidates = 0
277
+ for event in events:
278
+ ts = _parse_timestamp(event.get("timestamp") or event.get("ts"))
279
+ if ts and ts < cutoff:
280
+ prune_candidates += 1
281
+ else:
282
+ retained += 1
283
+ return {
284
+ "mode": "local-first",
285
+ "retention_days": retention_days,
286
+ "total_events": len(events),
287
+ "retained_events": retained,
288
+ "prune_candidates": prune_candidates,
289
+ "export_before_prune": True,
290
+ "editable": False,
291
+ "reason": "Retention is reported in Community mode; destructive pruning requires an explicit export workflow.",
292
+ }
293
+
159
294
  @router.get("/admin/product-hardening")
160
295
  async def admin_product_hardening(request: Request):
161
296
  require_admin(request)
@@ -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),
@@ -530,7 +543,14 @@ def create_chat_router(context: AppContext) -> APIRouter:
530
543
  if recent_context:
531
544
  stream_context = f"[RECENT CONVERSATION]\n{recent_context}\n\n{context}".strip()
532
545
  return StreamingResponse(
533
- _stream_chat(req, stream_context, req.image_data, trace_seed=trace_seed, effective_email=effective_email),
546
+ _stream_chat(
547
+ req,
548
+ stream_context,
549
+ req.image_data,
550
+ trace_seed=trace_seed,
551
+ effective_email=effective_email,
552
+ history_meta=history_meta,
553
+ ),
534
554
  media_type="text/event-stream",
535
555
  headers={"X-Model": router.current_model_id},
536
556
  )
@@ -549,7 +569,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
549
569
 
550
570
  result = await router.generate(req.message, full_context, req.max_tokens, req.temperature, req.image_data)
551
571
 
552
- save_to_history("assistant", str(result), source=req.source or "web", conversation_id=req.conversation_id, **history_user)
572
+ save_to_history("assistant", str(result), **history_meta, **history_user)
553
573
  trace_record = CHAT_SERVICE.record_trace(
554
574
  question=req.message,
555
575
  response=str(result),
@@ -637,6 +657,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
637
657
  *,
638
658
  trace_seed: Optional[Dict] = None,
639
659
  effective_email: Optional[str] = None,
660
+ history_meta: Optional[Dict] = None,
640
661
  ) -> AsyncIterator[str]:
641
662
  full_response = ""
642
663
  async for chunk in router.stream_generate(req.message, context, req.max_tokens, req.temperature, image_data):
@@ -651,8 +672,8 @@ def create_chat_router(context: AppContext) -> APIRouter:
651
672
 
652
673
  full_response += str(clean_chunk)
653
674
  yield f"data: {json.dumps({'chunk': clean_chunk, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
654
- 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)
675
+ history_user = get_history_user(effective_email or req.user_email, req.user_nickname)
676
+ save_to_history("assistant", full_response, **(history_meta or {}), **history_user)
656
677
  trace_record = CHAT_SERVICE.record_trace(
657
678
  question=req.message,
658
679
  response=full_response,
@@ -728,6 +749,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
728
749
  """
729
750
  current_user = require_user(request)
730
751
  enforce_rate_limit(current_user, "agent")
752
+ req.workspace_id = req.workspace_id or workspace_scope_from_request(request)
731
753
  if not router.current_model_id:
732
754
  raise HTTPException(status_code=400, detail="No model loaded. Call /models/load first.")
733
755
 
@@ -776,8 +798,8 @@ def create_chat_router(context: AppContext) -> APIRouter:
776
798
  asyncio.create_task(_AGENT_RUNTIME.memory_update(ctx, req, current_user))
777
799
 
778
800
  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)
801
+ save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
802
+ save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
781
803
  try:
782
804
  WORKSPACE_OS.record_agent_run(
783
805
  agent_id="agent:executor",