loopengine 0.1.95 → 0.1.98

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 (68) hide show
  1. package/README.md +21 -2
  2. package/dist/bin/ability-manager.d.ts +4 -0
  3. package/dist/bin/ability-manager.js +34 -2
  4. package/dist/bin/cli.d.ts +1 -1
  5. package/dist/bin/cli.js +3 -0
  6. package/dist/core/ability-details.d.ts +31 -0
  7. package/dist/core/ability-details.js +140 -0
  8. package/dist/core/ability-sources.d.ts +59 -0
  9. package/dist/core/ability-sources.js +312 -0
  10. package/dist/core/agent-config.d.ts +21 -1
  11. package/dist/core/discover-agents.js +11 -1
  12. package/dist/core/http-notifier.d.ts +7 -1
  13. package/dist/core/http-notifier.js +68 -9
  14. package/dist/core/http-notify-triggers/email.d.ts +2 -2
  15. package/dist/core/http-notify-triggers/email.js +2 -4
  16. package/dist/core/http-notify-triggers/lark.d.ts +2 -2
  17. package/dist/core/http-notify-triggers/lark.js +2 -4
  18. package/dist/core/http-notify-triggers/slack.d.ts +2 -2
  19. package/dist/core/http-notify-triggers/slack.js +2 -4
  20. package/dist/core/http-notify-triggers/telegram.d.ts +28 -0
  21. package/dist/core/http-notify-triggers/telegram.js +71 -0
  22. package/dist/core/http-notify-triggers/webhook.d.ts +2 -2
  23. package/dist/core/http-notify-triggers/webhook.js +2 -4
  24. package/dist/core/model-calls/meta-model-call.d.ts +15 -0
  25. package/dist/core/model-calls/meta-model-call.js +48 -0
  26. package/dist/core/model-calls/openai-model-call.d.ts +5 -0
  27. package/dist/core/model-calls/openai-model-call.js +47 -1
  28. package/dist/core/model-calls/xai-model-call.d.ts +15 -0
  29. package/dist/core/model-calls/xai-model-call.js +45 -0
  30. package/dist/core/notifier-file.d.ts +40 -6
  31. package/dist/core/notifier-file.js +119 -25
  32. package/dist/core/system-agents/workflow-planner.d.ts +100 -0
  33. package/dist/core/system-agents/workflow-planner.js +275 -0
  34. package/dist/core/workflows.d.ts +26 -0
  35. package/dist/core/workflows.js +39 -0
  36. package/dist/core/workspace-abilities.d.ts +31 -0
  37. package/dist/core/workspace-abilities.js +110 -0
  38. package/dist/index.d.ts +18 -4
  39. package/dist/index.js +17 -3
  40. package/dist/web/ability-details-admin.d.ts +17 -0
  41. package/dist/web/ability-details-admin.js +45 -0
  42. package/dist/web/ability-sources-admin.d.ts +33 -0
  43. package/dist/web/ability-sources-admin.js +65 -0
  44. package/dist/web/agent-file-admin.d.ts +1 -1
  45. package/dist/web/agent-file-admin.js +3 -0
  46. package/dist/web/agents-config-page.js +600 -220
  47. package/dist/web/agents-list-page.js +49 -20
  48. package/dist/web/console-shell.js +244 -3
  49. package/dist/web/dev-ui-styles.js +12 -0
  50. package/dist/web/global-config-page.js +348 -95
  51. package/dist/web/global-config.d.ts +44 -1
  52. package/dist/web/global-config.js +162 -2
  53. package/dist/web/interview-admin.js +2 -2
  54. package/dist/web/messaging-icons.d.ts +2 -0
  55. package/dist/web/messaging-icons.js +7 -0
  56. package/dist/web/notifier-admin.d.ts +33 -15
  57. package/dist/web/notifier-admin.js +109 -65
  58. package/dist/web/provider-icons.d.ts +2 -0
  59. package/dist/web/provider-icons.js +10 -0
  60. package/dist/web/templates-admin.d.ts +3 -0
  61. package/dist/web/templates-admin.js +5 -1
  62. package/dist/web/workflow-admin.d.ts +124 -0
  63. package/dist/web/workflow-admin.js +308 -0
  64. package/dist/web/workflow-page.d.ts +1 -0
  65. package/dist/web/workflow-page.js +226 -0
  66. package/dist/web/workspace-abilities-admin.d.ts +24 -0
  67. package/dist/web/workspace-abilities-admin.js +118 -0
  68. package/package.json +13 -1
@@ -21,6 +21,7 @@
21
21
  // is built, once when a browser parses the served <script> as JS).
22
22
  import { devUiCss } from './dev-ui-styles.js';
23
23
  import { consoleShellScript, consoleSidebarHtml } from './console-shell.js';
24
+ import { PROVIDER_ICONS } from './provider-icons.js';
24
25
  export const globalConfigPageHtml = `<!doctype html>
25
26
  <html lang="en">
26
27
  <head>
@@ -29,8 +30,12 @@ export const globalConfigPageHtml = `<!doctype html>
29
30
  <title>LoopEngine Settings</title>
30
31
  <style>${devUiCss}
31
32
  section { margin-bottom: 28px; }
33
+ .provider-name { display: inline-flex; align-items: center; gap: 8px; white-space: nowrap; }
34
+ .le-provider-icon { width: 18px; height: 18px; flex: none; }
32
35
  section h3 { font-size: 15px; font-weight: 600; margin: 0 0 10px; }
33
36
  section > table { border: 1px solid var(--line); border-radius: var(--radius); border-collapse: separate; border-spacing: 0; overflow: hidden; }
