ltcai 10.2.0 → 10.4.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 (149) hide show
  1. package/README.md +40 -44
  2. package/docs/CHANGELOG.md +125 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/MYPY_BACKLOG.md +65 -0
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/OPERATIONS.md +1 -1
  8. package/docs/SURFACE_PARITY.md +33 -6
  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 +23 -3
  14. package/lattice_brain/graph/_kg_common.py +164 -9
  15. package/lattice_brain/graph/_kg_contract.py +223 -0
  16. package/lattice_brain/graph/_kg_fsutil.py +2 -2
  17. package/lattice_brain/graph/curator.py +4 -4
  18. package/lattice_brain/graph/discovery.py +12 -1
  19. package/lattice_brain/graph/discovery_index.py +12 -1
  20. package/lattice_brain/graph/documents.py +17 -2
  21. package/lattice_brain/graph/ingest.py +12 -1
  22. package/lattice_brain/graph/proactive.py +9 -5
  23. package/lattice_brain/graph/projection.py +12 -1
  24. package/lattice_brain/graph/provenance.py +12 -1
  25. package/lattice_brain/graph/retrieval.py +12 -1
  26. package/lattice_brain/graph/retrieval_docgen.py +12 -1
  27. package/lattice_brain/graph/retrieval_reads.py +12 -1
  28. package/lattice_brain/graph/retrieval_vector.py +12 -1
  29. package/lattice_brain/graph/schema.py +6 -2
  30. package/lattice_brain/graph/write_master.py +12 -1
  31. package/lattice_brain/ingestion.py +1 -1
  32. package/lattice_brain/quality.py +5 -5
  33. package/lattice_brain/runtime/agent_runtime.py +7 -2
  34. package/lattice_brain/runtime/contracts.py +2 -1
  35. package/lattice_brain/runtime/hooks.py +1 -1
  36. package/lattice_brain/runtime/multi_agent.py +1 -1
  37. package/lattice_brain/workflow.py +10 -7
  38. package/latticeai/__init__.py +1 -1
  39. package/latticeai/api/admin.py +2 -2
  40. package/latticeai/api/chat.py +19 -15
  41. package/latticeai/api/chat_agent_http.py +19 -7
  42. package/latticeai/api/chat_helpers.py +9 -8
  43. package/latticeai/api/chat_stream.py +2 -2
  44. package/latticeai/api/computer_use.py +2 -2
  45. package/latticeai/api/hooks.py +4 -2
  46. package/latticeai/api/local_files.py +8 -4
  47. package/latticeai/api/mcp.py +2 -2
  48. package/latticeai/api/models.py +5 -5
  49. package/latticeai/api/permissions.py +2 -2
  50. package/latticeai/api/search.py +5 -3
  51. package/latticeai/api/security_dashboard.py +3 -5
  52. package/latticeai/api/setup.py +3 -3
  53. package/latticeai/api/static_routes.py +7 -2
  54. package/latticeai/api/tools.py +9 -4
  55. package/latticeai/api/workspace.py +25 -22
  56. package/latticeai/app_factory.py +132 -1346
  57. package/latticeai/cli/entrypoint.py +1 -1
  58. package/latticeai/core/agent.py +28 -14
  59. package/latticeai/core/audit.py +5 -3
  60. package/latticeai/core/embedding_providers.py +19 -6
  61. package/latticeai/core/legacy_compatibility.py +1 -1
  62. package/latticeai/core/marketplace.py +2 -2
  63. package/latticeai/core/mcp_catalog.py +3 -1
  64. package/latticeai/core/model_compat.py +1 -1
  65. package/latticeai/core/plugins.py +1 -1
  66. package/latticeai/core/project_sessions.py +1 -1
  67. package/latticeai/core/run_explain.py +6 -4
  68. package/latticeai/core/workspace_os.py +0 -4
  69. package/latticeai/core/workspace_os_constants.py +1 -1
  70. package/latticeai/core/workspace_os_state.py +1 -1
  71. package/latticeai/core/workspace_snapshots.py +3 -3
  72. package/latticeai/integrations/telegram_bot.py +52 -3
  73. package/latticeai/models/router.py +61 -34
  74. package/latticeai/runtime/access_runtime.py +3 -1
  75. package/latticeai/runtime/build_phases.py +1417 -0
  76. package/latticeai/runtime/history_writer.py +138 -0
  77. package/latticeai/runtime/review_wiring.py +6 -1
  78. package/latticeai/runtime/runtime_context.py +276 -0
  79. package/latticeai/runtime/sso_config_runtime.py +1 -1
  80. package/latticeai/runtime/stages.py +8 -2
  81. package/latticeai/services/app_context.py +23 -0
  82. package/latticeai/services/architecture_readiness.py +1 -1
  83. package/latticeai/services/brain_intelligence.py +4 -2
  84. package/latticeai/services/cloud_streaming.py +5 -3
  85. package/latticeai/services/command_center.py +1 -1
  86. package/latticeai/services/evidence_actions.py +2 -1
  87. package/latticeai/services/hybrid_chat.py +1 -1
  88. package/latticeai/services/local_knowledge.py +25 -9
  89. package/latticeai/services/memory_service.py +8 -4
  90. package/latticeai/services/model_catalog.py +12 -12
  91. package/latticeai/services/model_engines.py +15 -17
  92. package/latticeai/services/model_loading.py +1 -1
  93. package/latticeai/services/model_runtime.py +31 -33
  94. package/latticeai/services/multimodal_streaming.py +10 -4
  95. package/latticeai/services/openai_compatible_adapter.py +4 -3
  96. package/latticeai/services/product_readiness.py +1 -1
  97. package/latticeai/services/run_executor.py +12 -3
  98. package/latticeai/services/search_service.py +1 -1
  99. package/latticeai/services/triggers.py +1 -1
  100. package/latticeai/setup/auto_setup.py +1 -1
  101. package/latticeai/setup/wizard.py +12 -3
  102. package/latticeai/tools/computer.py +3 -1
  103. package/latticeai/tools/documents.py +2 -2
  104. package/package.json +4 -2
  105. package/scripts/check_current_release_docs.mjs +1 -1
  106. package/src-tauri/Cargo.lock +1 -1
  107. package/src-tauri/Cargo.toml +1 -1
  108. package/src-tauri/tauri.conf.json +1 -1
  109. package/static/app/asset-manifest.json +37 -37
  110. package/static/app/assets/{Act-CbdGD-2i.js → Act-BK__Y5VE.js} +1 -1
  111. package/static/app/assets/{AdminConsole-LgCkXpnf.js → AdminConsole-ZrTyN8_F.js} +1 -1
  112. package/static/app/assets/{Brain-CoPGJI1L.js → Brain-CYHDXpPB.js} +1 -1
  113. package/static/app/assets/{BrainHome-gVnaxSwp.js → BrainHome-bbJulb5g.js} +1 -1
  114. package/static/app/assets/{BrainSignals-ChxAYHtj.js → BrainSignals-CesSPtUP.js} +1 -1
  115. package/static/app/assets/{Capture-DBtgkHZg.js → Capture-p5upXmz2.js} +1 -1
  116. package/static/app/assets/{CommandPalette-Ovtv5I0Y.js → CommandPalette-0zkyC0RY.js} +1 -1
  117. package/static/app/assets/Library-Dt-vABI2.js +1 -0
  118. package/static/app/assets/{LivingBrain-CNz-6NSd.js → LivingBrain-Bz-h6LBH.js} +1 -1
  119. package/static/app/assets/{ProductFlow-ePX-Y73J.js → ProductFlow-DAZ7DJCE.js} +1 -1
  120. package/static/app/assets/{ReviewCard-C4mpvkwH.js → ReviewCard-Bv0WGbY6.js} +1 -1
  121. package/static/app/assets/{System-BvWNK1zc.js → System-BhrJQSD_.js} +1 -1
  122. package/static/app/assets/{activity-CiauPV_3.js → activity-AolpZJCH.js} +1 -1
  123. package/static/app/assets/{bot-Dwct-Q1x.js → bot-CsVsT2nd.js} +1 -1
  124. package/static/app/assets/{brain-BKDW1F0T.js → brain-vuppco5y.js} +1 -1
  125. package/static/app/assets/{button-CCB9Kuw7.js → button-1BP1PJZ3.js} +1 -1
  126. package/static/app/assets/{circle-pause-DNa8WHC5.js → circle-pause-5paeCOtq.js} +1 -1
  127. package/static/app/assets/{circle-play-8jmxn5mf.js → circle-play-By8mFYRV.js} +1 -1
  128. package/static/app/assets/{cpu-mxvF3V1I.js → cpu-DvUQxdKe.js} +1 -1
  129. package/static/app/assets/{download--SmCcx0p.js → download-CFqYG90a.js} +1 -1
  130. package/static/app/assets/{folder-open-D4Wy5roo.js → folder-open-CVSnp-Dp.js} +1 -1
  131. package/static/app/assets/{hard-drive-BwQcSPlS.js → hard-drive-Ckr4Vd6-.js} +1 -1
  132. package/static/app/assets/{index-DDV2YZwM.js → index--0zKF5Ow.js} +3 -3
  133. package/static/app/assets/index-CEkL4_M-.css +2 -0
  134. package/static/app/assets/{input-DFhSjmLS.js → input-D-JKquXU.js} +1 -1
  135. package/static/app/assets/{network-Bts7VO94.js → network-DB88FZ2T.js} +1 -1
  136. package/static/app/assets/{primitives-BuSMEJY8.js → primitives-D8U9OaIo.js} +1 -1
  137. package/static/app/assets/search-B8jsVTw_.js +1 -0
  138. package/static/app/assets/{shield-alert-BfTO6X_G.js → shield-alert-DnsvO79P.js} +1 -1
  139. package/static/app/assets/{textarea-BjctW1oh.js → textarea-4RchwbTa.js} +1 -1
  140. package/static/app/assets/{useFocusTrap-CE43-LrA.js → useFocusTrap-CFJjy7cS.js} +1 -1
  141. package/static/app/assets/{useQuery-Bv3ejLL5.js → useQuery-3Reu-A4a.js} +1 -1
  142. package/static/app/assets/{users-CsNqLZAj.js → users-BTxYksxa.js} +1 -1
  143. package/static/app/assets/{utils-KFFdVG_t.js → utils-D02Vf-fw.js} +1 -1
  144. package/static/app/assets/{workspace-wdCvdyPF.js → workspace-CSJNgX-C.js} +1 -1
  145. package/static/app/index.html +4 -4
  146. package/static/sw.js +1 -1
  147. package/static/app/assets/Library-CBia2Fvm.js +0 -1
  148. package/static/app/assets/index-CQWdDU3z.css +0 -2
  149. package/static/app/assets/search-DZzxhWaQ.js +0 -1
