ltcai 1.0.1 → 1.1.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.
@@ -18,7 +18,30 @@ from pathlib import Path
18
18
  from typing import Any, Callable, Dict, Iterable, List, Optional
19
19
 
20
20
 
21
- WORKSPACE_OS_VERSION = "1.0.1"
21
+ WORKSPACE_OS_VERSION = "1.1.0"
22
+
23
+ # Workspace types separate single-user Personal workspaces from shared
24
+ # Organization workspaces. Both keep the same local-first JSON store; the type
25
+ # only changes how membership and permissions are evaluated.
26
+ WORKSPACE_TYPES = ("personal", "organization")
27
+
28
+ DEFAULT_WORKSPACE_ID = "personal"
29
+
30
+ # Role hierarchy for Organization workspaces. Personal workspaces always grant
31
+ # their single local user the owner role.
32
+ WORKSPACE_ROLES = ("owner", "admin", "member", "viewer")
33
+
34
+ # Capability-style permissions. Kept intentionally small so Enterprise editions
35
+ # can layer advanced RBAC/ABAC on top via the enterprise seam without changing
36
+ # these community defaults.
37
+ WORKSPACE_PERMISSIONS = ("read", "write", "manage_members", "manage_workspace")
38
+
39
+ ROLE_PERMISSIONS: Dict[str, set] = {
40
+ "owner": {"read", "write", "manage_members", "manage_workspace"},
41
+ "admin": {"read", "write", "manage_members", "manage_workspace"},
42
+ "member": {"read", "write"},
43
+ "viewer": {"read"},
44
+ }
22
45
 
23
46
  WORKSPACE_AREAS = [
24
47
  "graph",
@@ -150,26 +173,94 @@ class WorkspaceOSStore:
150
173
  self.snapshots_dir.mkdir(parents=True, exist_ok=True)
151
174
  self.exports_dir.mkdir(parents=True, exist_ok=True)
152
175
 
176
+ @staticmethod
177
+ def _new_workspace_record(
178
+ *,
179
+ workspace_id: str,
180
+ name: str,
181
+ workspace_type: str,
182
+ owner_user_id: Optional[str],
183
+ settings: Optional[Dict[str, Any]] = None,
184
+ members: Optional[List[Dict[str, Any]]] = None,
185
+ ) -> Dict[str, Any]:
186
+ if workspace_type not in WORKSPACE_TYPES:
187
+ raise ValueError(f"unknown workspace type: {workspace_type}")
188
+ now = _now()
189
+ member_list = list(members or [])
190
+ if owner_user_id and not any(m.get("user_id") == owner_user_id for m in member_list):
191
+ member_list.insert(0, {"user_id": owner_user_id, "role": "owner", "added_at": now})
192
+ return {
193
+ "workspace_id": workspace_id,
194
+ "id": workspace_id,
195
+ "name": name,
196
+ "type": workspace_type,
197
+ "owner_user_id": owner_user_id,
198
+ "members": member_list,
199
+ "roles": {role: sorted(perms) for role, perms in ROLE_PERMISSIONS.items()},
200
+ "status": "active",
201
+ "areas": list(WORKSPACE_AREAS),
202
+ "settings": settings or {},
203
+ "created_at": now,
204
+ "updated_at": now,
205
+ }
206
+
207
+ def _migrate_workspaces(self, state: Dict[str, Any]) -> Dict[str, Any]:
208
+ """Non-destructive upgrade of legacy workspace entries to the v1.1 model.
209
+
210
+ Existing 1.0.x state files stored minimal ``{id,name,type,areas}`` dicts.
211
+ This backfills membership/role/timestamp fields without dropping data and
212
+ guarantees the default Personal workspace always exists.
213
+ """
214
+ workspaces = state.get("workspaces")
215
+ if not isinstance(workspaces, dict):
216
+ workspaces = {}
217
+ migrated: Dict[str, Any] = {}
218
+ for ws_id, ws in workspaces.items():
219
+ if not isinstance(ws, dict):
220
+ continue
221
+ ws_type = ws.get("type") if ws.get("type") in WORKSPACE_TYPES else "organization"
222
+ if ws_id == DEFAULT_WORKSPACE_ID:
223
+ ws_type = "personal"
224
+ base = self._new_workspace_record(
225
+ workspace_id=ws_id,
226
+ name=ws.get("name") or ws_id,
227
+ workspace_type=ws_type,
228
+ owner_user_id=ws.get("owner_user_id"),
229
+ settings=ws.get("settings") or {},
230
+ members=ws.get("members") if isinstance(ws.get("members"), list) else None,
231
+ )
232
+ # Preserve any pre-existing timestamps / status from the loaded record.
233
+ base["created_at"] = ws.get("created_at") or base["created_at"]
234
+ base["updated_at"] = ws.get("updated_at") or base["updated_at"]
235
+ base["status"] = ws.get("status") or base["status"]
236
+ migrated[ws_id] = base
237
+ if DEFAULT_WORKSPACE_ID not in migrated:
238
+ migrated[DEFAULT_WORKSPACE_ID] = self._new_workspace_record(
239
+ workspace_id=DEFAULT_WORKSPACE_ID,
240
+ name="Personal Workspace",
241
+ workspace_type="personal",
242
+ owner_user_id=None,
243
+ )
244
+ state["workspaces"] = migrated
245
+ active = state.get("active_workspace")
246
+ if active not in migrated:
247
+ state["active_workspace"] = DEFAULT_WORKSPACE_ID
248
+ return state
249
+
153
250
  def _default_state(self) -> Dict[str, Any]:
154
251
  return {
155
252
  "version": WORKSPACE_OS_VERSION,
156
253
  "identity": "AI Workspace OS",
157
254
  "created_at": _now(),
158
255
  "updated_at": _now(),
159
- "active_workspace": "personal",
256
+ "active_workspace": DEFAULT_WORKSPACE_ID,
160
257
  "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
- },
258
+ DEFAULT_WORKSPACE_ID: self._new_workspace_record(
259
+ workspace_id=DEFAULT_WORKSPACE_ID,
260
+ name="Personal Workspace",
261
+ workspace_type="personal",
262
+ owner_user_id=None,
263
+ ),
173
264
  },
