ltcai 9.3.0 → 9.5.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.
Files changed (45) hide show
  1. package/README.md +57 -42
  2. package/docs/CHANGELOG.md +48 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +4 -3
  4. package/docs/DEVELOPMENT.md +8 -8
  5. package/docs/LEGACY_COMPATIBILITY.md +1 -1
  6. package/docs/ONBOARDING.md +2 -2
  7. package/docs/OPERATIONS.md +1 -1
  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/docs/mcp-tools.md +1 -1
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/runtime/multi_agent.py +1 -1
  14. package/latticeai/__init__.py +1 -1
  15. package/latticeai/api/automation_intelligence.py +129 -0
  16. package/latticeai/api/command_center.py +51 -0
  17. package/latticeai/app_factory.py +32 -0
  18. package/latticeai/core/legacy_compatibility.py +1 -1
  19. package/latticeai/core/marketplace.py +1 -1
  20. package/latticeai/core/workspace_os.py +1 -1
  21. package/latticeai/runtime/persistence_runtime.py +8 -0
  22. package/latticeai/services/architecture_readiness.py +1 -1
  23. package/latticeai/services/automation_intelligence.py +450 -0
  24. package/latticeai/services/command_center.py +433 -0
  25. package/latticeai/services/product_readiness.py +1 -1
  26. package/package.json +1 -1
  27. package/scripts/check_current_release_docs.mjs +1 -1
  28. package/src-tauri/Cargo.lock +1 -1
  29. package/src-tauri/Cargo.toml +1 -1
  30. package/src-tauri/tauri.conf.json +1 -1
  31. package/static/app/asset-manifest.json +11 -11
  32. package/static/app/assets/Act-Cdfqx4wN.js +1 -0
  33. package/static/app/assets/{Brain-Jd3V4Obm.js → Brain-w_tAuyg6.js} +1 -1
  34. package/static/app/assets/{Capture-DLj9Uzed.js → Capture-iJwi9LmS.js} +1 -1
  35. package/static/app/assets/{Library-dPbez7Tc.js → Library-Do-jjhzi.js} +1 -1
  36. package/static/app/assets/{System-CkROWBuq.js → System-DFg_q3E6.js} +1 -1
  37. package/static/app/assets/index-BN6HIWVC.css +2 -0
  38. package/static/app/assets/index-aJuRi-Xo.js +17 -0
  39. package/static/app/assets/{primitives-DrwaJ7dy.js → primitives-ZTUlU7pR.js} +1 -1
  40. package/static/app/assets/{textarea-DNvCwmmK.js → textarea-CMfhqPhE.js} +1 -1
  41. package/static/app/index.html +2 -2
  42. package/static/sw.js +1 -1
  43. package/static/app/assets/Act-ls57ctKH.js +0 -1
  44. package/static/app/assets/index-BRUqy2zk.css +0 -2
  45. package/static/app/assets/index-CAlo2Lm-.js +0 -17