@@ -29,7 +29,7 @@ def _local_ips() -> list[str]:
29
29
  try:
30
30
  hostname = socket.gethostname()
31
31
  for info in socket.getaddrinfo(hostname, None):
32
- addr = info[4][0]
32
+ addr = str(info[4][0])
33
33
  if ":" not in addr and not addr.startswith("127."):
34
34
  if addr not in ips:
35
35
  ips.append(addr)
@@ -24,7 +24,17 @@ import json
24
24
  import logging
25
25
  from dataclasses import dataclass
26
26
  from pathlib import Path
27
- from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Optional, Tuple
27
+ from typing import (
28
+ Any,
29
+ Awaitable,
30
+ Callable,
31
+ Dict,
32
+ FrozenSet,
33
+ List,
34
+ Mapping,
35
+ Optional,
36
+ Tuple,
37
+ )
28
38
 
29
39
  from lattice_brain.runtime.contracts import (
30
40
  runtime_boundary_contract,
@@ -149,10 +159,10 @@ class AgentDeps:
149
159
 
150
160
  # ── tool port ────────────────────────────────────────────────────
151
161
  execute_tool: Callable[[str, dict], dict]
152
- policy_for: Callable[[str, dict], dict] # name, args -> governance policy
153
- risk_level: Callable[[dict], str] # policy -> "low"|"medium"|"high"
162
+ policy_for: Callable[[str, dict], Mapping[str, Any]] # name, args -> policy
163
+ risk_level: Callable[[Any], str] # policy -> "low"|"medium"|"high"
154
164
  check_role: Callable[[str, str], None] # tool_name, user -> raises if not allowed
155
- tool_governance: Dict[str, dict] # name -> policy (for auto_approve set)
165
+ tool_governance: Mapping[str, Mapping[str, Any]] # name -> policy (auto_approve set)
156
166
  file_create_actions: FrozenSet[str]
157
167
 
158
168
  # ── context / memory / audit ports ───────────────────────────────
@@ -246,18 +256,22 @@ class SingleAgentRuntime:
246
256
  injected = getattr(self.deps, "phase_budgets", None)
247
257
  if injected is not None:
248
258
  return injected
249
- if getattr(self, "_env_phase_budgets", None) is None:
250
- self._env_phase_budgets = PhaseBudgets.from_env()
251
- return self._env_phase_budgets
259
+ cached = getattr(self, "_env_phase_budgets", None)
260
+ if cached is None:
261
+ cached = PhaseBudgets.from_env()
262
+ self._env_phase_budgets = cached
263
+ return cached
252
264
 
253
265
  @property
254
266
  def transcript_budget(self) -> TranscriptBudget:
255
267
  injected = getattr(self.deps, "transcript_budget", None)
256
268
  if injected is not None:
257
269
  return injected
258
- if getattr(self, "_env_transcript_budget", None) is None:
259
- self._env_transcript_budget = TranscriptBudget.from_env()
260
- return self._env_transcript_budget
270
+ cached = getattr(self, "_env_transcript_budget", None)
271
+ if cached is None:
272
+ cached = TranscriptBudget.from_env()
273
+ self._env_transcript_budget = cached
274
+ return cached
261
275
 
262
276
  # ── permission mode (v9.9.8) ─────────────────────────────────────
263
277
  def resolve_permission_mode(
@@ -511,7 +525,7 @@ class SingleAgentRuntime:
511
525
  break
512
526
  continue
513
527
 
514
- name = action.get("action")
528
+ name = str(action.get("action") or "")
515
529
  thoughts = str(action.get("thoughts") or "")[:600]
516
530
  args = action.get("args") or {}
517
531
 
@@ -748,7 +762,7 @@ class SingleAgentRuntime:
748
762
 
749
763
  def _governor_review(
750
764
  self, ctx: AgentRunContext, name: str, thoughts: str, args: dict,
751
- policy: dict, risk: str, current_user: str, request_workspace: Optional[str],
765
+ policy: Mapping[str, Any], risk: str, current_user: str, request_workspace: Optional[str],
752
766
  conversation_id: Optional[str] = None,
753
767
  ) -> Tuple[bool, bool]:
754
768
  """Central change-class governance: create-new runs with minimal
@@ -817,7 +831,7 @@ class SingleAgentRuntime:
817
831
 
818
832
  def _blocked_by_gates(
819
833
  self, ctx: AgentRunContext, req: Any, name: str, thoughts: str, args: dict,
820
- policy: dict, risk: str, current_user: str, governor_allows_additive: bool,
834
+ policy: Mapping[str, Any], risk: str, current_user: str, governor_allows_additive: bool,
821
835
  ) -> bool:
822
836
  """Destructive / circuit-breaker / explicit-approval gates.
823
837
 
@@ -892,7 +906,7 @@ class SingleAgentRuntime:
892
906
 
893
907
  def _dispatch_step(
894
908
  self, ctx: AgentRunContext, name: str, thoughts: str, args: dict,
895
- policy: dict, risk: str, current_user: str,
909
+ policy: Mapping[str, Any], risk: str, current_user: str,
896
910
  ) -> None:
897
911
  """Role check + shared tool lifecycle, recorded on the transcript either way."""
898
912
  d = self.deps
@@ -87,7 +87,7 @@ def mask_sensitive_text(text: str, matches: List[Dict]) -> str:
87
87
 
88
88
  def classify_sensitive_message(item: Dict, index: int) -> Dict:
89
89
  content = str(item.get("content", ""))
90
- found = []
90
+ found: List[Dict[str, Any]] = []
91
91
  seen: set = set()
92
92
  for rule in SENSITIVE_PATTERNS:
93
93
  for match in re.finditer(rule["pattern"], content, flags=re.IGNORECASE):
@@ -104,9 +104,11 @@ def classify_sensitive_message(item: Dict, index: int) -> Dict:
104
104
  })