174
265
  "feature_flags": {
175
266
  "workspace_os": True,
@@ -180,6 +271,8 @@ class WorkspaceOSStore:
180
271
  "workflow_graph": True,
181
272
  "skill_marketplace": True,
182
273
  "local_computer_memory": False,
274
+ "organization_workspaces": True,
275
+ "enterprise_seam": True,
183
276
  },
184
277
  "onboarding": {
185
278
  "completed": False,
@@ -227,6 +320,7 @@ class WorkspaceOSStore:
227
320
  loaded = {}
228
321
  state = _deep_merge(default, loaded)
229
322
  state["version"] = WORKSPACE_OS_VERSION
323
+ self._migrate_workspaces(state)
230
324
  return state
231
325
 
232
326
  def save_state(self, state: Dict[str, Any]) -> Dict[str, Any]:
@@ -235,13 +329,14 @@ class WorkspaceOSStore:
235
329
  _atomic_write_json(self.state_path, state)
236
330
  return state
237
331
 
238
- def record_timeline_event(self, area: str, event_type: str, payload: Dict[str, Any]) -> Dict[str, Any]:
332
+ def record_timeline_event(self, area: str, event_type: str, payload: Dict[str, Any], workspace_id: Optional[str] = None) -> Dict[str, Any]:
239
333
  state = self.load_state()
240
334
  event = {
241
335
  "id": f"timeline-{_json_hash([area, event_type, payload, _now()])[:16]}",
242
336
  "area": area,
243
337
  "event_type": event_type,
244
338
  "timestamp": _now(),
339
+ "workspace_id": self._resolve_scope(workspace_id, state),
245
340
  "payload": payload,
246
341
  }
247
342
  state.setdefault("timeline", []).append(event)
@@ -275,6 +370,269 @@ class WorkspaceOSStore:
275
370
  },
276
371
  }
277
372
 
