loopengine 0.1.5 → 0.1.6

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/PROTOCOL.md CHANGED
@@ -190,7 +190,7 @@ loopengine run customer-service --json "order A-1001 arrived broken, can you ref
190
190
 
191
191
  ```
192
192
  {"type":"session","sessionId":"5a83c6b4-..."}
193
- {"type":"budget:check","action":"ok","usedTokens":2,"budgetTokens":8000,"ratio":0.00025}
193
+ {"type":"budget:check","action":"ok","usedTokens":2,"budgetTokens":100000,"ratio":0.00002}
194
194
  {"type":"tool:started","id":"call_1","tool":"issue_refund", ...}
195
195
  {"type":"done","text":"Refunded $42.00..."}
196
196
  ```
@@ -310,7 +310,12 @@ async function buildTurnContext(config, modelCall, options) {
310
310
  // that doesn't exist (a flat-file agent with no skills at all)
311
311
  // harmless: an empty index, not an error.
312
312
  const skillsDirs = config.skillsDirs ?? [`agents/${config.name}/skills`];
313
- const skillGarden = new SkillGarden({ dirs: [...skillsDirs, systemSkillsDir], indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 });
313
+ // 2000, not an arbitrary round number: skillgarden's own default is
314
+ // ~1% of a real agent context window (~100k-200k tokens for the
315
+ // providers this repo wires up) — see its own SkillGardenOptions doc
316
+ // comment. loopengine used to default lower (200), which left barely
317
+ // enough room for a handful of skills before truncating the index.
318
+ const skillGarden = new SkillGarden({ dirs: [...skillsDirs, systemSkillsDir], indexBudgetTokens: config.skillIndexBudgetTokens ?? 2000 });
314
319
  const skillIndex = skillGarden.buildIndex().included;
315
320
  // Also the tail-preservation window recover() below relies on — kept as
316
321
  // one named constant so the two can never drift out of sync.
@@ -321,8 +326,17 @@ async function buildTurnContext(config, modelCall, options) {
321
326
  // budgetTokens/softThreshold so a nudge firing and compaction's own
322
327
  // recovery target agree — see budget.ts's own BudgetTrackerOptions doc
323
328
  // comment.
324
- const budgetTracker = new BudgetTracker({ budgetTokens: config.contextBudgetTokens ?? 8000 });
325
- const compactor = new Compactor({ budgetTokens: config.contextBudgetTokens ?? 8000, softThreshold: budgetTracker.softThreshold, tailMessages });
329
+ // 100000, not 8000: the providers this repo wires up (Claude, GPT-4o,
330
+ // DeepSeek) all give a real conversation 128k-200k tokens of window,
331
+ // and 8000 was small enough that a handful of tool calls (each with a
332
+ // full JSON schema + result) routinely tripped compaction on an
333
+ // otherwise ordinary turn. 100000 leaves headroom under even the
334
+ // smallest of those windows for the system prompt, tool schemas, and
335
+ // the model's own response, while softThreshold's default 0.7 (a
336
+ // 70000-token nudge point) and hardThreshold's 0.92 still leave real
337
+ // margin before hitting it.
338
+ const budgetTracker = new BudgetTracker({ budgetTokens: config.contextBudgetTokens ?? 100000 });
339
+ const compactor = new Compactor({ budgetTokens: config.contextBudgetTokens ?? 100000, softThreshold: budgetTracker.softThreshold, tailMessages });
326
340
  const rules = loadRules(config);
327
341
  // AgentConfig.httpNotifier only ever stands in for the http channel —
328
342
  // see its own doc comment for why cli/http_stream never consult it at
package/dist/index.d.ts CHANGED
@@ -35,6 +35,8 @@ export { globalConfigPageHtml } from './web/global-config-page.js';
35
35
  export { playgroundHtml } from './web/playground.js';
36
36
  export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.js';
37
37
  export type { SkillContent } from './web/skills-admin.js';
38
+ export { listSkillgardenCatalog, readSkillgardenCatalogEntry, addSkillgardenSkillToAgent, SkillgardenUnavailableError } from './web/skillgarden-admin.js';
39
+ export type { SkillgardenCatalogEntry, SkillgardenCatalogDetail } from './web/skillgarden-admin.js';
38
40
  export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
39
41
  export type { ActauthRuleInput, ActauthConfigView } from './web/actauth-admin.js';
40
42
  export { describeModelProviders, describeGateways } from './web/global-config.js';
package/dist/index.js CHANGED
@@ -40,6 +40,7 @@ export { agentsListPageHtml } from './web/agents-list-page.js';
40
40
  export { globalConfigPageHtml } from './web/global-config-page.js';
41
41
  export { playgroundHtml } from './web/playground.js';
42
42
  export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.js';
43
+ export { listSkillgardenCatalog, readSkillgardenCatalogEntry, addSkillgardenSkillToAgent, SkillgardenUnavailableError } from './web/skillgarden-admin.js';
43
44
  export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
44
45
  export { describeModelProviders, describeGateways } from './web/global-config.js';
45
46
  export { createTrackedApprover, listApprovals, decideApproval, findApproval } from './web/web-approver.js';
@@ -9,6 +9,9 @@ export interface AgentEditableFields {
9
9
  provider: Provider;
10
10
  model?: string;
11
11
  };
12
+ maxTurns?: number;
13
+ contextBudgetTokens?: number;
14
+ skillIndexBudgetTokens?: number;
12
15
  }
13
16
  export interface AgentEditResult {
14
17
  systemPrompt?: string;
@@ -16,6 +19,9 @@ export interface AgentEditResult {
16
19
  provider: Provider;
17
20
  model: string;
18
21
  };
22
+ maxTurns?: number;
23
+ contextBudgetTokens?: number;
24
+ skillIndexBudgetTokens?: number;
19
25
  }
20
26
  /** Surgically rewrites just the fields given in `fields` — see this
21
27
  * file's own header comment for why, and for the "refuse rather than
@@ -89,6 +89,31 @@ function findConfigObjectLiteral(sourceFile) {
89
89
  function findProperty(obj, name) {
90
90
  return obj.properties.find((p) => p.name !== undefined && ts.isIdentifier(p.name) && p.name.text === name);
91
91
  }
92
+ // maxTurns/contextBudgetTokens/skillIndexBudgetTokens are the only three
93
+ // AgentConfig fields this file edits that are *usually absent* — every
94
+ // agent in this repo, and create-loopengine's own scaffold template,
95
+ // leaves all three unset and rides run-agent.ts's own defaults (25 /
96
+ // 100000 / 2000), unlike systemPrompt/model which agentIndexTemplate always
97
+ // writes out explicitly. So "the property isn't in this file" can't mean
98
+ // refuse here the way it does for a genuinely unusual systemPrompt/model
99
+ // shape — that would make every freshly scaffolded agent's limits
100
+ // permanently uneditable through this API. Missing properties are
101
+ // collected into `toInsert` instead and spliced in as new lines just
102
+ // before the object literal's closing brace (see editAgentFile's own use
103
+ // of this); an *existing* property whose value isn't a plain number
104
+ // literal is still refused, same "don't guess" rule as everything else
105
+ // in this file.
106
+ function upsertNumericProperty(configObj, sourceFile, edits, toInsert, name, value) {
107
+ const prop = findProperty(configObj, name);
108
+ if (!prop) {
109
+ toInsert.push(`${name}: ${value}`);
110
+ return;
111
+ }
112
+ if (!ts.isPropertyAssignment(prop) || !ts.isNumericLiteral(prop.initializer)) {
113
+ throw new AgentEditNotSupportedError(`${name} in this file is not a plain number literal — edit it directly.`);
114
+ }
115
+ edits.push({ start: prop.initializer.getStart(sourceFile), end: prop.initializer.getEnd(), text: String(value) });
116
+ }
92
117
  /** Surgically rewrites just the fields given in `fields` — see this
93
118
  * file's own header comment for why, and for the "refuse rather than
94
119
  * guess" rule this follows for anything that isn't a plain, recognizable
@@ -150,6 +175,31 @@ export function editAgentFile(agentName, fields) {
150
175
  edits.push({ start: comment.pos, end: comment.end, text: `// reads ${MODEL_ENV_VAR[provider]}` });
151
176
  }
152
177
  }
178
+ const toInsert = [];
179
+ for (const name of ['maxTurns', 'contextBudgetTokens', 'skillIndexBudgetTokens']) {
180
+ const value = fields[name];
181
+ if (value === undefined)
182
+ continue;
183
+ if (!Number.isInteger(value) || value < 1) {
184
+ throw new AgentEditNotSupportedError(`${name} must be a positive integer.`);
185
+ }
186
+ upsertNumericProperty(configObj, sourceFile, edits, toInsert, name, value);
187
+ result[name] = value;
188
+ }
189
+ if (toInsert.length) {
190
+ // Insert right before the closing "}", not right after the last
191
+ // property node — a hand-written trailing line comment there (like
192
+ // model's own "// reads ANTHROPIC_API_KEY" above) would otherwise end
193
+ // up with new properties spliced into the middle of it. The object's
194
+ // own pre-existing newline-before-"}" becomes the separator before
195
+ // the first inserted line for free; only an object with *no*
196
+ // properties at all needs its own leading newline.
197
+ const closeBracePos = configObj.getEnd() - 1;
198
+ const hasProps = configObj.properties.length > 0;
199
+ const needsLeadingComma = hasProps && !configObj.properties.hasTrailingComma;
200
+ const text = (hasProps ? '' : '\n') + (needsLeadingComma ? ',' : '') + toInsert.map((p) => ` ${p},\n`).join('');
201
+ edits.push({ start: closeBracePos, end: closeBracePos, text });
202
+ }
153
203
  if (!edits.length)
154
204
  return result;
155
205
  // Apply from the end of the file backward — each edit's start/end was
@@ -131,12 +131,12 @@ export const agentsConfigPageHtml = `<!doctype html>
131
131
  letter-spacing: normal;
132
132
  padding: 2px 8px;
133
133
  }
134
- .http-tool-subsection {
134
+ .admin-subsection {
135
135
  margin-top: 14px;
136
136
  padding-top: 14px;
137
137
  border-top: 1px dashed light-dark(#eee, #2a2a2e);
138
138
  }
139
- .http-tool-subsection h4 {
139
+ .admin-subsection h4 {
140
140
  font-size: 12px;
141
141
  text-transform: uppercase;
142
142
  letter-spacing: 0.04em;
@@ -309,6 +309,84 @@ export const agentsConfigPageHtml = `<!doctype html>
309
309
  stretches the checkbox to the full row width, which visually
310
310
  overlaps the description text under it instead of sitting beside it. */
311
311
  form.add-source input[type="checkbox"] { width: auto; flex-shrink: 0; margin-top: 2px; }
312
+ /* Same column-stacking issue as .tool-picker-item above: .http-tool-
313
+ inline-label (the "required" / "send as JSON body" checkboxes in the
314
+ HTTP tool builder) is a <label> too, so "form.add-source label" wins
315
+ on specificity alone and stacks the checkbox above its own text
316
+ instead of beside it — this rule needs to match "label" too to win. */
317
+ form.add-source label.http-tool-inline-label {
318
+ display: flex;
319
+ flex-direction: row;
320
+ align-items: center;
321
+ gap: 6px;
322
+ }
323
+ /* These three are plain <div>s of sibling inputs/selects/buttons, not
324
+ <label>s, so none of the rules above touch them — without an explicit
325
+ row layout they just wrap at the browser's default inline spacing,
326
+ which reads as everything jammed together with no breathing room.
327
+ form.add-source itself caps at max-width: 480px (see above), which
328
+ isn't wide enough to hold a field row's 5 items (name/type/
329
+ description/required/delete) on one line at a readable width, so
330
+ this wraps rather than forcing a single cramped line — each item
331
+ gets a min-width so text inputs wrap onto their own line instead of
332
+ shrinking to an unreadable sliver. */
333
+ .http-tool-field-row, .http-tool-header-row, .http-tool-method-url {
334
+ display: flex;
335
+ flex-wrap: wrap;
336
+ align-items: center;
337
+ gap: 8px;
338
+ margin-bottom: 8px;
339
+ }
340
+ /* Same "form.add-source input { width: 100% }" override as the checkbox
341
+ fix above — each field here sits beside its siblings in a row, not
342
+ stacked full-width above them. */
343
+ .http-tool-field-row input[type="text"],
344
+ .http-tool-field-row select,
345
+ .http-tool-header-row input[type="text"],
346
+ .http-tool-method-url input[type="text"],
347
+ .http-tool-method-url select {
348
+ width: auto;
349
+ }
350
+ .http-tool-field-row .http-tool-field-name { flex: 1 1 100px; min-width: 100px; }
351
+ .http-tool-field-row .http-tool-field-type { flex: 0 0 auto; }
352
+ .http-tool-field-row .http-tool-field-description { flex: 2 1 140px; min-width: 140px; }
353
+ .http-tool-field-row .http-tool-inline-label { flex: 0 0 auto; white-space: nowrap; }
354
+ .http-tool-header-row .http-tool-header-key { flex: 1 1 100px; min-width: 100px; }
355
+ .http-tool-header-row .http-tool-header-value { flex: 2 1 140px; min-width: 140px; }
356
+ .http-tool-method-url select { flex: 0 0 auto; }
357
+ .http-tool-method-url input[type="text"] { flex: 1 1 160px; min-width: 160px; }
358
+ .http-tool-field-row .http-tool-remove-row,
359
+ .http-tool-header-row .http-tool-remove-row {
360
+ flex: 0 0 auto;
361
+ margin-left: auto;
362
+ }
363
+ /* Taller than .hint-btn's own 2px 8px default (shared with the Gateway
364
+ Tools "Refresh" button, which sits inline in an <h3> and shouldn't
365
+ grow) — these two sit on their own line below the field/header rows,
366
+ so there's no header-row height to match. */
367
+ #httpToolAddFieldBtn, #httpToolAddHeaderBtn {
368
+ padding: 6px 12px;
369
+ }
370
+ .skillgarden-category-badge {
371
+ display: inline-block;
372
+ font-family: ui-sans-serif, system-ui, sans-serif;
373
+ font-size: 10px;
374
+ font-weight: 600;
375
+ text-transform: uppercase;
376
+ letter-spacing: 0.03em;
377
+ padding: 1px 7px;
378
+ border-radius: 999px;
379
+ background: light-dark(#eef1ec, #1e2519);
380
+ color: light-dark(#3f6b4a, #7fbf8f);
381
+ margin-right: 6px;
382
+ }
383
+ #skillgardenFilterInput { margin-bottom: 8px; }
384
+ .skillgarden-catalog-body {
385
+ margin-top: 8px;
386
+ padding-top: 8px;
387
+ border-top: 1px solid light-dark(#eee, #2a2a2e);
388
+ font-size: 13px;
389
+ }
312
390
  </style>
313
391
  </head>
314
392
  <body>
@@ -599,15 +677,31 @@ export const agentsConfigPageHtml = `<!doctype html>
599
677
  '<dt>tenantFor</dt><dd>' + badge(cfg.tenantFor.indexOf('custom') === 0 ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.tenantFor) + '</span></dd>' +
600
678
  '<dt>isSafeTool</dt><dd>' + badge(cfg.isSafeTool === 'custom' ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.isSafeTool) + '</span></dd>' +
601
679
  '</dl></section>' +
602
- '<section><h3>Limits &amp; budgets</h3><dl class="kv">' +
603
- '<dt>maxTurns</dt><dd>' + escapeHtml(cfg.maxTurns) + '</dd>' +
604
- '<dt>contextBudgetTokens</dt><dd>' + escapeHtml(cfg.contextBudgetTokens) + '</dd>' +
605
- '<dt>skillIndexBudgetTokens</dt><dd>' + escapeHtml(cfg.skillIndexBudgetTokens) + '</dd>' +
606
- '<dt>skillsDirs</dt><dd>' + escapeHtml(cfg.skillsDirs.join(', ') || '(none)') + '</dd>' +
607
- '</dl></section>';
680
+ '<section><h3>Limits &amp; budgets <button type="button" class="edit-btn" id="editLimitsBtn">Edit</button></h3>' +
681
+ '<dl class="kv" id="limitsDisplay">' +
682
+ '<dt>maxTurns</dt><dd>' + escapeHtml(cfg.maxTurns) + '</dd>' +
683
+ '<dt>contextBudgetTokens</dt><dd>' + escapeHtml(cfg.contextBudgetTokens) + '</dd>' +
684
+ '<dt>skillIndexBudgetTokens</dt><dd>' + escapeHtml(cfg.skillIndexBudgetTokens) + '</dd>' +
685
+ '</dl>' +
686
+ '<form class="add-source" id="limitsForm" style="display:none">' +
687
+ '<label>Max turns <span class="hint">(hard cap on model calls in one turn)</span>' +
688
+ '<input type="number" name="maxTurns" min="1" step="1" value="' + escapeHtml(cfg.maxTurns) + '">' +
689
+ '</label>' +
690
+ '<label>Context budget <span class="hint">(tokens — triggers compaction of older messages once exceeded)</span>' +
691
+ '<input type="number" name="contextBudgetTokens" min="1" step="1" value="' + escapeHtml(cfg.contextBudgetTokens) + '">' +
692
+ '</label>' +
693
+ '<label>Skill index budget <span class="hint">(tokens — caps the always-loaded skill name/description index)</span>' +
694
+ '<input type="number" name="skillIndexBudgetTokens" min="1" step="1" value="' + escapeHtml(cfg.skillIndexBudgetTokens) + '">' +
695
+ '</label>' +
696
+ '<button type="submit">Save</button>' +
697
+ '<button type="button" id="cancelLimitsBtn">Cancel</button>' +
698
+ '<div class="error" id="limitsError"></div>' +
699
+ '</form>' +
700
+ '<dl class="kv"><dt>skillsDirs</dt><dd>' + escapeHtml(cfg.skillsDirs.join(', ') || '(none)') + '</dd></dl>' +
701
+ '</section>';
608
702
  }
609
703
 
610
- // Wires the System prompt / Model Edit buttons rendered by
704
+ // Wires the System prompt / Model / Limits & budgets Edit buttons rendered by
611
705
  // renderOverviewHtml above — called every time that HTML gets (re)set
612
706
  // (renderDetail, refreshOverviewPanel, refreshSkillsDependentPanels),
613
707
  // same pattern every other tab's own wireXHandlers already follows.
@@ -708,6 +802,53 @@ export const agentsConfigPageHtml = `<!doctype html>
708
802
  });
709
803
  });