105
105
  severity = "none"
106
106
  if found:
107
- severity = max(found, key=lambda m: SEVERITY_SCORE[m["severity"]])["severity"]
107
+ severity = str(
108
+ max(found, key=lambda m: SEVERITY_SCORE[str(m["severity"])])["severity"]
109
+ )
108
110
  preview_text = content[:240]
109
- preview_matches = [m for m in found if m["start"] < len(preview_text)]
111
+ preview_matches = [m for m in found if int(m["start"]) < len(preview_text)]
110
112
  return {
111
113
  "index": index,
112
114
  "role": item.get("role", ""),
@@ -34,7 +34,7 @@ import math
34
34
  import os
35
35
  import struct
36
36
  from dataclasses import dataclass, field
37
- from typing import Any, Dict, Iterable, List, Optional, Sequence
37
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
38
38
 
39
39
  from latticeai.core.local_embeddings import DEFAULT_EMBEDDING_DIM, LocalEmbeddingModel
40
40
 
@@ -288,6 +288,17 @@ class HashEmbeddingProvider(EmbeddingProvider):
288
288
  return {"status": "ok", "detail": "deterministic local fallback"}
289
289
 
290
290
 
291
+ def _as_float_list(value: Any) -> List[Any]:
292
+ """A pooled embedding row as a flat list.
293
+
294
+ ``mx.array.tolist()`` is typed as scalar-or-nested; at this call site the
295
+ array is always 1-D, so a scalar would be a bug worth surfacing.
296
+ """
297
+ if isinstance(value, (int, float)):
298
+ raise EmbeddingUnavailable("MLX embedding produced a scalar, not a vector")
299
+ return list(value)
300
+
301
+
291
302
  # ── shared base for remote/model-backed providers ─────────────────────────────
292
303
  @dataclass
293
304
  class _RemoteConfig:
@@ -332,7 +343,7 @@ class MLXEmbeddingProvider(_NetworkEmbeddingProvider):
332
343
  def __init__(self, cfg: _RemoteConfig):
333
344
  super().__init__(cfg)
334
345
  self.model_id = f"mlx:{cfg.model}:{self.dim}"
335
- self._encoder = None
346
+ self._encoder: Optional[Tuple[str, Any, Any]] = None
336
347
 
337
348
  def _load(self):
338
349
  if self._encoder is not None:
@@ -358,7 +369,7 @@ class MLXEmbeddingProvider(_NetworkEmbeddingProvider):
358
369
  result = model(tokens)
359
370
  pooled = result[0] if isinstance(result, (tuple, list)) else result
360
371
  vec = mx.mean(pooled, axis=1)[0] if pooled.ndim == 3 else pooled[0]
361
- out.append([float(x) for x in vec.tolist()])
372
+ out.append([float(x) for x in _as_float_list(vec.tolist())])
362
373
  return out
363
374
  except EmbeddingUnavailable:
364
375
  raise
@@ -477,7 +488,7 @@ class CustomEmbeddingProvider(_NetworkEmbeddingProvider):
477
488
  super().__init__(cfg)
478
489
  self._target_ref = str(cfg.extra.get("target") or os.getenv("LATTICEAI_EMBEDDING_CUSTOM_TARGET", ""))
479
490
  self.model_id = f"custom:{cfg.model or self._target_ref or 'callable'}:{self.dim}"
480
- self._fn = None
491
+ self._fn: Optional[Callable[..., Any]] = None
481
492
 
482
493
  def _load(self):
483
494
  if self._fn is not None:
@@ -587,8 +598,10 @@ def resolve_embedder(
587
598
  """
588
599
  requested = str(provider or "hash").strip().lower() or "hash"
589
600
  if requested in {"hash", "local", "fallback", ""}:
590
- prov = HashEmbeddingProvider(dim=int(dim or DEFAULT_EMBEDDING_DIM))
591
- return ResolvedEmbedder(prov, "hash", "hash", False, prov.health(), "deterministic local fallback")
601
+ hash_prov = HashEmbeddingProvider(dim=int(dim or DEFAULT_EMBEDDING_DIM))
602
+ return ResolvedEmbedder(
603
+ hash_prov, "hash", "hash", False, hash_prov.health(), "deterministic local fallback"
604
+ )
592
605
 
593
606
  try:
594
607
  prov = build_embedding_provider(
@@ -13,7 +13,7 @@ from dataclasses import dataclass
13
13
  from pathlib import Path
14
14
  from typing import Any, Dict, List
15
15
 
16
- LEGACY_COMPATIBILITY_VERSION = "10.2.0"
16
+ LEGACY_COMPATIBILITY_VERSION = "10.4.0"
17
17
 
18
18
 
19
19
  @dataclass(frozen=True)
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
- MARKETPLACE_VERSION = "10.2.0"
13
+ MARKETPLACE_VERSION = "10.4.0"
14
14
  TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
15
15
 
16
16
 
@@ -242,7 +242,7 @@ class TemplateCatalog:
242
242
  if not isinstance(payload, dict):
243
243
  raise MarketplaceError("template import payload must be an object")
244
244
  template = deepcopy(payload.get("template") or payload)
245
- kind = _normalize_kind(template.get("kind") or payload.get("kind"))
245
+ kind = _normalize_kind(str(template.get("kind") or payload.get("kind") or ""))
246
246
  if not template.get("id"):
247
247
  raise MarketplaceError("template missing id")
248
248
  if not template.get("name"):
@@ -7,7 +7,9 @@ MCP_REGISTRY`` sites are unaffected.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- MCP_REGISTRY = [
10
+ from typing import Any, Dict, List
11
+
12
+ MCP_REGISTRY: List[Dict[str, Any]] = [
11
13
  {
12
14
  "id": "presentations",
13
15
  "name": "Presentations MCP",
@@ -431,7 +431,7 @@ def fast_postprocess(text: str, profile: Dict[str, Any]) -> str:
431
431
  SMOKE_PROMPT = "한국어로 한 문장만 답해. 2+2는?"
432
432
 
433
433
 
434
- def classify_smoke_response(text: str) -> Tuple[str, str]:
434
+ def classify_smoke_response(text: Optional[str]) -> Tuple[str, str]:
435
435
  """Smoke test 응답을 ok / degraded / failed 로 분류한다. (item 3-3)
436
436
 
437
437
  - failed: 채팅에 쓸 수 없는 수준 (빈 응답, 특수/role 토큰 누출, 심한 반복,
@@ -123,7 +123,7 @@ class PluginManifest:
123
123
  }
124
124
 
125
125
 
126
- def validate_manifest(data: Dict[str, Any], *, path: str = "") -> Tuple[Optional[PluginManifest], List[str]]:
126
+ def validate_manifest(data: Any, *, path: str = "") -> Tuple[Optional[PluginManifest], List[str]]:
127
127
  """Validate a manifest dict. Returns ``(manifest_or_None, errors)``.
128
128
 
129
129
  A manifest with errors still returns ``None`` so callers never accidentally
@@ -144,7 +144,7 @@ class ProjectSessionStore:
144
144
  workspace_id: Optional[str] = None,
145
145
  ) -> Dict[str, Any]:
146
146
  session_id = secrets.token_urlsafe(12)
147
- record = {
147
+ record: Dict[str, Any] = {
148
148
  "id": session_id,
149
149
  "title": _clean(title, 200) or "프로젝트",
150
150
  "goal": _clean(goal, 2000),
@@ -84,8 +84,10 @@ def _repaired_artifacts(steps: Sequence[Mapping[str, Any]]) -> List[str]:
84
84
  meta = step.get("content_sanitize")
85
85
  if not isinstance(meta, Mapping) or not meta.get("repaired"):
86
86
  continue
87
- result = step.get("result") if isinstance(step.get("result"), Mapping) else {}
88
- args = step.get("args") if isinstance(step.get("args"), Mapping) else {}
87
+ raw_result = step.get("result")
88
+ raw_args = step.get("args")
89
+ result: Mapping[str, Any] = raw_result if isinstance(raw_result, Mapping) else {}
90
+ args: Mapping[str, Any] = raw_args if isinstance(raw_args, Mapping) else {}
89
91
  path = result.get("path") or args.get("path")
90
92
  if path and str(path) not in paths:
91
93
  paths.append(str(path))
@@ -270,7 +272,7 @@ def explain_run(
270
272
  repair_sum = sum(repair_totals.values())
271
273
 
272
274
  strain_score = parse_errors * 2 + corrections + retries + repair_totals["format"]
273
- strain = {
275
+ strain: Dict[str, Any] = {
274
276
  "level": _strain_level(strain_score),
275
277
  "score": strain_score,
276
278
  "parse_errors": parse_errors,
@@ -336,7 +338,7 @@ def explain_run(
336
338
  "만들어지지 않은 파일: " + ", ".join(missing_files[:4]),
337
339
  "Files that were never written: " + ", ".join(missing_files[:4]),
338
340
  ))
339
- strain_phrase = _STRAIN_PHRASES.get(strain["level"])
341
+ strain_phrase = _STRAIN_PHRASES.get(str(strain["level"]))
340
342
  if strain_phrase is not None:
341
343
  details.append(dict(strain_phrase))
342
344
 
@@ -515,10 +515,6 @@ class WorkspaceOSStore:
515
515
  self.save_state(state)
516
516
  self.record_timeline_event("workspace", "member_removed", {"workspace_id": workspace_id, "user_id": user_id})
517
517
  return self._workspace_public(ws, actor)
518
- ws["updated_at"] = _now()
519
- self.save_state(state)
520
- self.record_timeline_event("workspace", "member_removed", {"workspace_id": workspace_id, "user_id": user_id})
521
- return self._workspace_public(ws, actor)
522
518
 
523
519
  def set_active_workspace(self, workspace_id: str, user_id: Optional[str] = None) -> Dict[str, Any]:
524
520
  state = self.load_state()
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  from typing import Dict
12
12
 
13
- WORKSPACE_OS_VERSION = "10.2.0"
13
+ WORKSPACE_OS_VERSION = "10.4.0"
14
14
 
15
15
  # Workspace types separate single-user Personal workspaces from shared
16
16
  # Organization workspaces. Both keep the same local-first JSON store; the type
@@ -71,7 +71,7 @@ def migrate_workspaces(state: Dict[str, Any]) -> Dict[str, Any]:
71
71
  for ws_id, ws in workspaces.items():
72
72
  if not isinstance(ws, dict):
73
73
  continue
74
- ws_type = ws.get("type") if ws.get("type") in WORKSPACE_TYPES else "organization"
74
+ ws_type = str(ws.get("type")) if ws.get("type") in WORKSPACE_TYPES else "organization"
75
75
  if ws_id == DEFAULT_WORKSPACE_ID:
76
76
  ws_type = "personal"
77
77
  base = new_workspace_record(
@@ -31,9 +31,9 @@ class WorkspaceSnapshots:
31
31
  workspace_id: Optional[str] = None,
32
32
  ) -> Dict[str, Any]:
33
33
  scope = self._store._resolve_scope(workspace_id)
34
- graph_payload = {"nodes": [], "edges": []}
35
- graph_stats = {}
36
- local_sources = {"sources": []}
34
+ graph_payload: Dict[str, Any] = {"nodes": [], "edges": []}
35
+ graph_stats: Dict[str, Any] = {}
36
+ local_sources: Dict[str, Any] = {"sources": []}
37
37
  if graph is not None:
38
38
  graph_payload = graph.graph(limit=2000)
39
39
  graph_stats = graph.stats()
@@ -7,6 +7,7 @@ import socket
7
7
  import tempfile
8
8
  import zipfile
9
9
  from pathlib import Path
10
+ from typing import Any, Dict
10
11
 
11
12
  import httpx
12
13
 
@@ -678,6 +679,50 @@ def collect_generated_files(agent_data):
678
679
  files.append((path, target))
679
680
  return files
680
681
 
682
+ def format_artifact_card(data, *, language: str = "ko") -> str:
683
+ """Artifact card summary for a finished run (v10.4.0).
684
+
685
+ Telegram used to send the produced files and nothing else, so a
686
+ deterministically repaired scaffold arrived looking exactly like clean
687
+ model output — SURFACE_PARITY recorded that as ◐. This renders the same
688
+ ``artifacts[]`` flags the web cards show, and reports only what the server
689
+ said: an absent flag is never promoted into a claim of validity.
690
+ """
691
+ if not isinstance(data, dict):
692
+ return ""
693
+ artifacts = data.get("artifacts")
694
+ if not isinstance(artifacts, list) or not artifacts:
695
+ return ""
696
+ korean = not str(language or "ko").startswith("en")
697
+ lines = ["📄 만든 파일" if korean else "📄 Files produced"]
698
+ for item in artifacts[:8]:
699
+ if not isinstance(item, dict):
700
+ continue
701
+ path = str(item.get("path") or "").strip()
702
+ if not path:
703
+ continue
704
+ notes = []
705
+ size = item.get("bytes")
706
+ if isinstance(size, (int, float)) and size > 0:
707
+ notes.append(f"{int(size):,} B")
708
+ if item.get("repaired") is True:
709
+ notes.append("자동 보정됨" if korean else "auto-repaired")
710
+ if item.get("valid") is False:
711
+ notes.append("검증 실패" if korean else "failed validation")
712
+ suffix = f" ({' · '.join(notes)})" if notes else ""
713
+ lines.append(f"• {path}{suffix}")
714
+ extra = len(artifacts) - 8
715
+ if extra > 0:
716
+ lines.append(f"… +{extra}")
717
+ return "\n".join(lines) if len(lines) > 1 else ""
718
+
719
+
720
+ async def send_artifact_card(client, chat_id, data, *, language: str = "ko") -> None:
721
+ text = format_artifact_card(data, language=language)
722
+ if text:
723
+ await send_message(client, chat_id, text)
724
+
725
+
681
726
  def format_grounding_badge(data, *, language: str = "ko") -> str:
682
727
  """Answer-grounding badge line for a `/chat` reply (v9.9.7).
683
728
 
@@ -732,8 +777,10 @@ def format_proposals(payload) -> list:
732
777
  item_id = str(raw.get("id") or "").strip()
733
778
  if not item_id:
734
779
  continue
735
- body = raw.get("payload") if isinstance(raw.get("payload"), dict) else {}
736
- provenance = raw.get("provenance") if isinstance(raw.get("provenance"), dict) else {}
780
+ raw_body = raw.get("payload")
781
+ raw_prov = raw.get("provenance")
782
+ body: Dict[str, Any] = raw_body if isinstance(raw_body, dict) else {}
783
+ provenance: Dict[str, Any] = raw_prov if isinstance(raw_prov, dict) else {}
737
784
  path = str(body.get("path") or provenance.get("path") or "")
738
785
  title = str(raw.get("title") or path or item_id)
739
786
  change_class = str(body.get("change_class") or provenance.get("change_class") or "")
@@ -969,7 +1016,7 @@ async def handle_plan_callback(client, chat_id, data: str) -> None:
969
1016
  # A callback is bound to the chat that received the plan. Even two allowed
970
1017
  # chats cannot approve or cancel each other's plan by replaying callback
971
1018
  # data.
972
- if pending and int(pending.get("chat_id")) != int(chat_id):
1019
+ if pending and int(pending.get("chat_id") or 0) != int(chat_id):
973
1020
  await send_message(client, chat_id, "❌ 다른 채팅의 작업은 처리할 수 없습니다.")
974
1021
  return
975
1022
  pending = _bot_pending_plans.pop(pause_id, None)
@@ -1003,6 +1050,7 @@ async def handle_plan_callback(client, chat_id, data: str) -> None:
1003
1050
  await send_message(client, chat_id, str(ans))
1004
1051
  if isinstance(data_r, dict):
1005
1052
  await send_run_explanation(client, chat_id, data_r)
1053
+ await send_artifact_card(client, chat_id, data_r)
1006
1054
  await send_generated_files(client, chat_id, collect_generated_files(data_r))
1007
1055
  await send_preview_links(client, chat_id, collect_preview_urls(data_r))
1008
1056
  except Exception as e:
@@ -1052,6 +1100,7 @@ async def process_ai_request(client, chat_id, user_text, image_data=None):
1052
1100
  await send_grounding_badge(client, chat_id, data)
1053
1101
  if not image_data and isinstance(data, dict):
1054
1102
  await send_run_explanation(client, chat_id, data)
1103
+ await send_artifact_card(client, chat_id, data)
1055
1104
  await send_generated_files(client, chat_id, collect_generated_files(data))
1056
1105
  await send_preview_links(client, chat_id, collect_preview_urls(data))
1057
1106
  except Exception as e: