ltcai 9.9.4 → 9.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +43 -42
  2. package/docs/CHANGELOG.md +228 -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 +45 -9
  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/rerank.py +160 -0
  19. package/lattice_brain/graph/retrieval.py +28 -442
  20. package/lattice_brain/graph/retrieval_reads.py +459 -0
  21. package/lattice_brain/graph/retrieval_vector.py +10 -0
  22. package/lattice_brain/graph/store.py +2 -0
  23. package/lattice_brain/ingestion.py +9 -106
  24. package/lattice_brain/ingestion_jobs.py +133 -0
  25. package/lattice_brain/quality.py +26 -6
  26. package/lattice_brain/runtime/multi_agent.py +1 -1
  27. package/latticeai/__init__.py +1 -1
  28. package/latticeai/api/brain_intelligence.py +8 -0
  29. package/latticeai/api/chat.py +9 -0
  30. package/latticeai/api/chat_agent_http.py +206 -48
  31. package/latticeai/api/chat_contracts.py +10 -1
  32. package/latticeai/api/chat_documents.py +16 -0
  33. package/latticeai/api/evidence_actions.py +48 -0
  34. package/latticeai/api/permission_mode.py +61 -0
  35. package/latticeai/api/project_sessions.py +112 -0
  36. package/latticeai/api/voice_capture.py +89 -0
  37. package/latticeai/app_factory.py +53 -0
  38. package/latticeai/core/agent.py +536 -60
  39. package/latticeai/core/agent_permission.py +160 -0
  40. package/latticeai/core/agent_profiles.py +110 -0
  41. package/latticeai/core/artifact_ledger.py +109 -0
  42. package/latticeai/core/context_builder.py +101 -2
  43. package/latticeai/core/legacy_compatibility.py +1 -1
  44. package/latticeai/core/marketplace.py +1 -1
  45. package/latticeai/core/permission_mode.py +296 -0
  46. package/latticeai/core/project_sessions.py +337 -0
  47. package/latticeai/core/run_explain.py +421 -0
  48. package/latticeai/core/run_store.py +7 -0
  49. package/latticeai/core/workspace_os.py +15 -80
  50. package/latticeai/core/workspace_review_items.py +107 -0
  51. package/latticeai/integrations/telegram_bot.py +270 -23
  52. package/latticeai/runtime/chat_wiring.py +8 -0
  53. package/latticeai/runtime/context_runtime.py +11 -0
  54. package/latticeai/runtime/permission_mode_wiring.py +116 -0
  55. package/latticeai/runtime/router_registration.py +9 -0
  56. package/latticeai/services/app_context.py +3 -0
  57. package/latticeai/services/architecture_readiness.py +1 -1
  58. package/latticeai/services/brain_intelligence.py +105 -0
  59. package/latticeai/services/evidence_actions.py +254 -0
  60. package/latticeai/services/funnel_metrics.py +111 -16
  61. package/latticeai/services/local_knowledge.py +26 -0
  62. package/latticeai/services/memory_service.py +12 -0
  63. package/latticeai/services/permission_mode_service.py +170 -0
  64. package/latticeai/services/product_readiness.py +1 -1
  65. package/latticeai/services/tool_dispatch.py +129 -4
  66. package/latticeai/services/voice_capture.py +189 -0
  67. package/package.json +6 -3
  68. package/scripts/check_bundle_budget.mjs +11 -0
  69. package/scripts/check_current_release_docs.mjs +1 -1
  70. package/scripts/check_i18n_namespace_coverage.mjs +148 -0
  71. package/scripts/run_sidecar_e2e.mjs +159 -0
  72. package/skills/meeting_notes/SKILL.md +82 -0
  73. package/skills/meeting_notes/examples.md +41 -0
  74. package/skills/meeting_notes/risk.json +9 -0
  75. package/skills/meeting_notes/schema.json +55 -0
  76. package/skills/weekly_review/SKILL.md +75 -0
  77. package/skills/weekly_review/examples.md +39 -0
  78. package/skills/weekly_review/risk.json +9 -0
  79. package/skills/weekly_review/schema.json +55 -0
  80. package/src-tauri/Cargo.lock +1 -1
  81. package/src-tauri/Cargo.toml +1 -1
  82. package/src-tauri/tauri.conf.json +1 -1
  83. package/static/app/asset-manifest.json +37 -29
  84. package/static/app/assets/Act-BFbpl2_Y.js +1 -0
  85. package/static/app/assets/AdminConsole-DaZPha9Y.js +1 -0
  86. package/static/app/assets/Brain-DGrV0heZ.js +321 -0
  87. package/static/app/assets/BrainHome-Re6Zxh_W.js +2 -0
  88. package/static/app/assets/BrainSignals-BQ11jDpJ.js +1 -0
  89. package/static/app/assets/Capture-DRaf1zLH.js +1 -0
  90. package/static/app/assets/CommandPalette-DAKPm4uJ.js +1 -0
  91. package/static/app/assets/LanguageSwitcher-BgTwHoWv.js +1 -0
  92. package/static/app/assets/Library-BVtrSt12.js +1 -0
  93. package/static/app/assets/LivingBrain-7glOmK0R.js +1 -0
  94. package/static/app/assets/ProductFlow-cB3EFinZ.js +1 -0
  95. package/static/app/assets/ReviewCard-CShhHS-w.js +3 -0
  96. package/static/app/assets/System-B4nQDy8a.js +1 -0
  97. package/static/app/assets/{bot-nB_buEZD.js → bot-DtKkLfXj.js} +1 -1
  98. package/static/app/assets/brain-wVV63_1z.js +1 -0
  99. package/static/app/assets/button-BTxZDMFJ.js +1 -0
  100. package/static/app/assets/{circle-pause-DLNw6Ucp.js → circle-pause--qxF9-EO.js} +1 -1
  101. package/static/app/assets/{circle-play-B-IsFL1y.js → circle-play-89x3S6jm.js} +1 -1
  102. package/static/app/assets/{cpu-CEPBHaBl.js → cpu-DTUI63vo.js} +1 -1
  103. package/static/app/assets/download-EJS5_O0f.js +1 -0
  104. package/static/app/assets/{folder-open-hmN0N9cX.js → folder-open-BL9zYTnc.js} +1 -1
  105. package/static/app/assets/{hard-drive-CBV_B_Yd.js → hard-drive-BFnwi1Lu.js} +1 -1
  106. package/static/app/assets/index-BFtDYI6l.js +10 -0
  107. package/static/app/assets/index-BY9bpxyx.css +2 -0
  108. package/static/app/assets/input-DEz5UjBz.js +1 -0
  109. package/static/app/assets/{network-jE42eKfT.js → network-zKa3jAwl.js} +1 -1
  110. package/static/app/assets/primitives-jNrZm2O9.js +1 -0
  111. package/static/app/assets/search-ku6-_6wY.js +1 -0
  112. package/static/app/assets/shield-alert-CpHeCsaF.js +1 -0
  113. package/static/app/assets/textarea-DpcQkn75.js +1 -0
  114. package/static/app/assets/{useFocusTrap-B7RPGfFy.js → useFocusTrap-58TNCONM.js} +1 -1
  115. package/static/app/assets/{navigation-Bot0hvuv.js → useQuery-CcVlXlBd.js} +1 -1
  116. package/static/app/assets/users-C0cFMj6o.js +1 -0
  117. package/static/app/assets/utils-DYf7pqrR.js +7 -0
  118. package/static/app/assets/workspace-CjthHsLN.js +1 -0
  119. package/static/app/index.html +4 -4
  120. package/static/sw.js +1 -1
  121. package/static/app/assets/Act-BCmTU0E2.js +0 -1
  122. package/static/app/assets/Brain-CTnjox7w.js +0 -321
  123. package/static/app/assets/BrainHome-BJ3sFNX0.js +0 -2
  124. package/static/app/assets/BrainSignals-C52lwZVD.js +0 -1
  125. package/static/app/assets/Capture-B6vBhFa3.js +0 -1
  126. package/static/app/assets/CommandPalette-90u9FWFH.js +0 -1
  127. package/static/app/assets/Library-pKCK0_tk.js +0 -1
  128. package/static/app/assets/LivingBrain-Jlf2wFqI.js +0 -1
  129. package/static/app/assets/ProductFlow-yg1fKP1P.js +0 -1
  130. package/static/app/assets/ReviewCard-DWvD7n9h.js +0 -3
  131. package/static/app/assets/System-BAEuHqNY.js +0 -1
  132. package/static/app/assets/index-7FAfYm4v.css +0 -2
  133. package/static/app/assets/index-DrmOCySv.js +0 -10
  134. package/static/app/assets/input-gtVCg-ll.js +0 -1
  135. package/static/app/assets/primitives-CX2Komon.js +0 -1
  136. package/static/app/assets/shield-alert-BftATuAA.js +0 -1
  137. package/static/app/assets/textarea-CiMJfOSI.js +0 -1
  138. package/static/app/assets/utils-SJUNVOj5.js +0 -7