373
+ # ------------------------------------------------------------------
374
+ # Organization workspaces, membership, and roles
375
+ # ------------------------------------------------------------------
376
+
377
+ def _active_workspace_id(self, state: Optional[Dict[str, Any]] = None) -> str:
378
+ state = state or self.load_state()
379
+ active = state.get("active_workspace") or DEFAULT_WORKSPACE_ID
380
+ if active not in (state.get("workspaces") or {}):
381
+ return DEFAULT_WORKSPACE_ID
382
+ return active
383
+
384
+ def _resolve_scope(self, workspace_id: Optional[str], state: Optional[Dict[str, Any]] = None) -> str:
385
+ """Resolve the workspace a write should be tagged with.
386
+
387
+ ``None`` falls back to the active workspace (Personal by default), so
388
+ legacy callers keep writing to the Personal workspace unchanged.
389
+ """
390
+ if workspace_id:
391
+ return str(workspace_id)
392
+ return self._active_workspace_id(state)
393
+
394
+ @staticmethod
395
+ def _record_workspace(record: Dict[str, Any]) -> str:
396
+ """Workspace a stored record belongs to (legacy records map to Personal)."""
397
+ return str(record.get("workspace_id") or DEFAULT_WORKSPACE_ID)
398
+
399
+ def _scoped(self, records: List[Dict[str, Any]], workspace_id: Optional[str]) -> List[Dict[str, Any]]:
400
+ if not workspace_id:
401
+ return records
402
+ target = str(workspace_id)
403
+ return [item for item in records if self._record_workspace(item) == target]
404
+
405
+ def list_workspaces(self, user_id: Optional[str] = None) -> Dict[str, Any]:
406
+ state = self.load_state()
407
+ workspaces = state.get("workspaces") or {}
408
+ items = []
409
+ for ws in workspaces.values():
410
+ if user_id and ws.get("type") == "organization":
411
+ role = self._member_role(ws, user_id)
412
+ if role is None:
413
+ continue
414
+ items.append(self._workspace_public(ws, user_id))
415
+ items.sort(key=lambda w: (w.get("type") != "personal", w.get("created_at") or ""))
416
+ return {
417
+ "active_workspace": self._active_workspace_id(state),
418
+ "workspaces": items,
419
+ "roles": list(WORKSPACE_ROLES),
420
+ "permissions": {role: sorted(perms) for role, perms in ROLE_PERMISSIONS.items()},
421
+ }
422
+
423
+ def _workspace_public(self, ws: Dict[str, Any], user_id: Optional[str] = None) -> Dict[str, Any]:
424
+ return {
425
+ "workspace_id": ws.get("workspace_id") or ws.get("id"),
426
+ "id": ws.get("workspace_id") or ws.get("id"),
427
+ "name": ws.get("name"),
428
+ "type": ws.get("type"),
429
+ "owner_user_id": ws.get("owner_user_id"),
430
+ "status": ws.get("status", "active"),
431
+ "member_count": len(_listify(ws.get("members"))),
432
+ "members": _listify(ws.get("members")),
433
+ "settings": ws.get("settings") or {},
434
+ "created_at": ws.get("created_at"),
435
+ "updated_at": ws.get("updated_at"),
436
+ "your_role": self._member_role(ws, user_id) if user_id else ("owner" if ws.get("type") == "personal" else None),
437
+ }
438
+
439
+ def get_workspace(self, workspace_id: str, user_id: Optional[str] = None) -> Dict[str, Any]:
440
+ state = self.load_state()
441
+ ws = (state.get("workspaces") or {}).get(workspace_id)
442
+ if not ws:
443
+ raise FileNotFoundError(workspace_id)
444
+ return self._workspace_public(ws, user_id)
445
+
446
+ def create_organization_workspace(
447
+ self,
448
+ *,
449
+ name: str,
450
+ owner_user_id: Optional[str],
451
+ settings: Optional[Dict[str, Any]] = None,
452
+ ) -> Dict[str, Any]:
453
+ if not str(name or "").strip():
454
+ raise ValueError("workspace name is required")
455
+ state = self.load_state()
456
+ workspaces = state.setdefault("workspaces", {})
457
+ base = _safe_slug(f"org-{name}")
458
+ workspace_id = base
459
+ suffix = 2
460
+ while workspace_id in workspaces:
461
+ workspace_id = f"{base}-{suffix}"
462
+ suffix += 1
463
+ record = self._new_workspace_record(
464
+ workspace_id=workspace_id,
465
+ name=name.strip(),
466
+ workspace_type="organization",
467
+ owner_user_id=owner_user_id,
468
+ settings=settings or {},
469
+ )
470
+ workspaces[workspace_id] = record
471
+ self.save_state(state)
472
+ self.record_timeline_event("workspace", "workspace_created", {"workspace_id": workspace_id, "type": "organization"})
473
+ return self._workspace_public(record, owner_user_id)
474
+
475
+ @staticmethod
476
+ def _member_role(ws: Dict[str, Any], user_id: Optional[str]) -> Optional[str]:
477
+ if ws.get("type") == "personal":
478
+ return "owner"
479
+ owner = ws.get("owner_user_id")
480
+ # Local single-user / no-auth mode: an ownerless org is owned by the
481
+ # local user (who has no identity), so they can manage what they create.
482
+ if not owner and not user_id:
483
+ return "owner"
484
+ if user_id and user_id == owner:
485
+ return "owner"
486
+ for member in _listify(ws.get("members")):
487
+ if member.get("user_id") == user_id:
488
+ return member.get("role")
489
+ return None
490
+
491
+ def get_member_role(self, workspace_id: str, user_id: Optional[str]) -> Optional[str]:
492
+ ws = (self.load_state().get("workspaces") or {}).get(workspace_id)
493
+ if not ws:
494
+ raise FileNotFoundError(workspace_id)
495
+ return self._member_role(ws, user_id)
496
+
497
+ def has_permission(self, workspace_id: str, user_id: Optional[str], permission: str) -> bool:
498
+ try:
499
+ role = self.get_member_role(workspace_id, user_id)
500
+ except FileNotFoundError:
501
+ return False
502
+ if role is None:
503
+ return False
504
+ return permission in ROLE_PERMISSIONS.get(role, set())
505
+
506
+ def _require_permission(self, ws: Dict[str, Any], actor: Optional[str], permission: str) -> None:
507
+ role = self._member_role(ws, actor)
508
+ if role is None or permission not in ROLE_PERMISSIONS.get(role, set()):
509
+ raise PermissionError(
510
+ f"'{actor or 'anonymous'}' lacks '{permission}' on workspace '{ws.get('workspace_id')}'"
511
+ )
512
+
513
+ def _load_org(self, state: Dict[str, Any], workspace_id: str) -> Dict[str, Any]:
514
+ ws = (state.get("workspaces") or {}).get(workspace_id)
515
+ if not ws:
516
+ raise FileNotFoundError(workspace_id)
517
+ if ws.get("type") != "organization":
518
+ raise ValueError("operation only valid for organization workspaces")
519
+ return ws
520
+
521
+ def update_workspace(
522
+ self,
523
+ workspace_id: str,
524
+ *,
525
+ name: Optional[str] = None,
526
+ settings: Optional[Dict[str, Any]] = None,
527
+ actor: Optional[str] = None,
528
+ ) -> Dict[str, Any]:
529
+ state = self.load_state()
530
+ ws = self._load_org(state, workspace_id)
531
+ self._require_permission(ws, actor, "manage_workspace")
532
+ if name is not None and str(name).strip():
533
+ ws["name"] = str(name).strip()
534
+ if settings is not None:
535
+ ws["settings"] = {**(ws.get("settings") or {}), **settings}
536
+ ws["updated_at"] = _now()
537
+ self.save_state(state)
538
+ self.record_timeline_event("workspace", "workspace_updated", {"workspace_id": workspace_id})
539
+ return self._workspace_public(ws, actor)
540
+
541
+ def archive_workspace(self, workspace_id: str, *, actor: Optional[str] = None) -> Dict[str, Any]:
542
+ """Soft-archive an organization workspace. Data is never deleted."""
543
+ state = self.load_state()
544
+ ws = self._load_org(state, workspace_id)
545
+ self._require_permission(ws, actor, "manage_workspace")
546
+ ws["status"] = "archived"
547
+ ws["updated_at"] = _now()
548
+ if state.get("active_workspace") == workspace_id:
549
+ state["active_workspace"] = DEFAULT_WORKSPACE_ID
550
+ self.save_state(state)
551
+ self.record_timeline_event("workspace", "workspace_archived", {"workspace_id": workspace_id})
552
+ return self._workspace_public(ws, actor)
553
+
554
+ def add_member(self, workspace_id: str, *, user_id: str, role: str = "member", actor: Optional[str] = None) -> Dict[str, Any]:
555
+ if role not in WORKSPACE_ROLES:
556
+ raise ValueError(f"unknown role: {role}")
557
+ if not str(user_id or "").strip():
558
+ raise ValueError("user_id is required")
559
+ state = self.load_state()
560
+ ws = self._load_org(state, workspace_id)
561
+ self._require_permission(ws, actor, "manage_members")
562
+ members = ws.setdefault("members", [])
563
+ existing = next((m for m in members if m.get("user_id") == user_id), None)
564
+ if existing:
565
+ existing["role"] = role
566
+ existing["updated_at"] = _now()
567
+ else:
568
+ members.append({"user_id": user_id, "role": role, "added_at": _now()})
569
+ ws["updated_at"] = _now()
570
+ self.save_state(state)
571
+ self.record_timeline_event("workspace", "member_added", {"workspace_id": workspace_id, "user_id": user_id, "role": role})
572
+ return self._workspace_public(ws, actor)
573
+
574
+ def update_member_role(self, workspace_id: str, *, user_id: str, role: str, actor: Optional[str] = None) -> Dict[str, Any]:
575
+ if role not in WORKSPACE_ROLES:
576
+ raise ValueError(f"unknown role: {role}")
577
+ state = self.load_state()
578
+ ws = self._load_org(state, workspace_id)
579
+ self._require_permission(ws, actor, "manage_members")
580
+ if user_id == ws.get("owner_user_id") and role != "owner":
581
+ raise ValueError("cannot demote the workspace owner")
582
+ member = next((m for m in _listify(ws.get("members")) if m.get("user_id") == user_id), None)
583
+ if not member:
584
+ raise FileNotFoundError(user_id)
585
+ member["role"] = role
586
+ member["updated_at"] = _now()
587
+ ws["updated_at"] = _now()
588
+ self.save_state(state)
589
+ self.record_timeline_event("workspace", "member_role_updated", {"workspace_id": workspace_id, "user_id": user_id, "role": role})
590
+ return self._workspace_public(ws, actor)
591
+
592
+ def remove_member(self, workspace_id: str, *, user_id: str, actor: Optional[str] = None) -> Dict[str, Any]:
593
+ state = self.load_state()
594
+ ws = self._load_org(state, workspace_id)
595
+ self._require_permission(ws, actor, "manage_members")
596
+ if user_id == ws.get("owner_user_id"):
597
+ raise ValueError("cannot remove the workspace owner")
598
+ members = _listify(ws.get("members"))
599
+ kept = [m for m in members if m.get("user_id") != user_id]
600
+ if len(kept) == len(members):
601
+ raise FileNotFoundError(user_id)
602
+ ws["members"] = kept
603
+ ws["updated_at"] = _now()
604
+ self.save_state(state)
605
+ self.record_timeline_event("workspace", "member_removed", {"workspace_id": workspace_id, "user_id": user_id})
606
+ return self._workspace_public(ws, actor)
607
+
608
+ def set_active_workspace(self, workspace_id: str, user_id: Optional[str] = None) -> Dict[str, Any]:
609
+ state = self.load_state()
610
+ ws = (state.get("workspaces") or {}).get(workspace_id)
611
+ if not ws:
612
+ raise FileNotFoundError(workspace_id)
613
+ if ws.get("type") == "organization" and self._member_role(ws, user_id) is None:
614
+ raise PermissionError(f"'{user_id or 'anonymous'}' is not a member of '{workspace_id}'")
615
+ state["active_workspace"] = workspace_id
616
+ self.save_state(state)
617
+ self.record_timeline_event("workspace", "workspace_activated", {"workspace_id": workspace_id})
618
+ return self._workspace_public(ws, user_id)
619
+
620
+ def workspace_summary(self, workspace_id: str, user_id: Optional[str] = None) -> Dict[str, Any]:
621
+ state = self.load_state()
622
+ ws = (state.get("workspaces") or {}).get(workspace_id)
623
+ if not ws:
624
+ raise FileNotFoundError(workspace_id)
625
+ public = self._workspace_public(ws, user_id)
626
+ public["counts"] = {
627
+ "snapshots": len(self._scoped(_listify(state.get("snapshots")), workspace_id)),
628
+ "memories": len(self._scoped(_listify(state.get("memories")), workspace_id)),
629
+ "agent_runs": len(self._scoped(_listify(state.get("agent_runs")), workspace_id)),
630
+ "workflows": len(self._scoped(_listify(state.get("workflows")), workspace_id)),
631
+ "traces": len(self._scoped(_listify(state.get("traces")), workspace_id)),
632
+ "timeline": len(self._scoped(_listify(state.get("timeline")), workspace_id)),
633
+ }
634
+ return public
635
+
278
636
  # ------------------------------------------------------------------
279
637
  # Onboarding
280
638
  # ------------------------------------------------------------------
@@ -438,6 +796,7 @@ class WorkspaceOSStore:
438
796
  conversation_id: Optional[str],
439
797
  user_email: Optional[str],
440
798
  trace: Dict[str, Any],
799
+ workspace_id: Optional[str] = None,
441
800
  ) -> Dict[str, Any]:
442
801
  state = self.load_state()
443
802
  trace_id = f"trace-{_json_hash([question, response, conversation_id, _now()])[:16]}"
@@ -447,6 +806,7 @@ class WorkspaceOSStore:
447
806
  "response_preview": str(response or "")[:700],
448
807
  "conversation_id": conversation_id,
449
808
  "user_email": user_email,
809
+ "workspace_id": self._resolve_scope(workspace_id, state),
450
810
  "created_at": _now(),
451
811
  **trace,
452
812
  }
@@ -456,8 +816,8 @@ class WorkspaceOSStore:
456
816
  self.record_timeline_event("graph", "answer_trace", {"trace_id": trace_id, "conversation_id": conversation_id})
457
817
  return record
458
818
 
459
- def list_traces(self, conversation_id: Optional[str] = None, limit: int = 50) -> Dict[str, Any]:
460
- traces = _listify(self.load_state().get("traces"))
819
+ def list_traces(self, conversation_id: Optional[str] = None, limit: int = 50, workspace_id: Optional[str] = None) -> Dict[str, Any]:
820
+ traces = self._scoped(_listify(self.load_state().get("traces")), workspace_id)
461
821
  if conversation_id:
