ltcai 3.6.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/README.md +11 -7
  2. package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
  3. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +509 -0
  4. package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
  5. package/docs/kg-schema.md +47 -53
  6. package/kg_schema.py +93 -10
  7. package/knowledge_graph.py +362 -33
  8. package/knowledge_graph_api.py +11 -127
  9. package/latticeai/__init__.py +1 -1
  10. package/latticeai/api/admin.py +1 -1
  11. package/latticeai/api/agents.py +7 -1
  12. package/latticeai/api/auth.py +27 -4
  13. package/latticeai/api/chat.py +112 -76
  14. package/latticeai/api/health.py +1 -1
  15. package/latticeai/api/hooks.py +1 -1
  16. package/latticeai/api/knowledge_graph.py +146 -0
  17. package/latticeai/api/local_files.py +1 -1
  18. package/latticeai/api/mcp.py +23 -11
  19. package/latticeai/api/memory.py +1 -1
  20. package/latticeai/api/models.py +1 -1
  21. package/latticeai/api/network.py +81 -0
  22. package/latticeai/api/realtime.py +1 -1
  23. package/latticeai/api/search.py +26 -2
  24. package/latticeai/api/security_dashboard.py +2 -3
  25. package/latticeai/api/setup.py +2 -2
  26. package/latticeai/api/static_routes.py +2 -4
  27. package/latticeai/api/tools.py +3 -0
  28. package/latticeai/api/workflow_designer.py +46 -0
  29. package/latticeai/api/workspace.py +71 -49
  30. package/latticeai/app_factory.py +1710 -0
  31. package/latticeai/brain/__init__.py +18 -0
  32. package/latticeai/brain/context.py +213 -0
  33. package/latticeai/brain/conversations.py +236 -0
  34. package/latticeai/brain/identity.py +175 -0
  35. package/latticeai/brain/memory.py +102 -0
  36. package/latticeai/brain/network.py +205 -0
  37. package/latticeai/core/agent.py +31 -7
  38. package/latticeai/core/audit.py +0 -7
  39. package/latticeai/core/config.py +1 -1
  40. package/latticeai/core/context_builder.py +1 -2
  41. package/latticeai/core/enterprise.py +1 -1
  42. package/latticeai/core/graph_curator.py +2 -2
  43. package/latticeai/core/marketplace.py +1 -1
  44. package/latticeai/core/mcp_registry.py +791 -0
  45. package/latticeai/core/model_compat.py +1 -1
  46. package/latticeai/core/model_resolution.py +0 -1
  47. package/latticeai/core/multi_agent.py +238 -4
  48. package/latticeai/core/security.py +1 -1
  49. package/latticeai/core/sessions.py +37 -7
  50. package/latticeai/core/workflow_engine.py +114 -2
  51. package/latticeai/core/workspace_os.py +58 -10
  52. package/latticeai/models/__init__.py +7 -0
  53. package/latticeai/models/router.py +779 -0
  54. package/latticeai/server_app.py +29 -1536
  55. package/latticeai/services/agent_runtime.py +1 -0
  56. package/latticeai/services/app_context.py +75 -14
  57. package/latticeai/services/ingestion.py +47 -0
  58. package/latticeai/services/kg_portability.py +33 -3
  59. package/latticeai/services/memory_service.py +39 -11
  60. package/latticeai/services/model_runtime.py +2 -5
  61. package/latticeai/services/platform_runtime.py +100 -23
  62. package/latticeai/services/search_service.py +17 -8
  63. package/latticeai/services/tool_dispatch.py +12 -2
  64. package/latticeai/services/triggers.py +241 -0
  65. package/latticeai/services/upload_service.py +37 -12
  66. package/latticeai/services/workspace_service.py +31 -0
  67. package/llm_router.py +29 -772
  68. package/ltcai_cli.py +1 -2
  69. package/mcp_registry.py +25 -788
  70. package/p_reinforce.py +124 -14
  71. package/package.json +9 -7
  72. package/scripts/bump_version.py +99 -0
  73. package/scripts/generate_diagrams.py +0 -1
  74. package/scripts/lint_v3.mjs +82 -18
  75. package/scripts/validate_release_artifacts.py +0 -1
  76. package/scripts/wheel_smoke.py +142 -0
  77. package/server.py +11 -7
  78. package/setup_wizard.py +1142 -0
  79. package/static/account.html +2 -4
  80. package/static/admin.html +3 -5
  81. package/static/chat.html +3 -6
  82. package/static/graph.html +2 -4
  83. package/static/sw.js +81 -52
  84. package/static/v3/asset-manifest.json +20 -19
  85. package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
  86. package/static/v3/css/lattice.base.css +1 -1
  87. package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
  88. package/static/v3/css/lattice.components.css +1 -1
  89. package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
  90. package/static/v3/css/lattice.shell.css +1 -1
  91. package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
  92. package/static/v3/css/lattice.tokens.css +3 -0
  93. package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
  94. package/static/v3/css/lattice.views.css +2 -2
  95. package/static/v3/index.html +3 -4
  96. package/static/v3/js/{app.c541f955.js → app.356e6452.js} +1 -1
  97. package/static/v3/js/core/{api.33d6320e.js → api.7a308b89.js} +1 -1
  98. package/static/v3/js/core/{routes.2ce3815a.js → routes.7222343d.js} +22 -22
  99. package/static/v3/js/core/routes.js +22 -22
  100. package/static/v3/js/core/{shell.8c163e0e.js → shell.a1657f20.js} +4 -4
  101. package/static/v3/js/core/shell.js +1 -1
  102. package/static/v3/js/core/{store.34ebd5e6.js → store.204a08b2.js} +1 -1
  103. package/static/v3/js/core/store.js +1 -1
  104. package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
  105. package/static/v3/js/views/graph-canvas.js +509 -0
  106. package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
  107. package/static/v3/js/views/hybrid-search.js +1 -2
  108. package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.5e40cbeb.js} +33 -37
  109. package/static/v3/js/views/knowledge-graph.js +33 -37
  110. package/static/vendor/chart.umd.min.js +20 -0
  111. package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
  112. package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
  113. package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
  114. package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
  115. package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
  116. package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
  117. package/static/vendor/fonts/inter.css +44 -0
  118. package/static/vendor/icons/tabler-icons.min.css +4 -0
  119. package/static/vendor/icons/tabler-icons.woff2 +0 -0
  120. package/static/vendor/marked.min.js +69 -0
  121. package/static/workspace.html +2 -2
  122. package/telegram_bot.py +1 -2
  123. package/tools/commands.py +4 -2
  124. package/tools/computer.py +1 -1
  125. package/tools/documents.py +1 -3
  126. package/tools/filesystem.py +0 -4
  127. package/tools/knowledge.py +1 -3
  128. package/tools/network.py +1 -3
  129. package/codex_telegram_bot.py +0 -195
  130. package/docs/assets/v3.4.0/agent-run.png +0 -0
  131. package/docs/assets/v3.4.0/agents.png +0 -0
  132. package/docs/assets/v3.4.0/before/chat-before.png +0 -0
  133. package/docs/assets/v3.4.0/before/files-before.png +0 -0
  134. package/docs/assets/v3.4.0/chat.png +0 -0
  135. package/docs/assets/v3.4.0/connect-folder.png +0 -0
  136. package/docs/assets/v3.4.0/files.png +0 -0
  137. package/docs/assets/v3.4.0/home.png +0 -0
  138. package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
  139. package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
  140. package/docs/assets/v3.4.0/local-agent.png +0 -0
  141. package/docs/assets/v3.4.0/memory.png +0 -0
  142. package/docs/assets/v3.4.0/settings.png +0 -0
  143. package/docs/assets/v3.4.0/vision-input.png +0 -0
  144. package/docs/assets/v3.4.0/workflows.png +0 -0
  145. package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
  146. package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
  147. package/docs/assets/v3.4.1/local-agent.png +0 -0
  148. package/docs/images/admin-dashboard.png +0 -0
  149. package/docs/images/architecture.png +0 -0
  150. package/docs/images/enterprise.png +0 -0
  151. package/docs/images/graph.png +0 -0
  152. package/docs/images/hero.gif +0 -0
  153. package/docs/images/knowledge-graph.png +0 -0
  154. package/docs/images/lattice-ai-demo.gif +0 -0
  155. package/docs/images/lattice-ai-hero.png +0 -0
  156. package/docs/images/logo.svg +0 -33
  157. package/docs/images/mobile-responsive.png +0 -0
  158. package/docs/images/model-recommendation.png +0 -0
  159. package/docs/images/onboarding.png +0 -0
  160. package/docs/images/organization.png +0 -0
  161. package/docs/images/pipeline.png +0 -0
  162. package/docs/images/screenshot-admin.png +0 -0
  163. package/docs/images/screenshot-chat.png +0 -0
  164. package/docs/images/screenshot-graph.png +0 -0
  165. package/docs/images/skills.png +0 -0
  166. package/docs/images/workspace-dark.png +0 -0
  167. package/docs/images/workspace-light.png +0 -0
  168. package/docs/images/workspace.png +0 -0
  169. package/requirements.txt +0 -16
