ltcai 10.7.0 → 10.9.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 (97) hide show
  1. package/README.md +55 -40
  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/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/TRUST_MODEL.md +1 -1
  8. package/docs/WHY_LATTICE.md +1 -1
  9. package/docs/kg-schema.md +1 -1
  10. package/lattice_brain/__init__.py +1 -1
  11. package/lattice_brain/graph/retrieval_vector.py +69 -33
  12. package/lattice_brain/runtime/multi_agent.py +96 -15
  13. package/latticeai/__init__.py +1 -1
  14. package/latticeai/api/admin.py +9 -6
  15. package/latticeai/api/auth.py +37 -38
  16. package/latticeai/api/browser.py +7 -6
  17. package/latticeai/api/chat.py +9 -6
  18. package/latticeai/api/chat_agent_http.py +2 -1
  19. package/latticeai/api/chat_history.py +4 -2
  20. package/latticeai/api/chat_intents.py +14 -14
  21. package/latticeai/api/knowledge_graph.py +5 -4
  22. package/latticeai/api/local_files.py +25 -15
  23. package/latticeai/api/mcp.py +15 -11
  24. package/latticeai/api/memory.py +2 -1
  25. package/latticeai/api/models.py +39 -24
  26. package/latticeai/api/network_boundary.py +9 -4
  27. package/latticeai/api/portability.py +27 -19
  28. package/latticeai/api/project_sessions.py +7 -5
  29. package/latticeai/api/review_queue.py +12 -7
  30. package/latticeai/api/setup.py +10 -11
  31. package/latticeai/api/static_routes.py +62 -46
  32. package/latticeai/api/tools.py +7 -6
  33. package/latticeai/core/file_generation.py +141 -8
  34. package/latticeai/core/legacy_compatibility.py +1 -1
  35. package/latticeai/core/marketplace.py +1 -1
  36. package/latticeai/core/mcp_registry.py +20 -4
  37. package/latticeai/core/messages.py +452 -0
  38. package/latticeai/core/workspace_computer_memory.py +84 -0
  39. package/latticeai/core/workspace_indexing.py +102 -0
  40. package/latticeai/core/workspace_onboarding.py +104 -0
  41. package/latticeai/core/workspace_os.py +24 -207
  42. package/latticeai/core/workspace_os_constants.py +1 -1
  43. package/latticeai/core/workspace_relationships.py +99 -0
  44. package/latticeai/integrations/telegram_bot.py +20 -18
  45. package/latticeai/services/architecture_readiness.py +1 -1
  46. package/latticeai/services/model_loading.py +63 -27
  47. package/latticeai/services/product_readiness.py +1 -1
  48. package/package.json +3 -2
  49. package/scripts/check_current_release_docs.mjs +1 -1
  50. package/scripts/check_screenshot_pixel_delta.py +92 -10
  51. package/scripts/check_server_i18n.mjs +96 -0
  52. package/scripts/release_screen_claims.json +46 -0
  53. package/src-tauri/Cargo.lock +1 -1
  54. package/src-tauri/Cargo.toml +1 -1
  55. package/src-tauri/tauri.conf.json +1 -1
  56. package/static/app/asset-manifest.json +37 -37
  57. package/static/app/assets/{Act-DIbkoJqs.js → Act-CS9IeqUX.js} +1 -1
  58. package/static/app/assets/{AdminConsole-Cbi6Jre6.js → AdminConsole-3UkIEWGA.js} +1 -1
  59. package/static/app/assets/{Brain-BPfuXZ71.js → Brain-B22EmNqS.js} +1 -1
  60. package/static/app/assets/{BrainHome-87XaAS4V.js → BrainHome-CMDqgJF4.js} +2 -2
  61. package/static/app/assets/{BrainSignals-ExpcdiUi.js → BrainSignals-BeE8RJo3.js} +1 -1
  62. package/static/app/assets/{Capture-Bxe4WiVX.js → Capture-ZX9bQh68.js} +1 -1
  63. package/static/app/assets/{CommandPalette-B1PV5Yd4.js → CommandPalette-86m4FCcN.js} +1 -1
  64. package/static/app/assets/{Library-BRlJJmwZ.js → Library-Bhz5LUca.js} +1 -1
  65. package/static/app/assets/{LivingBrain-D61qG3WY.js → LivingBrain-BSa0wpFG.js} +1 -1
  66. package/static/app/assets/ProductFlow-IP4Q-5aQ.js +1 -0
  67. package/static/app/assets/{ReviewCard-BoiWv0Y1.js → ReviewCard-BepjSDpN.js} +1 -1
  68. package/static/app/assets/{System-Ci0HEtTJ.js → System-Bwx1h_jT.js} +1 -1
  69. package/static/app/assets/arrow-left-ig7AZU8B.js +1 -0
  70. package/static/app/assets/{bot-BaPt4PPn.js → bot-DQj0-LkM.js} +1 -1
  71. package/static/app/assets/{brain-BSkWaj3i.js → brain-D8OEwmVj.js} +1 -1
  72. package/static/app/assets/{button-CKwtO1O0.js → button-CmTknyAP.js} +1 -1
  73. package/static/app/assets/{circle-pause-CiUc5RRk.js → circle-pause-yTCWRziJ.js} +1 -1
  74. package/static/app/assets/{circle-play-BaTDxzR9.js → circle-play-Ccrva84R.js} +1 -1
  75. package/static/app/assets/{cpu-Dm1gHuP1.js → cpu-CbJqWTlS.js} +1 -1
  76. package/static/app/assets/{download-C32oGU-C.js → download-CkSzbzU-.js} +1 -1
  77. package/static/app/assets/{folder-open-Ch2jLVdD.js → folder-open-CKyjQ4PU.js} +1 -1
  78. package/static/app/assets/{hard-drive-BBjA5st8.js → hard-drive-DAzk9um0.js} +1 -1
  79. package/static/app/assets/index-BfD-jhA9.css +2 -0
  80. package/static/app/assets/{index-J6h01X78.js → index-CxOcwsHV.js} +3 -3
  81. package/static/app/assets/{input-y6ABdKWY.js → input-DcMETmZ7.js} +1 -1
  82. package/static/app/assets/{permissionCopy-BEtc0Ihd.js → permissionCopy-BVf13_25.js} +1 -1
  83. package/static/app/assets/{primitives-GSFXF2Pd.js → primitives-CQV9Q2YM.js} +1 -1
  84. package/static/app/assets/search-CXGASMMH.js +1 -0
  85. package/static/app/assets/{share-2-BgGq2A63.js → share-2-COWCHNZm.js} +1 -1
  86. package/static/app/assets/{shield-alert-BYA491cj.js → shield-alert-BDrvilyK.js} +1 -1
  87. package/static/app/assets/{textarea-BAcim3fs.js → textarea-rUmsc8cP.js} +1 -1
  88. package/static/app/assets/{useFocusTrap-qLg24jiv.js → useFocusTrap-Bi5UY_8v.js} +1 -1
  89. package/static/app/assets/{useQuery-xkAu9eDX.js → useQuery-C-AicB-3.js} +1 -1
  90. package/static/app/assets/{utils-CogdofAA.js → utils-BMwWg78e.js} +1 -1
  91. package/static/app/assets/{workspace-BMvfx9_R.js → workspace-Y93tls8P.js} +1 -1
  92. package/static/app/index.html +4 -4
  93. package/static/sw.js +1 -1
  94. package/static/app/assets/ProductFlow-BxjxI8Aa.js +0 -1
  95. package/static/app/assets/arrow-left-DArdBUxd.js +0 -1
  96. package/static/app/assets/index-BV7Yf6Eq.css +0 -2
  97. package/static/app/assets/search-CaIZKyo_.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
@@ -939,63 +844,11 @@ class WorkspaceOSStore:
939
844
  # ------------------------------------------------------------------
940
845
 
941
846
  def relationship_explorer(self, graph: Any, node_id: str, target_id: Optional[str] = None, limit: int = 500) -> Dict[str, Any]:
942
- if graph is None:
943
- return {"node_id": node_id, "inbound": [], "outbound": [], "related_entities": [], "shortest_path": []}
944
- data = graph.graph(limit=limit)
945
- nodes = {node.get("id"): node for node in data.get("nodes") or [] if node.get("id")}
946
- edges = data.get("edges") or []
947
- inbound = [edge for edge in edges if edge.get("to") == node_id]
948
- outbound = [edge for edge in edges if edge.get("from") == node_id]
949
- if node_id not in nodes:
950
- try:
951
- neighbors = graph.neighbors(node_id)
952
- for node in neighbors.get("neighbors") or []:
953
- nodes[node.get("id")] = node
954
- edges.extend(neighbors.get("edges") or [])
955
- inbound = [edge for edge in edges if edge.get("to") == node_id]
956
- outbound = [edge for edge in edges if edge.get("from") == node_id]
957
- except Exception:
958
- quiet()
959
-
960
- related_ids = []
961
- for edge in inbound + outbound:
962
- other = edge.get("from") if edge.get("to") == node_id else edge.get("to")
963
- if other:
964
- related_ids.append(other)
965
- related = [nodes.get(rid, {"id": rid}) for rid in dict.fromkeys(related_ids)]
966
- shortest_path = self._shortest_path(edges, node_id, target_id) if target_id else []
967
- return {
968
- "node_id": node_id,
969
- "node": nodes.get(node_id, {"id": node_id}),
970
- "inbound": inbound,
971
- "outbound": outbound,
972
- "related_entities": related,
973
- "shortest_path": shortest_path,
974
- }
847
+ return self.relationships.explore(graph, node_id, target_id=target_id, limit=limit)
975
848
 