462
822
  traces = [trace for trace in traces if trace.get("conversation_id") == conversation_id]
463
823
  return {"traces": list(reversed(traces[-max(1, min(limit, 200)):]))}
@@ -550,7 +910,9 @@ class WorkspaceOSStore:
550
910
  history: Iterable[Dict[str, Any]],
551
911
  settings: Dict[str, Any],
552
912
  models: Dict[str, Any],
913
+ workspace_id: Optional[str] = None,
553
914
  ) -> Dict[str, Any]:
915
+ scope = self._resolve_scope(workspace_id)
554
916
  graph_payload = {"nodes": [], "edges": []}
555
917
  graph_stats = {}
556
918
  local_sources = {"sources": []}
@@ -563,7 +925,8 @@ class WorkspaceOSStore:
563
925
  "version": WORKSPACE_OS_VERSION,
564
926
  "name": name or "Workspace snapshot",
565
927
  "created_at": _now(),
566
- "workspace": self.load_state().get("active_workspace", "personal"),
928
+ "workspace": scope,
929
+ "workspace_id": scope,
567
930
  "graph": graph_payload,
568
931
  "graph_stats": graph_stats,
569
932
  "chat": chat,
@@ -581,6 +944,7 @@ class WorkspaceOSStore:
581
944
  "id": snapshot_id,