@@ -0,0 +1,102 @@
1
+ """Memory System — typed, durable memory records on the brain substrate.
2
+
3
+ Decision and Experience records become first-class graph nodes through the
4
+ unified ingestion pipeline (provenance + hooks), instead of markdown dumps
5
+ with swallowed errors. Episodic memory is the conversation store; semantic
6
+ memory is the workspace MEMORY_KINDS records — this module adds the typed
7
+ record kinds the schema always had but never populated.
8
+
9
+ Only REAL events become memories: simulation runs are rejected at this
10
+ boundary (the run record's own mode field is checked — fabricated artifacts
11
+ must never enter the brain as experience).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Dict, Optional
17
+
18
+ from latticeai.services.ingestion import IngestionItem
19
+
20
+
21
+ class BrainMemory:
22
+ """Writes Decision / Experience records through the ingestion pipeline."""
23
+
24
+ def __init__(self, ingestion_pipeline: Any):
25
+ self._pipeline = ingestion_pipeline
26
+
27
+ def available(self) -> bool:
28
+ return self._pipeline is not None and self._pipeline.available()
29
+
30
+ def record_decision(
31
+ self,
32
+ title: str,
33
+ detail: str = "",
34
+ *,
35
+ user_email: Optional[str] = None,
36
+ workspace_id: Optional[str] = None,
37
+ conversation_id: Optional[str] = None,
38
+ decided_by: Optional[str] = None,
39
+ metadata: Optional[Dict[str, Any]] = None,
40
+ ) -> Dict[str, Any]:
41
+ if not str(title or "").strip():
42
+ raise ValueError("a decision needs a title")
43
+ result = self._pipeline.ingest(
44
+ IngestionItem(
45
+ source_type="decision",
46
+ title=title.strip(),
47
+ text=detail,
48
+ owner=user_email,
49
+ workspace_id=workspace_id,
50
+ conversation_id=conversation_id,
51
+ metadata={"decided_by": decided_by or user_email, **(metadata or {})},
52
+ ),
53
+ user_email=user_email,
54
+ )
55
+ return result.as_dict()
56
+
57
+ def record_experience(
58
+ self,
59
+ title: str,
60
+ detail: str = "",
61
+ *,
62
+ run: Optional[Dict[str, Any]] = None,
63
+ user_email: Optional[str] = None,
64
+ workspace_id: Optional[str] = None,
65
+ metadata: Optional[Dict[str, Any]] = None,
66
+ ) -> Dict[str, Any]:
67
+ """Persist a completed run/action as an Experience node.
68
+
69
+ ``run`` is the persisted run record; simulated runs are refused —
70
+ a simulation is replay scaffolding, not something that happened.
71
+ """
72
+ if run is not None and run.get("mode", "simulation") == "simulation":
73
+ return {
74
+ "status": "rejected",
75
+ "detail": "simulation runs are not experiences and never enter the brain",
76
+ }
77
+ if not str(title or "").strip():
78
+ raise ValueError("an experience needs a title")
79
+ run_meta = {}
80
+ if run is not None:
81
+ run_meta = {
82
+ "run_id": run.get("id"),
83
+ "agent_id": run.get("agent_id"),
84
+ "run_status": run.get("status"),
85
+ "mode": run.get("mode"),
86
+ "retries": run.get("retries"),
87
+ }
88
+ result = self._pipeline.ingest(
89
+ IngestionItem(
90
+ source_type="experience",
91
+ title=title.strip(),
92
+ text=detail,
93
+ owner=user_email,
94
+ workspace_id=workspace_id,
95
+ metadata={**run_meta, **(metadata or {})},
96
+ ),
97
+ user_email=user_email,
98
+ )
99
+ return result.as_dict()
100
+
101
+
102
+ __all__ = ["BrainMemory"]
@@ -0,0 +1,205 @@
1
+ """Brain Network v1 — knowledge exchange between paired Lattice instances.
2
+
3
+ Local-first federation: no cloud rendezvous, no relay. A peer is another
4
+ Lattice installation you deliberately paired with by exchanging device
5
+ public keys (LAN/tailnet HTTP). Exchange is per-workspace, per-request,
6
+ owner-initiated: a signed export bundle is pushed to (or received from) a
7
+ paired peer, verified against the *paired* key, imported through the normal
8
+ import path, and stamped with origin-device provenance.
9
+
10
+ Peer requests authenticate independently of user sessions: each carries an
11
+ Ed25519 signature over (body sha256 + timestamp + nonce), with a freshness
12
+ window and a seen-nonce set for replay protection.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import json
19
+ import logging
20
+ import threading
21
+ import time
22
+ import uuid
23
+ from pathlib import Path
24
+ from typing import Any, Dict, List, Optional
25
+
26
+ from latticeai.brain.identity import DeviceIdentity, fingerprint_of, verify_signature
27
+
28
+ PEER_AUTH_WINDOW_SECONDS = 300
29
+ _NONCE_CACHE_MAX = 4096
30
+
31
+ HEADER_DEVICE = "x-lattice-device"
32
+ HEADER_TIMESTAMP = "x-lattice-timestamp"
33
+ HEADER_NONCE = "x-lattice-nonce"
34
+ HEADER_SIGNATURE = "x-lattice-signature"
35
+
36
+
37
+ def _signing_payload(body: bytes, timestamp: str, nonce: str) -> bytes:
38
+ body_digest = hashlib.sha256(body or b"").hexdigest()
39
+ return f"{body_digest}|{timestamp}|{nonce}".encode("ascii")
40
+
41
+
42
+ class BrainNetwork:
43
+ """Peer registry + signed bundle exchange."""
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ identity: DeviceIdentity,
49
+ portability: Any,
50
+ data_dir: Path,
51
+ http_client_factory: Any = None,
52
+ ) -> None:
53
+ self._identity = identity
54
+ self._portability = portability
55
+ self._peers_file = Path(data_dir) / "brain_peers.json"
56
+ self._lock = threading.Lock()
57
+ self._seen_nonces: Dict[str, float] = {}
58
+ # injectable for tests; default builds an httpx client per call
59
+ self._http_client_factory = http_client_factory
60
+
61
+ # ── peer registry (deliberate pairing) ─────────────────────────────────
62
+ def _load_peers(self) -> List[Dict[str, Any]]:
63
+ if not self._peers_file.exists():
64
+ return []
65
+ try:
66
+ return json.loads(self._peers_file.read_text(encoding="utf-8"))
67
+ except Exception as exc:
68
+ logging.warning("brain network: peer registry unreadable: %s", exc)
69
+ return []
70
+
71
+ def _save_peers(self, peers: List[Dict[str, Any]]) -> None:
72
+ self._peers_file.parent.mkdir(parents=True, exist_ok=True)
73
+ tmp = self._peers_file.with_suffix(".tmp")
74
+ tmp.write_text(json.dumps(peers, ensure_ascii=False, indent=2), encoding="utf-8")
75
+ tmp.replace(self._peers_file)
76
+
77
+ def list_peers(self) -> List[Dict[str, Any]]:
78
+ return self._load_peers()
79
+
80
+ def add_peer(self, *, name: str, base_url: str, public_key: str) -> Dict[str, Any]:
81
+ name = str(name or "").strip()
82
+ base_url = str(base_url or "").strip().rstrip("/")
83
+ public_key = str(public_key or "").strip()
84
+ if not name or not base_url or not public_key:
85
+ raise ValueError("pairing requires name, base_url, and the peer's public key")
86
+ if not base_url.startswith(("http://", "https://")):
87
+ raise ValueError("base_url must be an http(s) URL")
88
+ try:
89
+ fingerprint = fingerprint_of(public_key)
90
+ except Exception as exc:
91
+ raise ValueError(f"public_key is not a valid Ed25519 key: {exc}") from exc
92
+ with self._lock:
93
+ peers = self._load_peers()
94
+ if any(p.get("public_key") == public_key for p in peers):
95
+ raise ValueError("this device is already paired")
96
+ peer = {
97
+ "id": f"peer-{uuid.uuid4().hex[:12]}",
98
+ "name": name,
99
+ "base_url": base_url,
100
+ "public_key": public_key,
101
+ "fingerprint": fingerprint,
102
+ "added_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
103
+ }
104
+ peers.append(peer)
105
+ self._save_peers(peers)
106
+ return peer
107
+
108
+ def remove_peer(self, peer_id: str) -> Dict[str, Any]:
109
+ with self._lock:
110
+ peers = self._load_peers()
111
+ kept = [p for p in peers if p.get("id") != peer_id]
112
+ if len(kept) == len(peers):
113
+ raise FileNotFoundError(peer_id)
114
+ self._save_peers(kept)
115
+ return {"status": "removed", "peer_id": peer_id}
116
+
117
+ def _peer_by_id(self, peer_id: str) -> Dict[str, Any]:
118
+ peer = next((p for p in self._load_peers() if p.get("id") == peer_id), None)
119
+ if peer is None:
120
+ raise FileNotFoundError(peer_id)
121
+ return peer
122
+
123
+ # ── request authentication (peer → this brain) ────────────────────────
124
+ def auth_headers(self, body: bytes) -> Dict[str, str]:
125
+ """Headers this device attaches when pushing to a peer."""
126
+ timestamp = str(int(time.time()))
127
+ nonce = uuid.uuid4().hex
128
+ return {
129
+ HEADER_DEVICE: self._identity.public_key_b64,
130
+ HEADER_TIMESTAMP: timestamp,
131
+ HEADER_NONCE: nonce,
132
+ HEADER_SIGNATURE: self._identity.sign(_signing_payload(body, timestamp, nonce)),
133
+ }
134
+
135
+ def verify_peer_request(self, headers: Dict[str, str], body: bytes) -> Dict[str, Any]:
136
+ """Authenticate an inbound peer request. Raises PermissionError."""
137
+ lowered = {str(k).lower(): v for k, v in headers.items()}
138
+ device = lowered.get(HEADER_DEVICE) or ""
139
+ timestamp = lowered.get(HEADER_TIMESTAMP) or ""
140
+ nonce = lowered.get(HEADER_NONCE) or ""
141
+ signature = lowered.get(HEADER_SIGNATURE) or ""
142
+ if not device or not timestamp or not nonce or not signature:
143
+ raise PermissionError("missing peer authentication headers")
144
+ peer = next((p for p in self._load_peers() if p.get("public_key") == device), None)
145
+ if peer is None:
146
+ raise PermissionError("device is not a paired peer")
147
+ try:
148
+ age = abs(time.time() - int(timestamp))
149
+ except ValueError:
150
+ raise PermissionError("invalid timestamp")
151
+ if age > PEER_AUTH_WINDOW_SECONDS:
152
+ raise PermissionError("request outside the freshness window")
153
+ with self._lock:
154
+ if nonce in self._seen_nonces:
155
+ raise PermissionError("replayed nonce")
156
+ self._seen_nonces[nonce] = time.time()
157
+ if len(self._seen_nonces) > _NONCE_CACHE_MAX:
158
+ cutoff = time.time() - PEER_AUTH_WINDOW_SECONDS * 2
159
+ self._seen_nonces = {n: t for n, t in self._seen_nonces.items() if t > cutoff}
160
+ if not verify_signature(device, _signing_payload(body, timestamp, nonce), signature):
161
+ raise PermissionError("peer request signature invalid")
162
+ return peer
163
+
164
+ # ── exchange ────────────────────────────────────────────────────────────
165
+ def push_to_peer(self, peer_id: str, *, workspace_id: Optional[str] = None, timeout: float = 30.0) -> Dict[str, Any]:
166
+ """Owner-initiated: export (signed) and push to one paired peer."""
167
+ peer = self._peer_by_id(peer_id)
168
+ artifact = self._portability.export(workspace_id=workspace_id)
169
+ body = json.dumps(artifact, ensure_ascii=False).encode("utf-8")
170
+ headers = {**self.auth_headers(body), "Content-Type": "application/json"}
171
+ url = f"{peer['base_url']}/network/receive"
172
+ if self._http_client_factory is not None:
173
+ response = self._http_client_factory().post(url, content=body, headers=headers, timeout=timeout)
174
+ else:
175
+ import httpx
176
+
177
+ with httpx.Client() as client:
178
+ response = client.post(url, content=body, headers=headers, timeout=timeout)
179
+ payload = response.json() if response.headers.get("content-type", "").startswith("application/json") else {}
180
+ return {
181
+ "status": "ok" if response.status_code == 200 else "failed",
182
+ "http_status": response.status_code,
183
+ "peer": {"id": peer["id"], "name": peer["name"], "fingerprint": peer["fingerprint"]},
184
+ "peer_result": payload,
185
+ "counts": (artifact.get("header") or {}).get("counts"),
186
+ }
187
+
188
+ def receive(self, headers: Dict[str, str], body: bytes) -> Dict[str, Any]:
189
+ """Inbound: authenticate the peer, verify the bundle, import."""
190
+ peer = self.verify_peer_request(headers, body)
191
+ try:
192
+ artifact = json.loads(body.decode("utf-8"))
193
+ except Exception:
194
+ raise ValueError("body is not a JSON bundle")
195
+ signature = artifact.get("signature") or {}
196
+ # On the network path the bundle itself MUST be signed by the paired
197
+ # peer too (unsigned-legacy applies to local file imports only).
198
+ if signature.get("public_key") != peer.get("public_key"):
199
+ raise PermissionError("bundle signer does not match the paired peer")
200
+ result = self._portability.import_data(artifact, mode="merge")
201
+ result["peer"] = {"id": peer["id"], "name": peer["name"], "fingerprint": peer["fingerprint"]}
202
+ return result
203
+
204
+
205
+ __all__ = ["BrainNetwork", "PEER_AUTH_WINDOW_SECONDS"]
@@ -21,6 +21,7 @@ only owns the state machine.
21
21
  from __future__ import annotations
