@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/worker.js CHANGED
@@ -22952,6 +22952,7 @@ var ANY_SEGMENT_EXCLUDES = new Set([
22952
22952
  ".docker"
22953
22953
  ]);
22954
22954
  var ROOT_EXCLUDES = new Set(["dist", "build", ".turbo"]);
22955
+ var TOOL_SIDECAR_FILENAMES = new Set([".hasna-skills.json"]);
22955
22956
  var CREDENTIAL_FILENAMES = new Set([
22956
22957
  ".npmrc",
22957
22958
  ".pypirc",
@@ -32919,9 +32920,10 @@ function resolveServerConfig(env = process.env) {
32919
32920
  artifactBucket: env.HASNA_SKILLS_S3_BUCKET || env.SKILLS_S3_BUCKET || undefined,
32920
32921
  artifactPrefix: normalizePrefix(env.HASNA_SKILLS_S3_PREFIX || env.SKILLS_S3_PREFIX || "skills/artifacts"),
32921
32922
  inlineWorker: env.HASNA_SKILLS_INLINE_WORKER === "1",
32922
- bundleSigningKey: env.HASNA_SKILLS_SIGNING_KEY || undefined,
32923
+ bundleSigningKey: env.HASNA_SKILLS_API_SIGNING_KEY || env.HASNA_SKILLS_SIGNING_KEY || undefined,
32923
32924
  requestBodyLimitBytes: parsePositiveInt(env.HASNA_SKILLS_REQUEST_BODY_LIMIT_BYTES, 1e6),
32924
32925
  skillBundleLimitBytes: parsePositiveInt(env.HASNA_SKILLS_BUNDLE_LIMIT_BYTES, 25000000),
32926
+ tombstoneWindowMs: parsePositiveInt(env.HASNA_SKILLS_TOMBSTONE_WINDOW_MS, 7 * 24 * 60 * 60 * 1000),
32925
32927
  publicBaseUrl: (env.SKILLS_PUBLIC_BASE_URL || localOrigin(host, port)).replace(/\/+$/, ""),
32926
32928
  nodeEnv,
32927
32929
  allowEphemeralStore: env.HASNA_SKILLS_ALLOW_EPHEMERAL_STORE === "1"
@@ -33058,7 +33060,7 @@ function looksLikeSqlitePath(value) {
33058
33060
  }
33059
33061
 
33060
33062
  // src/server/handlers.ts
33061
- import { createHash as createHash4 } from "crypto";
33063
+ import { createHash as createHash5 } from "crypto";
33062
33064
 
33063
33065
  // src/server/store.ts
33064
33066
  import { randomUUID as randomUUID3 } from "crypto";
@@ -33079,6 +33081,19 @@ class StaleLeaseGenerationError extends Error {
33079
33081
  }
33080
33082
  }
33081
33083
 
33084
+ class SkillRevisionConflictError extends Error {
33085
+ slug;
33086
+ expectedRevisionId;
33087
+ currentRevisionId;
33088
+ constructor(slug, expectedRevisionId, currentRevisionId) {
33089
+ super(`revision conflict for '${slug}': expected revision ${expectedRevisionId ?? "(none)"}, ` + `current is ${currentRevisionId ?? "(none)"}. Refused rather than silently overwriting a newer revision.`);
33090
+ this.name = "SkillRevisionConflictError";
33091
+ this.slug = slug;
33092
+ this.expectedRevisionId = expectedRevisionId;
33093
+ this.currentRevisionId = currentRevisionId;
33094
+ }
33095
+ }
33096
+
33082
33097
  // src/server/rows.ts
33083
33098
  import { randomUUID } from "crypto";
33084
33099
  function nowIso() {
@@ -33159,7 +33174,20 @@ function rowToSkill(row) {
33159
33174
  ...row.bundle_byte_size === null || row.bundle_byte_size === undefined ? {} : { bundleByteSize: Number(row.bundle_byte_size) },
33160
33175
  ...typeof row.published_by_user_id === "string" ? { publishedByUserId: row.published_by_user_id } : {},
33161
33176
  createdAt: dateString(row.created_at),
33162
- updatedAt: dateString(row.updated_at)
33177
+ updatedAt: dateString(row.updated_at),
33178
+ revisionId: String(row.revision_id ?? ""),
33179
+ revisionNumber: Number(row.revision_number ?? 0),
33180
+ ...row.tombstoned_at ? { tombstonedAt: dateString(row.tombstoned_at) } : {},
33181
+ ...row.tombstone_purge_after ? { tombstonePurgeAfter: dateString(row.tombstone_purge_after) } : {}
33182
+ };
33183
+ }
33184
+ function rowToPin(row) {
33185
+ return {
33186
+ orgId: String(row.org_id),
33187
+ principal: String(row.principal),
33188
+ slug: String(row.slug),
33189
+ pinnedAt: dateString(row.pinned_at),
33190
+ metadata: parseJsonObject(row.metadata_json)
33163
33191
  };
33164
33192
  }
33165
33193
  function rowToSkillBundle(row) {
@@ -33211,6 +33239,28 @@ function dateString(value) {
33211
33239
  return String(value);
33212
33240
  }
33213
33241
 
33242
+ // src/lib/revision.ts
33243
+ import { createHash as createHash4 } from "crypto";
33244
+ function revisionIdOf(content) {
33245
+ const canonical = JSON.stringify({
33246
+ slug: content.slug,
33247
+ displayName: content.displayName,
33248
+ description: content.description,
33249
+ category: content.category,
33250
+ tags: content.tags,
33251
+ source: content.source,
33252
+ kind: content.kind,
33253
+ version: content.version ?? null,
33254
+ skillMd: content.skillMd ?? null,
33255
+ bundleSha256: content.bundleSha256 ?? null,
33256
+ bundleByteSize: content.bundleByteSize ?? null
33257
+ });
33258
+ return createHash4("sha256").update(canonical).digest("hex");
33259
+ }
33260
+ function revisionIdOfRecord(record) {
33261
+ return revisionIdOf(record);
33262
+ }
33263
+
33214
33264
  // src/server/sqlite-store.ts
33215
33265
  import { Database } from "bun:sqlite";
33216
33266
  import { randomUUID as randomUUID2 } from "crypto";
@@ -33258,6 +33308,7 @@ function defaultStartDirs() {
33258
33308
  // src/server/sqlite-store.ts
33259
33309
  var CLAIM_ATTEMPTS = 8;
33260
33310
  var CLAIMABLE_STATUSES = ["queued", "retrying"];
33311
+ var NO_REVISION_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000";
33261
33312
  var LAST_USED_RESOLUTION_MS = 60000;
33262
33313
 
33263
33314
  class SqliteSkillsStore {
@@ -33277,12 +33328,24 @@ class SqliteSkillsStore {
33277
33328
  if (options.migrate !== false) {
33278
33329
  applySqliteMigrations(this.db, options.migrationsDir);
33279
33330
  }
33331
+ this.backfillLegacyRevisions();
33280
33332
  this.backend = {
33281
33333
  kind: "sqlite",
33282
33334
  durable: !inMemory,
33283
33335
  label: inMemory ? "sqlite (in-memory)" : `sqlite (${path})`
33284
33336
  };
33285
33337
  }
33338
+ backfillLegacyRevisions() {
33339
+ const rows = this.all("SELECT * FROM skills_registry WHERE revision_id = ''", []);
33340
+ for (const row of rows) {
33341
+ const record = rowToSkill(row);
33342
+ this.db.run("UPDATE skills_registry SET revision_id = ? WHERE org_id = ? AND slug = ?", [
33343
+ revisionIdOfRecord(record),
33344
+ record.orgId,
33345
+ record.slug
33346
+ ]);
33347
+ }
33348
+ }
33286
33349
  get database() {
33287
33350
  return this.db;
33288
33351
  }
@@ -33506,8 +33569,29 @@ class SqliteSkillsStore {
33506
33569
  const orgId = input.principal.orgId;
33507
33570
  const now = nowIso();
33508
33571
  return this.db.transaction(() => {
33509
- const previous = this.get("SELECT bundle_sha256 FROM skills_registry WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
33572
+ 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]);
33510
33573
  const previousSha = typeof previous?.bundle_sha256 === "string" ? previous.bundle_sha256 : null;
33574
+ const previousRevisionId = typeof previous?.revision_id === "string" && previous.revision_id ? previous.revision_id : null;
33575
+ const tombstoned = previous?.tombstoned_at != null;
33576
+ const carriedSkillMd = typeof input.skillMd === "string" ? input.skillMd : typeof previous?.skill_md === "string" ? previous.skill_md : null;
33577
+ if (previous && !tombstoned && input.expectedRevisionId !== previousRevisionId) {
33578
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previousRevisionId);
33579
+ }
33580
+ const carriedSha = input.bundle?.sha256 ?? previousSha;
33581
+ const carriedSize = input.bundle?.byteSize ?? (previous?.bundle_byte_size == null ? null : Number(previous.bundle_byte_size));
33582
+ const revisionId = revisionIdOfRecord({
33583
+ slug: input.slug,
33584
+ displayName: input.displayName,
33585
+ description: input.description,
33586
+ category: input.category,
33587
+ tags: input.tags,
33588
+ source: input.source,
33589
+ kind: input.kind,
33590
+ ...input.version ? { version: input.version } : {},
33591
+ ...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
33592
+ ...carriedSha ? { bundleSha256: carriedSha } : {},
33593
+ ...carriedSize === null || carriedSize === undefined ? {} : { bundleByteSize: carriedSize }
33594
+ });
33511
33595
  if (input.bundle) {
33512
33596
  this.db.run(`INSERT INTO skills_bundles (org_id, sha256, byte_size, content_type, storage_kind, storage_key, body_blob, created_at)
33513
33597
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
@@ -33528,8 +33612,8 @@ class SqliteSkillsStore {
33528
33612
  ]);
33529
33613
  }
33530
33614
  const row = this.get(`INSERT INTO skills_registry (org_id, slug, display_name, description, category, tags_json, source, kind, version, skill_md,
33531
- bundle_sha256, bundle_byte_size, published_by_user_id, created_at, updated_at)
33532
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
33615
+ bundle_sha256, bundle_byte_size, published_by_user_id, revision_id, revision_number, created_at, updated_at)
33616
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
33533
33617
  ON CONFLICT (org_id, slug) DO UPDATE SET
33534
33618
  display_name = excluded.display_name,
33535
33619
  description = excluded.description,
@@ -33547,7 +33631,16 @@ class SqliteSkillsStore {
33547
33631
  bundle_sha256 = COALESCE(excluded.bundle_sha256, skills_registry.bundle_sha256),
33548
33632
  bundle_byte_size = COALESCE(excluded.bundle_byte_size, skills_registry.bundle_byte_size),
33549
33633
  published_by_user_id = excluded.published_by_user_id,
33634
+ revision_id = excluded.revision_id,
33635
+ -- Current + 1, not the inserted 1: on the update path the ACTUAL row's
33636
+ -- counter is the truth (it may have advanced since the pre-read, which is
33637
+ -- exactly what the WHERE guard below detects).
33638
+ revision_number = skills_registry.revision_number + 1,
33639
+ tombstoned_at = NULL,
33640
+ tombstone_purge_after = NULL,
33550
33641
  updated_at = excluded.updated_at
33642
+ WHERE skills_registry.tombstoned_at IS NOT NULL
33643
+ OR skills_registry.revision_id = ?
33551
33644
  RETURNING *`, [
33552
33645
  orgId,
33553
33646
  input.slug,
@@ -33558,62 +33651,165 @@ class SqliteSkillsStore {
33558
33651
  input.source,
33559
33652
  input.kind,
33560
33653
  input.version ?? null,
33561
- input.skillMd ?? null,
33654
+ carriedSkillMd,
33562
33655
  input.bundle?.sha256 ?? null,
33563
33656
  input.bundle?.byteSize ?? null,
33564
33657
  input.principal.userId,
33658
+ revisionId,
33659
+ now,
33565
33660
  now,
33566
- now
33661
+ input.expectedRevisionId ?? NO_REVISION_SENTINEL
33567
33662
  ]);
33663
+ if (!row) {
33664
+ const current = this.get("SELECT revision_id FROM skills_registry WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
33665
+ const currentId = typeof current?.revision_id === "string" ? current.revision_id : null;
33666
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, currentId);
33667
+ }
33568
33668
  if (previousSha && input.bundle && previousSha !== input.bundle.sha256)
33569
33669
  this.collectOrphanBundle(orgId, previousSha);
33670
+ this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
33671
+ const insertTag = this.db.prepare("INSERT OR IGNORE INTO skills_tags (org_id, slug, tag) VALUES (?, ?, ?)");
33672
+ for (const tag of input.tags) {
33673
+ if (!tag.trim())
33674
+ continue;
33675
+ insertTag.run(orgId, input.slug, tag);
33676
+ }
33570
33677
  return rowToSkill(row);
33571
33678
  })();
33572
33679
  }
33573
33680
  async listSkills(principal) {
33574
- return this.all("SELECT * FROM skills_registry WHERE org_id = ? ORDER BY slug ASC", [principal.orgId]).map(rowToSkill);
33681
+ await this.purgeExpiredTombstones(principal);
33682
+ return this.all("SELECT * FROM skills_registry WHERE org_id = ? AND tombstoned_at IS NULL ORDER BY slug ASC", [principal.orgId]).map(rowToSkill);
33575
33683
  }
33576
33684
  async getSkill(principal, slug) {
33577
33685
  const row = this.get("SELECT * FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [principal.orgId, slug]);
33578
33686
  return row ? rowToSkill(row) : null;
33579
33687
  }
33580
- async updateSkill(principal, slug, patch) {
33688
+ async updateSkill(principal, slug, patch, expectedRevisionId) {
33581
33689
  const current = await this.getSkill(principal, slug);
33582
- if (!current)
33690
+ if (!current || current.tombstonedAt)
33583
33691
  return null;
33692
+ if (expectedRevisionId !== current.revisionId) {
33693
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
33694
+ }
33584
33695
  const next = { ...current, ...patch };
33585
- const row = this.get(`UPDATE skills_registry
33586
- SET display_name = ?, description = ?, category = ?, tags_json = ?, kind = ?, version = ?, skill_md = ?, updated_at = ?
33587
- WHERE org_id = ? AND slug = ?
33588
- RETURNING *`, [
33589
- next.displayName,
33590
- next.description,
33591
- next.category,
33592
- JSON.stringify(next.tags),
33593
- next.kind,
33594
- next.version ?? null,
33595
- next.skillMd ?? null,
33596
- nowIso(),
33597
- principal.orgId,
33598
- slug
33599
- ]);
33600
- return row ? rowToSkill(row) : null;
33696
+ return this.db.transaction(() => {
33697
+ const revisionId = revisionIdOfRecord(next);
33698
+ const row = this.get(`UPDATE skills_registry
33699
+ SET display_name = ?, description = ?, category = ?, tags_json = ?, kind = ?, version = ?, skill_md = ?,
33700
+ revision_id = ?, revision_number = revision_number + 1, updated_at = ?
33701
+ WHERE org_id = ? AND slug = ? AND tombstoned_at IS NULL AND revision_id = ?
33702
+ RETURNING *`, [
33703
+ next.displayName,
33704
+ next.description,
33705
+ next.category,
33706
+ JSON.stringify(next.tags),
33707
+ next.kind,
33708
+ next.version ?? null,
33709
+ next.skillMd ?? null,
33710
+ revisionId,
33711
+ nowIso(),
33712
+ principal.orgId,
33713
+ slug,
33714
+ current.revisionId
33715
+ ]);
33716
+ if (!row) {
33717
+ const nowRow = this.get("SELECT revision_id, tombstoned_at FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [
33718
+ principal.orgId,
33719
+ slug
33720
+ ]);
33721
+ if (nowRow && nowRow.tombstoned_at == null) {
33722
+ const currentId = typeof nowRow.revision_id === "string" ? nowRow.revision_id : null;
33723
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, currentId);
33724
+ }
33725
+ return null;
33726
+ }
33727
+ this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
33728
+ const insertTag = this.db.prepare("INSERT OR IGNORE INTO skills_tags (org_id, slug, tag) VALUES (?, ?, ?)");
33729
+ for (const tag of next.tags) {
33730
+ if (!tag.trim())
33731
+ continue;
33732
+ insertTag.run(principal.orgId, slug, tag);
33733
+ }
33734
+ return rowToSkill(row);
33735
+ })();
33601
33736
  }
33602
- async deleteSkill(principal, slug) {
33737
+ async deleteSkill(principal, slug, tombstoneWindowMs) {
33603
33738
  return this.db.transaction(() => {
33604
- const existing = this.get("SELECT bundle_sha256 FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
33739
+ const existing = this.get("SELECT tombstoned_at FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
33605
33740
  if (!existing)
33606
- return false;
33607
- this.db.run("DELETE FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
33608
- if (typeof existing.bundle_sha256 === "string")
33609
- this.collectOrphanBundle(principal.orgId, existing.bundle_sha256);
33610
- return true;
33741
+ return null;
33742
+ if (existing.tombstoned_at != null) {
33743
+ const row2 = this.get("SELECT * FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [principal.orgId, slug]);
33744
+ return rowToSkill(row2);
33745
+ }
33746
+ const tombstonedAt = nowIso();
33747
+ const purgeAfter = new Date(Date.now() + tombstoneWindowMs).toISOString();
33748
+ const row = this.get(`UPDATE skills_registry
33749
+ SET tombstoned_at = ?, tombstone_purge_after = ?, updated_at = ?
33750
+ WHERE org_id = ? AND slug = ?
33751
+ RETURNING *`, [tombstonedAt, purgeAfter, tombstonedAt, principal.orgId, slug]);
33752
+ return rowToSkill(row);
33753
+ })();
33754
+ }
33755
+ async purgeExpiredTombstones(principal) {
33756
+ return this.db.transaction(() => {
33757
+ const now = nowIso();
33758
+ const expired = this.all("SELECT * FROM skills_registry WHERE org_id = ? AND tombstoned_at IS NOT NULL AND tombstone_purge_after <= ?", [principal.orgId, now]);
33759
+ const purged = [];
33760
+ for (const row of expired) {
33761
+ const record = rowToSkill(row);
33762
+ this.db.run("DELETE FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, record.slug]);
33763
+ this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [principal.orgId, record.slug]);
33764
+ if (record.bundleSha256)
33765
+ this.collectOrphanBundle(principal.orgId, record.bundleSha256);
33766
+ purged.push(record);
33767
+ }
33768
+ return purged;
33611
33769
  })();
33612
33770
  }
33613
33771
  async getSkillBundle(principal, sha256) {
33614
33772
  const row = this.get("SELECT * FROM skills_bundles WHERE org_id = ? AND sha256 = ? LIMIT 1", [principal.orgId, sha256]);
33615
33773
  return row ? rowToSkillBundle(row) : null;
33616
33774
  }
33775
+ async pinSkill(principal, slug, metadata = {}) {
33776
+ const row = this.get(`INSERT INTO skills_pins (org_id, principal, slug, pinned_at, metadata_json)
33777
+ VALUES (?, ?, ?, ?, ?)
33778
+ ON CONFLICT (org_id, principal, slug) DO UPDATE SET
33779
+ pinned_at = excluded.pinned_at,
33780
+ metadata_json = excluded.metadata_json
33781
+ RETURNING *`, [principal.orgId, principal.apiKeyId, slug, nowIso(), JSON.stringify(metadata)]);
33782
+ return rowToPin(row);
33783
+ }
33784
+ async unpinSkill(principal, slug) {
33785
+ const result = this.db.run("DELETE FROM skills_pins WHERE org_id = ? AND principal = ? AND slug = ?", [principal.orgId, principal.apiKeyId, slug]);
33786
+ return result.changes > 0;
33787
+ }
33788
+ async listPins(principal) {
33789
+ return this.all("SELECT * FROM skills_pins WHERE org_id = ? AND principal = ? ORDER BY slug ASC", [principal.orgId, principal.apiKeyId]).map(rowToPin);
33790
+ }
33791
+ async listTags(principal) {
33792
+ await this.purgeExpiredTombstones(principal);
33793
+ return this.all("SELECT DISTINCT tag FROM skills_tags WHERE org_id = ? ORDER BY tag ASC", [principal.orgId]).map((row) => String(row.tag));
33794
+ }
33795
+ async listSkillsByTag(principal, tag) {
33796
+ await this.purgeExpiredTombstones(principal);
33797
+ return this.all(`SELECT s.* FROM skills_registry s
33798
+ JOIN skills_tags t ON t.org_id = s.org_id AND t.slug = s.slug
33799
+ WHERE t.org_id = ? AND t.tag = ? AND s.tombstoned_at IS NULL
33800
+ ORDER BY s.slug ASC`, [principal.orgId, tag]).map(rowToSkill);
33801
+ }
33802
+ async listPinsByTag(principal, tag) {
33803
+ await this.purgeExpiredTombstones(principal);
33804
+ return this.all(`SELECT p.* FROM skills_pins p
33805
+ JOIN skills_tags t ON t.org_id = p.org_id AND t.slug = p.slug
33806
+ JOIN skills_registry s ON s.org_id = p.org_id AND s.slug = p.slug
33807
+ WHERE p.org_id = ? AND p.principal = ? AND t.tag = ? AND s.tombstoned_at IS NULL
33808
+ ORDER BY p.slug ASC`, [principal.orgId, principal.apiKeyId, tag]).map(rowToPin);
33809
+ }
33810
+ async listPublishedSlugs(principal) {
33811
+ 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));
33812
+ }
33617
33813
  collectOrphanBundle(orgId, sha256) {
33618
33814
  const referenced = this.get("SELECT 1 AS present FROM skills_registry WHERE org_id = ? AND bundle_sha256 = ? LIMIT 1", [orgId, sha256]);
33619
33815
  if (referenced)
@@ -33700,6 +33896,21 @@ function parseScopes(value) {
33700
33896
  }
33701
33897
 
33702
33898
  // src/server/store.ts
33899
+ function recordFieldsOf(input, carriedBundle, carriedSkillMd) {
33900
+ return {
33901
+ slug: input.slug,
33902
+ displayName: input.displayName,
33903
+ description: input.description,
33904
+ category: input.category,
33905
+ tags: input.tags,
33906
+ source: input.source,
33907
+ kind: input.kind,
33908
+ ...input.version ? { version: input.version } : {},
33909
+ ...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
33910
+ bundleSha256: input.bundle?.sha256 ?? carriedBundle.bundleSha256,
33911
+ bundleByteSize: input.bundle?.byteSize ?? carriedBundle.bundleByteSize
33912
+ };
33913
+ }
33703
33914
  function resolvePoolMax(env = process.env) {
33704
33915
  const parsed = Number.parseInt(env.HASNA_SKILLS_DATABASE_POOL_MAX || env.SKILLS_DATABASE_POOL_MAX || "", 10);
33705
33916
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 4;
@@ -33737,6 +33948,7 @@ class MemorySkillsStore {
33737
33948
  idempotency = new Map;
33738
33949
  skills = new Map;
33739
33950
  bundles = new Map;
33951
+ pins = new Map;
33740
33952
  constructor(apiKeys = []) {
33741
33953
  for (const key of apiKeys)
33742
33954
  this.addApiKey(key.token, key.principal);
@@ -33838,6 +34050,9 @@ class MemorySkillsStore {
33838
34050
  const key = skillKey(input.principal.orgId, input.slug);
33839
34051
  const now = nowIso();
33840
34052
  const previous = this.skills.get(key);
34053
+ if (previous && !previous.tombstonedAt && input.expectedRevisionId !== previous.revisionId) {
34054
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previous.revisionId);
34055
+ }
33841
34056
  if (input.bundle) {
33842
34057
  const bundleMapKey = skillKey(input.principal.orgId, input.bundle.sha256);
33843
34058
  this.bundles.set(bundleMapKey, {
@@ -33847,6 +34062,8 @@ class MemorySkillsStore {
33847
34062
  createdAt: this.bundles.get(bundleMapKey)?.createdAt ?? now
33848
34063
  });
33849
34064
  }
34065
+ const carriedBundle = !input.bundle && previous?.bundleSha256 ? { bundleSha256: previous.bundleSha256, ...previous.bundleByteSize === undefined ? {} : { bundleByteSize: previous.bundleByteSize } } : {};
34066
+ const carriedSkillMd = typeof input.skillMd === "string" ? input.skillMd : previous?.skillMd;
33850
34067
  const record = {
33851
34068
  orgId: input.principal.orgId,
33852
34069
  slug: input.slug,
@@ -33857,11 +34074,13 @@ class MemorySkillsStore {
33857
34074
  source: input.source,
33858
34075
  kind: input.kind,
33859
34076
  ...input.version ? { version: input.version } : {},
33860
- ...input.skillMd ? { skillMd: input.skillMd } : {},
33861
- ...input.bundle ? { bundleSha256: input.bundle.sha256, bundleByteSize: input.bundle.byteSize } : previous?.bundleSha256 ? { bundleSha256: previous.bundleSha256, ...previous.bundleByteSize === undefined ? {} : { bundleByteSize: previous.bundleByteSize } } : {},
34077
+ ...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
34078
+ ...input.bundle ? { bundleSha256: input.bundle.sha256, bundleByteSize: input.bundle.byteSize } : carriedBundle,
33862
34079
  publishedByUserId: input.principal.userId,
33863
34080
  createdAt: previous?.createdAt ?? now,
33864
- updatedAt: now
34081
+ updatedAt: now,
34082
+ revisionId: revisionIdOfRecord(recordFieldsOf(input, carriedBundle, carriedSkillMd)),
34083
+ revisionNumber: (previous?.revisionNumber ?? 0) + 1
33865
34084
  };
33866
34085
  this.skills.set(key, record);
33867
34086
  if (previous?.bundleSha256 && input.bundle && previous.bundleSha256 !== input.bundle.sha256) {
@@ -33870,33 +34089,107 @@ class MemorySkillsStore {
33870
34089
  return record;
33871
34090
  }
33872
34091
  async listSkills(principal) {
33873
- return Array.from(this.skills.values()).filter((skill) => skill.orgId === principal.orgId).sort((a3, b3) => a3.slug.localeCompare(b3.slug));
34092
+ return Array.from(this.skills.values()).filter((skill) => skill.orgId === principal.orgId && !skill.tombstonedAt).sort((a3, b3) => a3.slug.localeCompare(b3.slug));
33874
34093
  }
33875
34094
  async getSkill(principal, slug) {
33876
34095
  const skill = this.skills.get(skillKey(principal.orgId, slug));
33877
34096
  return skill && skill.orgId === principal.orgId ? skill : null;
33878
34097
  }
33879
- async updateSkill(principal, slug, patch) {
34098
+ async updateSkill(principal, slug, patch, expectedRevisionId) {
33880
34099
  const current = await this.getSkill(principal, slug);
33881
- if (!current)
34100
+ if (!current || current.tombstonedAt)
34101
+ return null;
34102
+ if (expectedRevisionId !== current.revisionId) {
34103
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
34104
+ }
34105
+ const latest = this.skills.get(skillKey(principal.orgId, slug));
34106
+ if (!latest || latest.tombstonedAt)
33882
34107
  return null;
33883
- const next = { ...current, ...patch, updatedAt: nowIso() };
34108
+ if (latest.revisionId !== current.revisionId) {
34109
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, latest.revisionId);
34110
+ }
34111
+ const next = {
34112
+ ...latest,
34113
+ ...patch,
34114
+ updatedAt: nowIso(),
34115
+ revisionId: revisionIdOfRecord({ ...latest, ...patch }),
34116
+ revisionNumber: latest.revisionNumber + 1
34117
+ };
33884
34118
  this.skills.set(skillKey(principal.orgId, slug), next);
33885
34119
  return next;
33886
34120
  }
33887
- async deleteSkill(principal, slug) {
34121
+ async deleteSkill(principal, slug, tombstoneWindowMs) {
33888
34122
  const current = await this.getSkill(principal, slug);
33889
34123
  if (!current)
33890
- return false;
33891
- this.skills.delete(skillKey(principal.orgId, slug));
33892
- if (current.bundleSha256)
33893
- this.collectOrphanBundle(principal.orgId, current.bundleSha256);
33894
- return true;
34124
+ return null;
34125
+ if (!current.tombstonedAt) {
34126
+ const tombstoned = nowIso();
34127
+ const purgeAfter = new Date(Date.now() + tombstoneWindowMs).toISOString();
34128
+ const next = { ...current, tombstonedAt: tombstoned, tombstonePurgeAfter: purgeAfter, updatedAt: tombstoned };
34129
+ this.skills.set(skillKey(principal.orgId, slug), next);
34130
+ return next;
34131
+ }
34132
+ return current;
34133
+ }
34134
+ async purgeExpiredTombstones(principal) {
34135
+ const now = nowIso();
34136
+ const purged = [];
34137
+ for (const [key, skill] of this.skills) {
34138
+ if (skill.orgId !== principal.orgId || !skill.tombstonedAt || !skill.tombstonePurgeAfter)
34139
+ continue;
34140
+ if (skill.tombstonePurgeAfter > now)
34141
+ continue;
34142
+ this.skills.delete(key);
34143
+ if (skill.bundleSha256)
34144
+ this.collectOrphanBundle(principal.orgId, skill.bundleSha256);
34145
+ purged.push(skill);
34146
+ }
34147
+ return purged;
33895
34148
  }
33896
34149
  async getSkillBundle(principal, sha256) {
33897
34150
  const bundle = this.bundles.get(skillKey(principal.orgId, sha256));
33898
34151
  return bundle && bundle.orgId === principal.orgId ? bundle : null;
33899
34152
  }
34153
+ async pinSkill(principal, slug, metadata = {}) {
34154
+ const pin = { orgId: principal.orgId, principal: principal.apiKeyId, slug, pinnedAt: nowIso(), metadata: { ...metadata } };
34155
+ this.pins.set(pinKey(principal.orgId, principal.apiKeyId, slug), pin);
34156
+ return pin;
34157
+ }
34158
+ async unpinSkill(principal, slug) {
34159
+ return this.pins.delete(pinKey(principal.orgId, principal.apiKeyId, slug));
34160
+ }
34161
+ async listPins(principal) {
34162
+ return Array.from(this.pins.values()).filter((pin) => pin.orgId === principal.orgId && pin.principal === principal.apiKeyId).sort((a3, b3) => a3.slug.localeCompare(b3.slug));
34163
+ }
34164
+ async listTags(principal) {
34165
+ await this.purgeExpiredTombstones(principal);
34166
+ const tags = new Set;
34167
+ for (const skill of this.skills.values()) {
34168
+ if (skill.orgId !== principal.orgId)
34169
+ continue;
34170
+ for (const tag of skill.tags) {
34171
+ if (tag.trim())
34172
+ tags.add(tag);
34173
+ }
34174
+ }
34175
+ return [...tags].sort();
34176
+ }
34177
+ async listSkillsByTag(principal, tag) {
34178
+ await this.purgeExpiredTombstones(principal);
34179
+ 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));
34180
+ }
34181
+ async listPinsByTag(principal, tag) {
34182
+ await this.purgeExpiredTombstones(principal);
34183
+ const taggedSlugs = new Set;
34184
+ for (const skill of this.skills.values()) {
34185
+ if (skill.orgId === principal.orgId && !skill.tombstonedAt && skill.tags.includes(tag))
34186
+ taggedSlugs.add(skill.slug);
34187
+ }
34188
+ 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));
34189
+ }
34190
+ async listPublishedSlugs(principal) {
34191
+ return Array.from(this.skills.values()).filter((skill) => skill.orgId === principal.orgId && !skill.tombstonedAt).map((skill) => skill.slug).sort();
34192
+ }
33900
34193
  collectOrphanBundle(orgId, sha256) {
33901
34194
  const referenced = Array.from(this.skills.values()).some((skill) => skill.orgId === orgId && skill.bundleSha256 === sha256);
33902
34195
  if (!referenced)
@@ -33914,6 +34207,9 @@ class MemorySkillsStore {
33914
34207
  function skillKey(orgId, slug) {
33915
34208
  return `${orgId.length}:${orgId}:${slug}`;
33916
34209
  }
34210
+ function pinKey(orgId, principal, slug) {
34211
+ return `${orgId.length}:${orgId}:${principal.length}:${principal}:${slug}`;
34212
+ }
33917
34213
 
33918
34214
  class PostgresSkillsStore {
33919
34215
  backend = { kind: "postgres", durable: true, label: "postgres" };
@@ -33933,6 +34229,14 @@ class PostgresSkillsStore {
33933
34229
  } catch (error) {
33934
34230
  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)}`);
33935
34231
  }
34232
+ await this.backfillLegacyRevisions();
34233
+ }
34234
+ async backfillLegacyRevisions() {
34235
+ const rows = await this.sql`SELECT * FROM skills_registry WHERE revision_id = ${""}`;
34236
+ for (const row of rows) {
34237
+ const record = rowToSkill(row);
34238
+ await this.sql`UPDATE skills_registry SET revision_id = ${revisionIdOfRecord(record)} WHERE org_id = ${record.orgId} AND slug = ${record.slug}`;
34239
+ }
33936
34240
  }
33937
34241
  async close() {
33938
34242
  await this.sql.close?.();
@@ -34167,8 +34471,33 @@ class PostgresSkillsStore {
34167
34471
  async publishSkill(input) {
34168
34472
  const orgId = input.principal.orgId;
34169
34473
  return await this.sql.begin(async (tx) => {
34170
- const previousRows = await tx`SELECT bundle_sha256 FROM skills_registry WHERE org_id = ${orgId} AND slug = ${input.slug} LIMIT 1`;
34171
- const previousSha = typeof previousRows[0]?.bundle_sha256 === "string" ? String(previousRows[0].bundle_sha256) : null;
34474
+ const previousRows = await tx`
34475
+ SELECT revision_id, revision_number, bundle_sha256, bundle_byte_size, skill_md, tombstoned_at
34476
+ FROM skills_registry WHERE org_id = ${orgId} AND slug = ${input.slug} LIMIT 1
34477
+ `;
34478
+ const previous = previousRows[0];
34479
+ const previousSha = typeof previous?.bundle_sha256 === "string" ? String(previous.bundle_sha256) : null;
34480
+ const previousRevisionId = typeof previous?.revision_id === "string" && previous.revision_id ? String(previous.revision_id) : null;
34481
+ const tombstoned = previous?.tombstoned_at != null;
34482
+ const carriedSkillMd = typeof input.skillMd === "string" ? input.skillMd : typeof previous?.skill_md === "string" ? String(previous.skill_md) : null;
34483
+ if (previous && !tombstoned && input.expectedRevisionId !== previousRevisionId) {
34484
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previousRevisionId);
34485
+ }
34486
+ const carriedSha = input.bundle?.sha256 ?? previousSha;
34487
+ const carriedSize = input.bundle?.byteSize ?? (previous?.bundle_byte_size == null ? null : Number(previous.bundle_byte_size));
34488
+ const revisionId = revisionIdOfRecord({
34489
+ slug: input.slug,
34490
+ displayName: input.displayName,
34491
+ description: input.description,
34492
+ category: input.category,
34493
+ tags: input.tags,
34494
+ source: input.source,
34495
+ kind: input.kind,
34496
+ ...input.version ? { version: input.version } : {},
34497
+ ...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
34498
+ ...carriedSha ? { bundleSha256: carriedSha } : {},
34499
+ ...carriedSize === null || carriedSize === undefined ? {} : { bundleByteSize: carriedSize }
34500
+ });
34172
34501
  if (input.bundle) {
34173
34502
  await tx`
34174
34503
  INSERT INTO skills_bundles (org_id, sha256, byte_size, content_type, storage_kind, storage_key, body_blob)
@@ -34183,10 +34512,10 @@ class PostgresSkillsStore {
34183
34512
  }
34184
34513
  const rows = await tx`
34185
34514
  INSERT INTO skills_registry (org_id, slug, display_name, description, category, tags_json, source, kind, version, skill_md,
34186
- bundle_sha256, bundle_byte_size, published_by_user_id, updated_at)
34515
+ bundle_sha256, bundle_byte_size, published_by_user_id, revision_id, revision_number, updated_at)
34187
34516
  VALUES (${orgId}, ${input.slug}, ${input.displayName}, ${input.description}, ${input.category}, ${JSON.stringify(input.tags)}::jsonb,
34188
- ${input.source}, ${input.kind}, ${input.version ?? null}, ${input.skillMd ?? null},
34189
- ${input.bundle?.sha256 ?? null}, ${input.bundle?.byteSize ?? null}, ${input.principal.userId}, now())
34517
+ ${input.source}, ${input.kind}, ${input.version ?? null}, ${carriedSkillMd},
34518
+ ${input.bundle?.sha256 ?? null}, ${input.bundle?.byteSize ?? null}, ${input.principal.userId}, ${revisionId}, 1, now())
34190
34519
  ON CONFLICT (org_id, slug) DO UPDATE SET
34191
34520
  display_name = EXCLUDED.display_name,
34192
34521
  description = EXCLUDED.description,
@@ -34201,9 +34530,23 @@ class PostgresSkillsStore {
34201
34530
  bundle_sha256 = COALESCE(EXCLUDED.bundle_sha256, skills_registry.bundle_sha256),
34202
34531
  bundle_byte_size = COALESCE(EXCLUDED.bundle_byte_size, skills_registry.bundle_byte_size),
34203
34532
  published_by_user_id = EXCLUDED.published_by_user_id,
34533
+ revision_id = EXCLUDED.revision_id,
34534
+ -- Current + 1, not EXCLUDED.revision_number: the insert path minted 1, but on
34535
+ -- the update path the ACTUAL row's counter is the truth (it may have advanced
34536
+ -- since the pre-read, which is exactly what the WHERE guard below detects).
34537
+ revision_number = skills_registry.revision_number + 1,
34538
+ tombstoned_at = NULL,
34539
+ tombstone_purge_after = NULL,
34204
34540
  updated_at = EXCLUDED.updated_at
34541
+ WHERE skills_registry.tombstoned_at IS NOT NULL
34542
+ OR skills_registry.revision_id = ${input.expectedRevisionId ?? NO_REVISION_SENTINEL2}
34205
34543
  RETURNING *
34206
34544
  `;
34545
+ if (!rows[0]) {
34546
+ const current = await tx`SELECT revision_id FROM skills_registry WHERE org_id = ${orgId} AND slug = ${input.slug} LIMIT 1`;
34547
+ const currentId = current[0] && typeof current[0].revision_id === "string" ? String(current[0].revision_id) : null;
34548
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, currentId);
34549
+ }
34207
34550
  if (previousSha && input.bundle && previousSha !== input.bundle.sha256) {
34208
34551
  await tx`
34209
34552
  DELETE FROM skills_bundles
@@ -34211,55 +34554,182 @@ class PostgresSkillsStore {
34211
34554
  AND NOT EXISTS (SELECT 1 FROM skills_registry WHERE org_id = ${orgId} AND bundle_sha256 = ${previousSha})
34212
34555
  `;
34213
34556
  }
34557
+ await tx`DELETE FROM skills_tags WHERE org_id = ${orgId} AND slug = ${input.slug}`;
34558
+ for (const tag of input.tags) {
34559
+ if (!tag.trim())
34560
+ continue;
34561
+ await tx`
34562
+ INSERT INTO skills_tags (org_id, slug, tag) VALUES (${orgId}, ${input.slug}, ${tag})
34563
+ ON CONFLICT DO NOTHING
34564
+ `;
34565
+ }
34214
34566
  return rowToSkill(rows[0]);
34215
34567
  });
34216
34568
  }
34217
34569
  async listSkills(principal) {
34218
- const rows = await this.sql`SELECT * FROM skills_registry WHERE org_id = ${principal.orgId} ORDER BY slug ASC`;
34570
+ await this.purgeExpiredTombstones(principal);
34571
+ const rows = await this.sql`
34572
+ SELECT * FROM skills_registry WHERE org_id = ${principal.orgId} AND tombstoned_at IS NULL ORDER BY slug ASC
34573
+ `;
34219
34574
  return rows.map(rowToSkill);
34220
34575
  }
34221
34576
  async getSkill(principal, slug) {
34222
34577
  const rows = await this.sql`SELECT * FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug} LIMIT 1`;
34223
34578
  return rows[0] ? rowToSkill(rows[0]) : null;
34224
34579
  }
34225
- async updateSkill(principal, slug, patch) {
34580
+ async updateSkill(principal, slug, patch, expectedRevisionId) {
34226
34581
  const current = await this.getSkill(principal, slug);
34227
- if (!current)
34582
+ if (!current || current.tombstonedAt)
34228
34583
  return null;
34584
+ if (expectedRevisionId !== current.revisionId) {
34585
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
34586
+ }
34229
34587
  const next = { ...current, ...patch };
34230
- const rows = await this.sql`
34231
- UPDATE skills_registry
34232
- SET display_name = ${next.displayName}, description = ${next.description}, category = ${next.category},
34233
- tags_json = ${JSON.stringify(next.tags)}::jsonb, kind = ${next.kind}, version = ${next.version ?? null},
34234
- skill_md = ${next.skillMd ?? null}, updated_at = now()
34235
- WHERE org_id = ${principal.orgId} AND slug = ${slug}
34236
- RETURNING *
34237
- `;
34238
- return rows[0] ? rowToSkill(rows[0]) : null;
34588
+ return await this.sql.begin(async (tx) => {
34589
+ const revisionId = revisionIdOfRecord(next);
34590
+ const updated = await tx`
34591
+ UPDATE skills_registry
34592
+ SET display_name = ${next.displayName}, description = ${next.description}, category = ${next.category},
34593
+ tags_json = ${JSON.stringify(next.tags)}::jsonb, kind = ${next.kind}, version = ${next.version ?? null},
34594
+ skill_md = ${next.skillMd ?? null}, revision_id = ${revisionId}, revision_number = revision_number + 1, updated_at = now()
34595
+ WHERE org_id = ${principal.orgId} AND slug = ${slug} AND tombstoned_at IS NULL AND revision_id = ${current.revisionId}
34596
+ RETURNING *
34597
+ `;
34598
+ if (!updated[0]) {
34599
+ const nowRows = await tx`
34600
+ SELECT revision_id, tombstoned_at FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug} LIMIT 1
34601
+ `;
34602
+ if (nowRows[0] && nowRows[0].tombstoned_at == null) {
34603
+ const currentId = String(nowRows[0].revision_id);
34604
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, currentId);
34605
+ }
34606
+ return null;
34607
+ }
34608
+ await tx`DELETE FROM skills_tags WHERE org_id = ${principal.orgId} AND slug = ${slug}`;
34609
+ for (const tag of next.tags) {
34610
+ if (!tag.trim())
34611
+ continue;
34612
+ await tx`
34613
+ INSERT INTO skills_tags (org_id, slug, tag) VALUES (${principal.orgId}, ${slug}, ${tag})
34614
+ ON CONFLICT DO NOTHING
34615
+ `;
34616
+ }
34617
+ return rowToSkill(updated[0]);
34618
+ });
34239
34619
  }
34240
- async deleteSkill(principal, slug) {
34620
+ async deleteSkill(principal, slug, tombstoneWindowMs) {
34241
34621
  return await this.sql.begin(async (tx) => {
34622
+ const existingRows = await tx`
34623
+ SELECT tombstoned_at FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug} LIMIT 1
34624
+ `;
34625
+ if (!existingRows[0])
34626
+ return null;
34627
+ if (existingRows[0].tombstoned_at != null) {
34628
+ const rows2 = await tx`SELECT * FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug} LIMIT 1`;
34629
+ return rowToSkill(rows2[0]);
34630
+ }
34242
34631
  const rows = await tx`
34243
- DELETE FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug}
34244
- RETURNING bundle_sha256
34632
+ UPDATE skills_registry
34633
+ SET tombstoned_at = now(), tombstone_purge_after = now() + (${tombstoneWindowMs}::int * interval '1 millisecond'), updated_at = now()
34634
+ WHERE org_id = ${principal.orgId} AND slug = ${slug}
34635
+ RETURNING *
34245
34636
  `;
34246
- if (!rows[0])
34247
- return false;
34248
- const sha = rows[0].bundle_sha256;
34249
- if (typeof sha === "string") {
34637
+ return rows[0] ? rowToSkill(rows[0]) : null;
34638
+ });
34639
+ }
34640
+ async purgeExpiredTombstones(principal) {
34641
+ return await this.sql.begin(async (tx) => {
34642
+ const expiredRows = await tx`
34643
+ SELECT * FROM skills_registry
34644
+ WHERE org_id = ${principal.orgId} AND tombstoned_at IS NOT NULL AND tombstone_purge_after <= now()
34645
+ `;
34646
+ if (!expiredRows.length)
34647
+ return [];
34648
+ const purged = [];
34649
+ for (const row of expiredRows) {
34650
+ const record = rowToSkill(row);
34250
34651
  await tx`
34251
- DELETE FROM skills_bundles
34252
- WHERE org_id = ${principal.orgId} AND sha256 = ${sha}
34253
- AND NOT EXISTS (SELECT 1 FROM skills_registry WHERE org_id = ${principal.orgId} AND bundle_sha256 = ${sha})
34652
+ DELETE FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${record.slug} AND tombstone_purge_after <= now()
34653
+ `;
34654
+ await tx`DELETE FROM skills_tags WHERE org_id = ${principal.orgId} AND slug = ${record.slug}`;
34655
+ await tx`
34656
+ DELETE FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${record.slug} AND tombstone_purge_after <= now()
34254
34657
  `;
34658
+ if (record.bundleSha256) {
34659
+ await tx`
34660
+ DELETE FROM skills_bundles
34661
+ WHERE org_id = ${principal.orgId} AND sha256 = ${record.bundleSha256}
34662
+ AND NOT EXISTS (SELECT 1 FROM skills_registry WHERE org_id = ${principal.orgId} AND bundle_sha256 = ${record.bundleSha256})
34663
+ `;
34664
+ }
34665
+ purged.push(record);
34255
34666
  }
34256
- return true;
34667
+ return purged;
34257
34668
  });
34258
34669
  }
34259
34670
  async getSkillBundle(principal, sha256) {
34260
34671
  const rows = await this.sql`SELECT * FROM skills_bundles WHERE org_id = ${principal.orgId} AND sha256 = ${sha256} LIMIT 1`;
34261
34672
  return rows[0] ? rowToSkillBundle(rows[0]) : null;
34262
34673
  }
34674
+ async pinSkill(principal, slug, metadata = {}) {
34675
+ const rows = await this.sql`
34676
+ INSERT INTO skills_pins (org_id, principal, slug, pinned_at, metadata_json)
34677
+ VALUES (${principal.orgId}, ${principal.apiKeyId}, ${slug}, now(), ${JSON.stringify(metadata)}::jsonb)
34678
+ ON CONFLICT (org_id, principal, slug) DO UPDATE SET
34679
+ pinned_at = now(),
34680
+ metadata_json = EXCLUDED.metadata_json
34681
+ RETURNING *
34682
+ `;
34683
+ return rowToPin(rows[0]);
34684
+ }
34685
+ async unpinSkill(principal, slug) {
34686
+ const rows = await this.sql`
34687
+ DELETE FROM skills_pins WHERE org_id = ${principal.orgId} AND principal = ${principal.apiKeyId} AND slug = ${slug}
34688
+ RETURNING 1 AS present
34689
+ `;
34690
+ return rows.length > 0;
34691
+ }
34692
+ async listPins(principal) {
34693
+ const rows = await this.sql`
34694
+ SELECT * FROM skills_pins WHERE org_id = ${principal.orgId} AND principal = ${principal.apiKeyId} ORDER BY slug ASC
34695
+ `;
34696
+ return rows.map(rowToPin);
34697
+ }
34698
+ async listTags(principal) {
34699
+ await this.purgeExpiredTombstones(principal);
34700
+ const rows = await this.sql`
34701
+ SELECT DISTINCT tag FROM skills_tags WHERE org_id = ${principal.orgId} ORDER BY tag ASC
34702
+ `;
34703
+ return rows.map((row) => String(row.tag));
34704
+ }
34705
+ async listSkillsByTag(principal, tag) {
34706
+ await this.purgeExpiredTombstones(principal);
34707
+ const rows = await this.sql`
34708
+ SELECT s.* FROM skills_registry s
34709
+ JOIN skills_tags t ON t.org_id = s.org_id AND t.slug = s.slug
34710
+ WHERE t.org_id = ${principal.orgId} AND t.tag = ${tag} AND s.tombstoned_at IS NULL
34711
+ ORDER BY s.slug ASC
34712
+ `;
34713
+ return rows.map(rowToSkill);
34714
+ }
34715
+ async listPinsByTag(principal, tag) {
34716
+ await this.purgeExpiredTombstones(principal);
34717
+ const rows = await this.sql`
34718
+ SELECT p.* FROM skills_pins p
34719
+ JOIN skills_tags t ON t.org_id = p.org_id AND t.slug = p.slug
34720
+ JOIN skills_registry s ON s.org_id = p.org_id AND s.slug = p.slug
34721
+ WHERE p.org_id = ${principal.orgId} AND p.principal = ${principal.apiKeyId}
34722
+ AND t.tag = ${tag} AND s.tombstoned_at IS NULL
34723
+ ORDER BY p.slug ASC
34724
+ `;
34725
+ return rows.map(rowToPin);
34726
+ }
34727
+ async listPublishedSlugs(principal) {
34728
+ const rows = await this.sql`
34729
+ SELECT slug FROM skills_registry WHERE org_id = ${principal.orgId} AND tombstoned_at IS NULL ORDER BY slug ASC
34730
+ `;
34731
+ return rows.map((row) => String(row.slug));
34732
+ }
34263
34733
  async collectOrphanBundle(orgId, sha256) {
34264
34734
  await this.sql`
34265
34735
  DELETE FROM skills_bundles
@@ -34268,6 +34738,7 @@ class PostgresSkillsStore {
34268
34738
  `;
34269
34739
  }
34270
34740
  }
34741
+ var NO_REVISION_SENTINEL2 = "0000000000000000000000000000000000000000000000000000000000000000";
34271
34742
  function isUniqueViolation(error) {
34272
34743
  const code = error?.code;
34273
34744
  if (code === "23505" || code === 23505)
@@ -34329,7 +34800,7 @@ ${summary}
34329
34800
  textArtifact(run, "show-notes.md", `# Show Notes
34330
34801
 
34331
34802
  - ${summary}
34332
- - Generated by the open-skills deterministic worker.
34803
+ - Generated by the skills deterministic worker.
34333
34804
  `),
34334
34805
  textArtifact(run, "clips.csv", `start,end,title,summary
34335
34806
  00:00,00:30,"Opening","${csv(summary)}"
@@ -34363,7 +34834,7 @@ function textArtifact(run, relativePath, bodyText, contentType = relativePath.en
34363
34834
  relativePath,
34364
34835
  contentType,
34365
34836
  byteSize: bytes.byteLength,
34366
- sha256: createHash4("sha256").update(bytes).digest("hex"),
34837
+ sha256: createHash5("sha256").update(bytes).digest("hex"),
34367
34838
  visibility: "private"
34368
34839
  },
34369
34840
  body: { relativePath, bodyText, contentType }
@@ -34582,7 +35053,7 @@ var DEVELOPMENT_TOOLS_SKILLS = [
34582
35053
  {
34583
35054
  name: "monitor",
34584
35055
  displayName: "Monitor",
34585
- description: "Operate the open-monitor MCP for machine health, processes, cron jobs, and cleanup workflows",
35056
+ description: "Operate the monitor MCP for machine health, processes, cron jobs, and cleanup workflows",
34586
35057
  category: "Development Tools",
34587
35058
  tags: ["monitoring", "mcp", "processes", "operations"]
34588
35059
  },
@@ -34628,6 +35099,14 @@ var DEVELOPMENT_TOOLS_SKILLS = [
34628
35099
  description: "Validate configuration files for syntax and schema compliance",
34629
35100
  category: "Development Tools",
34630
35101
  tags: ["config", "validation", "schema", "linting"]
35102
+ },
35103
+ {
35104
+ name: "session-inject-monitor",
35105
+ displayName: "Session Inject Monitor",
35106
+ 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",
35107
+ category: "Development Tools",
35108
+ tags: ["monitor", "session", "injection", "automation", "wake"],
35109
+ kind: "instruction"
34631
35110
  }
34632
35111
  ];
34633
35112
 
@@ -35015,7 +35494,7 @@ var DESIGN_BRANDING_SKILLS = [
35015
35494
  displayName: "Site Analyze",
35016
35495
  description: "Analyze any website's design system \u2014 detects shadcn/ui, Tailwind, extracts colors, typography, and components via Playwright + Claude Vision.",
35017
35496
  category: "Design & Branding",
35018
- tags: ["design", "shadcn", "tailwind", "colors", "typography", "playwright", "analysis", "open-styles"]
35497
+ tags: ["design", "shadcn", "tailwind", "colors", "typography", "playwright", "analysis", "styles"]
35019
35498
  }
35020
35499
  ];
35021
35500