582
945
  "name": snapshot_body["name"],
583
946
  "created_at": snapshot_body["created_at"],
947
+ "workspace_id": scope,
584
948
  "path": str(path),
585
949
  "node_count": len(graph_payload.get("nodes") or []),
586
950
  "edge_count": len(graph_payload.get("edges") or []),
@@ -594,8 +958,8 @@ class WorkspaceOSStore:
594
958
  self.record_timeline_event("snapshot", "snapshot_saved", {"snapshot_id": snapshot_id, "name": name})
595
959
  return {"snapshot": meta}
596
960
 
597
- def list_snapshots(self) -> Dict[str, Any]:
598
- snapshots = _listify(self.load_state().get("snapshots"))
961
+ def list_snapshots(self, workspace_id: Optional[str] = None) -> Dict[str, Any]:
962
+ snapshots = self._scoped(_listify(self.load_state().get("snapshots")), workspace_id)
599
963
  return {"snapshots": list(reversed(snapshots))}
600
964
 
601
965
  def get_snapshot(self, snapshot_id: str) -> Dict[str, Any]:
@@ -682,18 +1046,18 @@ class WorkspaceOSStore:
682
1046
  },
683
1047
  }
684
1048
 
685
- def timeline(self, audit_events: Optional[Iterable[Dict[str, Any]]] = None, limit: int = 100) -> Dict[str, Any]:
1049
+ def timeline(self, audit_events: Optional[Iterable[Dict[str, Any]]] = None, limit: int = 100, workspace_id: Optional[str] = None) -> Dict[str, Any]:
686
1050
  state = self.load_state()