710
804
  }
805
+
806
+ var editLimitsBtn = overviewPanel.querySelector('#editLimitsBtn');
807
+ if (editLimitsBtn) {
808
+ var limitsDisplay = overviewPanel.querySelector('#limitsDisplay');
809
+ var limitsForm = overviewPanel.querySelector('#limitsForm');
810
+ var limitsError = overviewPanel.querySelector('#limitsError');
811
+
812
+ editLimitsBtn.addEventListener('click', function () {
813
+ limitsDisplay.style.display = 'none';
814
+ editLimitsBtn.style.display = 'none';
815
+ limitsForm.style.display = '';
816
+ });
817
+ overviewPanel.querySelector('#cancelLimitsBtn').addEventListener('click', function () {
818
+ limitsForm.style.display = 'none';
819
+ limitsDisplay.style.display = '';
820
+ editLimitsBtn.style.display = '';
821
+ limitsError.textContent = '';
822
+ });
823
+ limitsForm.addEventListener('submit', function (ev) {
824
+ ev.preventDefault();
825
+ limitsError.textContent = '';
826
+ var data = new FormData(limitsForm);
827
+ var submitBtn = limitsForm.querySelector('button[type="submit"]');
828
+ submitBtn.disabled = true;
829
+ var body = {
830
+ maxTurns: Number(data.get('maxTurns')),
831
+ contextBudgetTokens: Number(data.get('contextBudgetTokens')),
832
+ skillIndexBudgetTokens: Number(data.get('skillIndexBudgetTokens')),
833
+ };
834
+ fetch('/agents/' + encodeURIComponent(name), {
835
+ method: 'PUT',
836
+ headers: { 'content-type': 'application/json' },
837
+ body: JSON.stringify(body),
838
+ })
839
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
840
+ .then(function (result) {
841
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
842
+ currentCfg = result.body;
843
+ overviewPanel.innerHTML = renderOverviewHtml(result.body);
844
+ wireOverviewHandlers(name);
845
+ })
846
+ .catch(function (err) {
847
+ limitsError.textContent = err.message;
848
+ submitBtn.disabled = false;
849
+ });
850
+ });
851
+ }
711
852
  }
