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.
- package/README.md +39 -37
- package/docs/CHANGELOG.md +234 -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 +6 -4
- 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-BtCREeN1.js +1 -0
- package/static/app/assets/AdminConsole-TPeeN18T.js +1 -0
- package/static/app/assets/Brain-BKs6JAp0.js +321 -0
- package/static/app/assets/BrainHome-BPGOvSd6.js +2 -0
- package/static/app/assets/BrainSignals-CtzQZ15J.js +1 -0
- package/static/app/assets/Capture-1_NaHWqB.js +1 -0
- package/static/app/assets/CommandPalette-pqvQOXe4.js +1 -0
- package/static/app/assets/Library-DhvoPvC7.js +1 -0
- package/static/app/assets/{LivingBrain-BVWk58MW.js → LivingBrain-DlQ20Q75.js} +1 -1
- package/static/app/assets/ProductFlow-BZvGDRi_.js +1 -0
- package/static/app/assets/ReviewCard-BWgI0D2s.js +3 -0
- package/static/app/assets/System-CSMdYLMy.js +1 -0
- package/static/app/assets/activity-Dlfk8YC7.js +1 -0
- package/static/app/assets/{bot-VmP0kkeA.js → bot-CDvUB76P.js} +1 -1
- package/static/app/assets/brain-xczrohrt.js +1 -0
- package/static/app/assets/button-SOdH3Oyf.js +1 -0
- package/static/app/assets/{circle-pause-T9POo4qy.js → circle-pause-CG1ythH4.js} +1 -1
- package/static/app/assets/{circle-play-Bz5iTD0p.js → circle-play-HXwvjS6W.js} +1 -1
- package/static/app/assets/{cpu-CJhjRtNq.js → cpu-B0d-rGyk.js} +1 -1
- package/static/app/assets/download-BGIkTQL6.js +1 -0
- package/static/app/assets/{folder-open-B2K_22VI.js → folder-open-Dst_Z0_K.js} +1 -1
- package/static/app/assets/{hard-drive-CjJtYqHf.js → hard-drive-D53MsWkV.js} +1 -1
- package/static/app/assets/index-C_IrlQMV.js +10 -0
- package/static/app/assets/index-FxDusbr0.css +2 -0
- package/static/app/assets/{input-CSi6OBJ9.js → input-C5m0riF6.js} +1 -1
- package/static/app/assets/{network-Ck1nqc-v.js → network-C5a-E5iS.js} +1 -1
- package/static/app/assets/primitives-vNXYf58F.js +1 -0
- package/static/app/assets/search-DhbSgW6m.js +1 -0
- package/static/app/assets/shield-alert-Cc-WVXqN.js +1 -0
- package/static/app/assets/{textarea-C4wM0D-Z.js → textarea-BkZ0EqVO.js} +1 -1
- package/static/app/assets/{useFocusTrap-CsnIhsDJ.js → useFocusTrap-CTtKbAOU.js} +1 -1
- package/static/app/assets/{navigation-BPQqEQwr.js → useQuery-Dx1fi4Wu.js} +1 -1
- package/static/app/assets/users-BFpQXtEF.js +1 -0
- package/static/app/assets/utils-BA_lmW3J.js +7 -0
- package/static/app/assets/workspace-DBPB0jkX.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/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/primitives-B2f9N7Dh.js +0 -1
- package/static/app/assets/shield-alert-yA_Y8lOB.js +0 -1
- 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
|
|
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
|
-
"
|
|
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": "
|
|
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": "
|
|
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 = "
|
|
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` | 모델 미로드 | 모델을 먼저 로드 |
|