687
1051
  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})
1052
+ events.extend(self._scoped(_listify(state.get("timeline")), workspace_id))
1053
+ for snapshot in self._scoped(_listify(state.get("snapshots")), workspace_id):
1054
+ events.append({"area": "snapshot", "event_type": "snapshot", "timestamp": snapshot.get("created_at"), "workspace_id": self._record_workspace(snapshot), "payload": snapshot})
1055
+ for trace in self._scoped(_listify(state.get("traces")), workspace_id):
1056
+ events.append({"area": "graph", "event_type": "answer_trace", "timestamp": trace.get("created_at"), "workspace_id": self._record_workspace(trace), "payload": trace})
1057
+ for run in self._scoped(_listify(state.get("agent_runs")), workspace_id):
1058
+ events.append({"area": "agent", "event_type": "agent_run", "timestamp": run.get("created_at"), "workspace_id": self._record_workspace(run), "payload": run})
1059
+ for workflow in self._scoped(_listify(state.get("workflows")), workspace_id):
1060
+ events.append({"area": "workflow", "event_type": "workflow", "timestamp": workflow.get("created_at"), "workspace_id": self._record_workspace(workflow), "payload": workflow})
697
1061
  for audit in audit_events or []:
698
1062
  events.append({"area": "audit", "event_type": audit.get("event_type") or "audit", "timestamp": audit.get("timestamp"), "payload": audit})
699
1063
  events.sort(key=lambda item: item.get("timestamp") or "", reverse=True)
@@ -713,6 +1077,7 @@ class WorkspaceOSStore:
713
1077
  memory_id: Optional[str] = None,
714
1078
  metadata: Optional[Dict[str, Any]] = None,
715
1079
  graph: Any = None,
1080
+ workspace_id: Optional[str] = None,
716
1081
  ) -> Dict[str, Any]:
717
1082
  if kind not in MEMORY_KINDS:
718
1083
  raise ValueError(f"unknown memory kind: {kind}")
@@ -733,6 +1098,7 @@ class WorkspaceOSStore:
733
1098
  "user_email": user_email,
734
1099
  "tags": tags or [],
735
1100
  "metadata": metadata or {},
1101
+ "workspace_id": self._resolve_scope(workspace_id, state) if existing is None else self._record_workspace(record),
736
1102
  "updated_at": now,
737
1103
  })
738
1104
  if graph is not None:
@@ -754,17 +1120,17 @@ class WorkspaceOSStore:
754
1120
  self.record_timeline_event("memory", "memory_upserted", {"memory_id": memory_id, "kind": kind})
755
1121
  return record
756
1122
 
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"))
1123
+ def list_memories(self, user_email: Optional[str] = None, kind: Optional[str] = None, workspace_id: Optional[str] = None) -> Dict[str, Any]:
1124
+ memories = self._scoped(_listify(self.load_state().get("memories")), workspace_id)
759
1125
  if user_email:
