ltcai 3.1.0 → 3.3.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 (54) hide show
  1. package/README.md +35 -8
  2. package/docs/CHANGELOG.md +53 -0
  3. package/docs/V3_2_AUDIT.md +82 -0
  4. package/docs/V3_FRONTEND.md +1 -1
  5. package/docs/architecture.md +6 -0
  6. package/latticeai/__init__.py +1 -1
  7. package/latticeai/api/agent_registry.py +103 -0
  8. package/latticeai/api/hooks.py +113 -0
  9. package/latticeai/api/marketplace.py +13 -0
  10. package/latticeai/api/memory.py +109 -0
  11. package/latticeai/core/agent_registry.py +234 -0
  12. package/latticeai/core/hooks.py +284 -0
  13. package/latticeai/core/marketplace.py +87 -2
  14. package/latticeai/core/multi_agent.py +1 -1
  15. package/latticeai/core/workspace_os.py +1 -1
  16. package/latticeai/server_app.py +41 -0
  17. package/latticeai/services/memory_service.py +324 -0
  18. package/package.json +2 -2
  19. package/scripts/build_v3_assets.mjs +7 -1
  20. package/static/css/{tokens.5a595671.css → tokens.8b8e31bd.css} +1 -1
  21. package/static/css/tokens.css +1 -1
  22. package/static/v3/asset-manifest.json +22 -14
  23. package/static/v3/css/{lattice.views.3ee19d4e.css → lattice.views.1d326beb.css} +5 -0
  24. package/static/v3/css/lattice.views.css +5 -0
  25. package/static/v3/js/{app.46fb61d9.js → app.cf5bb712.js} +1 -1
  26. package/static/v3/js/core/{api.22a41d42.js → api.113660c5.js} +123 -4
  27. package/static/v3/js/core/api.js +123 -4
  28. package/static/v3/js/core/{routes.f935dd50.js → routes.07ad6696.js} +11 -0
  29. package/static/v3/js/core/routes.js +11 -0
  30. package/static/v3/js/core/{shell.1b6199d6.js → shell.9e707234.js} +2 -2
  31. package/static/v3/js/views/{agents.14e48bdd.js → agents.c373d48c.js} +100 -0
  32. package/static/v3/js/views/agents.js +100 -0
  33. package/static/v3/js/views/{chat.718144ce.js → chat.c48fd9e2.js} +1 -1
  34. package/static/v3/js/views/chat.js +1 -1
  35. package/static/v3/js/views/{files.4935197e.js → files.8464634a.js} +64 -18
  36. package/static/v3/js/views/files.js +64 -18
  37. package/static/v3/js/views/hooks.f3edebca.js +99 -0
  38. package/static/v3/js/views/hooks.js +99 -0
  39. package/static/v3/js/views/marketplace.ab0583d4.js +141 -0
  40. package/static/v3/js/views/marketplace.js +141 -0
  41. package/static/v3/js/views/mcp.99b5c6a7.js +114 -0
  42. package/static/v3/js/views/mcp.js +114 -0
  43. package/static/v3/js/views/memory.4ebdf474.js +147 -0
  44. package/static/v3/js/views/memory.js +147 -0
  45. package/static/v3/js/views/planning.9ac3e313.js +153 -0
  46. package/static/v3/js/views/planning.js +153 -0
  47. package/static/v3/js/views/{settings.4f777210.js → settings.c7b0cc05.js} +16 -2
  48. package/static/v3/js/views/settings.js +16 -2
  49. package/static/v3/js/views/skills.c6c2f965.js +109 -0
  50. package/static/v3/js/views/skills.js +109 -0
  51. package/static/v3/js/views/tools.e4f11276.js +108 -0
  52. package/static/v3/js/views/tools.js +108 -0
  53. package/static/v3/js/views/workflows.26c57290.js +128 -0
  54. package/static/v3/js/views/workflows.js +128 -0
@@ -9,8 +9,10 @@ export async function render(ctx) {
9
9
  const { h, icon, c } = ctx;
10
10
 
11
11
  const statHost = h("div.lt3-statrow", c.loading({ lines: 1 }));
12
+ const registryHost = h("div", c.loading({ lines: 3, block: true }));
12
13
  const rosterHost = h("div", c.loading({ lines: 2, block: true }));
13
14
  const runsHost = h("div", c.loading({ lines: 4 }));
15
+ const registrySrc = h("span", c.sourceBadge("pending"));
14
16
  const rosterSrc = h("span", c.sourceBadge("pending"));
15
17
  const runsSrc = h("span", c.sourceBadge("pending"));
16
18
  const healthSlot = h("span", c.sourceBadge("pending"));
@@ -23,6 +25,10 @@ export async function render(ctx) {
23
25
  actions: [healthSlot],
24
26
  }),
25
27
  statHost,
28
+ h("section",
29
+ c.sectionHead("Agent Registry", registrySrc),
30
+ registryHost,
31
+ ),
26
32
  h("section",
27
33
  c.sectionHead("Agent roster", rosterSrc),
28
34
  rosterHost,
@@ -40,6 +46,7 @@ export async function render(ctx) {
40
46
  );
41
47
 
42
48
  hydrate(ctx, { statHost, rosterHost, runsHost, rosterSrc, runsSrc, healthSlot });
49
+ loadRegistry(ctx, { registryHost, registrySrc });
43
50
  return root;
44
51
  }
45
52
 
@@ -109,6 +116,83 @@ async function hydrate(ctx, hosts) {
109
116
  );
110
117
  }
111
118
 
119
+ async function loadRegistry(ctx, hosts) {
120
+ const { h, c } = ctx;
121
+ const { registryHost, registrySrc } = hosts;
122
+ const [registryRes, capsRes] = await Promise.all([ctx.api.agentRegistry(), ctx.api.agentCapabilities()]);
123
+ const agents = normalizeRegistry(registryRes.data);
124
+ const caps = (capsRes.data && capsRes.data.capabilities) || {};
125
+ registrySrc.replaceChildren(c.sourceBadge(registryRes.source === "live" || capsRes.source === "live" ? "live" : "unavailable"));
126
+
127
+ const nameInput = h("input.lt3-input", { type: "text", placeholder: "Custom agent name", "aria-label": "Custom agent name" });
128
+ const capsInput = h("input.lt3-input", { type: "text", placeholder: "capability-a, capability-b", "aria-label": "Custom agent capabilities" });
129
+ const registerBtn = h("button.lt3-btn.lt3-btn--primary.lt3-btn--sm", { on: { click: register } }, c.icon("plus"), "Register");
130
+
131
+ const capList = Object.keys(caps).sort();
132
+ const body = h("div.lt3-stack-4",
133
+ h("div.lt3-grid-2",
134
+ h("div.lt3-field", h("label", "Name"), nameInput),
135
+ h("div.lt3-field", h("label", "Capabilities"), capsInput),
136
+ ),
137
+ h("div.lt3-row-2", registerBtn,
138
+ h("span.lt3-faint", { style: { "font-size": "var(--lt3-text-xs)" } }, "Custom agents persist in the local registry.")),
139
+ capList.length
140
+ ? h("div.lt3-cluster", capList.slice(0, 18).map((cap) => h("span.lt3-chip", c.icon("sparkles"), `${cap} (${caps[cap].length})`)))
141
+ : h("p.lt3-faint", { style: { margin: 0 } }, "Capabilities appear here when the registry is live."),
142
+ agents.length
143
+ ? h("div.lt3-grid-auto", agents.map((agent) => registryCard(ctx, agent)))
144
+ : c.emptyState({ icon: "robot-off", title: "Agent registry unavailable", body: "Start the local server to register and configure agents." }),
145
+ );
146
+ registryHost.replaceChildren(c.panel({ title: "Registry controls", sub: "Register, discover, and configure built-in or custom agents.", children: body }));
147
+
148
+ async function register() {
149
+ const name = nameInput.value.trim();
150
+ if (!name) { ctx.toast("Enter an agent name", "info"); return; }
151
+ const capabilities = capsInput.value.split(",").map((s) => s.trim()).filter(Boolean);
152
+ registerBtn.disabled = true;
153
+ const res = await ctx.api.registerAgent({ name, type: "custom", capabilities });
154
+ registerBtn.disabled = false;
155
+ if (res && res.ok) {
156
+ ctx.toast(`Registered ${name}`, "ok");
157
+ loadRegistry(ctx, hosts);
158
+ } else {
159
+ ctx.toast("Register unavailable", "err");
160
+ }
161
+ }
162
+ }
163
+
164
+ function registryCard(ctx, agent) {
165
+ const { h, c } = ctx;
166
+ return c.card(h("div.lt3-stack-3",
167
+ h("div.lt3-row", { style: { "justify-content": "space-between", "align-items": "flex-start" } },
168
+ h("div",
169
+ h("b", agent.name),
170
+ h("div.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)", "font-family": "var(--lt3-font-mono)" } }, agent.id),
171
+ ),
172
+ c.pill(agent.source === "builtin" ? "built-in" : "custom", agent.source === "builtin" ? "info" : "warn"),
173
+ ),
174
+ h("p.lt3-muted", { style: { "font-size": "var(--lt3-text-sm)", margin: 0 } }, agent.description || "No description."),
175
+ h("div.lt3-cluster", [c.statePill(agent.enabled ? "ready" : "idle"), c.pill(agent.type), c.pill(`v${agent.version || "1.0.0"}`)]),
176
+ agent.capabilities.length ? h("div.lt3-cluster", agent.capabilities.slice(0, 8).map((cap) => h("span.lt3-chip", cap))) : null,
177
+ h("div.lt3-row-2",
178
+ h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm", { on: { click: () => toggleAgent(ctx, agent) } }, c.icon(agent.enabled ? "toggle-right" : "toggle-left"), agent.enabled ? "Disable" : "Enable"),
179
+ agent.removable ? h("button.lt3-btn.lt3-btn--ghost.lt3-btn--sm", { on: { click: () => removeAgent(ctx, agent) } }, c.icon("trash"), "Remove") : null,
180
+ ),
181
+ ), { interactive: false });
182
+ }
183
+
184
+ async function toggleAgent(ctx, agent) {
185
+ const res = await ctx.api.updateAgent(agent.id, { config: agent.config || {}, enabled: !agent.enabled });
186
+ ctx.toast(res && res.ok ? `${agent.name}: ${agent.enabled ? "disabled" : "enabled"}` : "Agent update unavailable", res && res.ok ? "ok" : "err");
187
+ if (res && res.ok) ctx.navigate("agents");
188
+ }
189
+
190
+ async function removeAgent(ctx, agent) {
191
+ const res = await ctx.api.removeAgent(agent.id);
192
+ ctx.toast(res && res.ok ? `Removed ${agent.name}` : "Agent remove unavailable", res && res.ok ? "ok" : "err");
193
+ if (res && res.ok) ctx.navigate("agents");
194
+ }
195
+
112
196
  /* ── Agent card ──────────────────────────────────────────────────────────── */
113
197
  function agentCard(ctx, agent, byId) {
114
198
  const { h, icon, c } = ctx;
@@ -158,6 +242,22 @@ function normalize(data) {
158
242
  }));
159
243
  }
160
244
 
245
+ function normalizeRegistry(data) {
246
+ const list = Array.isArray(data) ? data : (data && Array.isArray(data.agents) ? data.agents : []);
247
+ return list.map((agent, i) => ({
248
+ id: agent.id || `agent:${i}`,
249
+ name: agent.name || agent.id || `Agent ${i + 1}`,
250
+ type: agent.type || "custom",
251
+ version: agent.version || "1.0.0",
252
+ description: agent.description || "",
253
+ capabilities: Array.isArray(agent.capabilities) ? agent.capabilities : [],
254
+ source: agent.source || "user",
255
+ enabled: agent.enabled !== false,
256
+ removable: !!agent.removable,
257
+ config: agent.config || {},
258
+ }));
259
+ }
260
+
161
261
  const AVAILABLE_STATES = new Set(["available", "ready", "active", "ok", "idle"]);
162
262
  function isAvailable(state) {
163
263
  return AVAILABLE_STATES.has(String(state).toLowerCase());
@@ -52,7 +52,7 @@ export async function render(ctx) {
52
52
 
53
53
  const groundChip = (key, label, icn) => h("button.lt3-chip", {
54
54
  type: "button", dataset: { active: String(state.grounding[key]) }, "aria-pressed": String(state.grounding[key]),
55
- title: `Toggle ${label} grounding`,
55
+ title: `Show the ${label} signal in the retrieval-context panel`,
56
56
  on: { click: (e) => { state.grounding[key] = !state.grounding[key]; const b = e.currentTarget; b.dataset.active = String(state.grounding[key]); b.setAttribute("aria-pressed", String(state.grounding[key])); } },
57
57
  }, icon(icn), label);
58
58
 
@@ -52,7 +52,7 @@ export async function render(ctx) {
52
52
 
53
53
  const groundChip = (key, label, icn) => h("button.lt3-chip", {
54
54
  type: "button", dataset: { active: String(state.grounding[key]) }, "aria-pressed": String(state.grounding[key]),
55
- title: `Toggle ${label} grounding`,
55
+ title: `Show the ${label} signal in the retrieval-context panel`,
56
56
  on: { click: (e) => { state.grounding[key] = !state.grounding[key]; const b = e.currentTarget; b.dataset.active = String(state.grounding[key]); b.setAttribute("aria-pressed", String(state.grounding[key])); } },
57
57
  }, icon(icn), label);
58
58
 
@@ -61,11 +61,15 @@ function normalize(data) {
61
61
  }));
62
62
  }
63
63
 