22
22
 
23
23
  import json
24
+ import logging
24
25
  import re
25
26
  import subprocess
26
27
  from dataclasses import dataclass
@@ -128,6 +129,12 @@ class AgentDeps:
128
129
  # lifecycle, so the agent tool path no longer bypasses hooks.
129
130
  hooks: Any = None
130
131
 
132
+ # ── brain memory port (optional) ─────────────────────────────────
133
+ # When present, completed-run learnings become typed Experience records
134
+ # through the unified ingestion pipeline (with provenance), replacing
135
+ # the vault markdown dump.
136
+ brain_memory: Any = None
137
+
131
138
 
132
139
  class AgentRuntime:
133
140
  """Drives the agent state machine over injected :class:`AgentDeps`."""
@@ -429,13 +436,30 @@ class AgentRuntime:
429
436
  )
430
437
  mem = extract_action(str(raw))
431
438
  if mem.get("save_to_knowledge") and mem.get("learnings"):
432
- d.knowledge_save(
433
- "\n".join(mem["learnings"]),
434
- folder="30_Projects",
435
- title=f"Agent: {req.message[:60]}",
436
- )
437
- except Exception:
438
- pass
439
+ learnings = "\n".join(mem["learnings"])
440
+ if d.brain_memory is not None:
441
+ # This runtime is LLM-driven — its learnings are real
442
+ # experiences and enter the brain with provenance.
443
+ d.brain_memory.record_experience(
444
+ f"Agent: {req.message[:60]}",
445
+ learnings,
446
+ run={
447
+ "mode": "llm",
448
+ "status": "ok",
449
+ "agent_id": "agent:executor",
450
+ "steps": len(ctx.transcript),
451
+ },
452
+ user_email=current_user or None,
453
+ )
454
+ else:
455
+ d.knowledge_save(
456
+ learnings,
457
+ folder="30_Projects",
458
+ title=f"Agent: {req.message[:60]}",
459
+ )
460
+ except Exception as exc:
461
+ # Never crash a completed run, but never swallow silently either.
462
+ logging.warning("agent memory update failed: %s", exc)
439
463
 
