ltcai 9.9.5 → 10.0.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 (129) hide show
  1. package/README.md +39 -37
  2. package/docs/CHANGELOG.md +234 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +3 -1
  7. package/docs/PERMISSION_MODE.md +107 -0
  8. package/docs/SURFACE_PARITY.md +38 -8
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/kg-schema.md +1 -1
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/context.py +39 -4
  14. package/lattice_brain/graph/_kg_common.py +189 -14
  15. package/lattice_brain/graph/curator.py +62 -0
  16. package/lattice_brain/graph/discovery.py +76 -0
  17. package/lattice_brain/graph/ingest.py +37 -7
  18. package/lattice_brain/graph/retrieval_vector.py +10 -0
  19. package/lattice_brain/ingestion.py +9 -106
  20. package/lattice_brain/ingestion_jobs.py +133 -0
  21. package/lattice_brain/runtime/multi_agent.py +1 -1
  22. package/latticeai/__init__.py +1 -1
  23. package/latticeai/api/brain_intelligence.py +8 -0
  24. package/latticeai/api/chat.py +9 -0
  25. package/latticeai/api/chat_agent_http.py +119 -2
  26. package/latticeai/api/chat_contracts.py +9 -0
  27. package/latticeai/api/chat_documents.py +16 -0
  28. package/latticeai/api/evidence_actions.py +48 -0
  29. package/latticeai/api/permission_mode.py +61 -0
  30. package/latticeai/api/project_sessions.py +112 -0
  31. package/latticeai/api/voice_capture.py +89 -0
  32. package/latticeai/app_factory.py +53 -0
  33. package/latticeai/core/agent.py +381 -43
  34. package/latticeai/core/agent_permission.py +160 -0
  35. package/latticeai/core/agent_profiles.py +110 -0
  36. package/latticeai/core/artifact_ledger.py +109 -0
  37. package/latticeai/core/context_builder.py +101 -2
  38. package/latticeai/core/legacy_compatibility.py +1 -1
  39. package/latticeai/core/marketplace.py +1 -1
  40. package/latticeai/core/permission_mode.py +296 -0
  41. package/latticeai/core/project_sessions.py +337 -0
  42. package/latticeai/core/run_explain.py +421 -0
  43. package/latticeai/core/run_store.py +5 -0
  44. package/latticeai/core/workspace_os.py +15 -80
  45. package/latticeai/core/workspace_review_items.py +107 -0
  46. package/latticeai/integrations/telegram_bot.py +188 -0
  47. package/latticeai/runtime/chat_wiring.py +8 -0
  48. package/latticeai/runtime/context_runtime.py +11 -0
  49. package/latticeai/runtime/permission_mode_wiring.py +116 -0
  50. package/latticeai/runtime/router_registration.py +9 -0
  51. package/latticeai/services/app_context.py +3 -0
  52. package/latticeai/services/architecture_readiness.py +1 -1
  53. package/latticeai/services/brain_intelligence.py +105 -0
  54. package/latticeai/services/evidence_actions.py +254 -0
  55. package/latticeai/services/funnel_metrics.py +111 -16
  56. package/latticeai/services/local_knowledge.py +26 -0
  57. package/latticeai/services/memory_service.py +12 -0
  58. package/latticeai/services/permission_mode_service.py +170 -0
  59. package/latticeai/services/product_readiness.py +1 -1
  60. package/latticeai/services/tool_dispatch.py +73 -4
  61. package/latticeai/services/voice_capture.py +189 -0
  62. package/package.json +6 -4
  63. package/scripts/check_bundle_budget.mjs +11 -0
  64. package/scripts/check_current_release_docs.mjs +1 -1
  65. package/scripts/check_i18n_namespace_coverage.mjs +148 -0
  66. package/skills/meeting_notes/SKILL.md +82 -0
  67. package/skills/meeting_notes/examples.md +41 -0
  68. package/skills/meeting_notes/risk.json +9 -0
  69. package/skills/meeting_notes/schema.json +55 -0
  70. package/skills/weekly_review/SKILL.md +75 -0
  71. package/skills/weekly_review/examples.md +39 -0
  72. package/skills/weekly_review/risk.json +9 -0
  73. package/skills/weekly_review/schema.json +55 -0
  74. package/src-tauri/Cargo.lock +1 -1
  75. package/src-tauri/Cargo.toml +1 -1
  76. package/src-tauri/tauri.conf.json +1 -1
  77. package/static/app/asset-manifest.json +37 -29
  78. package/static/app/assets/Act-BtCREeN1.js +1 -0
  79. package/static/app/assets/AdminConsole-TPeeN18T.js +1 -0
  80. package/static/app/assets/Brain-BKs6JAp0.js +321 -0
  81. package/static/app/assets/BrainHome-BPGOvSd6.js +2 -0
  82. package/static/app/assets/BrainSignals-CtzQZ15J.js +1 -0
  83. package/static/app/assets/Capture-1_NaHWqB.js +1 -0
  84. package/static/app/assets/CommandPalette-pqvQOXe4.js +1 -0
  85. package/static/app/assets/Library-DhvoPvC7.js +1 -0
  86. package/static/app/assets/{LivingBrain-BVWk58MW.js → LivingBrain-DlQ20Q75.js} +1 -1
  87. package/static/app/assets/ProductFlow-BZvGDRi_.js +1 -0
  88. package/static/app/assets/ReviewCard-BWgI0D2s.js +3 -0
  89. package/static/app/assets/System-CSMdYLMy.js +1 -0
  90. package/static/app/assets/activity-Dlfk8YC7.js +1 -0
  91. package/static/app/assets/{bot-VmP0kkeA.js → bot-CDvUB76P.js} +1 -1
  92. package/static/app/assets/brain-xczrohrt.js +1 -0
  93. package/static/app/assets/button-SOdH3Oyf.js +1 -0
  94. package/static/app/assets/{circle-pause-T9POo4qy.js → circle-pause-CG1ythH4.js} +1 -1
  95. package/static/app/assets/{circle-play-Bz5iTD0p.js → circle-play-HXwvjS6W.js} +1 -1
  96. package/static/app/assets/{cpu-CJhjRtNq.js → cpu-B0d-rGyk.js} +1 -1
  97. package/static/app/assets/download-BGIkTQL6.js +1 -0
  98. package/static/app/assets/{folder-open-B2K_22VI.js → folder-open-Dst_Z0_K.js} +1 -1
  99. package/static/app/assets/{hard-drive-CjJtYqHf.js → hard-drive-D53MsWkV.js} +1 -1
  100. package/static/app/assets/index-C_IrlQMV.js +10 -0
  101. package/static/app/assets/index-FxDusbr0.css +2 -0
  102. package/static/app/assets/{input-CSi6OBJ9.js → input-C5m0riF6.js} +1 -1
  103. package/static/app/assets/{network-Ck1nqc-v.js → network-C5a-E5iS.js} +1 -1
  104. package/static/app/assets/primitives-vNXYf58F.js +1 -0
  105. package/static/app/assets/search-DhbSgW6m.js +1 -0
  106. package/static/app/assets/shield-alert-Cc-WVXqN.js +1 -0
  107. package/static/app/assets/{textarea-C4wM0D-Z.js → textarea-BkZ0EqVO.js} +1 -1
  108. package/static/app/assets/{useFocusTrap-CsnIhsDJ.js → useFocusTrap-CTtKbAOU.js} +1 -1
  109. package/static/app/assets/{navigation-BPQqEQwr.js → useQuery-Dx1fi4Wu.js} +1 -1
  110. package/static/app/assets/users-BFpQXtEF.js +1 -0
  111. package/static/app/assets/utils-BA_lmW3J.js +7 -0
  112. package/static/app/assets/workspace-DBPB0jkX.js +1 -0
  113. package/static/app/index.html +4 -4
  114. package/static/sw.js +1 -1
  115. package/static/app/assets/Act-DmxfbqBp.js +0 -1
  116. package/static/app/assets/Brain-DK_zKCda.js +0 -321
  117. package/static/app/assets/BrainHome-CCE_0hni.js +0 -2
  118. package/static/app/assets/BrainSignals-r_J68_zh.js +0 -1
  119. package/static/app/assets/Capture-CvxwzpJZ.js +0 -1
  120. package/static/app/assets/CommandPalette-CVLiFIQC.js +0 -1
  121. package/static/app/assets/Library-4As9VUvy.js +0 -1
  122. package/static/app/assets/ProductFlow-B-Tz0Um4.js +0 -1
  123. package/static/app/assets/ReviewCard-Va3fPQuj.js +0 -3
  124. package/static/app/assets/System-DSR2zKWd.js +0 -1
  125. package/static/app/assets/index-7FAfYm4v.css +0 -2
  126. package/static/app/assets/index-CEu0Wqjl.js +0 -10
  127. package/static/app/assets/primitives-B2f9N7Dh.js +0 -1
  128. package/static/app/assets/shield-alert-yA_Y8lOB.js +0 -1
  129. package/static/app/assets/utils-BqoznzGP.js +0 -7
