ltcai 10.6.3 → 10.8.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.
Files changed (95) hide show
  1. package/README.md +56 -59
  2. package/docs/CHANGELOG.md +68 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/LAYOUT_REBUILD_SPEC.md +810 -0
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/OPERATIONS.md +29 -1
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/graph/retrieval_vector.py +69 -33
  13. package/lattice_brain/runtime/multi_agent.py +96 -15
  14. package/latticeai/__init__.py +1 -1
  15. package/latticeai/api/admin.py +81 -6
  16. package/latticeai/api/auth.py +37 -38
  17. package/latticeai/api/automation_intelligence.py +112 -0
  18. package/latticeai/api/browser.py +7 -6
  19. package/latticeai/api/knowledge_graph.py +133 -0
  20. package/latticeai/api/static_routes.py +35 -1
  21. package/latticeai/core/file_generation.py +141 -8
  22. package/latticeai/core/legacy_compatibility.py +1 -1
  23. package/latticeai/core/marketplace.py +1 -1
  24. package/latticeai/core/messages.py +230 -0
  25. package/latticeai/core/workspace_computer_memory.py +84 -0
  26. package/latticeai/core/workspace_indexing.py +102 -0
  27. package/latticeai/core/workspace_onboarding.py +104 -0
  28. package/latticeai/core/workspace_os.py +27 -207
  29. package/latticeai/core/workspace_os_constants.py +1 -1
  30. package/latticeai/core/workspace_relationships.py +99 -0
  31. package/latticeai/core/workspace_runs.py +111 -0
  32. package/latticeai/services/architecture_readiness.py +1 -1
  33. package/latticeai/services/product_readiness.py +1 -1
  34. package/package.json +5 -3
  35. package/scripts/capture_release_evidence.mjs +335 -1
  36. package/scripts/check_current_release_docs.mjs +1 -1
  37. package/scripts/check_release_evidence_bound.mjs +116 -0
  38. package/scripts/check_screenshot_pixel_delta.py +517 -0
  39. package/scripts/measure_brain_home_fill.mjs +141 -0
  40. package/scripts/release_screen_claims.json +37 -0
  41. package/src-tauri/Cargo.lock +1 -1
  42. package/src-tauri/Cargo.toml +1 -1
  43. package/src-tauri/tauri.conf.json +1 -1
  44. package/static/app/asset-manifest.json +37 -39
  45. package/static/app/assets/{Act-05uZSgyw.js → Act-CbQNsFgc.js} +1 -1
  46. package/static/app/assets/AdminConsole-Bs8SR8fE.js +1 -0
  47. package/static/app/assets/{Brain-jjMYTdnU.js → Brain-BZIPsZlH.js} +2 -2
  48. package/static/app/assets/BrainHome-BaH5MDce.js +2 -0
  49. package/static/app/assets/{BrainSignals-BYxDqBH3.js → BrainSignals-aDt-nSsW.js} +1 -1
  50. package/static/app/assets/Capture-DDv940Bv.js +1 -0
  51. package/static/app/assets/{CommandPalette-Cj7z-qUE.js → CommandPalette-Dq_MmPBl.js} +1 -1
  52. package/static/app/assets/Library-BkShbqmN.js +1 -0
  53. package/static/app/assets/{LivingBrain-CTokkZn1.js → LivingBrain-CXb8094H.js} +1 -1
  54. package/static/app/assets/ProductFlow-BaiqFWLO.js +1 -0
  55. package/static/app/assets/{ReviewCard-uAII_l9v.js → ReviewCard-Be7E7X18.js} +2 -2
  56. package/static/app/assets/System-DCstbTxC.js +1 -0
  57. package/static/app/assets/arrow-left-DC9Oquhs.js +1 -0
  58. package/static/app/assets/{bot-CkrHFdUl.js → bot-BR0eoeXY.js} +1 -1
  59. package/static/app/assets/brain-BmWa1wea.js +1 -0
  60. package/static/app/assets/{button-Ic6omIbs.js → button-U1nU7xX4.js} +1 -1
  61. package/static/app/assets/{circle-pause-DBaaUR08.js → circle-pause-DG5KHjDL.js} +1 -1
  62. package/static/app/assets/{circle-play-Dz0OLYN9.js → circle-play-Chhcuq90.js} +1 -1
  63. package/static/app/assets/{cpu-N8uE9ono.js → cpu-pkOSlT9h.js} +1 -1
  64. package/static/app/assets/{download-CCjhOjms.js → download-CSRwz923.js} +1 -1
  65. package/static/app/assets/{folder-open-Bld9TtHp.js → folder-open-DiMJ7W_7.js} +1 -1
  66. package/static/app/assets/{hard-drive-BMIqDSyY.js → hard-drive-iyLPAPdp.js} +1 -1
  67. package/static/app/assets/index-BZRvGOM2.css +2 -0
  68. package/static/app/assets/{index-yNtiRyw3.js → index-_ldE9TAX.js} +4 -4
  69. package/static/app/assets/{input-DW1tXtc0.js → input-e_r2hFAY.js} +1 -1
  70. package/static/app/assets/{permissionCopy-Djpwn54H.js → permissionCopy-9uOHktz3.js} +1 -1
  71. package/static/app/assets/{primitives-B6WtkLgB.js → primitives-CnEAOmg2.js} +1 -1
  72. package/static/app/assets/search-veep4n6f.js +1 -0
  73. package/static/app/assets/{share-2-DR5L1qRc.js → share-2-DLDJJKmI.js} +1 -1
  74. package/static/app/assets/{shield-alert-CVE5eXsl.js → shield-alert-BTtE-2EZ.js} +1 -1
  75. package/static/app/assets/{textarea-DdaVFgvk.js → textarea-Bbzkcgqe.js} +1 -1
  76. package/static/app/assets/{useFocusTrap-B5TyGaiB.js → useFocusTrap-DJzQd-Za.js} +1 -1
  77. package/static/app/assets/{useQuery-CiRdTQlD.js → useQuery-6R_tqHRe.js} +1 -1
  78. package/static/app/assets/utils-XDsjcrhS.js +4 -0
  79. package/static/app/assets/workspace-CwrG8LHq.js +1 -0
  80. package/static/app/index.html +4 -4
  81. package/static/sw.js +1 -1
  82. package/static/app/assets/AdminConsole-C8yz1Cs_.js +0 -1
  83. package/static/app/assets/BrainHome-D3t45_D4.js +0 -2
  84. package/static/app/assets/Capture-DKgu7fB0.js +0 -1
  85. package/static/app/assets/Library-CO76FWeO.js +0 -1
  86. package/static/app/assets/ProductFlow-Bmted2ys.js +0 -1
  87. package/static/app/assets/System-PUMzDmof.js +0 -1
  88. package/static/app/assets/activity-C_7182GG.js +0 -1
  89. package/static/app/assets/arrow-left-BQCpWR37.js +0 -1
  90. package/static/app/assets/brain-CdPX7svt.js +0 -1
  91. package/static/app/assets/index-DYUs0cWy.css +0 -2
  92. package/static/app/assets/search-CkHYuKpY.js +0 -1
  93. package/static/app/assets/users-pZqCRxdD.js +0 -1
  94. package/static/app/assets/utils-C3rXoa-U.js +0 -4
  95. package/static/app/assets/workspace-DfxKEttN.js +0 -1
@@ -0,0 +1,104 @@
1
+ """First-run onboarding progress.
2
+
3
+ Extracted from ``WorkspaceOSStore``. Owns the ``onboarding`` branch of the
4
+ state document: which named step the person is on, what each step recorded, and
5
+ whether the whole run is finished.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict, Optional
11
+
12
+ from .timeutil import now_iso as _now
13
+ from .workspace_os_constants import ONBOARDING_STEPS
14
+
15
+ __all__ = ["WorkspaceOnboarding", "ONBOARDING_STATUSES"]
16
+
17
+ #: The statuses a single step may hold. Anything else is a caller bug, not a
18
+ #: value to store — an unknown status would silently stall the flow, because
19
+ #: `current_step` only advances on `complete`/`skipped`.
20
+ ONBOARDING_STATUSES = frozenset({"pending", "running", "complete", "failed", "skipped"})
21
+
22
+
23
+ class WorkspaceOnboarding:
24
+ """Tracks the named first-run steps and where the person stopped."""
25
+
26
+ def __init__(self, store: Any) -> None:
27
+ self.store = store
28
+
29
+ def status(
30
+ self,
31
+ users: Optional[Dict[str, Any]] = None,
32
+ graph_stats: Optional[Dict[str, Any]] = None,
33
+ ) -> Dict[str, Any]:
34
+ state = self.store.load_state()
35
+ users = users or {}
36
+ admins = [
37
+ email for email, user in users.items()
38
+ if isinstance(user, dict) and user.get("role") == "admin"
39
+ ]
40
+ onboarding = state.get("onboarding") or {}
41
+ steps = onboarding.get("steps") or {}
42
+ return {
43
+ **onboarding,
44
+ "steps": [steps.get(step, {"id": step, "status": "pending"}) for step in ONBOARDING_STEPS],
45
+ "has_account": bool(users),
46
+ "has_admin": bool(admins) or bool(users),
47
+ "graph_ready": bool(graph_stats and not graph_stats.get("disabled")),
48
+ "required_steps": list(ONBOARDING_STEPS),
49
+ }
50
+
51
+ def update_step(
52
+ self,
53
+ step: str,
54
+ *,
55
+ status: str = "complete",
56
+ data: Optional[Dict[str, Any]] = None,
57
+ error: str = "",
58
+ user_email: Optional[str] = None,
59
+ ) -> Dict[str, Any]:
60
+ if step not in ONBOARDING_STEPS:
61
+ raise ValueError(f"unknown onboarding step: {step}")
62
+ if status not in ONBOARDING_STATUSES:
63
+ raise ValueError(f"unknown onboarding status: {status}")
64
+ state = self.store.load_state()
65
+ onboarding = state.setdefault("onboarding", {})
66
+ steps = onboarding.setdefault("steps", {})
67
+ record = steps.setdefault(step, {"id": step})
68
+ record.update({
69
+ "id": step,
70
+ "status": status,
71
+ "data": data or record.get("data") or {},
72
+ "error": error,
73
+ "updated_at": _now(),
74
+ "user_email": user_email,
75
+ })
76
+ if status in {"complete", "skipped"}:
77
+ index = ONBOARDING_STEPS.index(step)
78
+ if step == "complete":
79
+ onboarding["completed"] = True
80
+ onboarding["completed_at"] = _now()
81
+ onboarding["current_step"] = "complete"
82
+ elif index + 1 < len(ONBOARDING_STEPS):
83
+ onboarding["current_step"] = ONBOARDING_STEPS[index + 1]
84
+ elif status == "failed":
85
+ onboarding["current_step"] = step
86
+ self.store.save_state(state)
87
+ self.store.record_timeline_event(
88
+ "workspace", "onboarding_step", {"step": step, "status": status}
89
+ )
90
+ return self.status()
91
+
92
+ def complete(
93
+ self,
94
+ data: Optional[Dict[str, Any]] = None,
95
+ user_email: Optional[str] = None,
96
+ ) -> Dict[str, Any]:
97
+ for step in ONBOARDING_STEPS:
98
+ self.update_step(
99
+ step,
100
+ status="complete",
101
+ data=data if step == "complete" else None,
102
+ user_email=user_email,
103
+ )
104
+ return self.status()
@@ -11,7 +11,6 @@ from __future__ import annotations
11
11
  import json
12
12
  import shutil
13
13
  import sqlite3
14
- from collections import deque
15
14
  from contextlib import contextmanager
16
15
  from pathlib import Path
17
16
  from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional
@@ -19,8 +18,11 @@ from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional
19
18
  from latticeai.core.quiet import quiet
20
19
 
21
20
  from .timeutil import now_iso as _now
21
+ from .workspace_computer_memory import WorkspaceComputerMemory
22
22
  from .workspace_graph_trace import WorkspaceGraphTrace
23
+ from .workspace_indexing import WorkspaceIndexing
23
24
  from .workspace_memory import WorkspaceMemory
25
+ from .workspace_onboarding import WorkspaceOnboarding
24
26
  from .workspace_os_constants import (
25
27
  DEFAULT_WORKSPACE_ID,
26
28
  EXECUTION_EVENT_TYPES,
@@ -49,6 +51,8 @@ from .workspace_permissions import ( # type: ignore
49
51
  _member_role,
50
52
  )
51
53
  from .workspace_plugins import WorkspacePluginManager
54
+ from .workspace_relationships import WorkspaceRelationships
55
+ from .workspace_relationships import shortest_path as _shortest_path
52
56
  from .workspace_review_items import WorkspaceReviewItems
53
57
  from .workspace_runs import WorkspaceRuns
54
58
  from .workspace_skills import WorkspaceSkills
@@ -108,6 +112,10 @@ class WorkspaceOSStore:
108
112
  self.runs = WorkspaceRuns(self)
109
113
  self.review_items = WorkspaceReviewItems(self)
110
114
  self.skills = WorkspaceSkills(self)
115
+ self.indexing = WorkspaceIndexing(self)
116
+ self.relationships = WorkspaceRelationships(self)
117
+ self.computer_memory = WorkspaceComputerMemory(self)
118
+ self.onboarding = WorkspaceOnboarding(self)
111
119
 
112
120
  @contextmanager
113
121
  def _connect_state_db(self) -> Iterator[sqlite3.Connection]:
@@ -552,22 +560,7 @@ class WorkspaceOSStore:
552
560
  # ------------------------------------------------------------------
553
561
 
554
562
  def onboarding_status(self, users: Optional[Dict[str, Any]] = None, graph_stats: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
555
- state = self.load_state()
556
- users = users or {}
557
- admins = [
558
- email for email, user in users.items()
559
- if isinstance(user, dict) and user.get("role") == "admin"
560
- ]
561
- onboarding = state.get("onboarding") or {}
562
- steps = onboarding.get("steps") or {}
563
- return {
564
- **onboarding,
565
- "steps": [steps.get(step, {"id": step, "status": "pending"}) for step in ONBOARDING_STEPS],
566
- "has_account": bool(users),
567
- "has_admin": bool(admins) or bool(users),
568
- "graph_ready": bool(graph_stats and not graph_stats.get("disabled")),
569
- "required_steps": list(ONBOARDING_STEPS),
570
- }
563
+ return self.onboarding.status(users=users, graph_stats=graph_stats)
571
564
 
572
565
  def update_onboarding_step(
573
566
  self,
@@ -578,40 +571,12 @@ class WorkspaceOSStore:
578
571
  error: str = "",
579
572
  user_email: Optional[str] = None,
580
573
  ) -> Dict[str, Any]:
581
- if step not in ONBOARDING_STEPS:
582
- raise ValueError(f"unknown onboarding step: {step}")
583
- if status not in {"pending", "running", "complete", "failed", "skipped"}:
584
- raise ValueError(f"unknown onboarding status: {status}")
585
- state = self.load_state()
586
- onboarding = state.setdefault("onboarding", {})
587
- steps = onboarding.setdefault("steps", {})
588
- record = steps.setdefault(step, {"id": step})
589
- record.update({
590
- "id": step,
591
- "status": status,
592
- "data": data or record.get("data") or {},
593
- "error": error,
594
- "updated_at": _now(),
595
- "user_email": user_email,
596
- })
597
- if status in {"complete", "skipped"}:
598
- index = ONBOARDING_STEPS.index(step)
599
- if step == "complete":
600
- onboarding["completed"] = True
601
- onboarding["completed_at"] = _now()
602
- onboarding["current_step"] = "complete"
603
- elif index + 1 < len(ONBOARDING_STEPS):
604
- onboarding["current_step"] = ONBOARDING_STEPS[index + 1]
605
- elif status == "failed":
606
- onboarding["current_step"] = step
607
- self.save_state(state)
608
- self.record_timeline_event("workspace", "onboarding_step", {"step": step, "status": status})
609
- return self.onboarding_status()
574
+ return self.onboarding.update_step(
575
+ step, status=status, data=data, error=error, user_email=user_email
576
+ )
610
577
 
611
578
  def complete_onboarding(self, data: Optional[Dict[str, Any]] = None, user_email: Optional[str] = None) -> Dict[str, Any]:
612
- for step in ONBOARDING_STEPS:
613
- self.update_onboarding_step(step, status="complete", data=data if step == "complete" else None, user_email=user_email)
614
- return self.onboarding_status()
579
+ return self.onboarding.complete(data=data, user_email=user_email)
615
580
 
616
581
  # ------------------------------------------------------------------
617
582
  # Graph answer traces
@@ -645,76 +610,16 @@ class WorkspaceOSStore:
645
610
  # ------------------------------------------------------------------
646
611
 
647
612
  def build_indexing_dashboard(self, graph: Any, watcher_status: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
648
- if graph is None:
649
- return {
650
- "sources": [],
651
- "watcher": watcher_status or {"available": False, "active": {}},
652
- "totals": {"success": 0, "failed": 0, "nodes": 0, "edges": 0},
653
- }
654
- stats = graph.stats()
655
- sources = graph.local_sources().get("sources", [])
656
- watcher_status = watcher_status or {"available": False, "active": {}}
657
- active = watcher_status.get("active", {})
658
- dashboard_sources = []
659
- total_success = 0
660
- total_failed = 0
661
- for source in sources:
662
- file_status = source.get("file_status") or {}
663
- success = int(file_status.get("indexed") or 0)
664
- failed = sum(int(file_status.get(key) or 0) for key in ("failed", "inaccessible", "skipped_empty_text"))
665
- total_success += success
666
- total_failed += failed
667
- watch = active.get(source.get("id")) or {}
668
- dashboard_sources.append({
669
- "id": source.get("id"),
670
- "label": source.get("label"),
671
- "root_path": source.get("root_path"),
672
- "status": source.get("status"),
673
- "watch_enabled": bool(source.get("watch_enabled")),
674
- "watch_active": source.get("id") in active,
675
- "watch_status": watch,
676
- "success_count": success,
677
- "failure_count": failed,
678
- "last_run_at": source.get("last_scanned_at") or source.get("updated_at"),
679
- "file_status": file_status,
680
- "include_ocr": bool(source.get("include_ocr")),
681
- })
682
- return {
683
- "sources": dashboard_sources,
684
- "watcher": watcher_status,
685
- "totals": {
686
- "success": total_success,
687
- "failed": total_failed,
688
- "nodes": sum(int(v or 0) for v in (stats.get("nodes") or {}).values()),
689
- "edges": sum(int(v or 0) for v in (stats.get("edges") or {}).values()),
690
- "local_sources": stats.get("local_sources", len(sources)),
691
- },
692
- "graph_stats": stats,
693
- }
613
+ return self.indexing.build_dashboard(graph, watcher_status)
694
614
 
695
615
  def pause_indexing(self, graph: Any, source_id: str, watcher: Any = None) -> Dict[str, Any]:
696
- result = graph.set_local_source_watch(source_id, False)
697
- watch = watcher.stop_source(source_id) if watcher else {"stopped": False, "source_id": source_id}
698
- self.record_timeline_event("graph", "indexing_paused", {"source_id": source_id})
699
- return {"status": "ok", "source": result, "watch": watch}
616
+ return self.indexing.pause(graph, source_id, watcher)
700
617
 
701
618
  def resume_indexing(self, graph: Any, source_id: str, watcher: Any = None) -> Dict[str, Any]:
702
- result = graph.set_local_source_watch(source_id, True)
703
- watch = {"watching": False, "source_id": source_id}
704
- source = next((item for item in graph.local_sources().get("sources", []) if item.get("id") == source_id), None)
705
- if watcher and source:
706
- watch = watcher.start_source(source)
707
- self.record_timeline_event("graph", "indexing_resumed", {"source_id": source_id})
708
- return {"status": "ok", "source": result, "watch": watch}
619
+ return self.indexing.resume(graph, source_id, watcher)
709
620
 
710
621
  def remove_index_source(self, graph: Any, source_id: str, watcher: Any = None) -> Dict[str, Any]:
711
- if watcher:
712
- watcher.stop_source(source_id)
713
- if not hasattr(graph, "remove_local_source"):
714
- raise ValueError("graph store does not support removing local sources")
715
- result = graph.remove_local_source(source_id)
716
- self.record_timeline_event("graph", "indexing_removed", {"source_id": source_id})
717
- return {"status": "ok", **result}
622
+ return self.indexing.remove_source(graph, source_id, watcher)
718
623
 
719
624
  # ------------------------------------------------------------------
720
625
  # Snapshots, Time Machine, and diffs
@@ -883,6 +788,9 @@ class WorkspaceOSStore:
883
788
  def list_workflow_runs(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
884
789
  return self.runs.list_workflow_runs(*args, **kwargs)
885
790
 
791
+ def list_combined_runs(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
792
+ return self.runs.list_combined_runs(*args, **kwargs)
793
+
886
794
  def mark_workflow_run_resolved(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
887
795
  return self.runs.mark_workflow_run_resolved(*args, **kwargs)
888
796
 
@@ -936,63 +844,11 @@ class WorkspaceOSStore:
936
844
  # ------------------------------------------------------------------
937
845
 
938
846
  def relationship_explorer(self, graph: Any, node_id: str, target_id: Optional[str] = None, limit: int = 500) -> Dict[str, Any]:
939
- if graph is None:
940
- return {"node_id": node_id, "inbound": [], "outbound": [], "related_entities": [], "shortest_path": []}
941
- data = graph.graph(limit=limit)
942
- nodes = {node.get("id"): node for node in data.get("nodes") or [] if node.get("id")}
943
- edges = data.get("edges") or []
944
- inbound = [edge for edge in edges if edge.get("to") == node_id]
945
- outbound = [edge for edge in edges if edge.get("from") == node_id]
946
- if node_id not in nodes:
947
- try:
948
- neighbors = graph.neighbors(node_id)
949
- for node in neighbors.get("neighbors") or []:
950
- nodes[node.get("id")] = node
951
- edges.extend(neighbors.get("edges") or [])
952
- inbound = [edge for edge in edges if edge.get("to") == node_id]
953
- outbound = [edge for edge in edges if edge.get("from") == node_id]
954
- except Exception:
955
- quiet()
956
-
957
- related_ids = []
958
- for edge in inbound + outbound:
959
- other = edge.get("from") if edge.get("to") == node_id else edge.get("to")
960
- if other:
961
- related_ids.append(other)
962
- related = [nodes.get(rid, {"id": rid}) for rid in dict.fromkeys(related_ids)]
963
- shortest_path = self._shortest_path(edges, node_id, target_id) if target_id else []
964
- return {
965
- "node_id": node_id,
966
- "node": nodes.get(node_id, {"id": node_id}),
967
- "inbound": inbound,
968
- "outbound": outbound,
969
- "related_entities": related,
970
- "shortest_path": shortest_path,
971
- }
847
+ return self.relationships.explore(graph, node_id, target_id=target_id, limit=limit)
972
848
 
973
849
  @staticmethod
974
850
  def _shortest_path(edges: List[Dict[str, Any]], start: str, target: Optional[str]) -> List[str]:
975
- if not start or not target:
976
- return []
977
- adjacency: Dict[str, List[str]] = {}
978
- for edge in edges:
979
- src = edge.get("from")
980
- dst = edge.get("to")
981
- if src and dst:
982
- adjacency.setdefault(src, []).append(dst)
983
- adjacency.setdefault(dst, []).append(src)
984
- queue: deque[List[str]] = deque([[start]])
985
- seen = {start}
986
- while queue:
987
- path = queue.popleft()
988
- node = path[-1]
989
- if node == target:
990
- return path
991
- for neighbor in adjacency.get(node, []):
992
- if neighbor not in seen:
993
- seen.add(neighbor)
994
- queue.append(path + [neighbor])
995
- return []
851
+ return _shortest_path(edges, start, target)
996
852
 
997
853
  # ------------------------------------------------------------------
998
854
  # Local Computer Memory
@@ -1006,48 +862,12 @@ class WorkspaceOSStore:
1006
862
  consent: Optional[Dict[str, Any]] = None,
1007
863
  scopes: Optional[List[str]] = None,
1008
864
  ) -> Dict[str, Any]:
1009
- consent = consent or {}
1010
- if enabled and not consent.get("approved"):
1011
- raise PermissionError("Local Computer Memory requires explicit approval.")
1012
- state = self.load_state()
1013
- config = state.setdefault("computer_memory", {})
1014
- config.update({
1015
- "enabled": bool(enabled),
1016
- "approved": bool(enabled),
1017
- "approved_at": _now() if enabled else config.get("approved_at"),
1018
- "approved_by": approved_by if enabled else config.get("approved_by"),
1019
- "scopes": scopes or config.get("scopes") or ["Downloads", "Documents", "Repositories"],
1020
- "consent": consent,
1021
- })
1022
- state.setdefault("feature_flags", {})["local_computer_memory"] = bool(enabled)
1023
- self.save_state(state)
1024
- self.record_timeline_event("memory", "computer_memory_configured", {"enabled": bool(enabled), "approved_by": approved_by})
1025
- return config
865
+ return self.computer_memory.configure(
866
+ enabled=enabled, approved_by=approved_by, consent=consent, scopes=scopes
867
+ )
1026
868
 
1027
869
  def record_computer_activity(self, activity: Dict[str, Any], graph: Any = None) -> Dict[str, Any]:
1028
- state = self.load_state()
1029
- config = state.setdefault("computer_memory", {})
1030
- if not config.get("enabled"):
1031
- return {"status": "ignored", "reason": "local computer memory is disabled"}
1032
- record = {
1033
- "id": f"activity-{_json_hash([activity, _now()])[:16]}",
1034
- "timestamp": _now(),
1035
- **activity,
1036
- }
1037
- config.setdefault("activities", []).append(record)
1038
- if graph is not None:
1039
- try:
1040
- graph.ingest_event(
1041
- "ComputerActivity",
1042
- str(activity.get("summary") or activity.get("path") or "Computer activity")[:120],
1043
- source="workspace_os",
1044
- metadata=record,
1045
- )
1046
- except Exception as exc:
1047
- record["graph_error"] = str(exc)
1048
- self.save_state(state)
1049
- self.record_timeline_event("memory", "computer_activity", {"activity_id": record["id"]})
1050
- return {"status": "ok", "activity": record}
870
+ return self.computer_memory.record_activity(activity, graph)
1051
871
 
1052
872
  # ------------------------------------------------------------------
1053
873
  # Skills
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  from typing import Dict
12
12
 
13
- WORKSPACE_OS_VERSION = "10.6.3"
13
+ WORKSPACE_OS_VERSION = "10.8.0"
14
14
 
15
15
  # Workspace types separate single-user Personal workspaces from shared
16
16
  # Organization workspaces. Both keep the same local-first JSON store; the type
@@ -0,0 +1,99 @@
1
+ """Relationship explorer: what a node connects to, and the way between two.
2
+
3
+ Extracted from ``WorkspaceOSStore``. Pure graph reading — it touches no state
4
+ file and records no events, so it holds no reference to the store at all.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections import deque
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ from latticeai.core.quiet import quiet
13
+
14
+ __all__ = ["WorkspaceRelationships", "shortest_path"]
15
+
16
+
17
+ def shortest_path(edges: List[Dict[str, Any]], start: str, target: Optional[str]) -> List[str]:
18
+ """Fewest-hops path between two nodes, treating every edge as undirected.
19
+
20
+ Breadth-first, so the first arrival at ``target`` is a shortest one.
21
+ Returns ``[]`` when either end is missing or nothing connects them.
22
+ """
23
+ if not start or not target:
24
+ return []
25
+ adjacency: Dict[str, List[str]] = {}
26
+ for edge in edges:
27
+ src = edge.get("from")
28
+ dst = edge.get("to")
29
+ if src and dst:
30
+ adjacency.setdefault(src, []).append(dst)
31
+ adjacency.setdefault(dst, []).append(src)
32
+ queue: deque[List[str]] = deque([[start]])
33
+ seen = {start}
34
+ while queue:
35
+ path = queue.popleft()
36
+ node = path[-1]
37
+ if node == target:
38
+ return path
39
+ for neighbor in adjacency.get(node, []):
40
+ if neighbor not in seen:
41
+ seen.add(neighbor)
42
+ queue.append(path + [neighbor])
43
+ return []
44
+
45
+
46
+ class WorkspaceRelationships:
47
+ """Answers "what is this connected to" for one node of the graph."""
48
+
49
+ def __init__(self, store: Any) -> None:
50
+ self.store = store
51
+
52
+ def explore(
53
+ self,
54
+ graph: Any,
55
+ node_id: str,
56
+ target_id: Optional[str] = None,
57
+ limit: int = 500,
58
+ ) -> Dict[str, Any]:
59
+ if graph is None:
60
+ return {
61
+ "node_id": node_id,
62
+ "inbound": [],
63
+ "outbound": [],
64
+ "related_entities": [],
65
+ "shortest_path": [],
66
+ }
67
+ data = graph.graph(limit=limit)
68
+ nodes = {node.get("id"): node for node in data.get("nodes") or [] if node.get("id")}
69
+ edges = data.get("edges") or []
70
+ inbound = [edge for edge in edges if edge.get("to") == node_id]
71
+ outbound = [edge for edge in edges if edge.get("from") == node_id]
72
+ # The capped `graph(limit=…)` window may not contain this node at all.
73
+ # Asking the store for its neighbours directly is the difference between
74
+ # "not connected" and "outside the page we happened to fetch".
75
+ if node_id not in nodes:
76
+ try:
77
+ neighbors = graph.neighbors(node_id)
78
+ for node in neighbors.get("neighbors") or []:
79
+ nodes[node.get("id")] = node
80
+ edges.extend(neighbors.get("edges") or [])
81
+ inbound = [edge for edge in edges if edge.get("to") == node_id]
82
+ outbound = [edge for edge in edges if edge.get("from") == node_id]
83
+ except Exception:
84
+ quiet()
85
+
86
+ related_ids = []
87
+ for edge in inbound + outbound:
88
+ other = edge.get("from") if edge.get("to") == node_id else edge.get("to")
89
+ if other:
90
+ related_ids.append(other)
91
+ related = [nodes.get(rid, {"id": rid}) for rid in dict.fromkeys(related_ids)]
92
+ return {
93
+ "node_id": node_id,
94
+ "node": nodes.get(node_id, {"id": node_id}),
95
+ "inbound": inbound,
96
+ "outbound": outbound,
97
+ "related_entities": related,
98
+ "shortest_path": shortest_path(edges, node_id, target_id) if target_id else [],
99
+ }
@@ -434,6 +434,117 @@ class WorkspaceRuns:
434
434
  runs = [run for run in runs if run.get("workflow_id") == workflow_id]