440
464
  # ── DRIVE LOOP ───────────────────────────────────────────────────
441
465
  async def run_to_completion(
@@ -5,7 +5,6 @@ import logging
5
5
  import os
6
6
  import re
7
7
  import threading
8
- from datetime import datetime
9
8
  from pathlib import Path
10
9
  from typing import Any, Callable, Dict, List, Optional
11
10
 
@@ -217,12 +216,6 @@ def build_admin_audit_report(
217
216
  u["high_sensitive_events"] += 1
218
217
  sensitive_events.append(_public_audit_event(event))
219
218
 
220
- allowed_keys = {
221
- "event_type", "timestamp", "role", "user_email", "user_nickname", "source",
222
- "conversation_id", "command", "scope", "target_email", "filename", "mime_type",
223
- "ext", "bytes", "extracted_chars", "graph_node", "keep_last", "removed", "kept",
224
- "started_at", "sensitivity", "sensitive_labels", "content_preview", "content_chars",
225
- }
226
219
  recent = [_public_audit_event(e) for e in events[-50:]]
227
220
 
228
221
  result: Dict[str, Any] = {
@@ -19,7 +19,7 @@ concept and are read dynamically at call time.
19
19
  from __future__ import annotations
20
20
 
21
21
  import sys
22
- from dataclasses import dataclass, field
22
+ from dataclasses import dataclass
23
23
  from pathlib import Path
24
24
  from typing import List, Mapping, Optional
25
25
 
@@ -7,9 +7,8 @@ retrieve_context_for_generation() 파이프라인:
7
7
  Step 3: Top-K 결과를 구조화된 Markdown Context로 변환
8
8
  """
9
9
 
10
- import logging
11
10
  import re
12
- from typing import Any, Dict, List, Optional
11
+ from typing import Any, Dict, List
13
12
 
14
13
  _CLEAN_RE = re.compile(r"\s+")
15
14
 
@@ -22,7 +22,7 @@ from __future__ import annotations
22
22
 
23
23
  import os
24
24
  from enum import Enum
25
- from typing import Dict, List, Optional, Protocol, runtime_checkable
25
+ from typing import Dict, List, Protocol, runtime_checkable
26
26
 
27
27
 
28
28
  class Edition(str, Enum):
@@ -20,8 +20,8 @@ import logging
20
20
  import math
21
21
  import re
22
22
  import time
23
- from dataclasses import dataclass, field, asdict
24
- from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
23
+ from dataclasses import dataclass, field
24
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Set
25
25
 
26
26
  logger = logging.getLogger(__name__)
27
27
 
@@ -11,7 +11,7 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "3.6.0"
14
+ MARKETPLACE_VERSION = "4.0.0"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent")
16
16
 
17
17