ltcai 3.0.1 → 3.2.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 (123) hide show
  1. package/README.md +54 -21
  2. package/docs/CHANGELOG.md +90 -0
  3. package/docs/V3_2_AUDIT.md +82 -0
  4. package/docs/V3_FRONTEND.md +20 -17
  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/auth.py +4 -1
  9. package/latticeai/api/hooks.py +113 -0
  10. package/latticeai/api/marketplace.py +13 -0
  11. package/latticeai/api/memory.py +109 -0
  12. package/latticeai/api/search.py +4 -0
  13. package/latticeai/core/agent_registry.py +234 -0
  14. package/latticeai/core/config.py +2 -0
  15. package/latticeai/core/embedding_providers.py +123 -0
  16. package/latticeai/core/hooks.py +284 -0
  17. package/latticeai/core/marketplace.py +87 -2
  18. package/latticeai/core/multi_agent.py +1 -1
  19. package/latticeai/core/workspace_os.py +1 -1
  20. package/latticeai/server_app.py +63 -6
  21. package/latticeai/services/memory_service.py +324 -0
  22. package/package.json +9 -4
  23. package/scripts/build_v3_assets.mjs +164 -0
  24. package/scripts/capture/README.md +28 -0
  25. package/scripts/capture/capture_enterprise.js +8 -0
  26. package/scripts/capture/capture_graph.js +8 -0
  27. package/scripts/capture/capture_onboarding.js +8 -0
  28. package/scripts/capture/capture_page.js +43 -0
  29. package/scripts/capture/capture_release_media.js +125 -0
  30. package/scripts/capture/capture_skills.js +8 -0
  31. package/scripts/capture/capture_workspace.js +8 -0
  32. package/scripts/generate_diagrams.py +513 -0
  33. package/scripts/lint_v3.mjs +33 -0
  34. package/scripts/release-0.3.1.sh +105 -0
  35. package/scripts/take_screenshots.js +69 -0
  36. package/scripts/validate_release_artifacts.py +167 -0
  37. package/static/account.html +9 -9
  38. package/static/activity.html +4 -4
  39. package/static/admin.html +8 -8
  40. package/static/agents.html +4 -4
  41. package/static/chat.html +9 -9
  42. package/static/css/tokens.5a595671.css +260 -0
  43. package/static/css/tokens.css +1 -1
  44. package/static/graph.html +9 -9
  45. package/static/plugins.html +4 -4
  46. package/static/sw.js +3 -1
  47. package/static/v3/asset-manifest.json +55 -0
  48. package/static/v3/css/lattice.base.e4cdd05d.css +128 -0
  49. package/static/v3/css/lattice.components.011e988b.css +447 -0
  50. package/static/v3/css/lattice.components.css +2 -2
  51. package/static/v3/css/lattice.shell.4920f42d.css +407 -0
  52. package/static/v3/css/lattice.tokens.c597ff81.css +132 -0
  53. package/static/v3/css/lattice.views.3ee19d4e.css +277 -0
  54. package/static/v3/index.html +38 -9
  55. package/static/v3/js/app.a5adc0f3.js +26 -0
  56. package/static/v3/js/core/api.603b978f.js +408 -0
  57. package/static/v3/js/core/api.js +132 -51
  58. package/static/v3/js/core/components.4c83e0a9.js +222 -0
  59. package/static/v3/js/core/components.js +9 -2
  60. package/static/v3/js/core/dom.a2773eb0.js +148 -0
  61. package/static/v3/js/core/router.584570f2.js +37 -0
  62. package/static/v3/js/core/routes.07ad6696.js +89 -0
  63. package/static/v3/js/core/routes.js +17 -1
  64. package/static/v3/js/core/shell.ea0b9ae5.js +363 -0
  65. package/static/v3/js/core/store.34ebd5e6.js +113 -0
  66. package/static/v3/js/views/admin-audit.660a1fb1.js +185 -0
  67. package/static/v3/js/views/admin-audit.js +1 -1
  68. package/static/v3/js/views/admin-permissions.a7ae5f09.js +177 -0
  69. package/static/v3/js/views/admin-permissions.js +4 -5
  70. package/static/v3/js/views/admin-policies.3658fd86.js +102 -0
  71. package/static/v3/js/views/admin-policies.js +4 -5
  72. package/static/v3/js/views/admin-private-vpc.7d342d36.js +135 -0
  73. package/static/v3/js/views/admin-private-vpc.js +2 -5
  74. package/static/v3/js/views/admin-security.07c66b72.js +180 -0
  75. package/static/v3/js/views/admin-security.js +4 -5
  76. package/static/v3/js/views/admin-users.03bac88c.js +168 -0
  77. package/static/v3/js/views/admin-users.js +6 -6
  78. package/static/v3/js/views/agents.c373d48c.js +293 -0
  79. package/static/v3/js/views/agents.js +101 -2
  80. package/static/v3/js/views/chat.718144ce.js +449 -0
  81. package/static/v3/js/views/chat.js +2 -3
  82. package/static/v3/js/views/files.4935197e.js +186 -0
  83. package/static/v3/js/views/files.js +27 -21
  84. package/static/v3/js/views/home.cdde3b32.js +119 -0
  85. package/static/v3/js/views/hooks.f3edebca.js +99 -0
  86. package/static/v3/js/views/hooks.js +99 -0
  87. package/static/v3/js/views/hybrid-search.b22b97e0.js +195 -0
  88. package/static/v3/js/views/hybrid-search.js +1 -1
  89. package/static/v3/js/views/knowledge-graph.a14ea7e7.js +237 -0
  90. package/static/v3/js/views/knowledge-graph.js +2 -3
  91. package/static/v3/js/views/marketplace.ab0583d4.js +141 -0
  92. package/static/v3/js/views/marketplace.js +141 -0
  93. package/static/v3/js/views/mcp.99b5c6a7.js +114 -0
  94. package/static/v3/js/views/mcp.js +114 -0
  95. package/static/v3/js/views/memory.d2ed7a7c.js +146 -0
  96. package/static/v3/js/views/memory.js +146 -0
  97. package/static/v3/js/views/models.a1ffa147.js +256 -0
  98. package/static/v3/js/views/models.js +17 -8
  99. package/static/v3/js/views/my-computer.1b2ff621.js +237 -0
  100. package/static/v3/js/views/my-computer.js +5 -5
  101. package/static/v3/js/views/pipeline.c522f1ce.js +157 -0
  102. package/static/v3/js/views/pipeline.js +3 -7
  103. package/static/v3/js/views/planning.9ac3e313.js +153 -0
  104. package/static/v3/js/views/planning.js +153 -0
  105. package/static/v3/js/views/settings.4f777210.js +250 -0
  106. package/static/v3/js/views/settings.js +6 -14
  107. package/static/v3/js/views/skills.c6c2f965.js +109 -0
  108. package/static/v3/js/views/skills.js +109 -0
  109. package/static/v3/js/views/tools.e4f11276.js +108 -0
  110. package/static/v3/js/views/tools.js +108 -0
  111. package/static/v3/js/views/workflows.26c57290.js +128 -0
  112. package/static/v3/js/views/workflows.js +128 -0
  113. package/static/workflows.html +4 -4
  114. package/static/workspace.html +5 -5
  115. package/docs/images/tmp_frames/frame_00.png +0 -0
  116. package/docs/images/tmp_frames/frame_01.png +0 -0
  117. package/docs/images/tmp_frames/frame_02.png +0 -0
  118. package/docs/images/tmp_frames/frame_03.png +0 -0
  119. package/docs/images/tmp_frames/hero_00.png +0 -0
  120. package/docs/images/tmp_frames/hero_01.png +0 -0
  121. package/docs/images/tmp_frames/hero_02.png +0 -0
  122. package/docs/images/tmp_frames/hero_03.png +0 -0
  123. package/static/v3/js/core/fixtures.js +0 -171
