ltcai 8.8.0 → 8.9.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 (63) hide show
  1. package/README.md +34 -27
  2. package/auto_setup.py +73 -8
  3. package/docs/CHANGELOG.md +37 -0
  4. package/docs/CODE_REVIEW_2026-07-06.md +764 -0
  5. package/docs/COMMUNITY_AND_PLUGINS.md +3 -3
  6. package/docs/DEVELOPMENT.md +10 -10
  7. package/docs/LEGACY_COMPATIBILITY.md +1 -1
  8. package/docs/ONBOARDING.md +2 -2
  9. package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
  10. package/docs/TRUST_MODEL.md +5 -1
  11. package/docs/WHY_LATTICE.md +4 -3
  12. package/docs/architecture.md +4 -0
  13. package/docs/kg-schema.md +1 -1
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/conversations.py +156 -21
  16. package/lattice_brain/graph/_kg_common.py +12 -34
  17. package/lattice_brain/graph/json_utils.py +25 -0
  18. package/lattice_brain/graph/retrieval.py +66 -27
  19. package/lattice_brain/graph/runtime.py +16 -0
  20. package/lattice_brain/runtime/multi_agent.py +1 -1
  21. package/latticeai/__init__.py +1 -1
  22. package/latticeai/api/chat.py +31 -14
  23. package/latticeai/api/mcp.py +3 -2
  24. package/latticeai/api/models.py +4 -1
  25. package/latticeai/api/permissions.py +69 -30
  26. package/latticeai/api/setup.py +17 -2
  27. package/latticeai/api/tools.py +104 -62
  28. package/latticeai/app_factory.py +93 -10
  29. package/latticeai/core/agent.py +25 -7
  30. package/latticeai/core/legacy_compatibility.py +1 -1
  31. package/latticeai/core/marketplace.py +1 -1
  32. package/latticeai/core/sessions.py +11 -3
  33. package/latticeai/core/tool_registry.py +15 -4
  34. package/latticeai/core/workspace_os.py +1 -1
  35. package/latticeai/runtime/bootstrap.py +1 -1
  36. package/latticeai/services/app_context.py +1 -0
  37. package/latticeai/services/architecture_readiness.py +2 -2
  38. package/latticeai/services/model_engines.py +79 -12
  39. package/latticeai/services/model_runtime.py +24 -4
  40. package/latticeai/services/process_audit.py +208 -0
  41. package/latticeai/services/product_readiness.py +11 -11
  42. package/latticeai/services/search_service.py +106 -30
  43. package/latticeai/services/tool_dispatch.py +66 -0
  44. package/latticeai/services/workspace_service.py +15 -0
  45. package/package.json +1 -1
  46. package/scripts/check_i18n_literals.mjs +20 -8
  47. package/scripts/i18n_literal_allowlist.json +34 -0
  48. package/scripts/lint_frontend.mjs +6 -2
  49. package/setup_wizard.py +185 -19
  50. package/src-tauri/Cargo.lock +1 -1
  51. package/src-tauri/Cargo.toml +1 -1
  52. package/src-tauri/tauri.conf.json +1 -1
  53. package/static/app/asset-manifest.json +10 -10
  54. package/static/app/assets/{Act-C7K9wsO9.js → Act-fZokUnC0.js} +1 -1
  55. package/static/app/assets/{Brain-I1OSzxJu.js → Brain-DtyuWubr.js} +1 -1
  56. package/static/app/assets/{Capture-B3V4_5Xp.js → Capture-D5KV3Cu7.js} +1 -1
  57. package/static/app/assets/{Library-Cgj-EF50.js → Library-C9kyFkSt.js} +1 -1
  58. package/static/app/assets/{System-D1Lkei3I.js → System-VbChmX7r.js} +1 -1
  59. package/static/app/assets/{index--P0ksosz.js → index-DPdcPoF0.js} +5 -5
  60. package/static/app/assets/{primitives-BLqaKk5g.js → primitives-DFeanEV6.js} +1 -1
  61. package/static/app/assets/{textarea-CVQkN2Tk.js → textarea-CD8UNKIy.js} +1 -1
  62. package/static/app/index.html +1 -1
  63. package/static/sw.js +1 -1
@@ -42,9 +42,84 @@ class SearchService:
42
42
  graph = self._require_graph()
43
43
  return graph.filter_scoped_nodes(matches, allowed_workspaces)
44
44
 