976
849
  @staticmethod
977
850
  def _shortest_path(edges: List[Dict[str, Any]], start: str, target: Optional[str]) -> List[str]:
978
- if not start or not target:
979
- return []
980
- adjacency: Dict[str, List[str]] = {}
981
- for edge in edges:
982
- src = edge.get("from")
983
- dst = edge.get("to")
984
- if src and dst:
985
- adjacency.setdefault(src, []).append(dst)
986
- adjacency.setdefault(dst, []).append(src)
987
- queue: deque[List[str]] = deque([[start]])
988
- seen = {start}
989
- while queue:
990
- path = queue.popleft()
991
- node = path[-1]
992
- if node == target:
993
- return path
994
- for neighbor in adjacency.get(node, []):
995
- if neighbor not in seen:
996
- seen.add(neighbor)
997
- queue.append(path + [neighbor])
998
- return []
851
+ return _shortest_path(edges, start, target)
999
852
 
1000
853
  # ------------------------------------------------------------------
1001
854
  # Local Computer Memory
@@ -1009,48 +862,12 @@ class WorkspaceOSStore:
1009
862
  consent: Optional[Dict[str, Any]] = None,
1010
863
  scopes: Optional[List[str]] = None,
1011
864
  ) -> Dict[str, Any]:
1012
- consent = consent or {}
1013
- if enabled and not consent.get("approved"):
1014
- raise PermissionError("Local Computer Memory requires explicit approval.")
1015
- state = self.load_state()
1016
- config = state.setdefault("computer_memory", {})
1017
- config.update({
1018
- "enabled": bool(enabled),
1019
- "approved": bool(enabled),
1020
- "approved_at": _now() if enabled else config.get("approved_at"),
1021
- "approved_by": approved_by if enabled else config.get("approved_by"),
1022
- "scopes": scopes or config.get("scopes") or ["Downloads", "Documents", "Repositories"],
1023
- "consent": consent,
1024
- })
1025
- state.setdefault("feature_flags", {})["local_computer_memory"] = bool(enabled)
1026
- self.save_state(state)
1027
- self.record_timeline_event("memory", "computer_memory_configured", {"enabled": bool(enabled), "approved_by": approved_by})
1028
- return config
865
+ return self.computer_memory.configure(
866
+ enabled=enabled, approved_by=approved_by, consent=consent, scopes=scopes
867
+ )
1029
868
 
1030
869
  def record_computer_activity(self, activity: Dict[str, Any], graph: Any = None) -> Dict[str, Any]:
