@geminixiang/mikan 1.0.0-beta.70 → 1.0.0-beta.72

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/CHANGELOG.md CHANGED
@@ -9,6 +9,24 @@ any release.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.0.0-beta.72]
13
+
14
+ ### Added
15
+
16
+ - Admin: the Skills panel (conversation and global) can now create, edit, and delete skills directly, not just list and preview them. Saving writes `SKILL.md` straight into the workspace skills tree the harness loader reads, so a new or edited skill is available on the conversation's next turn.
17
+
18
+ ## [1.0.0-beta.71]
19
+
20
+ ### Changed
21
+
22
+ - Built-in subagents now use the full 100-turn / $10 allowance, and the model-facing `subagent` tool can only request a larger token allowance instead of accidentally shrinking turn, cost, or duration budgets (for example, to $0.30).
23
+
24
+ ### Fixed
25
+
26
+ - Shared-memory guidance now keeps personal, project-specific, and tool-specific knowledge in its proper scope, prevents memory from overriding system mechanisms, and removes the obsolete shared `SYSTEM.md` environment log.
27
+ - Console usage summaries now show cache-read tokens alongside fresh input tokens.
28
+ - Sandbox provisioning no longer logs `Container … already running` on every readiness check; creation, restart, drift, and failure messages remain visible.
29
+
12
30
  ## [1.0.0-beta.70]
13
31
 
14
32
  ### Fixed
@@ -1 +1 @@
1
- {"version":3,"file":"portal.d.ts","sourceRoot":"","sources":["../../../../src/adapters/web/admin/portal.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAIjE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAC3D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACnD,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC7C,YAAY,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAI7C,qBAAa,uBAAwB,SAAQ,kBAAkB,CAAC,UAAU,CAAC;IACzE,MAAM,CAAC,IAAI,EAAE;QACX,QAAQ,EAAE,YAAY,CAAC;QACvB,cAAc,EAAE,MAAM,CAAC;QACvB,cAAc,EAAE,MAAM,CAAC;QACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;KAC3B,GAAG,UAAU,CAUb;CACF;AAoCD,YAAY,EAAE,kBAAkB,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAClF,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC9D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAYpD,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,GAAG,EAAE,GAAG,EACR,QAAQ,EAAE,aAAa,GACtB,OAAO,CAAC,OAAO,CAAC,CA6BlB;AAwGD,UAAU,sBAAsB;IAC9B,OAAO,EAAE,aAAa,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,WAAW,EAAE,MAAM,EACnB,iBAAiB,EAAE,MAAM,EACzB,KAAK,EAAE,UAAU,GAChB,sBAAsB,CAexB;AA08BD,UAAU,UAAU;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,QAAQ,GAAG,cAAc,CAAC;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;CACnB;AAwBD,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,CAAC,QAAQ,CAAC,GAAG,UAAU,EAAE,CAuB/F;AAqRD;;;;GAIG;AACH,wBAAsB,gBAAgB,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAmBjF"}
1
+ {"version":3,"file":"portal.d.ts","sourceRoot":"","sources":["../../../../src/adapters/web/admin/portal.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAKjE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAC3D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACnD,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC7C,YAAY,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAI7C,qBAAa,uBAAwB,SAAQ,kBAAkB,CAAC,UAAU,CAAC;IACzE,MAAM,CAAC,IAAI,EAAE;QACX,QAAQ,EAAE,YAAY,CAAC;QACvB,cAAc,EAAE,MAAM,CAAC;QACvB,cAAc,EAAE,MAAM,CAAC;QACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;KAC3B,GAAG,UAAU,CAUb;CACF;AAoCD,YAAY,EAAE,kBAAkB,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAClF,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC9D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAYpD,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,GAAG,EAAE,GAAG,EACR,QAAQ,EAAE,aAAa,GACtB,OAAO,CAAC,OAAO,CAAC,CA6BlB;AA2GD,UAAU,sBAAsB;IAC9B,OAAO,EAAE,aAAa,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,WAAW,EAAE,MAAM,EACnB,iBAAiB,EAAE,MAAM,EACzB,KAAK,EAAE,UAAU,GAChB,sBAAsB,CAexB;AA08BD,UAAU,UAAU;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,QAAQ,GAAG,cAAc,CAAC;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;CACnB;AAwBD,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,CAAC,QAAQ,CAAC,GAAG,UAAU,EAAE,CAuB/F;AA8XD;;;;GAIG;AACH,wBAAsB,gBAAgB,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAmBjF"}
@@ -1,7 +1,8 @@
1
1
  import { validateEventFilename } from "../../../events/index.js";
2
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, } from "node:fs";
3
3
  import { basename, join, resolve as pathResolve, sep as pathSep } from "node:path";
4
- import { MikanModels, parseFrontmatter } from "../../../harness/index.js";
4
+ import { atomicWritePrivateFile } from "../../../file-guards.js";
5
+ import { MikanModels, parseFrontmatter, validateSkill } from "../../../harness/index.js";
5
6
  import { SessionStore } from "../../../sessions/session-store.js";
6
7
  import { InMemoryTokenStore } from "../token-store.js";
7
8
  const ADMIN_TOKEN_TTL_MS = 30 * 60 * 1000;
@@ -104,7 +105,8 @@ async function routeGetApiRequest(res, url, services) {
104
105
  }
105
106
  }
