ltcai 0.5.1 → 1.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.
- package/README.md +29 -12
- package/docs/CHANGELOG.md +75 -0
- package/knowledge_graph.py +33 -0
- package/latticeai/__init__.py +3 -1
- package/latticeai/core/agent.py +2 -2
- package/latticeai/core/agent_prompts.py +101 -0
- package/latticeai/core/tool_registry.py +288 -0
- package/latticeai/core/workspace_os.py +1178 -0
- package/latticeai/server_app.py +6405 -0
- package/package.json +6 -3
- package/server.py +13 -6259
- package/static/admin.html +1 -0
- package/static/graph.html +1 -0
- package/static/manifest.json +2 -2
- package/static/scripts/chat.js +4 -2
- package/static/scripts/graph.js +3 -3
- package/static/scripts/workspace.js +382 -0
- package/static/sw.js +5 -1
- package/static/workspace.css +515 -0
- package/static/workspace.html +199 -0
- package/tools.py +6 -5
|
@@ -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.0"
|
|
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)}
|