760
1126
  memories = [item for item in memories if item.get("user_email") in {None, user_email}]
761
1127
  if kind:
762
1128
  memories = [item for item in memories if item.get("kind") == kind]
763
1129
  return {"memories": list(reversed(memories))}
764
1130
 
765
- def search_memories(self, query: str, user_email: Optional[str] = None, limit: int = 20) -> Dict[str, Any]:
1131
+ def search_memories(self, query: str, user_email: Optional[str] = None, limit: int = 20, workspace_id: Optional[str] = None) -> Dict[str, Any]:
766
1132
  q = str(query or "").lower().strip()
767
- memories = self.list_memories(user_email=user_email).get("memories", [])
1133
+ memories = self.list_memories(user_email=user_email, workspace_id=workspace_id).get("memories", [])
768
1134
  if q:
769
1135
  memories = [
770
1136
  item for item in memories
@@ -789,9 +1155,10 @@ class WorkspaceOSStore:
789
1155
  # Agent and workflow graph
790
1156
  # ------------------------------------------------------------------
791
1157
 
792
- def list_agents(self) -> Dict[str, Any]:
1158
+ def list_agents(self, workspace_id: Optional[str] = None) -> Dict[str, Any]:
793
1159
  state = self.load_state()
794
- return {"agents": _listify(state.get("agents")), "runs": list(reversed(_listify(state.get("agent_runs"))[-100:]))}
1160
+ runs = self._scoped(_listify(state.get("agent_runs")), workspace_id)
1161
+ return {"agents": _listify(state.get("agents")), "runs": list(reversed(runs[-100:]))}
795
1162
 
796
1163
  def record_agent_run(
797
1164
  self,
@@ -804,6 +1171,7 @@ class WorkspaceOSStore:
804
1171
  timeline: Optional[List[Dict[str, Any]]] = None,
805
1172
  relationships: Optional[List[str]] = None,
806
1173
  graph: Any = None,
1174
+ workspace_id: Optional[str] = None,
807
1175
  ) -> Dict[str, Any]:
808
1176
  state = self.load_state()
809
1177
  run = {
@@ -813,6 +1181,7 @@ class WorkspaceOSStore:
813
1181
  "input": input_text,
814
1182
  "output_preview": output_text[:1000],
815
1183
  "user_email": user_email,
1184
+ "workspace_id": self._resolve_scope(workspace_id, state),
816
1185
  "relationships": relationships or [],
817
1186
  "timeline": timeline or [],
818
1187
  "created_at": _now(),
@@ -843,6 +1212,7 @@ class WorkspaceOSStore:
843
1212
  user_email: Optional[str],
844
1213
  metadata: Optional[Dict[str, Any]] = None,
845
1214
  graph: Any = None,
1215
+ workspace_id: Optional[str] = None,
846
1216
  ) -> Dict[str, Any]:
847
1217
  state = self.load_state()
848
1218
  workflow = {
@@ -850,6 +1220,7 @@ class WorkspaceOSStore:
850
1220
  "name": name or "Untitled workflow",
851
1221
  "steps": steps,
852
1222
  "user_email": user_email,
1223
+ "workspace_id": self._resolve_scope(workspace_id, state),
853
1224
  "metadata": metadata or {},
854
1225
  "events": [{"type": "created", "timestamp": _now()}],
855
1226
  "created_at": _now(),
@@ -873,8 +1244,8 @@ class WorkspaceOSStore:
873
1244
  self.record_timeline_event("workflow", "workflow_created", {"workflow_id": workflow["id"], "name": workflow["name"]})
874
1245
  return workflow
875
1246
 
876
- def list_workflows(self, query: str = "") -> Dict[str, Any]:
877
- workflows = list(reversed(_listify(self.load_state().get("workflows"))))
1247
+ def list_workflows(self, query: str = "", workspace_id: Optional[str] = None) -> Dict[str, Any]:
1248
+ workflows = list(reversed(self._scoped(_listify(self.load_state().get("workflows")), workspace_id)))
878
1249
  q = str(query or "").lower().strip()
879
1250
  if q:
880
1251
  workflows = [