ltcai 5.3.0 → 5.4.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.
@@ -0,0 +1,214 @@
1
+ """Consent-first Brain automation recipes.
2
+
3
+ The recipes here are product-level starter workflows, not hidden background
4
+ jobs. Installing one creates a disabled draft workflow so the user can inspect
5
+ and enable it deliberately.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from copy import deepcopy
11
+ from dataclasses import dataclass
12
+ from typing import Any, Dict, List
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class BrainAutomationRecipe:
17
+ id: str
18
+ name: str
19
+ summary: str
20
+ user_value: str
21
+ cadence: str
22
+ trigger: Dict[str, Any]
23
+ prompt: str
24
+ creates: List[str]
25
+
26
+ def as_dict(self) -> Dict[str, Any]:
27
+ return {
28
+ "id": self.id,
29
+ "name": self.name,
30
+ "summary": self.summary,
31
+ "user_value": self.user_value,
32
+ "cadence": self.cadence,
33
+ "trigger": deepcopy(self.trigger),
34
+ "creates": list(self.creates),
35
+ "consent": {
36
+ "default_state": "draft_disabled",
37
+ "local_only": True,
38
+ "external_actions": False,
39
+ "requires_user_enable": True,
40
+ "review_before_run": True,
41
+ },
42
+ }
43
+
44
+
45
+ _RECIPES: List[BrainAutomationRecipe] = [
46
+ BrainAutomationRecipe(
47
+ id="daily-memory-digest",
48
+ name="Daily Memory Digest",
49
+ summary="Collects the day's new memories into a short review draft.",
50
+ user_value="Users see what the Brain kept today without searching through chats.",
51
+ cadence="daily",
52
+ trigger={"trigger": "interval", "interval_seconds": 86_400},
53
+ prompt=(
54
+ "Review today's new Brain memories and draft a concise digest with "
55
+ "important decisions, unresolved questions, and suggested next actions. "
56
+ "Do not contact external services."
57
+ ),
58
+ creates=["memory digest", "decision summary", "next-action suggestions"],
59
+ ),
60
+ BrainAutomationRecipe(
61
+ id="weekly-project-review",
62
+ name="Weekly Project Review",
63
+ summary="Turns project context into a weekly checkpoint draft.",
64
+ user_value="Users can restart a project without explaining the week again.",
65
+ cadence="weekly",
66
+ trigger={"trigger": "interval", "interval_seconds": 604_800},
67
+ prompt=(
68
+ "Review this workspace's recent memories, workflow runs, and decisions. "
69
+ "Draft a project checkpoint with progress, risks, blockers, and next steps. "
70
+ "Keep it local and ask before any external action."
71
+ ),
72
+ creates=["project checkpoint", "risk list", "next-week plan"],
73
+ ),
74
+ BrainAutomationRecipe(
75
+ id="follow-up-radar",
76
+ name="Follow-up Radar",
77
+ summary="Looks for follow-up candidates when new knowledge enters the Brain.",
78
+ user_value="Users get gentle reminders for loose ends without a noisy task system.",
79
+ cadence="when new memory is saved",
80
+ trigger={"trigger": "brain_event"},
81
+ prompt=(
82
+ "Inspect the new Brain memory for follow-up signals such as decisions, "
83
+ "promises, deadlines, unresolved questions, or 'later' language. "
84
+ "Return suggestions only; do not create tasks without approval."
85
+ ),
86
+ creates=["follow-up suggestions", "open-question list", "approval-ready task drafts"],
87
+ ),
88
+ ]
89
+
90
+ _RECIPE_BY_ID = {recipe.id: recipe for recipe in _RECIPES}
91
+
92
+
93
+ def list_brain_automation_recipes() -> Dict[str, Any]:
94
+ """Return user-facing, consent-first automation recipe metadata."""
95
+ return {
96
+ "recipes": [recipe.as_dict() for recipe in _RECIPES],
97
+ "principles": {
98
+ "local_first": True,
99
+ "drafts_before_automation": True,
100
+ "no_external_actions_without_consent": True,
101
+ },
102
+ }
103
+
104
+
105
+ def find_installed_recipe_workflow(
106
+ workflows: Any, recipe_id: str
107
+ ) -> Dict[str, Any] | None:
108
+ """Return an existing draft installed from ``recipe_id``, if any.
109
+
110
+ Installing a recipe is idempotent: clicking "Create reviewable draft" twice
111
+ should surface the existing draft instead of accumulating duplicates. We
112
+ match on the ``brain_automation_recipe`` provenance metadata stamped by
113
+ :func:`build_brain_automation_workflow`.
114
+ """
115
+ for workflow in workflows or []:
116
+ metadata = (workflow or {}).get("metadata") or {}
117
+ if (
118
+ metadata.get("created_from") == "brain_automation_recipe"
119
+ and metadata.get("recipe_id") == recipe_id
120
+ ):
121
+ return workflow
122
+ return None
123
+
124
+
125
+ def build_brain_automation_workflow(recipe_id: str, *, enabled: bool = False) -> Dict[str, Any]:
126
+ """Build a workflow definition for a recipe.
127
+
128
+ ``enabled`` defaults to ``False`` so installing a recipe creates an
129
+ inspectable draft. The trigger service treats explicit ``enabled: false`` as
130
+ disarmed, while legacy workflows without the field keep their behavior.
131
+ """
132
+ recipe = _RECIPE_BY_ID.get(recipe_id)
133
+ if recipe is None:
134
+ raise KeyError(recipe_id)
135
+
136
+ trigger_config = {
137
+ **deepcopy(recipe.trigger),
138
+ "enabled": bool(enabled),
139
+ "consent_required": True,
140
+ "local_only": True,
141
+ "external_actions": False,
142
+ }
143
+ return {
144
+ "name": recipe.name,
145
+ "nodes": [
146
+ {
147
+ "id": "trigger",
148
+ "type": "trigger",
149
+ "name": "User-enabled schedule" if recipe.trigger["trigger"] == "interval" else "New Brain memory",
150
+ "config": trigger_config,
151
+ "next": "draft",
152
+ },
153
+ {
154
+ "id": "draft",
155
+ "type": "agent",
156
+ "name": "Draft Brain review",
157
+ "config": {
158
+ "agent": "agent:planner",
159
+ "prompt": recipe.prompt,
160
+ "mode": "draft",
161
+ "local_only": True,
162
+ "external_actions": False,
163
+ "requires_review": True,
164
+ },
165
+ "next": "output",
166
+ },
167
+ {
168
+ "id": "output",
169
+ "type": "output",
170
+ "name": "Review before saving",
171
+ "config": {
172
+ "value": "Draft ready for review. Save, edit, or discard it before it becomes durable memory.",
173
+ },
174
+ "next": None,
175
+ },
176
+ ],
177
+ "metadata": {
178
+ "created_from": "brain_automation_recipe",
179
+ "recipe_id": recipe.id,
180
+ "recipe_summary": recipe.summary,
181
+ "recipe_user_value": recipe.user_value,
182
+ "automation_state": "enabled" if enabled else "draft_disabled",
183
+ "local_only": True,
184
+ "external_actions": False,
185
+ "requires_user_enable": not enabled,
186
+ "creates": list(recipe.creates),
187
+ },
188
+ }
189
+
190
+
191
+ # === A방향 (Act/automation + BrainAutomationPanel) E2E 시나리오 초안 ===
192
+ # (backend 인터페이스 list_brain_automation_recipes / find_installed... / build_... 완료 후 즉시 작성)
193
+ # 1. Recipe 목록 노출: frontend BrainAutomationPanel이 list_brain_automation_recipes() 호출 → recipes + consent metadata 표시.
194
+ # 2. "Create reviewable draft" 클릭:
195
+ # - build_brain_automation_workflow(recipe_id, enabled=False) 로 draft 생성 (metadata.recipe_id + created_from=brain_automation_recipe)
196
+ # - find_installed_recipe_workflow 로 사전 dedup 체크 → 이미 있으면 기존 반환, UI disabled + "✓ Reviewable draft ready" 피드백.
197
+ # - 생성된 workflow는 automation_state="draft_disabled", trigger.enabled=False → TriggerService 무시.
198
+ # 3. Dedup guard (UI + backend): metadata.recipe_id + created_from 기준. 중복 클릭 가드 (no-dup) 로 double submit 방지.
199
+ # 4. User가 draft를 review/수정 후 enabled=True 로 전환 → TriggerService._triggered_workflows 에서 enabled True인 것만 arm.
200
+ # 5. Interval trigger E2E:
201
+ # - reconcile_missed() : 다운타임 중 missed → "skipped" 이벤트 기록 (catch-up storm 없음).
202
+ # - tick_intervals() : last_fired_at + interval + last_attempt_at dedup 가드 (10s cooldown) 로 중복 실행 방지.
203
+ # - LATTICE_TZ 환경변수: describe()에 "tz" 노출, 이벤트 at 값은 epoch이지만 클라이언트가 LATTICE_TZ로 현지화.
204
+ # 6. Brain event trigger E2E: kg_ingest.* post_tool hook → on_brain_event → matching source_type 필터 → _fire (dedup 5s).
205
+ # 7. Failure degraded:
206
+ # - _fire 에서 run_workflow 예외 → _record_fire_outcome → consecutive_failures++ , describe() "status":"degraded" ( >=3 ).
207
+ # - 성공 시 reset. (실행 내부 실패는 workflow run record에 남음, scheduler는 launch health만).
208
+ # 8. Run provenance: fired run의 inputs["__trigger__"] = {"type": "interval"|"brain_event", ...} 로 감사/디버그 가능.
209
+ # 9. Consent-first: draft_disabled 기본, user enable 전까지 절대 실행 안 됨. "review_before_run": True.
210
+ # 10. End-to-end Act: draft → enable → trigger fire → agent:planner "Draft Brain review" 노드 → output (requires_review) → user review → save or discard.
211
+ #
212
+ # 다음: 실제 API (e.g. POST /brain/automation/install-draft) 가 UI에서 호출되면 위 시나리오에 대한 통합 테스트 + RunExecutor 경로 검증 즉시 추가.
213
+ # 현재 상태: backend recipe 인터페이스 + TriggerService edge 하드닝 + AgentRuntime wiring 완료. UI (App.tsx + styles) + test_brain_automation.py 는 별도 완료 보고됨.
214
+
@@ -20,11 +20,17 @@ from __future__ import annotations
20
20
 
21
21
  import json
22
22
  import logging
23
+ import os
23
24
  import threading
24
25
  import time
25
26
  from pathlib import Path
26
27
  from typing import Any, Callable, Dict, List, Optional
27
28
 
29
+ try:
30
+ from zoneinfo import ZoneInfo
31
+ except Exception: # pragma: no cover
32
+ ZoneInfo = None # type: ignore
33
+
28
34
  DEFAULT_TICK_SECONDS = 5.0
29
35
  MIN_INTERVAL_SECONDS = 60
30
36
 
@@ -51,6 +57,15 @@ class TriggerService:
51
57
  self._stop_event = threading.Event()
52
58
  self._thread: Optional[threading.Thread] = None
53
59
  self._lock = threading.Lock()
60
+ # LATTICE_TZ: wall-clock / display 용. interval 계산은 여전히 unix seconds (duration 기반, drift 방지).
61
+ # describe()와 이벤트에 tz 정보 노출. calendar "daily at HH:MM" semantics 는 추후 cron 확장 시 사용.
62
+ self._tz_name = os.environ.get("LATTICE_TZ") or "UTC"
63
+ self._tz = None
64
+ if ZoneInfo is not None:
65
+ try:
66
+ self._tz = ZoneInfo(self._tz_name)
67
+ except Exception:
68
+ self._tz = ZoneInfo("UTC") if ZoneInfo else None
54
69
 
55
70
  # ── durable state ──────────────────────────────────────────────────────
56
71
  def _load_state(self) -> Dict[str, Any]:
@@ -86,27 +101,37 @@ class TriggerService:
86
101
  continue
87
102
  cfg = node.get("config") or {}
88
103
  kind = str(cfg.get("trigger") or "manual")
104
+ if cfg.get("enabled") is False:
105
+ continue
89
106
  if kind in ("interval", "brain_event"):
90
107
  found.append({"workflow": wf, "node": node, "kind": kind, "config": cfg})
91
108
  return found
92
109
 
93
110
  def describe(self) -> Dict[str, Any]:
94
- """Honest status surface: what is armed, when it last fired/skipped."""
111
+ """Honest status surface: what is armed, when it last fired/skipped.
112
+ Includes LATTICE_TZ, per-trigger status (armed|degraded), consecutive_failures.
113
+ """
95
114
  state = self._load_state()
96
115
  armed = []
97
116
  for item in self._triggered_workflows():
98
117
  wf_id = item["workflow"].get("id")
118
+ entry = state.get(wf_id) or {}
119
+ fails = int(entry.get("consecutive_failures", 0))
120
+ status = "degraded" if fails >= 3 else "armed"
99
121
  armed.append({
100
122
  "workflow_id": wf_id,
101
123
  "name": item["workflow"].get("name"),
102
124
  "kind": item["kind"],
103
125
  "config": {k: v for k, v in item["config"].items() if k != "trigger"},
104
- "last_fired_at": (state.get(wf_id) or {}).get("last_fired_at"),
105
- "recent_events": (state.get(wf_id) or {}).get("events", [])[-5:],
126
+ "last_fired_at": entry.get("last_fired_at"),
127
+ "status": status,
128
+ "consecutive_failures": fails,
129
+ "recent_events": entry.get("events", [])[-5:],
106
130
  })
107
131
  return {
108
132
  "running": bool(self._thread and self._thread.is_alive()),
109
133
  "tick_seconds": self._tick,
134
+ "tz": self._tz_name,
110
135
  "armed": armed,
111
136
  }
112
137
 
@@ -133,6 +158,7 @@ class TriggerService:
133
158
  skipped += missed
134
159
  # Reset the cadence from now — no catch-up storm.
135
160
  entry["last_fired_at"] = now if last is not None else entry.get("last_fired_at")
161
+ entry["last_attempt_at"] = now
136
162
  self._save_state(state)
137
163
  return skipped
138
164
 
@@ -152,9 +178,16 @@ class TriggerService:
152
178
  if last is None:
153
179
  # First sighting arms the schedule; it fires one interval later.
154
180
  entry["last_fired_at"] = now
181
+ entry["last_attempt_at"] = now
155
182
  continue
156
183
  if now - float(last) < interval:
157
184
  continue
185
+ # Dedup guard (edge case): short cooldown + last_attempt prevents rapid re-fire on
186
+ # clock skew, tick jitter, or restart races. Interval 자체 + 이 가드로 중복 실행 방지.
187
+ last_attempt = float(entry.get("last_attempt_at") or 0)
188
+ if now - last_attempt < 10:
189
+ continue
190
+ entry["last_attempt_at"] = now
158
191
  entry["last_fired_at"] = now
159
192
  self._record_event(state, wf_id, {"type": "fired", "trigger": "interval"})
160
193
  fired += 1
@@ -181,7 +214,14 @@ class TriggerService:
181
214
  if wanted and wanted != source_type:
182
215
  continue
183
216
  wf_id = item["workflow"].get("id")
184
- state.setdefault(wf_id, {})["last_fired_at"] = self._clock()
217
+ entry = state.setdefault(wf_id, {})
218
+ now = self._clock()
219
+ # Dedup guard for brain_event too (rapid ingest burst 등).
220
+ last_attempt = float(entry.get("last_attempt_at") or 0)
221
+ if now - last_attempt < 5:
222
+ continue
223
+ entry["last_attempt_at"] = now
224
+ entry["last_fired_at"] = now
185
225
  self._record_event(state, wf_id, {
186
226
  "type": "fired", "trigger": "brain_event", "source_type": source_type,
187
227
  })
@@ -211,11 +251,28 @@ class TriggerService:
211
251
  def _run():
212
252
  try:
213
253
  self._run_workflow(workflow_id, {"__trigger__": trigger_info})
254
+ self._record_fire_outcome(workflow_id, ok=True)
214
255
  except Exception as exc:
215
256
  logging.warning("trigger run failed for %s: %s", workflow_id, exc)
257
+ self._record_fire_outcome(workflow_id, ok=False, detail=str(exc))
216
258
 
217
259
  threading.Thread(target=_run, name=f"trigger-{workflow_id}", daemon=True).start()
218
260
 
261
+ def _record_fire_outcome(self, wf_id: str, *, ok: bool, detail: str = "") -> None:
262
+ """Track consecutive launch failures for degraded status in describe().
263
+ (Deep execution failures are visible via workflow run records; 여기서는 scheduler fire 자체 실패를 카운트.)
264
+ """
265
+ with self._lock:
266
+ state = self._load_state()
267
+ entry = state.setdefault(wf_id, {})
268
+ if ok:
269
+ entry["consecutive_failures"] = 0
270
+ else:
271
+ fails = int(entry.get("consecutive_failures", 0)) + 1
272
+ entry["consecutive_failures"] = fails
273
+ self._record_event(state, wf_id, {"type": "failed", "detail": detail[:200]})
274
+ self._save_state(state)
275
+
219
276
  def start(self) -> None:
220
277
  if self._thread and self._thread.is_alive():
221
278
  return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "5.3.0",
3
+ "version": "5.4.0",
4
4
  "description": "Lattice AI — local-first Digital Brain that keeps your knowledge durable across any AI model.",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -1654,7 +1654,7 @@ dependencies = [
1654
1654
 
1655
1655
  [[package]]
1656
1656
  name = "lattice-ai-desktop"
1657
- version = "5.3.0"
1657
+ version = "5.4.0"
1658
1658
  dependencies = [
1659
1659
  "plist",
1660
1660
  "serde",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "lattice-ai-desktop"
3
- version = "5.3.0"
3
+ version = "5.4.0"
4
4
  description = "Lattice AI Digital Brain desktop shell"
5
5
  authors = ["TaeSoo Park"]
6
6
  edition = "2021"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://schema.tauri.app/config/2",
3
3
  "productName": "Lattice AI",
4
- "version": "5.3.0",
4
+ "version": "5.4.0",
5
5
  "identifier": "ai.lattice.desktop",
6
6
  "build": {
7
7
  "beforeDevCommand": "npm run frontend:dev",
@@ -1,13 +1,13 @@
1
1
  {
2
- "version": "5.3.0",
2
+ "version": "5.4.0",
3
3
  "generated_at": "vite",
4
4
  "entrypoints": {
5
5
  "app": "/static/app/index.html"
6
6
  },
7
7
  "assets": {
8
8
  "../node_modules/@tauri-apps/api/core.js": "/static/app/assets/core-CwxXejkd.js",
9
- "index.html": "/static/app/assets/index-sOXTFUQc.js",
10
- "assets/index-CQmHhk8Q.css": "/static/app/assets/index-CQmHhk8Q.css"
9
+ "index.html": "/static/app/assets/index-C7vzwUjU.js",
10
+ "assets/index-HN4f2wbe.css": "/static/app/assets/index-HN4f2wbe.css"
11
11
  },
12
12
  "vite": {
13
13
  "../node_modules/@tauri-apps/api/core.js": {
@@ -17,7 +17,7 @@
17
17
  "isDynamicEntry": true
18
18
  },
19
19
  "index.html": {
20
- "file": "assets/index-sOXTFUQc.js",
20
+ "file": "assets/index-C7vzwUjU.js",
21
21
  "name": "index",
22
22
  "src": "index.html",
23
23
  "isEntry": true,
@@ -25,7 +25,7 @@
25
25
  "../node_modules/@tauri-apps/api/core.js"
26
26
  ],
27
27
  "css": [
28
- "assets/index-CQmHhk8Q.css"
28
+ "assets/index-HN4f2wbe.css"
29
29
  ]
30
30
  }
31
31
  }