45
+ def _graph_search(self, graph, query: str, *, limit: int, allowed_workspaces=None):
46
+ try:
47
+ return graph.search(query, limit, allowed_workspaces=allowed_workspaces)
48
+ except TypeError:
49
+ payload = graph.search(query, limit)
50
+ if allowed_workspaces is not None:
51
+ payload = {
52
+ **payload,
53
+ "matches": graph.filter_scoped_nodes(
54
+ payload.get("matches", []),
55
+ allowed_workspaces,
56
+ ),
57
+ }
58
+ return payload
59
+
60
+ def _relationship_search(self, graph, *, allowed_workspaces=None, **kwargs):
61
+ try:
62
+ return graph.relationship_search(
63
+ **kwargs,
64
+ allowed_workspaces=allowed_workspaces,
65
+ )
66
+ except TypeError:
67
+ payload = graph.relationship_search(**kwargs)
68
+ if allowed_workspaces is not None:
69
+ kept = []
70
+ for rel in payload.get("relationships", []):
71
+ endpoints = [
72
+ {"id": (rel.get("source") or {}).get("id")},
73
+ {"id": (rel.get("target") or {}).get("id")},
74
+ ]
75
+ if len(graph.filter_scoped_nodes(endpoints, allowed_workspaces)) == 2:
76
+ kept.append(rel)
77
+ payload = {**payload, "relationships": kept}
78
+ return payload
79
+
80
+ def _traverse(self, graph, node_id: str, *, depth: int, limit: int, allowed_workspaces=None):
81
+ try:
82
+ return graph.traverse(
83
+ node_id,
84
+ depth=depth,
85
+ limit=limit,
86
+ allowed_workspaces=allowed_workspaces,
87
+ )
88
+ except TypeError:
89
+ neighborhood = graph.traverse(node_id, depth=depth, limit=limit)
90
+ if allowed_workspaces is not None:
91
+ nodes = graph.filter_scoped_nodes(
92
+ neighborhood.get("nodes", []),
93
+ allowed_workspaces,
94
+ )
95
+ kept = {item.get("id") for item in nodes}
96
+ edges = [
97
+ edge for edge in neighborhood.get("edges", [])
98
+ if edge.get("from") in kept and edge.get("to") in kept
99
+ ]
100
+ neighborhood = {**neighborhood, "nodes": nodes, "edges": edges}
101
+ return neighborhood
102
+
103
+ def _get_node(self, graph, node_id: str, *, allowed_workspaces=None):
104
+ try:
105
+ return graph.get_node(node_id, allowed_workspaces=allowed_workspaces)
106
+ except TypeError:
107
+ node = graph.get_node(node_id)
108
+ if allowed_workspaces is not None:
109
+ visible = graph.filter_scoped_nodes([node], allowed_workspaces)
110
+ if not visible:
111
+ raise ValueError(f"graph node not found: {node_id}")
112
+ return visible[0]
113
+ return node
114
+
45
115
  def keyword_search(self, query: str, *, limit: int = 30, allowed_workspaces=None) -> Dict[str, Any]:
46
116
  graph = self._require_graph()
47
- payload = graph.search(query, limit)
117
+ payload = self._graph_search(
118
+ graph,
119
+ query,
120
+ limit=limit,
121
+ allowed_workspaces=allowed_workspaces,
122
+ )
48
123
  matches = []
49
124
  for rank, match in enumerate(payload.get("matches", []), start=1):