64
+ /** Document types the backend accepts (latticeai/services/upload_service.py). */
65
+ const UPLOAD_ACCEPT = ".pdf,.docx,.xlsx,.pptx,.txt,.md,.csv";
66
+
64
67
  export async function render(ctx) {
65
68
  const { h, icon, api, c, navigate, toast } = ctx;
66
69
 
67
- // Folder connection/watch needs the desktop local-agent connector, which is
68
- // not enabled in this build. Say so plainly rather than implying it's coming.
70
+ // Connecting/watching a *folder* needs the desktop local-agent connector,
71
+ // which is not enabled in this build. Say so plainly. Manual document upload
72
+ // below works without it.
69
73
  const unavailableToast = () =>
70
74
  toast("Connecting a folder requires the Lattice desktop local agent — not available in this build.", "warn");
71
75
 
@@ -73,6 +77,56 @@ export async function render(ctx) {
73
77
  const srcSlot = h("span", c.sourceBadge("pending"));
74
78
  const tableHost = h("div", c.loading({ lines: 4 }));
75
79
 
80
+ // ── Manual upload (works in this build; no desktop agent required) ─────────
81
+ let busy = false;
82
+ const fileInput = h("input", {
83
+ type: "file", multiple: true, accept: UPLOAD_ACCEPT,
84
+ style: { display: "none" }, "aria-hidden": "true",
85
+ on: { change: (e) => uploadFiles(e.target.files) },
86
+ });
87
+ const pickFiles = () => { if (!busy) fileInput.click(); };
88
+ const slots = { statHost, srcSlot, tableHost, pickFiles };
89
+
90
+ async function uploadFiles(fileList) {
91
+ const files = Array.from(fileList || []);
92
+ if (!files.length || busy) return;
93
+ busy = true;
94
+ let ok = 0;
95
+ for (const file of files) {
96
+ toast(`Uploading “${file.name}”…`, "info");
97
+ const res = await api.uploadDocument(file);
98
+ if (res.ok && res.data && !res.data.detail && !res.data.error) {
99
+ ok++;
100
+ } else {
101
+ const detail = (res.data && (res.data.detail || res.data.error)) || "the backend is unavailable";
102
+ toast(`Could not ingest “${file.name}” — ${detail}.`, "warn");
103
+ }
104
+ }
105
+ fileInput.value = "";
106
+ busy = false;
107
+ if (ok) {
108
+ toast(`Indexed ${ok} document${ok === 1 ? "" : "s"} into the knowledge graph — now searchable in Chat and Hybrid Search.`, "ok");
109
+ }
110
+ hydrate(ctx, slots);
111
+ }
112
+
113
+ const dropZone = h("div.lt3-drop", {
114
+ on: {
115
+ dragover: (e) => { e.preventDefault(); dropZone.classList.add("is-dragover"); },
116
+ dragleave: () => dropZone.classList.remove("is-dragover"),
117
+ drop: (e) => { e.preventDefault(); dropZone.classList.remove("is-dragover"); uploadFiles(e.dataTransfer && e.dataTransfer.files); },
118
+ },
119
+ },
120
+ fileInput,
121
+ h("div.lt3-pillar__icon", icon("cloud-upload")),
122
+ h("div",
123
+ h("div", { style: { "font-weight": "var(--lt3-weight-semi)" } }, "Drag documents here, or upload manually"),
124
+ h("p.lt3-faint", { style: { "font-size": "var(--lt3-text-sm)", "margin-top": "var(--lt3-space-1)" } },
125
+ "Lattice parses each file, chunks it, embeds it, and links it into the knowledge graph. PDF · DOCX · XLSX · PPTX · TXT · MD · CSV, up to 10 MB each."),
126
+ ),
127
+ h("button.lt3-btn.lt3-btn--primary", { type: "button", on: { click: pickFiles } }, icon("upload"), "Upload files"),
128
+ );
129
+
76
130
  const root = h("div.lt3-stack-6",
77
131
  c.viewHeader({
78
132
  eyebrow: "Data",
@@ -80,19 +134,12 @@ export async function render(ctx) {
80
134
  sub: "Connected sources and the documents Lattice has indexed for retrieval. Everything stays on this machine.",
81
135
  actions: [
82
136
  h("button.lt3-btn.lt3-btn--ghost", { on: { click: () => navigate("knowledge-graph") } }, icon("chart-dots-3"), "View graph"),
137
+ h("button.lt3-btn.lt3-btn--primary", { on: { click: pickFiles } }, icon("upload"), "Upload files"),
83
138
  h("button.lt3-btn.lt3-btn--ghost", { title: "Requires the desktop local agent (not in this build)", on: { click: unavailableToast } }, icon("folder-plus"), "Connect folder"),
84
139
  ],
85
140
  }),
86
141
  statHost,
87
- h("div.lt3-drop",
88
- h("div.lt3-pillar__icon", icon("cloud-upload")),
89
- h("div",
90
- h("div", { style: { "font-weight": "var(--lt3-weight-semi)" } }, "Drag files or connect a folder"),
91
- h("p.lt3-faint", { style: { "font-size": "var(--lt3-text-sm)", "margin-top": "var(--lt3-space-1)" } },
92
- "Lattice watches the source, chunks it, embeds it, and links it into the knowledge graph."),
93
- ),
94
- h("button.lt3-btn.lt3-btn--ghost", { title: "Requires the desktop local agent (not in this build)", on: { click: unavailableToast } }, icon("folder-plus"), "Choose folder"),
95
- ),
142
+ dropZone,
96
143
  c.panel({
97
144
  head: h("div.lt3-row", { style: { "justify-content": "space-between", "align-items": "flex-start", width: "100%" } },
98
145
  h("div",
@@ -105,13 +152,13 @@ export async function render(ctx) {
105
152
  }),
106
153
  );
107
154
 
108
- hydrate(ctx, { statHost, srcSlot, tableHost });
155
+ hydrate(ctx, slots);
109
156
  return root;
110
157
  }
111
158
 
112
159
  async function hydrate(ctx, slots) {
113
160
  const { h, icon, api, c, toast } = ctx;
114
- const { statHost, srcSlot, tableHost } = slots;
161
+ const { statHost, srcSlot, tableHost, pickFiles } = slots;
115
162
 
116
163
  const probe = await api.get("/workspace/indexing", { sources: [], totals: {} });
117
164
  const liveFiles = probe.ok && probe.data ? normalize(probe.data) : null;
@@ -137,11 +184,10 @@ async function hydrate(ctx, slots) {
137
184
  tableHost.replaceChildren(c.emptyState({
138
185
  icon: "folder-off",
139
186
  title: "No documents indexed yet",
140
- body: "Connect a folder and Lattice will index it for hybrid retrieval.",
141
- action: h("button.lt3-btn.lt3-btn--ghost.lt3-btn--sm",
142
- { title: "Requires the desktop local agent (not in this build)",
143
- on: { click: () => toast("Connecting a folder requires the Lattice desktop local agent — not available in this build.", "warn") } },
144
- icon("folder-plus"), "Connect folder"),
187
+ body: "Upload a document and Lattice will parse, embed, and link it into the knowledge graph for hybrid retrieval.",
188
+ action: h("button.lt3-btn.lt3-btn--primary.lt3-btn--sm",
189
+ { on: { click: () => (pickFiles ? pickFiles() : null) } },
190
+ icon("upload"), "Upload files"),
145
191
  }));
146
192
  return;
147
193
  }
@@ -61,11 +61,15 @@ function normalize(data) {
61
61
  }));
62
62
  }
63
63
 
64
+ /** Document types the backend accepts (latticeai/services/upload_service.py). */
65
+ const UPLOAD_ACCEPT = ".pdf,.docx,.xlsx,.pptx,.txt,.md,.csv";
66
+
64
67
  export async function render(ctx) {
65
68
  const { h, icon, api, c, navigate, toast } = ctx;
66
69
 
67
- // Folder connection/watch needs the desktop local-agent connector, which is
68
- // not enabled in this build. Say so plainly rather than implying it's coming.
70
+ // Connecting/watching a *folder* needs the desktop local-agent connector,
71
+ // which is not enabled in this build. Say so plainly. Manual document upload
72
+ // below works without it.
69
73
  const unavailableToast = () =>
70
74
  toast("Connecting a folder requires the Lattice desktop local agent — not available in this build.", "warn");
71
75
 
@@ -73,6 +77,56 @@ export async function render(ctx) {
73
77
  const srcSlot = h("span", c.sourceBadge("pending"));
74
78
  const tableHost = h("div", c.loading({ lines: 4 }));
75
79
 
80
+ // ── Manual upload (works in this build; no desktop agent required) ─────────
81
+ let busy = false;
82
+ const fileInput = h("input", {
83
+ type: "file", multiple: true, accept: UPLOAD_ACCEPT,
84
+ style: { display: "none" }, "aria-hidden": "true",
85
+ on: { change: (e) => uploadFiles(e.target.files) },
86
+ });
87
+ const pickFiles = () => { if (!busy) fileInput.click(); };
88
+ const slots = { statHost, srcSlot, tableHost, pickFiles };
89
+
90
+ async function uploadFiles(fileList) {
91
+ const files = Array.from(fileList || []);
92
+ if (!files.length || busy) return;
93
+ busy = true;
94
+ let ok = 0;
95
+ for (const file of files) {
96
+ toast(`Uploading “${file.name}”…`, "info");
97
+ const res = await api.uploadDocument(file);
98
+ if (res.ok && res.data && !res.data.detail && !res.data.error) {
99
+ ok++;
100
+ } else {
101
+ const detail = (res.data && (res.data.detail || res.data.error)) || "the backend is unavailable";
102
+ toast(`Could not ingest “${file.name}” — ${detail}.`, "warn");
103
+ }
104
+ }
105
+ fileInput.value = "";
106
+ busy = false;
107
+ if (ok) {
108
+ toast(`Indexed ${ok} document${ok === 1 ? "" : "s"} into the knowledge graph — now searchable in Chat and Hybrid Search.`, "ok");
109
+ }
110
+ hydrate(ctx, slots);
111
+ }
112
+
113
+ const dropZone = h("div.lt3-drop", {
114
+ on: {
115
+ dragover: (e) => { e.preventDefault(); dropZone.classList.add("is-dragover"); },
116
+ dragleave: () => dropZone.classList.remove("is-dragover"),
117
+ drop: (e) => { e.preventDefault(); dropZone.classList.remove("is-dragover"); uploadFiles(e.dataTransfer && e.dataTransfer.files); },
118
+ },
119
+ },
120
+ fileInput,
121
+ h("div.lt3-pillar__icon", icon("cloud-upload")),
122
+ h("div",
123
+ h("div", { style: { "font-weight": "var(--lt3-weight-semi)" } }, "Drag documents here, or upload manually"),
124
+ h("p.lt3-faint", { style: { "font-size": "var(--lt3-text-sm)", "margin-top": "var(--lt3-space-1)" } },
125
+ "Lattice parses each file, chunks it, embeds it, and links it into the knowledge graph. PDF · DOCX · XLSX · PPTX · TXT · MD · CSV, up to 10 MB each."),
126
+ ),
127
+ h("button.lt3-btn.lt3-btn--primary", { type: "button", on: { click: pickFiles } }, icon("upload"), "Upload files"),
128
+ );
129
+
76
130
  const root = h("div.lt3-stack-6",
77
131
  c.viewHeader({
78
132
  eyebrow: "Data",
@@ -80,19 +134,12 @@ export async function render(ctx) {
80
134
  sub: "Connected sources and the documents Lattice has indexed for retrieval. Everything stays on this machine.",
81
135
  actions: [
82
136
  h("button.lt3-btn.lt3-btn--ghost", { on: { click: () => navigate("knowledge-graph") } }, icon("chart-dots-3"), "View graph"),
137
+ h("button.lt3-btn.lt3-btn--primary", { on: { click: pickFiles } }, icon("upload"), "Upload files"),
83
138
  h("button.lt3-btn.lt3-btn--ghost", { title: "Requires the desktop local agent (not in this build)", on: { click: unavailableToast } }, icon("folder-plus"), "Connect folder"),
84
139
  ],
85
140
  }),
86
141
  statHost,
87
- h("div.lt3-drop",
88
- h("div.lt3-pillar__icon", icon("cloud-upload")),
89
- h("div",
90
- h("div", { style: { "font-weight": "var(--lt3-weight-semi)" } }, "Drag files or connect a folder"),
91
- h("p.lt3-faint", { style: { "font-size": "var(--lt3-text-sm)", "margin-top": "var(--lt3-space-1)" } },
92
- "Lattice watches the source, chunks it, embeds it, and links it into the knowledge graph."),
93
- ),
94
- h("button.lt3-btn.lt3-btn--ghost", { title: "Requires the desktop local agent (not in this build)", on: { click: unavailableToast } }, icon("folder-plus"), "Choose folder"),
95
- ),
142
+ dropZone,
96
143
  c.panel({
97
144
  head: h("div.lt3-row", { style: { "justify-content": "space-between", "align-items": "flex-start", width: "100%" } },
98
145
  h("div",
@@ -105,13 +152,13 @@ export async function render(ctx) {
105
152
  }),
106
153
  );
107
154
 
108
- hydrate(ctx, { statHost, srcSlot, tableHost });
155
+ hydrate(ctx, slots);
109
156
  return root;
110
157
  }
111
158
 
112
159
  async function hydrate(ctx, slots) {
113
160
  const { h, icon, api, c, toast } = ctx;
114
- const { statHost, srcSlot, tableHost } = slots;
161
+ const { statHost, srcSlot, tableHost, pickFiles } = slots;
115
162
 
116
163
  const probe = await api.get("/workspace/indexing", { sources: [], totals: {} });
117
164
  const liveFiles = probe.ok && probe.data ? normalize(probe.data) : null;
@@ -137,11 +184,10 @@ async function hydrate(ctx, slots) {
137
184
  tableHost.replaceChildren(c.emptyState({
138
185
  icon: "folder-off",
139
186
  title: "No documents indexed yet",
140
- body: "Connect a folder and Lattice will index it for hybrid retrieval.",
141
- action: h("button.lt3-btn.lt3-btn--ghost.lt3-btn--sm",
142
- { title: "Requires the desktop local agent (not in this build)",
143
- on: { click: () => toast("Connecting a folder requires the Lattice desktop local agent — not available in this build.", "warn") } },
144
- icon("folder-plus"), "Connect folder"),
187
+ body: "Upload a document and Lattice will parse, embed, and link it into the knowledge graph for hybrid retrieval.",
188
+ action: h("button.lt3-btn.lt3-btn--primary.lt3-btn--sm",
189
+ { on: { click: () => (pickFiles ? pickFiles() : null) } },
190
+ icon("upload"), "Upload files"),
145
191
  }));
146
192
  return;
147
193
  }
@@ -0,0 +1,99 @@
1
+ /* ============================================================================
2
+ * View: Hooks — the lifecycle hooks registry.
3
+ * Reads /api/hooks (built-in + user hooks across pre_run/post_run/pre_tool/
4
+ * post_tool/agent/pipeline/workflow), toggles enabled state, reorders, and
5
+ * registers custom hooks. Built-in hooks are platform-managed and labelled.
6
+ * ========================================================================== */
7
+
8
+ const KIND_LABEL = {
9
+ pre_run: "Pre-run", post_run: "Post-run", pre_tool: "Pre-tool", post_tool: "Post-tool",
10
+ agent: "Agent", pipeline: "Pipeline", workflow: "Workflow",
11
+ };
12
+
13
+ export async function render(ctx) {
14
+ const { h, c } = ctx;
15
+ const src = h("span", c.sourceBadge("pending"));
16
+ const statHost = h("div.lt3-statrow", c.loading({ lines: 1 }));
17
+ const groupsHost = h("div", c.loading({ lines: 4, block: true }));
18
+
19
+ const nameInput = h("input.lt3-input", { type: "text", placeholder: "Hook name" });
20
+ const kindSelect = h("select.lt3-select", Object.keys(KIND_LABEL).map((k) => h("option", { value: k }, KIND_LABEL[k])));
21
+ const descInput = h("input.lt3-input", { type: "text", placeholder: "What it does (optional)" });
22
+
23
+ const root = h("div.lt3-stack-6",
24
+ c.viewHeader({
25
+ eyebrow: "Platform",
26
+ title: "Hooks",
27
+ sub: "Lifecycle extension points across runs, tools, agents, pipelines, and workflows — visible, ordered, and individually toggleable.",
28
+ actions: [src],
29
+ }),
30
+ statHost,
31
+ c.panel({
32
+ title: "Register a hook", sub: "Custom hooks are listed, ordered, and inspectable.",
33
+ children: h("div.lt3-stack-3",
34
+ h("div.lt3-grid-2", h("div.lt3-field", h("label", "Name"), nameInput), h("div.lt3-field", h("label", "Kind"), kindSelect)),
35
+ h("div.lt3-field", h("label", "Description"), descInput),
36
+ h("div.lt3-row-2", h("button.lt3-btn.lt3-btn--primary", { on: { click: register } }, c.icon("plus"), "Register hook")),
37
+ ),
38
+ }),
39
+ groupsHost,
40
+ );
41
+
42
+ load();
43
+ return root;
44
+
45
+ async function load() {
46
+ const res = await ctx.api.hooks();
47
+ src.replaceChildren(c.sourceBadge(res.source));
48
+ const hooks = (res.data && res.data.hooks) || [];
49
+ if (!hooks.length) {
50
+ statHost.replaceChildren(c.stat({ label: "Hooks", value: "—", icon: "webhook" }));
51
+ groupsHost.replaceChildren(c.emptyState({ icon: "webhook-off", title: "Hooks unavailable", body: "Start the backend to read the hooks registry." }));
52
+ return;
53
+ }
54
+ const en = hooks.filter((x) => x.enabled).length;
55
+ statHost.replaceChildren(
56
+ c.stat({ label: "Hooks", value: c.fmtNum(hooks.length), icon: "webhook" }),
57
+ c.stat({ label: "Enabled", value: c.fmtNum(en), icon: "circle-check" }),
58
+ c.stat({ label: "Kinds", value: c.fmtNum((res.data.kinds || []).length), icon: "layers" }),
59
+ );
60
+ const byKind = {};
61
+ for (const hk of hooks) (byKind[hk.kind] = byKind[hk.kind] || []).push(hk);
62
+ groupsHost.replaceChildren(h("div.lt3-stack-6", Object.keys(byKind).map((kind) =>
63
+ h("section", c.sectionHead(KIND_LABEL[kind] || kind, c.pill(String(byKind[kind].length))),
64
+ h("div.lt3-stack-2", byKind[kind].map((hk) => hookRow(ctx, hk)))))));
65
+ }
66
+
67
+ function hookRow(ctx2, hk) {
68
+ return c.card(h("div.lt3-row", { style: { "justify-content": "space-between", "align-items": "center", gap: "var(--lt3-space-3)" } },
69
+ h("div", { style: { "min-width": 0 } },
70
+ h("div.lt3-row-2", h("b", hk.name), c.pill(hk.source === "builtin" ? "built-in" : "custom", hk.source === "builtin" ? "info" : ""), hk.managed === "platform" ? c.pill("managed", "") : null),
71
+ h("p.lt3-muted", { style: { margin: "2px 0 0", "font-size": "var(--lt3-text-sm)" } }, hk.description || ""),
72
+ hk.binding ? h("div.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)", "font-family": "var(--lt3-font-mono)" } }, hk.binding) : null,
73
+ ),
74
+ h("div.lt3-row-2", { style: { "flex-shrink": 0 } },
75
+ h("span.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)" } }, `#${hk.order}`),
76
+ h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm", { on: { click: () => toggle(ctx2, hk) } }, c.icon(hk.enabled ? "toggle-right" : "toggle-left"), hk.enabled ? "On" : "Off"),
77
+ hk.removable ? h("button.lt3-btn.lt3-btn--ghost.lt3-btn--sm", { on: { click: () => remove(ctx2, hk) } }, c.icon("trash")) : null,
78
+ ),
79
+ ), { flat: true });
80
+ }
81
+
82
+ async function toggle(ctx2, hk) {
83
+ const res = hk.enabled ? await ctx2.api.hookDisable(hk.id) : await ctx2.api.hookEnable(hk.id, true);
84
+ ctx2.toast(res && res.ok ? `${hk.name}: ${hk.enabled ? "disabled" : "enabled"}` : "Action unavailable", res && res.ok ? "ok" : "err");
85
+ load();
86
+ }
87
+ async function remove(ctx2, hk) {
88
+ const res = await ctx2.api.hookRemove(hk.id);
89
+ ctx2.toast(res && res.ok ? `Removed ${hk.name}` : "Remove unavailable", res && res.ok ? "ok" : "err");
90
+ load();
91
+ }
92
+ async function register() {
93
+ const name = nameInput.value.trim();
94
+ if (!name) { ctx.toast("Enter a hook name", "info"); return; }
95
+ const res = await ctx.api.hookRegister({ name, kind: kindSelect.value, description: descInput.value.trim() });
96
+ if (res && res.ok) { ctx.toast(`Registered ${name}`, "ok"); nameInput.value = ""; descInput.value = ""; load(); }
97
+ else { ctx.toast("Register unavailable", "err"); }
98
+ }
99
+ }
@@ -0,0 +1,99 @@
1
+ /* ============================================================================
2
+ * View: Hooks — the lifecycle hooks registry.
3
+ * Reads /api/hooks (built-in + user hooks across pre_run/post_run/pre_tool/
4
+ * post_tool/agent/pipeline/workflow), toggles enabled state, reorders, and
5
+ * registers custom hooks. Built-in hooks are platform-managed and labelled.
6
+ * ========================================================================== */
7
+
8
+ const KIND_LABEL = {
9
+ pre_run: "Pre-run", post_run: "Post-run", pre_tool: "Pre-tool", post_tool: "Post-tool",
10
+ agent: "Agent", pipeline: "Pipeline", workflow: "Workflow",
11
+ };
12
+
13
+ export async function render(ctx) {
14
+ const { h, c } = ctx;
15
+ const src = h("span", c.sourceBadge("pending"));
16
+ const statHost = h("div.lt3-statrow", c.loading({ lines: 1 }));
17
+ const groupsHost = h("div", c.loading({ lines: 4, block: true }));
18
+
19
+ const nameInput = h("input.lt3-input", { type: "text", placeholder: "Hook name" });
20
+ const kindSelect = h("select.lt3-select", Object.keys(KIND_LABEL).map((k) => h("option", { value: k }, KIND_LABEL[k])));
21
+ const descInput = h("input.lt3-input", { type: "text", placeholder: "What it does (optional)" });
22
+
23
+ const root = h("div.lt3-stack-6",
24
+ c.viewHeader({
25
+ eyebrow: "Platform",
26
+ title: "Hooks",
27
+ sub: "Lifecycle extension points across runs, tools, agents, pipelines, and workflows — visible, ordered, and individually toggleable.",
28
+ actions: [src],
29
+ }),
30
+ statHost,
31
+ c.panel({
32
+ title: "Register a hook", sub: "Custom hooks are listed, ordered, and inspectable.",
33
+ children: h("div.lt3-stack-3",
34
+ h("div.lt3-grid-2", h("div.lt3-field", h("label", "Name"), nameInput), h("div.lt3-field", h("label", "Kind"), kindSelect)),
35
+ h("div.lt3-field", h("label", "Description"), descInput),
36
+ h("div.lt3-row-2", h("button.lt3-btn.lt3-btn--primary", { on: { click: register } }, c.icon("plus"), "Register hook")),
37
+ ),
38
+ }),
39
+ groupsHost,
40
+ );
41
+
42
+ load();
43
+ return root;
44
+
45
+ async function load() {
46
+ const res = await ctx.api.hooks();
47
+ src.replaceChildren(c.sourceBadge(res.source));
48
+ const hooks = (res.data && res.data.hooks) || [];
49
+ if (!hooks.length) {
50
+ statHost.replaceChildren(c.stat({ label: "Hooks", value: "—", icon: "webhook" }));
51
+ groupsHost.replaceChildren(c.emptyState({ icon: "webhook-off", title: "Hooks unavailable", body: "Start the backend to read the hooks registry." }));
52
+ return;
53
+ }
54
+ const en = hooks.filter((x) => x.enabled).length;
55
+ statHost.replaceChildren(
56
+ c.stat({ label: "Hooks", value: c.fmtNum(hooks.length), icon: "webhook" }),
57
+ c.stat({ label: "Enabled", value: c.fmtNum(en), icon: "circle-check" }),
58
+ c.stat({ label: "Kinds", value: c.fmtNum((res.data.kinds || []).length), icon: "layers" }),
59
+ );
60
+ const byKind = {};
61
+ for (const hk of hooks) (byKind[hk.kind] = byKind[hk.kind] || []).push(hk);
62
+ groupsHost.replaceChildren(h("div.lt3-stack-6", Object.keys(byKind).map((kind) =>
63
+ h("section", c.sectionHead(KIND_LABEL[kind] || kind, c.pill(String(byKind[kind].length))),
64
+ h("div.lt3-stack-2", byKind[kind].map((hk) => hookRow(ctx, hk)))))));
65
+ }
66
+
67
+ function hookRow(ctx2, hk) {
68
+ return c.card(h("div.lt3-row", { style: { "justify-content": "space-between", "align-items": "center", gap: "var(--lt3-space-3)" } },
69
+ h("div", { style: { "min-width": 0 } },
70
+ h("div.lt3-row-2", h("b", hk.name), c.pill(hk.source === "builtin" ? "built-in" : "custom", hk.source === "builtin" ? "info" : ""), hk.managed === "platform" ? c.pill("managed", "") : null),
71
+ h("p.lt3-muted", { style: { margin: "2px 0 0", "font-size": "var(--lt3-text-sm)" } }, hk.description || ""),
72
+ hk.binding ? h("div.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)", "font-family": "var(--lt3-font-mono)" } }, hk.binding) : null,
73
+ ),
74
+ h("div.lt3-row-2", { style: { "flex-shrink": 0 } },
75
+ h("span.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)" } }, `#${hk.order}`),
76
+ h("button.lt3-btn.lt3-btn--subtle.lt3-btn--sm", { on: { click: () => toggle(ctx2, hk) } }, c.icon(hk.enabled ? "toggle-right" : "toggle-left"), hk.enabled ? "On" : "Off"),
77
+ hk.removable ? h("button.lt3-btn.lt3-btn--ghost.lt3-btn--sm", { on: { click: () => remove(ctx2, hk) } }, c.icon("trash")) : null,
78
+ ),
79
+ ), { flat: true });
80
+ }
81
+
82
+ async function toggle(ctx2, hk) {
83
+ const res = hk.enabled ? await ctx2.api.hookDisable(hk.id) : await ctx2.api.hookEnable(hk.id, true);
84
+ ctx2.toast(res && res.ok ? `${hk.name}: ${hk.enabled ? "disabled" : "enabled"}` : "Action unavailable", res && res.ok ? "ok" : "err");
85
+ load();
86
+ }
87
+ async function remove(ctx2, hk) {
88
+ const res = await ctx2.api.hookRemove(hk.id);
89
+ ctx2.toast(res && res.ok ? `Removed ${hk.name}` : "Remove unavailable", res && res.ok ? "ok" : "err");
90
+ load();
91
+ }
92
+ async function register() {
93
+ const name = nameInput.value.trim();
94
+ if (!name) { ctx.toast("Enter a hook name", "info"); return; }
95
+ const res = await ctx.api.hookRegister({ name, kind: kindSelect.value, description: descInput.value.trim() });
96
+ if (res && res.ok) { ctx.toast(`Registered ${name}`, "ok"); nameInput.value = ""; descInput.value = ""; load(); }
97
+ else { ctx.toast("Register unavailable", "err"); }
98
+ }
99
+ }