@@ -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.4",
3
+ "version": "9.9.9",
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": {
@@ -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",
@@ -41,6 +41,7 @@
41
41
  "test:integration": "node scripts/run_integration_tests.mjs",
42
42
  "test:browser-extension": "node --check browser-extension/popup.js && node --check browser-extension/capture-page.js && node --test tests/browser-extension.test.cjs",
43
43
  "test:visual": "playwright test",
44
+ "test:e2e:sidecar": "node scripts/run_sidecar_e2e.mjs",
44
45
  "vercel:build": "node scripts/build_vercel_static.mjs",
45
46
  "desktop:tauri": "tauri dev",
46
47
  "desktop:tauri:build": "tauri build",
@@ -56,7 +57,9 @@
56
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",
57
58
  "publish:vscode": "cd vscode-extension && npm run package:vsix && npm run publish:vscode",
58
59
  "publish:openvsx": "cd vscode-extension && npm run package:vsix && npm run publish:openvsx",
59
- "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"
60
63
  },
61
64
  "keywords": [
62
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 = "Durable Loops";
9
+ const releaseTheme = "Lean Shell";
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,159 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Start an isolated Lattice sidecar and run Playwright first-value E2E
4
+ * against it (Wave 3.2 residual / v9.9.5).
5
+ *
6
+ * Mirrors scripts/run_integration_tests.mjs isolation rules: never touch
7
+ * the developer's real HOME / ~/.ltcai / Brain vault.
8
+ */
9
+ import { spawn } from "node:child_process";
10
+ import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ const host = process.env.LTCAI_E2E_HOST || "127.0.0.1";
15
+ if (!new Set(["127.0.0.1", "localhost"]).has(host)) {
16
+ throw new Error(`LTCAI_E2E_HOST must be loopback, received: ${host}`);
17
+ }
18
+ const port = process.env.LTCAI_E2E_PORT || "4899";
19
+ if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
20
+ throw new Error(`LTCAI_E2E_PORT must be 1–65535, received: ${port}`);
21
+ }
22
+ const baseUrl = `http://${host}:${port}`;
23
+ const venvPython = join(process.cwd(), ".venv", "bin", "python");
24
+ const python = process.env.PYTHON || (existsSync(venvPython) ? venvPython : "python3");
25
+
26
+ const sandboxRoot = mkdtempSync(join(tmpdir(), "ltcai-e2e-"));
27
+ const sandbox = {
28
+ home: join(sandboxRoot, "home"),
29
+ data: join(sandboxRoot, "data"),
30
+ brain: join(sandboxRoot, "brain"),
31
+ agent: join(sandboxRoot, "agent-workspace"),
32
+ vault: join(sandboxRoot, "vault"),
33
+ cache: join(sandboxRoot, "cache"),
34
+ config: join(sandboxRoot, "config"),
35
+ tmp: join(sandboxRoot, "tmp"),
36
+ };
37
+ for (const path of Object.values(sandbox)) mkdirSync(path, { recursive: true });
38
+
39
+ const isolatedEnv = {
40
+ ...process.env,
41
+ HOME: sandbox.home,
42
+ USERPROFILE: sandbox.home,
43
+ XDG_CACHE_HOME: sandbox.cache,
44
+ XDG_CONFIG_HOME: sandbox.config,
45
+ XDG_DATA_HOME: join(sandboxRoot, "share"),
46
+ TMPDIR: sandbox.tmp,
47
+ TEMP: sandbox.tmp,
48
+ TMP: sandbox.tmp,
49
+ LATTICEAI_MODE: "local",
50
+ LATTICEAI_HOST: host,
51
+ LATTICEAI_PORT: port,
52
+ LATTICEAI_DATA_DIR: sandbox.data,
53
+ LATTICEAI_BRAIN_DIR: sandbox.brain,
54
+ LATTICEAI_AGENT_ROOT: sandbox.agent,
55
+ LATTICEAI_OBSIDIAN_VAULT_DIR: sandbox.vault,
56
+ LATTICEAI_STORAGE_ENGINE: "sqlite",
57
+ LATTICEAI_POSTGRES_DSN: "",
58
+ LATTICEAI_REQUIRE_AUTH: "false",
59
+ LATTICEAI_ENABLE_TELEGRAM: "false",
60
+ LATTICEAI_AUTOLOAD_MODELS: "false",
61
+ LATTICEAI_ALLOW_MODEL_DOWNLOADS: "false",
62
+ LATTICEAI_AUTO_READ_CHAT_PATHS: "false",
63
+ LATTICEAI_TUNNEL: "false",
64
+ LATTICEAI_DISCORD_PERMISSION_WEBHOOK: "",
65
+ LATTICEAI_DISCORD_BOT_TOKEN: "",
66
+ OIDC_DISCOVERY_URL: "",
67
+ PYTHON_KEYRING_BACKEND: "keyring.backends.null.Keyring",
68
+ LTCAI_E2E_BASE_URL: baseUrl,
69
+ };
70
+
71
+ let sandboxCleaned = false;
72
+ function cleanupSandbox() {
73
+ if (sandboxCleaned) return;
74
+ sandboxCleaned = true;
75
+ rmSync(sandboxRoot, { recursive: true, force: true });
76
+ }
77
+ process.once("exit", cleanupSandbox);
78
+
79
+ function delay(ms) {
80
+ return new Promise((resolve) => setTimeout(resolve, ms));
81
+ }
82
+
83
+ async function waitForHealth(url, timeoutMs = 60000) {
84
+ const deadline = Date.now() + timeoutMs;
85
+ let lastError = "";
86
+ while (Date.now() < deadline) {
87
+ try {
88
+ const response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(3000) });
89
+ if (response.ok) return;
90
+ lastError = `HTTP ${response.status}`;
91
+ } catch (error) {
92
+ lastError = String(error?.message || error);
93
+ }
94
+ await delay(500);
95
+ }
96
+ throw new Error(`Timed out waiting for ${url}/health: ${lastError}`);
97
+ }
98
+
99
+ function stop(child) {
100
+ return new Promise((resolve) => {
101
+ if (child.exitCode !== null || child.signalCode !== null) {
102
+ resolve();
103
+ return;
104
+ }
105
+ const timer = setTimeout(() => {
106
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
107
+ }, 5000);
108
+ child.once("close", () => {
109
+ clearTimeout(timer);
110
+ resolve();
111
+ });
112
+ child.kill("SIGTERM");
113
+ });
114
+ }
115
+
116
+ async function main() {
117
+ console.log(`[e2e] sandbox ${sandboxRoot}`);
118
+ console.log(`[e2e] starting sidecar at ${baseUrl}`);
119
+ // Same process model as scripts/run_integration_tests.mjs: uvicorn on
120
+ // server:app with an isolated env. Do not double-append /health — the
121
+ // wait helper already probes `${url}/health`.
122
+ const server = spawn(
123
+ python,
124
+ ["-m", "uvicorn", "server:app", "--host", host, "--port", port],
125
+ { stdio: "inherit", env: isolatedEnv, cwd: process.cwd() },
126
+ );
127
+
128
+ let exitCode = 1;
129
+ try {
130
+ await waitForHealth(baseUrl);
131
+ console.log("[e2e] sidecar healthy — running Playwright");
132
+ // Playwright must use the host browser install (npx playwright install).
133
+ // The sidecar isolation rewrites HOME/XDG_* into a disposable sandbox —
134
+ // reusing that env here makes Chromium look under /tmp/.../ms-playwright
135
+ // and fail with "Executable doesn't exist".
136
+ const playwrightEnv = {
137
+ ...process.env,
138
+ LTCAI_E2E_BASE_URL: baseUrl,
139
+ };
140
+ const playwright = spawn(
141
+ "npx",
142
+ ["playwright", "test", "-c", "playwright.e2e.config.js"],
143
+ { stdio: "inherit", env: playwrightEnv, cwd: process.cwd() },
144
+ );
145
+ exitCode = await new Promise((resolve, reject) => {
146
+ playwright.on("error", reject);
147
+ playwright.on("close", (code) => resolve(code ?? 1));
148
+ });
149
+ } catch (error) {
150
+ console.error("[e2e] failed:", error);
151
+ exitCode = 1;
152
+ } finally {
153
+ await stop(server);
154
+ cleanupSandbox();
155
+ }
156
+ process.exit(exitCode);
157
+ }
158
+
159
+ main();
@@ -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` | 모델 미로드 | 모델을 먼저 로드 |