ltcai 1.7.0 → 2.1.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.
@@ -91,8 +91,18 @@ from latticeai.services.model_runtime import (
91
91
  verify_cloud_models,
92
92
  ensure_ollama_server,
93
93
  )
94
- from latticeai.api.workspace import create_workspace_router
94
+ from latticeai.api.workspace import create_workspace_router, _workspace_scope_from_request
95
95
  from latticeai.api.health import create_health_router
96
+ # ── v2 Agentic Workspace Platform layers ─────────────────────────────────────
97
+ from latticeai.core.plugins import PluginRegistry
98
+ from latticeai.core.realtime import RealtimeBus
99
+ from latticeai.core.marketplace import TemplateCatalog
100
+ from latticeai.services.platform_runtime import PlatformRuntime
101
+ from latticeai.api.plugins import create_plugins_router
102
+ from latticeai.api.workflow_designer import create_workflow_designer_router
103
+ from latticeai.api.agents import create_agents_router
104
+ from latticeai.api.realtime import create_realtime_router
105
+ from latticeai.api.marketplace import create_marketplace_router
96
106
  from latticeai.api.models import create_models_router
97
107
  from latticeai.api.chat import create_chat_router
98
108
  from latticeai.api.tools import create_tools_router
@@ -236,10 +246,17 @@ AUDIT_FILE = DATA_DIR / "audit_log.json"
236
246
  SSO_FILE = DATA_DIR / "sso_config.json"
237
247
  KNOWLEDGE_GRAPH = KnowledgeGraphStore(DATA_DIR / "knowledge_graph.sqlite", DATA_DIR / "knowledge_graph_blobs") if ENABLE_GRAPH else None
238
248
  LOCAL_KG_WATCHER = LocalKnowledgeWatcher(lambda: KNOWLEDGE_GRAPH) if ENABLE_GRAPH else None
239
- WORKSPACE_OS = WorkspaceOSStore(DATA_DIR)
249
+ # ── v2 Realtime bus: constructed first so the store can fan every timeline
250
+ # event into the realtime feed via a single additive sink (no per-call wiring).
251
+ REALTIME_BUS = RealtimeBus()
252
+ WORKSPACE_OS = WorkspaceOSStore(DATA_DIR, event_sink=REALTIME_BUS)
240
253
  # Service layer (latticeai.services) wraps the store with scope/permission
241
254
  # guardrails; routers and the app assembly share this single instance.
242
255
  WORKSPACE_SERVICE = WorkspaceService(WORKSPACE_OS)
256
+ # ── v2 Plugin SDK registry (extends skills; discovers plugins/<id>/plugin.json)
257
+ PLUGINS_DIR = Path(os.getenv("LATTICEAI_PLUGINS_DIR") or (BASE_DIR / "plugins"))
258
+ PLUGIN_REGISTRY = PluginRegistry(PLUGINS_DIR, store=WORKSPACE_OS)
259
+ TEMPLATE_CATALOG = TemplateCatalog()
243
260
 
244
261
  def _require_graph():
245
262
  if not ENABLE_GRAPH or KNOWLEDGE_GRAPH is None:
@@ -1187,6 +1204,75 @@ app.include_router(create_workspace_router(
1187
1204
  ))
1188
1205
 
1189
1206
 
1207
+ # ── v2 Agentic Workspace Platform: cross-system wiring ───────────────────────
1208
+ # All cross-subsystem closures live in latticeai.services.platform_runtime to
1209
+ # keep this assembly file lean; server_app only constructs it and mounts routers.
1210
+ PLATFORM = PlatformRuntime(
1211
+ store=WORKSPACE_OS,
1212
+ workspace_service=WORKSPACE_SERVICE,
1213
+ plugin_registry=PLUGIN_REGISTRY,
1214
+ get_current_user=get_current_user,
1215
+ workspace_graph=_workspace_graph,
1216
+ workspace_scope_from_request=_workspace_scope_from_request,
1217
+ get_tool_permission=get_tool_permission,
1218
+ )
1219
+
1220
+ app.include_router(create_plugins_router(
1221
+ registry=PLUGIN_REGISTRY,
1222
+ require_user=require_user,
1223
+ require_admin=require_admin,
1224
+ append_audit_event=append_audit_event,
1225
+ register_skill=PLATFORM.register_plugin_skill,
1226
+ plugin_runners_factory=lambda: PLATFORM.plugin_capability_runners(None, None),
1227
+ ui_file_response=ui_file_response,
1228
+ static_dir=STATIC_DIR,
1229
+ ))
1230
+
1231
+ app.include_router(create_workflow_designer_router(
1232
+ store=WORKSPACE_OS,
1233
+ require_user=require_user,
1234
+ get_current_user=get_current_user,
1235
+ gate_read=PLATFORM.gate_read,
1236
+ gate_write=PLATFORM.gate_write,
1237
+ workspace_graph=_workspace_graph,
1238
+ build_runners=PLATFORM.build_workflow_runners,
1239
+ append_audit_event=append_audit_event,
1240
+ ui_file_response=ui_file_response,
1241
+ static_dir=STATIC_DIR,
1242
+ ))
1243
+
1244
+ app.include_router(create_agents_router(
1245
+ store=WORKSPACE_OS,
1246
+ orchestrator_factory=PLATFORM.build_orchestrator,
1247
+ require_user=require_user,
1248
+ get_current_user=get_current_user,
1249
+ gate_read=PLATFORM.gate_read,
1250
+ gate_write=PLATFORM.gate_write,
1251
+ workspace_graph=_workspace_graph,
1252
+ append_audit_event=append_audit_event,
1253
+ ui_file_response=ui_file_response,
1254
+ static_dir=STATIC_DIR,
1255
+ ))
1256
+
1257
+ app.include_router(create_marketplace_router(
1258
+ store=WORKSPACE_OS,
1259
+ catalog=TEMPLATE_CATALOG,
1260
+ require_user=require_user,
1261
+ gate_read=PLATFORM.gate_read,
1262
+ gate_write=PLATFORM.gate_write,
1263
+ workspace_graph=_workspace_graph,
1264
+ ))
1265
+
1266
+ app.include_router(create_realtime_router(
1267
+ bus=REALTIME_BUS,
1268
+ require_user=require_user,
1269
+ get_current_user=get_current_user,
1270
+ allowed_scopes=PLATFORM.allowed_scopes,
1271
+ ui_file_response=ui_file_response,
1272
+ static_dir=STATIC_DIR,
1273
+ ))
1274
+
1275
+
1190
1276
  # ── Health & Info ──────────────────────────────────────────────────────────────
1191
1277
 
1192
1278
  # ── Model runtime/provider helpers moved to latticeai.services.model_runtime ──
