ltcai 4.7.0 → 5.0.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.
@@ -2,9 +2,10 @@
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
 
10
11
  from latticeai.core.workspace_os import DEFAULT_WORKSPACE_ID
@@ -86,6 +87,62 @@ def create_admin_router(
86
87
  scope = _workspace_scope(request)
87
88
  return [item for item in get_audit_log() if _matches_scope(item, scope)]
88
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
+
89
146
  @router.get("/admin/summary")
90
147
  async def admin_summary(request: Request):
91
148
  _, users = require_admin(request)
@@ -127,9 +184,34 @@ def create_admin_router(
127
184
  return build_sensitivity_report(_scoped_history(request))
128
185
 
129
186
  @router.get("/admin/audit")
130
- 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
+ ):
131
195
  _, users = require_admin(request)
132
- report = build_admin_audit_report(users, _scoped_audit_log(request))
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
+ }
133
215
  try:
134
216
  report["graph"] = get_graph_stats() if enable_graph else {"disabled": True}
135
217
  except Exception as e:
@@ -179,9 +261,36 @@ def create_admin_router(
179
261
  {"id": "invite_gate", "label": "Invite gate",
180
262
  "value": "Required for new accounts" if invite_gate_enabled else "Open registration",
181
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},
182
266
  ]
183
267
  }
184
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
+
185
294
  @router.get("/admin/product-hardening")
186
295
  async def admin_product_hardening(request: Request):
187
296
  require_admin(request)
@@ -543,7 +543,14 @@ def create_chat_router(context: AppContext) -> APIRouter:
543
543
  if recent_context:
544
544
  stream_context = f"[RECENT CONVERSATION]\n{recent_context}\n\n{context}".strip()
545
545
  return StreamingResponse(
546
- _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
+ ),
547
554
  media_type="text/event-stream",
548
555
  headers={"X-Model": router.current_model_id},
549
556
  )
@@ -650,6 +657,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
650
657
  *,
651
658
  trace_seed: Optional[Dict] = None,
652
659
  effective_email: Optional[str] = None,
660
+ history_meta: Optional[Dict] = None,
653
661
  ) -> AsyncIterator[str]:
654
662
  full_response = ""
655
663
  async for chunk in router.stream_generate(req.message, context, req.max_tokens, req.temperature, image_data):
@@ -664,8 +672,8 @@ def create_chat_router(context: AppContext) -> APIRouter:
664
672
 
665
673
  full_response += str(clean_chunk)
666
674
  yield f"data: {json.dumps({'chunk': clean_chunk, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
667
- history_user = get_history_user(req.user_email, req.user_nickname)
668
- save_to_history("assistant", full_response, **history_meta, **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)
669
677
  trace_record = CHAT_SERVICE.record_trace(
670
678
  question=req.message,
671
679
  response=full_response,
@@ -51,8 +51,6 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
51
51
  except Exception as e:
52
52
  print(f"⚠️ MLX Metal context unavailable: {e}")
53
53
  mx = None
54
- from typing import List
55
-
56
54
  import uvicorn
57
55
  from fastapi import FastAPI, HTTPException, Request
58
56
  from fastapi.middleware.cors import CORSMiddleware
@@ -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.7.0"
14
+ MARKETPLACE_VERSION = "5.0.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.7.0"
22
+ WORKSPACE_OS_VERSION = "5.0.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.7.0",
3
+ "version": "5.0.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": {
@@ -89,6 +89,7 @@
89
89
  "static/icons/",
90
90
  "plugins/",
91
91
  "scripts/",
92
+ "!scripts/launch-pts-grok.sh",
92
93
  "!docs/images/tmp_frames/",
93
94
  "!**/__pycache__/",
94
95
  "!**/*.pyc",
@@ -1654,7 +1654,7 @@ dependencies = [
1654
1654
 
1655
1655
  [[package]]
1656
1656
  name = "lattice-ai-desktop"
1657
- version = "4.7.0"
1657
+ version = "5.0.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.7.0"
3
+ version = "5.0.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.7.0",
4
+ "version": "5.0.0",
5
5
  "identifier": "ai.lattice.desktop",
6
6
  "build": {
7
7
  "beforeDevCommand": "npm run frontend:dev",
@@ -1,13 +1,13 @@
1
1
  {
2
- "version": "4.7.0",
2
+ "version": "5.0.0",
3
3
  "generated_at": "vite",
4
4
  "entrypoints": {
5
5
  "app": "/static/app/index.html"
6
6
  },
7
7
  "assets": {
8
8
  "../node_modules/@tauri-apps/api/core.js": "/static/app/assets/core-CwxXejkd.js",
9
- "index.html": "/static/app/assets/index-DwX3rNfA.js",
10
- "assets/index-DFmuiJ6t.css": "/static/app/assets/index-DFmuiJ6t.css"
9
+ "index.html": "/static/app/assets/index-FR1UZkCD.js",
10
+ "assets/index-DuYYT2oh.css": "/static/app/assets/index-DuYYT2oh.css"
11
11
  },
12
12
  "vite": {
13
13
  "../node_modules/@tauri-apps/api/core.js": {
@@ -17,7 +17,7 @@
17
17
  "isDynamicEntry": true
18
18
  },
19
19
  "index.html": {
20
- "file": "assets/index-DwX3rNfA.js",
20
+ "file": "assets/index-FR1UZkCD.js",
21
21
  "name": "index",
22
22
  "src": "index.html",
23
23
  "isEntry": true,
@@ -25,7 +25,7 @@
25
25
  "../node_modules/@tauri-apps/api/core.js"
26
26
  ],
27
27
  "css": [
28
- "assets/index-DFmuiJ6t.css"
28
+ "assets/index-DuYYT2oh.css"
29
29
  ]
30
30
  }
31
31
  }