@hasna/skills 0.1.63 → 0.1.64

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 (38) hide show
  1. package/README.md +28 -6
  2. package/bin/index.js +1593 -228
  3. package/bin/mcp.js +290 -24
  4. package/bin/migrate.js +229 -33
  5. package/bin/server.js +774 -116
  6. package/bin/worker.js +558 -79
  7. package/dist/cli/commands/registry-reconcile.d.ts +2 -0
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +559 -251
  10. package/dist/lib/agent-sync.d.ts +26 -6
  11. package/dist/lib/auth-store.d.ts +37 -0
  12. package/dist/lib/config.d.ts +51 -0
  13. package/dist/lib/home-census.d.ts +4 -0
  14. package/dist/lib/home-migration.d.ts +8 -9
  15. package/dist/lib/native-storage.d.ts +1 -1
  16. package/dist/lib/portable-skills.d.ts +35 -6
  17. package/dist/lib/pull.d.ts +31 -0
  18. package/dist/lib/registry-reconcile.d.ts +114 -0
  19. package/dist/lib/registry.d.ts +7 -4
  20. package/dist/lib/remote-client.d.ts +118 -1
  21. package/dist/lib/remote-registry.d.ts +26 -0
  22. package/dist/lib/revision.d.ts +29 -0
  23. package/dist/sdk/index.js +1351 -353
  24. package/dist/server/app.d.ts +1 -1
  25. package/dist/server/config.d.ts +12 -2
  26. package/dist/server/rows.d.ts +2 -1
  27. package/dist/server/skills-api.d.ts +108 -6
  28. package/dist/server/sqlite-store.d.ts +20 -3
  29. package/dist/server/store.d.ts +31 -5
  30. package/dist/server/types.d.ts +98 -3
  31. package/dist/storage.js +7 -2
  32. package/migrations/postgres/0004_hosted_pins.sql +28 -0
  33. package/migrations/postgres/0005_revision_tombstone_registry.sql +37 -0
  34. package/migrations/postgres/0005_tag_projection.sql +36 -0
  35. package/migrations/sqlite/0004_hosted_pins.sql +21 -0
  36. package/migrations/sqlite/0005_revision_tombstone_registry.sql +18 -0
  37. package/migrations/sqlite/0005_tag_projection.sql +25 -0
  38. package/package.json +3 -2
package/bin/server.js CHANGED
@@ -22955,6 +22955,7 @@ var ANY_SEGMENT_EXCLUDES = new Set([
22955
22955
  ".docker"
22956
22956
  ]);
22957
22957
  var ROOT_EXCLUDES = new Set(["dist", "build", ".turbo"]);
