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.
- package/README.md +55 -40
- package/docs/CHANGELOG.md +68 -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/api/chat.py +9 -6
- package/latticeai/api/chat_agent_http.py +2 -1
- package/latticeai/api/chat_history.py +4 -2
- package/latticeai/api/chat_intents.py +14 -14
- package/latticeai/api/knowledge_graph.py +5 -4
- package/latticeai/api/local_files.py +25 -15
- package/latticeai/api/mcp.py +15 -11
- package/latticeai/api/memory.py +2 -1
- package/latticeai/api/models.py +39 -24
- package/latticeai/api/network_boundary.py +9 -4
- package/latticeai/api/portability.py +27 -19
- package/latticeai/api/project_sessions.py +7 -5
- package/latticeai/api/review_queue.py +12 -7
- package/latticeai/api/setup.py +10 -11
- package/latticeai/api/static_routes.py +62 -46
- package/latticeai/api/tools.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/mcp_registry.py +20 -4
- package/latticeai/core/messages.py +452 -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/integrations/telegram_bot.py +20 -18
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/model_loading.py +63 -27
- package/latticeai/services/product_readiness.py +1 -1
- package/package.json +3 -2
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_screenshot_pixel_delta.py +92 -10
- package/scripts/check_server_i18n.mjs +96 -0
- package/scripts/release_screen_claims.json +46 -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-CS9IeqUX.js} +1 -1
- package/static/app/assets/{AdminConsole-Cbi6Jre6.js → AdminConsole-3UkIEWGA.js} +1 -1
- package/static/app/assets/{Brain-BPfuXZ71.js → Brain-B22EmNqS.js} +1 -1
- package/static/app/assets/{BrainHome-87XaAS4V.js → BrainHome-CMDqgJF4.js} +2 -2
- package/static/app/assets/{BrainSignals-ExpcdiUi.js → BrainSignals-BeE8RJo3.js} +1 -1
- package/static/app/assets/{Capture-Bxe4WiVX.js → Capture-ZX9bQh68.js} +1 -1
- package/static/app/assets/{CommandPalette-B1PV5Yd4.js → CommandPalette-86m4FCcN.js} +1 -1
- package/static/app/assets/{Library-BRlJJmwZ.js → Library-Bhz5LUca.js} +1 -1
- package/static/app/assets/{LivingBrain-D61qG3WY.js → LivingBrain-BSa0wpFG.js} +1 -1
- package/static/app/assets/ProductFlow-IP4Q-5aQ.js +1 -0
- package/static/app/assets/{ReviewCard-BoiWv0Y1.js → ReviewCard-BepjSDpN.js} +1 -1
- package/static/app/assets/{System-Ci0HEtTJ.js → System-Bwx1h_jT.js} +1 -1
- package/static/app/assets/arrow-left-ig7AZU8B.js +1 -0
- package/static/app/assets/{bot-BaPt4PPn.js → bot-DQj0-LkM.js} +1 -1
- package/static/app/assets/{brain-BSkWaj3i.js → brain-D8OEwmVj.js} +1 -1
- package/static/app/assets/{button-CKwtO1O0.js → button-CmTknyAP.js} +1 -1
- package/static/app/assets/{circle-pause-CiUc5RRk.js → circle-pause-yTCWRziJ.js} +1 -1
- package/static/app/assets/{circle-play-BaTDxzR9.js → circle-play-Ccrva84R.js} +1 -1
- package/static/app/assets/{cpu-Dm1gHuP1.js → cpu-CbJqWTlS.js} +1 -1
- package/static/app/assets/{download-C32oGU-C.js → download-CkSzbzU-.js} +1 -1
- package/static/app/assets/{folder-open-Ch2jLVdD.js → folder-open-CKyjQ4PU.js} +1 -1
- package/static/app/assets/{hard-drive-BBjA5st8.js → hard-drive-DAzk9um0.js} +1 -1
- package/static/app/assets/index-BfD-jhA9.css +2 -0
- package/static/app/assets/{index-J6h01X78.js → index-CxOcwsHV.js} +3 -3
- package/static/app/assets/{input-y6ABdKWY.js → input-DcMETmZ7.js} +1 -1
- package/static/app/assets/{permissionCopy-BEtc0Ihd.js → permissionCopy-BVf13_25.js} +1 -1
- package/static/app/assets/{primitives-GSFXF2Pd.js → primitives-CQV9Q2YM.js} +1 -1
- package/static/app/assets/search-CXGASMMH.js +1 -0
- package/static/app/assets/{share-2-BgGq2A63.js → share-2-COWCHNZm.js} +1 -1
- package/static/app/assets/{shield-alert-BYA491cj.js → shield-alert-BDrvilyK.js} +1 -1
- package/static/app/assets/{textarea-BAcim3fs.js → textarea-rUmsc8cP.js} +1 -1
- package/static/app/assets/{useFocusTrap-qLg24jiv.js → useFocusTrap-Bi5UY_8v.js} +1 -1
- package/static/app/assets/{useQuery-xkAu9eDX.js → useQuery-C-AicB-3.js} +1 -1
- package/static/app/assets/{utils-CogdofAA.js → utils-BMwWg78e.js} +1 -1
- package/static/app/assets/{workspace-BMvfx9_R.js → workspace-Y93tls8P.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
|
@@ -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
|
-
|
|
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.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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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)
|