ltcai 10.7.0 → 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 (77) hide show
  1. package/README.md +52 -39
  2. package/docs/CHANGELOG.md +31 -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/core/file_generation.py +141 -8
  18. package/latticeai/core/legacy_compatibility.py +1 -1
  19. package/latticeai/core/marketplace.py +1 -1
  20. package/latticeai/core/messages.py +230 -0
  21. package/latticeai/core/workspace_computer_memory.py +84 -0
  22. package/latticeai/core/workspace_indexing.py +102 -0
  23. package/latticeai/core/workspace_onboarding.py +104 -0
  24. package/latticeai/core/workspace_os.py +24 -207
  25. package/latticeai/core/workspace_os_constants.py +1 -1
  26. package/latticeai/core/workspace_relationships.py +99 -0
  27. package/latticeai/services/architecture_readiness.py +1 -1
  28. package/latticeai/services/product_readiness.py +1 -1
  29. package/package.json +1 -1
  30. package/scripts/check_current_release_docs.mjs +1 -1
  31. package/scripts/check_screenshot_pixel_delta.py +92 -10
  32. package/scripts/release_screen_claims.json +37 -0
  33. package/src-tauri/Cargo.lock +1 -1
  34. package/src-tauri/Cargo.toml +1 -1
  35. package/src-tauri/tauri.conf.json +1 -1
  36. package/static/app/asset-manifest.json +37 -37
  37. package/static/app/assets/{Act-DIbkoJqs.js → Act-CbQNsFgc.js} +1 -1
  38. package/static/app/assets/{AdminConsole-Cbi6Jre6.js → AdminConsole-Bs8SR8fE.js} +1 -1
  39. package/static/app/assets/{Brain-BPfuXZ71.js → Brain-BZIPsZlH.js} +1 -1
  40. package/static/app/assets/{BrainHome-87XaAS4V.js → BrainHome-BaH5MDce.js} +1 -1
  41. package/static/app/assets/{BrainSignals-ExpcdiUi.js → BrainSignals-aDt-nSsW.js} +1 -1
  42. package/static/app/assets/{Capture-Bxe4WiVX.js → Capture-DDv940Bv.js} +1 -1
  43. package/static/app/assets/{CommandPalette-B1PV5Yd4.js → CommandPalette-Dq_MmPBl.js} +1 -1
  44. package/static/app/assets/{Library-BRlJJmwZ.js → Library-BkShbqmN.js} +1 -1
  45. package/static/app/assets/{LivingBrain-D61qG3WY.js → LivingBrain-CXb8094H.js} +1 -1
  46. package/static/app/assets/ProductFlow-BaiqFWLO.js +1 -0
  47. package/static/app/assets/{ReviewCard-BoiWv0Y1.js → ReviewCard-Be7E7X18.js} +1 -1
  48. package/static/app/assets/{System-Ci0HEtTJ.js → System-DCstbTxC.js} +1 -1
  49. package/static/app/assets/arrow-left-DC9Oquhs.js +1 -0
  50. package/static/app/assets/{bot-BaPt4PPn.js → bot-BR0eoeXY.js} +1 -1
  51. package/static/app/assets/{brain-BSkWaj3i.js → brain-BmWa1wea.js} +1 -1
  52. package/static/app/assets/{button-CKwtO1O0.js → button-U1nU7xX4.js} +1 -1
  53. package/static/app/assets/{circle-pause-CiUc5RRk.js → circle-pause-DG5KHjDL.js} +1 -1
  54. package/static/app/assets/{circle-play-BaTDxzR9.js → circle-play-Chhcuq90.js} +1 -1
  55. package/static/app/assets/{cpu-Dm1gHuP1.js → cpu-pkOSlT9h.js} +1 -1
  56. package/static/app/assets/{download-C32oGU-C.js → download-CSRwz923.js} +1 -1
  57. package/static/app/assets/{folder-open-Ch2jLVdD.js → folder-open-DiMJ7W_7.js} +1 -1
  58. package/static/app/assets/{hard-drive-BBjA5st8.js → hard-drive-iyLPAPdp.js} +1 -1
  59. package/static/app/assets/index-BZRvGOM2.css +2 -0
  60. package/static/app/assets/{index-J6h01X78.js → index-_ldE9TAX.js} +3 -3
  61. package/static/app/assets/{input-y6ABdKWY.js → input-e_r2hFAY.js} +1 -1
  62. package/static/app/assets/{permissionCopy-BEtc0Ihd.js → permissionCopy-9uOHktz3.js} +1 -1
  63. package/static/app/assets/{primitives-GSFXF2Pd.js → primitives-CnEAOmg2.js} +1 -1
  64. package/static/app/assets/search-veep4n6f.js +1 -0
  65. package/static/app/assets/{share-2-BgGq2A63.js → share-2-DLDJJKmI.js} +1 -1
  66. package/static/app/assets/{shield-alert-BYA491cj.js → shield-alert-BTtE-2EZ.js} +1 -1
  67. package/static/app/assets/{textarea-BAcim3fs.js → textarea-Bbzkcgqe.js} +1 -1
  68. package/static/app/assets/{useFocusTrap-qLg24jiv.js → useFocusTrap-DJzQd-Za.js} +1 -1
  69. package/static/app/assets/{useQuery-xkAu9eDX.js → useQuery-6R_tqHRe.js} +1 -1
  70. package/static/app/assets/{utils-CogdofAA.js → utils-XDsjcrhS.js} +1 -1
  71. package/static/app/assets/{workspace-BMvfx9_R.js → workspace-CwrG8LHq.js} +1 -1
  72. package/static/app/index.html +4 -4
  73. package/static/sw.js +1 -1
  74. package/static/app/assets/ProductFlow-BxjxI8Aa.js +0 -1
  75. package/static/app/assets/arrow-left-DArdBUxd.js +0 -1
  76. package/static/app/assets/index-BV7Yf6Eq.css +0 -2
  77. package/static/app/assets/search-CaIZKyo_.js +0 -1