106
107
  async function routePostApiRequest(req, res, url, services) {
107
- const body = await readJsonBody(req, res, 32 * 1024);
108
+ const bodyLimit = url.pathname === "/admin/api/skills/mutate" ? 256 * 1024 : 32 * 1024;
109
+ const body = await readJsonBody(req, res, bodyLimit);
108
110
  if (!body)
109
111
  return;
110
112
  const token = services.adminTokenStore.peek(typeof body.token === "string" ? body.token : "");
@@ -127,6 +129,8 @@ async function routePostApiRequest(req, res, url, services) {
127
129
  return serveConversationEventDelete(res, body, services, token);
128
130
  case "/admin/api/mcp-servers/mutate":
129
131
  return serveMcpServerMutation(res, body, services, token);
132
+ case "/admin/api/skills/mutate":
133
+ return serveSkillMutation(res, body, services, token);
130
134
  case "/admin/api/settings/model":
131
135
  return serveGlobalModelUpdate(res, body, services);
132
136
  case "/admin/api/settings/sandbox":
@@ -1221,6 +1225,96 @@ function serveSkillFile(res, url, services, token) {
1221
1225
  }
1222
1226
  servePreviewFile(res, safe.absolute, { source, directory }, "Skill file not found");
1223
1227
  }
1228
+ const SKILL_DIRECTORY_PATTERN = /^[a-z0-9-]+$/;
1229
+ function resolveSkillsRoot(workspace, scope, source) {
1230
+ if (source !== "global" && source !== "conversation") {
1231
+ return { error: "Invalid skill source" };
1232
+ }
1233
+ return {
1234
+ root: source === "global" ? workspace.skillsDir : workspace.office(scope.address).skillsDir,
1235
+ source,
1236
+ };
1237
+ }
1238
+ /**
1239
+ * Create, overwrite, or delete one skill directory. The directory name is
1240
+ * fixed at creation (renaming would leave the old directory as an orphan);
1241
+ * editing an existing skill always targets its current directory. Writes go
1242
+ * straight to the workspace skills tree — the same location the harness
1243
+ * loader reads — so a saved skill is available on the conversation's next
1244
+ * turn without a restart.
1245
+ */
1246
+ async function serveSkillMutation(res, body, services, token) {
1247
+ const action = body.action;
1248
+ if (action !== "save" && action !== "delete") {
1249
+ jsonRes(res, 400, { error: "action must be 'save' or 'delete'" });
1250
+ return;
1251
+ }
1252
+ const scope = resolveTargetConversation(body, token);
1253
+ if (scope.error) {
1254
+ jsonRes(res, 403, { error: scope.error });
1255
+ return;
1256
+ }
1257
+ const workspace = requireAdminWorkspace(res, services);
1258
+ if (!workspace)
1259
+ return;
1260
+ const resolved = resolveSkillsRoot(workspace, scope, body.source);
1261
+ if ("error" in resolved) {
1262
+ jsonRes(res, 400, { error: resolved.error });
1263
+ return;
1264
+ }
1265
+ const directory = typeof body.directory === "string" ? body.directory.trim() : "";
1266
+ if (!directory || !SKILL_DIRECTORY_PATTERN.test(directory)) {
1267
+ jsonRes(res, 400, {
1268
+ error: "directory must be lowercase a-z, 0-9 and hyphens",
1269
+ });
1270
+ return;
1271
+ }
1272
+ const safe = safeJoinUnderRoot(resolved.root, directory);
1273
+ if (safe.error) {
1274
+ jsonRes(res, 400, { error: safe.error });
1275
+ return;
1276
+ }
1277
+ if (action === "delete") {
1278
+ if (!existsSync(join(safe.absolute, "SKILL.md"))) {
1279
+ jsonRes(res, 404, { error: "Skill not found" });
1280
+ return;
1281
+ }
1282
+ try {
1283
+ rmSync(safe.absolute, { recursive: true, force: true });
1284
+ }
1285
+ catch (err) {
1286
+ jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });
1287
+ return;
1288
+ }
1289
+ jsonRes(res, 200, { ok: true });
1290
+ return;
1291
+ }
1292
+ const name = typeof body.name === "string" ? body.name.trim() : directory;
1293
+ const description = typeof body.description === "string" ? body.description.trim() : "";
1294
+ const content = typeof body.content === "string" ? body.content : "";
1295
+ if (!description) {
1296
+ jsonRes(res, 400, { error: "description is required" });
1297
+ return;
1298
+ }
1299
+ const errors = validateSkill(name, description);
1300
+ if (errors.length > 0) {
1301
+ jsonRes(res, 400, { error: errors.join("; ") });
1302
+ return;
1303
+ }
1304
+ try {
1305
+ if (existsSync(safe.absolute) && lstatSync(safe.absolute).isSymbolicLink()) {
1306
+ throw new Error("Refusing to write through a symlinked skill directory");
1307
+ }
1308
+ mkdirSync(safe.absolute, { recursive: true });
1309
+ const frontmatter = ["---", `name: ${name}`, `description: ${description}`, "---", ""].join("\n");
1310
+ atomicWritePrivateFile(join(safe.absolute, "SKILL.md"), frontmatter + content.trim() + "\n");
1311
+ }
1312
+ catch (err) {
1313
+ jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });
1314
+ return;
1315
+ }
1316
+ jsonRes(res, 200, { ok: true, name, directory, source: resolved.source });
1317
+ }
1224
1318
  // ── Events ─────────────────────────────────────────────────────────────────────