50
125
  matches.append({
@@ -95,8 +170,18 @@ class SearchService:
95
170
  graph = self._require_graph()
96
171
  limit = max(1, min(int(limit or 30), 100))
97
172
  expand_depth = max(0, min(int(expand_depth or 1), 3))
98
- direct = graph.search(query, limit=max(limit, 10)).get("matches", [])
99
- relationships = graph.relationship_search(query=query, limit=limit).get("relationships", [])
173
+ direct = self._graph_search(
174
+ graph,
175
+ query,
176
+ limit=max(limit, 10),
177
+ allowed_workspaces=allowed_workspaces,
178
+ ).get("matches", [])
179
+ relationships = self._relationship_search(
180
+ graph,
181
+ query=query,
182
+ limit=limit,
183
+ allowed_workspaces=allowed_workspaces,
184
+ ).get("relationships", [])
100
185
  by_id: Dict[str, Dict[str, Any]] = {}
101
186
 
102
187
  def add_node(node: Mapping[str, Any], score: float, reason: str, edge: Optional[Mapping[str, Any]] = None) -> None:
@@ -138,7 +223,13 @@ class SearchService:
138
223
  if expand_depth <= 0:
139
224
  continue
140
225
  try:
141
- neighborhood = graph.traverse(match["id"], depth=expand_depth, limit=limit * 3)
226
+ neighborhood = self._traverse(
227
+ graph,
228
+ match["id"],
229
+ depth=expand_depth,
230
+ limit=limit * 3,
231
+ allowed_workspaces=allowed_workspaces,
232
+ )
142
233
  except Exception:
143
234
  neighborhood = {"nodes": [], "edges": []}
144
235
  edge_by_pair = {
@@ -262,23 +353,16 @@ class SearchService:
262
353
  allowed_workspaces=None,
263
354
  ) -> Dict[str, Any]:
264
355
  graph = self._require_graph()
265
- node = graph.get_node(node_id)
266
- if allowed_workspaces is not None:
267
- visible = graph.filter_scoped_nodes([node], allowed_workspaces)
268
- if not visible:
269
- raise ValueError(f"graph node not found: {node_id}")
270
- node = visible[0]
356
+ node = self._get_node(graph, node_id, allowed_workspaces=allowed_workspaces)
271
357
  payload = {"node": node}
272
358
  if include_neighbors:
273
- neighborhood = graph.traverse(node_id, depth=depth, limit=limit)
274
- if allowed_workspaces is not None:
275
- nodes = graph.filter_scoped_nodes(neighborhood.get("nodes", []), allowed_workspaces)
276
- kept = {item.get("id") for item in nodes}
277
- edges = [
278
- edge for edge in neighborhood.get("edges", [])
279
- if edge.get("from") in kept and edge.get("to") in kept
280
- ]
281
- neighborhood = {**neighborhood, "nodes": nodes, "edges": edges}
359
+ neighborhood = self._traverse(
360
+ graph,
361
+ node_id,
362
+ depth=depth,
363
+ limit=limit,
364
+ allowed_workspaces=allowed_workspaces,
365
+ )
282
366
  payload["neighborhood"] = neighborhood
283
367
  return payload
284
368
 
@@ -292,22 +376,14 @@ class SearchService:
292
376
  allowed_workspaces=None,
293
377
  ) -> Dict[str, Any]:
294
378
  graph = self._require_graph()
295
- payload = graph.relationship_search(
379
+ payload = self._relationship_search(
380
+ graph,
296
381
  query=query,
297
382
  node_id=node_id,
298
383
  relationship_type=relationship_type,
299
384
  limit=limit,
385
+ allowed_workspaces=allowed_workspaces,
300
386
  )
301
- if allowed_workspaces is not None:
302
- kept = []
303
- for rel in payload.get("relationships", []):
304
- endpoints = [
305
- {"id": (rel.get("source") or {}).get("id")},
306
- {"id": (rel.get("target") or {}).get("id")},
307
- ]
308
- if len(graph.filter_scoped_nodes(endpoints, allowed_workspaces)) == 2:
309
- kept.append(rel)
310
- payload = {**payload, "relationships": kept}
311
387
  return payload
312
388
 
313
389
  def index_status(self) -> Dict[str, Any]:
@@ -107,6 +107,53 @@ class ToolDispatchService:
107
107
  detail=f"'{tool_name}' 툴은 관리자 전용입니다.",
108
108
  )
109
109
 
110
+ def user_role(self, current_user: str) -> str:
111
+ users = self.load_users()
112
+ try:
113
+ return str(self.get_user_role(current_user, users) or "user")
114
+ except Exception:
115
+ return "user"
116
+
117
+ def enforce_policy(
118
+ self,
119
+ tool_name: str,
120
+ args: Optional[dict],
121
+ *,
122
+ current_user: str,
123
+ source: str,
124
+ require_auto_approval: bool = True,
125
+ trusted_admin: bool = False,
126
+ ) -> ToolPolicy:
127
+ """Authorize a tool call before any hook or handler can execute.
128
+
129
+ This is the shared policy gate for direct HTTP/MCP/tool surfaces. Agent
130
+ runtime uses the same policy data and blocks non-auto-approved steps in
131
+ its state machine so Computer Use direct endpoints can remain unchanged
132
+ when explicitly excluded from a hardening pass.
133
+ """
134
+ policy = self.policy_for(tool_name, args or {})
135
+ if not trusted_admin:
136
+ self.check_role(tool_name, current_user)
137
+ if policy["destructive"] or policy["risk"] == "destructive":
138
+ raise HTTPException(
139
+ status_code=403,
140
+ detail=f"'{tool_name}' 툴은 파괴적 작업으로 차단되었습니다.",
141
+ )
142
+ if (
143
+ require_auto_approval
144
+ and not trusted_admin
145
+ and not policy["auto_approve"]
146
+ and self.user_role(current_user) != "admin"
147
+ ):
148
+ raise HTTPException(
149
+ status_code=403,
150
+ detail=(
151
+ f"'{tool_name}' 툴은 명시 승인이 필요합니다. "
152
+ "8.9.0에서는 승인 UI가 없는 직접 실행 경로에서 기본 차단됩니다."
153
+ ),
154
+ )
155
+ return policy
156
+
110
157
  def rollback_file(self, path: str) -> Dict[str, Any]:
111
158
  r = subprocess.run(
112
159
  ["git", "checkout", "--", path],
@@ -160,6 +207,25 @@ def check_tool_role(tool_name: str, current_user: str) -> None:
160
207
  DEFAULT_TOOL_DISPATCH_SERVICE.check_role(tool_name, current_user)
161
208
 
162
209
 
210
+ def enforce_tool_policy(
211
+ tool_name: str,
212
+ args: Optional[dict],
213
+ *,
214
+ current_user: str,
215
+ source: str,
216
+ require_auto_approval: bool = True,
217
+ trusted_admin: bool = False,
218
+ ) -> ToolPolicy:
219
+ return DEFAULT_TOOL_DISPATCH_SERVICE.enforce_policy(
220
+ tool_name,
221
+ args or {},
222
+ current_user=current_user,
223
+ source=source,
224
+ require_auto_approval=require_auto_approval,
225
+ trusted_admin=trusted_admin,
226
+ )
227
+
228
+
163
229
  def collect_created_files(transcript: list) -> list:
164
230
  files = []
165
231
  for step in transcript:
@@ -79,6 +79,21 @@ class WorkspaceService:
79
79
  def can_write(self, workspace_id: str, user_id: Optional[str]) -> bool:
80
80
  return self.store.has_permission(workspace_id, self._identity(user_id), "write")
81
81
 
82
+ def readable_workspaces(self, user_id: Optional[str]) -> list[str]:
83
+ """Return workspace ids the caller can read.
84
+
85
+ This keeps scoped read APIs from each reconstructing membership logic
86
+ from raw workspace state. The personal workspace remains readable via
87
+ the store's normal permission rules.
88
+ """
89
+ resolved_user = self._identity(user_id)
90
+ workspaces = (self.store.load_state().get("workspaces") or {})
91
+ return [
92
+ str(workspace_id)
93
+ for workspace_id in workspaces
94
+ if self.store.has_permission(str(workspace_id), resolved_user, "read")
95
+ ]
96
+
82
97
  # ── record-level authorization (by-id access must not bypass gating) ──
83
98
 
84
99
  def authorize_record_read(self, record: Dict[str, Any], user_id: Optional[str]) -> None:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "8.8.0",
3
+ "version": "8.9.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": {
@@ -4,10 +4,12 @@ import { join, relative } from "node:path";
4
4
 
5
5
  const repo = join(import.meta.dirname, "..");
6
6
  const roots = [
7
- join(repo, "frontend", "src", "features", "brain"),
8
- join(repo, "frontend", "src", "features", "admin"),
9
- join(repo, "frontend", "src", "components", "onboarding"),
7
+ join(repo, "frontend", "src"),
10
8
  ];
9
+ const allowlistPath = join(repo, "scripts", "i18n_literal_allowlist.json");
10
+ const allowlist = existsSync(allowlistPath)
11
+ ? JSON.parse(readFileSync(allowlistPath, "utf8"))
12
+ : {};
11
13
 
12
14
  function walk(dir) {
13
15
  if (!existsSync(dir)) return [];
@@ -16,7 +18,9 @@ function walk(dir) {
16
18
  const path = join(dir, name);
17
19
  const stat = statSync(path);
18
20
  if (stat.isDirectory()) out.push(...walk(path));
19
- else if (name.endsWith(".tsx")) out.push(path);
21
+ else if ((name.endsWith(".tsx") || name.endsWith(".ts")) && name !== "openapi.ts") {
22
+ out.push(path);
23
+ }
20
24
  }
21
25
  return out;
22
26
  }
@@ -25,6 +29,7 @@ const rawLocalizedProps = /\b(?:aria-label|placeholder|title)=["'][^"'{]*[A-Za-z
25
29
  const rawJsxText = />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,})\s*</g;
26
30
  const rawComponentCopy = /\b(?:title|detail|description|successLabel|empty)=["'][^"'{]*[A-Za-z][^"']*["']/g;
27
31
  let failures = 0;
32
+ let allowed = 0;
28
33
 
29
34
  for (const file of roots.flatMap(walk)) {
30
35
  const text = readFileSync(file, "utf8");
@@ -38,9 +43,16 @@ for (const file of roots.flatMap(walk)) {
38
43
  matches.push(`JSX text: ${literal}`);
39
44
  }
40
45
  if (!matches.length) continue;
41
- failures += matches.length;
42
- for (const match of matches) {
43
- console.error(`${relative(repo, file)}: hardcoded localized prop: ${match}`);
46
+ const rel = relative(repo, file);
47
+ const budget = Number(allowlist[rel]?.maxFindings || 0);
48
+ if (budget >= matches.length) {
49
+ allowed += matches.length;
50
+ continue;
51
+ }
52
+ const newMatches = matches.slice(budget);
53
+ failures += newMatches.length;
54
+ for (const match of newMatches) {
55
+ console.error(`${rel}: hardcoded localized prop: ${match}`);
44
56
  }
45
57
  }
46
58
 
@@ -49,4 +61,4 @@ if (failures) {
49
61
  process.exit(1);
50
62
  }
51
63
 
52
- console.log("i18n literal check: localized props use translation keys");
64
+ console.log(`i18n literal check: localized props use translation keys (${allowed} allowlisted legacy literal(s))`);
@@ -0,0 +1,34 @@
1
+ {
2
+ "frontend/src/App.tsx": {
3
+ "maxFindings": 1,
4
+ "reason": "legacy shell aria label pending translation-key migration"
5
+ },
6
+ "frontend/src/components/LivingBrain.tsx": {
7
+ "maxFindings": 1,
8
+ "reason": "legacy visual component aria label pending translation-key migration"
9
+ },
10
+ "frontend/src/components/primitives.tsx": {
11
+ "maxFindings": 1,
12
+ "reason": "shared empty-state fallback pending translation-key migration"
13
+ },
14
+ "frontend/src/pages/Act.tsx": {
15
+ "maxFindings": 41,
16
+ "reason": "large legacy automation page pending incremental copy migration"
17
+ },
18
+ "frontend/src/pages/Brain.tsx": {
19
+ "maxFindings": 25,
20
+ "reason": "large legacy memory page pending incremental copy migration"
21
+ },
22
+ "frontend/src/pages/Capture.tsx": {
23
+ "maxFindings": 2,
24
+ "reason": "technical type-name literals are intentionally preserved for now"
25
+ },
26
+ "frontend/src/pages/Library.tsx": {
27
+ "maxFindings": 21,
28
+ "reason": "large legacy model/plugin page pending incremental copy migration"
29
+ },
30
+ "frontend/src/pages/System.tsx": {
31
+ "maxFindings": 118,
32
+ "reason": "large legacy system/admin page pending incremental copy migration"
33
+ }
34
+ }
@@ -52,8 +52,12 @@ for (const file of files) {
52
52
  console.log(`privacy: ${scanned} frontend/static files scanned`);
53
53
 
54
54
  const client = readFileSync(join(frontend, "src", "api", "client.ts"), "utf8");
55
- if (!client.includes("openapi-fetch") || !client.includes("./openapi")) {
56
- fail("frontend/src/api/client.ts must use the generated OpenAPI client");
55
+ const apiBase = readFileSync(join(frontend, "src", "api", "base.ts"), "utf8");
56
+ if (
57
+ !(client.includes("openapi-fetch") || apiBase.includes("openapi-fetch")) ||
58
+ !(client.includes("./openapi") || apiBase.includes("./openapi"))
59
+ ) {
60
+ fail("frontend/src/api/client.ts or base.ts must use the generated OpenAPI client");
57
61
  }
58
62
  if (!existsSync(join(frontend, "src", "api", "openapi.ts"))) {
59
63
  fail("generated OpenAPI types missing");