ltcai 9.9.5 → 9.9.9
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 +41 -36
- package/docs/CHANGELOG.md +193 -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 +3 -1
- package/docs/PERMISSION_MODE.md +107 -0
- package/docs/SURFACE_PARITY.md +38 -8
- 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/context.py +39 -4
- package/lattice_brain/graph/_kg_common.py +189 -14
- package/lattice_brain/graph/curator.py +62 -0
- package/lattice_brain/graph/discovery.py +76 -0
- package/lattice_brain/graph/ingest.py +37 -7
- package/lattice_brain/graph/retrieval_vector.py +10 -0
- package/lattice_brain/ingestion.py +9 -106
- package/lattice_brain/ingestion_jobs.py +133 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +8 -0
- package/latticeai/api/chat.py +9 -0
- package/latticeai/api/chat_agent_http.py +119 -2
- package/latticeai/api/chat_contracts.py +9 -0
- package/latticeai/api/chat_documents.py +16 -0
- package/latticeai/api/evidence_actions.py +48 -0
- package/latticeai/api/permission_mode.py +61 -0
- package/latticeai/api/project_sessions.py +112 -0
- package/latticeai/api/voice_capture.py +89 -0
- package/latticeai/app_factory.py +53 -0
- package/latticeai/core/agent.py +381 -43
- package/latticeai/core/agent_permission.py +160 -0
- package/latticeai/core/agent_profiles.py +110 -0
- package/latticeai/core/artifact_ledger.py +109 -0
- package/latticeai/core/context_builder.py +101 -2
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/permission_mode.py +296 -0
- package/latticeai/core/project_sessions.py +337 -0
- package/latticeai/core/run_explain.py +421 -0
- package/latticeai/core/run_store.py +5 -0
- package/latticeai/core/workspace_os.py +15 -80
- package/latticeai/core/workspace_review_items.py +107 -0
- package/latticeai/integrations/telegram_bot.py +188 -0
- package/latticeai/runtime/chat_wiring.py +8 -0
- package/latticeai/runtime/context_runtime.py +11 -0
- package/latticeai/runtime/permission_mode_wiring.py +116 -0
- package/latticeai/runtime/router_registration.py +9 -0
- package/latticeai/services/app_context.py +3 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/brain_intelligence.py +105 -0
- package/latticeai/services/evidence_actions.py +254 -0
- package/latticeai/services/funnel_metrics.py +111 -16
- package/latticeai/services/local_knowledge.py +26 -0
- package/latticeai/services/memory_service.py +12 -0
- package/latticeai/services/permission_mode_service.py +170 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/tool_dispatch.py +73 -4
- package/latticeai/services/voice_capture.py +189 -0
- package/package.json +5 -3
- package/scripts/check_bundle_budget.mjs +11 -0
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_i18n_namespace_coverage.mjs +148 -0
- package/skills/meeting_notes/SKILL.md +82 -0
- package/skills/meeting_notes/examples.md +41 -0
- package/skills/meeting_notes/risk.json +9 -0
- package/skills/meeting_notes/schema.json +55 -0
- package/skills/weekly_review/SKILL.md +75 -0
- package/skills/weekly_review/examples.md +39 -0
- package/skills/weekly_review/risk.json +9 -0
- package/skills/weekly_review/schema.json +55 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -29
- package/static/app/assets/Act-BFbpl2_Y.js +1 -0
- package/static/app/assets/AdminConsole-DaZPha9Y.js +1 -0
- package/static/app/assets/Brain-DGrV0heZ.js +321 -0
- package/static/app/assets/BrainHome-Re6Zxh_W.js +2 -0
- package/static/app/assets/BrainSignals-BQ11jDpJ.js +1 -0
- package/static/app/assets/Capture-DRaf1zLH.js +1 -0
- package/static/app/assets/CommandPalette-DAKPm4uJ.js +1 -0
- package/static/app/assets/LanguageSwitcher-BgTwHoWv.js +1 -0
- package/static/app/assets/Library-BVtrSt12.js +1 -0
- package/static/app/assets/LivingBrain-7glOmK0R.js +1 -0
- package/static/app/assets/ProductFlow-cB3EFinZ.js +1 -0
- package/static/app/assets/ReviewCard-CShhHS-w.js +3 -0
- package/static/app/assets/System-B4nQDy8a.js +1 -0
- package/static/app/assets/{bot-VmP0kkeA.js → bot-DtKkLfXj.js} +1 -1
- package/static/app/assets/brain-wVV63_1z.js +1 -0
- package/static/app/assets/button-BTxZDMFJ.js +1 -0
- package/static/app/assets/{circle-pause-T9POo4qy.js → circle-pause--qxF9-EO.js} +1 -1
- package/static/app/assets/{circle-play-Bz5iTD0p.js → circle-play-89x3S6jm.js} +1 -1
- package/static/app/assets/{cpu-CJhjRtNq.js → cpu-DTUI63vo.js} +1 -1
- package/static/app/assets/download-EJS5_O0f.js +1 -0
- package/static/app/assets/{folder-open-B2K_22VI.js → folder-open-BL9zYTnc.js} +1 -1
- package/static/app/assets/{hard-drive-CjJtYqHf.js → hard-drive-BFnwi1Lu.js} +1 -1
- package/static/app/assets/index-BFtDYI6l.js +10 -0
- package/static/app/assets/index-BY9bpxyx.css +2 -0
- package/static/app/assets/input-DEz5UjBz.js +1 -0
- package/static/app/assets/{network-Ck1nqc-v.js → network-zKa3jAwl.js} +1 -1
- package/static/app/assets/primitives-jNrZm2O9.js +1 -0
- package/static/app/assets/search-ku6-_6wY.js +1 -0
- package/static/app/assets/shield-alert-CpHeCsaF.js +1 -0
- package/static/app/assets/textarea-DpcQkn75.js +1 -0
- package/static/app/assets/{useFocusTrap-CsnIhsDJ.js → useFocusTrap-58TNCONM.js} +1 -1
- package/static/app/assets/{navigation-BPQqEQwr.js → useQuery-CcVlXlBd.js} +1 -1
- package/static/app/assets/users-C0cFMj6o.js +1 -0
- package/static/app/assets/utils-DYf7pqrR.js +7 -0
- package/static/app/assets/workspace-CjthHsLN.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-DmxfbqBp.js +0 -1
- package/static/app/assets/Brain-DK_zKCda.js +0 -321
- package/static/app/assets/BrainHome-CCE_0hni.js +0 -2
- package/static/app/assets/BrainSignals-r_J68_zh.js +0 -1
- package/static/app/assets/Capture-CvxwzpJZ.js +0 -1
- package/static/app/assets/CommandPalette-CVLiFIQC.js +0 -1
- package/static/app/assets/Library-4As9VUvy.js +0 -1
- package/static/app/assets/LivingBrain-BVWk58MW.js +0 -1
- package/static/app/assets/ProductFlow-B-Tz0Um4.js +0 -1
- package/static/app/assets/ReviewCard-Va3fPQuj.js +0 -3
- package/static/app/assets/System-DSR2zKWd.js +0 -1
- package/static/app/assets/index-7FAfYm4v.css +0 -2
- package/static/app/assets/index-CEu0Wqjl.js +0 -10
- package/static/app/assets/input-CSi6OBJ9.js +0 -1
- package/static/app/assets/primitives-B2f9N7Dh.js +0 -1
- package/static/app/assets/shield-alert-yA_Y8lOB.js +0 -1
- package/static/app/assets/textarea-C4wM0D-Z.js +0 -1
- package/static/app/assets/utils-BqoznzGP.js +0 -7
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Permission modes for agent/tool autonomy (v9.9.8).
|
|
2
|
+
|
|
3
|
+
Frontier agents expose a mode dial (Claude Code: default → acceptEdits →
|
|
4
|
+
auto → bypassPermissions; Cursor: allowlist → auto-review → run everything;
|
|
5
|
+
Codex: approval × sandbox). LatticeAI maps the same idea onto the existing
|
|
6
|
+
ToolRegistry + Change Governor without throwing those gates away.
|
|
7
|
+
|
|
8
|
+
Modes
|
|
9
|
+
-----
|
|
10
|
+
* ``strict`` — current fail-closed defaults (every non-auto tool needs
|
|
11
|
+
approval; mutations become review proposals).
|
|
12
|
+
* ``trusted`` — workspace autonomy: knowledge reads, workspace writes
|
|
13
|
+
(additive + mutation), and computer *observation* auto-run. Exec,
|
|
14
|
+
destructive, host-control, and system-sandbox writes still gate.
|
|
15
|
+
* ``bypass`` — YOLO within the agent workspace. Hard circuit breakers
|
|
16
|
+
still fire (blocked path prefixes, destructive root/home removals,
|
|
17
|
+
system-sandbox writes that escape the workspace).
|
|
18
|
+
|
|
19
|
+
Circuit breakers are mode-invariant: a mode never overrides a hard deny.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from enum import Enum
|
|
25
|
+
from typing import Any, Dict, Mapping, Optional, Set
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PermissionMode(str, Enum):
|
|
29
|
+
STRICT = "strict"
|
|
30
|
+
TRUSTED = "trusted"
|
|
31
|
+
BYPASS = "bypass"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
DEFAULT_MODE = PermissionMode.STRICT
|
|
35
|
+
|
|
36
|
+
# Computer-use split: observation is low-friction in trusted; control stays gated.
|
|
37
|
+
COMPUTER_OBSERVATION_TOOLS: Set[str] = frozenset({
|
|
38
|
+
"computer_screenshot",
|
|
39
|
+
"computer_status",
|
|
40
|
+
"computer_use_status",
|
|
41
|
+
"chrome_status",
|
|
42
|
+
"vision_analyze",
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
COMPUTER_CONTROL_TOOLS: Set[str] = frozenset({
|
|
46
|
+
"computer_click",
|
|
47
|
+
"computer_type",
|
|
48
|
+
"computer_key",
|
|
49
|
+
"computer_scroll",
|
|
50
|
+
"computer_drag",
|
|
51
|
+
"computer_move",
|
|
52
|
+
"computer_open_app",
|
|
53
|
+
"computer_open_url",
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
KNOWLEDGE_READ_TOOLS: Set[str] = frozenset({
|
|
57
|
+
"knowledge_search",
|
|
58
|
+
"knowledge_tree",
|
|
59
|
+
"obsidian_search",
|
|
60
|
+
"obsidian_tree",
|
|
61
|
+
"knowledge_graph_search",
|
|
62
|
+
"knowledge_graph_graph",
|
|
63
|
+
"knowledge_graph_context",
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
WORKSPACE_WRITE_TOOLS: Set[str] = frozenset({
|
|
67
|
+
"write_file",
|
|
68
|
+
"edit_file",
|
|
69
|
+
"create_docx",
|
|
70
|
+
"create_xlsx",
|
|
71
|
+
"create_pptx",
|
|
72
|
+
"create_pdf",
|
|
73
|
+
"create_web_project",
|
|
74
|
+
"knowledge_save",
|
|
75
|
+
"obsidian_save",
|
|
76
|
+
"knowledge_graph_ingest",
|
|
77
|
+
"todo_write",
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
# Always blocked regardless of mode (Claude-style circuit breakers).
|
|
81
|
+
HARD_BLOCK_SANDBOXES: Set[str] = frozenset({"system"})
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def normalize_mode(value: Any) -> PermissionMode:
|
|
85
|
+
"""Parse user/API/env input into a PermissionMode; unknown → strict."""
|
|
86
|
+
if isinstance(value, PermissionMode):
|
|
87
|
+
return value
|
|
88
|
+
text = str(value or "").strip().lower()
|
|
89
|
+
aliases = {
|
|
90
|
+
"strict": PermissionMode.STRICT,
|
|
91
|
+
"default": PermissionMode.STRICT,
|
|
92
|
+
"manual": PermissionMode.STRICT,
|
|
93
|
+
"trusted": PermissionMode.TRUSTED,
|
|
94
|
+
"acceptedits": PermissionMode.TRUSTED,
|
|
95
|
+
"accept_edits": PermissionMode.TRUSTED,
|
|
96
|
+
"workspace": PermissionMode.TRUSTED,
|
|
97
|
+
"bypass": PermissionMode.BYPASS,
|
|
98
|
+
"bypasspermissions": PermissionMode.BYPASS,
|
|
99
|
+
"bypass_permissions": PermissionMode.BYPASS,
|
|
100
|
+
"yolo": PermissionMode.BYPASS,
|
|
101
|
+
"dangerously-skip-permissions": PermissionMode.BYPASS,
|
|
102
|
+
}
|
|
103
|
+
return aliases.get(text, DEFAULT_MODE)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def mode_catalog() -> list[Dict[str, Any]]:
|
|
107
|
+
"""UI/API catalog for the mode selector."""
|
|
108
|
+
return [
|
|
109
|
+
{
|
|
110
|
+
"id": PermissionMode.STRICT.value,
|
|
111
|
+
"label": "Strict",
|
|
112
|
+
"label_ko": "엄격",
|
|
113
|
+
"summary": "Reads auto; writes and exec need approval or review proposals.",
|
|
114
|
+
"summary_ko": "읽기는 자동, 쓰기·실행은 승인 또는 변경 제안.",
|
|
115
|
+
"risk": "low",
|
|
116
|
+
"requires_ack": False,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"id": PermissionMode.TRUSTED.value,
|
|
120
|
+
"label": "Trusted",
|
|
121
|
+
"label_ko": "신뢰",
|
|
122
|
+
"summary": "Workspace writes and knowledge reads auto-run; exec/desktop control still gated.",
|
|
123
|
+
"summary_ko": "워크스페이스 쓰기·지식 읽기 자동. 실행·데스크톱 제어는 승인 필요.",
|
|
124
|
+
"risk": "medium",
|
|
125
|
+
"requires_ack": False,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"id": PermissionMode.BYPASS.value,
|
|
129
|
+
"label": "Bypass",
|
|
130
|
+
"label_ko": "바이패스",
|
|
131
|
+
"summary": "YOLO inside the agent workspace. Hard circuit breakers still apply.",
|
|
132
|
+
"summary_ko": "에이전트 워크스페이스 안에서 전부 자동. 하드 차단만 남음.",
|
|
133
|
+
"risk": "high",
|
|
134
|
+
"requires_ack": True,
|
|
135
|
+
"warning": (
|
|
136
|
+
"Bypass skips routine approval prompts. Destructive system paths, "
|
|
137
|
+
"root/home wipes, and blocked prefixes remain denied."
|
|
138
|
+
),
|
|
139
|
+
"warning_ko": (
|
|
140
|
+
"바이패스는 일상 승인 프롬프트를 건너뜁니다. 시스템 경로 파괴, "
|
|
141
|
+
"루트/홈 삭제, 차단 접두사는 계속 거부됩니다."
|
|
142
|
+
),
|
|
143
|
+
},
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def is_circuit_breaker(
|
|
148
|
+
tool_name: str,
|
|
149
|
+
policy: Mapping[str, Any],
|
|
150
|
+
args: Optional[Mapping[str, Any]] = None,
|
|
151
|
+
) -> Optional[str]:
|
|
152
|
+
"""Return a reason string when the call must be denied in every mode."""
|
|
153
|
+
risk = str(policy.get("risk") or "")
|
|
154
|
+
destructive = bool(policy.get("destructive"))
|
|
155
|
+
|
|
156
|
+
if destructive or risk == "destructive":
|
|
157
|
+
return "destructive action is always blocked"
|
|
158
|
+
|
|
159
|
+
# NOTE: system-sandbox writes are *mode-sensitive*, not circuit breakers —
|
|
160
|
+
# ``effective_auto_approve`` keeps them gated under every mode and the
|
|
161
|
+
# blocked-path-prefix guard upstream denies escapes outright. Deciding them
|
|
162
|
+
# here would make ``bypass`` unable to drive desktop control at all.
|
|
163
|
+
|
|
164
|
+
path = str((args or {}).get("path") or (args or {}).get("filename") or "")
|
|
165
|
+
if path:
|
|
166
|
+
normalized = path.replace("\\", "/")
|
|
167
|
+
# Root / home mass-delete style paths — Claude circuit breaker analogue.
|
|
168
|
+
if normalized in {"/", "~", "/home", "/Users"} or normalized.rstrip("/") in {"/", "~"}:
|
|
169
|
+
return f"circuit breaker: refusing path {path!r}"
|
|
170
|
+
|
|
171
|
+
command = str((args or {}).get("command") or (args or {}).get("cmd") or "")
|
|
172
|
+
lowered = command.lower()
|
|
173
|
+
if any(token in lowered for token in ("rm -rf /", "rm -rf ~", "rm -rf /*", "rm -rf $home")):
|
|
174
|
+
return "circuit breaker: refusing destructive shell command"
|
|
175
|
+
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def effective_auto_approve(
|
|
180
|
+
mode: PermissionMode | str,
|
|
181
|
+
tool_name: str,
|
|
182
|
+
policy: Mapping[str, Any],
|
|
183
|
+
*,
|
|
184
|
+
change_class: Optional[str] = None,
|
|
185
|
+
args: Optional[Mapping[str, Any]] = None,
|
|
186
|
+
) -> bool:
|
|
187
|
+
"""Whether this tool call may run without an extra human approval prompt.
|
|
188
|
+
|
|
189
|
+
Does **not** override circuit breakers — callers must check
|
|
190
|
+
:func:`is_circuit_breaker` first and deny when it returns a reason.
|
|
191
|
+
"""
|
|
192
|
+
mode = normalize_mode(mode)
|
|
193
|
+
if policy.get("auto_approve"):
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
risk = str(policy.get("risk") or "")
|
|
197
|
+
sandbox = str(policy.get("sandbox") or "workspace")
|
|
198
|
+
|
|
199
|
+
if mode == PermissionMode.STRICT:
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
if mode == PermissionMode.TRUSTED:
|
|
203
|
+
if tool_name in KNOWLEDGE_READ_TOOLS:
|
|
204
|
+
return True
|
|
205
|
+
if tool_name in COMPUTER_OBSERVATION_TOOLS:
|
|
206
|
+
return True
|
|
207
|
+
if tool_name in WORKSPACE_WRITE_TOOLS and sandbox == "workspace":
|
|
208
|
+
# Additive and mutation both auto under trusted; destructive still
|
|
209
|
+
# blocked by circuit breaker / risk check above.
|
|
210
|
+
if risk in {"write", "write_scoped", "read"} or change_class in {
|
|
211
|
+
"additive", "mutation", "read", None,
|
|
212
|
+
}:
|
|
213
|
+
if risk != "destructive" and not policy.get("destructive"):
|
|
214
|
+
return True
|
|
215
|
+
# Explicit reads that were gated only for consent (local_list etc.) stay gated.
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
# bypass
|
|
219
|
+
if risk == "destructive" or policy.get("destructive"):
|
|
220
|
+
return False
|
|
221
|
+
if sandbox == "system" and tool_name not in COMPUTER_OBSERVATION_TOOLS | COMPUTER_CONTROL_TOOLS:
|
|
222
|
+
# Non-desktop system tools stay gated even in bypass.
|
|
223
|
+
if risk in {"write", "exec"}:
|
|
224
|
+
return False
|
|
225
|
+
return True
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def should_stage_proposal(
|
|
229
|
+
mode: PermissionMode | str,
|
|
230
|
+
*,
|
|
231
|
+
proposal_required: bool,
|
|
232
|
+
) -> bool:
|
|
233
|
+
"""Whether mutation/destructive file changes should become Review proposals.
|
|
234
|
+
|
|
235
|
+
* strict — yes (proposal-first, current behaviour)
|
|
236
|
+
* trusted — no for mutations (auto-apply + audit); destructive still blocked
|
|
237
|
+
upstream so this only affects mutation proposals
|
|
238
|
+
* bypass — no
|
|
239
|
+
"""
|
|
240
|
+
if not proposal_required:
|
|
241
|
+
return False
|
|
242
|
+
mode = normalize_mode(mode)
|
|
243
|
+
return mode == PermissionMode.STRICT
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def plan_requires_approval(
|
|
247
|
+
mode: PermissionMode | str,
|
|
248
|
+
*,
|
|
249
|
+
non_auto_steps: list,
|
|
250
|
+
plan_flag: bool = False,
|
|
251
|
+
) -> bool:
|
|
252
|
+
"""Plan-level gate used by SingleAgentRuntime.approval_requirements."""
|
|
253
|
+
mode = normalize_mode(mode)
|
|
254
|
+
if mode == PermissionMode.BYPASS:
|
|
255
|
+
return False
|
|
256
|
+
if mode == PermissionMode.TRUSTED:
|
|
257
|
+
# Only steps that remain non-auto under trusted force a plan pause.
|
|
258
|
+
return bool(non_auto_steps) or bool(plan_flag)
|
|
259
|
+
return bool(non_auto_steps) or bool(plan_flag)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def mode_contract(mode: PermissionMode | str) -> Dict[str, Any]:
|
|
263
|
+
"""Serializable contract for API / agent responses."""
|
|
264
|
+
mode = normalize_mode(mode)
|
|
265
|
+
entry = next((m for m in mode_catalog() if m["id"] == mode.value), mode_catalog()[0])
|
|
266
|
+
return {
|
|
267
|
+
"mode": mode.value,
|
|
268
|
+
"label": entry["label"],
|
|
269
|
+
"label_ko": entry["label_ko"],
|
|
270
|
+
"risk": entry["risk"],
|
|
271
|
+
"requires_ack": entry["requires_ack"],
|
|
272
|
+
"proposal_first": mode == PermissionMode.STRICT,
|
|
273
|
+
"workspace_writes_auto": mode in {PermissionMode.TRUSTED, PermissionMode.BYPASS},
|
|
274
|
+
"knowledge_reads_auto": mode in {PermissionMode.TRUSTED, PermissionMode.BYPASS},
|
|
275
|
+
"exec_auto": mode == PermissionMode.BYPASS,
|
|
276
|
+
"computer_observation_auto": mode in {PermissionMode.TRUSTED, PermissionMode.BYPASS},
|
|
277
|
+
"computer_control_auto": mode == PermissionMode.BYPASS,
|
|
278
|
+
"circuit_breakers": True,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
__all__ = [
|
|
283
|
+
"PermissionMode",
|
|
284
|
+
"DEFAULT_MODE",
|
|
285
|
+
"COMPUTER_OBSERVATION_TOOLS",
|
|
286
|
+
"COMPUTER_CONTROL_TOOLS",
|
|
287
|
+
"KNOWLEDGE_READ_TOOLS",
|
|
288
|
+
"WORKSPACE_WRITE_TOOLS",
|
|
289
|
+
"normalize_mode",
|
|
290
|
+
"mode_catalog",
|
|
291
|
+
"is_circuit_breaker",
|
|
292
|
+
"effective_auto_approve",
|
|
293
|
+
"should_stage_proposal",
|
|
294
|
+
"plan_requires_approval",
|
|
295
|
+
"mode_contract",
|
|
296
|
+
]
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Persistent project sessions — the multi-turn project loop (v9.9.6).
|
|
2
|
+
|
|
3
|
+
A single agent run is well modelled: PLAN → EXECUTE → VERIFY, with a durable
|
|
4
|
+
approval pause and a transcript. What was missing is the loop *above* it —
|
|
5
|
+
"만들고 → 고치고 → 검증하고 → 다시 고치는" work that spans several runs and
|
|
6
|
+
several days. Each run started from a blank workspace picture, so the second
|
|
7
|
+
turn could not see what the first produced, what still failed verification, or
|
|
8
|
+
what was left to do.
|
|
9
|
+
|
|
10
|
+
A project session is that missing state:
|
|
11
|
+
|
|
12
|
+
* ``files`` — every artifact the project's runs actually produced;
|
|
13
|
+
* ``todos`` — what is still open (explicit, user- or agent-authored);
|
|
14
|
+
* ``last_verification`` — the most recent honest outcome (never upgraded);
|
|
15
|
+
* ``runs`` — the run history that got there.
|
|
16
|
+
|
|
17
|
+
Storage mirrors :mod:`latticeai.core.run_store`: one JSON file per session
|
|
18
|
+
under the data dir, atomic replace on write, no new dependencies. Reads are
|
|
19
|
+
fail-open (a corrupt file is skipped, not fatal); writes never break a run —
|
|
20
|
+
a project session is context, not a gate.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
import re
|
|
27
|
+
import secrets
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Dict, List, Optional
|
|
30
|
+
|
|
31
|
+
from latticeai.core.io_utils import atomic_write_json
|
|
32
|
+
from latticeai.core.timeutil import now_iso
|
|
33
|
+
|
|
34
|
+
LOGGER = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
__all__ = ["ProjectSessionStore", "PROJECT_STATUSES"]
|
|
37
|
+
|
|
38
|
+
# Terminal-ish project states. "active" is the default; "archived" hides a
|
|
39
|
+
# project from the default listing without deleting its history.
|
|
40
|
+
PROJECT_STATUSES = ("active", "archived")
|
|
41
|
+
|
|
42
|
+
_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
|
|
43
|
+
_MAX_FILES = 200
|
|
44
|
+
_MAX_TODOS = 100
|
|
45
|
+
_MAX_RUNS = 100
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _clean(value: Any, limit: int) -> str:
|
|
49
|
+
return str(value or "").strip()[:limit]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ProjectSessionStore:
|
|
53
|
+
"""JSON-file store for long-running project sessions."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, root: Path) -> None:
|
|
56
|
+
self._root = Path(root)
|
|
57
|
+
|
|
58
|
+
# ── paths ────────────────────────────────────────────────────────────
|
|
59
|
+
def _path(self, session_id: str) -> Optional[Path]:
|
|
60
|
+
if not _SESSION_ID_RE.match(str(session_id or "")):
|
|
61
|
+
return None
|
|
62
|
+
return self._root / f"{session_id}.json"
|
|
63
|
+
|
|
64
|
+
def _ensure_root(self) -> None:
|
|
65
|
+
self._root.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
|
|
67
|
+
# ── read ─────────────────────────────────────────────────────────────
|
|
68
|
+
def _load(self, session_id: str) -> Optional[Dict[str, Any]]:
|
|
69
|
+
path = self._path(session_id)
|
|
70
|
+
if path is None or not path.exists():
|
|
71
|
+
return None
|
|
72
|
+
try:
|
|
73
|
+
import json
|
|
74
|
+
|
|
75
|
+
with path.open(encoding="utf-8") as handle:
|
|
76
|
+
data = json.load(handle)
|
|
77
|
+
return data if isinstance(data, dict) else None
|
|
78
|
+
except Exception as exc: # noqa: BLE001 — a bad file is skipped, not fatal
|
|
79
|
+
LOGGER.warning("project session load failed for %s: %s", session_id, exc)
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
def get(
|
|
83
|
+
self,
|
|
84
|
+
session_id: str,
|
|
85
|
+
*,
|
|
86
|
+
user_email: Optional[str] = None,
|
|
87
|
+
workspace_id: Optional[str] = None,
|
|
88
|
+
) -> Optional[Dict[str, Any]]:
|
|
89
|
+
"""One session, scoped. Returns None when it does not exist or is
|
|
90
|
+
outside the caller's scope — never a partial record."""
|
|
91
|
+
record = self._load(session_id)
|
|
92
|
+
if record is None:
|
|
93
|
+
return None
|
|
94
|
+
if user_email is not None and record.get("user_email") != user_email:
|
|
95
|
+
return None
|
|
96
|
+
if workspace_id is not None and record.get("workspace_id") != workspace_id:
|
|
97
|
+
return None
|
|
98
|
+
return record
|
|
99
|
+
|
|
100
|
+
def list(
|
|
101
|
+
self,
|
|
102
|
+
*,
|
|
103
|
+
user_email: Optional[str] = None,
|
|
104
|
+
workspace_id: Optional[str] = None,
|
|
105
|
+
status: str = "active",
|
|
106
|
+
) -> Dict[str, Any]:
|
|
107
|
+
"""Sessions for a caller, newest first. ``status="all"`` includes archived."""
|
|
108
|
+
if not self._root.exists():
|
|
109
|
+
return {"projects": [], "count": 0}
|
|
110
|
+
sessions: List[Dict[str, Any]] = []
|
|
111
|
+
for path in self._root.glob("*.json"):
|
|
112
|
+
record = self._load(path.stem)
|
|
113
|
+
if record is None:
|
|
114
|
+
continue
|
|
115
|
+
if user_email is not None and record.get("user_email") != user_email:
|
|
116
|
+
continue
|
|
117
|
+
if workspace_id is not None and record.get("workspace_id") != workspace_id:
|
|
118
|
+
continue
|
|
119
|
+
if status != "all" and record.get("status") != status:
|
|
120
|
+
continue
|
|
121
|
+
sessions.append(record)
|
|
122
|
+
sessions.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
|
|
123
|
+
return {"projects": sessions, "count": len(sessions)}
|
|
124
|
+
|
|
125
|
+
# ── write ────────────────────────────────────────────────────────────
|
|
126
|
+
def _save(self, record: Dict[str, Any]) -> Dict[str, Any]:
|
|
127
|
+
record["updated_at"] = now_iso()
|
|
128
|
+
path = self._path(str(record.get("id") or ""))
|
|
129
|
+
if path is None:
|
|
130
|
+
raise ValueError("invalid project session id")
|
|
131
|
+
try:
|
|
132
|
+
self._ensure_root()
|
|
133
|
+
atomic_write_json(path, record)
|
|
134
|
+
except Exception as exc: # noqa: BLE001 — persistence never breaks a run
|
|
135
|
+
LOGGER.warning("project session save failed for %s: %s", record.get("id"), exc)
|
|
136
|
+
return record
|
|
137
|
+
|
|
138
|
+
def create(
|
|
139
|
+
self,
|
|
140
|
+
*,
|
|
141
|
+
title: str,
|
|
142
|
+
goal: str = "",
|
|
143
|
+
user_email: Optional[str] = None,
|
|
144
|
+
workspace_id: Optional[str] = None,
|
|
145
|
+
) -> Dict[str, Any]:
|
|
146
|
+
session_id = secrets.token_urlsafe(12)
|
|
147
|
+
record = {
|
|
148
|
+
"id": session_id,
|
|
149
|
+
"title": _clean(title, 200) or "프로젝트",
|
|
150
|
+
"goal": _clean(goal, 2000),
|
|
151
|
+
"status": "active",
|
|
152
|
+
"user_email": user_email,
|
|
153
|
+
"workspace_id": workspace_id,
|
|
154
|
+
"created_at": now_iso(),
|
|
155
|
+
"updated_at": now_iso(),
|
|
156
|
+
"files": [],
|
|
157
|
+
"todos": [],
|
|
158
|
+
"runs": [],
|
|
159
|
+
"last_verification": None,
|
|
160
|
+
}
|
|
161
|
+
return self._save(record)
|
|
162
|
+
|
|
163
|
+
def update(
|
|
164
|
+
self,
|
|
165
|
+
session_id: str,
|
|
166
|
+
*,
|
|
167
|
+
title: Optional[str] = None,
|
|
168
|
+
goal: Optional[str] = None,
|
|
169
|
+
status: Optional[str] = None,
|
|
170
|
+
user_email: Optional[str] = None,
|
|
171
|
+
workspace_id: Optional[str] = None,
|
|
172
|
+
) -> Optional[Dict[str, Any]]:
|
|
173
|
+
record = self.get(session_id, user_email=user_email, workspace_id=workspace_id)
|
|
174
|
+
if record is None:
|
|
175
|
+
return None
|
|
176
|
+
if title is not None:
|
|
177
|
+
record["title"] = _clean(title, 200) or record["title"]
|
|
178
|
+
if goal is not None:
|
|
179
|
+
record["goal"] = _clean(goal, 2000)
|
|
180
|
+
if status is not None and status in PROJECT_STATUSES:
|
|
181
|
+
record["status"] = status
|
|
182
|
+
return self._save(record)
|
|
183
|
+
|
|
184
|
+
def delete(
|
|
185
|
+
self,
|
|
186
|
+
session_id: str,
|
|
187
|
+
*,
|
|
188
|
+
user_email: Optional[str] = None,
|
|
189
|
+
workspace_id: Optional[str] = None,
|
|
190
|
+
) -> bool:
|
|
191
|
+
record = self.get(session_id, user_email=user_email, workspace_id=workspace_id)
|
|
192
|
+
if record is None:
|
|
193
|
+
return False
|
|
194
|
+
path = self._path(session_id)
|
|
195
|
+
try:
|
|
196
|
+
if path is not None and path.exists():
|
|
197
|
+
path.unlink()
|
|
198
|
+
return True
|
|
199
|
+
except OSError as exc:
|
|
200
|
+
LOGGER.warning("project session delete failed for %s: %s", session_id, exc)
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
def set_todos(
|
|
204
|
+
self,
|
|
205
|
+
session_id: str,
|
|
206
|
+
todos: List[Any],
|
|
207
|
+
*,
|
|
208
|
+
user_email: Optional[str] = None,
|
|
209
|
+
workspace_id: Optional[str] = None,
|
|
210
|
+
) -> Optional[Dict[str, Any]]:
|
|
211
|
+
"""Replace the open-work list. Accepts strings or ``{text, done}`` dicts."""
|
|
212
|
+
record = self.get(session_id, user_email=user_email, workspace_id=workspace_id)
|
|
213
|
+
if record is None:
|
|
214
|
+
return None
|
|
215
|
+
normalized: List[Dict[str, Any]] = []
|
|
216
|
+
for raw in list(todos or [])[:_MAX_TODOS]:
|
|
217
|
+
if isinstance(raw, dict):
|
|
218
|
+
text = _clean(raw.get("text"), 300)
|
|
219
|
+
done = bool(raw.get("done"))
|
|
220
|
+
else:
|
|
221
|
+
text = _clean(raw, 300)
|
|
222
|
+
done = False
|
|
223
|
+
if text:
|
|
224
|
+
normalized.append({"text": text, "done": done})
|
|
225
|
+
record["todos"] = normalized
|
|
226
|
+
return self._save(record)
|
|
227
|
+
|
|
228
|
+
def record_run(
|
|
229
|
+
self,
|
|
230
|
+
session_id: str,
|
|
231
|
+
*,
|
|
232
|
+
run_id: str = "",
|
|
233
|
+
goal: str = "",
|
|
234
|
+
status: str = "",
|
|
235
|
+
final_state: str = "",
|
|
236
|
+
files: Optional[List[Any]] = None,
|
|
237
|
+
explanation: Optional[Dict[str, Any]] = None,
|
|
238
|
+
user_email: Optional[str] = None,
|
|
239
|
+
workspace_id: Optional[str] = None,
|
|
240
|
+
) -> Optional[Dict[str, Any]]:
|
|
241
|
+
"""Fold one finished agent run into the project's state.
|
|
242
|
+
|
|
243
|
+
The verification result is recorded exactly as the run reported it —
|
|
244
|
+
a NEEDS_REVIEW run never becomes a project's "done".
|
|
245
|
+
"""
|
|
246
|
+
record = self.get(session_id, user_email=user_email, workspace_id=workspace_id)
|
|
247
|
+
if record is None:
|
|
248
|
+
return None
|
|
249
|
+
paths: List[str] = []
|
|
250
|
+
for entry in list(files or []):
|
|
251
|
+
path = entry if isinstance(entry, str) else (entry or {}).get("path")
|
|
252
|
+
path = _clean(path, 400)
|
|
253
|
+
if path and path not in paths:
|
|
254
|
+
paths.append(path)
|
|
255
|
+
known = list(record.get("files") or [])
|
|
256
|
+
for path in paths:
|
|
257
|
+
if path not in known:
|
|
258
|
+
known.append(path)
|
|
259
|
+
record["files"] = known[-_MAX_FILES:]
|
|
260
|
+
headline = ""
|
|
261
|
+
next_step = ""
|
|
262
|
+
if isinstance(explanation, dict):
|
|
263
|
+
headline = _clean((explanation.get("headline") or {}).get("ko"), 400)
|
|
264
|
+
next_step = _clean((explanation.get("next_step") or {}).get("ko"), 400)
|
|
265
|
+
entry = {
|
|
266
|
+
"run_id": _clean(run_id, 120),
|
|
267
|
+
"goal": _clean(goal, 400),
|
|
268
|
+
"status": _clean(status, 40),
|
|
269
|
+
"final_state": _clean(final_state, 40),
|
|
270
|
+
"files": paths,
|
|
271
|
+
"at": now_iso(),
|
|
272
|
+
**({"headline": headline} if headline else {}),
|
|
273
|
+
**({"next_step": next_step} if next_step else {}),
|
|
274
|
+
}
|
|
275
|
+
runs = list(record.get("runs") or [])
|
|
276
|
+
runs.append(entry)
|
|
277
|
+
record["runs"] = runs[-_MAX_RUNS:]
|
|
278
|
+
record["last_verification"] = {
|
|
279
|
+
"final_state": entry["final_state"],
|
|
280
|
+
"ok": bool(isinstance(explanation, dict) and explanation.get("ok")),
|
|
281
|
+
"at": entry["at"],
|
|
282
|
+
**({"headline": headline} if headline else {}),
|
|
283
|
+
# Failure-learning loop (review 루프 §3): what the *last* attempt
|
|
284
|
+
# says to do differently travels into the next run's plan context
|
|
285
|
+
# instead of dying with the run.
|
|
286
|
+
**({"next_step": next_step} if next_step else {}),
|
|
287
|
+
}
|
|
288
|
+
return self._save(record)
|
|
289
|
+
|
|
290
|
+
# ── prompt context ───────────────────────────────────────────────────
|
|
291
|
+
def summary(
|
|
292
|
+
self,
|
|
293
|
+
session_id: str,
|
|
294
|
+
*,
|
|
295
|
+
user_email: Optional[str] = None,
|
|
296
|
+
workspace_id: Optional[str] = None,
|
|
297
|
+
max_files: int = 15,
|
|
298
|
+
max_todos: int = 10,
|
|
299
|
+
) -> str:
|
|
300
|
+
"""Prompt block describing where this project stands.
|
|
301
|
+
|
|
302
|
+
Injected into the planner/executor context so a later run sees the
|
|
303
|
+
files that already exist, what is still open, and whether the last
|
|
304
|
+
attempt actually passed. Empty string when there is nothing to say —
|
|
305
|
+
an empty project must not pad the prompt.
|
|
306
|
+
"""
|
|
307
|
+
record = self.get(session_id, user_email=user_email, workspace_id=workspace_id)
|
|
308
|
+
if record is None:
|
|
309
|
+
return ""
|
|
310
|
+
lines: List[str] = []
|
|
311
|
+
title = record.get("title") or ""
|
|
312
|
+
goal = record.get("goal") or ""
|
|
313
|
+
lines.append(f"Project: {title}" + (f" — {goal}" if goal else ""))
|
|
314
|
+
files = list(record.get("files") or [])[-max_files:]
|
|
315
|
+
if files:
|
|
316
|
+
lines.append("Files this project has already produced (they exist now):")
|
|
317
|
+
lines.extend(f"- {path}" for path in files)
|
|
318
|
+
open_todos = [
|
|
319
|
+
todo for todo in (record.get("todos") or []) if not todo.get("done")
|
|
320
|
+
][:max_todos]
|
|
321
|
+
if open_todos:
|
|
322
|
+
lines.append("Still open:")
|
|
323
|
+
lines.extend(f"- {todo['text']}" for todo in open_todos)
|
|
324
|
+
verification = record.get("last_verification")
|
|
325
|
+
if isinstance(verification, dict) and verification.get("final_state"):
|
|
326
|
+
verdict = verification["final_state"]
|
|
327
|
+
headline = verification.get("headline") or ""
|
|
328
|
+
lines.append(
|
|
329
|
+
f"Last verification: {verdict}" + (f" — {headline}" if headline else "")
|
|
330
|
+
)
|
|
331
|
+
next_step = verification.get("next_step") or ""
|
|
332
|
+
if next_step and not verification.get("ok"):
|
|
333
|
+
# The previous attempt's own diagnosis, so this plan does not
|
|
334
|
+
# repeat the same failure.
|
|
335
|
+
lines.append(f"What the last attempt said to do differently: {next_step}")
|
|
336
|
+
# Only the header line means nothing worth injecting.
|
|
337
|
+
return "\n".join(lines) if len(lines) > 1 else ""
|