1225
1319
  /**
1226
1320
  * List events through the owning store, whose payloads are validated by the
@@ -1362,7 +1456,10 @@ const adminViewBody = `<nav class="tab-nav" role="tablist" aria-label="Admin sec
1362
1456
  <p class="eyebrow">Skills</p>
1363
1457
  <h2 class="card-title">可用的 skills</h2>
1364
1458
  </div></div>
1365
- <button class="refresh-btn" onclick="event.stopPropagation(); loadSkills()">↻</button>
1459
+ <div class="sect-head-actions">
1460
+ <button class="primary-action-btn" onclick="event.stopPropagation(); openSkillDialog('conversation')">+ New skill</button>
1461
+ <button class="refresh-btn" onclick="event.stopPropagation(); loadSkills()">↻</button>
1462
+ </div>
1366
1463
  </summary>
1367
1464
  <div class="sect-body">
1368
1465
  <div class="workspace-split">
@@ -1487,9 +1584,15 @@ const adminViewBody = `<nav class="tab-nav" role="tablist" aria-label="Admin sec
1487
1584
  <p class="eyebrow">Global Skills</p>
1488
1585
  <h2 class="card-title">全域 skills</h2>
1489
1586
  </div>
1490
- <button class="refresh-btn" onclick="loadGlobalSkills()">↻</button>
1587
+ <div class="sect-head-actions">
1588
+ <button class="primary-action-btn" onclick="openSkillDialog('global')">+ New global skill</button>
1589
+ <button class="refresh-btn" onclick="loadGlobalSkills()">↻</button>
1590
+ </div>
1491
1591
  </header>
1492
- <div id="global-skills-content"><div class="loading-msg">Loading…</div></div>
1592
+ <div class="workspace-split">
1593
+ <div id="global-skills-content" class="workspace-tree"><div class="loading-msg">Loading…</div></div>
1594
+ <div id="global-skills-preview" class="workspace-preview"><div class="placeholder-msg">Click a skill to preview SKILL.md</div></div>
1595
+ </div>
1493
1596
  </section>
1494
1597
 
1495
1598
  <section class="card sect">
@@ -1534,6 +1637,38 @@ const adminViewBody = `<nav class="tab-nav" role="tablist" aria-label="Admin sec
1534
1637
  <button class="mcp-btn" type="button" onclick="closeMcpCustomDialog()">Cancel</button>
1535
1638
  <button id="mcp-custom-dialog-submit" class="primary-action-btn" type="button" onclick="submitMcpCustomDialog(this)">新增並測試連線</button>
1536
1639
  </div>
1640
+ </dialog>
1641
+
1642
+ <dialog id="skill-dialog" class="mcp-dialog" aria-labelledby="skill-dialog-title">
1643
+ <div class="mcp-dialog-head">
1644
+ <div>
1645
+ <p class="eyebrow" id="skill-dialog-eyebrow">Skill</p>
1646
+ <h2 id="skill-dialog-title" class="card-title">New skill</h2>
1647
+ </div>
1648
+ <button class="mcp-dialog-close" type="button" aria-label="Close" onclick="closeSkillDialog()">×</button>
1649
+ </div>
1650
+ <div class="mcp-field">
1651
+ <span>Directory</span>
1652
+ <input id="skill-dialog-directory" class="form-input mcp-json" placeholder="my-skill" autocomplete="off" />
1653
+ <small class="mcp-secret-note">Lowercase letters, digits, hyphens. Cannot be changed after creation.</small>
1654
+ </div>
1655
+ <div class="mcp-field">
1656
+ <span>Name</span>
1657
+ <input id="skill-dialog-name" class="form-input mcp-json" placeholder="my-skill" autocomplete="off" />
1658
+ </div>
1659
+ <div class="mcp-field">
1660
+ <span>Description</span>
1661
+ <input id="skill-dialog-description" class="form-input mcp-json" placeholder="Use when …" autocomplete="off" />
1662
+ </div>
1663
+ <div class="mcp-field">
1664
+ <span>Instructions (SKILL.md body)</span>
1665
+ <textarea id="skill-dialog-content" class="form-input mcp-json" spellcheck="false" rows="12" placeholder="# My skill&#10;&#10;Instructions here."></textarea>
1666
+ </div>
1667
+ <div id="skill-dialog-error" class="inline-result err" style="display:none"></div>
1668
+ <div class="mcp-dialog-actions">
1669
+ <button class="mcp-btn" type="button" onclick="closeSkillDialog()">Cancel</button>
1670
+ <button id="skill-dialog-submit" class="primary-action-btn" type="button" onclick="submitSkillDialog(this)">Save</button>
1671
+ </div>
1537
1672
  </dialog>`;
1538
1673
  const adminViewScript = ` let activeConversationKey = defaultConversationKey;
1539
1674
  function scopeOf(key) {
@@ -2287,6 +2422,31 @@ const adminViewScript = ` let activeConversationKey = defaultConversationKey;
2287
2422
  void mutateMcpServer(btn.dataset.mcpScope, btn.dataset.mcpAction, btn.dataset.mcpName);
2288
2423
  });
2289
2424
 
2425
+ let skillsCache = [];
2426
+
2427
+ function renderSkillsInto(containerId, skills, allowedSource) {
2428
+ const container = document.getElementById(containerId);
2429
+ if (!container) return;
2430
+ const filtered = allowedSource ? skills.filter((s) => s.source === allowedSource) : skills;
2431
+ if (filtered.length === 0) {
2432
+ container.innerHTML = '<div class="empty-state">No skills available</div>';
2433
+ return;
2434
+ }
2435
+ container.innerHTML = '<div class="skills-list">' +
2436
+ filtered.map((s) =>
2437
+ '<div class="skill-row">' +
2438
+ '<button class="skill-row-btn" data-skill-source="' + escAttr(s.source) + '" data-skill-directory="' + escAttr(s.directory) + '" data-skill-name="' + escAttr(s.name) + '">' +
2439
+ '<div class="skill-name">' + escHtml(s.name) + '<span class="skill-source skill-source-' + s.source + '">' + s.source + '</span></div>' +
2440
+ (s.description ? '<div class="skill-desc">' + escHtml(s.description) + '</div>' : '') +
2441
+ '</button>' +
2442
+ '<div class="skill-row-actions">' +
2443
+ '<button class="mcp-btn" data-skill-edit-source="' + escAttr(s.source) + '" data-skill-edit-directory="' + escAttr(s.directory) + '">Edit</button>' +
2444
+ '<button class="mcp-btn mcp-btn-danger" data-skill-delete-source="' + escAttr(s.source) + '" data-skill-delete-directory="' + escAttr(s.directory) + '" data-skill-delete-name="' + escAttr(s.name) + '">Delete</button>' +
2445
+ '</div>' +
2446
+ '</div>'
2447
+ ).join('') + '</div>';
2448
+ }
2449
+
2290
2450
  async function loadSkills() {
2291
2451
  const container = document.getElementById('skills-content');
2292
2452
  const previewEl = document.getElementById('skills-preview');
@@ -2294,25 +2454,19 @@ const adminViewScript = ` let activeConversationKey = defaultConversationKey;
2294
2454
  if (previewEl) previewEl.innerHTML = '<div class="placeholder-msg">Click a skill to preview SKILL.md</div>';
2295
2455
  try {
2296
2456
  const data = await apiGet('/admin/api/skills?' + scopeQuery());
2297
- if (data.skills.length === 0) {
2298
- container.innerHTML = '<div class="empty-state">No skills available</div>';
2299
- return;
2300
- }
2301
- container.innerHTML = '<div class="skills-list">' +
2302
- data.skills.map((s) =>
2303
- '<button class="skill-row skill-row-btn" data-skill-source="' + escAttr(s.source) + '" data-skill-directory="' + escAttr(s.directory) + '" data-skill-name="' + escAttr(s.name) + '">' +
2304
- '<div class="skill-name">' + escHtml(s.name) + '<span class="skill-source skill-source-' + s.source + '">' + s.source + '</span></div>' +
2305
- (s.description ? '<div class="skill-desc">' + escHtml(s.description) + '</div>' : '') +
2306
- '</button>'
2307
- ).join('') + '</div>';
2308
-
2457
+ skillsCache = Array.isArray(data.skills) ? data.skills : [];
2458
+ renderSkillsInto('skills-content', skillsCache);
2459
+ renderSkillsInto('global-skills-content', skillsCache, 'global');
2309
2460
  } catch (err) {
2310
2461
  container.innerHTML = '<div class="err-msg">' + escHtml(err.message) + '</div>';
2311
2462
  }
2312
2463
  }
2313
2464
 
2314
- async function previewSkill(source, directory, name) {
2315
- const previewEl = document.getElementById('skills-preview');
2465
+ const loadGlobalSkills = loadSkills;
2466
+
2467
+ async function previewSkillInto(previewId, source, directory, name) {
2468
+ const previewEl = document.getElementById(previewId);
2469
+ if (!previewEl) return;
2316
2470
  if (!source || !directory) {
2317
2471
  previewEl.innerHTML = '<div class="err-msg">Missing skill source or directory</div>';
2318
2472
  return;
@@ -2326,11 +2480,105 @@ const adminViewScript = ` let activeConversationKey = defaultConversationKey;
2326
2480
  }
2327
2481
  }
2328
2482
 
2329
- document.getElementById('skills-content').addEventListener('click', (event) => {
2330
- const btn = event.target.closest('[data-skill-source]');
2331
- if (!btn) return;
2332
- previewSkill(btn.dataset.skillSource, btn.dataset.skillDirectory, btn.dataset.skillName);
2333
- });
2483
+ async function deleteSkill(source, directory, name) {
2484
+ if (!confirm('Delete skill "' + (name || directory) + '" (' + source + ')?')) return;
2485
+ try {
2486
+ await apiPost('/admin/api/skills/mutate', { action: 'delete', source: source, directory: directory, ...scopeBody() });
2487
+ await loadSkills();
2488
+ } catch (err) {
2489
+ alert(err.message);
2490
+ }
2491
+ }
2492
+
2493
+ function bindSkillListEvents(containerId, previewId) {
2494
+ const el = document.getElementById(containerId);
2495
+ if (!el) return;
2496
+ el.addEventListener('click', (event) => {
2497
+ const previewBtn = event.target.closest('[data-skill-source]');
2498
+ if (previewBtn) {
2499
+ previewSkillInto(previewId, previewBtn.dataset.skillSource, previewBtn.dataset.skillDirectory, previewBtn.dataset.skillName);
2500
+ return;
2501
+ }
2502
+ const editBtn = event.target.closest('[data-skill-edit-source]');
2503
+ if (editBtn) {
2504
+ const skill = skillsCache.find((s) => s.source === editBtn.dataset.skillEditSource && s.directory === editBtn.dataset.skillEditDirectory);
2505
+ if (skill) void openSkillDialog(skill.source, skill);
2506
+ return;
2507
+ }
2508
+ const deleteBtn = event.target.closest('[data-skill-delete-source]');
2509
+ if (deleteBtn) {
2510
+ void deleteSkill(deleteBtn.dataset.skillDeleteSource, deleteBtn.dataset.skillDeleteDirectory, deleteBtn.dataset.skillDeleteName);
2511
+ }
2512
+ });
2513
+ }
2514
+ bindSkillListEvents('skills-content', 'skills-preview');
2515
+ bindSkillListEvents('global-skills-content', 'global-skills-preview');
2516
+
2517
+ let editingSkill = null;
2518
+ let pendingSkillScope = null;
2519
+
2520
+ async function openSkillDialog(scope, existing) {
2521
+ editingSkill = existing ? { source: existing.source, directory: existing.directory } : null;
2522
+ document.getElementById('skill-dialog-eyebrow').textContent = scope === 'global' ? 'Global skill' : 'Conversation skill';
2523
+ document.getElementById('skill-dialog-title').textContent = existing ? 'Edit skill' : 'New skill';
2524
+ const dirInput = document.getElementById('skill-dialog-directory');
2525
+ dirInput.value = existing ? existing.directory : '';
2526
+ dirInput.disabled = Boolean(existing);
2527
+ document.getElementById('skill-dialog-name').value = existing ? existing.name : '';
2528
+ document.getElementById('skill-dialog-description').value = existing ? existing.description : '';
2529
+ const contentEl = document.getElementById('skill-dialog-content');
2530
+ contentEl.value = '';
2531
+ const dialogError = document.getElementById('skill-dialog-error');
2532
+ dialogError.style.display = 'none';
2533
+ dialogError.textContent = '';
2534
+ pendingSkillScope = scope;
2535
+ if (existing) {
2536
+ contentEl.value = 'Loading…';
2537
+ try {
2538
+ const data = await apiGet('/admin/api/skills/file?' + scopeQuery() + '&source=' + encodeURIComponent(existing.source) + '&directory=' + encodeURIComponent(existing.directory));
2539
+ contentEl.value = data.binary ? '' : (data.content || '').replace(/^---[\\s\\S]*?---\\n?/, '').trim();
2540
+ } catch (err) {
2541
+ contentEl.value = '';
2542
+ dialogError.textContent = 'Failed to load current content: ' + err.message;
2543
+ dialogError.style.display = 'block';
2544
+ }
2545
+ }
2546
+ document.getElementById('skill-dialog').showModal();
2547
+ }
2548
+
2549
+ function closeSkillDialog() {
2550
+ editingSkill = null;
2551
+ pendingSkillScope = null;
2552
+ document.getElementById('skill-dialog').close();
2553
+ }
2554
+
2555
+ async function submitSkillDialog(btn) {
2556
+ const dialogError = document.getElementById('skill-dialog-error');
2557
+ dialogError.style.display = 'none';
2558
+ dialogError.textContent = '';
2559
+ const directory = document.getElementById('skill-dialog-directory').value.trim();
2560
+ const name = document.getElementById('skill-dialog-name').value.trim() || directory;
2561
+ const description = document.getElementById('skill-dialog-description').value.trim();
2562
+ const content = document.getElementById('skill-dialog-content').value;
2563
+ if (!directory) { dialogError.textContent = 'Directory is required'; dialogError.style.display = 'block'; return; }
2564
+ if (!description) { dialogError.textContent = 'Description is required'; dialogError.style.display = 'block'; return; }
2565
+ const source = editingSkill ? editingSkill.source : pendingSkillScope;
2566
+ btn.disabled = true;
2567
+ btn.textContent = 'Saving…';
2568
+ try {
2569
+ await apiPost('/admin/api/skills/mutate', {
2570
+ action: 'save', source: source, directory: directory, name: name, description: description, content: content, ...scopeBody(),
2571
+ });
2572
+ closeSkillDialog();
2573
+ await loadSkills();
2574
+ } catch (err) {
2575
+ dialogError.textContent = err.message;
2576
+ dialogError.style.display = 'block';
2577
+ } finally {
2578
+ btn.disabled = false;
2579
+ btn.textContent = 'Save';
2580
+ }
2581
+ }
2334
2582
 
2335
2583
  // ── Vault (Login link) ───────────────────────────────────────────────────────
2336
2584
 
@@ -2673,26 +2921,6 @@ const adminViewScript = ` let activeConversationKey = defaultConversationKey;
2673
2921
  await saveSetting(btn, result, 'settings/slack', { replyMode }, 'Save Slack');
2674
2922
  }
2675
2923
 
2676
- async function loadGlobalSkills() {
2677
- const container = document.getElementById('global-skills-content');
2678
- container.innerHTML = '<div class="loading-msg">Loading…</div>';
2679
- try {
2680
- // Reuse skills endpoint scoped to a conversation that doesn't have any of its own; the global half is what we want.
2681
- const data = await apiGet('/admin/api/skills?' + scopeQuery());
2682
- const globals = data.skills.filter((s) => s.source === 'global');
2683
- if (globals.length === 0) {
2684
- container.innerHTML = '<div class="empty-state">No global skills</div>';
2685
- return;
2686
- }
2687
- container.innerHTML = '<div class="skills-list">' + globals.map((s) =>
2688
- '<div class="skill-row"><div class="skill-name">' + escHtml(s.name) + '</div>' +
2689
- (s.description ? '<div class="skill-desc">' + escHtml(s.description) + '</div>' : '') + '</div>'
2690
- ).join('') + '</div>';
2691
- } catch (err) {
2692
- container.innerHTML = '<div class="err-msg">' + escHtml(err.message) + '</div>';
2693
- }
2694
- }
2695
-
2696
2924
  // ── Init ─────────────────────────────────────────────────────────────────────
2697
2925
 
2698
2926
  // Sections load on demand (see initSections); loadSettingsPanel already
@@ -2831,6 +3059,7 @@ const adminViewStyles = `
2831
3059
  font: 500 0.84rem/1.2 'DM Sans', sans-serif; cursor: pointer;
2832
3060
  }
2833
3061
  .refresh-btn:hover { background: rgba(0,0,0,0.06); color: var(--text); }
3062
+ .sect-head-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
2834
3063
 
2835
3064
  /* ── Workspace ──────────────────────────────────────────────────────── */
2836
3065
 
@@ -2881,11 +3110,14 @@ const adminViewStyles = `
2881
3110
  .skill-row {
2882
3111
  padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px;
2883
3112
  background: rgba(0,0,0,0.02);
3113
+ display: flex; align-items: flex-start; justify-content: space-between; gap: 10px;
2884
3114
  }
2885
3115
  .skill-row-btn {
2886
- width: 100%; text-align: left; cursor: pointer; font-family: inherit;
3116
+ flex: 1 1 auto; min-width: 0; text-align: left; cursor: pointer; font-family: inherit;
3117
+ background: transparent; border: none; padding: 0;
2887
3118
  }
2888
- .skill-row-btn:hover { background: rgba(0,0,0,0.05); }
3119
+ .skill-row-btn:hover .skill-name { color: var(--accent); }
3120
+ .skill-row-actions { display: flex; gap: 6px; flex-shrink: 0; }
2889
3121
  .skill-name {
2890
3122
  font-weight: 650; font-size: 0.9rem; color: var(--text);
2891
3123
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;