@@ -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.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
+ }
@@ -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.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.7.0"
21
+ PRODUCT_VERSION_TARGET = "10.8.0"
22
22
 
23
23
 
24
24
  @dataclass(frozen=True)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "10.7.0",
3
+ "version": "10.8.0",
4
4
  "description": "Lattice AI — local-first Digital Brain that keeps your knowledge durable across any AI model.",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -6,7 +6,7 @@ const root = process.cwd();
6
6
  const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
7
7
  const version = pkg.version;
8
8
  const releaseDir = `output/release/v${version}`;
9
- const releaseTheme = "Plain Surface";
9
+ const releaseTheme = "Within Reach";
10
10
  const title = `${version} — ${releaseTheme}`;
11
11
  const escapedVersion = version.replaceAll(".", "\\.");
12
12
 
@@ -279,8 +279,18 @@ def _default_baseline(root: Path, current: Path) -> Optional[Path]:
279
279
  return path
280
280
 
281
281
 
282
- def _pixel_delta_pct(a: Path, b: Path) -> Tuple[float, int, int, int, int]:
283
- """Return (changed_pct, changed_px, total_px, height_a, height_b)."""
282
+ def _pixel_delta_pct(
283
+ a: Path,
284
+ b: Path,
285
+ region: Optional[List[float]] = None,
286
+ ) -> Tuple[float, int, int, int, int]:
287
+ """Return (changed_pct, changed_px, total_px, height_a, height_b).
288
+
289
+ ``region`` is ``[x0, y0, x1, y1]`` as fractions of the aligned canvas. It
290
+ narrows the comparison to the part of the screen a release says it
291
+ changed, so an animated element elsewhere cannot mask a real change — or
292
+ manufacture one. Heights are always reported for the full images.
293
+ """
284
294
  with Image.open(a) as im_a, Image.open(b) as im_b:
285
295
  im_a = im_a.convert("RGB")
286
296
  im_b = im_b.convert("RGB")
@@ -292,6 +302,18 @@ def _pixel_delta_pct(a: Path, b: Path) -> Tuple[float, int, int, int, int]:
292
302
  canvas_b = Image.new("RGB", (w, h), (0, 0, 0))
293
303
  canvas_a.paste(im_a, (0, 0))
294
304
  canvas_b.paste(im_b, (0, 0))
305
+ if region:
306
+ x0, y0, x1, y1 = region
307
+ box = (
308
+ max(0, min(w, int(round(w * x0)))),
309
+ max(0, min(h, int(round(h * y0)))),
310
+ max(0, min(w, int(round(w * x1)))),
311
+ max(0, min(h, int(round(h * y1)))),
312
+ )
313
+ if box[2] > box[0] and box[3] > box[1]:
314
+ canvas_a = canvas_a.crop(box)
315
+ canvas_b = canvas_b.crop(box)
316
+ w, h = canvas_a.size
295
317
  diff = ImageChops.difference(canvas_a, canvas_b)