@@ -14,12 +14,19 @@ from typing import Any, Callable, Dict, Mapping, Optional
14
14
  from fastapi import HTTPException
15
15
 
16
16
  from latticeai.core.agent import AgentDeps, SingleAgentRuntime
17
+ from latticeai.core.agent_permission import call_mode_source
17
18
  from latticeai.core.agent_prompts import (
18
19
  CRITIC_PROMPT,
19
20
  EXECUTOR_PROMPT,
20
21
  MEMORY_UPDATER_PROMPT,
21
22
  PLANNER_PROMPT,
22
23
  )
24
+ from latticeai.core.permission_mode import (
25
+ DEFAULT_MODE,
26
+ effective_auto_approve,
27
+ is_circuit_breaker,
28
+ normalize_mode,
29
+ )
23
30
  from latticeai.core.policy import role_has_capability
24
31
  from latticeai.core.tool_governor import classify_tool_call
25
32
  from latticeai.core.tool_registry import ToolPermission, ToolPolicy
@@ -34,6 +41,10 @@ def _default_get_user_role(_email, _users=None) -> str:
34
41
  return "user"
35
42
 
36
43
 
44
+ def _default_permission_mode() -> Any:
45
+ return DEFAULT_MODE
46
+
47
+
37
48
  FILE_CREATE_ACTIONS = set(DEFAULT_TOOL_REGISTRY.file_create_actions)
38
49
  TOOL_GOVERNANCE: Dict[str, ToolPolicy] = dict(DEFAULT_TOOL_REGISTRY.governance)
39
50
  TOOL_GOVERNANCE_DEFAULT: ToolPolicy = DEFAULT_TOOL_REGISTRY.default_policy
@@ -62,6 +73,10 @@ class ToolDispatchService:
62
73
  registry: Any = field(default_factory=lambda: DEFAULT_TOOL_REGISTRY)