@@ -0,0 +1,204 @@
1
+ """v2 Agentic Workspace Platform runtime — cross-system wiring.
2
+
3
+ This is the single place the v2 subsystems (Plugin SDK, Workflow
4
+ Designer, Multi-Agent Runtime, Realtime) connect to one another and to the
5
+ workspace. Keeping it out of ``server_app`` honours the AGENTS.md preference for
6
+ small, composable modules and keeps the wiring independently testable.
7
+
8
+ Recursion is bounded by construction: a workflow's ``agent`` node runs an
9
+ orchestrator *without* a workflow runner, and an orchestrator's workflow runner
10
+ runs an engine *without* an ``agent`` runner — so the deepest chains are
11
+ ``agent → workflow → (tool|skill|plugin)`` and ``workflow → agent → plugin``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Callable, Dict, Optional, Set
17
+
18
+ from fastapi import HTTPException, Request
19
+
20
+ from latticeai.core.multi_agent import MultiAgentOrchestrator, default_role_runner
21
+ from latticeai.core.workflow_engine import WorkflowEngine
22
+
23
+
24
+ class PlatformRuntime:
25
+ def __init__(
26
+ self,
27
+ *,
28
+ store,
29
+ workspace_service,
30
+ plugin_registry,
31
+ get_current_user: Callable[[Request], Optional[str]],
32
+ workspace_graph: Callable[[], Any],
33
+ workspace_scope_from_request: Callable[[Request], Optional[str]],
34
+ get_tool_permission: Callable[..., Dict[str, Any]],
35
+ ):
36
+ self.store = store
37
+ self.svc = workspace_service
38
+ self.registry = plugin_registry
39
+ self.get_current_user = get_current_user
40
+ self.workspace_graph = workspace_graph
41
+ self.scope_from_request = workspace_scope_from_request
42
+ self.get_tool_permission = get_tool_permission
43
+
44
+ # ── request gating ────────────────────────────────────────────────────
45
+
46
+ def gate_read(self, request: Request) -> Optional[str]:
47
+ try:
48
+ return self.svc.resolve_read_scope(self.scope_from_request(request), self.get_current_user(request))
49
+ except PermissionError as exc:
50
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
51
+
52
+ def gate_write(self, request: Request) -> Optional[str]:
53
+ try:
54
+ return self.svc.resolve_write_scope(self.scope_from_request(request), self.get_current_user(request))
55
+ except PermissionError as exc:
56
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
57
+
58
+ def allowed_scopes(self, user: Optional[str]) -> Optional[Set[str]]:
59
+ try:
60
+ workspaces = self.svc.list_workspaces(user or None).get("workspaces", [])
61
+ return {ws.get("workspace_id") for ws in workspaces if ws.get("workspace_id")}
62
+ except Exception:
63
+ return None
64
+
65
+ # ── plugin lifecycle hooks ────────────────────────────────────────────
66
+
67
+ def register_plugin_skill(self, skill_name: str, plugin_id: str):
68
+ return self.store.mark_skill_installed(
69
+ skill_name, version=f"plugin:{plugin_id}", metadata={"source": f"plugin:{plugin_id}"}
70
+ )
71
+
72
+ # ── shared node runners ───────────────────────────────────────────────
73
+
74
+ def _tool_node_runner(self):
75
+ """Workflow tool node: records the invocation + governance decision but
76
+ never silently executes exec/destructive tools (those need approval)."""
77
+ def runner(*, node, context):
78
+ cfg = node.get("config") or {}
79
+ name = cfg.get("tool") or ""
80
+ try:
81
+ permission = dict(self.get_tool_permission(name))
82
+ except Exception:
83
+ permission = {"tool": name, "risk": "unknown"}
84
+ return {"tool": name, "args": cfg.get("args") or {}, "recorded": True, "permission": permission}
85
+ return runner
86
+
87
+ def _skill_node_runner(self):
88
+ def runner(*, node, context):
89
+ cfg = node.get("config") or {}
90
+ name = cfg.get("skill") or ""
91
+ entry = self.store.load_state().get("skill_registry", {}).get(name) or {}
92
+ return {"skill": name, "found": bool(entry), "enabled": bool(entry.get("enabled"))}
93
+ return runner
94
+
95
+ def _context_provider(self, user, scope):
96
+ def provider(goal: str):
97
+ try:
98
+ mems = self.store.search_memories(goal, user_email=user, workspace_id=scope).get("memories", [])
99
+ return [str(m.get("content") or "")[:160] for m in mems[:5]]
100
+ except Exception:
101
+ return []
102
+ return provider
103
+
104
+ def plugin_capability_runners(self, user, scope) -> Dict[str, Callable[..., Any]]:
105
+ """Runners the Plugin SDK boundary dispatches to (one per capability)."""
106
+ def run_skill(*, plugin_id, action, args, manifest):
107
+ return {"plugin": plugin_id, "ran_skills": manifest.provides.get("skills", [])}
108
+
109
+ def run_tool(*, plugin_id, action, args, manifest):
110
+ tool = args.get("tool") or (manifest.provides.get("tools") or [None])[0]
111
+ try:
112
+ permission = dict(self.get_tool_permission(tool)) if tool else {}
113
+ except Exception:
114
+ permission = {}
115
+ return {"plugin": plugin_id, "tool": tool, "permission": permission, "recorded": True}
116
+
117
+ def run_workflow(*, plugin_id, action, args, manifest):
118
+ wf_id = args.get("workflow_id")
119
+ if not wf_id:
120
+ return {"plugin": plugin_id, "skipped": "no workflow_id"}
121
+ return self.run_workflow_by_id(wf_id, user, scope, with_agent=False, inputs=args.get("inputs"))
122
+
123
+ def run_agent(*, plugin_id, action, args, manifest):
124
+ goal = args.get("goal") or f"Plugin {plugin_id} agent task"
125
+ return self.run_agent(goal, user, scope, with_workflow=False, inputs=args.get("inputs"))
126
+
127
+ return {"skills": run_skill, "tools": run_tool, "workflows": run_workflow, "agents": run_agent}
128
+
129
+ def _plugin_node_runner(self, user, scope):
130
+ def runner(*, node, context):
131
+ cfg = node.get("config") or {}
132
+ plugin_id = cfg.get("plugin_id") or cfg.get("plugin") or ""
133
+ action = cfg.get("action") or "run_skill"
134
+ result = self.registry.execute_action(
135
+ plugin_id, action, cfg.get("args") or {}, runners=self.plugin_capability_runners(user, scope), workspace_id=scope
136
+ )
137
+ return result.as_dict()
138
+ return runner
139
+
140
+ def _agent_node_runner(self, user, scope):
141
+ def runner(*, node, context):
142
+ cfg = node.get("config") or {}
143
+ goal = cfg.get("goal") or context.get("goal") or "Run agent"
144
+ return self.run_agent(goal, user, scope, with_workflow=False, roles=cfg.get("roles"), inputs=context.get("inputs"))
145
+ return runner
146
+
147
+ # ── cross-system runs ─────────────────────────────────────────────────
148
+
149
+ def run_workflow_by_id(self, workflow_id, user, scope, *, with_agent: bool, inputs=None) -> Dict[str, Any]:
150
+ try:
151
+ workflow = self.store.get_workflow(workflow_id, workspace_id=scope)
152
+ except FileNotFoundError:
153
+ return {"error": f"workflow not found: {workflow_id}"}
154
+ runners = {
155
+ "tool": self._tool_node_runner(),
156
+ "skill": self._skill_node_runner(),
157
+ "plugin": self._plugin_node_runner(user, scope),
158
+ }
159
+ if with_agent:
160
+ runners["agent"] = self._agent_node_runner(user, scope)
161
+ result = WorkflowEngine(runners).run(workflow, inputs=inputs or {})
162
+ run = self.store.record_workflow_run(
163
+ workflow_id=workflow_id, name=workflow.get("name") or "workflow",
164
+ status=result.status, timeline=result.timeline, outputs=result.outputs,
165
+ user_email=user, graph=self.workspace_graph(), workspace_id=scope,
166
+ )
167
+ return {"workflow_run_id": run["id"], "status": result.status}
168
+
169
+ def run_agent(self, goal, user, scope, *, with_workflow: bool, roles=None, inputs=None) -> Dict[str, Any]:
170
+ role_runner = default_role_runner(
171
+ workflow_runner=(lambda wf_ref, ctx: self.run_workflow_by_id(wf_ref, user, scope, with_agent=False, inputs=ctx.inputs)) if with_workflow else None,
172
+ plugin_runner=lambda pid, ctx: self.registry.execute_action(pid, "run_skill", {}, runners=self.plugin_capability_runners(user, scope), workspace_id=scope).as_dict(),
173
+ context_provider=self._context_provider(user, scope),
174
+ )
175
+ result = MultiAgentOrchestrator(role_runner=role_runner).run(
176
+ goal, user_email=user, workspace_id=scope, roles=roles, inputs=inputs or {}
177
+ )
178
+ run = self.store.record_agent_run(
179
+ agent_id=result.agent_id, status=result.status, input_text=goal,
180
+ output_text=result.output, timeline=result.timeline, relationships=[],
181
+ handoffs=result.handoffs, context_packets=result.context_packets,
182
+ plan=result.plan, plan_review=result.plan_review,
183
+ review_history=result.review_history, retry_history=result.retry_history,
184
+ memory_snapshots=result.memory_snapshots,
185
+ user_email=user, graph=self.workspace_graph(), workspace_id=scope,
186
+ )
187
+ return {"agent_run_id": run["id"], "status": result.status, "output": result.output}
188
+
189
+ # ── factories passed to routers ───────────────────────────────────────
190
+
191
+ def build_workflow_runners(self, user, scope) -> Dict[str, Callable[..., Any]]:
192
+ return {
193
+ "tool": self._tool_node_runner(),
194
+ "skill": self._skill_node_runner(),
195
+ "plugin": self._plugin_node_runner(user, scope),
196
+ "agent": self._agent_node_runner(user, scope),
197
+ }
198
+
199
+ def build_orchestrator(self, user, scope) -> MultiAgentOrchestrator:
200
+ return MultiAgentOrchestrator(role_runner=default_role_runner(
201
+ workflow_runner=lambda wf_ref, ctx: self.run_workflow_by_id(wf_ref, user, scope, with_agent=False, inputs=ctx.inputs),
202
+ plugin_runner=lambda pid, ctx: self.registry.execute_action(pid, "run_skill", {}, runners=self.plugin_capability_runners(user, scope), workspace_id=scope).as_dict(),
203
+ context_provider=self._context_provider(user, scope),
204
+ ))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "1.7.0",
3
+ "version": "2.1.0",
4
4
  "description": "Lattice AI Workspace OS for local-first graph, memory, agent, workflow, and skill operations",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -19,7 +19,7 @@
19
19
  "dev": "python3 ltcai_cli.py --reload",
20
20
  "build": "npm run build:python",
21
21
  "build:python": "python3 -m build",
22
- "check:python": "python3 -m py_compile ltcai_cli.py server.py latticeai/server_app.py latticeai/api/chat.py latticeai/api/computer_use.py latticeai/api/deps.py latticeai/api/garden.py latticeai/api/local_files.py latticeai/api/permissions.py latticeai/api/setup.py latticeai/api/static_routes.py latticeai/api/tools.py latticeai/services/app_context.py latticeai/services/model_runtime.py latticeai/services/model_catalog.py latticeai/services/model_recommendation.py latticeai/services/tool_dispatch.py latticeai/services/upload_service.py latticeai/core/tool_registry.py latticeai/core/enterprise.py latticeai/core/enterprise_admin.py latticeai/core/agent_prompts.py latticeai/core/workspace_os.py knowledge_graph.py knowledge_graph_api.py local_knowledge_api.py llm_router.py p_reinforce.py telegram_bot.py tools.py codex_telegram_bot.py",
22
+ "check:python": "python3 -m py_compile ltcai_cli.py server.py latticeai/server_app.py latticeai/api/chat.py latticeai/api/computer_use.py latticeai/api/deps.py latticeai/api/garden.py latticeai/api/local_files.py latticeai/api/permissions.py latticeai/api/setup.py latticeai/api/static_routes.py latticeai/api/tools.py latticeai/api/plugins.py latticeai/api/workflow_designer.py latticeai/api/agents.py latticeai/api/realtime.py latticeai/api/marketplace.py latticeai/services/app_context.py latticeai/services/model_runtime.py latticeai/services/model_catalog.py latticeai/services/model_recommendation.py latticeai/services/tool_dispatch.py latticeai/services/upload_service.py latticeai/core/tool_registry.py latticeai/core/enterprise.py latticeai/core/enterprise_admin.py latticeai/core/agent_prompts.py latticeai/core/workspace_os.py latticeai/core/plugins.py latticeai/core/marketplace.py latticeai/core/workflow_engine.py latticeai/core/multi_agent.py latticeai/core/realtime.py knowledge_graph.py knowledge_graph_api.py local_knowledge_api.py llm_router.py p_reinforce.py telegram_bot.py tools.py codex_telegram_bot.py",
23
23
  "test": "python3 -m pytest tests/ -v",
24
24
  "test:unit": "python3 -m pytest tests/unit/ -v",
25
25
  "test:integration": "python3 -m pytest tests/integration/ -v",
@@ -68,13 +68,19 @@
68
68
  "static/admin.html",
69
69
  "static/graph.html",
70
70
  "static/workspace.html",
71
+ "static/plugins.html",
72
+ "static/workflows.html",
73
+ "static/agents.html",
74
+ "static/activity.html",
71
75
  "static/manifest.json",
72
76
  "static/sw.js",
73
77
  "static/lattice-reference.css",
74
78
  "static/workspace.css",
79
+ "static/platform.css",
75
80
  "static/scripts/",
76
81
  "static/css/",
77
82
  "static/icons/",
83
+ "plugins/",
78
84
  "docs/",
79
85
  "requirements.txt",
80
86
  "README.md"
@@ -0,0 +1,35 @@
1
+ # Lattice AI Plugins
2
+
3
+ This directory holds **Plugin SDK** packages discovered by the Lattice AI server
4
+ at runtime. Each plugin is a folder containing a `plugin.json` manifest.
5
+
6
+ A plugin is an additive, versioned, permissioned unit that **extends** the
7
+ existing Skill / Tool / Workflow surfaces — it never replaces them. Installed
8
+ standalone skills keep working unchanged.
9
+
10
+ See [`docs/PLUGIN_SDK.md`](../docs/PLUGIN_SDK.md) for the full manifest schema,
11
+ permission model, lifecycle, and execution boundary.
12
+
13
+ ## Bundled examples
14
+
15
+ | Plugin | What it shows |
16
+ | --- | --- |
17
+ | `hello-world` | Bundling a skill + a workflow template + a declarative action |
18
+ | `git-insights` | Declaring `run_tools` and surfacing read-only git insights through the permission boundary |
19
+
20
+ ## Minimal manifest
21
+
22
+ ```json
23
+ {
24
+ "id": "my-plugin",
25
+ "name": "My Plugin",
26
+ "version": "1.0.0",
27
+ "description": "What it does.",
28
+ "lattice_version": ">=2.0.0",
29
+ "permissions": ["read_workspace"],
30
+ "provides": { "skills": [], "tools": [], "workflows": [], "actions": [] }
31
+ }
32
+ ```
33
+
34
+ Permissions must be drawn from the SDK allow-list; the execution boundary
35
+ refuses any capability a plugin did not declare and was not granted at install.
@@ -0,0 +1,15 @@
1
+ {
2
+ "id": "git-insights",
3
+ "name": "Git Insights",
4
+ "version": "1.0.0",
5
+ "description": "Example plugin that surfaces read-only git status and log insights through the permissioned tool execution boundary.",
6
+ "author": "Lattice AI",
7
+ "lattice_version": ">=2.0.0",
8
+ "permissions": ["read_workspace", "run_tools"],
9
+ "provides": {
10
+ "tools": ["git_status", "git_log"],
11
+ "actions": ["summarize_repo"]
12
+ },
13
+ "entrypoint": "",
14
+ "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI/blob/main/docs/PLUGIN_SDK.md"
15
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "id": "hello-world",
3
+ "name": "Hello World",
4
+ "version": "1.0.0",
5
+ "description": "Example plugin demonstrating the Lattice AI Plugin SDK: bundles a skill, a workflow template, and a greet action.",
6
+ "author": "Lattice AI",
7
+ "lattice_version": "2.0.0",
8
+ "permissions": ["read_workspace", "run_skills"],
9
+ "provides": {
10
+ "skills": ["hello_skill"],
11
+ "workflows": ["hello-workflow"],
12
+ "actions": ["greet"]
13
+ },
14
+ "entrypoint": "",
15
+ "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI/blob/main/docs/PLUGIN_SDK.md"
16
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: hello_skill
3
+ description: A minimal example skill bundled by the hello-world plugin to show that plugins extend (not replace) the existing skill system.
4
+ ---
5
+
6
+ # Hello Skill
7
+
8
+ This skill is contributed by the `hello-world` plugin. When the plugin is
9
+ installed, the Plugin SDK registers this skill into the existing Workspace skill
10
+ registry via the same `mark_skill_installed` path used by standalone skills —
11
+ demonstrating that plugins are an additive layer on top of skills.
12
+
13
+ ## Usage
14
+
15
+ Ask Lattice AI to greet a workspace member, or invoke the plugin action `greet`.
@@ -0,0 +1,70 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Realtime Activity — Lattice AI</title>
7
+ <link rel="stylesheet" href="/static/platform.css" />
8
+ </head>
9
+ <body>
10
+ <main>
11
+ <h1>Realtime Activity</h1>
12
+ <p class="sub" id="sub">Live presence + activity feed across workspace, graph, agent, and workflow events (SSE).</p>
13
+
14
+ <div class="row">
15
+ <span class="badge" id="connBadge">connecting…</span>
16
+ <span class="badge" id="presenceBadge">presence: 0</span>
17
+ <div class="spacer"></div>
18
+ <button class="ghost" id="clearBtn">Clear view</button>
19
+ </div>
20
+
21
+ <div class="section">
22
+ <h3>Live feed</h3>
23
+ <div id="feed"><div class="empty">Waiting for events…</div></div>
24
+ </div>
25
+ </main>
26
+
27
+ <script type="module">
28
+ import { mountHeader, api, escapeHtml, badge } from "/static/scripts/platform.js";
29
+ mountHeader("/activity");
30
+
31
+ const feed = document.getElementById("feed");
32
+ const seen = new Set();
33
+
34
+ function renderEvent(ev) {
35
+ if (ev.seq && seen.has(ev.seq)) return;
36
+ if (ev.seq) seen.add(ev.seq);
37
+ if (feed.querySelector(".empty")) feed.innerHTML = "";
38
+ const node = document.createElement("div");
39
+ node.className = "timeline-item";
40
+ node.innerHTML = `<div class="row">${badge(ev.area || "event")} <strong>${escapeHtml(ev.event_type || "")}</strong>
41
+ <span class="t-meta">${escapeHtml(ev.workspace_id || "personal")} · ${escapeHtml(ev.received_at || ev.timestamp || "")}</span></div>
42
+ <pre style="max-height:160px">${escapeHtml(JSON.stringify(ev.payload || {}, null, 2))}</pre>`;
43
+ feed.prepend(node);
44
+ while (feed.children.length > 100) feed.lastChild.remove();
45
+ }
46
+
47
+ async function refreshPresence() {
48
+ try {
49
+ const data = await api("/realtime/presence");
50
+ document.getElementById("presenceBadge").textContent = `presence: ${data.presence.length}`;
51
+ document.getElementById("sub").textContent =
52
+ `Transport: ${data.stats.transport.toUpperCase()} · subscribers: ${data.stats.subscribers} · feed: ${data.stats.feed_size}`;
53
+ } catch {}
54
+ }
55
+
56
+ document.getElementById("clearBtn").addEventListener("click", () => { feed.innerHTML = `<div class="empty">Cleared.</div>`; });
57
+
58
+ // Seed with recent feed, then connect to the SSE stream.
59
+ api("/realtime/feed?limit=30").then((d) => (d.events || []).reverse().forEach(renderEvent)).catch(() => {});
60
+
61
+ const es = new EventSource("/realtime/stream");
62
+ es.onopen = () => { document.getElementById("connBadge").textContent = "● live"; document.getElementById("connBadge").className = "badge ok"; };
63
+ es.onerror = () => { document.getElementById("connBadge").textContent = "○ reconnecting"; document.getElementById("connBadge").className = "badge warn"; };
64
+ es.onmessage = (e) => { try { renderEvent(JSON.parse(e.data)); } catch {} };
65
+
66
+ refreshPresence();
67
+ setInterval(refreshPresence, 8000);
68
+ </script>
69
+ </body>
70
+ </html>
@@ -0,0 +1,136 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Multi-Agent Runtime — Lattice AI</title>
7
+ <link rel="stylesheet" href="/static/platform.css" />
8
+ </head>
9
+ <body>
10
+ <main>
11
+ <h1>Multi-Agent Runtime</h1>
12
+ <p class="sub">Planner · Executor · Reviewer · Researcher · Release — with handoff, context packets, memory, retry, and replay.</p>
13
+
14
+ <div class="section">
15
+ <label>Goal</label>
16
+ <textarea id="goal" placeholder="e.g. Draft a release checklist for v2.0.0">Summarize and verify the latest workspace activity</textarea>
17
+ <label>Roles (pipeline)</label>
18
+ <div id="roleChips" class="row"></div>
19
+ <div class="row" style="margin-top:14px"><button id="runBtn">Run agents</button></div>
20
+ </div>
21
+
22
+ <div class="section">
23
+ <h3>Run result</h3>
24
+ <div id="result"><div class="empty">No run yet.</div></div>
25
+ </div>
26
+
27
+ <div class="section">
28
+ <h3>Recent agent runs</h3>
29
+ <div id="runs"><div class="empty">Loading…</div></div>
30
+ </div>
31
+
32
+ <div class="section">
33
+ <h3>Replay viewer</h3>
34
+ <div id="replay"><div class="empty">Select a recent run.</div></div>
35
+ </div>
36
+ </main>
37
+
38
+ <script type="module">
39
+ import { mountHeader, api, escapeHtml, badge, toast } from "/static/scripts/platform.js";
40
+ mountHeader("/agents");
41
+
42
+ const selected = new Set(["planner", "executor", "reviewer"]);
43
+ async function loadRoles() {
44
+ const data = await api("/agents/api/roles");
45
+ document.getElementById("roleChips").innerHTML = data.roles.map((r) =>
46
+ `<label class="badge" style="cursor:pointer"><input type="checkbox" value="${r.role}" ${selected.has(r.role)?"checked":""} style="width:auto;margin-right:6px">${escapeHtml(r.role)}</label>`
47
+ ).join(" ");
48
+ document.getElementById("roleChips").addEventListener("change", (e) => {
49
+ if (e.target.checked) selected.add(e.target.value); else selected.delete(e.target.value);
50
+ });
51
+ }
52
+
53
+ function renderTimeline(timeline) {
54
+ return (timeline || []).map((t) => {
55
+ const label = (t.event || "").startsWith("handoff_") ? `↪ ${escapeHtml(t.event)} ${escapeHtml(t.from||"")} → ${escapeHtml(t.to||"")}`
56
+ : t.event === "handoff" ? `↪ handoff ${escapeHtml(t.from)} → ${escapeHtml(t.to)}`
57
+ : t.event === "role" ? `● ${escapeHtml(t.role)} ${badge(t.status)}`
58
+ : t.event === "retry_requested" ? `↻ retry ${escapeHtml(t.reason||"")}`
59
+ : t.event === "review_approved" ? `✓ review approved`
60
+ : `· ${escapeHtml(t.event)}`;
61
+ return `<div class="timeline-item">${label}<div class="t-meta">${escapeHtml(t.note||t.timestamp||"")}</div></div>`;
62
+ }).join("");
63
+ }
64
+
65
+ function renderHandoffs(handoffs) {
66
+ if (!handoffs?.length) return `<div class="empty">No handoffs recorded.</div>`;
67
+ return handoffs.map((h) => `<div class="timeline-item">
68
+ <strong>${escapeHtml(h.handoff_id)}</strong> ${badge(h.status)}
69
+ <div class="t-meta">${escapeHtml(h.source_agent)} → ${escapeHtml(h.target_agent)} · ${escapeHtml(h.reason||"")}</div>
70
+ </div>`).join("");
71
+ }
72
+
73
+ function renderReview(result) {
74
+ const reviews = result.review_history || [];
75
+ const retries = result.retry_history || [];
76
+ return `<div class="grid two">
77
+ <div>${reviews.length ? reviews.map((r) => `<div class="timeline-item">${badge(r.outcome)} ${escapeHtml(r.reason||"")}<div class="t-meta">retry ${r.retry_count}</div></div>`).join("") : `<div class="empty">No review history.</div>`}</div>
78
+ <div>${retries.length ? retries.map((r) => `<div class="timeline-item">${badge("retry " + r.retry)} ${escapeHtml(r.reason||"")}<div class="t-meta">limit ${r.limit}</div></div>`).join("") : `<div class="empty">No retries.</div>`}</div>
79
+ </div>`;
80
+ }
81
+
82
+ document.getElementById("runBtn").addEventListener("click", async () => {
83
+ const btn = document.getElementById("runBtn");
84
+ btn.disabled = true;
85
+ try {
86
+ const res = await api("/agents/api/run", { method: "POST", body: JSON.stringify({
87
+ goal: document.getElementById("goal").value, roles: [...selected], inputs: {}
88
+ }) });
89
+ const r = res.result;
90
+ document.getElementById("result").innerHTML = `
91
+ <div class="card">
92
+ <div class="row"><h3>${escapeHtml(r.output)}</h3><div class="spacer"></div>${badge(r.status)}</div>
93
+ <div class="meta">retries: ${r.retries} · roles: ${(r.roles_run||[]).join(" → ")}</div>
94
+ <div class="section"><h3>Handoff chain</h3>${renderHandoffs(r.handoffs)}</div>
95
+ <div class="section"><h3>Review panel</h3>${renderReview(r)}</div>
96
+ <div class="section"><h3>Timeline</h3>${renderTimeline(r.timeline)}</div>
97
+ </div>`;
98
+ toast(`Agent run: ${r.status}`);
99
+ await loadRuns();
100
+ } catch (err) { toast(err.message); } finally { btn.disabled = false; }
101
+ });
102
+
103
+ async function loadRuns() {
104
+ const data = await api("/agents/api/runs");
105
+ const runs = data.runs || [];
106
+ const box = document.getElementById("runs");
107
+ if (!runs.length) { box.innerHTML = `<div class="empty">No agent runs yet.</div>`; return; }
108
+ box.innerHTML = runs.slice(0, 20).map((r) => `
109
+ <div class="card" style="margin-bottom:10px">
110
+ <div class="row"><h3>${escapeHtml((r.input||"").slice(0,80))}</h3><div class="spacer"></div>${badge(r.status)}</div>
111
+ <div class="meta">${escapeHtml(r.agent_id)} · ${escapeHtml(r.created_at)} · ${(r.timeline||[]).length} timeline events</div>
112
+ <div class="row" style="margin-top:10px"><button class="ghost" data-replay="${r.id}">Replay</button></div>
113
+ </div>`).join("");
114
+ }
115
+
116
+ document.getElementById("runs").addEventListener("click", async (e) => {
117
+ const btn = e.target.closest("button[data-replay]");
118
+ if (!btn) return;
119
+ const out = document.getElementById("replay");
120
+ out.innerHTML = `<div class="empty">Loading replay…</div>`;
121
+ try {
122
+ const data = await api(`/agents/api/runs/${btn.dataset.replay}/replay`);
123
+ const frames = data.replay.frames || [];
124
+ out.innerHTML = frames.map((f) => `<div class="timeline-item">
125
+ <div class="row"><strong>${escapeHtml(f.event)}</strong><div class="spacer"></div>${badge(f.decision || "event")}</div>
126
+ <div class="t-meta">${escapeHtml(String(f.actor||""))} · ${escapeHtml(f.when||"")}</div>
127
+ <pre>${escapeHtml(JSON.stringify({ why: f.why, input: f.input, output: f.output }, null, 2))}</pre>
128
+ </div>`).join("") || `<div class="empty">No replay frames.</div>`;
129
+ } catch (err) { out.innerHTML = `<div class="empty">${escapeHtml(err.message)}</div>`; }
130
+ });
131
+
132
+ loadRoles().catch((e) => toast(e.message));
133
+ loadRuns().catch(() => {});
134
+ </script>
135
+ </body>
136
+ </html>