296
318
  # Any channel delta > 8 counts as changed (ignore sub-pixel AA noise).
297
319
  # Count on RGB getdata: a pixel is changed if any channel exceeds the
@@ -304,6 +326,33 @@ def _pixel_delta_pct(a: Path, b: Path) -> Tuple[float, int, int, int, int]:
304
326
  return pct, changed, total, h_a, h_b
305
327
 
306
328
 
329
+ CLAIMS_PATH = "scripts/release_screen_claims.json"
330
+
331
+
332
+ def _load_screen_claims(root: Path, version: str) -> Optional[Dict[str, Dict[str, object]]]:
333
+ """What this release says it changed, or None for "all twelve".
334
+
335
+ A version with no entry is gated on every screen, so forgetting to write a
336
+ claim makes this check stricter rather than weaker.
337
+ """
338
+ path = root / CLAIMS_PATH
339
+ if not path.is_file():
340
+ return None
341
+ try:
342
+ data = json.loads(path.read_text(encoding="utf-8"))
343
+ except ValueError as exc:
344
+ print(f"pixel delta: {CLAIMS_PATH} is not valid JSON ({exc})", file=sys.stderr)
345
+ return None
346
+ entry = data.get(_normalize_version(version))
347
+ if not isinstance(entry, dict):
348
+ return None
349
+ return {
350
+ name: (options if isinstance(options, dict) else {})
351
+ for name, options in entry.items()
352
+ if name in SHOTS
353
+ }
354
+
355
+
307
356
  def main(argv: Optional[List[str]] = None) -> int:
308
357
  root = _repo_root()
309
358
  parser = argparse.ArgumentParser(description=__doc__)
@@ -379,10 +428,21 @@ def main(argv: Optional[List[str]] = None) -> int:
379
428
  )
380
429
  print("-" * 64)
381
430
 
431
+ claims = _load_screen_claims(root, _package_version(root))
432
+ if claims is None:
433
+ print(f"pixel delta: no claim for this version in {CLAIMS_PATH} — gating all {len(SHOTS)} screens")
434
+ else:
435
+ print(
436
+ f"pixel delta: gating {len(claims)} claimed screen(s); "
437
+ f"{len(SHOTS) - len(claims)} unclaimed screen(s) measured but not gated"
438
+ )
439
+
382
440
  failures: List[str] = []
383
441
  results: List[Dict[str, object]] = []
384
442
 
385
443
  for name in SHOTS:
444
+ claimed = claims is None or name in SHOTS and name in claims
445
+ options = (claims or {}).get(name, {}) if claims is not None else {}
386
446
  cur = current / name
387
447
  base = baseline / name
388
448
  if not cur.is_file():
@@ -394,19 +454,27 @@ def main(argv: Optional[List[str]] = None) -> int:
394
454
  print(f"{name:<32} {'—':>8} {'—':>6} {'—':>6} FAIL")
395
455
  continue
396
456
 
397
- pct, changed, total, h_cur, h_base = _pixel_delta_pct(cur, base)
457
+ region = options.get("region") if isinstance(options.get("region"), list) else None
458
+ pct, changed, total, h_cur, h_base = _pixel_delta_pct(cur, base, region)
398
459
  height_delta = abs(h_cur - h_base)
399
460
  live = name in LIVING_BRAIN
400
- min_pct = args.min_pct_live if live else args.min_pct
461
+ # A claim that names its region has already excluded the animated part
462
+ # of the screen, so the live threshold no longer buys anything there.
463
+ default_min = args.min_pct_live if (live and not region) else args.min_pct
464
+ override = options.get("min_pct")
465
+ min_pct = float(override) if isinstance(override, (int, float)) else default_min
401
466
  # Height growth alone proves layout rebuild for full-page shots.
402
- ok = pct >= min_pct or height_delta >= args.min_height_px
403
- tag = "ok" if ok else "FAIL"
404
- if not ok:
467
+ moved = pct >= min_pct or height_delta >= args.min_height_px
468
+ ok = moved or not claimed
469
+ tag = "ok" if moved else ("—" if not claimed else "FAIL")
470
+ if claimed and not moved:
405
471
  failures.append(
406
472
  f"{name}: pixel={pct:.2f}% (need>={min_pct:.1f}%) "
407
473
  f"height_delta={height_delta}px (need>={args.min_height_px})"
474
+ + (f" region={region}" if region else "")
408
475
  )