63
74
  load_users: Callable[[], Dict[str, Any]] = field(default=_default_load_users)
64
75
  get_user_role: Callable[..., str] = field(default=_default_get_user_role)
76
+ # Static mode, or a callable resolved per enforce_policy call. A callable
77
+ # may accept ``user_email``/``workspace_id`` scope kwargs (preferred) or no
78
+ # arguments at all — see ``call_mode_source``.
79
+ permission_mode: Any = field(default_factory=lambda: _default_permission_mode)
65
80
 
66
81
  @property
67
82
  def file_create_actions(self) -> frozenset[str]:
@@ -80,9 +95,29 @@ class ToolDispatchService:
80
95
  *,
81
96
  load_users: Callable[[], Dict[str, Any]],
82
97
  get_user_role: Callable[..., str],
98
+ permission_mode: Any = None,
83
99
  ) -> None:
84
100
  self.load_users = load_users
85
101
  self.get_user_role = get_user_role
102
+ if permission_mode is not None:
103
+ self.permission_mode = permission_mode
104
+
105
+ def resolve_permission_mode(
106
+ self,
107
+ *,
108
+ user_email: Optional[str] = None,
109
+ workspace_id: Optional[str] = None,
110
+ ) -> Any:
111
+ """Active autonomy dial for this caller.
112
+
113
+ The scope matters: per-user and per-workspace overrides are stored by
114
+ ``PermissionModeService``, so resolving without a scope would silently
115
+ collapse every caller onto the process-wide default.
116
+ """
117
+ raw = call_mode_source(
118
+ self.permission_mode, user_email=user_email, workspace_id=workspace_id,
119
+ )
120
+ return normalize_mode(raw if raw is not None else DEFAULT_MODE)
86
121
 
87
122
  def policy_for(self, action_name: str, args: dict) -> ToolPolicy:
88
123
  return self.registry.policy_for(action_name, args)
@@ -155,6 +190,8 @@ class ToolDispatchService:
155
190
  source: str,
156
191
  require_auto_approval: bool = True,
157
192
  trusted_admin: bool = False,
193
+ permission_mode: Any = None,
194
+ workspace_id: Optional[str] = None,
158
195
  ) -> ToolPolicy:
159
196
  """Authorize a tool call before any hook or handler can execute.
160
197
 
@@ -162,8 +199,18 @@ class ToolDispatchService:
162
199
  runtime uses the same policy data and blocks non-auto-approved steps in
163
200
  its state machine so Computer Use direct endpoints can remain unchanged
164
201
  when explicitly excluded from a hardening pass.
202
+
203
+ The active :class:`PermissionMode` widens what may run without an extra
204
+ approval prompt; it never widens a circuit breaker.
165
205
  """
166
206
  policy = self.policy_for(tool_name, args or {})
207
+ mode = normalize_mode(
208
+ permission_mode
209
+ if permission_mode is not None
210
+ else self.resolve_permission_mode(
211
+ user_email=current_user, workspace_id=workspace_id,
212
+ )
213
+ )
167
214
  if source in {"mcp", "plugin"} and tool_name in EXPLICIT_CONSENT_TOOLS:
168
215
  raise HTTPException(
169
216
  status_code=403,
@@ -174,14 +221,22 @@ class ToolDispatchService:
174
221
  )
175
222
  if not trusted_admin:
176
223
  self.check_role(tool_name, current_user)
224
+
225
+ breaker = is_circuit_breaker(tool_name, policy, args or {})
226
+ if breaker:
227
+ raise HTTPException(status_code=403, detail=f"'{tool_name}' 차단: {breaker}")
228
+
177
229
  if policy["destructive"] or policy["risk"] == "destructive":
178
230
  raise HTTPException(
179
231
  status_code=403,
180
232
  detail=f"'{tool_name}' 툴은 파괴적 작업으로 차단되었습니다.",
181
233
  )
234
+
182
235
  # Fail-closed governance: a call that would rewrite existing content but
183
236
  # cannot be staged as a reviewable proposal is blocked, never applied
184
- # silently. New-file (additive) calls are unaffected.
237
+ # silently. New-file (additive) calls are unaffected. This is
238
+ # mode-invariant — trusted/bypass skip *proposal staging*, but a binary
239
+ # overwrite has no safe apply path in any mode, so it stays blocked.
185
240
  verdict = classify_tool_call(
186
241
  tool_name, args or {},
187
242
  policy=dict(policy), path_exists=self._governed_path_exists,
@@ -195,17 +250,22 @@ class ToolDispatchService:
195
250
  "지원되는 편집 도구(write_file/edit_file)를 사용하세요."
196
251
  ),
197
252
  )
253
+
254
+ auto = policy["auto_approve"] or effective_auto_approve(
255
+ mode, tool_name, policy, change_class=verdict.get("change_class"), args=args or {},
256
+ )
198
257
  if (
199
258
  require_auto_approval
200
259
  and not trusted_admin
201
- and not policy["auto_approve"]
260
+ and not auto
202
261
  and self.user_role(current_user) not in {"admin", "owner"}
203
262
  ):
204
263
  raise HTTPException(
205
264
  status_code=403,
206
265
  detail=(
207
- f"'{tool_name}' 툴은 명시 승인이 필요합니다. "
208
- "현재 릴리스에서는 승인 UI가 없는 직접 실행 경로에서 기본 차단됩니다."
266
+ f"'{tool_name}' 툴은 명시 승인이 필요합니다 "
267
+ f"(permission_mode={mode.value}). "
268
+ "승인 UI가 없는 직접 실행 경로에서 차단되었습니다."
209
269
  ),
210
270
  )
