ltcai 0.6.0 → 1.0.1

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.
@@ -0,0 +1,1178 @@
1
+ """Workspace OS persistence and orchestration primitives.
2
+
3
+ This module keeps the 1.0 Workspace OS surface intentionally local-first:
4
+ state is stored as JSON under the configured LatticeAI data directory, graph
5
+ operations are additive, and snapshots are immutable files that can be
6
+ exported or compared without mutating the live knowledge graph.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import shutil
14
+ import zipfile
15
+ from collections import deque
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any, Callable, Dict, Iterable, List, Optional
19
+
20
+
21
+ WORKSPACE_OS_VERSION = "1.0.1"
22
+
23
+ WORKSPACE_AREAS = [
24
+ "graph",
25
+ "snapshot",
26
+ "memory",
27
+ "agent",
28
+ "workflow",
29
+ "skills",
30
+ "timeline",
31
+ ]
32
+
33
+ ONBOARDING_STEPS = [
34
+ "account",
35
+ "admin",
36
+ "hardware",
37
+ "model_recommendation",
38
+ "model_install",
39
+ "model_connection",
40
+ "folder_connection",
41
+ "first_question",
42
+ "complete",
43
+ ]
44
+
45
+ MEMORY_KINDS = {
46
+ "preferences",
47
+ "decisions",
48
+ "working_style",
49
+ "frequently_used_tools",
50
+ "long_term",
51
+ }
52
+
53
+ DEFAULT_AGENTS = [
54
+ {
55
+ "id": "agent:planner",
56
+ "name": "Planner",
57
+ "role": "Breaks workspace goals into executable plans.",
58
+ "status": "available",
59
+ "relationships": ["agent:executor", "agent:reviewer"],
60
+ },
61
+ {
62
+ "id": "agent:executor",
63
+ "name": "Executor",
64
+ "role": "Runs approved tool and code workflows.",
65
+ "status": "available",
66
+ "relationships": ["agent:planner", "agent:reviewer"],
67
+ },
68
+ {
69
+ "id": "agent:reviewer",
70
+ "name": "Reviewer",
71
+ "role": "Checks outputs, tests, and regressions.",
72
+ "status": "available",
73
+ "relationships": ["agent:executor", "agent:release"],
74
+ },
75
+ {
76
+ "id": "agent:researcher",
77
+ "name": "Researcher",
78
+ "role": "Finds and curates relevant workspace knowledge.",
79
+ "status": "available",
80
+ "relationships": ["agent:planner"],
81
+ },
82
+ {
83
+ "id": "agent:release",
84
+ "name": "Release Agent",
85
+ "role": "Coordinates versioning, packaging, and release checks.",
86
+ "status": "available",
87
+ "relationships": ["agent:reviewer"],
88
+ },
89
+ ]
90
+
91
+
92
+ def _now() -> str:
93
+ return datetime.now().isoformat(timespec="seconds")
94
+
95
+
96
+ def _safe_slug(raw: str) -> str:
97
+ value = "".join(ch if ch.isalnum() or ch in "-_." else "-" for ch in str(raw or "").strip())
98
+ value = "-".join(part for part in value.split("-") if part)
99
+ return (value or "item")[:96]
100
+
101
+
102
+ def _json_hash(value: Any) -> str:
103
+ import hashlib
104
+
105
+ payload = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
106
+ return hashlib.sha256(payload.encode("utf-8", errors="replace")).hexdigest()
107
+
108
+
109
+ def _deep_merge(default: Any, loaded: Any) -> Any:
110
+ if isinstance(default, dict) and isinstance(loaded, dict):
111
+ merged = {key: _deep_merge(value, loaded.get(key)) for key, value in default.items()}
112
+ for key, value in loaded.items():
113
+ if key not in merged:
114
+ merged[key] = value
115
+ return merged
116
+ if loaded is None:
117
+ return default
118
+ return loaded
119
+
120
+
121
+ def _atomic_write_json(path: Path, payload: Dict[str, Any]) -> None:
122
+ path.parent.mkdir(parents=True, exist_ok=True)
123
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
124
+ tmp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
125
+ os.replace(tmp_path, path)
126
+
127
+
128
+ def _listify(value: Any) -> List[Any]:
129
+ return value if isinstance(value, list) else []
130
+
131
+
132
+ def _parse_iso(value: Optional[str]) -> Optional[datetime]:
133
+ if not value:
134
+ return None
135
+ try:
136
+ return datetime.fromisoformat(str(value))
137
+ except (TypeError, ValueError):
138
+ return None
139
+
140
+
141
+ class WorkspaceOSStore:
142
+ """Local-first state store for Workspace OS APIs."""
143
+
144
+ def __init__(self, data_dir: Path | str):
145
+ self.data_dir = Path(data_dir).expanduser()
146
+ self.state_path = self.data_dir / "workspace_os.json"
147
+ self.snapshots_dir = self.data_dir / "workspace_snapshots"
148
+ self.exports_dir = self.data_dir / "workspace_exports"
149
+ self.data_dir.mkdir(parents=True, exist_ok=True)
150
+ self.snapshots_dir.mkdir(parents=True, exist_ok=True)
151
+ self.exports_dir.mkdir(parents=True, exist_ok=True)
152
+
153
+ def _default_state(self) -> Dict[str, Any]:
154
+ return {
155
+ "version": WORKSPACE_OS_VERSION,
156
+ "identity": "AI Workspace OS",
157
+ "created_at": _now(),
158
+ "updated_at": _now(),
159
+ "active_workspace": "personal",
160
+ "workspaces": {
161
+ "personal": {
162
+ "id": "personal",
163
+ "name": "Personal Workspace",
164
+ "type": "personal",
165
+ "areas": list(WORKSPACE_AREAS),
166
+ },
167
+ "organization": {
168
+ "id": "organization",
169
+ "name": "Organization Workspace",
170
+ "type": "organization",
171
+ "areas": list(WORKSPACE_AREAS),
172
+ },
173
+ },
174
+ "feature_flags": {
175
+ "workspace_os": True,
176
+ "graph_trace": True,
177
+ "snapshots": True,
178
+ "personal_memory": True,
179
+ "multi_agent_graph": True,
180
+ "workflow_graph": True,
181
+ "skill_marketplace": True,
182
+ "local_computer_memory": False,
183
+ },
184
+ "onboarding": {
185
+ "completed": False,
186
+ "current_step": "account",
187
+ "steps": {
188
+ step: {
189
+ "id": step,
190
+ "status": "pending",
191
+ "data": {},
192
+ "error": "",
193
+ "updated_at": None,
194
+ }
195
+ for step in ONBOARDING_STEPS
196
+ },
197
+ },
198
+ "snapshots": [],
199
+ "traces": [],
200
+ "memories": [],
201
+ "agents": list(DEFAULT_AGENTS),
202
+ "agent_runs": [],
203
+ "workflows": [],
204
+ "skill_registry": {},
205
+ "computer_memory": {
206
+ "enabled": False,
207
+ "approved": False,
208
+ "approved_at": None,
209
+ "approved_by": None,
210
+ "scopes": ["Downloads", "Documents", "Repositories"],
211
+ "activities": [],
212
+ "notice": "Local Computer Memory is OFF by default and requires explicit approval.",
213
+ },
214
+ "timeline": [],
215
+ }
216
+
217
+ def load_state(self) -> Dict[str, Any]:
218
+ default = self._default_state()
219
+ if not self.state_path.exists():
220
+ self.save_state(default)
221
+ return default
222
+ try:
223
+ loaded = json.loads(self.state_path.read_text(encoding="utf-8"))
224
+ if not isinstance(loaded, dict):
225
+ loaded = {}
226
+ except Exception:
227
+ loaded = {}
228
+ state = _deep_merge(default, loaded)
229
+ state["version"] = WORKSPACE_OS_VERSION
230
+ return state
231
+
232
+ def save_state(self, state: Dict[str, Any]) -> Dict[str, Any]:
233
+ state["version"] = WORKSPACE_OS_VERSION
234
+ state["updated_at"] = _now()
235
+ _atomic_write_json(self.state_path, state)
236
+ return state
237
+
238
+ def record_timeline_event(self, area: str, event_type: str, payload: Dict[str, Any]) -> Dict[str, Any]:
239
+ state = self.load_state()
240
+ event = {
241
+ "id": f"timeline-{_json_hash([area, event_type, payload, _now()])[:16]}",
242
+ "area": area,
243
+ "event_type": event_type,
244
+ "timestamp": _now(),
245
+ "payload": payload,
246
+ }
247
+ state.setdefault("timeline", []).append(event)
248
+ state["timeline"] = state["timeline"][-500:]
249
+ self.save_state(state)
250
+ return event
251
+
252
+ def summary(self) -> Dict[str, Any]:
253
+ state = self.load_state()
254
+ return {
255
+ "version": WORKSPACE_OS_VERSION,
256
+ "identity": state.get("identity"),
257
+ "active_workspace": state.get("active_workspace"),
258
+ "workspaces": state.get("workspaces"),
259
+ "navigation": list(WORKSPACE_AREAS),
260
+ "feature_flags": state.get("feature_flags"),
261
+ "counts": {
262
+ "snapshots": len(_listify(state.get("snapshots"))),
263
+ "traces": len(_listify(state.get("traces"))),
264
+ "memories": len(_listify(state.get("memories"))),
265
+ "agent_runs": len(_listify(state.get("agent_runs"))),
266
+ "workflows": len(_listify(state.get("workflows"))),
267
+ "skills": len(state.get("skill_registry") or {}),
268
+ "timeline": len(_listify(state.get("timeline"))),
269
+ },
270
+ "onboarding": state.get("onboarding"),
271
+ "storage": {
272
+ "state_path": str(self.state_path),
273
+ "snapshots_dir": str(self.snapshots_dir),
274
+ "exports_dir": str(self.exports_dir),
275
+ },
276
+ }
277
+
278
+ # ------------------------------------------------------------------
279
+ # Onboarding
280
+ # ------------------------------------------------------------------
281
+
282
+ def onboarding_status(self, users: Optional[Dict[str, Any]] = None, graph_stats: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
283
+ state = self.load_state()
284
+ users = users or {}
285
+ admins = [
286
+ email for email, user in users.items()
287
+ if isinstance(user, dict) and user.get("role") == "admin"
288
+ ]
289
+ onboarding = state.get("onboarding") or {}
290
+ steps = onboarding.get("steps") or {}
291
+ return {
292
+ **onboarding,
293
+ "steps": [steps.get(step, {"id": step, "status": "pending"}) for step in ONBOARDING_STEPS],
294
+ "has_account": bool(users),
295
+ "has_admin": bool(admins) or bool(users),
296
+ "graph_ready": bool(graph_stats and not graph_stats.get("disabled")),
297
+ "required_steps": list(ONBOARDING_STEPS),
298
+ }
299
+
300
+ def update_onboarding_step(
301
+ self,
302
+ step: str,
303
+ *,
304
+ status: str = "complete",
305
+ data: Optional[Dict[str, Any]] = None,
306
+ error: str = "",
307
+ user_email: Optional[str] = None,
308
+ ) -> Dict[str, Any]:
309
+ if step not in ONBOARDING_STEPS:
310
+ raise ValueError(f"unknown onboarding step: {step}")
311
+ if status not in {"pending", "running", "complete", "failed", "skipped"}:
312
+ raise ValueError(f"unknown onboarding status: {status}")
313
+ state = self.load_state()
314
+ onboarding = state.setdefault("onboarding", {})
315
+ steps = onboarding.setdefault("steps", {})
316
+ record = steps.setdefault(step, {"id": step})
317
+ record.update({
318
+ "id": step,
319
+ "status": status,
320
+ "data": data or record.get("data") or {},
321
+ "error": error,
322
+ "updated_at": _now(),
323
+ "user_email": user_email,
324
+ })
325
+ if status in {"complete", "skipped"}:
326
+ index = ONBOARDING_STEPS.index(step)
327
+ if step == "complete":
328
+ onboarding["completed"] = True
329
+ onboarding["completed_at"] = _now()
330
+ onboarding["current_step"] = "complete"
331
+ elif index + 1 < len(ONBOARDING_STEPS):
332
+ onboarding["current_step"] = ONBOARDING_STEPS[index + 1]
333
+ elif status == "failed":
334
+ onboarding["current_step"] = step
335
+ self.save_state(state)
336
+ self.record_timeline_event("workspace", "onboarding_step", {"step": step, "status": status})
337
+ return self.onboarding_status()
338
+
339
+ def complete_onboarding(self, data: Optional[Dict[str, Any]] = None, user_email: Optional[str] = None) -> Dict[str, Any]:
340
+ for step in ONBOARDING_STEPS:
341
+ self.update_onboarding_step(step, status="complete", data=data if step == "complete" else None, user_email=user_email)
342
+ return self.onboarding_status()
343
+
344
+ # ------------------------------------------------------------------
345
+ # Graph answer traces
346
+ # ------------------------------------------------------------------
347
+
348
+ def build_graph_trace(self, question: str, graph: Any, context: str = "", *, limit: int = 8) -> Dict[str, Any]:
349
+ if graph is None:
350
+ return {
351
+ "source_files": [],
352
+ "graph_nodes": [],
353
+ "graph_edges": [],
354
+ "confidence": 0.0,
355
+ "retrieval_metadata": {
356
+ "query": question,
357
+ "matched_nodes": 0,
358
+ "graph_enabled": False,
359
+ "context_chars": len(context or ""),
360
+ },
361
+ }
362
+
363
+ matches: List[Dict[str, Any]] = []
364
+ search_error = ""
365
+ try:
366
+ matches = graph.search(question, limit=limit).get("matches", [])
367
+ except Exception as exc:
368
+ search_error = str(exc)
369
+ matches = []
370
+
371
+ source_files: List[Dict[str, Any]] = []
372
+ seen_sources = set()
373
+ for match in matches:
374
+ meta = match.get("metadata") or {}
375
+ source = (
376
+ meta.get("relative_path")
377
+ or meta.get("file_path")
378
+ or meta.get("filename")
379
+ or meta.get("blob_path")
380
+ or meta.get("source")
381
+ )
382
+ if source and source not in seen_sources:
383
+ seen_sources.add(source)
384
+ source_files.append({
385
+ "source": source,
386
+ "node_id": match.get("id"),
387
+ "node_title": match.get("title"),
388
+ "node_type": match.get("type"),
389
+ "jump": {
390
+ "graph": f"/graph?node={match.get('id')}",
391
+ "source": source,
392
+ },
393
+ })
394
+
395
+ edges: List[Dict[str, Any]] = []
396
+ edge_seen = set()
397
+ for match in matches[:5]:
398
+ node_id = match.get("id")
399
+ if not node_id:
400
+ continue
401
+ try:
402
+ for edge in graph.neighbors(node_id).get("edges", []):
403
+ key = (edge.get("from"), edge.get("to"), edge.get("type"))
404
+ if key in edge_seen:
405
+ continue
406
+ edge_seen.add(key)
407
+ edges.append(edge)
408
+ if len(edges) >= 24:
409
+ break
410
+ except Exception:
411
+ continue
412
+
413
+ if matches:
414
+ confidence = min(0.95, 0.35 + min(len(matches), limit) / max(limit, 1) * 0.45 + (0.10 if edges else 0.0))
415
+ else:
416
+ confidence = 0.05 if context else 0.0
417
+
418
+ return {
419
+ "source_files": source_files,
420
+ "graph_nodes": matches,
421
+ "graph_edges": edges,
422
+ "confidence": round(confidence, 4),
423
+ "retrieval_metadata": {
424
+ "query": question,
425
+ "matched_nodes": len(matches),
426
+ "matched_edges": len(edges),
427
+ "graph_enabled": True,
428
+ "context_chars": len(context or ""),
429
+ "search_error": search_error,
430
+ },
431
+ }
432
+
433
+ def record_trace(
434
+ self,
435
+ *,
436
+ question: str,
437
+ response: str,
438
+ conversation_id: Optional[str],
439
+ user_email: Optional[str],
440
+ trace: Dict[str, Any],
441
+ ) -> Dict[str, Any]:
442
+ state = self.load_state()
443
+ trace_id = f"trace-{_json_hash([question, response, conversation_id, _now()])[:16]}"
444
+ record = {
445
+ "id": trace_id,
446
+ "question": question,
447
+ "response_preview": str(response or "")[:700],
448
+ "conversation_id": conversation_id,
449
+ "user_email": user_email,
450
+ "created_at": _now(),
451
+ **trace,
452
+ }
453
+ state.setdefault("traces", []).append(record)
454
+ state["traces"] = state["traces"][-200:]
455
+ self.save_state(state)
456
+ self.record_timeline_event("graph", "answer_trace", {"trace_id": trace_id, "conversation_id": conversation_id})
457
+ return record
458
+
459
+ def list_traces(self, conversation_id: Optional[str] = None, limit: int = 50) -> Dict[str, Any]:
460
+ traces = _listify(self.load_state().get("traces"))
461
+ if conversation_id:
462
+ traces = [trace for trace in traces if trace.get("conversation_id") == conversation_id]
463
+ return {"traces": list(reversed(traces[-max(1, min(limit, 200)):]))}
464
+
465
+ # ------------------------------------------------------------------
466
+ # Indexing dashboard
467
+ # ------------------------------------------------------------------
468
+
469
+ def build_indexing_dashboard(self, graph: Any, watcher_status: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
470
+ if graph is None:
471
+ return {
472
+ "sources": [],
473
+ "watcher": watcher_status or {"available": False, "active": {}},
474
+ "totals": {"success": 0, "failed": 0, "nodes": 0, "edges": 0},
475
+ }
476
+ stats = graph.stats()
477
+ sources = graph.local_sources().get("sources", [])
478
+ watcher_status = watcher_status or {"available": False, "active": {}}
479
+ active = watcher_status.get("active", {})
480
+ dashboard_sources = []
481
+ total_success = 0
482
+ total_failed = 0
483
+ for source in sources:
484
+ file_status = source.get("file_status") or {}
485
+ success = int(file_status.get("indexed") or 0)
486
+ failed = sum(int(file_status.get(key) or 0) for key in ("failed", "inaccessible", "skipped_empty_text"))
487
+ total_success += success
488
+ total_failed += failed
489
+ watch = active.get(source.get("id")) or {}
490
+ dashboard_sources.append({
491
+ "id": source.get("id"),
492
+ "label": source.get("label"),
493
+ "root_path": source.get("root_path"),
494
+ "status": source.get("status"),
495
+ "watch_enabled": bool(source.get("watch_enabled")),
496
+ "watch_active": source.get("id") in active,
497
+ "watch_status": watch,
498
+ "success_count": success,
499
+ "failure_count": failed,
500
+ "last_run_at": source.get("last_scanned_at") or source.get("updated_at"),
501
+ "file_status": file_status,
502
+ "include_ocr": bool(source.get("include_ocr")),
503
+ })
504
+ return {
505
+ "sources": dashboard_sources,
506
+ "watcher": watcher_status,
507
+ "totals": {
508
+ "success": total_success,
509
+ "failed": total_failed,
510
+ "nodes": sum(int(v or 0) for v in (stats.get("nodes") or {}).values()),
511
+ "edges": sum(int(v or 0) for v in (stats.get("edges") or {}).values()),
512
+ "local_sources": stats.get("local_sources", len(sources)),
513
+ },
514
+ "graph_stats": stats,
515
+ }
516
+
517
+ def pause_indexing(self, graph: Any, source_id: str, watcher: Any = None) -> Dict[str, Any]:
518
+ result = graph.set_local_source_watch(source_id, False)
519
+ watch = watcher.stop_source(source_id) if watcher else {"stopped": False, "source_id": source_id}
520
+ self.record_timeline_event("graph", "indexing_paused", {"source_id": source_id})
521
+ return {"status": "ok", "source": result, "watch": watch}
522
+
523
+ def resume_indexing(self, graph: Any, source_id: str, watcher: Any = None) -> Dict[str, Any]:
524
+ result = graph.set_local_source_watch(source_id, True)
525
+ watch = {"watching": False, "source_id": source_id}
526
+ source = next((item for item in graph.local_sources().get("sources", []) if item.get("id") == source_id), None)
527
+ if watcher and source:
528
+ watch = watcher.start_source(source)
529
+ self.record_timeline_event("graph", "indexing_resumed", {"source_id": source_id})
530
+ return {"status": "ok", "source": result, "watch": watch}
531
+
532
+ def remove_index_source(self, graph: Any, source_id: str, watcher: Any = None) -> Dict[str, Any]:
533
+ if watcher:
534
+ watcher.stop_source(source_id)
535
+ if not hasattr(graph, "remove_local_source"):
536
+ raise ValueError("graph store does not support removing local sources")
537
+ result = graph.remove_local_source(source_id)
538
+ self.record_timeline_event("graph", "indexing_removed", {"source_id": source_id})
539
+ return {"status": "ok", **result}
540
+
541
+ # ------------------------------------------------------------------
542
+ # Snapshots, Time Machine, and diffs
543
+ # ------------------------------------------------------------------
544
+
545
+ def create_snapshot(
546
+ self,
547
+ *,
548
+ name: str,
549
+ graph: Any,
550
+ history: Iterable[Dict[str, Any]],
551
+ settings: Dict[str, Any],
552
+ models: Dict[str, Any],
553
+ ) -> Dict[str, Any]:
554
+ graph_payload = {"nodes": [], "edges": []}
555
+ graph_stats = {}
556
+ local_sources = {"sources": []}
557
+ if graph is not None:
558
+ graph_payload = graph.graph(limit=2000)
559
+ graph_stats = graph.stats()
560
+ local_sources = graph.local_sources()
561
+ chat = list(history or [])
562
+ snapshot_body = {
563
+ "version": WORKSPACE_OS_VERSION,
564
+ "name": name or "Workspace snapshot",
565
+ "created_at": _now(),
566
+ "workspace": self.load_state().get("active_workspace", "personal"),
567
+ "graph": graph_payload,
568
+ "graph_stats": graph_stats,
569
+ "chat": chat,
570
+ "settings": settings,
571
+ "indexed_folders": local_sources.get("sources", []),
572
+ "models": models,
573
+ }
574
+ snapshot_id = f"snapshot-{datetime.now().strftime('%Y%m%d%H%M%S')}-{_json_hash(snapshot_body)[:10]}"
575
+ snapshot_body["id"] = snapshot_id
576
+ path = self.snapshots_dir / f"{snapshot_id}.json"
577
+ _atomic_write_json(path, snapshot_body)
578
+
579
+ state = self.load_state()
580
+ meta = {
581
+ "id": snapshot_id,
582
+ "name": snapshot_body["name"],
583
+ "created_at": snapshot_body["created_at"],
584
+ "path": str(path),
585
+ "node_count": len(graph_payload.get("nodes") or []),
586
+ "edge_count": len(graph_payload.get("edges") or []),
587
+ "chat_count": len(chat),
588
+ "model_count": len(models.get("loaded_models") or []),
589
+ "indexed_folder_count": len(local_sources.get("sources") or []),
590
+ }
591
+ state.setdefault("snapshots", []).append(meta)
592
+ state["snapshots"] = state["snapshots"][-200:]
593
+ self.save_state(state)
594
+ self.record_timeline_event("snapshot", "snapshot_saved", {"snapshot_id": snapshot_id, "name": name})
595
+ return {"snapshot": meta}
596
+
597
+ def list_snapshots(self) -> Dict[str, Any]:
598
+ snapshots = _listify(self.load_state().get("snapshots"))
599
+ return {"snapshots": list(reversed(snapshots))}
600
+
601
+ def get_snapshot(self, snapshot_id: str) -> Dict[str, Any]:
602
+ path = self.snapshots_dir / f"{_safe_slug(snapshot_id)}.json"
603
+ if not path.exists():
604
+ state = self.load_state()
605
+ meta = next((item for item in _listify(state.get("snapshots")) if item.get("id") == snapshot_id), None)
606
+ if meta:
607
+ path = Path(meta.get("path") or path)
608
+ if not path.exists():
609
+ raise FileNotFoundError(snapshot_id)
610
+ return json.loads(path.read_text(encoding="utf-8"))
611
+
612
+ def snapshot_view(self, snapshot_id: str, area: str) -> Dict[str, Any]:
613
+ snapshot = self.get_snapshot(snapshot_id)
614
+ if area == "graph":
615
+ return {"snapshot_id": snapshot_id, "graph": snapshot.get("graph") or {}, "graph_stats": snapshot.get("graph_stats") or {}}
616
+ if area == "chat":
617
+ return {"snapshot_id": snapshot_id, "chat": snapshot.get("chat") or []}
618
+ if area == "decision":
619
+ nodes = (snapshot.get("graph") or {}).get("nodes") or []
620
+ return {"snapshot_id": snapshot_id, "decisions": [node for node in nodes if node.get("type") == "Decision"]}
621
+ return {"snapshot_id": snapshot_id, "snapshot": snapshot}
622
+
623
+ def export_snapshot(self, snapshot_id: str) -> Dict[str, Any]:
624
+ snapshot = self.get_snapshot(snapshot_id)
625
+ export_path = self.exports_dir / f"{_safe_slug(snapshot_id)}.zip"
626
+ with zipfile.ZipFile(export_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
627
+ zf.writestr("snapshot.json", json.dumps(snapshot, ensure_ascii=False, indent=2))
628
+ zf.writestr("graph.json", json.dumps(snapshot.get("graph") or {}, ensure_ascii=False, indent=2))
629
+ zf.writestr("chat.json", json.dumps(snapshot.get("chat") or [], ensure_ascii=False, indent=2))
630
+ zf.writestr("settings.json", json.dumps(snapshot.get("settings") or {}, ensure_ascii=False, indent=2))
631
+ zf.writestr("indexed_folders.json", json.dumps(snapshot.get("indexed_folders") or [], ensure_ascii=False, indent=2))
632
+ zf.writestr("models.json", json.dumps(snapshot.get("models") or {}, ensure_ascii=False, indent=2))
633
+ self.record_timeline_event("snapshot", "snapshot_exported", {"snapshot_id": snapshot_id, "path": str(export_path)})
634
+ return {"snapshot_id": snapshot_id, "export_path": str(export_path), "bytes": export_path.stat().st_size}
635
+
636
+ def compare_snapshots(self, before_id: str, after_id: str) -> Dict[str, Any]:
637
+ before = self.get_snapshot(before_id)
638
+ after = self.get_snapshot(after_id)
639
+ before_nodes = {node.get("id"): node for node in (before.get("graph") or {}).get("nodes") or [] if node.get("id")}
640
+ after_nodes = {node.get("id"): node for node in (after.get("graph") or {}).get("nodes") or [] if node.get("id")}
641
+
642
+ def edge_key(edge: Dict[str, Any]) -> str:
643
+ return "|".join(str(edge.get(key) or "") for key in ("from", "to", "type"))
644
+
645
+ before_edges = {edge_key(edge): edge for edge in (before.get("graph") or {}).get("edges") or []}
646
+ after_edges = {edge_key(edge): edge for edge in (after.get("graph") or {}).get("edges") or []}
647
+
648
+ added_nodes = [after_nodes[key] for key in sorted(set(after_nodes) - set(before_nodes))]
649
+ removed_nodes = [before_nodes[key] for key in sorted(set(before_nodes) - set(after_nodes))]
650
+ changed_nodes = [
651
+ {"before": before_nodes[key], "after": after_nodes[key]}
652
+ for key in sorted(set(before_nodes) & set(after_nodes))
653
+ if _json_hash(before_nodes[key]) != _json_hash(after_nodes[key])
654
+ ]
655
+ added_edges = [after_edges[key] for key in sorted(set(after_edges) - set(before_edges))]
656
+ removed_edges = [before_edges[key] for key in sorted(set(before_edges) - set(after_edges))]
657
+
658
+ before_decisions = {key: value for key, value in before_nodes.items() if value.get("type") == "Decision"}
659
+ after_decisions = {key: value for key, value in after_nodes.items() if value.get("type") == "Decision"}
660
+ decisions_changed = [
661
+ {"before": before_decisions.get(key), "after": after_decisions.get(key)}
662
+ for key in sorted(set(before_decisions) | set(after_decisions))
663
+ if _json_hash(before_decisions.get(key)) != _json_hash(after_decisions.get(key))
664
+ ]
665
+
666
+ return {
667
+ "before": before_id,
668
+ "after": after_id,
669
+ "nodes_added": added_nodes,
670
+ "nodes_removed": removed_nodes,
671
+ "nodes_changed": changed_nodes,
672
+ "edges_added": added_edges,
673
+ "edges_removed": removed_edges,
674
+ "decisions_changed": decisions_changed,
675
+ "summary": {
676
+ "nodes_added": len(added_nodes),
677
+ "nodes_removed": len(removed_nodes),
678
+ "nodes_changed": len(changed_nodes),
679
+ "edges_added": len(added_edges),
680
+ "edges_removed": len(removed_edges),
681
+ "decisions_changed": len(decisions_changed),
682
+ },
683
+ }
684
+
685
+ def timeline(self, audit_events: Optional[Iterable[Dict[str, Any]]] = None, limit: int = 100) -> Dict[str, Any]:
686
+ state = self.load_state()
687
+ events: List[Dict[str, Any]] = []
688
+ events.extend(_listify(state.get("timeline")))
689
+ for snapshot in _listify(state.get("snapshots")):
690
+ events.append({"area": "snapshot", "event_type": "snapshot", "timestamp": snapshot.get("created_at"), "payload": snapshot})
691
+ for trace in _listify(state.get("traces")):
692
+ events.append({"area": "graph", "event_type": "answer_trace", "timestamp": trace.get("created_at"), "payload": trace})
693
+ for run in _listify(state.get("agent_runs")):
694
+ events.append({"area": "agent", "event_type": "agent_run", "timestamp": run.get("created_at"), "payload": run})
695
+ for workflow in _listify(state.get("workflows")):
696
+ events.append({"area": "workflow", "event_type": "workflow", "timestamp": workflow.get("created_at"), "payload": workflow})
697
+ for audit in audit_events or []:
698
+ events.append({"area": "audit", "event_type": audit.get("event_type") or "audit", "timestamp": audit.get("timestamp"), "payload": audit})
699
+ events.sort(key=lambda item: item.get("timestamp") or "", reverse=True)
700
+ return {"events": events[: max(1, min(limit, 500))]}
701
+
702
+ # ------------------------------------------------------------------
703
+ # Personal memory
704
+ # ------------------------------------------------------------------
705
+
706
+ def upsert_memory(
707
+ self,
708
+ *,
709
+ kind: str,
710
+ content: str,
711
+ user_email: Optional[str],
712
+ tags: Optional[List[str]] = None,
713
+ memory_id: Optional[str] = None,
714
+ metadata: Optional[Dict[str, Any]] = None,
715
+ graph: Any = None,
716
+ ) -> Dict[str, Any]:
717
+ if kind not in MEMORY_KINDS:
718
+ raise ValueError(f"unknown memory kind: {kind}")
719
+ if not str(content or "").strip():
720
+ raise ValueError("content is required")
721
+ state = self.load_state()
722
+ memories = _listify(state.get("memories"))
723
+ now = _now()
724
+ memory_id = memory_id or f"memory-{_json_hash([kind, content, user_email, now])[:16]}"
725
+ existing = next((item for item in memories if item.get("id") == memory_id), None)
726
+ record = existing or {
727
+ "id": memory_id,
728
+ "created_at": now,
729
+ }
730
+ record.update({
731
+ "kind": kind,
732
+ "content": content,
733
+ "user_email": user_email,
734
+ "tags": tags or [],
735
+ "metadata": metadata or {},
736
+ "updated_at": now,
737
+ })
738
+ if graph is not None:
739
+ try:
740
+ ingested = graph.ingest_event(
741
+ "Memory",
742
+ f"{kind}: {content[:80]}",
743
+ user_email=user_email,
744
+ source="workspace_os",
745
+ metadata={"memory_id": memory_id, "kind": kind, "tags": tags or []},
746
+ )
747
+ record["graph_node_id"] = ingested.get("node_id")
748
+ except Exception as exc:
749
+ record["graph_error"] = str(exc)
750
+ if existing is None:
751
+ memories.append(record)
752
+ state["memories"] = memories[-500:]
753
+ self.save_state(state)
754
+ self.record_timeline_event("memory", "memory_upserted", {"memory_id": memory_id, "kind": kind})
755
+ return record
756
+
757
+ def list_memories(self, user_email: Optional[str] = None, kind: Optional[str] = None) -> Dict[str, Any]:
758
+ memories = _listify(self.load_state().get("memories"))
759
+ if user_email:
760
+ memories = [item for item in memories if item.get("user_email") in {None, user_email}]
761
+ if kind:
762
+ memories = [item for item in memories if item.get("kind") == kind]
763
+ return {"memories": list(reversed(memories))}
764
+
765
+ def search_memories(self, query: str, user_email: Optional[str] = None, limit: int = 20) -> Dict[str, Any]:
766
+ q = str(query or "").lower().strip()
767
+ memories = self.list_memories(user_email=user_email).get("memories", [])
768
+ if q:
769
+ memories = [
770
+ item for item in memories
771
+ if q in str(item.get("content") or "").lower()
772
+ or q in " ".join(item.get("tags") or []).lower()
773
+ or q in str(item.get("kind") or "").lower()
774
+ ]
775
+ return {"query": query, "memories": memories[: max(1, min(limit, 100))]}
776
+
777
+ def delete_memory(self, memory_id: str) -> Dict[str, Any]:
778
+ state = self.load_state()
779
+ memories = _listify(state.get("memories"))
780
+ kept = [item for item in memories if item.get("id") != memory_id]
781
+ if len(kept) == len(memories):
782
+ raise FileNotFoundError(memory_id)
783
+ state["memories"] = kept
784
+ self.save_state(state)
785
+ self.record_timeline_event("memory", "memory_deleted", {"memory_id": memory_id})
786
+ return {"status": "ok", "memory_id": memory_id}
787
+
788
+ # ------------------------------------------------------------------
789
+ # Agent and workflow graph
790
+ # ------------------------------------------------------------------
791
+
792
+ def list_agents(self) -> Dict[str, Any]:
793
+ state = self.load_state()
794
+ return {"agents": _listify(state.get("agents")), "runs": list(reversed(_listify(state.get("agent_runs"))[-100:]))}
795
+
796
+ def record_agent_run(
797
+ self,
798
+ *,
799
+ agent_id: str,
800
+ status: str,
801
+ input_text: str,
802
+ output_text: str,
803
+ user_email: Optional[str],
804
+ timeline: Optional[List[Dict[str, Any]]] = None,
805
+ relationships: Optional[List[str]] = None,
806
+ graph: Any = None,
807
+ ) -> Dict[str, Any]:
808
+ state = self.load_state()
809
+ run = {
810
+ "id": f"agent-run-{_json_hash([agent_id, input_text, output_text, _now()])[:16]}",
811
+ "agent_id": agent_id,
812
+ "status": status,
813
+ "input": input_text,
814
+ "output_preview": output_text[:1000],
815
+ "user_email": user_email,
816
+ "relationships": relationships or [],
817
+ "timeline": timeline or [],
818
+ "created_at": _now(),
819
+ }
820
+ if graph is not None:
821
+ try:
822
+ ingested = graph.ingest_event(
823
+ "AgentRun",
824
+ f"{agent_id} {status}",
825
+ user_email=user_email,
826
+ source="workspace_os",
827
+ metadata={"run_id": run["id"], "agent_id": agent_id, "status": status},
828
+ )
829
+ run["graph_node_id"] = ingested.get("node_id")
830
+ except Exception as exc:
831
+ run["graph_error"] = str(exc)
832
+ state.setdefault("agent_runs", []).append(run)
833
+ state["agent_runs"] = state["agent_runs"][-300:]
834
+ self.save_state(state)
835
+ self.record_timeline_event("agent", "agent_run", {"run_id": run["id"], "agent_id": agent_id, "status": status})
836
+ return run
837
+
838
+ def create_workflow(
839
+ self,
840
+ *,
841
+ name: str,
842
+ steps: List[Dict[str, Any]],
843
+ user_email: Optional[str],
844
+ metadata: Optional[Dict[str, Any]] = None,
845
+ graph: Any = None,
846
+ ) -> Dict[str, Any]:
847
+ state = self.load_state()
848
+ workflow = {
849
+ "id": f"workflow-{_json_hash([name, steps, user_email, _now()])[:16]}",
850
+ "name": name or "Untitled workflow",
851
+ "steps": steps,
852
+ "user_email": user_email,
853
+ "metadata": metadata or {},
854
+ "events": [{"type": "created", "timestamp": _now()}],
855
+ "created_at": _now(),
856
+ "updated_at": _now(),
857
+ }
858
+ if graph is not None:
859
+ try:
860
+ ingested = graph.ingest_event(
861
+ "Workflow",
862
+ workflow["name"],
863
+ user_email=user_email,
864
+ source="workspace_os",
865
+ metadata={"workflow_id": workflow["id"], "steps": steps},
866
+ )
867
+ workflow["graph_node_id"] = ingested.get("node_id")
868
+ except Exception as exc:
869
+ workflow["graph_error"] = str(exc)
870
+ state.setdefault("workflows", []).append(workflow)
871
+ state["workflows"] = state["workflows"][-300:]
872
+ self.save_state(state)
873
+ self.record_timeline_event("workflow", "workflow_created", {"workflow_id": workflow["id"], "name": workflow["name"]})
874
+ return workflow
875
+
876
+ def list_workflows(self, query: str = "") -> Dict[str, Any]:
877
+ workflows = list(reversed(_listify(self.load_state().get("workflows"))))
878
+ q = str(query or "").lower().strip()
879
+ if q:
880
+ workflows = [
881
+ wf for wf in workflows
882
+ if q in str(wf.get("name") or "").lower()
883
+ or q in json.dumps(wf.get("steps") or [], ensure_ascii=False).lower()
884
+ ]
885
+ return {"workflows": workflows}
886
+
887
+ def record_workflow_event(self, workflow_id: str, event_type: str, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
888
+ state = self.load_state()
889
+ workflows = _listify(state.get("workflows"))
890
+ workflow = next((item for item in workflows if item.get("id") == workflow_id), None)
891
+ if not workflow:
892
+ raise FileNotFoundError(workflow_id)
893
+ event = {"type": event_type, "timestamp": _now(), "payload": payload or {}}
894
+ workflow.setdefault("events", []).append(event)
895
+ workflow["updated_at"] = _now()
896
+ self.save_state(state)
897
+ self.record_timeline_event("workflow", "workflow_event", {"workflow_id": workflow_id, "event_type": event_type})
898
+ return workflow
899
+
900
+ # ------------------------------------------------------------------
901
+ # Relationship explorer
902
+ # ------------------------------------------------------------------
903
+
904
+ def relationship_explorer(self, graph: Any, node_id: str, target_id: Optional[str] = None, limit: int = 500) -> Dict[str, Any]:
905
+ if graph is None:
906
+ return {"node_id": node_id, "inbound": [], "outbound": [], "related_entities": [], "shortest_path": []}
907
+ data = graph.graph(limit=limit)
908
+ nodes = {node.get("id"): node for node in data.get("nodes") or [] if node.get("id")}
909
+ edges = data.get("edges") or []
910
+ inbound = [edge for edge in edges if edge.get("to") == node_id]
911
+ outbound = [edge for edge in edges if edge.get("from") == node_id]
912
+ if node_id not in nodes:
913
+ try:
914
+ neighbors = graph.neighbors(node_id)
915
+ for node in neighbors.get("neighbors") or []:
916
+ nodes[node.get("id")] = node
917
+ edges.extend(neighbors.get("edges") or [])
918
+ inbound = [edge for edge in edges if edge.get("to") == node_id]
919
+ outbound = [edge for edge in edges if edge.get("from") == node_id]
920
+ except Exception:
921
+ pass
922
+
923
+ related_ids = []
924
+ for edge in inbound + outbound:
925
+ other = edge.get("from") if edge.get("to") == node_id else edge.get("to")
926
+ if other:
927
+ related_ids.append(other)
928
+ related = [nodes.get(rid, {"id": rid}) for rid in dict.fromkeys(related_ids)]
929
+ shortest_path = self._shortest_path(edges, node_id, target_id) if target_id else []
930
+ return {
931
+ "node_id": node_id,
932
+ "node": nodes.get(node_id, {"id": node_id}),
933
+ "inbound": inbound,
934
+ "outbound": outbound,
935
+ "related_entities": related,
936
+ "shortest_path": shortest_path,
937
+ }
938
+
939
+ @staticmethod
940
+ def _shortest_path(edges: List[Dict[str, Any]], start: str, target: Optional[str]) -> List[str]:
941
+ if not start or not target:
942
+ return []
943
+ adjacency: Dict[str, List[str]] = {}
944
+ for edge in edges:
945
+ src = edge.get("from")
946
+ dst = edge.get("to")
947
+ if src and dst:
948
+ adjacency.setdefault(src, []).append(dst)
949
+ adjacency.setdefault(dst, []).append(src)
950
+ queue: deque[List[str]] = deque([[start]])
951
+ seen = {start}
952
+ while queue:
953
+ path = queue.popleft()
954
+ node = path[-1]
955
+ if node == target:
956
+ return path
957
+ for neighbor in adjacency.get(node, []):
958
+ if neighbor not in seen:
959
+ seen.add(neighbor)
960
+ queue.append(path + [neighbor])
961
+ return []
962
+
963
+ # ------------------------------------------------------------------
964
+ # Local Computer Memory
965
+ # ------------------------------------------------------------------
966
+
967
+ def configure_computer_memory(
968
+ self,
969
+ *,
970
+ enabled: bool,
971
+ approved_by: Optional[str],
972
+ consent: Optional[Dict[str, Any]] = None,
973
+ scopes: Optional[List[str]] = None,
974
+ ) -> Dict[str, Any]:
975
+ consent = consent or {}
976
+ if enabled and not consent.get("approved"):
977
+ raise PermissionError("Local Computer Memory requires explicit approval.")
978
+ state = self.load_state()
979
+ config = state.setdefault("computer_memory", {})
980
+ config.update({
981
+ "enabled": bool(enabled),
982
+ "approved": bool(enabled),
983
+ "approved_at": _now() if enabled else config.get("approved_at"),
984
+ "approved_by": approved_by if enabled else config.get("approved_by"),
985
+ "scopes": scopes or config.get("scopes") or ["Downloads", "Documents", "Repositories"],
986
+ "consent": consent,
987
+ })
988
+ state.setdefault("feature_flags", {})["local_computer_memory"] = bool(enabled)
989
+ self.save_state(state)
990
+ self.record_timeline_event("memory", "computer_memory_configured", {"enabled": bool(enabled), "approved_by": approved_by})
991
+ return config
992
+
993
+ def record_computer_activity(self, activity: Dict[str, Any], graph: Any = None) -> Dict[str, Any]:
994
+ state = self.load_state()
995
+ config = state.setdefault("computer_memory", {})
996
+ if not config.get("enabled"):
997
+ return {"status": "ignored", "reason": "local computer memory is disabled"}
998
+ record = {
999
+ "id": f"activity-{_json_hash([activity, _now()])[:16]}",
1000
+ "timestamp": _now(),
1001
+ **activity,
1002
+ }
1003
+ config.setdefault("activities", []).append(record)
1004
+ config["activities"] = config["activities"][-500:]
1005
+ if graph is not None:
1006
+ try:
1007
+ graph.ingest_event(
1008
+ "ComputerActivity",
1009
+ str(activity.get("summary") or activity.get("path") or "Computer activity")[:120],
1010
+ source="workspace_os",
1011
+ metadata=record,
1012
+ )
1013
+ except Exception as exc:
1014
+ record["graph_error"] = str(exc)
1015
+ self.save_state(state)
1016
+ self.record_timeline_event("memory", "computer_activity", {"activity_id": record["id"]})
1017
+ return {"status": "ok", "activity": record}
1018
+
1019
+ # ------------------------------------------------------------------
1020
+ # Skills
1021
+ # ------------------------------------------------------------------
1022
+
1023
+ def list_skill_registry(self, skills_dir: Path, marketplace: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
1024
+ state = self.load_state()
1025
+ registry = state.setdefault("skill_registry", {})
1026
+ installed = []
1027
+ if skills_dir.exists():
1028
+ for skill_dir in sorted(skills_dir.iterdir()):
1029
+ if not skill_dir.is_dir():
1030
+ continue
1031
+ skill_md = skill_dir / "SKILL.md"
1032
+ schema = skill_dir / "schema.json"
1033
+ if not skill_md.exists():
1034
+ continue
1035
+ desc = ""
1036
+ try:
1037
+ for line in skill_md.read_text(encoding="utf-8").splitlines():
1038
+ if line.startswith("description:"):
1039
+ desc = line.split(":", 1)[1].strip()
1040
+ break
1041
+ except Exception:
1042
+ desc = ""
1043
+ version = "local"
1044
+ if schema.exists():
1045
+ try:
1046
+ version = str((json.loads(schema.read_text(encoding="utf-8")) or {}).get("version") or "local")
1047
+ except Exception:
1048
+ version = "local"
1049
+ entry = registry.setdefault(skill_dir.name, {})
1050
+ entry.setdefault("enabled", True)
1051
+ entry.update({
1052
+ "name": skill_dir.name,
1053
+ "description": desc,
1054
+ "version": version,
1055
+ "installed": True,
1056
+ "path": str(skill_dir),
1057
+ "updated_at": entry.get("updated_at") or _now(),
1058
+ })
1059
+ installed.append(entry)
1060
+ available = []
1061
+ for item in marketplace or []:
1062
+ name = item.get("skill") or item.get("name")
1063
+ if not name:
1064
+ continue
1065
+ state_entry = registry.get(name, {})
1066
+ available.append({
1067
+ **item,
1068
+ "enabled": bool(state_entry.get("enabled", True)),
1069
+ "installed": bool(state_entry.get("installed")),
1070
+ "version": state_entry.get("version") or item.get("version") or "remote",
1071
+ })
1072
+ self.save_state(state)
1073
+ return {
1074
+ "installed": installed,
1075
+ "available": available,
1076
+ "registry": registry,
1077
+ "total_installed": len(installed),
1078
+ "total_available": len(available),
1079
+ }
1080
+
1081
+ def set_skill_enabled(self, skill: str, enabled: bool) -> Dict[str, Any]:
1082
+ state = self.load_state()
1083
+ entry = state.setdefault("skill_registry", {}).setdefault(skill, {"name": skill})
1084
+ entry["enabled"] = bool(enabled)
1085
+ entry["updated_at"] = _now()
1086
+ self.save_state(state)
1087
+ self.record_timeline_event("skills", "skill_enabled" if enabled else "skill_disabled", {"skill": skill})
1088
+ return entry
1089
+
1090
+ def mark_skill_installed(self, skill: str, *, version: str = "local", metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
1091
+ state = self.load_state()
1092
+ entry = state.setdefault("skill_registry", {}).setdefault(skill, {"name": skill})
1093
+ entry.update({
1094
+ "installed": True,
1095
+ "enabled": entry.get("enabled", True),
1096
+ "version": version,
1097
+ "metadata": metadata or entry.get("metadata") or {},
1098
+ "updated_at": _now(),
1099
+ })
1100
+ self.save_state(state)
1101
+ self.record_timeline_event("skills", "skill_installed", {"skill": skill, "version": version})
1102
+ return entry
1103
+
1104
+ def mark_skill_uninstalled(self, skill: str) -> Dict[str, Any]:
1105
+ state = self.load_state()
1106
+ entry = state.setdefault("skill_registry", {}).setdefault(skill, {"name": skill})
1107
+ entry.update({"installed": False, "enabled": False, "updated_at": _now()})
1108
+ self.save_state(state)
1109
+ self.record_timeline_event("skills", "skill_uninstalled", {"skill": skill})
1110
+ return entry
1111
+
1112
+ # ------------------------------------------------------------------
1113
+ # Audit timeline
1114
+ # ------------------------------------------------------------------
1115
+
1116
+ def filter_audit_timeline(
1117
+ self,
1118
+ audit_events: Iterable[Dict[str, Any]],
1119
+ *,
1120
+ user: Optional[str] = None,
1121
+ event_type: Optional[str] = None,
1122
+ model: Optional[str] = None,
1123
+ since: Optional[str] = None,
1124
+ until: Optional[str] = None,
1125
+ limit: int = 100,
1126
+ ) -> Dict[str, Any]:
1127
+ since_dt = _parse_iso(since)
1128
+ until_dt = _parse_iso(until)
1129
+ filtered = []
1130
+ for event in audit_events:
1131
+ stamp = _parse_iso(event.get("timestamp"))
1132
+ if user and user.lower() not in str(event.get("user_email") or event.get("user") or "").lower():
1133
+ continue
1134
+ if event_type and event_type.lower() not in str(event.get("event_type") or "").lower():
1135
+ continue
1136
+ if model and model.lower() not in json.dumps(event, ensure_ascii=False).lower():
1137
+ continue
1138
+ if since_dt and stamp and stamp < since_dt:
1139
+ continue
1140
+ if until_dt and stamp and stamp > until_dt:
1141
+ continue
1142
+ filtered.append({
1143
+ **event,
1144
+ "category": self._audit_category(event),
1145
+ })
1146
+ filtered.sort(key=lambda item: item.get("timestamp") or "", reverse=True)
1147
+ return {"events": filtered[: max(1, min(limit, 1000))], "total": len(filtered)}
1148
+
1149
+ @staticmethod
1150
+ def _audit_category(event: Dict[str, Any]) -> str:
1151
+ raw = str(event.get("event_type") or "").lower()
1152
+ if "model" in raw or "chat" in raw:
1153
+ return "model_usage"
1154
+ if "file" in raw or "document" in raw or "local" in raw:
1155
+ return "file_access"
1156
+ if "folder" in raw or "permission" in raw:
1157
+ return "folder_approval"
1158
+ if "sensitive" in raw or "secret" in raw:
1159
+ return "sensitive_data"
1160
+ if "admin" in raw or "user" in raw or "sso" in raw:
1161
+ return "admin_action"
1162
+ if "security" in raw or "auth" in raw or "login" in raw:
1163
+ return "security_event"
1164
+ return "workspace_event"
1165
+
1166
+
1167
+ def remove_skill_directory(skills_dir: Path, skill: str) -> Dict[str, Any]:
1168
+ """Remove an installed skill directory after caller has performed auth checks."""
1169
+
1170
+ safe_name = _safe_slug(skill)
1171
+ target = (skills_dir / safe_name).resolve()
1172
+ root = skills_dir.resolve()
1173
+ if not str(target).startswith(str(root)):
1174
+ raise ValueError("invalid skill path")
1175
+ if not target.exists() or not target.is_dir():
1176
+ raise FileNotFoundError(skill)
1177
+ shutil.rmtree(target)
1178
+ return {"status": "ok", "skill": safe_name, "removed_path": str(target)}