ltcai 9.5.0 → 9.7.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 +67 -43
- package/auto_setup.py +11 -1
- package/docs/CHANGELOG.md +86 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +2 -2
- package/docs/DEVELOPMENT.md +8 -8
- package/docs/LEGACY_COMPATIBILITY.md +1 -1
- package/docs/ONBOARDING.md +2 -2
- package/docs/OPERATIONS.md +1 -1
- package/docs/PERFORMANCE.md +106 -0
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/docs/mcp-tools.md +1 -1
- package/kg_schema.py +11 -1
- package/knowledge_graph.py +12 -2
- package/knowledge_graph_api.py +11 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/proactive.py +583 -0
- package/lattice_brain/graph/retrieval.py +211 -7
- package/lattice_brain/graph/retrieval_vector.py +102 -0
- package/lattice_brain/ingestion.py +360 -4
- package/lattice_brain/quality.py +44 -9
- package/lattice_brain/runtime/multi_agent.py +38 -2
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +27 -2
- package/latticeai/api/change_proposals.py +89 -0
- package/latticeai/api/chat_agent_http.py +2 -0
- package/latticeai/api/review_queue.py +66 -2
- package/latticeai/app_factory.py +23 -0
- package/latticeai/cli/entrypoint.py +3 -1
- package/latticeai/core/agent.py +273 -101
- package/latticeai/core/agent_eval.py +411 -0
- package/latticeai/core/agent_trace.py +104 -0
- package/latticeai/core/legacy_compatibility.py +15 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/tool_governor.py +101 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/runtime/router_registration.py +2 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/brain_intelligence.py +97 -1
- package/latticeai/services/change_proposals.py +322 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/review_queue.py +47 -3
- package/latticeai/tools/__init__.py +6 -1
- package/llm_router.py +10 -1
- package/local_knowledge_api.py +10 -1
- package/ltcai_cli.py +11 -1
- package/mcp_registry.py +10 -1
- package/p_reinforce.py +10 -1
- package/package.json +1 -1
- package/scripts/agent_eval.py +46 -0
- package/scripts/brain_quality_eval.py +1 -1
- package/scripts/check_current_release_docs.mjs +2 -1
- package/scripts/profile_kg.py +360 -0
- package/setup_wizard.py +10 -1
- 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 +11 -11
- package/static/app/assets/{Act-Cdfqx4wN.js → Act-B6c39ays.js} +2 -1
- package/static/app/assets/{Brain-w_tAuyg6.js → Brain-D7Qg4k6M.js} +1 -1
- package/static/app/assets/{Capture-iJwi9LmS.js → Capture-VF_di68r.js} +1 -1
- package/static/app/assets/{Library-Do-jjhzi.js → Library-D_Gis2PA.js} +1 -1
- package/static/app/assets/{System-DFg_q3E6.js → System-C5s5H2ov.js} +1 -1
- package/static/app/assets/{index-BN6HIWVC.css → index-85wQvEie.css} +1 -1
- package/static/app/assets/index-DJC_2oub.js +18 -0
- package/static/app/assets/{primitives-ZTUlU7pR.js → primitives-DL4Nip8C.js} +1 -1
- package/static/app/assets/{textarea-CMfhqPhE.js → textarea-woZfCXHy.js} +1 -1
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/static/app/assets/index-aJuRi-Xo.js +0 -17
|
@@ -604,6 +604,7 @@ def register_review_and_brain_tail_routers(
|
|
|
604
604
|
gardener: Any,
|
|
605
605
|
create_setup_router: Any,
|
|
606
606
|
model_router: Any,
|
|
607
|
+
change_proposals: Any = None,
|
|
607
608
|
) -> Any:
|
|
608
609
|
"""Register the final review/browser/brain tail routes in legacy order."""
|
|
609
610
|
|
|
@@ -616,6 +617,7 @@ def register_review_and_brain_tail_routers(
|
|
|
616
617
|
gate_write=gate_write,
|
|
617
618
|
run_review_item=run_review_item,
|
|
618
619
|
append_audit_event=append_audit_event,
|
|
620
|
+
change_proposals=change_proposals,
|
|
619
621
|
),
|
|
620
622
|
create_browser_router(
|
|
621
623
|
pipeline=ingestion_pipeline,
|
|
@@ -65,6 +65,23 @@ class BrainIntelligenceService:
|
|
|
65
65
|
self._enable_graph = bool(enable_graph and knowledge_graph is not None)
|
|
66
66
|
self._memory_quality = MemoryQualityManager()
|
|
67
67
|
self._edge_quality = GraphEdgeQualityManager()
|
|
68
|
+
self._proactive_brain: Any = None
|
|
69
|
+
|
|
70
|
+
def _proactive(self) -> Any:
|
|
71
|
+
"""Lazy graph-layer ProactiveBrain over the injected store (or None)."""
|
|
72
|
+
if not self._enable_graph:
|
|
73
|
+
return None
|
|
74
|
+
if self._proactive_brain is None:
|
|
75
|
+
try:
|
|
76
|
+
from lattice_brain.graph.proactive import ProactiveBrain
|
|
77
|
+
|
|
78
|
+
self._proactive_brain = ProactiveBrain(
|
|
79
|
+
self._kg, sample_limit=_GRAPH_SAMPLE_LIMIT
|
|
80
|
+
)
|
|
81
|
+
except Exception:
|
|
82
|
+
LOGGER.exception("proactive brain initialization failed")
|
|
83
|
+
return None
|
|
84
|
+
return self._proactive_brain
|
|
68
85
|
|
|
69
86
|
# ── shared graph sampling ─────────────────────────────────────────────
|
|
70
87
|
|
|
@@ -302,6 +319,56 @@ class BrainIntelligenceService:
|
|
|
302
319
|
"generated_at": _now(),
|
|
303
320
|
}
|
|
304
321
|
|
|
322
|
+
# ── graph-layer proactive quality (v9.6.x) ───────────────────────────
|
|
323
|
+
|
|
324
|
+
def graph_duplicates(
|
|
325
|
+
self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
|
|
326
|
+
) -> Dict[str, Any]:
|
|
327
|
+
"""Duplicate graph nodes (exact groups + near pairs) — read only."""
|
|
328
|
+
proactive = self._proactive()
|
|
329
|
+
if proactive is None:
|
|
330
|
+
return {
|
|
331
|
+
"available": False,
|
|
332
|
+
"exact_groups": [],
|
|
333
|
+
"near_pairs": [],
|
|
334
|
+
"exact_duplicate_nodes": 0,
|
|
335
|
+
"nodes_scanned": 0,
|
|
336
|
+
"generated_at": _now(),
|
|
337
|
+
}
|
|
338
|
+
try:
|
|
339
|
+
result = dict(proactive.find_duplicates(workspace_id=workspace_id))
|
|
340
|
+
except Exception as exc:
|
|
341
|
+
LOGGER.exception("graph duplicates scan failed")
|
|
342
|
+
return {
|
|
343
|
+
"available": False,
|
|
344
|
+
"error": str(exc),
|
|
345
|
+
"exact_groups": [],
|
|
346
|
+
"near_pairs": [],
|
|
347
|
+
"exact_duplicate_nodes": 0,
|
|
348
|
+
"nodes_scanned": 0,
|
|
349
|
+
"generated_at": _now(),
|
|
350
|
+
}
|
|
351
|
+
result["available"] = True
|
|
352
|
+
result["generated_at"] = _now()
|
|
353
|
+
return result
|
|
354
|
+
|
|
355
|
+
def quality_report(
|
|
356
|
+
self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
|
|
357
|
+
) -> Dict[str, Any]:
|
|
358
|
+
"""Combined graph quality report: duplicates, contradictions, stale
|
|
359
|
+
nodes, edge quality — one workspace-scoped graph sample."""
|
|
360
|
+
proactive = self._proactive()
|
|
361
|
+
if proactive is None:
|
|
362
|
+
return {"available": False, "generated_at": _now()}
|
|
363
|
+
try:
|
|
364
|
+
result = dict(proactive.quality_report(workspace_id=workspace_id))
|
|
365
|
+
except Exception as exc:
|
|
366
|
+
LOGGER.exception("graph quality report failed")
|
|
367
|
+
return {"available": False, "error": str(exc), "generated_at": _now()}
|
|
368
|
+
result["available"] = True
|
|
369
|
+
result["generated_at"] = _now()
|
|
370
|
+
return result
|
|
371
|
+
|
|
305
372
|
# ── contradictions ───────────────────────────────────────────────────
|
|
306
373
|
|
|
307
374
|
def contradictions(
|
|
@@ -365,7 +432,21 @@ class BrainIntelligenceService:
|
|
|
365
432
|
"signal": "contradicts_edge",
|
|
366
433
|
})
|
|
367
434
|
|
|
368
|
-
|
|
435
|
+
# v9.6.x additive: graph-layer node-content contradictions (proactive
|
|
436
|
+
# detector over node title/summary), on top of the memory + edge scans.
|
|
437
|
+
graph_pair_items: List[Dict[str, Any]] = []
|
|
438
|
+
proactive = self._proactive()
|
|
439
|
+
if proactive is not None:
|
|
440
|
+
try:
|
|
441
|
+
graph_result = proactive.detect_contradictions(workspace_id=workspace_id)
|
|
442
|
+
graph_pair_items = [
|
|
443
|
+
{"kind": "graph_node_pair", **pair}
|
|
444
|
+
for pair in graph_result.get("node_pairs") or []
|
|
445
|
+
]
|
|
446
|
+
except Exception:
|
|
447
|
+
LOGGER.exception("graph contradiction scan failed")
|
|
448
|
+
|
|
449
|
+
items = conflicts + temporal_items + edge_items + graph_pair_items
|
|
369
450
|
return {
|
|
370
451
|
"items": items,
|
|
371
452
|
"count": len(items),
|
|
@@ -373,6 +454,7 @@ class BrainIntelligenceService:
|
|
|
373
454
|
"memory_pairs": len(conflicts),
|
|
374
455
|
"temporal": len(temporal_items),
|
|
375
456
|
"graph_edges": len(edge_items),
|
|
457
|
+
"graph_node_pairs": len(graph_pair_items),
|
|
376
458
|
},
|
|
377
459
|
"memories_scanned": len(memory_rows),
|
|
378
460
|
"generated_at": _now(),
|
|
@@ -422,6 +504,19 @@ class BrainIntelligenceService:
|
|
|
422
504
|
except Exception:
|
|
423
505
|
LOGGER.exception("consolidation prune failed")
|
|
424
506
|
|
|
507
|
+
# v9.6.x additive: graph-layer node merge plan. Always dry-run from
|
|
508
|
+
# this service — graph content changes stay proposal-first; the plan
|
|
509
|
+
# is surfaced so a governed apply path can adopt it later.
|
|
510
|
+
graph_consolidation: Optional[Dict[str, Any]] = None
|
|
511
|
+
proactive = self._proactive()
|
|
512
|
+
if proactive is not None:
|
|
513
|
+
try:
|
|
514
|
+
graph_consolidation = proactive.consolidate_duplicates(
|
|
515
|
+
workspace_id=workspace_id, dry_run=True
|
|
516
|
+
)
|
|
517
|
+
except Exception:
|
|
518
|
+
LOGGER.exception("graph consolidation plan failed")
|
|
519
|
+
|
|
425
520
|
return {
|
|
426
521
|
"mode": "applied" if apply else "dry_run",
|
|
427
522
|
"memories_scanned": len(memory_rows),
|
|
@@ -432,6 +527,7 @@ class BrainIntelligenceService:
|
|
|
432
527
|
# mutates graph content directly.
|
|
433
528
|
"duplicate_edges": duplicate_edge_ids[:50],
|
|
434
529
|
"duplicate_edge_count": len(duplicate_edge_ids),
|
|
530
|
+
"graph_consolidation": graph_consolidation,
|
|
435
531
|
"generated_at": _now(),
|
|
436
532
|
}
|
|
437
533
|
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Change proposals — 수정/삭제는 제안으로, 생성은 바로 (v9.6.0).
|
|
2
|
+
|
|
3
|
+
The consent model most users actually want is git's: creating something new
|
|
4
|
+
is cheap and reversible, but changing or deleting what already exists
|
|
5
|
+
deserves a review. This service implements that model for the agent
|
|
6
|
+
workspace:
|
|
7
|
+
|
|
8
|
+
* **additive** operations (new files, new notes) run with minimal friction;
|
|
9
|
+
* **mutation/destructive** operations (overwrite, in-place edit, delete of
|
|
10
|
+
an existing file) are *staged as proposals* instead of applied: a review
|
|
11
|
+
item (source ``change_proposal``) carrying the target path, a unified
|
|
12
|
+
diff, the exact resulting content, and a small/large tier. Nothing changes
|
|
13
|
+
on disk until the user approves; rejecting discards the staged change.
|
|
14
|
+
|
|
15
|
+
Approving applies the staged content exactly as reviewed (never recomputed),
|
|
16
|
+
then marks the review item approved — so the review timeline doubles as the
|
|
17
|
+
change audit log. The classification itself lives in
|
|
18
|
+
:mod:`latticeai.core.tool_governor` so every entry point shares one policy.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import difflib
|
|
24
|
+
import logging
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
27
|
+
|
|
28
|
+
from latticeai.core.tool_governor import classify_tool_call
|
|
29
|
+
|
|
30
|
+
LOGGER = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
_MAX_STAGED_BYTES = 400_000
|
|
33
|
+
_MAX_DIFF_LINES = 400
|
|
34
|
+
_SMALL_TIER_DIFF_LINES = 40
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _unified_diff(before: str, after: str, path: str) -> List[str]:
|
|
38
|
+
lines = list(
|
|
39
|
+
difflib.unified_diff(
|
|
40
|
+
before.splitlines(), after.splitlines(),
|
|
41
|
+
fromfile=f"a/{path}", tofile=f"b/{path}", lineterm="",
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
return lines[:_MAX_DIFF_LINES]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ChangeProposalService:
|
|
48
|
+
"""Stages mutation/destructive file changes as reviewable proposals."""
|
|
49
|
+
|
|
50
|
+
#: Tools whose plan-time approval is delegated to per-call governance.
|
|
51
|
+
governed_tools = frozenset({"write_file", "edit_file"})
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
review_queue: Any,
|
|
57
|
+
resolve_path: Callable[[str], Path],
|
|
58
|
+
audit: Optional[Callable[..., None]] = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self._review_queue = review_queue
|
|
61
|
+
self._resolve_path = resolve_path
|
|
62
|
+
self._audit = audit or (lambda *a, **kw: None)
|
|
63
|
+
|
|
64
|
+
# ── classification / staging (agent-loop governor port) ─────────────
|
|
65
|
+
|
|
66
|
+
def review(
|
|
67
|
+
self,
|
|
68
|
+
name: str,
|
|
69
|
+
args: Dict[str, Any],
|
|
70
|
+
*,
|
|
71
|
+
policy: Optional[Dict[str, Any]] = None,
|
|
72
|
+
user_email: Optional[str] = None,
|
|
73
|
+
workspace_id: Optional[str] = None,
|
|
74
|
+
conversation_id: Optional[str] = None,
|
|
75
|
+
) -> Optional[Dict[str, Any]]:
|
|
76
|
+
"""Governor port for the agent loop.
|
|
77
|
+
|
|
78
|
+
Returns ``None`` to fall through to the existing gates, or a verdict:
|
|
79
|
+
``{"decision": "allow_additive"}`` (execute without extra approval) /
|
|
80
|
+
``{"decision": "proposed", "proposal": {...}}`` (staged for review).
|
|
81
|
+
"""
|
|
82
|
+
if name not in {"write_file", "edit_file"}:
|
|
83
|
+
return None
|
|
84
|
+
verdict = classify_tool_call(
|
|
85
|
+
name, args, policy=policy, path_exists=self._path_exists
|
|
86
|
+
)
|
|
87
|
+
if verdict["change_class"] == "additive":
|
|
88
|
+
return {"decision": "allow_additive", "classification": verdict}
|
|
89
|
+
if not verdict["proposal_required"]:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
path = str(args.get("path") or "")
|
|
93
|
+
after = self._staged_content(name, args)
|
|
94
|
+
if after is None:
|
|
95
|
+
# The edit cannot be computed deterministically (e.g. old_string
|
|
96
|
+
# not found) — let the normal tool path surface the real error.
|
|
97
|
+
return None
|
|
98
|
+
try:
|
|
99
|
+
proposal = self.propose_file_update(
|
|
100
|
+
path=path,
|
|
101
|
+
new_content=after,
|
|
102
|
+
proposed_by="agent",
|
|
103
|
+
reason=verdict["reason"],
|
|
104
|
+
user_email=user_email,
|
|
105
|
+
workspace_id=workspace_id,
|
|
106
|
+
context={
|
|
107
|
+
# Full provenance for the Review Center: which tool asked,
|
|
108
|
+
# how the governor classified it, and the policy risk.
|
|
109
|
+
"tool": name,
|
|
110
|
+
"change_class": verdict.get("change_class"),
|
|
111
|
+
"risk": (policy or {}).get("risk"),
|
|
112
|
+
"conversation_id": conversation_id,
|
|
113
|
+
"source_detail": "agent change governor",
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
except Exception:
|
|
117
|
+
LOGGER.exception("change proposal staging failed")
|
|
118
|
+
return None
|
|
119
|
+
return {"decision": "proposed", "classification": verdict, "proposal": proposal}
|
|
120
|
+
|
|
121
|
+
def _path_exists(self, path: str) -> bool:
|
|
122
|
+
try:
|
|
123
|
+
return self._resolve_path(path).is_file()
|
|
124
|
+
except Exception:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
def _read_before(self, path: str) -> str:
|
|
128
|
+
try:
|
|
129
|
+
target = self._resolve_path(path)
|
|
130
|
+
if not target.is_file():
|
|
131
|
+
return ""
|
|
132
|
+
raw = target.read_bytes()[:_MAX_STAGED_BYTES]
|
|
133
|
+
return raw.decode("utf-8", errors="replace")
|
|
134
|
+
except Exception:
|
|
135
|
+
LOGGER.exception("change proposal read failed")
|
|
136
|
+
return ""
|
|
137
|
+
|
|
138
|
+
def _staged_content(self, name: str, args: Dict[str, Any]) -> Optional[str]:
|
|
139
|
+
if name == "write_file":
|
|
140
|
+
return str(args.get("content") or "")[:_MAX_STAGED_BYTES]
|
|
141
|
+
if name == "edit_file":
|
|
142
|
+
before = self._read_before(str(args.get("path") or ""))
|
|
143
|
+
old = str(args.get("old_string") or "")
|
|
144
|
+
new = str(args.get("new_string") or "")
|
|
145
|
+
if not old or old not in before:
|
|
146
|
+
return None
|
|
147
|
+
if args.get("replace_all"):
|
|
148
|
+
return before.replace(old, new)[:_MAX_STAGED_BYTES]
|
|
149
|
+
if before.count(old) != 1:
|
|
150
|
+
return None
|
|
151
|
+
return before.replace(old, new, 1)[:_MAX_STAGED_BYTES]
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
# ── proposal creation ────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
def propose_file_update(
|
|
157
|
+
self,
|
|
158
|
+
*,
|
|
159
|
+
path: str,
|
|
160
|
+
new_content: str,
|
|
161
|
+
proposed_by: str = "agent",
|
|
162
|
+
reason: str = "",
|
|
163
|
+
user_email: Optional[str] = None,
|
|
164
|
+
workspace_id: Optional[str] = None,
|
|
165
|
+
context: Optional[Dict[str, Any]] = None,
|
|
166
|
+
) -> Dict[str, Any]:
|
|
167
|
+
before = self._read_before(path)
|
|
168
|
+
diff = _unified_diff(before, new_content, path)
|
|
169
|
+
tier = "small" if len(diff) <= _SMALL_TIER_DIFF_LINES else "large"
|
|
170
|
+
provenance: Dict[str, Any] = {"proposed_by": proposed_by, "reason": reason}
|
|
171
|
+
for key, value in (context or {}).items():
|
|
172
|
+
if value is not None and key not in provenance:
|
|
173
|
+
provenance[key] = value
|
|
174
|
+
item = self._review_queue.create(
|
|
175
|
+
title=f"파일 수정 제안: {path}",
|
|
176
|
+
summary=reason or "기존 파일을 변경하는 작업이라 검토 후 적용됩니다.",
|
|
177
|
+
source="change_proposal",
|
|
178
|
+
kind="file_update",
|
|
179
|
+
payload={
|
|
180
|
+
"path": path,
|
|
181
|
+
"diff": diff,
|
|
182
|
+
"new_content": new_content[:_MAX_STAGED_BYTES],
|
|
183
|
+
"tier": tier,
|
|
184
|
+
"before_bytes": len(before.encode("utf-8")),
|
|
185
|
+
"after_bytes": len(new_content.encode("utf-8")),
|
|
186
|
+
},
|
|
187
|
+
provenance=provenance,
|
|
188
|
+
user_email=user_email,
|
|
189
|
+
workspace_id=workspace_id,
|
|
190
|
+
)
|
|
191
|
+
self._audit(
|
|
192
|
+
"change_proposal_created", user_email=user_email,
|
|
193
|
+
proposal_id=item.get("id"), path=path, kind="file_update", tier=tier,
|
|
194
|
+
)
|
|
195
|
+
return item
|
|
196
|
+
|
|
197
|
+
def propose_file_delete(
|
|
198
|
+
self,
|
|
199
|
+
*,
|
|
200
|
+
path: str,
|
|
201
|
+
proposed_by: str = "agent",
|
|
202
|
+
reason: str = "",
|
|
203
|
+
user_email: Optional[str] = None,
|
|
204
|
+
workspace_id: Optional[str] = None,
|
|
205
|
+
) -> Dict[str, Any]:
|
|
206
|
+
before = self._read_before(path)
|
|
207
|
+
item = self._review_queue.create(
|
|
208
|
+
title=f"파일 삭제 제안: {path}",
|
|
209
|
+
summary=reason or "기존 파일을 삭제하는 작업이라 검토 후 적용됩니다.",
|
|
210
|
+
source="change_proposal",
|
|
211
|
+
kind="file_delete",
|
|
212
|
+
payload={
|
|
213
|
+
"path": path,
|
|
214
|
+
"diff": _unified_diff(before, "", path),
|
|
215
|
+
"tier": "large",
|
|
216
|
+
"before_bytes": len(before.encode("utf-8")),
|
|
217
|
+
"after_bytes": 0,
|
|
218
|
+
},
|
|
219
|
+
provenance={"proposed_by": proposed_by, "reason": reason},
|
|
220
|
+
user_email=user_email,
|
|
221
|
+
workspace_id=workspace_id,
|
|
222
|
+
)
|
|
223
|
+
self._audit(
|
|
224
|
+
"change_proposal_created", user_email=user_email,
|
|
225
|
+
proposal_id=item.get("id"), path=path, kind="file_delete", tier="large",
|
|
226
|
+
)
|
|
227
|
+
return item
|
|
228
|
+
|
|
229
|
+
# ── listing / apply / reject ─────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
def pending(
|
|
232
|
+
self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
|
|
233
|
+
) -> Dict[str, Any]:
|
|
234
|
+
listed = self._review_queue.list(
|
|
235
|
+
workspace_id=workspace_id, user_email=user_email,
|
|
236
|
+
status="pending", source="change_proposal",
|
|
237
|
+
)
|
|
238
|
+
items = listed.get("items") or []
|
|
239
|
+
return {
|
|
240
|
+
"items": items,
|
|
241
|
+
"count": len(items),
|
|
242
|
+
"contract": {
|
|
243
|
+
"additive_writes": "auto",
|
|
244
|
+
"mutations": "proposal",
|
|
245
|
+
"deletions": "proposal",
|
|
246
|
+
"applied_content": "exactly_as_reviewed",
|
|
247
|
+
},
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
def counts(
|
|
251
|
+
self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
|
|
252
|
+
) -> Dict[str, Any]:
|
|
253
|
+
"""Pending proposal count for the Review Center badge."""
|
|
254
|
+
listed = self._review_queue.list(
|
|
255
|
+
workspace_id=workspace_id, user_email=user_email,
|
|
256
|
+
status="pending", source="change_proposal",
|
|
257
|
+
)
|
|
258
|
+
return {"pending": len(listed.get("items") or [])}
|
|
259
|
+
|
|
260
|
+
def get_proposal(
|
|
261
|
+
self, item_id: str, *, workspace_id: Optional[str] = None
|
|
262
|
+
) -> Dict[str, Any]:
|
|
263
|
+
"""Full proposal detail (diff + staged content) for the preview UI.
|
|
264
|
+
|
|
265
|
+
Raises ``KeyError`` when the id exists but is not a change proposal,
|
|
266
|
+
so the API surface cannot leak arbitrary review items.
|
|
267
|
+
"""
|
|
268
|
+
item = self._review_queue.get(item_id, workspace_id=workspace_id)
|
|
269
|
+
if item.get("source") != "change_proposal":
|
|
270
|
+
raise KeyError(f"not a change proposal: {item_id}")
|
|
271
|
+
return item
|
|
272
|
+
|
|
273
|
+
def approve_and_apply(
|
|
274
|
+
self, item_id: str, *, user_email: Optional[str] = None,
|
|
275
|
+
workspace_id: Optional[str] = None,
|
|
276
|
+
) -> Dict[str, Any]:
|
|
277
|
+
item = self._review_queue.get(item_id, workspace_id=workspace_id)
|
|
278
|
+
if item.get("source") != "change_proposal":
|
|
279
|
+
raise KeyError(f"not a change proposal: {item_id}")
|
|
280
|
+
payload = item.get("payload") or {}
|
|
281
|
+
kind = item.get("kind")
|
|
282
|
+
path = str(payload.get("path") or "")
|
|
283
|
+
target = self._resolve_path(path)
|
|
284
|
+
if kind == "file_update":
|
|
285
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
286
|
+
target.write_text(str(payload.get("new_content") or ""), encoding="utf-8")
|
|
287
|
+
elif kind == "file_delete":
|
|
288
|
+
if target.is_file():
|
|
289
|
+
target.unlink()
|
|
290
|
+
else:
|
|
291
|
+
raise ValueError(f"unknown change proposal kind: {kind}")
|
|
292
|
+
approved = self._review_queue.approve(item_id, workspace_id=workspace_id)
|
|
293
|
+
self._audit(
|
|
294
|
+
"change_proposal_applied", user_email=user_email,
|
|
295
|
+
proposal_id=item_id, path=path, kind=kind,
|
|
296
|
+
)
|
|
297
|
+
return {"item": approved, "applied": True, "path": path, "kind": kind}
|
|
298
|
+
|
|
299
|
+
def reject(
|
|
300
|
+
self, item_id: str, *, user_email: Optional[str] = None,
|
|
301
|
+
workspace_id: Optional[str] = None, reason: str = "",
|
|
302
|
+
) -> Dict[str, Any]:
|
|
303
|
+
self.get_proposal(item_id, workspace_id=workspace_id) # source guard
|
|
304
|
+
reason = str(reason or "").strip()[:500]
|
|
305
|
+
if reason:
|
|
306
|
+
try:
|
|
307
|
+
dismissed = self._review_queue.dismiss(
|
|
308
|
+
item_id, workspace_id=workspace_id, reason=reason
|
|
309
|
+
)
|
|
310
|
+
except TypeError:
|
|
311
|
+
# Older/fake queues without reason support still dismiss.
|
|
312
|
+
dismissed = self._review_queue.dismiss(item_id, workspace_id=workspace_id)
|
|
313
|
+
else:
|
|
314
|
+
dismissed = self._review_queue.dismiss(item_id, workspace_id=workspace_id)
|
|
315
|
+
self._audit(
|
|
316
|
+
"change_proposal_rejected", user_email=user_email, proposal_id=item_id,
|
|
317
|
+
reason=reason or None,
|
|
318
|
+
)
|
|
319
|
+
return {"item": dismissed, "applied": False, "reason": reason}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
__all__ = ["ChangeProposalService"]
|
|
@@ -28,7 +28,10 @@ OPEN_STATUSES = {"pending", "snoozed"}
|
|
|
28
28
|
TERMINAL_STATUSES = {"approved", "dismissed"}
|
|
29
29
|
ALL_STATUSES = OPEN_STATUSES | TERMINAL_STATUSES
|
|
30
30
|
|
|
31
|
-
REVIEW_SOURCES = frozenset({
|
|
31
|
+
REVIEW_SOURCES = frozenset({
|
|
32
|
+
"workflow_run", "trigger", "kg_change_digest", "chat_followup",
|
|
33
|
+
"agent_followup", "change_proposal",
|
|
34
|
+
})
|
|
32
35
|
|
|
33
36
|
# Which source statuses each action is allowed from.
|
|
34
37
|
_ALLOWED_FROM: Dict[str, set] = {
|
|
@@ -138,8 +141,49 @@ class ReviewQueueService:
|
|
|
138
141
|
updated = self._store.update_review_item(item_id, workspace_id=workspace_id, **patch)
|
|
139
142
|
return self._view(updated)
|
|
140
143
|
|
|
141
|
-
def dismiss(
|
|
142
|
-
|
|
144
|
+
def dismiss(
|
|
145
|
+
self, item_id: str, *, workspace_id: Optional[str] = None,
|
|
146
|
+
reason: Optional[str] = None,
|
|
147
|
+
) -> Dict[str, Any]:
|
|
148
|
+
"""Dismiss an item; an optional ``reason`` is kept in provenance.
|
|
149
|
+
|
|
150
|
+
The reason matters most for ``change_proposal`` items — the review
|
|
151
|
+
timeline doubles as the change audit log, so "why was this rejected"
|
|
152
|
+
should survive next to the staged diff.
|
|
153
|
+
"""
|
|
154
|
+
if not reason:
|
|
155
|
+
return self._transition(item_id, "dismiss", "dismissed", workspace_id=workspace_id)
|
|
156
|
+
item = self._store.get_review_item(item_id, workspace_id=workspace_id)
|
|
157
|
+
self._guard("dismiss", item)
|
|
158
|
+
provenance = dict(item.get("provenance") or {})
|
|
159
|
+
provenance["dismiss_reason"] = str(reason)[:500]
|
|
160
|
+
patch: Dict[str, Any] = {"status": "dismissed", "provenance": provenance}
|
|
161
|
+
if item.get("snoozed_until") is not None:
|
|
162
|
+
patch["snoozed_until"] = None
|
|
163
|
+
updated = self._store.update_review_item(item_id, workspace_id=workspace_id, **patch)
|
|
164
|
+
return self._view(updated)
|
|
165
|
+
|
|
166
|
+
def counts(
|
|
167
|
+
self, *, workspace_id: Optional[str] = None, user_email: Optional[str] = None,
|
|
168
|
+
) -> Dict[str, Any]:
|
|
169
|
+
"""Badge-friendly effective-status counts (pending includes expired snoozes)."""
|
|
170
|
+
items = [
|
|
171
|
+
self._view(it)
|
|
172
|
+
for it in self._store.list_review_items(
|
|
173
|
+
workspace_id=workspace_id, user_email=user_email,
|
|
174
|
+
)
|
|
175
|
+
]
|
|
176
|
+
pending = [it for it in items if it["effective_status"] == "pending"]
|
|
177
|
+
snoozed = [it for it in items if it["effective_status"] == "snoozed"]
|
|
178
|
+
by_source: Dict[str, int] = {}
|
|
179
|
+
for it in pending:
|
|
180
|
+
source = str(it.get("source") or "workflow_run")
|
|
181
|
+
by_source[source] = by_source.get(source, 0) + 1
|
|
182
|
+
return {
|
|
183
|
+
"pending": len(pending),
|
|
184
|
+
"snoozed": len(snoozed),
|
|
185
|
+
"pending_by_source": by_source,
|
|
186
|
+
}
|
|
143
187
|
|
|
144
188
|
def snooze(
|
|
145
189
|
self, item_id: str, *, until: str, workspace_id: Optional[str] = None,
|
|
@@ -124,6 +124,11 @@ def _resolve_path(path: str = "") -> Path:
|
|
|
124
124
|
return candidate
|
|
125
125
|
|
|
126
126
|
|
|
127
|
+
def resolve_workspace_path(path: str = "") -> Path:
|
|
128
|
+
"""Public alias of the sandboxed workspace path resolver (v9.6.0)."""
|
|
129
|
+
return _resolve_path(path)
|
|
130
|
+
|
|
131
|
+
|
|
127
132
|
def _relative(path: Path) -> str:
|
|
128
133
|
return str(path.relative_to(AGENT_ROOT))
|
|
129
134
|
|
|
@@ -259,7 +264,7 @@ def execute_tool(action: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
259
264
|
|
|
260
265
|
|
|
261
266
|
__all__ = [
|
|
262
|
-
"AGENT_ROOT", "ToolError", "ensure_agent_root",
|
|
267
|
+
"AGENT_ROOT", "ToolError", "ensure_agent_root", "resolve_workspace_path",
|
|
263
268
|
"list_dir", "workspace_tree", "read_file", "write_file", "edit_file", "grep",
|
|
264
269
|
"search_files", "inspect_html", "preview_url", "create_web_project",
|
|
265
270
|
"todo_read", "todo_write",
|
package/llm_router.py
CHANGED
|
@@ -5,7 +5,16 @@ monkeypatching keep working through the old import path.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import sys
|
|
8
|
+
import warnings
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
warnings.warn(
|
|
11
|
+
"Importing 'llm_router' from the repository root is deprecated; "
|
|
12
|
+
"use 'import latticeai.models.router' instead. "
|
|
13
|
+
"The root shim will be removed in a future major release.",
|
|
14
|
+
DeprecationWarning,
|
|
15
|
+
stacklevel=2,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
import latticeai.models.router as _impl # noqa: E402
|
|
10
19
|
|
|
11
20
|
sys.modules[__name__] = _impl
|
package/local_knowledge_api.py
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
"""Compatibility shim for :mod:`latticeai.services.local_knowledge`."""
|
|
2
2
|
|
|
3
3
|
import sys
|
|
4
|
+
import warnings
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
warnings.warn(
|
|
7
|
+
"Importing 'local_knowledge_api' from the repository root is deprecated; "
|
|
8
|
+
"use 'from latticeai.services import local_knowledge' instead. "
|
|
9
|
+
"The root shim will be removed in a future major release.",
|
|
10
|
+
DeprecationWarning,
|
|
11
|
+
stacklevel=2,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from latticeai.services import local_knowledge as _impl # noqa: E402
|
|
6
15
|
|
|
7
16
|
sys.modules[__name__] = _impl
|
package/ltcai_cli.py
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
"""Compatibility shim for the historical root CLI module."""
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import warnings
|
|
4
|
+
|
|
5
|
+
warnings.warn(
|
|
6
|
+
"Importing 'ltcai_cli' from the repository root is deprecated; "
|
|
7
|
+
"use 'from latticeai.cli import entrypoint' instead. "
|
|
8
|
+
"The root shim will be removed in a future major release.",
|
|
9
|
+
DeprecationWarning,
|
|
10
|
+
stacklevel=2,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from latticeai.cli import entrypoint as _impl # noqa: E402
|
|
4
14
|
|
|
5
15
|
|
|
6
16
|
if __name__ == "__main__":
|
package/mcp_registry.py
CHANGED
|
@@ -5,7 +5,16 @@ and monkeypatching keep working through the old import path.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import sys
|
|
8
|
+
import warnings
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
warnings.warn(
|
|
11
|
+
"Importing 'mcp_registry' from the repository root is deprecated; "
|
|
12
|
+
"use 'import latticeai.core.mcp_registry' instead. "
|
|
13
|
+
"The root shim will be removed in a future major release.",
|
|
14
|
+
DeprecationWarning,
|
|
15
|
+
stacklevel=2,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
import latticeai.core.mcp_registry as _impl # noqa: E402
|
|
10
19
|
|
|
11
20
|
sys.modules[__name__] = _impl
|
package/p_reinforce.py
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
"""Compatibility shim for the historical root P-Reinforce module."""
|
|
2
2
|
|
|
3
3
|
import sys
|
|
4
|
+
import warnings
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
warnings.warn(
|
|
7
|
+
"Importing 'p_reinforce' from the repository root is deprecated; "
|
|
8
|
+
"use 'from latticeai.services import p_reinforce' instead. "
|
|
9
|
+
"The root shim will be removed in a future major release.",
|
|
10
|
+
DeprecationWarning,
|
|
11
|
+
stacklevel=2,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from latticeai.services import p_reinforce as _impl # noqa: E402
|
|
6
15
|
|
|
7
16
|
sys.modules[__name__] = _impl
|