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.
Files changed (81) hide show
  1. package/README.md +43 -38
  2. package/docs/CHANGELOG.md +65 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/SURFACE_PARITY.md +47 -0
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/graph/_kg_common.py +335 -0
  13. package/lattice_brain/graph/discovery_index.py +8 -1
  14. package/lattice_brain/graph/ingest.py +43 -9
  15. package/lattice_brain/graph/projection.py +221 -37
  16. package/lattice_brain/graph/retrieval.py +61 -12
  17. package/lattice_brain/graph/retrieval_policy.py +174 -0
  18. package/lattice_brain/graph/retrieval_vector.py +122 -2
  19. package/lattice_brain/portability.py +41 -12
  20. package/lattice_brain/runtime/multi_agent.py +1 -1
  21. package/latticeai/__init__.py +1 -1
  22. package/latticeai/api/chat.py +6 -0
  23. package/latticeai/api/chat_agent_http.py +175 -6
  24. package/latticeai/api/chat_intents.py +37 -18
  25. package/latticeai/api/chat_stream.py +76 -2
  26. package/latticeai/api/knowledge_graph.py +35 -1
  27. package/latticeai/core/agent.py +264 -9
  28. package/latticeai/core/enterprise.py +5 -0
  29. package/latticeai/core/file_generation.py +130 -5
  30. package/latticeai/core/legacy_compatibility.py +1 -1
  31. package/latticeai/core/marketplace.py +1 -1
  32. package/latticeai/core/run_store.py +243 -0
  33. package/latticeai/core/workspace_os.py +1 -1
  34. package/latticeai/models/router.py +25 -12
  35. package/latticeai/services/architecture_readiness.py +1 -1
  36. package/latticeai/services/command_center.py +90 -1
  37. package/latticeai/services/folder_watch.py +5 -0
  38. package/latticeai/services/funnel_metrics.py +9 -1
  39. package/latticeai/services/product_readiness.py +1 -1
  40. package/latticeai/services/search_service.py +43 -13
  41. package/package.json +1 -1
  42. package/scripts/bench_agent_smoke.py +410 -0
  43. package/scripts/check_current_release_docs.mjs +1 -1
  44. package/scripts/funnel_soft_gate.py +192 -0
  45. package/src-tauri/Cargo.lock +1 -1
  46. package/src-tauri/Cargo.toml +1 -1
  47. package/src-tauri/tauri.conf.json +1 -1
  48. package/static/app/asset-manifest.json +29 -28
  49. package/static/app/assets/Act-BCmTU0E2.js +1 -0
  50. package/static/app/assets/{Brain-DEY9jLVt.js → Brain-CTnjox7w.js} +2 -2
  51. package/static/app/assets/BrainHome-BJ3sFNX0.js +2 -0
  52. package/static/app/assets/BrainSignals-C52lwZVD.js +1 -0
  53. package/static/app/assets/{Capture-CVZ09QXi.js → Capture-B6vBhFa3.js} +1 -1
  54. package/static/app/assets/{CommandPalette-DepwOQFv.js → CommandPalette-90u9FWFH.js} +1 -1
  55. package/static/app/assets/{Library-Bp0n-HlW.js → Library-pKCK0_tk.js} +1 -1
  56. package/static/app/assets/{LivingBrain-DxP4efJF.js → LivingBrain-Jlf2wFqI.js} +1 -1
  57. package/static/app/assets/{ProductFlow-DRbm7NEq.js → ProductFlow-yg1fKP1P.js} +1 -1
  58. package/static/app/assets/{ReviewCard-C4HAO7A3.js → ReviewCard-DWvD7n9h.js} +1 -1
  59. package/static/app/assets/{System-ByQcmJW-.js → System-BAEuHqNY.js} +1 -1
  60. package/static/app/assets/{bot-BNDyZLR7.js → bot-nB_buEZD.js} +1 -1
  61. package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
  62. package/static/app/assets/{circle-play-BkhdcHgd.js → circle-play-B-IsFL1y.js} +1 -1
  63. package/static/app/assets/{cpu-C6jjYm6i.js → cpu-CEPBHaBl.js} +1 -1
  64. package/static/app/assets/{folder-open-DjGIvDBQ.js → folder-open-hmN0N9cX.js} +1 -1
  65. package/static/app/assets/{hard-drive-BlSbwSaT.js → hard-drive-CBV_B_Yd.js} +1 -1
  66. package/static/app/assets/{index-Bge3DXW7.css → index-7FAfYm4v.css} +1 -1
  67. package/static/app/assets/{index-CHu7cgj3.js → index-DrmOCySv.js} +3 -3
  68. package/static/app/assets/{input-DVDI0YR3.js → input-gtVCg-ll.js} +1 -1
  69. package/static/app/assets/{navigation-BddhEWA0.js → navigation-Bot0hvuv.js} +1 -1
  70. package/static/app/assets/{network-pYQt5oBu.js → network-jE42eKfT.js} +1 -1
  71. package/static/app/assets/{primitives-D7gCdEvS.js → primitives-CX2Komon.js} +1 -1
  72. package/static/app/assets/{shield-alert-K9RKGQeg.js → shield-alert-BftATuAA.js} +1 -1
  73. package/static/app/assets/{textarea-sqQmoBKL.js → textarea-CiMJfOSI.js} +1 -1
  74. package/static/app/assets/{useFocusTrap-7EV9dFP2.js → useFocusTrap-B7RPGfFy.js} +1 -1
  75. package/static/app/assets/utils-SJUNVOj5.js +7 -0
  76. package/static/app/index.html +4 -4
  77. package/static/sw.js +1 -1
  78. package/static/app/assets/Act-DmdruVKV.js +0 -1
  79. package/static/app/assets/BrainHome-CeNaxjP1.js +0 -2
  80. package/static/app/assets/BrainSignals-CStjIqYi.js +0 -1
  81. 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 = _BUNDLE_HTML_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 web-project manifest from a creation request.
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, an HTML-page intent,
619
- at least one *additional* named technology (css/js), and no explicit
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))
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
  from typing import Any, Dict, List
15
15
 
16
16
 
17
- LEGACY_COMPATIBILITY_VERSION = "9.9.3"
17
+ LEGACY_COMPATIBILITY_VERSION = "9.9.4"
18
18
 
19
19
 
20
20
  @dataclass(frozen=True)
@@ -11,7 +11,7 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "9.9.3"
14
+ MARKETPLACE_VERSION = "9.9.4"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
16
16
 
17
17
 
@@ -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.3"
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
- if context:
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
- if context:
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
- if context:
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
- if context:
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,
@@ -17,7 +17,7 @@ from typing import Any, Dict, List
17
17
  from latticeai.core.legacy_compatibility import legacy_shim_report
18
18
 
19
19
 
20
- ARCHITECTURE_VERSION_TARGET = "9.9.3"
20
+ ARCHITECTURE_VERSION_TARGET = "9.9.4"
21
21
 
22
22
  PREFERRED_REFACTORING_ORDER = [
23
23
  "agent-runtime",
@@ -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