ltcai 9.9.3 → 9.9.4
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 +43 -38
- package/docs/CHANGELOG.md +65 -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/SURFACE_PARITY.md +47 -0
- 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/_kg_common.py +335 -0
- package/lattice_brain/graph/discovery_index.py +8 -1
- package/lattice_brain/graph/ingest.py +43 -9
- package/lattice_brain/graph/projection.py +221 -37
- package/lattice_brain/graph/retrieval.py +61 -12
- package/lattice_brain/graph/retrieval_policy.py +174 -0
- package/lattice_brain/graph/retrieval_vector.py +122 -2
- package/lattice_brain/portability.py +41 -12
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +6 -0
- package/latticeai/api/chat_agent_http.py +175 -6
- package/latticeai/api/chat_intents.py +37 -18
- package/latticeai/api/chat_stream.py +76 -2
- package/latticeai/api/knowledge_graph.py +35 -1
- package/latticeai/core/agent.py +264 -9
- package/latticeai/core/enterprise.py +5 -0
- package/latticeai/core/file_generation.py +130 -5
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/run_store.py +243 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/models/router.py +25 -12
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/command_center.py +90 -1
- package/latticeai/services/folder_watch.py +5 -0
- package/latticeai/services/funnel_metrics.py +9 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +43 -13
- package/package.json +1 -1
- package/scripts/bench_agent_smoke.py +410 -0
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/funnel_soft_gate.py +192 -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 +29 -28
- package/static/app/assets/Act-BCmTU0E2.js +1 -0
- package/static/app/assets/{Brain-DEY9jLVt.js → Brain-CTnjox7w.js} +2 -2
- package/static/app/assets/BrainHome-BJ3sFNX0.js +2 -0
- package/static/app/assets/BrainSignals-C52lwZVD.js +1 -0
- package/static/app/assets/{Capture-CVZ09QXi.js → Capture-B6vBhFa3.js} +1 -1
- package/static/app/assets/{CommandPalette-DepwOQFv.js → CommandPalette-90u9FWFH.js} +1 -1
- package/static/app/assets/{Library-Bp0n-HlW.js → Library-pKCK0_tk.js} +1 -1
- package/static/app/assets/{LivingBrain-DxP4efJF.js → LivingBrain-Jlf2wFqI.js} +1 -1
- package/static/app/assets/{ProductFlow-DRbm7NEq.js → ProductFlow-yg1fKP1P.js} +1 -1
- package/static/app/assets/{ReviewCard-C4HAO7A3.js → ReviewCard-DWvD7n9h.js} +1 -1
- package/static/app/assets/{System-ByQcmJW-.js → System-BAEuHqNY.js} +1 -1
- package/static/app/assets/{bot-BNDyZLR7.js → bot-nB_buEZD.js} +1 -1
- package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
- package/static/app/assets/{circle-play-BkhdcHgd.js → circle-play-B-IsFL1y.js} +1 -1
- package/static/app/assets/{cpu-C6jjYm6i.js → cpu-CEPBHaBl.js} +1 -1
- package/static/app/assets/{folder-open-DjGIvDBQ.js → folder-open-hmN0N9cX.js} +1 -1
- package/static/app/assets/{hard-drive-BlSbwSaT.js → hard-drive-CBV_B_Yd.js} +1 -1
- package/static/app/assets/{index-Bge3DXW7.css → index-7FAfYm4v.css} +1 -1
- package/static/app/assets/{index-CHu7cgj3.js → index-DrmOCySv.js} +3 -3
- package/static/app/assets/{input-DVDI0YR3.js → input-gtVCg-ll.js} +1 -1
- package/static/app/assets/{navigation-BddhEWA0.js → navigation-Bot0hvuv.js} +1 -1
- package/static/app/assets/{network-pYQt5oBu.js → network-jE42eKfT.js} +1 -1
- package/static/app/assets/{primitives-D7gCdEvS.js → primitives-CX2Komon.js} +1 -1
- package/static/app/assets/{shield-alert-K9RKGQeg.js → shield-alert-BftATuAA.js} +1 -1
- package/static/app/assets/{textarea-sqQmoBKL.js → textarea-CiMJfOSI.js} +1 -1
- package/static/app/assets/{useFocusTrap-7EV9dFP2.js → useFocusTrap-B7RPGfFy.js} +1 -1
- package/static/app/assets/utils-SJUNVOj5.js +7 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-DmdruVKV.js +0 -1
- package/static/app/assets/BrainHome-CeNaxjP1.js +0 -2
- package/static/app/assets/BrainSignals-CStjIqYi.js +0 -1
- package/static/app/assets/utils-uQYKXNeq.js +0 -7
|
@@ -358,6 +358,23 @@ _BUNDLE_HTML_RULE = (
|
|
|
358
358
|
"blocks, no inline behavior scripts."
|
|
359
359
|
)
|
|
360
360
|
|
|
361
|
+
# Vite/React bundles need a module entry point, not classic script tags.
|
|
362
|
+
_BUNDLE_HTML_MODULE_RULE = (
|
|
363
|
+
"Produce ONE complete HTML5 document: <!DOCTYPE html>, <html>, <head> with "
|
|
364
|
+
"<meta charset=\"utf-8\"> and a <title>, and a closed </html> tag. "
|
|
365
|
+
"This page is the Vite entry of a React project: the <body> must contain "
|
|
366
|
+
"<div id=\"root\"></div> and load the app with "
|
|
367
|
+
"<script type=\"module\" src=\"/src/main.jsx\"></script> just before "
|
|
368
|
+
"</body>. No inline <style> blocks, no other scripts, no external files."
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _bundle_html_rule(bundle_files: List[str]) -> str:
|
|
373
|
+
"""Pick the HTML bundle rule that matches the bundle's technology."""
|
|
374
|
+
if any(str(path).lower().endswith((".jsx", ".tsx")) for path in bundle_files):
|
|
375
|
+
return _BUNDLE_HTML_MODULE_RULE
|
|
376
|
+
return _BUNDLE_HTML_RULE
|
|
377
|
+
|
|
361
378
|
|
|
362
379
|
def build_file_generation_context(
|
|
363
380
|
target_path: str,
|
|
@@ -381,7 +398,7 @@ def build_file_generation_context(
|
|
|
381
398
|
]
|
|
382
399
|
type_rule = _TYPE_RULES.get(ext)
|
|
383
400
|
if bundle_files and ext in (".html", ".htm"):
|
|
384
|
-
type_rule =
|
|
401
|
+
type_rule = _bundle_html_rule(bundle_files)
|
|
385
402
|
if type_rule:
|
|
386
403
|
parts.append(f"- {type_rule}")
|
|
387
404
|
if bundle_files:
|
|
@@ -608,16 +625,116 @@ _EXPLICIT_FILENAME_RE = re.compile(
|
|
|
608
625
|
re.IGNORECASE,
|
|
609
626
|
)
|
|
610
627
|
_PROJECT_NAME_RE = re.compile(r"([A-Za-z][A-Za-z0-9_-]{1,30})\s*(?:앱|app\b)", re.IGNORECASE)
|
|
628
|
+
# React/Vite intent: the react keyword is specific enough on its own.
|
|
629
|
+
_REACT_HINT_RE = re.compile(r"(?<![a-z0-9])react(?![a-z0-9])|리액트")
|
|
630
|
+
_VITE_HINT_RE = re.compile(r"(?<![a-z0-9])vite(?![a-z0-9])")
|
|
631
|
+
# Python package intent: language + package word, both required.
|
|
632
|
+
_PYTHON_HINT_RE = re.compile(r"(?<![a-z0-9])python(?![a-z0-9])|파이썬")
|
|
633
|
+
_PACKAGE_HINT_RE = re.compile(r"패키지|(?<![a-z0-9])package(?![a-z0-9])")
|
|
634
|
+
_PKG_NAME_RE = re.compile(
|
|
635
|
+
r"([A-Za-z][A-Za-z0-9_-]{1,30})\s*(?:패키지|package\b)", re.IGNORECASE
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _react_manifest(text: str) -> Dict[str, Any]:
|
|
640
|
+
"""Vite + React starter manifest (review Wave 4: manifest 확장)."""
|
|
641
|
+
name_match = _PROJECT_NAME_RE.search(text)
|
|
642
|
+
name = f"{name_match.group(1).lower()}-app" if name_match else "react-app"
|
|
643
|
+
return {
|
|
644
|
+
"name": name,
|
|
645
|
+
"kind": "react",
|
|
646
|
+
"files": [
|
|
647
|
+
{
|
|
648
|
+
"path": "package.json",
|
|
649
|
+
"brief": (
|
|
650
|
+
f'Vite React app manifest: strictly valid JSON with "name": "{name}", '
|
|
651
|
+
'"private": true, "type": "module", "scripts" {"dev": "vite", '
|
|
652
|
+
'"build": "vite build", "preview": "vite preview"}, "dependencies" '
|
|
653
|
+
'with react and react-dom (^18), and "devDependencies" with vite '
|
|
654
|
+
"and @vitejs/plugin-react."
|
|
655
|
+
),
|
|
656
|
+
},
|
|
657
|
+
{
|
|
658
|
+
"path": "index.html",
|
|
659
|
+
"brief": (
|
|
660
|
+
"The Vite entry HTML: <div id=\"root\"></div> in <body> and "
|
|
661
|
+
"<script type=\"module\" src=\"/src/main.jsx\"></script> just "
|
|
662
|
+
"before </body>. No inline styles or scripts."
|
|
663
|
+
),
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
"path": "src/main.jsx",
|
|
667
|
+
"brief": (
|
|
668
|
+
"React entry: createRoot from react-dom/client rendering <App /> "
|
|
669
|
+
"into #root; imports ./App.jsx and ./App.css."
|
|
670
|
+
),
|
|
671
|
+
},
|
|
672
|
+
{
|
|
673
|
+
"path": "src/App.jsx",
|
|
674
|
+
"brief": (
|
|
675
|
+
"The main App component implementing the user's request as one "
|
|
676
|
+
"self-contained React component (hooks allowed, no extra deps)."
|
|
677
|
+
),
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
"path": "src/App.css",
|
|
681
|
+
"brief": "All visual styles for the App component.",
|
|
682
|
+
},
|
|
683
|
+
],
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _python_package_manifest(text: str) -> Dict[str, Any]:
|
|
688
|
+
"""Multi-file Python package manifest (review Wave 4: manifest 확장)."""
|
|
689
|
+
name_match = _PKG_NAME_RE.search(text)
|
|
690
|
+
raw_name = name_match.group(1).lower() if name_match else "my_package"
|
|
691
|
+
module = re.sub(r"[^a-z0-9_]", "_", raw_name)
|
|
692
|
+
if not re.match(r"[a-z_]", module):
|
|
693
|
+
module = f"pkg_{module}"
|
|
694
|
+
return {
|
|
695
|
+
"name": module,
|
|
696
|
+
"kind": "python",
|
|
697
|
+
"files": [
|
|
698
|
+
{
|
|
699
|
+
"path": f"{module}/__init__.py",
|
|
700
|
+
"brief": (
|
|
701
|
+
f"Package init for {module}: import and re-export the public "
|
|
702
|
+
"API from .core with an explicit __all__."
|
|
703
|
+
),
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
"path": f"{module}/core.py",
|
|
707
|
+
"brief": (
|
|
708
|
+
"Implement the user's request as clean, documented functions/"
|
|
709
|
+
"classes with type hints. Standard library only."
|
|
710
|
+
),
|
|
711
|
+
},
|
|
712
|
+
{
|
|
713
|
+
"path": f"{module}/cli.py",
|
|
714
|
+
"brief": (
|
|
715
|
+
"argparse CLI wrapping the core API: a main() function and an "
|
|
716
|
+
'if __name__ == "__main__": main() guard.'
|
|
717
|
+
),
|
|
718
|
+
},
|
|
719
|
+
{
|
|
720
|
+
"path": "README.md",
|
|
721
|
+
"brief": (
|
|
722
|
+
f"Usage documentation for the {module} package: install, import "
|
|
723
|
+
"example, and CLI example."
|
|
724
|
+
),
|
|
725
|
+
},
|
|
726
|
+
],
|
|
727
|
+
}
|
|
611
728
|
|
|
612
729
|
|
|
613
730
|
def infer_project_manifest(message: str) -> Optional[Dict[str, Any]]:
|
|
614
|
-
"""Infer a multi-file
|
|
731
|
+
"""Infer a multi-file project manifest from a creation request.
|
|
615
732
|
|
|
616
733
|
"todo 앱 html+css+js로 만들어줘" should yield real linked files, not one
|
|
617
734
|
inlined page. Deliberately narrow and deterministic (weak local models
|
|
618
|
-
never see this decision): requires a creation verb,
|
|
619
|
-
|
|
620
|
-
filename. Single-type requests return ``None`` so the existing
|
|
735
|
+
never see this decision): requires a creation verb, a recognized project
|
|
736
|
+
intent (web page + css/js, React/Vite app, or Python package), and no
|
|
737
|
+
explicit filename. Single-type requests return ``None`` so the existing
|
|
621
738
|
single-file flow is completely unchanged.
|
|
622
739
|
"""
|
|
623
740
|
text = (message or "").strip()
|
|
@@ -626,6 +743,14 @@ def infer_project_manifest(message: str) -> Optional[Dict[str, Any]]:
|
|
|
626
743
|
if _EXPLICIT_FILENAME_RE.search(text):
|
|
627
744
|
return None
|
|
628
745
|
lower = text.lower()
|
|
746
|
+
|
|
747
|
+
# Most-specific first: React (its own structure), then Python package,
|
|
748
|
+
# then the classic html+css/js web bundle.
|
|
749
|
+
if _REACT_HINT_RE.search(lower) or _VITE_HINT_RE.search(lower):
|
|
750
|
+
return _react_manifest(text)
|
|
751
|
+
if _PYTHON_HINT_RE.search(lower) and _PACKAGE_HINT_RE.search(lower):
|
|
752
|
+
return _python_package_manifest(text)
|
|
753
|
+
|
|
629
754
|
wants_html = bool(_HTML_HINT_RE.search(lower))
|
|
630
755
|
wants_css = bool(_CSS_HINT_RE.search(lower))
|
|
631
756
|
wants_js = bool(_JS_HINT_RE.search(lower))
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Durable persistence for paused agent runs (review 2026-07-25 Wave 0.1).
|
|
2
|
+
|
|
3
|
+
The ``awaiting_approval`` loop parked runs in an in-process dict, so a server
|
|
4
|
+
restart between pause and resume turned every outstanding approval into a 404
|
|
5
|
+
("승인하려다 사라짐"). This store writes each paused run to one JSON file under
|
|
6
|
+
the data dir, so a valid, unexpired token can resume its run across restarts
|
|
7
|
+
and across worker processes.
|
|
8
|
+
|
|
9
|
+
Design constraints (deliberate, matching :mod:`latticeai.services.funnel_metrics`):
|
|
10
|
+
|
|
11
|
+
* **One JSON file per run**, atomic replace on write — no new dependencies.
|
|
12
|
+
* **Fail-open on save, fail-closed on resume.** A persistence error never
|
|
13
|
+
breaks the pause response (the in-memory path still works); a load error on
|
|
14
|
+
resume is treated as "run not found".
|
|
15
|
+
* **Tokens are never stored in plaintext.** Only the SHA-256 hex digest is
|
|
16
|
+
persisted; resume hashes the presented token and compares digests.
|
|
17
|
+
* **Wall-clock expiry.** The in-memory dict keeps its monotonic deadline, but
|
|
18
|
+
monotonic time does not survive a restart — the durable record carries an
|
|
19
|
+
absolute UTC epoch and files past it are swept, never resumed.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import tempfile
|
|
30
|
+
import time
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Dict, List, Optional
|
|
33
|
+
|
|
34
|
+
from latticeai.core.agent import AgentRunContext, AgentState
|
|
35
|
+
from latticeai.core.agent_trace import LoopTrace
|
|
36
|
+
|
|
37
|
+
LOGGER = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# run_id comes from ``secrets.token_urlsafe`` — validate before any path use
|
|
40
|
+
# so a crafted id can never traverse out of the store directory.
|
|
41
|
+
_RUN_ID_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def hash_approval_token(token: str) -> str:
|
|
45
|
+
"""Stable digest used both at save and at resume comparison time."""
|
|
46
|
+
return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def serialize_run_context(ctx: AgentRunContext) -> Dict[str, Any]:
|
|
50
|
+
"""Reduce an :class:`AgentRunContext` to plain JSON-safe data."""
|
|
51
|
+
return {
|
|
52
|
+
"state": ctx.state.value,
|
|
53
|
+
"plan": ctx.plan,
|
|
54
|
+
"transcript": ctx.transcript,
|
|
55
|
+
"retry_count": ctx.retry_count,
|
|
56
|
+
"state_history": ctx.state_history,
|
|
57
|
+
"corrections": ctx.corrections,
|
|
58
|
+
"final_message": ctx.final_message,
|
|
59
|
+
"rollback_log": ctx.rollback_log,
|
|
60
|
+
"executing_model": ctx.executing_model,
|
|
61
|
+
"reviewing_model": ctx.reviewing_model,
|
|
62
|
+
"approved_by_human": ctx.approved_by_human,
|
|
63
|
+
"trace": {"events": ctx.trace.events, "truncated": ctx.trace.truncated},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def restore_run_context(payload: Dict[str, Any]) -> AgentRunContext:
|
|
68
|
+
"""Rebuild an :class:`AgentRunContext` from :func:`serialize_run_context`."""
|
|
69
|
+
ctx = AgentRunContext()
|
|
70
|
+
try:
|
|
71
|
+
ctx.state = AgentState(str(payload.get("state") or AgentState.WAITING_APPROVAL.value))
|
|
72
|
+
except ValueError:
|
|
73
|
+
ctx.state = AgentState.WAITING_APPROVAL
|
|
74
|
+
ctx.plan = dict(payload.get("plan") or {})
|
|
75
|
+
ctx.transcript = list(payload.get("transcript") or [])
|
|
76
|
+
ctx.retry_count = int(payload.get("retry_count") or 0)
|
|
77
|
+
ctx.state_history = list(payload.get("state_history") or [])
|
|
78
|
+
ctx.corrections = list(payload.get("corrections") or [])
|
|
79
|
+
ctx.final_message = str(payload.get("final_message") or "")
|
|
80
|
+
ctx.rollback_log = list(payload.get("rollback_log") or [])
|
|
81
|
+
ctx.executing_model = payload.get("executing_model")
|
|
82
|
+
ctx.reviewing_model = payload.get("reviewing_model")
|
|
83
|
+
ctx.approved_by_human = bool(payload.get("approved_by_human"))
|
|
84
|
+
trace_payload = payload.get("trace") or {}
|
|
85
|
+
trace = LoopTrace()
|
|
86
|
+
trace.events = list(trace_payload.get("events") or [])
|
|
87
|
+
trace.truncated = int(trace_payload.get("truncated") or 0)
|
|
88
|
+
ctx.trace = trace
|
|
89
|
+
return ctx
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AgentRunStore:
|
|
93
|
+
"""One-JSON-file-per-run persistence for paused approval runs."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, root: Path) -> None:
|
|
96
|
+
self._root = Path(root)
|
|
97
|
+
|
|
98
|
+
def _path_for(self, run_id: str) -> Optional[Path]:
|
|
99
|
+
if not _RUN_ID_RE.fullmatch(str(run_id or "")):
|
|
100
|
+
return None
|
|
101
|
+
return self._root / f"{run_id}.json"
|
|
102
|
+
|
|
103
|
+
# ── writes ───────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
def save(
|
|
106
|
+
self,
|
|
107
|
+
run_id: str,
|
|
108
|
+
*,
|
|
109
|
+
ctx: AgentRunContext,
|
|
110
|
+
req_payload: Dict[str, Any],
|
|
111
|
+
language_hint: str,
|
|
112
|
+
user: str,
|
|
113
|
+
token: str,
|
|
114
|
+
expires_epoch: float,
|
|
115
|
+
expires_at: str,
|
|
116
|
+
) -> bool:
|
|
117
|
+
"""Persist a paused run. Best-effort: returns False on any failure."""
|
|
118
|
+
path = self._path_for(run_id)
|
|
119
|
+
if path is None:
|
|
120
|
+
return False
|
|
121
|
+
record = {
|
|
122
|
+
"version": 1,
|
|
123
|
+
"run_id": run_id,
|
|
124
|
+
"user": user,
|
|
125
|
+
"language_hint": language_hint,
|
|
126
|
+
"token_hash": hash_approval_token(token),
|
|
127
|
+
"expires_epoch": float(expires_epoch),
|
|
128
|
+
"expires_at": expires_at,
|
|
129
|
+
"req": req_payload,
|
|
130
|
+
"ctx": serialize_run_context(ctx),
|
|
131
|
+
}
|
|
132
|
+
try:
|
|
133
|
+
self._root.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
fd, tmp_name = tempfile.mkstemp(prefix=path.name, dir=str(self._root))
|
|
135
|
+
try:
|
|
136
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
137
|
+
json.dump(record, handle, ensure_ascii=False)
|
|
138
|
+
os.replace(tmp_name, path)
|
|
139
|
+
finally:
|
|
140
|
+
if os.path.exists(tmp_name):
|
|
141
|
+
try:
|
|
142
|
+
os.unlink(tmp_name)
|
|
143
|
+
except OSError:
|
|
144
|
+
pass
|
|
145
|
+
return True
|
|
146
|
+
except Exception as exc: # noqa: BLE001 — pause must still answer without disk
|
|
147
|
+
LOGGER.warning("agent run store save failed for %s: %s", run_id, exc)
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
def delete(self, run_id: str) -> None:
|
|
151
|
+
path = self._path_for(run_id)
|
|
152
|
+
if path is None:
|
|
153
|
+
return
|
|
154
|
+
try:
|
|
155
|
+
path.unlink(missing_ok=True)
|
|
156
|
+
except OSError as exc:
|
|
157
|
+
LOGGER.warning("agent run store delete failed for %s: %s", run_id, exc)
|
|
158
|
+
|
|
159
|
+
# ── reads ────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
def load(self, run_id: str) -> Optional[Dict[str, Any]]:
|
|
162
|
+
"""Raw persisted record, or None when missing/corrupt/invalid id."""
|
|
163
|
+
path = self._path_for(run_id)
|
|
164
|
+
if path is None:
|
|
165
|
+
return None
|
|
166
|
+
try:
|
|
167
|
+
record = json.loads(path.read_text(encoding="utf-8"))
|
|
168
|
+
except FileNotFoundError:
|
|
169
|
+
return None
|
|
170
|
+
except Exception as exc: # noqa: BLE001 — corrupt record == not found
|
|
171
|
+
LOGGER.warning("agent run store load failed for %s: %s", run_id, exc)
|
|
172
|
+
return None
|
|
173
|
+
if not isinstance(record, dict) or record.get("run_id") != run_id:
|
|
174
|
+
return None
|
|
175
|
+
return record
|
|
176
|
+
|
|
177
|
+
def pending_summaries(self, user: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
178
|
+
"""Unexpired pending runs (optionally per user) for surfacing in UI."""
|
|
179
|
+
summaries: List[Dict[str, Any]] = []
|
|
180
|
+
now = time.time()
|
|
181
|
+
try:
|
|
182
|
+
paths = sorted(self._root.glob("*.json"))
|
|
183
|
+
except OSError:
|
|
184
|
+
return summaries
|
|
185
|
+
for path in paths:
|
|
186
|
+
try:
|
|
187
|
+
record = json.loads(path.read_text(encoding="utf-8"))
|
|
188
|
+
except Exception: # noqa: BLE001
|
|
189
|
+
continue
|
|
190
|
+
if not isinstance(record, dict):
|
|
191
|
+
continue
|
|
192
|
+
if float(record.get("expires_epoch") or 0) <= now:
|
|
193
|
+
continue
|
|
194
|
+
if user is not None and record.get("user") != user:
|
|
195
|
+
continue
|
|
196
|
+
plan = (record.get("ctx") or {}).get("plan") or {}
|
|
197
|
+
summaries.append({
|
|
198
|
+
"run_id": record.get("run_id"),
|
|
199
|
+
"user": record.get("user"),
|
|
200
|
+
"goal": str(plan.get("goal") or "")[:200],
|
|
201
|
+
"expires_at": record.get("expires_at"),
|
|
202
|
+
})
|
|
203
|
+
return summaries
|
|
204
|
+
|
|
205
|
+
def sweep_expired(
|
|
206
|
+
self,
|
|
207
|
+
now_epoch: Optional[float] = None,
|
|
208
|
+
*,
|
|
209
|
+
retention_seconds: float = 86400.0,
|
|
210
|
+
) -> int:
|
|
211
|
+
"""Remove long-expired run files; returns how many were removed.
|
|
212
|
+
|
|
213
|
+
Recently expired records are *kept* for ``retention_seconds`` so that
|
|
214
|
+
a resume attempt after expiry can still answer 410 with a one-click
|
|
215
|
+
replan hint (the original request message) instead of a bare 404.
|
|
216
|
+
"""
|
|
217
|
+
removed = 0
|
|
218
|
+
now = time.time() if now_epoch is None else float(now_epoch)
|
|
219
|
+
try:
|
|
220
|
+
paths = list(self._root.glob("*.json"))
|
|
221
|
+
except OSError:
|
|
222
|
+
return removed
|
|
223
|
+
for path in paths:
|
|
224
|
+
try:
|
|
225
|
+
record = json.loads(path.read_text(encoding="utf-8"))
|
|
226
|
+
expires = float(record.get("expires_epoch") or 0)
|
|
227
|
+
except Exception: # noqa: BLE001 — unreadable == expired garbage
|
|
228
|
+
expires = -float(retention_seconds)
|
|
229
|
+
if expires + float(retention_seconds) <= now:
|
|
230
|
+
try:
|
|
231
|
+
path.unlink(missing_ok=True)
|
|
232
|
+
removed += 1
|
|
233
|
+
except OSError:
|
|
234
|
+
pass
|
|
235
|
+
return removed
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
__all__ = [
|
|
239
|
+
"AgentRunStore",
|
|
240
|
+
"hash_approval_token",
|
|
241
|
+
"restore_run_context",
|
|
242
|
+
"serialize_run_context",
|
|
243
|
+
]
|
|
@@ -49,7 +49,7 @@ __all__ = [
|
|
|
49
49
|
"remove_skill_directory",
|
|
50
50
|
]
|
|
51
51
|
|
|
52
|
-
WORKSPACE_OS_VERSION = "9.9.
|
|
52
|
+
WORKSPACE_OS_VERSION = "9.9.4"
|
|
53
53
|
|
|
54
54
|
# Workspace types separate single-user Personal workspaces from shared
|
|
55
55
|
# Organization workspaces. Both keep the same local-first JSON store; the type
|
|
@@ -78,6 +78,27 @@ If context or old chat history mentions those names, treat them only as legacy a
|
|
|
78
78
|
You are a Vision-Language Model (VLM). If an image is provided, analyze it.
|
|
79
79
|
Be concise and respond in the user's language."""
|
|
80
80
|
|
|
81
|
+
# Appended ONLY when retrieved context exists (review 2026-07-25 Wave 2.3):
|
|
82
|
+
# grounded answers should cite their sources and admit gaps. Advisory prompt
|
|
83
|
+
# guidance — grounding assessment stays annotation-only and never blocks.
|
|
84
|
+
CITATION_INSTRUCTION = """The Context section above contains retrieved sources.
|
|
85
|
+
Ground your claims in those sources and cite them inline as [1], [2], ... matching the order they appear in the Context.
|
|
86
|
+
If the context does not cover the question, say so instead of inventing sources.
|
|
87
|
+
Never cite a source that is not in the Context."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _compose_system(base: str, context: str) -> str:
|
|
91
|
+
"""Compose the system prompt with optional retrieved context.
|
|
92
|
+
|
|
93
|
+
Byte-compatible with the historical prompt when ``context`` is empty:
|
|
94
|
+
the return value is exactly ``base``. When context exists, the Context
|
|
95
|
+
block plus :data:`CITATION_INSTRUCTION` are appended.
|
|
96
|
+
"""
|
|
97
|
+
if not context:
|
|
98
|
+
return base
|
|
99
|
+
return f"{base}\n\nContext:\n{context}\n\n{CITATION_INSTRUCTION}"
|
|
100
|
+
|
|
101
|
+
|
|
81
102
|
def normalize_branding(text: Optional[str]) -> str:
|
|
82
103
|
if not text:
|
|
83
104
|
return ""
|
|
@@ -497,10 +518,8 @@ class LLMRouter:
|
|
|
497
518
|
return raw
|
|
498
519
|
|
|
499
520
|
def _build_prompt(self, message: str, context: Optional[str], tokenizer) -> str:
|
|
500
|
-
system = SYSTEM_PROMPT
|
|
501
521
|
context = normalize_branding(context)
|
|
502
|
-
|
|
503
|
-
system += f"\n\nContext:\n{context}"
|
|
522
|
+
system = _compose_system(SYSTEM_PROMPT, context)
|
|
504
523
|
if hasattr(tokenizer, "apply_chat_template"):
|
|
505
524
|
try:
|
|
506
525
|
msgs = [{"role": "system", "content": system}, {"role": "user", "content": message}]
|
|
@@ -510,10 +529,8 @@ class LLMRouter:
|
|
|
510
529
|
return f"<|im_start|>system\n{system}<|im_end|>\n<|im_start|>user\n{message}<|im_end|>\n<|im_start|>assistant\n"
|
|
511
530
|
|
|
512
531
|
def _build_vlm_prompt(self, model, processor, message: str, context: Optional[str], num_images: int) -> str:
|
|
513
|
-
system = SYSTEM_PROMPT
|
|
514
532
|
context = normalize_branding(context)
|
|
515
|
-
|
|
516
|
-
system += f"\n\nContext:\n{context}"
|
|
533
|
+
system = _compose_system(SYSTEM_PROMPT, context)
|
|
517
534
|
try:
|
|
518
535
|
from mlx_vlm import apply_chat_template
|
|
519
536
|
|
|
@@ -616,10 +633,8 @@ class LLMRouter:
|
|
|
616
633
|
return normalize_branding(str(result))
|
|
617
634
|
|
|
618
635
|
async def _cloud_generate(self, cloud: CloudModel, message: str, context: Optional[str], max_tokens: int, temperature: float) -> str:
|
|
619
|
-
system = SYSTEM_PROMPT
|
|
620
636
|
context = normalize_branding(context)
|
|
621
|
-
|
|
622
|
-
system += f"\n\nContext:\n{context}"
|
|
637
|
+
system = _compose_system(SYSTEM_PROMPT, context)
|
|
623
638
|
try:
|
|
624
639
|
response = await cloud.client.chat.completions.create(
|
|
625
640
|
model=cloud.model,
|
|
@@ -703,10 +718,8 @@ class LLMRouter:
|
|
|
703
718
|
yield chunk
|
|
704
719
|
|
|
705
720
|
async def _cloud_stream_generate(self, cloud: CloudModel, message: str, context: Optional[str], max_tokens: int, temperature: float) -> AsyncIterator[str]:
|
|
706
|
-
system = SYSTEM_PROMPT
|
|
707
721
|
context = normalize_branding(context)
|
|
708
|
-
|
|
709
|
-
system += f"\n\nContext:\n{context}"
|
|
722
|
+
system = _compose_system(SYSTEM_PROMPT, context)
|
|
710
723
|
try:
|
|
711
724
|
stream = await cloud.client.chat.completions.create(
|
|
712
725
|
model=cloud.model,
|
|
@@ -23,9 +23,10 @@ calls a model.
|
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
25
|
import logging
|
|
26
|
+
from datetime import datetime, timedelta
|
|
26
27
|
from typing import Any, Dict, List, Optional
|
|
27
28
|
|
|
28
|
-
from latticeai.core.timeutil import now_iso as _now
|
|
29
|
+
from latticeai.core.timeutil import local_now, now_iso as _now, parse_iso
|
|
29
30
|
|
|
30
31
|
LOGGER = logging.getLogger(__name__)
|
|
31
32
|
|
|
@@ -33,6 +34,13 @@ _RECENT_NODE_LIMIT = 6
|
|
|
33
34
|
_SEARCH_HISTORY_LIMIT = 2000
|
|
34
35
|
_BRIEFING_HISTORY_LIMIT = 2000
|
|
35
36
|
|
|
37
|
+
# Graph-hygiene advisory (review 2026-07-25 Wave 2.5): once the graph passes
|
|
38
|
+
# this many nodes with no noise-curate pass in the stale window, the briefing
|
|
39
|
+
# suggests a *dry-run* /knowledge-graph/curate/noise. Advisory only — nothing
|
|
40
|
+
# is deleted until the user explicitly applies the job.
|
|
41
|
+
_HYGIENE_MIN_NODES = 200
|
|
42
|
+
_HYGIENE_STALE_DAYS = 7
|
|
43
|
+
|
|
36
44
|
|
|
37
45
|
def _clip(text: Any, limit: int = 160) -> str:
|
|
38
46
|
value = str(text or "").strip()
|
|
@@ -240,6 +248,73 @@ class CommandCenterService:
|
|
|
240
248
|
]
|
|
241
249
|
return {"available": True, "count": len(suggestions), "top": top}
|
|
242
250
|
|
|
251
|
+
def _hygiene_section(self) -> Dict[str, Any]:
|
|
252
|
+
"""Advisory graph-hygiene signal (review 2026-07-25 Wave 2.5).
|
|
253
|
+
|
|
254
|
+
Suggests a dry-run noise-curate pass when the graph has grown past
|
|
255
|
+
``_HYGIENE_MIN_NODES`` nodes and the last applied pass is missing or
|
|
256
|
+
older than ``_HYGIENE_STALE_DAYS`` days. Fail-open: any backend error
|
|
257
|
+
degrades to "no suggestion" — the briefing itself never breaks.
|
|
258
|
+
"""
|
|
259
|
+
section: Dict[str, Any] = {
|
|
260
|
+
"available": False,
|
|
261
|
+
"suggest_noise_curate": False,
|
|
262
|
+
"reason": "",
|
|
263
|
+
"last_noise_curate_at": None,
|
|
264
|
+
"node_count": 0,
|
|
265
|
+
}
|
|
266
|
+
if not self._enable_graph or not hasattr(self._kg, "stats"):
|
|
267
|
+
return section
|
|
268
|
+
try:
|
|
269
|
+
stats = self._kg.stats() or {}
|
|
270
|
+
node_count = sum(
|
|
271
|
+
int(count or 0) for count in (stats.get("nodes") or {}).values()
|
|
272
|
+
)
|
|
273
|
+
last = (
|
|
274
|
+
self._kg.last_noise_curate_at()
|
|
275
|
+
if hasattr(self._kg, "last_noise_curate_at")
|
|
276
|
+
else None
|
|
277
|
+
)
|
|
278
|
+
section.update(
|
|
279
|
+
{
|
|
280
|
+
"available": True,
|
|
281
|
+
"node_count": node_count,
|
|
282
|
+
"last_noise_curate_at": last,
|
|
283
|
+
}
|
|
284
|
+
)
|
|
285
|
+
if node_count < _HYGIENE_MIN_NODES:
|
|
286
|
+
return section
|
|
287
|
+
if last is not None and not self._older_than_days(
|
|
288
|
+
last, _HYGIENE_STALE_DAYS
|
|
289
|
+
):
|
|
290
|
+
return section
|
|
291
|
+
section["suggest_noise_curate"] = True
|
|
292
|
+
section["reason"] = (
|
|
293
|
+
f"{node_count} nodes and no noise curation in the last "
|
|
294
|
+
f"{_HYGIENE_STALE_DAYS} days"
|
|
295
|
+
if last
|
|
296
|
+
else f"{node_count} nodes and no noise curation recorded"
|
|
297
|
+
)
|
|
298
|
+
except Exception:
|
|
299
|
+
LOGGER.exception("command center hygiene read failed")
|
|
300
|
+
return {
|
|
301
|
+
"available": False,
|
|
302
|
+
"suggest_noise_curate": False,
|
|
303
|
+
"reason": "",
|
|
304
|
+
"last_noise_curate_at": None,
|
|
305
|
+
"node_count": 0,
|
|
306
|
+
}
|
|
307
|
+
return section
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _older_than_days(stamp: str, days: int) -> bool:
|
|
311
|
+
parsed = parse_iso(stamp)
|
|
312
|
+
if parsed is None:
|
|
313
|
+
# Unreadable stamp → treat as stale; suggesting a dry-run is safe.
|
|
314
|
+
return True
|
|
315
|
+
now = datetime.now(parsed.tzinfo) if parsed.tzinfo else local_now()
|
|
316
|
+
return (now - parsed) > timedelta(days=days)
|
|
317
|
+
|
|
243
318
|
# ── quick actions ────────────────────────────────────────────────────
|
|
244
319
|
|
|
245
320
|
def _quick_actions(self, sections: Dict[str, Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
@@ -295,6 +370,19 @@ class CommandCenterService:
|
|
|
295
370
|
"target": "/brain/graph",
|
|
296
371
|
}
|
|
297
372
|
)
|
|
373
|
+
hygiene = sections.get("hygiene") or {}
|
|
374
|
+
if hygiene.get("suggest_noise_curate"):
|
|
375
|
+
actions.append(
|
|
376
|
+
{
|
|
377
|
+
"id": "curate-noise",
|
|
378
|
+
"kind": "hygiene",
|
|
379
|
+
"count": int(hygiene.get("node_count") or 0),
|
|
380
|
+
"target": "/brain/graph",
|
|
381
|
+
# Dry-run by default on the API side: this endpoint only
|
|
382
|
+
# *reports* removals until dry_run=false is sent explicitly.
|
|
383
|
+
"endpoint": "/knowledge-graph/curate/noise",
|
|
384
|
+
}
|
|
385
|
+
)
|
|
298
386
|
if not actions:
|
|
299
387
|
actions.append(
|
|
300
388
|
{"id": "ask-brain", "kind": "chat", "count": 0, "target": "/brain"}
|
|
@@ -319,6 +407,7 @@ class CommandCenterService:
|
|
|
319
407
|
"suggestions": self._suggestion_section(
|
|
320
408
|
user_email=user_email, workspace_id=workspace_id
|
|
321
409
|
),
|
|
410
|
+
"hygiene": self._hygiene_section(),
|
|
322
411
|
}
|
|
323
412
|
return {
|
|
324
413
|
"generated_at": _now(),
|
|
@@ -303,6 +303,10 @@ class FolderWatchService:
|
|
|
303
303
|
for key in ("status", "new", "changed", "removed", "ingested", "duplicate", "failed")
|
|
304
304
|
if key in result
|
|
305
305
|
}
|
|
306
|
+
# Watch trust UI (review Wave 1.3): keep a small sample of the
|
|
307
|
+
# latest scan's failures so the home health card can show *why*
|
|
308
|
+
# a watch is unhealthy, not just a count.
|
|
309
|
+
watch["last_errors"] = list(result.get("errors") or [])[:3]
|
|
306
310
|
if snapshot is not None:
|
|
307
311
|
watch["snapshot"] = snapshot
|
|
308
312
|
self._save_config()
|
|
@@ -353,6 +357,7 @@ class FolderWatchService:
|
|
|
353
357
|
"created_at": watch.get("created_at"),
|
|
354
358
|
"last_scan_at": watch.get("last_scan_at"),
|
|
355
359
|
"last_result": watch.get("last_result"),
|
|
360
|
+
"last_errors": list(watch.get("last_errors") or []),
|
|
356
361
|
"tracked_files": len(watch.get("snapshot") or {}),
|
|
357
362
|
}
|
|
358
363
|
|