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.
- package/README.md +52 -39
- package/docs/CHANGELOG.md +31 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/retrieval_vector.py +69 -33
- package/lattice_brain/runtime/multi_agent.py +96 -15
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +9 -6
- package/latticeai/api/auth.py +37 -38
- package/latticeai/api/browser.py +7 -6
- package/latticeai/core/file_generation.py +141 -8
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +230 -0
- package/latticeai/core/workspace_computer_memory.py +84 -0
- package/latticeai/core/workspace_indexing.py +102 -0
- package/latticeai/core/workspace_onboarding.py +104 -0
- package/latticeai/core/workspace_os.py +24 -207
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_relationships.py +99 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/package.json +1 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_screenshot_pixel_delta.py +92 -10
- package/scripts/release_screen_claims.json +37 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -37
- package/static/app/assets/{Act-DIbkoJqs.js → Act-CbQNsFgc.js} +1 -1
- package/static/app/assets/{AdminConsole-Cbi6Jre6.js → AdminConsole-Bs8SR8fE.js} +1 -1
- package/static/app/assets/{Brain-BPfuXZ71.js → Brain-BZIPsZlH.js} +1 -1
- package/static/app/assets/{BrainHome-87XaAS4V.js → BrainHome-BaH5MDce.js} +1 -1
- package/static/app/assets/{BrainSignals-ExpcdiUi.js → BrainSignals-aDt-nSsW.js} +1 -1
- package/static/app/assets/{Capture-Bxe4WiVX.js → Capture-DDv940Bv.js} +1 -1
- package/static/app/assets/{CommandPalette-B1PV5Yd4.js → CommandPalette-Dq_MmPBl.js} +1 -1
- package/static/app/assets/{Library-BRlJJmwZ.js → Library-BkShbqmN.js} +1 -1
- package/static/app/assets/{LivingBrain-D61qG3WY.js → LivingBrain-CXb8094H.js} +1 -1
- package/static/app/assets/ProductFlow-BaiqFWLO.js +1 -0
- package/static/app/assets/{ReviewCard-BoiWv0Y1.js → ReviewCard-Be7E7X18.js} +1 -1
- package/static/app/assets/{System-Ci0HEtTJ.js → System-DCstbTxC.js} +1 -1
- package/static/app/assets/arrow-left-DC9Oquhs.js +1 -0
- package/static/app/assets/{bot-BaPt4PPn.js → bot-BR0eoeXY.js} +1 -1
- package/static/app/assets/{brain-BSkWaj3i.js → brain-BmWa1wea.js} +1 -1
- package/static/app/assets/{button-CKwtO1O0.js → button-U1nU7xX4.js} +1 -1
- package/static/app/assets/{circle-pause-CiUc5RRk.js → circle-pause-DG5KHjDL.js} +1 -1
- package/static/app/assets/{circle-play-BaTDxzR9.js → circle-play-Chhcuq90.js} +1 -1
- package/static/app/assets/{cpu-Dm1gHuP1.js → cpu-pkOSlT9h.js} +1 -1
- package/static/app/assets/{download-C32oGU-C.js → download-CSRwz923.js} +1 -1
- package/static/app/assets/{folder-open-Ch2jLVdD.js → folder-open-DiMJ7W_7.js} +1 -1
- package/static/app/assets/{hard-drive-BBjA5st8.js → hard-drive-iyLPAPdp.js} +1 -1
- package/static/app/assets/index-BZRvGOM2.css +2 -0
- package/static/app/assets/{index-J6h01X78.js → index-_ldE9TAX.js} +3 -3
- package/static/app/assets/{input-y6ABdKWY.js → input-e_r2hFAY.js} +1 -1
- package/static/app/assets/{permissionCopy-BEtc0Ihd.js → permissionCopy-9uOHktz3.js} +1 -1
- package/static/app/assets/{primitives-GSFXF2Pd.js → primitives-CnEAOmg2.js} +1 -1
- package/static/app/assets/search-veep4n6f.js +1 -0
- package/static/app/assets/{share-2-BgGq2A63.js → share-2-DLDJJKmI.js} +1 -1
- package/static/app/assets/{shield-alert-BYA491cj.js → shield-alert-BTtE-2EZ.js} +1 -1
- package/static/app/assets/{textarea-BAcim3fs.js → textarea-Bbzkcgqe.js} +1 -1
- package/static/app/assets/{useFocusTrap-qLg24jiv.js → useFocusTrap-DJzQd-Za.js} +1 -1
- package/static/app/assets/{useQuery-xkAu9eDX.js → useQuery-6R_tqHRe.js} +1 -1
- package/static/app/assets/{utils-CogdofAA.js → utils-XDsjcrhS.js} +1 -1
- package/static/app/assets/{workspace-BMvfx9_R.js → workspace-CwrG8LHq.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/ProductFlow-BxjxI8Aa.js +0 -1
- package/static/app/assets/arrow-left-DArdBUxd.js +0 -1
- package/static/app/assets/index-BV7Yf6Eq.css +0 -2
- 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
|
-
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -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 = "
|
|
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(
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
if not
|
|
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
|
-
|
|
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)}/{
|
|
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
|
-
|
|
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
|
+
}
|