37
+ section h4 { font-size: 13px; font-weight: 600; margin: 18px 0 8px; }
38
+ section h4 .hint { font-weight: 400; margin-left: 4px; }
34
39
  .source {
35
40
  border: 1px solid var(--line);
36
41
  border-radius: 8px;
@@ -38,6 +43,12 @@ export const globalConfigPageHtml = `<!doctype html>
38
43
  margin-bottom: 10px;
39
44
  }
40
45
  .source.unsupported { opacity: 0.6; }
46
+ /* Settings → Private ability sources: one field per row, label above. */
47
+ .source-form { display: grid; gap: 12px; max-width: 520px; margin-top: 12px; }
48
+ .source-form label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; font-weight: 500; }
49
+ .source-form label .hint { font-weight: 400; }
50
+ .source-form input, .source-form select { width: 100%; box-sizing: border-box; }
51
+ .source-form button[type="submit"] { justify-self: start; }
41
52
  .source-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
42
53
  .source-head h4 { margin: 0; font-size: 14px; font-family: ui-monospace, monospace; }
43
54
  .hint { font-size: 11px; color: var(--ink-muted); }
@@ -110,6 +121,12 @@ ${consoleShellScript}
110
121
 
111
122
  // ---- Models section ----
112
123
 
124
+ // Each provider's logo (web/provider-icons.ts), shown beside its name.
125
+ var PROVIDER_ICONS = ${JSON.stringify(PROVIDER_ICONS)};
126
+ function providerName(provider) {
127
+ return '<span class="provider-name">' + (PROVIDER_ICONS[provider] || '') + '<code>' + escapeHtml(provider) + '</code></span>';
128
+ }
129
+
113
130
  function renderModels(data) {
114
131
  var providerRows = data.providers.map(function (p) {
115
132
  // Write-only: the field is always empty; the server never sends a
@@ -121,7 +138,7 @@ ${consoleShellScript}
121
138
  (p.configured ? '<button type="button" class="delete-btn" data-remove-key="' + escapeHtml(p.provider) + '">Remove</button>' : '') +
122
139
  '</form><div class="key-msg" data-key-msg="' + escapeHtml(p.provider) + '"></div>'
123
140
  : '<span class="hint">Set LOOPENGINE_ADMIN_AUTH to manage keys here, or put ' + escapeHtml(p.envVar) + ' in .env.</span>';
124
- return '<tr><td><code>' + escapeHtml(p.provider) + '</code></td>' +
141
+ return '<tr><td>' + providerName(p.provider) + '</td>' +
125
142
  '<td><code>' + escapeHtml(p.envVar) + '</code></td>' +
126
143
  '<td>' + badge(p.configured, 'configured', 'not set') + '</td>' +
127
144
  '<td>' + keyCell + '</td></tr>';
@@ -130,7 +147,7 @@ ${consoleShellScript}
130
147
  var agentRows = data.agents.length
131
148
  ? data.agents.map(function (a) {
132
149
  return '<tr><td><code>' + escapeHtml(a.agent) + '</code></td>' +
133
- '<td><code>' + escapeHtml(a.provider) + '</code></td>' +
150
+ '<td>' + providerName(a.provider) + '</td>' +
134
151
  '<td><code>' + escapeHtml(a.model) + '</code></td></tr>';
135
152
  }).join('')
136
153
  : '<tr><td colspan="3"><p class="hint">No agents with a resolved model config (a custom createModelCall has nothing to report here).</p></td></tr>';
@@ -203,121 +220,263 @@ ${consoleShellScript}
203
220
  }
204
221
 
205
222
  // ---- Abilities section ----
206
- // How agents reach outside apps: an ability (lp-gmail, lp-shopify...)
207
- // installed into an agent brings its tools, skills and permission
208
- // rules. Abilities live in each agent, so this page gathers them from
209
- // every agent's own Abilities routes and installs through the same
210
- // route, which also keeps it working with any project template.
211
-
212
- var abilityAgents = [];
213
- var abilitiesByAgent = {};
214
-
215
- function agentAbilitiesHref(agent) {
216
- return '/agents/config?agent=' + encodeURIComponent(agent) + '&tab=abilities';
217
- }
218
-
219
- function renderInstalledAbilities() {
220
- var byName = {};
221
- abilityAgents.forEach(function (agent) {
222
- (abilitiesByAgent[agent] || []).forEach(function (a) {
223
- if (!byName[a.name]) byName[a.name] = { name: a.name, versions: [], agents: [] };
224
- if (byName[a.name].versions.indexOf(a.version) === -1) byName[a.name].versions.push(a.version);
225
- byName[a.name].agents.push(agent);
226
- });
227
- });
228
- var names = Object.keys(byName).sort();
229
- if (!names.length) return '<p class="hint">No agent has an ability yet. Add one below, or start from a template, which installs the ones it needs.</p>';
230
- return '<table><thead><tr><th>Ability</th><th>Version</th><th>Agents</th></tr></thead><tbody>' +
231
- names.map(function (n) {
232
- var a = byName[n];
233
- return '<tr><td><code>' + escapeHtml(a.name) + '</code></td>' +
234
- '<td>' + escapeHtml(a.versions.join(', ')) + '</td>' +
235
- '<td>' + a.agents.map(function (agent) { return '<a href="' + agentAbilitiesHref(agent) + '">' + escapeHtml(agent) + '</a>'; }).join(', ') + '</td></tr>';
236
- }).join('') + '</tbody></table>';
223
+ // How agents reach outside apps. Public abilities (npm) are browsed
224
+ // here and installed from an agent's own Abilities tab. A private one
225
+ // (from the sources in Settings) is installed here into the workspace
226
+ // (core/workspace-abilities.ts), then added to any agent from its tab.
227
+
228
+ // An ability's details (tools with their permissions, skills, settings)
229
+ // come from the console shell, shared with each agent's Abilities tab.
230
+ var toggleAbilityDetails = leShell.toggleAbilityDetails;
231
+
232
+ function detailsButton(spec) {
233
+ return '<button type="button" class="ability-details-btn" data-spec="' + escapeHtml(spec) + '">Details</button>';
237
234
  }
238
235
 
236
+ var lastSearch = { q: null, results: [] };
237
+ var lastPrivate = null;
238
+ var workspaceAbilities = null;
239
+
239
240
  function renderAbilities() {
240
241
  var content = document.getElementById('abilitiesContent');
241
- var add = abilityAgents.length
242
- ? '<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px">' +
243
- '<label for="abilityAgent">Install into</label>' +
244
- '<select id="abilityAgent">' + abilityAgents.map(function (a) { return '<option value="' + escapeHtml(a) + '">' + escapeHtml(a) + '</option>'; }).join('') + '</select>' +
245
- '<form id="abilitySearchForm" style="display:flex;gap:6px;flex:1;min-width:220px;margin:0">' +
246
- '<input type="search" name="q" placeholder="Search abilities, e.g. gmail" aria-label="Search abilities" style="flex:1">' +
247
- '<button type="submit">Search</button>' +
248
- '</form>' +
249
- '</div>' +
250
- '<p id="abilityInstallStatus" class="hint"></p>' +
251
- '<div id="abilitySearchResults"><p class="hint">Loading&hellip;</p></div>'
252
- : '<p>Create an agent first: abilities are installed into one. <a href="/templates">Start from a template</a> or <a href="/agents?new=1">create an agent</a>.</p>';
253
- content.innerHTML =
254
- '<section><h3>Installed</h3><div id="abilitiesInstalled">' + renderInstalledAbilities() + '</div></section>' +
255
- '<section><h3>Add an ability</h3>' + add + '</section>';
256
- if (!abilityAgents.length) return;
242
+ content.innerHTML = '<section>' +
243
+ '<h4>Public <span class="hint">npm packages tagged loopengine-ability; add one to an agent from its Abilities tab</span></h4>' +
244
+ '<form id="abilitySearchForm" style="display:flex;gap:6px;margin:0 0 10px;max-width:560px">' +
245
+ '<input type="search" name="q" placeholder="Search abilities, e.g. gmail" aria-label="Search public abilities" style="flex:1">' +
246
+ '<button type="submit">Search</button>' +
247
+ '</form>' +
248
+ '<div id="abilitySearchResults"><p class="hint">Loading&hellip;</p></div>' +
249
+ '<h4 id="private-abilities">Private <span class="hint">your own, from the sources in <a href="/config?section=settings">Settings</a>; install one into the workspace, then add it to agents from their Abilities tab</span></h4>' +
250
+ '<p id="abilityInstallStatus" class="hint"></p>' +
251
+ '<div id="workspaceAbilities"></div>' +
252
+ '<div id="privateAbilities"><p class="hint">Loading&hellip;</p></div>' +
253
+ '<form id="abilitySpecForm" style="display:flex;gap:6px;margin:10px 0 0;max-width:560px">' +
254
+ '<input name="spec" placeholder="Or install by name or spec: @company/lp-x, github:org/repo" aria-label="Ability name or spec" style="flex:1">' +
255
+ '<button type="submit">Install</button>' +
256
+ '</form>' +
257
+ '<div class="source" style="margin-top:12px;max-width:560px;box-sizing:border-box">' +
258
+ '<p style="margin:0 0 8px"><strong>Upload an ability</strong> <span class="hint">from your computer: a .zip or .tgz, or its folder (up to 25 MB)</span></p>' +
259
+ '<input type="file" id="abilityUploadFile" accept=".zip,.tgz,.gz" hidden>' +
260
+ '<input type="file" id="abilityUploadFolder" webkitdirectory hidden>' +
261
+ '<div style="display:flex;gap:6px;flex-wrap:wrap"><button type="button" id="abilityUploadFileBtn">Choose a file' + String.fromCharCode(8230) + '</button>' +
262
+ '<button type="button" id="abilityUploadFolderBtn">Choose a folder' + String.fromCharCode(8230) + '</button></div>' +
263
+ '</div></section>';
257
264
  var form = document.getElementById('abilitySearchForm');
258
265
  form.addEventListener('submit', function (ev) { ev.preventDefault(); searchAbilities(form.q.value.trim()); });
259
- document.getElementById('abilityAgent').addEventListener('change', function () { searchAbilities(form.q.value.trim()); });
266
+ var specForm = document.getElementById('abilitySpecForm');
267
+ specForm.addEventListener('submit', function (ev) {
268
+ ev.preventDefault();
269
+ var spec = specForm.spec.value.trim();
270
+ if (spec) installIntoWorkspace(spec, specForm.querySelector('button'));
271
+ });
272
+ wireAbilityUpload();
260
273
  searchAbilities('');
274
+ loadWorkspaceAbilities();
275
+ loadPrivateAbilities();
261
276
  }
262
277
 
263
- var lastSearch = { q: null, results: [] };
278
+ // ---- Uploading an ability from your computer ----
279
+
280
+ // A file's contents as base64 (the part after a data: URL's comma).
281
+ function readBase64(file) {
282
+ return new Promise(function (resolve, reject) {
283
+ var reader = new FileReader();
284
+ reader.onload = function () { resolve(String(reader.result).split(',')[1] || ''); };
285
+ reader.onerror = function () { reject(reader.error || new Error('Could not read ' + file.name)); };
286
+ reader.readAsDataURL(file);
287
+ });
288
+ }
289
+
290
+ function uploadAbility(body, label, buttons) {
291
+ var status = document.getElementById('abilityInstallStatus');
292
+ buttons.forEach(function (b) { b.disabled = true; });
293
+ status.className = 'hint';
294
+ status.textContent = 'Uploading ' + label + String.fromCharCode(8230);
295
+ return fetch('/abilities/workspace/upload', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
296
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
297
+ .then(function (r) {
298
+ if (!r.ok) throw new Error(r.body.error || 'upload failed');
299
+ status.textContent = 'Uploaded ' + label + ' into the workspace. Add it to an agent from the agent' + String.fromCharCode(8217) + 's Abilities tab.';
300
+ workspaceAbilities = r.body.abilities || [];
301
+ renderWorkspaceAbilities();
302
+ renderPrivateAbilities();
303
+ })
304
+ .catch(function (err) {
305
+ status.className = 'error';
306
+ status.textContent = 'Could not upload ' + label + ': ' + err.message;
307
+ })
308
+ .then(function () { buttons.forEach(function (b) { b.disabled = false; }); });
309
+ }
310
+
311
+ function wireAbilityUpload() {
312
+ var fileInput = document.getElementById('abilityUploadFile');
313
+ var folderInput = document.getElementById('abilityUploadFolder');
314
+ var fileBtn = document.getElementById('abilityUploadFileBtn');
315
+ var folderBtn = document.getElementById('abilityUploadFolderBtn');
316
+ var buttons = [fileBtn, folderBtn];
317
+ var MAX = 25 * 1024 * 1024;
318
+ fileBtn.addEventListener('click', function () { fileInput.value = ''; fileInput.click(); });
319
+ folderBtn.addEventListener('click', function () { folderInput.value = ''; folderInput.click(); });
320
+ fileInput.addEventListener('change', function () {
321
+ var file = fileInput.files[0];
322
+ if (!file) return;
323
+ if (file.size > MAX) { document.getElementById('abilityInstallStatus').textContent = file.name + ' is larger than 25 MB.'; return; }
324
+ readBase64(file).then(function (data) { return uploadAbility({ kind: 'archive', fileName: file.name, data: data }, file.name, buttons); });
325
+ });
326
+ folderInput.addEventListener('change', function () {
327
+ // Leaves out what an ability never needs: installed packages, git
328
+ // data and hidden files.
329
+ var files = Array.prototype.filter.call(folderInput.files, function (f) {
330
+ return !f.webkitRelativePath.split('/').some(function (part) { return part === 'node_modules' || part.charAt(0) === '.'; });
331
+ });
332
+ if (!files.length) return;
333
+ var folderName = files[0].webkitRelativePath.split('/')[0];
334
+ var total = files.reduce(function (sum, f) { return sum + f.size; }, 0);
335
+ if (total > MAX) { document.getElementById('abilityInstallStatus').textContent = folderName + ' is larger than 25 MB.'; return; }
336
+ Promise.all(files.map(function (f) { return readBase64(f).then(function (data) { return { path: f.webkitRelativePath, data: data }; }); }))
337
+ .then(function (list) { return uploadAbility({ kind: 'files', folderName: folderName, files: list }, folderName, buttons); });
338
+ });
339
+ }
340
+
341
+ // Details, Install and Remove buttons anywhere in the section.
342
+ document.getElementById('abilitiesContent').addEventListener('click', function (ev) {
343
+ var btn = ev.target.closest && ev.target.closest('button');
344
+ if (!btn) return;
345
+ if (btn.classList.contains('ability-details-btn')) toggleAbilityDetails(btn);
346
+ else if (btn.classList.contains('workspace-install-btn')) installIntoWorkspace(btn.dataset.spec, btn);
347
+ else if (btn.classList.contains('workspace-remove-btn')) removeFromWorkspace(btn);
348
+ });
264
349
 
265
350
  function renderSearchResults() {
266
351
  var resultsEl = document.getElementById('abilitySearchResults');
267
- var agent = document.getElementById('abilityAgent').value;
268
- var installed = (abilitiesByAgent[agent] || []).map(function (a) { return a.name; });
269
352
  var results = lastSearch.results;
270
353
  resultsEl.innerHTML = results.length
271
354
  ? '<table><thead><tr><th>Ability</th><th>What it does</th><th></th></tr></thead><tbody>' + results.map(function (pkg) {
272
- var action = installed.indexOf(pkg.name) !== -1
273
- ? '<span class="hint">In ' + escapeHtml(agent) + '</span>'
274
- : '<button type="button" class="ability-install-btn" data-spec="' + escapeHtml(pkg.name) + '">Install</button>';
275
355
  return '<tr><td><code>' + escapeHtml(pkg.name) + '</code> <span class="hint">' + escapeHtml(pkg.version) + '</span></td>' +
276
- '<td>' + escapeHtml(pkg.description || '') + '</td><td>' + action + '</td></tr>';
356
+ '<td>' + escapeHtml(pkg.description || '') + '</td>' +
357
+ '<td style="white-space:nowrap">' + detailsButton(pkg.name + '@' + pkg.version) + '</td></tr>';
277
358
  }).join('') + '</tbody></table>'
278
359
  : '<p class="hint">No published abilities match.</p>';
279
- var buttons = resultsEl.querySelectorAll('.ability-install-btn');
280
- for (var i = 0; i < buttons.length; i++) {
281
- buttons[i].addEventListener('click', function (ev) { installAbility(agent, ev.currentTarget.dataset.spec, ev.currentTarget); });
360
+ }
361
+
362
+ function workspaceCopy(name) {
363
+ return (workspaceAbilities || []).filter(function (a) { return a.name === name; })[0];
364
+ }
365
+
366
+ // What's already in the workspace, ready to add to agents.
367
+ function renderWorkspaceAbilities() {
368
+ var el = document.getElementById('workspaceAbilities');
369
+ if (!el || !workspaceAbilities) return;
370
+ el.innerHTML = workspaceAbilities.length
371
+ ? '<div class="source"><p style="margin:0 0 8px"><strong>In this workspace</strong> <span class="hint">add these to an agent from its Abilities tab</span></p>' +
372
+ '<table><thead><tr><th>Ability</th><th>From</th><th></th></tr></thead><tbody>' + workspaceAbilities.map(function (a) {
373
+ return '<tr><td><code>' + escapeHtml(a.name) + '</code>' + (a.version ? ' <span class="hint">' + escapeHtml(a.version) + '</span>' : '') + '</td>' +
374
+ '<td><span class="hint">' + escapeHtml(a.source.indexOf('upload:') === 0 ? 'Uploaded ' + a.source.slice(7) : a.source) + '</span></td>' +
375
+ '<td style="white-space:nowrap">' + detailsButton(a.spec) + ' <button type="button" class="delete-btn workspace-remove-btn" data-name="' + escapeHtml(a.name) + '">Remove</button></td></tr>';
376
+ }).join('') + '</tbody></table></div>'
377
+ : '';
378
+ }
379
+
380
+ // Each source's abilities: Install puts one into the workspace; one
381
+ // already there shows so, with Update when the source has another version.
382
+ function renderPrivateAbilities() {
383
+ var el = document.getElementById('privateAbilities');
384
+ if (!el || !lastPrivate) return;
385
+ var sources = lastPrivate.sources || [];
386
+ if (lastPrivate.error) { el.innerHTML = '<p class="error">' + escapeHtml(lastPrivate.error) + '</p>'; return; }
387
+ if (!sources.length) {
388
+ el.innerHTML = '<p class="hint">No private sources yet. <a href="/config?section=settings">Add one in Settings</a> (a GitHub org or user, or a private npm registry), or upload an ability below.</p>';
389
+ return;
282
390
  }
391
+ el.innerHTML = sources.map(function (s) {
392
+ var body = s.error
393
+ ? '<p class="error" style="margin:0">' + escapeHtml(s.error) + '</p>'
394
+ : s.abilities.length
395
+ ? '<table><thead><tr><th>Ability</th><th>What it does</th><th></th></tr></thead><tbody>' + s.abilities.map(function (a) {
396
+ var copy = workspaceCopy(a.name);
397
+ var action = !copy
398
+ ? '<button type="button" class="workspace-install-btn" data-spec="' + escapeHtml(a.spec) + '">Install</button>'
399
+ : copy.version && a.version && copy.version !== a.version
400
+ ? '<span class="hint">In workspace (' + escapeHtml(copy.version) + ')</span> <button type="button" class="workspace-install-btn" data-spec="' + escapeHtml(a.spec) + '">Update to ' + escapeHtml(a.version) + '</button>'
401
+ : '<span class="hint">In workspace</span>';
402
+ return '<tr><td><code>' + escapeHtml(a.name) + '</code>' + (a.version ? ' <span class="hint">' + escapeHtml(a.version) + '</span>' : '') + '</td>' +
403
+ '<td>' + escapeHtml(a.description || '') + '</td>' +
404
+ '<td style="white-space:nowrap">' + detailsButton(a.spec) + ' ' + action + '</td></tr>';
405
+ }).join('') + '</tbody></table>'
406
+ : '<p class="hint" style="margin:0">No abilities found here.</p>';
407
+ return '<div class="source"><p style="margin:0 0 8px"><strong>' + escapeHtml(s.label) + '</strong></p>' + body + '</div>';
408
+ }).join('');
409
+ }
410
+
411
+ // Linked from an agent's Abilities tab (/config?section=abilities#private-abilities):
412
+ // once every list has loaded, so the public results don't push it down.
413
+ var scrolledToPrivate = false;
414
+ function scrollToPrivateIfLinked() {
415
+ if (scrolledToPrivate || location.hash !== '#private-abilities' || !lastPrivate || !workspaceAbilities || lastSearch.q === null) return;
416
+ var heading = document.getElementById('private-abilities');
417
+ if (!heading) return;
418
+ scrolledToPrivate = true;
419
+ heading.scrollIntoView({ block: 'start' });
420
+ }
421
+
422
+ function loadWorkspaceAbilities() {
423
+ return fetch('/abilities/workspace')
424
+ .then(function (r) { return r.json(); })
425
+ .then(function (d) {
426
+ workspaceAbilities = d.abilities || [];
427
+ renderWorkspaceAbilities();
428
+ renderPrivateAbilities();
429
+ scrollToPrivateIfLinked();
430
+ })
431
+ .catch(function (err) {
432
+ var el = document.getElementById('workspaceAbilities');
433
+ if (el) el.innerHTML = '<p class="error">Could not list the workspace' + String.fromCharCode(8217) + 's abilities: ' + escapeHtml(err.message) + '</p>';
434
+ });
435
+ }
436
+
437
+ function loadPrivateAbilities() {
438
+ fetch('/abilities/private')
439
+ .then(function (r) { return r.json(); })
440
+ .then(function (d) {
441
+ lastPrivate = d;
442
+ renderPrivateAbilities();
443
+ scrollToPrivateIfLinked();
444
+ })
445
+ .catch(function (err) {
446
+ var el = document.getElementById('privateAbilities');
447
+ if (el) el.innerHTML = '<p class="error">Could not list private abilities: ' + escapeHtml(err.message) + '</p>';
448
+ });
283
449
  }
284
450
 
285
451
  function searchAbilities(q) {
286
452
  var resultsEl = document.getElementById('abilitySearchResults');
287
453
  if (lastSearch.q === q) { renderSearchResults(); return; }
288
454
  resultsEl.innerHTML = '<p class="hint">Loading&hellip;</p>';
289
- // Search doesn't depend on the agent; the route just lives under one.
290
- fetch('/agents/' + encodeURIComponent(abilityAgents[0]) + '/abilities/search' + (q ? '?q=' + encodeURIComponent(q) : ''))
455
+ fetch('/abilities/search' + (q ? '?q=' + encodeURIComponent(q) : ''))
291
456
  .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
292
457
  .then(function (r) {
293
458
  if (!r.ok) throw new Error(r.body.error || 'search failed');
294
459
  lastSearch = { q: q, results: r.body.results || [] };
295
460
  renderSearchResults();
461
+ scrollToPrivateIfLinked();
296
462
  })
297
463
  .catch(function (err) { resultsEl.innerHTML = '<p class="error">Could not search abilities: ' + escapeHtml(err.message) + '</p>'; });
298
464
  }
299
465
 
300
- function installAbility(agent, spec, btn) {
466
+ function installIntoWorkspace(spec, btn) {
301
467
  var status = document.getElementById('abilityInstallStatus');
302
468
  btn.disabled = true;
303
469
  status.className = 'hint';
304
- status.textContent = 'Installing ' + spec + ' into ' + agent + String.fromCharCode(8230);
305
- fetch('/agents/' + encodeURIComponent(agent) + '/abilities', {
306
- method: 'POST',
307
- headers: { 'content-type': 'application/json' },
308
- body: JSON.stringify({ spec: spec }),
309
- })
470
+ status.textContent = 'Installing ' + spec + ' into the workspace' + String.fromCharCode(8230);
471
+ fetch('/abilities/workspace', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ spec: spec }) })
310
472
  .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
311
473
  .then(function (r) {
312
474
  if (!r.ok) throw new Error(r.body.error || 'request failed');
313
- var deps = r.body.dependencies || [];
314
- status.innerHTML = 'Installed ' + escapeHtml(spec) + ' into ' + escapeHtml(agent) + '. ' +
315
- '<a href="' + agentAbilitiesHref(agent) + '">Add its keys in ' + escapeHtml(agent) + String.fromCharCode(8217) + 's Abilities tab</a>.' +
316
- (deps.length ? ' <strong>It also needs <code>' + escapeHtml(deps.join(', ')) + '</code>:</strong> use Install deps there.' : '');
317
- return loadAgentAbilities(agent).then(function () {
318
- document.getElementById('abilitiesInstalled').innerHTML = renderInstalledAbilities();
319
- renderSearchResults();
320
- });
475
+ status.textContent = 'Installed ' + spec + ' into the workspace. Add it to an agent from the agent' + String.fromCharCode(8217) + 's Abilities tab.';
476
+ workspaceAbilities = r.body.abilities || [];
477
+ renderWorkspaceAbilities();
478
+ renderPrivateAbilities();
479
+ btn.disabled = false;
321
480
  })
322
481
  .catch(function (err) {
323
482
  btn.disabled = false;
@@ -326,27 +485,22 @@ ${consoleShellScript}
326
485
  });
327
486
  }
328
487
 
329
- function loadAgentAbilities(agent) {
330
- return fetch('/agents/' + encodeURIComponent(agent) + '/abilities')
331
- .then(function (r) { return r.ok ? r.json() : { abilities: [] }; })
332
- .then(function (d) { abilitiesByAgent[agent] = d.abilities || []; })
333
- .catch(function () { abilitiesByAgent[agent] = []; });
488
+ // A second click confirms. Agents that added it keep their own copies.
489
+ function removeFromWorkspace(btn) {
490
+ if (btn.dataset.confirming !== '1') { btn.dataset.confirming = '1'; btn.textContent = 'Click again to remove'; return; }
491
+ btn.disabled = true;
492
+ fetch('/abilities/workspace?name=' + encodeURIComponent(btn.dataset.name), { method: 'DELETE' })
493
+ .then(function (r) { return r.json(); })
494
+ .then(function (d) {
495
+ workspaceAbilities = d.abilities || [];
496
+ renderWorkspaceAbilities();
497
+ renderPrivateAbilities();
498
+ });
334
499
  }
335
500
 
336
501
  function loadAbilities() {
337
- var content = document.getElementById('abilitiesContent');
338
- leShell.ready
339
- .then(function (agents) {
340
- abilityAgents = agents.map(function (a) { return a.name; });
341
- return Promise.all(abilityAgents.map(loadAgentAbilities));
342
- })
343
- .then(function () {
344
- renderAbilities();
345
- loadedSections.abilities = true;
346
- })
347
- .catch(function (err) {
348
- content.innerHTML = '<p class="error">Could not load abilities: ' + escapeHtml(err.message) + '</p>';
349
- });
502
+ renderAbilities();
503
+ loadedSections.abilities = true;
350
504
  }
351
505
 
352
506
  // ---- Settings section ----
@@ -372,9 +526,107 @@ ${consoleShellScript}
372
526
  rows.map(function (r) { return '<tr><td>' + r[0] + '</td><td>' + r[1] + '</td></tr>'; }).join('') +
373
527
  '</tbody></table></section>' +
374
528
  (d.timeZone ? '<section><h3>Time zone</h3><div class="source" id="timeZoneBox"><p class="hint">Loading' + String.fromCharCode(8230) + '</p></div></section>' : '') +
529
+ '<section><h3>Private ability sources</h3><p class="muted" style="margin:0 0 10px">Where your own abilities come from, listed under Private on the Abilities page next to the public ones on npm.</p><div class="source" id="abilitySourcesBox"><p class="hint">Loading' + String.fromCharCode(8230) + '</p></div></section>' +
375
530
  '<section><h3>Updates</h3><div class="source" id="upgradeBox"><p class="hint">Checking for a newer LoopEngine' + String.fromCharCode(8230) + '</p></div></section>';
376
531
  }
377
532
 
533
+ // ---- Private ability sources (Settings) ----
534
+ // Where this project's own abilities come from, next to the public ones
535
+ // on npm: a GitHub org or user, or a private npm registry
536
+ // (core/ability-sources.ts). There's no folder kind here: this server
537
+ // may run in the cloud, so local files are uploaded on the Abilities
538
+ // page instead. A token is write-only, like a model key.
539
+
540
+ var SOURCE_TYPES = [
541
+ ['github', 'GitHub org or user', 'Each ability is a repo with loopengine.ability.json at its root.'],
542
+ ['npm', 'Private npm registry', 'Packages in one scope, tagged loopengine-ability.']
543
+ ];
544
+
545
+ function renderAbilitySources(d) {
546
+ var box = document.getElementById('abilitySourcesBox');
547
+ if (!box) return;
548
+ var rows = (d.sources || []).map(function (s) {
549
+ var token = s.tokenVar
550
+ ? (s.tokenSet ? '<span class="le-pill good">Token saved</span>' : '<span class="le-pill warn">No token</span>') + ' <span class="hint">' + escapeHtml(s.tokenVar) + '</span>'
551
+ : '<span class="hint">No token needed</span>';
552
+ return '<tr><td>' + escapeHtml(s.label) + '</td><td>' + token + '</td>' +
553
+ '<td><button type="button" class="delete-btn source-remove" data-id="' + escapeHtml(s.source.id) + '"' + (d.canSave ? '' : ' disabled') + '>Remove</button></td></tr>';
554
+ }).join('');
555
+ box.innerHTML =
556
+ (d.error ? '<p class="error">' + escapeHtml(d.error) + '</p>' : '') +
557
+ (rows
558
+ ? '<table><thead><tr><th>Source</th><th>Token</th><th></th></tr></thead><tbody>' + rows + '</tbody></table>'
559
+ : '<p class="hint" style="margin-top:0">No private sources yet. Abilities from npm are always listed; add a source to also list your own.</p>') +
560
+ (d.canSave
561
+ ? '<form id="sourceForm" class="source-form">' +
562
+ '<label>Kind<select name="type" id="sourceType">' + SOURCE_TYPES.map(function (t) { return '<option value="' + t[0] + '">' + t[1] + '</option>'; }).join('') + '</select></label>' +
563
+ '<p class="hint" id="sourceTypeHint" style="margin:0"></p>' +
564
+ '<label data-source-field="github">Org or user<input name="owner" placeholder="loopengine-co"></label>' +
565
+ '<label data-source-field="npm">Registry URL<input name="registry" placeholder="https://npm.pkg.github.com"></label>' +
566
+ '<label data-source-field="npm">Scope<input name="scope" placeholder="@your-company"></label>' +
567
+ '<label data-source-field="github npm">Token <span class="hint" id="sourceTokenHint"></span><input type="password" name="token" autocomplete="off" spellcheck="false"></label>' +
568
+ '<button type="submit">Add source</button>' +
569
+ '<div class="error" id="sourceError"></div>' +
570
+ '</form>'
571
+ : '<p class="hint">Adding a source needs <code>LOOPENGINE_ADMIN_AUTH</code> set, because it stores a token. Or edit <code>ability-sources.yml</code> and <code>.env</code>.</p>');
572
+ var form = document.getElementById('sourceForm');
573
+ if (!form) return;
574
+ var TOKEN_HINTS = {
575
+ github: '(needed for private repos: a token that can read their contents)',
576
+ npm: '(the registry' + String.fromCharCode(8217) + 's auth token, if it needs one)'
577
+ };
578
+ function showFields() {
579
+ var type = form.type.value;
580
+ var fields = form.querySelectorAll('[data-source-field]');
581
+ for (var i = 0; i < fields.length; i++) fields[i].style.display = fields[i].dataset.sourceField.split(' ').indexOf(type) !== -1 ? '' : 'none';
582
+ SOURCE_TYPES.forEach(function (t) { if (t[0] === type) document.getElementById('sourceTypeHint').textContent = t[2]; });
583
+ document.getElementById('sourceTokenHint').textContent = TOKEN_HINTS[type] || '';
584
+ }
585
+ form.type.addEventListener('change', showFields);
586
+ showFields();
587
+ form.addEventListener('submit', function (ev) {
588
+ ev.preventDefault();
589
+ var type = form.type.value;
590
+ var body = { type: type };
591
+ if (type === 'github') body.owner = form.owner.value;
592
+ if (type === 'npm') { body.registry = form.registry.value; body.scope = form.scope.value; }
593
+ if (form.token.value) body.token = form.token.value;
594
+ sendAbilitySources('PUT', '/config/ability-sources', body, document.getElementById('sourceError'));
595
+ });
596
+ }
597
+
598
+ function sendAbilitySources(method, url, body, errorEl) {
599
+ if (errorEl) errorEl.textContent = '';
600
+ return fetch(url, { method: method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined })
601
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
602
+ .then(function (r) {
603
+ if (!r.ok) throw new Error(r.body.error || 'request failed');
604
+ renderAbilitySources(r.body);
605
+ // The Abilities page lists what the sources offer; reload it next time.
606
+ loadedSections.abilities = false;
607
+ })
608
+ .catch(function (err) { if (errorEl) errorEl.textContent = err.message; });
609
+ }
610
+
611
+ function loadAbilitySources() {
612
+ fetch('/config/ability-sources')
613
+ .then(function (r) { return r.json(); })
614
+ .then(renderAbilitySources)
615
+ .catch(function (err) {
616
+ var box = document.getElementById('abilitySourcesBox');
617
+ if (box) box.innerHTML = '<p class="error">Could not load the sources: ' + escapeHtml(err.message) + '</p>';
618
+ });
619
+ }
620
+
621
+ // Remove a source: a second click confirms. Installed abilities stay.
622
+ document.getElementById('settingsContent').addEventListener('click', function (ev) {
623
+ var btn = ev.target.closest && ev.target.closest('.source-remove');
624
+ if (!btn) return;
625
+ if (btn.dataset.confirming !== '1') { btn.dataset.confirming = '1'; btn.textContent = 'Click again to remove'; return; }
626
+ btn.disabled = true;
627
+ sendAbilitySources('DELETE', '/config/ability-sources/' + encodeURIComponent(btn.dataset.id), null, null);
628
+ });
629
+
378
630
  // ---- Time zone ----
379
631
 
380
632
  function renderTimeZone(t) {
@@ -568,6 +820,7 @@ ${consoleShellScript}
568
820
  content.innerHTML = renderSettings(d);
569
821
  loadedSections.settings = true;
570
822
  loadTimeZone();
823
+ loadAbilitySources();
571
824
  loadUpgrade();
572
825
  })
573
826
  .catch(function (err) {
@@ -1,6 +1,6 @@
1
1
  import type { AgentConfig } from '../core/agent-config.js';
2
2
  export interface ModelProviderInfo {
3
- provider: 'anthropic' | 'openai' | 'deepseek' | 'kimi' | 'glm' | 'gemini';
3
+ provider: 'anthropic' | 'openai' | 'deepseek' | 'kimi' | 'glm' | 'gemini' | 'xai' | 'meta';
4
4
  envVar: string;
5
5
  configured: boolean;
6
6
  }
@@ -69,6 +69,49 @@ export declare function modelKeyEnvVar(provider: string): string | undefined;
69
69
  */
70
70
  export declare function setModelProviderKey(provider: string, key: unknown): Promise<void>;
71
71
  export declare function clearModelProviderKey(provider: string): Promise<void>;
72
+ export interface ProviderModelsView {
73
+ provider: string;
74
+ /** Model ids to offer, newest first where the provider says when each
75
+ * was released. Empty when they couldn't be listed (see `error`). */
76
+ models: string[];
77
+ /** Why the list is short or empty: no key saved (only common models,
78
+ * if any, are listed), or the provider refused or timed out. Shown
79
+ * under the dropdown. */
80
+ error?: string;
81
+ }
82
+ export declare const MODELS_FILE_ENV = "LOOPENGINE_MODELS_FILE";
83
+ /** models.yaml in the project root (where .env is), or the file
84
+ * LOOPENGINE_MODELS_FILE names (absolute, or relative to the root). */
85
+ export declare function modelsFilePath(): string;
86
+ /**
87
+ * The hand-edited model lists in models.yaml: provider name to the models
88
+ * its dropdown offers, in order.
89
+ *
90
+ * anthropic:
91
+ * - claude-opus-5-5
92
+ * - claude-sonnet-5-5
93
+ *
94
+ * Read fresh on every call (it's a few lines), so an edit shows the next
95
+ * time a dropdown loads. A missing file is no lists; a malformed one is
96
+ * reported in `error` and ignored as a whole, so a typo can't hide one
97
+ * provider's models while the rest still load.
98
+ */
99
+ export declare function readModelsFile(): {
100
+ models: Partial<Record<ModelProviderInfo['provider'], string[]>>;
101
+ error?: string;
102
+ };
103
+ /**
104
+ * The models a provider offers, for the model dropdown in the New agent
105
+ * and Model forms. A provider listed in models.yaml (readModelsFile)
106
+ * offers exactly that list, with no key needed. Otherwise this asks the
107
+ * provider itself with the saved key (cached for ten minutes per key),
108
+ * so new models show up without a release. Never throws for a provider
109
+ * problem: the view carries an `error` instead, and the form lets the
110
+ * model name be typed. Throws ModelKeyError for an unknown provider.
111
+ */
112
+ export declare function listProviderModels(provider: string, options?: {
113
+ fetch?: typeof fetch;
114
+ }): Promise<ProviderModelsView>;
72
115
  export declare class TimeZoneError extends Error {
73
116
  }
74
117
  export interface TimeZoneView {