712
853
 
713
854
  // ---- Overview's own Actauth section: read-only, from cfg.permissions
@@ -1016,18 +1157,194 @@ export const agentsConfigPageHtml = `<!doctype html>
1016
1157
  '<button type="submit" id="skillSubmitBtn">Add</button>' +
1017
1158
  '<button type="button" id="skillCancelBtn" style="display:none">Cancel</button>' +
1018
1159
  '<div id="skillFormError" class="error"></div>' +
1019
- '</form></section>';
1160
+ '</form></section>' +
1161
+ '<section><h3>Browse skillgarden catalog</h3>' +
1162
+ '<input type="text" id="skillgardenFilterInput" placeholder="Filter by name or category&hellip;">' +
1163
+ '<div id="skillgardenCatalogList"><p class="hint">Loading&hellip;</p></div>' +
1164
+ '</section>';
1020
1165
  }
1021
1166
 
1022
1167
  function skillsPanelEl() {
1023
1168
  return detail.querySelector('[data-tab-panel="skills"]');
1024
1169
  }
1025
1170
 
1171
+ // ---- Skills tab's "Browse skillgarden catalog" section: a
1172
+ // read-only, agent-independent list of everything GET /skillgarden-
1173
+ // catalog reports (see web/skillgarden-admin.ts) — cached in this one
1174
+ // module-level variable, not per-agent like gatewayLoadedFor, since
1175
+ // the catalog itself never varies by agent. renderSkillsTabHtml
1176
+ // regenerates the whole Skills panel (including this section's own
1177
+ // markup) on every agent switch/refresh, so loadSkillgardenCatalogSection
1178
+ // re-renders from this cache instead of re-fetching every time — only
1179
+ // the very first time this tab is opened in the page's lifetime hits
1180
+ // the network at all. ----
1181
+ var skillgardenCatalog = null;
1182
+
1183
+ function skillgardenCatalogListEl() {
1184
+ var panel = skillsPanelEl();
1185
+ return panel ? panel.querySelector('#skillgardenCatalogList') : null;
1186
+ }
1187
+
1188
+ function renderSkillgardenCatalogEntry(entry) {
1189
+ return '<div class="source" data-category="' + escapeHtml(entry.category) + '" data-id="' + escapeHtml(entry.id) + '">' +
1190
+ '<div class="source-head">' +
1191
+ '<h4><span class="skillgarden-category-badge">' + escapeHtml(entry.category) + '</span>' + escapeHtml(entry.id) + '</h4>' +
1192
+ '<span>' +
1193
+ '<button type="button" class="skillgarden-view-btn" data-category="' + escapeHtml(entry.category) + '" data-id="' + escapeHtml(entry.id) + '">View</button> ' +
1194
+ '<button type="button" class="skillgarden-add-btn" data-category="' + escapeHtml(entry.category) + '" data-id="' + escapeHtml(entry.id) + '">Add to this agent</button>' +
1195
+ '</span>' +
1196
+ '</div>' +
1197
+ '<p class="hint">' + escapeHtml(entry.description) + '</p>' +
1198
+ '<div class="skillgarden-catalog-body" style="display:none"></div>' +
1199
+ '</div>';
1200
+ }
1201
+
1202
+ function renderSkillgardenCatalogList(entries) {
1203
+ return entries.length ? entries.map(renderSkillgardenCatalogEntry).join('') : '<p class="hint">No skills match.</p>';
1204
+ }
1205
+
1206
+ function applySkillgardenCatalog(name, entries) {
1207
+ var listEl = skillgardenCatalogListEl();
1208
+ if (!listEl) return;
1209
+ listEl.innerHTML = renderSkillgardenCatalogList(entries);
1210
+ wireSkillgardenCatalogHandlers(name);
1211
+ }
1212
+
1213
+ function filteredSkillgardenCatalog(filterText) {
1214
+ if (!filterText) return skillgardenCatalog;
1215
+ var needle = filterText.toLowerCase();
1216
+ return skillgardenCatalog.filter(function (entry) {
1217
+ return entry.category.toLowerCase().indexOf(needle) !== -1 || entry.id.toLowerCase().indexOf(needle) !== -1;
1218
+ });
1219
+ }
1220
+
1221
+ function loadSkillgardenCatalogSection(name) {
1222
+ var listEl = skillgardenCatalogListEl();
1223
+ if (!listEl) return;
1224
+ if (skillgardenCatalog) {
1225
+ var panel = skillsPanelEl();
1226
+ var filterInput = panel ? panel.querySelector('#skillgardenFilterInput') : null;
1227
+ applySkillgardenCatalog(name, filteredSkillgardenCatalog(filterInput ? filterInput.value.trim() : ''));
1228
+ return;
1229
+ }
1230
+ listEl.innerHTML = '<p class="hint">Loading&hellip;</p>';
1231
+ fetch('/skillgarden-catalog')
1232
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
1233
+ .then(function (result) {
1234
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
1235
+ skillgardenCatalog = result.body;
1236
+ if (currentName !== name) return;
1237
+ applySkillgardenCatalog(name, skillgardenCatalog);
1238
+ })
1239
+ .catch(function (err) {
1240
+ if (currentName !== name) return;
1241
+ listEl.innerHTML = '<p class="error">Could not load skillgarden catalog: ' + escapeHtml(err.message) + '</p>';
1242
+ });
1243
+ }
1244
+
1245
+ // force=true retries as an explicit overwrite after the operator
1246
+ // confirms — same "ask once a plain add collides, don't ask up front"
1247
+ // flow the rest of this page never has to think about since nothing
1248
+ // else lets an operator add something whose id they don't already
1249
+ // control the uniqueness of themselves.
1250
+ function performSkillgardenAdd(name, category, id, btn, originalText, force) {
1251
+ fetch('/agents/' + encodeURIComponent(name) + '/skillgarden-catalog/' + encodeURIComponent(category) + '/' + encodeURIComponent(id), {
1252
+ method: 'POST',
1253
+ headers: { 'content-type': 'application/json' },
1254
+ body: JSON.stringify({ force: !!force }),
1255
+ })
1256
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
1257
+ .then(function (result) {
1258
+ if (result.ok) {
1259
+ refreshSkillsDependentPanels(name);
1260
+ return;
1261
+ }
1262
+ if (!force && /already exists/.test(result.body.error || '')) {
1263
+ if (confirm('A skill named "' + id + '" already exists for this agent. Overwrite it?')) {
1264
+ performSkillgardenAdd(name, category, id, btn, originalText, true);
1265
+ return;
1266
+ }
1267
+ btn.disabled = false;
1268
+ btn.textContent = originalText;
1269
+ return;
1270
+ }
1271
+ throw new Error(result.body.error || 'request failed');
1272
+ })
1273
+ .catch(function (err) {
1274
+ alert('Could not add "' + id + '": ' + err.message);
1275
+ btn.disabled = false;
1276
+ btn.textContent = originalText;
1277
+ });
1278
+ }
1279
+
1280
+ function wireSkillgardenCatalogHandlers(name) {
1281
+ var listEl = skillgardenCatalogListEl();
1282
+ if (!listEl) return;
1283
+
1284
+ var viewButtons = listEl.querySelectorAll('.skillgarden-view-btn');
1285
+ for (var i = 0; i < viewButtons.length; i++) {
1286
+ viewButtons[i].addEventListener('click', function (ev) {
1287
+ var btn = ev.currentTarget;
1288
+ var category = btn.getAttribute('data-category');
1289
+ var id = btn.getAttribute('data-id');
1290
+ var card = btn.closest('.source');
1291
+ var bodyEl = card.querySelector('.skillgarden-catalog-body');
1292
+ if (bodyEl.style.display !== 'none') {
1293
+ bodyEl.style.display = 'none';
1294
+ btn.textContent = 'View';
1295
+ return;
1296
+ }
1297
+ if (bodyEl.getAttribute('data-loaded') === '1') {
1298
+ bodyEl.style.display = 'block';
1299
+ btn.textContent = 'Hide';
1300
+ return;
1301
+ }
1302
+ btn.disabled = true;
1303
+ fetch('/skillgarden-catalog/' + encodeURIComponent(category) + '/' + encodeURIComponent(id))
1304
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
1305
+ .then(function (result) {
1306
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
1307
+ bodyEl.innerHTML = renderMarkdownPreview(result.body.body);
1308
+ bodyEl.setAttribute('data-loaded', '1');
1309
+ bodyEl.style.display = 'block';
1310
+ btn.textContent = 'Hide';
1311
+ })
1312
+ .catch(function (err) {
1313
+ bodyEl.innerHTML = '<p class="error">' + escapeHtml(err.message) + '</p>';
1314
+ bodyEl.style.display = 'block';
1315
+ })
1316
+ .then(function () { btn.disabled = false; });
1317
+ });
1318
+ }
1319
+
1320
+ var addButtons = listEl.querySelectorAll('.skillgarden-add-btn');
1321
+ for (var j = 0; j < addButtons.length; j++) {
1322
+ addButtons[j].addEventListener('click', function (ev) {
1323
+ var btn = ev.currentTarget;
1324
+ var category = btn.getAttribute('data-category');
1325
+ var id = btn.getAttribute('data-id');
1326
+ var originalText = btn.textContent;
1327
+ btn.disabled = true;
1328
+ btn.textContent = 'Adding…';
1329
+ performSkillgardenAdd(name, category, id, btn, originalText, false);
1330
+ });
1331
+ }
1332
+ }
1333
+
1026
1334
  function wireSkillsHandlers(name) {
1027
1335
  var panel = skillsPanelEl();
1028
1336
  if (!panel) return;
1029
1337
  var editingSkillId = null;
1030
1338
 
1339
+ loadSkillgardenCatalogSection(name);
1340
+ var skillgardenFilterInput = panel.querySelector('#skillgardenFilterInput');
1341
+ if (skillgardenFilterInput) {
1342
+ skillgardenFilterInput.addEventListener('input', function () {
1343
+ if (!skillgardenCatalog) return;
1344
+ applySkillgardenCatalog(name, filteredSkillgardenCatalog(skillgardenFilterInput.value.trim()));
1345
+ });
1346
+ }
1347
+
1031
1348
  // Write/Preview toggle for the Body field — same idea as GitHub's own
1032
1349
  // issue/PR editor: the textarea and the rendered preview show/hide
1033
1350
  // each other, the textarea's value is the only source of truth (the
@@ -1158,7 +1475,7 @@ export const agentsConfigPageHtml = `<!doctype html>
1158
1475
 