409
- print(f"{name:<32} {pct:7.2f}% {height_delta:5d}p {min_pct:5.1f}% {tag:>5}")
476
+ marker = "" if claimed else " (unclaimed)"
477
+ print(f"{name:<32} {pct:7.2f}% {height_delta:5d}p {min_pct:5.1f}% {tag:>5}{marker}")
410
478
  results.append(
411
479
  {
412
480
  "file": name,
@@ -416,18 +484,32 @@ def main(argv: Optional[List[str]] = None) -> int:
416
484
  "height_delta": height_delta,
417
485
  "min_pct": min_pct,
418
486
  "living_brain": live,
487
+ "region": region,
488
+ "claimed": claimed,
489
+ "moved": moved,
419
490
  "pass": ok,
420
491
  }
421
492
  )
422
493
 
423
494
  print("-" * 64)
495
+ gated = sum(1 for item in results if item["claimed"])
424
496
  if failures:
425
- print(f"pixel delta: FAIL — {len(failures)}/{len(SHOTS)} screen(s) below threshold")
497
+ print(f"pixel delta: FAIL — {len(failures)}/{gated} claimed screen(s) below threshold")
426
498
  for item in failures:
427
499
  print(f" - {item}", file=sys.stderr)
428
500
  return 1
429
501
 
430
- print(f"pixel delta: PASS — all {len(SHOTS)} screens moved enough vs baseline v{expected_version}")
502
+ # An unclaimed screen that moved is not a failure, but it is worth saying:
503
+ # it is either an unintended side effect or a claim someone forgot to write.
504
+ surprises = [
505
+ f"{item['file']} ({item['pixel_pct']:.2f}%)"
506
+ for item in results
507
+ if not item["claimed"] and item["moved"]
508
+ ]
509
+ if surprises:
510
+ print(f"pixel delta: note — unclaimed screens that changed anyway: {', '.join(surprises)}")
511
+
512
+ print(f"pixel delta: PASS — all {gated} claimed screen(s) moved vs baseline v{expected_version}")
431
513
  return 0
432
514
 
433
515
 
@@ -0,0 +1,37 @@
1
+ {
2
+ "_comment": [
3
+ "Which screens each release claims to have changed, and where.",
4
+ "",
5
+ "The pixel gate used to require all twelve screens to move on every",
6
+ "release. That was right for 10.6.x/10.7.0, which were explicitly",
7
+ "'rearrange every screen' releases, and wrong as a standing rule: a",
8
+ "release that deliberately touches four screens would have to churn the",
9
+ "other eight or fail — which is the dishonesty the gate exists to catch,",
10
+ "inverted.",
11
+ "",
12
+ "A release lists the screens it claims to have changed. Claimed screens",
13
+ "are gated exactly as before. Unclaimed screens are still measured and",
14
+ "printed, so an unintended change is visible, but do not fail the run.",
15
+ "A version with no entry here is gated on all twelve — forgetting to",
16
+ "write a claim makes the gate stricter, never weaker.",
17
+ "",
18
+ "'region' narrows the comparison to [x0, y0, x1, y1] as fractions of the",
19
+ "image. Use it when a screen carries an animated element that dominates a",
20
+ "whole-image delta: naming where the change is makes the claim more",
21
+ "specific, not less."
22
+ ],
23
+
24
+ "10.8.0": {
25
+ "01-login.png": {},
26
+ "02-recommended-models.png": {},
27
+ "03-install-load-progress.png": {},
28
+ "05-memory-graph.png": {
29
+ "_why": "The provenance meter in this screen's header wrapped into a ragged three-line block that ran into the page title. It is a bordered column with a straight left edge now."
30
+ },
31
+ "04-brain-chat-home.png": {
32
+ "_why": "The Brain organism sits in this screen's greeting banner and moved 7.34% of that band between 10.6.3 and 10.7.0 with no layout intent, which is why 04 carries the 8% live threshold. 10.8.0 changed the suggestion deck and the shelf row below the composer; measuring that region directly is both stricter (the claim names where) and able to see a change the whole-image metric cannot.",
33
+ "region": [0.0, 0.45, 1.0, 0.85],
34
+ "min_pct": 1.5
35
+ }
36
+ }
37
+ }
@@ -1584,7 +1584,7 @@ dependencies = [
1584
1584
 
1585
1585
  [[package]]
1586
1586
  name = "lattice-ai-desktop"
1587
- version = "10.7.0"
1587
+ version = "10.8.0"
1588
1588
  dependencies = [
1589
1589
  "plist",
1590
1590
  "serde",