211
271
  return policy
@@ -282,10 +342,12 @@ def configure_tool_dispatch(
282
342
  *,
283
343
  load_users: Callable[[], Dict[str, Any]],
284
344
  get_user_role: Callable[..., str],
345
+ permission_mode: Any = None,
285
346
  ) -> None:
286
347
  DEFAULT_TOOL_DISPATCH_SERVICE.configure(
287
348
  load_users=load_users,
288
349
  get_user_role=get_user_role,
350
+ permission_mode=permission_mode,
289
351
  )
290
352
 
291
353
 
@@ -325,6 +387,8 @@ def enforce_tool_policy(
325
387
  source: str,
326
388
  require_auto_approval: bool = True,
327
389
  trusted_admin: bool = False,
390
+ permission_mode: Any = None,
391
+ workspace_id: Optional[str] = None,
328
392
  ) -> ToolPolicy:
329
393
  return DEFAULT_TOOL_DISPATCH_SERVICE.enforce_policy(
330
394
  tool_name,
@@ -333,6 +397,8 @@ def enforce_tool_policy(
333
397
  source=source,
334
398
  require_auto_approval=require_auto_approval,
335
399
  trusted_admin=trusted_admin,
400
+ workspace_id=workspace_id,
401
+ permission_mode=permission_mode,
336
402
  )
337
403
 
338
404
 
@@ -408,8 +474,10 @@ def build_agent_runtime(
408
474
  hooks: Any = None,
409
475
  brain_memory: Any = None,
410
476
  dispatch_service: ToolDispatchService = DEFAULT_TOOL_DISPATCH_SERVICE,
477
+ permission_mode: Any = None,
411
478
  ) -> SingleAgentRuntime:
412
479
  ensure_agent_root()
480
+ mode = permission_mode if permission_mode is not None else dispatch_service.permission_mode
413
481
  deps = AgentDeps(
414
482
  generate_as=model_router.generate_as,
415
483
  generate=model_router.generate,
@@ -433,6 +501,7 @@ def build_agent_runtime(
433
501
  restore_snapshot=dispatch_service.restore_snapshot,
434
502
  hooks=hooks,
435
503
  brain_memory=brain_memory,
504
+ permission_mode=mode,
436
505
  )
437
506
  return SingleAgentRuntime(deps)
438
507
 
@@ -0,0 +1,189 @@
1
+ """Voice memo capture — the fastest path from a thought to the Brain (v9.9.7).
2
+
3
+ Typing is the slowest part of capture. A 15-second voice memo on a phone or a
4
+ laptop is the shortest distance between "I should remember this" and the Brain
5
+ actually having it.
6
+
7
+ The hard constraint is local-first: a voice memo must never leave the machine
8
+ to become text. So transcription is an **injected, optional port**:
9
+
10
+ * when a local transcriber is available, the memo is transcribed and ingested
11
+ exactly like a typed note — same pipeline, same provenance, same quality
12
+ signals;
13
+ * when it is not, the audio is still stored and indexed by whatever the user
14
+ gave us (title, tags), and the result says ``transcription="unavailable"``
15
+ with the reason. **A missing transcriber never produces an invented
16
+ transcript**, and the memo is never silently dropped either.
17
+
18
+ Nothing here reaches for a cloud speech API, and nothing here installs a model
19
+ behind the user's back — an absent transcriber is a reported state, not a
20
+ prompt to download something.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ from pathlib import Path
27
+ from typing import Any, Callable, Dict, Optional
28
+
29
+ LOGGER = logging.getLogger(__name__)
30
+
31
+ __all__ = ["VoiceCaptureService", "TranscriptionUnavailable", "SUPPORTED_AUDIO_EXTENSIONS"]
32
+
33
+ # Container formats a local transcriber can plausibly open. Anything else is
34
+ # refused with a clear reason rather than handed to a transcriber that will
35
+ # fail obscurely.
36
+ SUPPORTED_AUDIO_EXTENSIONS = frozenset({
37
+ ".m4a", ".mp3", ".wav", ".aac", ".flac", ".ogg", ".webm", ".mp4",
38
+ })
39
+
40
+ # A memo is short by nature; a huge file is a recording session, not a memo,
41
+ # and would block the request for minutes.
42
+ MAX_AUDIO_BYTES = 50 * 1024 * 1024
43
+
44
+
45
+ class TranscriptionUnavailable(RuntimeError):
46
+ """No local transcriber could turn this audio into text."""
47
+
48
+
49
+ class VoiceCaptureService:
50
+ """Ingest a voice memo through the unified ingestion pipeline."""
51
+
52
+ def __init__(
53
+ self,
54
+ *,
55
+ pipeline: Any,
56
+ transcriber: Optional[Callable[[str], str]] = None,
57
+ max_bytes: int = MAX_AUDIO_BYTES,
58
+ ) -> None:
59
+ self._pipeline = pipeline
60
+ self._transcriber = transcriber
61
+ self._max_bytes = max(1, int(max_bytes))
62
+
63
+ # ── capability ───────────────────────────────────────────────────────
64
+ def status(self) -> Dict[str, Any]:
65
+ """What this install can actually do with a voice memo, honestly."""
66
+ return {
67
+ "capture": self._pipeline is not None,
68
+ "transcription": self._transcriber is not None,
69
+ "supported_extensions": sorted(SUPPORTED_AUDIO_EXTENSIONS),
70
+ "max_bytes": self._max_bytes,
71
+ "detail": (
72
+ "음성 메모를 글로 바꿔 Brain에 저장합니다."
73
+ if self._transcriber is not None
74
+ else "이 컴퓨터에는 음성 인식기가 없어서, 메모는 저장되지만 글로 바뀌지는 않습니다."
75
+ ),
76
+ }
77
+
78
+ # ── transcription ────────────────────────────────────────────────────
79
+ def _transcribe(self, path: Path) -> str:
80
+ if self._transcriber is None:
81
+ raise TranscriptionUnavailable("no local transcriber is configured")
82
+ text = self._transcriber(str(path))
83
+ cleaned = str(text or "").strip()
84
+ if not cleaned:
85
+ # An empty transcript is not text: reporting it as one would put an
86
+ # empty note in the Brain and call it a memory.
87
+ raise TranscriptionUnavailable("the transcriber returned no text")
88
+ return cleaned
89
+
90
+ # ── capture ──────────────────────────────────────────────────────────
91
+ def capture(
92
+ self,
93
+ audio_path: str,
94
+ *,
95
+ title: Optional[str] = None,
96
+ user_email: Optional[str] = None,
97
+ workspace_id: Optional[str] = None,
98
+ conversation_id: Optional[str] = None,
99
+ transcript: Optional[str] = None,
100
+ ) -> Dict[str, Any]:
101
+ """Ingest one voice memo.
102
+
103
+ ``transcript`` lets a client that already has text (a phone's own
104
+ dictation, for example) skip local transcription entirely — the memo
105
+ still lands in the Brain through the same pipeline.
106
+ """
107
+ from lattice_brain.ingestion import IngestionItem
108
+
109
+ path = Path(str(audio_path or "")).expanduser()
110
+ if not path.exists() or not path.is_file():
111
+ return self._failed("FILE_NOT_FOUND", f"audio file not found: {path}")
112
+ suffix = path.suffix.lower()
113
+ if suffix not in SUPPORTED_AUDIO_EXTENSIONS:
114
+ return self._failed(
115
+ "UNSUPPORTED_FORMAT",
116
+ f"{suffix or 'this file'} is not a supported audio container",
117
+ )
118
+ try:
119
+ size = path.stat().st_size
120
+ except OSError as exc:
121
+ return self._failed("FILE_NOT_FOUND", f"audio file unreadable: {exc}")
122
+ if size > self._max_bytes:
123
+ return self._failed(
124
+ "SIZE_LIMIT",
125
+ f"audio is {size} bytes; the memo limit is {self._max_bytes}",
126
+ )
127
+ if self._pipeline is None:
128
+ return self._failed("UNAVAILABLE", "the ingestion pipeline is not available")
129
+
130
+ supplied = str(transcript or "").strip()
131
+ transcription_state = "supplied" if supplied else "ok"
132
+ transcription_detail = ""
133
+ text = supplied
134
+ if not text:
135
+ try:
136
+ text = self._transcribe(path)
137
+ except TranscriptionUnavailable as exc:
138
+ transcription_state = "unavailable"
139
+ transcription_detail = str(exc)
140
+ text = ""
141
+ except Exception as exc: # noqa: BLE001 — a broken transcriber is a state
142
+ LOGGER.exception("voice transcription failed")
143
+ transcription_state = "failed"
144
+ transcription_detail = str(exc)
145
+ text = ""
146
+
147
+ display_title = str(title or "").strip() or path.stem
148
+ # With no transcript there is no text to index — the memo is recorded
149
+ # by what we honestly know (title + the audio file itself), and the
150
+ # response says so. It is never presented as a searchable note.
151
+ body = text or (
152
+ f"[음성 메모] {display_title}\n"
153
+ "이 메모는 아직 글로 바뀌지 않았습니다 — 음성 인식기가 없어 내용 검색은 되지 않습니다."
154
+ )
155
+ item = IngestionItem(
156
+ source_type="note",
157
+ title=display_title,
158
+ text=body,
159
+ source_uri=str(path),
160
+ mime_type=f"audio/{suffix.lstrip('.')}",
161
+ owner=user_email,
162
+ workspace_id=workspace_id,
163
+ conversation_id=conversation_id,
164
+ metadata={
165
+ "capture": "voice",
166
+ "audio_path": str(path),
167
+ "audio_bytes": size,
168
+ "transcription": transcription_state,
169
+ **({"transcription_detail": transcription_detail} if transcription_detail else {}),
170
+ },
171
+ )
172
+ result = self._pipeline.ingest(item, user_email=user_email)
173
+ payload = result.as_dict() if hasattr(result, "as_dict") else dict(result)
174
+ payload["transcription"] = transcription_state
175
+ payload["searchable"] = bool(text)
176
+ if transcription_detail:
177
+ payload["transcription_detail"] = transcription_detail
178
+ return payload
179
+
180
+ @staticmethod
181
+ def _failed(error: str, message: str) -> Dict[str, Any]:
182
+ return {
183
+ "status": "failed",
184
+ "source_type": "note",
185
+ "error": error,
186
+ "message": message,
187
+ "transcription": "skipped",
188
+ "searchable": False,
189
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "9.9.5",
3
+ "version": "10.0.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": {
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "scripts": {
18
18
  "start": "LTCAI",
19
- "dev": "python3 -m latticeai.cli.entrypoint --reload",
19
+ "dev": "node scripts/run_python.mjs -m latticeai.cli.entrypoint --reload",
20
20
  "build": "npm run build:assets && npm run build:python",
21
21
  "frontend:dev": "vite --host 127.0.0.1",
22
22
  "frontend:openapi": "node scripts/run_python.mjs scripts/export_openapi.py frontend/openapi.json && npx openapi-typescript frontend/openapi.json -o frontend/src/api/openapi.ts",
@@ -24,7 +24,7 @@
24
24
  "build:assets": "vite build && node scripts/build_frontend_assets.mjs",
25
25
  "build:python": "node scripts/run_python.mjs -m build",
26
26
  "check:python": "node scripts/run_python.mjs scripts/check_python.py",
27
- "lint": "npm run lint:python && node --check tests/visual/mock_server.cjs && node --check tests/visual/v3.spec.js && npm run lint:frontend && npm run frontend:openapi:check && node scripts/check_i18n_literals.mjs && npm run check:bundle && npm run check:legacy-debt && npm run test:browser-extension",
27
+ "lint": "npm run lint:python && node --check tests/visual/mock_server.cjs && node --check tests/visual/v3.spec.js && npm run lint:frontend && npm run frontend:openapi:check && node scripts/check_i18n_literals.mjs && npm run check:i18n-namespaces && npm run check:bundle && npm run check:legacy-debt && npm run test:browser-extension && npm run test:vscode-extension",
28
28
  "check:legacy-debt": "node scripts/check_legacy_debt.mjs",
29
29
  "lint:python": "node scripts/run_python.mjs -m ruff check .",
30
30
  "lint:frontend": "node scripts/lint_frontend.mjs",
@@ -57,7 +57,9 @@
57
57
  "publish:pypi": "npm run build:python && node scripts/run_python.mjs -m twine upload --skip-existing dist/ltcai-$npm_package_version.tar.gz dist/ltcai-$npm_package_version-py3-none-any.whl",
58
58
  "publish:vscode": "cd vscode-extension && npm run package:vsix && npm run publish:vscode",
59
59
  "publish:openvsx": "cd vscode-extension && npm run package:vsix && npm run publish:openvsx",
60
- "publish:all": "npm run release:artifacts && npm run release:validate && npm publish ltcai-$npm_package_version.tgz --access public && node scripts/run_python.mjs -m twine upload --skip-existing dist/ltcai-$npm_package_version.tar.gz dist/ltcai-$npm_package_version-py3-none-any.whl && cd vscode-extension && npm run publish:vscode && npm run publish:openvsx"
60
+ "publish:all": "npm run release:artifacts && npm run release:validate && npm publish ltcai-$npm_package_version.tgz --access public && node scripts/run_python.mjs -m twine upload --skip-existing dist/ltcai-$npm_package_version.tar.gz dist/ltcai-$npm_package_version-py3-none-any.whl && cd vscode-extension && npm run publish:vscode && npm run publish:openvsx",
61
+ "test:vscode-extension": "cd vscode-extension && npm run build && cd .. && node --test tests/vscode-extension.test.cjs",
62
+ "check:i18n-namespaces": "node scripts/check_i18n_namespace_coverage.mjs"
61
63
  },
62
64
  "keywords": [
63
65
  "ltcai",
@@ -23,6 +23,17 @@ const manifestPath = join(outDir, "asset-manifest.json");
23
23
  // pre-split baseline (632.9 kB raw / ~180 kB gzip single chunk) to lock in the
24
24
  // code-splitting win and catch regressions that pull heavy code back onto the
25
25
  // first-paint path. Raise it only with a deliberate, reviewed reason.
26
+ //
27
+ // 9.9.8 raised this to 152 KiB because `frontend/src/i18n/*` was one synchronous
28
+ // table, so every route's copy sat in the entry chunk and the tree had ~0.7 KiB
29
+ // of headroom left. 9.9.9 fixed the cause instead: namespaces register on
30
+ // import and each lazy route pulls only the copy it reads, which dropped the
31
+ // initial closure from 150.0 KiB to ~99 KiB gzip. The ceiling is back at its
32
+ // original 150 KiB with real headroom underneath it again.
33
+ //
34
+ // `scripts/check_i18n_namespace_coverage.mjs` guards the split — it fails if a
35
+ // module reads a key whose namespace its chunk never imports, which would
36
+ // otherwise render the raw key instead of translated text.
26
37
  const INITIAL_JS_GZIP_BUDGET = 153_600; // 150 KiB
27
38
 
28
39
  function build() {
@@ -6,7 +6,7 @@ const root = process.cwd();
6
6
  const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
7
7
  const version = pkg.version;
8
8
  const releaseDir = `output/release/v${version}`;
9
- const releaseTheme = "Closed Gaps";
9
+ const releaseTheme = "Plain Language";
10
10
  const title = `${version} — ${releaseTheme}`;
11
11
  const escapedVersion = version.replaceAll(".", "\\.");
12
12
 
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ // Proves the lazy i18n split is safe.
3
+ //
4
+ // `frontend/src/i18n/*` used to be one synchronous table merged into the entry
5
+ // chunk, so every route's copy sat on the first-paint path (~3,000 lines).
6
+ // Namespaces now register themselves on import, and a route pulls the namespace
7
+ // it needs into its own lazy chunk.
8
+ //
9
+ // The failure mode that buys is silent: a component reads a key whose namespace
10
+ // its chunk never imported, `t()` falls through to returning the raw key, and
11
+ // the UI renders "system.permission.title" instead of Korean text. No error, no
12
+ // test failure unless that exact component is rendered in a test.
13
+ //
14
+ // So this check walks the real module graph. For each entry — the eager root
15
+ // plus every React.lazy() boundary — it collects the static closure, every
16
+ // `t(lang, "key")` used in it, and every i18n namespace it imports. A key whose
17
+ // namespace is not reachable from that entry is an error.
18
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
19
+ import { join, dirname, resolve, relative } from "node:path";
20
+ import process from "node:process";
21
+
22
+ const repo = join(import.meta.dirname, "..");
23
+ const src = join(repo, "frontend", "src");
24
+ const i18nDir = join(src, "i18n");
25
+
26
+ const NAMESPACES = readdirSync(i18nDir)
27
+ .filter((name) => name.endsWith(".ts"))
28
+ .map((name) => name.replace(/\.ts$/, ""))
29
+ .filter((name) => !["types", "registry"].includes(name));
30
+
31
+ // key -> namespace that defines it
32
+ const keyOwner = new Map();
33
+ for (const ns of NAMESPACES) {
34
+ const text = readFileSync(join(i18nDir, `${ns}.ts`), "utf8");
35
+ for (const match of text.matchAll(/^\s+"([^"]+)":/gm)) keyOwner.set(match[1], ns);
36
+ }
37
+
38
+ // `shell` is registered by i18n.ts itself, so it is always available.
39
+ const ALWAYS_AVAILABLE = new Set(["shell"]);
40
+
41
+ const STATIC_IMPORT = /^\s*import\s+(?!type\s)([^;]*?)from\s+"([^"]+)"/gm;
42
+ const BARE_IMPORT = /^\s*import\s+"([^"]+)"/gm;
43
+ const LAZY_IMPORT = /import\(\s*"([^"]+)"\s*\)/g;
44
+ const KEY_USE = /\bt\(\s*[A-Za-z_$][\w.$]*\s*,\s*"([^"]+)"/g;
45
+
46
+ function resolveSpec(spec, importer) {
47
+ let base;
48
+ if (spec.startsWith("@/")) base = join(src, spec.slice(2));
49
+ else if (spec.startsWith(".")) base = resolve(dirname(importer), spec);
50
+ else return null;
51
+ for (const candidate of [`${base}.ts`, `${base}.tsx`, join(base, "index.ts"), join(base, "index.tsx")]) {
52
+ if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ function readModule(file) {
58
+ const raw = readFileSync(file, "utf8");
59
+ // Drop `import { type X }`-only specifiers: TypeScript elides them, so they
60
+ // are not runtime edges and must not count as namespace coverage.
61
+ const statics = [];
62
+ for (const match of raw.matchAll(STATIC_IMPORT)) {
63
+ const clause = match[1];
64
+ const named = clause.match(/\{([^}]*)\}/);
65
+ const onlyTypes = named && named[1].trim().length > 0
66
+ && named[1].split(",").every((part) => !part.trim() || part.trim().startsWith("type "))
67
+ && !/^\s*\w+\s*,/.test(clause);
68
+ if (!onlyTypes) statics.push(match[2]);
69
+ }
70
+ for (const match of raw.matchAll(BARE_IMPORT)) statics.push(match[1]);
71
+ return {
72
+ statics,
73
+ lazy: [...raw.matchAll(LAZY_IMPORT)].map((m) => m[1]),
74
+ keys: [...raw.matchAll(KEY_USE)].map((m) => m[1]),
75
+ };
76
+ }
77
+
78
+ const cache = new Map();
79
+ function moduleInfo(file) {
80
+ if (!cache.has(file)) cache.set(file, readModule(file));
81
+ return cache.get(file);
82
+ }
83
+
84
+ /** Static closure of an entry, not crossing further lazy boundaries. */
85
+ function closure(entry) {
86
+ const seen = new Set();
87
+ const stack = [entry];
88
+ const lazyEdges = new Set();
89
+ while (stack.length) {
90
+ const file = stack.pop();
91
+ if (seen.has(file) || !existsSync(file)) continue;
92
+ seen.add(file);
93
+ const info = moduleInfo(file);
94
+ for (const spec of info.lazy) {
95
+ const target = resolveSpec(spec, file);
96
+ if (target) lazyEdges.add(target);
97
+ }
98
+ for (const spec of info.statics) {
99
+ const target = resolveSpec(spec, file);
100
+ if (target) stack.push(target);
101
+ }
102
+ }
103
+ return { modules: seen, lazyEdges };
104
+ }
105
+
106
+ const root = join(src, "main.tsx");
107
+ const entries = new Map([["<entry> main.tsx", root]]);
108
+ const rootClosure = closure(root);
109
+ for (const target of rootClosure.lazyEdges) {
110
+ entries.set(`<lazy> ${relative(src, target)}`, target);
111
+ }
112
+
113
+ const errors = [];
114
+ for (const [label, entry] of entries) {
115
+ const { modules } = closure(entry);
116
+ const available = new Set(ALWAYS_AVAILABLE);
117
+ for (const file of modules) {
118
+ if (dirname(file) === i18nDir) {
119
+ const ns = relative(i18nDir, file).replace(/\.ts$/, "");
120
+ if (NAMESPACES.includes(ns)) available.add(ns);
121
+ }
122
+ }
123
+ for (const file of modules) {
124
+ if (dirname(file) === i18nDir) continue;
125
+ for (const key of moduleInfo(file).keys) {
126
+ const owner = keyOwner.get(key);
127
+ if (!owner) {
128
+ errors.push(`${label}: ${relative(src, file)} uses unknown key "${key}"`);
129
+ } else if (!available.has(owner)) {
130
+ errors.push(
131
+ `${label}: ${relative(src, file)} uses "${key}" from the "${owner}" namespace, `
132
+ + `which this chunk never imports (add: import "@/i18n/${owner}";)`,
133
+ );
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ if (errors.length) {
140
+ console.error("i18n namespace coverage check failed:");
141
+ for (const error of [...new Set(errors)].sort()) console.error(`- ${error}`);
142
+ process.exit(1);
143
+ }
144
+
145
+ console.log(
146
+ `i18n namespace coverage: ${entries.size} entry chunk(s) verified against `
147
+ + `${NAMESPACES.length} namespaces (${keyOwner.size} keys).`,
148
+ );
@@ -0,0 +1,82 @@
1
+ # Skill: meeting_notes
2
+
3
+ description: 회의록·메모에서 결정사항과 할 일을 뽑아 정리된 노트 파일로 저장한다.
4
+
5
+ ## 메타데이터
6
+ - **버전**: 1.0.0
7
+ - **카테고리**: document
8
+ - **위험도**: low
9
+ - **필요 권한**: workspace_write
10
+
11
+ ## 설명
12
+
13
+ 붙여넣은 회의 메모(또는 워크스페이스의 메모 파일)를 읽어 **결정사항 / 할 일 /
14
+ 미해결 질문**으로 갈라 정리하고, 워크스페이스에 마크다운 노트 한 장으로
15
+ 저장한다. 저장된 파일은 다른 생성물과 동일하게 Brain에 수집되므로, 다음 주에
16
+ "지난 회의에서 뭘 정하기로 했지?"라고 물으면 근거와 함께 답이 나온다.
17
+
18
+ 이 스킬이 "설치하자마자 값이 보이는" 이유: 입력이 사람이 이미 가지고 있는
19
+ 것(회의 메모)이고, 출력이 사람이 이미 원하던 것(할 일 목록)이며, 중간에
20
+ 설정할 것이 없다.
21
+
22
+ ## 거버넌스
23
+
24
+ `risk.json` 참고. 요약: `risk=write`, `destructive=false`, `shell=false`,
25
+ `network=false`, `auto_approve=true`, `sandbox=workspace`, `rollback=snapshot`.
26
+
27
+ 새 파일 생성은 additive라 바로 실행되고, **기존 파일을 덮어쓰는 경우에는
28
+ ChangeGovernor가 변경 제안으로 돌린다** — 승인 전에는 디스크가 바뀌지 않는다.
29
+
30
+ ## 트리거 조건
31
+
32
+ 호출해야 하는 상황:
33
+ - "회의록 정리해줘", "이 메모에서 할 일만 뽑아줘"
34
+ - 통화/미팅 직후 메모를 붙여넣었을 때
35
+ - 주간 회의 노트를 매번 같은 형식으로 남기고 싶을 때
36
+
37
+ 호출하면 **안** 되는 상황:
38
+ - 문서 전체 요약이 목적일 때 → `summarize_document`
39
+ - 코드 리뷰 → `code_review`
40
+ - 표/수치 분석 → `data_analysis`
41
+
42
+ ## Side Effects
43
+
44
+ | 항목 | 내용 |
45
+ |------|------|
46
+ | 파일 변경 | 대상 경로에 마크다운 1개 생성 (기존 파일이면 변경 제안) |
47
+ | 생성 파일 | `notes/<slug>.md` (기본) |
48
+ | 프로세스 | 없음 |
49
+ | 네트워크 | 없음 |
50
+
51
+ ## Rollback
52
+
53
+ `rollback=snapshot`. 실행 실패 시 쓰기 전 스냅샷으로 복구하며, 이 실행이 새로
54
+ 만든 파일은 삭제된다.
55
+
56
+ ## 입력/출력 스키마
57
+
58
+ `schema.json` 참고.
59
+
60
+ ## 실행 조건
61
+
62
+ - LLM 모델이 로드되어 있어야 함
63
+ - `notes` 텍스트 또는 읽을 수 있는 `source_path` 중 하나는 필수
64
+
65
+ ## 정직성 규칙
66
+
67
+ - 메모에 없는 결정/할 일을 **만들어 내지 않는다.** 근거 문장이 없으면 그 항목은
68
+ 넣지 않는다.
69
+ - 담당자·기한이 적혀 있지 않으면 비워 둔다. 추측해서 채우지 않는다.
70
+ - 결정사항이 하나도 없으면 "결정된 사항 없음"이라고 그대로 적는다.
71
+
72
+ ## 예제
73
+
74
+ `examples.md` 참고.
75
+
76
+ ## 실패 처리
77
+
78
+ | 에러 코드 | 원인 | 처리 방법 |
79
+ |-----------|------|-----------|
80
+ | `INVALID_INPUT` | notes와 source_path가 모두 없음 | 둘 중 하나 제공 |
81
+ | `FILE_NOT_FOUND` | source_path 없음 | 경로 확인 |
82
+ | `MODEL_NOT_LOADED` | 모델 미로드 | 모델을 먼저 로드 |