435
435
  return {"runs": list(reversed(runs[-max(1, min(limit, 300)):]))}
436
436
 
437
+ def list_combined_runs(
438
+ self,
439
+ *,
440
+ limit: int = 20,
441
+ workspace_id: Optional[str] = None,
442
+ ) -> Dict[str, Any]:
443
+ """Merge agent + workflow runs into one ``started_at``-descending timeline.
444
+
445
+ Response shape is the product contract for Act screen 09 and the
446
+ layout-rebuild activity feed: each row carries ``source``, a human
447
+ ``title``, stop/resume affordances, and normalized timestamps.
448
+ """
449
+ capped = max(1, min(int(limit or 20), 100))
450
+ state = self.load_state()
451
+ agent_runs = self._scoped(_listify(state.get("agent_runs")), workspace_id)
452
+ workflow_runs = self._scoped(_listify(state.get("workflow_runs")), workspace_id)
453
+ combined: List[Dict[str, Any]] = []
454
+ for run in agent_runs:
455
+ combined.append(self.activity_run_row(run, source="agent"))
456
+ for run in workflow_runs:
457
+ combined.append(self.activity_run_row(run, source="workflow"))
458
+ combined.sort(key=lambda row: str(row.get("started_at") or ""), reverse=True)
459
+ total = len(combined)
460
+ return {
461
+ "runs": combined[:capped],
462
+ "total": total,
463
+ "truncated": total > capped,
464
+ }
465
+
466
+ @staticmethod
467
+ def _first_string(*values: Any) -> str:
468
+ for value in values:
469
+ if isinstance(value, str) and value.strip():
470
+ return value.strip()
471
+ return ""
472
+
473
+ @classmethod
474
+ def activity_run_title(cls, run: Dict[str, Any]) -> str:
475
+ """Public title resolver shared by list_combined_runs and router fallbacks."""
476
+ direct = cls._first_string(
477
+ run.get("workflow_name"),
478
+ run.get("name"),
479
+ run.get("goal"),
480
+ run.get("title"),
481
+ run.get("query"),
482
+ )
483
+ if direct:
484
+ return direct
485
+ raw_input = run.get("input")
486
+ if isinstance(raw_input, str) and raw_input.strip():
487
+ return raw_input.strip()
488
+ if isinstance(raw_input, dict):
489
+ nested = cls._first_string(
490
+ raw_input.get("goal"),
491
+ raw_input.get("name"),
492
+ raw_input.get("title"),
493
+ raw_input.get("query"),
494
+ raw_input.get("text"),
495
+ raw_input.get("prompt"),
496
+ )
497
+ if nested:
498
+ return nested
499
+ return ""
500
+
501
+ @classmethod
502
+ def activity_run_row(cls, run: Dict[str, Any], *, source: str) -> Dict[str, Any]:
503
+ """Normalize an agent or workflow run into the Act activity-feed row shape.
504
+
505
+ Public so API routers (e.g. automation_intelligence fallback) can reuse
506
+ the same contract without reaching for a private method.
507
+
508
+ ``source`` is the product discriminator for the 레시피/목표 badge.
509
+ Legacy agent runs may lack ``agent_id`` (and both ids may be None) —
510
+ those rows must still leave this method with ``source="agent"`` so the
511
+ Act feed does not collapse every goal row into a recipe badge.
512
+ """
513
+ status = str(run.get("status") or "")
514
+ started_at = (
515
+ run.get("started_at")
516
+ or run.get("created_at")
517
+ or run.get("updated_at")
518
+ or ""
519
+ )
520
+ finished_at = (
521
+ run.get("finished_at")
522
+ or run.get("completed_at")
523
+ or run.get("resolved_at")
524
+ or run.get("interrupted_at")
525
+ )
526
+ run_id = run.get("id") or run.get("run_id")
527
+ normalized_source = str(source or "").strip().lower()
528
+ if normalized_source not in {"agent", "workflow"}:
529
+ # Infer only when the caller failed to pin a valid source; prefer
530
+ # workflow_id when present, otherwise agent (including bare legacy).
531
+ if run.get("workflow_id"):
532
+ normalized_source = "workflow"
533
+ else:
534
+ normalized_source = "agent"
535
+ return {
536
+ "id": run_id,
537
+ "source": normalized_source,
538
+ "title": cls.activity_run_title(run),
539
+ "status": status,
540
+ "started_at": started_at,
541
+ "finished_at": finished_at,
542
+ "can_stop": status in RUN_ACTIVE_STATUSES,
543
+ "can_resume": status in {"awaiting_approval", "paused", "waiting_approval"},
544
+ "workflow_id": run.get("workflow_id"),
545
+ "agent_id": run.get("agent_id"),
546
+ }
547
+
437
548
  def mark_workflow_run_resolved(
438
549
  self, run_id: str, *, resumed_run_id: str, approved: bool,
439
550
  workspace_id: Optional[str] = None,
@@ -16,7 +16,7 @@ from typing import Any, Dict, List
16
16
 
17
17
  from latticeai.core.legacy_compatibility import legacy_shim_report
18
18
 
19
- ARCHITECTURE_VERSION_TARGET = "10.6.3"
19
+ ARCHITECTURE_VERSION_TARGET = "10.8.0"
20
20
 
21
21
  PREFERRED_REFACTORING_ORDER = [
22
22
  "agent-runtime",
@@ -18,7 +18,7 @@ from typing import Any, Dict, List
18
18
 
19
19
  from latticeai.services.architecture_readiness import architecture_readiness
20
20
 
21
- PRODUCT_VERSION_TARGET = "10.6.3"
21
+ PRODUCT_VERSION_TARGET = "10.8.0"
22
22
 
23
23
 
24
24
  @dataclass(frozen=True)