@@ -36,6 +36,7 @@ def build_persistence_runtime(
36
36
  from latticeai.core.plugins import PluginRegistry
37
37
  from latticeai.core.realtime import RealtimeBus
38
38
  from latticeai.core.workspace_os import WorkspaceOSStore
39
+ from latticeai.services.automation_intelligence import AutomationIntelligenceService
39
40
  from latticeai.services.brain_intelligence import BrainIntelligenceService
40
41
  from latticeai.services.memory_service import MemoryService
41
42
  from latticeai.services.workspace_service import WorkspaceService
@@ -63,6 +64,12 @@ def build_persistence_runtime(
63
64
  memory_service=memory_service,
64
65
  enable_graph=enable_graph,
65
66
  )
67
+ automation_intelligence = AutomationIntelligenceService(
68
+ conversation_store=conversations,
69
+ knowledge_graph=knowledge_graph,
70
+ store=workspace_os,
71
+ enable_graph=enable_graph,
72
+ )
66
73
  ingestion_pipeline = IngestionPipeline(
67
74
  knowledge_graph,
68
75
  hooks=hooks_registry,
@@ -88,6 +95,7 @@ def build_persistence_runtime(
88
95
  "AGENT_REGISTRY": agent_registry,
89
96
  "MEMORY_SERVICE": memory_service,
90
97
  "BRAIN_INTELLIGENCE": brain_intelligence,
98
+ "AUTOMATION_INTELLIGENCE": automation_intelligence,
91
99
  "INGESTION_PIPELINE": ingestion_pipeline,
92
100
  "DEVICE_IDENTITY": device_identity,
93
101
  "KG_PORTABILITY": kg_portability,
@@ -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.3.0"
20
+ ARCHITECTURE_VERSION_TARGET = "9.5.0"
21
21
 
22
22
  PREFERRED_REFACTORING_ORDER = [
23
23
  "agent-runtime",
@@ -0,0 +1,450 @@
1
+ """Question-driven everyday automation intelligence (v9.4.0).
2
+
3
+ Users repeat themselves: the same "오늘 뭐 해야 하지?", "프로젝트 상태 정리해줘",
4
+ "그 폴더에 새 문서 뭐 들어왔어?" questions come back every day. This service
5
+ watches for those patterns and turns them into one-click, consent-first
6
+ automation suggestions:
7
+
8
+ * **question_patterns** — mines the user's own chat history for recurring
9
+ question intents. Clustering is deterministic and local (token-signature
10
+ similarity, no model call), so the evidence shown to the user is their own
11
+ literal questions.
12
+ * **suggestions** — converts recurring patterns and active local knowledge
13
+ sources (folders the user connected to the Brain) into concrete automation
14
+ proposals: a scheduled answer for a recurring question, a digest for a busy
15
+ knowledge folder, or one of the starter recipes when the intent matches.
16
+ * **build_suggestion_workflow** — turns an accepted suggestion into a
17
+ workflow definition using the same consent-first shape as the starter
18
+ recipes: installed as a disabled draft, review-queue gated, local-only,
19
+ no external actions.
20
+ * **overview** — one payload for the automation surface: suggestions,
21
+ installed automations with their enable state, and pattern evidence.
22
+
23
+ Every suggestion id is deterministic, so install calls are idempotent and the
24
+ UI can safely re-request suggestions without duplicates.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ import logging
31
+ import re
32
+ from dataclasses import dataclass, field
33
+ from typing import Any, Dict, List, Optional
34
+
35
+ from latticeai.core.timeutil import now_iso as _now
36
+
37
+ LOGGER = logging.getLogger(__name__)
38
+
39
+ _MIN_PATTERN_COUNT = 2
40
+ _MAX_HISTORY = 4000
41
+ _SIGNATURE_SIMILARITY = 0.6
42
+
43
+ _QUESTION_HINT_RE = re.compile(
44
+ r"(\?|어때|뭐야|뭐가|뭘까|알려줘|보여줘|정리해|요약해|정리 좀|요약 좀|해줘"
45
+ r"|what|how|why|when|where|status|summar|remind|list|show me|tell me)",
46
+ re.IGNORECASE,
47
+ )
48
+
49
+ _STOPWORDS = {
50
+ "the", "a", "an", "is", "are", "was", "to", "of", "in", "on", "for", "and",
51
+ "or", "me", "my", "you", "please", "can", "could", "would", "it", "this",
52
+ "that", "do", "does", "what", "how", "about",
53
+ "좀", "그", "이", "저", "것", "거", "게", "내", "제", "나", "너", "우리",
54
+ "해줘", "해주세요", "주세요", "합니다", "있어", "있나", "있는지", "어떻게",
55
+ "뭐야", "뭐가", "알려줘", "보여줘",
56
+ }
57
+
58
+ # Intent buckets map recurring question language onto the starter recipes.
59
+ _INTENT_RULES = (
60
+ (
61
+ "digest",
62
+ re.compile(r"(오늘|하루|today|daily).{0,12}(정리|요약|digest|summary|기억|메모)|"
63
+ r"(정리|요약).{0,8}(해줘|해 줘|부탁)|summar(y|ize)", re.IGNORECASE),
64
+ "daily-memory-digest",
65
+ ),
66
+ (
67
+ "project_review",
68
+ re.compile(r"(프로젝트|project|진행|progress|status|상태|주간|weekly|이번 주)", re.IGNORECASE),
69
+ "weekly-project-review",
70
+ ),
71
+ (
72
+ "follow_up",
73
+ re.compile(r"(리마인드|remind|챙겨|잊지|deadline|마감|follow.?up|나중에|까먹)", re.IGNORECASE),
74
+ "follow-up-radar",
75
+ ),
76
+ )
77
+
78
+
79
+ def _tokens(text: str) -> List[str]:
80
+ return [
81
+ token
82
+ for token in re.findall(r"[\w가-힣]+", str(text or "").lower())
83
+ if len(token) > 1 and token not in _STOPWORDS
84
+ ]
85
+
86
+
87
+ def _signature(tokens: List[str]) -> frozenset:
88
+ return frozenset(tokens)
89
+
90
+
91
+ def _similarity(left: frozenset, right: frozenset) -> float:
92
+ if not left or not right:
93
+ return 0.0
94
+ return len(left & right) / len(left | right)
95
+
96
+
97
+ def _stable_id(prefix: str, seed: str) -> str:
98
+ return f"{prefix}-{hashlib.sha256(seed.encode('utf-8')).hexdigest()[:10]}"
99
+
100
+
101
+ @dataclass
102
+ class _Pattern:
103
+ representative: str
104
+ signature: frozenset
105
+ count: int = 1
106
+ last_asked: str = ""
107
+ intent: str = "recurring_question"
108
+ recipe_id: Optional[str] = None
109
+ examples: List[str] = field(default_factory=list)
110
+
111
+ def as_dict(self) -> Dict[str, Any]:
112
+ return {
113
+ "id": _stable_id("pat", " ".join(sorted(self.signature))),
114
+ "representative": self.representative[:160],
115
+ "count": self.count,
116
+ "last_asked": self.last_asked,
117
+ "intent": self.intent,
118
+ "recipe_id": self.recipe_id,
119
+ "examples": self.examples[:3],
120
+ }
121
+
122
+
123
+ class AutomationIntelligenceService:
124
+ def __init__(
125
+ self,
126
+ *,
127
+ conversation_store: Any = None,
128
+ knowledge_graph: Any = None,
129
+ store: Any = None,
130
+ enable_graph: bool = True,
131
+ ) -> None:
132
+ self._conversations = conversation_store
133
+ self._kg = knowledge_graph
134
+ self._store = store
135
+ self._enable_graph = bool(enable_graph and knowledge_graph is not None)
136
+
137
+ # ── question pattern mining ──────────────────────────────────────────
138
+
139
+ def _user_questions(
140
+ self, *, user_email: Optional[str], workspace_id: Optional[str]
141
+ ) -> List[Dict[str, Any]]:
142
+ if self._conversations is None:
143
+ return []
144
+ try:
145
+ items = self._conversations.history(
146
+ user_email=user_email,
147
+ allowed_workspaces={workspace_id} if workspace_id is not None else None,
148
+ include_legacy_global=workspace_id is None,
149
+ limit=_MAX_HISTORY,
150
+ )
151
+ except Exception:
152
+ LOGGER.exception("automation intelligence history read failed")
153
+ return []
154
+ questions = []
155
+ for item in items:
156
+ if item.get("role") != "user":
157
+ continue
158
+ content = str(item.get("content") or "").strip()
159
+ if not content or len(content) < 6 or content.startswith("/"):
160
+ continue
161
+ if not _QUESTION_HINT_RE.search(content):
162
+ continue
163
+ questions.append({"content": content, "timestamp": str(item.get("timestamp") or "")})
164
+ return questions
165
+
166
+ def question_patterns(
167
+ self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
168
+ ) -> Dict[str, Any]:
169
+ questions = self._user_questions(user_email=user_email, workspace_id=workspace_id)
170
+ patterns: List[_Pattern] = []
171
+ for question in questions:
172
+ tokens = _tokens(question["content"])
173
+ if len(tokens) < 2:
174
+ continue
175
+ signature = _signature(tokens)
176
+ match = None
177
+ for pattern in patterns:
178
+ if _similarity(pattern.signature, signature) >= _SIGNATURE_SIMILARITY:
179
+ match = pattern
180
+ break
181
+ if match is None:
182
+ patterns.append(
183
+ _Pattern(
184
+ representative=question["content"],
185
+ signature=signature,
186
+ last_asked=question["timestamp"],
187
+ examples=[question["content"]],
188
+ )
189
+ )
190
+ continue
191
+ match.count += 1
192
+ # Union keeps the cluster stable as phrasing drifts.
193
+ match.signature = match.signature | signature
194
+ if question["timestamp"] >= match.last_asked:
195
+ match.last_asked = question["timestamp"]
196
+ match.representative = question["content"]
197
+ if question["content"] not in match.examples:
198
+ match.examples.append(question["content"])
199
+
200
+ recurring = [p for p in patterns if p.count >= _MIN_PATTERN_COUNT]
201
+ for pattern in recurring:
202
+ for intent, rule, recipe_id in _INTENT_RULES:
203
+ if rule.search(pattern.representative):
204
+ pattern.intent = intent
205
+ pattern.recipe_id = recipe_id
206
+ break
207
+
208
+ recurring.sort(key=lambda p: (p.count, p.last_asked), reverse=True)
209
+ return {
210
+ "questions_scanned": len(questions),
211
+ "patterns": [p.as_dict() for p in recurring[:20]],
212
+ "generated_at": _now(),
213
+ }
214
+
215
+ # ── knowledge sources ────────────────────────────────────────────────
216
+
217
+ def _knowledge_sources(self) -> List[Dict[str, Any]]:
218
+ if not self._enable_graph or not hasattr(self._kg, "local_sources"):
219
+ return []
220
+ try:
221
+ return list(self._kg.local_sources().get("sources") or [])
222
+ except Exception:
223
+ LOGGER.exception("automation intelligence source read failed")
224
+ return []
225
+
226
+ # ── suggestions ──────────────────────────────────────────────────────
227
+
228
+ def _installed_suggestion_ids(self, *, workspace_id: Optional[str]) -> Dict[str, Dict[str, Any]]:
229
+ installed: Dict[str, Dict[str, Any]] = {}
230
+ if self._store is None:
231
+ return installed
232
+ try:
233
+ workflows = self._store.list_workflows(workspace_id=workspace_id).get("workflows") or []
234
+ except Exception:
235
+ LOGGER.exception("automation intelligence workflow read failed")
236
+ return installed
237
+ for workflow in workflows:
238
+ metadata = (workflow or {}).get("metadata") or {}
239
+ suggestion_id = metadata.get("suggestion_id")
240
+ if metadata.get("created_from") == "automation_suggestion" and suggestion_id:
241
+ installed[str(suggestion_id)] = workflow
242
+ return installed
243
+
244
+ def suggestions(
245
+ self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
246
+ ) -> Dict[str, Any]:
247
+ pattern_report = self.question_patterns(user_email=user_email, workspace_id=workspace_id)
248
+ installed = self._installed_suggestion_ids(workspace_id=workspace_id)
249
+
250
+ items: List[Dict[str, Any]] = []
251
+ for pattern in pattern_report["patterns"]:
252
+ suggestion_id = _stable_id("sug-q", pattern["id"])
253
+ items.append({
254
+ "id": suggestion_id,
255
+ "kind": "recurring_question",
256
+ "intent": pattern["intent"],
257
+ "recipe_id": pattern["recipe_id"],
258
+ "title": pattern["representative"],
259
+ "reason": {
260
+ "type": "repeated_question",
261
+ "count": pattern["count"],
262
+ "last_asked": pattern["last_asked"],
263
+ "examples": pattern["examples"],
264
+ },
265
+ "cadence": "daily",
266
+ "installed": suggestion_id in installed,
267
+ "workflow_id": (installed.get(suggestion_id) or {}).get("id"),
268
+ })
269
+
270
+ for source in self._knowledge_sources():
271
+ file_status = source.get("file_status") or {}
272
+ indexed = sum(int(v or 0) for v in file_status.values())
273
+ if indexed <= 0:
274
+ continue
275
+ suggestion_id = _stable_id("sug-src", str(source.get("id") or source.get("root_path") or ""))
276
+ items.append({
277
+ "id": suggestion_id,
278
+ "kind": "knowledge_source",
279
+ "intent": "source_digest",
280
+ "recipe_id": None,
281
+ "title": str(source.get("label") or source.get("root_path") or "knowledge folder"),
282
+ "reason": {
283
+ "type": "connected_source",
284
+ "root_path": source.get("root_path"),
285
+ "indexed_files": indexed,
286
+ "watch_enabled": bool(source.get("watch_enabled")),
287
+ },
288
+ "cadence": "when new knowledge arrives",
289
+ "installed": suggestion_id in installed,
290
+ "workflow_id": (installed.get(suggestion_id) or {}).get("id"),
291
+ })
292
+
293
+ return {
294
+ "suggestions": items,
295
+ "questions_scanned": pattern_report["questions_scanned"],
296
+ "consent": {
297
+ "default_state": "draft_disabled",
298
+ "local_only": True,
299
+ "external_actions": False,
300
+ "requires_user_enable": True,
301
+ "review_before_run": True,
302
+ },
303
+ "generated_at": _now(),
304
+ }
305
+
306
+ def find_suggestion(
307
+ self,
308
+ suggestion_id: str,
309
+ *,
310
+ user_email: Optional[str] = None,
311
+ workspace_id: Optional[str] = None,
312
+ ) -> Optional[Dict[str, Any]]:
313
+ for suggestion in self.suggestions(user_email=user_email, workspace_id=workspace_id)["suggestions"]:
314
+ if suggestion["id"] == suggestion_id:
315
+ return suggestion
316
+ return None
317
+
318
+ # ── workflow building ────────────────────────────────────────────────
319
+
320
+ def build_suggestion_workflow(
321
+ self, suggestion: Dict[str, Any], *, enabled: bool = False
322
+ ) -> Dict[str, Any]:
323
+ """Build a consent-first workflow definition from a suggestion.
324
+
325
+ Mirrors the starter-recipe shape: trigger → draft agent → review
326
+ output, installed disabled by default, review-queue gated, local
327
+ only, and stamped with provenance so installs stay idempotent.
328
+ """
329
+ kind = suggestion.get("kind")
330
+ title = str(suggestion.get("title") or "automation")[:80]
331
+ if kind == "knowledge_source":
332
+ trigger: Dict[str, Any] = {"trigger": "brain_event"}
333
+ trigger_name = "New knowledge in connected folder"
334
+ name = f"Folder digest: {title}"
335
+ reason = suggestion.get("reason") or {}
336
+ prompt = (
337
+ "New knowledge arrived from the connected folder "
338
+ f"'{reason.get('root_path') or title}'. Draft a short digest of what "
339
+ "was added, what changed, and any follow-ups it suggests. Use only "
340
+ "the local Brain; do not contact external services."
341
+ )
342
+ creates = ["folder digest", "change summary", "follow-up suggestions"]
343
+ else:
344
+ trigger = {"trigger": "interval", "interval_seconds": 86_400}
345
+ trigger_name = "User-enabled schedule"
346
+ name = f"Scheduled answer: {title}"
347
+ prompt = (
348
+ "The user repeatedly asks this question:\n"
349
+ f"“{suggestion.get('title')}”\n"
350
+ "Answer it from the current Brain (memories, knowledge graph, "
351
+ "recent activity) as a concise draft the user can review. "
352
+ "Use only local knowledge; do not contact external services."
353
+ )
354
+ creates = ["scheduled answer draft", "supporting evidence", "suggested follow-ups"]
355
+
356
+ trigger_config = {
357
+ **trigger,
358
+ "enabled": bool(enabled),
359
+ "review_queue": True,
360
+ "consent_required": True,
361
+ "local_only": True,
362
+ "external_actions": False,
363
+ }
364
+ return {
365
+ "name": name,
366
+ "nodes": [
367
+ {
368
+ "id": "trigger",
369
+ "type": "trigger",
370
+ "name": trigger_name,
371
+ "config": trigger_config,
372
+ "next": "draft",
373
+ },
374
+ {
375
+ "id": "draft",
376
+ "type": "agent",
377
+ "name": "Draft for review",
378
+ "config": {
379
+ "agent": "agent:planner",
380
+ "goal": prompt,
381
+ "prompt": prompt,
382
+ "roles": ["researcher", "planner", "executor", "reviewer"],
383
+ "mode": "draft",
384
+ "local_only": True,
385
+ "external_actions": False,
386
+ "requires_review": True,
387
+ },
388
+ "next": "output",
389
+ },
390
+ {
391
+ "id": "output",
392
+ "type": "output",
393
+ "name": "Review before saving",
394
+ "config": {
395
+ "value": "Draft ready for review. Save, edit, or discard it before it becomes durable memory.",
396
+ },
397
+ "next": None,
398
+ },
399
+ ],
400
+ "metadata": {
401
+ "created_from": "automation_suggestion",
402
+ "suggestion_id": suggestion.get("id"),
403
+ "suggestion_kind": kind,
404
+ "suggestion_title": title,
405
+ "suggestion_reason": suggestion.get("reason"),
406
+ "automation_state": "enabled" if enabled else "draft_disabled",
407
+ "local_only": True,
408
+ "external_actions": False,
409
+ "requires_user_enable": not enabled,
410
+ "creates": creates,
411
+ },
412
+ }
413
+
414
+ # ── overview for the automation surface ──────────────────────────────
415
+
416
+ def overview(
417
+ self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
418
+ ) -> Dict[str, Any]:
419
+ suggestion_report = self.suggestions(user_email=user_email, workspace_id=workspace_id)
420
+ installed: List[Dict[str, Any]] = []
421
+ if self._store is not None:
422
+ try:
423
+ workflows = self._store.list_workflows(workspace_id=workspace_id).get("workflows") or []
424
+ except Exception:
425
+ LOGGER.exception("automation intelligence workflow read failed")
426
+ workflows = []
427
+ for workflow in workflows:
428
+ metadata = (workflow or {}).get("metadata") or {}
429
+ if metadata.get("created_from") not in {"automation_suggestion", "brain_automation_recipe"}:
430
+ continue
431
+ installed.append({
432
+ "id": workflow.get("id"),
433
+ "name": workflow.get("name"),
434
+ "created_from": metadata.get("created_from"),
435
+ "suggestion_id": metadata.get("suggestion_id"),
436
+ "recipe_id": metadata.get("recipe_id"),
437
+ "enabled": metadata.get("automation_state") == "enabled",
438
+ "requires_user_enable": bool(metadata.get("requires_user_enable", True)),
439
+ "creates": metadata.get("creates") or [],
440
+ })
441
+ return {
442
+ "suggestions": suggestion_report["suggestions"],
443
+ "questions_scanned": suggestion_report["questions_scanned"],
444
+ "installed": installed,
445
+ "consent": suggestion_report["consent"],
446
+ "generated_at": _now(),
447
+ }
448
+
449
+
450
+ __all__ = ["AutomationIntelligenceService"]