1159
1476
  function renderToolsTabHtml(cfg) {
1160
1477
  return '<section><h3>Local tools (' + cfg.localTools.length + ')</h3>' + renderTools(cfg.localTools) +
1161
- '<div class="http-tool-subsection"><h4>Create an HTTP tool</h4>' + renderHttpToolFormHtml() + '</div>' +
1478
+ '<div class="admin-subsection"><h4>Create an HTTP tool</h4>' + renderHttpToolFormHtml() + '</div>' +
1162
1479
  '</section>' +
1163
1480
  '<section id="gatewayToolsSection">' +
1164
1481
  '<h3>Gateway Tools <button type="button" id="gatewayToolsRefreshBtn" class="hint-btn">Refresh</button></h3>' +
@@ -1207,7 +1524,7 @@ export const agentsConfigPageHtml = `<!doctype html>
1207
1524
  '<input type="text" name="name" required pattern="[a-z][a-z0-9_]*" placeholder="lookup_order_status">' +
1208
1525
  '</label>' +
1209
1526
  '<label>Description' +
1210
- '<input type="text" name="description" required placeholder="What this tool does">' +
1527
+ '<textarea name="description" id="httpToolDescriptionInput" required placeholder="What this tool does"></textarea>' +
1211
1528
  '</label>' +
1212
1529
  '<label>Fields' +
1213
1530
  '<div id="httpToolFieldRows"></div>' +
@@ -0,0 +1,24 @@
1
+ export declare class SkillgardenUnavailableError extends Error {
2
+ }
3
+ export interface SkillgardenCatalogEntry {
4
+ category: string;
5
+ id: string;
6
+ description: string;
7
+ }
8
+ export interface SkillgardenCatalogDetail extends SkillgardenCatalogEntry {
9
+ body: string;
10
+ }
11
+ export declare function listSkillgardenCatalog(): SkillgardenCatalogEntry[];
12
+ export declare function readSkillgardenCatalogEntry(category: string, id: string): SkillgardenCatalogDetail;
13
+ /** Copies `registry/<category>/<id>/` into `agents/<agentName>/skills/<id>/`
14
+ * — delegates entirely to skillgarden's own `addSkill`, which already
15
+ * knows this exact `agents/<agent>/skills` destination convention (see
16
+ * its own doc comment) and already refuses (or, with `force`,
17
+ * overwrites) an existing destination. Returns the bare skill id actually
18
+ * installed — never category-qualified, matching `addSkill`'s own
19
+ * "category is a registry-organization concept, not forced onto what a
20
+ * caller now owns" convention. */
21
+ export declare function addSkillgardenSkillToAgent(agentName: string, category: string, id: string, force?: boolean): {
22
+ id: string;
23
+ path: string;
24
+ };
@@ -0,0 +1,76 @@
1
+ // Lets an operator browse skillgarden's own bundled skill registry from
2
+ // the Skills tab in /agents/config, and add one straight into an agent's
3
+ // agents/<name>/skills/ folder — a shortcut for the same thing
4
+ // `npx skillgarden add <skill> --agent <name>` already does from a
5
+ // terminal, surfaced in the UI instead. This module only ever *reads*
6
+ // the registry and delegates the actual copy to skillgarden's own
7
+ // exported `addSkill` (see below) — it never re-implements that logic.
8
+ import { createRequire } from 'node:module';
9
+ import { dirname, join } from 'node:path';
10
+ import { SkillGarden, addSkill as skillgardenAddSkill } from 'skillgarden';
11
+ export class SkillgardenUnavailableError extends Error {
12
+ }
13
+ // skillgarden's own CLI resolves its bundled registry/ relative to its
14
+ // own compiled dist/cli.js location (see its cli.ts's own
15
+ // DEFAULT_REGISTRY_DIR), but doesn't export that path — `addSkill`
16
+ // itself defaults to it internally, which is enough for *installing* a
17
+ // skill, but browsing/previewing the catalog needs the directory path
18
+ // itself. Re-derived here the same way Node's own module resolution
19
+ // would: from package.json's real installed location, not assumed
20
+ // relative to this file (skillgarden is a real npm dependency, not a
21
+ // vendored copy).
22
+ function registryDir() {
23
+ const require = createRequire(import.meta.url);
24
+ let pkgJsonPath;
25
+ try {
26
+ pkgJsonPath = require.resolve('skillgarden/package.json');
27
+ }
28
+ catch {
29
+ throw new SkillgardenUnavailableError('skillgarden is not installed — run `npm install skillgarden` to enable the skill catalog.');
30
+ }
31
+ return join(dirname(pkgJsonPath), 'registry');
32
+ }
33
+ // A large, fixed budget, not run-agent.ts's own live skillIndexBudgetTokens
34
+ // default — this indexes a small, fixed, bundled catalog for browsing,
35
+ // not an agent's own live context, so nothing here should ever silently
36
+ // truncate the list an operator sees.
37
+ const CATALOG_INDEX_BUDGET_TOKENS = 1_000_000;
38
+ // discoverSkillFiles namespaces registry/<category>/<skill>/SKILL.md as
39
+ // "<category>:<skill>" (colon-joined, one level of nesting) — split on
40
+ // just the first colon, not skillId itself, in case a future entry ever
41
+ // nests deeper than one category level.
42
+ function splitNamespacedName(namespacedName) {
43
+ const colon = namespacedName.indexOf(':');
44
+ return { category: namespacedName.slice(0, colon), id: namespacedName.slice(colon + 1) };
45
+ }
46
+ function catalogGarden() {
47
+ return new SkillGarden({ dirs: [registryDir()], indexBudgetTokens: CATALOG_INDEX_BUDGET_TOKENS });
48
+ }
49
+ export function listSkillgardenCatalog() {
50
+ const { included } = catalogGarden().buildIndex();
51
+ return included
52
+ .map((entry) => ({ ...splitNamespacedName(entry.name), description: entry.description }))
53
+ .sort((a, b) => (a.category === b.category ? a.id.localeCompare(b.id) : a.category.localeCompare(b.category)));
54
+ }
55
+ export function readSkillgardenCatalogEntry(category, id) {
56
+ const garden = catalogGarden();
57
+ garden.buildIndex();
58
+ // load() throws a plain Error for an unindexed name — same "not found"
59
+ // shape a bad category/id combination should surface as; the caller
60
+ // (adapters/http.ts) maps any throw here to 404, so there's nothing
61
+ // more specific to catch and re-throw.
62
+ const loaded = garden.load(`${category}:${id}`);
63
+ return { category, id, description: loaded.description, body: loaded.body };
64
+ }
65
+ /** Copies `registry/<category>/<id>/` into `agents/<agentName>/skills/<id>/`
66
+ * — delegates entirely to skillgarden's own `addSkill`, which already
67
+ * knows this exact `agents/<agent>/skills` destination convention (see
68
+ * its own doc comment) and already refuses (or, with `force`,
69
+ * overwrites) an existing destination. Returns the bare skill id actually
70
+ * installed — never category-qualified, matching `addSkill`'s own
71
+ * "category is a registry-organization concept, not forced onto what a
72
+ * caller now owns" convention. */
73
+ export function addSkillgardenSkillToAgent(agentName, category, id, force = false) {
74
+ const result = skillgardenAddSkill({ skill: `${category}/${id}`, agent: agentName, registryDir: registryDir(), force });
75
+ return { id, path: join(result.destination, 'SKILL.md') };
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",
@@ -78,7 +78,7 @@
78
78
  "mcpplug": "^0.0.1",
79
79
  "openai": "^7.4.0",
80
80
  "sessionknit": "^0.0.2",
81
- "skillgarden": "^0.0.5",
81
+ "skillgarden": "^0.0.9",
82
82
  "toollane": "^0.0.1",
83
83
  "typescript": "^5.7.0",
84
84
  "yaml": "^2.6.0"