loopengine 0.1.28 → 0.1.31

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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # LoopEngine
2
2
 
3
- [![CI](https://github.com/evanyan22/loopengine/actions/workflows/ci.yml/badge.svg)](https://github.com/evanyan22/loopengine/actions/workflows/ci.yml)
3
+ [![CI](https://github.com/loopengine-co/loopengine/actions/workflows/ci.yml/badge.svg)](https://github.com/loopengine-co/loopengine/actions/workflows/ci.yml)
4
4
 
5
5
  LoopEngine is a runtime for building AI agents: a persona, a set of tools,
6
6
  and permission rules, run through a transparent ReAct loop. No chain DSL,
@@ -52,6 +52,14 @@ export interface InstalledAbilityRecord {
52
52
  * this rather than storing/refetching full content to diff. */
53
53
  contentHashes: Record<string, string>;
54
54
  }
55
+ /** Every ability currently installed for `agentName`, as recorded in
56
+ * .loopengine-abilities.json — powers the Admin UI's Abilities tab so an
57
+ * operator can see what's already there before installing something new
58
+ * (and get a clear collision error, via installAbility itself, rather
59
+ * than guessing blind). */
60
+ export declare function listInstalledAbilities(agentName: string): Array<{
61
+ name: string;
62
+ } & InstalledAbilityRecord>;
55
63
  /** `npm pack <spec>` + tar extract — generalized from
56
64
  * create-loopengine's own `fetchPublishedTemplateDir` (that function
57
65
  * only ever fetches the hardcoded `create-loopengine@<version>`; this
@@ -117,6 +117,34 @@ function matchesInstalledToolName(abilityName, manifestToolFile, installedToolNa
117
117
  const bareToolName = basename(manifestToolFile, '.ts');
118
118
  return bareToolName === installedToolName || namespacedToolName(abilityName, bareToolName) === installedToolName;
119
119
  }
120
+ /** Two abilities can each declare a rule with the same bare `name` too —
121
+ * unlike a genuine `actauth` scope/tool/decision collision (which would
122
+ * be a real policy conflict worth refusing over), a bare *name* clash is
123
+ * just an addressing problem: `actauth`'s own Gate.evaluate()/
124
+ * RuleSet.resolve() never reads a rule's `name` at all (see
125
+ * models.js's own ruleSpecificity/scopeKey — resolution is entirely
126
+ * tool+scope+when), so two identically-named rules would never actually
127
+ * behave ambiguously at decision time. `name` only matters as
128
+ * addActauthRule's own lookup key (and the Admin UI's Actauth tab
129
+ * :ruleName route) — the same "it's an addressing problem, not a policy
130
+ * one" reasoning namespacedSkillId already applies to skill ids.
131
+ * Namespaced under the ability's own name only when the bare name is
132
+ * already taken, same fallback tools/skills already get; still refuses
133
+ * if even *that's* somehow already taken (two same-named installs of
134
+ * the same ability can't happen anyway — see AbilityAlreadyInstalledError
135
+ * above — so this only bites on a truly pathological manual edit). */
136
+ function namespacedRuleName(abilityName, ruleName) {
137
+ return `${sanitizeForToolName(abilityName)}__${ruleName}`;
138
+ }
139
+ /** matchesInstalledToolName's own sibling for rules — upgradeAbility needs
140
+ * this to recompute forward from a freshly re-parsed manifest's bare rule
141
+ * name and compare against provenance's possibly-namespaced one, rather
142
+ * than comparing bare names directly (which would silently treat every
143
+ * namespaced rule as unmatched, reporting it 'unchanged' instead of
144
+ * actually diffing it against the new version). */
145
+ function matchesInstalledRuleName(abilityName, manifestRuleName, installedRuleName) {
146
+ return manifestRuleName === installedRuleName || namespacedRuleName(abilityName, manifestRuleName) === installedRuleName;
147
+ }
120
148
  function provenancePath(agentName) {
121
149
  return join(agentDir(agentName), '.loopengine-abilities.json');
122
150
  }
@@ -126,6 +154,15 @@ function readProvenance(agentName) {
126
154
  return {};
127
155
  return JSON.parse(readFileSync(path, 'utf8'));
128
156
  }
157
+ /** Every ability currently installed for `agentName`, as recorded in
158
+ * .loopengine-abilities.json — powers the Admin UI's Abilities tab so an
159
+ * operator can see what's already there before installing something new
160
+ * (and get a clear collision error, via installAbility itself, rather
161
+ * than guessing blind). */
162
+ export function listInstalledAbilities(agentName) {
163
+ const provenance = readProvenance(agentName);
164
+ return Object.entries(provenance).map(([name, record]) => ({ name, ...record }));
165
+ }
129
166
  function writeProvenance(agentName, data) {
130
167
  writeFileSync(provenancePath(agentName), JSON.stringify(data, null, 2) + '\n');
131
168
  }
@@ -329,10 +366,18 @@ export async function installAbility(agentName, spec, options = {}) {
329
366
  skillIds.push(installedSkillId);
330
367
  }
331
368
  const existingRuleNames = new Set(readActauthConfig(agentName).rules.map((r) => r.name));
369
+ const ruleNames = [];
332
370
  for (const rule of rules) {
333
- if (existingRuleNames.has(rule.name)) {
334
- throw new AbilityCollisionError(`An actauth rule named '${rule.name}' already exists for '${agentName}'.`);
371
+ // The bare name if it's free; otherwise namespace under this
372
+ // ability's own name rather than refusing the install outright —
373
+ // see namespacedRuleName's own doc comment for why this is safe
374
+ // (a rule's `name` is an addressing key, not something `actauth`'s
375
+ // own resolution logic reads).
376
+ const installedRuleName = existingRuleNames.has(rule.name) ? namespacedRuleName(manifest.name, rule.name) : rule.name;
377
+ if (existingRuleNames.has(installedRuleName)) {
378
+ throw new AbilityCollisionError(`An actauth rule named '${installedRuleName}' already exists for '${agentName}'.`);
335
379
  }
380
+ ruleNames.push(installedRuleName);
336
381
  }
337
382
  const contentHashes = {};
338
383
  // Write tool files and patch tools/index.ts — pure filesystem
@@ -393,21 +438,25 @@ export async function installAbility(agentName, spec, options = {}) {
393
438
  contentHashes[`skills/${skillId}/SKILL.md`] = sha256(readFileSync(skillMdPath, 'utf8'));
394
439
  }
395
440
  }
396
- // Append actauth rules.
397
- for (const rule of rules) {
398
- addActauthRule(agentName, rule);
441
+ // Append actauth rules — ruleNames[i] is rules[i]'s already-resolved
442
+ // installed name (bare, or namespaced under this ability's own name if
443
+ // the bare name collided — computed once, above, not recomputed here,
444
+ // so this loop can't disagree with the collision check that already
445
+ // ran against it, same reasoning the skills-copy loop above follows).
446
+ for (const [i, rule] of rules.entries()) {
447
+ addActauthRule(agentName, { ...rule, name: ruleNames[i] });
399
448
  }
400
449
  provenance[manifest.name] = {
401
450
  version: manifest.version,
402
451
  spec,
403
452
  tools: toolNames,
404
453
  skills: skillIds,
405
- actauthRules: rules.map((r) => r.name),
454
+ actauthRules: ruleNames,
406
455
  env: manifest.env ?? [],
407
456
  contentHashes,
408
457
  };
409
458
  writeProvenance(agentName, provenance);
410
- return { installed: [...toolNames.map((n) => `tools/${n}.ts`), ...skillIds.map((id) => `skills/${id}/`), ...rules.map((r) => `actauth:${r.name}`)] };
459
+ return { installed: [...toolNames.map((n) => `tools/${n}.ts`), ...skillIds.map((id) => `skills/${id}/`), ...ruleNames.map((n) => `actauth:${n}`)] };
411
460
  }
412
461
  // ---- Upgrade ----
413
462
  // git merge-file's own exit code *is* its conflict count (0 = clean),
@@ -557,8 +606,11 @@ export async function upgradeAbility(agentName, abilityName, options = {}) {
557
606
  const newRules = parseActauthRules(newDir, newManifest);
558
607
  const currentRules = new Map(readActauthConfig(agentName).rules.map((r) => [r.name, r]));
559
608
  for (const ruleName of record.actauthRules) {
560
- const oldRule = oldRules.find((r) => r.name === ruleName);
561
- const newRule = newRules.find((r) => r.name === ruleName);
609
+ // matchesInstalledRuleName, not a bare r.name === ruleName comparison
610
+ // — ruleName may be namespaced (see namespacedRuleName), and the
611
+ // freshly re-parsed old/new manifests only ever carry the bare name.
612
+ const oldRule = oldRules.find((r) => matchesInstalledRuleName(abilityName, r.name, ruleName));
613
+ const newRule = newRules.find((r) => matchesInstalledRuleName(abilityName, r.name, ruleName));
562
614
  const current = currentRules.get(ruleName);
563
615
  const path = `actauth:${ruleName}`;
564
616
  if (!oldRule || !newRule || !current) {
package/dist/index.d.ts CHANGED
@@ -57,6 +57,10 @@ export { listDeclaredEnvVars, setEnvVar, EnvVarNameError } from './web/env-admin
57
57
  export type { DeclaredEnvVar } from './web/env-admin.js';
58
58
  export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
59
59
  export type { ActauthRuleInput, ActauthConfigView } from './web/actauth-admin.js';
60
+ export { installAbility, listInstalledAbilities, AbilityManifestError, AbilityVersionError, AbilityCollisionError, AbilityAlreadyInstalledError, } from './bin/ability-manager.js';
61
+ export type { InstalledAbilityRecord, AbilityEnvDecl } from './bin/ability-manager.js';
62
+ export { searchPublicAbilities } from './web/abilities-admin.js';
63
+ export type { PublicAbilitySearchResult } from './web/abilities-admin.js';
60
64
  export { describeModelProviders, describeGateways } from './web/global-config.js';
61
65
  export type { ModelProviderInfo, AgentModelUsage, ModelsView, GatewayProviderInfo, GatewaysView } from './web/global-config.js';
62
66
  export { createTrackedApprover, listApprovals, decideApproval, findApproval } from './web/web-approver.js';
package/dist/index.js CHANGED
@@ -51,6 +51,8 @@ export { playgroundHtml } from './web/playground.js';
51
51
  export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.js';
52
52
  export { listDeclaredEnvVars, setEnvVar, EnvVarNameError } from './web/env-admin.js';
53
53
  export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
54
+ export { installAbility, listInstalledAbilities, AbilityManifestError, AbilityVersionError, AbilityCollisionError, AbilityAlreadyInstalledError, } from './bin/ability-manager.js';
55
+ export { searchPublicAbilities } from './web/abilities-admin.js';
54
56
  export { describeModelProviders, describeGateways } from './web/global-config.js';
55
57
  export { createTrackedApprover, listApprovals, decideApproval, findApproval } from './web/web-approver.js';
56
58
  export { scaffoldAgent, scaffoldSubagent, AgentNameError, AgentExistsError, AgentNotFoundError, AgentModelError } from './bin/cli.js';
@@ -0,0 +1,6 @@
1
+ export interface PublicAbilitySearchResult {
2
+ name: string;
3
+ description: string;
4
+ version: string;
5
+ }
6
+ export declare function searchPublicAbilities(query?: string): Promise<PublicAbilitySearchResult[]>;
@@ -0,0 +1,26 @@
1
+ const NPM_SEARCH_TIMEOUT_MS = 10000;
2
+ const NPM_SEARCH_SIZE = 20;
3
+ export async function searchPublicAbilities(query) {
4
+ const text = query ? `keywords:loopengine-ability ${query}` : 'keywords:loopengine-ability';
5
+ const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(text)}&size=${NPM_SEARCH_SIZE}`;
6
+ const controller = new AbortController();
7
+ const timeout = setTimeout(() => controller.abort(), NPM_SEARCH_TIMEOUT_MS);
8
+ let res;
9
+ try {
10
+ res = await fetch(url, { signal: controller.signal });
11
+ }
12
+ catch (err) {
13
+ throw new Error(`Could not reach the npm registry: ${err instanceof Error ? err.message : String(err)}`);
14
+ }
15
+ finally {
16
+ clearTimeout(timeout);
17
+ }
18
+ if (!res.ok)
19
+ throw new Error(`npm registry search failed (HTTP ${res.status})`);
20
+ const data = (await res.json());
21
+ return (data.objects ?? []).map((o) => ({
22
+ name: o.package.name,
23
+ description: o.package.description ?? '',
24
+ version: o.package.version,
25
+ }));
26
+ }
@@ -407,6 +407,8 @@ export const agentsConfigPageHtml = `<!doctype html>
407
407
  var actauthLoadedFor = null;
408
408
  // Same idea again, for the Environment tab's own GET .../env.
409
409
  var envLoadedFor = null;
410
+ // Same idea again, for the Abilities tab's own GET .../abilities.
411
+ var abilitiesLoadedFor = null;
410
412
  // The full /agents/:name/config response the currently-open agent was
411
413
  // last rendered from — kept around so refreshActauthDependentPanels/
412
414
  // refreshSkillsDependentPanels below can re-fetch and re-render just
@@ -1208,6 +1210,172 @@ export const agentsConfigPageHtml = `<!doctype html>
1208
1210
  });
1209
1211
  }
1210
1212
 
1213
+ // ---- Abilities tab: what's already installed for this agent, a
1214
+ // search box over public (npm, tagged loopengine-ability) packages,
1215
+ // and a plain spec field for a private ability (a git URL, a local
1216
+ // file:../path, or a private registry name) — both installs go
1217
+ // through the same POST .../abilities, since installAbility itself
1218
+ // already resolves any spec npm pack accepts (see
1219
+ // bin/ability-manager.ts's own doc comment on fetchAbilityDir).
1220
+ // Lazily loaded the same way Actauth/Environment are. ----
1221
+
1222
+ function renderAbilitiesTabPlaceholder() {
1223
+ return '<section id="abilitiesSection"><div id="abilitiesContent"><p class="hint">Loading&hellip;</p></div></section>';
1224
+ }
1225
+
1226
+ function renderInstalledAbilityRow(a) {
1227
+ return '<tr>' +
1228
+ '<td><code>' + escapeHtml(a.name) + '</code></td>' +
1229
+ '<td>' + escapeHtml(a.version) + '</td>' +
1230
+ '<td class="hint">' + escapeHtml((a.tools || []).join(', ')) + '</td>' +
1231
+ '</tr>';
1232
+ }
1233
+
1234
+ function renderAbilitySearchResultRow(pkg) {
1235
+ return '<tr>' +
1236
+ '<td><code>' + escapeHtml(pkg.name) + '</code> <span class="hint">' + escapeHtml(pkg.version) + '</span></td>' +
1237
+ '<td>' + escapeHtml(pkg.description || '') + '</td>' +
1238
+ '<td><button type="button" class="ability-install-btn" data-spec="' + escapeHtml(pkg.name) + '">Install</button></td>' +
1239
+ '</tr>';
1240
+ }
1241
+
1242
+ function renderAbilitiesConfigHtml(data) {
1243
+ var installed = data.abilities || [];
1244
+ var installedHtml = installed.length
1245
+ ? '<table><thead><tr><th>Name</th><th>Version</th><th>Tools</th></tr></thead><tbody>' + installed.map(renderInstalledAbilityRow).join('') + '</tbody></table>'
1246
+ : '<p class="hint">No abilities installed for this agent yet.</p>';
1247
+
1248
+ return '<h3>Installed</h3>' + installedHtml +
1249
+ '<h3>Search public abilities</h3>' +
1250
+ '<form id="abilitySearchForm" class="add-source">' +
1251
+ '<input type="text" name="q" placeholder="Search by name or keyword (optional)">' +
1252
+ '<button type="submit">Search</button>' +
1253
+ '</form>' +
1254
+ '<div id="abilitySearchResults"></div>' +
1255
+ '<h3 style="margin-top:24px">Install from a spec</h3>' +
1256
+ '<p class="hint">A git URL, a local <code>file:../path</code>, or a private npm package name/version — for anything not published publicly.</p>' +
1257
+ '<form id="abilityInstallForm" class="add-source">' +
1258
+ '<input type="text" name="spec" placeholder="e.g. git+https://github.com/org/repo.git or file:../my-ability" required>' +
1259
+ '<button type="submit">Install</button>' +
1260
+ '</form>' +
1261
+ '<p id="abilityInstallStatus" class="hint"></p>';
1262
+ }
1263
+
1264
+ function abilitiesContentEl() {
1265
+ return detail.querySelector('#abilitiesContent');
1266
+ }
1267
+
1268
+ // Shared by both the search-results "Install" button and the
1269
+ // spec-form submit — same endpoint either way, installAbility itself
1270
+ // doesn't distinguish "found via search" from "pasted directly".
1271
+ function installAbilitySpec(name, spec, statusEl, btn) {
1272
+ if (btn) { btn.disabled = true; }
1273
+ statusEl.className = 'hint';
1274
+ statusEl.textContent = 'Installing ' + spec + '…';
1275
+ fetch('/agents/' + encodeURIComponent(name) + '/abilities', {
1276
+ method: 'POST',
1277
+ headers: { 'content-type': 'application/json' },
1278
+ body: JSON.stringify({ spec: spec }),
1279
+ })
1280
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
1281
+ .then(function (result) {
1282
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
1283
+ // Same "is this dev or serve" distinction the CLI's own
1284
+ // add-ability command prints after installing — a web install
1285
+ // doesn't splice into an already-running server's in-memory
1286
+ // registry any more than the CLI does (see installAbility's own
1287
+ // doc comment on why), so the operator needs the same nudge here.
1288
+ statusEl.textContent = 'Installed: ' + result.body.installed.join(', ') + '. Already running under npx loopengine dev? It becomes active automatically. Running under serve (or nothing yet)? Restart the server to pick it up.';
1289
+ loadAbilitiesTab(name);
1290
+ })
1291
+ .catch(function (err) {
1292
+ statusEl.className = 'error';
1293
+ statusEl.textContent = 'Could not install: ' + err.message;
1294
+ if (btn) { btn.disabled = false; }
1295
+ });
1296
+ }
1297
+
1298
+ // Shared by the initial (empty-query) load right after the tab opens
1299
+ // and the search form's own submit — the empty-query case is just
1300
+ // "list every published ability" (the keyword filter alone, no extra
1301
+ // search text), so an operator sees what's out there immediately
1302
+ // instead of having to search for something before anything shows up.
1303
+ function runAbilitySearch(name, q, content, resultsEl) {
1304
+ resultsEl.innerHTML = '<p class="hint">Loading&hellip;</p>';
1305
+ fetch('/agents/' + encodeURIComponent(name) + '/abilities/search' + (q ? '?q=' + encodeURIComponent(q) : ''))
1306
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
1307
+ .then(function (result) {
1308
+ if (!result.ok) throw new Error(result.body.error || 'search failed');
1309
+ var results = result.body.results || [];
1310
+ resultsEl.innerHTML = results.length
1311
+ ? '<table><thead><tr><th>Package</th><th>Description</th><th></th></tr></thead><tbody>' + results.map(renderAbilitySearchResultRow).join('') + '</tbody></table>'
1312
+ : '<p class="hint">No public abilities found.</p>';
1313
+ var installBtns = resultsEl.querySelectorAll('.ability-install-btn');
1314
+ for (var i = 0; i < installBtns.length; i++) {
1315
+ installBtns[i].addEventListener('click', function (ev2) {
1316
+ var btn = ev2.currentTarget;
1317
+ installAbilitySpec(name, btn.dataset.spec, content.querySelector('#abilityInstallStatus'), btn);
1318
+ });
1319
+ }
1320
+ })
1321
+ .catch(function (err) {
1322
+ resultsEl.innerHTML = '<p class="error">' + escapeHtml(err.message) + '</p>';
1323
+ });
1324
+ }
1325
+
1326
+ function wireAbilitiesHandlers(name) {
1327
+ var content = abilitiesContentEl();
1328
+ if (!content) return;
1329
+
1330
+ var searchForm = content.querySelector('#abilitySearchForm');
1331
+ var resultsEl = content.querySelector('#abilitySearchResults');
1332
+ if (searchForm && resultsEl) {
1333
+ searchForm.addEventListener('submit', function (ev) {
1334
+ ev.preventDefault();
1335
+ var q = new FormData(searchForm).get('q');
1336
+ runAbilitySearch(name, q, content, resultsEl);
1337
+ });
1338
+ // List every published ability immediately, not just after the
1339
+ // operator explicitly searches for something.
1340
+ runAbilitySearch(name, '', content, resultsEl);
1341
+ }
1342
+
1343
+ var installForm = content.querySelector('#abilityInstallForm');
1344
+ if (installForm) {
1345
+ installForm.addEventListener('submit', function (ev) {
1346
+ ev.preventDefault();
1347
+ var spec = new FormData(installForm).get('spec');
1348
+ var statusEl = content.querySelector('#abilityInstallStatus');
1349
+ var btn = installForm.querySelector('button[type="submit"]');
1350
+ installAbilitySpec(name, spec, statusEl, btn);
1351
+ });
1352
+ }
1353
+ }
1354
+
1355
+ function applyAbilitiesConfig(name, data) {
1356
+ var content = abilitiesContentEl();
1357
+ if (!content) return;
1358
+ content.innerHTML = renderAbilitiesConfigHtml(data);
1359
+ wireAbilitiesHandlers(name);
1360
+ abilitiesLoadedFor = name;
1361
+ }
1362
+
1363
+ function loadAbilitiesTab(name) {
1364
+ var content = abilitiesContentEl();
1365
+ if (!content) return;
1366
+ content.innerHTML = '<p class="hint">Loading&hellip;</p>';
1367
+ fetch('/agents/' + encodeURIComponent(name) + '/abilities')
1368
+ .then(function (r) { return r.json(); })
1369
+ .then(function (data) {
1370
+ if (currentName !== name) return;
1371
+ applyAbilitiesConfig(name, data);
1372
+ })
1373
+ .catch(function (err) {
1374
+ if (currentName !== name) return;
1375
+ content.innerHTML = '<p class="error">Could not load abilities: ' + escapeHtml(err.message) + '</p>';
1376
+ });
1377
+ }
1378
+
1211
1379
  // ---- Skills tab: editable — add/edit/delete SKILL.md files via
1212
1380
  // skills-admin.ts, restricted (see that file's own doc comment) to
1213
1381
  // flat, non-nested skill ids. Unlike the Tools tab's Gateway Tools
@@ -1995,6 +2163,9 @@ export const agentsConfigPageHtml = `<!doctype html>
1995
2163
  if (tab === 'env' && envLoadedFor !== currentName) {
1996
2164
  loadEnvTab(currentName);
1997
2165
  }
2166
+ if (tab === 'abilities' && abilitiesLoadedFor !== currentName) {
2167
+ loadAbilitiesTab(currentName);
2168
+ }
1998
2169
  }
1999
2170
 
2000
2171
  // Refreshes just Overview's own summary — used after anything that
@@ -2070,13 +2241,15 @@ export const agentsConfigPageHtml = `<!doctype html>
2070
2241
  '<button class="tab" data-tab="skills">Skills</button>' +
2071
2242
  '<button class="tab" data-tab="tools">Tools</button>' +
2072
2243
  '<button class="tab" data-tab="actauth">ActAuth</button>' +
2244
+ '<button class="tab" data-tab="abilities">Abilities</button>' +
2073
2245
  '<button class="tab" data-tab="env">Environment</button>' +
2074
2246
  '</div>' +
2075
2247
  '<div class="tab-panel" data-tab-panel="overview">' + renderOverviewHtml(cfg) + '</div>' +
2076
2248
  '<div class="tab-panel" data-tab-panel="skills">' + renderSkillsTabHtml(cfg) + '</div>' +
2077
2249
  '<div class="tab-panel" data-tab-panel="tools">' + renderToolsTabHtml(cfg) + '</div>' +
2078
2250
  '<div class="tab-panel" data-tab-panel="actauth">' + renderActauthTabPlaceholder() + '</div>' +
2079
- '<div class="tab-panel" data-tab-panel="env">' + renderEnvTabPlaceholder() + '</div>';
2251
+ '<div class="tab-panel" data-tab-panel="env">' + renderEnvTabPlaceholder() + '</div>' +
2252
+ '<div class="tab-panel" data-tab-panel="abilities">' + renderAbilitiesTabPlaceholder() + '</div>';
2080
2253
 
2081
2254
  var buttons = detail.querySelectorAll('.tabs button');
2082
2255
  for (var i = 0; i < buttons.length; i++) {
@@ -2101,6 +2274,7 @@ export const agentsConfigPageHtml = `<!doctype html>
2101
2274
  gatewayLoadedFor = null;
2102
2275
  actauthLoadedFor = null;
2103
2276
  envLoadedFor = null;
2277
+ abilitiesLoadedFor = null;
2104
2278
  var items = agentList.querySelectorAll('li');
2105
2279
  for (var i = 0; i < items.length; i++) {
2106
2280
  items[i].classList.toggle('active', items[i].dataset.name === name);
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.28",
3
+ "version": "0.1.31",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",
7
7
  "homepage": "https://loopengine.co",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "git+https://github.com/evanyan22/loopengine.git"
10
+ "url": "git+https://github.com/loopengine-co/loopengine.git"
11
11
  },
12
12
  "main": "dist/index.js",
13
13
  "types": "dist/index.d.ts",