22958
+ var TOOL_SIDECAR_FILENAMES = new Set([".hasna-skills.json"]);
22958
22959
  var CREDENTIAL_FILENAMES = new Set([
22959
22960
  ".npmrc",
22960
22961
  ".pypirc",
@@ -32951,9 +32952,10 @@ function resolveServerConfig(env = process.env) {
32951
32952
  artifactBucket: env.HASNA_SKILLS_S3_BUCKET || env.SKILLS_S3_BUCKET || undefined,
32952
32953
  artifactPrefix: normalizePrefix(env.HASNA_SKILLS_S3_PREFIX || env.SKILLS_S3_PREFIX || "skills/artifacts"),
32953
32954
  inlineWorker: env.HASNA_SKILLS_INLINE_WORKER === "1",
32954
- bundleSigningKey: env.HASNA_SKILLS_SIGNING_KEY || undefined,
32955
+ bundleSigningKey: env.HASNA_SKILLS_API_SIGNING_KEY || env.HASNA_SKILLS_SIGNING_KEY || undefined,
32955
32956
  requestBodyLimitBytes: parsePositiveInt(env.HASNA_SKILLS_REQUEST_BODY_LIMIT_BYTES, 1e6),
32956
32957
  skillBundleLimitBytes: parsePositiveInt(env.HASNA_SKILLS_BUNDLE_LIMIT_BYTES, 25000000),
32958
+ tombstoneWindowMs: parsePositiveInt(env.HASNA_SKILLS_TOMBSTONE_WINDOW_MS, 7 * 24 * 60 * 60 * 1000),
32957
32959
  publicBaseUrl: (env.SKILLS_PUBLIC_BASE_URL || localOrigin(host, port)).replace(/\/+$/, ""),
32958
32960
  nodeEnv,
32959
32961
  allowEphemeralStore: env.HASNA_SKILLS_ALLOW_EPHEMERAL_STORE === "1"
@@ -33031,6 +33033,11 @@ function normalizeConfigValue(key, value) {
33031
33033
  }
33032
33034
  var DATA_DIR_ENV = "HASNA_SKILLS_DIR";
33033
33035
  var INSTALLED_SKILLS_DIRNAME = "installed";
33036
+ var SKILLS_CACHE_DIRNAME = "skills";
33037
+ var LAYOUT_MIGRATION_RECORD = ".layout-migration.json";
33038
+ function isOwnerLayoutMigrated(appDir) {
33039
+ return existsSync(join2(appDir, SKILLS_CACHE_DIRNAME, LAYOUT_MIGRATION_RECORD));
33040
+ }
33034
33041
  function getDataDir() {
33035
33042
  const override = process.env[DATA_DIR_ENV];
33036
33043
  if (override) {
@@ -33154,7 +33161,7 @@ function looksLikeSqlitePath(value) {
33154
33161
  }
33155
33162
 
33156
33163
  // src/server/handlers.ts
33157
- import { createHash as createHash4 } from "crypto";
33164
+ import { createHash as createHash5 } from "crypto";
33158
33165
 
33159
33166
  // src/server/store.ts
33160
33167
  import { randomUUID as randomUUID3 } from "crypto";
@@ -33175,6 +33182,19 @@ class StaleLeaseGenerationError extends Error {
33175
33182
  }
33176
33183
  }
33177
33184
 
33185
+ class SkillRevisionConflictError extends Error {
33186
+ slug;
33187
+ expectedRevisionId;
33188
+ currentRevisionId;
33189
+ constructor(slug, expectedRevisionId, currentRevisionId) {
33190
+ super(`revision conflict for '${slug}': expected revision ${expectedRevisionId ?? "(none)"}, ` + `current is ${currentRevisionId ?? "(none)"}. Refused rather than silently overwriting a newer revision.`);
33191
+ this.name = "SkillRevisionConflictError";
33192
+ this.slug = slug;
33193
+ this.expectedRevisionId = expectedRevisionId;
33194
+ this.currentRevisionId = currentRevisionId;
33195
+ }
33196
+ }
33197
+
33178
33198
  // src/server/rows.ts
33179
33199
  import { randomUUID } from "crypto";
33180
33200
  function nowIso() {
@@ -33255,7 +33275,20 @@ function rowToSkill(row) {
33255
33275
  ...row.bundle_byte_size === null || row.bundle_byte_size === undefined ? {} : { bundleByteSize: Number(row.bundle_byte_size) },
33256
33276
  ...typeof row.published_by_user_id === "string" ? { publishedByUserId: row.published_by_user_id } : {},
33257
33277
  createdAt: dateString(row.created_at),
33258
- updatedAt: dateString(row.updated_at)
33278
+ updatedAt: dateString(row.updated_at),
33279
+ revisionId: String(row.revision_id ?? ""),
33280
+ revisionNumber: Number(row.revision_number ?? 0),
33281
+ ...row.tombstoned_at ? { tombstonedAt: dateString(row.tombstoned_at) } : {},
33282
+ ...row.tombstone_purge_after ? { tombstonePurgeAfter: dateString(row.tombstone_purge_after) } : {}
33283
+ };
33284
+ }
33285
+ function rowToPin(row) {
33286
+ return {
33287
+ orgId: String(row.org_id),
33288
+ principal: String(row.principal),
33289
+ slug: String(row.slug),
33290
+ pinnedAt: dateString(row.pinned_at),
33291
+ metadata: parseJsonObject(row.metadata_json)
33259
33292
  };
33260
33293
  }
33261
33294
  function rowToSkillBundle(row) {
@@ -33307,6 +33340,29 @@ function dateString(value) {
33307
33340
  return String(value);
33308
33341
  }
33309
33342
 
33343
+ // src/lib/revision.ts
33344
+ import { createHash as createHash4 } from "crypto";
33345
+ var REVISION_ID_PATTERN = /^[0-9a-f]{64}$/;
33346
+ function revisionIdOf(content) {
33347
+ const canonical = JSON.stringify({
33348
+ slug: content.slug,
33349
+ displayName: content.displayName,
33350
+ description: content.description,
33351
+ category: content.category,
33352
+ tags: content.tags,
33353
+ source: content.source,
33354
+ kind: content.kind,
33355
+ version: content.version ?? null,
33356
+ skillMd: content.skillMd ?? null,
33357
+ bundleSha256: content.bundleSha256 ?? null,
33358
+ bundleByteSize: content.bundleByteSize ?? null
33359
+ });
33360
+ return createHash4("sha256").update(canonical).digest("hex");
33361
+ }
33362
+ function revisionIdOfRecord(record) {
33363
+ return revisionIdOf(record);
33364
+ }
33365
+
33310
33366
  // src/server/sqlite-store.ts
33311
33367
  import { Database } from "bun:sqlite";
33312
33368
  import { randomUUID as randomUUID2 } from "crypto";
@@ -33354,6 +33410,7 @@ function defaultStartDirs() {
33354
33410
  // src/server/sqlite-store.ts
33355
33411
  var CLAIM_ATTEMPTS = 8;
33356
33412
  var CLAIMABLE_STATUSES = ["queued", "retrying"];
33413
+ var NO_REVISION_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000";
33357
33414
  var LAST_USED_RESOLUTION_MS = 60000;
33358
33415
 
33359
33416
  class SqliteSkillsStore {
@@ -33373,12 +33430,24 @@ class SqliteSkillsStore {
33373
33430
  if (options.migrate !== false) {
33374
33431
  applySqliteMigrations(this.db, options.migrationsDir);
33375
33432
  }
33433
+ this.backfillLegacyRevisions();
33376
33434
  this.backend = {
33377
33435
  kind: "sqlite",
33378
33436
  durable: !inMemory,
33379
33437
  label: inMemory ? "sqlite (in-memory)" : `sqlite (${path})`
33380
33438
  };
33381
33439
  }
33440
+ backfillLegacyRevisions() {
33441
+ const rows = this.all("SELECT * FROM skills_registry WHERE revision_id = ''", []);
33442
+ for (const row of rows) {
33443
+ const record = rowToSkill(row);
33444
+ this.db.run("UPDATE skills_registry SET revision_id = ? WHERE org_id = ? AND slug = ?", [
33445
+ revisionIdOfRecord(record),
33446
+ record.orgId,
33447
+ record.slug
33448
+ ]);
33449
+ }
33450
+ }
33382
33451
  get database() {
33383
33452
  return this.db;
33384
33453
  }
@@ -33602,8 +33671,29 @@ class SqliteSkillsStore {
33602
33671
  const orgId = input.principal.orgId;
33603
33672
  const now = nowIso();
33604
33673
  return this.db.transaction(() => {
33605
- const previous = this.get("SELECT bundle_sha256 FROM skills_registry WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
33674
+ const previous = this.get("SELECT revision_id, revision_number, bundle_sha256, bundle_byte_size, skill_md, tombstoned_at FROM skills_registry WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
33606
33675
  const previousSha = typeof previous?.bundle_sha256 === "string" ? previous.bundle_sha256 : null;
33676
+ const previousRevisionId = typeof previous?.revision_id === "string" && previous.revision_id ? previous.revision_id : null;
33677
+ const tombstoned = previous?.tombstoned_at != null;
33678
+ const carriedSkillMd = typeof input.skillMd === "string" ? input.skillMd : typeof previous?.skill_md === "string" ? previous.skill_md : null;
33679
+ if (previous && !tombstoned && input.expectedRevisionId !== previousRevisionId) {
33680
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previousRevisionId);
33681
+ }
33682
+ const carriedSha = input.bundle?.sha256 ?? previousSha;
33683
+ const carriedSize = input.bundle?.byteSize ?? (previous?.bundle_byte_size == null ? null : Number(previous.bundle_byte_size));
33684
+ const revisionId = revisionIdOfRecord({
33685
+ slug: input.slug,
33686
+ displayName: input.displayName,
33687
+ description: input.description,
33688
+ category: input.category,
33689
+ tags: input.tags,
33690
+ source: input.source,
33691
+ kind: input.kind,
33692
+ ...input.version ? { version: input.version } : {},
33693
+ ...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
33694
+ ...carriedSha ? { bundleSha256: carriedSha } : {},
33695
+ ...carriedSize === null || carriedSize === undefined ? {} : { bundleByteSize: carriedSize }
33696
+ });
33607
33697
  if (input.bundle) {
33608
33698
  this.db.run(`INSERT INTO skills_bundles (org_id, sha256, byte_size, content_type, storage_kind, storage_key, body_blob, created_at)
33609
33699
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
@@ -33624,8 +33714,8 @@ class SqliteSkillsStore {
33624
33714
  ]);
33625
33715
  }
33626
33716
  const row = this.get(`INSERT INTO skills_registry (org_id, slug, display_name, description, category, tags_json, source, kind, version, skill_md,
33627
- bundle_sha256, bundle_byte_size, published_by_user_id, created_at, updated_at)
33628
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
33717
+ bundle_sha256, bundle_byte_size, published_by_user_id, revision_id, revision_number, created_at, updated_at)
33718
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
33629
33719
  ON CONFLICT (org_id, slug) DO UPDATE SET
33630
33720
  display_name = excluded.display_name,
33631
33721
  description = excluded.description,
@@ -33643,7 +33733,16 @@ class SqliteSkillsStore {
33643
33733
  bundle_sha256 = COALESCE(excluded.bundle_sha256, skills_registry.bundle_sha256),
33644
33734
  bundle_byte_size = COALESCE(excluded.bundle_byte_size, skills_registry.bundle_byte_size),
33645
33735
  published_by_user_id = excluded.published_by_user_id,
33736
+ revision_id = excluded.revision_id,
33737
+ -- Current + 1, not the inserted 1: on the update path the ACTUAL row's
33738
+ -- counter is the truth (it may have advanced since the pre-read, which is
33739
+ -- exactly what the WHERE guard below detects).
33740
+ revision_number = skills_registry.revision_number + 1,
33741
+ tombstoned_at = NULL,
33742
+ tombstone_purge_after = NULL,
33646
33743
  updated_at = excluded.updated_at
33744
+ WHERE skills_registry.tombstoned_at IS NOT NULL
33745
+ OR skills_registry.revision_id = ?
33647
33746
  RETURNING *`, [
33648
33747
  orgId,
33649
33748
  input.slug,
@@ -33654,62 +33753,165 @@ class SqliteSkillsStore {
33654
33753
  input.source,
33655
33754
  input.kind,
33656
33755
  input.version ?? null,
33657
- input.skillMd ?? null,
33756
+ carriedSkillMd,
33658
33757
  input.bundle?.sha256 ?? null,
33659
33758
  input.bundle?.byteSize ?? null,
33660
33759
  input.principal.userId,
33760
+ revisionId,
33661
33761
  now,
33662
- now
33762
+ now,
33763
+ input.expectedRevisionId ?? NO_REVISION_SENTINEL
33663
33764
  ]);
33765
+ if (!row) {
33766
+ const current = this.get("SELECT revision_id FROM skills_registry WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
33767
+ const currentId = typeof current?.revision_id === "string" ? current.revision_id : null;
33768
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, currentId);
33769
+ }
33664
33770
  if (previousSha && input.bundle && previousSha !== input.bundle.sha256)
33665
33771
  this.collectOrphanBundle(orgId, previousSha);
33772
+ this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
33773
+ const insertTag = this.db.prepare("INSERT OR IGNORE INTO skills_tags (org_id, slug, tag) VALUES (?, ?, ?)");
33774
+ for (const tag of input.tags) {
33775
+ if (!tag.trim())
33776
+ continue;
33777
+ insertTag.run(orgId, input.slug, tag);
33778
+ }
33666
33779
  return rowToSkill(row);
33667
33780
  })();
33668
33781
  }
33669
33782
  async listSkills(principal) {
33670
- return this.all("SELECT * FROM skills_registry WHERE org_id = ? ORDER BY slug ASC", [principal.orgId]).map(rowToSkill);
33783
+ await this.purgeExpiredTombstones(principal);
33784
+ return this.all("SELECT * FROM skills_registry WHERE org_id = ? AND tombstoned_at IS NULL ORDER BY slug ASC", [principal.orgId]).map(rowToSkill);
33671
33785
  }
33672
33786
  async getSkill(principal, slug) {
33673
33787
  const row = this.get("SELECT * FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [principal.orgId, slug]);
33674
33788
  return row ? rowToSkill(row) : null;
33675
33789
  }
33676
- async updateSkill(principal, slug, patch) {
33790
+ async updateSkill(principal, slug, patch, expectedRevisionId) {
33677
33791
  const current = await this.getSkill(principal, slug);
33678
- if (!current)
33792
+ if (!current || current.tombstonedAt)
33679
33793
  return null;
33794
+ if (expectedRevisionId !== current.revisionId) {
33795
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
33796
+ }
33680
33797
  const next = { ...current, ...patch };
33681
- const row = this.get(`UPDATE skills_registry
33682
- SET display_name = ?, description = ?, category = ?, tags_json = ?, kind = ?, version = ?, skill_md = ?, updated_at = ?
33683
- WHERE org_id = ? AND slug = ?
33684
- RETURNING *`, [
33685
- next.displayName,
33686
- next.description,
33687
- next.category,
33688
- JSON.stringify(next.tags),
33689
- next.kind,
33690
- next.version ?? null,
33691
- next.skillMd ?? null,
33692
- nowIso(),
33693
- principal.orgId,
33694
- slug
33695
- ]);
33696
- return row ? rowToSkill(row) : null;
33798
+ return this.db.transaction(() => {
33799
+ const revisionId = revisionIdOfRecord(next);
33800
+ const row = this.get(`UPDATE skills_registry
33801
+ SET display_name = ?, description = ?, category = ?, tags_json = ?, kind = ?, version = ?, skill_md = ?,
33802
+ revision_id = ?, revision_number = revision_number + 1, updated_at = ?
33803
+ WHERE org_id = ? AND slug = ? AND tombstoned_at IS NULL AND revision_id = ?
33804
+ RETURNING *`, [
33805
+ next.displayName,
33806
+ next.description,
33807
+ next.category,
33808
+ JSON.stringify(next.tags),
33809
+ next.kind,
33810
+ next.version ?? null,
33811
+ next.skillMd ?? null,
33812
+ revisionId,
33813
+ nowIso(),
33814
+ principal.orgId,
33815
+ slug,
33816
+ current.revisionId
33817
+ ]);
33818
+ if (!row) {
33819
+ const nowRow = this.get("SELECT revision_id, tombstoned_at FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [
33820
+ principal.orgId,
33821
+ slug
33822
+ ]);
33823
+ if (nowRow && nowRow.tombstoned_at == null) {
33824
+ const currentId = typeof nowRow.revision_id === "string" ? nowRow.revision_id : null;
33825
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, currentId);
33826
+ }
33827
+ return null;
33828
+ }
33829
+ this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
33830
+ const insertTag = this.db.prepare("INSERT OR IGNORE INTO skills_tags (org_id, slug, tag) VALUES (?, ?, ?)");
33831
+ for (const tag of next.tags) {
33832
+ if (!tag.trim())
33833
+ continue;
33834
+ insertTag.run(principal.orgId, slug, tag);
33835
+ }
33836
+ return rowToSkill(row);
33837
+ })();
33697
33838
  }
33698
- async deleteSkill(principal, slug) {
33839
+ async deleteSkill(principal, slug, tombstoneWindowMs) {
33699
33840
  return this.db.transaction(() => {
33700
- const existing = this.get("SELECT bundle_sha256 FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
33841
+ const existing = this.get("SELECT tombstoned_at FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
33701
33842
  if (!existing)
33702
- return false;
33703
- this.db.run("DELETE FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
33704
- if (typeof existing.bundle_sha256 === "string")
33705
- this.collectOrphanBundle(principal.orgId, existing.bundle_sha256);
33706
- return true;
33843
+ return null;
33844
+ if (existing.tombstoned_at != null) {
33845
+ const row2 = this.get("SELECT * FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [principal.orgId, slug]);
33846
+ return rowToSkill(row2);
33847
+ }
33848
+ const tombstonedAt = nowIso();
33849
+ const purgeAfter = new Date(Date.now() + tombstoneWindowMs).toISOString();
33850
+ const row = this.get(`UPDATE skills_registry
33851
+ SET tombstoned_at = ?, tombstone_purge_after = ?, updated_at = ?
33852
+ WHERE org_id = ? AND slug = ?
33853
+ RETURNING *`, [tombstonedAt, purgeAfter, tombstonedAt, principal.orgId, slug]);
33854
+ return rowToSkill(row);
33855
+ })();
33856
+ }
33857
+ async purgeExpiredTombstones(principal) {
33858
+ return this.db.transaction(() => {
33859
+ const now = nowIso();
33860
+ const expired = this.all("SELECT * FROM skills_registry WHERE org_id = ? AND tombstoned_at IS NOT NULL AND tombstone_purge_after <= ?", [principal.orgId, now]);
33861
+ const purged = [];
33862
+ for (const row of expired) {
33863
+ const record = rowToSkill(row);
33864
+ this.db.run("DELETE FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, record.slug]);
33865
+ this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [principal.orgId, record.slug]);
33866
+ if (record.bundleSha256)
33867
+ this.collectOrphanBundle(principal.orgId, record.bundleSha256);
33868
+ purged.push(record);
33869
+ }
33870
+ return purged;
33707
33871
  })();
33708
33872
  }
33709
33873
  async getSkillBundle(principal, sha256) {
33710
33874
  const row = this.get("SELECT * FROM skills_bundles WHERE org_id = ? AND sha256 = ? LIMIT 1", [principal.orgId, sha256]);
33711
33875
  return row ? rowToSkillBundle(row) : null;
33712
33876
  }
33877
+ async pinSkill(principal, slug, metadata = {}) {
33878
+ const row = this.get(`INSERT INTO skills_pins (org_id, principal, slug, pinned_at, metadata_json)
33879
+ VALUES (?, ?, ?, ?, ?)
33880
+ ON CONFLICT (org_id, principal, slug) DO UPDATE SET
33881
+ pinned_at = excluded.pinned_at,
33882
+ metadata_json = excluded.metadata_json
33883
+ RETURNING *`, [principal.orgId, principal.apiKeyId, slug, nowIso(), JSON.stringify(metadata)]);
33884
+ return rowToPin(row);
33885
+ }
33886
+ async unpinSkill(principal, slug) {
33887
+ const result = this.db.run("DELETE FROM skills_pins WHERE org_id = ? AND principal = ? AND slug = ?", [principal.orgId, principal.apiKeyId, slug]);
33888
+ return result.changes > 0;
33889
+ }
33890
+ async listPins(principal) {
33891
+ return this.all("SELECT * FROM skills_pins WHERE org_id = ? AND principal = ? ORDER BY slug ASC", [principal.orgId, principal.apiKeyId]).map(rowToPin);
33892
+ }
33893
+ async listTags(principal) {
33894
+ await this.purgeExpiredTombstones(principal);
33895
+ return this.all("SELECT DISTINCT tag FROM skills_tags WHERE org_id = ? ORDER BY tag ASC", [principal.orgId]).map((row) => String(row.tag));
33896
+ }
33897
+ async listSkillsByTag(principal, tag) {
33898
+ await this.purgeExpiredTombstones(principal);
33899
+ return this.all(`SELECT s.* FROM skills_registry s
33900
+ JOIN skills_tags t ON t.org_id = s.org_id AND t.slug = s.slug
33901
+ WHERE t.org_id = ? AND t.tag = ? AND s.tombstoned_at IS NULL
33902
+ ORDER BY s.slug ASC`, [principal.orgId, tag]).map(rowToSkill);
33903
+ }
33904
+ async listPinsByTag(principal, tag) {
33905
+ await this.purgeExpiredTombstones(principal);
33906
+ return this.all(`SELECT p.* FROM skills_pins p
33907
+ JOIN skills_tags t ON t.org_id = p.org_id AND t.slug = p.slug
33908
+ JOIN skills_registry s ON s.org_id = p.org_id AND s.slug = p.slug
33909
+ WHERE p.org_id = ? AND p.principal = ? AND t.tag = ? AND s.tombstoned_at IS NULL
33910
+ ORDER BY p.slug ASC`, [principal.orgId, principal.apiKeyId, tag]).map(rowToPin);
33911
+ }
33912
+ async listPublishedSlugs(principal) {
33913
+ return this.all("SELECT slug FROM skills_registry WHERE org_id = ? AND tombstoned_at IS NULL ORDER BY slug ASC", [principal.orgId]).map((row) => String(row.slug));
33914
+ }
33713
33915
  collectOrphanBundle(orgId, sha256) {
33714
33916
  const referenced = this.get("SELECT 1 AS present FROM skills_registry WHERE org_id = ? AND bundle_sha256 = ? LIMIT 1", [orgId, sha256]);
33715
33917
  if (referenced)
@@ -33796,6 +33998,21 @@ function parseScopes(value) {
33796
33998
  }
33797
33999
 
33798
34000
  // src/server/store.ts
34001
+ function recordFieldsOf(input, carriedBundle, carriedSkillMd) {
34002
+ return {
34003
+ slug: input.slug,
34004
+ displayName: input.displayName,
34005
+ description: input.description,
34006
+ category: input.category,
34007
+ tags: input.tags,
34008
+ source: input.source,
34009
+ kind: input.kind,
34010
+ ...input.version ? { version: input.version } : {},
34011
+ ...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
34012
+ bundleSha256: input.bundle?.sha256 ?? carriedBundle.bundleSha256,
34013
+ bundleByteSize: input.bundle?.byteSize ?? carriedBundle.bundleByteSize
34014
+ };
34015
+ }
33799
34016
  function resolvePoolMax(env = process.env) {
33800
34017
  const parsed = Number.parseInt(env.HASNA_SKILLS_DATABASE_POOL_MAX || env.SKILLS_DATABASE_POOL_MAX || "", 10);
33801
34018
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 4;
@@ -33833,6 +34050,7 @@ class MemorySkillsStore {
33833
34050
  idempotency = new Map;
33834
34051
  skills = new Map;
33835
34052
  bundles = new Map;
34053
+ pins = new Map;
33836
34054
  constructor(apiKeys = []) {
33837
34055
  for (const key of apiKeys)
33838
34056
  this.addApiKey(key.token, key.principal);
@@ -33934,6 +34152,9 @@ class MemorySkillsStore {
33934
34152
  const key = skillKey(input.principal.orgId, input.slug);
33935
34153
  const now = nowIso();
33936
34154
  const previous = this.skills.get(key);
34155
+ if (previous && !previous.tombstonedAt && input.expectedRevisionId !== previous.revisionId) {
34156
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previous.revisionId);
34157
+ }
33937
34158
  if (input.bundle) {
33938
34159
  const bundleMapKey = skillKey(input.principal.orgId, input.bundle.sha256);
33939
34160
  this.bundles.set(bundleMapKey, {
@@ -33943,6 +34164,8 @@ class MemorySkillsStore {
33943
34164
  createdAt: this.bundles.get(bundleMapKey)?.createdAt ?? now
33944
34165
  });
33945
34166
  }
34167
+ const carriedBundle = !input.bundle && previous?.bundleSha256 ? { bundleSha256: previous.bundleSha256, ...previous.bundleByteSize === undefined ? {} : { bundleByteSize: previous.bundleByteSize } } : {};
34168
+ const carriedSkillMd = typeof input.skillMd === "string" ? input.skillMd : previous?.skillMd;
33946
34169
  const record = {
33947
34170
  orgId: input.principal.orgId,
33948
34171
  slug: input.slug,
@@ -33953,11 +34176,13 @@ class MemorySkillsStore {
33953
34176
  source: input.source,
33954
34177
  kind: input.kind,
33955
34178
  ...input.version ? { version: input.version } : {},
33956
- ...input.skillMd ? { skillMd: input.skillMd } : {},
33957
- ...input.bundle ? { bundleSha256: input.bundle.sha256, bundleByteSize: input.bundle.byteSize } : previous?.bundleSha256 ? { bundleSha256: previous.bundleSha256, ...previous.bundleByteSize === undefined ? {} : { bundleByteSize: previous.bundleByteSize } } : {},
34179
+ ...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
34180
+ ...input.bundle ? { bundleSha256: input.bundle.sha256, bundleByteSize: input.bundle.byteSize } : carriedBundle,
33958
34181
  publishedByUserId: input.principal.userId,
33959
34182
  createdAt: previous?.createdAt ?? now,
33960
- updatedAt: now
34183
+ updatedAt: now,
34184
+ revisionId: revisionIdOfRecord(recordFieldsOf(input, carriedBundle, carriedSkillMd)),
34185
+ revisionNumber: (previous?.revisionNumber ?? 0) + 1
33961
34186
  };
33962
34187
  this.skills.set(key, record);
33963
34188
  if (previous?.bundleSha256 && input.bundle && previous.bundleSha256 !== input.bundle.sha256) {
@@ -33966,33 +34191,107 @@ class MemorySkillsStore {
33966
34191
  return record;
33967
34192
  }
33968
34193
  async listSkills(principal) {
33969
- return Array.from(this.skills.values()).filter((skill) => skill.orgId === principal.orgId).sort((a3, b3) => a3.slug.localeCompare(b3.slug));
34194
+ return Array.from(this.skills.values()).filter((skill) => skill.orgId === principal.orgId && !skill.tombstonedAt).sort((a3, b3) => a3.slug.localeCompare(b3.slug));
33970
34195
  }
33971
34196
  async getSkill(principal, slug) {
33972
34197
  const skill = this.skills.get(skillKey(principal.orgId, slug));
33973
34198
  return skill && skill.orgId === principal.orgId ? skill : null;
33974
34199
  }
33975
- async updateSkill(principal, slug, patch) {
34200
+ async updateSkill(principal, slug, patch, expectedRevisionId) {
33976
34201
  const current = await this.getSkill(principal, slug);
33977
- if (!current)
34202
+ if (!current || current.tombstonedAt)
33978
34203
  return null;
33979
- const next = { ...current, ...patch, updatedAt: nowIso() };
34204
+ if (expectedRevisionId !== current.revisionId) {
34205
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
34206
+ }
34207
+ const latest = this.skills.get(skillKey(principal.orgId, slug));
34208
+ if (!latest || latest.tombstonedAt)
34209
+ return null;
34210
+ if (latest.revisionId !== current.revisionId) {
34211
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, latest.revisionId);
34212
+ }
34213
+ const next = {
34214
+ ...latest,
34215
+ ...patch,
34216
+ updatedAt: nowIso(),
34217
+ revisionId: revisionIdOfRecord({ ...latest, ...patch }),
34218
+ revisionNumber: latest.revisionNumber + 1
34219
+ };
33980
34220
  this.skills.set(skillKey(principal.orgId, slug), next);
33981
34221
  return next;
33982
34222
  }
33983
- async deleteSkill(principal, slug) {
34223
+ async deleteSkill(principal, slug, tombstoneWindowMs) {
33984
34224
  const current = await this.getSkill(principal, slug);
33985
34225
  if (!current)
33986
- return false;
33987
- this.skills.delete(skillKey(principal.orgId, slug));
33988
- if (current.bundleSha256)
33989
- this.collectOrphanBundle(principal.orgId, current.bundleSha256);
33990
- return true;
34226
+ return null;
34227
+ if (!current.tombstonedAt) {
34228
+ const tombstoned = nowIso();
34229
+ const purgeAfter = new Date(Date.now() + tombstoneWindowMs).toISOString();
34230
+ const next = { ...current, tombstonedAt: tombstoned, tombstonePurgeAfter: purgeAfter, updatedAt: tombstoned };
34231
+ this.skills.set(skillKey(principal.orgId, slug), next);
34232
+ return next;
34233
+ }
34234
+ return current;
34235
+ }
34236
+ async purgeExpiredTombstones(principal) {
34237
+ const now = nowIso();
34238
+ const purged = [];
34239
+ for (const [key, skill] of this.skills) {
34240
+ if (skill.orgId !== principal.orgId || !skill.tombstonedAt || !skill.tombstonePurgeAfter)
34241
+ continue;
34242
+ if (skill.tombstonePurgeAfter > now)
34243
+ continue;
34244
+ this.skills.delete(key);
34245
+ if (skill.bundleSha256)
34246
+ this.collectOrphanBundle(principal.orgId, skill.bundleSha256);
34247
+ purged.push(skill);
34248
+ }
34249
+ return purged;
33991
34250
  }
33992
34251
  async getSkillBundle(principal, sha256) {
33993
34252
  const bundle = this.bundles.get(skillKey(principal.orgId, sha256));
33994
34253
  return bundle && bundle.orgId === principal.orgId ? bundle : null;
33995
34254
  }
34255
+ async pinSkill(principal, slug, metadata = {}) {
34256
+ const pin = { orgId: principal.orgId, principal: principal.apiKeyId, slug, pinnedAt: nowIso(), metadata: { ...metadata } };
34257
+ this.pins.set(pinKey(principal.orgId, principal.apiKeyId, slug), pin);
34258
+ return pin;
34259
+ }
34260
+ async unpinSkill(principal, slug) {
34261
+ return this.pins.delete(pinKey(principal.orgId, principal.apiKeyId, slug));
34262
+ }
34263
+ async listPins(principal) {
34264
+ return Array.from(this.pins.values()).filter((pin) => pin.orgId === principal.orgId && pin.principal === principal.apiKeyId).sort((a3, b3) => a3.slug.localeCompare(b3.slug));
34265
+ }
34266
+ async listTags(principal) {
34267
+ await this.purgeExpiredTombstones(principal);
34268
+ const tags = new Set;
34269
+ for (const skill of this.skills.values()) {
34270
+ if (skill.orgId !== principal.orgId)
34271
+ continue;
34272
+ for (const tag of skill.tags) {
34273
+ if (tag.trim())
34274
+ tags.add(tag);
34275
+ }
34276
+ }
34277
+ return [...tags].sort();
34278
+ }
34279
+ async listSkillsByTag(principal, tag) {
34280
+ await this.purgeExpiredTombstones(principal);
34281
+ return Array.from(this.skills.values()).filter((skill) => skill.orgId === principal.orgId && !skill.tombstonedAt && skill.tags.includes(tag)).sort((a3, b3) => a3.slug.localeCompare(b3.slug));
34282
+ }
34283
+ async listPinsByTag(principal, tag) {
34284
+ await this.purgeExpiredTombstones(principal);
34285
+ const taggedSlugs = new Set;
34286
+ for (const skill of this.skills.values()) {
34287
+ if (skill.orgId === principal.orgId && !skill.tombstonedAt && skill.tags.includes(tag))
34288
+ taggedSlugs.add(skill.slug);
34289
+ }
34290
+ return Array.from(this.pins.values()).filter((pin) => pin.orgId === principal.orgId && pin.principal === principal.apiKeyId && taggedSlugs.has(pin.slug)).sort((a3, b3) => a3.slug.localeCompare(b3.slug));
34291
+ }
34292
+ async listPublishedSlugs(principal) {
34293
+ return Array.from(this.skills.values()).filter((skill) => skill.orgId === principal.orgId && !skill.tombstonedAt).map((skill) => skill.slug).sort();
34294
+ }
33996
34295
  collectOrphanBundle(orgId, sha256) {
33997
34296
  const referenced = Array.from(this.skills.values()).some((skill) => skill.orgId === orgId && skill.bundleSha256 === sha256);
33998
34297
  if (!referenced)
@@ -34010,6 +34309,9 @@ class MemorySkillsStore {
34010
34309
  function skillKey(orgId, slug) {
34011
34310
  return `${orgId.length}:${orgId}:${slug}`;
34012
34311
  }
34312
+ function pinKey(orgId, principal, slug) {
34313
+ return `${orgId.length}:${orgId}:${principal.length}:${principal}:${slug}`;
34314
+ }
34013
34315
 
34014
34316
  class PostgresSkillsStore {
34015
34317
  backend = { kind: "postgres", durable: true, label: "postgres" };
@@ -34029,6 +34331,14 @@ class PostgresSkillsStore {
34029
34331
  } catch (error) {
34030
34332
  throw new Error("the configured Postgres database is reachable but has no skills schema. Run `skills-migrate` against it " + "before starting the server - unlike SQLite, Postgres is not migrated automatically, so that several " + `replicas cannot race to migrate a shared database. Driver reported: ${connectionFailureSummary(error)}`);
34031
34333
  }
34334
+ await this.backfillLegacyRevisions();
34335
+ }
34336
+ async backfillLegacyRevisions() {
34337
+ const rows = await this.sql`SELECT * FROM skills_registry WHERE revision_id = ${""}`;
34338
+ for (const row of rows) {
34339
+ const record = rowToSkill(row);
34340
+ await this.sql`UPDATE skills_registry SET revision_id = ${revisionIdOfRecord(record)} WHERE org_id = ${record.orgId} AND slug = ${record.slug}`;
34341
+ }
34032
34342
  }
34033
34343
  async close() {
34034
34344
  await this.sql.close?.();
@@ -34263,8 +34573,33 @@ class PostgresSkillsStore {
34263
34573
  async publishSkill(input) {
34264
34574
  const orgId = input.principal.orgId;
34265
34575
  return await this.sql.begin(async (tx) => {
34266
- const previousRows = await tx`SELECT bundle_sha256 FROM skills_registry WHERE org_id = ${orgId} AND slug = ${input.slug} LIMIT 1`;
34267
- const previousSha = typeof previousRows[0]?.bundle_sha256 === "string" ? String(previousRows[0].bundle_sha256) : null;
34576
+ const previousRows = await tx`
34577
+ SELECT revision_id, revision_number, bundle_sha256, bundle_byte_size, skill_md, tombstoned_at
34578
+ FROM skills_registry WHERE org_id = ${orgId} AND slug = ${input.slug} LIMIT 1
34579
+ `;
34580
+ const previous = previousRows[0];
34581
+ const previousSha = typeof previous?.bundle_sha256 === "string" ? String(previous.bundle_sha256) : null;
34582
+ const previousRevisionId = typeof previous?.revision_id === "string" && previous.revision_id ? String(previous.revision_id) : null;
34583
+ const tombstoned = previous?.tombstoned_at != null;
34584
+ const carriedSkillMd = typeof input.skillMd === "string" ? input.skillMd : typeof previous?.skill_md === "string" ? String(previous.skill_md) : null;
34585
+ if (previous && !tombstoned && input.expectedRevisionId !== previousRevisionId) {
34586
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previousRevisionId);
34587
+ }
34588
+ const carriedSha = input.bundle?.sha256 ?? previousSha;
34589
+ const carriedSize = input.bundle?.byteSize ?? (previous?.bundle_byte_size == null ? null : Number(previous.bundle_byte_size));
34590
+ const revisionId = revisionIdOfRecord({
34591
+ slug: input.slug,
34592
+ displayName: input.displayName,
34593
+ description: input.description,
34594
+ category: input.category,
34595
+ tags: input.tags,
34596
+ source: input.source,
34597
+ kind: input.kind,
34598
+ ...input.version ? { version: input.version } : {},
34599
+ ...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
34600
+ ...carriedSha ? { bundleSha256: carriedSha } : {},
34601
+ ...carriedSize === null || carriedSize === undefined ? {} : { bundleByteSize: carriedSize }
34602
+ });
34268
34603
  if (input.bundle) {
34269
34604
  await tx`
34270
34605
  INSERT INTO skills_bundles (org_id, sha256, byte_size, content_type, storage_kind, storage_key, body_blob)
@@ -34279,10 +34614,10 @@ class PostgresSkillsStore {
34279
34614
  }
34280
34615
  const rows = await tx`
34281
34616
  INSERT INTO skills_registry (org_id, slug, display_name, description, category, tags_json, source, kind, version, skill_md,
34282
- bundle_sha256, bundle_byte_size, published_by_user_id, updated_at)
34617
+ bundle_sha256, bundle_byte_size, published_by_user_id, revision_id, revision_number, updated_at)
34283
34618
  VALUES (${orgId}, ${input.slug}, ${input.displayName}, ${input.description}, ${input.category}, ${JSON.stringify(input.tags)}::jsonb,
34284
- ${input.source}, ${input.kind}, ${input.version ?? null}, ${input.skillMd ?? null},
34285
- ${input.bundle?.sha256 ?? null}, ${input.bundle?.byteSize ?? null}, ${input.principal.userId}, now())
34619
+ ${input.source}, ${input.kind}, ${input.version ?? null}, ${carriedSkillMd},
34620
+ ${input.bundle?.sha256 ?? null}, ${input.bundle?.byteSize ?? null}, ${input.principal.userId}, ${revisionId}, 1, now())
34286
34621
  ON CONFLICT (org_id, slug) DO UPDATE SET
34287
34622
  display_name = EXCLUDED.display_name,
34288
34623
  description = EXCLUDED.description,
@@ -34297,9 +34632,23 @@ class PostgresSkillsStore {
34297
34632
  bundle_sha256 = COALESCE(EXCLUDED.bundle_sha256, skills_registry.bundle_sha256),
34298
34633
  bundle_byte_size = COALESCE(EXCLUDED.bundle_byte_size, skills_registry.bundle_byte_size),
34299
34634
  published_by_user_id = EXCLUDED.published_by_user_id,
34635
+ revision_id = EXCLUDED.revision_id,
34636
+ -- Current + 1, not EXCLUDED.revision_number: the insert path minted 1, but on
34637
+ -- the update path the ACTUAL row's counter is the truth (it may have advanced
34638
+ -- since the pre-read, which is exactly what the WHERE guard below detects).
34639
+ revision_number = skills_registry.revision_number + 1,
34640
+ tombstoned_at = NULL,
34641
+ tombstone_purge_after = NULL,
34300
34642
  updated_at = EXCLUDED.updated_at
34643
+ WHERE skills_registry.tombstoned_at IS NOT NULL
34644
+ OR skills_registry.revision_id = ${input.expectedRevisionId ?? NO_REVISION_SENTINEL2}
34301
34645
  RETURNING *
34302
34646
  `;
34647
+ if (!rows[0]) {
34648
+ const current = await tx`SELECT revision_id FROM skills_registry WHERE org_id = ${orgId} AND slug = ${input.slug} LIMIT 1`;
34649
+ const currentId = current[0] && typeof current[0].revision_id === "string" ? String(current[0].revision_id) : null;
34650
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, currentId);
34651
+ }
34303
34652
  if (previousSha && input.bundle && previousSha !== input.bundle.sha256) {
34304
34653
  await tx`
34305
34654
  DELETE FROM skills_bundles
@@ -34307,55 +34656,182 @@ class PostgresSkillsStore {
34307
34656
  AND NOT EXISTS (SELECT 1 FROM skills_registry WHERE org_id = ${orgId} AND bundle_sha256 = ${previousSha})
34308
34657
  `;
34309
34658
  }
34659
+ await tx`DELETE FROM skills_tags WHERE org_id = ${orgId} AND slug = ${input.slug}`;
34660
+ for (const tag of input.tags) {
34661
+ if (!tag.trim())
34662
+ continue;
34663
+ await tx`
34664
+ INSERT INTO skills_tags (org_id, slug, tag) VALUES (${orgId}, ${input.slug}, ${tag})
34665
+ ON CONFLICT DO NOTHING
34666
+ `;
34667
+ }
34310
34668
  return rowToSkill(rows[0]);
34311
34669
  });
34312
34670
  }
34313
34671
  async listSkills(principal) {
34314
- const rows = await this.sql`SELECT * FROM skills_registry WHERE org_id = ${principal.orgId} ORDER BY slug ASC`;
34672
+ await this.purgeExpiredTombstones(principal);
34673
+ const rows = await this.sql`
34674
+ SELECT * FROM skills_registry WHERE org_id = ${principal.orgId} AND tombstoned_at IS NULL ORDER BY slug ASC
34675
+ `;
34315
34676
  return rows.map(rowToSkill);
34316
34677
  }
34317
34678
  async getSkill(principal, slug) {
34318
34679
  const rows = await this.sql`SELECT * FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug} LIMIT 1`;
34319
34680
  return rows[0] ? rowToSkill(rows[0]) : null;
34320
34681
  }
34321
- async updateSkill(principal, slug, patch) {
34682
+ async updateSkill(principal, slug, patch, expectedRevisionId) {
34322
34683
  const current = await this.getSkill(principal, slug);
34323
- if (!current)
34684
+ if (!current || current.tombstonedAt)
34324
34685
  return null;
34686
+ if (expectedRevisionId !== current.revisionId) {
34687
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
34688
+ }
34325
34689
  const next = { ...current, ...patch };
34326
- const rows = await this.sql`
34327
- UPDATE skills_registry
34328
- SET display_name = ${next.displayName}, description = ${next.description}, category = ${next.category},
34329
- tags_json = ${JSON.stringify(next.tags)}::jsonb, kind = ${next.kind}, version = ${next.version ?? null},
34330
- skill_md = ${next.skillMd ?? null}, updated_at = now()
34331
- WHERE org_id = ${principal.orgId} AND slug = ${slug}
34332
- RETURNING *
34333
- `;
34334
- return rows[0] ? rowToSkill(rows[0]) : null;
34690
+ return await this.sql.begin(async (tx) => {
34691
+ const revisionId = revisionIdOfRecord(next);
34692
+ const updated = await tx`
34693
+ UPDATE skills_registry
34694
+ SET display_name = ${next.displayName}, description = ${next.description}, category = ${next.category},
34695
+ tags_json = ${JSON.stringify(next.tags)}::jsonb, kind = ${next.kind}, version = ${next.version ?? null},
34696
+ skill_md = ${next.skillMd ?? null}, revision_id = ${revisionId}, revision_number = revision_number + 1, updated_at = now()
34697
+ WHERE org_id = ${principal.orgId} AND slug = ${slug} AND tombstoned_at IS NULL AND revision_id = ${current.revisionId}
34698
+ RETURNING *
34699
+ `;
34700
+ if (!updated[0]) {
34701
+ const nowRows = await tx`
34702
+ SELECT revision_id, tombstoned_at FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug} LIMIT 1
34703
+ `;
34704
+ if (nowRows[0] && nowRows[0].tombstoned_at == null) {
34705
+ const currentId = String(nowRows[0].revision_id);
34706
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, currentId);
34707
+ }
34708
+ return null;
34709
+ }
34710
+ await tx`DELETE FROM skills_tags WHERE org_id = ${principal.orgId} AND slug = ${slug}`;
34711
+ for (const tag of next.tags) {
34712
+ if (!tag.trim())
34713
+ continue;
34714
+ await tx`
34715
+ INSERT INTO skills_tags (org_id, slug, tag) VALUES (${principal.orgId}, ${slug}, ${tag})
34716
+ ON CONFLICT DO NOTHING
34717
+ `;
34718
+ }
34719
+ return rowToSkill(updated[0]);
34720
+ });
34335
34721
  }
34336
- async deleteSkill(principal, slug) {
34722
+ async deleteSkill(principal, slug, tombstoneWindowMs) {
34337
34723
  return await this.sql.begin(async (tx) => {
34724
+ const existingRows = await tx`
34725
+ SELECT tombstoned_at FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug} LIMIT 1
34726
+ `;
34727
+ if (!existingRows[0])
34728
+ return null;
34729
+ if (existingRows[0].tombstoned_at != null) {
34730
+ const rows2 = await tx`SELECT * FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug} LIMIT 1`;
34731
+ return rowToSkill(rows2[0]);
34732
+ }
34338
34733
  const rows = await tx`
34339
- DELETE FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug}
34340
- RETURNING bundle_sha256
34734
+ UPDATE skills_registry
34735
+ SET tombstoned_at = now(), tombstone_purge_after = now() + (${tombstoneWindowMs}::int * interval '1 millisecond'), updated_at = now()
34736
+ WHERE org_id = ${principal.orgId} AND slug = ${slug}
34737
+ RETURNING *
34341
34738
  `;
34342
- if (!rows[0])
34343
- return false;
34344
- const sha = rows[0].bundle_sha256;
34345
- if (typeof sha === "string") {
34739
+ return rows[0] ? rowToSkill(rows[0]) : null;
34740
+ });
34741
+ }
34742
+ async purgeExpiredTombstones(principal) {
34743
+ return await this.sql.begin(async (tx) => {
34744
+ const expiredRows = await tx`
34745
+ SELECT * FROM skills_registry
34746
+ WHERE org_id = ${principal.orgId} AND tombstoned_at IS NOT NULL AND tombstone_purge_after <= now()
34747
+ `;
34748
+ if (!expiredRows.length)
34749
+ return [];
34750
+ const purged = [];
34751
+ for (const row of expiredRows) {
34752
+ const record = rowToSkill(row);
34346
34753
  await tx`
34347
- DELETE FROM skills_bundles
34348
- WHERE org_id = ${principal.orgId} AND sha256 = ${sha}
34349
- AND NOT EXISTS (SELECT 1 FROM skills_registry WHERE org_id = ${principal.orgId} AND bundle_sha256 = ${sha})
34754
+ DELETE FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${record.slug} AND tombstone_purge_after <= now()
34350
34755
  `;
34756
+ await tx`DELETE FROM skills_tags WHERE org_id = ${principal.orgId} AND slug = ${record.slug}`;
34757
+ await tx`
34758
+ DELETE FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${record.slug} AND tombstone_purge_after <= now()
34759
+ `;
34760
+ if (record.bundleSha256) {
34761
+ await tx`
34762
+ DELETE FROM skills_bundles
34763
+ WHERE org_id = ${principal.orgId} AND sha256 = ${record.bundleSha256}
34764
+ AND NOT EXISTS (SELECT 1 FROM skills_registry WHERE org_id = ${principal.orgId} AND bundle_sha256 = ${record.bundleSha256})
34765
+ `;
34766
+ }
34767
+ purged.push(record);
34351
34768
  }
34352
- return true;
34769
+ return purged;
34353
34770
  });
34354
34771
  }
34355
34772
  async getSkillBundle(principal, sha256) {
34356
34773
  const rows = await this.sql`SELECT * FROM skills_bundles WHERE org_id = ${principal.orgId} AND sha256 = ${sha256} LIMIT 1`;
34357
34774
  return rows[0] ? rowToSkillBundle(rows[0]) : null;
34358
34775
  }
34776
+ async pinSkill(principal, slug, metadata = {}) {
34777
+ const rows = await this.sql`
34778
+ INSERT INTO skills_pins (org_id, principal, slug, pinned_at, metadata_json)
34779
+ VALUES (${principal.orgId}, ${principal.apiKeyId}, ${slug}, now(), ${JSON.stringify(metadata)}::jsonb)
34780
+ ON CONFLICT (org_id, principal, slug) DO UPDATE SET
34781
+ pinned_at = now(),
34782
+ metadata_json = EXCLUDED.metadata_json
34783
+ RETURNING *
34784
+ `;
34785
+ return rowToPin(rows[0]);
34786
+ }
34787
+ async unpinSkill(principal, slug) {
34788
+ const rows = await this.sql`
34789
+ DELETE FROM skills_pins WHERE org_id = ${principal.orgId} AND principal = ${principal.apiKeyId} AND slug = ${slug}
34790
+ RETURNING 1 AS present
34791
+ `;
34792
+ return rows.length > 0;
34793
+ }
34794
+ async listPins(principal) {
34795
+ const rows = await this.sql`
34796
+ SELECT * FROM skills_pins WHERE org_id = ${principal.orgId} AND principal = ${principal.apiKeyId} ORDER BY slug ASC
34797
+ `;
34798
+ return rows.map(rowToPin);
34799
+ }
34800
+ async listTags(principal) {
34801
+ await this.purgeExpiredTombstones(principal);
34802
+ const rows = await this.sql`
34803
+ SELECT DISTINCT tag FROM skills_tags WHERE org_id = ${principal.orgId} ORDER BY tag ASC
34804
+ `;
34805
+ return rows.map((row) => String(row.tag));
34806
+ }
34807
+ async listSkillsByTag(principal, tag) {
34808
+ await this.purgeExpiredTombstones(principal);
34809
+ const rows = await this.sql`
34810
+ SELECT s.* FROM skills_registry s
34811
+ JOIN skills_tags t ON t.org_id = s.org_id AND t.slug = s.slug
34812
+ WHERE t.org_id = ${principal.orgId} AND t.tag = ${tag} AND s.tombstoned_at IS NULL
34813
+ ORDER BY s.slug ASC
34814
+ `;
34815
+ return rows.map(rowToSkill);
34816
+ }
34817
+ async listPinsByTag(principal, tag) {
34818
+ await this.purgeExpiredTombstones(principal);
34819
+ const rows = await this.sql`
34820
+ SELECT p.* FROM skills_pins p
34821
+ JOIN skills_tags t ON t.org_id = p.org_id AND t.slug = p.slug
34822
+ JOIN skills_registry s ON s.org_id = p.org_id AND s.slug = p.slug
34823
+ WHERE p.org_id = ${principal.orgId} AND p.principal = ${principal.apiKeyId}
34824
+ AND t.tag = ${tag} AND s.tombstoned_at IS NULL
34825
+ ORDER BY p.slug ASC
34826
+ `;
34827
+ return rows.map(rowToPin);
34828
+ }
34829
+ async listPublishedSlugs(principal) {
34830
+ const rows = await this.sql`
34831
+ SELECT slug FROM skills_registry WHERE org_id = ${principal.orgId} AND tombstoned_at IS NULL ORDER BY slug ASC
34832
+ `;
34833
+ return rows.map((row) => String(row.slug));
34834
+ }
34359
34835
  async collectOrphanBundle(orgId, sha256) {
34360
34836
  await this.sql`
34361
34837
  DELETE FROM skills_bundles
@@ -34364,6 +34840,7 @@ class PostgresSkillsStore {
34364
34840
  `;
34365
34841
  }
34366
34842
  }
34843
+ var NO_REVISION_SENTINEL2 = "0000000000000000000000000000000000000000000000000000000000000000";
34367
34844
  function isUniqueViolation(error) {
34368
34845
  const code = error?.code;
34369
34846
  if (code === "23505" || code === 23505)
@@ -34425,7 +34902,7 @@ ${summary}
34425
34902
  textArtifact(run, "show-notes.md", `# Show Notes
34426
34903
 
34427
34904
  - ${summary}
34428
- - Generated by the open-skills deterministic worker.
34905
+ - Generated by the skills deterministic worker.
34429
34906
  `),
34430
34907
  textArtifact(run, "clips.csv", `start,end,title,summary
34431
34908
  00:00,00:30,"Opening","${csv(summary)}"
@@ -34459,7 +34936,7 @@ function textArtifact(run, relativePath, bodyText, contentType = relativePath.en
34459
34936
  relativePath,
34460
34937
  contentType,
34461
34938
  byteSize: bytes.byteLength,
34462
- sha256: createHash4("sha256").update(bytes).digest("hex"),
34939
+ sha256: createHash5("sha256").update(bytes).digest("hex"),
34463
34940
  visibility: "private"
34464
34941
  },
34465
34942
  body: { relativePath, bodyText, contentType }
@@ -34521,7 +34998,7 @@ function csv(value) {
34521
34998
  }
34522
34999
 
34523
35000
  // src/server/skills-api.ts
34524
- import { createHash as createHash5 } from "crypto";
35001
+ import { createHash as createHash6 } from "crypto";
34525
35002
 
34526
35003
  // src/lib/registry-merge.ts
34527
35004
  var SKILL_SOURCE_PRECEDENCE = {
@@ -34749,7 +35226,7 @@ var DEVELOPMENT_TOOLS_SKILLS = [
34749
35226
  {
34750
35227
  name: "monitor",
34751
35228
  displayName: "Monitor",
34752
- description: "Operate the open-monitor MCP for machine health, processes, cron jobs, and cleanup workflows",
35229
+ description: "Operate the monitor MCP for machine health, processes, cron jobs, and cleanup workflows",
34753
35230
  category: "Development Tools",
34754
35231
  tags: ["monitoring", "mcp", "processes", "operations"]
34755
35232
  },
@@ -34795,6 +35272,14 @@ var DEVELOPMENT_TOOLS_SKILLS = [
34795
35272
  description: "Validate configuration files for syntax and schema compliance",
34796
35273
  category: "Development Tools",
34797
35274
  tags: ["config", "validation", "schema", "linting"]
35275
+ },
35276
+ {
35277
+ name: "session-inject-monitor",
35278
+ displayName: "Session Inject Monitor",
35279
+ description: "Set up a declarative monitor that injects a prompt into a live coding-agent session when a watched source (conversations, email, todos, knowledge, command output) has new content",
35280
+ category: "Development Tools",
35281
+ tags: ["monitor", "session", "injection", "automation", "wake"],
35282
+ kind: "instruction"
34798
35283
  }
34799
35284
  ];
34800
35285
 
@@ -35182,7 +35667,7 @@ var DESIGN_BRANDING_SKILLS = [
35182
35667
  displayName: "Site Analyze",
35183
35668
  description: "Analyze any website's design system \u2014 detects shadcn/ui, Tailwind, extracts colors, typography, and components via Playwright + Claude Vision.",
35184
35669
  category: "Design & Branding",
35185
- tags: ["design", "shadcn", "tailwind", "colors", "typography", "playwright", "analysis", "open-styles"]
35670
+ tags: ["design", "shadcn", "tailwind", "colors", "typography", "playwright", "analysis", "styles"]
35186
35671
  }
35187
35672
  ];
35188
35673
 
@@ -35613,6 +36098,9 @@ function getPortableSkillsRoot(options = {}) {
35613
36098
  if (options.rootDir)
35614
36099
  return options.rootDir;
35615
36100
  const appDir = options.homeDir ? join7(options.homeDir, ".hasna", "skills") : getDataDir();
36101
+ const cache3 = join7(appDir, SKILLS_CACHE_DIRNAME);
36102
+ if (isOwnerLayoutMigrated(appDir) && safeIsDirectory(cache3))
36103
+ return cache3;
35616
36104
  const installed = join7(appDir, INSTALLED_SKILLS_DIRNAME);
35617
36105
  migrateLegacySkillLayout(appDir, installed);
35618
36106
  return installed;
@@ -35895,7 +36383,6 @@ import { join as join10 } from "path";
35895
36383
  import { existsSync as existsSync6, readFileSync as readFileSync6, rmSync as rmSync2 } from "fs";
35896
36384
  import { dirname as dirname7, join as join9 } from "path";
35897
36385
  import { fileURLToPath as fileURLToPath2 } from "url";
35898
-
35899
36386
  // src/lib/utils.ts
35900
36387
  function normalizeSkillName(name) {
35901
36388
  return name;
@@ -35996,6 +36483,17 @@ var MAX_SLUG_LENGTH = 128;
35996
36483
  var MAX_SKILL_MD_BYTES = 512000;
35997
36484
  var MAX_MANIFEST_BYTES = MAX_SKILL_MD_BYTES + 64000;
35998
36485
  var ALLOWED_PUBLISH_PARTS = new Set(["manifest", "bundle"]);
36486
+ function pinPayload(pin) {
36487
+ return { slug: pin.slug, pinnedAt: pin.pinnedAt, metadata: pin.metadata };
36488
+ }
36489
+ function pinMetadataField(body) {
36490
+ if (body.metadata === undefined)
36491
+ return {};
36492
+ if (!body.metadata || typeof body.metadata !== "object" || Array.isArray(body.metadata)) {
36493
+ throw new SkillRequestError(400, "INVALID_METADATA", "`metadata` must be a JSON object");
36494
+ }
36495
+ return body.metadata;
36496
+ }
35999
36497
 
36000
36498
  class SkillRequestError extends Error {
36001
36499
  status;
@@ -36024,33 +36522,130 @@ function publishedPayload(record) {
36024
36522
  ...publishedSkillMeta(record),
36025
36523
  slug: record.slug,
36026
36524
  publishedSource: record.source,
36525
+ ...record.skillMd ? { skillMd: record.skillMd } : {},
36027
36526
  ...record.bundleSha256 ? { bundleSha256: record.bundleSha256, bundleByteSize: record.bundleByteSize } : {},
36028
36527
  publishedAt: record.createdAt,
36029
- updatedAt: record.updatedAt
36528
+ updatedAt: record.updatedAt,
36529
+ revisionId: record.revisionId,
36530
+ revisionNumber: record.revisionNumber
36531
+ };
36532
+ }
36533
+ function revisionEtag(revisionId) {
36534
+ return `"${revisionId}"`;
36535
+ }
36536
+ function parseIfMatch(value) {
36537
+ if (value === null || value.trim() === "")
36538
+ return;
36539
+ const trimmed = value.trim();
36540
+ if (trimmed === "*") {
36541
+ throw new SkillRequestError(400, "INVALID_IF_MATCH", "If-Match must name the exact revision id (the ETag of the current revision); '*' is not accepted");
36542
+ }
36543
+ const unquoted = trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"') ? trimmed.slice(1, -1) : trimmed;
36544
+ if (!REVISION_ID_PATTERN.test(unquoted)) {
36545
+ throw new SkillRequestError(400, "INVALID_IF_MATCH", "If-Match must carry a revision id: a 64-character lowercase hex sha-256, quoted as the server's ETag");
36546
+ }
36547
+ return unquoted;
36548
+ }
36549
+ async function tombstoneStatus(store, artifactStorage, principal, record) {
36550
+ if (!record.tombstonedAt)
36551
+ return "live";
36552
+ if (record.tombstonePurgeAfter && record.tombstonePurgeAfter <= new Date().toISOString()) {
36553
+ const purged = await store.purgeExpiredTombstones(principal);
36554
+ for (const removed of purged) {
36555
+ if (removed.bundleSha256)
36556
+ await discardCollectedObject(store, artifactStorage, principal, removed.bundleSha256);
36557
+ }
36558
+ return "purged";
36559
+ }
36560
+ return {
36561
+ slug: record.slug,
36562
+ deleted: true,
36563
+ code: "TOMBSTONED",
36564
+ tombstonedAt: record.tombstonedAt,
36565
+ tombstonePurgeAfter: record.tombstonePurgeAfter,
36566
+ revisionId: record.revisionId
36030
36567
  };
36031
36568
  }
36032
36569
  async function listMergedSkills(store, principal) {
36033
36570
  const published = await store.listSkills(principal);
36571
+ return mergedSkillPayloads(published, listServerSkills());
36572
+ }
36573
+ function mergedSkillPayloads(published, bundled) {
36034
36574
  const publishedBySlug = new Map(published.map((record) => [record.slug, record]));
36035
- const merged = mergeSkillRegistryLists(listServerSkills(), published.map(publishedSkillMeta));
36575
+ const merged = mergeSkillRegistryLists(bundled, published.map(publishedSkillMeta));
36036
36576
  return merged.map((skill) => {
36037
36577
  const record = publishedBySlug.get(skill.name);
36038
36578
  return record ? publishedPayload(record) : skill;
36039
36579
  });
36040
36580
  }
36041
- async function getMergedSkill(store, principal, slug) {
36581
+ async function resolvePublishedSkill(store, artifactStorage, principal, slug) {
36042
36582
  const record = await store.getSkill(principal, slug);
36043
- if (record)
36044
- return publishedPayload(record);
36583
+ if (!record)
36584
+ return { kind: "absent" };
36585
+ const status = await tombstoneStatus(store, artifactStorage, principal, record);
36586
+ if (status === "purged")
36587
+ return { kind: "absent" };
36588
+ if (status !== "live")
36589
+ return { kind: "tombstone", payload: status };
36590
+ return { kind: "published", record };
36591
+ }
36592
+ async function listOrgTags(store, principal) {
36593
+ const publishedSlugs = await store.listPublishedSlugs(principal);
36594
+ const tags = new Set;
36595
+ for (const tag of await store.listTags(principal)) {
36596
+ if (tag.trim())
36597
+ tags.add(tag);
36598
+ }
36599
+ for (const skill of listServerSkills()) {
36600
+ if (publishedSlugs.includes(skill.name))
36601
+ continue;
36602
+ for (const tag of skill.tags) {
36603
+ if (tag.trim())
36604
+ tags.add(tag);
36605
+ }
36606
+ }
36607
+ return [...tags].sort();
36608
+ }
36609
+ async function listMergedSkillsByTag(store, principal, tag) {
36610
+ const published = await store.listSkillsByTag(principal, tag);
36611
+ const publishedSlugs = await store.listPublishedSlugs(principal);
36612
+ const bundled = listServerSkills().filter((skill) => skill.tags.includes(tag) && !publishedSlugs.includes(skill.name));
36613
+ return mergedSkillPayloads(published, bundled);
36614
+ }
36615
+ function skillSummary(skill) {
36616
+ return {
36617
+ slug: String(skill.slug ?? skill.name),
36618
+ ...typeof skill.name === "string" ? { name: skill.name } : {},
36619
+ ...typeof skill.version === "string" ? { version: skill.version } : {},
36620
+ ...typeof skill.updatedAt === "string" ? { updatedAt: skill.updatedAt } : {}
36621
+ };
36622
+ }
36623
+ async function listPinsByTag(store, principal, tag) {
36624
+ const publishedSlugs = await store.listPublishedSlugs(principal);
36625
+ const bundledTaggedSlugs = new Set;
36626
+ for (const skill of listServerSkills()) {
36627
+ if (skill.tags.includes(tag) && !publishedSlugs.includes(skill.name))
36628
+ bundledTaggedSlugs.add(skill.name);
36629
+ }
36630
+ const publishedPins = await store.listPinsByTag(principal, tag);
36631
+ const bundledPins = bundledTaggedSlugs.size ? (await store.listPins(principal)).filter((pin) => bundledTaggedSlugs.has(pin.slug)) : [];
36632
+ return [...publishedPins, ...bundledPins].sort((a3, b3) => a3.slug.localeCompare(b3.slug)).map(pinPayload);
36633
+ }
36634
+ async function getMergedSkill(store, artifactStorage, principal, slug) {
36635
+ const resolved = await resolvePublishedSkill(store, artifactStorage, principal, slug);
36636
+ if (resolved.kind === "tombstone")
36637
+ return resolved.payload;
36638
+ if (resolved.kind === "published")
36639
+ return publishedPayload(resolved.record);
36045
36640
  const bundled = getServerSkill(slug);
36046
36641
  return bundled ? bundled : null;
36047
36642
  }
36048
- async function getMergedSkillMd(store, principal, slug) {
36049
- const record = await store.getSkill(principal, slug);
36050
- if (record?.skillMd)
36051
- return record.skillMd;
36052
- if (record)
36643
+ async function getMergedSkillMd(store, artifactStorage, principal, slug) {
36644
+ const resolved = await resolvePublishedSkill(store, artifactStorage, principal, slug);
36645
+ if (resolved.kind === "tombstone")
36053
36646
  return null;
36647
+ if (resolved.kind === "published")
36648
+ return resolved.record.skillMd ?? null;
36054
36649
  return getServerSkillMd(slug);
36055
36650
  }
36056
36651
  async function parsePublishRequest(request, config) {
@@ -36092,7 +36687,7 @@ async function parsePublishRequest(request, config) {
36092
36687
  if (bundleBytes.byteLength === 0) {
36093
36688
  throw new SkillRequestError(400, "BUNDLE_EMPTY", "the uploaded bundle is empty");
36094
36689
  }
36095
- const sha256 = createHash5("sha256").update(bundleBytes).digest("hex");
36690
+ const sha256 = createHash6("sha256").update(bundleBytes).digest("hex");
36096
36691
  const claimed = optionalString(manifest.bundleSha256);
36097
36692
  if (claimed)
36098
36693
  assertSha2562(claimed);
@@ -36120,9 +36715,13 @@ async function parsePublishRequest(request, config) {
36120
36715
  }
36121
36716
  return { input: buildPublishInput(parseManifestJson(text)) };
36122
36717
  }
36123
- async function storePublishedSkill(store, artifactStorage, principal, parsed) {
36718
+ async function storePublishedSkill(store, artifactStorage, principal, parsed, expectedRevisionId) {
36124
36719
  const superseded = (await store.getSkill(principal, parsed.input.slug))?.bundleSha256;
36125
- let input = { ...parsed.input, principal };
36720
+ let input = {
36721
+ ...parsed.input,
36722
+ principal,
36723
+ ...expectedRevisionId ? { expectedRevisionId } : {}
36724
+ };
36126
36725
  if (parsed.bundleBytes && input.bundle) {
36127
36726
  const placement = await artifactStorage.putBundle(principal.orgId, input.bundle.sha256, parsed.bundleBytes, input.bundle.contentType);
36128
36727
  input = { ...input, bundle: { ...input.bundle, ...placement } };
@@ -36133,15 +36732,8 @@ async function storePublishedSkill(store, artifactStorage, principal, parsed) {
36133
36732
  }
36134
36733
  return record;
36135
36734
  }
36136
- async function deletePublishedSkill(store, artifactStorage, principal, slug) {
36137
- const record = await store.getSkill(principal, slug);
36138
- if (!record)
36139
- return false;
36140
- const deleted = await store.deleteSkill(principal, slug);
36141
- if (deleted && record.bundleSha256) {
36142
- await discardCollectedObject(store, artifactStorage, principal, record.bundleSha256);
36143
- }
36144
- return deleted;
36735
+ async function deletePublishedSkill(store, artifactStorage, principal, slug, tombstoneWindowMs) {
36736
+ return store.deleteSkill(principal, slug, tombstoneWindowMs);
36145
36737
  }
36146
36738
  async function discardCollectedObject(store, artifactStorage, principal, sha256) {
36147
36739
  if (await store.getSkillBundle(principal, sha256))
@@ -36161,7 +36753,7 @@ async function readPublishedBundle(store, artifactStorage, principal, slug) {
36161
36753
  if (!bytes) {
36162
36754
  throw new SkillRequestError(503, "BUNDLE_BACKEND_UNAVAILABLE", "bundle storage backend unavailable");
36163
36755
  }
36164
- const actual = createHash5("sha256").update(bytes).digest("hex");
36756
+ const actual = createHash6("sha256").update(bytes).digest("hex");
36165
36757
  if (actual !== record.bundleSha256) {
36166
36758
  throw new SkillRequestError(500, "BUNDLE_DIGEST_DRIFT", `stored bundle for '${slug}' hashes to ${actual} but was published as ${record.bundleSha256}`);
36167
36759
  }
@@ -36277,10 +36869,10 @@ async function createSkillsFetchHandler(options = {}) {
36277
36869
  const segments = pathSegments(url.pathname);
36278
36870
  try {
36279
36871
  if (request.method === "GET" && url.pathname === "/health") {
36280
- return json({ ok: true, service: "open-skills", time: new Date().toISOString() });
36872
+ return json({ ok: true, service: "skills", time: new Date().toISOString() });
36281
36873
  }
36282
36874
  if (request.method === "GET" && url.pathname === "/ready") {
36283
- return json({ ok: true, service: "open-skills" });
36875
+ return json({ ok: true, service: "skills" });
36284
36876
  }
36285
36877
  if (url.pathname.startsWith("/api/")) {
36286
36878
  const principal = await authenticateRequest(store, request);
@@ -36298,6 +36890,14 @@ async function createSkillsFetchHandler(options = {}) {
36298
36890
  if (error instanceof SkillRequestError) {
36299
36891
  return json({ error: error.message, code: error.code }, { status: error.status });
36300
36892
  }
36893
+ if (error instanceof SkillRevisionConflictError) {
36894
+ return json({
36895
+ error: error.message,
36896
+ code: "REVISION_CONFLICT",
36897
+ slug: error.slug,
36898
+ ...error.currentRevisionId ? { currentRevisionId: error.currentRevisionId } : {}
36899
+ }, { status: 409 });
36900
+ }
36301
36901
  return json({ error: "internal server error", detail: error.message }, { status: 500 });
36302
36902
  }
36303
36903
  };
@@ -36317,24 +36917,43 @@ async function handleApiV1(store, principal, request, parts, config, artifactSto
36317
36917
  return json({ error: "invalid path segment", code: "INVALID_PATH" }, { status: 400 });
36318
36918
  }
36319
36919
  if (resource === "skills") {
36320
- if (request.method === "GET" && !id)
36920
+ if (request.method === "GET" && !id) {
36921
+ const tag = new URL(request.url).searchParams.get("tag");
36922
+ if (tag !== null && tag !== "")
36923
+ return json(await listMergedSkillsByTag(store, principal, tag));
36321
36924
  return json(await listMergedSkills(store, principal));
36925
+ }
36322
36926
  if (request.method === "POST" && !id) {
36927
+ const expectedRevisionId = parseIfMatch(request.headers.get("if-match"));
36323
36928
  const parsed = await parsePublishRequest(request, config);
36324
- const record = await storePublishedSkill(store, artifactStorage, principal, parsed);
36325
- return json(publishedPayload(record), { status: 201 });
36929
+ const record = await storePublishedSkill(store, artifactStorage, principal, parsed, expectedRevisionId);
36930
+ return json(publishedPayload(record), { status: 201, headers: { ETag: revisionEtag(record.revisionId) } });
36326
36931
  }
36327
36932
  if (request.method === "GET" && id && subresource === "skill.md") {
36328
- const docs = await getMergedSkillMd(store, principal, id);
36933
+ const resolved = await resolvePublishedSkill(store, artifactStorage, principal, id);
36934
+ if (resolved.kind === "tombstone") {
36935
+ return json({ error: "skill was deleted", ...resolved.payload }, { status: 410 });
36936
+ }
36937
+ const docs = await getMergedSkillMd(store, artifactStorage, principal, id);
36329
36938
  return docs ? new Response(docs, { headers: { "Content-Type": "text/markdown; charset=utf-8", "Cache-Control": "no-store" } }) : json({ error: "skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
36330
36939
  }
36331
36940
  if (request.method === "GET" && id && subresource === "bundle") {
36941
+ const resolved = await resolvePublishedSkill(store, artifactStorage, principal, id);
36942
+ if (resolved.kind === "tombstone") {
36943
+ return json({ error: "skill was deleted", ...resolved.payload }, { status: 410 });
36944
+ }
36945
+ if (resolved.kind === "absent") {
36946
+ return json({ error: "skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
36947
+ }
36332
36948
  const { record, bytes } = await readPublishedBundle(store, artifactStorage, principal, id);
36333
36949
  const headers = {
36334
36950
  "Content-Type": "application/gzip",
36335
36951
  "Content-Length": String(bytes.byteLength),
36336
36952
  "Content-Disposition": `attachment; filename="${record.slug}.tar.gz"`,
36337
36953
  "X-Skill-Bundle-Sha256": record.bundleSha256 ?? "",
36954
+ "X-Skill-Revision-Id": record.revisionId,
36955
+ "X-Skill-Revision-Number": String(record.revisionNumber),
36956
+ ETag: revisionEtag(record.revisionId),
36338
36957
  "Cache-Control": "no-store"
36339
36958
  };
36340
36959
  if (config.bundleSigningKey) {
@@ -36343,17 +36962,56 @@ async function handleApiV1(store, principal, request, parts, config, artifactSto
36343
36962
  return new Response(bytes, { headers });
36344
36963
  }
36345
36964
  if (request.method === "GET" && id && !subresource) {
36346
- const skill = await getMergedSkill(store, principal, id);
36965
+ const resolved = await resolvePublishedSkill(store, artifactStorage, principal, id);
36966
+ if (resolved.kind === "tombstone") {
36967
+ return json({ error: "skill was deleted", ...resolved.payload }, { status: 410 });
36968
+ }
36969
+ if (resolved.kind === "published") {
36970
+ return json(publishedPayload(resolved.record), { headers: { ETag: revisionEtag(resolved.record.revisionId) } });
36971
+ }
36972
+ const skill = await getMergedSkill(store, artifactStorage, principal, id);
36347
36973
  return skill ? json(skill) : json({ error: "skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
36348
36974
  }
36349
36975
  if ((request.method === "PUT" || request.method === "PATCH") && id && !subresource) {
36976
+ const expectedRevisionId = parseIfMatch(request.headers.get("if-match"));
36350
36977
  const body = await readJson(request, config.requestBodyLimitBytes);
36351
- const updated = await store.updateSkill(principal, id, skillPatch(body));
36352
- return updated ? json(publishedPayload(updated)) : json({ error: "published skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
36978
+ const updated = await store.updateSkill(principal, id, skillPatch(body), expectedRevisionId);
36979
+ return updated ? json(publishedPayload(updated), { headers: { ETag: revisionEtag(updated.revisionId) } }) : json({ error: "published skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
36353
36980
  }
36354
36981
  if (request.method === "DELETE" && id && !subresource) {
36355
- const removed = await deletePublishedSkill(store, artifactStorage, principal, id);
36356
- return removed ? json({ deleted: true, slug: id }) : json({ error: "published skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
36982
+ const removed = await deletePublishedSkill(store, artifactStorage, principal, id, config.tombstoneWindowMs);
36983
+ return removed ? json({
36984
+ deleted: true,
36985
+ slug: id,
36986
+ ...removed.tombstonedAt ? { tombstonedAt: removed.tombstonedAt, tombstonePurgeAfter: removed.tombstonePurgeAfter } : {}
36987
+ }) : json({ error: "published skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
36988
+ }
36989
+ }
36990
+ if (resource === "pins") {
36991
+ if (request.method === "GET" && !id) {
36992
+ const tag = new URL(request.url).searchParams.get("tag");
36993
+ if (tag !== null && tag !== "")
36994
+ return json(await listPinsByTag(store, principal, tag));
36995
+ return json((await store.listPins(principal)).map(pinPayload));
36996
+ }
36997
+ if (request.method === "PUT" && id && !subresource) {
36998
+ assertPublishableSlug(id);
36999
+ const body = await readJson(request, config.requestBodyLimitBytes);
37000
+ const pin = await store.pinSkill(principal, id, pinMetadataField(body));
37001
+ return json(pinPayload(pin));
37002
+ }
37003
+ if (request.method === "DELETE" && id && !subresource) {
37004
+ assertPublishableSlug(id);
37005
+ const removed = await store.unpinSkill(principal, id);
37006
+ return removed ? json({ deleted: true, slug: id }) : json({ error: "pin not found", code: "PIN_NOT_FOUND" }, { status: 404 });
37007
+ }
37008
+ }
37009
+ if (resource === "tags") {
37010
+ if (request.method === "GET" && !id) {
37011
+ return json(await listOrgTags(store, principal));
37012
+ }
37013
+ if (request.method === "GET" && id && subresource === "skills") {
37014
+ return json((await listMergedSkillsByTag(store, principal, id)).map(skillSummary));
36357
37015
  }
36358
37016
  }
36359
37017
  if (resource === "runs") {
@@ -36505,5 +37163,5 @@ function clampInt(value, fallback, max) {
36505
37163
  // src/server/index.ts
36506
37164
  var config = resolveServerConfig();
36507
37165
  var server = await startSkillsServer({ config });
36508
- console.log(`open-skills API listening on http://${config.host}:${server.port}`);
37166
+ console.log(`skills API listening on http://${config.host}:${server.port}`);
36509
37167
  console.log(`storage: ${resolveDatabaseTarget(config.databaseUrl).label}`);