1031
- state = self.load_state()
1032
- config = state.setdefault("computer_memory", {})
1033
- if not config.get("enabled"):
1034
- return {"status": "ignored", "reason": "local computer memory is disabled"}
1035
- record = {
1036
- "id": f"activity-{_json_hash([activity, _now()])[:16]}",
1037
- "timestamp": _now(),
1038
- **activity,
1039
- }
1040
- config.setdefault("activities", []).append(record)
1041
- if graph is not None:
1042
- try:
1043
- graph.ingest_event(
1044
- "ComputerActivity",
1045
- str(activity.get("summary") or activity.get("path") or "Computer activity")[:120],
1046
- source="workspace_os",
1047
- metadata=record,
1048
- )
1049
- except Exception as exc:
1050
- record["graph_error"] = str(exc)
1051
- self.save_state(state)
1052
- self.record_timeline_event("memory", "computer_activity", {"activity_id": record["id"]})
1053
- return {"status": "ok", "activity": record}
870
+ return self.computer_memory.record_activity(activity, graph)
1054
871
 
1055
872
  # ------------------------------------------------------------------
1056
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.7.0"
13
+ WORKSPACE_OS_VERSION = "10.9.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
+ }
@@ -157,9 +157,12 @@ async def send_message(client, chat_id, text, reply_markup=None):
157
157
  async def send_photo(client, chat_id, file_path: Path, caption: str = ""):
158
158
  url = f"{API_URL}/sendPhoto"
159
159
  try:
160
- with open(file_path, "rb") as f:
161
- res = await client.post(url, data={"chat_id": str(chat_id), "caption": caption[:1024]},
162
- files={"photo": (file_path.name, f)}, timeout=60.0)
160
+ # Read on a worker thread: a screenshot is megabytes, and reading it
161
+ # inline stalled the bot's poll loop (and everything else on the loop)
162
+ # for the length of the read.
163
+ blob = await asyncio.to_thread(Path(file_path).read_bytes)
164
+ res = await client.post(url, data={"chat_id": str(chat_id), "caption": caption[:1024]},
165
+ files={"photo": (file_path.name, blob)}, timeout=60.0)
163
166
  if res.status_code != 200:
164
167
  await send_message(client, chat_id, f"사진 전송 실패 ({res.status_code})")
165
168
  except Exception as e:
@@ -169,15 +172,15 @@ async def send_photo(client, chat_id, file_path: Path, caption: str = ""):
169
172
  async def send_document(client, chat_id, file_path, caption=None, filename=None):
170
173
  url = f"{API_URL}/sendDocument"
171
174
  try:
172
- with open(file_path, "rb") as f:
173
- res = await client.post(
174
- url,
175
- data={"chat_id": str(chat_id), **({"caption": caption[:1024]} if caption else {})},
176
- files={"document": (filename or Path(file_path).name, f)},
177
- timeout=300.0,
178
- )
179
- if res.status_code != 200:
180
- logger.error("파일 전송 실패 (%s): %s", res.status_code, safe_log_text(res.text))
175
+ blob = await asyncio.to_thread(Path(file_path).read_bytes)
176
+ res = await client.post(
177
+ url,
178
+ data={"chat_id": str(chat_id), **({"caption": caption[:1024]} if caption else {})},
179
+ files={"document": (filename or Path(file_path).name, blob)},
180
+ timeout=300.0,
181
+ )
182
+ if res.status_code != 200:
183
+ logger.error("파일 전송 실패 (%s): %s", res.status_code, safe_log_text(res.text))
181
184
  except Exception as e:
182
185
  logger.error("파일 전송 실패: %s", safe_log_text(e))
183
186
 
@@ -584,12 +587,11 @@ async def process_document_file(client, chat_id, file_id: str, filename: str, ca
584
587
  try:
585
588
  tmp.write_bytes(raw)
586
589
  async with _server_client() as lc:
587
- with open(tmp, "rb") as f:
588
- res = await lc.post(
589
- UPLOAD_DOC_URL,
590
- files={"file": (filename, f)},
591
- timeout=60.0,
592
- )
590
+ res = await lc.post(
591
+ UPLOAD_DOC_URL,
592
+ files={"file": (filename, raw)},
593
+ timeout=60.0,
594
+ )
593
595
  if res.status_code == 200:
594
596
  data = res.json()
595
597
  chars = data.get("chars") or len(raw)
@@ -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.7.0"
19
+ ARCHITECTURE_VERSION_TARGET = "10.9.0"
20
20
 
21
21
  PREFERRED_REFACTORING_ORDER = [
22
22
  "agent-runtime",