@@ -1,15 +1,14 @@
1
1
  /* ============================================================================
2
2
  * View: Files — connected sources & indexed documents.
3
- * Lists the documents the workspace has ingested, with a human-readable size
4
- * roll-up and per-file index state. Data comes from /local/list (live) and
5
- * degrades to clearly-badged sample files when the local agent isn't reachable.
3
+ * Lists the sources the workspace has indexed, with a human-readable status
4
+ * roll-up. Data comes from /workspace/indexing (live); when indexing is
5
+ * unavailable, the table renders an empty unavailable state.
6
6
  *
7
7
  * View contract (shared by all views):
8
8
  * export async function render(ctx) -> single DOM node
9
9
  * ctx = { h, icon, api, store, c, route, params, navigate, toast }
10
10
  * ========================================================================== */
11
11
 
12
- import * as fx from "../core/fixtures.js";
13
12
  import { timeAgo } from "../core/dom.js";
14
13
 
15
14
  /** Tabler glyph per file kind — keeps the table scannable. */
@@ -24,6 +23,7 @@ const iconForKind = (k) => KIND_ICON[k] || KIND_ICON.default;
24
23
 
25
24
  /** Bytes → compact human string (1.0 KB / 4.7 KB / 180 KB / 1.2 MB). */
26
25
  function humanSize(bytes) {
26
+ if (bytes === null || bytes === undefined || bytes === "") return "—";
27
27
  const n = Number(bytes);
28
28
  if (!Number.isFinite(n) || n < 0) return "—";
29
29
  if (n < 1024) return `${n} B`;
@@ -33,8 +33,20 @@ function humanSize(bytes) {
33
33
  return `${v.toFixed(v >= 100 || Number.isInteger(v) ? 0 : 1)} ${units[i]}`;
34
34
  }
35
35
 
36
- /** Live shape may be {files:[...]} or a bare array — normalize defensively. */
36
+ /** Live shape is {sources:[...]}; legacy {files:[...]} payloads normalize too. */
37
37
  function normalize(data) {
38
+ if (data && Array.isArray(data.sources)) {
39
+ return data.sources.map((source) => ({
40
+ name: source.label || source.id || "local source",
41
+ kind: "default",
42
+ size: null,
43
+ path: source.root_path || source.id || "",
44
+ indexed: Number(source.success_count || 0) > 0,
45
+ updated: source.last_run_at || source.updated_at || null,
46
+ count: Number(source.success_count || 0),
47
+ status: source.status || (source.watch_active ? "watching" : "idle"),
48
+ }));
49
+ }
38
50
  const list = Array.isArray(data) ? data : (data && Array.isArray(data.files) ? data.files : null);
39
51
  if (!list) return null;
40
52
  return list.map((f) => ({
@@ -44,6 +56,8 @@ function normalize(data) {
44
56
  path: f.path || f.name || "",
45
57
  indexed: f.indexed === true,
46
58
  updated: f.updated || f.modified || f.mtime || null,
59
+ count: Number(f.count || 0),
60
+ status: f.status || null,
47
61
  }));
48
62
  }
49
63
 
@@ -99,16 +113,10 @@ async function hydrate(ctx, slots) {
99
113
  const { h, icon, api, c, toast } = ctx;
100
114
  const { statHost, srcSlot, tableHost } = slots;
101
115
 
102
- // /local/list is permission-gated: it requires a `path` query param and, in
103
- // the browser, returns a permission-request object rather than a bare file
104
- // list. Probe it with the required param (avoids a 422) and only treat an
105
- // actual listing as live — otherwise show clearly-badged sample documents.
106
- const probe = await api.raw("/local/list?path=" + encodeURIComponent("."));
107
- const liveFiles = probe.ok && probe.data && !probe.data.permission_required
108
- ? normalize(probe.data)
109
- : null;
110
- const source = liveFiles ? "live" : "placeholder";
111
- const files = liveFiles || normalize(fx.FILES) || [];
116
+ const probe = await api.get("/workspace/indexing", { sources: [], totals: {} });
117
+ const liveFiles = probe.ok && probe.data ? normalize(probe.data) : null;
118
+ const source = probe.source || (liveFiles ? "live" : "unavailable");
119
+ const files = liveFiles || [];
112
120
  srcSlot.replaceChildren(c.sourceBadge(source));
113
121
 
114
122
  // ── Stat roll-up ──────────────────────────────────────────────────────────
@@ -152,14 +160,12 @@ async function hydrate(ctx, slots) {
152
160
  render: (row) => h("span.lt3-mono.lt3-faint", row.path || "—"),
153
161
  },
154
162
  {
155
- key: "size", label: "Size", width: "92px",
156
- render: (row) => h("span.lt3-mono", humanSize(row.size)),
163
+ key: "count", label: "Indexed", width: "92px",
164
+ render: (row) => h("span.lt3-mono", row.count ? c.fmtNum(row.count) : humanSize(row.size)),
157
165
  },
158
166
  {
159
- key: "indexed", label: "Indexed", width: "120px",
160
- render: (row) => row.indexed
161
- ? c.statePill("indexed")
162
- : c.statePill("pending"),
167
+ key: "status", label: "Status", width: "120px",
168
+ render: (row) => c.statePill(row.indexed ? "indexed" : (row.status || "pending")),
163
169
  },
164
170
  {
165
171
  key: "updated", label: "Updated", width: "104px",
@@ -0,0 +1,119 @@
1
+ /* ============================================================================
2
+ * View: Home — the workspace command center.
3
+ * Leads with the product identity (the retrieval lattice: Knowledge Graph +
4
+ * Vector Index + Hybrid Search) and routes into every primary area.
5
+ *
6
+ * View contract (shared by all views):
7
+ * export async function render(ctx) -> single DOM node
8
+ * ctx = { h, icon, api, store, c, route, params, navigate, toast }
9
+ * ========================================================================== */
10
+
11
+ export async function render(ctx) {
12
+ const { h, icon, api, store, c, navigate } = ctx;
13
+ const ws = store.activeWorkspace();
14
+
15
+ const root = h("div.lt3-stack-6",
16
+ c.viewHeader({
17
+ eyebrow: "Local-first AI workspace",
18
+ title: `Welcome to ${ws.name}`,
19
+ sub: "Everything you index stays on this machine. Ask questions, explore the graph, and fuse structure with semantics — no data leaves your computer.",
20
+ actions: [
21
+ h("button.lt3-btn.lt3-btn--ghost", { on: { click: () => navigate("hybrid-search") } }, icon("arrows-join"), "Hybrid search"),
22
+ h("button.lt3-btn.lt3-btn--primary", { on: { click: () => navigate("chat", { new: "1" }) } }, icon("message-plus"), "New chat"),
23
+ ],
24
+ }),
25
+ buildHero(ctx),
26
+ h("section",
27
+ c.sectionHead("Retrieval lattice", h("span", { id: "home-idx-src" }, c.sourceBadge("pending"))),
28
+ h("div", { id: "home-pillars" }, c.loading({ lines: 2, block: true })),
29
+ ),
30
+ h("section",
31
+ c.sectionHead("Jump back in"),
32
+ buildQuickGrid(ctx),
33
+ ),
34
+ h("div.lt3-grid-2",
35
+ c.panel({ eyebrow: "Index", title: "Connected sources", children: h("div", { id: "home-sources" }, c.loading({ lines: 3 })) }),
36
+ c.panel({ eyebrow: "Workspace", title: "At a glance", children: h("div", { id: "home-stats" }, c.loading({ lines: 3 })) }),
37
+ ),
38
+ );
39
+
40
+ hydrate(ctx, root);
41
+ return root;
42
+ }
43
+
44
+ function buildHero({ h, icon, navigate }) {
45
+ return h("div.lt3-hero",
46
+ h("div.lt3-eyebrow.lt3-hero__eyebrow", icon("sparkles"), "Knowledge Graph · Vector Index · Hybrid Search"),
47
+ h("h2.lt3-hero__title", "One workspace. Three ways to recall everything."),
48
+ h("p.lt3-hero__sub", "Lattice builds a knowledge graph and a vector field from your files, then fuses them so every answer is grounded in both structure and meaning."),
49
+ h("div.lt3-hero__actions",
50
+ h("button.lt3-btn.lt3-btn--primary.lt3-btn--lg", { on: { click: () => navigate("knowledge-graph") } }, icon("chart-dots-3"), "Explore the graph"),
51
+ h("button.lt3-btn.lt3-btn--ghost.lt3-btn--lg", { on: { click: () => navigate("files") } }, icon("folder-plus"), "Connect files"),
52
+ ),
53
+ );
54
+ }
55
+
56
+ const QUICK = [
57
+ { key: "knowledge-graph", icon: "chart-dots-3", title: "Knowledge Graph", desc: "Browse entities and relations." },
58
+ { key: "hybrid-search", icon: "arrows-join", title: "Hybrid Search", desc: "Fuse graph + vector recall." },
59
+ { key: "chat", icon: "message-2", title: "Chat", desc: "Grounded conversation." },
60
+ { key: "files", icon: "folders", title: "Files", desc: "Sources and indexing." },
61
+ { key: "pipeline", icon: "git-branch", title: "Pipeline", desc: "Ingest and embed flows." },
62
+ { key: "models", icon: "cpu", title: "Models", desc: "Local MLX runtime." },
63
+ ];
64
+
65
+ function buildQuickGrid({ h, icon, navigate }) {
66
+ return h("div.lt3-quickgrid",
67
+ QUICK.map((q) => h("button.lt3-quick", { style: { "text-align": "left" }, on: { click: () => navigate(q.key) } },
68
+ h("div.lt3-quick__icon", icon(q.icon)),
69
+ h("div.lt3-quick__title", q.title),
70
+ h("div.lt3-quick__desc", q.desc),
71
+ )),
72
+ );
73
+ }
74
+
75
+ async function hydrate(ctx, root) {
76
+ const { h, icon, api, store, c } = ctx;
77
+ const numFmt = c.fmtNum;
78
+
79
+ // Index status → pillars + sources + topbar chip.
80
+ const idx = store.get().indexStatus
81
+ ? { data: store.get().indexStatus, source: "live" }
82
+ : await api.indexStatus().then((r) => { store.setIndexStatus(r.data); return r; });
83
+
84
+ root.querySelector("#home-idx-src")?.replaceChildren(c.sourceBadge(idx.source));
85
+ root.querySelector("#home-pillars")?.replaceChildren(c.pillars(idx.data));
86
+
87
+ const sources = (idx.data && idx.data.sources) || [];
88
+ const srcHost = root.querySelector("#home-sources");
89
+ if (srcHost) {
90
+ srcHost.replaceChildren(
91
+ sources.length
92
+ ? h("div.lt3-stack-3", sources.map((s) => h("div.lt3-stack-2",
93
+ h("div.lt3-row", { style: { "justify-content": "space-between" } },
94
+ h("div.lt3-row-2", icon("database"), h("b", { style: { "font-size": "var(--lt3-text-sm)" } }, s.label)),
95
+ c.statePill(s.state),
96
+ ),
97
+ c.meter(s.progress ?? (s.state === "indexed" ? 1 : 0.5), s.state === "indexing" ? "warn" : "vector"),
98
+ h("div.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)" } }, `${numFmt(s.files)} files`),
99
+ )))
100
+ : c.emptyState({ icon: "database-off", title: "No sources connected", body: "Connect a folder to start indexing." }),
101
+ );
102
+ }
103
+
104
+ // Workspace counts.
105
+ const os = await api.workspaceOs();
106
+ const counts = (os.data && os.data.counts) || {};
107
+ const statHost = root.querySelector("#home-stats");
108
+ if (statHost) {
109
+ statHost.replaceChildren(
110
+ h("div.lt3-statrow",
111
+ c.stat({ label: "Memories", value: numFmt(counts.memories), icon: "brain" }),
112
+ c.stat({ label: "Traces", value: numFmt(counts.traces), icon: "route" }),
113
+ c.stat({ label: "Workflows", value: numFmt(counts.workflows), icon: "git-branch" }),
114
+ c.stat({ label: "Skills", value: numFmt(counts.skills), icon: "puzzle" }),
115
+ ),
116
+ h("div", { style: { "margin-top": "var(--lt3-space-3)" } }, c.sourceBadge(os.source)),
117
+ );
118
+ }
119
+ }
@@ -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
+ }
@@ -0,0 +1,195 @@
1
+ /* ============================================================================
2
+ * View: Hybrid Search — the fused-retrieval surface (headline capability).
3
+ * Runs api.hybridSearch(query, {weights}) and shows, per result, how keyword,
4
+ * local vector and graph signals combine into the fused score. Missing
5
+ * endpoints render an unavailable state.
6
+ * ========================================================================== */
7
+
8
+ const MODES = [
9
+ { key: "hybrid", label: "Hybrid" },
10
+ { key: "vector", label: "Vector" },
11
+ { key: "graph", label: "Graph" },
12
+ { key: "keyword", label: "Keyword" },
13
+ ];
14
+
15
+ const MODE_WEIGHTS = {
16
+ hybrid: { keyword: 0.35, vector: 0.40, graph: 0.25 },
17
+ vector: { keyword: 0, vector: 1, graph: 0 },
18
+ graph: { keyword: 0, vector: 0, graph: 1 },
19
+ keyword: { keyword: 1, vector: 0, graph: 0 },
20
+ };
21
+
22
+ const EXAMPLES = ["retrieval design", "vector index config", "rank fusion", "graph adjacency"];
23
+
24
+ const SIGNALS = [
25
+ { key: "vector", label: "Vector", variant: "vector", icon: "grid-dots", desc: "Local vector similarity from the configured embedding index." },
26
+ { key: "keyword", label: "Keyword", variant: "", icon: "abc", desc: "Lexical overlap — exact terms and phrases." },
27
+ { key: "graph", label: "Graph", variant: "graph", icon: "chart-dots-3", desc: "Structural proximity in the knowledge graph." },
28
+ ];
29
+
30
+ export async function render(ctx) {
31
+ const { h, icon, api, store, c } = ctx;
32
+
33
+ const state = { query: "", mode: "hybrid", source: "pending" };
34
+
35
+ let activeWeights = MODE_WEIGHTS.hybrid;
36
+ api.indexStatus().then((r) => { if (r.data) store.setIndexStatus(r.data); });
37
+
38
+ const input = h("input", {
39
+ type: "text", placeholder: "Search your workspace…", "aria-label": "Search query",
40
+ on: { keydown: (e) => { if (e.key === "Enter") run(input.value); } },
41
+ });
42
+ const weightPill = h("span", c.pill(weightLabel(activeWeights), "info"));
43
+ const srcSlot = h("span", c.sourceBadge("pending"));
44
+ const resultsHost = h("div.lt3-stack-6", introBlock());
45
+
46
+ const seg = h("div.lt3-fusion", { role: "tablist", "aria-label": "Fusion mode" },
47
+ MODES.map((m) => h("button", {
48
+ type: "button", role: "tab",
49
+ dataset: { active: String(m.key === state.mode) },
50
+ "aria-selected": String(m.key === state.mode),
51
+ on: { click: () => { state.mode = m.key; syncSeg(); if (state.query) run(state.query); } },
52
+ }, m.label)),
53
+ );
54
+ function syncSeg() {
55
+ seg.querySelectorAll("button").forEach((b, i) => {
56
+ const on = MODES[i].key === state.mode;
57
+ b.dataset.active = String(on); b.setAttribute("aria-selected", String(on));
58
+ });
59
+ }
60
+
61
+ const root = h("div.lt3-stack-6",
62
+ c.viewHeader({
63
+ eyebrow: "Retrieval · fusion",
64
+ title: "Hybrid Search",
65
+ sub: "Fuse keyword recall, local vector similarity, and knowledge-graph structure. Each result shows the contributing signals behind its rank.",
66
+ actions: [srcSlot],
67
+ }),
68
+ h("section.lt3-search-hero",
69
+ h("div.lt3-row-2", { style: { "align-items": "stretch" } },
70
+ h("div.lt3-search", { style: { flex: "1", height: "46px" } }, icon("search"), input),
71
+ h("button.lt3-btn.lt3-btn--primary.lt3-btn--lg", { on: { click: () => run(input.value) } }, icon("arrows-join"), "Search"),
72
+ ),
73
+ h("div.lt3-row", { style: { "justify-content": "space-between", "flex-wrap": "wrap", gap: "var(--lt3-space-3)" } },
74
+ seg,
75
+ h("div.lt3-row-2", h("span.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)" } }, "Weights"), weightPill),
76
+ ),
77
+ h("div.lt3-cluster",
78
+ h("span.lt3-faint", { style: { "font-size": "var(--lt3-text-2xs)" } }, "Try"),
79
+ EXAMPLES.map((q) => h("button.lt3-chip", { type: "button", on: { click: () => run(q) } }, icon("search"), q)),
80
+ ),
81
+ ),
82
+ resultsHost,
83
+ );
84
+
85
+ /* ── search flow ───────────────────────────────────────────────────────── */
86
+ async function run(rawQuery) {
87
+ const q = String(rawQuery || "").trim();
88
+ if (!q) { input.focus(); return; }
89
+ state.query = q;
90
+ if (input.value !== q) input.value = q;
91
+ resultsHost.replaceChildren(
92
+ c.sectionHead(`Results for “${q}”`, srcSlot.cloneNode(true)),
93
+ c.loading({ lines: 4 }),
94
+ );
95
+ activeWeights = MODE_WEIGHTS[state.mode] || MODE_WEIGHTS.hybrid;
96
+ weightPill.replaceChildren(c.pill(weightLabel(activeWeights), "info"));
97
+ const res = await api.hybridSearch(q, { weights: activeWeights });
98
+ if (res.weights) {
99
+ activeWeights = res.weights;
100
+ weightPill.replaceChildren(c.pill(weightLabel(activeWeights), "info"));
101
+ }
102
+ state.source = res.source;
103
+ srcSlot.replaceChildren(c.sourceBadge(res.source));
104
+ renderResults(res);
105
+ }
106
+
107
+ function renderResults(res) {
108
+ if (!res.ok) {
109
+ resultsHost.replaceChildren(c.errorState(res.error || "Search failed", () => run(state.query)));
110
+ return;
111
+ }
112
+ const rows = Array.isArray(res.data) ? res.data : [];
113
+ if (!rows.length) {
114
+ resultsHost.replaceChildren(
115
+ c.sectionHead(`No results for “${state.query}”`, c.sourceBadge(res.source)),
116
+ c.emptyState({ icon: "search-off", title: "Nothing matched", body: "Try broader terms, or switch the fusion mode above." }),
117
+ );
118
+ return;
119
+ }
120
+ resultsHost.replaceChildren(
121
+ c.sectionHead(
122
+ `${rows.length} ${rows.length === 1 ? "result" : "results"}`,
123
+ c.pill(MODES.find((m) => m.key === state.mode).label, "info"),
124
+ c.sourceBadge(res.source),
125
+ ),
126
+ h("div.lt3-stack-3", rows.map((r) => resultCard(r))),
127
+ );
128
+ }
129
+
130
+ function resultCard(r) {
131
+ const score = typeof r.score === "number" ? r.score : (0.5 * (r.vector || 0) + 0.2 * (r.lexical || 0) + 0.3 * (r.graph || 0));
132
+ return h("article.lt3-result",
133
+ h("div.lt3-result__top",
134
+ h("div.lt3-result__title", { style: { flex: "1", "min-width": "0" } }, String(r.title || "Untitled")),
135
+ c.pill(`${(score).toFixed(2)} score`, "info", { dot: true }),
136
+ ),
137
+ h("div.lt3-faint.lt3-mono", { style: { "font-size": "var(--lt3-text-2xs)" } }, String(r.path || "")),
138
+ r.snippet && h("p.lt3-result__snippet", String(r.snippet)),
139
+ h("div.lt3-result__scores",
140
+ scoreBlock("Vector", r.vector, "vector"),
141
+ scoreBlock("Keyword", r.lexical, ""),
142
+ scoreBlock("Graph", r.graph, "graph"),
143
+ ),
144
+ );
145
+ }
146
+
147
+ function scoreBlock(label, value, variant) {
148
+ const v = Number(value) || 0;
149
+ return h("div.lt3-score",
150
+ h("div.lt3-score__row", h("span", label), h("b", v.toFixed(2))),
151
+ c.meter(v, variant),
152
+ );
153
+ }
154
+
155
+ /* ── pre-search intro ──────────────────────────────────────────────────── */
156
+ function introBlock() {
157
+ return h("div.lt3-stack-6",
158
+ c.emptyState({
159
+ icon: "arrows-join",
160
+ title: "Search across structure and vector signals",
161
+ body: "Enter a query above. Results show keyword, local vector, and graph scores before fusion.",
162
+ }),
163
+ h("section",
164
+ c.sectionHead("How fusion scores a match"),
165
+ h("div.lt3-grid-3",
166
+ SIGNALS.map((s) => signalCard(s)),
167
+ ),
168
+ ),
169
+ );
170
+ }
171
+
172
+ function signalCard(s) {
173
+ const tint = s.variant === "vector" ? "var(--lt3-pillar-vector)"
174
+ : s.variant === "graph" ? "var(--lt3-pillar-graph)"
175
+ : "var(--accent-3)";
176
+ return c.card(
177
+ h("div.lt3-stack-3",
178
+ h("div.lt3-row-2",
179
+ h("span.lt3-result__title", { style: { display: "grid", "place-items": "center", width: "32px", height: "32px", "border-radius": "var(--lt3-radius-sm)", background: `color-mix(in srgb, ${tint} 16%, transparent)`, color: tint } }, icon(s.icon)),
180
+ h("b", s.label),
181
+ ),
182
+ h("p.lt3-muted", { style: { "font-size": "var(--lt3-text-sm)" } }, s.desc),
183
+ c.meter(s.key === "graph" ? 0.85 : s.key === "vector" ? 0.7 : 0.55, s.variant),
184
+ ),
185
+ { flat: true },
186
+ );
187
+ }
188
+
189
+ return root;
190
+ }
191
+
192
+ function weightLabel(weights) {
193
+ const w = { ...MODE_WEIGHTS.hybrid, ...(weights || {}) };
194
+ return `K ${Number(w.keyword || 0).toFixed(2)} · V ${Number(w.vector || 0).toFixed(2)} · G ${Number(w.graph || 0).toFixed(2)}`;
195
+ }
@@ -2,7 +2,7 @@
2
2
  * View: Hybrid Search — the fused-retrieval surface (headline capability).
3
3
  * Runs api.hybridSearch(query, {weights}) and shows, per result, how keyword,
4
4
  * local vector and graph signals combine into the fused score. Missing
5
- * endpoints degrade to clearly-badged sample data.
5
+ * endpoints render an unavailable state.
6
6
  * ========================================================================== */
7
7
 
8
8
  const MODES = [